Этап 4: слово активации через Vosk

- Фоновое прослушивание микрофона маленькой моделью Vosk, фразы настраиваются
- Свободное распознавание + требование устойчивой гипотезы: грамматика давала массу ложных срабатываний
- Пауза прослушивания, пока ассистент слушает команду, думает или говорит
- Модель скачивается с зеркала HuggingFace, проверка слов по словарю модели
- Переключатель в меню трея
- Тесты поиска фразы и фонового слушателя

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nid
2026-09-17 03:56:32 +03:00
co-authored by Claude Opus 5
parent 513d010c19
commit a719980d74
12 changed files with 931 additions and 5 deletions
+129
View File
@@ -0,0 +1,129 @@
import threading
import time
from collections.abc import Callable
import pytest
from PySide6.QtCore import QCoreApplication
from agr_assistent.core.wake import WakeWordListener
@pytest.fixture(scope="module")
def qapp() -> QCoreApplication:
return QCoreApplication.instance() or QCoreApplication([])
class FakeStream:
def __init__(self, owner: "FakeMicrophone") -> None:
self.owner = owner
def __enter__(self) -> "FakeStream":
with self.owner.lock:
self.owner.open_streams += 1
self.owner.opened += 1
return self
def __exit__(self, *args: object) -> None:
with self.owner.lock:
self.owner.open_streams -= 1
def read(self, frames: int) -> tuple[bytes, bool]:
time.sleep(0.005)
return b"\0\0" * frames, False
class FakeMicrophone:
def __init__(self) -> None:
self.lock = threading.Lock()
self.open_streams = 0
self.opened = 0
def __call__(self) -> FakeStream:
return FakeStream(self)
class FakeSession:
def __init__(self, detect_after: int | None) -> None:
self.detect_after = detect_after
self.blocks = 0
def accept(self, pcm16: bytes) -> bool:
self.blocks += 1
return self.detect_after is not None and self.blocks >= self.detect_after
class FakeDetector:
def __init__(self, detect_after: int | None = 3, error: Exception | None = None) -> None:
self.detect_after = detect_after
self.error = error
self.sessions = 0
def load(self) -> None:
pass
def create_session(self) -> FakeSession:
if self.error:
raise self.error
self.sessions += 1
return FakeSession(self.detect_after)
def _wait_until(condition: Callable[[], bool], timeout: float = 3.0) -> None:
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "условие не выполнилось вовремя"
QCoreApplication.processEvents()
time.sleep(0.005)
def test_detection_pauses_listening_until_resumed(qapp: QCoreApplication) -> None:
detector, microphone = FakeDetector(detect_after=3), FakeMicrophone()
listener = WakeWordListener(detector, enabled=True, open_stream=microphone)
detections: list[bool] = []
listener.detected.connect(lambda: detections.append(True))
_wait_until(lambda: detections == [True])
# После срабатывания микрофон освобождён и новая сессия не начинается
time.sleep(0.1)
assert microphone.open_streams == 0
assert detector.sessions == 1
listener.resume()
_wait_until(lambda: len(detections) == 2)
assert detector.sessions == 2
def test_disabled_listener_does_not_open_microphone(qapp: QCoreApplication) -> None:
microphone = FakeMicrophone()
listener = WakeWordListener(FakeDetector(), enabled=False, open_stream=microphone)
time.sleep(0.1)
assert microphone.opened == 0
listener.set_enabled(True)
_wait_until(lambda: microphone.opened == 1)
def test_pause_releases_microphone(qapp: QCoreApplication) -> None:
microphone = FakeMicrophone()
listener = WakeWordListener(FakeDetector(detect_after=None), enabled=True, open_stream=microphone)
_wait_until(lambda: microphone.open_streams == 1)
listener.pause()
_wait_until(lambda: microphone.open_streams == 0)
def test_failure_disables_listener_and_reports(qapp: QCoreApplication) -> None:
listener = WakeWordListener(
FakeDetector(error=RuntimeError("нет модели")), enabled=True, open_stream=FakeMicrophone()
)
errors: list[str] = []
enabled_changes: list[bool] = []
listener.error_occurred.connect(errors.append)
listener.enabled_changed.connect(enabled_changes.append)
_wait_until(lambda: bool(errors))
assert "нет модели" in errors[0]
assert enabled_changes == [False]
assert not listener.enabled
+33
View File
@@ -0,0 +1,33 @@
from agr_assistent.audio.wakeword import (
WakeWordMatcher,
contains_phrase,
normalize_phrase,
)
def test_normalize_phrase() -> None:
assert normalize_phrase(" Эй, Ассистент ") == ("эй", "ассистент")
def test_contains_phrase_matches_whole_words_in_order() -> None:
words = "слушай эй ассистент включи".split()
assert contains_phrase(words, ("эй", "ассистент"))
assert not contains_phrase(words, ("ассистент", "эй"))
assert not contains_phrase("ассистентка пришла".split(), ("ассистент",))
def test_partial_hypothesis_must_hold_for_several_updates() -> None:
matcher = WakeWordMatcher([("ассистент",)], required_streak=2)
assert not matcher.update_partial("ассистент")
assert not matcher.update_partial("ассистентка") # гипотеза исправилась — счёт сброшен
assert not matcher.update_partial("ассистентка ассистент")
assert matcher.update_partial("ассистентка ассистент какая")
def test_final_result_matches_immediately() -> None:
matcher = WakeWordMatcher([("эй", "ассистент")], required_streak=3)
assert matcher.update_final("ну эй ассистент")
assert not matcher.update_final("просто ассистент")