Skip to content

[bug] A deaf Web Speech gate vetoes 10 turns the energy detector proved were speech — isAlive() answers "has ever run", not "is hearing now" #535

Description

@serge-ivo

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

  1. 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.
  2. Where the energy detector has already proven onset (path:"end" is reachable only after onset), gate silence alone does not discard.
  3. 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.
  4. A recognizer failing audio-capture is reflected in what the gate claims about itself.
  5. 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 vanishonresult 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-talkshouldAutoDetectEndOfTurn (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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingvoiceVoice / STT / TTS

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions