A microphone-starved Web Speech gate vetoes turns the platform's own detector proved were speech
Live on the owner, 2026-08-12 08:50–08:57 UTC, hands-free + Whisper. Ten discards, each with the telemetry 97bf5a4 added today:
"voice turn discarded at end-of-turn — the live gate heard no words"
path:"end" peakLevel:0.664 noiseFloor:0 onsetFloor:0.1
frames:244 gateAlive:true gateHeardSpeech:false
The mic measured 0.664 against a 0.1 onset floor over 244 frames — unambiguous speech by vad.ts:99-101's own definition — and the turn was destroyed unheard. The mic then reopened with no cue, which is what the owner reported as "it transcribes, then doesn't send, it just starts listening again."
isAlive() answers the wrong question
packages/sdk/src/voice/gate.ts assigns alive = true in exactly two places — :160 (onresult, honest) and :221 (onend, commented "it started and ended → the engine is running") — and never assigns false after :143. reset() (:290-296) clears heard and the utterance but not alive.
A recognizer that dies on audio-capture still fires onend. So a gate that has never received one audio frame reports isAlive() === true permanently.
machine.ts:145-148 then reads it as authority to condemn:
if (gate?.isAlive && !gate.heardSpeech) return "discard";
isAlive() means "has this engine ever run". endOfTurnAction needs "is this gate hearing right now". Those are different questions and only the first is answered.
The discard ignores evidence it holds
turn.ts:166-167:
if (decision === "end") {
return endOfTurnAction(s.gate) === "discard" ? { action: "discard", report: CLOSE_END_DISCARDED } : { action: "transcribe" };
}
s.peakLevel and s.voiceFloor are in scope and unused — the idle branch one line below uses both.
And path:"end" is itself proof of speech: vadStep returns "idle"/null while !seen (vad.ts:305-331); "end" is only reachable at :340/:343, after onset, which requires level > onsetFloor && speaking (vad.ts:322).
Nothing is uploaded. stopDiscard() sets _discard = true (stt.ts:315-319) and mediaRec.onstop returns before _transcribeWhisper (stt.ts:479-483). idleRecycleRef suppresses the listening chime (use-voice.ts:771), so the recycle is silent.
Three rules over one body of evidence, and the harshest runs first
planClipGate (vad.ts:212-223) consults condemned (alive && !heard) only when frames === 0; with frames > 0 it falls through to hadSpeech(peakLevel, noiseFloor) at :220 and would have transcribed this clip.
planTurnClose's end branch runs first and destroys it, so the forgiving rule is unreachable.
planNoiseRejection is a third.
97bf5a4's own docstring states the principle as "a gate that never ran vouches for nothing" — but a gate refused the microphone did run, so it is trusted to condemn.
The upstream cause is measured
list_errors, same window:
| row |
created |
last seen |
count |
client:voice-gate "speech gate refused the microphone: audio-capture" {fails:1,burstMs:0} |
08:51:09 |
08:57:20 |
10 |
client:voice "discarded at end-of-turn" |
08:50:18 |
08:57:20 |
10 |
Identical last-seen, identical count. Dedup is one row per message per ~30s (mic-retry.ts:17-21) so counts are a floor, not a 1:1 pairing — but the lockstep is not coincidence. audio-capture is neither benign (mic-retry.ts:88-90) nor a permission verdict (convo.ts:1034-1036), so the gate retries forever, never latches denied, and stays "alive". fails:1 on every report means the counter resets between failures, so #425's backoff never accumulates past 400 ms.
Not a regression from #510
git show 97bf5a4 -- use-voice.ts: the removed lines are the same gate discard. 97bf5a4 unified the two branches and added the telemetry; the behaviour predates it, and alive = true on onend dates to 15466e3, the gate's introduction.
Caveat, stated: the client:voice-gate rows are themselves new as of 4cf17eb today (reporting was previously gated on isMicPermissionDenied), so their appearance today is not evidence that contention began today. Inferred, not verified: 4cf17eb also added a 400 ms+ delayed re-arm (gate.ts:245-247) where the gate previously re-armed instantly, which would widen the alive-but-deaf window. Not separable from pre-existing contention with the data available.
Issue coverage
Acceptance criteria
- A gate that has not received audio for the current turn cannot condemn it. Distinguish "has ever run" from "is hearing now" — a per-turn liveness fact, not a session-lifetime one.
- Where the energy detector has already proven onset (
path:"end" is reachable only after onset), gate silence alone does not discard.
- The three rules over this evidence agree, or their precedence is stated where they are defined and tested. Today the harshest runs first and the most forgiving is unreachable.
- A recognizer failing
audio-capture is reflected in what the gate claims about itself.
- Tests:
alive:true, heard:false, frames>0, peak>floor must transcribe; a genuine silent clip must still discard.
What could not be reproduced
No microphone available. One observation from the owner settles the remaining ambiguity — does he see his words on screen before it recycles?
- No visible words → confirms the above (gate has no device).
- Words appear, then vanish →
onresult is firing, so the gate has the device and heard is false for a different reason: acceptSpeech() returning false, or isNoiseTranscript(delta) at gate.ts:196. Same discard line, materially different cause and fix.
Workarounds that work today, verified in code
- Tap-to-talk —
shouldAutoDetectEndOfTurn (vad.ts:261-268) returns false when manualTalk is set (use-voice.ts:452, :1724), so planTurnClose never runs and the clip reaches planClipGate, which transcribes it. Requires the tab visible, or frames === 0 puts it back on the condemned branch (vad.ts:215-217).
- Dictation mode — the gate is only constructed when
sttIsWhisperRef.current && speechGateAvailable() (use-voice.ts:317).
Hands-free + Whisper is the only combination that hits this.
A microphone-starved Web Speech gate vetoes turns the platform's own detector proved were speech
Live on the owner, 2026-08-12 08:50–08:57 UTC, hands-free + Whisper. Ten discards, each with the telemetry
97bf5a4added today:The mic measured 0.664 against a 0.1 onset floor over 244 frames — unambiguous speech by
vad.ts:99-101's own definition — and the turn was destroyed unheard. The mic then reopened with no cue, which is what the owner reported as "it transcribes, then doesn't send, it just starts listening again."isAlive()answers the wrong questionpackages/sdk/src/voice/gate.tsassignsalive = truein exactly two places —:160(onresult, honest) and:221(onend, commented "it started and ended → the engine is running") — and never assignsfalseafter:143.reset()(:290-296) clearsheardand the utterance but notalive.A recognizer that dies on
audio-capturestill firesonend. So a gate that has never received one audio frame reportsisAlive() === truepermanently.machine.ts:145-148then reads it as authority to condemn:isAlive()means "has this engine ever run".endOfTurnActionneeds "is this gate hearing right now". Those are different questions and only the first is answered.The discard ignores evidence it holds
turn.ts:166-167:s.peakLevelands.voiceFloorare in scope and unused — theidlebranch one line below uses both.And
path:"end"is itself proof of speech:vadStepreturns"idle"/nullwhile!seen(vad.ts:305-331);"end"is only reachable at:340/:343, after onset, which requireslevel > onsetFloor && speaking(vad.ts:322).Nothing is uploaded.
stopDiscard()sets_discard = true(stt.ts:315-319) andmediaRec.onstopreturns before_transcribeWhisper(stt.ts:479-483).idleRecycleRefsuppresses the listening chime (use-voice.ts:771), so the recycle is silent.Three rules over one body of evidence, and the harshest runs first
planClipGate(vad.ts:212-223) consultscondemned(alive && !heard) only whenframes === 0; with frames > 0 it falls through tohadSpeech(peakLevel, noiseFloor)at:220and would have transcribed this clip.planTurnClose'sendbranch runs first and destroys it, so the forgiving rule is unreachable.planNoiseRejectionis a third.97bf5a4's own docstring states the principle as "a gate that never ran vouches for nothing" — but a gate refused the microphone did run, so it is trusted to condemn.The upstream cause is measured
list_errors, same window:client:voice-gate"speech gate refused the microphone: audio-capture"{fails:1,burstMs:0}client:voice"discarded at end-of-turn"Identical last-seen, identical count. Dedup is one row per message per ~30s (
mic-retry.ts:17-21) so counts are a floor, not a 1:1 pairing — but the lockstep is not coincidence.audio-captureis neither benign (mic-retry.ts:88-90) nor a permission verdict (convo.ts:1034-1036), so the gate retries forever, never latchesdenied, and stays "alive".fails:1on every report means the counter resets between failures, so #425's backoff never accumulates past 400 ms.Not a regression from #510
git show 97bf5a4 -- use-voice.ts: the removed lines are the same gate discard.97bf5a4unified the two branches and added the telemetry; the behaviour predates it, andalive = trueononenddates to15466e3, the gate's introduction.Caveat, stated: the
client:voice-gaterows are themselves new as of4cf17ebtoday (reporting was previously gated onisMicPermissionDenied), so their appearance today is not evidence that contention began today. Inferred, not verified:4cf17ebalso added a 400 ms+ delayed re-arm (gate.ts:245-247) where the gate previously re-armed instantly, which would widen the alive-but-deaf window. Not separable from pre-existing contention with the data available.Issue coverage
no_speech_probdead code) is independent — that lives in a branch never reached here, because nothing is uploaded.Acceptance criteria
path:"end"is reachable only after onset), gate silence alone does not discard.audio-captureis reflected in what the gate claims about itself.alive:true, heard:false, frames>0, peak>floormust transcribe; a genuine silent clip must still discard.What could not be reproduced
No microphone available. One observation from the owner settles the remaining ambiguity — does he see his words on screen before it recycles?
onresultis firing, so the gate has the device andheardis false for a different reason:acceptSpeech()returning false, orisNoiseTranscript(delta)atgate.ts:196. Same discard line, materially different cause and fix.Workarounds that work today, verified in code
shouldAutoDetectEndOfTurn(vad.ts:261-268) returns false whenmanualTalkis set (use-voice.ts:452,:1724), soplanTurnClosenever runs and the clip reachesplanClipGate, which transcribes it. Requires the tab visible, orframes === 0puts it back on thecondemnedbranch (vad.ts:215-217).sttIsWhisperRef.current && speechGateAvailable()(use-voice.ts:317).Hands-free + Whisper is the only combination that hits this.