vocal_helper.diar module
vocal_helper.diar
Two diarization paths : online for live streams and offline for batch / file-based inputs.
OnlineDiarStage— consumesVoicedSegmentas the VAD emits them, embeds each one, and runs a per-segment cosine running-mean clusterer. The current best online answer per the pdbms 2026-06-29 canonical study : matcheshungarian_nemo/hungarian_pyannotein spirit, simpler because the VAD has already isolated each segment.OfflineDiarStage— receives the full PCM buffer and hands it to the canonical offline backend (pyannote/speaker-diarization-3.1by default, NeMo Sortformer as alternative). Runs whole-buffer by default : the 2026-07-14 offline map-reduce study found whole-buffer strictly best for DER, so pyannote only chunks pastideal_duration_s= 3600 s (a memory backstop), while NeMo keeps 60 s (Sortformer 90 s cap). When chunking does kick in, the stage overlaps chunks and stitches via cosine AHC (pdbms §10.5, AMI dev-slice median DER 0.116, inside Bredin 2023’s band).
Reliability — which path to use
The offline path is the reliable one and should be preferred for any
batch / file input. A 2026-07-16 DER sweep (studies/diar_der_paths.py,
pyannote.metrics, collar 0.25) measured every path against ground truth:
corpus |
offline pyannote |
offline nemo (Sortf) |
online (no ref / ref) |
|---|---|---|---|
AMI (20-40 min meetings) |
0.122 |
0.242 |
0.497 / 0.351 |
bagarre (~30 s, <=4 spk) |
0.338 |
0.177 |
0.586 / 0.592 |
Takeaways: (1) offline pyannote is literature-grade (Bredin 2023 ~ 0.188
uncollared) and wins on long meetings, running whole-buffer with global
clustering and no speaker-count cap. (2) offline NeMo Sortformer
(diar_sortformer_4spk-v1) is end-to-end and overlap-aware and nearly
halves the DER on short <=4-speaker clips — but it is capped at 4
speakers and ~90 s per window, so it degrades once it must chunk long audio.
(3) The online streaming path (nemo TitaNet embeddings) stays ~3x the
offline DER — a latency-bound approximation that cannot model overlapped
speech ; refine_on_close roughly halves its DER on meetings that
over-segment (ES2011a 0.588 -> 0.296) and never hurts.
Default policy: pyannote is the offline default — robust across any length
and speaker count, best on the long inputs that dominate file use. Pick
--offline --diar-backend nemo for short <=4-speaker workloads where
Sortformer wins. The CLI file --no-real-time auto-selects offline pyannote
when the bundle is present ; reserve OnlineDiarStage for live streams.
Downstream integrators embedding diarization in a larger pipeline should use
OfflineDiarStage / OfflinePipeline for batch.
Online algorithm — minimal cosine-AHC online clusterer
Algorithm — minimal cosine-AHC online clusterer
We don’t carry the full pdbms HungarianDiar across this boundary. The full sliding-window Hungarian wrapper assumes the diarizer is fed raw PCM windows, but here the VAD already gives us isolated voiced segments — one embedding per segment is enough and the global stitching collapses to a 1-D nearest-centroid match on cosine distance with running-mean updates.
For each incoming VoicedSegment :
Embed via the configured backend (pyannote/embedding or NVIDIA TitaNet). The embedding is L2-normalised.
Compute cosine distance to every existing centroid.
The minimum-distance centroid wins iff its distance is below
join_threshold(defaultJOIN_THRESHOLD, measured over 8 full AMI meetings on the shipped TitaNet embeddings — the table under that constant is the evidence). Otherwise, mint a new speaker.Update the matched centroid by exponential moving average with coefficient
ema_alpha(default 0.1) so the centroid adapts slowly to within-speaker variation.
The stage is meant to run online — every voiced segment is
labelled at most embed_latency_ms after the speech ends.
Backend choice
backend='pyannote' is the default and only requires the
pyannote extra (pip install vocal-helper[pyannote]). It
uses pyannote/embedding (160 ms minimum input). backend='nemo'
uses NVIDIA TitaNet via the nemo extra ; slower to load but
better cosine separation on noisy mixes.
- vocal_helper.diar.DEFAULT_DIARIZATION_ENGINES_SHA256: str | None = '0e5ef7c41820c05f8207a92eb8814ff6484f095304a122c43c739f48c47cfe40'
Digest of the bundle served at
DEFAULT_DIARIZATION_ENGINES_URL, so the default path is verified without anyone configuring anything. It belongs to THAT url and to no other: a digest applied to a different, configured source would fail every time and read as a corrupt download, so it is only ever passed when the resolved source is the default one.It must be the digest of the file actually served. Until 2026-11-05 it pinned a 991 MB build that was never the one online (the server has held a 794 MB build since 2026-11-01), so every fresh install failed on
sha256 mismatchand reached no bundle at all. The served build carriessherpa/but neithernemotron3/norpyannote-community-1/: the Nemotron graph is fetched on its own (vocal_helper.nemotron.resolve_graph()) and pyannote falls back to 3.1 with a warning.tests/test_bundle_online.pychecks both against the server, outside CI.
- class vocal_helper.diar.DiartDiarStage(*, sample_rate=16000, **config)[source]
Bases:
objectOnline diarization through diart, with this package’s stage surface.
Ported from
operator-assistance-streaming/streaming/diar_diart.py, which is being absorbed. Worth porting rather than dropping because that repository had already measured the thing, in a comment nobody outside it could see: on their long reference diart reaches 6.04 % speaker error against 11.7 % for the pyannote community-1 path, and a first label in 500 ms against 20 s.That is the opposite of the offline verdict, where community-1 wins on every axis measured. Two regimes, two winners, both measured — which is why
OnlineDiarStageis not being replaced, only joined.Two costs recorded rather than buried. diart needs its own environment (the source repository ran its GUI from a dedicated conda env), so the import is lazy and says what is missing. And its snapshot semantics mean it revises what it already emitted — see
snapshot_to_delta(), and measure it withvocal_helper.live_metricsbefore choosing it over a quieter diarizer.- DEFAULTS = {'duration': 5.0, 'latency': 0.5, 'step': 0.5, 'tau_active': 0.5}
5 s window, 500 ms step, 500 ms latency at 16 kHz.
- Type:
diart’s published recommendation, and what the source repository ran
- vocal_helper.diar.JOIN_THRESHOLD = 0.5
Cosine distance below which a streaming segment joins an existing speaker.
Was 0.30, calibrated for the
pyannote/embeddingdistribution — and then the default embedder became TitaNet, which sits on a different cosine scale, and the threshold never moved.DIARIZATION-TROUBLES.mdlists that mismatch as root cause 5 of the over-segmentation it documents; this is the measurement that closes it.pasdebonneoudemauvaisesituation/scripts/exp_online_centroid_rules.pyreplays the clusterer over 8 full AMI meetings (4 speakers each) using the shipped TitaNet embeddings. Raw labels before the close-of-stream repair:threshold
speakers found
confusion median
0.30
87 (true 4)
0.234
0.40
58
0.137
0.50
28
0.095
0.60
13
0.125
End-to-end, with the close repair applied, 0.50 lowers median confusion from 0.101 to 0.074 and the worst meeting from 0.154 to 0.117. It is a free improvement: no new model, no extra pass, one number.
0.60 finds a count closer to the truth but costs accuracy (confusion 0.113) — it starts forcing genuinely different people together, which is the unrecoverable direction.
- vocal_helper.diar.MERGE_LINKAGE = 'complete'
How distance between two groups of voiceprints is defined when merging.
"complete"— the FARTHEST pair decides — rather than the single-linkage union-find this module used before. Single linkage merges two groups as soon as any one member of each is close, so one ambiguous segment welds two speakers together, and the weld is transitive: A-B and B-C collapse A-C even when A and C are plainly different people. On AMI that shows up as a cliff — single linkage recovers the right speakers at 0.30 and collapses every speaker in the meeting into ONE cluster at 0.40. Complete linkage requires every member of both groups to be close, which costs a few correct merges and removes the cliff.The practical consequence is margin, and it is measured: on the same 32 AMI cases the old default (single linkage, 0.30, uncentred) is safe in 81 % of them and drops to 47 % one notch up at 0.40. Complete linkage in the centred space holds 94 % across a band three notches wide. A cliff was replaced by a plateau.
Complete linkage is also the right KIND of error. It errs towards leaving one speaker split in two, which the next stage can still repair; single and average linkage err towards welding two people into one, which nothing downstream can undo. When in doubt, fail in the direction that is recoverable.
- vocal_helper.diar.MERGE_THRESHOLD = 0.6
Cosine distance below which two provisional speakers are taken to be the same person.
Measured, not guessed.
pasdebonneoudemauvaisesituation/scripts/exp_merge_overclustered.pysplits each AMI reference speaker into m pure sub-clusters (m = 2, 3, 4, 6 — perfect over-clustering), pools a voiceprint per sub-cluster, and asks whether a merge puts them back together. 8 meetings × 4 factors = 32 cases, scored as “recovered the true speaker count AND left under 5 % of speech time on the wrong person”.This number lives in the CENTRED space (see the
centreargument ofmerge_speakers()). On that experiment alone the best value is 0.9, which repairs 94 % of cases against 81 % at 0.6 — and shipping 0.9 would have been a mistake, because a second experiment measures a workload where it does harm.exp_merge_realclusters.pyproduces the over-clustering the way a diarizer actually does — agglomerative clustering asked for K̂ = m·K — instead of splitting each speaker’s turns at random. On that input, 0.9 leaves 9 of 32 cases worse than not merging at all, while 0.6 and below never damage a single one. 0.6 is therefore the largest value that is harmless on the hard workload, and it still repairs 81 % of the easy one.The gap between the two experiments is the real lesson, and
merge_speakers()’s docstring spells it out: random fragmentation is repairable, a clusterer’s considered opinion is not. Withcentre=Falsethe geometry differs and the safe band moves down to roughly 0.3.
- vocal_helper.diar.OFFLINE_PREFERENCE = ('nemotron', 'pyannote', 'nemo', 'sherpa')
Speaker-embedding backends, and what picking between them actually costs.
Both emit the same 192-dimensional TitaNet-large vector, so downstream clustering and cross-recording identity matching are unaffected by the choice — it is an installation and platform decision, not a quality one.
"nemo"runs it through torch/NeMo (sharpest on a desktop);"sherpa"runs the same weights through onnxruntime, torch-free, which is what ships on iOS and Android. Offline backends in order of measured quality, best first. Quality is the ONLY criterion here — offline means waiting, so the real-time factor is beside the point — and the ranking comes from the 2026-11-01 shootout on a synthetic mix whose ground truth is exact by construction, anechoic and reverberated:nemotrontherefore leads: three times better than the previous default, the right speaker count in every condition, and speaker confusion of 0.000 on clean speech. It needs no torch — the graph runs on onnxruntime, a core dependency — and it ships in the weights bundle, so a bare install that has fetched the bundle has it.On AMI it ranks LAST (0.487 against pyannote’s 0.192), and that ranking was not taken at face value. AMI’s reference is transcribed from the headset microphones while the engine is given the room mix, and the speech nemotron does not emit there sits 21.8 dB BELOW the speech it does emit and only 5.4 dB above annotated silence — it is at the room mic’s noise floor, so the signal does not carry it. Four candidate explanations were tested and three rejected (extraction threshold, signal level, reverberation); the annotation is what is left. pyannote scores well there by emitting into that noise floor, which the biased reference rewards and the exact one punishes: on the reverberated mix its missed detection is 0.296 against nemotron’s 0.101.
nemokeeps second place on quality but hangs past ~25 min, which is why it was never the general default.sherpais torch-free and last. Seevocal_helper.router.
- vocal_helper.diar.ONLINE_PREFERENCE = ('nemo', 'pyannote', 'sherpa')
nemo 0.586, pyannote 0.590, then sherpa.
- Type:
Online backends in order of measured quality, best first
- class vocal_helper.diar.OfflineDiarStage(*, backend='auto', ideal_duration_s=None, overlap_s=10.0, stitch_threshold=0.35, sherpa_cluster_threshold=0.5, sherpa_num_clusters=-1, pyannoteai_model=None, device=None)[source]
Bases:
objectOffline diarization on the full PCM buffer.
Designed for batch / file-based use : the upstream source is expected to drain end-to-end, the stage collects the full PCM, then hands it to the canonical offline backend.
Backends
"pyannote"—pyannote/speaker-diarization-3.1. The production default for any meeting / podcast / lecture (pdbms §10.5 : AMI dev-slice median DER 0.116, inside Bredin 2023’s 0.188 band)."nemo"— NVIDIA Sortformer (thenvidia/diar_sortformer_v1checkpoint). Better for short clips ≤ 60 s, struggles past its 90 s training cap. Auto-chunked whenlen > IDEAL_DURATION_S.
Long-form chunking
For inputs longer than
ideal_duration_sthe stage replicates the pdbmsChunkedOfflineDiarizerstrategy at minimal cost :Split the audio into chunks of
ideal_duration_swithoverlap_s(default 10 s) of shared content at boundaries.Run the backend on each chunk.
Embed each chunk-local speaker on its concatenated audio.
Cluster all chunk-local embeddings via cosine AHC (
stitch_threshold=0.35, the value selected in the 2026-06-30 stitch_threshold sweep on AMI dev-slice N=8 where t∈{0.30..0.40} forms the operating plateau).
The full pdbms variant adds VAD-aware cut-point selection and pink-noise pad ; vocal-helper trades these for a simpler hard-cut + zero-pad pair to keep the dependency surface small. For mission- critical AMI-style work, use
pdbms.diar.offline_chunked.ChunkedOfflineDiarizerdirectly.Chunking is a memory ceiling, not a quality lever. The 2026-07-14 offline map-reduce study (full stack VAD + ASR + diar on AMI,
doc/studies/offline-mapreduce-study.md) found DER strictly monotone in chunk size — whole-buffer is best (median DER 0.143 vs 0.170 at 300 s, and cliffs to 0.31 / 0.50 at 120 s / 60 s as speaker fragmentation outruns the stitch) — and ASR destabilises when chunked (a long-window whisper loop drove one meeting to WER 1.17). So the pyannote default now runs whole-buffer for any realistic input (ideal_duration_s= 3600 s) and only chunks past ~1 h as a memory backstop. NeMo is the exception: its Sortformer 90 s training cap forces chunking atideal_duration_s= 60 s.- param backend:
Backend to use. Default
"pyannote"."sherpa"is the portable ONNX pipeline (community-1 seg + TitaNet-large emb, no torch) — see ADR 0002.- type backend:
“pyannote” | “nemo” | “sherpa”
- param ideal_duration_s:
Whole-buffer ceiling : inputs longer than this are chunked + stitched, shorter ones run as a single call. Default depends on the backend — 3600 s for pyannote (effectively whole-buffer for any realistic meeting; chunking is a memory backstop only), 60 s for NeMo (forced by its Sortformer 90 s cap).
- type ideal_duration_s:
float, optional
- param overlap_s:
Overlap between adjacent chunks. Default 10 s.
- type overlap_s:
float
- param stitch_threshold:
Cosine-distance threshold for cross-chunk AHC stitching. Default 0.35.
- type stitch_threshold:
float
- param device:
Torch device for the pyannote pipeline + embedder.
None(default) auto-picks CUDA > MPS > CPU. On Apple Silicon CPU is ~ 10× slower than MPS, so the auto-pick matters in practice. Has no effect on the NeMo backend.- type device:
“cpu” | “cuda” | “mps”, optional
Notes
Model weights load from the self-hosted diarization-engines bundle (see
resolve_diarization_engines()) — no HuggingFace token is used or accepted.- diarize(pcm, sr, *, num_speakers=None, min_speakers=None, max_speakers=None)[source]
Return
[(t0, t1, speaker), …]sorted by start time.- Parameters:
num_speakers (int, optional) – Exact speaker count, when the set really is closed.
min_speakers (int, optional) – A FLOOR — the hint to reach for whenever a roster is known. Panel shows announce their panel, so the count is available before a sample is processed; but the roster is a floor and not a total, because the episode also carries reporters, archive clips and narrators nobody announced. Forcing an exact count has to put those extra voices somewhere, and where it puts them is inside a real person.
max_speakers (int, optional) – A ceiling. Leave unset unless a hard upper bound is genuinely known.
sr (int)
- Return type:
Notes
These reach the backend’s OWN clustering rather than being applied afterwards, and that is the point. Repairing an over-estimated count after the fact is measurably not possible with the same embedding that produced it — the clusterer has already spent that information (see
merge_speakers()). A count hint is only useful where it can still change the decision.
- async run(inbox, outbox)[source]
Drain
inboxofPcmFrame; emitDiarizedSegment.Collects every frame until the upstream sends
None, then runsdiarizeon the full buffer in a worker thread and emits oneDiarizedSegmentper identified speaker span.- Parameters:
inbox (Queue)
outbox (Queue)
- Return type:
None
- class vocal_helper.diar.OnlineDiarStage(*, backend='auto', join_threshold=0.5, ema_alpha=0.1, min_segment_ms=500, device=None, max_speakers=None, refine_on_close=False, min_cluster_size=2, merge_threshold=None, merge_linkage='complete')[source]
Bases:
objectProducer/consumer online speaker diarizer.
- Parameters:
backend ("pyannote" | "nemo" | "sherpa") – Which embedding model to use. Default
"pyannote"."sherpa"runs TitaNet-large through onnxruntime (no torch) — see_SherpaEmbedder.join_threshold (float) – Cosine-distance threshold below which a new segment joins an existing centroid. See
JOIN_THRESHOLD.ema_alpha (float) – Exponential-moving-average coefficient for centroid updates. Default 0.1.
min_segment_ms (int) – Minimum voiced-segment duration to attempt embedding. Default 500 ms (pyannote/embedding’s convolutional kernels choke on shorter inputs).
device ("cpu" | "cuda" | "mps", optional) – Torch device for the pyannote embedder.
None(default) auto-picks CUDA > MPS > CPU. Has no effect on the NeMo backend.max_speakers (int | None)
refine_on_close (bool)
min_cluster_size (int)
merge_threshold (float | None)
merge_linkage (str)
Notes
Model weights load from the self-hosted diarization-engines bundle (see
resolve_diarization_engines()) — no HuggingFace token is used or accepted.- async run(inbox, outbox)[source]
Consume
VoicedSegmentfrominbox, pushDiarizedSegment.In streaming mode (
refine_on_close=False) each segment is labelled and emitted as it arrives. In batch mode (refine_on_close=True) segments are buffered, globally re-clustered when the stream closes, then emitted with corrected labels — see_run_refine().- Parameters:
inbox (Queue)
outbox (Queue)
- Return type:
None
- vocal_helper.diar.make_embedder(backend='nemo', *, model_path=None)[source]
Build a speaker embedder: the public way to get one.
Exists because every consumer in the suite was reaching into this module’s private names for it —
notes-helperimported_TitaNetEmbedder,_SherpaEmbedderand_resolve_sherpa_models;sidekickimported_SherpaEmbedder. Three repositories depending on a leading underscore is not a dependency, it is a promise nobody made, and it is exactly what has to go before vocal-helper can be “the tool you use for any free local mode”.- Parameters:
backend (str, optional) –
"nemo"(default) or"sherpa"— seeEMBEDDER_BACKENDS. The default is TitaNet via NeMo, picked bystudies/diar_embedding_backend.py: a 0.354 separability margin against 0.201 forpyannote/embedding, a 76 % uplift.model_path (str, optional) –
sherpaonly: an explicit ONNX path.Noneresolves it the usual way (resolve_sherpa_models()), which never reaches the hub.
- Returns:
An object exposing
load()andembed(pcm, sr) -> np.ndarray.- Return type:
Any
- Raises:
ValueError – On an unknown backend — named here rather than failing later inside a model load.
- vocal_helper.diar.merge_speakers(embeddings, labels, *, durations=None, threshold=0.6, linkage='complete', min_cluster_size=1, min_speakers=None, max_speakers=None, centre=True)[source]
Fold an over-estimated set of speaker labels back down using voiceprints.
Every diarizer we measure on real panel audio finds too many speakers rather than too few: on C dans l’air episodes announcing a six-person panel, pyannote community-1 returns 13–18 labels, NeMo 20–24, and the online path over 100.
DIARIZATION-TROUBLES.mdrecords the same signature — 203 labels for four people.When this works, and when it cannot
It depends entirely on WHERE the extra labels came from, and the difference is not a detail — it is the difference between a near-perfect repair and no repair at all.
Random fragmentation is repairable. A streaming clusterer that mints a duplicate because its centroid drifted, or spawns a one-off label for a laugh, splits a speaker in a way unrelated to how that speaker actually sounds. Each fragment is then a random sample of the same voice, and the fragments look alike. Measured on AMI with such splits, the voiceprints separate same-speaker from different-speaker fragments at AUC 0.99 and the merge recovers the true count in 94 % of cases.
A clusterer’s considered opinion is not. When the extra labels come from asking a clusterer for too many speakers, the groups it returns are precisely the ones it judged FAR APART — using the same cosine similarity this function would use to merge them. The information needed for the repair has already been consumed. Measured on the same meetings, that AUC collapses from 0.99 to 0.60 median, 0.27 worst — at or below chance. No threshold and no linkage recovers it, because the signal is not there.
The practical rule: use this to repair fragmentation, never to undo a deliberate over-clustering. For the latter the only real fix is a different, stronger embedding, or a diarizer asked for the right count in the first place. The shipped default is deliberately conservative for exactly this reason — see
MERGE_THRESHOLD.That asymmetry is the reason this function exists. Over-clustering is repairable and under-clustering is not. When one person has been split across three labels, each piece is still pure, and a merge can reassemble them. When two people have been collapsed into one label, their speech is mixed inside a single cluster and nothing downstream can separate them again without redoing the segmentation. So the safe operating point for any diarizer in this suite is to over-split deliberately and repair here, rather than to gamble on hitting the count exactly.
- param embeddings:
One unit-norm voiceprint per segment.
Nonemarks a segment that was never embedded; those keep their incoming label untouched rather than being guessed at.- type embeddings:
list of ndarray or None
- param labels:
The provisional speaker id per segment, index-aligned with embeddings.
- type labels:
list of str
- param durations:
Seconds per segment. Used to weight the per-speaker voiceprint, because a 20 s turn is far stronger evidence of who is talking than a 1 s one, and an unweighted mean lets a crowd of short backchannels outvote the speech that actually identifies somebody. Defaults to equal weight.
- type durations:
list of float, optional
- param threshold:
Cosine distance at which merging stops. See
MERGE_THRESHOLD.- type threshold:
float, optional
- param linkage:
"complete"(default),"average"or"single"— seeMERGE_LINKAGE.- type linkage:
str, optional
- param min_cluster_size:
Merged clusters holding fewer segments than this are dissolved and their segments reassigned to the nearest surviving cluster. This is what absorbs the one-off micro-speakers that overlap, laughter and jingles produce.
- type min_cluster_size:
int, optional
- param min_speakers:
A FLOOR the merge may never go below. Panel shows announce their panel, so the roster is known before a single sample is processed — and it is a floor rather than a total, since an episode also carries reporters, archive clips and off-screen narrators nobody announces. Merging past the floor destroys the very separation the roster proves exists, which is why a floor is the right hint to pass a diarizer and an exact count is not.
- type min_speakers:
int, optional
- param max_speakers:
A ceiling, applied after the floor. Useful when a hard upper bound is genuinely known; leave unset otherwise, since guessing it wrong forces real speakers together.
- type max_speakers:
int, optional
- param centre:
Subtract the mean of the per-speaker voiceprints before comparing them, then re-normalise. Every segment of one recording shares a component that is not the speaker — the microphone, the room, the codec — and on single-device audio (a meeting through one array, a panel through one broadcast chain) that shared component can dominate the cosine geometry and swamp the smaller differences that actually distinguish people. Removing it widened the safe band from 94 % of AMI cases at a single threshold (0.30, uncentred) to 94 % across a three-notch span.
The mean is taken over SPEAKERS, not over segments, and that detail matters: a host holding 40 % of a panel’s airtime drags a segment-mean towards himself, so centering would subtract mostly him rather than the shared channel. One vote per provisional speaker removes that failure before it can happen.
Borrowed from
notes_helper.diarize.cluster, which has done this for its local path all along; measuring it is what moved it from one consumer’s private trick to everyone’s default. Note that it changes what threshold means — seeMERGE_THRESHOLD.- type centre:
bool, optional
- returns:
One label per input segment, renamed compactly to
"S0","S1", … in order of first appearance. Segments whose embedding wasNonekeep their original label.- rtype:
list of str
Notes
Pure and model-free: it takes vectors and returns labels, touching no audio and loading nothing. That is deliberate — it lets any consumer repair the output of any diarizer, including offline and paid cloud ones whose clustering we do not control.
- vocal_helper.diar.resolve_backend(requested, *, mode)[source]
Turn
"auto"into the best backend this installation can actually run.Why the defaults are
"auto"and not a nameThey used to be names —
"pyannote"offline,"nemo"online — and both name an extra. So a barepip install vocal-helperdid not diarize with reduced quality, it raised: the library’s own default asked for something the install did not contain. That is why sherpa-onnx is now a core dependency and why these defaults probe.The order is the measured quality order, so installing
[pyannote]upgrades the result with no code change: the install is the switch.- param requested:
A backend name, or
"auto".- type requested:
str
- param mode:
"offline"or"online"— they do not share an order.- type mode:
str, keyword-only
- returns:
A concrete backend name. An explicit request is returned untouched even if it looks absent: the probe can be wrong, and an override exists for exactly that case.
"auto"never returns something absent.- rtype:
str
- raises ValueError:
On an unknown mode.
Examples
>>> resolve_backend("sherpa", mode="offline") 'sherpa' >>> resolve_backend("auto", mode="offline") in OFFLINE_PREFERENCE True >>> resolve_backend("auto", mode="online") in ONLINE_PREFERENCE True
- vocal_helper.diar.resolve_diarization_engines()[source]
Locate the HF-free diarization-engines bundle, or
None.Source order: the explicit
$VH_DIARIZATION_ENGINESenv var, thenengines.diarization_urlinsettings.yaml(the canonical config), thenDEFAULT_DIARIZATION_ENGINES_URL. A local dir is used as-is ; a URL todiarization-engines.zipis downloaded once and cached under$VH_CACHE_DIR(default~/.cache/vocal-helper). Returns the directory that containsmanifest.json.Present locally, it is used; absent, it is fetched — and the fetch goes through os-helper, which streams, resumes, retries, writes atomically and verifies a digest when one is configured.
$VH_DIARIZATION_ENGINES_SHA256carries that digest: without it a truncated download of a near-gigabyte archive yields a bundle that is simply wrong, with nothing to say so.- Return type:
Path | None
- vocal_helper.diar.resolve_sherpa_models()[source]
Public name for the sherpa ONNX resolution —
(segmentation, embedding)paths.Same function as the private one it wraps, published because consumers already depend on it. Never reaches HuggingFace: environment override, then the self-hosted bundle, then the shared ai-helpers mirror.
- vocal_helper.diar.snapshot_to_delta(snapshot, already_sent)[source]
Turn one of diart’s full-annotation snapshots into what is actually new.
diart re-emits its entire running annotation on every step: each call extends a few segments and may merge others. The original wrapper handled this by rebuilding its result list from scratch each time, which is correct for a list and impossible for a queue — a downstream stage consuming a stream cannot un-receive what it already got, and appending the whole snapshot would make every segment arrive once per step.
So the snapshot is diffed against what has already gone out, keyed by
(start, speaker): a segment is new if that key has never been sent, and a revision if the key was sent with a different end time. Revisions are forwarded too, because a segment that grew is news, and they are counted separately so the caller can see them rather than discover them as duplicates.That count is the honest price of diart’s latency. It reaches a first label in about 500 ms where a finalise-then-emit diarizer takes seconds, and it pays for that by revising what it already said — which is exactly the flicker
vocal_helper.live_metricsmeasures. Neither number means anything without the other.