Files
smart-home-tablet/components/VoiceController.tsx
Cosmo 96bd846a08
All checks were successful
Deploy / deploy (push) Successful in 3m46s
fix(voice): ship non-asyncify ort-wasm + force single-thread
В public/vad/ были только asyncify-варианты, а onnxruntime-web по дефолту
просит ort-wasm-simd-threaded.{mjs,wasm} → 404 → MicVAD init falls.

- Положили ort-wasm-simd-threaded.{mjs,wasm} рядом.
- ortConfig forces numThreads=1, чтобы не требовать SharedArrayBuffer
  (нет COOP/COEP headers и не хотим их вешать на весь сайт).
- Раздельный getUserMedia probe перед VAD init, чтобы отличить отказ
  по микрофону от ошибки VAD/wasm в UI-сообщении.
2026-04-27 09:01:52 +00:00

213 lines
7.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')
// 1. Сначала отдельно проверяем mic — иначе ошибка VAD маскирует разрешения.
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// VAD откроет свой stream; этот закрываем, мы только проверяли разрешение.
stream.getTracks().forEach((t) => t.stop())
} catch (e: any) {
console.error('[voice] mic permission failed:', e?.name, e?.message)
setState('error')
const reason = e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону'
: e?.name === 'NotFoundError' ? 'Микрофон не найден'
: 'Микрофон не открылся'
emitLocal('error', AGENT, reason)
return
}
// 2. Инициализируем VAD. Single-threaded WASM — без COOP/COEP headers
// threaded режим всё равно не заработает, плюс не нужна SharedArrayBuffer.
try {
const { MicVAD } = await import('@ricky0123/vad-web')
const vad = await MicVAD.new({
model: 'v5',
baseAssetPath: '/vad/',
onnxWASMBasePath: '/vad/',
ortConfig: (ort: any) => {
ort.env.wasm.numThreads = 1
ort.env.wasm.simd = true
},
positiveSpeechThreshold: 0.6,
negativeSpeechThreshold: 0.45,
minSpeechMs: 160,
redemptionMs: 750,
onSpeechStart: () => {
emitLocal('wake', AGENT)
},
onSpeechEnd: handleSpeechEnd,
})
vadRef.current = vad
vad.start()
setState('active')
} catch (e: any) {
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
setState('error')
emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
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>
)
}