Безопасность:
- Rate-limit на /api/voice/chat (20/мин per cookie/IP, env VOICE_RATE_LIMIT).
Защищает от случайных циклов и утечки PIN.
- Усечение user prompt'а до 4000 символов в /api/voice/chat.
- Tool-loop защита от циклов: если LLM дважды просит тот же tool с теми же
args — прерываем (раньше мог уйти в бесконечный цикл при tool error'ах).
Чистка кода:
- lib/debug.ts — vlog/vwarn/verror гейтят браузерные логи за
NEXT_PUBLIC_VOICE_DEBUG=1 (или localStorage 'voice-debug=1').
Серверные console.log оставлены — полезны в Docker logs.
- lib/audio-wav.ts — вынесена дублированная floatToWav из VoiceController.
- Удалены orphan компоненты FocusCard.tsx и CountdownCard.tsx
(не подключены, отвергнуты по UX-фидбеку).
Resilience:
- WakeWordDetector: drop-on-busy в onChunk — на медленных устройствах
(Android, бюджетный CPU) backlog inference больше не копится.
- voice-history fallback на /tmp/voice-history если /data не примонтирован
(локальная разработка / нестандартная конфигурация).
iOS Safari блокирует <audio>.play() даже после silent-WAV unlock —
каждый new Audio() считается новым элементом без gesture.
Решение: при тапе кнопки в VoiceController создаём общий
AudioContext (под user-gesture) и пробуждаем его. VoiceOverlay
теперь играет TTS через этот ctx (decodeAudioData + BufferSource).
HTMLAudioElement остаётся fallback'ом если ctx недоступен.
decodeAudioData в Safari исторически callback, в Chrome — Promise:
используем оба варианта.
Android Chrome требует user-gesture для <audio>.play(). Wake-word
триггерит TTS «сам», без тапа, поэтому play() тихо отвергался.
При тапе на кнопку микрофона теперь проигрываем 1мс silent WAV →
браузер помечает страницу как разрешённую для autoplay в текущей
сессии. Дальше TTS-ответы Cosmo/Lusya играют без проблем.
В VoiceOverlay логируем причину если play() всё ещё отвергнут.
Параллельный getUserMedia от MicVAD конфликтует со stream'ом wake-word —
видимо Chrome применяет AGC/NS по-разному и wake получает «глухое» аудио.
Score упал с 0.988 до 0.093 — wake перестал срабатывать.
Возвращаемся: VAD создаётся ПОСЛЕ первого wake (~1-2с пауза),
но cancel/onSpeechEnd теперь только pause (не destroy), так что
повторные wake мгновенные.
В логах было видно: между wake-trigger и реальным VAD recording
проходило 1-2с (Loading VAD... → finished loading → started micVAD).
Каждый cancel дополнительно destroy'ил VAD, и следующий wake снова
ждал инициализацию.
Теперь:
- VAD создаётся один раз в paused-режиме сразу после wake.start()
(в фоне, не блокирует UI).
- На каждый wake → vad.start() мгновенно.
- onSpeechEnd → vad.pause() (был implicit pause; явно ставим).
- voice-cancel → vad.pause(), а не destroy. Wake продолжает слушать.
- destroy только при полном выключении ассистента.
В overlay появляется крестик в правом верхнем углу. Тап = эмитит
voice-cancel → VoiceController прерывает активный VAD-захват и сам
overlay закрывается. Wake-word, если был активен, продолжает слушать
в фоне.
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.
В 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-сообщении.
Шаг 2 миграции: убираем зависимость от Python-агента для базового
голосового сценария. Тап на круглую кнопку-микрофон в правом нижнем
углу → MicVAD (Silero v5) ловит речь → автостоп по тишине → /api/voice/stt
→ /api/voice/chat → ответ через SSE и TTS как раньше.
- components/VoiceController.tsx — push-to-talk UI + MicVAD orchestration
- VoiceOverlay теперь слушает window CustomEvent('voice-local'), чтобы
орб моргал ещё до round-trip на сервер (wake/listening мгновенно).
- public/vad/ — silero v5/legacy onnx + ort wasm + audio worklet,
раздаются через baseAssetPath: '/vad/' (не зависит от внешнего CDN,
важно если планшет без интернета или с RU-блоком).
Что осталось от home-voice-assistant: только wake-word. После Шага 3
(onnxruntime-web + перенос openwakeword .onnx) Python-агент уйдёт целиком.