Files
voculinux/tests/test_record_during_reload.py
e66bd218d9 fix(audio): prevent dictation hangs on stalled microphones (#974)
* 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>
2026-10-26 21:25:50 -07:00

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