The fork takes its own name everywhere a user can see it: binaries (voculinux, voculinux-gui), desktop entry, autostart, tray, D-Bus namespace (com.voculinux.Voculinux, no alias for the old name), config/data/cache dirs, single-instance lock, IBus engine identity, tray icons, resource prefixes, VOCULINUX_* environment names, and the update-checker user agent. The internal Python module stays vocalinux and the distribution metadata keeps the upstream URLs as provenance. On first run a previous Vocalinux install's config.json, custom dictionary, and portal restore token are copied once into the new locations (new config always wins, no merge, no write-back); models re-resolve through the pinned-checksum download path. An offline naming-sweep test proves no old-name strings remain on user-visible surfaces outside the allowlist. Refs #18
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, "_voculinux_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()
|