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>
This commit is contained in:
Michael
2026-10-26 21:25:50 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent be690f0bd8
commit e66bd218d9
37 changed files with 6636 additions and 667 deletions
+11
View File
@@ -31,6 +31,17 @@ vocalinux
4. Reset a bad device index: set `audio.device_index` to `null` in `~/.config/vocalinux/config.json`
5. Bluetooth mics: try a wired device to isolate SCO capture issues
### Microphone Stalled
A **Microphone Stalled** notification means the selected input stopped delivering
audio. Vocalinux stops the failed dictation attempt.
Open **Settings → Audio**. Use **Microphone Test → Test** to record two seconds and
check the audio level, or select a different **Input Device**, then retry
dictation. **Test Dictation** is a separate speech-recognition test. See
[Audio input](USER_GUIDE.md#audio-input) for explicit microphone selection and
**System Default** behavior.
## GTK / AppIndicator / `No module named gi`
Recreate the venv with system site packages:
+14
View File
@@ -40,6 +40,20 @@ The clipboard copy behind these pastes is temporary: your previous clipboard com
**Other audio.** Settings → Audio → Other audio can lower speakers and headphones while the microphone is open, then put that volume back. It stays off until you turn it on. Level while dictating is a percent of the current volume (0 is silent). Quitting mid-dictation puts the volume back; if the app crashes first, the next launch does.
## Audio input
Open **Settings → Audio → Input Device** to select a specific microphone or choose
**System Default**. A valid explicit microphone selection uses that device and
bypasses the System Default preference below.
With **System Default**, Vocalinux leaves valid default or virtual inputs under
the audio system's control rather than pinning a hardware device.
On ALSA only, if PortAudio substitutes arbitrary hardware for the default input,
Vocalinux prefers an input-capable `pulse` PCM on the same host API to follow the
microphone selected in desktop sound settings. If no suitable `pulse` PCM exists,
it keeps PortAudio's fallback device and logs a warning.
## Shortcuts
Configure under **Settings → Shortcuts**:
+165 -36
View File
@@ -15,8 +15,9 @@ import ctypes
import logging
import subprocess
import time
from typing import TYPE_CHECKING, Any, Optional, cast
from typing import TYPE_CHECKING, Any, Callable, Optional, cast
from .errors import CaptureStalledError
from .pipewire import (
PIPEWIRE_INDEX_BASE,
_test_pipewire_input,
@@ -40,6 +41,45 @@ logger = logging.getLogger(__name__)
# PortAudio frames read per capture call.
CAPTURE_CHUNK = 1024
# Match PipeWire's stall deadline; polling also bounds cancellation latency.
STALL_TIMEOUT_SECONDS = 5.0
_READ_POLL_SECONDS = 0.01
_PA_INPUT_OVERFLOWED = -9981
_PA_ALSA = 8
def _read_frames_bounded(
stream: Any,
frames: int,
timeout: float = STALL_TIMEOUT_SECONDS,
should_continue: Optional[Callable[[], bool]] = None,
) -> Optional[bytes]:
"""Wait for a full chunk, returning None on cancellation or raising on stall.
Only read frames PortAudio reports as available, avoiding a blocking read
on a device that has stopped producing samples. Input overflows are
tolerated just as with ``exception_on_overflow=False``; other errors
belong to the caller's recovery path.
"""
deadline = time.monotonic() + timeout
while True:
if should_continue is not None and not should_continue():
return None
remaining = deadline - time.monotonic()
if remaining <= 0:
raise CaptureStalledError(f"PortAudio capture stalled: no audio for {timeout:g}s")
try:
if stream.get_read_available() >= frames:
if should_continue is not None and not should_continue():
return None
data: bytes = stream.read(frames, exception_on_overflow=False)
return data
except OSError as exc:
# PyAudio versions use either OSError.errno or args for PA codes.
if exc.errno != _PA_INPUT_OVERFLOWED and _PA_INPUT_OVERFLOWED not in exc.args:
raise
time.sleep(min(_READ_POLL_SECONDS, remaining))
# ALSA error handler to suppress warnings during PyAudio initialization
def _setup_alsa_error_handler() -> Optional[Any]:
@@ -398,6 +438,58 @@ def _resolve_valid_input_device(audio: Any, preferred_index: Optional[int] = Non
return input_device_indices[0]
def _resolve_system_default(audio: Any) -> Optional[int]:
"""Prefer ALSA's sound-server PCM when PortAudio substituted hardware.
A real host default stays implicit. ALSA can silently promote arbitrary
hardware when its ``default`` PCM is busy or absent; in that case the
input-capable ``pulse`` PCM follows the desktop's selected microphone.
This only inspects enumeration metadata and never probes a stream.
"""
default_info = _get_device_info_safe(audio)
name = default_info.get("name", "Unknown")
if isinstance(name, str) and (
name.strip().lower() in {"default", "pulse", "pipewire"} or _is_virtual_device(name)
):
return None
host_api_index = default_info.get("hostApi")
if not isinstance(host_api_index, int):
return None
try:
host_info = audio.get_host_api_info_by_index(host_api_index)
except (IOError, OSError, TypeError, ValueError, AttributeError):
return None
if not isinstance(host_info, dict) or host_info.get("type") != _PA_ALSA:
return None
try:
device_count = int(audio.get_device_count())
except (IOError, OSError, TypeError, ValueError, AttributeError):
device_count = 0
for index in range(max(0, device_count)):
info = _get_device_info_safe(audio, index)
pcm_name = info.get("name", "")
channels = info.get("maxInputChannels", 0)
if (
isinstance(pcm_name, str)
and pcm_name.strip().lower() == "pulse"
and not _is_virtual_device(pcm_name)
and isinstance(channels, (int, float))
and channels > 0
and info.get("hostApi", host_api_index) == host_api_index
):
logger.info("ALSA default resolved to '%s'; using pulse PCM [%s]", name, index)
return index
logger.warning(
"System Default resolved to ALSA device '%s' [%s]; no input-capable pulse PCM "
"is available. Using that fallback device.",
name,
default_info.get("index", "unknown"),
)
return None
def _open_capture_stream(audio: Any, device_index: Optional[int] = None) -> tuple[int, int, object]:
"""
Negotiate a working (channels, sample_rate) and return the opened stream.
@@ -663,13 +755,18 @@ def _get_supported_sample_rate(audio: Any, device_index: Optional[int], channels
return 16000
def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0) -> dict:
def test_audio_input(
device_index: Optional[int] = None,
duration: float = 1.0,
should_continue: Optional[Callable[[], bool]] = None,
) -> dict:
"""
Test audio input from a device and return diagnostic information.
Args:
device_index: The device index to test (None for default)
duration: How long to record in seconds
should_continue: Optional predicate; False cancels the test.
Returns:
Dictionary with test results including:
@@ -693,8 +790,15 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
}
if is_pipewire_device_index(cast(int, device_index)):
return _test_pipewire_input(cast(int, device_index), duration)
return _test_pipewire_input(
cast(int, device_index), duration, should_continue=should_continue
)
audio: Any = None
stream: Any = None
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
return result
# A snap PulseAudio source opens through the default device with
# PULSE_SOURCE pointing at it, so PortAudio never sees its index.
pulse_selected, pulse_source = _resolve_pulse_selection(device_index, None)
@@ -703,6 +807,9 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
device_index = None
try:
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
return result
import numpy as np
import pyaudio
@@ -711,13 +818,12 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
audio = pyaudio.PyAudio()
# Get device info for display. Keep open_device_index as None for
# System Default so PortAudio opens the host default instead of an
# explicit pseudo-device index like "default" / DeepFilterNet (#624).
open_device_index = device_index
# Keep valid host defaults implicit; the shared resolver only pins
# a sound-server PCM when ALSA silently substituted hardware.
open_device_index = _resolve_system_default(audio) if device_index is None else device_index
try:
if device_index is not None:
info = audio.get_device_info_by_index(device_index)
if open_device_index is not None:
info = audio.get_device_info_by_index(open_device_index)
else:
info = audio.get_default_input_device_info()
result["device_name"] = info.get("name", "Unknown")
@@ -726,7 +832,6 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
result["device_name"] = pulse_source.display_name
except (IOError, OSError) as e:
result["error"] = f"Cannot get device info: {e}"
audio.terminate()
return result
# Negotiate the format and open the capture stream in ONE PortAudio
@@ -755,7 +860,6 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
stream = audio.open(**stream_kwargs)
except (IOError, OSError) as e:
result["error"] = f"Cannot open audio stream: {e}"
audio.terminate()
return result
# Record and analyze
@@ -764,7 +868,10 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
for _ in range(frames_to_read):
try:
data = cast(Any, stream).read(CHUNK, exception_on_overflow=False)
data = _read_frames_bounded(stream, CHUNK, should_continue=should_continue)
if data is None:
result["error"] = "Audio test cancelled"
break
audio_data = np.frombuffer(data, dtype=np.int16)
amplitudes = np.abs(audio_data)
all_amplitudes.extend(amplitudes)
@@ -772,10 +879,9 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
result["error"] = f"Error reading audio: {e}"
break
_safe_close_stream(stream)
audio.terminate()
if all_amplitudes:
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
if all_amplitudes and result["error"] is None:
all_amplitudes = np.array(all_amplitudes)
result["success"] = True
result["sample_count"] = len(all_amplitudes)
@@ -790,6 +896,17 @@ def test_audio_input(device_index: Optional[int] = None, duration: float = 1.0)
except (OSError, ValueError, RuntimeError) as e:
result["error"] = f"Unexpected error: {e}"
finally:
_safe_close_stream(stream)
if audio is not None:
try:
audio.terminate()
except (OSError, ValueError, RuntimeError) as exc:
logger.warning("Error terminating PyAudio after audio test: %s", exc)
result["success"] = False
if result["error"] is None:
result["error"] = f"Error closing audio test: {exc}"
return result
@@ -819,6 +936,8 @@ class PortAudioCaptureSource:
) -> None:
self.device_index = device_index
self.device_name = device_name
self.resolved_device_name: Optional[str] = None
self.last_error: Optional[OSError] = None
self.audio: Any = None
self.stream: Any = None
self.sample_rate = 16000
@@ -856,7 +975,7 @@ class PortAudioCaptureSource:
self.device_index is None and self.device_name is None
)
if use_system_default:
resolved_device_index = None
resolved_device_index = _resolve_system_default(audio)
else:
resolved_device_index = _resolve_device_by_name(
audio, self.device_name, self.device_index
@@ -869,7 +988,7 @@ class PortAudioCaptureSource:
logger.warning(
"No safe audio input devices enumerated; " "falling back to system default capture."
)
resolved_device_index = None
resolved_device_index = _resolve_system_default(audio)
use_system_default = True
# Log available devices for debugging (skip virtual devices)
@@ -905,6 +1024,7 @@ class PortAudioCaptureSource:
logger.info(
f"Using audio device [{resolved_device_index}]: {device_info.get('name')}"
)
self.resolved_device_name = device_info.get("name")
except (IOError, OSError):
logger.warning(f"Could not get info for device index {resolved_device_index}")
@@ -922,27 +1042,25 @@ class PortAudioCaptureSource:
"frames_per_buffer": CAPTURE_CHUNK,
}
# Use the resolved device (skip if already system default)
try:
default_idx = audio.get_default_input_device_info().get("index")
except (IOError, OSError):
default_idx = None
if resolved_device_index is not None and resolved_device_index != default_idx:
if resolved_device_index is not None:
stream_kwargs["input_device_index"] = resolved_device_index
with pulse_source_env(pulse_name):
self.stream = audio.open(**stream_kwargs)
def read_chunk(self) -> bytes:
def read_chunk(self, should_continue: Optional[Callable[[], bool]] = None) -> Optional[bytes]:
"""Read one chunk and return mono 16 kHz int16 PCM bytes.
Multi-channel captures are downmixed to mono (see
:func:`_downmix_to_mono`); devices negotiating above 16 kHz are
resampled so every consumer sees the format speech engines expect.
Return None when the optional cancellation predicate becomes false.
"""
import numpy as np
data: bytes = self.stream.read(CAPTURE_CHUNK, exception_on_overflow=False)
data = _read_frames_bounded(self.stream, CAPTURE_CHUNK, should_continue=should_continue)
if data is None:
return None
# Convert multi-channel capture to mono if necessary
# Speech recognition engines expect mono (1 channel) audio
@@ -966,11 +1084,14 @@ class PortAudioCaptureSource:
return data
def reopen(self, audio_instance: Any) -> bool:
def reopen(
self, audio_instance: Any, should_continue: Optional[Callable[[], bool]] = None
) -> bool:
"""Close the current stream and reopen on the resolved device.
Args:
audio_instance: The PyAudio instance to reconnect through.
should_continue: Optional predicate; False cancels validation.
Returns:
True when a new stream was opened and produced audio data.
@@ -981,12 +1102,16 @@ class PortAudioCaptureSource:
self.audio = audio_instance
new_stream: Any = None
self.last_error = None
try:
# Close existing stream if it exists
if self.stream:
_safe_close_stream(self.stream)
self.stream = None
if should_continue is not None and not should_continue():
return False
# Resolve a valid input device — by name first, then by index
pulse_selected, pulse_source = _resolve_pulse_selection(
self.device_index, self.device_name
@@ -996,7 +1121,7 @@ class PortAudioCaptureSource:
self.device_index is None and self.device_name is None
)
if use_system_default:
resolved_device_index = None
resolved_device_index = _resolve_system_default(audio_instance)
else:
resolved_device_index = _resolve_device_by_name(
audio_instance, self.device_name, self.device_index
@@ -1008,7 +1133,7 @@ class PortAudioCaptureSource:
"Reconnection: no safe input devices enumerated; "
"falling back to system default capture."
)
resolved_device_index = None
resolved_device_index = _resolve_system_default(audio_instance)
# Negotiate the format and open the capture stream in ONE
# PortAudio open — Bluetooth SCO devices abort with heap
@@ -1032,22 +1157,25 @@ class PortAudioCaptureSource:
"frames_per_buffer": CAPTURE_CHUNK,
}
# Use resolved device (skip if already system default)
try:
default_idx = audio_instance.get_default_input_device_info().get("index")
except (IOError, OSError):
default_idx = None
if resolved_device_index is not None and resolved_device_index != default_idx:
if resolved_device_index is not None:
stream_kwargs["input_device_index"] = resolved_device_index
with pulse_source_env(pulse_name):
new_stream = audio_instance.open(**stream_kwargs)
# Test the stream by reading a small amount of data
test_data = new_stream.read(CAPTURE_CHUNK, exception_on_overflow=False)
test_data = _read_frames_bounded(
new_stream, CAPTURE_CHUNK, should_continue=should_continue
)
if should_continue is not None and not should_continue():
_safe_close_stream(new_stream)
return False
if test_data:
self.stream = new_stream
self.resolved_device_name = _get_device_info_safe(
audio_instance, resolved_device_index
).get("name")
logger.info("Audio reconnection successful")
return True
else:
@@ -1056,6 +1184,7 @@ class PortAudioCaptureSource:
return False
except (IOError, OSError) as e:
self.last_error = e
logger.error(f"Audio reconnection failed: {e}")
_safe_close_stream(new_stream)
return False
+5
View File
@@ -0,0 +1,5 @@
"""Errors shared by microphone and system-audio capture sources."""
class CaptureStalledError(IOError):
"""The capture device stopped delivering audio frames."""
+45 -8
View File
@@ -28,6 +28,7 @@ from dataclasses import dataclass
from typing import Any, Callable, List, Optional, Tuple
from ..utils.host_process import host_env
from .errors import CaptureStalledError
logger = logging.getLogger(__name__)
@@ -327,6 +328,7 @@ class PipeWireCaptureSource:
) -> None:
self.device_index = device_index
self.device_name = device_name
self.last_error: Optional[OSError] = None
self.audio: Any = None
self.stream: Optional["subprocess.Popen[bytes]"] = None
self.sample_rate = sample_rate
@@ -375,12 +377,21 @@ class PipeWireCaptureSource:
env=host_env(),
)
def reopen(self, audio_instance: Any) -> bool:
def reopen(
self, audio_instance: Any, should_continue: Optional[Callable[[], bool]] = None
) -> bool:
"""Close the current process and spawn a fresh one. True on success."""
self.last_error = None
self.close()
if should_continue is not None and not should_continue():
return False
try:
self.open()
if should_continue is not None and not should_continue():
self.close()
return False
except (IOError, OSError) as exc:
self.last_error = exc
logger.error("PipeWire reconnection failed: %s", exc)
return False
return True
@@ -390,14 +401,16 @@ class PipeWireCaptureSource:
#: emits zeros — so a long gap means the process is wedged, not quiet.
_STALL_TIMEOUT_SECONDS = 5.0
def read_chunk(self) -> bytes:
def read_chunk(self, should_continue: Optional[Callable[[], bool]] = None) -> Optional[bytes]:
"""Return one 1024-frame chunk of mono int16 PCM.
Raises IOError when the process dies or when no samples arrive within
``_STALL_TIMEOUT_SECONDS`` — a blocking ``read`` would keep the
capture thread (and ``_buffer_lock``) held forever on a wedged
pw-record, preventing a clean stop.
pw-record, preventing a clean stop. Return None on cancellation.
"""
if should_continue is not None and not should_continue():
return None
process = self.stream
if process is None or process.stdout is None:
raise IOError("PipeWire capture stream is not open")
@@ -412,20 +425,26 @@ class PipeWireCaptureSource:
parts: List[bytes] = []
remaining = need
while remaining > 0:
if should_continue is not None and not should_continue():
return None
left = deadline - time.monotonic()
if left <= 0:
code = process.poll()
raise IOError(
raise CaptureStalledError(
"pw-record stalled: no audio for "
f"{self._STALL_TIMEOUT_SECONDS:.0f}s (exit status {code})"
)
if fd is not None:
try:
ready, _, _ = select.select([fd], [], [], left)
ready, _, _ = select.select(
[fd], [], [], min(left, 0.01) if should_continue is not None else left
)
except (OSError, ValueError) as exc:
raise IOError(f"PipeWire capture read failed: {exc}") from exc
if not ready:
continue
if should_continue is not None and not should_continue():
return None
try:
chunk = os.read(fd, remaining)
except OSError as exc:
@@ -437,6 +456,8 @@ class PipeWireCaptureSource:
chunk = process.stdout.read(remaining)
except (OSError, ValueError) as exc:
raise IOError(f"PipeWire capture read failed: {exc}") from exc
if should_continue is not None and not should_continue():
return None
if not chunk:
code = process.poll()
raise IOError(f"pw-record stopped streaming (exit status {code})")
@@ -477,13 +498,18 @@ class PipeWireCaptureSource:
self.stream = None
def _test_pipewire_input(device_index: int, duration: float = 1.0) -> dict:
def _test_pipewire_input(
device_index: int,
duration: float = 1.0,
should_continue: Optional[Callable[[], bool]] = None,
) -> dict:
"""
Test capture from a PipeWire system-audio source.
Args:
device_index: Negative PipeWire source index (see PIPEWIRE_INDEX_BASE)
duration: How long to record in seconds
should_continue: Optional predicate; False cancels the test.
Returns:
Dictionary in the same shape as :func:`test_audio_input`.
@@ -499,6 +525,9 @@ def _test_pipewire_input(device_index: int, duration: float = 1.0) -> dict:
"error": None,
}
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
return result
if resolve_pipewire_source(device_index, None) is None:
result["error"] = "PipeWire system-audio source is not available"
return result
@@ -507,17 +536,25 @@ def _test_pipewire_input(device_index: int, duration: float = 1.0) -> dict:
try:
import numpy as np
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
return result
source.open()
result["device_name"] = source.source.display_name if source.source else "Unknown"
result["sample_rate"] = source.sample_rate
all_amplitudes = []
chunks_to_read = max(1, int(source.sample_rate * duration / source._CHUNK_FRAMES))
for _ in range(chunks_to_read):
data = source.read_chunk()
data = source.read_chunk(should_continue=should_continue)
if data is None:
result["error"] = "Audio test cancelled"
break
audio_data = np.frombuffer(data, dtype=np.int16)
all_amplitudes.extend(np.abs(audio_data))
if all_amplitudes:
if should_continue is not None and not should_continue():
result["error"] = "Audio test cancelled"
if all_amplitudes and result["error"] is None:
all_amplitudes = np.array(all_amplitudes)
max_amplitude = float(np.max(all_amplitudes))
result["success"] = True
+11 -3
View File
@@ -155,6 +155,12 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Reconfigure the engine on the fly."""
...
def update_live_settings(
self, *, vad_sensitivity: Optional[int] = None, silence_timeout: Optional[float] = None
) -> None:
"""Apply VAD controls without stopping capture or replacing pending model work."""
...
def try_begin_download(self) -> bool:
"""Claim the engine for a model download. False if one is running."""
...
@@ -209,11 +215,13 @@ class CaptureSource(Protocol):
"""Resolve the input device and open the capture stream."""
...
def read_chunk(self) -> bytes:
"""Return one chunk of mono 16 kHz int16 PCM audio."""
def read_chunk(self, should_continue: Optional[Callable[[], bool]] = None) -> Optional[bytes]:
"""Return mono 16 kHz int16 PCM, or None when cancelled."""
...
def reopen(self, audio_instance: Any) -> bool:
def reopen(
self, audio_instance: Any, should_continue: Optional[Callable[[], bool]] = None
) -> bool:
"""Re-open the stream after a device failure. True on success."""
...
File diff suppressed because it is too large Load Diff
+47 -10
View File
@@ -192,6 +192,9 @@ logger = logging.getLogger(__name__)
# a reopened dialog shares the same engine and config manager, so both sides
# must agree on whose snapshot is newest.
_apply_settings_lock = threading.Lock()
# Live edits can arrive on GTK while a model applies on a worker. Hold this
# only around their short update/save boundary, never around a model load.
_live_settings_lock = threading.RLock()
_apply_settings_generation = 0
# Generation of the apply that last wrote each advanced key. A deferred
# persist uses it to tell "a newer apply rewrote this key" apart from "the
@@ -2553,6 +2556,8 @@ class SettingsDialog(AboutSectionMixin):
self._recording_shortcut_target: Optional[tuple[Any, Callable[[str], None], Any, Any]] = (
None
)
self._audio_test_cancel: Optional[threading.Event] = None
self.connect("destroy", self._cancel_audio_test)
self._test_active = False
self._test_token = 0
self._test_cancelled = False
@@ -7208,6 +7213,7 @@ class SettingsDialog(AboutSectionMixin):
def _on_settings_dialog_response(self, dialog, response_id):
"""Persist deferred text edits before the settings dialog closes."""
if response_id in (Gtk.ResponseType.CLOSE, Gtk.ResponseType.DELETE_EVENT):
self._cancel_audio_test()
self._flush_advanced_prompt_if_dirty()
self._resync_engine_ui_if_unapplied()
@@ -8918,13 +8924,15 @@ class SettingsDialog(AboutSectionMixin):
# not dropped while a download is staged. Not reconfigure() —
# it cancels a buffered recording and waits on its worker
# here on the GTK thread, and every whispercpp_* kwarg forces
# a model reload. Live-set only what actually changed; the
# a model reload. Apply live values through the
# manager so an overlapping model rollback cannot discard them;
# load-time params stay saved-only until the Download apply
# or the next start.
non_model_settings = {
key: value for key, value in settings.items() if key not in _MODEL_SPEC_KEYS
}
applied = True
live_settings: dict[str, Any] = {}
for key, clamp in _LIVE_ENGINE_SETTINGS.items():
if key not in non_model_settings:
continue
@@ -8934,11 +8942,12 @@ class SettingsDialog(AboutSectionMixin):
logger.error(f"Invalid {key} while staging: {e}")
applied = False
continue
if getattr(self.speech_engine, key, None) == live_value:
continue
setattr(self.speech_engine, key, live_value)
if non_model_settings and applied:
self._save_selected_settings(non_model_settings)
live_settings[key] = live_value
with _live_settings_lock:
if live_settings:
self.speech_engine.update_live_settings(**live_settings)
if non_model_settings and applied:
self._save_selected_settings(non_model_settings)
self._update_model_info()
return
@@ -9819,7 +9828,15 @@ For now, the engine has been reverted to VOSK."""
time.sleep(0.5)
self.speech_engine.reconfigure(force_reinit=force_reinit, **settings)
self._save_selected_settings(settings)
# A second dialog may have accepted live VAD edits while this
# model was loading. Its queued values are now active; save
# those instead of overwriting them with this older snapshot.
with _live_settings_lock:
accepted_settings = dict(settings)
for key, clamp in _LIVE_ENGINE_SETTINGS.items():
if key in accepted_settings:
accepted_settings[key] = clamp(getattr(self.speech_engine, key))
self._save_selected_settings(accepted_settings)
written_gen = (
apply_generation if apply_generation is not None else _apply_settings_generation
)
@@ -9948,8 +9965,26 @@ For now, the engine has been reverted to VOSK."""
logger.info(f"Audio device changed to: [{device_index}] {device_name}")
self.audio_test_status.set_markup(f"<i>Selected: {device_label}</i>")
def _cancel_audio_test(self, *_args: object) -> None:
"""Cancel capture and invalidate any queued result when Settings closes."""
cancel = getattr(self, "_audio_test_cancel", None)
if cancel is not None:
cancel.set()
self._audio_test_cancel = None
def _complete_audio_test(self, result: dict, cancel: threading.Event) -> bool:
"""Deliver only the current test's result on the GTK main thread."""
if cancel.is_set() or self._audio_test_cancel is not cancel:
return False
self._audio_test_cancel = None
self._handle_audio_test_result(result)
return False
def _on_test_audio_clicked(self, widget):
"""Handle test audio button click."""
self._cancel_audio_test()
cancel = threading.Event()
self._audio_test_cancel = cancel
self.test_audio_btn.set_sensitive(False)
self.test_audio_btn.set_label("Testing...")
self.audio_test_status.set_markup("<i>Recording... speak into your microphone</i>")
@@ -9958,11 +9993,13 @@ For now, the engine has been reverted to VOSK."""
device_id = self.audio_device_combo.get_active_id()
device_index = None if device_id == "-1" else int(device_id)
def run_test():
def run_test() -> None:
from ..speech_recognition.recognition_manager import test_audio_input
result = test_audio_input(device_index=device_index, duration=2.0)
GLib.idle_add(self._handle_audio_test_result, result)
result = test_audio_input(
device_index=device_index, duration=2.0, should_continue=lambda: not cancel.is_set()
)
GLib.idle_add(self._complete_audio_test, result, cancel)
threading.Thread(target=run_test, daemon=True).start()
+101 -38
View File
@@ -79,7 +79,13 @@ from .keyboard_backends import (
shortcut_gestures_collide,
)
from .keyboard_shortcuts import KeyboardShortcutManager
from .settings_dialog import ModelDownloadDialog, SettingsDialog, recommended_model_for_engine
from .settings_dialog import (
ModelDownloadDialog,
SettingsDialog,
_apply_settings_lock,
_live_settings_lock,
recommended_model_for_engine,
)
from .transcription_history import DEFAULT_MAX_ITEMS, TranscriptionHistory
from .tray_menu_model import GMenuIndicator
@@ -288,6 +294,7 @@ class TrayIndicator:
# Initialize the indicator (in the GTK main thread)
GLib.idle_add(self._init_indicator)
self._system_suspended = False
self._suspend_handler = SuspendHandler(
on_suspend=self._on_system_suspend,
on_resume=self._on_system_resume,
@@ -1013,33 +1020,62 @@ class TrayIndicator:
try:
self.speech_engine.set_download_progress_callback(on_progress)
self.speech_engine.reconfigure(
engine=engine,
model_size=recommendation.model_id,
force_download=True,
# reconfigure() only re-initializes when engine, model or
# language change, and force_download is read during that
# re-init. The recommendation is often what is already
# configured (first run is whisper_cpp + tiny, and tiny is what
# a CPU-only machine is recommended), so without this the call
# is a no-op that downloads nothing.
force_reinit=True,
)
if not self.speech_engine.model_ready:
raise RuntimeError("the engine did not load the model after downloading it")
try:
# The download+load is long; holding _apply_settings_lock across
# it would freeze every GTK apply (e.g. Test Dictation). The
# engine serializes reconfigures internally, so the lock is
# only needed around the verify+save: an apply landing after
# the load must keep its own selection and its receipt.
self.speech_engine.reconfigure(
engine=engine,
model_size=recommendation.model_id,
force_download=True,
# A first-run recommendation often matches the current
# selection; force re-init so it still fetches the model.
force_reinit=True,
)
with _apply_settings_lock:
if (
getattr(self.speech_engine, "engine", None) != engine
or getattr(self.speech_engine, "model_size", None)
!= recommendation.model_id
):
raise RuntimeError(
"another speech model was selected before this download completed"
)
paused = getattr(self.speech_engine, "is_auto_paused", False) is True
if not self.speech_engine.model_ready and not paused:
raise RuntimeError("the engine did not load the model after downloading it")
# Persist only now: the model is on disk and the engine loaded it.
self.config_manager.set_model_size_for_engine(engine, recommendation.model_id)
self.config_manager.save_config()
shortcut = self.config_manager.get_str(
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
)
# Auto-pause can unload the verified model before this worker
# returns. Keep the accepted selection for its deferred resume.
with _live_settings_lock:
self.config_manager.set_model_size_for_engine(
engine, recommendation.model_id
)
self.config_manager.save_config()
if paused:
title = "Speech model downloaded"
body = (
f"{recommendation.display_name} is installed. "
"Dictation will be available when Vocalinux resumes."
)
else:
shortcut = self.config_manager.get_str(
"shortcuts", "toggle_recognition", DEFAULT_SHORTCUT
)
title = "Speech model ready"
body = (
f"{recommendation.display_name} is installed. "
f"Press {shortcut} to dictate."
)
finally:
self.speech_engine.set_download_progress_callback(None)
_idle_once(notifications.close, progress)
_idle_once(
notifications.notify,
"Speech model ready",
f"{recommendation.display_name} is installed. Press {shortcut} to dictate.",
title,
body,
"emblem-ok",
)
except Exception as e:
@@ -1053,7 +1089,6 @@ class TrayIndicator:
"dialog-error",
)
finally:
self.speech_engine.set_download_progress_callback(None)
self.speech_engine.end_download()
self._model_download_active = False
@@ -1681,24 +1716,37 @@ class TrayIndicator:
if self.speech_engine.state != RecognitionState.IDLE:
self.speech_engine.stop_recognition()
def _on_auto_resume(self):
def _on_auto_resume(self) -> None:
"""Reload speech model after configured games/apps have exited."""
if getattr(self, "_system_suspended", False):
# Wake will recover even if the monitor does not transition again.
return
logger.info("Auto-pause cleared — reloading speech model")
reinit = getattr(self.speech_engine, "reinitialize_after_resume", None)
if callable(reinit):
try:
reinit()
reinit(auto_resume=True)
except Exception:
logger.error("Failed to reload speech engine after auto-pause", exc_info=True)
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.bump()
def _on_system_suspend(self):
def _on_system_suspend(self) -> None:
"""Stop active recognition before the system goes to sleep."""
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.cancel()
self._system_suspended = True
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(monitor, "paused", False) is True:
# Replace pending initialization with a current unload obligation;
# invalidating it alone would lose cleanup until the game exits.
self._on_auto_pause()
else:
cancel_pending = getattr(self.speech_engine, "cancel_pending_auto_pause", None)
if callable(cancel_pending):
cancel_pending()
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.cancel()
# Release the internal hotkey listeners (and their EVIOCGRABs)
# before the machine sleeps: a grab held through suspend keeps
# feeding a forwarder whose liveness across resume cannot be
@@ -1711,7 +1759,7 @@ class TrayIndicator:
logger.info("System suspending — stopping active recognition")
self.speech_engine.stop_recognition()
def _on_system_resume(self):
def _on_system_resume(self) -> None:
"""Reinitialize subsystems after the system wakes up.
Speech engine reinitializes after 2s (audio hardware recovers fast).
@@ -1721,12 +1769,21 @@ class TrayIndicator:
If auto-pause still has a configured app running, skip speech reinit —
the auto-pause monitor owns model lifecycle until that app exits.
"""
self._system_suspended = False
logger.info("System resumed — scheduling reinit")
# Use `is True` so duck-typed mocks without a real bool flag still reinit.
if getattr(self.speech_engine, "is_auto_paused", False) is True:
# The manager also stays guarded during deferred auto-resume. If sleep
# cancelled that work after the game exited, wake must finish recovery.
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(monitor, "paused", False) is True or (
monitor is None and getattr(self.speech_engine, "is_auto_paused", False) is True
):
logger.info("Skipping resume reinit: auto-pause still active")
else:
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume)
# Own recovery during the hardware-settle delay too, so a failed
# settings change can inherit it while superseding this timer.
prepare = getattr(self.speech_engine, "prepare_auto_resume", None)
request = prepare() if callable(prepare) else None
GLib.timeout_add_seconds(2, self._reinit_speech_after_resume, request)
# External-activation mode never started the /dev/input listener in
# the first place; watching it here on every resume would open the
@@ -1736,15 +1793,21 @@ class TrayIndicator:
else:
GLib.timeout_add_seconds(2, self._start_input_device_monitor)
def _reinit_speech_after_resume(self):
def _reinit_speech_after_resume(self, request: Optional[object] = None) -> bool:
"""Recover only while this wake still owns the model lifecycle."""
monitor = getattr(self, "_auto_pause_monitor", None)
if getattr(self, "_system_suspended", False) or getattr(monitor, "paused", False) is True:
return False
try:
self.speech_engine.reinitialize_after_resume()
reinit = getattr(self.speech_engine, "reinitialize_after_resume", None)
if callable(reinit):
reinit(auto_resume=True, expected_request=request)
except Exception:
logger.error("Failed to reinitialize after resume", exc_info=True)
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.bump()
return GLib.SOURCE_REMOVE
return False
def _start_input_device_monitor(self):
"""Watch /dev/input for changes; restart keyboard when devices settle."""
+10 -1
View File
@@ -443,7 +443,10 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase):
"""SpeechRecognitionManager.unload_model and start_recognition guard."""
def _make_manager(self):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
_CaptureSession,
)
with patch.object(SpeechRecognitionManager, "__init__", lambda self: None):
mgr = SpeechRecognitionManager()
@@ -463,6 +466,12 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase):
mgr._http_session = None
mgr._faster_whisper_engine = None
mgr._reconnection_attempts = 0
mgr._lifecycle_lock = threading.RLock()
mgr._worker_session = threading.local()
mgr._session = _CaptureSession()
mgr._buffer_lock = threading.Lock()
mgr.audio_thread = None
mgr.recognition_thread = None
mgr._update_state = MagicMock()
mgr._recognition_workers = []
mgr._recognition_workers_lock = threading.Lock()
+263 -3
View File
@@ -432,6 +432,103 @@ def test_late_worker_handoff_keeps_processing_until_transcription_finishes() ->
assert not worker_thread.is_alive()
def test_worker_finishing_during_stop_handoff_returns_to_idle() -> None:
"""Completion between stop's final check and handoff cannot strand PROCESSING."""
manager = _make_manager()
worker_finishing = threading.Event()
release_worker = threading.Event()
release_held = manager._release_held_transcripts
def finish_worker(
gate: Optional[_ReleaseGate],
force: bool = False,
cancel: Optional[threading.Event] = None,
) -> None:
release_held(gate, force=force, cancel=cancel)
if force:
worker_finishing.set()
assert release_worker.wait(timeout=3.0)
manager._release_held_transcripts = finish_worker
_start_session(manager, _no_audio_capture)
worker = manager.recognition_thread
worker_exited = manager._worker_exited
assert worker is not None and worker_exited is not None
is_set = worker_exited.is_set
stopper = threading.current_thread()
handoff_checked = threading.Event()
def finish_during_check() -> bool:
finished = is_set()
if threading.current_thread() is stopper and not finished:
assert worker_finishing.is_set()
handoff_checked.set()
release_worker.set()
# A synchronized handoff blocks completion until stop releases it.
worker.join(timeout=0.2)
return finished
try:
with patch.object(worker_exited, "is_set", side_effect=finish_during_check):
_stop_with_short_worker_joins(manager)
finally:
release_worker.set()
worker.join(timeout=1.0)
assert handoff_checked.is_set()
assert not worker.is_alive()
assert manager.state is RecognitionState.IDLE
assert not manager.is_transcribing()
def test_late_worker_idle_callback_does_not_hold_up_another_stop() -> None:
"""An arbitrary state callback runs outside the lifecycle critical section."""
manager = _make_manager()
transcribing = threading.Event()
release_transcriber = threading.Event()
callback_entered = threading.Event()
release_callback = threading.Event()
stopped = threading.Event()
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "final text"
def state_changed(state: RecognitionState) -> None:
if state is RecognitionState.IDLE:
callback_entered.set()
assert release_callback.wait(3)
def stop_again() -> None:
manager.stop_recognition()
stopped.set()
manager._transcribe_with_whispercpp = transcribe
manager._voice_commands_enabled = False
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"slow audio"])
assert transcribing.wait(1)
_stop_with_short_worker_joins(manager)
manager.register_state_callback(state_changed)
worker = manager.recognition_thread
assert worker is not None
stopper = threading.Thread(target=stop_again)
try:
release_transcriber.set()
assert callback_entered.wait(1)
stopper.start()
assert stopped.wait(0.5)
assert not release_callback.is_set()
finally:
release_transcriber.set()
release_callback.set()
worker.join(1)
if stopper.ident is not None:
stopper.join(1)
assert not worker.is_alive()
assert not stopper.is_alive()
def test_cancel_late_worker_discards_text_and_next_session_succeeds() -> None:
manager = _make_manager()
transcriber_started = threading.Event()
@@ -643,11 +740,12 @@ def test_stale_capture_reconnection_does_not_touch_new_session_device() -> None:
manager._capture_source = new_source
manager._capture_source_for_session = MagicMock(return_value=new_source)
def new_session_starts(_delay: float) -> None:
def new_session_starts(_delay: float) -> bool:
old_session.set()
manager._session_cancel = threading.Event()
return False
with patch.object(recognition_module.time, "sleep", side_effect=new_session_starts):
with patch.object(manager._session.cancel, "wait", side_effect=new_session_starts):
assert manager._attempt_audio_reconnection(MagicMock(), old_session) is False
manager._capture_source_for_session.assert_not_called()
@@ -665,8 +763,9 @@ def test_cancelled_capture_thread_does_not_write_into_new_session_buffer() -> No
source.requires_pyaudio = False
source.sample_rate = 16000
def read_chunk() -> bytes:
def read_chunk(should_continue: Callable[[], bool]) -> bytes:
nonlocal reads
assert should_continue()
reads += 1
if reads == 1:
old_session.set()
@@ -683,3 +782,164 @@ def test_cancelled_capture_thread_does_not_write_into_new_session_buffer() -> No
assert manager.audio_buffer == [b"new-session"]
assert manager.state is not RecognitionState.ERROR
source.close.assert_called_once()
def test_cancel_idle_callback_can_start_a_listening_session() -> None:
"""A callback's explicit start owns its state while the old stopper stays stale."""
manager = _make_manager()
manager._perform_recognition = lambda: None
restarted: list[bool] = []
def restart(state: RecognitionState) -> None:
if state is RecognitionState.IDLE and not restarted:
restarted.append(manager.start_recognition(mode="push_to_talk"))
manager.register_state_callback(restart)
_start_session(manager, _no_audio_capture)
try:
assert manager.cancel_transcription()
assert restarted == [True]
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
finally:
manager.should_record = False
manager._session.cancel.set()
manager._session_cancel.set()
manager._signal_recognition_stop()
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(timeout=1.0)
def test_old_cancel_drain_cannot_stop_a_new_session() -> None:
"""Finishing an old cancellation cannot stop a recording started during its drain."""
manager = _make_manager()
manager._voice_commands_enabled = False
transcribing = threading.Event()
release_transcriber = threading.Event()
draining = threading.Event()
release_cancel = threading.Event()
cancel_results: list[bool] = []
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "old text"
manager._transcribe_with_whispercpp = transcribe
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"old audio"])
assert transcribing.wait(1)
old_queue = manager._segment_queue
get_nowait = old_queue.get_nowait
gated = False
def pause_cancel_drain() -> object:
nonlocal gated
if not gated:
gated = True
draining.set()
assert release_cancel.wait(3)
return get_nowait()
stopper = threading.Thread(target=_stop_session, args=(manager,))
canceller = threading.Thread(
target=lambda: cancel_results.append(manager.cancel_transcription())
)
try:
stopper.start()
assert manager._session.capture_finished.wait(1)
with patch.object(old_queue, "get_nowait", side_effect=pause_cancel_drain):
canceller.start()
assert draining.wait(1)
release_transcriber.set()
stopper.join(1)
assert not stopper.is_alive()
assert manager.state is RecognitionState.IDLE
_start_session(manager, _no_audio_capture)
new_session = manager._session
release_cancel.set()
canceller.join(2)
assert not canceller.is_alive()
assert cancel_results == [True]
assert manager._session is new_session
assert not new_session.cancel.is_set()
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
finally:
release_cancel.set()
release_transcriber.set()
if canceller.ident is not None:
canceller.join(2)
if stopper.ident is not None:
stopper.join(2)
manager._session_cancel.set()
if manager.should_record:
_stop_session(manager)
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(1)
def test_old_stopper_signals_only_its_original_queue() -> None:
"""A delayed old stop sentinel cannot terminate the next session's consumer."""
manager = _make_manager()
manager._voice_commands_enabled = False
transcribing = threading.Event()
release_transcriber = threading.Event()
stop_signalling = threading.Event()
release_signal = threading.Event()
stop_calls = 0
stopper: threading.Thread
def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str:
transcribing.set()
assert release_transcriber.wait(3)
return "old text"
manager._transcribe_with_whispercpp = transcribe
_start_session(manager, _no_audio_capture)
manager._enqueue_audio_segment([b"old audio"])
assert transcribing.wait(1)
signal = manager._signal_recognition_stop
def pause_late_signal(segments: Optional[queue.Queue] = None) -> None:
nonlocal stop_calls
if threading.current_thread() is stopper:
stop_calls += 1
if stop_calls == 2:
stop_signalling.set()
assert release_signal.wait(3)
if segments is None:
signal()
else:
signal(segments)
stopper = threading.Thread(target=_stop_with_short_worker_joins, args=(manager,))
new_worker: Optional[threading.Thread] = None
try:
with patch.object(manager, "_signal_recognition_stop", side_effect=pause_late_signal):
stopper.start()
assert stop_signalling.wait(1)
assert manager.cancel_transcription()
_start_session(manager, _no_audio_capture)
new_worker = manager.recognition_thread
assert new_worker is not None
release_signal.set()
stopper.join(1)
assert not stopper.is_alive()
new_worker.join(0.2)
assert manager.should_record
assert manager.state is RecognitionState.LISTENING
assert new_worker.is_alive()
finally:
release_signal.set()
release_transcriber.set()
if stopper.ident is not None:
stopper.join(2)
manager._session_cancel.set()
if manager.should_record:
_stop_session(manager)
for worker in (manager.audio_thread, manager.recognition_thread):
if worker is not None:
worker.join(1)
+362
View File
@@ -0,0 +1,362 @@
"""Deterministic capture deadlines, cancellation, and resource ownership tests."""
import io
import math
import os
import sys
import threading
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
import pytest
from vocalinux.audio import capture, pipewire
@pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
"""Advance capture's monotonic clock only when it polls, without sleeping."""
timer = SimpleNamespace(now=0.0, sleeps=[])
def sleep(seconds: float) -> None:
timer.sleeps.append(seconds)
timer.now += seconds
monkeypatch.setattr(capture, "time", SimpleNamespace(monotonic=lambda: timer.now, sleep=sleep))
return timer
@pytest.fixture
def stream() -> MagicMock:
"""A ready blocking stream with exactly one mono chunk available."""
result = MagicMock()
result.get_read_available.return_value = capture.CAPTURE_CHUNK
result.read.return_value = b"\x01\x00" * capture.CAPTURE_CHUNK
return result
def test_capture_waits_for_full_chunk(stream: MagicMock, clock: SimpleNamespace) -> None:
stream.get_read_available.side_effect = [0, 1023, 1024]
assert capture._read_frames_bounded(stream, 1024) == stream.read.return_value
stream.read.assert_called_once_with(1024, exception_on_overflow=False)
assert clock.sleeps == [0.01, 0.01]
def test_capture_deadline_never_calls_blocking_read(
stream: MagicMock, clock: SimpleNamespace
) -> None:
stream.get_read_available.return_value = 0
with pytest.raises(capture.CaptureStalledError, match="stalled"):
capture._read_frames_bounded(stream, 1024, timeout=0.025)
stream.read.assert_not_called()
assert math.isclose(clock.now, 0.025, rel_tol=0, abs_tol=1e-9)
assert max(clock.sleeps) <= 0.01
@pytest.mark.parametrize("ready", [0, 1024])
def test_capture_cancelled_before_read(stream: MagicMock, ready: int) -> None:
stream.get_read_available.return_value = ready
assert capture._read_frames_bounded(stream, 1024, should_continue=lambda: False) is None
stream.get_read_available.assert_not_called()
stream.read.assert_not_called()
def test_capture_cancelled_during_readiness_check(stream: MagicMock) -> None:
cancel = threading.Event()
def available() -> int:
cancel.set()
return 1024
stream.get_read_available.side_effect = available
assert (
capture._read_frames_bounded(stream, 1024, should_continue=lambda: not cancel.is_set())
is None
)
stream.read.assert_not_called()
def test_capture_cancelled_while_polling(stream: MagicMock, clock: SimpleNamespace) -> None:
stream.get_read_available.return_value = 0
assert (
capture._read_frames_bounded(stream, 1024, should_continue=lambda: clock.now < 0.02) is None
)
assert math.isclose(clock.now, 0.02, rel_tol=0, abs_tol=1e-9)
stream.read.assert_not_called()
@pytest.mark.parametrize("error", [OSError(-9981, "overflow"), OSError("overflow", -9981)])
@pytest.mark.parametrize("method", ["get_read_available", "read"])
def test_capture_tolerates_only_input_overflow(
stream: MagicMock, clock: SimpleNamespace, error: OSError, method: str
) -> None:
operation = getattr(stream, method)
operation.side_effect = [error, operation.return_value]
assert capture._read_frames_bounded(stream, 1024) == stream.read.return_value
assert clock.sleeps == [0.01]
@pytest.mark.parametrize("method", ["get_read_available", "read"])
def test_capture_propagates_device_errors(stream: MagicMock, method: str) -> None:
error = OSError(-9999, "device lost")
getattr(stream, method).side_effect = error
with pytest.raises(OSError) as raised:
capture._read_frames_bounded(stream, 1024)
assert raised.value is error
def test_overflow_does_not_reset_deadline(stream: MagicMock, clock: SimpleNamespace) -> None:
stream.get_read_available.side_effect = OSError("overflow", -9981)
with pytest.raises(capture.CaptureStalledError):
capture._read_frames_bounded(stream, 1024, timeout=0.02)
assert math.isclose(clock.now, 0.02, rel_tol=0, abs_tol=1e-9)
def test_portaudio_chunk_preserves_normalization(
stream: MagicMock, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy)
stream.read.return_value = b"\x64\x00\x64\x00" * 1024
source = capture.PortAudioCaptureSource()
source.stream = stream
source.channels = 2
source.sample_rate = 48000
data = source.read_chunk()
assert data == b"\x64\x00" * int(1024 * 16000 / 48000)
assert source.read_chunk(should_continue=lambda: False) is None
assert stream.read.call_count == 1
@pytest.fixture
def audio_test(monkeypatch: pytest.MonkeyPatch, stream: MagicMock) -> tuple[MagicMock, MagicMock]:
"""Supply one negotiated stream without touching PortAudio or the desktop."""
audio = MagicMock()
audio.get_default_input_device_info.return_value = {"name": "Test Mic", "index": 3}
monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy)
monkeypatch.setitem(sys.modules, "pyaudio", MagicMock(PyAudio=lambda: audio, paInt16=8))
monkeypatch.setattr(capture, "_open_capture_stream", lambda *_args: (1, 16000, stream))
return audio, stream
@pytest.mark.parametrize("failure", ["stall", "cancel", "device"])
def test_audio_test_partial_failure_is_unsuccessful_and_cleans_up(
audio_test: tuple[MagicMock, MagicMock], failure: str
) -> None:
audio, stream = audio_test
cancel = threading.Event()
def available() -> int:
if stream.read.called:
if failure == "cancel":
cancel.set()
else:
raise (
capture.CaptureStalledError("stalled")
if failure == "stall"
else OSError("device lost")
)
return 1024
stream.get_read_available.side_effect = available
result = capture.test_audio_input(duration=0.2, should_continue=lambda: not cancel.is_set())
assert result["success"] is False
assert result["error"]
stream.read.assert_called_once()
stream.stop_stream.assert_called_once()
stream.close.assert_called_once()
audio.terminate.assert_called_once()
def test_audio_test_stall_is_bounded(
audio_test: tuple[MagicMock, MagicMock], clock: SimpleNamespace
) -> None:
audio, stream = audio_test
stream.get_read_available.return_value = 0
result = capture.test_audio_input()
assert result["success"] is False
assert "stalled" in result["error"]
assert math.isclose(clock.now, 5.0, rel_tol=0, abs_tol=1e-9)
stream.read.assert_not_called()
stream.close.assert_called_once()
audio.terminate.assert_called_once()
@pytest.mark.parametrize("failure", ["stall", "cancel", "cancel_after_read"])
def test_reopen_cleans_up_failed_validation(
audio_test: tuple[MagicMock, MagicMock], clock: SimpleNamespace, failure: str
) -> None:
audio, stream = audio_test
source = capture.PortAudioCaptureSource()
old_stream = MagicMock()
source.stream = old_stream
cancel = threading.Event()
if failure == "stall":
stream.get_read_available.return_value = 0
elif failure == "cancel":
def available() -> int:
cancel.set()
return 1024
stream.get_read_available.side_effect = available
else:
def read(*_args: Any, **_kwargs: Any) -> bytes:
cancel.set()
return b"\x00\x00" * 1024
stream.read.side_effect = read
assert source.reopen(audio, should_continue=lambda: not cancel.is_set()) is False
assert source.stream is None
old_stream.close.assert_called_once()
stream.close.assert_called_once()
if failure == "stall":
assert math.isclose(clock.now, 5.0, rel_tol=0, abs_tol=1e-9)
def test_reopen_cancelled_before_open(audio_test: tuple[MagicMock, MagicMock]) -> None:
audio, stream = audio_test
source = capture.PortAudioCaptureSource()
assert source.reopen(audio, should_continue=lambda: False) is False
stream.get_read_available.assert_not_called()
audio.open.assert_not_called()
def test_pipewire_select_is_sliced_and_cancellable(monkeypatch: pytest.MonkeyPatch) -> None:
read_fd, write_fd = os.pipe()
source = pipewire.PipeWireCaptureSource()
cancel = threading.Event()
try:
with os.fdopen(read_fd, "rb", buffering=0) as stdout:
source.stream = MagicMock(stdout=stdout)
def select(*args: Any) -> tuple[list, list, list]:
assert args[3] <= 0.01
cancel.set()
return [], [], []
monkeypatch.setattr(pipewire.select, "select", select)
assert source.read_chunk(should_continue=lambda: not cancel.is_set()) is None
finally:
os.close(write_fd)
@pytest.mark.parametrize("failure", ["cancel", "stall"])
def test_pipewire_test_partial_failure_cleans_up(
monkeypatch: pytest.MonkeyPatch, failure: str
) -> None:
monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy)
source = MagicMock(sample_rate=16000, _CHUNK_FRAMES=1024)
source.read_chunk.side_effect = [
b"\x01\x00" * 1024,
None if failure == "cancel" else OSError("stalled"),
]
monkeypatch.setattr(pipewire, "resolve_pipewire_source", lambda *_args: object())
monkeypatch.setattr(pipewire, "PipeWireCaptureSource", lambda **_kwargs: source)
result = pipewire._test_pipewire_input(-2, duration=0.2)
assert result["success"] is False
assert result["error"]
source.close.assert_called_once()
def test_reopen_success_retains_the_single_negotiated_stream(
audio_test: tuple[MagicMock, MagicMock],
) -> None:
audio, stream = audio_test
source = capture.PortAudioCaptureSource()
assert source.reopen(audio) is True
assert source.stream is stream
stream.read.assert_called_once_with(1024, exception_on_overflow=False)
stream.close.assert_not_called()
audio.open.assert_not_called()
def test_audio_test_cleans_up_when_negotiation_fails(
audio_test: tuple[MagicMock, MagicMock], monkeypatch: pytest.MonkeyPatch
) -> None:
audio, _stream = audio_test
def negotiate(*_args: Any) -> None:
raise OSError("device disappeared")
monkeypatch.setattr(capture, "_open_capture_stream", negotiate)
result = capture.test_audio_input()
assert result["success"] is False
assert "device disappeared" in result["error"]
audio.terminate.assert_called_once()
def test_audio_test_routes_cancellation_to_pipewire(monkeypatch: pytest.MonkeyPatch) -> None:
run_test = MagicMock(return_value={"success": False})
monkeypatch.setattr(capture, "_test_pipewire_input", run_test)
def predicate() -> bool:
return False
assert capture.test_audio_input(-2, 2.0, predicate) == {"success": False}
run_test.assert_called_once_with(-2, 2.0, should_continue=predicate)
def test_audio_test_cancels_before_pulse_source_lookup(monkeypatch: pytest.MonkeyPatch) -> None:
lookup = MagicMock()
monkeypatch.setattr(capture, "_resolve_pulse_selection", lookup)
result = capture.test_audio_input(device_index=-1000, should_continue=lambda: False)
assert result["success"] is False
assert result["error"] == "Audio test cancelled"
lookup.assert_not_called()
def test_pipewire_cancel_discards_partial_chunk(monkeypatch: pytest.MonkeyPatch) -> None:
cancel = threading.Event()
source = pipewire.PipeWireCaptureSource()
stdout = MagicMock()
stdout.fileno.side_effect = io.UnsupportedOperation()
def read(_remaining: int) -> bytes:
cancel.set()
return b"\x01\x00"
stdout.read.side_effect = read
source.stream = MagicMock(stdout=stdout)
assert source.read_chunk(should_continue=lambda: not cancel.is_set()) is None
stdout.read.assert_called_once()
@pytest.mark.parametrize("after_open", [False, True])
def test_pipewire_reopen_cancellation_closes_process(
monkeypatch: pytest.MonkeyPatch, after_open: bool
) -> None:
source = pipewire.PipeWireCaptureSource()
cancel = threading.Event()
close = MagicMock()
open_stream = MagicMock(side_effect=cancel.set)
monkeypatch.setattr(source, "close", close)
monkeypatch.setattr(source, "open", open_stream)
if not after_open:
cancel.set()
assert source.reopen(None, should_continue=lambda: not cancel.is_set()) is False
assert open_stream.call_count == int(after_open)
assert close.call_count == 1 + int(after_open)
@pytest.mark.parametrize("capture_fails", [False, True])
@pytest.mark.parametrize("error_type", [OSError, ValueError, RuntimeError])
def test_audio_test_reports_termination_failure(
audio_test: tuple[MagicMock, MagicMock], capture_fails: bool, error_type: type[Exception]
) -> None:
"""Cleanup failures return a result without masking the capture failure."""
audio, stream = audio_test
audio.terminate.side_effect = error_type("termination failed")
if capture_fails:
stream.get_read_available.side_effect = OSError("device lost")
result = capture.test_audio_input(duration=0.1)
assert result["success"] is False
assert ("device lost" if capture_fails else "termination failed") in result["error"]
stream.stop_stream.assert_called_once()
stream.close.assert_called_once()
audio.terminate.assert_called_once()
+387
View File
@@ -0,0 +1,387 @@
"""Capture recovery must honor stop and leave failed dictation restartable."""
import sys
import threading
from collections.abc import Generator
from typing import Callable
from unittest.mock import Mock
import pytest
from vocalinux.audio import capture
from vocalinux.common_types import RecognitionState
from vocalinux.speech_recognition import recognition_manager as rm
@pytest.fixture
def manager(monkeypatch: pytest.MonkeyPatch) -> Generator[rm.SpeechRecognitionManager, None, None]:
monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_vosk", lambda self: None)
monkeypatch.setattr(rm, "load_silero_vad", lambda: None)
monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy)
for name in ("play_start_sound", "play_stop_sound", "play_error_sound"):
monkeypatch.setattr(rm, name, Mock())
# Close this patch before the autouse notification patch exits.
with monkeypatch.context() as patcher:
patcher.setattr(rm, "_show_notification", Mock())
instance = rm.SpeechRecognitionManager(engine="vosk")
instance.model = object()
instance._model_initialized = True
instance._reconnection_delay = 0
instance.stop_sound_guard_ms = 0
yield instance
instance._session.cancel.set()
if instance.should_record:
instance.stop_recognition()
for worker in (instance.audio_thread, instance.recognition_thread):
if worker is not None:
worker.join(2)
assert not worker.is_alive()
@pytest.fixture
def source(manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch) -> Mock:
source = Mock(
requires_pyaudio=False,
sample_rate=16000,
channels=1,
audio=None,
resolved_device_name="Webcam (hw:1,0)",
last_error=None,
)
source.reopen.return_value = False
monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
manager.should_record = True
manager.state = RecognitionState.LISTENING
return source
@pytest.mark.parametrize("entry", ["open", "read_chunk"])
def test_stop_after_failure_prevents_recovery_and_error_reporting(
manager: rm.SpeechRecognitionManager, source: Mock, entry: str
) -> None:
def fail_after_stop(*_args: object, **_kwargs: object) -> None:
manager.stop_recognition()
raise capture.CaptureStalledError("stopped device")
getattr(source, entry).side_effect = fail_after_stop
manager._record_audio()
source.reopen.assert_not_called()
source.close.assert_called_once()
rm.play_error_sound.assert_not_called()
rm._show_notification.assert_not_called()
assert manager.state == RecognitionState.IDLE
def test_stop_interrupts_backoff_before_reopen(
manager: rm.SpeechRecognitionManager, source: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
manager._capture_source = source
manager._reconnection_delay = 60
entered = threading.Event()
wait = manager._session.cancel.wait
results = []
def backoff(seconds: float) -> bool:
entered.set()
return wait(seconds)
monkeypatch.setattr(manager._session.cancel, "wait", backoff)
worker = threading.Thread(
target=lambda: results.append(
manager._attempt_audio_reconnection(None, should_continue=lambda: manager.should_record)
)
)
worker.start()
try:
assert entered.wait(1)
manager.stop_recognition()
finally:
manager._session.cancel.set()
worker.join(1)
assert not worker.is_alive()
assert results == [False]
source.reopen.assert_not_called()
def test_cancellation_after_backoff_prevents_reopen(
manager: rm.SpeechRecognitionManager, source: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
manager._capture_source = source
def backoff(_seconds: float) -> bool:
manager.should_record = False
return False
monkeypatch.setattr(manager._session.cancel, "wait", backoff)
assert not manager._attempt_audio_reconnection(
None, should_continue=lambda: manager.should_record
)
source.reopen.assert_not_called()
def test_cancellation_after_validation_prevents_capture_state_publication(
manager: rm.SpeechRecognitionManager, source: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
manager._capture_source = source
sync = Mock()
monkeypatch.setattr(manager, "_sync_capture_state", sync)
def reopen(_audio: object, should_continue: Callable[[], bool]) -> bool:
assert should_continue()
manager.should_record = False
return True
source.reopen.side_effect = reopen
assert not manager._attempt_audio_reconnection(
None, should_continue=lambda: manager.should_record
)
sync.assert_not_called()
@pytest.mark.parametrize("entry", ["open", "read_chunk"])
def test_only_one_stall_recovery_and_original_device_is_reported(
manager: rm.SpeechRecognitionManager, source: Mock, caplog: pytest.LogCaptureFixture, entry: str
) -> None:
errors = iter(
[capture.CaptureStalledError("first stall"), capture.CaptureStalledError("again")]
)
states = []
closed_on = []
timeline = []
manager.state_callbacks.append(states.append)
def read(**_kwargs: object) -> bytes:
manager._last_audio_error_time = 0 # Exercise the limit, not the time throttle.
raise next(errors)
if entry == "open":
def fail_open(_audio: object) -> None:
raise next(errors)
source.open.side_effect = fail_open
source.read_chunk.side_effect = read
source.reopen.return_value = True
def close() -> None:
closed_on.append(threading.current_thread())
timeline.append("closed")
source.close.side_effect = close
rm.play_error_sound.side_effect = lambda: timeline.append("error cue")
manager._record_audio()
assert timeline == ["closed", "error cue"]
source.reopen.assert_called_once()
assert closed_on == [threading.current_thread()]
rm.play_error_sound.assert_called_once()
rm.play_stop_sound.assert_not_called()
rm._show_notification.assert_called_once()
notification = rm._show_notification.call_args.args
assert notification[0] == "Microphone Stalled"
assert "Webcam (hw:1,0)" in notification[1] and "Settings" in notification[1]
assert any(
record.levelname == "ERROR"
and "first stall" in record.message
and "Webcam (hw:1,0)" in record.message
for record in caplog.records
)
assert RecognitionState.ERROR in states
assert states[-1] == RecognitionState.IDLE
assert not manager.should_record
@pytest.mark.parametrize("initial_stall", [False, True])
def test_validation_stall_preserves_cause_and_resolved_device(
manager: rm.SpeechRecognitionManager,
monkeypatch: pytest.MonkeyPatch,
initial_stall: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
source = capture.PortAudioCaptureSource()
audio = Mock()
audio.get_device_count.return_value = 0
audio.get_default_input_device_info.return_value = {"index": 9, "name": "Original microphone"}
first_stream = Mock()
stream = Mock()
monkeypatch.setitem(sys.modules, "pyaudio", Mock(PyAudio=lambda: audio, paInt16=8))
monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
monkeypatch.setattr(
capture,
"_open_capture_stream",
Mock(side_effect=[(1, 16000, first_stream), (1, 16000, stream)]),
)
first_error = (
capture.CaptureStalledError("original stall") if initial_stall else OSError("lost")
)
validation_error = capture.CaptureStalledError("validation stall")
def read(**_kwargs: object) -> bytes:
audio.get_default_input_device_info.return_value = {"index": 5, "name": "Changed default"}
raise first_error
def validate(*_args: object, **_kwargs: object) -> bytes:
raise validation_error
monkeypatch.setattr(source, "read_chunk", read)
monkeypatch.setattr(capture, "_read_frames_bounded", validate)
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager._record_audio()
assert manager._session.stall_error is (first_error if initial_stall else validation_error)
assert source.last_error is validation_error
first_stream.close.assert_called_once()
audio.terminate.assert_called_once()
assert "Original microphone" in rm._show_notification.call_args.args[1]
assert "original stall" in caplog.text if initial_stall else "validation stall" in caplog.text
assert manager.state == RecognitionState.IDLE
stream.close.assert_called_once()
def test_real_validation_honors_stop_without_error_cue(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
) -> None:
source = capture.PortAudioCaptureSource()
stream = Mock()
audio = Mock()
audio.get_default_input_device_info.return_value = {"name": "Microphone"}
monkeypatch.setitem(sys.modules, "pyaudio", Mock(PyAudio=lambda: audio, paInt16=8))
monkeypatch.setattr(source, "open", lambda _audio: None)
monkeypatch.setattr(
source, "read_chunk", Mock(side_effect=capture.CaptureStalledError("stalled"))
)
monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
monkeypatch.setattr(capture, "_open_capture_stream", lambda *_args: (1, 16000, stream))
def validate(*_args: object, should_continue: Callable[[], bool]) -> bytes:
assert should_continue()
manager.stop_recognition()
assert not should_continue()
return b"validation data"
monkeypatch.setattr(capture, "_read_frames_bounded", validate)
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager._record_audio()
rm.play_error_sound.assert_not_called()
rm._show_notification.assert_not_called()
stream.close.assert_called_once()
assert manager.state == RecognitionState.IDLE
def test_failed_buffered_reload_reports_once_and_subsequent_dictation_succeeds(
manager: rm.SpeechRecognitionManager, source: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
manager.should_record = False
manager.state = RecognitionState.IDLE
manager._idle_unloaded = True
manager._model_initialized = False
manager.buffer_during_reload = True
loading = threading.Event()
release = threading.Event()
failed = threading.Event()
source.read_chunk.side_effect = capture.CaptureStalledError("stalled")
def load() -> None:
loading.set()
assert release.wait(2)
manager._model_initialized = True
monkeypatch.setattr(manager, "_init_selected_engine", load)
manager.state_callbacks.append(
lambda state: failed.set() if state == RecognitionState.ERROR else None
)
assert manager.start_recognition()
try:
assert loading.wait(1) and failed.wait(1)
finally:
release.set()
manager.audio_thread.join(2)
manager.recognition_thread.join(2)
assert manager.state == RecognitionState.IDLE
assert not manager._buffered_reload_session
rm.play_error_sound.assert_called_once()
rm._show_notification.assert_called_once()
source.read_chunk.side_effect = None
source.read_chunk.return_value = b"\xe8\x03" * 1024
transcribed = []
monkeypatch.setattr(
manager, "_process_audio_buffer", lambda audio, language: transcribed.append(audio)
)
manager._audio_level_callbacks.append(lambda _level: manager.stop_recognition())
assert manager.start_recognition()
manager.audio_thread.join(2)
manager.recognition_thread.join(2)
assert manager.state == RecognitionState.IDLE
assert len(transcribed) == 1
@pytest.mark.parametrize("reconnecting", [False, True])
def test_stop_during_audio_initialization_keeps_teardown_owned(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, reconnecting: bool
) -> None:
source = capture.PortAudioCaptureSource()
audio = Mock()
open_stream = Mock()
monkeypatch.setattr(source, "open", open_stream)
monkeypatch.setattr(source, "reopen", open_stream)
monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
manager.should_record = True
manager.state = RecognitionState.LISTENING
def create_audio() -> Mock:
manager.stop_recognition()
return audio
monkeypatch.setitem(sys.modules, "pyaudio", Mock(PyAudio=create_audio))
if reconnecting:
manager._capture_source = source
assert not manager._attempt_audio_reconnection(None)
source.close() # The caller remains responsible for owner-thread teardown.
else:
manager._record_audio()
open_stream.assert_not_called()
audio.terminate.assert_called_once()
rm.play_error_sound.assert_not_called()
def test_successful_reopen_updates_identity_for_a_later_stall(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
) -> None:
source = capture.PortAudioCaptureSource()
audio = Mock()
audio.get_device_count.return_value = 0
audio.get_default_input_device_info.return_value = {"index": 2, "name": "Old microphone"}
monkeypatch.setitem(sys.modules, "pyaudio", Mock(PyAudio=lambda: audio, paInt16=8))
monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
streams = [Mock(), Mock(), Mock()]
monkeypatch.setattr(
capture,
"_open_capture_stream",
Mock(side_effect=[(1, 16000, stream) for stream in streams]),
)
monkeypatch.setattr(
capture,
"_read_frames_bounded",
Mock(side_effect=[b"valid samples", OSError("validation failed")]),
)
failures = iter([OSError("old device lost"), capture.CaptureStalledError("new device stalled")])
def read(**_kwargs: object) -> bytes:
audio.get_default_input_device_info.return_value = {"index": 7, "name": "New microphone"}
manager._last_audio_error_time = 0
raise next(failures)
monkeypatch.setattr(source, "read_chunk", read)
manager.should_record = True
manager.state = RecognitionState.LISTENING
manager._record_audio()
notification = rm._show_notification.call_args.args[1]
assert "New microphone" in notification
assert "Old microphone" not in notification
assert source.resolved_device_name == "New microphone"
assert str(manager._session.stall_error) == "new device stalled"
assert manager.state == RecognitionState.IDLE
for stream in streams:
stream.close.assert_called_once()
audio.terminate.assert_called_once()
File diff suppressed because it is too large Load Diff
+6
View File
@@ -168,12 +168,15 @@ def _manager_stub(
"""A manager carrying only the fields the one-shot path touches."""
from vocalinux.speech_recognition.recognition_manager import (
SpeechRecognitionManager,
_CaptureSession,
_ReleaseGate,
)
manager = SpeechRecognitionManager.__new__(SpeechRecognitionManager)
manager.engine = engine
manager.model_size = model_size
manager.model = None
manager._model_initialized = False
manager.language = language
manager.language_preference = language_preference
manager.state = RecognitionState.IDLE
@@ -183,7 +186,10 @@ def _manager_stub(
manager._pending_language_override = None
manager._oneshot_language_restore = None
manager._segment_started_at = None
manager._session = _CaptureSession()
manager._session_cancel = threading.Event()
manager._download_claim = threading.Lock()
manager._download_cancelled = False
manager._key_release_gate = _ReleaseGate()
manager._key_release_gate.set()
return manager
+268
View File
@@ -1,7 +1,9 @@
"""Tests for offering the recommended model when dictation finds none."""
import importlib
import json
import sys
import threading
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -66,6 +68,9 @@ def _tray_stub():
tray._model_download_active = False
tray.config_manager.get_str.return_value = "en-us"
tray.speech_engine.model_ready = True
tray.speech_engine.engine = "whisper_cpp"
tray.speech_engine.model_size = "small.en"
tray.speech_engine.is_auto_paused = False
return tray
@@ -207,6 +212,7 @@ class _RestartGatedEngine:
self.model_size = model_size
self.language = language
self.model_ready = False
self.is_auto_paused = False
self.reinits = 0
self.state = None
@@ -411,6 +417,27 @@ def _reconfigure_manager():
manager.model_size = "tiny"
manager.language = "en-us"
manager._defer_download = True
# Request gating needs an allocated token and concrete resume state.
manager._model_request_lock = threading.Lock()
manager._model_request = object()
manager._auto_resume_pending = False
manager._reconfigure_request = None
manager._download_claim = threading.Lock()
manager._download_cancelled = False
manager.recognition_thread = None
manager._recognition_workers = []
for name in (
"_settings_reconfigure_context",
"_reconfigure_needs_restart",
"_finish_recognition_before_reconfigure",
"_apply_reconfigure",
"_check_reconfigure_cancelled",
):
setattr(manager, name, getattr(manager_class, name).__get__(manager, manager_class))
manager._new_model_request = lambda **kwargs: manager_class._new_model_request(
manager, **kwargs
)
manager._hot_reconfigure_settings = manager_class._hot_reconfigure_settings
# reconfigure() dispatches through _init_selected_engine(); bind the real
# method so force_reinit still reaches _init_whispercpp on this mock.
manager._init_selected_engine = lambda: manager_class._init_selected_engine(manager)
@@ -459,6 +486,247 @@ def test_an_unchanged_reconfigure_still_does_nothing_by_default():
manager._init_whispercpp.assert_not_called()
@pytest.fixture
def download_manager(monkeypatch):
from vocalinux.speech_recognition import recognition_manager as rm
monkeypatch.setattr(rm, "load_silero_vad", lambda: None)
monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_whispercpp", lambda self: None)
manager = rm.SpeechRecognitionManager(engine="whisper_cpp", model_size="tiny", language="en-us")
manager.model = ("whisper_cpp", "tiny")
manager._model_initialized = True
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(manager, "_init_whispercpp", initialize)
return manager
@pytest.fixture
def download_config(monkeypatch, tmp_path):
from vocalinux.ui import config_manager
monkeypatch.setattr(config_manager, "CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(config_manager, "CONFIG_FILE", str(tmp_path / "config.json"))
config = config_manager.ConfigManager()
config.update_speech_recognition_settings(
{"engine": "whisper_cpp", "model_size": "tiny", "language": "en-us"}
)
assert config.save_config()
return config
def test_tray_download_during_auto_pause_saves_the_model_for_resume(
monkeypatch, tray_indicator, recommendation, download_manager, download_config, tmp_path
):
"""A completed transfer stays accepted when a game unloads its target model."""
manager = download_manager
downloading, finish_download = threading.Event(), threading.Event()
downloaded = []
def download():
downloading.set()
assert finish_download.wait(3)
downloaded.append(manager.model_size)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
notifications = Mock()
monkeypatch.setattr(tray_indicator, "notifications", notifications)
monkeypatch.setattr(tray_indicator, "_idle_once", lambda func, *args: func(*args))
tray = Mock(speech_engine=manager, config_manager=download_config)
tray._model_download_active = True
tray._model_keepalive = None
tray._system_suspended = False
assert manager.try_begin_download()
worker = threading.Thread(
target=tray_indicator.TrayIndicator._run_recommended_model_download,
args=(tray, "whisper_cpp", recommendation, object()),
)
worker.start()
try:
assert downloading.wait(1)
tray_indicator.TrayIndicator._on_auto_pause(tray)
assert manager.is_auto_paused
assert manager.model is None
finally:
finish_download.set()
worker.join(2)
assert not worker.is_alive()
assert downloaded == [recommendation.model_id]
assert not manager.model_ready
assert not manager.download_in_progress
assert tray._model_download_active is False
title, body, _icon = notifications.notify.call_args.args
assert title == "Speech model downloaded"
assert "resumes" in body
assert "Press" not in body
with (tmp_path / "config.json").open() as handle:
saved = json.load(handle)["speech_recognition"]
assert saved["model_size"] == recommendation.model_id
tray_indicator.TrayIndicator._on_auto_resume(tray)
assert not manager.is_auto_paused
assert manager.model == (saved["engine"], saved["model_size"])
assert manager.model_ready
@pytest.mark.parametrize("failure", ["download", "initialize"])
def test_paused_download_or_initializer_exception_still_fails_without_saving(
monkeypatch,
tray_indicator,
recommendation,
download_manager,
download_config,
tmp_path,
failure,
):
"""Being paused must never turn a real transfer/load failure into success."""
manager = download_manager
manager.unload_model(reason="auto_pause")
def initialize():
if manager.model_size == recommendation.model_id:
raise RuntimeError("model load failed")
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
if failure == "download":
download = Mock(side_effect=OSError("network failed"))
else:
download = Mock()
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
notifications = Mock()
monkeypatch.setattr(tray_indicator, "notifications", notifications)
monkeypatch.setattr(tray_indicator, "_idle_once", lambda func, *args: func(*args))
tray = Mock(speech_engine=manager, config_manager=download_config)
tray._model_download_active = True
assert manager.try_begin_download()
tray_indicator.TrayIndicator._run_recommended_model_download(
tray, "whisper_cpp", recommendation, object()
)
assert notifications.notify.call_args.args[0] == "Model download failed"
assert manager.model_size == "tiny"
with (tmp_path / "config.json").open() as handle:
assert json.load(handle)["speech_recognition"]["model_size"] == "tiny"
assert not manager.download_in_progress
assert tray._model_download_active is False
assert manager._download_progress_callback is None
def test_paused_resident_model_is_reported_as_waiting_for_resume(
tray_indicator, tray, recommendation
):
"""A pending unload can leave resident weights while dictation is blocked."""
tray.speech_engine.is_auto_paused = True
with (
patch.object(tray_indicator, "notifications") as notifications,
patch.object(tray_indicator, "_idle_once", side_effect=lambda func, *args: func(*args)),
):
tray_indicator.TrayIndicator._run_recommended_model_download(
tray, "whisper_cpp", recommendation, object()
)
assert notifications.notify.call_args.args[0] == "Speech model downloaded"
assert "resumes" in notifications.notify.call_args.args[1]
tray.config_manager.save_config.assert_called_once_with()
@pytest.mark.parametrize("paused", [False, True])
@pytest.mark.parametrize("changed", [{"engine": "vosk"}, {"model_size": "medium"}])
def test_superseded_download_target_is_not_saved_even_with_a_ready_model(
tray_indicator, tray, recommendation, changed, paused
):
"""Readiness alone must not save a downloaded choice over a newer selection."""
tray.speech_engine.is_auto_paused = paused
def supersede(**kwargs):
for name, value in changed.items():
setattr(tray.speech_engine, name, value)
tray.speech_engine.reconfigure.side_effect = supersede
with (
patch.object(tray_indicator, "notifications") as notifications,
patch.object(tray_indicator, "_idle_once", side_effect=lambda func, *args: func(*args)),
):
tray_indicator.TrayIndicator._run_recommended_model_download(
tray, "whisper_cpp", recommendation, object()
)
tray.config_manager.set_model_size_for_engine.assert_not_called()
tray.config_manager.save_config.assert_not_called()
assert notifications.notify.call_args.args[0] == "Model download failed"
def test_download_save_finishes_before_a_new_settings_model_apply(
monkeypatch, settings_dialog, tray_indicator, recommendation, download_manager, download_config
):
"""Settings cannot overtake the tray between its model apply and config save."""
manager = download_manager
save_entered, finish_save, settings_started, settings_done = (
threading.Event(),
threading.Event(),
threading.Event(),
threading.Event(),
)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", lambda: None)
save = download_config.save_config
def tray_save():
if threading.current_thread() is worker:
save_entered.set()
assert finish_save.wait(3)
return save()
monkeypatch.setattr(download_config, "save_config", tray_save)
monkeypatch.setattr(tray_indicator, "notifications", Mock())
monkeypatch.setattr(tray_indicator, "_idle_once", lambda func, *args: func(*args))
tray = Mock(speech_engine=manager, config_manager=download_config)
dialog = Mock(speech_engine=manager, config_manager=download_config)
dialog._save_selected_settings = settings_dialog.SettingsDialog._save_selected_settings.__get__(
dialog
)
errors = []
def apply_settings():
settings_started.set()
try:
assert settings_dialog.SettingsDialog._apply_settings_internal(
dialog,
{"engine": "whisper_cpp", "model_size": "medium"},
raise_errors=True,
)
except Exception as error:
errors.append(error)
finally:
settings_done.set()
worker = threading.Thread(
target=tray_indicator.TrayIndicator._run_recommended_model_download,
args=(tray, "whisper_cpp", recommendation, object()),
)
settings_worker = threading.Thread(target=apply_settings)
assert manager.try_begin_download()
worker.start()
try:
assert save_entered.wait(1)
settings_worker.start()
assert settings_started.wait(1)
assert not settings_done.wait(0.1)
assert manager.model_size == recommendation.model_id
finally:
finish_save.set()
worker.join(2)
if settings_worker.ident is not None:
settings_worker.join(2)
assert not worker.is_alive()
assert not settings_worker.is_alive()
assert errors == []
assert manager.model_size == "medium"
assert download_config.get("speech_recognition", "model_size") == "medium"
class TestOneDownloadAtATime:
"""The tray and Settings share one engine, so only one of them downloads."""
+1 -1
View File
@@ -517,7 +517,7 @@ class TestManagerPipeWireIntegration(unittest.TestCase):
fake_source.stream = MagicMock()
fake_source.audio = None
def _read_once() -> bytes:
def _read_once(**_kwargs: Any) -> bytes:
manager.should_record = False
return b"\x00" * 2048
+1
View File
@@ -728,6 +728,7 @@ def test_recognition_hook_schedules_after_start_and_restores_before_stop_cue(
patch("vocalinux.speech_recognition.recognition_manager.threading.Thread") as thread_cls,
):
thread_cls.return_value = MagicMock()
thread_cls.return_value.is_alive.return_value = False
assert manager.start_recognition() is True
assert manager._worker_exited is not None
manager._worker_exited.set()
+1
View File
@@ -473,6 +473,7 @@ class TestCaptureIntegration:
def test_audio_test_uses_the_chosen_source(self) -> None:
mock_audio, mock_pyaudio = _pyaudio_with_one_mic()
stream = MagicMock()
stream.get_read_available.return_value = 1024
stream.read.return_value = b"\x00\x10" * 1024
seen: Dict[str, object] = {}
+5
View File
@@ -214,6 +214,10 @@ class TestSpeechRecognition(unittest.TestCase):
# Stop recognition
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
self.threadInstance.is_alive.return_value = True
self.threadInstance.join.side_effect = lambda *_args, **_kwargs: setattr(
self.threadInstance.is_alive, "return_value", False
)
manager._worker_exited.set()
manager.stop_recognition()
@@ -879,6 +883,7 @@ class TestModuleLevelFunctions(unittest.TestCase):
# Create mock for PyAudio
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 2048
mock_pyaudio_instance = MagicMock()
+10 -3
View File
@@ -560,7 +560,12 @@ class TestStartStopRecognition(unittest.TestCase):
mgr.audio_thread = MagicMock()
mgr.audio_thread.is_alive.return_value = True
events = []
mgr.audio_thread.join.side_effect = lambda timeout=None: events.append("join")
def capture_exited(timeout: float | None = None) -> None:
events.append("join")
mgr.audio_thread.is_alive.return_value = False
mgr.audio_thread.join.side_effect = capture_exited
with patch(
"vocalinux.speech_recognition.recognition_manager.play_stop_sound",
@@ -1241,10 +1246,11 @@ class TestPendingTranscription(unittest.TestCase):
self.assertFalse(manager.has_pending_transcription())
def test_orphaned_final_segment_is_not_pending(self) -> None:
def test_final_segment_is_handed_off_before_worker_exits(self) -> None:
manager = _make_manager()
manager.state = RecognitionState.LISTENING
manager.should_record = True
manager._process_audio_buffer = MagicMock()
started = threading.Event()
audio_release = threading.Event()
@@ -1275,7 +1281,8 @@ class TestPendingTranscription(unittest.TestCase):
recognition_thread.join(timeout=1.0)
with manager._segment_queue.mutex:
self.assertTrue(any(item is not None for item in manager._segment_queue.queue))
self.assertFalse(any(item is not None for item in manager._segment_queue.queue))
manager._process_audio_buffer.assert_called_once()
self.assertFalse(manager.has_pending_transcription())
def test_worker_does_not_count_itself_as_pending(self) -> None:
+5
View File
@@ -504,6 +504,9 @@ class TestStartStopRecognition(unittest.TestCase):
manager.start_recognition()
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
self.threadInstance.join.side_effect = lambda timeout=None: setattr(
self.threadInstance.is_alive, "return_value", False
)
manager._worker_exited.set()
manager.stop_recognition()
@@ -811,6 +814,8 @@ class TestReconfiguration(unittest.TestCase):
manager.audio_thread = self.threadInstance
manager.recognition_thread = self.threadInstance
# The previous capture thread has finished before a new start.
self.threadInstance.is_alive.return_value = False
# Start listening
manager.start_recognition()
self.assertEqual(manager.state, RecognitionState.LISTENING)
@@ -129,6 +129,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
manager.should_record = False
return b"\x00" * 2048
mock_stream.get_read_available.return_value = 1024
mock_stream.read.side_effect = _read_once
mock_audio = MagicMock()
@@ -177,6 +178,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
manager.should_record = False
return b"\x00" * 2048
mock_stream.get_read_available.return_value = 1024
mock_stream.read.side_effect = _read_once
mock_audio = MagicMock()
mock_audio.get_device_count.return_value = 1
@@ -218,6 +220,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
def test_test_audio_input_system_default_fallback_open_omits_device_index(self):
"""Fallback mic-test open for System Default must omit input_device_index."""
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
@@ -502,6 +505,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
from vocalinux.speech_recognition.recognition_manager import test_audio_input
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
@@ -534,6 +538,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
from vocalinux.speech_recognition.recognition_manager import test_audio_input
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
@@ -564,6 +569,7 @@ class TestAudioDeviceDetection(unittest.TestCase):
def test_test_audio_input_negotiation_fallback_open(self):
"""When negotiation returns no stream, mic test falls back to a plain open."""
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\x01" * 1024
mock_audio = MagicMock()
mock_audio.open.return_value = mock_stream
@@ -957,6 +963,7 @@ class TestRecordAudioNegotiationFallback(unittest.TestCase):
manager.should_record = False
return b"\x00" * 2048
mock_stream.get_read_available.return_value = 1024
mock_stream.read.side_effect = _read_once
mock_audio = MagicMock()
@@ -286,6 +286,117 @@ class TestDownloadFunctions(unittest.TestCase):
"dialog-information",
)
def test_resume_during_prefetch_still_loads_the_selected_model(self) -> None:
"""Wake must not supersede the Settings worker that will load the model."""
manager = self._create_manager(engine="whisper")
previous_model = object()
selected_model = object()
manager.model = previous_model
manager._model_initialized = True
def prefetch() -> None:
assert manager._prefetching_model
manager.reinitialize_after_resume(auto_resume=True)
assert manager.is_auto_paused
def initialize() -> None:
manager.model = selected_model
manager._model_initialized = True
with (
patch.object(manager, "_download_selected_model_if_missing", side_effect=prefetch),
patch.object(manager, "_init_selected_engine", side_effect=initialize) as init,
):
manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=True)
init.assert_called_once_with()
assert manager.model is selected_model
assert manager.engine == "whisper_cpp"
assert manager.model_size == "medium"
assert not manager.is_auto_paused
assert not manager._auto_resume_pending
def test_failed_prefetch_completes_inherited_resume_with_previous_model(self) -> None:
"""Rejected Settings must not abandon a wake request that they superseded."""
manager = self._create_manager(engine="whisper")
previous_model = object()
manager.model = previous_model
manager._model_initialized = True
manager._auto_paused = True
manager._auto_resume_pending = True
with (
patch.object(
manager,
"_download_selected_model_if_missing",
side_effect=RuntimeError("Download cancelled"),
),
patch.object(manager, "_init_selected_engine") as init,
):
with self.assertRaisesRegex(RuntimeError, "Download cancelled"):
manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=True)
init.assert_not_called()
assert manager.model is previous_model
assert manager.engine == "whisper"
assert not manager.is_auto_paused
assert not manager._auto_resume_pending
def test_failed_prefetch_reloads_previous_engine_for_inherited_resume(self) -> None:
"""An already unloaded model is recovered after the new download fails."""
manager = self._create_manager(engine="whisper")
previous_model = object()
manager.model = None
manager._model_initialized = False
manager._auto_paused = True
manager._auto_resume_pending = True
def initialize() -> None:
manager.model = previous_model
manager._model_initialized = True
with (
patch.object(
manager,
"_download_selected_model_if_missing",
side_effect=RuntimeError("Download cancelled"),
),
patch.object(manager, "_init_whisper", side_effect=initialize) as init,
):
with self.assertRaisesRegex(RuntimeError, "Download cancelled"):
manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=True)
init.assert_called_once_with()
assert manager.model is previous_model
assert manager.engine == "whisper"
assert manager.model_ready
assert not manager.is_auto_paused
assert not manager._auto_resume_pending
def test_cancelled_model_lock_wait_completes_inherited_resume(self) -> None:
"""Cancelling behind a busy engine preserves the usable previous model."""
manager = self._create_manager(engine="whisper")
previous_model = object()
manager.model = previous_model
manager._model_initialized = True
manager._auto_paused = True
manager._auto_resume_pending = True
manager._model_lock.acquire()
assert manager.try_begin_download()
manager.cancel_download()
try:
with self.assertRaisesRegex(RuntimeError, "Download cancelled"):
manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=False)
assert manager._model_lock.locked()
finally:
manager._model_lock.release()
manager.end_download()
assert manager.model is previous_model
assert manager.engine == "whisper"
assert not manager.is_auto_paused
assert not manager._auto_resume_pending
def test_reconfigure_without_download_permission_skips_prefetch(self) -> None:
manager = self._create_manager()
manager.engine = "whisper"
@@ -1010,6 +1121,7 @@ class TestAudioDeviceReconnection(unittest.TestCase):
mock_audio = MagicMock()
mock_stream = MagicMock()
mock_audio.open.return_value = mock_stream
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"audio_data"
result = manager._attempt_audio_reconnection(mock_audio)
+9 -2
View File
@@ -1283,6 +1283,7 @@ class TestAudioReconnection:
mock_pyaudio_mod = MagicMock()
mock_pyaudio_mod.paInt16 = 8
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 1024
mock_audio_instance = MagicMock()
mock_audio_instance.open.return_value = mock_stream
@@ -1301,6 +1302,7 @@ class TestAudioReconnection:
mock_pyaudio_mod = MagicMock()
mock_pyaudio_mod.paInt16 = 8
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 1024
mock_audio_instance = MagicMock()
mock_audio_instance.get_default_input_device_info.return_value = {"index": 0}
@@ -1341,6 +1343,7 @@ class TestAudioReconnection:
mock_pyaudio_mod.paInt16 = 8
mock_audio_instance = MagicMock()
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
with (
patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}),
@@ -1398,17 +1401,19 @@ class TestAudioReconnection:
mock_pyaudio_mod = MagicMock()
mock_pyaudio_mod.paInt16 = 8
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 1024
mock_audio_instance = MagicMock()
mock_audio_instance.open.return_value = mock_stream
sleep_durations = []
def track_sleep(duration):
def track_wait(duration):
sleep_durations.append(duration)
return False
with patch.dict("sys.modules", {"pyaudio": mock_pyaudio_mod}):
with patch("time.sleep", side_effect=track_sleep):
with patch.object(manager._session.cancel, "wait", side_effect=track_wait):
manager._reconnection_attempts = 0
manager._attempt_audio_reconnection(mock_audio_instance)
first_delay = sleep_durations[-1]
@@ -1427,6 +1432,7 @@ class TestAudioReconnection:
mock_pyaudio_mod = MagicMock()
mock_pyaudio_mod.paInt16 = 8
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 1024
mock_audio_instance = MagicMock()
mock_audio_instance.open.return_value = mock_stream
@@ -1452,6 +1458,7 @@ class TestAudioReconnection:
mock_pyaudio_mod = MagicMock()
mock_pyaudio_mod.paInt16 = 8
mock_stream = MagicMock()
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b""
mock_audio_instance = MagicMock()
mock_audio_instance.open.return_value = mock_stream
@@ -257,6 +257,7 @@ class TestTestAudioInput(unittest.TestCase):
mock_stream = MagicMock()
mock_pa_inst.open.return_value = mock_stream
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00\xf4" * 1024
mock_np = MagicMock()
@@ -294,6 +295,7 @@ class TestTestAudioInput(unittest.TestCase):
mock_stream = MagicMock()
mock_pa_inst.open.return_value = mock_stream
mock_stream.get_read_available.return_value = 1024
mock_stream.read.return_value = b"\x00" * 2048
mock_np = MagicMock()
+12 -7
View File
@@ -138,10 +138,11 @@ def _scripted_stream(probs_per_chunk, manager, max_iters=None, chunk_bytes=None)
def _read(*args, **kwargs):
counter["n"] += 1
if counter["n"] >= limit:
if counter["n"] > limit:
manager.should_record = False
return payload
stream.get_read_available.return_value = 1024
stream.read.side_effect = _read
return stream
@@ -296,20 +297,21 @@ class TestRecordAudioSileroPath(unittest.TestCase):
self.assertEqual(len(self.mgr.audio_buffer), 26)
self.assertTrue(self.mgr._recording_segment_has_speech)
def test_reload_buffer_limit_reports_error_without_trimming_speech(self) -> None:
"""A stalled load must not silently discard the start of an utterance."""
def test_reload_buffer_limit_fails_instead_of_continuing_partial_capture(self) -> None:
"""A full reload buffer must fail the session instead of trimming speech."""
self.mgr._reload_pending = True
self.mgr._buffered_reload_session = True
self.mgr._max_buffer_size = 4
self._drive(probs=[0.95] * 20)
self.assertEqual(len(self.mgr.audio_buffer), 4)
self.assertEqual(self.mgr.audio_buffer, [])
self.assertTrue(self.mgr._buffered_capture_failed)
self.assertEqual(self.mgr.state, RecognitionState.ERROR)
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
def test_reload_reconnection_failure_marks_capture_failed(self) -> None:
"""Losing the microphone during reload must fail instead of dropping audio."""
self.mgr._buffered_reload_session = True
stream = MagicMock()
stream.get_read_available.return_value = 1024
stream.read.side_effect = OSError("microphone disconnected")
pyaudio_mod, _audio = _make_pyaudio_module(stream)
@@ -324,13 +326,14 @@ class TestRecordAudioSileroPath(unittest.TestCase):
self.mgr._record_audio()
self.assertTrue(self.mgr._buffered_capture_failed)
self.assertEqual(self.mgr.state, RecognitionState.ERROR)
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
def test_reload_rapid_audio_error_marks_capture_failed(self) -> None:
"""A repeated microphone error during reload must fail the capture."""
self.mgr._buffered_reload_session = True
self.mgr._last_audio_error_time = time.time()
stream = MagicMock()
stream.get_read_available.return_value = 1024
stream.read.side_effect = OSError("microphone still disconnected")
pyaudio_mod, _audio = _make_pyaudio_module(stream)
@@ -346,7 +349,7 @@ class TestRecordAudioSileroPath(unittest.TestCase):
reconnect.assert_not_called()
self.assertTrue(self.mgr._buffered_capture_failed)
self.assertEqual(self.mgr.state, RecognitionState.ERROR)
self.assertEqual(self.mgr.state, RecognitionState.IDLE)
def test_sensitivity_lowest_blocks_borderline_speech(self):
"""At sensitivity=1 (threshold 0.8), prob=0.6 is silence."""
@@ -449,6 +452,7 @@ class TestRecordAudioAmplitudeFallback(unittest.TestCase):
self.mgr.should_record = False
return payload
stream.get_read_available.return_value = 1024
stream.read.side_effect = _read
pyaudio_mod, _ = _make_pyaudio_module(stream)
with (
@@ -509,6 +513,7 @@ class TestAudioLevelCallback(unittest.TestCase):
mgr.should_record = False
return b"\x00" * (1024 * 2)
stream.get_read_available.return_value = 1024
stream.read.side_effect = _read
pyaudio_mod, _ = _make_pyaudio_module(stream)
with (
+559
View File
@@ -0,0 +1,559 @@
"""Concurrent hot settings must follow the model transaction that owns resources."""
import threading
import time
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from test_model_missing_offer import _load
from test_reconfigure_worker_handoff import _make_manager
@pytest.fixture
def settings_module():
yield from _load("vocalinux.ui.settings_dialog")
@pytest.fixture
def tray_module(settings_module):
yield from _load("vocalinux.ui.tray_indicator")
@pytest.mark.parametrize(
("phase", "failure"),
[
("prefetch", None),
("prefetch", "download"),
("prefetch", "initialize"),
("model_lock", None),
("model_lock", "initialize"),
("initialize", None),
("initialize", "initialize"),
],
)
def test_accepted_hot_settings_survive_model_success_or_rollback(
monkeypatch, settings_module, phase, failure
):
manager = _make_manager(monkeypatch, engine="vosk")
entered, release = threading.Event(), threading.Event()
errors = []
initializations = []
original_wait = manager._wait_for_model_settings_handoff
previous_model = manager.model
def download():
if phase == "prefetch":
entered.set()
assert release.wait(3)
if failure == "download":
raise OSError("network failed")
def wait_for_handoff():
if phase == "model_lock":
entered.set()
original_wait()
def initialize():
initializations.append((manager.engine, manager.model_size))
if manager.engine == "whisper_cpp":
if phase == "initialize":
entered.set()
assert release.wait(3)
if failure == "initialize":
raise RuntimeError("new model failed")
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
def apply_model():
try:
manager.reconfigure(engine="whisper_cpp", model_size="medium")
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait_for_handoff)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
if phase == "model_lock":
manager._model_lock.acquire()
worker = threading.Thread(target=apply_model)
worker.start()
try:
assert entered.wait(1)
request = manager._model_request
done = manager._reconfigure_done
dialog = Mock(speech_engine=manager, _initializing=False, _applying_settings=False)
started = time.monotonic()
settings_module.SettingsDialog._on_voice_commands_toggled(dialog, None, False)
settings_module.SettingsDialog._on_voice_commands_toggled(dialog, None, True)
manager.reconfigure(vad_sensitivity=9, silence_timeout=0.1, stop_sound_guard_ms=120)
assert time.monotonic() - started < 0.3
assert manager._model_request is request
assert manager._reconfigure_request is request
assert manager._reconfigure_done is done
assert not done.is_set()
assert not manager.start_recognition()
dialog.config_manager.set.assert_called_with(
"speech_recognition", "voice_commands_enabled", True
)
finally:
release.set()
if phase == "model_lock":
manager._model_lock.release()
worker.join(2)
assert not worker.is_alive()
assert bool(errors) == (failure is not None)
expected = ("vosk", "small") if failure else ("whisper_cpp", "medium")
assert (manager.engine, manager.model_size) == expected
assert manager.model == expected
if failure == "download":
assert manager.model is previous_model
assert initializations == []
assert manager.model_ready
assert not manager._prefetching_model
assert manager._reconfigure_request is None
assert manager._reconfigure_done.is_set()
assert manager._voice_commands_preference is True
assert manager._voice_commands_enabled is True
assert manager.vad_sensitivity == 5
assert manager.silence_timeout == 0.5
assert manager.stop_sound_guard_ms == 120
monkeypatch.setattr(manager, "_record_audio", lambda: None)
monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
assert manager.start_recognition()
manager.stop_recognition()
@pytest.mark.parametrize("settings", [{"engine": "whisper"}, {"language": "de"}])
def test_another_model_or_language_change_rejects_before_replacing_active_owner(
monkeypatch, settings
):
manager = _make_manager(monkeypatch)
entered, release = threading.Event(), threading.Event()
errors = []
def download():
entered.set()
assert release.wait(3)
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
def apply_model():
try:
manager.reconfigure(model_size="medium")
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
worker = threading.Thread(target=apply_model)
worker.start()
try:
assert entered.wait(1)
owner = manager._model_request
previous = manager._snapshot_reconfigure_state()
started = time.monotonic()
with pytest.raises(RuntimeError, match="already changing"):
manager.reconfigure(**settings)
assert time.monotonic() - started < 0.3
assert manager._model_request is owner
assert manager._reconfigure_request is owner
assert manager._snapshot_reconfigure_state() == previous
finally:
release.set()
worker.join(2)
assert not worker.is_alive()
assert errors == []
assert manager.model == (manager.engine, manager.model_size) == ("whisper_cpp", "medium")
assert not manager._prefetching_model
def test_tray_download_announces_the_new_model_after_a_concurrent_voice_toggle(
monkeypatch, settings_module, tray_module
):
manager = _make_manager(monkeypatch)
entered, release = threading.Event(), threading.Event()
def download():
entered.set()
assert release.wait(3)
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(tray_module, "_idle_once", lambda fn, *args: fn(*args))
notifications = Mock()
monkeypatch.setattr(tray_module, "notifications", notifications)
tray = Mock(speech_engine=manager)
assert manager.try_begin_download()
recommendation = SimpleNamespace(model_id="medium", display_name="Medium")
worker = threading.Thread(
target=tray_module.TrayIndicator._run_recommended_model_download,
args=(tray, "whisper_cpp", recommendation, object()),
)
worker.start()
try:
assert entered.wait(1)
dialog = Mock(speech_engine=manager, _initializing=False, _applying_settings=False)
settings_module.SettingsDialog._on_voice_commands_toggled(dialog, None, True)
finally:
release.set()
worker.join(2)
assert not worker.is_alive()
assert manager.model == ("whisper_cpp", "medium")
assert manager._voice_commands_preference is True
assert not manager._prefetching_model
assert not manager.download_in_progress
tray.config_manager.set_model_size_for_engine.assert_called_once_with("whisper_cpp", "medium")
tray.config_manager.save_config.assert_called_once_with()
assert notifications.notify.call_args.args[0] == "Speech model ready"
def test_hot_toggle_during_idle_cleanup_keeps_lazy_recovery_available(monkeypatch, settings_module):
manager = _make_manager(monkeypatch, engine="faster_whisper")
entered, release = threading.Event(), threading.Event()
manager._faster_whisper_engine = Mock()
def cleanup():
entered.set()
assert release.wait(3)
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
manager._faster_whisper_engine.cleanup.side_effect = cleanup
monkeypatch.setattr(manager, "_init_faster_whisper", initialize)
monkeypatch.setattr(manager, "_record_audio", lambda: None)
monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
worker = threading.Thread(target=manager.unload_model, kwargs={"reason": "idle_keepalive"})
worker.start()
try:
assert entered.wait(1)
assert not manager.model_ready
assert manager.is_idle_unloaded
dialog = Mock(speech_engine=manager, _initializing=False, _applying_settings=False)
settings_module.SettingsDialog._on_voice_commands_toggled(dialog, None, True)
finally:
release.set()
worker.join(2)
assert not worker.is_alive()
assert manager.is_idle_unloaded
assert manager.start_recognition()
assert manager.model_ready
manager.stop_recognition()
@pytest.mark.parametrize("failure", [None, "download", "initialize"])
def test_staged_vad_controls_survive_owner_rollback_and_snapshot_save(
monkeypatch, settings_module, failure
):
manager = _make_manager(monkeypatch, engine="vosk")
entered, release = threading.Event(), threading.Event()
errors = []
saved = {"engine": "vosk", "model_size": "small", "vad_sensitivity": 3, "silence_timeout": 1.2}
def download():
if failure != "initialize":
entered.set()
assert release.wait(3)
if failure == "download":
raise OSError("download failed")
def initialize():
if manager.engine == "whisper_cpp" and failure == "initialize":
entered.set()
assert release.wait(3)
raise RuntimeError("model failed")
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
owner = Mock(speech_engine=manager)
owner._save_selected_settings.side_effect = lambda settings: saved.update(settings)
owner_settings = {
"engine": "whisper_cpp",
"model_size": "medium",
"vad_sensitivity": 2,
"silence_timeout": 1.1,
}
def apply_model():
try:
settings_module.SettingsDialog._apply_settings_internal(
owner, owner_settings, raise_errors=True
)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(
settings_module, "_undownloaded_model_info", lambda *_args: {"size_mb": 100}
)
worker = threading.Thread(target=apply_model)
worker.start()
try:
assert entered.wait(1)
request = manager._model_request
staged = Mock(
speech_engine=manager,
language="en-us",
_initializing=False,
_applying_settings=False,
_test_active=False,
_populating_models=False,
_simple_driving=False,
)
staged.get_selected_settings.return_value = {
"engine": "whisper_cpp",
"model_size": "large",
"vad_sensitivity": 5,
"silence_timeout": 0.8,
}
staged._save_selected_settings.side_effect = lambda settings: saved.update(settings)
started = time.monotonic()
settings_module.SettingsDialog._auto_apply_settings(staged)
assert time.monotonic() - started < 0.3
assert manager._model_request is request
assert saved["vad_sensitivity"] == 5
assert saved["silence_timeout"] == 0.8
assert manager._cancel_buffered_session.is_set() is False
# Returning a control to the owner's in-flight value must replace its
# earlier queued edit too; live state alone cannot detect this change.
staged.get_selected_settings.return_value["vad_sensitivity"] = 2
settings_module.SettingsDialog._auto_apply_settings(staged)
assert saved["vad_sensitivity"] == 2
finally:
release.set()
worker.join(2)
assert not worker.is_alive()
assert bool(errors) == (failure is not None)
expected = ("vosk", "small") if failure else ("whisper_cpp", "medium")
assert (manager.engine, manager.model_size) == expected
assert manager.model == expected
assert (saved["engine"], saved["model_size"]) == expected
assert saved["vad_sensitivity"] == manager.vad_sensitivity == 2
assert saved["silence_timeout"] == manager.silence_timeout == 0.8
assert manager.model_ready
assert not manager._prefetching_model
@pytest.mark.parametrize("change", [False, True])
def test_settings_with_a_resident_model_settles_idle_unloaded_flag(monkeypatch, change):
manager = _make_manager(monkeypatch)
manager.unload_model(reason="idle_keepalive")
assert manager.is_idle_unloaded
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
manager.reconfigure(
model_size="medium" if change else None, force_reinit=True, force_download=False
)
assert manager.model_ready
assert not manager.is_idle_unloaded
def test_staged_vad_edit_at_owner_save_boundary_remains_the_latest_saved_value(
monkeypatch, settings_module
):
manager = _make_manager(monkeypatch, engine="vosk")
saving, release_save = threading.Event(), threading.Event()
staging, staged_done = threading.Event(), threading.Event()
saved = {}
errors = []
def initialize():
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
def save_owner(settings):
saving.set()
assert release_save.wait(3)
saved.update(settings)
owner = Mock(speech_engine=manager)
owner._save_selected_settings.side_effect = save_owner
staged = Mock(
speech_engine=manager,
language="en-us",
_initializing=False,
_applying_settings=False,
_test_active=False,
_populating_models=False,
_simple_driving=False,
)
def staged_settings():
staging.set()
return {
"engine": "whisper_cpp",
"model_size": "large",
"vad_sensitivity": 5,
"silence_timeout": 2.3,
}
staged.get_selected_settings.side_effect = staged_settings
staged._save_selected_settings.side_effect = lambda settings: saved.update(settings)
def apply_model():
try:
settings_module.SettingsDialog._apply_settings_internal(
owner,
{"engine": "whisper_cpp", "model_size": "medium", "vad_sensitivity": 3},
raise_errors=True,
)
except Exception as error:
errors.append(error)
def apply_staged():
try:
settings_module.SettingsDialog._auto_apply_settings(staged)
except Exception as error:
errors.append(error)
finally:
staged_done.set()
monkeypatch.setattr(manager, "_download_selected_model_if_missing", lambda: None)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(
settings_module, "_undownloaded_model_info", lambda *_args: {"size_mb": 100}
)
owner_worker = threading.Thread(target=apply_model)
staged_worker = threading.Thread(target=apply_staged)
owner_worker.start()
try:
assert saving.wait(1)
staged_worker.start()
assert staging.wait(1)
# Without persistence serialization this lets the newer staged save
# finish first, exposing the older owner's subsequent overwrite.
staged_done.wait(0.1)
finally:
release_save.set()
owner_worker.join(2)
if staged_worker.ident is not None:
staged_worker.join(2)
assert not owner_worker.is_alive()
assert not staged_worker.is_alive()
assert errors == []
assert saved["vad_sensitivity"] == manager.vad_sensitivity == 5
assert saved["silence_timeout"] == manager.silence_timeout == 2.3
@pytest.mark.parametrize("control", ["voice_commands", "staged_vad"])
@pytest.mark.parametrize("buffer_during_reload", [False, True])
def test_hot_settings_handlers_keep_native_idle_reload_responsive(
monkeypatch, tmp_path, settings_module, control, buffer_during_reload
):
"""Real GTK handlers return while either kind of idle reload still owns native work."""
from vocalinux.ui import config_manager
monkeypatch.setattr(config_manager, "CONFIG_DIR", str(tmp_path))
monkeypatch.setattr(config_manager, "CONFIG_FILE", str(tmp_path / "config.json"))
config = config_manager.ConfigManager()
config.update_speech_recognition_settings(
{"engine": "vosk", "model_size": "small", "language": "en-us"}
)
manager = _make_manager(monkeypatch, engine="vosk")
manager.buffer_during_reload = buffer_during_reload
manager.unload_model(reason="idle_keepalive")
loading, release, returned = threading.Event(), threading.Event(), threading.Event()
errors = []
started = []
loaded_model = object()
def initialize():
loading.set()
assert release.wait(3)
manager.model = loaded_model
manager._model_initialized = True
monkeypatch.setattr(manager, "_init_vosk", initialize)
monkeypatch.setattr(manager, "_record_audio", lambda: None)
monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
monkeypatch.setattr(
settings_module, "_undownloaded_model_info", lambda *_args: {"size_mb": 100}
)
dialog = Mock(
speech_engine=manager,
config_manager=config,
language="en-us",
_initializing=False,
_applying_settings=False,
_test_active=False,
_populating_models=False,
_simple_driving=False,
)
dialog.get_selected_settings.return_value = {
"engine": "whisper_cpp",
"model_size": "large",
"vad_sensitivity": 5,
"silence_timeout": 2.3,
}
dialog._save_selected_settings = settings_module.SettingsDialog._save_selected_settings.__get__(
dialog
)
def start():
try:
started.append(manager.start_recognition())
except Exception as error:
errors.append(error)
def edit():
try:
if control == "voice_commands":
settings_module.SettingsDialog._on_voice_commands_toggled(dialog, None, True)
else:
settings_module.SettingsDialog._auto_apply_settings(dialog)
except Exception as error:
errors.append(error)
finally:
returned.set()
starter, editor = threading.Thread(target=start), threading.Thread(target=edit)
starter.start()
try:
assert loading.wait(1)
request = manager._model_request
session = manager._session
editor.start()
assert returned.wait(0.5)
assert not release.is_set()
assert manager._model_request is request
assert manager._session is session
assert not manager._cancel_buffered_session.is_set()
if control == "voice_commands":
assert manager._voice_commands_preference is True
assert config.get("speech_recognition", "voice_commands_enabled") is True
else:
assert (
manager.vad_sensitivity == config.get("speech_recognition", "vad_sensitivity") == 5
)
assert (
manager.silence_timeout
== config.get("speech_recognition", "silence_timeout")
== 2.3
)
assert config.get("speech_recognition", "engine") == "vosk"
finally:
release.set()
starter.join(2)
if editor.ident is not None:
editor.join(2)
if manager.recognition_thread is not None:
manager.recognition_thread.join(2)
manager.stop_recognition()
assert not starter.is_alive()
assert not editor.is_alive()
assert errors == []
assert started == [True]
assert manager.model is loaded_model
+292
View File
@@ -0,0 +1,292 @@
"""Settings must not publish load-time fields into a superseded initializer."""
import sys
import threading
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from test_reconfigure_worker_handoff import _make_manager
from vocalinux.speech_recognition import recognition_manager as rm
def _track_deferred_workers(
manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
) -> list[threading.Thread]:
workers: list[threading.Thread] = []
for method_name in ("_resume_after_reload", "_unload_after_reload"):
original = getattr(manager, method_name)
def run(*args: object, target: Callable[..., None] = original) -> None:
workers.append(threading.current_thread())
target(*args)
monkeypatch.setattr(manager, method_name, run)
return workers
def _gate_whispercpp_initializer(
manager: rm.SpeechRecognitionManager,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> tuple[threading.Event, threading.Event, list[tuple[str, str]]]:
entered, release = threading.Event(), threading.Event()
loads: list[tuple[str, str]] = []
checkpoint = tmp_path / "ggml-tiny.bin"
checkpoint.write_bytes(b"model")
def preload() -> None:
entered.set()
assert release.wait(3)
def load_whispercpp(path: str) -> None:
loads.append(("whisper_cpp", manager.model_size))
manager.model = ("whisper_cpp", manager.model_size)
manager._model_initialized = True
def create_parakeet(**kwargs: object) -> tuple[str, str]:
loads.append(("parakeet", manager.model_size))
return ("parakeet", manager.model_size)
monkeypatch.setattr(rm, "_preload_pywhispercpp_shared_libraries", preload)
monkeypatch.setitem(sys.modules, "pywhispercpp.model", SimpleNamespace(Model=Mock()))
monkeypatch.setitem(
sys.modules,
"sherpa_onnx",
SimpleNamespace(OfflineRecognizer=SimpleNamespace(from_transducer=create_parakeet)),
)
monkeypatch.setattr(rm, "get_model_path", lambda size: str(checkpoint))
monkeypatch.setattr(manager, "_whispercpp_model_is_verified", lambda path: True)
monkeypatch.setattr(manager, "_load_whispercpp_model", load_whispercpp)
monkeypatch.setattr(rm.parakeet, "is_model_downloaded", lambda size: True)
monkeypatch.setattr(manager, "_parakeet_model_is_verified", lambda *args: True)
return entered, release, loads
def test_superseded_resume_cannot_replace_the_requested_parakeet_model(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch)
entered, release, loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
# Enter the production deferred path, then block the real whisper.cpp
# initializer immediately before its model-name validation.
manager._model_lock.acquire()
try:
manager.reinitialize_after_resume(auto_resume=True)
finally:
manager._model_lock.release()
assert entered.wait(1)
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
assert (manager.engine, manager.model_size) == ("whisper_cpp", "small")
finally:
release.set()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert errors == []
assert loads == [("whisper_cpp", "small"), ("parakeet", "v2-english")]
assert (manager.engine, manager.model_size) == ("parakeet", "v2-english")
assert manager.model == ("parakeet", "v2-english")
assert manager.model_ready
assert not manager.is_auto_paused
def test_cancel_during_initial_handoff_does_not_restore_active_initializer_fields(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch, model_size="obsolete")
entered, release, _loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
waiting, loading, release_load = threading.Event(), threading.Event(), threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
original_load = manager._load_whispercpp_model
def wait() -> None:
waiting.set()
original_wait()
def load(path: str) -> None:
loading.set()
assert release_load.wait(3)
original_load(path)
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
monkeypatch.setattr(manager, "_load_whispercpp_model", load)
initializer = threading.Thread(target=manager.reinitialize_after_resume)
initializer.start()
assert entered.wait(1)
assert manager.try_begin_download()
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
release.set()
assert loading.wait(1)
# This write is the real old initializer's invalid-model fallback.
assert manager.model_size == "tiny"
manager.cancel_download()
settings_worker.join(0.5)
assert not settings_worker.is_alive()
assert len(errors) == 1
assert str(errors[0]) == "Download cancelled"
assert (manager.engine, manager.model_size) == ("whisper_cpp", "tiny")
assert initializer.is_alive()
finally:
release.set()
release_load.set()
settings_worker.join(2)
initializer.join(2)
manager.end_download()
assert not initializer.is_alive()
assert manager.model == ("whisper_cpp", "tiny")
def test_settings_releases_initial_handoff_lock_before_network_prefetch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _make_manager(monkeypatch)
prefetched: list[tuple[str, str]] = []
def download() -> None:
assert manager._model_lock.acquire(blocking=False)
try:
prefetched.append((manager.engine, manager.model_size))
finally:
manager._model_lock.release()
def initialize() -> None:
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
monkeypatch.setattr(manager, "_download_selected_model_if_missing", download)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
manager.reconfigure(model_size="medium")
assert prefetched == [("whisper_cpp", "medium")]
assert manager.model == ("whisper_cpp", "medium")
def test_resume_queued_before_settings_cannot_initialize_after_its_handoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _make_manager(monkeypatch)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
loads: list[tuple[str, str]] = []
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def initialize() -> None:
loads.append((manager.engine, manager.model_size))
manager.model = (manager.engine, manager.model_size)
manager._model_initialized = True
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
monkeypatch.setattr(manager, "_init_selected_engine", initialize)
monkeypatch.setattr(
manager, "_init_whispercpp", lambda: pytest.fail("stale resume initialized")
)
manager._model_lock.acquire()
settings_worker = threading.Thread(target=reconfigure)
try:
manager.reinitialize_after_resume(auto_resume=True)
settings_worker.start()
assert waiting.wait(1)
finally:
manager._model_lock.release()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert errors == []
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert loads == [("parakeet", "v2-english")]
assert manager.model == ("parakeet", "v2-english")
def test_auto_pause_after_settings_reservation_still_owns_the_unload(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
manager = _make_manager(monkeypatch)
entered, release, loads = _gate_whispercpp_initializer(manager, monkeypatch, tmp_path)
workers = _track_deferred_workers(manager, monkeypatch)
waiting = threading.Event()
errors: list[Exception] = []
original_wait = manager._wait_for_model_settings_handoff
def wait() -> None:
waiting.set()
original_wait()
def reconfigure() -> None:
try:
manager.reconfigure(engine="parakeet", model_size="v2-english", force_download=False)
except Exception as error:
errors.append(error)
monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait)
manager._model_lock.acquire()
try:
manager.reinitialize_after_resume(auto_resume=True)
finally:
manager._model_lock.release()
assert entered.wait(1)
settings_worker = threading.Thread(target=reconfigure)
settings_worker.start()
try:
assert waiting.wait(1)
manager.unload_model(reason="auto_pause")
assert manager.is_auto_paused
finally:
release.set()
settings_worker.join(2)
for worker in workers:
worker.join(2)
assert errors == []
assert not settings_worker.is_alive()
assert all(not worker.is_alive() for worker in workers)
assert loads == [("whisper_cpp", "small")]
assert (manager.engine, manager.model_size) == ("parakeet", "v2-english")
assert manager.model is None
assert not manager.model_ready
assert manager.is_auto_paused
assert not manager._auto_resume_pending
+190
View File
@@ -0,0 +1,190 @@
"""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
+3 -1
View File
@@ -276,6 +276,8 @@ def test_capture_thread_failure_releases_reload_worker(
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)
@@ -285,7 +287,7 @@ def test_capture_thread_failure_releases_reload_worker(
assert manager._buffered_capture_failed
assert not manager.should_record
assert manager.state == RecognitionState.ERROR
assert manager.state == RecognitionState.IDLE
assert manager._capture_finished.is_set()
manager._cancel_buffered_session.set()
+1 -1
View File
@@ -1070,7 +1070,7 @@ def test_a_picker_change_only_stages_a_missing_model(
# a buffered recording and reload the model on whispercpp_* keys.
dialog._apply_settings_internal.assert_not_called()
dialog.speech_engine.reconfigure.assert_not_called()
assert dialog.speech_engine.vad_sensitivity == 2
dialog.speech_engine.update_live_settings.assert_called_once_with(vad_sensitivity=2)
dialog._save_selected_settings.assert_called_once_with({"vad_sensitivity": 2})
assert dialog._staged_model_spec == {
"engine": "whisper_cpp",
+63
View File
@@ -2,6 +2,7 @@
import importlib
import sys
import threading
from typing import Any, Optional
from unittest.mock import MagicMock, Mock, patch
@@ -766,3 +767,65 @@ def test_cancel_worker_reraises_unexpected_defect() -> None:
assert raised is True
glib.idle_add.assert_not_called()
def test_audio_test_cancellation_invalidates_queued_results() -> None:
"""A closed dialog and a superseded test must never receive stale UI work."""
dialog = Mock()
old_cancel = threading.Event()
dialog._audio_test_cancel = old_cancel
SettingsDialog._cancel_audio_test(dialog)
assert old_cancel.is_set()
assert dialog._audio_test_cancel is None
assert SettingsDialog._complete_audio_test(dialog, {}, old_cancel) is False
dialog._handle_audio_test_result.assert_not_called()
current = threading.Event()
dialog._audio_test_cancel = current
assert SettingsDialog._complete_audio_test(dialog, {}, threading.Event()) is False
dialog._handle_audio_test_result.assert_not_called()
assert SettingsDialog._complete_audio_test(dialog, {"success": True}, current) is False
dialog._handle_audio_test_result.assert_called_once_with({"success": True})
assert dialog._audio_test_cancel is None
def test_settings_close_cancels_audio_before_persisting_edits() -> None:
"""The response path cancels even if the caller delays destruction."""
dialog = Mock()
SettingsDialog._on_settings_dialog_response(
dialog, dialog, settings_dialog.Gtk.ResponseType.CLOSE
)
assert dialog.method_calls[0][0] == "_cancel_audio_test"
dialog._cancel_audio_test.assert_called_once_with()
def test_settings_audio_worker_propagates_cancellation() -> None:
"""Closing Settings cancels the predicate held by its background worker."""
dialog = Mock()
dialog._audio_test_cancel = None
dialog.audio_device_combo.get_active_id.return_value = "-1"
dialog._cancel_audio_test = SettingsDialog._cancel_audio_test.__get__(dialog)
with (
patch.object(settings_dialog.threading, "Thread") as thread_cls,
patch.object(settings_dialog, "GLib") as glib,
patch("vocalinux.speech_recognition.recognition_manager.test_audio_input") as test_audio,
):
SettingsDialog._on_test_audio_clicked(dialog, None)
worker = thread_cls.call_args.kwargs["target"]
cancel = dialog._audio_test_cancel
def capture(**kwargs: Any) -> dict:
assert kwargs["device_index"] is None
assert kwargs["should_continue"]()
dialog._cancel_audio_test()
assert not kwargs["should_continue"]()
return {"success": False, "error": "cancelled"}
test_audio.side_effect = capture
worker()
assert cancel.is_set()
glib.idle_add.assert_called_once()
callback, result, token = glib.idle_add.call_args.args
assert callback == dialog._complete_audio_test
assert result["success"] is False
assert token is cancel
+4
View File
@@ -432,6 +432,10 @@ class TestSpeechRecognition(unittest.TestCase):
manager.should_record = True
manager.audio_thread = self.mock_thread
manager.recognition_thread = self.mock_thread
self.mock_thread.is_alive.return_value = True
self.mock_thread.join.side_effect = lambda *_args, **_kwargs: setattr(
self.mock_thread.is_alive, "return_value", False
)
# Reset mocks
self.mock_play_stop.reset_mock()
+8 -4
View File
@@ -85,12 +85,14 @@ def test_stop_racing_start_is_not_lost() -> None:
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.
# 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()
stop.join(timeout=5.0)
manager._record_audio = record_audio
manager.register_state_callback(release_key_on_another_thread)
@@ -99,8 +101,10 @@ def test_stop_racing_start_is_not_lost() -> None:
with patch.object(manager, "_arm_playback_duck"):
assert manager.start_recognition(mode="push_to_talk")
assert stops and not stops[0].is_alive()
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)
@@ -120,7 +124,7 @@ def test_stop_completes_while_capture_read_is_blocked() -> None:
source.requires_pyaudio = False
source.sample_rate = 16000
def read_chunk() -> bytes:
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():
+115 -7
View File
@@ -290,7 +290,9 @@ class TestReinitializeAfterResume(unittest.TestCase):
mgr.reinitialize_after_resume()
mgr._update_state.assert_called_once_with(RecognitionState.ERROR)
mgr._update_state.assert_called_once_with(
RecognitionState.ERROR, model_request=mgr._model_request
)
class TestTrayIndicatorResumeFlow(unittest.TestCase):
@@ -303,10 +305,15 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
indicator = TrayIndicator.__new__(TrayIndicator)
indicator.speech_engine = MagicMock()
indicator.speech_engine.prepare_auto_resume.return_value = object()
indicator.speech_engine.is_auto_paused = False
indicator._auto_pause_monitor = None
indicator.config_manager = MagicMock()
indicator.config_manager.get_bool.return_value = False
indicator._external_activation_unavailable = False
indicator._setup_keyboard_shortcuts = MagicMock()
indicator.shortcut_manager = MagicMock()
indicator._language_shortcut_managers = []
indicator._input_monitor = None
indicator._settle_timer_id = None
indicator._monitor_timeout_id = None
@@ -317,16 +324,16 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
indicator._reinit_speech_after_resume()
indicator.speech_engine.reinitialize_after_resume.assert_called_once()
indicator.speech_engine.reinitialize_after_resume.assert_called_once_with(
auto_resume=True, expected_request=None
)
def test_reinit_speech_returns_source_remove(self):
from gi.repository import GLib
indicator = self._make_tray_indicator()
result = indicator._reinit_speech_after_resume()
assert result == GLib.SOURCE_REMOVE
assert result is False
def test_reinit_speech_failure_is_caught(self):
indicator = self._make_tray_indicator()
@@ -334,6 +341,48 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
indicator._reinit_speech_after_resume()
def test_reinit_speech_uses_captured_request(self) -> None:
indicator = self._make_tray_indicator()
request = indicator.speech_engine.prepare_auto_resume.return_value
indicator._reinit_speech_after_resume(request)
indicator.speech_engine.reinitialize_after_resume.assert_called_once_with(
auto_resume=True, expected_request=request
)
def test_reinit_speech_skips_new_game_pause(self) -> None:
indicator = self._make_tray_indicator()
indicator._auto_pause_monitor = MagicMock(paused=True)
indicator._model_keepalive = MagicMock()
indicator._reinit_speech_after_resume(object())
indicator.speech_engine.reinitialize_after_resume.assert_not_called()
indicator._model_keepalive.bump.assert_not_called()
def test_reinit_speech_skips_new_suspend(self) -> None:
indicator = self._make_tray_indicator()
indicator._model_keepalive = MagicMock()
indicator._on_system_suspend()
indicator._reinit_speech_after_resume(object())
indicator.speech_engine.reinitialize_after_resume.assert_not_called()
indicator._model_keepalive.bump.assert_not_called()
indicator.speech_engine.cancel_pending_auto_pause.assert_called_once_with()
def test_suspend_stops_active_recognition(self) -> None:
indicator = self._make_tray_indicator()
indicator.speech_engine.state = RecognitionState.LISTENING
indicator._model_keepalive = MagicMock()
indicator._on_system_suspend()
assert indicator._system_suspended
indicator.speech_engine.stop_recognition.assert_called_once_with()
indicator._model_keepalive.cancel.assert_called_once_with()
@patch("vocalinux.ui.tray_indicator.GLib")
def test_on_system_resume_schedules_speech_and_monitor(self, mock_glib):
indicator = self._make_tray_indicator()
@@ -344,6 +393,8 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
assert len(calls) == 2
assert calls[0][0][0] == 2
assert calls[0][0][1] == indicator._reinit_speech_after_resume
assert calls[0][0][2] is indicator.speech_engine.prepare_auto_resume.return_value
indicator.speech_engine.prepare_auto_resume.assert_called_once_with()
assert calls[1][0][0] == 2
assert calls[1][0][1] == indicator._start_input_device_monitor
@@ -359,6 +410,7 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase):
calls = mock_glib.timeout_add_seconds.call_args_list
assert len(calls) == 1
assert calls[0][0][1] == indicator._reinit_speech_after_resume
assert calls[0][0][2] is indicator.speech_engine.prepare_auto_resume.return_value
@patch("vocalinux.ui.tray_indicator.Gio")
@patch("vocalinux.ui.tray_indicator.GLib")
@@ -591,6 +643,8 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
indicator._model_keepalive = MagicMock()
indicator._auto_pause_monitor = MagicMock()
indicator._auto_pause_monitor.paused = False
indicator.shortcut_manager = MagicMock()
indicator._language_shortcut_managers = []
return indicator
def test_get_auto_pause_config(self):
@@ -616,9 +670,29 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
def test_on_auto_resume_calls_reinit(self):
indicator = self._make_tray_indicator()
indicator._on_auto_resume()
indicator.speech_engine.reinitialize_after_resume.assert_called_once()
indicator.speech_engine.reinitialize_after_resume.assert_called_once_with(auto_resume=True)
indicator._model_keepalive.bump.assert_called_once()
def test_auto_resume_during_suspend_waits_for_wake(self) -> None:
indicator = self._make_tray_indicator()
indicator._on_system_suspend()
indicator._on_auto_resume()
indicator.speech_engine.reinitialize_after_resume.assert_not_called()
indicator._model_keepalive.bump.assert_not_called()
def test_suspend_reissues_unload_when_game_stays_active(self) -> None:
indicator = self._make_tray_indicator()
indicator._auto_pause_monitor.paused = True
indicator._on_system_suspend()
assert indicator._system_suspended
indicator.speech_engine.unload_model.assert_called_once_with(reason="auto_pause")
indicator.speech_engine.cancel_pending_auto_pause.assert_not_called()
indicator._model_keepalive.cancel.assert_called_once_with()
def test_get_model_keepalive_config(self):
indicator = self._make_tray_indicator()
indicator.config_manager.get_bool.return_value = True
@@ -652,6 +726,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
def test_system_resume_skips_reinit_when_auto_paused(self, mock_glib):
indicator = self._make_tray_indicator()
indicator.speech_engine.is_auto_paused = True
indicator._auto_pause_monitor.paused = True
indicator.config_manager.get_bool.return_value = False
indicator._start_input_device_monitor = MagicMock()
@@ -661,6 +736,39 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
assert (
mock_glib.timeout_add_seconds.call_args[0][1] == indicator._start_input_device_monitor
)
indicator.speech_engine.prepare_auto_resume.assert_not_called()
@patch("vocalinux.ui.tray_indicator.GLib")
def test_system_resume_rechecks_monitor_even_without_manager_guard(
self, mock_glib: MagicMock
) -> None:
indicator = self._make_tray_indicator()
indicator._auto_pause_monitor.paused = True
indicator.config_manager.get_bool.return_value = False
indicator._on_system_resume()
mock_glib.timeout_add_seconds.assert_called_once_with(
2, indicator._start_input_device_monitor
)
@patch("vocalinux.ui.tray_indicator.GLib")
def test_system_resume_schedules_guarded_recovery_when_game_is_gone(
self, mock_glib: MagicMock
) -> None:
indicator = self._make_tray_indicator()
indicator.speech_engine.is_auto_paused = True
indicator.config_manager.get_bool.return_value = False
indicator._system_suspended = True
indicator._on_system_resume()
assert not indicator._system_suspended
assert mock_glib.timeout_add_seconds.call_args_list[0].args == (
2,
indicator._reinit_speech_after_resume,
indicator.speech_engine.prepare_auto_resume.return_value,
)
@patch("vocalinux.ui.tray_indicator.GLib")
def test_system_resume_reinits_when_not_auto_paused(self, mock_glib):
@@ -724,7 +832,7 @@ class TestTrayAutoPauseWiring(unittest.TestCase):
indicator._on_auto_resume() # must not raise
indicator.speech_engine.reinitialize_after_resume.assert_called_once()
indicator.speech_engine.reinitialize_after_resume.assert_called_once_with(auto_resume=True)
def test_on_auto_resume_without_reinit_method(self):
indicator = self._make_tray_indicator()
+223
View File
@@ -0,0 +1,223 @@
"""System Default must follow a sound-server PCM instead of substituted ALSA hardware."""
import sys
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from vocalinux.audio import capture
def device(index: int, name: str, channels: int = 1, host_api: int = 0) -> dict:
"""Build realistic PortAudio enumeration metadata without probing hardware."""
return {
"index": index,
"name": name,
"maxInputChannels": channels,
"hostApi": host_api,
"defaultSampleRate": 16000,
}
@pytest.fixture
def audio(monkeypatch: pytest.MonkeyPatch) -> Mock:
devices = [device(0, "Webcam (hw:1,0)"), device(1, "pulse", 32)]
audio = Mock()
audio.get_default_input_device_info.return_value = devices[0]
audio.get_host_api_info_by_index.return_value = {"type": 8, "name": "ALSA"}
audio.get_device_count.return_value = len(devices)
audio.get_device_info_by_index.side_effect = lambda index: devices[index]
audio.open.return_value.get_read_available.return_value = 1024
audio.open.return_value.read.return_value = b"\x00\x01" * 1024
monkeypatch.setitem(sys.modules, "pyaudio", SimpleNamespace(PyAudio=lambda: audio, paInt16=8))
monkeypatch.setitem(sys.modules, "numpy", sys._vocalinux_real_numpy)
return audio
@pytest.mark.parametrize("name", ["default", "pulse", "pipewire", "DeepFilterNet", "default: Mic"])
def test_valid_defaults_stay_implicit(audio: Mock, name: str) -> None:
audio.get_default_input_device_info.return_value = device(0, name)
assert capture._resolve_system_default(audio) is None
audio.open.assert_not_called()
@pytest.mark.parametrize("host_type", [1, 2, 12, 16])
def test_other_host_apis_keep_their_default(audio: Mock, host_type: int) -> None:
audio.get_host_api_info_by_index.return_value = {"type": host_type}
assert capture._resolve_system_default(audio) is None
audio.get_device_count.assert_not_called()
def test_hardware_default_prefers_input_pulse_without_probing(audio: Mock) -> None:
assert capture._resolve_system_default(audio) == 1
audio.open.assert_not_called()
audio.is_format_supported.assert_not_called()
@pytest.mark.parametrize(
"candidate",
[
device(1, "pulse", 0),
device(1, "pulse.monitor"),
device(1, "pipewire"),
device(1, "pulse", 1, host_api=1),
device(1, "speech-dispatcher-dummy"),
],
)
def test_ineligible_pcm_warns_with_actual_hardware(
audio: Mock, candidate: dict, caplog: pytest.LogCaptureFixture
) -> None:
audio.get_device_info_by_index.side_effect = lambda index: (
device(0, "Webcam (hw:1,0)") if index == 0 else candidate
)
assert capture._resolve_system_default(audio) is None
assert "Webcam (hw:1,0)" in caplog.text and "no input-capable pulse PCM" in caplog.text
audio.open.assert_not_called()
def test_unavailable_pcm_warns_and_keeps_implicit_fallback(
audio: Mock, caplog: pytest.LogCaptureFixture
) -> None:
def info(index: int) -> dict:
if index == 1:
raise OSError("device unavailable")
return device(0, "Webcam (hw:1,0)")
audio.get_device_info_by_index.side_effect = info
assert capture._resolve_system_default(audio) is None
assert "Webcam (hw:1,0)" in caplog.text
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_all_opening_paths_select_pulse_once(audio: Mock, endpoint: str) -> None:
source = capture.PortAudioCaptureSource()
try:
if endpoint == "open":
source.open(audio)
assert source.resolved_device_name == "pulse"
elif endpoint == "reopen":
assert source.reopen(audio)
assert source.resolved_device_name == "pulse"
else:
result = capture.test_audio_input(duration=0.1)
assert result["success"]
assert result["device_name"] == "pulse" and result["device_index"] == 1
audio.open.assert_called_once()
assert audio.open.call_args.kwargs["input_device_index"] == 1
audio.get_host_api_info_by_index.assert_called_once_with(0)
finally:
if endpoint != "settings":
source.close()
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_explicit_hardware_is_preserved(audio: Mock, endpoint: str) -> None:
source = capture.PortAudioCaptureSource(device_index=0)
try:
if endpoint == "open":
source.open(audio)
elif endpoint == "reopen":
assert source.reopen(audio)
else:
assert capture.test_audio_input(device_index=0, duration=0.1)["success"]
assert audio.open.call_args.kwargs["input_device_index"] == 0
audio.get_host_api_info_by_index.assert_not_called()
finally:
if endpoint != "settings":
source.close()
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_empty_enumeration_never_opens_an_explicit_index(audio: Mock, endpoint: str) -> None:
audio.get_device_count.return_value = 0
source = capture.PortAudioCaptureSource()
try:
if endpoint == "open":
source.open(audio)
elif endpoint == "reopen":
assert source.reopen(audio)
else:
assert capture.test_audio_input(duration=0.1)["success"]
assert "input_device_index" not in audio.open.call_args.kwargs
finally:
if endpoint != "settings":
source.close()
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_selected_pulse_failure_never_switches_to_hardware(audio: Mock, endpoint: str) -> None:
audio.open.side_effect = OSError("pulse cannot open")
source = capture.PortAudioCaptureSource()
try:
if endpoint == "open":
with pytest.raises(OSError, match="pulse cannot open"):
source.open(audio)
elif endpoint == "reopen":
assert not source.reopen(audio)
assert "pulse cannot open" in str(source.last_error)
else:
result = capture.test_audio_input(duration=0.1)
assert not result["success"] and "pulse cannot open" in result["error"]
assert audio.open.call_count > 1 # Negotiation plus the final plain open.
assert all(call.kwargs.get("input_device_index") == 1 for call in audio.open.call_args_list)
audio.get_host_api_info_by_index.assert_called_once_with(0)
finally:
if endpoint != "settings":
source.close()
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_no_safe_pcm_reports_the_actual_implicit_fallback(
audio: Mock, endpoint: str, caplog: pytest.LogCaptureFixture
) -> None:
audio.get_device_count.return_value = 1
source = capture.PortAudioCaptureSource()
try:
if endpoint == "open":
source.open(audio)
assert source.resolved_device_name == "Webcam (hw:1,0)"
elif endpoint == "reopen":
assert source.reopen(audio)
assert source.resolved_device_name == "Webcam (hw:1,0)"
else:
result = capture.test_audio_input(duration=0.1)
assert result["success"] and result["device_name"] == "Webcam (hw:1,0)"
assert "Webcam (hw:1,0)" in caplog.text
assert "no input-capable pulse PCM" in caplog.text
audio.open.assert_called_once()
assert "input_device_index" not in audio.open.call_args.kwargs
finally:
if endpoint != "settings":
source.close()
@pytest.mark.parametrize("endpoint", ["open", "reopen", "settings"])
def test_final_plain_open_retains_selected_pcm_when_host_default_changes(
audio: Mock, monkeypatch: pytest.MonkeyPatch, endpoint: str
) -> None:
def negotiate(_audio: object, index: int | None) -> tuple[int, int, None]:
assert index == 1
audio.get_default_input_device_info.return_value = device(1, "pulse", 32)
return 1, 16000, None
monkeypatch.setattr(capture, "_open_capture_stream", negotiate)
source = capture.PortAudioCaptureSource()
try:
if endpoint == "open":
source.open(audio)
elif endpoint == "reopen":
assert source.reopen(audio)
else:
assert capture.test_audio_input(duration=0.1)["success"]
audio.open.assert_called_once()
assert audio.open.call_args.kwargs["input_device_index"] == 1
finally:
if endpoint != "settings":
source.close()
def test_unknown_host_api_keeps_the_host_default(audio: Mock) -> None:
audio.get_host_api_info_by_index.side_effect = OSError("host metadata unavailable")
assert capture._resolve_system_default(audio) is None
audio.open.assert_not_called()