vocal_helper package
Submodules
- vocal_helper.api module
- vocal_helper.asr module
- vocal_helper.cli module
- vocal_helper.cli_argparse module
- vocal_helper.cli_click module
- vocal_helper.diar module
- vocal_helper.eot module
- vocal_helper.eot_bench module
- vocal_helper.gui module
- vocal_helper.lid module
- vocal_helper.llm module
- vocal_helper.mcp module
- vocal_helper.parallel_pipelines module
- vocal_helper.pipeline module
- vocal_helper.router module
- vocal_helper.sources module
- vocal_helper.types module
- vocal_helper.vad module
Module contents
Vocal Helper
Producer/consumer pipeline turning a live PCM stream into diarized, transcribed utterances and (optionally) a rolling LLM summary.
Stages, all stitched by Pipeline :
Source — any async iterator of
PcmFrame. Three shipped :sources.from_microphone()(live mic viacapture-helper),sources.from_wav_file()(replay a mono 16 kHz WAV at real-time or burst speed),sources.from_numpy_array()(in-memory PCM, for tests).VAD — Silero v5 ONNX on CPU (
SileroVADStage). 32 ms windows,activity_threshold=0.5, defaultmin_silence_ms=300.Online diarization —
OnlineDiarStage. Per-segment embedding (pyannote/embedding by default, TitaNet via NeMo withbackend='nemo') + cosine-distance running-mean clustering withjoin_threshold=0.30(calibrated on AMI dev-slice N=8, 2026-06-30 sweep).STT —
WhisperStage. pywhispercpp turbo (large-v3-turbo-q5_0), threads default 6, word timestamps on. Runs inasyncio.to_thread()so the loop is never blocked.LLM analyst (optional) —
GemmaAnalystStage. Ollama servesgemma4:e4b(auto-selects the-mlxvariant on Apple-Silicon) ; the stage keeps a rolling summary of everything older thanrecent_window_s = 60seconds and emits a freshSummarySnapshotafter every accepted utterance.
Quickstart
>>> import asyncio, vocal_helper as voh
>>>
>>> async def main():
... pipeline = voh.Pipeline(
... source=lambda: voh.sources.from_microphone(),
... config=voh.PipelineConfig(
... diar={"backend": "pyannote"},
... llm={"model": "gemma4:e4b"},
... ),
... )
... async for ev in pipeline.run():
... if isinstance(ev, dict) and "text" in ev:
... print(f"[{ev['t0']:.1f}s {ev['speaker']}] {ev['text']}")
... elif isinstance(ev, dict) and "summary" in ev:
... print(f"--- summary ---\n{ev['summary']}")
>>>
>>> asyncio.run(main())
Usage Example
>>> import asyncio, vocal_helper as voh
>>>
>>> async def main():
... pipeline = voh.Pipeline(
... source=lambda: voh.sources.from_wav_file("clip.wav"),
... config=voh.PipelineConfig(diar={"backend": "pyannote"}),
... )
... async for ev in pipeline.run():
... if "text" in ev:
... print(ev["text"])
>>>
>>> asyncio.run(main())
- class vocal_helper.BackendPlan(mode, backend, expected_der, expected_rtf, reason)[source]
Bases:
objectOne routing decision: which diarizer, and its quality + speed.
- mode
"online"(streamingOnlineDiarStage) or"offline"(whole-bufferOfflineDiarStage).- Type:
- backend
Diarization backend —
"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:
- class vocal_helper.DiarizedSegment[source]
Bases:
TypedDictVoiced segment with a global speaker id attached.
Emitted by
vocal_helper.diar.OnlineDiarStageafter each voiced segment has been embedded and matched against the running speaker centroids.speakeris a stable string id of the form"S0","S1"— same speaker across the whole session.
- class vocal_helper.EOTPair(true_turn_end_s, detector_commit_s)[source]
Bases:
objectOne aligned ground-truth / detector datum.
- true_turn_end_s
Absolute (mix-time) second at which the speaker actually released the floor. In practice pulled from a hand-labelled turn boundary or a bridged word-level RTTM.
- Type:
- detector_commit_s
Absolute second at which the detector emitted its end-of-turn decision. For a semantic-EOT model this is typically
true_turn_end + inference_latencyon hits.- Type:
- class vocal_helper.GemmaAnalystStage(*, model='gemma3:4b', recent_window_s=60.0, flush_every_n=5, flush_every_s=60.0, host=None, prompt_template='You are a meeting note-taker. Update the running summary below by integrating the new utterances. Keep it concise (≤ 6 bullet points), preserve speaker attributions, and drop low-signal small talk. Output only the updated summary, nothing else.\n\nCurrent summary:\n{summary}\n\nNew utterances (older → newer):\n{new_block}\n')[source]
Bases:
objectProducer/consumer LLM analyst with a rolling summary.
- Parameters:
model (str) – Ollama model tag. Default
"gemma4:e4b"— Gemma 4 4B effective, the canonical light analyst across the AI Helpers suite. On Apple-Silicon,ollamaresolves the-mlxvariant automatically when present.recent_window_s (float) – How many seconds of verbatim transcript to keep before folding into the summary. Default 60 s.
flush_every_n (int) – Update the summary every
flush_every_nnew utterances that crossed the recent window. Default 5.host (str, optional) – Ollama host URL. Defaults to the
OLLAMA_HOSTenv var orhttp://localhost:11434.prompt_template (str) – Override the canonical summarisation prompt. Two placeholders :
{summary}(current digest) and{new_block}(newly evicted utterances). Default keeps a ≤ 6-bullet meeting digest.flush_every_s (float | None)
- async run(inbox, outbox)[source]
Consume
Utterancefrominbox, pushSummarySnapshot.- Parameters:
inbox (Queue)
outbox (Queue)
- Return type:
None
- class vocal_helper.LangRegion(lang, t0, t1)[source]
Bases:
objectA contiguous mono-language span of audio (seconds), ISO-639-1
lang.
- class vocal_helper.OfflineDiarStage(*, backend='pyannote', ideal_duration_s=None, overlap_s=10.0, stitch_threshold=0.35, 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.- 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.OfflinePipeline(*, source, config=None)[source]
Bases:
objectEnd-to-end batch chain : source → offline diar → ASR → LLM.
Trades the live VAD + per-segment online clustering for a single call to
OfflineDiarStageon the full PCM buffer (with long-form chunking baked in). This is the best-quality path when the input is fully available — typical use cases : meeting recordings, podcasts, voicemail batches, lecture archives.Usage
>>> import asyncio, vocal_helper as voh >>> >>> async def main(): ... pipeline = voh.OfflinePipeline( ... source=lambda: voh.sources.from_wav_file("meeting.wav", ... real_time=False), ... config=voh.OfflinePipelineConfig( ... diar={"backend": "pyannote"}, ... asr={"language": "en"}, ... llm={"model": "gemma4:e4b"}, ... ), ... ) ... async for ev in pipeline.run(): ... print(ev) ... >>> asyncio.run(main())
The cadence trade
Because the four stages are decoupled by queues, each can run at its own pace : the diarizer waits for the entire PCM, the ASR streams through diarized segments as the diarizer finishes emitting them, the LLM analyst aggregates utterances as they land. The only end-to-end blocker is the offline diarizer itself.
- subscribe_diarized(cb)[source]
Register an async callback fired for every
DiarizedSegment.- Parameters:
cb (Callable[[DiarizedSegment], Awaitable[None]])
- Return type:
None
- Parameters:
source (SourceFactory)
config (OfflinePipelineConfig | None)
- class vocal_helper.OfflinePipelineConfig(diar=<factory>, asr=<factory>, llm=None, qsize_pcm=200, qsize_seg=32)[source]
Bases:
objectConfiguration object for
OfflinePipeline.Same shape as
PipelineConfigminus the streaming-specificvadblock — the offline diarizer ingests the full audio and relies on the backend’s own VAD / segmentation.
- class vocal_helper.OnlineDiarStage(*, backend='nemo', join_threshold=0.3, ema_alpha=0.1, min_segment_ms=500, device=None, max_speakers=None, refine_on_close=False, min_cluster_size=2, merge_threshold=None)[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. Default 0.30 — calibrated on AMI dev-slice N=8 in 2026-06-30 stitch_threshold sweep, where the pyannote/embedding distribution exhibits a clear DER minimum at the 0.30-0.45 plateau.
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)
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
- class vocal_helper.PcmFrame[source]
Bases:
TypedDictOne PCM frame at the configured sample rate.
Mirrors
capture_helper.MicFrameandpodcast_helper.PcmFrameso a producer from either library can feed this pipeline directly.
- class vocal_helper.Pipeline(*, source, config=None)[source]
Bases:
objectEnd-to-end audio→text(→summary) producer/consumer chain.
Usage
>>> import asyncio, vocal_helper as voh >>> >>> async def main(): ... pipeline = voh.Pipeline( ... source=lambda: voh.sources.from_microphone(), ... config=voh.PipelineConfig( ... diar={"backend": "pyannote"}, ... asr={"model": "large-v3-turbo-q5_0"}, ... llm={"model": "gemma4:e4b"}, ... ), ... ) ... async for event in pipeline.run(): ... print(event) ... >>> asyncio.run(main())
Yielded events are a mix of
UtteranceandSummarySnapshot(the latter only whenllmis configured). For per-stage observation seePipeline.subscribe_voiced()/subscribe_diarized.- subscribe_diarized(cb)[source]
Async callback for every
DiarizedSegmentafter diarization.- Parameters:
cb (Callable[[DiarizedSegment], Awaitable[None]])
- Return type:
None
- subscribe_voiced(cb)[source]
Async callback for every
VoicedSegmentafter VAD.- Parameters:
cb (Callable[[VoicedSegment], Awaitable[None]])
- Return type:
None
- Parameters:
source (SourceFactory)
config (PipelineConfig | None)
- class vocal_helper.PipelineConfig(vad=<factory>, eot=None, diar=<factory>, asr=<factory>, llm=None, qsize_pcm=200, qsize_seg=32)[source]
Bases:
objectConfiguration object for
Pipeline.Pass per-stage settings as dicts. The pipeline forwards them verbatim to each stage’s constructor.
- Parameters:
- class vocal_helper.RegionVerdict(t0, t1, primary, speechbrain, sb_prob, agree)[source]
Bases:
objectOne region cross-checked against the independent SpeechBrain LID.
- class vocal_helper.SemanticEOTStage(*, eot_model='qwen2.5:3b', stt_model='large-v3-turbo-q5_0', max_merge_s=4.0, min_incomplete_ms=800, host=None)[source]
Bases:
objectProducer/consumer EOT gating stage.
- Parameters:
eot_model (str) – Ollama model used as the EOT classifier. Default
qwen2.5:3b— small enough to run at ~ 50 ms / classification on Apple Silicon while broadly equivalent in capability to the LiveKit turn-detector’s 0.5B target.stt_model (str) – pywhispercpp model used for the partial transcript pass. Default
large-v3-turbo-q5_0— same as the downstreamWhisperStage. We could cache one instance shared by both stages in a future revision.max_merge_s (float) – Maximum total duration of a merged-on-incomplete chain. After this we force-emit regardless of the classifier’s verdict.
min_incomplete_ms (int) – Segments shorter than this are presumed back-channels (acks / breaths) and gated by the classifier ; longer segments are emitted directly without an LLM call (cheap heuristic).
host (str, optional) – Ollama host URL. Defaults to the
OLLAMA_HOSTenv var orhttp://localhost:11434.
- class vocal_helper.SileroVADStage(*, activity_threshold=0.5, min_silence_ms=300, min_speech_ms=300, edge_pad_ms=200, sample_rate=16000)[source]
Bases:
objectProducer/consumer Silero-VAD stage.
- Parameters:
activity_threshold (float) – Silero score above which a window is considered voiced. Default 0.5 (canonical).
min_silence_ms (int) – Trailing silence required to close a voiced run. Default 300 ms.
min_speech_ms (int) – Reject runs shorter than this. Default 300 ms.
edge_pad_ms (int) – Lead / trail padding kept around the voiced span so the ASR sees a natural envelope. Default 200 ms.
sample_rate (int) – Required to be 16 000 — Silero v5 is trained at 16 kHz.
Notes
The stage owns a single Silero model instance (loaded lazily on first frame). The model is CPU-only ONNX so this is cheap.
- class vocal_helper.SummarySnapshot[source]
Bases:
TypedDictOne running summary as produced by the optional LLM analyst.
recentis the verbatim transcript of the lastrecent_window_sseconds ;summaryis the LLM’s running digest of everything older than that.
- class vocal_helper.Utterance[source]
Bases:
TypedDictTranscribed diarized segment.
Emitted by
vocal_helper.asr.WhisperStage.wordsis a list of(t0, t1, text)triplets when the underlying backend supports word-level timestamps, else a single triplet spanning the whole utterance.
- class vocal_helper.VoicedSegment[source]
Bases:
TypedDictA contiguous run of voiced speech as detected by VAD.
Emitted by
vocal_helper.vad.SileroVADStagewhen speech ends (i.e. aftermin_silence_msof trailing silence) — the PCM buffer holds the full voiced span minus the trailing silence.
- class vocal_helper.WhisperStage(*, model='large-v3-turbo-q5_0', language='auto', threads=6, word_timestamps=True, initial_prompt='', min_segment_ms=250, batch=False, max_chunk_s=24.0, warmup=False)[source]
Bases:
objectProducer/consumer pywhispercpp transcription stage.
- Parameters:
model (str) – whisper.cpp model name. Default
"large-v3-turbo-q5_0"— the cheapest word-timestamp-capable variant.language (str) – ISO-639-1 code (
"en","fr", …) or"auto"for language identification.threads (int) – CPU threads handed to whisper.cpp.
word_timestamps (bool) – Emit per-word timestamps. Default
True— matches theUtterance.wordscontract.initial_prompt (str) – Bias / vocabulary prompt passed to whisper before every transcription. Empty by default. Strongly recommended : the 2026-06-30 sweep on AMI dev-slice (
studies/whisper_prompt_lang_lock.py) showed a domain- aligned prompt cuts WER by 15-25 percentage points and saves up to 39 % RTF. Good prompts name the conversational domain and a handful of expected proper nouns / technical terms, e.g."AMI meeting transcript: remote control design, " "marketing plan, user interface, requirements."min_segment_ms (int) – Drop segments shorter than this — whisper hallucinates on very short inputs.
batch (bool) – Offline full-throttle mode. When
Truethe stage stops transcribing one diarized segment at a time and instead packs consecutive segments intomax_chunk_swindows, running a single whisper call per window (whisper.cpp pads every call to a fixed 30 s mel, so fewer/fuller calls amortise that cost). Each returned phrase is re-mapped back to the diarized segment whose local time window contains it, so the output contract is unchanged — still oneUtteranceper input segment, with the diarizer’s speaker id preserved. Off by default ; the streamingPipelinenever sets it. The 2026-07-09 sweep (studies/asr_offline_batching.py) measured 6.5× lower RTF and better WER vs the per-segment path.max_chunk_s (float) – Max concatenated-audio length per batched whisper call. Only used when
batch=True. Default 24 s (fills the 30 s window well — the sweep’s Pareto winner). Lowering toward 12 s trades a little speed for safety if inputs switch language every few seconds (a chunk straddling an en→fr switch forces one language).warmup (bool) – When
True,run()runs one throwaway inference on silence before consuming the queue, moving whisper’s ~1 s first-inference stall off the streaming hot path. Off by default ; the streamingPipelineenables it.
Notes
The pywhispercpp model is loaded lazily on the first frame so cold pipelines don’t pay the ~ 1.5 GB download until they actually need it.
- async run(inbox, outbox)[source]
Consume
DiarizedSegmentfrominbox, pushUtterance.- Parameters:
inbox (Queue)
outbox (Queue)
- Return type:
None
- vocal_helper.cross_check_regions(pcm, regions, sample_rate=16000, *, supported=None)[source]
Corroborate each region’s language with the independent SpeechBrain LID.
An
agree=Falseverdict is a genuine disagreement between two models that share only the audio — a signal to inspect, not a code bug. Regions shorter than 1 s are too short to judge and pass through as agreeing.- Parameters:
- Return type:
- vocal_helper.detect_language(pcm, *, model='large-v3-turbo-q5_0', threads=6, offset_ms=0, supported=None)[source]
Discover the actual language of
pcm(single window).Runs whisper.cpp’s
auto_detect_languageand, by default, returns whisper’s true argmax over its full language head — the language the input actually is, with no default and no restriction.supportedis an optional, opt-in routing guard: pass the ISO-639-1 codes you can actually route and detection is re-ranked strictly within that set (the “mind the codes” guard — on a short window whisper may rank a close relative such as Galicianglabove the routable Spanishes). Leave itNoneto let the input speak for itself.- Parameters:
pcm (NDArray[np.float32]) – Mono waveform window at the model’s sample rate.
model (str, optional) – whisper.cpp model name (default
DEFAULT_MODEL).threads (int, optional) – Inference threads (default
DEFAULT_THREADS).offset_ms (int, optional) – Offset into
pcmhanded to whisper’s detector (default0).supported (tuple[str, ...] or None, optional) – Opt-in routable-language whitelist.
None(default) means discover freely — return whisper’s own top language. A tuple restricts the answer to those codes (e.g.DEFAULT_SUPPORTED_LANGS).
- Returns:
(iso_639_1_code, probability)— the discovered language and its posterior. Whensupportedis given, the top-ranked code within it.- Return type:
Examples
>>> code, prob = detect_language(pcm) >>> code 'fr'
- vocal_helper.detect_language_regions(pcm, sample_rate=16000, *, model='large-v3-turbo-q5_0', threads=6, window_s=10.0, hop_s=3.0, smooth_s=6.0, min_region_s=8.0, refine_s=4.0, snap_s=1.0, supported=None)[source]
Partition
pcminto mono-language regions (posterior-curve method).A language switch has no sharp acoustic cue, so instead of hunting for a boundary we (1) sample a posterior curve over overlapping windows, (2) Gaussian-smooth it, (3) take the per-frame argmax, (4) place change points where it flips, (5) absorb sub-
min_region_sregions, (6) locally refine each change point, and (7) snap it to the nearest silence. Each region’s language is discovered from the audio, never defaulted. The regions must be transcribed before committing to a language, so each is transcribed in its own.supportedis an opt-in routing whitelist (None= discover freely, seedetect_language()).Empty or too-short-to-identify audio has no language to discover, so this returns an empty list (no region invented) rather than guessing one.
- vocal_helper.detect_language_regions_fast(pcm, sample_rate=16000, *, conf_gate=0.5, model='large-v3-turbo-q5_0', threads=6, supported=None)[source]
Partition
pcminto language regions, fast path for monolingual audio.Most real recordings are one language end to end, yet
detect_language_regions()pays for a full overlapping-window posterior scan on every file. This wrapper first runs a single cheap globaldetect_language()over the whole signal: when that detection clearsconf_gateon a routable language the file is taken to be monolingual and returned as one region (a single whisper call instead of dozens). Only when the global detection is uncertain — a genuinely code-switched or noisy file — does it fall back to the accurate posterior-curve segmentation ofdetect_language_regions().On a corpus of support-call recordings this cut per-file language identification from ~73 s to ~1 s with identical region output on the monolingual majority, while the low-confidence fallback still recovers the switches. It is a drop-in replacement for
detect_language_regions()wherever code-switching is the exception rather than the rule.- Parameters:
pcm (NDArray[np.float32]) – Mono waveform at
sample_rate.sample_rate (int, optional) – Sample rate of
pcmin Hz (defaultDEFAULT_SR).conf_gate (float, optional) – Minimum whole-file detection probability required to accept the single-region fast path (default
DEFAULT_FAST_CONF_GATE). Below it, the robustdetect_language_regions()scan runs.model (str, optional) – whisper.cpp model name (default
DEFAULT_MODEL).threads (int, optional) – Inference threads (default
DEFAULT_THREADS).supported (tuple[str, ...] or None, optional) – Opt-in routable-language whitelist.
None(default) discovers the language freely (seedetect_language()).
- Returns:
A single region spanning the file on the fast path, or the full multi-region partition on the fallback path. Always ≥ 1 region.
- Return type:
Examples
>>> regions = detect_language_regions_fast(pcm, 16_000) >>> regions[0].lang 'en'
- vocal_helper.detect_language_speechbrain(pcm, *, supported=None)[source]
Identify the language of
pcmwith SpeechBrain VoxLingua107.An ECAPA-TDNN spoken-language classifier that shares nothing with whisper but the audio — the independent second opinion. VoxLingua107 labels are
"<iso>: <English name>"(e.g."fr: French"); the ISO-639-1 prefix is returned. By default the classifier’s true label is returned so the second opinion stays genuinely independent — it is never silently remapped to a preferred language. Passsupportedonly if you want an out-of-whitelist label dropped to the closest routable one.- Parameters:
- Returns:
(iso_639_1_code, probability).- Return type:
- vocal_helper.eot_score(pairs, *, latency_bands_ms=(300, 600, 1200), tolerance_ms=50)
Full LiveKit-style report on a set of paired detections.
- Parameters:
pairs (Iterable[EOTPair]) – Iterable of aligned (true_end, detector_commit) pairs.
latency_bands_ms (Sequence[int]) – Latency budgets to score the hang rate against. Default is LiveKit’s canonical (300, 600, 1200) ms bands.
tolerance_ms (int) – Acceptable early-commit slack before we count a false-cutoff. Default 50 ms sits below human turn-taking noise floor (Heldner & Edlund 2010 median gap 200 ms).
- Returns:
{"n": int, "median_latency_ms": float, "false_cutoff_rate": float, "hang_rate_at_ms": {band: rate, ...}}.- Return type:
- vocal_helper.false_cutoff_rate(pairs, *, tolerance_s)[source]
Share of pairs where the detector fired
tolerance_searly.A “false cutoff” is a decision to end the turn while the speaker is still speaking — the perceptual failure LiveKit’s metric targets. Setting
tolerance_s = 0treats any early commit as a false cutoff ; realistic tolerances are 50-150 ms (below human turn-taking noise floor).
- vocal_helper.hang_rate(pairs, *, latency_budget_s)[source]
Share of pairs where the detector waited >
latency_budget_s.The other-side failure : the speaker finished but the agent kept waiting, producing perceived agent hesitation.
latency_budget_sis the acceptable upper bound (LiveKit uses 300 / 600 / 1200 ms bands).
- vocal_helper.language_posterior_curve(pcm, sample_rate=16000, *, model='large-v3-turbo-q5_0', threads=6, window_s=10.0, hop_s=3.0, supported=None)[source]
Sample a per-window language posterior over time.
Returns
(centers[T], langs[L], P[T, L])wherecentersare window centre times (s),langsthe candidate codes, and each row ofPa posterior overlangsfrom whisper’sauto_detect_languageon awindow_swindow centred there. Overlapping windows (hop_s < window_s) give a finely sampled curve.supportedis opt-in:None(default) discovers freely — the posterior axis is whisper’s full language head (adopted from the first usable window) so any language can surface. A tuple restricts (and renormalises over) that routable set instead.
- async vocal_helper.run_parallel_async(upstream_input, branches)[source]
Run every async branch on the same upstream input concurrently.
Same shape as
run_parallel_sync()but for coroutines. Usesasyncio.gather()so a failure in one branch cancels the others ; wrap in try/except at the branch level if per-branch fault isolation matters.
- vocal_helper.run_parallel_sync(upstream_input, branches, *, max_workers=None)[source]
Run every branch on the same upstream input in parallel.
- Parameters:
upstream_input (T) – The shared value every branch consumes — typically an audio buffer, a segment list, or a DiarSegment stream. Branches must not mutate it (defensive copy inside each branch is the caller’s responsibility).
branches (list[tuple[str, Callable[[T], R]]]) – Ordered list of
(name, callable)pairs. The order in the returned dict matches the input order (Python 3.7+ dict insertion order is guaranteed).max_workers (int | None) – Concurrency cap. Defaults to
len(branches)which is usually what you want for a compare-N-backends sweep.
- Returns:
{name: (result, wall_seconds)}in the input order.- Return type:
- vocal_helper.select_diarization(*, live, duration_s=None, max_speakers=None, torch_free=False, pyannote_available=True, nemo_available=True)[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.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.
- 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'
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.
- vocal_helper.transcribe_pcm(pcm, sr, *, model='large-v3-turbo-q5_0', language='auto', threads=6, initial_prompt='')[source]
Synchronous one-shot transcription. Loads whisper.cpp on call.
initial_promptis the same domain-bias lever asWhisperStage— empty by default, but strongly recommended (cuts WER 15-25 pp on AMI, saves up to 39 % RTF). Seetranscribe_pcm_with_language()to also get the discovered language.
- vocal_helper.transcribe_pcm_with_language(pcm, sr, *, model='large-v3-turbo-q5_0', language='auto', threads=6, initial_prompt='')[source]
Synchronous one-shot transcription returning text and the language.
Same as
transcribe_pcm(), but also surfaces the language whisper actually used. Withlanguage="auto"(the default) that is the language discovered from the audio — so callers can report the real language of the input instead of echoing back"auto".- Parameters:
pcm (NDArray[np.float32]) – Mono waveform.
sr (int) – Sample rate of
pcmin Hz.model (str, optional) – whisper.cpp model name (default
DEFAULT_MODEL).language (str, optional) – ISO-639-1 code, or
"auto"to discover it (defaultDEFAULT_LANGUAGE).threads (int, optional) – Inference threads (default
DEFAULT_THREADS).initial_prompt (str, optional) – Domain-bias prompt (default
DEFAULT_INITIAL_PROMPT).
- Returns:
(text, detected_language). The language isNoneonly when whisper reported none (e.g. an empty / sub-threshold segment).- Return type:
Examples
>>> text, lang = transcribe_pcm_with_language(pcm, 16_000) >>> lang 'fr'