fix(vad): stop fragmenting live speech into sub-4s ASR requests, and filter hallucinations on every engine - #679
Conversation
|
Pushed What was wrongThe hallucination filter I added in Whisper genuinely does hallucinate those on near-silent input — that is what made the list tempting, and the meeting I diagnosed had 14 standalone So the list traded a cosmetic problem (a stray "thanks" in the transcript) for a semantic one (a participant's answer silently missing). And #675 is that exact failure already reported — "OK", "Yes", "Thanks" dropped by the byte-length confidence heuristic. My filter would have made a filed bug worse from a second, independent direction. Bad trade, now reverted. What replaces itShort-clip hallucination is handled where the evidence actually lives — the provider layer, gating on Whisper's own Only unambiguous multi-word boilerplate is still filtered by text — Degenerate repetition is still caught, now language-agnostically: the repetition ratio is measured on the original text rather than after collapsing. Measuring after collapsing hid it — a collapsed run leaves one word, and one word has a repetition ratio of zero. TestsThe two inverted tests are replaced by Full suite unchanged at 202 passed, 1 failed — Note on #675This PR no longer makes #675 worse, but it does not fix it either — the byte-length confidence heuristic in |
|
Thanks @Hus-Mek for the thoughtful work on this PR. The investigation into live VAD fragmentation, the 2-second redemption alignment, and the final-segment timestamp path is valuable; this is the strongest current OSS contribution in this area. Our plan is to address the transcript-loss cluster in focused, independently reviewable pieces:
Could you please rebase #679 onto the latest For this rebased PR, please keep only the VAD-focused work:
Please move the shared cross-provider Please ensure the rebased branch integrates cleanly with #681 and does not reintroduce the old Thank you again. Splitting this work will make it substantially easier to review, test, and land the VAD improvements safely. |
|
Thanks @safvanatzack — the four-way split makes sense, and scoping this PR to the VAD fix is the right call. I'll restructure it exactly as you've laid out. Plan for the rebased #679:
The shared cross-provider |
Two defects in the VAD path that together produced short, silence-heavy audio
clips and one corrupt segment per recording.
1. Live redemption time was 400ms
`pipeline.rs` passed 400ms while `import.rs` and `retranscription.rs` both use
2000ms, so live recording and offline re-transcription of the same audio
segmented completely differently. The platform ternary was also dead
(`if macos { 400 } else { 400 }`), and `vad.rs` carried a comment claiming the
pipeline already passed 2000ms.
Redemption time decides how long a silence must be before a speech segment is
closed, and every segment becomes its own ASR request. At 400ms a 26-minute
meeting was split into 322 requests with a median length of 3.5s. Whisper is a
fixed 30-second-window model: below that it zero-pads the window and falls back
on its language-model prior, which was trained on web subtitles, so short clips
return memorised boilerplate ("subscribe to the channel", "thank you") rather
than speech. On a real recording 47% of segment boundaries sat in the
0.42-0.75s range, i.e. mid-sentence breaths a longer redemption simply bridges.
Now uses a named constant kept equal to the offline paths. Costs up to ~1.6s of
additional live-transcript latency at the end of each utterance.
2. `speech_start_sample` double-counted the session position
It was computed as `processed_samples + timestamp_ms`, but silero's
`timestamp_ms` is already session-absolute (`processed_duration()` minus
`pre_speech_pad`), so the two absolute values were summed and the start position
came out at roughly 2x the truth. The error grows with how late in the session
the utterance begins.
The only reader is the force-end branch in `flush()`, so it surfaced once per
recording, on the final segment: a phantom row timestamped past the end of the
audio, which sorted to the end of the stored transcript. Observed on a 1539.7s
recording as a segment starting at 3083.1s.
Adds two regression tests, both verified to fail before the change and pass
after. They use a leading-silence-then-speech fixture because the force-end path
only runs when recording stops mid-utterance, and the error is only large enough
to violate the invariant when speech starts late.
Also promotes VAD_SAMPLE_RATE to a module constant, since every sample count and
timestamp in the module is expressed in it.
d5abf16 to
4d626bb
Compare
|
Done — #679 is now rebased onto
Both branches integrate cleanly with the current |
|
@Hus-Mek I manually tested the rebased branch and found a live-transcription issue with the current 2000 ms redemption setting. With continuous system audio from a podcast, VAD kept one speech segment open and no live transcript segment was emitted for an extended period. The buffer grew past the 62.5 s warning threshold and began producing repeated accumulation warnings. For comparison only, I locally changed the live value to 800 ms and repeated the same test. It emitted transcript segments, and the resulting transcript had no material missing content compared with the source video. However, the segments were still large and the accumulated-buffer warning still occurred. The timestamp correction and its regression coverage still look correct. I am sharing this manual-test finding now; we will complete internal testing and follow up separately on the appropriate live VAD policy/value. |
|
Thank you, @Hus-Mek, for following through on the rebase and for keeping this PR carefully focused. The VAD investigation, timestamp correction, and regression coverage are all appreciated. One update from our live validation: Could you please make the following final adjustments to this PR?
The Thanks again for the thoughtful work and for working with us to keep these changes safe and independently reviewable. |
Live validation showed 2000ms keeps a VAD segment open indefinitely under continuous system audio, withholding live transcript emission and crossing the accumulated-speech-buffer warning threshold. Set the live path to 500ms (established Meetily Pro live policy) and keep import/retranscription at 2000ms, since batch has no latency requirement. Bounded live segments during continuous speech (forced boundaries, max update interval) are tracked separately in Zackriya-Solutions#756. Adds test_live_vad_redemption_matches_pro_policy locking the live value; the session-absolute speech_start_sample correction and its regression coverage are unchanged.
|
Done — pushed
Verification on the current branch: Agreed on the #756 framing — 500 ms fixes the fragmentation scope here, but a qualifying silence is still required to close a segment, so continuous speech needs the forced-boundary/max-update-interval work tracked there. Happy to pick that up if useful. |
|
Thank you, @Hus-Mek — I rechecked One timestamp issue remains: Adding the missing assertion reproduces the failure: Please preserve the real post-resampling endpoint while retaining padded VAD inference, then assert: seg.end_timestamp_ms <= audio_duration_msAlso, please adjust the two comments implying 500 ms bounds continuous-audio delivery; that remains #756. The previous live-redemption blocker is resolved. These are the remaining focused corrections. |
What this fixes
Live VAD segmentation quality. Diagnosed from a real 26-minute Arabic meeting; both causes are
provider-agnostic, so local Whisper, Parakeet, and cloud providers are equally affected.
1. Live VAD redemption time was 400ms, fragmenting speech at mid-sentence breaths
pipeline.rspassed400, whileimport.rs:58andretranscription.rs:52both defineVAD_REDEMPTION_TIME_MS = 2000. Supporting evidence that 400 was not deliberate:if cfg!(target_os = "macos") { 400 } else { 400 }, under a comment claiming the two platforms differvad.rscarried a comment asserting "Use full redemption_time from pipeline (2000ms)" - false for the live pathvad.rsalready contains a test namedtest_vad_400ms_vs_2000ms_segmentationwhose docstring states that 400ms causes excessive fragmentationRedemption time decides how long a silence must last before a speech segment closes, and every segment becomes its own ASR request. Measured on the real recording at 400ms:
That last row is the point: nearly half the segment boundaries were mid-sentence breaths that a longer redemption bridges.
Whisper is a fixed 30-second-window model. Below that it zero-pads the window and leans on its language-model prior, which was trained on web subtitles, so short clips come back as memorised boilerplate instead of speech. The VAD also pads every segment by 300 ms + 400 ms, a fixed cost that dominates short segments:
Live-path policy (updated after review validation): an earlier revision of this PR aligned the live path to the batch value (2000 ms). Maintainer live testing with continuous system audio showed that is not viable for the live path: VAD kept one speech segment open for an extended period, no live transcript was emitted, and the accumulated-speech buffer crossed its warning threshold. The live path therefore now uses
VAD_REDEMPTION_TIME_MS = 500, matching the established Meetily Pro live policy, whileimport.rsandretranscription.rskeep 2000 ms - batch has no latency requirement, so live and batch deliberately diverge. 500 ms addresses the fragmentation scope of this PR; it does not by itself guarantee bounded transcript delivery during continuous speech where no qualifying silence occurs - bounded live segments, forced-boundary correctness, and a maximum live-transcript-update interval are tracked separately in #756.2.
speech_start_sampledouble-counted the session positionThe buggy live path was:
Both operands are already session-absolute.
processed_samplesincrements forever, and silero computesSpeechStart.timestamp_msasprocessed_duration().saturating_sub(pre_speech_pad)- verified insilero-rs/src/lib.rs:302-305, emitted at321-324. Summing them puts the start position at roughly 2x the truth, and the error grows with how late in the session the utterance begins.The only reader is the force-end branch in
flush(), so it escaped once per recording, on the final segment. On a 1539.7 s recording that segment was stored withaudio_start_time = 3083.1 s(ratio 2.0007) and sorted to the end of the transcript. Every other segment tracked wall-clock at ratio ~1.01, which is why it looked like a one-off rather than drift. Relevant to #399.devtesthas since applied an equivalent literal fix to theSpeechStartpath; this branch keeps the same correction expressed against theVAD_SAMPLE_RATEmodule constant, and retains the regression coverage below.What changed
pipeline.rs- namedVAD_REDEMPTION_TIME_MS = 500constant (live policy, per Meetily Pro), documented against the batch paths' 2000 ms; dead platform ternary removed;test_live_vad_redemption_matches_pro_policylocks the valuevad.rs- session-absolute timestamp fix;VAD_SAMPLE_RATEpromoted to a module constant; the false comment corrected; two regression tests addedimport.rs,retranscription.rs- unchanged at 2000 ms; stale doc comments (which described the live path as 400 ms) updatedVerification
Focused VAD tests, on the current branch:
The two regression tests requested in review:
test_flush_segment_timestamps_stay_within_audio_duration- a final unfinished utterance satisfiesstart <= end <= input duration.test_speech_start_sample_never_exceeds_processed_samples- the VAD start position never exceeds processed audio. Asserted directly rather than via silero's segmentation of synthetic audio, because the force-end path only runs when recording stops mid-utterance and the error is only large enough to violate the invariant when speech begins late in the session. The fixture is therefore leading-silence-then-speech; with the bug present it reportsstart=41.67s vs processed=22.98s.Both were verified in both directions - each bug reintroduced to confirm the test fails, then the fix restored to confirm it passes.
Full suite (
cargo test --lib): 212 passed; the only failure isaudio::device_detection::tests::test_calculate_buffer_timeout_bluetooth, which fails identically on unmodifieddevtest(confirmed by stashing). Unrelated to this PR.Deliberately not included
vad.rs::resample_to_16k. Its filter size issample_rate / (0.4 * sample_rate)- the rate algebraically cancels to a fixed 5-tap moving average with no real anti-aliasing at 3:1, which reads like a serious bug. I ported that exact algorithm and re-ran the full meeting against ffmpeg's polyphase resampler with chunking and language held constant: 2229 words vs 2207, medianavg_logprob-0.293 vs -0.334 (the naive version scored marginally better), and both recovered the proper nouns. Whisper's mel front-end weights energy below ~4 kHz heavily, so aliasing folded in above 8 kHz barely reaches the features. Worth fixing for hygiene, but it is not a quality fix and I did not want to bundle it here.text_cleanuprewrite (hallucination filtering across all engines, repetition ratio measured on original text, multi-word boilerplate denylist). Removed from this branch; now under review separately as fix(transcription): shared cross-provider hallucination filtering (follow-up to #679) #747.