Skip to content

[bug] The #490 noise gate is wrong in both directions — no_speech_prob is dead code on the default model, and the adaptive floor discards peaks the same file calls speech #511

Description

@serge-ivo

#490's noise gate is wrong in both directions: it never runs on the default model, and its floor discards speech the same file calls speech

#490 shipped three layers to stop phantom Whisper turns. Measured against production, the phantoms still arrive and real speech is at risk. Both halves are verified.

1. The no_speech_prob layer is dead code on the default model

packages/sdk/src/voice/stt.ts:414

conststreaming=this.model!=="whisper-1";

response_format=verbose_json and the no_speech_prob > 0.6 discard live in the else branch (stt.ts:417-421, :461-473). The default model is gpt-4o-transcribe (stt.ts:10; defaultVoiceSettings()STT_MODELS[0], workers/api/src/lib/preferences.ts:39,234), which takes the streaming branch. So the model-side confidence gate never executes unless the user has explicitly selected legacy whisper-1.

Confirmed live.deploy-host.yml succeeded on 1449ee1 at 2026-08-10 12:26:33 UTC. Phantom transcripts still reached Whisper afterwards:

UTCrow
Aug 11 01:25:42client:voice "rejected as noise — nothing was heard, discarded" · "Thank you for watching!"
Aug 11 08:03:17client:voice "result ignored" · "Thank you for watching."

13+ hours after the fix deployed. The phantom path #490 was opened for is not closed for the default model.

2. The adaptive floor discards a peak the same file defines as speech

packages/sdk/src/voice/vad.ts:115-117

exportfunctionhadSpeech(peakLevel: number,noiseFloor=-1): boolean{constadaptiveFloor=noiseFloor>=0 ? Math.max(VOICE_FLOOR,NOISE_ONSET_RATIO*noiseFloor) : VOICE_FLOOR;returnpeakLevel>adaptiveFloor;}

The repo's own green test, packages/sdk/src/voice/vad.test.ts:153-154:

constadaptiveFloor=Math.max(VOICE_FLOOR,NOISE_ONSET_RATIO*roomNoise);// 0.24expect(hadSpeech(0.2,roomNoise)).toBe(false);// 0.2 < 0.24

with roomNoise = 0.08. And fifty lines up in the same file, vad.ts:99-101 states:

"0.2 separates typical unambiguous speech peaks (~0.2–0.5) from ambient noise"

So a shipped, passing test asserts that a clip peaking at the bottom of the file's own stated speech range is thrown away in a room with moderate ambient noise. Quiet speech, a distant mic, or a fan running puts a real utterance under the floor.

Coverage gap: every test in the #490 block uses a 0.40 spike against a 0.12 floor. Nothing tests the 0.15–0.3 band against a 0.07–0.12 floor — precisely the case that matters.

Input trace, fully walked: noiseFloorvadStep (vad.ts:157-164, minimum of the first 5 frames) ← levelcomputeRmsLevel (audio.ts:23-28) ← analyser, called from the rAF tick at use-voice.ts:421-446, pushed onto the recorder at :433-434, consumed at stt.ts:369 and :408.

3. The onset gate got strictly stricter in two independent ways

vad.ts:183-193 now requires the instantaneous level to clear max(0.1, 3 × noiseFloor). Before #490, onset was satisfied by the ratcheted peak clearing a fixed 0.1 (git show 1449ee1 -- vad.ts, removed: const heardVoice = s.peak > VOICE_FLOOR; if (heardVoice && speaking) { … if (!s.seen) s.seen = true; }). Instantaneous-vs-peak and fixed-vs-adaptive are two separate tightenings shipped together. When onset never fires the turn goes to the silent idle-recycle path (filed separately).

4. Three dated cases of "I spoke and it wasn't transcribed" — independent of the above

"voice turn rejected as noise — the live capture was kept" is emitted only when planNoiseRejection finds gate.isAlive && gate.heardSpeech (turn.ts:176-180) — i.e. the browser recognizer proved real words were spoken this turn, and Whisper returned a silence-hallucination phrase anyway. Recorded: Aug 8 02:47:02, Aug 10 10:33:40, Aug 11 01:35:01 (the last one post-#490).

Acceptance criteria

  1. The no_speech_prob gate runs on the default streaming model, or [bug] STT transcribes silence in hands-free mode — Whisper hallucinations posted as phantom user turns ("Pottery Barn", "Thank you for watching") — gate fails on iOS Safari and when SpeechRecognition stalls #490's phantom protection is re-implemented for it. Today it protects only a model nobody is using by default.
  2. hadSpeech does not discard peaks inside the range vad.ts:99-101 defines as speech. If the adaptive floor is kept, the ratio or the floor is re-derived and the reasoning is written down.
  3. Tests cover the 0.15–0.3 peak band against a 0.07–0.12 noise floor, in both directions.
  4. Where the Web Speech gate proves words were spoken, that evidence outranks an energy heuristic — as planNoiseRejection already does at turn.ts:176-180.
  5. State explicitly which of the two tightenings in §3 is load-bearing; shipping both together is why this is hard to attribute.

Verified vs inferred

  • Verified: every file:line, the test assertion, the deploy timestamp, and all five dated error rows.
  • Inferred: that the adaptive floor has actually eaten one of the owner's real utterances. The error rows carry no peakLevel/noiseFloor, so it is not decidable from the log as it stands — which is itself a finding, covered in the silent-drops issue.

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