vocal_helper.router module
vocal_helper.router
The aiguilleur: the study-grounded diarization backend router.
Diarization is the one pipeline stage with a genuine backend fork, and there is no single best backend: the right one depends on whether the audio is a live stream or a batch file, on its duration, on the speaker count, and on whether the deployment can afford a PyTorch install. This module turns those scenario conditions into one explicit, testable decision that carries both quality (DER) and speed (RTF) so the CLIs and downstream code never hard-code a backend, re-derive the trade-off, or hide the cost.
Quality × speed per scenario (the whole point)
Numbers were re-validated on this machine (2026-07-19,
studies/router_profile_validation.py, pyannote.metrics collar 0.25, median
DER + RTF) against ground truth: bagarre (30 short mixes) + AMI dev-slice (2 real
meetings). The sherpa DER is from ADR 0002; its ONNX models (pyannote-3.0
segmentation + TitaNet-large embedding) now ship in the diarization-engines
bundle, so the torch-free path runs with no HuggingFace and no PyTorch. DER =
quality (lower is better); RTF = speed (< 1 = faster than real time):
mode |
backend |
DER |
RTF |
when the router picks it |
|---|---|---|---|---|
offline |
nemo |
0.142 |
0.051 |
short (≤300 s), ≤4 speakers; dense interleaved turns |
offline |
pyannote |
0.122 |
0.067 |
long / unknown length / >4 speakers; the robust default |
offline |
sherpa |
0.174 |
0.58 |
torch-free deployment (no PyTorch); ADR 0002 |
online |
nemo |
0.586 |
0.030 |
any live stream (the default online embedder) |
online |
sherpa |
0.174 |
0.58 |
torch-free streaming = periodic offline re-diarization |
What the router will never do
It never routes to a paid backend. pyannoteai exists as an offline
diarization backend — it is the whole of the hybrid mode, hosted speakers with
local words — but it is reachable only by asking for it by name
(diar={"backend": "pyannoteai"}).
This is not an oversight, it is the rule that makes the rest safe. A router that could pick a billed engine would mean the same command sometimes costs money and sometimes does not, decided by a duration threshold nobody remembers. And since cost is not on the same scale as DER or RTF, there is no honest way to put it in the table below and let a comparison decide: the two local columns trade quality against speed, which are commensurable; adding euros is a different kind of quantity, and the person spending them is the one who should choose.
So: the router arbitrates among the free local backends on measurement. Paying is a decision, made by name, by a human.
Why a router, not a default
Two independent findings, both measured on this machine:
Offline: a length crossover. On short dense turns (bagarre, ~30 s) NeMo Sortformer wins by ~2.3x (offline DER 0.142 vs pyannote 0.330): its end-to-end slot attribution drives speaker-confusion to ~0. On long meetings (AMI) the verdict reverses: pyannote median DER 0.122 (inside Bredin 2023’s 0.188 band), and Sortformer hangs past ~25 min (no output on a 27-min meeting), since its 90 s / 4-speaker cap puts long/crowded form out of distribution. So offline needs a router: “ship nemo” or “ship pyannote” is wrong for one common workload.
Online: nemo, always — but online is not the only live option, and it is not the good one. The streaming stage’s error rate is three to four times the offline one (0.586 against 0.122) because it cannot model overlapping speech, and no choice of embedder repairs that: the limitation is the latency.
When the speaker labels matter as much as the words, the answer is not a better online clusterer, it is
vocal_helper.rediar— run the offline diarizer periodically on a bounded window and correct the labels already shown.Measured, it depends on the length, and on whole meetings it loses. On five minutes of AMI IS1008a it halved the error: online alone 0.666, windowed re-diarization 0.348, offline whole-file 0.149 (
studies/asr_acceleration_rtf.md). On the whole meetings it did worse than the online stage it was meant to correct: 0.679 against 0.597 on IS1008a (944 s), 0.817 against 0.616 on ES2011a (1114 s), offline 0.124 and 0.188 (studies/rediar_live_der.json, fromstudies/rediar_live_der.py). An earlier version of this docstring claimed the full 0.122, then a plain halving; both were one measurement read as a rule. The growing buffer (vocal_helper.growing) replaced it as the live default for that reason.Why it stops halfway is structural and worth knowing: the published rows are the voice-activity detector’s segments, and relabelling cannot repair a segment that contains a speaker change. The offline pass gets to choose its own boundaries; this one inherits them. Emitting the offline segmentation instead of relabelling the VAD’s is the next lever, not a better clusterer.
The online stage remains the right choice for the opposite priority — a label within 500 ms and no retroactive correction, which an interactive agent needs and a transcript does not.
The rest of this finding still stands for the online stage itself: vocal-helper’s
OnlineDiarStageis a latency-bound cosine-clustering approximation, inherently ~3-4x the offline DER (it cannot model overlap). Across lengths the NeMo TitaNet embedder is the best online backend (bagarre 0.586, AMI 0.497) and beats pyannote/embedding online (0.590 / 0.844), matching the 2026-06-30 embedding sweep that made it the default. There is no online length crossover: streaming routes to nemo (refine_on_closeroughly halves the DER on meetings that over-segment, a stage knob the router leaves to the stage).
The torch-free sherpa (ONNX TitaNet-large, DER 0.174/0.148, beats NeMo
Sortformer 0.267, FR+EN validated, ADR 0002) is the portability pick either way.
Scope
This module decides the diarization backend (mode + backend), the one stage
with a real fork. The other stages are single-backend by study verdict and are
not routed: VAD is Silero v5 (32 ms cadence), ASR is pywhispercpp
large-v3-turbo (no faster-whisper win on CPU/Apple in the study), the
analyst is Gemma via Ollama. Language is discovered, never routed to a default
(see vocal_helper.lid).
Usage example
>>> from vocal_helper.router import select_diarization
>>> plan = select_diarization(live=False, duration_s=45.0, max_speakers=3)
>>> plan.backend, plan.expected_der, plan.expected_rtf
('nemo', 0.142, 0.051)
>>> select_diarization(live=False, duration_s=1800.0).backend
'pyannote'
- class vocal_helper.router.BackendPlan(mode, backend, expected_der, expected_rtf, reason, sherpa_num_clusters=None)[source]
Bases:
objectOne routing decision: which diarizer, and its quality + speed.
- Parameters:
- mode
"online"(streamingOnlineDiarStage) or"offline"(whole-bufferOfflineDiarStage).- Type:
- backend
Diarization backend —
"nemotron","pyannote","nemo"or"sherpa"— to pass straight to the stage’sdiar={"backend": ...}config.- Type:
- expected_der
Representative diarization error rate (quality — lower is better) for this scenario, from the pdbms study / ADR 0002.
- Type:
- expected_rtf
Representative real-time factor (speed —
< 1is faster than real time) for this scenario.- Type:
- reason
Human-readable justification citing the deciding measurement, surfaced to the operator so the choice is never a black box.
- Type:
- sherpa_num_clusters
When the backend is
sherpaand the exact speaker count is known, the count to pin its clustering to (FastClusteringConfig(num_clusters=...)).Noneleaves sherpa on auto-clustering, so meeting-audio behaviour is unchanged. This is the lever that collapses sherpa’s 2-party telephony over-segmentation (pdbms diar-study §12).- Type:
int or None
- vocal_helper.router.select_diarization(*, live, duration_s=None, max_speakers=None, num_speakers=None, torch_free=False, pyannote_available=True, nemo_available=True, nemotron_available=False)[source]
Route to the diarization backend that the experiments justify.
Encodes the pdbms unified-study crossover (see the module docstring) as a single, testable decision over the conditions that actually move DER (quality) and RTF (speed): live-vs-batch, duration, speaker count, and torch-availability. The returned plan carries both axes explicitly.
- Parameters:
live (bool) –
Truefor a live stream (streaming diarizer),Falsefor a batch file (whole-buffer offline diarizer — the reliable default for files).duration_s (float or None, optional) – Audio duration in seconds when known (a file’s length is cheap to read).
Nonemeans “unknown” and is treated as long-form — the safe, robust branch. DefaultNone.max_speakers (int or None, optional) – Known upper bound on the number of speakers. Used only to keep audio with more than
SORTFORMER_MAX_SPEAKERSspeakers off NeMo Sortformer (its hard 4-speaker cap).None= unknown. DefaultNone.num_speakers (int or None, optional) – Exact known speaker count (distinct from the
max_speakersceiling). When the router pickssherpaand this is set, the plan carries it assherpa_num_clustersto pin sherpa’s clustering — the fix for its 2-party telephony over-segmentation (pdbms diar-study §12.1). Leaving itNonekeeps sherpa on auto-clustering, so meeting behaviour is unchanged. DefaultNone.torch_free (bool, optional) –
Truewhen the deployment cannot install PyTorch — routes to thesherpaonnxruntime backend regardless of length. DefaultFalse.pyannote_available (bool, optional) – Whether the pyannote backend can actually run (extra installed + bundle present). When a rule would pick
pyannotebut it is unavailable, the router falls back rather than choosing an unrunnable backend. DefaultTrue.nemo_available (bool, optional) – Whether the NeMo Sortformer backend can actually run (the
nemoextra is importable). When the short/dense rule would picknemobut it is not installed, the router falls through to the robustpyannotebranch rather than emitting an unrunnable backend. DefaultTrue.nemotron_available (bool, optional) – Whether the Nemotron 3 ONNX graph is in the diarization-engines bundle. When it is, every offline run routes to
nemotron: it leadsvocal_helper.diar.OFFLINE_PREFERENCEon error rate alone, is torch-free, and has neither Sortformer’s length hang nor its 4-speaker cap. DefaultFalse, so a caller that does not probe keeps the duration crossover; the CLI and the HTTP surface probe.
- Returns:
The chosen
mode+backend, itsexpected_der/expected_rtf, and thereason.- Return type:
Examples
>>> select_diarization(live=False, duration_s=45.0, max_speakers=3).backend 'nemo' >>> select_diarization(live=False, duration_s=1800.0).backend 'pyannote' >>> select_diarization(live=True, torch_free=True).backend 'sherpa' >>> # a short clip still routes to pyannote when the nemo extra is absent >>> select_diarization(live=False, duration_s=45.0, nemo_available=False).backend 'pyannote' >>> # torch-free with a known 2-party count pins sherpa's clustering >>> select_diarization(live=False, torch_free=True, num_speakers=2).sherpa_num_clusters 2 >>> # without a known count, sherpa stays on auto-clustering (meeting default) >>> select_diarization(live=False, torch_free=True).sherpa_num_clusters is None True
Notes
The router decides which diarizer and reports its scenario quality/speed; the online/offline stages keep their own tuned knobs (join threshold, refine pass, chunk ceiling). Numbers were re-validated on this machine (
studies/router_profile_validation.py, 2026-07-19) against ground truth; sherpa is from ADR 0002.