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

const streaming = 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:

UTC row
Aug 11 01:25:42 client:voice "rejected as noise — nothing was heard, discarded" · "Thank you for watching!"
Aug 11 08:03:17 client: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

export function hadSpeech(peakLevel: number, noiseFloor = -1): boolean {
	const adaptiveFloor = noiseFloor >= 0 ? Math.max(VOICE_FLOOR, NOISE_ONSET_RATIO * noiseFloor) : VOICE_FLOOR;
	return peakLevel > adaptiveFloor;
}

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

const adaptiveFloor = Math.max(VOICE_FLOOR, NOISE_ONSET_RATIO * roomNoise); // 0.24
expect(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.

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