HomePilot / frontend /src /ui /call /bargeIn.ts
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
0ef8bc0
Raw
History Blame Contribute Delete
4.59 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'
export interface UseBargeInDetectorArgs {
/** Shared ref mutated at rAF cadence by the parent — typically
* ``voice.audioLevel`` mirrored into a ref so we avoid stale
* reads without thrashing React. 0..1 range. */
audioLevelRef: React.MutableRefObject<number>
/** Only fire barge-ins when this is true. Wire this to
* ``tts.isSpeaking && session.streamingNegotiated``. */
enabled: boolean
/** Callback fired once per speech-start episode. Debounced via
* ``minRefractoryMs`` to avoid double-firing on sustained speech. */
onBargeIn: () => void
/** Audio level (0..1) above which the tap considers the user to
* be speaking. Default 0.08 — see header comment on TTS bleed. */
threshold?: number
/** How long the level must stay above ``threshold`` before we
* declare speech-start. 80 ms matches the minimum voiced-phoneme
* duration, so a single keyboard clack doesn't trigger. */
minSustainMs?: number
/** After firing, ignore further triggers for this long. Prevents
* a single barge-in from firing twice while speech continues. */
minRefractoryMs?: number
}
/**
* 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,
}: UseBargeInDetectorArgs): void {
// 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: number | null = 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: Array<{ tMs: number; level: number }>,
opts: {
threshold: number
minSustainMs: number
minRefractoryMs: number
},
): number[] {
const events: number[] = []
let aboveSince: number | null = 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
}