Files
voculinux/tests/test_reconfigure_worker_handoff.py
T
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

191 lines
7.1 KiB
Python

"""Resource-changing settings cannot overtake speech from the previous engine."""
import threading
import time
from collections.abc import Iterator
from contextlib import contextmanager
from typing import Any
from unittest.mock import Mock
import pytest
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition import recognition_manager as rm
def _make_manager(
monkeypatch: pytest.MonkeyPatch, engine: str = "whisper_cpp", model_size: str = "small"
) -> rm.SpeechRecognitionManager:
monkeypatch.setattr(rm, "load_silero_vad", lambda: None)
for sound in ("play_start_sound", "play_stop_sound", "play_error_sound"):
monkeypatch.setattr(rm, sound, Mock())
with monkeypatch.context() as patcher:
for initializer in (
"_init_vosk",
"_init_whisper",
"_init_whispercpp",
"_init_parakeet",
"_init_faster_whisper",
"_init_remote_api",
):
patcher.setattr(rm.SpeechRecognitionManager, initializer, lambda self: None)
manager = rm.SpeechRecognitionManager(
engine=engine,
model_size=model_size,
language="en-us",
playback_duck=Mock(enabled=Mock(return_value=False)),
)
manager.model = (engine, model_size)
manager._model_initialized = True
manager.stop_sound_guard_ms = 0
manager._record_audio = lambda: None
return manager
@contextmanager
def _cancelled_native_worker(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
) -> Iterator[threading.Thread]:
entered = threading.Event()
release = threading.Event()
def transcribe(*_args: object) -> str:
with manager._model_lock:
entered.set()
assert release.wait(3)
return "cancelled old speech"
transcribers = {
"vosk": "_transcribe_with_vosk",
"whisper": "_transcribe_with_whisper",
"whisper_cpp": "_transcribe_with_whispercpp",
"parakeet": "_transcribe_with_parakeet",
"faster_whisper": "_transcribe_with_faster_whisper",
"remote_api": "_transcribe_remote_api_segment",
}
monkeypatch.setattr(manager, transcribers[manager.engine], transcribe)
monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 0.02)
monkeypatch.setattr(rm, "_RECOGNITION_FINAL_JOIN_SECONDS", 0.02)
assert manager.start_recognition()
manager._enqueue_audio_segment([b"old speech"])
worker = manager.recognition_thread
assert worker is not None
try:
assert entered.wait(1)
manager.stop_recognition()
assert manager.state is RecognitionState.PROCESSING
assert manager.cancel_transcription()
assert manager.state is RecognitionState.IDLE
assert worker.is_alive()
yield worker
finally:
release.set()
worker.join(2)
assert not worker.is_alive()
def test_switching_engines_drains_final_speech_before_publishing_new_engine(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _make_manager(monkeypatch, engine="vosk")
processed = []
monkeypatch.setattr(
manager,
"_process_audio_buffer",
lambda audio, language: processed.append((manager.engine, manager.model, list(audio))),
)
def initialize() -> None:
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
assert manager.start_recognition()
with manager._buffer_lock:
manager.audio_buffer = [b"last spoken word"]
manager._recording_segment_has_speech = True
try:
manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=False)
finally:
manager._session_cancel.set()
manager._signal_recognition_stop()
if manager.recognition_thread is not None:
manager.recognition_thread.join(2)
assert processed == [("vosk", ("vosk", "small"), [b"last spoken word"])]
assert manager.engine == "whisper_cpp"
assert manager.model == ("whisper_cpp", "medium")
@pytest.mark.parametrize("tracked_only", [False, True])
def test_cancelled_native_worker_rejects_engine_change_before_mutating_settings(
monkeypatch: pytest.MonkeyPatch, tracked_only: bool
) -> None:
manager = _make_manager(monkeypatch)
initialize = Mock()
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
with _cancelled_native_worker(manager, monkeypatch) as worker:
if tracked_only:
manager.recognition_thread = None
previous = manager._snapshot_reconfigure_state()
previous_model = manager.model
started = time.monotonic()
with pytest.raises(TimeoutError, match="previous transcription is still finishing"):
manager.reconfigure(
engine="vosk", model_size="medium", vad_sensitivity=5, force_download=False
)
assert time.monotonic() - started < 0.3
assert manager._snapshot_reconfigure_state() == previous
assert manager.model is previous_model
assert worker.is_alive()
initialize.assert_not_called()
@pytest.mark.parametrize(
("engine", "model_size", "settings"),
[
("whisper_cpp", "small", {"model_size": "medium"}),
("whisper_cpp", "small", {"force_reinit": True}),
("whisper_cpp", "small", {"whispercpp_temperature": 0.4}),
("whisper_cpp", "small.en", {"language": "de"}),
("vosk", "small", {"language": "de"}),
("parakeet", "small", {"language": "de"}),
("remote_api", "small", {"remote_api_url": "https://new.example.invalid"}),
],
)
def test_other_resource_changes_also_wait_for_cancelled_native_workers(
monkeypatch: pytest.MonkeyPatch, engine: str, model_size: str, settings: dict[str, Any]
) -> None:
manager = _make_manager(monkeypatch, engine=engine, model_size=model_size)
with _cancelled_native_worker(manager, monkeypatch):
previous = manager._snapshot_reconfigure_state()
with pytest.raises(TimeoutError):
manager.reconfigure(force_download=False, **settings)
assert manager._snapshot_reconfigure_state() == previous
@pytest.mark.parametrize(
"settings",
[
{"vad_sensitivity": 4, "silence_timeout": 1.7, "voice_commands_enabled": False},
{"language": "de"},
],
)
def test_nonrestarting_settings_remain_hot_with_a_cancelled_native_worker(
monkeypatch: pytest.MonkeyPatch, settings: dict[str, Any]
) -> None:
manager = _make_manager(monkeypatch)
previous_model = manager.model
with _cancelled_native_worker(manager, monkeypatch) as worker:
started = time.monotonic()
manager.reconfigure(force_download=False, **settings)
assert time.monotonic() - started < 0.3
assert worker.is_alive()
assert manager.model is previous_model
assert manager.engine == "whisper_cpp"
if "language" in settings:
assert manager.language == "de"
else:
assert manager.vad_sensitivity == 4
assert manager.silence_timeout == 1.7
assert manager._voice_commands_enabled is False