Source code for diarize.pipeline

"""
Transcription + speaker-diarization pipeline for MOSAIC recorded audio.

Two independent stages combined via the standard WhisperX-style recipe:
    1. transcribe_audio()  — faster-whisper, always run.
    2. diarize_audio()     — pyannote.audio, run only if a Hugging Face
       token is available and the caller hasn't opted out.
    3. assign_speakers()   — pure interval-overlap matching that labels
       each transcribed segment with whichever diarization turn covers it
       the most. This is the one function in this module with zero
       dependencies beyond the standard library, so it can be unit-tested
       without installing torch/faster-whisper/pyannote at all — see
       analysis/tests/test_diarize_pipeline.py.

faster-whisper and pyannote.audio are imported lazily (inside the
functions that need them) rather than at module level, for the same
reason: importing this module (e.g. to reach assign_speakers() in a test)
should not require the heavy ML stack to be installed.
"""

from __future__ import annotations

from pathlib import Path
from typing import TypedDict


[docs] class WhisperSegment(TypedDict): """One faster-whisper transcription segment. Attributes ---------- start, end : float Segment bounds, in seconds. text : str Transcribed text for this segment. """ start: float # seconds end: float # seconds text: str
[docs] class DiarizationTurn(TypedDict): """One pyannote.audio speaker turn. Attributes ---------- start, end : float Turn bounds, in seconds. speaker : str Speaker label (e.g. ``"SPEAKER_00"``). """ start: float # seconds end: float # seconds speaker: str
[docs] class TranscriptSegment(TypedDict): """One final, speaker-labeled transcript segment (:func:`assign_speakers`'s output). Attributes ---------- start_ms, end_ms : int Segment bounds, in milliseconds. speaker : str or None Assigned speaker label, or ``None`` if no diarization turn overlapped this segment (or diarization wasn't run at all). text : str Transcribed text for this segment. """ start_ms: int end_ms: int speaker: str | None # None = no diarization turn overlapped (or none was run) text: str
# ── Device selection ──────────────────────────────────────────────────────────
[docs] def resolve_device(device_arg: str | None) -> str: """Resolve which device to run inference on. Parameters ---------- device_arg : str or None Explicit device (e.g. ``"cuda"``, ``"cpu"``), or ``None`` to auto-detect. Returns ------- str ``device_arg`` unchanged if given, else ``"cuda"`` if a CUDA device is available, else ``"cpu"``. Notes ----- Shared by :func:`load_whisper_model`/:func:`load_diarization_pipeline` so they never disagree about which hardware to use for the same run. """ if device_arg: return device_arg import torch return "cuda" if torch.cuda.is_available() else "cpu"
# ── Transcription (faster-whisper) ────────────────────────────────────────────
[docs] def load_whisper_model(model_size: str, device: str): """Load a faster-whisper model once, for reuse across a whole session. Parameters ---------- model_size : str faster-whisper model size (e.g. ``"small"``, ``"large-v3"``). device : str Inference device, typically from :func:`resolve_device`. Returns ------- faster_whisper.WhisperModel A loaded model, ready for repeated :func:`transcribe_audio` calls. Notes ----- Call this once per run and pass the result to every :func:`transcribe_audio` call in that run — building a fresh model per audio file reloads weights from disk/cache once per microphone instead of once per run. """ from faster_whisper import WhisperModel compute_type = "float16" if device == "cuda" else "int8" return WhisperModel(model_size, device=device, compute_type=compute_type)
[docs] def transcribe_audio( model, audio_path: Path, language: str | None ) -> tuple[list[WhisperSegment], str]: """Transcribe one audio file with a pre-loaded whisper model. Parameters ---------- model : faster_whisper.WhisperModel A model from :func:`load_whisper_model`. audio_path : pathlib.Path Path to the audio file. faster-whisper decodes/resamples it internally (mono/16kHz) — no separate preprocessing step is needed regardless of the recording's original sample rate/channel count. language : str or None Force a language code (e.g. ``"en"``), or ``None`` to auto-detect. Returns ------- tuple of (list of WhisperSegment, str) ``(segments, detected_language)``. """ segments_iter, info = model.transcribe(str(audio_path), language=language or None) segments: list[WhisperSegment] = [ {"start": seg.start, "end": seg.end, "text": seg.text.strip()} for seg in segments_iter ] return segments, info.language
# ── Diarization (pyannote.audio) ────────────────────────────────────────────── _GATING_HELP = ( "pyannote's diarization models are gated on Hugging Face. To use one: " "(1) create a free account at https://huggingface.co, (2) accept the " "terms of use for https://huggingface.co/pyannote/speaker-diarization-community-1, " "(3) generate an access token at https://huggingface.co/settings/tokens and " "paste it into the Diarization plugin's token field." )
[docs] def load_diarization_pipeline(hf_token: str, device: str): """Load the pyannote diarization pipeline once, for reuse across a session. Parameters ---------- hf_token : str Hugging Face access token with the gated diarization models accepted (see the module docstring's gating instructions). device : str Inference device, typically from :func:`resolve_device`. Returns ------- pyannote.audio.Pipeline A loaded pipeline, ready for repeated :func:`diarize_audio` calls. Raises ------ RuntimeError If the token is missing/invalid or the gated models haven't been accepted yet — raised with actionable setup instructions rather than letting a raw HTTP/auth exception propagate to the caller's log. Notes ----- Call this once per run and pass the result to every :func:`diarize_audio` call in that run — building a fresh pipeline per audio file reloads weights from disk/cache once per microphone instead of once per run. """ import torch from pyannote.audio import Pipeline try: # "community-1" (not the older "speaker-diarization-3.1") — the # pipeline pyannote.audio 4.x itself recommends: better speaker- # assignment/counting accuracy, and avoids a confusing 2-hop gating # chain (this installed pyannote.audio version's own "3.1" wrapper # transitively pulls a community-1 component anyway, which would # otherwise fail with an unrelated-looking GatedRepoError). The auth # kwarg is `token=`, not the older `use_auth_token=` — that name was # removed in pyannote.audio 4.x (confirmed via # inspect.signature(Pipeline.from_pretrained) against the installed # version), so a still-`use_auth_token=`-shaped call throws # TypeError immediately, never reaching the network at all. pipeline = Pipeline.from_pretrained( "pyannote/speaker-diarization-community-1", token=hf_token ) except Exception as exc: # noqa: BLE001 - re-raised with actionable context below raise RuntimeError( f"Could not load the pyannote diarization pipeline ({exc}). {_GATING_HELP}" ) from exc pipeline.to(torch.device(device)) return pipeline
def _load_waveform_dict(audio_path: Path) -> dict: """Pre-decodes a WAV file into pyannote's ``{'waveform': (channel, time) torch.Tensor, 'sample_rate': int}`` escape-hatch format. pyannote.audio 4.x's own audio-loading path (handing it a raw file path) goes through ``torchcodec``, which is confirmed broken on this machine — its Windows DLLs fail to load against every FFmpeg build it tries (4 through 8), turning into ``RuntimeError: torchcodec is not available`` at diarize time regardless of the pipeline itself loading fine. ``scipy.io.wavfile`` has zero torchcodec/ffmpeg dependency and Mosaic's recordings are always plain PCM WAV (see docs/recording.rst), so pre-decoding ourselves is a safe, always-available bypass rather than a narrow special case — verified directly against a real session recording before landing this. """ import numpy as np import torch from scipy.io import wavfile sample_rate, samples = wavfile.read(str(audio_path)) if samples.ndim == 1: samples = samples[:, None] # mono -> (time, 1) if np.issubdtype(samples.dtype, np.integer): # e.g. int16 -> 32768.0, matching this project's established PCM # normalization convention (src/audio/audio_envelope.cpp). max_val = float(2 ** (samples.dtype.itemsize * 8 - 1)) samples = samples.astype(np.float32) / max_val waveform = torch.from_numpy(np.ascontiguousarray(samples.T.astype(np.float32))) return {"waveform": waveform, "sample_rate": int(sample_rate)}
[docs] def diarize_audio( pipeline, audio_path: Path, min_speakers: int, max_speakers: int ) -> list[DiarizationTurn]: """Run a pre-loaded diarization pipeline against one audio file. Parameters ---------- pipeline : pyannote.audio.Pipeline A pipeline from :func:`load_diarization_pipeline`. audio_path : pathlib.Path Path to the audio file. min_speakers, max_speakers : int Optional pyannote hints for the expected speaker count; ``0`` means "no hint" and is omitted from the call. Returns ------- list of DiarizationTurn One entry per detected speaker turn. Notes ----- pyannote.audio 4.x's pipeline call now returns a ``DiarizeOutput`` dataclass, not the bare ``Annotation`` 3.x returned. Uses ``exclusive_speaker_diarization`` (turns with overlapping speech resolved to a single speaker) rather than ``speaker_diarization`` (keeps overlaps) — its own docstring calls this out as "adapted to downstream transcription", exactly this function's use case via :func:`assign_speakers`'s single-best-speaker-per-segment matching, where an overlapping turn would only add ambiguity. """ kwargs = {} if min_speakers > 0: kwargs["min_speakers"] = min_speakers if max_speakers > 0: kwargs["max_speakers"] = max_speakers output = pipeline(_load_waveform_dict(audio_path), **kwargs) turns: list[DiarizationTurn] = [] for turn, _, speaker in output.exclusive_speaker_diarization.itertracks(yield_label=True): turns.append({"start": turn.start, "end": turn.end, "speaker": speaker}) return turns
# ── Speaker assignment (pure, no I/O) ─────────────────────────────────────────
[docs] def assign_speakers( whisper_segments: list[WhisperSegment], diarization_turns: list[DiarizationTurn] ) -> list[TranscriptSegment]: """Label each transcribed segment with its best-overlapping speaker turn. Parameters ---------- whisper_segments : list of WhisperSegment Transcription segments from :func:`transcribe_audio`, ``start``/ ``end`` in seconds. diarization_turns : list of DiarizationTurn Speaker turns from :func:`diarize_audio`, ``start``/``end`` in seconds plus a ``speaker`` label. May be empty (diarization was skipped) — every output segment then gets ``speaker=None``. Returns ------- list of TranscriptSegment One entry per input segment, in order, with ``start_ms``/ ``end_ms`` (rounded to the nearest millisecond) and ``speaker`` set to whichever turn overlaps it most by duration, or ``None`` if no turn overlaps it at all. Notes ----- Max-overlap interval matching, the standard WhisperX-style recipe. See :doc:`/math/speaker_diarization` for the exact overlap formula. """ result: list[TranscriptSegment] = [] for seg in whisper_segments: best_speaker: str | None = None best_overlap = 0.0 for turn in diarization_turns: overlap = min(seg["end"], turn["end"]) - max(seg["start"], turn["start"]) if overlap > best_overlap: best_overlap = overlap best_speaker = turn["speaker"] result.append( { "start_ms": round(seg["start"] * 1000), "end_ms": round(seg["end"] * 1000), "speaker": best_speaker, "text": seg["text"], } ) return result