feat(voice): wake-word «Космо» в браузере (Шаг 3)
All checks were successful
Deploy / deploy (push) Successful in 6m33s
All checks were successful
Deploy / deploy (push) Successful in 6m33s
openWakeWord pipeline на onnxruntime-web прямо на планшете. Цепочка: mic (16kHz, AudioWorklet) → melspectrogram.onnx → embedding_model.onnx (sliding 76-frame window, stride 8) → cosmo.onnx → score 0..1. Триггер при score≥0.5 → запускается тот же VAD-flow что и push-to-talk. - public/wake/ — cosmo.onnx (custom-trained на голос Даниила) + melspectrogram.onnx + embedding_model.onnx (~2.9MB вместе). - lib/wake-word.ts — WakeWordDetector class. ort грузится через <script src=/vad/ort.wasm.min.js> на клиенте — обход проблемы next-swc с парсингом import.meta.url в onnxruntime-web .mjs билдах. - VoiceController: тап = активация (нужен для AudioContext user-gesture), далее непрерывное слушание wake-word; на детект → MicVAD флоу. Долгий тап = выкл. Ручной тап остаётся как fallback. После деплоя Python-агент на .103 не нужен — можно архивировать home-voice-assistant. На .103 остаётся только ElevenLabs прокси :8888.
This commit is contained in:
@@ -1,21 +1,27 @@
|
||||
'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-слова.
|
||||
* 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'
|
||||
|
||||
type Agent = 'cosmo' | 'lusya'
|
||||
type ControllerState = 'idle' | 'loading' | 'active' | 'busy' | 'error'
|
||||
type ControllerState = 'idle' | 'loading' | 'listening' | 'recording' | 'busy' | 'error'
|
||||
|
||||
const AGENT: Agent = 'cosmo' // на этом этапе всегда Cosmo; Люся через wake-word на Шаге 3
|
||||
const AGENT: Agent = 'cosmo'
|
||||
const WAKE_THRESHOLD = 0.5
|
||||
|
||||
function emitLocal(event: string, agent: Agent, text?: string) {
|
||||
window.dispatchEvent(
|
||||
@@ -25,26 +31,23 @@ function emitLocal(event: string, agent: Agent, text?: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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(16, 16, true)
|
||||
view.setUint16(20, 1, true)
|
||||
view.setUint16(22, 1, true)
|
||||
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
|
||||
view.setUint32(28, sampleRate * 2, true)
|
||||
view.setUint16(32, 2, true)
|
||||
view.setUint16(34, 16, true)
|
||||
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]))
|
||||
@@ -59,23 +62,26 @@ function writeStr(view: DataView, offset: number, s: string) {
|
||||
|
||||
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)
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
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 // <0.4с — мусор/эхо
|
||||
if (audio.length < 16000 * 0.4) return
|
||||
busyRef.current = true
|
||||
setState('busy')
|
||||
emitLocal('listening', AGENT) // показываем что обрабатываем
|
||||
emitLocal('listening', AGENT)
|
||||
|
||||
try {
|
||||
const wav = floatToWav(audio, 16000)
|
||||
@@ -91,45 +97,25 @@ export default function VoiceController() {
|
||||
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))
|
||||
// После обработки — возвращаем wake-режим (если активен)
|
||||
try { wakeRef.current?.resume?.() } catch {}
|
||||
setState((s) => (s === 'busy' ? 'listening' : 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.
|
||||
// Создание VAD по запросу: либо после wake-детекта, либо после ручного тапа.
|
||||
const startVAD = async () => {
|
||||
try {
|
||||
const { MicVAD } = await import('@ricky0123/vad-web')
|
||||
const vad = await MicVAD.new({
|
||||
@@ -151,7 +137,6 @@ export default function VoiceController() {
|
||||
})
|
||||
vadRef.current = vad
|
||||
vad.start()
|
||||
setState('active')
|
||||
} catch (e: any) {
|
||||
console.error('[voice] VAD init failed:', e?.name, e?.message, e)
|
||||
setState('error')
|
||||
@@ -159,27 +144,110 @@ export default function VoiceController() {
|
||||
}
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
const onWakeDetected = async (score: number) => {
|
||||
console.log(`[wake] cosmo score=${score.toFixed(3)}`)
|
||||
if (busyRef.current) return
|
||||
// Пауза wake чтобы VAD-инициализация и команда не триггерили wake снова на эхе.
|
||||
try { wakeRef.current?.pause?.() } catch {}
|
||||
setState('recording')
|
||||
emitLocal('wake', AGENT)
|
||||
// Если VAD ещё не готов — создаём; иначе reset+start.
|
||||
if (!vadRef.current) {
|
||||
await startVAD()
|
||||
} else {
|
||||
try { vadRef.current.start?.() } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const start = async () => {
|
||||
if (state !== 'idle' && state !== 'error') return
|
||||
setState('loading')
|
||||
|
||||
// 1. Запрос разрешения на микрофон отдельно
|
||||
try {
|
||||
const probe = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
probe.getTracks().forEach((t) => t.stop())
|
||||
} catch (e: any) {
|
||||
console.error('[voice] mic permission failed:', e?.name, e?.message)
|
||||
setState('error')
|
||||
emitLocal('error', AGENT, e?.name === 'NotAllowedError' ? 'Нет доступа к микрофону' : 'Микрофон не открылся')
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Запуск wake-word
|
||||
try {
|
||||
const wake = new WakeWordDetector({
|
||||
modelPath: '/wake/cosmo.onnx',
|
||||
threshold: WAKE_THRESHOLD,
|
||||
onWake: (s) => onWakeDetected(s),
|
||||
// onScore: (s) => { if (s > 0.1) console.log('[wake] score', s.toFixed(3)) },
|
||||
onError: (e) => console.warn('[wake] error', e),
|
||||
})
|
||||
await wake.start()
|
||||
wakeRef.current = wake
|
||||
setState('listening')
|
||||
} catch (e: any) {
|
||||
console.error('[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)
|
||||
}
|
||||
|
||||
const onTap = () => {
|
||||
if (state === 'idle' || state === 'error') start()
|
||||
else stop()
|
||||
// Долгий тап = ручной триггер (как раньше push-to-talk). Короткий — toggle вкл/выкл.
|
||||
// Для простоты сейчас: короткий тап в idle = активация; короткий тап в active = выкл.
|
||||
const onTap = async () => {
|
||||
if (state === 'idle' || state === 'error') {
|
||||
await start()
|
||||
} else if (state === 'listening') {
|
||||
// ручной trigger — эмулируем wake-event
|
||||
onWakeDetected(1.0)
|
||||
} else {
|
||||
await stop()
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = state === 'active' || state === 'busy'
|
||||
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
|
||||
onClick={onTap}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={() => { if (pressTimer.current) clearTimeout(pressTimer.current); pressTimer.current = null }}
|
||||
data-swipe-ignore
|
||||
aria-label={isActive ? 'Выключить микрофон' : 'Включить микрофон'}
|
||||
aria-label={isActive ? 'Выключить ассистента' : 'Активировать ассистента'}
|
||||
title={isActive ? 'Скажи «Космо» · долгий тап = выкл' : 'Тап = активировать'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 24,
|
||||
|
||||
Reference in New Issue
Block a user