Build a meeting-transcription app

This tutorial wires the whole thing together: a user uploads a meeting recording, watches a live progress bar, and ends up with a clean, speaker-labeled transcript — "Speaker A said this, then Speaker B replied." It builds directly on two guides you should skim first: Live progress (turning the SDK's callbacks into a 0–100 bar) and Speaker diarization (what speaker_labels gives you). Here we combine them into one end-to-end app.

What you'll build

A backend endpoint that accepts a recording and transcribes it with speaker_labels on; a progress endpoint your frontend polls while the job runs; and a transcript view that renders the Speech Revolutions .utterances as timestamped speaker turns. No spinner — a real bar, because Speech Revolutions reports progress for pre-recorded audio.

The shape of the result

When you pass speaker_labels=True, Speech Revolutions labels who spoke each segment, and the SDK parses the response into a transcript object with an .utterances list. Each utterance is one contiguous speaker turn:

  • utterance.speaker — the speaker label, a stable string id within the job (e.g. "SPEAKER_0", "SPEAKER_1"). Map them to display names yourself; the API does not know who is who.
  • utterance.text — what that speaker said in this turn
  • utterance.start / utterance.end — turn boundaries in seconds

That maps one-to-one onto the UI you want: a list of turns, each with a speaker chip, a timestamp, and the spoken text. result.text still holds the full flat transcript if you need it.

Step 1 — transcribe with speakers and progress

Run the transcription in the background and stream its progress into a per-job store, exactly as in the Live progress guide. The only additions here are speaker_labels=True and keeping the finished result around so the transcript endpoint can serve its .utterances.

import threading
from dataclasses import dataclass, field

from speechrevolutions import AsyncSpeechRevolutions, ProgressEvent

# Give upload the first slice of the bar; transcription fills the rest.
UPLOAD_WEIGHT = 0.15


@dataclass
class Meeting:
    """Everything the frontend needs for one meeting, kept in memory."""

    phase: str = "starting"          # "upload" | "transcribe" | "done" | "failed"
    percent: float = 0.0             # overall 0–100
    turns: list[dict] = field(default_factory=list)  # speaker turns, filled when done
    lock: threading.Lock = field(default_factory=threading.Lock, repr=False)

    def _bar(self, phase: str, overall: float) -> None:
        with self.lock:
            self.phase = phase
            self.percent = max(self.percent, round(overall, 1))  # never go backwards

    def on_upload(self, e: ProgressEvent) -> None:
        self._bar("upload", (e.percent or 0.0) * UPLOAD_WEIGHT)

    def on_transcribe(self, e: ProgressEvent) -> None:
        self._bar("transcribe", UPLOAD_WEIGHT * 100 + (e.percent or 0.0) * (1 - UPLOAD_WEIGHT))

    def snapshot(self) -> dict:
        with self.lock:
            return {"phase": self.phase, "percent": self.percent, "turns": self.turns}


async def transcribe_meeting(audio: str, meeting: Meeting) -> None:
    async with AsyncSpeechRevolutions() as client:
        result = await client.transcribe(
            audio,
            speaker_labels=True,             # <- label who spoke each segment
            on_upload_progress=meeting.on_upload,
            on_progress=meeting.on_transcribe,
        )
    # Turn the SDK's utterances into plain dicts for the UI.
    meeting.turns = [
        {"speaker": u.speaker, "text": u.text, "start": u.start, "end": u.end}
        for u in result.utterances
    ]
    meeting._bar("done", 100.0)

Step 2 — expose start, progress, and transcript endpoints

Kick the job off in the background and return a job_id immediately. The frontend polls /progress for the bar, and once phase is "done" it reads the speaker turns from the same snapshot.

import uuid
from fastapi import FastAPI, BackgroundTasks, HTTPException

app = FastAPI()
MEETINGS: dict[str, Meeting] = {}

@app.post("/meetings")
async def start(url: str, background: BackgroundTasks):
    job_id = uuid.uuid4().hex
    MEETINGS[job_id] = Meeting()
    background.add_task(transcribe_meeting, url, MEETINGS[job_id])
    return {"job_id": job_id}                 # returns immediately

@app.get("/meetings/{job_id}")
def get(job_id: str):
    meeting = MEETINGS.get(job_id)
    if meeting is None:                       # restarted, expired, or a typo
        raise HTTPException(status_code=404, detail="unknown meeting")
    return meeting.snapshot()
    # -> {"phase": "transcribe", "percent": 63.5, "turns": []}
    #    ...and once done: "turns": [{"speaker": "SPEAKER_0", "text": "...", "start": 0.4, "end": 5.1}, ...]

Holding the transcription in a background task is fine for a single box and a demo. For anything that restarts or scales out, use submit() plus a callback_url so a job survives a redeploy — the pattern in the batch tutorial. Persist turns to your database instead of an in-memory map.

Step 3 — render the transcript as speaker turns

With the turns in hand the frontend is straightforward: one row per utterance, a speaker chip, a mm:ss timestamp from start, and the text. This is a minimal React view; style it however you like.

import { useEffect, useState } from "react";

type Turn = { speaker: string | null; text: string; start: number | null; end: number | null };
type Snapshot = { phase: string; percent: number; turns: Turn[] };

const mmss = (s: number | null) =>
  s == null ? "" : `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;

export function MeetingView({ jobId }: { jobId: string }) {
  const [snap, setSnap] = useState<Snapshot | null>(null);

  useEffect(() => {
    const id = setInterval(async () => {
      const s: Snapshot = await fetch(`/meetings/${jobId}`).then((r) => r.json());
      setSnap(s);
      if (s.phase === "done" || s.phase === "failed") clearInterval(id);
    }, 1000);
    return () => clearInterval(id);
  }, [jobId]);

  if (!snap) return <p>Starting…</p>;

  if (snap.phase !== "done") {
    return (
      <div>
        <progress value={snap.percent} max={100} />
        <span>{Math.round(snap.percent)}% — {snap.phase}</span>
      </div>
    );
  }

  return (
    <div>
      {snap.turns.map((t, i) => (
        <div key={i} className="turn">
          <span className="speaker">Speaker {t.speaker ?? "?"}</span>
          <span className="time">{mmss(t.start)}</span>
          <p>{t.text}</p>
        </div>
      ))}
    </div>
  );
}

That's the whole loop: upload, a real progress bar while Speech Revolutions works, and a speaker-labeled transcript rendered from .utterances. From here you might persist meetings, add search across turns, or export the transcript — see the subtitles tutorial to turn the same job into an .srt/.vtt file, or the cookbook for more recipes.