'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('idle') const vadRef = useRef(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 ( ) }