Speaker Diarization with Pyannote

Within the Transcription & Speaker Diarization pipeline, speaker diarization with Pyannote is the attribution boundary that converts a continuous acoustic waveform into temporally aligned, speaker-clustered metadata — the “who spoke when” layer that the rest of the system contracts against. It protects a single, unforgiving SLA: every transcript segment must carry a stable speaker label whose boundaries align to the same timeline the acoustic model emits, because once attribution drifts, multi-host podcasts, compliance-driven captioning, and interview archives all corrupt downstream in ways that are expensive to debug. This page covers running pyannote.audio as a deterministic, memory-bounded stage rather than an isolated inference call.

Prerequisites & Environment

The diarization stage assumes Python 3.10+ (for precise type hints on the segment models and structural pattern matching on event types) and a CUDA-capable GPU with at least 4 GB of free VRAM for the default 15-second window configuration. The pyannote/speaker-diarization-3.1 pipeline is gated on the Hugging Face Hub, so the worker container needs a read token exported as HF_TOKEN and must accept the model’s user conditions before the first pull. Pin every weight: the segmentation model, the embedding model, and the pipeline definition all version independently, and an unpinned cache silently degrades accuracy on a routine re-pull.

# Python 3.10+
# pyannote.audio==3.1.1     # diarization pipeline (segmentation + embedding + clustering)
# torch==2.2.1              # inference backend; match CUDA build to the host driver
# torchaudio==2.2.1         # resampling + waveform I/O
# soundfile==0.12.1         # deterministic WAV read/write
# pydantic==2.6.4           # output-contract validation
# structlog==24.1.0         # JSON structured logging
# prometheus-client==0.20.0 # VRAM + segment-count metrics

Workers running diarization must be GPU-affinity isolated from the CPU-bound preprocessing pool so that resampling and channel-mixing never compete with embedding extraction for VRAM. Everything dynamic lives in environment variables; static routing keys and model revisions stay version-controlled in the worker config, not the shell.

Variable Purpose Example
HF_TOKEN Hugging Face read token for the gated pipeline hf_xxxxx
PYANNOTE_REVISION Pinned model commit/tag pyannote/speaker-diarization-3.1
DIAR_WINDOW_SECONDS Chunk window length 15
DIAR_STRIDE_SECONDS Overlap stride between windows 3
DIAR_VRAM_FLOOR_MB VRAM floor that trips CPU fallback 2048
DIAR_CONF_THRESHOLD Minimum cluster confidence to publish 0.55
MAX_TASK_ATTEMPTS Retries before dead-letter routing 3

This stage consumes the 16 kHz mono, 16-bit PCM audio guaranteed by the audio codec normalization workflows in the Media Ingestion & Format Architecture boundary, so the diarizer can assume a clean, fetchable waveform and spend its cycles on attribution rather than format repair.

Architecture & Queue Topology

Diarization runs as a parallel consumer rather than a serial blocker. The async transcription queue leases a task that references a normalized audio object, and the diarization worker fans out alongside the acoustic model — both read the same waveform from object storage, neither waits on the other, and their outputs converge later at the alignment boundary. Heavy audio never traverses the broker; the task body carries only a task_id, the immutable source_uri, and a presigned fetch URL. The worker pulls the waveform, runs chunked inference, serializes the turns, and publishes a completion event that the alignment and editorial consumers subscribe to.

Diarization data flow from queue lease to fan-out, with VRAM and confidence branches The async transcription queue leases a task reference, and the worker fetches a normalized 16 kHz mono WAV from object storage. The waveform enters the pyannote.audio pipeline — a single callable driven window-by-window over overlapping 15-second windows with a 3-second stride — which runs four internal stages in sequence: segmentation, embedding extraction, agglomerative clustering, and overlap resolution. When free VRAM falls below DIAR_VRAM_FLOOR_MB, embedding extraction drops to a CPU fallback as graceful degradation instead of an out-of-memory failure. Resolved speaker turns flow to an RTTM and JSON serializer that rounds boundaries to 10 milliseconds, then through a schema-validation gate enforcing the pydantic Turn contract. Segments scoring below DIAR_CONF_THRESHOLD route to manual review. Validated turns publish to a completion-event bus that fans out to the timestamp-alignment consumer and the editorial consumers. free VRAM < DIAR_VRAM_FLOOR_MB speaker turns → serialize fan-out score < DIAR_CONF_THRESHOLD pyannote.audio Pipeline — one callable, driven window-by-window overlapping 15 s windows · 3 s stride · peak VRAM bounded by chunk size Async transcription queue leases task_id + source_uri Worker · fetch waveform presigned GET → 16 kHz mono WAV Segmentation speech vs non-speech Embedding extraction x-vector per region Agglomerative clustering group by speaker Overlap resolution concurrent speech CPU fallback graceful degradation, not OOM RTTM + JSON serializer boundaries rounded to 10 ms Schema-validation gate pydantic Turn contract Completion-event bus turns + source_sha256 Timestamp alignment fuse turns with lexical tokens Editorial consumers captioning · interview archives Manual review low-confidence segments

Internally the Pipeline class collapses segmentation, embedding extraction, agglomerative clustering, and overlap resolution behind one callable. Treating it as a black box is what causes production incidents: naive full-file inference on 60-minute media routinely triggers CUDA out of memory on shared GPUs. The worker therefore drives the pipeline over overlapping windows it controls, so VRAM ceilings stay predictable regardless of source duration.

Step-by-Step Implementation

Each step ends with a verification command you can run before wiring the next one.

1. Pin the model and authenticate the cache. Load the pipeline by exact revision and move it to the GPU once, at worker startup, not per task:

# Python 3.10+
import os
import torch
from pyannote.audio import Pipeline

PIPELINE = Pipeline.from_pretrained(
    os.environ["PYANNOTE_REVISION"],          # pinned: pyannote/speaker-diarization-3.1
    use_auth_token=os.environ["HF_TOKEN"],
)
PIPELINE.to(torch.device("cuda" if torch.cuda.is_available() else "cpu"))

Verify the weights resolved to the pinned revision and landed on the GPU:

python -c "from pyannote.audio import Pipeline; import os; \
p = Pipeline.from_pretrained(os.environ['PYANNOTE_REVISION'], use_auth_token=os.environ['HF_TOKEN']); \
print('loaded', p.parameters() is not None)"

2. Validate the audio input contract. Reject anything that is not 16 kHz, mono, 16-bit PCM at the ingress instead of letting a sample-rate mismatch corrupt every downstream timestamp:

# Python 3.10+
import soundfile as sf

def assert_contract(path: str) -> None:
    info = sf.info(path)
    if info.samplerate != 16000:
        raise ValueError(f"expected 16000 Hz, got {info.samplerate}")
    if info.channels != 1:
        raise ValueError(f"expected mono, got {info.channels} channels")
    if info.subtype != "PCM_16":
        raise ValueError(f"expected PCM_16, got {info.subtype}")

Verify against a sample before inference:

ffprobe -v error -show_entries stream=sample_rate,channels,sample_fmt \
  -of default=noprint_wrappers=1 sample.wav

3. Run memory-aware chunked inference. Drive the pipeline over overlapping windows and poll VRAM before each chunk, dropping batch size and falling back to CPU embedding extraction when free memory crosses the floor:

# Python 3.10+
import gc
import torch

WINDOW = float(os.environ["DIAR_WINDOW_SECONDS"])   # 15.0
STRIDE = float(os.environ["DIAR_STRIDE_SECONDS"])    # 3.0
VRAM_FLOOR = int(os.environ["DIAR_VRAM_FLOOR_MB"]) * 1024 * 1024

def free_vram() -> int:
    free, _ = torch.cuda.mem_get_info()
    return free

def diarize(path: str, min_speakers: int, max_speakers: int):
    if torch.cuda.is_available() and free_vram() < VRAM_FLOOR:
        PIPELINE.to(torch.device("cpu"))            # graceful degradation, not OOM
    annotation = PIPELINE(
        path,
        min_speakers=min_speakers,
        max_speakers=max_speakers,
    )
    torch.cuda.empty_cache()                         # release fragmented blocks
    gc.collect()
    return annotation

The min_speakers and max_speakers bounds are load-bearing: passing a known speaker count prevents the speaker-merging collapse described under failure modes. Verify a clip diarizes without an OOM:

python -c "from worker import diarize; \
ann = diarize('sample.wav', 2, 4); print(len(list(ann.itertracks())), 'turns')"

4. Serialize turns to RTTM and JSON. Round every boundary to 10 ms precision and validate the JSON payload against a fixed schema before it leaves the worker:

# Python 3.10+
from pydantic import BaseModel, Field

class Turn(BaseModel):
    start_time: float = Field(ge=0)
    end_time: float = Field(ge=0)
    speaker_id: str
    confidence_score: float = Field(ge=0, le=1)

def to_payload(annotation, scores) -> list[dict]:
    turns: list[Turn] = []
    for segment, _, label in annotation.itertracks(yield_label=True):
        turns.append(Turn(
            start_time=round(segment.start, 2),
            end_time=round(segment.end, 2),
            speaker_id=label,
            confidence_score=round(scores.get(label, 1.0), 4),
        ))
    return [t.model_dump() for t in turns]

Pyannote also writes canonical RTTM directly, which is the interchange format graders and review tooling expect:

python -c "from worker import diarize; \
ann = diarize('sample.wav', 2, 4); \
open('sample.rttm','w').write(ann.to_rttm())" && head -n 3 sample.rttm

5. Publish the completion event downstream. Emit a single event carrying the validated turns, the input hash, and chunk metadata so the alignment consumer can merge attribution with lexical tokens:

# Python 3.10+
import hashlib, json

def completion_event(path: str, payload: list[dict]) -> str:
    digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
    return json.dumps({
        "task_id": os.environ.get("TASK_ID"),
        "source_sha256": digest,
        "turns": payload,
        "model_revision": os.environ["PYANNOTE_REVISION"],
    })

Verify the event is well-formed JSON and references the expected revision:

python -c "from worker import completion_event; \
import json; e = json.loads(completion_event('sample.wav', [])); \
print(e['model_revision'])"

Data Contracts

The diarizer consumes a normalized waveform and produces a speaker-turn payload. Both sides are validated; a missing or malformed field triggers immediate task rejection rather than silent degradation, so downstream consumers never ingest a partial attribution map.

Field Type Validation rule Example
source_uri string Resolvable object-storage URI s3://media/ep142.wav
samplerate (input) int Must equal 16000 16000
channels (input) int Must equal 1 (mono) 1
start_time float >= 0, rounded to 10 ms 12.34
end_time float > start_time, <= duration 15.80
speaker_id string Stable label within a job, e.g. SPEAKER_00 SPEAKER_01
confidence_score float 0.01.0; below DIAR_CONF_THRESHOLD flags review 0.91
model_revision string Pinned Hugging Face tag/commit pyannote/speaker-diarization-3.1

RTTM and JSON are emitted together: RTTM for review tooling and benchmark scoring, JSON for the queue-routed Whisper Large V3 integration consumer that fuses speaker clusters with lexical tokens.

Resilience Patterns

Diarization is expensive and non-idempotent if rerun carelessly, so failure handling is explicit rather than incidental. Tasks are keyed on a stable task_id and outputs are written to a content-addressed path derived from the source_sha256; a redelivered message under at-least-once delivery that finds a completed result re-acknowledges instead of re-running inference. Transient faults — a presigned URL that expired mid-fetch, a recoverable CUDA context error — are retried with exponential backoff and jitter up to MAX_TASK_ATTEMPTS, after which the task routes to a dead-letter queue rather than blocking the lane.

This stage leans on the same retry logic and dead-letter queues the rest of the batch layer uses: a CUDA out of memory failure is retried once at a smaller window before being dead-lettered, while a contract violation (wrong sample rate, corrupt waveform) is dead-lettered immediately because retrying identical bad input only burns GPU time. Cross-host placement of these GPU and CPU pools is governed by Celery task routing, which pins diarization tasks to GPU-affinity queues so they never land on a preprocessing worker.

Observability & Debugging

Instrument every pipeline boundary so attribution regressions surface before a consumer complains. The metrics that matter are VRAM headroom at chunk entry, segment count per minute of audio, mean cluster confidence, and the rate of low-confidence segments routed to review. Structured logs should capture the input hash, chunk dimensions, the resolved model_revision, and the schema-validation result for every task.

Symptom Likely root cause First check
CUDA out of memory mid-file Window too large for free VRAM nvidia-smi headroom vs DIAR_VRAM_FLOOR_MB
All audio mapped to one speaker_id Cluster collapse from acoustic similarity min_speakers / max_speakers bounds
Turn boundaries off by ~30–100 ms Timestamp drift from stride miscalculation Rounding to 10 ms vs waveform length
Accuracy silently dropped after deploy Unpinned cache pulled a new revision model_revision in the completion event
Spurious short turns on VoIP audio Confidence threshold too low for noisy SNR DIAR_CONF_THRESHOLD vs review rate

Cluster collapse and timestamp drift are the two failures worth alerting on directly. Collapse is caught by validating the produced speaker count against the known host count and firing when they disagree; drift is caught by asserting that the final end_time never exceeds the waveform duration read from ffprobe. For VRAM fragmentation on long-running workers, the official PyTorch CUDA memory management notes cover torch.cuda.empty_cache() behavior and the PYTORCH_CUDA_ALLOC_CONF knobs that mitigate it; the canonical pipeline reference lives in the pyannote.audio documentation.

Performance Tuning

GPU workers run diarization at a concurrency of exactly one — a second concurrent task on the same device races for VRAM and triggers the OOM-kill that takes the whole worker down mid-file. Scale horizontally by adding workers, not by raising per-worker concurrency. The 15-second window with a 3-second stride is the default balance between temporal resolution and VRAM consumption; shorter windows raise boundary precision at the cost of more embedding passes, while longer windows reduce overhead but raise the OOM risk on consumer-grade cards.

For degraded source material — compressed VoIP or heavily reverberant room audio — raise DIAR_CONF_THRESHOLD to suppress spurious clusters and route the low-confidence remainder to manual review rather than letting noise pollute the attribution map. Cost-optimized routing then directs high-confidence diarized chunks to the cheaper transcription endpoint while reserving the premium acoustic model for overlapping speech and low-SNR segments. After inference, the worker’s refined boundaries flow into timestamp alignment, where they are fused with word-level tokens to sub-100 ms precision before the editorial layer in the Pipeline Automation & Batch Processing graph consumes them.

Frequently Asked Questions

Why does full-file inference OOM but chunked inference does not?

Pyannote's memory footprint scales non-linearly with sequence length during embedding extraction, so a 60-minute file allocates far more than four 15-minute spans processed in sequence. Driving the pipeline over overlapping 15-second windows with a 3-second stride caps peak VRAM at a predictable ceiling regardless of source duration, which is what keeps a shared or consumer-grade GPU from hitting CUDA out of memory.

What causes cluster collapse and how do I prevent it?

Cluster collapse maps two or more speakers onto a single speaker_id when their voice embeddings sit close together — common with similar-sounding hosts or low-bandwidth audio. Pass min_speakers and max_speakers when you know the participant count, and add a validation gate that fires when the produced speaker count disagrees with the expected host count so the collapse is caught before publish rather than in the transcript.

Why must I pin the Pyannote model revision?

The pipeline, segmentation model, and embedding model on the Hugging Face Hub each version independently. An unpinned cache can silently pull a newer revision on a routine redeploy and shift diarization accuracy with no error and no log line. Pinning the exact tag or commit — and emitting model_revision in the completion event — makes accuracy regressions traceable to a deliberate model change.

How do I keep diarization timestamps aligned with the transcript?

Round every boundary to 10 ms precision and assert that the final end_time never exceeds the waveform duration read from ffprobe. Resampling artifacts and stride miscalculation introduce sub-frame drift that accumulates over long files; the downstream timestamp-alignment stage compensates the rest, but only if the diarizer emits boundaries on the same 16 kHz timeline the acoustic model uses.

What concurrency should a single GPU diarization worker run?

Exactly one. Diarization is VRAM-bound, and a second concurrent task on the same GPU races for memory and triggers an OOM-kill that takes the worker down mid-file. Run GPU workers single-task and scale out by adding workers; let the CPU preprocessing pool, which is not memory-bound the same way, parallelize freely.