feat(voice): wake-word «Космо» в браузере (Шаг 3)
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:
Cosmo
2026-04-27 09:43:53 +00:00
parent 96bd846a08
commit 522d36d1a2
10 changed files with 1567 additions and 53 deletions

View File

@@ -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,