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:
191
components/VoiceController.tsx
Normal file
191
components/VoiceController.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Push-to-talk кнопка-микрофон. Тап → MicVAD ловит речь → автостоп по тишине →
|
||||
* /api/voice/stt → /api/voice/chat. Отправляет локальные voice-local события
|
||||
* для VoiceOverlay (wake/listening/command), финальный response приходит
|
||||
* через SSE с сервера.
|
||||
*
|
||||
* Когда добавим wake-word (Шаг 3) — этот же код переиспользуется, только
|
||||
* стартовать VAD будет автоматически по детекту wake-слова.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Mic, MicOff } from 'lucide-react'
|
||||
|
||||
type Agent = 'cosmo' | 'lusya'
|
||||
type ControllerState = 'idle' | 'loading' | 'active' | 'busy' | 'error'
|
||||
|
||||
const AGENT: Agent = 'cosmo' // на этом этапе всегда Cosmo; Люся через wake-word на Шаге 3
|
||||
|
||||
function emitLocal(event: string, agent: Agent, text?: string) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('voice-local', {
|
||||
detail: { event, agent, text, timestamp: new Date().toISOString() },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Float32Array @ 16kHz → WAV blob (mono, 16-bit PCM).
|
||||
function floatToWav(audio: Float32Array, sampleRate = 16000): Blob {
|
||||
const numSamples = audio.length
|
||||
const buffer = new ArrayBuffer(44 + numSamples * 2)
|
||||
const view = new DataView(buffer)
|
||||
// RIFF header
|
||||
writeStr(view, 0, 'RIFF')
|
||||
view.setUint32(4, 36 + numSamples * 2, true)
|
||||
writeStr(view, 8, 'WAVE')
|
||||
writeStr(view, 12, 'fmt ')
|
||||
view.setUint32(16, 16, true) // fmt chunk size
|
||||
view.setUint16(20, 1, true) // PCM
|
||||
view.setUint16(22, 1, true) // channels
|
||||
view.setUint32(24, sampleRate, true)
|
||||
view.setUint32(28, sampleRate * 2, true) // byte rate
|
||||
view.setUint16(32, 2, true) // block align
|
||||
view.setUint16(34, 16, true) // bits per sample
|
||||
writeStr(view, 36, 'data')
|
||||
view.setUint32(40, numSamples * 2, true)
|
||||
// PCM samples
|
||||
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 vadRef = useRef<any>(null)
|
||||
const busyRef = useRef(false)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try { vadRef.current?.destroy?.() } catch {}
|
||||
vadRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSpeechEnd = async (audio: Float32Array) => {
|
||||
if (busyRef.current) return
|
||||
if (audio.length < 16000 * 0.4) return // <0.4с — мусор/эхо
|
||||
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
|
||||
}
|
||||
|
||||
// Chat-эндпоинт сам эмитит command/response через voice-bus → SSE → orb.
|
||||
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}`)
|
||||
// Ответ уже зачитывается через SSE — здесь больше делать нечего.
|
||||
} catch (e) {
|
||||
console.error('[voice] pipeline error:', e)
|
||||
emitLocal('error', AGENT, 'Не получилось')
|
||||
} finally {
|
||||
busyRef.current = false
|
||||
setState((s) => (s === 'busy' ? 'active' : s))
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
if (state !== 'idle' && state !== 'error') return
|
||||
setState('loading')
|
||||
try {
|
||||
const { MicVAD } = await import('@ricky0123/vad-web')
|
||||
const vad = await MicVAD.new({
|
||||
model: 'v5',
|
||||
baseAssetPath: '/vad/',
|
||||
onnxWASMBasePath: '/vad/',
|
||||
// sensitivity tuned conservatively — тише = меньше ложных срабатываний эха
|
||||
positiveSpeechThreshold: 0.6,
|
||||
negativeSpeechThreshold: 0.45,
|
||||
minSpeechMs: 160, // ~5 фреймов по 32мс
|
||||
redemptionMs: 750, // тишина перед onSpeechEnd
|
||||
onSpeechStart: () => {
|
||||
emitLocal('wake', AGENT)
|
||||
},
|
||||
onSpeechEnd: handleSpeechEnd,
|
||||
})
|
||||
vadRef.current = vad
|
||||
vad.start()
|
||||
setState('active')
|
||||
} catch (e) {
|
||||
console.error('[voice] VAD init failed:', e)
|
||||
setState('error')
|
||||
emitLocal('error', AGENT, 'Микрофон недоступен')
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
try { vadRef.current?.pause?.() } catch {}
|
||||
try { vadRef.current?.destroy?.() } catch {}
|
||||
vadRef.current = null
|
||||
setState('idle')
|
||||
emitLocal('idle', AGENT)
|
||||
}
|
||||
|
||||
const onTap = () => {
|
||||
if (state === 'idle' || state === 'error') start()
|
||||
else stop()
|
||||
}
|
||||
|
||||
const isActive = state === 'active' || state === 'busy'
|
||||
const isLoading = state === 'loading'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onTap}
|
||||
data-swipe-ignore
|
||||
aria-label={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>
|
||||
)
|
||||
}
|
||||
@@ -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