* feat(shortcuts): per-language dictation shortcuts (#805) Bind extra keys that start dictation in a specific catalog language for one utterance, then restore the configured language. Bindings live in shortcuts.language_shortcuts as [{shortcut, language}], normalized against the language catalog and the backend's shortcut parser. The tray spawns one KeyboardShortcutManager per binding in the active mode; toggle presses route through start_recognition_with_language, and push-to-talk arms press/release the same way. The manager keeps the override one-shot: it applies before the utterance starts (outranking follow-keyboard-layout) and unwinds on the next IDLE/ERROR transition or when a mid-dictation reconfigure picks a new language. Engines that cannot re-language without a reload (VOSK, Parakeet, English-only whisper.cpp) refuse with the error cue and a notification instead of dictating in the wrong language. Settings gains a Language Shortcuts group under Shortcuts: each row is a language picker, an editable key entry, a Record button that retargets the shared key-capture, and a remove button; edits persist immediately and rebuild the live listeners. A non-dict shortcuts section now rebuilds instead of crashing config load. * fix(shortcuts): class-level default for language shortcut listeners TrayIndicator stubs built with __new__ (e.g. test_suspend_handler) skip __init__ but still run _quit(); give _language_shortcut_managers a class-level default so teardown iterates an empty list. * Fix Greptile review findings on per-language shortcuts (#805) - Bind each dictation session's language for transcription workers so the one-shot restore cannot rewrite a queued segment's language - Judge a shortcut's capability against the model the layout refresh will serve (whisper.cpp sibling swap) and refuse requests the engine drops - Add _oneshot_language_restore to the reconfigure snapshot and settle a revived pending restore on failure - Dedupe language bindings by gesture (modifier reordering, enter/return alias, pure-modifier side sets) instead of by string - Stop per-language listeners under external activation and never re-arm - Gate push-to-talk release on the binding that started the session, and stop a held hold before rebuilding listeners - Keep a row's last valid binding during partial edits, report rejected duplicates, cancel a capture aimed at a removed row, disarm the previous Record target, persist a recorded key once, and add the missing type hints * fix(shortcuts): resolve remaining Greptile race findings on PR #880 - Stamp each queued audio segment with the session language it was recorded under, so a worker that outlives stop_recognition's joins transcribes each utterance in its own language rather than whatever a newer session stored in _session_language. - Serialize push-to-talk press/release behind _ptt_lock: a release landing while start_recognition runs now waits for the owner claim instead of being dropped, so a quick key tap cannot leave the microphone running. - Add the missing return type hint on _stop_language_shortcut_managers. * test(shortcuts): stub _segment_started_at on bare manager stubs * fix(shortcuts): carry segment language as attribute, default language on bare managers * fix(shortcuts): keep (segment, language) queue tuples, stamp segment.language too * fix(settings): apply history toggle and limit to the live store --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
263 lines
9.2 KiB
Python
263 lines
9.2 KiB
Python
"""Recording and model reload must overlap without losing a released utterance."""
|
|
|
|
import sys
|
|
import threading
|
|
from collections.abc import Generator
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from vocalinux.common_types import RecognitionState
|
|
from vocalinux.speech_recognition import recognition_manager as rm
|
|
|
|
Session = tuple[
|
|
rm.SpeechRecognitionManager,
|
|
threading.Event,
|
|
threading.Event,
|
|
threading.Event,
|
|
list[list[bytes]],
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def session(monkeypatch: pytest.MonkeyPatch) -> Generator[Session, None, None]:
|
|
"""Use real worker threads with a deterministic, blocked model loader."""
|
|
|
|
def skip_vosk_init(_manager: rm.SpeechRecognitionManager) -> None:
|
|
return None
|
|
|
|
def skip_silero_load() -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_vosk", skip_vosk_init)
|
|
monkeypatch.setattr(rm, "load_silero_vad", skip_silero_load)
|
|
for name in ("play_start_sound", "play_stop_sound", "play_error_sound", "_show_notification"):
|
|
monkeypatch.setattr(rm, name, Mock())
|
|
manager = rm.SpeechRecognitionManager(engine="vosk", buffer_during_reload=True)
|
|
manager._idle_unloaded = True
|
|
manager.stop_sound_guard_ms = 0
|
|
captured = threading.Event()
|
|
loading = threading.Event()
|
|
release_load = threading.Event()
|
|
transcribed: list[list[bytes]] = []
|
|
|
|
def record() -> None:
|
|
with manager._buffer_lock:
|
|
manager.audio_buffer = [b"early speech"]
|
|
manager._recording_segment_has_speech = True
|
|
captured.set()
|
|
|
|
def load() -> None:
|
|
loading.set()
|
|
assert release_load.wait(3)
|
|
manager.model = object()
|
|
manager._model_initialized = True
|
|
|
|
def collect_transcription(audio: list[bytes], language: str | None = None) -> None:
|
|
transcribed.append(audio)
|
|
|
|
monkeypatch.setattr(manager, "_record_audio", record)
|
|
monkeypatch.setattr(manager, "_init_selected_engine", load)
|
|
monkeypatch.setattr(manager, "_process_audio_buffer", collect_transcription)
|
|
yield manager, captured, loading, release_load, transcribed
|
|
release_load.set()
|
|
if manager.should_record:
|
|
manager.stop_recognition()
|
|
if manager.recognition_thread:
|
|
manager.recognition_thread.join(3)
|
|
assert not manager.recognition_thread.is_alive()
|
|
|
|
|
|
def test_release_before_reload_finishes_preserves_whole_recording(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager.audio_buffer.append(b"later speech")
|
|
assert transcribed == []
|
|
|
|
manager.stop_recognition()
|
|
assert not release_load.is_set()
|
|
assert manager.state == RecognitionState.PROCESSING
|
|
assert not manager.start_recognition()
|
|
manager.stop_recognition() # Repeated stop must not erase pending audio.
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
|
|
assert transcribed == [[b"early speech", b"later speech"]]
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert not manager.is_idle_unloaded
|
|
|
|
|
|
def test_toggle_transcribes_queued_speech_after_reload(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager._enqueue_audio_segment(manager.audio_buffer)
|
|
manager.audio_buffer = []
|
|
manager.stop_recognition()
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert transcribed == [[b"early speech"]]
|
|
|
|
|
|
def test_disabled_option_keeps_synchronous_reload(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
manager.buffer_during_reload = False
|
|
ensure = Mock(return_value=False)
|
|
monkeypatch.setattr(manager, "ensure_model_loaded", ensure)
|
|
assert not manager.start_recognition()
|
|
ensure.assert_called_once()
|
|
assert not captured.is_set()
|
|
assert not loading.is_set()
|
|
|
|
|
|
def test_auto_pause_does_not_start_microphone(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
manager._auto_paused = True
|
|
assert not manager.start_recognition()
|
|
assert not captured.is_set()
|
|
assert not loading.is_set()
|
|
|
|
|
|
def test_reload_failure_after_release_allows_retry(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
|
|
def fail() -> None:
|
|
loading.set()
|
|
assert release_load.wait(3)
|
|
raise RuntimeError("model unavailable")
|
|
|
|
monkeypatch.setattr(manager, "_init_selected_engine", fail)
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager.stop_recognition()
|
|
assert manager.state == RecognitionState.PROCESSING
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert not manager.recognition_thread.is_alive()
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert manager._segment_queue.empty()
|
|
assert transcribed == []
|
|
assert manager.is_idle_unloaded
|
|
|
|
manager.model = object()
|
|
manager._model_initialized = True
|
|
assert manager.start_recognition()
|
|
manager.stop_recognition()
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
|
|
def test_reload_that_returns_without_a_ready_model_stops_and_allows_retry(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, captured, _loading, _release_load, transcribed = session
|
|
|
|
def leave_model_unready() -> None:
|
|
assert captured.wait(1)
|
|
|
|
monkeypatch.setattr(manager, "_init_selected_engine", leave_model_unready)
|
|
assert manager.start_recognition()
|
|
manager.recognition_thread.join(2)
|
|
|
|
assert not manager.recognition_thread.is_alive()
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
assert transcribed == []
|
|
assert manager.is_idle_unloaded
|
|
|
|
|
|
def test_reload_does_not_hide_unexpected_worker_errors(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
manager, _captured, _loading, _release_load, _transcribed = session
|
|
|
|
def fail_with_programming_error() -> None:
|
|
raise AssertionError("worker defect")
|
|
|
|
manager._buffered_reload_session = True
|
|
manager._capture_finished.set()
|
|
monkeypatch.setattr(manager, "_init_selected_engine", fail_with_programming_error)
|
|
|
|
with pytest.raises(AssertionError, match="worker defect"):
|
|
manager._reload_and_recognize()
|
|
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert not manager._buffered_reload_session
|
|
|
|
|
|
def test_unload_cancels_pending_transcription(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
unloading = threading.Thread(target=manager.unload_model, kwargs={"reason": "auto_pause"})
|
|
unloading.start()
|
|
assert manager._cancel_buffered_session.wait(1)
|
|
release_load.set()
|
|
unloading.join(2)
|
|
assert not unloading.is_alive()
|
|
assert transcribed == []
|
|
assert manager.model is None
|
|
assert manager.is_auto_paused
|
|
|
|
|
|
def test_capture_failure_survives_key_release_during_reload(session: Session) -> None:
|
|
manager, captured, loading, release_load, transcribed = session
|
|
assert manager.start_recognition()
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
manager._buffered_capture_failed = True
|
|
manager._update_state(RecognitionState.ERROR)
|
|
manager.stop_recognition()
|
|
release_load.set()
|
|
manager.recognition_thread.join(2)
|
|
assert transcribed == []
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert manager.audio_buffer == []
|
|
|
|
|
|
def test_capture_thread_failure_releases_reload_worker(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A dead mic during buffered reload must not hang the reload worker."""
|
|
manager, _captured, _loading, _release_load, _transcribed = session
|
|
manager._buffered_reload_session = True
|
|
manager._buffered_capture_failed = False
|
|
manager._capture_finished.clear()
|
|
# The audio thread exits through its ImportError teardown: no
|
|
# stop_recognition handoff is left to set _capture_finished.
|
|
monkeypatch.setitem(sys.modules, "pyaudio", None)
|
|
monkeypatch.setattr("vocalinux.ui.audio_feedback.play_error_sound", Mock())
|
|
# Bypass the fixture's _record_audio stub to run the real capture thread.
|
|
rm.SpeechRecognitionManager._record_audio(manager)
|
|
|
|
assert manager._buffered_capture_failed
|
|
assert not manager.should_record
|
|
assert manager.state == RecognitionState.ERROR
|
|
assert manager._capture_finished.is_set()
|
|
|
|
manager._cancel_buffered_session.set()
|
|
worker = threading.Thread(target=manager._reload_and_recognize)
|
|
worker.start()
|
|
worker.join(2)
|
|
assert not worker.is_alive()
|
|
assert manager.state == RecognitionState.IDLE
|
|
assert not manager._buffered_reload_session
|
|
|
|
|
|
def test_setting_defaults_to_disabled() -> None:
|
|
from vocalinux.ui.config_manager import DEFAULT_CONFIG
|
|
|
|
assert DEFAULT_CONFIG["model_keepalive"]["buffer_during_reload"] is False
|