Uh oh!
There was an error while loading. Please reload this page.
Gate the model on speech instead of transcribing everything - #2
Merged
Conversation
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.
Uh oh!
There was an error while loading. Please reload this page.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
So the gate goes where the audio is buffered, and all three inherit it.
The fix
SileroVAD.processnow 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 msVAD_HANGOVER_MS(200) kept past each offsetVAD_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 editingvad.pyand rebuilding the imageSpeechGateholds the padding rules with no model and no torch import, so CI can test it without a GPU. Also addsSileroVAD.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
gatesuite: 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.FakeVADnow drives the realSpeechGate, 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:
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_MSis for.Trade-off worth knowing: the gate costs accuracy on very quiet speech. At those bottom two rows the ungated
/v1path 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
fullis now speech-only, soFINAL_MAX_Sbounds speaking time rather than wall-clock — a long, mostly-quiet dictation that used to exceed it now gets the better one-shot final./v1uploads 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