Этап 3: голосовой ввод по горячей клавише
- Глобальная горячая клавиша через RegisterHotKey (по умолчанию Win+Alt+Space) - Запись с микрофона, конец фразы по паузе через Silero VAD, звуковые сигналы - Распознавание faster-whisper large-v3-turbo на GPU, CUDA-библиотеки из pip-пакетов nvidia-* - Состояния «слушаю» и «распознаю», кнопка «Говорить» в чате и пункт в трее - Голосовая команда прерывает текущий ответ и озвучку - Тесты горячей клавиши, детектора конца фразы и сеанса голосового ввода Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fec52dd1c2
commit
513d010c19
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
|
||||
from agr_assistent.hotkey import HotkeyError, parse_hotkey
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec", "expected"),
|
||||
[
|
||||
("ctrl+alt+space", (0x0002 | 0x0001, 0x20)),
|
||||
("Win + Shift + A", (0x0008 | 0x0004, ord("A"))),
|
||||
("f12", (0, 0x7B)),
|
||||
("ctrl+7", (0x0002, ord("7"))),
|
||||
],
|
||||
)
|
||||
def test_parse_hotkey(spec: str, expected: tuple[int, int]) -> None:
|
||||
assert parse_hotkey(spec) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", ["", "ctrl+", "hyper+a", "ctrl+f25", "ctrl+ж"])
|
||||
def test_parse_hotkey_rejects_invalid(spec: str) -> None:
|
||||
with pytest.raises(HotkeyError):
|
||||
parse_hotkey(spec)
|
||||
@@ -0,0 +1,43 @@
|
||||
from agr_assistent.audio.recorder import UtteranceDetector, UtteranceStatus
|
||||
|
||||
# Частота 100 отсчётов в секунду, окно — 10 отсчётов (0.1 с): так проще считать
|
||||
_WINDOW = 10
|
||||
|
||||
|
||||
def _detector(**overrides: float) -> UtteranceDetector:
|
||||
settings = dict(threshold=0.5, start_timeout_seconds=1, silence_seconds=0.3, max_seconds=5)
|
||||
settings.update(overrides)
|
||||
return UtteranceDetector(sample_rate=100, **settings)
|
||||
|
||||
|
||||
def _feed(detector: UtteranceDetector, probabilities: list[float]) -> list[UtteranceStatus]:
|
||||
return [detector.update(p, _WINDOW) for p in probabilities]
|
||||
|
||||
|
||||
def test_phrase_completes_after_silence() -> None:
|
||||
statuses = _feed(_detector(), [0.1, 0.9, 0.8, 0.2, 0.1, 0.1])
|
||||
|
||||
assert statuses[:-1] == [UtteranceStatus.CONTINUE] * 5
|
||||
assert statuses[-1] is UtteranceStatus.COMPLETE
|
||||
|
||||
|
||||
def test_short_pause_does_not_end_phrase() -> None:
|
||||
statuses = _feed(_detector(), [0.9, 0.1, 0.1, 0.9, 0.1, 0.1])
|
||||
|
||||
assert UtteranceStatus.COMPLETE not in statuses
|
||||
|
||||
|
||||
def test_no_speech_before_timeout() -> None:
|
||||
statuses = _feed(_detector(), [0.1] * 10)
|
||||
|
||||
assert statuses[-1] is UtteranceStatus.NO_SPEECH
|
||||
assert statuses[:-1] == [UtteranceStatus.CONTINUE] * 9
|
||||
|
||||
|
||||
def test_max_duration_completes_ongoing_speech() -> None:
|
||||
detector = _detector(max_seconds=0.5)
|
||||
|
||||
statuses = _feed(detector, [0.9] * 5)
|
||||
|
||||
assert statuses[-1] is UtteranceStatus.COMPLETE
|
||||
assert detector.speech_detected
|
||||
@@ -0,0 +1,127 @@
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication
|
||||
|
||||
from agr_assistent.core.voice import VoiceInput
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp() -> QCoreApplication:
|
||||
return QCoreApplication.instance() or QCoreApplication([])
|
||||
|
||||
|
||||
class FakeRecorder:
|
||||
def __init__(self, *, wait_for_stop: bool = False, speech: bool = True) -> None:
|
||||
self.wait_for_stop = wait_for_stop
|
||||
self.speech = speech
|
||||
self.recording = threading.Event()
|
||||
|
||||
def load(self) -> None:
|
||||
pass
|
||||
|
||||
def record(self, should_stop: Callable[[], bool]) -> np.ndarray | None:
|
||||
self.recording.set()
|
||||
while self.wait_for_stop and not should_stop():
|
||||
time.sleep(0.005)
|
||||
return np.zeros(160, dtype=np.float32) if self.speech else None
|
||||
|
||||
|
||||
class FakeRecognizer:
|
||||
def __init__(self, text: str = "привет", error: Exception | None = None) -> None:
|
||||
self.text = text
|
||||
self.error = error
|
||||
|
||||
def load(self) -> None:
|
||||
pass
|
||||
|
||||
def transcribe(self, audio: np.ndarray) -> str:
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.text
|
||||
|
||||
|
||||
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 _voice(recorder: FakeRecorder, recognizer: FakeRecognizer) -> VoiceInput:
|
||||
return VoiceInput(recorder, recognizer, sound_cues=False)
|
||||
|
||||
|
||||
def test_recognized_text_is_emitted(qapp: QCoreApplication) -> None:
|
||||
voice = _voice(FakeRecorder(), FakeRecognizer("какая погода"))
|
||||
events: list[str] = []
|
||||
voice.recognizing_started.connect(lambda: events.append("recognizing"))
|
||||
voice.recognized.connect(lambda text: events.append(text))
|
||||
|
||||
voice.start()
|
||||
assert voice.is_listening
|
||||
|
||||
_wait_until(lambda: "какая погода" in events)
|
||||
assert events == ["recognizing", "какая погода"]
|
||||
assert not voice.is_active
|
||||
|
||||
|
||||
def test_stop_finishes_recording_early(qapp: QCoreApplication) -> None:
|
||||
recorder = FakeRecorder(wait_for_stop=True)
|
||||
voice = _voice(recorder, FakeRecognizer())
|
||||
recognized: list[str] = []
|
||||
voice.recognized.connect(recognized.append)
|
||||
|
||||
voice.start()
|
||||
assert recorder.recording.wait(1)
|
||||
voice.stop()
|
||||
|
||||
_wait_until(lambda: recognized == ["привет"])
|
||||
|
||||
|
||||
def test_cancel_discards_result(qapp: QCoreApplication) -> None:
|
||||
recorder = FakeRecorder(wait_for_stop=True)
|
||||
voice = _voice(recorder, FakeRecognizer())
|
||||
recognized: list[str] = []
|
||||
finished: list[bool] = []
|
||||
voice.recognized.connect(recognized.append)
|
||||
voice.finished.connect(lambda: finished.append(True))
|
||||
|
||||
voice.start()
|
||||
assert recorder.recording.wait(1)
|
||||
voice.cancel()
|
||||
|
||||
assert finished == [True]
|
||||
time.sleep(0.1)
|
||||
QCoreApplication.processEvents()
|
||||
assert recognized == []
|
||||
assert finished == [True]
|
||||
|
||||
|
||||
def test_no_speech_finishes_silently(qapp: QCoreApplication) -> None:
|
||||
voice = _voice(FakeRecorder(speech=False), FakeRecognizer())
|
||||
events: list[str] = []
|
||||
voice.recognized.connect(lambda text: events.append("recognized"))
|
||||
voice.error_occurred.connect(lambda message: events.append("error"))
|
||||
voice.finished.connect(lambda: events.append("finished"))
|
||||
|
||||
voice.start()
|
||||
|
||||
_wait_until(lambda: "finished" in events)
|
||||
assert events == ["finished"]
|
||||
|
||||
|
||||
def test_recognition_error_is_reported(qapp: QCoreApplication) -> None:
|
||||
voice = _voice(FakeRecorder(), FakeRecognizer(error=RuntimeError("нет CUDA")))
|
||||
errors: list[str] = []
|
||||
voice.error_occurred.connect(errors.append)
|
||||
|
||||
voice.start()
|
||||
|
||||
_wait_until(lambda: bool(errors))
|
||||
assert "нет CUDA" in errors[0]
|
||||
assert not voice.is_active
|
||||
Reference in New Issue
Block a user