Files
smart-home-tablet/components/VoiceController.tsx
Cosmo 93bf34f216
All checks were successful
Deploy / deploy (push) Successful in 6m53s
feat(voice): push-to-talk button — браузерный mic+VAD pipeline
Шаг 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-агент уйдёт целиком.
2026-04-27 08:48:22 +00:00

192 lines
6.6 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'
/**
* 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>
)
}