Description
#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.24with 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
- The
no_speech_probgate 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. hadSpeechdoes not discard peaks inside the rangevad.ts:99-101defines as speech. If the adaptive floor is kept, the ratio or the floor is re-derived and the reasoning is written down.- Tests cover the 0.15–0.3 peak band against a 0.07–0.12 noise floor, in both directions.
- Where the Web Speech gate proves words were spoken, that evidence outranks an energy heuristic — as
planNoiseRejectionalready does atturn.ts:176-180. - 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
fix(voice): no room is noisy enough to make real speech inaudible, an…
serge-ivo commentedon Aug 12, 2026
Calibration fixed in
1925dfa(onmain; 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
onsetFloorForis now capped byONSET_FLOOR_CEILING = 0.18, chosen to sit strictly below thebottom of the band
vad.ts:99-101defines as speech (0.2–0.5), with margin. Whatever the room, apeak 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 thefloor outright, the same precedence
planNoiseRejectionapplies 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_FLOORof 0.1, so a fixed floor admits it whether compared against the instantaneous level orthe 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
speakingcheck, so the two converge on the same turn. Reverting that halfalone 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.tssaid "at 66ms/frame(LEVEL_THROTTLE_MS in use-voice.ts)", but
LEVEL_THROTTLE_MSthrottles only the ReactsetAudioLevelcall —vadStepruns on everyrequestAnimationFrametick (use-voice.ts,the
tickclosure). 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_FRAMESwas chosen against a number that was wrong by 4×. The constantis 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/:§1 — STILL OPEN, and why I did not implement it
supportsNoSpeechProb(model)now names the capability instead of inferring it from a transportflag, 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_jsonis not available ongpt-4o-transcribe. The one model-side signal it does exposeis
include[]=logprobs. I declined to ship that, deliberately:data, on a repo that deploys on push;
than
no_speech_prob(silence hallucinations are often high-confidence);What does run on the streaming model is now named in the code: the energy gate before upload, the
isNoiseTranscriptfilter on the way to the agent, and the low-energy suppression of the biasprompt. 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_errorsshows phantoms that are notin the silence-hallucination phrase list reaching the agent,
include[]=logprobsis worth the riskand should be tuned against the
peakLevel/onsetFloorrows #510 now writes. If the phrase filteris catching them all, this is better left alone. I would not guess at the threshold before that data
exists.
fix(voice): the dictation gate can only condemn a turn it is actually…
fix(errors): the voice stack says whether a report is a failure or a …
fix(voice): the phantom that reached an agent as ad copy is caught by…
serge-ivo commentedon Aug 15, 2026
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 by1925dfa; this isthe last criterion.
The decision rule left on this ticket was explicit: "if
list_errorsshows phantoms that are NOTin the silence-hallucination phrase list reaching the agent,
include[]=logprobsis 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)"), message9898719a, with anaudioKeyand nodictation— i.e. a real user turn, sent to the agent:Whisper's advertising-boilerplate hallucination, doubled.
isNoiseTranscriptcould never catchit: 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-controlrow577a70b2(not-allowed) runs08:54:09 → 09:16:24, matching to the second the two user messages at 08:54:09.893Z and09: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 thismeasurement 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.
isRepetitionLoopmissed 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:
dropNoteputs the wordsheard 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:
"Pepega""Kodex bless""Cortana agent is idling""Agent idling""No, pop yes yes yeah yeah…""Yeah Wake me up Milk milk milk"The
"Pepega"one is the sharp one:client:voice-transcriptwrote{"heard":"Kodex bless","final":"Pepega","lostTail":2}at 12:07:11 and the message shipped at12: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
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.
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[]=logprobsis still not here is recorded in thecode beside the constants.
serge-ivo commentedon Aug 15, 2026
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 reasoninclude[]=logprobsis 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.