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

Closed

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: noiseFloor ← vadStep (vad.ts:157-164, minimum of the first 5 frames) ← level ← computeRmsLevel (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

  1. added
    bugSomething isn't working
    voiceVoice / STT / TTS
    on Aug 12, 2026
  2. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Calibration fixed in 1925dfa (on main; CI green on that SHA; live — verified below).
    Leaving this open for criterion 1 only — see the bottom.

    §2 — the adaptive floor no longer discards what this file calls speech

    onsetFloorFor is now capped by ONSET_FLOOR_CEILING = 0.18, chosen to sit strictly below the
    bottom of the band vad.ts:99-101 defines as speech (0.2–0.5), with margin. Whatever the room, a
    peak in 0.2–0.5 clears the floor. #490's protection survives intact: steady room tone at 0.12 peaks
    around 0.13 and is still rejected — the bar still rises with the room, it just cannot rise past
    the definition of the thing it is measuring.

    The test you quoted asserted the bug. It now asserts the invariant, plus a sweep over nine noise
    floors from 0 to 0.9 × four speech peaks, so re-raising the ratio or removing the cap fails.

    The band this knowingly cannot resolve is 0.15–0.18. The file's other comment (vad.ts:92)
    puts the bottom of real speech at ~0.15 and room tone reaches ~0.13, so peak energy alone genuinely
    cannot separate them. Moving the number down re-admits the phantoms and up eats speech. That gap is
    closed by evidence of a different kind, which is criterion 4.

    §3 — coverage

    Added, in both directions, exactly the band you named: 0.3/0.12, 0.25/0.1, 0.2/0.07, 0.19/0.07 kept;
    0.13/0.12, 0.12/0.1, 0.09/0.02 dropped.

    §4 — the gate outranks the energy heuristic

    Done in #510's planClipGate: a proven-alive dictation gate that heard real words overrules the
    floor outright, the same precedence planNoiseRejection applies at the other end of the turn.

    §5 — which tightening is load-bearing: the adaptive floor

    Measured, not argued, and both halves are now tests. Room tone at 0.12 is above the fixed
    VOICE_FLOOR of 0.1, so a fixed floor admits it whether compared against the instantaneous level or
    the ratcheted peak — only the adaptive floor can do the job #490 was opened for.
    Instantaneous-vs-peak costs latency, not decisions: the loudest frame satisfies both the floor
    and the peak-relative speaking check, so the two converge on the same turn. Reverting that half
    alone would not have recovered the lost speech.

    A finding not in the issue, and it is load-bearing

    The noise-sampling window is ~83ms, not the ~330ms the code claims. vad.ts said "at 66ms/frame
    (LEVEL_THROTTLE_MS in use-voice.ts)", but LEVEL_THROTTLE_MS throttles only the React
    setAudioLevel call — vadStep runs on every requestAnimationFrame tick (use-voice.ts,
    the tick closure). Five frames is five sixtieths of a second, taken from the instant the mic opens.

    This matters because the estimate is a minimum, and a minimum over fewer frames is likelier to
    land on a loud one. So a user already speaking when the mic opens poisoned the floor with their own
    ramp: a 0.15 ramp set the bar at 0.45, which their whole sentence could never clear → onset never
    fires → the silent idle path. That is a concrete, code-verified mechanism for "it wasn't capturing
    my voice", and NOISE_SAMPLE_FRAMES was chosen against a number that was wrong by 4×. The constant
    is left at 5 (lengthening it would delay onset past the end of a one-word answer) but the ceiling
    now bounds what a poisoned estimate can cost, and the comment tells the truth. Covered by a test.

    Verified live

    Deployed console bundle at https://proagentstore.online/console/:

    function as(e){return e>=0?Math.min(os,Math.max(rs,3*e)):rs}var os=.18;
    function fs(e){return e===`whisper-1`}   // supportsNoSpeechProb

    §1 — STILL OPEN, and why I did not implement it

    supportsNoSpeechProb(model) now names the capability instead of inferring it from a transport
    flag, and the streaming and confidence questions are asked separately — so the trap that produced
    this bug is gone. But the gate itself still does not run on the default model, and I did not
    re-implement it.

    verbose_json is not available on gpt-4o-transcribe. The one model-side signal it does expose
    is include[]=logprobs. I declined to ship that, deliberately:

    • it is a new, blind discard layer on the only transcription path anyone uses, tuned against no
      data, on a repo that deploys on push;
    • the live complaint being worked is speech going missing, and avg-logprob is a weaker proxy
      than no_speech_prob (silence hallucinations are often high-confidence);
    • I cannot verify the API accepts it from here, and a 400 would break transcription for everyone.

    What does run on the streaming model is now named in the code: the energy gate before upload, the
    isNoiseTranscript filter on the way to the agent, and the low-energy suppression of the bias
    prompt. Worth noting — both phantoms dated in this issue were caught by the second one; neither
    became a user turn.
    They cost an upload, not a false message.

    Your call, and the measurement that decides it: if list_errors shows phantoms that are not
    in the silence-hallucination phrase list reaching the agent, include[]=logprobs is worth the risk
    and should be tuned against the peakLevel/onsetFloor rows #510 now writes. If the phrase filter
    is catching them all, this is better left alone. I would not guess at the threshold before that data
    exists.

  3. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    Criterion 1 — the measurement was taken, and it decided against the phrase list

    9b4c6564, live and verified in the served bundle. §2/§3/§4/§5 were closed by 1925dfa; this is
    the last criterion.

    The decision rule left on this ticket was explicit: "if list_errors shows phantoms that are NOT
    in the silence-hallucination phrase list reaching the agent, include[]=logprobs is worth the risk…
    If the phrase filter is catching them all, this is better left alone."
    It is not catching them all.

    The phantom that landed

    2026-08-10 09:16:24Z, instance cda75e28 ("Heartfull (tmux)"), message 9898719a, with an
    audioKey and no dictation — i.e. a real user turn, sent to the agent:

    "Pottery Barn Please visit www.potterybarn.com for more ideas and inspiration. Please visit www.potterybarn.com for more ideas and inspiration."

    Whisper's advertising-boilerplate hallucination, doubled. isNoiseTranscript could never catch
    it
    : that set is a whole-utterance lookup of known sign-offs, and no list of sign-offs contains an
    arbitrary retailer's ad copy. Widening the list is whack-a-mole against a training corpus.

    One correlation, labelled as such: the client:voice-control row 577a70b2 (not-allowed) runs
    08:54:09 → 09:16:24, matching to the second the two user messages at 08:54:09.893Z and
    09:16:24.941Z. Microphone refusals bracket exactly the turns that produced this. Suggestive of the
    #425 mechanism feeding this one; not established, and nothing here depends on it.

    What shipped, and what deliberately did not

    Not include[]=logprobs. It stays rejected for the reason it was rejected before, which this
    measurement does not change: a blind discard layer on the only transcription path anyone uses, with
    no measured logprob distribution to tune a threshold against, while the live complaint being
    worked is speech going missing. I have phantoms now; I still have no confidence numbers.

    The shape, which is the signal #512 already established as the right one. isRepetitionLoop
    missed this only because its bars are tuned for the opposite case — "chess academy" ×14, a short
    unit repeated many times. The same scan now carries a second band for the other end: a unit of ≥6
    words repeated ≥2× covering ≥85%
    of the transcript. Verbatim repetition gets rapidly less human as
    the unit grows — "no, no, no" is a person insisting; an eleven-word clause reproduced word for word
    as essentially the whole turn is a decoder — and the coverage bar is what keeps out a person who
    restated themselves and then carried on. A drop is not silent either: dropNote puts the words
    heard live back on the bubble, so a false positive costs a tap, not the turn.

    Tests hold both directions, including the exact recorded transcript, watched failing first.

    Live: var Xo=.5,Zo=5,Qo=.85; … let n=e>=6;if(!n&&e>Zo)continue;let r=n?2:4,i=n?Qo:Xo;

    Four more phantoms measured, and deliberately NOT acted on here

    The same scan found four others that landed, and they are a different mechanism:

    when landed as live capture said
    08-12 12:07:12Z "Pepega" "Kodex bless"
    08-10 02:26:31Z "Cortana agent is idling" "Agent idling"
    08-10 02:39:18Z "No, pop yes yes yeah yeah…" "Yeah Wake me up Milk milk milk"
    08-10 08:08:28Z the same sentence three times one utterance

    The "Pepega" one is the sharp one: client:voice-transcript wrote
    {"heard":"Kodex bless","final":"Pepega","lostTail":2} at 12:07:11 and the message shipped at
    12:07:12.283 — the platform detected the divergence, logged it, and sent the transcript anyway.

    That is not an oversight, it is #512's recorded decision ("surfaced rather than silently
    resolved", and blocking left to the shape detectors because "a false positive here drops a real
    turn"
    ). Overturning it needs its own ticket and its own measurement, and I am not doing it inside
    this one. Flagging it here so the data is attached to something.

    Not verified

    • No microphone here, and voice has been unused in production since 2026-08-12 12:21Z — so
      any post-deploy "no phantoms observed" reading would currently be indistinguishable from "nobody
      spoke". There is no traffic to validate against until voice is used again.
    • The message scan covered 8 of 35 instances, and 4 of those 8 had more pages I did not read.
      Five confirmed landings is a floor, not a count.

    Closing on criterion 1: the phrase list's gap is measured, the fix is a shape rule rather than a
    blind confidence layer, and the reason include[]=logprobs is still not here is recorded in the
    code beside the constants.

  4. serge-ivo commented on Aug 15, 2026

    @serge-ivo
    ContributorAuthor

    CI green on 7521eed7; the two-band repetition rule is live in the served bundle (let n=e>=6;…let r=n?2:4,i=n?Qo:Xo;). Closing on criterion 1 — the measurement decided it, and the reason include[]=logprobs is still not here is recorded beside the constants. The four #512-class divergences measured on the way are flagged in the comment above and want their own ticket rather than a silent reversal of #512's decision.

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