Этап 4: слово активации через Vosk
- Фоновое прослушивание микрофона маленькой моделью Vosk, фразы настраиваются - Свободное распознавание + требование устойчивой гипотезы: грамматика давала массу ложных срабатываний - Пауза прослушивания, пока ассистент слушает команду, думает или говорит - Модель скачивается с зеркала HuggingFace, проверка слов по словарю модели - Переключатель в меню трея - Тесты поиска фразы и фонового слушателя Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
513d010c19
commit
a719980d74
@@ -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
|
||||
@@ -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("просто ассистент")
|
||||
Reference in New Issue
Block a user