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:
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Errors shared by microphone and system-audio capture sources."""
|
||||
|
||||
|
||||
class CaptureStalledError(IOError):
|
||||
"""The capture device stopped delivering audio frames."""
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user