Spaces:
Sleeping
feat(voice): KI-057 — noise-robust Live VAD + flush-on-stop; KI-058 — scope lib/ ignore to venvs
Browse filesKI-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>
- .gitignore +7 -2
- frontend/src/lib/useLiveConversation.ts +158 -12
|
@@ -18,8 +18,13 @@ dist/
|
|
| 18 |
downloads/
|
| 19 |
eggs/
|
| 20 |
.eggs/
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/
|
|
@@ -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 |
-
*
|
| 9 |
-
*
|
| 10 |
-
*
|
| 11 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 —
|
|
|
|
|
|
|
| 54 |
rmsThreshold: 18,
|
| 55 |
-
// KI-
|
| 56 |
-
//
|
| 57 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
const avg = sum / buf.length;
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/057 — minimum 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-057 — 3 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;
|