* fix(audio): bound capture reads and support cancellation * fix(recognition): bound and serialize capture shutdown * fix(recognition): cancel retries and report stalled microphones * fix(audio): prefer pulse when the ALSA default is unavailable * fix(recognition): guard late callbacks and finalize capture tests * docs(audio): explain input selection and stall recovery * fix(speech): enforce auto-pause across buffered reload timeouts * fix(speech): recover auto-resume after reload timeout * test(speech): fix auto-pause follow-up test regressions * fix(speech): retain auto-resume after reconfigure timeout * fix(tray): preserve auto-pause recovery across suspend * test(capture-shutdown): give the bare tray the suspend path's shortcut managers _on_system_suspend now releases the main and per-language hotkey listeners before sleep (#1025); the fixture builds TrayIndicator without __init__, so it needs the two attributes present. * fix(tray): release hotkey listeners on suspend even while auto-paused Merging #1025's suspend cleanup with this branch's auto-pause handoff left an early return that skipped shortcut_manager.stop() whenever a configured game was active, keeping EVIOCGRABs held through sleep. The paused branch now only owns the model-lifecycle work; listener release and stopping active recognition run for every suspend. * fix(tray): hold the apply lock only around the download's verify and save Wrapping the whole recommended-model download in _apply_settings_lock blocked every GTK-thread settings apply (e.g. Test Dictation) for the length of the transfer. The engine already serializes reconfigures, so the lock only needs to cover verifying the download still owns the receipt and saving it; an apply that lands mid-download now proceeds instead of freezing the app. --------- Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
306 lines
11 KiB
Python
306 lines
11 KiB
Python
"""Recording and model reload must overlap without losing a released utterance."""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
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_worker_exit_during_release_join_still_transcribes_final_buffer(
|
|
session: Session, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A release whose final enqueue lands after the worker exits is kept.
|
|
|
|
stop_recognition joins the audio thread with a bounded wait; while it
|
|
waits, the recognition worker can notice ``should_record`` is false and
|
|
exit. The final buffer it then enqueues must still be transcribed by
|
|
the reload teardown instead of being thrown away with the queue.
|
|
"""
|
|
manager, captured, loading, release_load, transcribed = session
|
|
capture_release = threading.Event()
|
|
|
|
def slow_record() -> None:
|
|
with manager._buffer_lock:
|
|
manager.audio_buffer = [b"final speech"]
|
|
manager._recording_segment_has_speech = True
|
|
captured.set()
|
|
assert capture_release.wait(3)
|
|
|
|
monkeypatch.setattr(manager, "_record_audio", slow_record)
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
assert captured.wait(1)
|
|
assert loading.wait(1)
|
|
release_load.set() # Model loads; the worker starts polling an empty queue.
|
|
|
|
stopper = threading.Thread(target=manager.stop_recognition)
|
|
stopper.start()
|
|
# The worker's 0.5s second-chance window elapses while the stop path is
|
|
# still joining the audio thread, so the tail buffer is enqueued only
|
|
# after the worker has already exited.
|
|
time.sleep(1.2)
|
|
capture_release.set()
|
|
stopper.join(3)
|
|
manager.recognition_thread.join(3)
|
|
|
|
assert transcribed == [[b"final speech"]]
|
|
assert manager.state == RecognitionState.IDLE
|
|
|
|
|
|
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()
|
|
manager.should_record = True
|
|
manager.state = RecognitionState.LISTENING
|
|
# 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.IDLE
|
|
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
|