Skip to content

fix: catch clips whose audio holds far more speech than the label - #27

Merged
Rebreda merged 3 commits into
mainfrom
fix/two-sided-rate-check
Aug 26, 2026
Merged

fix: catch clips whose audio holds far more speech than the label#27
Rebreda merged 3 commits into
mainfrom
fix/two-sided-rate-check

Conversation

@Rebreda

@Rebreda Rebreda commented Aug 24, 2026

Copy link
Copy Markdown
Owner

The rate check added in #25 only looked one way. It caught a transcript paired with too little audio and was blind to the reverse, which is exactly what a VAD that never fires produces.

What a real session looks like

56 of 79 clips are exactly max_segment_s long, because vad.threshold was 0.02 while the room's noise floor ran 0.029 to 0.047. The gate never closed, so every segment was cut by the timeout rather than by silence.

Six of those clips are 20 seconds of audio labelled with almost nothing:

 0.05 w/s   1w   20.06s  'No?'
 0.10 w/s   2w   20.06s  'Thank you.'
 0.15 w/s   3w   20.06s  'I love you.'

The old check caught zero of them.

Training on these is as damaging as the too-fast case and less obvious. A model learns to emit two words for twenty seconds of speech, which is an omission trainer. Both halves look individually fine, so nothing downstream caught it.

Change

speech_rate_mismatch replaces implausible_speech_rate and reports which direction failed, so the skip breakdown says what is wrong rather than only that something is.

The floor is 0.5 words per second, against a measured median of 2.2 and a 25th percentile of 1.2 over that session, and it only applies above 2 seconds, since a one second "Yes" is legitimately one word per second.

Against the real manifest

techvocab: 79 rows -> 67 usable
  dropped 6: audio and transcript do not match (audio holds far more speech than the transcript)
  dropped 6: transcript under --min-chars (10) after tag stripping

Docs

Two troubleshooting entries. One for the symptom itself, since "every clip is exactly max_segment_s" is the visible sign that VAD is not working, with how to read your noise floor off the --debug RMS output and pick a threshold above it. One for the drop reason, explaining both directions.

Worth stating plainly there: max_segment_s is a backstop against Whisper's 30 second window, not a segmenter. If it fires every time, VAD is not working.

371 tests on 3.13, 349 and 12 skipped on 3.11.

Not fixed here

The VAD threshold itself is configuration, not code, and a recording session is live, so I have not touched config.toml.

g added 2 commits August 24, 2026 14:57
The rate check only looked one way. It caught a transcript paired with too
little audio, and was blind to the reverse, which a VAD that never fires
produces constantly.

From a real session: 56 of 79 clips are exactly max_segment_s long, because
vad.threshold was 0.02 while the room's noise floor ran 0.029 to 0.047, so the
gate never closed and every segment was cut by the timeout rather than by
silence. Six of those clips are 20 seconds of audio labelled "Thank you.",
"No?" or "I love you.", between 0.05 and 0.15 words per second. The old check
caught none of them.

Training on those is as damaging as the too-fast case and less obvious: a
model learns to emit two words for twenty seconds of speech, which is an
omission trainer. Both halves look individually fine, which is why nothing
downstream caught it.

speech_rate_mismatch replaces implausible_speech_rate and reports which
direction failed, so the skip breakdown says what is wrong rather than just
that something is. The floor is 0.5 words per second, against a measured
median of 2.2 and a 25th percentile of 1.2, and only applies above 2 seconds,
since a one second "Yes" is legitimately one word per second.

Over the real manifest: 79 rows to 67 usable, 6 dropped for the new reason.

Documented both the VAD symptom and the drop reason in troubleshooting, with
how to read your noise floor off the debug output and pick a threshold above
it.
Every clip in a real session ended on the max_segment_s timeout rather than on
silence, 56 of 79 at exactly 20.063s, cutting sentences mid-phrase and
producing labels like "Thank you." over twenty seconds of audio.

The cause is not the threshold. The capture device returns a constant
+0.03135 offset, and nothing removed it, so the RMS was the offset. Measured
over the 84 clips on disk, the level moved by a factor of 1.18 between silence
and speech. No threshold can separate those, and lowering the threshold, which
is what the config had done, makes it worse rather than better.

Removing the offset first: the same clips separate by 5.52x, silence at 0.0025
RMS and speech at 0.0178, with speech correlated 0.98 against the original.

Implemented as a one-pole DC blocker through scipy.signal.lfilter with its
state carried across chunks, so the output matches filtering the whole stream
and there is no step at a chunk boundary. Subtracting each chunk's own mean
would introduce one. The state is primed from the first sample, so a session
does not open with a time constant of un-blocked offset.

I first wrote the recursion by hand as a cumulative sum weighted by powers of
the pole. It is accurate over a chunk and degrades over long inputs, because
the weights underflow. lfilter is exact and already available.

The debug line now reports the offset when it removes one, because a
threshold chosen against the old reading will be far too high once the
reading is correct. Documented, with the measurement to make and the value it
implies here.
@Rebreda

Rebreda commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Found the actual cause of the VAD failure, and it was not the threshold.

The capture device returns a constant +0.03135 DC offset, and nothing removed it. The RMS in the debug log was the offset:

DC offset      : +0.03135
RMS as-is      : 0.03238
RMS DC-removed : 0.00810

So the measured level never changed whether or not anyone was speaking. Across the 84 clips on disk, silence and speech differ by a factor of 1.18. No threshold can separate that, and lowering the threshold, which is what the config had done to catch soft word endings, makes it strictly worse.

With the offset removed, the same clips separate by 5.52x: silence at 0.0025 RMS, speech at 0.0178, speech correlated 0.98 against the original.

Implementation

One-pole DC blocker through scipy.signal.lfilter, state carried across chunks so the output matches filtering the whole stream, primed from the first sample so a session does not open with a time constant of un-blocked offset.

I first wrote the recursion by hand as a cumulative sum weighted by powers of the pole. It is accurate over one chunk and degrades over long inputs because the weights underflow, which a test that only asserted finiteness would not have caught. lfilter is exact and scipy is already a core dependency.

The debug line now reports the removal, because a threshold chosen against the old reading is far too high once the reading is correct:

[DEBUG] Mic: 9720 chunks sent, RMS=0.0081 (removed 0.0313 offset), ...

Threshold

Measured after the fix, a working threshold sits between 0.0031 and 0.0090. Roughly:

[vad]
threshold = 0.006

The current config has 0.02, which was set against the offset-inflated reading and is now three times too high.

384 tests on 3.13, 362 and 12 skipped on 3.11 and on a dev-only install.

Whisper-Large-v3-Turbo produced both when re-transcribing a real session.
Only the Cloud variant was mapped, so the Claude one passed through.
@Rebreda
Rebreda merged commit 2b36404 into main Aug 26, 2026
6 checks passed
@Rebreda
Rebreda deleted the fix/two-sided-rate-check branch August 26, 2026 04:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant