Files
smart-home-tablet/components/VoiceController.tsx
Cosmo 7e3c5072bb
All checks were successful
Deploy / deploy (push) Successful in 1m36s
fix(voice): preload VAD один раз — мгновенная реакция после «Космо»
В логах было видно: между 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 только при полном выключении ассистента.
2026-04-27 10:55:18 +00:00

313 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* Голосовой контроллер.
*
* UX:
* - Idle: кнопка-микрофон (перечёркнут). Тап = «активировать ассистента» (нужен
* user gesture чтобы AudioContext стартанул).
* - Active: загружаются wake-модели (один раз) → запускается wake-word listener
* на постоянный фон. Кнопка горит фиолетовым, говорит «Космо».
* - Wake-word triggered → MicVAD стартует → onSpeechEnd → STT → chat → TTS.
* - Параллельно тап на кнопку = ручной trigger (как раньше) если wake не
* срабатывает или wake тренировка ещё слабая.
* - Tap во время Active → выключает wake и mic полностью.
*/
import { useEffect, useRef, useState } from 'react'
import { Mic, MicOff } from 'lucide-react'
import { WakeWordDetector } from '@/lib/wake-word'
type Agent = 'cosmo' | 'lusya'
type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error'
const AGENT: Agent = 'cosmo'
const WAKE_THRESHOLD = 0.5
function emitLocal(event: string, agent: Agent, text?: string) {
window.dispatchEvent(
new CustomEvent('voice-local', {
detail: { event, agent, text, timestamp: new Date().toISOString() },
}),
)
}
function floatToWav(audio: Float32Array, sampleRate = 16000): Blob {
const numSamples = audio.length
const buffer = new ArrayBuffer(44 + numSamples * 2)
const view = new DataView(buffer)
writeStr(view, 0, 'RIFF')
view.setUint32(4, 36 + numSamples * 2, true)
writeStr(view, 8, 'WAVE')
writeStr(view, 12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeStr(view, 36, 'data')
view.setUint32(40, numSamples * 2, true)
let offset = 44
for (let i = 0; i < numSamples; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, audio[i]))
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true)
}
return new Blob([buffer], { type: 'audio/wav' })
}
function writeStr(view: DataView, offset: number, s: string) {
for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i))
}
export default function VoiceController() {
const [state, setState] = useState<ControllerState>('idle')
const wakeRef = useRef<WakeWordDetector | null>(null)
const vadRef = useRef<any>(null)
const busyRef = useRef(false)
useEffect(() => {
console.log('[VoiceController] mounted, state=idle, ждём тап на микрофон')
// Кнопка X в overlay шлёт voice-cancel → ставим VAD на паузу
// (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию).
const onCancel = () => {
console.log('[voice] cancel — пауза VAD')
try { vadRef.current?.pause?.() } catch {}
busyRef.current = false
try { wakeRef.current?.resume?.() } catch {}
setState((s) => (wakeRef.current ? 'listening' : 'idle'))
emitLocal('idle', AGENT)
}
window.addEventListener('voice-cancel', onCancel)
return () => {
window.removeEventListener('voice-cancel', onCancel)
try { vadRef.current?.destroy?.() } catch {}
try { wakeRef.current?.stop?.() } catch {}
vadRef.current = null
wakeRef.current = null
}
}, [])
// Обрабатываем результат VAD-захвата фразы и шлём по pipeline.
const handleSpeechEnd = async (audio: Float32Array) => {
if (busyRef.current) return
if (audio.length < 16000 * 0.4) return
busyRef.current = true
setState('busy')
emitLocal('listening', AGENT)
try {
const wav = floatToWav(audio, 16000)
const sttResp = await fetch('/api/voice/stt', {
method: 'POST',
headers: { 'Content-Type': 'audio/wav' },
body: wav,
})
if (!sttResp.ok) throw new Error(`stt ${sttResp.status}`)
const { text } = await sttResp.json()
const userText = (text || '').trim()
if (!userText || userText.length < 2) {
emitLocal('idle', AGENT)
return
}
const chatResp = await fetch('/api/voice/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: userText, agent: AGENT }),
})
if (!chatResp.ok) throw new Error(`chat ${chatResp.status}`)
} catch (e) {
console.error('[voice] pipeline error:', e)
emitLocal('error', AGENT, 'Не получилось')
} finally {
busyRef.current = false
// VAD на паузу — переиспользуем при следующем wake (без re-init).
try { vadRef.current?.pause?.() } catch {}
// Wake возобновляем — снова слушаем фоном.
try { wakeRef.current?.resume?.() } catch {}
setState((s) => (s === 'busy' ? 'listening' : s))
}
}
// Однократная инициализация VAD. Создаётся в paused-состоянии и переиспользуется
// на каждый wake — без этого пауза до первой записи ~1-2с.
const initVAD = async () => {
if (vadRef.current) return
try {
const { MicVAD } = await import('@ricky0123/vad-web')
const vad = await MicVAD.new({
model: 'v5',
baseAssetPath: '/vad/',
onnxWASMBasePath: '/vad/',
ortConfig: (ort: any) => {
ort.env.wasm.numThreads = 1
ort.env.wasm.simd = true
},
positiveSpeechThreshold: 0.6,
negativeSpeechThreshold: 0.45,
minSpeechMs: 160,
redemptionMs: 750,
onSpeechStart: () => emitLocal('wake', AGENT),
onSpeechEnd: handleSpeechEnd,
})
vadRef.current = vad
// Не вызываем start — ждём пока wake-word триггернёт.
console.log('[voice] VAD preloaded (paused)')
} catch (e: any) {
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
// Не вырубаем wake — может на ручной trigger ещё попробуем
emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
const onWakeDetected = async (score: number) => {
console.log(`[wake] cosmo score=${score.toFixed(3)}`)
if (busyRef.current) return
// Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе.
try { wakeRef.current?.pause?.() } catch {}
setState('recording')
emitLocal('wake', AGENT)
// VAD должен быть уже preloaded — мгновенный старт.
if (!vadRef.current) await initVAD()
try { vadRef.current?.start?.() } catch {}
}
const start = async () => {
if (state !== 'idle' && state !== 'error') return
setState('loading')
// 1. Запрос разрешения на микрофон отдельно
try {
const probe = await navigator.mediaDevices.getUserMedia({ audio: true })
probe.getTracks().forEach((t) => t.stop())
} catch (e: any) {
console.error('[voice] mic permission failed:', e?.name, e?.message)
setState('error')
emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся')
return
}
// 2. Запуск wake-word
try {
// Логируем периодически max-score и просто что pipeline жив, чтобы было
// видно, что инференс идёт.
let maxScore = 0
let scoreCount = 0
const wake = new WakeWordDetector({
modelPath: '/wake/cosmo.onnx',
threshold: WAKE_THRESHOLD,
onWake: (s) => onWakeDetected(s),
onScore: (s) => {
if (s > maxScore) maxScore = s
scoreCount++
if (scoreCount % 25 === 0) {
console.log(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`)
maxScore = 0
}
if (s > 0.15) console.log(`[wake] score=${s.toFixed(3)}`)
},
onError: (e) => console.warn('[wake] error', e),
})
await wake.start()
wakeRef.current = wake
setState('listening')
// Прелоадим VAD в фоне — после первого wake реакция будет мгновенной,
// вместо +1-2с на загрузку Silero VAD.
initVAD().catch((e) => console.warn('[voice] VAD preload failed', e))
} catch (e: any) {
console.error('[wake] init failed:', e)
setState('error')
emitLocal('error', AGENT, `Wake: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
const stop = async () => {
try { vadRef.current?.pause?.() } catch {}
try { vadRef.current?.destroy?.() } catch {}
vadRef.current = null
try { await wakeRef.current?.stop?.() } catch {}
wakeRef.current = null
setState('idle')
emitLocal('idle', AGENT)
}
// Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
// Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
const onTap = async () => {
console.log(`[VoiceController] tap! state=${state}`)
if (state === 'idle' || state === 'error') {
await start()
} else if (state === 'listening') {
// ручной trigger — эмулируем wake-event
onWakeDetected(1.0)
} else {
await stop()
}
}
const onLongPress = async () => {
// Длинный тап всегда выключает (на случай если случайно зашли в плохое состояние)
await stop()
}
// primitive long-press detection
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressed = useRef(false)
const onPointerDown = () => {
longPressed.current = false
pressTimer.current = setTimeout(() => {
longPressed.current = true
onLongPress()
}, 700)
}
const onPointerUp = () => {
if (pressTimer.current) clearTimeout(pressTimer.current)
pressTimer.current = null
if (!longPressed.current) onTap()
}
const isActive = state === 'listening' || state === 'recording' || state === 'busy'
const isLoading = state === 'loading'
return (
<button
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => { if (pressTimer.current) clearTimeout(pressTimer.current); pressTimer.current = null }}
data-swipe-ignore
aria-label={isActive ? 'Выключить ассистента' : 'Активировать ассистента'}
title={isActive ? 'Скажи «Космо» · долгий тап = выкл' : 'Тап = активировать'}
style={{
position: 'fixed',
right: 24,
bottom: 24,
zIndex: 250,
width: 64,
height: 64,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: isActive
? 'linear-gradient(135deg, #7c3aed 0%, #a5b4fc 100%)'
: 'rgba(255,255,255,0.08)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)' as any,
boxShadow: isActive
? '0 0 32px rgba(124, 58, 237, 0.6), 0 8px 24px rgba(0,0,0,0.4)'
: '0 4px 12px rgba(0,0,0,0.3)',
color: isActive ? '#fff' : 'rgba(255,255,255,0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.25s, box-shadow 0.25s, transform 0.15s',
transform: isLoading ? 'scale(0.92)' : 'scale(1)',
}}
>
{isActive ? <Mic size={28} /> : <MicOff size={28} />}
</button>
)
}