rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
23a6d29
·
1 Parent(s): 2fbd062

feat(voice): KI-057 — noise-robust Live VAD + flush-on-stop; KI-058 — scope lib/ ignore to venvs

Browse files

KI-057 — Live mode VAD hardening (useLiveConversation.ts)
═══════════════════════════════════════════════════════════════

User report: "Background noise continues to play a big issue, even random
noise without people speaking or just ambient noise keeps the live listening
on, and it keeps processing on something and not moving on. Also, if an
entire series of things have been said and I turn off the live chat, should
that not auto submit?"

Root causes:
- Static rmsThreshold: 18 triggered on HVAC / fan / traffic noise.
- Once triggered, silenceEndFrames: 40 never accumulated because ambient
kept the meter above threshold → segment never closed.
- tearDown() silently dropped speechBufferRef when Live was toggled off
mid-utterance.

Six layered fixes (all overridable via opts; tuned defaults shipped):
A. Adaptive noise floor — EMA of ambient avg while idle. Effective
threshold = max(rmsThreshold, noise_floor * 2 + 4). HVAC keeps the bar high.
B. Voice-band spectral gate — require ≥35% of FFT energy in bins 2-22
(~190-2150 Hz at 48 kHz). Broadband noise fails this even when loud.
C. speechStartFrames raised 1 → 3 (~48 ms). Single clicks/clacks don't
trigger. KI-044's preroll buffer (300 ms lookback) still catches the
first phoneme.
D. maxUtteranceMs: 18 s hard cap. Force-close stuck segments where
ambient noise prevented silence-end from firing.
E. Post-utterance cooldown (700 ms). Prevents bot TTS attack transient
from re-triggering capture through mic loopback.
F. Flush-on-teardown. Toggling Live OFF while a capture is in progress
now encodes + fires onUtterance once (fire-and-forget) before
clearing buffers — so the user's words still get submitted.

Verification:
- npx tsc --noEmit → 0 errors
- npm run build (Next.js prod) → ✓ Compiled successfully

KI-058 — Scope lib/ gitignore to virtualenv layouts only
═══════════════════════════════════════════════════════════════

Bare `lib/` + `lib64/` (intended for `venv/lib/`) globbed against
`frontend/src/lib/` and made `git add` refuse to add new files there —
even though existing files were already tracked and committing normally.
This caused friction during this very session (KI-057 file edit hit the
"paths are ignored" wall on staging). Tightened to anchored patterns:
`/lib/`, `/lib64/`, `/.venv/lib/`, `/venv/lib/`. Verified
`git check-ignore -v frontend/src/lib/useLiveConversation.ts` now exits 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files changed (2) hide show
  1. .gitignore +7 -2
  2. frontend/src/lib/useLiveConversation.ts +158 -12
.gitignore CHANGED
@@ -18,8 +18,13 @@ dist/
18
  downloads/
19
  eggs/
20
  .eggs/
21
- lib/
22
- lib64/
 
 
 
 
 
23
  parts/
24
  sdist/
25
  var/
 
18
  downloads/
19
  eggs/
20
  .eggs/
21
+ # KI-058 (2026-05-15) — scope these to virtualenv layouts only. A bare
22
+ # `lib/` matched `frontend/src/lib/` and made `git add` refuse new files
23
+ # in that path even though existing files were already tracked.
24
+ /lib/
25
+ /lib64/
26
+ /.venv/lib/
27
+ /venv/lib/
28
  parts/
29
  sdist/
30
  var/
frontend/src/lib/useLiveConversation.ts CHANGED
@@ -4,11 +4,46 @@
4
  * useLiveConversation — full-duplex voice mode with barge-in.
5
  *
6
  * KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
 
 
 
7
  * --------------------------------------------------------------------
8
- * Previous implementation started a MediaRecorder ONLY when VAD declared
9
- * "speech started" by which point ~50-80 ms of the first word was
10
- * already past the mic. Users reported the bot only hearing "ello" / "i
11
- * am" rather than "hello" / "hi i am".
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  *
13
  * Current implementation:
14
  * - Single getUserMedia stream + AudioContext stay open while Live is on.
@@ -50,17 +85,32 @@ export type LiveConversationState = {
50
  };
51
 
52
  const DEFAULTS = {
53
- // KI-041 — sensitivity bumped to catch normal conversational volume.
 
 
54
  rmsThreshold: 18,
55
- // KI-043/044fire on first loud frame (~16 ms) so the preroll buffer
56
- // snapshot captures as much pre-trigger audio as possible.
57
- speechStartFrames: 1,
 
58
  silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
59
  minUtteranceMs: 400,
60
  // KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
61
  // 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
62
  // user onset before the first detectable frame, with margin.
63
  prerollMs: 300,
 
 
 
 
 
 
 
 
 
 
 
 
64
  };
65
 
66
  // AudioWorklet processor source — inlined as a Blob URL so we don't need
@@ -136,6 +186,10 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
136
  const rafIdRef = useRef<number | null>(null);
137
  const inflightAbortRef = useRef<AbortController | null>(null);
138
  const recStartTsRef = useRef<number>(0);
 
 
 
 
139
 
140
  const onUtteranceRef = useRef(opts.onUtterance);
141
  const onSpeechStartRef = useRef(opts.onSpeechStart);
@@ -152,6 +206,9 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
152
  silenceEndFrames: opts.silenceEndFrames ?? DEFAULTS.silenceEndFrames,
153
  minUtteranceMs: DEFAULTS.minUtteranceMs,
154
  prerollMs: DEFAULTS.prerollMs,
 
 
 
155
  };
156
 
157
  const interruptBotAudio = useCallback(() => {
@@ -178,10 +235,12 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
178
  }, []);
179
 
180
  // KI-044 — close speech capture: encode WAV, run guards, fire onUtterance.
 
181
  const endSpeechCapture = useCallback(async () => {
182
  if (!recordingRef.current) return;
183
  recordingRef.current = false;
184
  setRecording(false);
 
185
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
186
  const chunks = speechBufferRef.current;
187
  speechBufferRef.current = [];
@@ -225,20 +284,49 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
225
  }, [cfg.minUtteranceMs]);
226
 
227
  // VAD loop — runs while `live` is true.
 
228
  const tickVAD = useCallback(() => {
229
  if (!analyserRef.current) return;
230
  const a = analyserRef.current;
231
  const buf = new Uint8Array(a.frequencyBinCount);
232
  let loud = 0;
233
  let quiet = 0;
 
 
 
 
 
234
  const loop = () => {
235
  if (!analyserRef.current) return;
236
  a.getByteFrequencyData(buf);
 
237
  let sum = 0;
238
- for (let i = 0; i < buf.length; i++) sum += buf[i];
 
 
 
 
239
  const avg = sum / buf.length;
240
-
241
- if (avg > cfg.rmsThreshold) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  loud++;
243
  quiet = 0;
244
  if (loud === cfg.speechStartFrames && !recordingRef.current) {
@@ -253,11 +341,32 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
253
  } else {
254
  quiet++;
255
  loud = 0;
 
 
 
 
 
 
 
 
256
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
257
  void endSpeechCapture();
258
  }
259
  }
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  rafIdRef.current = requestAnimationFrame(loop);
262
  };
263
  rafIdRef.current = requestAnimationFrame(loop);
@@ -265,6 +374,9 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
265
  cfg.rmsThreshold,
266
  cfg.silenceEndFrames,
267
  cfg.speechStartFrames,
 
 
 
268
  interruptBotAudio,
269
  beginSpeechCapture,
270
  endSpeechCapture,
@@ -278,10 +390,44 @@ export function useLiveConversation(opts: LiveConversationOptions): LiveConversa
278
  cancelAnimationFrame(rafIdRef.current);
279
  rafIdRef.current = null;
280
  }
281
- // Drop any pending capture without firing onUtterance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282
  recordingRef.current = false;
283
  speechBufferRef.current = [];
284
  prerollRef.current = [];
 
 
285
  if (workletRef.current) {
286
  try { workletRef.current.disconnect(); } catch {}
287
  workletRef.current = null;
 
4
  * useLiveConversation — full-duplex voice mode with barge-in.
5
  *
6
  * KI-044 (2026-05-14) — PCM pre-roll via AudioWorklet.
7
+ * KI-057 (2026-05-15) — Noise-robust VAD + flush-on-stop.
8
+ *
9
+ * Why KI-057 was needed
10
  * --------------------------------------------------------------------
11
+ * Real user feedback after KI-044 shipped: "Background noise continues
12
+ * to play a big issue, even random noise without people speaking or
13
+ * just ambient noise keeps the live listening on, and it keeps
14
+ * processing on something and not moving on. Also, if an entire series
15
+ * of things have been said and I turn off the live chat, should that
16
+ * not auto submit?"
17
+ *
18
+ * Two failure modes:
19
+ * 1. Static `rmsThreshold: 18` triggered on HVAC / fan / traffic.
20
+ * Once triggered, `silenceEndFrames: 40` (~640 ms of silence)
21
+ * never accumulated because ambient noise kept the meter above
22
+ * threshold — segment never closed, "Hearing you…" stuck on.
23
+ * 2. Toggling Live OFF mid-utterance ran tearDown() which silently
24
+ * dropped `speechBufferRef` — user's words were lost.
25
+ *
26
+ * Fixes layered in (defaults — overridable via opts):
27
+ * A. Adaptive noise floor. While not recording, EMA the ambient
28
+ * energy. Effective threshold = max(noise_floor * 2 + 4,
29
+ * cfg.rmsThreshold). HVAC keeps the bar high.
30
+ * B. Voice-band spectral gate. Require ≥35% of FFT energy to live
31
+ * in bins 2-22 (~190-2150 Hz at 48 kHz) — the voiced-speech band.
32
+ * Broadband noise fails this even when loud.
33
+ * C. `speechStartFrames: 3` (~48 ms) so a single click/clack
34
+ * doesn't open a segment.
35
+ * D. Hard cap: `maxUtteranceMs: 18 s`. If a segment runs that long
36
+ * without silence-end firing, force-close it. Prevents the
37
+ * "noise pinned the meter open forever" state.
38
+ * E. Post-utterance cooldown (700 ms). After we close + dispatch a
39
+ * segment, suppress new triggers — even with echoCancellation,
40
+ * the bot's TTS attack transient sometimes bleeds in.
41
+ * F. Flush-on-teardown. If the user toggles Live OFF while a
42
+ * capture is in progress and the duration meets minUtteranceMs,
43
+ * encode + fire `onUtterance` once before tearing down — so
44
+ * whatever they were saying gets submitted.
45
+ *
46
+ * KI-044 still applies — see below.
47
  *
48
  * Current implementation:
49
  * - Single getUserMedia stream + AudioContext stay open while Live is on.
 
85
  };
86
 
87
  const DEFAULTS = {
88
+ // KI-041/057minimum bar. Effective threshold is max(this, adaptive
89
+ // noise_floor * 2 + 4). With KI-044's preroll buffer we can afford to
90
+ // require more frames before declaring speech.
91
  rmsThreshold: 18,
92
+ // KI-0573 consecutive loud frames (~48 ms). Single clicks/clacks
93
+ // don't open a segment. KI-044's preroll buffer still captures the
94
+ // first phoneme since we look back 300 ms.
95
+ speechStartFrames: 3,
96
  silenceEndFrames: 40, // ~640 ms of silence to declare utterance end
97
  minUtteranceMs: 400,
98
  // KI-044 — How much pre-trigger PCM we keep in the rolling buffer.
99
  // 300 ms is generous; covers the ~80 ms VAD latency + ~100 ms of
100
  // user onset before the first detectable frame, with margin.
101
  prerollMs: 300,
102
+ // KI-057 — hard cap. If silence-end never fires (e.g. continuous
103
+ // ambient noise pinned the meter open), force-close the segment.
104
+ maxUtteranceMs: 18000,
105
+ // KI-057 — suppress new triggers for this long after we close a
106
+ // segment. Avoids bot's TTS attack transient bleeding through even
107
+ // with echoCancellation on.
108
+ postUtteranceCooldownMs: 700,
109
+ // KI-057 — minimum fraction of total FFT energy that must sit in the
110
+ // voice band (bins 2-22 ≈ 190-2150 Hz at 48 kHz). Broadband HVAC /
111
+ // fan / traffic noise typically scores 0.20-0.30; voiced speech
112
+ // typically scores 0.40-0.70.
113
+ voiceBandMinProp: 0.35,
114
  };
115
 
116
  // AudioWorklet processor source — inlined as a Blob URL so we don't need
 
186
  const rafIdRef = useRef<number | null>(null);
187
  const inflightAbortRef = useRef<AbortController | null>(null);
188
  const recStartTsRef = useRef<number>(0);
189
+ // KI-057 — adaptive noise floor (EMA of ambient avg while idle).
190
+ const noiseFloorRef = useRef<number>(0);
191
+ // KI-057 — gates "did the bot just stop talking?" cooldown.
192
+ const lastUtteranceEndedAtRef = useRef<number>(0);
193
 
194
  const onUtteranceRef = useRef(opts.onUtterance);
195
  const onSpeechStartRef = useRef(opts.onSpeechStart);
 
206
  silenceEndFrames: opts.silenceEndFrames ?? DEFAULTS.silenceEndFrames,
207
  minUtteranceMs: DEFAULTS.minUtteranceMs,
208
  prerollMs: DEFAULTS.prerollMs,
209
+ maxUtteranceMs: DEFAULTS.maxUtteranceMs,
210
+ postUtteranceCooldownMs: DEFAULTS.postUtteranceCooldownMs,
211
+ voiceBandMinProp: DEFAULTS.voiceBandMinProp,
212
  };
213
 
214
  const interruptBotAudio = useCallback(() => {
 
235
  }, []);
236
 
237
  // KI-044 — close speech capture: encode WAV, run guards, fire onUtterance.
238
+ // KI-057 — also anchors the post-utterance cooldown.
239
  const endSpeechCapture = useCallback(async () => {
240
  if (!recordingRef.current) return;
241
  recordingRef.current = false;
242
  setRecording(false);
243
+ lastUtteranceEndedAtRef.current = Date.now();
244
  const durationMs = Date.now() - (recStartTsRef.current || Date.now());
245
  const chunks = speechBufferRef.current;
246
  speechBufferRef.current = [];
 
284
  }, [cfg.minUtteranceMs]);
285
 
286
  // VAD loop — runs while `live` is true.
287
+ // KI-057 — adaptive threshold + voice-band gate + max-utterance cap.
288
  const tickVAD = useCallback(() => {
289
  if (!analyserRef.current) return;
290
  const a = analyserRef.current;
291
  const buf = new Uint8Array(a.frequencyBinCount);
292
  let loud = 0;
293
  let quiet = 0;
294
+ // Voice band: bins 2-22 at fftSize=512 cover ~190-2150 Hz at 48 kHz —
295
+ // where voiced speech lives. Capped at bin count for safety.
296
+ const voiceBandStart = 2;
297
+ const voiceBandEnd = Math.min(22, buf.length - 1);
298
+
299
  const loop = () => {
300
  if (!analyserRef.current) return;
301
  a.getByteFrequencyData(buf);
302
+
303
  let sum = 0;
304
+ let voiceSum = 0;
305
+ for (let i = 0; i < buf.length; i++) {
306
+ sum += buf[i];
307
+ if (i >= voiceBandStart && i <= voiceBandEnd) voiceSum += buf[i];
308
+ }
309
  const avg = sum / buf.length;
310
+ const voiceProp = sum > 0 ? voiceSum / sum : 0;
311
+
312
+ // KI-057 — adaptive threshold. Floor at cfg.rmsThreshold so
313
+ // genuinely quiet rooms don't open the gate too low.
314
+ const effectiveThreshold = Math.max(
315
+ cfg.rmsThreshold,
316
+ noiseFloorRef.current * 2.0 + 4,
317
+ );
318
+
319
+ // KI-057 — suppress new triggers right after we closed a segment
320
+ // (bot's TTS onset can bleed in via the mic loopback).
321
+ const cooldownActive =
322
+ Date.now() - lastUtteranceEndedAtRef.current < cfg.postUtteranceCooldownMs;
323
+
324
+ const speechLike =
325
+ avg > effectiveThreshold &&
326
+ voiceProp >= cfg.voiceBandMinProp &&
327
+ !cooldownActive;
328
+
329
+ if (speechLike) {
330
  loud++;
331
  quiet = 0;
332
  if (loud === cfg.speechStartFrames && !recordingRef.current) {
 
341
  } else {
342
  quiet++;
343
  loud = 0;
344
+ // KI-057 — only learn the noise floor while idle, so ongoing
345
+ // speech doesn't poison the EMA.
346
+ if (!recordingRef.current) {
347
+ noiseFloorRef.current =
348
+ noiseFloorRef.current === 0
349
+ ? avg
350
+ : noiseFloorRef.current * 0.95 + avg * 0.05;
351
+ }
352
  if (quiet === cfg.silenceEndFrames && recordingRef.current) {
353
  void endSpeechCapture();
354
  }
355
  }
356
 
357
+ // KI-057 — max-utterance cap. If recording has run too long
358
+ // without silence-end firing, force-close it. Prevents the
359
+ // "noise pinned the meter open" stuck state.
360
+ if (
361
+ recordingRef.current &&
362
+ recStartTsRef.current > 0 &&
363
+ Date.now() - recStartTsRef.current > cfg.maxUtteranceMs
364
+ ) {
365
+ // eslint-disable-next-line no-console
366
+ console.debug("[live-mode] force-closing at max-utterance cap");
367
+ void endSpeechCapture();
368
+ }
369
+
370
  rafIdRef.current = requestAnimationFrame(loop);
371
  };
372
  rafIdRef.current = requestAnimationFrame(loop);
 
374
  cfg.rmsThreshold,
375
  cfg.silenceEndFrames,
376
  cfg.speechStartFrames,
377
+ cfg.maxUtteranceMs,
378
+ cfg.postUtteranceCooldownMs,
379
+ cfg.voiceBandMinProp,
380
  interruptBotAudio,
381
  beginSpeechCapture,
382
  endSpeechCapture,
 
390
  cancelAnimationFrame(rafIdRef.current);
391
  rafIdRef.current = null;
392
  }
393
+ // KI-057 flush a mid-utterance capture before dropping refs.
394
+ // If the user toggled Live OFF while speaking, encode + fire
395
+ // onUtterance once (fire-and-forget) so their words still land.
396
+ if (recordingRef.current && speechBufferRef.current.length > 0) {
397
+ const durationMs = Date.now() - (recStartTsRef.current || Date.now());
398
+ if (durationMs >= DEFAULTS.minUtteranceMs) {
399
+ let total = 0;
400
+ for (const c of speechBufferRef.current) total += c.length;
401
+ const merged = new Float32Array(total);
402
+ let off = 0;
403
+ for (const c of speechBufferRef.current) {
404
+ merged.set(c, off);
405
+ off += c.length;
406
+ }
407
+ const wav = encodeWAV(merged, sampleRateRef.current);
408
+ if (wav.size >= 3000) {
409
+ const handler = onUtteranceRef.current;
410
+ const abort = new AbortController();
411
+ // Fire-and-forget. The page handler is independent of Live
412
+ // being on, so the response will still render in the chat
413
+ // pane after teardown completes.
414
+ try {
415
+ handler(wav, abort).catch((e) => {
416
+ const name = (e as { name?: string })?.name;
417
+ if (name !== "AbortError") {
418
+ // eslint-disable-next-line no-console
419
+ console.error("[live-mode] flush-on-stop failed:", e);
420
+ }
421
+ });
422
+ } catch {}
423
+ }
424
+ }
425
+ }
426
  recordingRef.current = false;
427
  speechBufferRef.current = [];
428
  prerollRef.current = [];
429
+ noiseFloorRef.current = 0;
430
+ recStartTsRef.current = 0;
431
  if (workletRef.current) {
432
  try { workletRef.current.disconnect(); } catch {}
433
  workletRef.current = null;