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 turnutterance.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}, ...]For real servers, prefer a webhook
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.