Build a meeting-transcription app
In this tutorial you build an app where a user uploads a meeting recording, watches a live progress bar, and gets a speaker-labeled transcript. It combines two guides: Live progress (turning the SDK's callbacks into a 0–100 bar) and Speaker diarization (what speaker_labels returns).
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 .utterances as timestamped speaker turns. Speech Revolutions reports progress for pre-recorded audio, so you show a real progress bar instead of a spinner.
The result shape
With speaker_labels=True, each segment is labeled with its speaker, and the transcript object has an .utterances list. Each utterance is one contiguous speaker turn:
utterance.speaker: the speaker label, a string ID that is stable within the job (for example"SPEAKER_1","SPEAKER_2"). The API does not identify people; map labels to display names yourself.utterance.text: what the speaker said in this turnutterance.start/utterance.end: turn boundaries in seconds
Each utterance maps to one row in the UI: a speaker chip, a timestamp, and the text. result.text holds the full flat transcript.
Step 1: Transcribe with speakers and progress
Run the transcription in the background and write its progress to a per-job store, as in the Live progress guide. Add speaker_labels=True, and keep the finished result's .utterances so the transcript endpoint can serve them.
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:
try:
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,
)
except Exception:
meeting._bar("failed", meeting.percent) # the frontend stops polling on "failed"
raise
# 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 and status endpoints
Start the job in the background and return a job_id immediately. The frontend polls the meeting endpoint for the bar. 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_1", "text": "...", "start": 0.4, "end": 5.1}, ...]A background task works for a demo on a single server. If your server restarts or scales out, use submit() with a callback_url so jobs survive a redeploy, as shown in the batch tutorial. Store turns in your database instead of an in-memory map.
Step 3: Render the transcript as speaker turns
Render one row per utterance: a speaker chip, a mm:ss timestamp from start, and the text. This is a minimal React view.
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>
);
}Next steps: 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.