Skip to content

Gate the model on speech instead of transcribing everything - #2

Merged
z33b0t merged 1 commit into
mainfrom
vad-speech-gate
Jul 27, 2026
Merged

Gate the model on speech instead of transcribing everything#2
z33b0t merged 1 commit into
mainfrom
vad-speech-gate

Conversation

@z33b0t

Copy link
Copy Markdown
Contributor

Fixes background chatter being transcribed while the HUD showed no voice activity.

The bug

The VAD's verdict decided when to cut a segment and what the HUD displayed, but never whether to decode. Every frame was buffered and handed to Parakeet, which has no VAD of its own — so a room's background conversation came back as words while the HUD sat dimmed.

Three paths fed the model unconditionally, and gating only the first would have been worse than useless:

patheffect of leaving it ungated
live partialnoise appears in the live view
segment commitnoise lands in the committed transcript
one-shot final re-decode of the whole dictationnoise comes back when you press stop

So the gate goes where the audio is buffered, and all three inherit it.

The fix

SileroVAD.process now returns the audio worth keeping; the session buffers that instead of the raw frame. Padding keeps the gate from eating real speech — Silero crosses its threshold a beat after a word starts and falls below it while the last consonant is still sounding:

  • VAD_PREROLL_MS (250) of held audio flushed ahead of each onset, as a ring of the most recent rejected audio so an hour of silence still costs 250 ms
  • VAD_HANGOVER_MS (200) kept past each offset
  • VAD_THRESHOLD (0.5) is now settable — it was a constructor default no call site passed, so tuning the knob most likely to need tuning per-room meant editing vad.py and rebuilding the image

SpeechGate holds the padding rules with no model and no torch import, so CI can test it without a GPU. Also adds SileroVAD.flush(): whole-window classification always holds back <32 ms, which is exactly the end of your last word when you stop mid-sentence.

Verification

New gate suite: 5 unit checks on the padding rules plus an end-to-end dictation of nothing but rejected audio asserting neither partials nor the final carry text. FakeVAD now drives the real SpeechGate, so what CI covers is production code.

Measured against the real Silero + Parakeet in the container, driving it with espeak speech over the WebSocket. Sweeping amplitude, checking whether text ever came out of audio the VAD never flagged:

amplitudepeakvad firedtext
1.00025685yesfull sentence
0.020513yesfull sentence
0.004102yesfirst word lost
0.00251yes(empty)
0.00125no(empty)

No row pairs "didn't fire" with text. At normal dictation volume the VAD fires 68 ms after speech starts and the pre-roll opens the gate 156 ms before it; at −48 dBFS the VAD lags 436 ms, which is what VAD_PREROLL_MS is for.

Trade-off worth knowing: the gate costs accuracy on very quiet speech. At those bottom two rows the ungated /v1 path transcribes both in full. Those levels are far below anything you'd dictate at, but it isn't free.

Running live on the dictation server for 32 hours at the 0.5 default, confirmed by the reporter as fixing the original symptom.

Side effects

  • full is now speech-only, so FINAL_MAX_S bounds speaking time rather than wall-clock — a long, mostly-quiet dictation that used to exceed it now gets the better one-shot final.
  • /v1 uploads get the threshold but are never gated: handing us a file is itself a decision that its contents are worth transcribing. Makes _segment_bounds' "same thresholds the live path uses" true rather than aspirational.

🤖 Generated with Claude Code

The VAD's verdict decided when to *cut* a segment and what the HUD showed, but
never whether to decode. Every frame the client sent was buffered and handed to
Parakeet, which has no VAD of its own — so background chatter the VAD had
correctly scored as non-speech came back as words. The HUD dimmed while the
transcript filled up with the room.
Three paths fed the model unconditionally, and gating only the first would have
been worse than useless: the live partial (drops the noise from the live view),
the segment commit, and the one-shot final re-decode of the whole dictation
(puts it right back when you stop). So the gate goes where the audio is
buffered, and all three inherit it.
SileroVAD.process now returns the audio worth keeping, and the session buffers
that instead of the raw frame. Padding keeps the gate from eating real speech:
Silero crosses its threshold a beat after a word starts and falls below it
while the last consonant is still sounding, so VAD_PREROLL_MS (250) of held
audio is flushed ahead of each onset and VAD_HANGOVER_MS (200) is kept past
each offset. The pre-roll is a ring of the most recent rejected audio, so an
hour of silence still costs 250 ms.
Measured against the real Silero + Parakeet: at normal dictation volume the VAD
fires 68 ms after speech starts and the pre-roll opens the gate 156 ms before
it, so nothing is clipped. Down at -48 dBFS the VAD lags 436 ms and the first
word is lost — which is what VAD_PREROLL_MS is for.
VAD_THRESHOLD (0.5) is now configurable too. It was a constructor default no
call site passed, so tuning the one knob most likely to need tuning per-room
meant editing vad.py and rebuilding the image. It decides what counts as
background at all: Silero flags even quiet clean speech, so a room whose
audible conversations still get transcribed needs a stricter threshold, not
more padding. Applies to the Wyoming and /v1 paths as well, keeping
_segment_bounds' "same thresholds the live path uses" honest — though uploads
are segmented, never gated: handing us a file is itself a decision that its
contents are worth transcribing.
The padding rules are split into SpeechGate — no model, no torch import — since
that is the part with edge cases (a gate that opens late eats onsets, one that
never closes defeats the point), and the test suite has no GPU. A new `gate`
suite exercises it directly, plus an end-to-end dictation of nothing but
rejected audio asserting that neither partials nor the final carry text. FakeVAD
now drives the real SpeechGate, so what CI covers is production code.
Also adds SileroVAD.flush(): whole-window classification always holds back
<32 ms, which is exactly the end of the last word when you stop mid-sentence.
Side effect worth knowing: `full` is now speech-only, so FINAL_MAX_S bounds
speaking time rather than wall-clock, and a long, mostly-quiet dictation that
used to exceed it now gets the better one-shot final.
@z33b0t
z33b0t merged commit cb31d92 into mainJul 27, 2026
3 checks passed
@z33b0t
z33b0t deleted the vad-speech-gate branch July 27, 2026 21:21
z33b0t added a commit that referenced this pull request Jul 29, 2026
Neither workflow declared a `permissions:` block, so GITHUB_TOKEN was issued
with the repository default scope. Both only ever check out — the www deploy
authenticates to Cloudflare with its own API token — so `contents: read` is
what they need, and stating it means a later change to the repository default
can't silently widen them.
mac.yml and windows.yml already declare `contents: write` at job level for
`gh release create` / `upload`, so they are left alone.
Clears CodeQL actions/missing-workflow-permissions alerts #1 and #2.
Closes#15
Co-authored-by: z33b0t <z33b0t@users.noreply.github.com>
Sign up for freeto 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

@z33b0t