* 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>
160 lines
6.1 KiB
Python
160 lines
6.1 KiB
Python
"""Regression tests: a stop must never be lost, and a stalled mic must not hang it.
|
|
|
|
1. A stop that arrives while start_recognition() is still running (key
|
|
released on another thread right after the state became LISTENING) used
|
|
to be undone by the later ``should_record = True``: the capture thread
|
|
then recorded with nothing left to stop it.
|
|
2. A capture read that blocks (audio server reconfigures the device, a
|
|
USB/Bluetooth mic stalls) used to hold ``_buffer_lock``, so
|
|
stop_recognition() blocked forever on that lock after its bounded join.
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import Iterator
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import vocalinux.speech_recognition.recognition_manager as recognition_module
|
|
from vocalinux.common_types import RecognitionState
|
|
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _numpy_is_real_while_capturing() -> Iterator[None]:
|
|
"""Run the real capture loop against the real NumPy.
|
|
|
|
Older tests assign ``sys.modules["numpy"] = MagicMock()`` at import and
|
|
never put the real module back. ``_record_audio`` imports numpy lazily, so
|
|
under that leak its first chunk raises and the loop exits before the
|
|
stalled read this file depends on.
|
|
"""
|
|
current = sys.modules.get("numpy")
|
|
real = getattr(sys, "_vocalinux_real_numpy", None)
|
|
if isinstance(current, MagicMock) and real is not None:
|
|
sys.modules["numpy"] = real
|
|
try:
|
|
yield
|
|
finally:
|
|
sys.modules["numpy"] = current
|
|
else:
|
|
yield
|
|
|
|
|
|
def _make_manager() -> SpeechRecognitionManager:
|
|
duck = MagicMock()
|
|
duck.enabled.return_value = False
|
|
with patch.object(SpeechRecognitionManager, "_init_whispercpp"):
|
|
manager = SpeechRecognitionManager(
|
|
engine="whisper_cpp",
|
|
model_size="small",
|
|
language="en-us",
|
|
playback_duck=duck,
|
|
)
|
|
manager.model = MagicMock()
|
|
manager._model_initialized = True
|
|
manager._silero_vad = None
|
|
return manager
|
|
|
|
|
|
def _stop_session(manager: SpeechRecognitionManager) -> None:
|
|
with patch.object(recognition_module, "play_stop_sound"):
|
|
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.2):
|
|
with patch.object(SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.05):
|
|
manager.stop_recognition()
|
|
|
|
|
|
def _wait_for_state(manager: SpeechRecognitionManager, state: RecognitionState) -> None:
|
|
deadline = time.monotonic() + 3.0
|
|
while manager.state is not state and time.monotonic() < deadline:
|
|
time.sleep(0.005)
|
|
assert manager.state is state
|
|
|
|
|
|
def test_stop_racing_start_is_not_lost() -> None:
|
|
manager = _make_manager()
|
|
stops: list[threading.Thread] = []
|
|
|
|
def record_audio() -> None:
|
|
# Stand-in for the capture loop: records while should_record is set.
|
|
deadline = time.monotonic() + 3.0
|
|
while manager.should_record and time.monotonic() < deadline:
|
|
time.sleep(0.005)
|
|
|
|
def release_key_on_another_thread(state: RecognitionState) -> None:
|
|
# The first LISTENING notification is the earliest point another
|
|
# thread can observe the session and stop it. The stop elects on the
|
|
# lifecycle lock the moment the start's hold ends, so it completes
|
|
# right after start_recognition() returns; joining it inside the
|
|
# callback would stall the start it must outlive.
|
|
if state is RecognitionState.LISTENING and not stops:
|
|
stop = threading.Thread(target=_stop_session, args=(manager,))
|
|
stops.append(stop)
|
|
stop.start()
|
|
|
|
manager._record_audio = record_audio
|
|
manager.register_state_callback(release_key_on_another_thread)
|
|
try:
|
|
with patch.object(recognition_module, "play_start_sound"):
|
|
with patch.object(manager, "_arm_playback_duck"):
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
|
|
assert stops
|
|
stops[0].join(timeout=5.0)
|
|
# The stop must survive the rest of start_recognition().
|
|
assert not stops[0].is_alive()
|
|
assert manager.should_record is False
|
|
assert manager.audio_thread is not None
|
|
manager.audio_thread.join(timeout=1.0)
|
|
assert not manager.audio_thread.is_alive()
|
|
_wait_for_state(manager, RecognitionState.IDLE)
|
|
finally:
|
|
manager.should_record = False
|
|
|
|
|
|
def test_stop_completes_while_capture_read_is_blocked() -> None:
|
|
manager = _make_manager()
|
|
first_read_done = threading.Event()
|
|
read_blocking = threading.Event()
|
|
release_read = threading.Event()
|
|
|
|
source = MagicMock()
|
|
source.requires_pyaudio = False
|
|
source.sample_rate = 16000
|
|
|
|
def read_chunk(*args: object, **kwargs: object) -> bytes:
|
|
# The first read returns normally, every later one stalls like a
|
|
# device that disappeared under the audio server.
|
|
if first_read_done.is_set():
|
|
read_blocking.set()
|
|
release_read.wait(timeout=10.0)
|
|
first_read_done.set()
|
|
return b"\x00\x00" * 1024
|
|
|
|
source.read_chunk.side_effect = read_chunk
|
|
manager._new_capture_source = MagicMock(return_value=source)
|
|
|
|
try:
|
|
with patch.object(recognition_module, "play_start_sound"):
|
|
with patch.object(manager, "_arm_playback_duck"):
|
|
assert manager.start_recognition(mode="push_to_talk")
|
|
# Start the stop only once the capture thread is inside the stalled read.
|
|
assert read_blocking.wait(timeout=2.0)
|
|
|
|
stop = threading.Thread(target=_stop_session, args=(manager,), daemon=True)
|
|
stop.start()
|
|
# stop_recognition() joins the capture thread for at most 2 s; it
|
|
# must then finish instead of waiting on the buffer lock forever.
|
|
stop.join(timeout=5.0)
|
|
assert not stop.is_alive(), "stop_recognition() hung behind a blocked capture read"
|
|
_wait_for_state(manager, RecognitionState.IDLE)
|
|
finally:
|
|
release_read.set()
|
|
manager.should_record = False
|
|
if manager.audio_thread is not None:
|
|
manager.audio_thread.join(timeout=2.0)
|
|
|
|
assert not manager.audio_thread.is_alive()
|