/** * bargeIn.ts โ€” VAD-tap hook that detects user speech-start while * the assistant's TTS is playing, so the client can interrupt the * persona the way a person would. * * Design (ยง 5.4 of docs/analysis/voice-call-streaming-design.md): * * - Reads the EMA-smoothed mic level that useVoiceController * already exposes (audioLevelRef), sampled at rAF cadence. * - Fires onBargeIn when the level stays above ``threshold`` for * at least ``minSustainMs``. Sustain protects against a * single spike (keyboard click, cough) cutting the persona off. * - Only enabled when the overlay says it should be โ€” i.e. the * TTS is actually speaking AND the session negotiated streaming. * Outside that window it's a pure no-op. * * The threshold here (default 0.08) is deliberately HIGHER than the * baseline VAD threshold (~0.035) that useVoiceController uses for * end-of-user-turn silence detection. Reason: during TTS playback a * fraction of the persona's own audio returns into the mic on most * laptops; a low threshold trips on that bleed and generates * phantom barge-ins. 0.08 is above typical TTS bleed but well below * normal voiced-speech peaks. */ import { useEffect, useRef } from 'react'; /** * Mount a VAD tap that fires ``onBargeIn`` when sustained speech is * detected during an active TTS phase. Cleanup on unmount. */ export function useBargeInDetector({ audioLevelRef, enabled, onBargeIn, threshold = 0.08, minSustainMs = 80, minRefractoryMs = 400, }) { // Latest callback in a ref so a change in ``onBargeIn`` identity // doesn't tear down the whole rAF loop. const onBargeInRef = useRef(onBargeIn); useEffect(() => { onBargeInRef.current = onBargeIn; }, [onBargeIn]); useEffect(() => { if (!enabled) return; let raf = 0; let aboveSince = null; let refractoryUntil = 0; const tick = () => { const now = performance.now(); const level = audioLevelRef.current ?? 0; if (level >= threshold) { if (aboveSince === null) aboveSince = now; else if (now >= refractoryUntil && now - aboveSince >= minSustainMs) { refractoryUntil = now + minRefractoryMs; aboveSince = null; try { onBargeInRef.current(); } catch { /* listener errors must not crash the rAF loop */ } } } else { aboveSince = null; } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => { cancelAnimationFrame(raf); }; }, [enabled, audioLevelRef, threshold, minSustainMs, minRefractoryMs]); } // Exported for tests โ€” pure detector without the React hook. export function detectBargeIn(samples, opts) { const events = []; let aboveSince = null; let refractoryUntil = 0; for (const { tMs, level } of samples) { if (level >= opts.threshold) { if (aboveSince === null) aboveSince = tMs; else if (tMs >= refractoryUntil && tMs - aboveSince >= opts.minSustainMs) { refractoryUntil = tMs + opts.minRefractoryMs; aboveSince = null; events.push(tMs); } } else { aboveSince = null; } } return events; }