fix(voice): preload VAD один раз — мгновенная реакция после «Космо»
All checks were successful
Deploy / deploy (push) Successful in 1m36s

В логах было видно: между wake-trigger и реальным VAD recording
проходило 1-2с (Loading VAD... → finished loading → started micVAD).
Каждый cancel дополнительно destroy'ил VAD, и следующий wake снова
ждал инициализацию.

Теперь:
- VAD создаётся один раз в paused-режиме сразу после wake.start()
  (в фоне, не блокирует UI).
- На каждый wake → vad.start() мгновенно.
- onSpeechEnd → vad.pause() (был implicit pause; явно ставим).
- voice-cancel → vad.pause(), а не destroy. Wake продолжает слушать.
- destroy только при полном выключении ассистента.
This commit is contained in:
Cosmo
2026-04-27 10:55:18 +00:00
parent fddca5de66
commit 7e3c5072bb

View File

@@ -69,13 +69,11 @@ export default function VoiceController() {
useEffect(() => { useEffect(() => {
console.log('[VoiceController] mounted, state=idle, ждём тап на микрофон') console.log('[VoiceController] mounted, state=idle, ждём тап на микрофон')
// Кнопка X в overlay шлёт voice-cancel → прерываем активную запись фразы, // Кнопка X в overlay шлёт voice-cancel → ставим VAD на паузу
// но wake-word оставляем слушать в фоне (если он включён). // (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию).
const onCancel = () => { const onCancel = () => {
console.log('[voice] cancel — прерываю запись') console.log('[voice] cancel — пауза VAD')
try { vadRef.current?.pause?.() } catch {} try { vadRef.current?.pause?.() } catch {}
try { vadRef.current?.destroy?.() } catch {}
vadRef.current = null
busyRef.current = false busyRef.current = false
try { wakeRef.current?.resume?.() } catch {} try { wakeRef.current?.resume?.() } catch {}
setState((s) => (wakeRef.current ? 'listening' : 'idle')) setState((s) => (wakeRef.current ? 'listening' : 'idle'))
@@ -125,14 +123,18 @@ export default function VoiceController() {
emitLocal('error', AGENT, 'Не получилось') emitLocal('error', AGENT, 'Не получилось')
} finally { } finally {
busyRef.current = false busyRef.current = false
// После обработки — возвращаем wake-режим (если активен) // VAD на паузу — переиспользуем при следующем wake (без re-init).
try { vadRef.current?.pause?.() } catch {}
// Wake возобновляем — снова слушаем фоном.
try { wakeRef.current?.resume?.() } catch {} try { wakeRef.current?.resume?.() } catch {}
setState((s) => (s === 'busy' ? 'listening' : s)) setState((s) => (s === 'busy' ? 'listening' : s))
} }
} }
// Создание VAD по запросу: либо после wake-детекта, либо после ручного тапа. // Однократная инициализация VAD. Создаётся в paused-состоянии и переиспользуется
const startVAD = async () => { // на каждый wake — без этого пауза до первой записи ~1-2с.
const initVAD = async () => {
if (vadRef.current) return
try { try {
const { MicVAD } = await import('@ricky0123/vad-web') const { MicVAD } = await import('@ricky0123/vad-web')
const vad = await MicVAD.new({ const vad = await MicVAD.new({
@@ -147,16 +149,15 @@ export default function VoiceController() {
negativeSpeechThreshold: 0.45, negativeSpeechThreshold: 0.45,
minSpeechMs: 160, minSpeechMs: 160,
redemptionMs: 750, redemptionMs: 750,
onSpeechStart: () => { onSpeechStart: () => emitLocal('wake', AGENT),
emitLocal('wake', AGENT)
},
onSpeechEnd: handleSpeechEnd, onSpeechEnd: handleSpeechEnd,
}) })
vadRef.current = vad vadRef.current = vad
vad.start() // Не вызываем start — ждём пока wake-word триггернёт.
console.log('[voice] VAD preloaded (paused)')
} catch (e: any) { } catch (e: any) {
console.error('[voice] VAD init failed:', e?.name, e?.message, e) console.error('[voice] VAD init failed:', e?.name, e?.message, e)
setState('error') // Не вырубаем wake — может на ручной trigger ещё попробуем
emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`) emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`)
} }
} }
@@ -168,12 +169,9 @@ export default function VoiceController() {
try { wakeRef.current?.pause?.() } catch {} try { wakeRef.current?.pause?.() } catch {}
setState('recording') setState('recording')
emitLocal('wake', AGENT) emitLocal('wake', AGENT)
// Если VAD ещё не готов — создаём; иначе reset+start. // VAD должен быть уже preloaded — мгновенный старт.
if (!vadRef.current) { if (!vadRef.current) await initVAD()
await startVAD() try { vadRef.current?.start?.() } catch {}
} else {
try { vadRef.current.start?.() } catch {}
}
} }
const start = async () => { const start = async () => {
@@ -215,6 +213,10 @@ export default function VoiceController() {
await wake.start() await wake.start()
wakeRef.current = wake wakeRef.current = wake
setState('listening') setState('listening')
// Прелоадим VAD в фоне — после первого wake реакция будет мгновенной,
// вместо +1-2с на загрузку Silero VAD.
initVAD().catch((e) => console.warn('[voice] VAD preload failed', e))
} catch (e: any) { } catch (e: any) {
console.error('[wake] init failed:', e) console.error('[wake] init failed:', e)
setState('error') setState('error')