vocal_helper.diar module

vocal_helper.diar

Two diarization paths : online for live streams and offline for batch / file-based inputs.

  • OnlineDiarStage — consumes VoicedSegment as 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 : matches hungarian_nemo / hungarian_pyannote in 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.1 by 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 past ideal_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 :

  1. Embed via the configured backend (pyannote/embedding or NVIDIA TitaNet). The embedding is L2-normalised.

  2. Compute cosine distance to every existing centroid.

  3. The minimum-distance centroid wins iff its distance is below join_threshold (default JOIN_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.

  4. 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.

Author

Warith HARCHAOUI , https://linkedin.com/in/warith-harchaoui

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 mismatch and reached no bundle at all. The served build carries sherpa/ but neither nemotron3/ nor pyannote-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.py checks both against the server, outside CI.

class vocal_helper.diar.DiartDiarStage(*, sample_rate=16000, **config)[source]

Bases: object

Online 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 OnlineDiarStage is 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 with vocal_helper.live_metrics before choosing it over a quieter diarizer.

Parameters:
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

consume_snapshot(snapshot)[source]

One diart snapshot in, the segments a stream has not seen yet out.

Split from the async plumbing on purpose: this is where the reasoning lives, so it is testable without diart, without audio and without an event loop.

Parameters:

snapshot (list[tuple[float, float, str]])

Return type:

list[DiarizedSegment]

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/embedding distribution — and then the default embedder became TitaNet, which sits on a different cosine scale, and the threshold never moved. DIARIZATION-TROUBLES.md lists 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.py replays 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.py splits 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 centre argument of merge_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.py produces 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. With centre=False the 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:

nemotron therefore 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.

nemo keeps second place on quality but hangs past ~25 min, which is why it was never the general default. sherpa is torch-free and last. See vocal_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: object

Offline 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 (the nvidia/diar_sortformer_v1 checkpoint). Better for short clips ≤ 60 s, struggles past its 90 s training cap. Auto-chunked when len > IDEAL_DURATION_S.

Long-form chunking

For inputs longer than ideal_duration_s the stage replicates the pdbms ChunkedOfflineDiarizer strategy at minimal cost :

  1. Split the audio into chunks of ideal_duration_s with overlap_s (default 10 s) of shared content at boundaries.

  2. Run the backend on each chunk.

  3. Embed each chunk-local speaker on its concatenated audio.

  4. 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.ChunkedOfflineDiarizer directly.

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 at ideal_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.

  • pcm (ndarray[tuple[int, ...], dtype[float32]])

  • sr (int)

Return type:

list[tuple[float, float, str]]

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 inbox of PcmFrame ; emit DiarizedSegment.

Collects every frame until the upstream sends None, then runs diarize on the full buffer in a worker thread and emits one DiarizedSegment per identified speaker span.

Parameters:
  • inbox (Queue)

  • outbox (Queue)

Return type:

None

Parameters:
  • backend (str)

  • ideal_duration_s (float | None)

  • overlap_s (float)

  • stitch_threshold (float)

  • sherpa_cluster_threshold (float)

  • sherpa_num_clusters (int)

  • pyannoteai_model (str | None)

  • device (str | 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: object

Producer/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 VoicedSegment from inbox, push DiarizedSegment.

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-helper imported _TitaNetEmbedder, _SherpaEmbedder and _resolve_sherpa_models; sidekick imported _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" — see EMBEDDER_BACKENDS. The default is TitaNet via NeMo, picked by studies/diar_embedding_backend.py: a 0.354 separability margin against 0.201 for pyannote/embedding, a 76 % uplift.

  • model_path (str, optional) – sherpa only: an explicit ONNX path. None resolves it the usual way (resolve_sherpa_models()), which never reaches the hub.

Returns:

An object exposing load() and embed(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.md records 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. None marks 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" — see MERGE_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 — see MERGE_THRESHOLD.

type centre:

bool, optional

returns:

One label per input segment, renamed compactly to "S0", "S1", … in order of first appearance. Segments whose embedding was None keep 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.

Parameters:
Return type:

list[str]

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 name

They used to be names — "pyannote" offline, "nemo" online — and both name an extra. So a bare pip install vocal-helper did 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
Parameters:
Return type:

str

vocal_helper.diar.resolve_diarization_engines()[source]

Locate the HF-free diarization-engines bundle, or None.

Source order: the explicit $VH_DIARIZATION_ENGINES env var, then engines.diarization_url in settings.yaml (the canonical config), then DEFAULT_DIARIZATION_ENGINES_URL. A local dir is used as-is ; a URL to diarization-engines.zip is downloaded once and cached under $VH_CACHE_DIR (default ~/.cache/vocal-helper). Returns the directory that contains manifest.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_SHA256 carries 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.

Return type:

tuple[str, str]

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_metrics measures. Neither number means anything without the other.

Parameters:
  • snapshot (list of (start, end, label)) – The full annotation as of this step.

  • already_sent (dict) – Mutated in place: maps (start, speaker) to the last tuple emitted for it.

Returns:

(to_emit, n_revisions).

Return type:

tuple