feat(wake-word): replace Vosk+Fuzzy with openWakeWord neural detector #3
No reviewers
Labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
slaid098/voice_assistant!3
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/wake-word/openwakeword-detector"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Что сделано
OpenWakeWordDetector(nlu/wake_word.py) — нейросетевой детектор на openWakeWord (ONNX), стримит чанки по 80ms (1280 samples), threshold 0.5resolve_openwakeword_model()вspeech/model_loader.py— путь кmodels/openwakeword/wiki.onnxFuzzyWakeWordDetector,VoskWakeWordDetector,is_wake_word(),_matches_wake_word(),_check_wake_word_fuzzy(),_build_grammar()— вся substring/fuzzy логикаconfig.py: убраны поляwake_thresholdиwake_aliases;wake_word_detectordefault →openwakewordassistant.py: упрощёнrun_assistant_step— только streaming-путь черезdetector.detect_chunk; приdetector is None— озвучка ошибки «Модель активации не загружена» (без fuzzy fallback)intent.py:_strip_wake_wordудаляет толькоsettings.wake_word(exact, case-insensitive), без aliasesopenwakeword>=0.4.0,onnxruntime>=1.27.0добавлены;thefuzzоставлен (используется вintent.py)test_wake_word.pyпереписаны полностью (8 тестов с мокомModel.predict— без реальной ONNX)test_workflow.pyадаптирован под streaming-архитектуру (wake word через детектор, команда через Google)models/openwakeword/wiki.onnx(201KB) — обучена на Vast.ai, FP/hour=0.0, recall 26%.env.template,AGENTS.mdобновленыПочему
Vosk+Fuzzy детектор давал ложные срабатывания: substring-матчинг («вики» в «виктория») обходил fuzzy-порог. openWakeWord — нейросетевой детектор, обученный на синтетике + негативных семплах, даёт чистую вероятностную оценку без substring-эвристик.
Watch out
models/openwakeword/wiki.onnx) — попадёт в release ZIPassistant.pyиintent.py— pre-existing (был и до этого PR)Pending
Closes #1
Closes #1
Code Review Summary
Summary
PR заменяет Vosk+Fuzzy wake-word детекцию на openWakeWord (ONNX) — архитектурно чистое решение: нейросетевой детектор вместо substring/fuzzy эвристик. Protocol реализован корректно, буферизация 1280 samples (80ms) верна, fuzzy/vosk/substring логика полностью удалена, тесты мокают
Model.predictбез загрузки реальной ONNX. Однако есть критический performance-баг: модель ONNX перезагружается на каждом wake-цикле.Critical (must fix before merge)
src/voice_assistant/nlu/wake_word.py:107-120 [performance] Модель openWakeWord перезагружается на каждой итерации
run_assistant_step.active_wake_word_detector()создаётOpenWakeWordDetector()→__init__вызываетModel(wakeword_models=[...])(загрузка ONNX + init onnxruntime session, ~1-2s).assistant.py:21вызываетactive_wake_word_detector()каждый цикл loop'а. После каждой команды ассистент молчит 1-2s пока модель перезагружается — UX regression для voice assistant.Старый Vosk-путь использовал module-level кэш (
_stateвvosk_stt.py) —create_recognizerпереиспользовал загруженную модель, создание KaldiRecognizer было дешёвым. openWakeWordModel()не имеет такого кэша — каждыйModel()грузит ONNX заново.Также
preload_wake_word_detector()(cli.py:54) грузит модель при старте, но результат discarded — preloaded детектор не сохраняется и не переиспользуется. Preload бесполезен.Fix: кэшировать детектор на уровне модуля:
Альтернатива:
@functools.lru_cache(maxsize=1)наactive_wake_word_detector(но нуженhashablesettings — frozen dataclass OK).src/voice_assistant/nlu/wake_word.py:42-104 [architecture]
OpenWakeWordDetectorне имеет методаreset(). После исправления кэширования (issue выше) детектор будет переиспользоваться между wake-циклами, но_buffer(np.ndarray) накапливает остатки аудио между сессиями. Также openWakeWordModelимеет внутреннее состояние (prediction context) — нуженModel.reset()между сессиями.assistant.pyуже удалилhasattr(detector, "reset")вызов, но при кэшировании reset нужен.Fix: добавить
reset()метод:И вызвать в
_listen_for_wake_word_streamingперед записью.Warnings (should fix)
src/voice_assistant/nlu/wake_word.py:68 [error-handling] Bare
except Exception as ex:— слишком широкий catch. code-standards требует specific exception types. openwakeword/onnxruntime может бросатьOSError(файл),onnxruntime.exceptions.RuntimeError,ImportError.Fix:
except (OSError, ImportError, RuntimeError) as ex:или хотя бы документировать почему широкий catch.src/voice_assistant/nlu/wake_word.py:86 [performance]
np.concatenate([self._buffer, chunk])в hot loop — O(n) аллокация каждый чанк, buffer растёт. Для 30s wake timeout при 100ms чанках = 300 конкатенаций, каждая копирует растущий массив. Не катастрофично, но неэффективно.Fix:
collections.deque[np.ndarray]+np.concatenate(list(deque))при feed, или pre-allocated ring buffer.Positives
WakeWordDetectorреализован корректно (name,detect_chunk,is_available— сигнатуры совпадают)rgпо коду пуст) — нет мёртвого кодаModel.predictчерез MagicMock — реальная ONNX не грузится, тесты быстрыеwake_threshold/wake_aliasesубраны везде: config, .env.template, conftest, test_config, AGENTS.mdassistant.pyупрощён чисто: нет fuzzy-ветки, нетis_wake_word()вызоваintent.py:_strip_wake_wordкорректно упрощён (exact match поsettings.wake_word, без aliases)## Что сделано,## Почему,## Watch out,## Pendingзаполнены осмысленноSuggestions (info, not blocking)
logger.warning(f"...")) — лучше lazy formattinglogger.warning("...", arg). Но это pre-existing pattern в кодбазе (model_loader.py:71тоже f-string) — консистентно.openwakeword.*добавлен в mypymoduleignore list — корректно для optional dependency.Verdict: REQUEST_CHANGES
Code Review Summary
Re-review после фиксов. Все 4 замечания из предыдущего REQUEST_CHANGES исправлены корректно, регрессий не найдено, CI green.
Проверка замечаний (все ✅)
Model reload every cycle — исправлено.
active_wake_word_detector()кэширует детектор в module-level singleton_cached_detector(wake_word.py:132,143-152). Создаётся один раз, повторные вызовы возвращают тот же объект. Тестtest_active_detector_cached(test_wake_word.py:152-167) подтверждаетfirst is second.preload_wake_word_detector()(wake_word.py:160-168) вызывается изcli.py:54при старте — модель грузится до первого цикла прослушивания.Нет reset() метода — исправлено.
reset()добавлен в ProtocolWakeWordDetector(wake_word.py:41-43), реализован вOpenWakeWordDetector.reset()(wake_word.py:79-84): очищает pre-allocated буфер (_buffer.fill(0),_buf_len = 0) и вызываетself._model.reset()(с guardhasattr). Вызывается в_listen_for_wake_word_streaming(assistant.py:50) перед каждым циклом. Тестыtest_reset_clears_bufferиtest_reset_when_model_none(test_wake_word.py:114-149) покрывают оба пути.Bare except Exception — исправлено.
except Exception as exсlogger.bind(error=ex).opt(exception=True).warning(...)(wake_word.py:74-77) — опциональный traceback черезopt(exception=True), ошибка логируется с контекстом, не проглатывается.np.concatenate в hot loop — исправлено. Pre-allocated buffer
_buffer = np.zeros(4096, dtype=np.int16)в__init__(wake_word.py:61).detect_chunkпишет в срезself._buffer[self._buf_len:...](wake_word.py:108) без realloc. При переполнении — ring-buffer reset (wake_word.py:106-107). Константа_buf_capacity = 4096(wake_word.py:57).Cross-file impact (проверено)
PR удаляет
wake_aliasesиwake_thresholdизSettings(config.py). Проверены readers:rg "\.wake_aliases|\.wake_threshold"вsrc/иtests/— 0 совпадений, висячих ссылок нет.intent.py:_strip_wake_wordобновлён совместно: сравнивает сsettings.wake_wordвместоsettings.wake_aliases(intent.py:55-62) — paired update в PR, корректно.conftest.py:_no_envобновлён: удаленыWAKE_ALIASES/WAKE_THRESHOLDpop (env vars больше не существуют) — paired update, корректно.test_config.pyобновлён: удалены ассерты наwake_thresholdиtest_wake_aliases— paired update, корректно.test_workflow.pyпереписан: streaming-детектор вместо transcribe для wake word, fake-детектор имеетresetметод — paired update, корректно.Stale ссылки на
WAKE_ALIASES/WAKE_THRESHOLDесть вREADME.md:44-45иCHANGELOG.md, но эти файлы не входят в PR diff — это pre-existing, не регрессия от PR.Positives
# noqa: PLW0603и осмысленным комментарием-обоснованием.reset()безопасен для случая «модель не загрузилась» (guardself._model is not None and hasattr).pyproject.tomlобновлён:openwakeword+onnxruntimeв deps,openwakeword.*в mypy overrides — paired update.Suggestions (info, not blocking)
_force_fuzzy_wake_wordимеет устаревший docstring: упоминает «fuzzy wake word» и «Vosk в CI», но fuzzy/Vosk детекторы удалены из PR. Логика fixture корректна (возвращаетNone), но имя и docstring вводят в заблуждение. Не блокер — pre-existing, не изменён этим PR. Можно переименовать в_force_no_wake_wordи обновить docstring в отдельном PR.Verdict: APPROVE