ADR-131: Speech-normalized coverage for the transcription quality gate (revises ADR-123)¶
- Status: Accepted (approach C — reuse the diarizer's speech regions)
- Date: 2026-07-26
- Authors: Marko Dragoljevic
- Related ADRs: ADR-123 (the coverage-gate
failover this revises), ADR-122 (
hold) - Related: #1178/#1179 (ASR bake-off), #1258 (coverage gate)
Amendment (2026-08-03): the speech gate is now the active gate; raw coverage becomes a metric¶
When ADR-131 landed, the registry still emitted the raw ADR-123 gate
(transcription_coverage_min=0.85) for every DGX preset, and set
transcription_speech_coverage_min nowhere — so the diarizing profiles kept
failing over on the total-audio denominator this ADR was written to replace. A
music/ad-heavy episode transcribed fully still read ~71% raw coverage and
spuriously re-routed to MOSS. Fixed:
- Registry now emits
transcription_speech_coverage_min=0.85(raw=0.0) forprod_dgx_full,cloud_with_dgx_primary. The speech-normalized gate is the single active quality gate whenever diarization runs; the raw gate stays a no-op fallback only for non-diarizing configs (where no speech denominator exists). - The failover now honors
transcription_coverage_failover_provider(e.g.moss), matching the ADR-123 factory routing — it re-transcribes on the MOSS service, not by sending the MOSS model id to the speaches whisper endpoint. - Raw coverage lives on as
speech_audio_ratio— see the ADR-123 amendment. It is a metric, not a gate.
Validated live: a real 630s episode with ~28% non-speech read 93.1% speech coverage → gate passed, no failover (raw would have read 71.3% → failover).
Context & Problem Statement¶
ADR-123 gates a turbo→large-v3 failover on coverage:
coverage = Σ(segment_end − segment_start) / audio_duration_seconds
The denominator is the full audio duration, so every second of non-speech — intro music, ad breaks, stingers, silence — counts as "dropped speech." The metric therefore cannot tell a genuine ASR failure apart from an episode that simply contains a lot of non-speech. It fires the failover on the wrong signal.
Evidence (measured on the real v2.3 turbo corpus, 90 episodes)¶
- The gate never actually fired. All 90 episodes are turbo (no
large-v3/Systranmodel_usedanywhere; the runs carried nofailover_model). So the corpus is raw turbo output. - Recomputing
Σseg / audio_dur: 11 / 90 (12%) fall below the 0.85 gate — but on transcripts no model can improve, because the shortfall is non-speech:
| episode | coverage | length |
|---|---|---|
| Move Over Humans (WSJ) | 67.4% | 20 min |
| Tim Cook Built the Apple Empire | 71.3% | 21 min |
| R.I.P. Spirit Airlines | 72.0% | 19 min |
| … 8 more, 72–84% | mostly 19–27 min |
These are short NPR/WSJ/The-Daily-style episodes with intro music + ad breaks — not the long-episode cliff. On "Move Over Humans" large-v3 also reached only ~75.9%, so ~24% is music both models correctly skip and turbo's real speech drop was ~9%. Under the current gate all 11 would failover to large-v3 (4× slower) and recover nothing.
The proposed metric, validated on the same corpus¶
Recomputing speech_coverage = Σ(transcript) / Σ(pyannote speech) (denominator from the persisted
diarization caches) on the WSJ feed — the false-positive cluster:
| episode | raw coverage | speech coverage |
|---|---|---|
| Move Over Humans | 76.1% | 96.9% |
| Tim Cook Built the Apple Empire | 79.9% | 103.6% |
| The College Student Who Defeated… | 79.7% | 99.4% |
| … all 10 WSJ episodes | 76–89% | 97–105% |
Every episode raw-coverage would have failed over shows ~100% speech coverage — turbo transcribed essentially all the speech; the shortfall was non-speech both models skip. (Values slightly over 100% are transcript/diarization boundary overlap; clamp at 1.0.) This is the direct empirical confirmation that the speech-normalized denominator removes the false positives.
But the gate catches a real, different failure¶
The genuine turbo problem is the long-episode cliff: the 100-min Ezra Klein bake-off episode scored 29.9% WER vs large-v3's ~6% — a true long-form speech drop. That episode should failover. A correct metric must still catch it while passing the 11 music-heavy ones. The two are distinguishable only once non-speech is out of the denominator: the cliff has low speech coverage; the music-heavy episodes have high speech coverage.
Decision¶
Gate on speech coverage, not raw audio coverage:
speech_coverage = Σ(segment_end − segment_start) / speech_seconds
where speech_seconds is the duration of actual speech in the audio (non-speech removed).
Failover when speech_coverage < transcription_speech_coverage_min. The failover mode (ADR-123's
CoverageGatedTranscriptionProvider wrapper, the hold orthogonality, the per-episode provenance
breadcrumb) is unchanged — only the metric's denominator changes, plus the breadcrumb now records
both raw and speech coverage.
The implementation fork (the decision that needs your call)¶
The gate runs client-side (in fallback.py), but the whisper model is a remote DGX server —
so speech_seconds is not free. ffmpeg silencedetect (already a dependency) is not sufficient:
it is energy-based and detects silence, not music, and music is exactly the false-positive case.
The viable options:
| # | approach | new dep? | catches music? | where VAD runs | cost |
|---|---|---|---|---|---|
| A | silero-vad client-side | yes (silero-vad, ~2 MB torch model) | yes | laptop CPU | ~1–3 s/ep; keeps the gate exactly where ADR-123 put it |
| B | DGX whisper server enables vad_filter, returns speech_seconds |
no (client) | yes | DGX (where faster-whisper already is) | server-side change to homelab infra |
| C | reuse pyannote diarization as the VAD (Σ speaker-speech / audio) | no | yes (music has no speaker) | DGX (already running) | move the gate downstream of diarization; re-transcribe-only on failover (diarization stays valid) |
- A is the smallest change to this repo but adds a client runtime dependency (needs approval per the no-new-deps rule).
- B is architecturally cleanest (VAD where the model is, zero client cost) but touches the DGX whisper server, i.e. homelab infra outside this repo.
- C adds no dependency and reuses a signal we already compute, but relocates the gate from the transcription-provider wrapper to the orchestration level (after diarization) — a larger refactor of ADR-123's clean wrapper design. Diarization is audio-based, so it is valid for both the turbo and the (possible) large-v3 transcript; only transcription is re-run on failover.
Chosen: C. Keeps the pipeline fully local (the v2→v3 goal), adds nothing, and reuses the speech regions the diarizer already produces.
C is provider-agnostic, not pyannote-specific. Every diarization provider (Deepgram, MOSS,
Gemini, pyannote, tailnet-DGX) returns the same DiarizationResult(segments=[start,end,speaker]), so
speech_seconds = Σ(merged segments) reads off the common interface regardless of which diarizer
runs. Two consequences to design for:
- Diarization must be ON. When
cfg.diarizeis off, or the diarizer returns no speaker turns, there is no speech denominator — the gate then falls back to the raw-coverage gate (ADR-123's original metric) so behaviour never regresses; the speech-normalized path is a strict upgrade that engages only when diarization is present. - The denominator inherits the diarizer's speech detection. A word-level diarizer (Deepgram) gives slightly tighter spans than a turn-level one (pyannote); a diarizer that labelled music as a speaker would inflate the denominator. All current providers do internal VAD and the threshold carries margin (clean ≈ 100%, real drops far lower), so the gate is robust — but its denominator quality does track the configured diarizer. Full diarizer-independence would require A (silero-vad).
Threshold recalibration (required, not optional)¶
The current 0.85 was set on the raw metric and is meaningless on speech coverage (normal
episodes will jump to ~0.98). Before enabling, recompute speech_coverage on the bake-off 18
(which have both turbo and large-v3, plus the ep6 cliff) and pick a transcription_speech_coverage_min
that (a) failovers the Ezra Klein cliff and (b) passes "Move Over Humans." Record the chosen value
and the two anchor points.
Alternatives considered¶
- Keep raw coverage, lower the threshold. Rejected: no single raw threshold separates the ep6 cliff (69% raw, real drop) from "Move Over Humans" (67% raw, non-speech) — they overlap. The confound is structural, not a threshold-tuning problem.
- Always transcribe on both and compare (turbo vs large-v3). Rejected: defeats the 4× speed purpose of turbo — you would run large-v3 on every episode.
- ffmpeg
silencedetect. Rejected as the sole signal: misses music, the main false-positive.
Non-Goals¶
- Not removing the gate — the long-episode cliff is real and must still failover.
- Not changing the failover mode — ADR-123's wrapper,
holdorthogonality, and provenance breadcrumb stay; only the denominator and the recorded fields change. - Not a new ASR model or a turbo replacement.
Consequences¶
- ~12% fewer false failovers on the corpus profile measured — music/ad-heavy episodes stay on turbo (correctly) instead of paying 4× for large-v3 that recovers nothing.
- The gate measures what it claims — real speech drop, not audio composition.
- No re-transcription of the v2.3 corpus needed — the 11 flagged episodes are fine (non-speech); this only changes what the next reprocess does.
- Breadcrumb gains
speech_coverage+speech_secondsalongside the existing raw coverage, so provenance shows both. - One of: a new client dep (A), a DGX server change (B), or a gate relocation (C) — the open decision.
Validation plan¶
- Unit: speech-coverage on a synthetic segments+VAD fixture (speech-only, music-padded, real-drop).
- Recompute speech coverage on the bake-off 18 → set the threshold; assert ep6 fails and Move Over Humans passes.
- Smoke: reprocess "Move Over Humans" (must NOT failover) and the Ezra Klein episode (must failover).