Files
smart-home-tablet/components/VoiceController.tsx
Cosmo d30ed1bac1
All checks were successful
Deploy / deploy (push) Successful in 1m37s
feat: follow-up dialog window — VAD stays active 8s after agent response
2026-05-01 13:47:51 +00:00

347 lines
14 KiB
TypeScript
Raw Permalink 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'
/**
* Голосовой контроллер.
*
* UX:
* - Idle: кнопка-микрофон (перечёркнут). Тап = «активировать ассистента» (нужен
* user gesture чтобы AudioContext стартанул).
* - Active: загружаются wake-модели (один раз) → запускается wake-word listener
* на постоянный фон. Кнопка горит фиолетовым, говорит «Космо».
* - Wake-word triggered → MicVAD стартует → onSpeechEnd → STT → chat → TTS.
* - Параллельно тап на кнопку = ручной trigger (как раньше) если wake не
* срабатывает или wake тренировка ещё слабая.
* - Tap во время Active → выключает wake и mic полностью.
*/
import { useEffect, useRef, useState } from 'react'
import { Mic, MicOff } from 'lucide-react'
import { WakeWordDetector } from '@/lib/wake-word'
import { floatToWav } from '@/lib/audio-wav'
import { vlog, vwarn, verror } from '@/lib/debug'
type Agent = 'cosmo' | 'lusya'
type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error'
const AGENT: Agent = 'cosmo'
const WAKE_THRESHOLD = 0.5
function emitLocal(event: string, agent: Agent, text?: string) {
window.dispatchEvent(
new CustomEvent('voice-local', {
detail: { event, agent, text, timestamp: new Date().toISOString() },
}),
)
}
export default function VoiceController() {
const [state, setState] = useState<ControllerState>('idle')
const wakeRef = useRef<WakeWordDetector | null>(null)
const vadRef = useRef<any>(null)
const busyRef = useRef(false)
const followUpRef = useRef(false)
const followUpTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
vlog('[VoiceController] mounted, state=idle, ждём тап на микрофон')
// Кнопка X в overlay шлёт voice-cancel → ставим VAD на паузу
// (НЕ destroy — иначе следующий wake снова будет ждать 1-2с на инициализацию).
const onCancel = () => {
vlog('[voice] cancel — пауза VAD')
followUpRef.current = false
if (followUpTimerRef.current) { clearTimeout(followUpTimerRef.current); followUpTimerRef.current = null }
try { vadRef.current?.pause?.() } catch {}
busyRef.current = false
try { wakeRef.current?.resume?.() } catch {}
setState((s) => (wakeRef.current ? 'listening' : 'idle'))
emitLocal('idle', AGENT)
}
window.addEventListener('voice-cancel', onCancel)
const onFollowUp = () => {
if (!wakeRef.current) return // ассистент выключен
vlog('[voice] follow-up window открыто (8s)')
followUpRef.current = true
// Очистить предыдущий таймер если был
if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current)
// Запустить VAD сразу без wake word
if (vadRef.current) {
try { vadRef.current.start() } catch {}
}
setState('recording')
emitLocal('wake', AGENT)
// Через 8 секунд — если никто не заговорил — вернуться к wake
followUpTimerRef.current = setTimeout(() => {
if (followUpRef.current) {
followUpRef.current = false
vlog('[voice] follow-up timeout — возврат к wake word')
try { vadRef.current?.pause?.() } catch {}
try { wakeRef.current?.resume?.() } catch {}
setState('listening')
emitLocal('idle', AGENT)
}
}, 8000)
}
window.addEventListener('voice-follow-up', onFollowUp)
return () => {
window.removeEventListener('voice-cancel', onCancel)
window.removeEventListener('voice-follow-up', onFollowUp)
if (followUpTimerRef.current) clearTimeout(followUpTimerRef.current)
try { vadRef.current?.destroy?.() } catch {}
try { wakeRef.current?.stop?.() } catch {}
vadRef.current = null
wakeRef.current = null
}
}, [])
// Обрабатываем результат VAD-захвата фразы и шлём по pipeline.
const handleSpeechEnd = async (audio: Float32Array) => {
if (busyRef.current) return
if (audio.length < 16000 * 0.4) return
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
}
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}`)
} catch (e) {
verror('[voice] pipeline error:', e)
emitLocal('error', AGENT, 'Не получилось')
} finally {
busyRef.current = false
// Если активен follow-up режим — НЕ переключаемся на wake, ответ сам откроет новое окно
if (!followUpRef.current) {
try { vadRef.current?.pause?.() } catch {}
try { wakeRef.current?.resume?.() } catch {}
setState((s) => (s === 'busy' ? 'listening' : s))
}
// Сбросить follow-up флаг — новое окно откроется после TTS
followUpRef.current = false
if (followUpTimerRef.current) {
clearTimeout(followUpTimerRef.current)
followUpTimerRef.current = null
}
}
}
// Однократная инициализация VAD. Создаётся в paused-состоянии и переиспользуется
// на каждый wake — без этого пауза до первой записи ~1-2с.
const initVAD = async () => {
if (vadRef.current) return
try {
const { MicVAD } = await import('@ricky0123/vad-web')
// Подавить VAD | debug логи из библиотеки
const _origDebug = (console as any)._vadOrig || console.debug
;(console as any)._vadOrig = _origDebug
console.debug = (...args: any[]) => {
if (typeof args[0] === 'string' && (args[0].startsWith('VAD |') || args[0].startsWith('using default audio'))) return
_origDebug.apply(console, args)
}
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
// Не вызываем start — ждём пока wake-word триггернёт.
vlog('[voice] VAD preloaded (paused)')
} catch (e: any) {
verror('[voice] VAD init failed:', e?.name, e?.message, e)
// Не вырубаем wake — может на ручной trigger ещё попробуем
emitLocal('error', AGENT, `VAD: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
const onWakeDetected = async (score: number) => {
vlog(`[wake] cosmo score=${score.toFixed(3)}`)
if (busyRef.current) return
// Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе.
try { wakeRef.current?.pause?.() } catch {}
setState('recording')
emitLocal('wake', AGENT)
// VAD должен быть уже preloaded — мгновенный старт.
if (!vadRef.current) await initVAD()
try { vadRef.current?.start?.() } catch {}
}
const start = async () => {
if (state !== 'idle' && state !== 'error') return
setState('loading')
// 0. «Audio unlock» — iOS Safari / Android Chrome не дают воспроизводить
// звук без user-gesture. Wake-word срабатывает сам, поэтому позже TTS
// тихо отвергнется. Создаём общий AudioContext прямо сейчас (тап = gesture)
// и сохраняем в window — VoiceOverlay будет играть через него.
try {
const w = window as any
if (!w.__voicePlaybackCtx) {
const Ctx = w.AudioContext || w.webkitAudioContext
if (Ctx) w.__voicePlaybackCtx = new Ctx()
}
const ctx: AudioContext | undefined = w.__voicePlaybackCtx
if (ctx && ctx.state === 'suspended') await ctx.resume()
vlog('[voice] playback AudioContext state=', ctx?.state)
} catch (e: any) {
vwarn('[voice] AudioContext init failed:', e?.message)
}
// 1. Запрос разрешения на микрофон отдельно
try {
const probe = await navigator.mediaDevices.getUserMedia({ audio: true })
probe.getTracks().forEach((t) => t.stop())
} catch (e: any) {
verror('[voice] mic permission failed:', e?.name, e?.message)
setState('error')
emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся')
return
}
// 2. Запуск wake-word
try {
// Логируем периодически max-score и просто что pipeline жив, чтобы было
// видно, что инференс идёт.
let maxScore = 0
let scoreCount = 0
const wake = new WakeWordDetector({
modelPath: '/wake/cosmo.onnx',
threshold: WAKE_THRESHOLD,
onWake: (s) => onWakeDetected(s),
onScore: (s) => {
if (s > maxScore) maxScore = s
scoreCount++
if (scoreCount % 25 === 0) {
vlog(`[wake] alive · max score за окно=${maxScore.toFixed(3)} · scoreCount=${scoreCount}`)
maxScore = 0
}
if (s > 0.15) vlog(`[wake] score=${s.toFixed(3)}`)
},
onError: (e) => vwarn('[wake] error', e),
})
await wake.start()
wakeRef.current = wake
setState('listening')
// VAD НЕ прелоадим — его второй getUserMedia мешает wake-word audio.
// Грузится при первом wake (~1-2с), но дальше переиспользуется (см. handleSpeechEnd).
} catch (e: any) {
verror('[wake] init failed:', e)
setState('error')
emitLocal('error', AGENT, `Wake: ${e?.message?.slice(0, 60) || 'init'}`)
}
}
const stop = async () => {
try { vadRef.current?.pause?.() } catch {}
try { vadRef.current?.destroy?.() } catch {}
vadRef.current = null
try { await wakeRef.current?.stop?.() } catch {}
wakeRef.current = null
setState('idle')
emitLocal('idle', AGENT)
}
// Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
// Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
const onTap = async () => {
vlog(`[VoiceController] tap! state=${state}`)
if (state === 'idle' || state === 'error') {
await start()
} else if (state === 'listening') {
// ручной trigger — эмулируем wake-event
onWakeDetected(1.0)
} else {
await stop()
}
}
const onLongPress = async () => {
// Длинный тап всегда выключает (на случай если случайно зашли в плохое состояние)
await stop()
}
// primitive long-press detection
const pressTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressed = useRef(false)
const onPointerDown = () => {
longPressed.current = false
pressTimer.current = setTimeout(() => {
longPressed.current = true
onLongPress()
}, 700)
}
const onPointerUp = () => {
if (pressTimer.current) clearTimeout(pressTimer.current)
pressTimer.current = null
if (!longPressed.current) onTap()
}
const isActive = state === 'listening' || state === 'recording' || state === 'busy'
const isLoading = state === 'loading'
return (
<button
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={() => { if (pressTimer.current) clearTimeout(pressTimer.current); pressTimer.current = null }}
data-swipe-ignore
aria-label={isActive ? 'Выключить ассистента' : 'Активировать ассистента'}
title={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>
)
}