HomePilot / frontend /src /ui /call /bargeIn.js
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
3.62 kB
/**
* 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;
}