TL;DR
We'll turn a two-person video into a WebVTT caption file where each cue says who is speaking. Pipeline:
ffmpegextracts audio,whisperx --diarizetranscribes and labels speakers, a small Python script groups words into<v Speaker>cues.
Plain speech-to-text gives you a wall of words. For interviews, podcasts and meetings, readers need to know who said what. We'll use WhisperX (Whisper with voice activity detection, forced alignment and pyannote-based diarization) and the standard WebVTT voice span so any player can show the names. #python #video
You'll need python 3.10+, ffmpeg, a Hugging Face account, and ideally a CUDA GPU. CPU works with --compute_type int8 --device cpu, just slower.
1. 🔑 Get access to the diarization model
WhisperX's diarization uses pyannote's speaker-diarization-community-1 pipeline, which is gated on Hugging Face:
- Create a read token at hf.co/settings/tokens.
- Open the
pyannote/speaker-diarization-community-1model page and accept the user conditions.
export HF_TOKEN=hf_xxx # never commit this ⚠️ Note: if you skip the user-conditions step, the pipeline fails to load with an authorization error even with a valid token. Accept the conditions first.
2. 🎧 Extract audio from the video
# extract_audio.sh ffmpeg -y -i interview.mp4 -vn -ac 1 -ar 16000 audio.wav Mono, 16 kHz is what speech models work with internally, and a WAV file avoids decoder surprises.
3. 🗣️ Transcribe and diarize
pip install whisperx whisperx audio.wav --model large-v2 --diarize --hf_token "$HF_TOKEN" \ --output_format json --output_dir out On CPU:
whisperx audio.wav --model large-v2 --diarize --hf_token "$HF_TOKEN" \ --compute_type int8 --device cpu --output_format json --output_dir out The README lists the CPU flags and the diarize flag. WhisperX does four things in order: voice activity detection, Whisper transcription, wav2vec2 forced alignment (word-level timestamps), then pyannote diarization with speakers assigned to words.
💡 Tip: WhisperX's README claims 70x realtime for
large-v2batched transcription. That's their best case. Time it on your own hardware before planning capacity.
4. 🔍 Look at the JSON
Each segment has a speaker, and each aligned word carries one too (shortened example):
{ "segments": [ { "start": 0.5, "end": 4.2, "text": "Welcome back to the show.", "speaker": "SPEAKER_00", "words": [ {"word": "Welcome", "start": 0.5, "end": 1.0, "speaker": "SPEAKER_00"}, {"word": "42", "speaker": "SPEAKER_01"} ] } ] } Notice the last word: numbers and symbols often come back with no start/end. Our converter has to skip those or it will crash.
⚠️ Note: the exact JSON fields can shift between WhisperX versions. Open one real output file and confirm
words[].speakerexists before trusting the script below.
5. 🧩 Group words into WebVTT cues
The rules:
- a new cue starts when the speaker changes,
- or after a pause longer than 1.5 s,
- or when a cue would run longer than 7 s (so captions stay readable).
#!/usr/bin/env python3 """Convert WhisperX JSON (with --diarize) into a WebVTT file using <v Speaker> voice spans.""" from __future__ import annotations import json import sys MAX_CUE_SECONDS = 7.0 # split long monologues so captions stay readable GAP_SECONDS = 1.5 # a pause this long starts a new cue even for the same speaker def ts(seconds: float) -> str: ms = round(seconds * 1000) h, ms = divmod(ms, 3_600_000) m, ms = divmod(ms, 60_000) s, ms = divmod(ms, 1000) return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}" def words_with_speakers(data: dict) -> list[dict]: out = [] last_speaker = "UNKNOWN" for seg in data["segments"]: for w in seg.get("words", []): if "start" not in w: # numbers and symbols often get no timestamp continue last_speaker = w.get("speaker", seg.get("speaker", last_speaker)) out.append({"word": w["word"], "start": w["start"], "end": w["end"], "speaker": last_speaker}) return out def build_cues(words: list[dict]) -> list[dict]: cues, cur = [], None for w in words: new = ( cur is None or w["speaker"] != cur["speaker"] or w["start"] - cur["end"] > GAP_SECONDS or w["end"] - cur["start"] > MAX_CUE_SECONDS ) if new: cur = {"speaker": w["speaker"], "start": w["start"], "end": w["end"], "text": [w["word"]]} cues.append(cur) else: cur["text"].append(w["word"]) cur["end"] = w["end"] return cues def to_vtt(cues: list[dict], names: dict[str, str]) -> str: lines = ["WEBVTT", ""] for i, c in enumerate(cues, 1): name = names.get(c["speaker"], c["speaker"]) lines += [str(i), f"{ts(c['start'])} --> {ts(c['end'])}", f"<v {name}>{' '.join(c['text'])}", ""] return "\n".join(lines) if __name__ == "__main__": data = json.load(open(sys.argv[1])) names = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {} print(to_vtt(build_cues(words_with_speakers(data)), names)) Run it, mapping the generic labels to names you know:
python3 diar_to_vtt.py out/audio.json '{"SPEAKER_00":"Host","SPEAKER_01":"Guest"}' > captions.vtt On a small hand-written sample in the same shape as WhisperX output, the result is:
WEBVTT 1 00:00:00.500 --> 00:00:02.100 <v Host>Welcome back to the show. 2 00:00:02.600 --> 00:00:04.100 <v Guest>Thanks for having me. The <v Name> syntax is the WebVTT voice span from the W3C spec. The closing </v> can be omitted when the span covers the whole cue, which is the case here.
6. ▶️ Attach it to a player
<!-- player.html --> <video controls src="interview.mp4"> <track kind="captions" srclang="en" label="English" src="captions.vtt" default /> </video> HLS players take the same file as a subtitles rendition if you package it. Style the speaker with the ::cue(v[voice="Host"]) selector.
/* captions.css */ video::cue(v[voice="Host"]) { color: #ffd166; } video::cue(v[voice="Guest"]) { color: #8ecae6; } 7. 🚧 Limits to know before shipping
| Limit | What happens | What to do |
|---|---|---|
| Generic labels | Output says SPEAKER_00, not a name | Map by hand, from invite metadata, or a speaker-ID step |
| Per-file labels | SPEAKER_00 in one file is not SPEAKER_00 in another | Carry identity yourself across recordings |
| Overlapping speech | One word gets one speaker | Review a sample of crosstalk-heavy content |
| Model accuracy | pyannote reports 11.2 DER on VoxConverse for community-1 (project's own benchmark) | Score on your own labelled sample |
What's next
- Score your own accuracy: hand-label ten minutes of your content and compute diarization error rate against it.
- Index the speaker field so search can answer "what did the guest say about X".
- If your content has fixed hosts, look at speaker-embedding matching to name them automatically.
Wrapping up: three commands and one small script give you captions that identify speakers, in a format every player already understands.