feat(voice): push-to-talk button — браузерный mic+VAD pipeline
All checks were successful
Deploy / deploy (push) Successful in 6m53s
All checks were successful
Deploy / deploy (push) Successful in 6m53s
Шаг 2 миграции: убираем зависимость от Python-агента для базового
голосового сценария. Тап на круглую кнопку-микрофон в правом нижнем
углу → MicVAD (Silero v5) ловит речь → автостоп по тишине → /api/voice/stt
→ /api/voice/chat → ответ через SSE и TTS как раньше.
- components/VoiceController.tsx — push-to-talk UI + MicVAD orchestration
- VoiceOverlay теперь слушает window CustomEvent('voice-local'), чтобы
орб моргал ещё до round-trip на сервер (wake/listening мгновенно).
- public/vad/ — silero v5/legacy onnx + ort wasm + audio worklet,
раздаются через baseAssetPath: '/vad/' (не зависит от внешнего CDN,
важно если планшет без интернета или с RU-блоком).
Что осталось от home-voice-assistant: только wake-word. После Шага 3
(onnxruntime-web + перенос openwakeword .onnx) Python-агент уйдёт целиком.
This commit is contained in:
@@ -105,61 +105,56 @@ export default function VoiceOverlay() {
|
||||
let retry: ReturnType<typeof setTimeout> | null = null
|
||||
let closedByUs = false
|
||||
|
||||
// Единый обработчик для SSE и локальных CustomEvent от VoiceController.
|
||||
const handleEvent = (evt: VoiceEvent) => {
|
||||
const currentAgent: Agent = evt.agent ?? agent
|
||||
if (evt.agent) setAgent(evt.agent)
|
||||
|
||||
// Safety: если событийный поток заглохнет, через 60с сами закроемся.
|
||||
const armSafety = () => scheduleDismiss(60_000)
|
||||
|
||||
if (evt.event === 'wake') {
|
||||
stopAudio()
|
||||
setState('wake')
|
||||
setText('')
|
||||
armSafety()
|
||||
} else if (evt.event === 'listening') {
|
||||
setState('listening')
|
||||
armSafety()
|
||||
} else if (evt.event === 'command') {
|
||||
setState('command')
|
||||
setText(evt.text || '')
|
||||
armSafety()
|
||||
} else if (evt.event === 'response') {
|
||||
setState('response')
|
||||
setText(evt.text || '')
|
||||
clearDismiss()
|
||||
if (evt.text) {
|
||||
playTTS(evt.text, currentAgent, () => scheduleDismiss(4000))
|
||||
} else {
|
||||
scheduleDismiss(4000)
|
||||
}
|
||||
} else if (evt.event === 'error') {
|
||||
setState('error')
|
||||
setText(evt.text || 'Ошибка')
|
||||
clearDismiss()
|
||||
if (evt.text) {
|
||||
playTTS(evt.text, currentAgent, () => scheduleDismiss(3000))
|
||||
} else {
|
||||
scheduleDismiss(3000)
|
||||
}
|
||||
} else if (evt.event === 'idle') {
|
||||
clearDismiss()
|
||||
stopAudio()
|
||||
setState('idle')
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
es = new EventSource('/api/voice/stream')
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const evt: VoiceEvent = JSON.parse(e.data)
|
||||
const currentAgent: Agent = evt.agent ?? agent
|
||||
if (evt.agent) setAgent(evt.agent)
|
||||
|
||||
// Safety: если Python упадёт и не пришлёт idle, через 60с сами закроемся.
|
||||
const armSafety = () => scheduleDismiss(60_000)
|
||||
|
||||
if (evt.event === 'wake') {
|
||||
// Свежая активация: barge-in аудио, чистим текст.
|
||||
stopAudio()
|
||||
setState('wake')
|
||||
setText('')
|
||||
armSafety()
|
||||
} else if (evt.event === 'listening') {
|
||||
// Follow-up: сохраняем последний текст, орб мягко пульсирует.
|
||||
setState('listening')
|
||||
armSafety()
|
||||
} else if (evt.event === 'command') {
|
||||
setState('command')
|
||||
setText(evt.text || '')
|
||||
armSafety()
|
||||
} else if (evt.event === 'response') {
|
||||
setState('response')
|
||||
setText(evt.text || '')
|
||||
// Dismiss ТОЛЬКО когда аудио доиграло (не по таймеру!).
|
||||
// После окончания даём Python шанс прислать listening/wake/command — тогда
|
||||
// scheduleDismiss перезатрётся. Иначе через 4с автозакрытие.
|
||||
clearDismiss()
|
||||
if (evt.text) {
|
||||
playTTS(evt.text, currentAgent, () => scheduleDismiss(4000))
|
||||
} else {
|
||||
scheduleDismiss(4000)
|
||||
}
|
||||
} else if (evt.event === 'error') {
|
||||
setState('error')
|
||||
setText(evt.text || 'Ошибка')
|
||||
clearDismiss()
|
||||
if (evt.text) {
|
||||
playTTS(evt.text, currentAgent, () => scheduleDismiss(3000))
|
||||
} else {
|
||||
scheduleDismiss(3000)
|
||||
}
|
||||
} else if (evt.event === 'idle') {
|
||||
clearDismiss()
|
||||
stopAudio()
|
||||
setState('idle')
|
||||
}
|
||||
} catch {}
|
||||
try { handleEvent(JSON.parse(e.data) as VoiceEvent) } catch {}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
if (closedByUs) return
|
||||
es?.close()
|
||||
@@ -169,12 +164,20 @@ export default function VoiceOverlay() {
|
||||
|
||||
connect()
|
||||
|
||||
// Локальные события (push-to-talk до round-trip на сервер).
|
||||
const onLocal = (e: Event) => {
|
||||
const detail = (e as CustomEvent<VoiceEvent>).detail
|
||||
if (detail) handleEvent(detail)
|
||||
}
|
||||
window.addEventListener('voice-local', onLocal as EventListener)
|
||||
|
||||
return () => {
|
||||
closedByUs = true
|
||||
clearDismiss()
|
||||
stopAudio()
|
||||
if (retry) clearTimeout(retry)
|
||||
es?.close()
|
||||
window.removeEventListener('voice-local', onLocal as EventListener)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
Reference in New Issue
Block a user