feat(voice): push-to-talk button — браузерный mic+VAD pipeline
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:
Cosmo
2026-04-27 08:48:22 +00:00
parent eeac2eefb3
commit 93bf34f216
10 changed files with 509 additions and 51 deletions

View File

@@ -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
}, [])