Project Overview

MoM AI is a web application that automates the process of turning meeting recordings into structured minutes. You upload an audio or video file, the system transcribes it using a speech provider of your choice, and then sends the transcript to an LLM to generate organized Minutes of Meeting.

Six transcription backends are supported: Deepgram, AssemblyAI, ElevenLabs Scribe and Sarvam AI over their APIs, any endpoint that speaks the OpenAI transcription format, and Whisper running on your own machine with no API key at all. Summarization works with any OpenAI-compatible chat API, which covers the hosted providers as well as Ollama, LM Studio and vLLM locally. Every one of these is picked from a dropdown at runtime.

MoM AI - Home Page

The output includes a meeting summary, key discussion points, decisions made, action items, and next steps. Everything is presented in a clean, readable format inside the browser. The app handles the entire pipeline in the background, so you can upload a recording and come back when it is done.

Why I Built This

After attending several team meetings, I noticed that writing minutes manually was repetitive, time-consuming, and often inconsistent. Notes were scattered, key decisions got missed, and action items were forgotten.

I built this as a side project to solve that problem for myself first. It was also a good opportunity to learn how to integrate multiple AI APIs into a single pipeline, work with background task processing, and understand how different speech-to-text providers handle real-world audio.

The project is open source because I believe the pattern of combining speech-to-text with LLM summarization is useful for many developers. Sharing the full implementation felt more valuable than keeping it private.

The Problem

Writing meeting minutes manually has a few common issues:

MoM AI reduces this work to a single upload. The output follows a consistent structure every time: summary, attendees, discussion points, decisions, action items, and next steps. This makes it easier to review meetings, track responsibilities, and share outcomes with the team.

Key Features

Six Transcription Backends

Deepgram, AssemblyAI, ElevenLabs, Sarvam AI, any OpenAI-compatible endpoint, or local Whisper. The first four return speaker labels

LLM Summarization

Works with any OpenAI-compatible API - OpenAI, Groq, OpenRouter, Ollama, and others

Background Processing

Celery + Redis for async processing - upload and come back when it is done

Docker-Ready Deployment

Full stack runs with a single docker compose up command

Runs Without the Cloud

Whisper locally, an LLM on Ollama, no CDN and no telemetry. The only outbound calls are the ones you configure

Any Meeting Length

Long audio is split for the transcriber; long transcripts are summarized in parts and merged, so the context window is not the ceiling

How It Works

The processing flow has four clear steps:

  1. Upload - The user uploads a meeting recording through the web interface. The file is saved to the server and a background job is queued.
  2. Audio Conversion - The Celery worker picks up the job and uses ffmpeg to convert the uploaded file into a 16 kHz mono WAV file. This standardized format works reliably across all speech providers.
  3. Transcription - The WAV file is sent to the configured speech-to-text provider. The transcript comes back with speaker labels (e.g., Speaker 0, Speaker 1) when diarization is available.
  4. Summarization - The transcript is sent to the configured LLM with a structured prompt asking for a summary, discussion points, decisions, action items, and next steps. If the transcript is longer than the configured chunk size, it is summarized part by part and those notes are merged into the final minutes, so a three-hour meeting works even on a small context window. The result is stored as Markdown and sanitized when rendered.

The interface polls the backend for progress and shows each step as it happens, so a job that is going to fail says why while it is still running rather than at the end. Failed and cancelled meetings can be re-run as long as the recording is still on disk.

Screenshots

Login

MoM AI - Login Page

Upload Page

MoM AI - Upload Page

Meetings Dashboard

MoM AI - Meetings Dashboard

Meeting Outcomes

MoM AI - Meeting Outcomes

Tech Stack

Backend

Python 3.12+ Flask Flask-SQLAlchemy Flask-Login Flask-WTF Gunicorn

Task Queue & Storage

Celery Redis SQLite (default) PostgreSQL (optional)

AI & Speech

OpenAI SDK Deepgram SDK AssemblyAI SDK ElevenLabs SDK faster-whisper

Security & Observability

cryptography (Fernet) nh3 prometheus-client python-json-logger

Frontend & Deployment

Jinja2 Tailwind CSS Alpine.js FFmpeg Docker Compose

Core Logic: How the Code Works

This section shows the key parts of the implementation. The full source is on GitHub.

Processing Pipeline (Celery Task)

This is the main task that ties everything together. It converts the audio, transcribes it, sends the transcript to the LLM, and stores the result.

Python - Celery task orchestrating the full pipeline
@celery.task(bind=True, name="app.tasks.process.process_meeting")
def process_meeting(self, meeting_id: int) -> dict:
    meeting = db.session.get(Meeting, meeting_id)
    config = settings.snapshot()
    workdir = tempfile.mkdtemp(prefix="mom-")

    def step(status: str, progress: int, message: str | None = None) -> None:
        meeting.status = status
        meeting.progress = progress
        if message:
            meeting.log(message)
        db.session.commit()

    def guard() -> None:
        db.session.commit()  # persists pending work and re-reads the row
        if meeting.cancel_requested:
            raise Cancelled()

    try:
        guard()
        step("converting", 5, "Preparing audio")
        audio = convert_to_wav(recording_path(meeting), workdir)
        meeting.duration_seconds = probe_duration(audio)

        guard()
        provider = get_speech_provider(config)
        step("transcribing", 20, f"Transcribing with {provider.label}")
        transcript = (provider.transcribe(audio) or "").strip()
        meeting.transcript = transcript
        step("transcribing", 65, f"Transcript ready ({len(transcript.split()):,} words)")

        guard()
        client = get_llm_client(config)
        step("summarizing", 70, f"Writing minutes with {client.model}")
        meeting.minutes_of_meeting = generate_minutes(
            client, transcript, config["minutes_prompt"],
            int(config["transcript_chunk_chars"]), on_progress,
        )

        step("completed", 100, "Minutes ready")
        return {"meeting_id": meeting_id, "status": "completed"}

    except Cancelled:
        step("cancelled", meeting.progress or 0, "Cancelled")
        return {"meeting_id": meeting_id, "status": "cancelled"}
    except Exception as exc:
        _fail(meeting, str(exc))
        return {"meeting_id": meeting_id, "status": "failed"}
    finally:
        shutil.rmtree(workdir, ignore_errors=True)

Summarizing a Meeting of Any Length

A three-hour meeting produces a transcript far larger than most context windows. Rather than truncate it, the summarizer splits the transcript on paragraph boundaries, takes notes on each part, and then writes the final minutes from those notes. Short meetings skip all of that and go through in a single call. The prompt itself is no longer hardcoded — it is a setting, editable from the admin screen.

Python - Map-reduce summarization that ignores the context window
def generate_minutes(
    client: OpenAICompatibleLLM,
    transcript: str,
    prompt: str,
    chunk_chars: int,
    on_progress: Callable[[int, int], None] | None = None,
) -> str:
    chunks = split_text(transcript, chunk_chars)
    if len(chunks) == 1:
        return client.complete(prompt, f"Transcript:\n\n{transcript}")

    notes = []
    for index, chunk in enumerate(chunks, start=1):
        notes.append(f"### Part {index} of {len(chunks)}\n{client.complete(PART_PROMPT, chunk)}")
        if on_progress:
            on_progress(index, len(chunks))
    return client.complete(prompt, MERGE_HEADER + "\n\n".join(notes))

Speech Provider Registry

Every backend implements the same two methods, so adding one is a matter of writing the class and putting it in the tuple. The whole selection layer is the file below. Each provider receives the settings snapshot and reads whatever it needs from it, which keeps the caller free of provider-specific branching.

Python - Runtime-configurable speech provider selection
PROVIDERS: dict[str, type[BaseSpeechProvider]] = {
    p.name: p
    for p in (
        DeepgramProvider,
        AssemblyAIProvider,
        ElevenLabsProvider,
        SarvamProvider,
        OpenAICompatibleSpeechProvider,
        LocalWhisperProvider,
    )
}


def get_speech_provider(config: dict) -> BaseSpeechProvider:
    name = (config.get("speech_provider") or "deepgram").strip().lower()
    provider = PROVIDERS.get(name)
    if provider is None:
        raise RuntimeError(f"Unknown speech provider {name!r}.")
    return provider(config)

Audio Conversion

All uploaded files are normalized to 16 kHz mono WAV before being sent to any speech provider. This keeps the transcription step consistent regardless of the input format.

Python - FFmpeg-based audio normalization
def convert_to_wav(input_path: str, output_dir: str, timeout: int = 7200) -> str:
    """Normalise any recording to mono 16 kHz WAV, the format every provider accepts."""
    if not ffmpeg_available():
        raise RuntimeError(
            "ffmpeg was not found. Install it and make sure it is on PATH, "
            "or run the app with Docker where it is already included."
        )

    os.makedirs(output_dir, exist_ok=True)
    output_path = os.path.join(output_dir, "audio.wav")
    result = _run(
        ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", input_path,
         "-ar", "16000", "-ac", "1", "-vn", output_path],
        timeout,
    )
    if result.returncode != 0 or not os.path.exists(output_path):
        raise RuntimeError(f"ffmpeg could not read the recording: {_tail(result.stderr)}")
    return output_path

My Contribution

I designed and built this project end to end.

Challenges & Learnings

Handling different speech provider APIs and output formats
Each provider returns transcripts in a different structure. Some give word-level timestamps, others give utterances, and speaker labels vary across providers. I had to normalize these outputs into a consistent format that the LLM could work with reliably. This taught me the importance of building a clean abstraction layer when dealing with multiple external APIs.

Managing long-running tasks without blocking the web server
Audio files can be large, and transcription plus summarization can take minutes. I set up Celery with Redis to handle this asynchronously. Getting the progress tracking right (so the UI could show real-time status updates via polling) required careful state management on the database side.

Prompt engineering for consistent LLM output
Early versions of the summarization prompt produced inconsistent results. Sometimes the LLM would skip sections, use different headings, or add unnecessary commentary. Iterating on the system prompt to get a reliable, structured output taught me that small wording changes in prompts can significantly affect the quality and consistency of the result.

Audio format normalization across different input types
Users upload files in all sorts of formats (MP4, WebM, M4A, OGG). Not all speech providers handle all formats well. Converting everything to a standardized 16 kHz mono WAV using ffmpeg before transcription solved compatibility issues across all providers.

Two different ceilings on meeting length
A long recording runs into two unrelated limits, and I initially conflated them. The transcription APIs cap the upload size, which is a bytes problem solved by splitting the audio with ffmpeg and joining the results. The LLM caps the context window, which is a tokens problem and needs the transcript summarized in parts and merged. Separating them meant each fix landed in exactly one place, and only the provider that actually has an upload limit pays the cost of chunking.

Installation & Setup

Bash - Run with Docker Compose
# Clone the repository
git clone https://github.com/inboxpraveen/LLM-Minutes-of-Meeting.git
cd LLM-Minutes-of-Meeting

# Start the full stack (Flask, Celery worker, Celery beat, Redis)
docker compose up --build

# Or include Whisper in the image to transcribe with no API key at all
WITH_LOCAL_WHISPER=true docker compose up --build

No .env file and no API keys are needed to start. Open http://localhost:5000, and the first screen asks you to create the administrator account. From there, Settings is where you pick a speech provider and an LLM endpoint, with a Test button on each to confirm the credentials work before you upload anything.

Future Improvements

Closing Note

MoM AI is open source and built to be useful. Whether you want to automate your own meeting notes, learn how to integrate speech-to-text and LLM APIs, or just explore the codebase for ideas, you are welcome to use, fork, or improve it.

If you find it helpful or have suggestions, feel free to open an issue or contribute on GitHub.

Resources & Links

GitHub Repository

Tags:

LLM & RAG Flask Celery Redis Whisper OpenAI SDK Docker