diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 21bbadf..01d72e0 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -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:
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index a949b04..f8dfb0c 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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**:
diff --git a/src/vocalinux/audio/capture.py b/src/vocalinux/audio/capture.py
index 2c29990..1e539d1 100644
--- a/src/vocalinux/audio/capture.py
+++ b/src/vocalinux/audio/capture.py
@@ -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
diff --git a/src/vocalinux/audio/errors.py b/src/vocalinux/audio/errors.py
new file mode 100644
index 0000000..72fe98f
--- /dev/null
+++ b/src/vocalinux/audio/errors.py
@@ -0,0 +1,5 @@
+"""Errors shared by microphone and system-audio capture sources."""
+
+
+class CaptureStalledError(IOError):
+ """The capture device stopped delivering audio frames."""
diff --git a/src/vocalinux/audio/pipewire.py b/src/vocalinux/audio/pipewire.py
index b1571ad..50d9d47 100644
--- a/src/vocalinux/audio/pipewire.py
+++ b/src/vocalinux/audio/pipewire.py
@@ -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
diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py
index 35f7eb4..3b6ee8e 100644
--- a/src/vocalinux/common_types.py
+++ b/src/vocalinux/common_types.py
@@ -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."""
...
diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py
index 4eb9ef4..626c4a3 100644
--- a/src/vocalinux/speech_recognition/recognition_manager.py
+++ b/src/vocalinux/speech_recognition/recognition_manager.py
@@ -18,6 +18,8 @@ import subprocess
import sys
import threading
import time
+from collections.abc import Iterator
+from contextlib import contextmanager, nullcontext
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Optional, Union, cast
@@ -27,6 +29,7 @@ if TYPE_CHECKING:
from ..audio.capture import ( # noqa: F401 (re-exported for compatibility)
_STICKY_LOCK_MIN_MEAN_SQUARE,
+ CaptureStalledError,
PortAudioCaptureSource,
_alsa_handler,
_downmix_to_mono,
@@ -89,6 +92,41 @@ if TYPE_CHECKING:
from ..custom_dictionary import CustomDictionaryManager
+# Shutdown budgets are separate so tests can shorten waits without sleeping.
+_CAPTURE_JOIN_SECONDS = 2.0
+_RECOGNITION_JOIN_SECONDS = 5.0
+_RECOGNITION_FINAL_JOIN_SECONDS = 1.0
+_FINAL_BUFFER_SECONDS = 1.0
+_STOP_WAIT_SECONDS = 9.0
+
+
+class _CaptureSession:
+ """Cancellation and handoff state that must never be reused by a later recording."""
+
+ def __init__(self) -> None:
+ self.cancel = threading.Event()
+ self.capture_finished = threading.Event()
+ self.stop_complete = threading.Event()
+ self.stop_owner: Optional[threading.Thread] = None
+ self.stopping = False
+ self.stall_recovery_attempted = False
+ self.stall_error: Optional[OSError] = None
+ self.device_name = "System Default"
+ self.segments: queue.Queue = queue.Queue(maxsize=32)
+ # A dispatched callback can outlive the bounded recognition join.
+ # A new session must not start until that callback has returned.
+ self.dispatching = 0
+
+
+class _ReconfigureOutcome:
+ """Whether a completed Settings change still needs an inherited model reload."""
+
+ def __init__(self) -> None:
+ # A rejected change may reuse the restored ready model. A successful
+ # change without an engine restart must still finish inherited resume.
+ self.reload_for_resume = False
+
+
class _HeldTranscript(NamedTuple):
"""A transcript held until its push-to-talk session is released."""
@@ -506,6 +544,10 @@ class SpeechRecognitionManager:
self._model_missing_handler: Optional[Callable[[str], bool]] = None
self._download_cancelled = False
self._prefetching_model = False
+ self._reconfigure_request: Optional[object] = None
+ self._reconfigure_hot_settings: dict[str, Any] = {}
+ self._reconfigure_done = threading.Event()
+ self._reconfigure_done.set()
# Held by whichever UI is downloading a model. The tray and the settings
# dialog both download in the background, and there is only one progress
# callback and one engine configuration between them.
@@ -516,8 +558,14 @@ class SpeechRecognitionManager:
self._download_openers: set[threading.Thread] = set()
self._defer_download = defer_download
self._model_initialized = False
- # True while auto-pause has unloaded the model for a configured app/game
+ # True while a configured app/game blocks dictation, including pending unloads
self._auto_paused = False
+ # Request publication must never wait for a native model load. Resource
+ # changes are serialized separately by _model_lock.
+ self._model_request_lock = threading.Lock()
+ self._model_request: Optional[object] = None
+ # Distinguish a recovery obligation from a configured app's active pause.
+ self._auto_resume_pending = False
# True while idle keep-alive has unloaded the model (dictation may lazy-reload)
self._idle_unloaded = False
self.buffer_during_reload = bool(kwargs.get("buffer_during_reload", False))
@@ -580,6 +628,10 @@ class SpeechRecognitionManager:
# propagated onto each enqueued _AudioSegment.
self._segment_started_at: Optional[float] = None
self._recording_segment_has_speech = False
+ self._lifecycle_lock = threading.RLock()
+ self._worker_session = threading.local()
+ self._session = _CaptureSession()
+ self._capture_finished = self._session.capture_finished
self._buffer_lock = threading.Lock() # Thread safety for audio_buffer
self._model_lock = threading.Lock() # Thread safety for model/recognizer access
self._segment_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue(maxsize=32)
@@ -599,7 +651,7 @@ class SpeechRecognitionManager:
self._reconnection_attempts = 0
self._max_reconnection_attempts = 5
self._reconnection_delay = 1.0 # Initial delay in seconds
- self._last_audio_error_time = 0.0
+ self._last_audio_error_time: float = 0.0
self._audio_stream = None
self._pyaudio_instance = None
self._capture_sample_rate = 16000 # Default, updated when device is opened
@@ -1052,7 +1104,7 @@ class SpeechRecognitionManager:
except ImportError:
logger.error("Failed to import VOSK. Please install it with 'pip install vosk'")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
@staticmethod
@@ -1163,11 +1215,11 @@ class SpeechRecognitionManager:
except ImportError as e:
logger.error(f"Failed to import required libraries for Whisper: {e}")
logger.error("Please install with: pip install openai-whisper torch")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
except (RuntimeError, OSError) as e:
logger.error(f"Failed to initialize Whisper engine: {e}")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
def _transcribe_with_whisper(
@@ -1366,7 +1418,7 @@ class SpeechRecognitionManager:
except ImportError as e:
logger.error(f"Failed to import faster-whisper engine: {e}")
logger.error("Please install with: pip install faster-whisper")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
try:
@@ -1422,7 +1474,7 @@ class SpeechRecognitionManager:
logger.info("faster-whisper engine initialized successfully.")
except (ImportError, RuntimeError, OSError, ValueError, ChecksumError) as e:
logger.error(f"Failed to initialize faster-whisper engine: {e}", exc_info=True)
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
def _transcribe_with_faster_whisper(
@@ -1517,11 +1569,11 @@ class SpeechRecognitionManager:
logger.error(f"Failed to import pywhispercpp: {e}")
logger.error(f"Python path: {sys.path}")
logger.error("Please install with: pip install pywhispercpp")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
except (FileNotFoundError, RuntimeError, OSError) as e:
logger.error(f"Failed to initialize whisper.cpp engine: {e}", exc_info=True)
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
@staticmethod
@@ -2299,11 +2351,11 @@ class SpeechRecognitionManager:
except ImportError as e:
logger.error(f"Failed to import sherpa-onnx: {e}")
logger.error("Please install it with 'pip install sherpa-onnx'")
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
except (ChecksumError, FileNotFoundError, RuntimeError, OSError) as e:
logger.error(f"Failed to initialize Parakeet engine: {e}", exc_info=True)
- self.state = RecognitionState.ERROR
+ self._set_engine_error()
raise
def _transcribe_with_parakeet(self, audio_buffer: list[bytes]) -> str:
@@ -3166,19 +3218,30 @@ class SpeechRecognitionManager:
"""Get the last recorded audio level (0-100)."""
return self._last_audio_level
- def _update_state(self, new_state: RecognitionState):
+ def _update_state(
+ self, new_state: RecognitionState, *, model_request: Optional[object] = None
+ ) -> None:
"""
Update the recognition state and notify callbacks.
Args:
new_state: The new recognition state
+ model_request: Ignore publication if this model request was superseded.
"""
- self.state = new_state
- if new_state in (RecognitionState.IDLE, RecognitionState.ERROR):
- # A per-language shortcut's override lasts for exactly one
- # dictation (#805); restore the configured language here so every
- # exit path — normal stop, error, quit — unwinds it.
- self._restore_language_after_oneshot()
+ # Serialize the ownership check and state write with a new start.
+ # Lightweight __new__-built consumers have no lifecycle lock.
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ if not self._owns_session():
+ return
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if not self._owns_model_request(model_request):
+ return
+ self.state = new_state
+ if new_state in (RecognitionState.IDLE, RecognitionState.ERROR):
+ # A per-language shortcut's override lasts for exactly one
+ # dictation (#805); restore the configured language here so every
+ # exit path — normal stop, error, quit — unwinds it.
+ self._restore_language_after_oneshot()
for callback in self.state_callbacks:
callback(new_state)
@@ -3280,13 +3343,63 @@ class SpeechRecognitionManager:
microphone is still open. Failures are logged and never raised.
"""
with self._playback_duck_lock:
+ if not self._owns_session():
+ return
self._cancel_pending_playback_duck()
try:
self._playback_duck.restore()
except Exception:
logger.error("Could not restore playback volume", exc_info=True)
+ def _owns_session(self) -> bool:
+ """Whether this worker still belongs to the manager's current recording."""
+ context = getattr(self, "_worker_session", None)
+ return context is None or getattr(context, "session", self._session) is self._session
+
+ def _run_session_worker(self, session: _CaptureSession, target: Callable[[], None]) -> None:
+ """Bind a worker to its original session even if it starts running late."""
+ previous = getattr(self._worker_session, "session", None)
+ self._worker_session.session = session
+ try:
+ if self._owns_session():
+ target()
+ finally:
+ if previous is None:
+ del self._worker_session.session
+ else:
+ self._worker_session.session = previous
+
def start_recognition(self, mode: str = "toggle") -> bool:
+ """Start a recording, serializing it with stop election and other starts."""
+ with self._lifecycle_lock:
+ # A native open/close can outlive the capture join. Do not reuse its
+ # device or VAD until its owning thread has finished teardown.
+ if self.audio_thread and self.audio_thread.is_alive():
+ logger.warning("Cannot start recognition while capture is still stopping")
+ return False
+ if (
+ self._session.stop_owner is not None
+ and not self._session.stop_complete.is_set()
+ and not (self._session_cancel.is_set() and self._session.capture_finished.is_set())
+ ):
+ return False
+ if self._session.dispatching:
+ logger.warning("Cannot start recognition while a previous callback is finishing")
+ return False
+ # An explicit start may be requested by an IDLE callback running
+ # on the old stopper/worker. Own the new start independently, then
+ # restore the old context so its remaining work stays stale.
+ context = getattr(self, "_worker_session", None)
+ if context is None or not hasattr(context, "session"):
+ return self._start_recognition(mode)
+ previous_session = context.session
+ del context.session
+ try:
+ return self._start_recognition(mode)
+ finally:
+ context.session = previous_session
+
+ def _start_recognition(self, mode: str = "toggle") -> bool:
"""Start the speech recognition process.
Returns:
@@ -3309,6 +3422,16 @@ class SpeechRecognitionManager:
)
return False
+ if getattr(self, "_reconfigure_request", None) is not None:
+ logger.warning("Cannot start recognition while speech settings are changing")
+ play_error_sound()
+ _show_notification(
+ "Speech settings are changing",
+ "Dictation will be available when the settings have finished applying.",
+ "dialog-information",
+ )
+ return False
+
if self._auto_paused:
logger.warning(
"Cannot start recognition: auto-paused while a configured app/game is running"
@@ -3352,10 +3475,13 @@ class SpeechRecognitionManager:
)
return False
+ session = _CaptureSession()
+ self._session = session
+ self._capture_finished = session.capture_finished
self._buffered_reload_session = reload_in_background
self._reload_pending = reload_in_background
+ self._buffered_capture_failed = False
if reload_in_background:
- self._buffered_capture_failed = False
self._capture_finished.clear()
self._cancel_buffered_session.clear()
@@ -3366,7 +3492,7 @@ class SpeechRecognitionManager:
# A per-language shortcut's override runs after the layout refresh so
# the explicit key press still wins over follow-mode resolution, and
# so its capability check sees the model the refresh will serve (#805).
- if not self._apply_pending_language_override():
+ if not self._apply_pending_language_override() or self._session is not session:
return False
# Bind this session's language for the transcription workers: they can
@@ -3374,8 +3500,16 @@ class SpeechRecognitionManager:
# rewrite the language of a segment still in the queue (#805).
self._session_language = self.language
- logger.info("Starting speech recognition")
- with self._buffer_lock:
+ if not self._buffer_lock.acquire(timeout=_FINAL_BUFFER_SECONDS):
+ logger.error("Cannot start recognition while the previous buffer is locked")
+ self._buffered_reload_session = False
+ self._reload_pending = False
+ self._restore_language_after_oneshot()
+ return False
+ try:
+ self.audio_buffer = []
+ self._segment_started_at = None
+ self._recording_segment_has_speech = False
self._session_cancel = threading.Event()
self._worker_exited = threading.Event()
self._idle_on_worker_exit = None
@@ -3386,23 +3520,39 @@ class SpeechRecognitionManager:
# recording with nothing left to stop it.
self.should_record = True
self._recognition_mode = mode
- self.audio_buffer = []
- self._segment_started_at = None
- self._segment_queue = queue.Queue(maxsize=32)
self._key_release_gate = _ReleaseGate()
- self._update_state(RecognitionState.LISTENING)
+ finally:
+ self._buffer_lock.release()
+ self._segment_queue = session.segments
- # Play the start sound
- play_start_sound()
+ logger.info("Starting speech recognition")
+ self._update_state(RecognitionState.LISTENING)
+ if self._session is not session:
+ return False
+ if not session.cancel.is_set():
+ play_start_sound()
+ if self._session is not session:
+ return False
+ if session.cancel.is_set():
+ # A state callback or start-cue hook may synchronously request stop
+ # before either worker exists to complete a buffered reload.
+ self._buffered_reload_session = False
+ self._reload_pending = False
+ self._update_state(RecognitionState.IDLE)
+ return False
# Start the audio recording thread
- self.audio_thread = threading.Thread(target=self._record_audio)
+ self.audio_thread = threading.Thread(
+ target=self._run_session_worker, args=(session, self._record_audio)
+ )
self.audio_thread.daemon = True
self.audio_thread.start()
# Start the recognition thread
target = self._reload_and_recognize if reload_in_background else self._perform_recognition
- self.recognition_thread = threading.Thread(target=target)
+ self.recognition_thread = threading.Thread(
+ target=self._run_session_worker, args=(session, target)
+ )
self.recognition_thread.daemon = True
self._track_recognition_worker(self.recognition_thread)
self.recognition_thread.start()
@@ -3419,24 +3569,22 @@ class SpeechRecognitionManager:
Returns True if something was cancelled.
"""
- if self.state not in (RecognitionState.LISTENING, RecognitionState.PROCESSING):
- return False
-
- cancel = self._session_cancel
- with self._buffer_lock:
+ with self._lifecycle_lock:
if (
- self._session_cancel is not cancel
+ not self._owns_session()
or self.state not in (RecognitionState.LISTENING, RecognitionState.PROCESSING)
- or cancel.is_set()
+ or self._session_cancel.is_set()
):
return False
+ session = self._session
+ cancel = self._session_cancel
+ segment_queue = self._segment_queue
+ buffered = getattr(self, "_buffered_reload_session", False)
logger.info("Cancelling transcription")
cancel.set()
self._idle_on_worker_exit = None
- segment_queue = self._segment_queue
- buffered = getattr(self, "_buffered_reload_session", False)
- if buffered:
- self._cancel_buffered_session.set()
+ if buffered:
+ self._cancel_buffered_session.set()
while True:
try:
@@ -3444,137 +3592,146 @@ class SpeechRecognitionManager:
except queue.Empty:
break
- self._signal_recognition_stop()
+ self._signal_recognition_stop(segment_queue)
- if self.should_record:
- self.stop_recognition()
- elif not buffered:
- self._recognition_mode = "toggle"
- self._update_state(RecognitionState.IDLE)
+ def finish_cancel() -> None:
+ with self._lifecycle_lock:
+ if self._session is not session or self._session_cancel is not cancel:
+ return
+ recording = self.should_record
+ if not recording and not buffered:
+ self._recognition_mode = "toggle"
+ if recording:
+ self.stop_recognition()
+ elif not buffered:
+ self._update_state(RecognitionState.IDLE)
+
+ self._run_session_worker(session, finish_cancel)
return True
def stop_recognition(self) -> None:
- """Stop the speech recognition process."""
- if self.state == RecognitionState.IDLE:
+ """Stop once per session; all waits on capture and handoff are bounded."""
+ current = threading.current_thread()
+ with self._lifecycle_lock:
+ if not self._owns_session():
+ return
+ session = self._session
+ if session.stop_owner is not None:
+ # A worker cannot wait for a stopper that may be joining it.
+ wait = current not in (
+ session.stop_owner,
+ self.audio_thread,
+ self.recognition_thread,
+ )
+ elif self.state == RecognitionState.IDLE:
+ return
+ else:
+ session.stop_owner = current
+ wait = False
+ self.should_record = False
+ session.cancel.set()
+ owner = session.stop_owner is current
+ if not owner:
+ if wait and not session.stop_complete.wait(_STOP_WAIT_SECONDS):
+ logger.warning("Timed out waiting for capture shutdown")
return
+ # Reentrant callbacks on the stop owner must not execute the handoff twice.
+ if session.stopping:
+ return
+ session.stopping = True
+ try:
+ self._run_session_worker(session, lambda: self._stop_session(session))
+ finally:
+ session.capture_finished.set()
+ session.stop_complete.set()
- if getattr(self, "_buffered_reload_session", False) and not self.should_record:
- return
- session_cancel = self._session_cancel
- audio_thread = self.audio_thread
- recognition_thread = self.recognition_thread
- worker_exited = getattr(self, "_worker_exited", None)
+ def _stop_session(self, session: _CaptureSession) -> None:
+ """Perform the elected stop's final-segment handoff before waking recognition."""
+ with self._lifecycle_lock:
+ if not self._owns_session() or self._session is not session:
+ return
+ session_cancel = self._session_cancel
+ worker_exited = self._worker_exited
+ segment_queue = self._segment_queue
+ self._key_release_gate.set()
logger.info("Stopping speech recognition")
-
- # Mark recording over before touching the duck so a concurrent arm
- # sees the dictation as ended. The volume goes back only once the
- # microphone thread has left the device, and before the stop cue.
- self.should_record = False
- self._key_release_gate.set()
self._update_state(RecognitionState.PROCESSING)
self._cancel_pending_playback_duck()
-
- # Wait for audio thread to finish recording and enqueue any pending audio
- # This is critical to prevent race condition where recognition thread exits
- # before the final audio segment is enqueued
- if audio_thread and audio_thread.is_alive():
- audio_thread.join(timeout=2.0)
+ current = threading.current_thread()
+ audio_thread = self.audio_thread
+ recognition_thread = self.recognition_thread
+ if audio_thread and audio_thread is not current and audio_thread.is_alive():
+ audio_thread.join(timeout=_CAPTURE_JOIN_SECONDS)
self.release_playback_duck()
+ # Capture failure has its own cue, after device teardown.
+ if not self._buffered_capture_failed:
+ if not audio_thread or not audio_thread.is_alive():
+ play_stop_sound()
+ else:
+ logger.warning("Capture is still alive; suppressing stop cue")
- # Play stop sound now that the audio thread is done and cannot capture it.
- # Kept before buffer processing so the cue still feels immediate.
- play_stop_sound()
-
- if self._session_cancel is session_cancel:
- # Trim only a small tail to avoid the stop sound without clipping the user's final word.
- with self._buffer_lock:
- if self._session_cancel is not session_cancel:
- return
- if session_cancel.is_set():
- if self.audio_buffer:
- logger.debug("Dropping final audio buffer from a cancelled session")
- self.audio_buffer = []
- self._recording_segment_has_speech = False
- else:
- stop_sound_guard_chunks = self._get_stop_sound_guard_chunks()
- if (
- stop_sound_guard_chunks > 0
- and len(self.audio_buffer) > stop_sound_guard_chunks
- ):
- discarded_chunks = self.audio_buffer[-stop_sound_guard_chunks:]
- self.audio_buffer = self.audio_buffer[:-stop_sound_guard_chunks]
- logger.debug(
- "Discarded %s audio chunks (~%sms) to avoid transcribing feedback sound",
- len(discarded_chunks),
- self.stop_sound_guard_ms,
- )
-
- if self.audio_buffer and self._recording_segment_has_speech:
- logger.debug(
- f"Enqueuing final speech buffer with {len(self.audio_buffer)} chunks"
- )
- self._enqueue_audio_segment(
- self.audio_buffer,
- release_gate=(
- self._key_release_gate
- if self._recognition_mode == "push_to_talk"
- else None
- ),
- )
- self.audio_buffer = []
- elif self.audio_buffer:
- logger.debug(
- "Dropping final audio buffer with no detected speech "
- f"({len(self.audio_buffer)} chunks)"
- )
- self.audio_buffer = []
- self._recording_segment_has_speech = False
-
- if self._session_cancel is not session_cancel:
- return
+ if self._buffer_lock.acquire(timeout=_FINAL_BUFFER_SECONDS):
+ try:
+ stop_sound_guard_chunks = self._get_stop_sound_guard_chunks()
+ if stop_sound_guard_chunks > 0 and len(self.audio_buffer) > stop_sound_guard_chunks:
+ self.audio_buffer = self.audio_buffer[:-stop_sound_guard_chunks]
+ if (
+ not session_cancel.is_set()
+ and self.audio_buffer
+ and self._recording_segment_has_speech
+ ):
+ self._enqueue_audio_segment(
+ self.audio_buffer,
+ release_gate=(
+ self._key_release_gate
+ if self._recognition_mode == "push_to_talk"
+ else None
+ ),
+ )
+ self.audio_buffer = []
+ self._recording_segment_has_speech = False
+ self._segment_started_at = None
+ finally:
+ self._buffer_lock.release()
+ else:
+ # Cancellation prevents the capture worker from publishing this
+ # segment later. A new start resets it only after capture exits.
+ logger.error("Timed out acquiring final audio buffer; dropping final segment")
+ self._signal_recognition_stop(segment_queue)
+ session.capture_finished.set()
if getattr(self, "_buffered_reload_session", False):
- # Keep the session busy until its queued audio has been transcribed.
- # In particular, key release must not wait for a cold model load.
- self._update_state(RecognitionState.PROCESSING)
- self._signal_recognition_stop()
- self._capture_finished.set()
+ # The reload worker owns completion; releasing a key must not wait
+ # for a cold model load or let the next start reuse its engine.
return
-
- # Wake up recognition thread so it can drain queued segments and stop
- if not session_cancel.is_set():
- self._signal_recognition_stop()
-
- if recognition_thread and recognition_thread.is_alive():
- recognition_thread.join(timeout=self._STOP_WORKER_JOIN_TIMEOUT_S)
- if not session_cancel.is_set():
- self._signal_recognition_stop()
-
- if recognition_thread and recognition_thread.is_alive():
- recognition_thread.join(timeout=self._STOP_WORKER_FINAL_JOIN_TIMEOUT_S)
-
- with self._buffer_lock:
- if self._session_cancel is not session_cancel:
+ if recognition_thread and recognition_thread is not current:
+ if recognition_thread.is_alive():
+ recognition_thread.join(
+ timeout=min(_RECOGNITION_JOIN_SECONDS, self._STOP_WORKER_JOIN_TIMEOUT_S)
+ )
+ self._signal_recognition_stop(segment_queue)
+ if recognition_thread.is_alive():
+ recognition_thread.join(
+ timeout=min(
+ _RECOGNITION_FINAL_JOIN_SECONDS, self._STOP_WORKER_FINAL_JOIN_TIMEOUT_S
+ )
+ )
+ with self._lifecycle_lock:
+ if not self._owns_session() or self._session_cancel is not session_cancel:
return
worker_still_running = (
recognition_thread is not None
+ and recognition_thread.is_alive()
and worker_exited is not None
and not worker_exited.is_set()
and not session_cancel.is_set()
)
if worker_still_running:
self._idle_on_worker_exit = session_cancel
- else:
- self._recognition_mode = "toggle"
- if worker_still_running:
- logger.info(
- "Transcription still running after stop; staying in PROCESSING until it finishes "
- "or is cancelled"
- )
- return
- if self._session_cancel is not session_cancel:
- return
+ logger.info("Transcription still running after stop; staying in PROCESSING")
+ return
+ self._recognition_mode = "toggle"
self._update_state(RecognitionState.IDLE)
def _track_recognition_worker(self, worker: threading.Thread) -> None:
@@ -3616,50 +3773,74 @@ class SpeechRecognitionManager:
if not self._cancel_buffered_session.is_set():
self._perform_recognition()
except (ChecksumError, ImportError, OSError, RuntimeError, ValueError):
- logger.exception("Failed to reload model or transcribe buffered speech")
- play_error_sound()
- _show_notification(
- "Dictation Failed",
- "Could not reload the speech model or transcribe the recording. Please try again.",
- "dialog-warning",
- )
+ if not self._buffered_capture_failed:
+ logger.exception("Failed to reload model or transcribe buffered speech")
+ play_error_sound()
+ _show_notification(
+ "Dictation Failed",
+ "Could not reload the speech model or transcribe the recording. Please try again.",
+ "dialog-warning",
+ )
finally:
self._reload_pending = False
- if self.should_record:
+ if self.should_record and not self._buffered_capture_failed:
self.stop_recognition()
- # stop_recognition owns the final buffer. Do not finish or allow a
- # new session until key release has completed that handoff.
- self._capture_finished.wait()
- # The worker can exit during the bounded audio-thread join in
- # stop_recognition, leaving the released key's final buffer — and
- # any stragglers it never consumed — queued behind the stop
- # sentinel. Transcribe what is still here before replacing the
- # queue so the tail of the recording is not silently dropped.
- # A failed capture or an explicit cancel still discards it.
- if (
- self.model_ready
- and not self._buffered_capture_failed
- and not self._cancel_buffered_session.is_set()
- ):
- self._transcribe_queued_segments()
- with self._buffer_lock:
+ self._finish_buffered_session()
+
+ def _finish_buffered_session(self) -> None:
+ """Complete a reload recording only after the stopper's final handoff."""
+ # stop_recognition owns the final buffer. Do not finish or allow a
+ # new session until key release has completed that handoff.
+ if not self._capture_finished.wait(_STOP_WAIT_SECONDS):
+ logger.error("Timed out waiting for buffered capture handoff")
+ return
+ # The worker can exit during the bounded audio-thread join in
+ # stop_recognition, leaving the released key's final buffer — and
+ # any stragglers it never consumed — queued behind the stop
+ # sentinel. Transcribe what is still here before replacing the
+ # queue so the tail of the recording is not silently dropped.
+ # A failed capture or an explicit cancel still discards it.
+ if (
+ self.model_ready
+ and not self._buffered_capture_failed
+ and not self._cancel_buffered_session.is_set()
+ ):
+ self._transcribe_queued_segments()
+ if self._buffer_lock.acquire(timeout=_FINAL_BUFFER_SECONDS):
+ try:
self.audio_buffer = []
- self._segment_queue = queue.Queue(maxsize=32)
- self._recognition_mode = "toggle"
- self._buffered_reload_session = False
- # The notification above reports the failed attempt. Return to IDLE
- # so a release that happened during reload cannot strand the manager
- # in ERROR and block every later dictation attempt.
- self._update_state(RecognitionState.IDLE)
+ self._recording_segment_has_speech = False
+ self._segment_started_at = None
+ finally:
+ self._buffer_lock.release()
+ else:
+ logger.error("Timed out clearing buffered capture; dropping final segment")
+ self._segment_queue = queue.Queue(maxsize=32)
+ self._recognition_mode = "toggle"
+ self._buffered_reload_session = False
+ # The notification above reports the failed attempt. Return to IDLE
+ # so a release that happened during reload cannot strand the manager
+ # in ERROR and block every later dictation attempt.
+ self._update_state(RecognitionState.IDLE)
def _cancel_reload_recording(self) -> None:
- """Discard a buffered session before changing or releasing its engine."""
+ """Cancel buffered speech or raise before an unsafe engine handoff."""
if not getattr(self, "_buffered_reload_session", False):
return
self._cancel_buffered_session.set()
self.stop_recognition()
- if self.recognition_thread and self.recognition_thread is not threading.current_thread():
- self.recognition_thread.join()
+ worker = self.recognition_thread
+ if worker and worker.is_alive():
+ if worker is threading.current_thread():
+ raise RuntimeError("Cannot change the speech engine from its active reload worker")
+ worker.join(timeout=_RECOGNITION_JOIN_SECONDS)
+ if worker.is_alive():
+ worker.join(timeout=_RECOGNITION_FINAL_JOIN_SECONDS)
+ if worker.is_alive():
+ raise TimeoutError(
+ "The speech model is still loading. Please try changing settings again "
+ "after it finishes."
+ )
def _signal_buffered_capture_done(self) -> None:
"""Release the reload worker once a failed capture can no longer hand off.
@@ -3712,22 +3893,38 @@ class SpeechRecognitionManager:
"""Record audio from the microphone with reconnection logic."""
gate = self._key_release_gate
session_cancel = self._session_cancel
- # Lazy import to avoid circular dependency
- from ..ui.audio_feedback import play_error_sound # noqa: F401
+ session = self._session
+ source = None
+ audio = None
+ failure: Optional[Exception] = None
+
+ def recording() -> bool:
+ return (
+ self._owns_session()
+ and not session.cancel.is_set()
+ and self._capture_session_current(session_cancel)
+ )
+
+ def recover(error: OSError) -> bool:
+ if not recording():
+ return False
+ if session.stall_error is None:
+ session.device_name = self._capture_device_name(source)
+ if isinstance(error, CaptureStalledError):
+ session.stall_error = session.stall_error or error
+ if session.stall_error is not None:
+ if session.stall_recovery_attempted:
+ return False
+ session.stall_recovery_attempted = True
+ recovered = self._attempt_audio_reconnection(audio, should_continue=recording)
+ validation_error = getattr(self._capture_source, "last_error", None)
+ if isinstance(validation_error, CaptureStalledError):
+ session.stall_error = session.stall_error or validation_error
+ session.stall_recovery_attempted = True
+ return recovered and recording()
try:
- try:
- import numpy as np
- except ImportError as e:
- logger.error(f"Failed to import required audio libraries: {e}")
- logger.error("Please install required dependencies: pip install pyaudio numpy")
- self.should_record = False
- self.release_playback_duck()
- play_error_sound()
- self._buffered_capture_failed = True
- self._update_state(RecognitionState.ERROR)
- self._signal_buffered_capture_done()
- return
+ import numpy as np
# PyAudio configuration
CHUNK = 1024
@@ -3741,21 +3938,14 @@ class SpeechRecognitionManager:
source = self._new_capture_source()
audio = None
if getattr(source, "requires_pyaudio", True):
- try:
- import pyaudio
- except ImportError as e:
- logger.error(f"Failed to import required audio libraries: {e}")
- logger.error("Please install required dependencies: pip install pyaudio numpy")
- self.should_record = False
- self.release_playback_duck()
- play_error_sound()
- self._buffered_capture_failed = True
- self._update_state(RecognitionState.ERROR)
- self._signal_buffered_capture_done()
- return
+ import pyaudio
+
self._pyaudio_instance = pyaudio.PyAudio()
audio = self._pyaudio_instance
+ source.audio = audio
self._capture_source = source
+ if not recording():
+ return
# The attempt count belongs to this session — a previous thread
# may still be finishing and must not leave its retries here.
# _last_audio_error_time stays: it also throttles a device that
@@ -3772,35 +3962,32 @@ class SpeechRecognitionManager:
logger.error(f"Failed to open audio stream: {e}")
logger.error("This may indicate a problem with the audio device or permissions.")
- # Attempt reconnection
- if not self._attempt_audio_reconnection(audio, session_cancel):
- if not self._capture_session_current(session_cancel):
- logger.info("Audio capture stopped: its session was cancelled or replaced")
- if audio is not None:
- audio.terminate()
- return
- self.should_record = False
- self.release_playback_duck()
- play_error_sound()
- if audio is not None:
- audio.terminate()
- self._buffered_capture_failed = True
- self._update_state(RecognitionState.ERROR)
+ if not recording():
return
+ if not recover(e):
+ failure = e
+ return
+ source = self._capture_source
+ assert source is not None
+ audio = source.audio
else:
RATE = source.sample_rate
+ if not recording():
+ return
+ if session.stall_error is None:
+ session.device_name = self._capture_device_name(source)
self._sync_capture_state()
logger.info("Audio recording started")
# Record audio while should_record is True
- silence_counter = 0.0
+ silence_counter: float = 0.0
speech_detected_in_session = False
- self._recording_segment_has_speech = False
+ with self._buffer_lock:
+ self._recording_segment_has_speech = False
log_level_interval = 0 # Counter for periodic level logging
max_level_seen = 0.0
- capture_failed = False
# Accumulator for 512-sample Silero chunks. When the capture rate
# is higher than 16 kHz (e.g. 48 kHz), resampling produces fewer
# than 1024 samples per read (~341 at 48 kHz), so the buffer may
@@ -3810,15 +3997,19 @@ class SpeechRecognitionManager:
silero_chunk_buf = np.array([], dtype=np.int16)
# Reset Silero VAD state for this recording session
- if self._silero_vad is not None:
- self._silero_vad.reset()
+ with self._buffer_lock:
+ if self._silero_vad is not None:
+ self._silero_vad.reset()
- while self._capture_session_current(session_cancel):
+ while recording():
try:
- # Check buffer size and enforce limits (with lock for thread safety)
+ data = source.read_chunk(should_continue=recording)
+ if data is None or not recording():
+ break
+ # Buffer and VAD decisions form one atomic chunk publication.
+ # No capture-source call may execute while this lock is held.
with self._buffer_lock:
- # A newer session owns the shared buffer once this one is cancelled.
- if session_cancel.is_set() or self._session_cancel is not session_cancel:
+ if not recording():
break
if len(self.audio_buffer) >= self._max_buffer_size:
if getattr(self, "_reload_pending", False):
@@ -3831,19 +4022,6 @@ class SpeechRecognitionManager:
self.audio_buffer = self.audio_buffer[remove_count:]
logger.info(f"Buffer trimmed by {remove_count} chunks")
- # Read outside _buffer_lock. A capture read can block
- # indefinitely (the audio server reconfigures the device,
- # a USB/Bluetooth mic stalls); holding the lock across it
- # would block stop_recognition() on that same lock after
- # its bounded join, leaving the engine stuck in PROCESSING.
- data = source.read_chunk()
-
- with self._buffer_lock:
- # Re-check: the session may have been cancelled or
- # replaced while this thread was reading.
- if session_cancel.is_set() or self._session_cancel is not session_cancel:
- break
-
if not self.audio_buffer:
# First chunk of a new segment: remember when its
# capture began. A mid-session history clear
@@ -3852,16 +4030,135 @@ class SpeechRecognitionManager:
self._segment_started_at = time.monotonic()
self.audio_buffer.append(data)
- # Voice Activity Detection (VAD)
- audio_data = np.frombuffer(data, dtype=np.int16)
- volume = np.abs(audio_data).mean()
+ # Voice Activity Detection (VAD)
+ audio_data = np.frombuffer(data, dtype=np.int16)
+ volume = np.abs(audio_data).mean()
- # Track max level and notify callbacks
- # Normalize to 0-100 scale (16-bit audio max is ~32768)
- normalized_level = min(100.0, (volume / 327.68))
- self._last_audio_level = normalized_level
- max_level_seen = max(max_level_seen, normalized_level)
+ # Track max level and notify callbacks
+ # Normalize to 0-100 scale (16-bit audio max is ~32768)
+ normalized_level = min(100.0, (volume / 327.68))
+ self._last_audio_level = normalized_level
+ max_level_seen = max(max_level_seen, normalized_level)
+ # Log audio levels periodically for debugging
+ log_level_interval += 1
+ if log_level_interval >= 50: # Every ~3 seconds at 16kHz/1024 chunks
+ logger.debug(
+ f"Audio level: current={normalized_level:.1f}%, max_seen={max_level_seen:.1f}%, buffer_size={len(self.audio_buffer)}"
+ )
+ log_level_interval = 0
+
+ # Determine if current chunk contains speech
+ is_speech = False
+ if self._silero_vad is not None:
+ # Silero VAD: accumulate samples into 512-sample chunks
+ speech_prob = 0.0
+ chunk_processed = False
+ silero_chunk_buf = np.concatenate([silero_chunk_buf, audio_data])
+ while len(silero_chunk_buf) >= SILERO_CHUNK_SIZE:
+ chunk_512 = silero_chunk_buf[:SILERO_CHUNK_SIZE]
+ silero_chunk_buf = silero_chunk_buf[SILERO_CHUNK_SIZE:]
+ speech_prob = max(speech_prob, self._silero_vad.process(chunk_512))
+ chunk_processed = True
+
+ # Map vad_sensitivity (1-5) to threshold:
+ # 1 (least sensitive) -> 0.8, 5 (most sensitive) -> 0.3
+ try:
+ vad_sens = int(self.vad_sensitivity)
+ vad_sens = max(1, min(5, vad_sens))
+ except ValueError:
+ vad_sens = 3
+ silero_threshold = 0.8 - (vad_sens - 1) * 0.125
+
+ # Skip speech decision until at least one full chunk
+ # has been processed to avoid false silence detection
+ if chunk_processed:
+ is_speech = speech_prob >= silero_threshold
+ else:
+ # Amplitude fallback when Silero is unavailable
+ try:
+ vad_sens = int(self.vad_sensitivity)
+ threshold = 500 / max(1, min(5, vad_sens))
+ except ValueError:
+ logger.warning(
+ f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3."
+ )
+ threshold = 500 / 3
+ is_speech = volume >= threshold
+
+ if not recording():
+ break
+ if not is_speech: # Silence
+ silence_counter += CHUNK / RATE # Convert chunks to seconds
+ if silence_counter > self.silence_timeout:
+ if len(self.audio_buffer) > 0:
+ if not self._recording_segment_has_speech:
+ logger.debug(
+ "Silence detected with no speech, dropping audio buffer"
+ )
+ self.audio_buffer = []
+ elif self._recognition_mode == "push_to_talk" or getattr(
+ self, "_reload_pending", False
+ ):
+ logger.debug(
+ "Keeping speech until key release or model reload completes"
+ )
+ else:
+ logger.debug("Silence detected, queueing audio segment")
+ self._enqueue_audio_segment(self.audio_buffer)
+ self.audio_buffer = []
+ self._recording_segment_has_speech = False
+ silence_counter = 0
+ else: # Speech
+ self._recording_segment_has_speech = True
+ if not speech_detected_in_session:
+ if self._silero_vad is not None:
+ logger.debug(
+ f"Speech detected (silero_prob={speech_prob:.2f}, "
+ f"threshold={silero_threshold:.3f})"
+ )
+ else:
+ logger.debug(
+ f"Speech detected (level={normalized_level:.1f}%, "
+ f"threshold={500 / max(1, min(5, int(self.vad_sensitivity))):.0f})"
+ )
+ speech_detected_in_session = True
+ silence_counter = 0
+ # Continuous speech with no pause (a held push-to-talk
+ # key, or a toggle session that never goes quiet) keeps
+ # one segment growing until the safety cap silently drops
+ # its oldest audio. Queue the segment long before that so
+ # every word is transcribed in order while recording keeps
+ # going. Capture chunks are 16 kHz mono, so their duration
+ # follows from the byte count at any negotiated rate.
+ # Push-to-talk text is held until the key is released.
+ chunk_seconds = len(data) / (2 * 16000)
+ if chunk_seconds > 0 and self._recording_segment_has_speech:
+ buffered_seconds = len(self.audio_buffer) * chunk_seconds
+ flush_seconds = min(
+ self._max_segment_seconds,
+ self._max_buffer_size * chunk_seconds * 0.75,
+ )
+ if buffered_seconds >= flush_seconds and not getattr(
+ self, "_reload_pending", False
+ ):
+ segment = self.audio_buffer.copy()
+ self.audio_buffer = []
+ self._recording_segment_has_speech = False
+ silence_counter = 0
+ logger.info(
+ "Continuing dictation: queued %.0fs segment "
+ "(%d chunks) for transcription; push-to-talk text "
+ "will be held until release",
+ buffered_seconds,
+ len(segment),
+ )
+ self._enqueue_audio_segment(
+ segment,
+ release_gate=(
+ gate if self._recognition_mode == "push_to_talk" else None
+ ),
+ )
# Notify audio level callbacks
for callback in self._audio_level_callbacks:
try:
@@ -3869,129 +4166,10 @@ class SpeechRecognitionManager:
except Exception as e:
logger.debug(f"Audio level callback error: {e}")
- # Log audio levels periodically for debugging
- log_level_interval += 1
- if log_level_interval >= 50: # Every ~3 seconds at 16kHz/1024 chunks
- logger.debug(
- f"Audio level: current={normalized_level:.1f}%, max_seen={max_level_seen:.1f}%, buffer_size={len(self.audio_buffer)}"
- )
- log_level_interval = 0
-
- # Determine if current chunk contains speech
- is_speech = False
- if self._silero_vad is not None:
- # Silero VAD: accumulate samples into 512-sample chunks
- speech_prob = 0.0
- chunk_processed = False
- silero_chunk_buf = np.concatenate([silero_chunk_buf, audio_data])
- while len(silero_chunk_buf) >= SILERO_CHUNK_SIZE:
- chunk_512 = silero_chunk_buf[:SILERO_CHUNK_SIZE]
- silero_chunk_buf = silero_chunk_buf[SILERO_CHUNK_SIZE:]
- speech_prob = max(speech_prob, self._silero_vad.process(chunk_512))
- chunk_processed = True
-
- # Map vad_sensitivity (1-5) to threshold:
- # 1 (least sensitive) -> 0.8, 5 (most sensitive) -> 0.3
- try:
- vad_sens = int(self.vad_sensitivity)
- vad_sens = max(1, min(5, vad_sens))
- except ValueError:
- vad_sens = 3
- silero_threshold = 0.8 - (vad_sens - 1) * 0.125
-
- # Skip speech decision until at least one full chunk
- # has been processed to avoid false silence detection
- if chunk_processed:
- is_speech = speech_prob >= silero_threshold
- else:
- # Amplitude fallback when Silero is unavailable
- try:
- vad_sens = int(self.vad_sensitivity)
- threshold = 500 / max(1, min(5, vad_sens))
- except ValueError:
- logger.warning(
- f"Invalid VAD sensitivity value: {self.vad_sensitivity}. Using default 3."
- )
- threshold = 500 / 3
- is_speech = volume >= threshold
-
- if not is_speech: # Silence
- silence_counter += CHUNK / RATE # Convert chunks to seconds
- if silence_counter > self.silence_timeout:
- if len(self.audio_buffer) > 0:
- if not self._recording_segment_has_speech:
- logger.debug(
- "Silence detected with no speech, dropping audio buffer"
- )
- self.audio_buffer = []
- elif self._recognition_mode == "push_to_talk" or getattr(
- self, "_reload_pending", False
- ):
- logger.debug(
- "Keeping speech until key release or model reload completes"
- )
- else:
- logger.debug("Silence detected, queueing audio segment")
- self._enqueue_audio_segment(self.audio_buffer)
- self.audio_buffer = []
- self._recording_segment_has_speech = False
- silence_counter = 0
- else: # Speech
- self._recording_segment_has_speech = True
- if not speech_detected_in_session:
- if self._silero_vad is not None:
- logger.debug(
- f"Speech detected (silero_prob={speech_prob:.2f}, "
- f"threshold={silero_threshold:.3f})"
- )
- else:
- logger.debug(
- f"Speech detected (level={normalized_level:.1f}%, "
- f"threshold={500 / max(1, min(5, int(self.vad_sensitivity))):.0f})"
- )
- speech_detected_in_session = True
- silence_counter = 0
-
- # Continuous speech with no pause (a held push-to-talk
- # key, or a toggle session that never goes quiet) keeps
- # one segment growing until the safety cap silently drops
- # its oldest audio. Queue the segment long before that so
- # every word is transcribed in order while recording keeps
- # going. Capture chunks are 16 kHz mono, so their duration
- # follows from the byte count at any negotiated rate.
- # Push-to-talk text is held until the key is released.
- chunk_seconds = len(data) / (2 * 16000)
- if chunk_seconds > 0 and self._recording_segment_has_speech:
- buffered_seconds = len(self.audio_buffer) * chunk_seconds
- flush_seconds = min(
- self._max_segment_seconds,
- self._max_buffer_size * chunk_seconds * 0.75,
- )
- if buffered_seconds >= flush_seconds and not getattr(
- self, "_reload_pending", False
- ):
- with self._buffer_lock:
- segment = self.audio_buffer.copy()
- self.audio_buffer = []
- self._recording_segment_has_speech = False
- silence_counter = 0
- logger.info(
- "Continuing dictation: queued %.0fs segment "
- "(%d chunks) for transcription; push-to-talk text "
- "will be held until release",
- buffered_seconds,
- len(segment),
- )
- self._enqueue_audio_segment(
- segment,
- release_gate=(
- gate if self._recognition_mode == "push_to_talk" else None
- ),
- )
except (IOError, OSError) as e:
- if not self._capture_session_current(session_cancel):
- logger.info("Audio capture stopped: its session was cancelled or replaced")
+ if not recording():
break
+ failure = e
current_time = time.time()
logger.error(f"Audio device error: {e}")
@@ -4001,12 +4179,15 @@ class SpeechRecognitionManager:
): # Prevent rapid reconnection attempts
self._last_audio_error_time = current_time
- if self._attempt_audio_reconnection(audio, session_cancel):
+ if recover(e):
+ failure = None
logger.info("Audio reconnection successful, continuing recording")
# The session source may have been rebuilt (e.g.
# the configured device switched between a mic and
# a PipeWire sink) — keep reading the new source.
source = self._capture_source
+ assert source is not None
+ audio = source.audio
continue # Continue recording with the reopened source
elif not self._capture_session_current(session_cancel):
logger.info(
@@ -4015,42 +4196,17 @@ class SpeechRecognitionManager:
break
else:
logger.error("Audio reconnection failed, stopping recording")
- capture_failed = True
break
else:
logger.warning(
"Audio error occurred too soon after last error, stopping recording"
)
- capture_failed = True
break
except Exception as e:
logger.error(f"Unexpected error reading audio data: {e}")
- capture_failed = True
+ failure = e
break
- # A dead microphone must not leave other audio lowered, or the
- # session stuck in LISTENING, until the user happens to stop.
- if capture_failed:
- self.should_record = False
- self.release_playback_duck()
- play_error_sound()
- self._buffered_capture_failed = True
- self._update_state(RecognitionState.ERROR)
-
- # Clean up
- source.close()
-
- # Reset audio stream reference and reconnection state. A session
- # started after a stop-timeout may already own these fields, so
- # only clear the ones still bound to this capture.
- if self._capture_source is source:
- self._audio_stream = None
- self._pyaudio_instance = None
- self._capture_downmix_channel = None
- self._reconnection_attempts = 0
- self._last_audio_error_time = 0
- self._capture_source = None
-
# Log summary
if not speech_detected_in_session and max_level_seen < 5:
logger.warning(
@@ -4062,17 +4218,82 @@ class SpeechRecognitionManager:
logger.info("Audio recording stopped")
except Exception as e:
+ failure = e
logger.error(f"Error in audio recording: {e}")
- self.should_record = False
- self.release_playback_duck()
- play_error_sound()
- self._buffered_capture_failed = True
- self._update_state(RecognitionState.ERROR)
finally:
gate.set()
- self._signal_buffered_capture_done()
+ if failure is not None and recording():
+ self._buffered_capture_failed = True
+ try:
+ # Reconnection may have rebuilt the source even on failure.
+ source = self._capture_source or source
+ if source is not None:
+ source.close()
+ except (OSError, RuntimeError) as e:
+ failure = failure or e
+ logger.error("Failed to close audio capture: %s", e)
+ finally:
+ # close() owns the PyAudio instance as well as the stream.
+ # Only clear manager fields still bound to this source.
+ if self._capture_source is source:
+ self._audio_stream = None
+ self._pyaudio_instance = None
+ self._capture_downmix_channel = None
+ self._reconnection_attempts = 0
+ self._last_audio_error_time = 0.0
+ self._capture_source = None
- def _process_final_buffer(self):
+ if failure is not None and recording():
+ self._report_capture_failure(session, failure)
+ self._signal_buffered_capture_done()
+
+ def _capture_device_name(self, source: object) -> str:
+ """Describe the resolved capture device before recovery can replace it."""
+ name = getattr(source, "resolved_device_name", None)
+ if isinstance(name, str) and name:
+ return name
+ node = getattr(source, "source", None)
+ name = getattr(node, "display_name", None)
+ if isinstance(name, str) and name:
+ return name
+ name = getattr(source, "device_name", None)
+ if isinstance(name, str) and name:
+ return name
+ info = _get_device_info_safe(
+ getattr(source, "audio", None), getattr(source, "device_index", None)
+ )
+ name = info.get("name")
+ return name if isinstance(name, str) and name else "System Default"
+
+ def _report_capture_failure(self, session: _CaptureSession, error: Exception) -> None:
+ """Report once after owner-thread teardown, then complete the failed session."""
+ with self._lifecycle_lock:
+ if not self._owns_session() or session.cancel.is_set():
+ return
+ self.should_record = False
+ self._buffered_capture_failed = True
+ self.release_playback_duck()
+ self._update_state(RecognitionState.ERROR)
+ play_error_sound()
+ if isinstance(error, CaptureStalledError):
+ session.stall_error = session.stall_error or error
+ if session.stall_error is not None:
+ logger.error("Microphone '%s' stalled: %s", session.device_name, session.stall_error)
+ _show_notification(
+ "Microphone Stalled",
+ f"{session.device_name} stopped delivering audio. "
+ "Open Settings to test or select another microphone, then try dictation again.",
+ "dialog-warning",
+ )
+ else:
+ logger.error("Audio capture failed for '%s': %s", session.device_name, error)
+ self.stop_recognition()
+ if self._buffered_reload_session and (
+ self.recognition_thread is None or not self.recognition_thread.is_alive()
+ ):
+ self._finish_buffered_session()
+
+ def _process_final_buffer(self) -> None:
"""Process the final audio buffer after silence is detected."""
with self._buffer_lock:
if not self.audio_buffer:
@@ -4080,6 +4301,8 @@ class SpeechRecognitionManager:
audio_buffer = self.audio_buffer.copy()
self.audio_buffer = []
+ self._recording_segment_has_speech = False
+ self._segment_started_at = None
self._process_audio_buffer(audio_buffer)
@@ -4113,23 +4336,35 @@ class SpeechRecognitionManager:
started_at: Optional[float],
) -> None:
"""Deliver a transcribed segment to registered callbacks."""
- logger.debug(
- f"processed_text='{text[:50] if text else '(empty)'}...', callbacks={len(self.text_callbacks)}"
- )
- if text:
- if started_at is None:
- started_at = time.monotonic()
- for segment_callback in self.segment_callbacks:
- segment_callback(text, started_at)
- for callback in self.text_callbacks:
- logger.debug(
- f"invoking text callback: {callback.__name__ if hasattr(callback, '__name__') else callback}"
- )
- callback(text)
+ lifecycle_lock = getattr(self, "_lifecycle_lock", nullcontext())
+ with lifecycle_lock:
+ if not self._owns_session():
+ return
+ session = getattr(self, "_session", None)
+ if session is not None:
+ session.dispatching += 1
+ try:
+ logger.debug(
+ f"processed_text='{text[:50] if text else '(empty)'}...', callbacks={len(self.text_callbacks)}"
+ )
+ if text:
+ if started_at is None:
+ started_at = time.monotonic()
+ for segment_callback in self.segment_callbacks:
+ segment_callback(text, started_at)
+ for callback in self.text_callbacks:
+ logger.debug(
+ f"invoking text callback: {callback.__name__ if hasattr(callback, '__name__') else callback}"
+ )
+ callback(text)
- for action in actions:
- for callback in self.action_callbacks:
- callback(action)
+ for action in actions:
+ for callback in self.action_callbacks:
+ callback(action)
+ finally:
+ if session is not None:
+ with lifecycle_lock:
+ session.dispatching -= 1
def _release_held_transcripts(
self,
@@ -4175,7 +4410,7 @@ class SpeechRecognitionManager:
if cancel_event is not None and cancel_event.is_set():
logger.debug("Skipping audio processing for a cancelled session")
return
- if not audio_buffer:
+ if not audio_buffer or not self._owns_session():
return
if language is None:
language = getattr(audio_buffer, "language", None)
@@ -4209,6 +4444,8 @@ class SpeechRecognitionManager:
self._release_held_transcripts(gate, cancel=cancel_event)
return
+ if not self._owns_session():
+ return
# Process text - either with voice commands or pass through directly
logger.debug(f"_process_audio_buffer got text='{text[:50] if text else '(empty)'}...'")
processed_text = ""
@@ -4257,8 +4494,9 @@ class SpeechRecognitionManager:
cancel = self._session_cancel
segment_queue = self._segment_queue
worker_exited = getattr(self, "_worker_exited", None)
+ session = self._session
try:
- while True:
+ while self._owns_session():
if cancel.is_set():
logger.debug("Recognition loop exiting for a cancelled session")
break
@@ -4274,6 +4512,10 @@ class SpeechRecognitionManager:
self._release_held_transcripts(gate, cancel=cancel)
# Only exit if we're not recording AND queue is empty
if not self.should_record and segment_queue.empty():
+ if session.cancel.is_set() and not session.capture_finished.is_set():
+ # The stopper may still be joining capture or taking
+ # the final buffer. It owns the last enqueue.
+ continue
logger.debug(
"Recognition loop - not recording and queue empty, checking for final items..."
)
@@ -4292,10 +4534,12 @@ class SpeechRecognitionManager:
logger.debug("Recognition loop - queue timeout, continuing")
continue
+ if not self._owns_session():
+ break
if queued is None:
logger.debug("Recognition loop - got None signal, draining remaining items...")
# Drain any remaining items before exiting
- while not segment_queue.empty():
+ while self._owns_session() and not segment_queue.empty():
if cancel.is_set():
logger.debug("Recognition loop drain stopped for a cancelled session")
break
@@ -4332,19 +4576,25 @@ class SpeechRecognitionManager:
logger.debug("_perform_recognition thread exiting")
self._release_held_transcripts(gate, force=True, cancel=cancel)
hand_off = False
- if worker_exited is not None:
- with self._buffer_lock:
+ # Stop elects the late-worker handoff under this same lock. Using
+ # the buffer lock here could miss its publication and strand
+ # PROCESSING after the worker has already exited.
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ if worker_exited is not None:
worker_exited.set()
hand_off = self._idle_on_worker_exit is cancel
if hand_off:
self._idle_on_worker_exit = None
- if (
- hand_off
- and self._session_cancel is cancel
- and not cancel.is_set()
- and not self.should_record
- ):
- self._recognition_mode = "toggle"
+ hand_off = (
+ hand_off
+ and self._owns_session()
+ and self._session_cancel is cancel
+ and not cancel.is_set()
+ and not self.should_record
+ )
+ if hand_off:
+ self._recognition_mode = "toggle"
+ if hand_off:
self._update_state(RecognitionState.IDLE)
def _enqueue_audio_segment(
@@ -4382,14 +4632,15 @@ class SpeechRecognitionManager:
except queue.Empty:
logger.warning("Could not recover queue space for transcription segment")
- def _signal_recognition_stop(self):
- """Signal recognition thread to wake up and stop cleanly."""
+ def _signal_recognition_stop(self, segment_queue: Optional[queue.Queue] = None) -> None:
+ """Signal the selected session's queue without targeting a later recording."""
+ segments: queue.Queue = self._segment_queue if segment_queue is None else segment_queue
try:
- self._segment_queue.put_nowait(None)
+ segments.put_nowait(None)
except queue.Full:
try:
- self._segment_queue.get_nowait()
- self._segment_queue.put_nowait(None)
+ segments.get_nowait()
+ segments.put_nowait(None)
except queue.Empty:
logger.debug("Recognition queue emptied before stop signal")
@@ -4452,8 +4703,232 @@ class SpeechRecognitionManager:
f"audio_device={audio_device_index}, audio_device_name={audio_device_name}"
)
- self._cancel_reload_recording()
+ hot_settings = self._hot_reconfigure_settings(
+ engine,
+ model_size,
+ language,
+ vad_sensitivity,
+ silence_timeout,
+ audio_device_index,
+ audio_device_name,
+ force_reinit,
+ **kwargs,
+ )
+ if hot_settings is not None:
+ self._update_hot_settings(hot_settings)
+ return
+ with self._settings_reconfigure_context() as (request, outcome):
+ resolved_language = (
+ resolve_language_preference(language) if language is not None else None
+ )
+ restart_needed = self._reconfigure_needs_restart(
+ engine, model_size, resolved_language, force_reinit, **kwargs
+ )
+ self._cancel_reload_recording()
+ if restart_needed:
+ self._finish_recognition_before_reconfigure()
+ self._wait_for_model_settings_handoff()
+ restarted = self._apply_reconfigure(
+ request,
+ engine=engine,
+ model_size=model_size,
+ language=language,
+ vad_sensitivity=vad_sensitivity,
+ silence_timeout=silence_timeout,
+ audio_device_index=audio_device_index,
+ audio_device_name=audio_device_name,
+ force_download=force_download,
+ force_reinit=force_reinit,
+ resolved_language=resolved_language,
+ **kwargs,
+ )
+ outcome.reload_for_resume = not restarted
+ @staticmethod
+ def _hot_reconfigure_settings(
+ engine: Optional[str],
+ model_size: Optional[str],
+ language: Optional[str],
+ vad_sensitivity: Optional[int],
+ silence_timeout: Optional[float],
+ audio_device_index: Optional[int],
+ audio_device_name: Optional[str],
+ force_reinit: bool,
+ **kwargs: Any,
+ ) -> Optional[dict[str, Any]]:
+ """Validate the settings that can follow an in-flight model change."""
+ if (
+ force_reinit
+ or any(
+ value is not None
+ for value in (engine, model_size, language, audio_device_index, audio_device_name)
+ )
+ or kwargs.keys() - {"voice_commands_enabled", "stop_sound_guard_ms"}
+ ):
+ return None
+ return SpeechRecognitionManager._validated_hot_settings(
+ vad_sensitivity, silence_timeout, **kwargs
+ )
+
+ @staticmethod
+ def _validated_hot_settings(
+ vad_sensitivity: Optional[int], silence_timeout: Optional[float], **kwargs: Any
+ ) -> dict[str, Any]:
+ """Normalize hot values before accepting them into a transaction."""
+ settings = dict(kwargs)
+ if vad_sensitivity is not None:
+ settings["vad_sensitivity"] = max(1, min(5, int(vad_sensitivity)))
+ if silence_timeout is not None:
+ settings["silence_timeout"] = max(0.5, min(5.0, float(silence_timeout)))
+ return settings
+
+ def update_live_settings(
+ self, *, vad_sensitivity: Optional[int] = None, silence_timeout: Optional[float] = None
+ ) -> None:
+ """Apply VAD controls without stopping capture or superseding model work."""
+ settings = self._validated_hot_settings(vad_sensitivity, silence_timeout)
+ self._update_hot_settings(settings)
+
+ def _update_hot_settings(self, settings: dict[str, Any]) -> None:
+ """Accept live controls without waiting for native load or capture ownership."""
+ # A synchronous idle reload owns the lifecycle lock for its whole
+ # native initializer. Hot GTK controls must not wait for that work or
+ # replace the model request that already owns pause/resume recovery.
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if getattr(self, "_reconfigure_request", None) is not None:
+ self._reconfigure_hot_settings.update(settings)
+ else:
+ self._apply_hot_reconfigure(settings)
+
+ def _apply_hot_reconfigure(self, settings: dict[str, Any]) -> None:
+ """Apply accepted hot values after the model transaction and any rollback."""
+ for name, value in settings.items():
+ if name == "voice_commands_enabled":
+ self._voice_commands_preference = value
+ else:
+ setattr(self, name, value)
+ self._voice_commands_enabled = self._resolve_voice_commands_enabled()
+
+ def _reconfigure_needs_restart(
+ self,
+ engine: Optional[str],
+ model_size: Optional[str],
+ resolved_language: Optional[str],
+ force_reinit: bool,
+ **kwargs: Any,
+ ) -> bool:
+ """Decide whether the requested settings replace model resources, without publishing them."""
+ selected_engine = self.engine if engine is None else engine
+ selected_model = self.model_size if model_size is None else model_size
+ selected_language = self.language if resolved_language is None else resolved_language
+ can_relanguage = selected_engine in self._PER_UTTERANCE_LANGUAGE_ENGINES and (
+ selected_engine == "remote_api" or not is_english_only_model(selected_model)
+ )
+ return (
+ force_reinit
+ or selected_engine != self.engine
+ or selected_model != self.model_size
+ or (selected_language != self.language and not can_relanguage)
+ or normalize_language_for_engine(selected_engine, selected_language)
+ != selected_language
+ or any(
+ name in kwargs
+ for name in self._RECONFIGURE_STATE_ATTRS
+ if name.startswith("whispercpp_")
+ )
+ or (
+ selected_engine == "remote_api"
+ and "remote_api_url" in kwargs
+ and kwargs["remote_api_url"] != self.remote_api_url
+ )
+ )
+
+ def _finish_recognition_before_reconfigure(self) -> None:
+ """Drain old speech before new dispatch settings can be observed by its worker."""
+ if self.state != RecognitionState.IDLE:
+ self.stop_recognition()
+ with getattr(self, "_recognition_workers_lock", nullcontext()):
+ workers = list(getattr(self, "_recognition_workers", []))
+ worker = getattr(self, "recognition_thread", None)
+ if worker is not None:
+ workers.append(worker)
+ if any(worker.is_alive() for worker in workers):
+ raise TimeoutError(
+ "The previous transcription is still finishing. Please try changing "
+ "settings again after it finishes."
+ )
+
+ def _wait_for_model_settings_handoff(self) -> None:
+ """Let an earlier initializer finish before publishing its replacement settings."""
+ # A superseded resume may already be inside an initializer, which can
+ # normalize model_size and still reads the live load-time settings.
+ # The Settings marker prevents new initializers from entering after
+ # this barrier; release the resource lock before network prefetch.
+ # Cancel here precedes our snapshot and must not restore any fields
+ # that the previous initializer is still allowed to finish writing.
+ while True:
+ if self._download_claim.locked() and self._download_cancelled:
+ raise RuntimeError("Download cancelled")
+ if self._model_lock.acquire(timeout=0.1):
+ break
+ try:
+ if self._download_claim.locked() and self._download_cancelled:
+ raise RuntimeError("Download cancelled")
+ finally:
+ self._model_lock.release()
+
+ @contextmanager
+ def _settings_reconfigure_context(self) -> Iterator[tuple[object, _ReconfigureOutcome]]:
+ """Transfer model obligations only after Settings has stopped mutating state."""
+ done = threading.Event()
+ outcome = _ReconfigureOutcome()
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if getattr(self, "_reconfigure_request", None) is not None:
+ raise RuntimeError(
+ "Speech settings are already changing. Please wait for the "
+ "current change to finish before applying another model or language."
+ )
+ request = self._new_model_request(inherit_resume=True)
+ assert request is not None
+ worker = getattr(self, "recognition_thread", None)
+ with getattr(self, "_model_request_lock", nullcontext()):
+ self._reconfigure_request = request
+ self._reconfigure_hot_settings = {}
+ self._reconfigure_done = done
+ try:
+ yield request, outcome
+ finally:
+ # Clear the active marker before settling. A wake before this
+ # point attaches to this request; a later wake gets a fresh token
+ # and supersedes any cleanup/reload dispatched here.
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._reconfigure_request is request:
+ self._apply_hot_reconfigure(self._reconfigure_hot_settings)
+ self._reconfigure_hot_settings = {}
+ if self._model_request is request and self.model_ready:
+ self._idle_unloaded = False
+ self._reconfigure_request = None
+ done.set()
+ self._settle_reconfigure_lifecycle(request, worker, outcome.reload_for_resume)
+
+ def _apply_reconfigure(
+ self,
+ request: object,
+ engine: Optional[str],
+ model_size: Optional[str],
+ language: Optional[str],
+ vad_sensitivity: Optional[int],
+ silence_timeout: Optional[float],
+ audio_device_index: Optional[int],
+ audio_device_name: Optional[str],
+ force_download: bool,
+ force_reinit: bool,
+ resolved_language: Optional[str] = None,
+ **kwargs: Any,
+ ) -> bool:
+ """Apply Settings and return whether the selected engine was reinitialized."""
whispercpp_attrs = tuple(
name for name in self._RECONFIGURE_STATE_ATTRS if name.startswith("whispercpp_")
)
@@ -4481,7 +4956,11 @@ class SpeechRecognitionManager:
# Settings hands over the preference, which may be the sentinel. Keep
# it so the follow mode survives, and resolve what the engine gets.
self.language_preference = language
- resolved = resolve_language_preference(language)
+ resolved = (
+ resolved_language
+ if resolved_language is not None
+ else resolve_language_preference(language)
+ )
if resolved != self.language:
self.language = resolved
language_changed = True
@@ -4566,12 +5045,9 @@ class SpeechRecognitionManager:
old_defer = self._defer_download
self._defer_download = not force_download
if force_download:
+ self._prefetching_model = True
try:
- self._prefetching_model = True
- try:
- self._download_selected_model_if_missing()
- finally:
- self._prefetching_model = False
+ self._download_selected_model_if_missing()
except Exception:
self._restore_reconfigure_state(previous)
if language_changed:
@@ -4579,60 +5055,70 @@ class SpeechRecognitionManager:
self._restore_language_after_oneshot()
self._defer_download = old_defer
raise
+ finally:
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._reconfigure_request is request:
+ self._prefetching_model = False
# Lock model access during reinitialization to prevent race condition
# with transcription threads that may be using the model/recognizer.
# A download worker can be waiting here while Cancel is pressed, so
# poll instead of blocking indefinitely on the active transcription.
- while not self._model_lock.acquire(timeout=0.1):
- if self._download_claim.locked() and self._download_cancelled:
- self._restore_reconfigure_state(previous)
- self.command_processor.set_language(self.language)
- self._defer_download = old_defer
- raise RuntimeError("Download cancelled")
+ while True:
+ self._check_reconfigure_cancelled(previous, request, old_defer)
+ if self._model_lock.acquire(timeout=0.1):
+ break
try:
- # Release old resources explicitly if necessary (Python's GC might handle it)
- self.model = None
- self.recognizer = None
- if self._faster_whisper_engine is not None:
- self._faster_whisper_engine.cleanup()
- self._faster_whisper_engine = None
- if old_engine == "remote_api" and self.engine != "remote_api":
- if self._http_session is not None:
- self._http_session.close()
- self._http_session = None
- try:
- self._init_selected_engine()
- logger.info("Speech engine re-initialized successfully.")
- except Exception as e:
- logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True)
- # Settings reverts the pickers to the saved engine on failure.
- # Restore every mutated live field, then reload that engine
- # so dictation is not left on the failed backend in ERROR
- # with unsaved VAD/device/API knobs while the UI shows the
- # previous configuration.
- self._restore_reconfigure_state(previous)
- # The snapshot can revive a one-shot language restore
- # whose dictation was already stopped above; settle it
- # now so the shortcut language does not leak into the
- # next ordinary dictation (#805).
- self._restore_language_after_oneshot()
- self._defer_download = True
+ self._check_reconfigure_cancelled(previous, request, old_defer)
+ with self._model_request_context(request):
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return False
+ # Release old resources explicitly if necessary (Python's GC might handle it)
+ self.model = None
+ self.recognizer = None
+ if self._faster_whisper_engine is not None:
+ self._faster_whisper_engine.cleanup()
+ self._faster_whisper_engine = None
+ if old_engine == "remote_api" and self.engine != "remote_api":
+ if self._http_session is not None:
+ self._http_session.close()
+ self._http_session = None
try:
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return False
self._init_selected_engine()
- if self.state == RecognitionState.ERROR:
- self._update_state(RecognitionState.IDLE)
- logger.info(
- "Restored previous speech engine %s after failed reconfigure",
- self.engine,
- )
- except Exception:
- logger.error(
- "Failed to restore previous speech engine after reconfigure",
- exc_info=True,
- )
- self._update_state(RecognitionState.ERROR)
- raise
+ logger.info("Speech engine re-initialized successfully.")
+ except Exception as e:
+ logger.error(f"Failed to re-initialize speech engine: {e}", exc_info=True)
+ # Settings reverts the pickers to the saved engine on failure.
+ # Restore every mutated live field, then reload that engine
+ # so dictation is not left on the failed backend in ERROR
+ # with unsaved VAD/device/API knobs while the UI shows the
+ # previous configuration.
+ self._restore_reconfigure_state(previous)
+ # The snapshot can revive a one-shot language restore
+ # whose dictation was already stopped above; settle it
+ # now so the shortcut language does not leak into the
+ # next ordinary dictation (#805).
+ self._restore_language_after_oneshot()
+ self._defer_download = True
+ try:
+ self._init_selected_engine()
+ if self.state == RecognitionState.ERROR:
+ self._update_state(RecognitionState.IDLE)
+ logger.info(
+ "Restored previous speech engine %s after failed reconfigure",
+ self.engine,
+ )
+ except Exception:
+ logger.error(
+ "Failed to restore previous speech engine after reconfigure",
+ exc_info=True,
+ )
+ self._update_state(RecognitionState.ERROR)
+ raise
finally:
self._defer_download = old_defer
self._model_lock.release()
@@ -4640,6 +5126,52 @@ class SpeechRecognitionManager:
# If only VOSK params changed, just log it
logger.info("Applied VAD/silence timeout changes.")
+ return restart_needed
+
+ def _check_reconfigure_cancelled(
+ self, previous: dict[str, object], request: object, old_defer: bool
+ ) -> None:
+ """Honor Cancel before engine mutation, even when the lock becomes free."""
+ if self._download_claim.locked() and self._download_cancelled:
+ self._restore_reconfigure_state(previous)
+ self.command_processor.set_language(self.language)
+ self._defer_download = old_defer
+ raise RuntimeError("Download cancelled")
+
+ def _settle_reconfigure_lifecycle(
+ self,
+ request: object,
+ worker: Optional[threading.Thread],
+ reload_for_resume: bool,
+ ) -> None:
+ """Keep an active pause or pending wake when Settings supersedes its owner."""
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return
+ resume_pending = self._auto_resume_pending
+ pause_pending = getattr(self, "_auto_paused", False) is True and not resume_pending
+ if pause_pending:
+ # A native loader may still own resources even after Settings was
+ # rejected. Retain the unload obligation without blocking GTK.
+ threading.Thread(
+ target=self._unload_after_reload, args=(worker, request), daemon=True
+ ).start()
+ return
+ if not resume_pending:
+ return
+ if reload_for_resume or (worker is not None and worker.is_alive()):
+ # Even a free model lock does not make native initialization fast.
+ # Cheap Settings callbacks (notably voice commands) run on GTK.
+ threading.Thread(
+ target=self._resume_after_reload, args=(worker, request), daemon=True
+ ).start()
+ elif self.model_ready:
+ self._finish_model_resume(request)
+ elif not self._reinitialize_model(request, blocking=False):
+ threading.Thread(
+ target=self._resume_after_reload, args=(worker, request), daemon=True
+ ).start()
+
def _new_capture_source(self) -> Union[PortAudioCaptureSource, PipeWireCaptureSource]:
"""Build the capture source matching the configured device."""
if is_pipewire_device(self.audio_device_index, self.audio_device_name):
@@ -4665,6 +5197,10 @@ class SpeechRecognitionManager:
)
source = getattr(self, "_capture_source", None)
if source is None or isinstance(source, PipeWireCaptureSource) != want_pipewire:
+ if source is not None:
+ source.close()
+ self._audio_stream = None
+ self._pyaudio_instance = None
source = self._new_capture_source()
self._capture_source = source
else:
@@ -4697,7 +5233,10 @@ class SpeechRecognitionManager:
)
def _attempt_audio_reconnection(
- self, audio_instance: Any, session_cancel: Optional[threading.Event] = None
+ self,
+ audio_instance: Any,
+ session_cancel: Optional[threading.Event] = None,
+ should_continue: Optional[Callable[[], bool]] = None,
) -> bool:
"""
Attempt to reconnect to the audio device.
@@ -4707,10 +5246,23 @@ class SpeechRecognitionManager:
session_cancel: The capture thread's session token. When given, a
session cancelled or replaced during the backoff gives up
without reopening the device.
+ should_continue: Optional session predicate; False cancels recovery.
Returns:
bool: True if reconnection was successful, False otherwise
"""
+ session = self._session
+
+ def continuing() -> bool:
+ return (
+ self._owns_session()
+ and not session.cancel.is_set()
+ and (session_cancel is None or self._capture_session_current(session_cancel))
+ and (should_continue is None or should_continue())
+ )
+
+ if not continuing():
+ return False
self._reconnection_attempts += 1
if self._reconnection_attempts > self._max_reconnection_attempts:
@@ -4726,17 +5278,21 @@ class SpeechRecognitionManager:
)
# Wait before attempting reconnection
- time.sleep(delay)
+ if session.cancel.wait(delay) or not continuing():
+ return False
if session_cancel is not None and not self._capture_session_current(session_cancel):
logger.info("Skipping audio reconnection for a cancelled or replaced session")
return False
+ previous_source = self._capture_source
source = self._capture_source_for_session()
# The source may not know about the stream it is asked to replace
# (e.g. when tests drive this method directly).
source.stream = getattr(self, "_audio_stream", None)
audio = audio_instance
+ if previous_source is not None and source is not previous_source:
+ audio = None # The old source terminated its own PortAudio instance.
if getattr(source, "requires_pyaudio", True) and audio is None:
# A session that started on a PipeWire sink never built a PyAudio
# instance; a PortAudio source still needs one to reopen.
@@ -4747,10 +5303,15 @@ class SpeechRecognitionManager:
audio = pyaudio.PyAudio()
self._pyaudio_instance = audio
+ source.audio = audio
except (ImportError, OSError, AttributeError) as e:
logger.error(f"Failed to initialize PyAudio for reconnection: {e}")
return False
- ok = source.reopen(audio)
+ if not continuing():
+ return False
+ ok = source.reopen(audio, should_continue=continuing)
+ if not continuing():
+ return False
self._sync_capture_state()
return ok
@@ -4765,16 +5326,67 @@ class SpeechRecognitionManager:
lazy-reload via :meth:`ensure_model_loaded`.
- ``"manual"`` / other: unload only without setting pause/idle flags.
"""
- self._cancel_reload_recording()
- logger.info("Unloading speech model (reason=%s)", reason)
+ # Serialize the guard and worker snapshot with recognition starts;
+ # release the lifecycle lock before cancellation joins the worker.
+ with self._lifecycle_lock:
+ request = self._new_model_request(auto_pause=reason == "auto_pause")
+ reload_worker = self.recognition_thread
+
+ try:
+ self._cancel_reload_recording()
+ except TimeoutError:
+ if reason != "auto_pause":
+ raise
+ # The monitor will not retry while the app is still running. Keep
+ # dictation blocked and release resources only after this worker exits.
+ threading.Thread(
+ target=self._unload_after_reload,
+ args=(reload_worker, request),
+ daemon=True,
+ ).start()
+ return
if self.state != RecognitionState.IDLE:
logger.info("Stopping active recognition before model unload")
self.stop_recognition()
- with self._model_lock:
- self.model = None
- self.recognizer = None
+ if not self._unload_model_resources(reason, request, blocking=reason != "auto_pause"):
+ # A deferred resume may already be initializing under _model_lock.
+ # The new pause takes effect immediately without waiting for it.
+ threading.Thread(
+ target=self._unload_after_reload,
+ args=(reload_worker, request),
+ daemon=True,
+ ).start()
+
+ def _unload_after_reload(self, worker: Optional[threading.Thread], request: object) -> None:
+ """Finish a timed-out auto-pause away from the monitor/GTK callback."""
+ if worker is not None:
+ worker.join()
+ self._unload_model_resources("auto_pause", request)
+
+ def _unload_model_resources(self, reason: str, request: object, blocking: bool = True) -> bool:
+ """Release resources after reload cancellation has completed."""
+ if not self._model_lock.acquire(blocking=blocking):
+ return False
+ try:
+ # Check after taking the resource lock: a newer resume may have
+ # replaced the model while this cleanup was waiting for it.
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return True
+ old_model, old_recognizer = self.model, self.recognizer
+ self.model = None
+ self.recognizer = None
+ self._model_initialized = False
+ if reason == "idle_keepalive" and not self._auto_paused:
+ # Publish lazy recovery with the missing resources. Cleanup
+ # may outlive a newer hot Settings request.
+ self._idle_unloaded = True
+ # Last-reference drops can run native destructors. Keep them out of
+ # the request lock so a newer pause can publish its guard promptly.
+ del old_model, old_recognizer
+ logger.info("Unloading speech model (reason=%s)", reason)
if self._faster_whisper_engine is not None:
self._faster_whisper_engine.cleanup()
self._faster_whisper_engine = None
@@ -4784,19 +5396,8 @@ class SpeechRecognitionManager:
except Exception:
logger.debug("Error closing HTTP session during unload", exc_info=True)
self._http_session = None
- self._model_initialized = False
-
- if reason == "auto_pause":
- self._auto_paused = True
- self._idle_unloaded = False
- logger.info("Speech model unloaded; dictation blocked until auto-pause clears")
- elif reason == "idle_keepalive":
- # Auto-pause owns lifecycle if both could apply
- if not self._auto_paused:
- self._idle_unloaded = True
- logger.info("Speech model unloaded after idle keep-alive timeout")
- else:
- logger.info("Speech model unloaded")
+ finally:
+ self._model_lock.release()
try:
import gc
@@ -4804,10 +5405,69 @@ class SpeechRecognitionManager:
gc.collect()
except Exception:
pass
+ return True
+
+ def _new_model_request(
+ self,
+ auto_pause: bool = False,
+ auto_resume: bool = False,
+ inherit_resume: bool = False,
+ expected_request: Optional[object] = None,
+ ) -> Optional[object]:
+ """Invalidate older work, or reject a superseded delayed request.
+
+ Never wait for model resources or workers while publishing ownership.
+ """
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if expected_request is not None and self._model_request is not expected_request:
+ return None
+ request = object()
+ self._model_request = request
+ self._auto_resume_pending = auto_resume or (
+ inherit_resume and getattr(self, "_auto_resume_pending", False)
+ )
+ if auto_pause or auto_resume:
+ self._auto_paused = True
+ self._idle_unloaded = False
+ return request
+
+ @contextmanager
+ def _model_request_context(self, request: object) -> Iterator[None]:
+ """Bind initializer state writes to the request owning this thread."""
+ context = getattr(self, "_worker_session", None)
+ if context is None:
+ self._worker_session = context = threading.local()
+ previous = getattr(context, "model_request", None)
+ context.model_request = request
+ try:
+ yield
+ finally:
+ context.model_request = previous
+
+ def _owns_model_request(self, request: Optional[object] = None) -> bool:
+ """Check request ownership while the caller holds the request lock."""
+ if request is None:
+ request = getattr(getattr(self, "_worker_session", None), "model_request", None)
+ return request is None or self._model_request is request
+
+ def _set_engine_error(self) -> None:
+ """Publish an initializer error only while its request is current."""
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._owns_model_request():
+ self.state = RecognitionState.ERROR
+
+ def cancel_pending_auto_pause(self) -> None:
+ """Invalidate deferred pause/resume work before another lifecycle operation."""
+ self._new_model_request()
+
+ def prepare_auto_resume(self) -> object:
+ """Reserve automatic recovery and return its token for a delayed callback."""
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ return self._new_model_request(auto_resume=True)
@property
def is_auto_paused(self) -> bool:
- """True when the model was unloaded for auto-pause and not yet reloaded."""
+ """True while auto-pause blocks dictation, even if unload is still pending."""
return bool(self._auto_paused)
@property
@@ -4836,7 +5496,9 @@ class SpeechRecognitionManager:
return self.model_ready
- def reinitialize_after_resume(self):
+ def reinitialize_after_resume(
+ self, *, auto_resume: bool = False, expected_request: Optional[object] = None
+ ) -> None:
"""Reinitialize the speech engine after system resume from suspend.
Stops any active recognition, releases stale model resources,
@@ -4844,8 +5506,49 @@ class SpeechRecognitionManager:
are in a clean state for new dictation.
Also used after auto-pause / idle keep-alive to reload the model.
+ Auto-resume completes in the background if the old reload is still
+ running; other callers retain the explicit cancellation timeout.
+ Delayed callers may pass ``expected_request`` to yield to newer work.
"""
- self._cancel_reload_recording()
+ with getattr(self, "_lifecycle_lock", nullcontext()):
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if expected_request is not None and self._model_request is not expected_request:
+ return
+ settings_request = getattr(self, "_reconfigure_request", None)
+ settings_done = self._reconfigure_done if settings_request is not None else None
+ if settings_done is not None and self._model_request is settings_request:
+ # Settings still owns initialization or rollback, including
+ # time spent waiting for the engine after prefetch ends.
+ self._auto_resume_pending = self._auto_resume_pending or auto_resume
+ self._auto_paused = self._auto_resume_pending
+ self._idle_unloaded = not self._auto_resume_pending and not self.model_ready
+ return
+ request = self._new_model_request(
+ auto_resume=auto_resume, expected_request=expected_request
+ )
+ if request is None:
+ return
+ reload_worker = getattr(self, "recognition_thread", None)
+ if settings_done is not None:
+ # A pause superseded Settings before this wake. Wait for its whole
+ # operation, so rollback cannot race this request's initialization.
+ threading.Thread(
+ target=self._resume_after_reconfigure,
+ args=(settings_done, request),
+ daemon=True,
+ ).start()
+ return
+ try:
+ self._cancel_reload_recording()
+ except TimeoutError:
+ if not auto_resume:
+ raise
+ threading.Thread(
+ target=self._resume_after_reload,
+ args=(reload_worker, request),
+ daemon=True,
+ ).start()
+ return
if getattr(self, "_prefetching_model", False) is True:
# reconfigure() is fetching the next model and will load it itself;
# initializing here would start a second transfer of the same files.
@@ -4860,42 +5563,94 @@ class SpeechRecognitionManager:
logger.info("Stopping active recognition before resume reinit")
self.stop_recognition()
- with self._model_lock:
- self.model = None
- self.recognizer = None
+ if not self._reinitialize_model(request, blocking=not auto_resume):
+ threading.Thread(
+ target=self._resume_after_reload,
+ args=(reload_worker, request),
+ daemon=True,
+ ).start()
+
+ def _resume_after_reconfigure(self, done: threading.Event, request: object) -> None:
+ """Defer a superseding wake until Settings and any rollback have settled."""
+ done.wait()
+ self._resume_after_reload(None, request)
+
+ def _resume_after_reload(self, worker: Optional[threading.Thread], request: object) -> None:
+ """Complete only the current auto-resume, outside GTK and lifecycle locks."""
+ if worker is not None:
+ worker.join()
+ try:
+ self._reinitialize_model(request)
+ except Exception:
+ logger.error("Failed to complete deferred auto-resume", exc_info=True)
+
+ def _reinitialize_model(self, request: object, blocking: bool = True) -> bool:
+ """Serialize resource changes, then publish readiness only for this request."""
+ if not self._model_lock.acquire(blocking=blocking):
+ return False
+ failed = False
+ try:
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return True
+ old_model, old_recognizer = self.model, self.recognizer
+ self.model = None
+ self.recognizer = None
+ self._model_initialized = False
+ del old_model, old_recognizer
if self._faster_whisper_engine is not None:
self._faster_whisper_engine.cleanup()
self._faster_whisper_engine = None
if self._http_session is not None:
self._http_session.close()
self._http_session = None
- self._model_initialized = False
try:
- if self.engine == "vosk":
- self._init_vosk()
- elif self.engine == "whisper":
- self._init_whisper()
- elif self.engine == "whisper_cpp":
- self._init_whispercpp()
- elif self.engine == "parakeet":
- self._init_parakeet()
- elif self.engine == "faster_whisper":
- self._init_faster_whisper()
- elif self.engine == "remote_api":
- self._init_remote_api()
- else:
- logger.error("Cannot reinitialize: unknown engine '%s'", self.engine)
- return
-
- self._auto_paused = False
- self._idle_unloaded = False
- logger.info("Speech engine reinitialized after resume")
+ # Destruction/cleanup above may have outlasted a newer pause.
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is not request:
+ return True
+ with self._model_request_context(request):
+ if self.engine == "vosk":
+ self._init_vosk()
+ elif self.engine == "whisper":
+ self._init_whisper()
+ elif self.engine == "whisper_cpp":
+ self._init_whispercpp()
+ elif self.engine == "parakeet":
+ self._init_parakeet()
+ elif self.engine == "faster_whisper":
+ self._init_faster_whisper()
+ elif self.engine == "remote_api":
+ self._init_remote_api()
+ else:
+ logger.error("Cannot reinitialize: unknown engine '%s'", self.engine)
+ return True
except Exception:
logger.error("Failed to reinitialize speech engine after resume", exc_info=True)
- self._update_state(RecognitionState.ERROR)
+ failed = True
+ finally:
+ self._model_lock.release()
+ if failed:
+ self._update_state(RecognitionState.ERROR, model_request=request)
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is request:
+ self._reconnection_attempts = 0
+ else:
+ self._finish_model_resume(request)
+ return True
- self._reconnection_attempts = 0
+ def _finish_model_resume(self, request: object) -> None:
+ """Publish usable dictation only for the current successful recovery."""
+ if self.state == RecognitionState.ERROR:
+ self._update_state(RecognitionState.IDLE, model_request=request)
+ with getattr(self, "_model_request_lock", nullcontext()):
+ if self._model_request is request:
+ self._auto_paused = False
+ self._idle_unloaded = False
+ self._auto_resume_pending = False
+ self._reconnection_attempts = 0
+ logger.info("Speech engine reinitialized after resume")
def set_buffer_limit(self, max_chunks: int):
"""
diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py
index e51cec3..40fab6e 100644
--- a/src/vocalinux/ui/settings_dialog.py
+++ b/src/vocalinux/ui/settings_dialog.py
@@ -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"Selected: {device_label}")
+ 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("Recording... speak into your microphone")
@@ -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()
diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py
index a9564b1..14aa5c2 100644
--- a/src/vocalinux/ui/tray_indicator.py
+++ b/src/vocalinux/ui/tray_indicator.py
@@ -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."""
diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py
index 93f8788..89011c0 100644
--- a/tests/test_auto_pause_monitor.py
+++ b/tests/test_auto_pause_monitor.py
@@ -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()
diff --git a/tests/test_cancel_transcription.py b/tests/test_cancel_transcription.py
index 579ddde..67e4bbb 100644
--- a/tests/test_cancel_transcription.py
+++ b/tests/test_cancel_transcription.py
@@ -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)
diff --git a/tests/test_capture_cancellation.py b/tests/test_capture_cancellation.py
new file mode 100644
index 0000000..908b8a8
--- /dev/null
+++ b/tests/test_capture_cancellation.py
@@ -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()
diff --git a/tests/test_capture_recovery.py b/tests/test_capture_recovery.py
new file mode 100644
index 0000000..ce0bf3b
--- /dev/null
+++ b/tests/test_capture_recovery.py
@@ -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()
diff --git a/tests/test_capture_shutdown.py b/tests/test_capture_shutdown.py
new file mode 100644
index 0000000..8239ee2
--- /dev/null
+++ b/tests/test_capture_shutdown.py
@@ -0,0 +1,2021 @@
+"""Deterministic shutdown races without microphone hardware or model loading."""
+
+import importlib
+import queue
+import sys
+import threading
+import time
+from collections.abc import Generator, Iterator
+from contextlib import contextmanager, nullcontext
+from functools import partial
+from pathlib import Path
+from types import ModuleType
+from typing import Callable
+from unittest.mock import MagicMock, Mock
+
+import pytest
+
+from vocalinux.auto_pause_monitor import AutoPauseMonitor
+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]:
+ """Use real threads, mocked engines and short, independently adjustable budgets."""
+ monkeypatch.setattr(rm.SpeechRecognitionManager, "_init_vosk", lambda self: None)
+ monkeypatch.setattr(rm, "load_silero_vad", lambda: None)
+ monkeypatch.setattr(rm.SpeechRecognitionManager, "_download_selected_model_if_missing", Mock())
+ 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())
+ monkeypatch.setattr(rm, "_CAPTURE_JOIN_SECONDS", 0.02)
+ monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 0.02)
+ monkeypatch.setattr(rm, "_RECOGNITION_FINAL_JOIN_SECONDS", 0.02)
+ monkeypatch.setattr(rm, "_FINAL_BUFFER_SECONDS", 0.02)
+ # Restore 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.stop_sound_guard_ms = 0
+ yield instance
+ instance._session.cancel.set()
+ instance.should_record = False
+ instance._capture_finished.set()
+ instance._signal_recognition_stop()
+ for worker in (instance.audio_thread, instance.recognition_thread):
+ if worker is not None:
+ worker.join(2)
+ assert not worker.is_alive()
+
+
+def test_blocked_read_leaves_lock_available_and_preserves_final_speech(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ entered = threading.Event()
+ closed_on = []
+ transcribed = []
+ source = Mock(requires_pyaudio=False, sample_rate=16000, channels=1, audio=None)
+
+ def read(should_continue: Callable[[], bool]) -> bytes:
+ entered.set()
+ assert manager._session.cancel.wait(2)
+ assert not should_continue()
+ return b"late samples"
+
+ source.read_chunk.side_effect = read
+ source.close.side_effect = lambda: closed_on.append(threading.current_thread())
+ monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
+ monkeypatch.setattr(
+ manager, "_process_audio_buffer", lambda audio, language: transcribed.append(audio)
+ )
+ monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 1)
+ assert manager.start_recognition()
+ assert entered.wait(1)
+ try:
+ assert manager._buffer_lock.acquire(timeout=0.1)
+ try:
+ manager.audio_buffer = [b"final word"]
+ manager._recording_segment_has_speech = True
+ finally:
+ manager._buffer_lock.release()
+ manager.stop_recognition()
+ assert transcribed == [[b"final word"]]
+ assert closed_on == [manager.audio_thread]
+ rm.play_stop_sound.assert_called_once()
+ assert manager.audio_buffer == []
+ assert manager.state == RecognitionState.IDLE
+ finally:
+ manager._session.cancel.set()
+
+
+def test_final_lock_timeout_drops_segment_and_allows_restart(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ manager.state = RecognitionState.LISTENING
+ manager.should_record = True
+ manager.audio_buffer = [b"unavailable tail"]
+ manager._recording_segment_has_speech = True
+ with manager._buffer_lock:
+ started = time.monotonic()
+ manager.stop_recognition()
+ assert time.monotonic() - started < 0.5
+ assert "dropping final segment" in caplog.text
+ assert manager._segment_queue.get_nowait() is None
+ assert manager.state == RecognitionState.IDLE
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ assert manager.audio_buffer == []
+
+
+def test_stuck_capture_suppresses_cue_and_blocks_reuse_until_owner_exits(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ entered = threading.Event()
+ release = threading.Event()
+
+ def capture() -> None:
+ entered.set()
+ assert release.wait(2)
+
+ monkeypatch.setattr(manager, "_record_audio", capture)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ assert entered.wait(1)
+ old_capture = manager.audio_thread
+ try:
+ manager.stop_recognition()
+ assert manager.state == RecognitionState.IDLE
+ rm.play_stop_sound.assert_not_called()
+ assert not manager.start_recognition()
+ finally:
+ release.set()
+ old_capture.join(1)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ assert manager.start_recognition()
+
+
+def test_concurrent_and_reentrant_stops_handoff_only_once(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ manager.state = RecognitionState.LISTENING
+ manager.should_record = True
+ manager.audio_buffer = [b"one final segment"]
+ manager._recording_segment_has_speech = True
+ entered = threading.Event()
+ release = threading.Event()
+ waiting = threading.Event()
+ stop = manager._stop_session
+ wait = manager._session.stop_complete.wait
+
+ def elected(session: rm._CaptureSession) -> None:
+ entered.set()
+ assert release.wait(2)
+ stop(session)
+
+ def waiter(timeout: float) -> bool:
+ waiting.set()
+ return wait(timeout)
+
+ monkeypatch.setattr(manager, "_stop_session", elected)
+ monkeypatch.setattr(manager._session.stop_complete, "wait", waiter)
+ manager.state_callbacks.append(lambda state: manager.stop_recognition())
+ owner = threading.Thread(target=manager.stop_recognition)
+ follower = threading.Thread(target=manager.stop_recognition)
+ try:
+ owner.start()
+ assert entered.wait(1)
+ follower.start()
+ assert waiting.wait(1)
+ finally:
+ release.set()
+ owner.join(1)
+ if follower.ident is not None:
+ follower.join(1)
+ assert not owner.is_alive() and not follower.is_alive()
+ queued = []
+ while not manager._segment_queue.empty():
+ item = manager._segment_queue.get_nowait()
+ if item is not None:
+ queued.append(item[0])
+ assert queued == [[b"one final segment"]]
+ rm.play_stop_sound.assert_called_once()
+ assert manager._session.stop_complete.is_set()
+
+
+@pytest.mark.parametrize("worker_name", ["audio_thread", "recognition_thread"])
+def test_worker_can_stop_itself(manager: rm.SpeechRecognitionManager, worker_name: str) -> None:
+ manager.state = RecognitionState.LISTENING
+ manager.should_record = True
+ worker = threading.Thread(target=manager.stop_recognition)
+ setattr(manager, worker_name, worker)
+ worker.start()
+ worker.join(1)
+ assert not worker.is_alive()
+ assert manager.state == RecognitionState.IDLE
+
+
+@pytest.mark.parametrize("operation", ["unload_model", "reinitialize_after_resume", "reconfigure"])
+def test_reload_callers_report_timeout_before_engine_handoff(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, operation: str
+) -> None:
+ manager.buffer_during_reload = True
+ manager._idle_unloaded = True
+ manager._model_initialized = False
+ original_model = manager.model
+ original_timeout = manager.silence_timeout
+ settings = (
+ {"silence_timeout": 1.25, "audio_device_name": "Configured microphone"}
+ if operation == "reconfigure"
+ else {}
+ )
+ if operation == "unload_model":
+ settings = {"reason": "manual"}
+ loading = threading.Event()
+ release = threading.Event()
+
+ def load() -> None:
+ loading.set()
+ assert release.wait(2)
+ manager._model_initialized = True
+
+ monkeypatch.setattr(manager, "_init_selected_engine", load)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ assert manager.start_recognition()
+ assert loading.wait(1)
+ try:
+ started = time.monotonic()
+ with pytest.raises(TimeoutError, match="still loading"):
+ getattr(manager, operation)(**settings)
+ assert time.monotonic() - started < 0.5
+ assert manager.model is original_model
+ assert manager.silence_timeout == original_timeout
+ assert manager._buffered_reload_session
+ assert manager._cancel_buffered_session.is_set()
+ assert not manager.start_recognition()
+ finally:
+ release.set()
+ manager.recognition_thread.join(1)
+ assert not manager._buffered_reload_session
+ assert manager.state == RecognitionState.IDLE
+ if operation == "reconfigure":
+ manager.reconfigure(**settings)
+ assert manager.silence_timeout == 1.25
+ assert manager.start_recognition()
+
+
+class _AutoPauseFlow:
+ """Exercise production monitor/tray callbacks with gated model workers."""
+
+ def __init__(
+ self, manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ # The fixture isolates this import from other tests' mocked GLib modules.
+ from vocalinux.ui.tray_indicator import TrayIndicator
+
+ self.manager = manager
+ self.loading = threading.Event()
+ self.release_load = threading.Event()
+ self.unload_started = threading.Event()
+ self.release_unload = threading.Event()
+ self.resume_started = threading.Event()
+ self.release_resume = threading.Event()
+ self.unload_threads: list[threading.Thread] = []
+ self.resume_threads: list[threading.Thread] = []
+ self.cleanup_during_reload: list[bool] = []
+ self.resumed_model = object()
+ self.backend = Mock()
+ self.http_session = Mock()
+ self.transcribe = Mock()
+ self.reinit = Mock(side_effect=self._reinit)
+ manager.buffer_during_reload = True
+ manager._idle_unloaded = True
+ manager._model_initialized = False
+ manager._faster_whisper_engine = self.backend
+ manager._http_session = self.http_session
+
+ def load() -> None:
+ self.loading.set()
+ assert self.release_load.wait(3)
+ manager.model = object()
+ manager._model_initialized = True
+
+ unload_after_reload = manager._unload_after_reload
+ resume_after_reload = manager._resume_after_reload
+
+ def deferred_unload(worker: threading.Thread, request: object) -> None:
+ self.unload_threads.append(threading.current_thread())
+ self.unload_started.set()
+ assert self.release_unload.wait(3)
+ unload_after_reload(worker, request)
+
+ def deferred_resume(worker: threading.Thread, request: object) -> None:
+ self.resume_threads.append(threading.current_thread())
+ self.resume_started.set()
+ assert self.release_resume.wait(3)
+ resume_after_reload(worker, request)
+
+ monkeypatch.setattr(manager, "_init_selected_engine", load)
+ monkeypatch.setattr(manager, "_init_vosk", self.reinit)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_unload_after_reload", deferred_unload)
+ monkeypatch.setattr(manager, "_resume_after_reload", deferred_resume)
+ monkeypatch.setattr(manager, "_process_audio_buffer", self.transcribe)
+ self.tray = TrayIndicator.__new__(TrayIndicator)
+ self.tray.speech_engine = manager
+ self.tray._model_keepalive = Mock()
+ # The suspend path releases hotkey listeners before the machine sleeps.
+ self.tray.shortcut_manager = Mock()
+ self.tray._language_shortcut_managers = []
+ self.wake_callbacks: list[Callable[[], bool]] = []
+
+ def schedule(seconds: int, callback: Callable[..., bool], *args: object) -> int:
+ assert seconds == 2
+ assert callback == self.tray._reinit_speech_after_resume
+ self.wake_callbacks.append(partial(callback, *args))
+ return len(self.wake_callbacks)
+
+ monkeypatch.setattr(
+ sys.modules[TrayIndicator.__module__].GLib, "timeout_add_seconds", schedule
+ )
+ monkeypatch.setattr(self.tray, "_external_activation_active", lambda: True)
+ self.running: set[str] = set()
+ self.monitor = AutoPauseMonitor(
+ get_config=lambda: (True, ["game"], 5),
+ on_pause=self.tray._on_auto_pause,
+ on_resume=self.tray._on_auto_resume,
+ process_snapshot=lambda: self.running,
+ use_glib=False,
+ )
+ self.tray._auto_pause_monitor = self.monitor
+ self.backend.cleanup.side_effect = lambda: self.cleanup_during_reload.append(
+ self.reload_worker.is_alive()
+ )
+
+ def _reinit(self) -> None:
+ self.manager.model = self.resumed_model
+ self.manager._model_initialized = True
+
+ def poll(self, running: bool) -> None:
+ self.running.clear()
+ if running:
+ self.running.add("game")
+ started = time.monotonic()
+ assert self.monitor.check_once() is running
+ assert time.monotonic() - started < 0.5
+ assert self.monitor.paused is running
+
+ def finish_reload(self) -> None:
+ self.release_load.set()
+ self.reload_worker.join(1)
+ assert not self.reload_worker.is_alive()
+ assert not self.manager._buffered_reload_session
+ assert self.manager.state == RecognitionState.IDLE
+ self.transcribe.assert_not_called()
+
+ def join(self, workers: list[threading.Thread]) -> None:
+ for worker in workers:
+ worker.join(1)
+ assert not worker.is_alive()
+
+
+@contextmanager
+def _isolated_tray_import() -> Iterator[ModuleType]:
+ """Restore local modules and package bindings, including newly imported dependencies."""
+ saved_modules = {
+ name: module
+ for name, module in sys.modules.copy().items()
+ if name == "vocalinux" or name.startswith("vocalinux.")
+ }
+ saved_packages = {
+ name: (module, vars(module).copy())
+ for name, module in saved_modules.items()
+ if isinstance(module, ModuleType) and hasattr(module, "__path__")
+ }
+ try:
+ # A cached tray may be bound to a different test's GLib mock.
+ sys.modules.pop("vocalinux.ui.tray_indicator", None)
+ yield importlib.import_module("vocalinux.ui.tray_indicator")
+ finally:
+ module_names = set(saved_modules) | {
+ name for name in sys.modules.copy() if name.startswith("vocalinux.")
+ }
+ for name in module_names:
+ if name in saved_modules:
+ sys.modules[name] = saved_modules[name]
+ else:
+ sys.modules.pop(name, None)
+ parent_name, _, attribute = name.rpartition(".")
+ if parent_name in saved_packages:
+ parent, attributes = saved_packages[parent_name]
+ if attribute in attributes:
+ setattr(parent, attribute, attributes[attribute])
+ else:
+ vars(parent).pop(attribute, None)
+
+
+@pytest.fixture
+def auto_pause_flow(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> Generator[_AutoPauseFlow, None, None]:
+ with _isolated_tray_import():
+ flow = _AutoPauseFlow(manager, monkeypatch)
+ try:
+ assert manager.start_recognition()
+ flow.reload_worker = manager.recognition_thread
+ assert flow.loading.wait(1)
+ yield flow
+ finally:
+ flow.release_load.set()
+ flow.release_unload.set()
+ flow.release_resume.set()
+ flow.join(
+ [
+ worker
+ for worker in (manager.audio_thread, manager.recognition_thread)
+ if worker is not None
+ ]
+ + flow.unload_threads
+ + flow.resume_threads
+ )
+
+
+@pytest.mark.parametrize("cached", [False, True])
+@pytest.mark.parametrize("package_attribute", [False, True])
+@pytest.mark.parametrize("fail", [False, True])
+def test_tray_import_restores_modules_and_package_attributes(
+ monkeypatch: pytest.MonkeyPatch, cached: bool, package_attribute: bool, fail: bool
+) -> None:
+ """An earlier GLib mock must not survive an isolated flow through import caching."""
+ import vocalinux.ui
+
+ tray_name = "vocalinux.ui.tray_indicator"
+ settings_name = "vocalinux.ui.settings_dialog"
+ # Model a cached tray and a freshly imported dependency independently of
+ # their package attributes; those bindings can be absent or out of sync.
+ old_tray = ModuleType(tray_name)
+ old_tray.GLib = MagicMock()
+ old_attribute = ModuleType(tray_name)
+ if cached:
+ monkeypatch.setitem(sys.modules, tray_name, old_tray)
+ else:
+ monkeypatch.delitem(sys.modules, tray_name, raising=False)
+ monkeypatch.delitem(sys.modules, settings_name, raising=False)
+ for attribute in ("tray_indicator", "settings_dialog"):
+ if package_attribute:
+ monkeypatch.setattr(vocalinux.ui, attribute, old_attribute, raising=False)
+ else:
+ monkeypatch.delattr(vocalinux.ui, attribute, raising=False)
+
+ for _ in range(2):
+ glib = MagicMock()
+ monkeypatch.setattr(sys.modules["gi.repository"], "GLib", glib)
+ with pytest.raises(RuntimeError, match="flow failed") if fail else nullcontext():
+ with _isolated_tray_import() as tray:
+ assert tray is not old_tray
+ assert tray.GLib is glib
+ assert vocalinux.ui.tray_indicator is tray
+ assert vocalinux.ui.settings_dialog is sys.modules[settings_name]
+ assert tray.TrayIndicator._reinit_keyboard_fallback(Mock()) is glib.SOURCE_REMOVE
+ if fail:
+ raise RuntimeError("flow failed")
+
+ if cached:
+ assert sys.modules[tray_name] is old_tray
+ else:
+ assert tray_name not in sys.modules
+ assert settings_name not in sys.modules
+ for attribute in ("tray_indicator", "settings_dialog"):
+ if package_attribute:
+ assert vars(vocalinux.ui)[attribute] is old_attribute
+ else:
+ assert attribute not in vars(vocalinux.ui)
+
+
+@pytest.mark.parametrize(
+ "resume", ["after_unload", "during_reload", "during_cancel", "before_deferred_unload"]
+)
+def test_monitor_auto_pause_survives_reload_timeout(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ resume: str,
+) -> None:
+ """A single monitor resume recovers automatically, including after timeout."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ assert flow.reload_worker.is_alive()
+ assert manager.is_auto_paused
+ assert not manager.is_idle_unloaded
+ assert not manager.start_recognition()
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+ flow.poll(True)
+ assert len(flow.unload_threads) == 1
+
+ if resume == "during_reload":
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ # Allow the real deferred callback to wait for the captured worker.
+ flow.release_resume.set()
+ elif resume == "during_cancel":
+ cancel_reload = manager._cancel_reload_recording
+
+ def cancel_after_release() -> None:
+ assert flow.reload_worker.is_alive()
+ flow.release_load.set()
+ cancel_reload()
+
+ monkeypatch.setattr(manager, "_cancel_reload_recording", cancel_after_release)
+ monkeypatch.setattr(rm, "_RECOGNITION_JOIN_SECONDS", 1)
+ flow.poll(False)
+ assert not flow.resume_started.is_set()
+
+ flow.finish_reload()
+ if resume == "after_unload":
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert not manager.is_idle_unloaded
+ assert not manager.start_recognition()
+ flow.poll(True)
+ assert len(flow.unload_threads) == 1
+ flow.poll(False)
+ elif resume == "before_deferred_unload":
+ flow.poll(False)
+ elif resume == "during_reload":
+ flow.join(flow.resume_threads)
+ # No further monitor transition or manual resume is needed.
+ flow.poll(False)
+ assert len(flow.resume_threads) == 1
+
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ assert not manager.is_idle_unloaded
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ # Let the stale unload arrive after resumed dictation has started.
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ assert manager.model is flow.resumed_model
+ assert manager.state == RecognitionState.LISTENING
+ assert manager.should_record
+ flow.reinit.assert_called_once_with()
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+
+
+@pytest.mark.parametrize("supersede", ["pause", "resume"])
+def test_new_monitor_transition_invalidates_deferred_resume(
+ manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow, supersede: str
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ assert manager.is_auto_paused
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ assert len(flow.unload_threads) == 2
+ flow.finish_reload()
+
+ if supersede == "resume":
+ flow.poll(False)
+ assert manager.model is flow.resumed_model
+ assert not manager.is_auto_paused
+ flow.release_resume.set()
+ flow.join(flow.resume_threads)
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ if supersede == "pause":
+ flow.reinit.assert_not_called()
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ else:
+ flow.reinit.assert_called_once_with()
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+
+
+@pytest.mark.parametrize("pending", ["unload", "resume"])
+@pytest.mark.parametrize("operation", ["manual", "idle_keepalive", "reconfigure", "system_resume"])
+def test_lifecycle_operation_supersedes_deferred_auto_pause_work(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ pending: str,
+ operation: str,
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if pending == "resume":
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ if operation == "reconfigure":
+ flow.resume_started.clear()
+
+ if operation == "reconfigure" and pending == "unload":
+ flow.unload_started.clear()
+ started = time.monotonic()
+ with pytest.raises(TimeoutError, match="still loading"):
+ if operation == "reconfigure":
+ manager.reconfigure(silence_timeout=1.25, audio_device_name="Configured microphone")
+ elif operation == "system_resume":
+ manager.reinitialize_after_resume()
+ else:
+ manager.unload_model(reason=operation)
+ assert time.monotonic() - started < 0.5
+ assert manager.silence_timeout != 1.25
+ if operation == "reconfigure" and pending == "resume":
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == 2
+ if operation == "reconfigure" and pending == "unload":
+ assert flow.unload_started.wait(1)
+ assert len(flow.unload_threads) == 2
+ flow.finish_reload()
+ loaded_model = manager.model
+ flow.release_resume.set()
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ if operation == "reconfigure" and pending == "resume":
+ flow.reinit.assert_called_once_with()
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ return
+ flow.reinit.assert_not_called()
+ if operation == "reconfigure" and pending == "unload":
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ assert manager.model is None
+ assert manager.is_auto_paused
+ return
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+ assert manager.model is loaded_model
+ assert manager.is_auto_paused
+
+
+@pytest.mark.parametrize("exit_before_wake", [False, True])
+@pytest.mark.parametrize("suspends", [1, 2])
+def test_suspend_retains_unload_while_game_persists(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ exit_before_wake: bool,
+ suspends: int,
+) -> None:
+ """Sleep cannot abandon cleanup or allow dictation while the monitor stays paused."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ for _ in range(suspends):
+ flow.unload_started.clear()
+ started = time.monotonic()
+ flow.tray._on_system_suspend()
+ assert time.monotonic() - started < 0.5
+ assert flow.unload_started.wait(1)
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ assert len(flow.unload_threads) == suspends + 1
+ flow.release_unload.set()
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+ if exit_before_wake:
+ flow.finish_reload()
+ flow.join(flow.unload_threads)
+ assert manager.model is None
+
+ flow.tray._on_system_resume()
+ assert not flow.wake_callbacks
+ flow.poll(True)
+ if not exit_before_wake:
+ flow.finish_reload()
+ flow.join(flow.unload_threads)
+ flow.reinit.assert_not_called()
+ assert len(flow.unload_threads) == suspends + 1
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+
+ flow.poll(False)
+ flow.reinit.assert_called_once_with()
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+
+
+@pytest.mark.parametrize("exit_before_wake", [False, True])
+@pytest.mark.parametrize("game_exit", ["before_suspend", "during_suspend"])
+def test_wake_recovers_auto_resume_without_monitor_transition(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ exit_before_wake: bool,
+ game_exit: str,
+) -> None:
+ """Wake rearms canceled recovery, even when the native worker still owns the model."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if game_exit == "before_suspend":
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.unload_started.clear()
+ flow.tray._on_system_suspend()
+ if game_exit == "during_suspend":
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert not flow.resume_started.is_set()
+ stale_resumes = len(flow.resume_threads)
+ flow.release_resume.set()
+ flow.release_unload.set()
+ if exit_before_wake:
+ flow.finish_reload()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.reinit.assert_not_called()
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+
+ flow.tray._on_system_resume()
+ assert len(flow.wake_callbacks) == 1
+ flow.resume_started.clear()
+ started = time.monotonic()
+ flow.wake_callbacks[0]()
+ assert time.monotonic() - started < 0.5
+ if not exit_before_wake:
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == stale_resumes + 1
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+ flow.finish_reload()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.poll(False)
+ flow.reinit.assert_called_once_with()
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+
+
+@pytest.mark.parametrize("stage", ["delayed", "deferred"])
+@pytest.mark.parametrize(
+ "operation",
+ [
+ "pause",
+ "pause_resume",
+ "suspend",
+ "manual",
+ "reconfigure",
+ "reconfigure_model",
+ "system_resume",
+ ],
+)
+def test_new_lifecycle_request_supersedes_wake_recovery(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ stage: str,
+ operation: str,
+) -> None:
+ """Neither a timer nor deferred wake initialization can override a newer request."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.tray._on_system_suspend()
+ flow.tray._on_system_resume()
+ stale_wake = flow.wake_callbacks[0]
+ if stage == "deferred":
+ flow.resume_started.clear()
+ stale_wake()
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == 2
+
+ flow.resume_started.clear()
+ if operation in ("pause", "pause_resume"):
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if operation == "pause_resume":
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ elif operation == "suspend":
+ flow.tray._on_system_suspend()
+ # Run the first wake's timer after a second wake, when a sleep flag
+ # alone cannot reject it. Ownership must still belong to the new wake.
+ flow.tray._on_system_resume()
+ assert len(flow.wake_callbacks) == 2
+ else:
+ with pytest.raises(TimeoutError, match="still loading"):
+ if operation == "manual":
+ manager.unload_model(reason="manual")
+ elif operation in ("reconfigure", "reconfigure_model"):
+ manager.reconfigure(
+ silence_timeout=1.25,
+ audio_device_name="Configured microphone",
+ force_reinit=operation == "reconfigure_model",
+ )
+ else:
+ manager.reinitialize_after_resume()
+ if operation in ("reconfigure", "reconfigure_model"):
+ assert flow.resume_started.wait(1)
+ assert manager.silence_timeout != 1.25
+
+ resumes = len(flow.resume_threads)
+ if stage == "delayed":
+ stale_wake()
+ assert len(flow.resume_threads) == resumes
+ flow.finish_reload()
+ loaded_model = manager.model
+ flow.release_resume.set()
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ recovered = operation in ("pause_resume", "reconfigure", "reconfigure_model")
+ if recovered:
+ flow.reinit.assert_called_once_with()
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ else:
+ flow.reinit.assert_not_called()
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ if operation == "pause":
+ assert manager.model is None
+ else:
+ assert manager.model is loaded_model
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+ if recovered or operation == "pause":
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ if operation == "suspend":
+ flow.wake_callbacks[-1]()
+ flow.reinit.assert_called_once_with()
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+
+
+@pytest.mark.parametrize("stage", ["delayed", "deferred"])
+@pytest.mark.parametrize("new_pause", [False, True])
+def test_reconfigure_rollback_completes_inherited_wake_recovery(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ stage: str,
+ new_pause: bool,
+) -> None:
+ """A restored engine recovers dictation unless a newer game pause owns it."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.tray._on_system_suspend()
+ flow.tray._on_system_resume()
+ stale_wake = flow.wake_callbacks[0]
+ if stage == "deferred":
+ flow.resume_started.clear()
+ stale_wake()
+ assert flow.resume_started.wait(1)
+ flow.finish_reload()
+ previous = manager._snapshot_reconfigure_state()
+ previous_defer = manager._defer_download
+ rolling_back = threading.Event()
+ release_rollback = threading.Event()
+ settings_error = RuntimeError("new engine failed")
+ errors: list[Exception] = []
+ restored_settings: list[dict[str, object]] = []
+
+ def reject_settings() -> None:
+ manager._model_initialized = False
+ manager._set_engine_error()
+ raise settings_error
+
+ def restore() -> None:
+ restored_settings.append(manager._snapshot_reconfigure_state())
+ rolling_back.set()
+ assert release_rollback.wait(3)
+ flow._reinit()
+
+ def reconfigure() -> None:
+ try:
+ manager.reconfigure(engine="whisper", model_size="small", silence_timeout=1.25)
+ except Exception as error:
+ errors.append(error)
+
+ flow.reinit.side_effect = restore
+ monkeypatch.setattr(manager, "_init_whisper", reject_settings)
+ monkeypatch.setattr(
+ manager,
+ "_init_selected_engine",
+ rm.SpeechRecognitionManager._init_selected_engine.__get__(manager),
+ )
+ settings_worker = threading.Thread(target=reconfigure)
+ try:
+ settings_worker.start()
+ assert rolling_back.wait(1)
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ if new_pause:
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ release_rollback.set()
+ flow.join([settings_worker])
+ assert errors == [settings_error]
+ assert restored_settings == [previous]
+ assert manager._snapshot_reconfigure_state() == previous
+ assert manager._defer_download is previous_defer
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert manager.is_auto_paused is new_pause
+ if stage == "delayed":
+ stale_wake()
+ flow.release_resume.set()
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.reinit.assert_called_once_with()
+ if new_pause:
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ flow.poll(False)
+ assert flow.reinit.call_count == 2
+ else:
+ # The monitor already resumed before suspend; it need not
+ # transition again to clear the guard after rejected settings.
+ flow.poll(False)
+ flow.reinit.assert_called_once_with()
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ finally:
+ release_rollback.set()
+ if settings_worker.ident is not None:
+ flow.join([settings_worker])
+
+
+def test_new_auto_resume_supersedes_waiting_resume(
+ manager: rm.SpeechRecognitionManager, auto_pause_flow: _AutoPauseFlow
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.resume_started.clear()
+ flow.tray._on_auto_resume()
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == 2
+ flow.finish_reload()
+ flow.release_resume.set()
+ flow.join(flow.resume_threads)
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ flow.reinit.assert_called_once_with()
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+
+
+@pytest.mark.parametrize("restart", [False, True])
+@pytest.mark.parametrize("attempts", [1, 2])
+def test_failed_reconfigure_retains_deferred_resume(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ restart: bool,
+ attempts: int,
+) -> None:
+ """Rejected settings recover the saved engine without another monitor transition."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ previous = manager._snapshot_reconfigure_state()
+ loaded_model = manager.model
+ initialized_settings: list[dict[str, object]] = []
+
+ def initialize() -> None:
+ initialized_settings.append(manager._snapshot_reconfigure_state())
+ flow._reinit()
+
+ flow.reinit.side_effect = initialize
+ rejected_init = Mock()
+ monkeypatch.setattr(manager, "_init_whisper", rejected_init)
+ for attempt in range(attempts):
+ flow.resume_started.clear()
+ started = time.monotonic()
+ with pytest.raises(TimeoutError, match="still loading"):
+ manager.reconfigure(
+ engine="whisper" if restart else None,
+ model_size="small" if restart else None,
+ silence_timeout=1.25,
+ audio_device_name="Configured microphone",
+ )
+ assert time.monotonic() - started < 0.5
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == attempt + 2
+ assert manager._snapshot_reconfigure_state() == previous
+ assert manager.model is loaded_model
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ flow.reinit.assert_not_called()
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+
+ # Both stale and current resumes can run, but must wait for the native worker.
+ flow.release_resume.set()
+ flow.finish_reload()
+ flow.join(flow.resume_threads)
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ flow.poll(False)
+ assert len(flow.resume_threads) == attempts + 1
+ flow.reinit.assert_called_once_with()
+ rejected_init.assert_not_called()
+ assert initialized_settings == [previous]
+ assert manager._snapshot_reconfigure_state() == previous
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+
+
+@pytest.mark.parametrize("restart", [False, True])
+@pytest.mark.parametrize("operation", ["pause", "suspend", "manual", "system_resume"])
+def test_new_request_supersedes_failed_reconfigure_recovery(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ restart: bool,
+ operation: str,
+) -> None:
+ """A failed settings attempt cannot revive recovery superseded by newer work."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.resume_started.clear()
+ with pytest.raises(TimeoutError, match="still loading"):
+ manager.reconfigure(
+ silence_timeout=1.25, audio_device_name="Configured microphone", force_reinit=restart
+ )
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == 2
+
+ started = time.monotonic()
+ if operation == "pause":
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ elif operation == "suspend":
+ flow.tray._on_system_suspend()
+ else:
+ with pytest.raises(TimeoutError, match="still loading"):
+ if operation == "system_resume":
+ manager.reinitialize_after_resume()
+ else:
+ manager.unload_model(reason="manual")
+ assert time.monotonic() - started < 0.5
+ flow.finish_reload()
+ loaded_model = manager.model
+ flow.release_resume.set()
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.reinit.assert_not_called()
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ if operation == "pause":
+ assert manager.model is None
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+ else:
+ assert manager.model is loaded_model
+ flow.backend.cleanup.assert_not_called()
+ flow.http_session.close.assert_not_called()
+
+
+@pytest.mark.parametrize("restart", [False, True])
+@pytest.mark.parametrize("new_pause", [False, True])
+def test_successful_reconfigure_takes_over_deferred_resume(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ restart: bool,
+ new_pause: bool,
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.finish_reload()
+ initializing = threading.Event()
+ release_init = threading.Event()
+
+ def initialize() -> None:
+ initializing.set()
+ assert release_init.wait(3)
+ flow.reinit()
+
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ monkeypatch.setattr(manager, "_init_selected_engine", initialize)
+ reconfigure = threading.Thread(
+ target=manager.reconfigure,
+ kwargs={
+ "silence_timeout": 1.25,
+ "audio_device_name": "Configured microphone",
+ "force_reinit": restart,
+ },
+ )
+ try:
+ reconfigure.start()
+ if not restart:
+ assert flow.resume_started.wait(1)
+ flow.release_resume.set()
+ assert initializing.wait(1)
+ if new_pause:
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ release_init.set()
+ flow.join([reconfigure])
+ assert manager.silence_timeout == 1.25
+ flow.release_resume.set()
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.reinit.assert_called_once_with()
+ if new_pause:
+ assert manager.is_auto_paused
+ assert manager.model is None
+ assert not manager.start_recognition()
+ flow.poll(False)
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ finally:
+ release_init.set()
+ flow.join([reconfigure])
+
+
+@pytest.mark.parametrize("operation", ["unload", "resume"])
+@pytest.mark.parametrize("resource", ["model", "recognizer"])
+def test_native_destruction_does_not_block_new_pause(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ operation: str,
+ resource: str,
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if operation == "resume":
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.finish_reload()
+ destroying = threading.Event()
+ release_destroy = threading.Event()
+ pause_done = threading.Event()
+
+ class NativeResource:
+ def __del__(self) -> None:
+ destroying.set()
+ assert release_destroy.wait(3)
+
+ def pause() -> None:
+ flow.tray._on_auto_pause()
+ pause_done.set()
+
+ # The manager holds the only reference, so clearing it runs __del__.
+ setattr(manager, resource, NativeResource())
+ pausing = threading.Thread(target=pause)
+ try:
+ if operation == "resume":
+ flow.release_resume.set()
+ else:
+ flow.release_unload.set()
+ assert destroying.wait(1)
+ flow.unload_started.clear()
+ pausing.start()
+ assert pause_done.wait(0.5)
+ assert flow.unload_started.wait(1)
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ assert not release_destroy.is_set()
+ finally:
+ release_destroy.set()
+ if pausing.ident is not None:
+ flow.join([pausing])
+ flow.release_unload.set()
+ flow.join(flow.resume_threads + flow.unload_threads)
+ flow.reinit.assert_not_called()
+ assert manager.model is None
+ assert manager.is_auto_paused
+
+
+@pytest.mark.parametrize("superseded", [False, True])
+def test_initializer_failure_cannot_poison_newer_resume(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ tmp_path: Path,
+ superseded: bool,
+) -> None:
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.finish_reload()
+ first_loading = threading.Event()
+ release_first = threading.Event()
+ second_loading = threading.Event()
+ release_second = threading.Event()
+ states: list[RecognitionState] = []
+ manager.state_callbacks.append(states.append)
+
+ def load(*args: object, **kwargs: object) -> object:
+ if not first_loading.is_set():
+ first_loading.set()
+ assert release_first.wait(3)
+ raise RuntimeError("old initializer failed")
+ second_loading.set()
+ if superseded:
+ assert release_second.wait(3)
+ return flow.resumed_model
+
+ # Exercise the engine's own error write as well as the manager's handler.
+ manager.engine = "whisper"
+ manager._defer_download = False
+ monkeypatch.setattr(rm, "MODELS_DIR", str(tmp_path))
+ monkeypatch.setattr(manager, "_download_whisper_model", Mock())
+ monkeypatch.setitem(sys.modules, "whisper", Mock(load_model=load))
+ torch = Mock()
+ torch.cuda.is_available.return_value = False
+ monkeypatch.setitem(sys.modules, "torch", torch)
+ try:
+ flow.release_resume.set()
+ assert first_loading.wait(1)
+ if superseded:
+ flow.resume_started.clear()
+ flow.tray._on_auto_resume()
+ assert flow.resume_started.wait(1)
+ assert len(flow.resume_threads) == 2
+ release_first.set()
+ if not superseded:
+ flow.join(flow.resume_threads)
+ assert manager.state == RecognitionState.ERROR
+ flow.tray._on_auto_resume()
+ assert second_loading.wait(1)
+ if superseded:
+ assert manager.state == RecognitionState.IDLE
+ assert RecognitionState.ERROR not in states
+ release_second.set()
+ flow.join(flow.resume_threads)
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ assert manager.state == RecognitionState.IDLE
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ finally:
+ release_first.set()
+ release_second.set()
+
+
+@pytest.mark.parametrize("supersede", ["pause", "resume"])
+def test_transition_while_deferred_resume_initializes(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ auto_pause_flow: _AutoPauseFlow,
+ supersede: str,
+) -> None:
+ """Native initialization cannot delay a new pause or publish a stale guard."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ flow.finish_reload()
+ initializing = threading.Event()
+ release_init = threading.Event()
+
+ def initialize() -> None:
+ initializing.set()
+ assert release_init.wait(3)
+ flow._reinit()
+
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ try:
+ flow.release_resume.set()
+ assert initializing.wait(1)
+ assert not manager._model_lock.acquire(blocking=False)
+ flow.unload_started.clear()
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if supersede == "resume":
+ flow.resume_started.clear()
+ flow.poll(False)
+ assert flow.resume_started.wait(1)
+ release_init.set()
+ flow.join(flow.resume_threads)
+ if supersede == "pause":
+ assert manager.is_auto_paused
+ assert not manager.start_recognition()
+ else:
+ assert not manager.is_auto_paused
+ assert manager.model is flow.resumed_model
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ if supersede == "pause":
+ assert manager.model is None
+ assert manager.is_auto_paused
+ else:
+ assert manager.model is flow.resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+ finally:
+ release_init.set()
+
+
+def test_late_recognizer_keeps_its_queue_and_cannot_change_next_session(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ decoding = threading.Event()
+ release = threading.Event()
+ attempts = []
+
+ def decode(audio: list[bytes], language: str) -> None:
+ attempts.append(audio)
+ decoding.set()
+ assert release.wait(2)
+ manager._update_state(RecognitionState.ERROR)
+ manager.release_playback_duck()
+
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_process_audio_buffer", decode)
+ assert manager.start_recognition()
+ manager._segment_queue.put_nowait(([b"old speech"], "en"))
+ assert decoding.wait(1)
+ old_worker = manager.recognition_thread
+ old_session = manager._session
+ try:
+ manager.stop_recognition()
+ assert manager.is_transcribing()
+ assert manager.cancel_transcription()
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ assert manager._session is not old_session
+ new_queue = manager._segment_queue
+ new_queue.put_nowait(([b"new speech"], "en"))
+ restore = Mock()
+ monkeypatch.setattr(manager._playback_duck, "restore", restore)
+ finally:
+ release.set()
+ old_worker.join(1)
+ assert not old_worker.is_alive()
+ assert manager.state == RecognitionState.LISTENING
+ assert manager.should_record
+ assert new_queue.get_nowait() == ([b"new speech"], "en")
+ with pytest.raises(queue.Empty):
+ new_queue.get_nowait()
+ assert attempts == [[b"old speech"]]
+ restore.assert_not_called()
+
+
+def test_stale_recognizer_cannot_deliver_text_after_slow_corrections(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ correcting = threading.Event()
+ release = threading.Event()
+ delivered: list[str] = []
+
+ def correct(text: str) -> str:
+ correcting.set()
+ assert release.wait(2)
+ return text
+
+ manager.engine = "whisper"
+ manager._voice_commands_enabled = False
+ manager.text_callbacks.append(delivered.append)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_transcribe_with_whisper", lambda audio, language: "old text")
+ monkeypatch.setattr(manager, "_apply_dictionary_corrections", correct)
+ assert manager.start_recognition()
+ manager._segment_queue.put_nowait(([b"old speech"], "en"))
+ assert correcting.wait(1)
+ old_worker = manager.recognition_thread
+ try:
+ manager.stop_recognition()
+ assert manager.is_transcribing()
+ assert manager.cancel_transcription()
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ finally:
+ release.set()
+ old_worker.join(1)
+ assert not old_worker.is_alive()
+ assert delivered == []
+ assert manager.state == RecognitionState.LISTENING
+
+
+def test_stop_stays_bounded_and_restart_waits_for_active_text_callback(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ delivering = threading.Event()
+ release = threading.Event()
+
+ def deliver(_text: str) -> None:
+ delivering.set()
+ assert release.wait(2)
+
+ manager.engine = "whisper"
+ manager._voice_commands_enabled = False
+ manager.text_callbacks.append(deliver)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_transcribe_with_whisper", lambda audio, language: "old text")
+ assert manager.start_recognition()
+ manager._segment_queue.put_nowait(([b"old speech"], "en"))
+ assert delivering.wait(1)
+ old_worker = manager.recognition_thread
+ try:
+ started = time.monotonic()
+ manager.stop_recognition()
+ assert time.monotonic() - started < 0.5
+ assert not manager.start_recognition()
+ finally:
+ release.set()
+ old_worker.join(1)
+ assert not old_worker.is_alive()
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+
+
+def test_failed_start_restores_one_shot_language_after_buffer_timeout(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ original_language = manager.language
+ monkeypatch.setattr(manager, "_serving_model_supports_language_switch", lambda: True)
+ monkeypatch.setattr(manager, "_can_relanguage_without_reload", lambda: True)
+ with manager._buffer_lock:
+ assert not manager.start_recognition_with_language("de")
+ assert manager.language == original_language
+ assert manager._oneshot_language_restore is None
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ assert manager._session_language == original_language
+
+
+def test_healthy_stop_adds_no_polling_delay(manager: rm.SpeechRecognitionManager) -> None:
+ manager.state = RecognitionState.LISTENING
+ manager.should_record = True
+ started = time.monotonic()
+ manager.stop_recognition()
+ assert time.monotonic() - started < 0.05
+ assert manager.state == RecognitionState.IDLE
+
+
+def test_stop_from_start_callback_does_not_launch_capture(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ capture = Mock()
+ monkeypatch.setattr(manager, "_record_audio", capture)
+ manager.state_callbacks.append(
+ lambda state: manager.stop_recognition() if state == RecognitionState.LISTENING else None
+ )
+ assert not manager.start_recognition()
+ capture.assert_not_called()
+ assert not manager.should_record
+ assert manager.state == RecognitionState.IDLE
+
+
+def test_final_handoff_waits_for_vad_without_publishing_after_timeout(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ vad_entered = threading.Event()
+ release = threading.Event()
+ source = Mock(requires_pyaudio=False, sample_rate=16000, channels=1, audio=None)
+ source.read_chunk.return_value = b"\x01\x00" * 1024
+ vad = Mock()
+
+ def process(_chunk: object) -> float:
+ vad_entered.set()
+ assert release.wait(2)
+ return 0.99
+
+ vad.process.side_effect = process
+ manager._silero_vad = vad
+ monkeypatch.setattr(manager, "_new_capture_source", lambda: source)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ assert manager.start_recognition()
+ assert vad_entered.wait(1)
+ try:
+ assert not manager._buffer_lock.acquire(blocking=False)
+ manager.stop_recognition()
+ assert manager._session.stop_complete.is_set()
+ finally:
+ release.set()
+ manager.audio_thread.join(1)
+ assert not manager._recording_segment_has_speech
+ assert manager._segment_queue.get_nowait() is None
+ assert manager._segment_queue.get_nowait() is None
+ with pytest.raises(queue.Empty):
+ manager._segment_queue.get_nowait()
+
+
+@pytest.mark.parametrize("hook", ["state", "cue"])
+def test_reentrant_restart_cannot_be_overwritten_by_original_start(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, hook: str
+) -> None:
+ captured_sessions = []
+ restarted = []
+ reentered = False
+
+ def capture() -> None:
+ captured_sessions.append(manager._worker_session.session)
+
+ def restart() -> None:
+ nonlocal reentered
+ if reentered:
+ return
+ reentered = True
+ manager.stop_recognition()
+ restarted.append(manager.start_recognition())
+
+ monkeypatch.setattr(manager, "_record_audio", capture)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ if hook == "state":
+ manager.state_callbacks.append(
+ lambda state: restart() if state == RecognitionState.LISTENING else None
+ )
+ else:
+ monkeypatch.setattr(rm, "play_start_sound", restart)
+ assert not manager.start_recognition()
+ manager.audio_thread.join(1)
+ manager.recognition_thread.join(1)
+ assert restarted == [True]
+ assert captured_sessions == [manager._session]
+ assert manager.should_record
+ assert manager.state == RecognitionState.LISTENING
+
+
+@pytest.mark.parametrize("cancelled", [False, True])
+@pytest.mark.parametrize("superseded", [False, True])
+def test_wake_after_pause_during_prefetch_keeps_current_model_request(
+ manager: rm.SpeechRecognitionManager,
+ monkeypatch: pytest.MonkeyPatch,
+ cancelled: bool,
+ superseded: bool,
+) -> None:
+ """Wake waits for Settings rollback/download; a newer pause still wins."""
+ downloading = threading.Event()
+ release_download = threading.Event()
+ resuming = threading.Event()
+ resumed = threading.Event()
+ resume_workers: list[threading.Thread] = []
+ errors: list[Exception] = []
+ initialized: list[tuple[str, str]] = []
+ selected_model = object()
+ previous_engine, previous_size = manager.engine, manager.model_size
+ resume_after_reconfigure = manager._resume_after_reconfigure
+
+ def prefetch() -> None:
+ downloading.set()
+ assert release_download.wait(3)
+ if cancelled:
+ raise RuntimeError("Download cancelled")
+
+ def initialize() -> None:
+ initialized.append((manager.engine, manager.model_size))
+ manager.model = selected_model
+ manager._model_initialized = True
+
+ def reconfigure() -> None:
+ try:
+ manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=True)
+ except Exception as error:
+ errors.append(error)
+
+ def resume(done: threading.Event, request: object) -> None:
+ resume_workers.append(threading.current_thread())
+ resuming.set()
+ try:
+ resume_after_reconfigure(done, request)
+ finally:
+ resumed.set()
+
+ monkeypatch.setattr(manager, "_download_selected_model_if_missing", prefetch)
+ monkeypatch.setattr(manager, "_init_selected_engine", initialize)
+ monkeypatch.setattr(manager, "_init_whispercpp", initialize)
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ monkeypatch.setattr(manager, "_resume_after_reconfigure", resume)
+ settings_worker = threading.Thread(target=reconfigure)
+ settings_worker.start()
+ try:
+ assert downloading.wait(1)
+ manager.unload_model(reason="auto_pause")
+ assert manager.is_auto_paused
+ manager.reinitialize_after_resume(auto_resume=True)
+ assert resuming.wait(1)
+ assert initialized == []
+ if superseded:
+ manager.unload_model(reason="auto_pause")
+ release_download.set()
+ settings_worker.join(2)
+ assert resumed.wait(2)
+ finally:
+ release_download.set()
+ settings_worker.join(2)
+ for worker in resume_workers:
+ worker.join(2)
+ assert not worker.is_alive()
+ assert not settings_worker.is_alive()
+ assert [str(error) for error in errors] == (["Download cancelled"] if cancelled else [])
+ assert manager.is_auto_paused is superseded
+ if superseded:
+ assert initialized == []
+ assert manager.model is None
+ else:
+ assert initialized == [
+ (previous_engine, previous_size) if cancelled else ("whisper_cpp", "medium")
+ ]
+ assert manager.model is selected_model
+ assert manager.model_ready
+
+
+@pytest.mark.parametrize("prefetch", [False, True])
+def test_cancelled_settings_and_wake_cannot_mix_engine_and_model(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, prefetch: bool
+) -> None:
+ """A wake cannot load one engine while Settings rolls back to another."""
+ settings_waiting = threading.Event()
+ release_settings = threading.Event()
+ initializing = threading.Event()
+ release_initializer = threading.Event()
+ settings_finished = threading.Event()
+ wake_workers: list[threading.Thread] = []
+ errors: list[Exception] = []
+ real_lock = threading.Lock()
+ real_lock.acquire()
+ gate_settings = True
+ settings_worker: threading.Thread
+ manager.model = (manager.engine, manager.model_size)
+ resume_after_reload = manager._resume_after_reload
+
+ class ModelLock:
+ def acquire(self, blocking: bool = True, timeout: float = -1) -> bool:
+ nonlocal gate_settings
+ if threading.current_thread() is settings_worker and gate_settings:
+ gate_settings = False
+ settings_waiting.set()
+ assert release_settings.wait(3)
+ if timeout < 0:
+ return real_lock.acquire(blocking=blocking)
+ return real_lock.acquire(blocking=blocking, timeout=timeout)
+
+ def release(self) -> None:
+ real_lock.release()
+
+ def initialize() -> None:
+ loaded_for = (manager.engine, manager.model_size)
+ initializing.set()
+ assert release_initializer.wait(3)
+ manager.model = loaded_for
+ manager._model_initialized = True
+
+ def reconfigure() -> None:
+ try:
+ manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=prefetch)
+ except Exception as error:
+ errors.append(error)
+ finally:
+ settings_finished.set()
+
+ def wake(worker: threading.Thread | None, request: object) -> None:
+ wake_workers.append(threading.current_thread())
+ resume_after_reload(worker, request)
+
+ monkeypatch.setattr(manager, "_model_lock", ModelLock())
+ monkeypatch.setattr(manager, "_init_selected_engine", initialize)
+ monkeypatch.setattr(manager, "_init_whispercpp", initialize)
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ monkeypatch.setattr(manager, "_resume_after_reload", wake)
+ assert manager.try_begin_download()
+ settings_worker = threading.Thread(target=reconfigure)
+ settings_worker.start()
+ try:
+ assert settings_waiting.wait(1)
+ manager.reinitialize_after_resume(auto_resume=True)
+ manager.cancel_download()
+ real_lock.release()
+ # If wake owns a separate initializer, let it enter before rollback.
+ # A correctly serialized wake stays pending until Settings settles.
+ initializing.wait(0.2)
+ release_settings.set()
+ settings_finished.wait(0.3)
+ finally:
+ release_settings.set()
+ release_initializer.set()
+ settings_worker.join(2)
+ for worker in wake_workers:
+ worker.join(2)
+ assert not worker.is_alive()
+ manager.end_download()
+ assert not settings_worker.is_alive()
+ assert [str(error) for error in errors] == ["Download cancelled"]
+ assert manager.model == (manager.engine, manager.model_size)
+ assert manager.model_ready
+
+
+@pytest.mark.parametrize("prefetch", [False, True])
+def test_settings_model_handoff_blocks_new_dictation_until_complete(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, prefetch: bool
+) -> None:
+ """A new engine name cannot dispatch audio through the previous model."""
+ waiting = threading.Event()
+ errors: list[Exception] = []
+ old_model = manager.model
+ old_engine = manager.engine
+ selected_model = object()
+ original_wait = manager._wait_for_model_settings_handoff
+
+ def wait_for_handoff() -> None:
+ waiting.set()
+ original_wait()
+
+ def initialize() -> None:
+ manager.model = selected_model
+ manager._model_initialized = True
+
+ def apply() -> None:
+ try:
+ manager.reconfigure(engine="whisper_cpp", model_size="medium", force_download=prefetch)
+ except Exception as error:
+ errors.append(error)
+
+ monkeypatch.setattr(manager, "_wait_for_model_settings_handoff", wait_for_handoff)
+ monkeypatch.setattr(manager, "_init_selected_engine", initialize)
+ monkeypatch.setattr(manager, "_record_audio", lambda: None)
+ monkeypatch.setattr(manager, "_perform_recognition", lambda: None)
+ manager._model_lock.acquire()
+ settings = threading.Thread(target=apply)
+ settings.start()
+ try:
+ assert waiting.wait(1)
+ assert manager.engine == old_engine
+ assert manager.model is old_model
+ assert manager.state is RecognitionState.IDLE
+ assert not manager.start_recognition()
+ rm._show_notification.assert_called_with(
+ "Speech settings are changing",
+ "Dictation will be available when the settings have finished applying.",
+ "dialog-information",
+ )
+ finally:
+ manager._model_lock.release()
+ settings.join(2)
+ assert not settings.is_alive()
+ assert errors == []
+ assert manager.engine == "whisper_cpp"
+ assert manager.model is selected_model
+ assert manager.start_recognition()
+ manager.stop_recognition()
+
+
+@pytest.mark.parametrize("settings_during_reload", [False, True])
+def test_settings_preserves_active_game_unload_obligation(
+ manager: rm.SpeechRecognitionManager,
+ auto_pause_flow: _AutoPauseFlow,
+ settings_during_reload: bool,
+) -> None:
+ """Hot Settings leave the original game unload and buffered worker intact."""
+ flow = auto_pause_flow
+ flow.poll(True)
+ assert flow.unload_started.wait(1)
+ if not settings_during_reload:
+ flow.finish_reload()
+ request = manager._model_request
+ worker = manager.recognition_thread
+ manager.reconfigure(silence_timeout=1.25)
+ assert manager.silence_timeout == 1.25
+ assert manager._model_request is request
+ assert manager.recognition_thread is worker
+ assert len(flow.unload_threads) == 1
+ if settings_during_reload:
+ flow.finish_reload()
+ flow.release_unload.set()
+ flow.join(flow.unload_threads)
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert flow.monitor.paused
+ flow.backend.cleanup.assert_called_once_with()
+ flow.http_session.close.assert_called_once_with()
+ assert flow.cleanup_during_reload == [False]
+
+
+@pytest.mark.parametrize("new_pause", [False, True])
+def test_fast_settings_defers_inherited_resume_without_blocking_caller(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, new_pause: bool
+) -> None:
+ """The GTK voice-command toggle stays responsive behind a native initializer."""
+ manager._auto_paused = True
+ manager._auto_resume_pending = True
+ manager.model = None
+ manager._model_initialized = False
+ returned = threading.Event()
+ recovery_started = threading.Event()
+ workers: list[threading.Thread] = []
+ errors: list[Exception] = []
+ initialized = Mock()
+ resumed_model = object()
+ original_resume = manager._resume_after_reload
+ original_unload = manager._unload_after_reload
+
+ def initialize() -> None:
+ initialized()
+ manager.model = resumed_model
+ manager._model_initialized = True
+
+ def resume(worker: threading.Thread | None, request: object) -> None:
+ workers.append(threading.current_thread())
+ recovery_started.set()
+ original_resume(worker, request)
+
+ def unload(worker: threading.Thread | None, request: object) -> None:
+ workers.append(threading.current_thread())
+ original_unload(worker, request)
+
+ def toggle() -> None:
+ try:
+ manager.reconfigure(voice_commands_enabled=False, force_download=False)
+ except Exception as error:
+ errors.append(error)
+ finally:
+ returned.set()
+
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ monkeypatch.setattr(manager, "_resume_after_reload", resume)
+ monkeypatch.setattr(manager, "_unload_after_reload", unload)
+ manager._model_lock.acquire()
+ manager.reinitialize_after_resume(auto_resume=True)
+ assert recovery_started.wait(1)
+ request = manager._model_request
+ caller = threading.Thread(target=toggle)
+ caller.start()
+ try:
+ assert returned.wait(0.5)
+ assert manager._model_request is request
+ assert len(workers) == 1
+ assert manager._voice_commands_preference is False
+ assert manager.is_auto_paused
+ if new_pause:
+ manager.unload_model(reason="auto_pause")
+ finally:
+ manager._model_lock.release()
+ caller.join(2)
+ for worker in workers:
+ worker.join(2)
+ assert not worker.is_alive()
+ assert not caller.is_alive()
+ assert errors == []
+ if new_pause:
+ initialized.assert_not_called()
+ assert manager.model is None
+ assert manager.is_auto_paused
+ else:
+ initialized.assert_called_once_with()
+ assert manager.model is resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
+
+
+@pytest.mark.parametrize("failure", ["prefetch", "cancel", "initialize"])
+def test_rejected_settings_preserves_active_pause_after_rollback(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch, failure: str
+) -> None:
+ """Every rejected Settings path retains the configured game's unload request."""
+ manager._auto_paused = True
+ old_model = manager.model
+ unload_started = threading.Event()
+ workers: list[threading.Thread] = []
+ original_unload = manager._unload_after_reload
+
+ def unload(worker: threading.Thread | None, request: object) -> None:
+ workers.append(threading.current_thread())
+ unload_started.set()
+ original_unload(worker, request)
+
+ def initialize() -> None:
+ if manager.engine == "whisper_cpp":
+ raise RuntimeError("initialization rejected")
+ manager.model = old_model
+ manager._model_initialized = True
+
+ monkeypatch.setattr(manager, "_unload_after_reload", unload)
+ monkeypatch.setattr(manager, "_init_selected_engine", initialize)
+ if failure == "prefetch":
+ monkeypatch.setattr(
+ manager,
+ "_download_selected_model_if_missing",
+ Mock(side_effect=RuntimeError("prefetch rejected")),
+ )
+ elif failure == "cancel":
+ assert manager.try_begin_download()
+ manager.cancel_download()
+ try:
+ with pytest.raises(RuntimeError):
+ manager.reconfigure(
+ engine="whisper_cpp", model_size="medium", force_download=failure == "prefetch"
+ )
+ assert unload_started.wait(1)
+ finally:
+ for worker in workers:
+ worker.join(2)
+ assert not worker.is_alive()
+ if failure == "cancel":
+ manager.end_download()
+ assert manager.engine == "vosk"
+ assert manager.model is None
+ assert manager.is_auto_paused
+ assert not manager._auto_resume_pending
+
+
+def test_fast_settings_defers_even_uncontended_slow_resume(
+ manager: rm.SpeechRecognitionManager, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A free lock must not move a slow native reload onto the GTK caller."""
+ manager._auto_paused = True
+ manager._auto_resume_pending = True
+ manager.model = None
+ manager._model_initialized = False
+ loading = threading.Event()
+ release = threading.Event()
+ returned = threading.Event()
+ errors: list[Exception] = []
+ resumed_model = object()
+
+ def initialize() -> None:
+ loading.set()
+ assert release.wait(3)
+ manager.model = resumed_model
+ manager._model_initialized = True
+
+ def toggle() -> None:
+ try:
+ manager.reconfigure(voice_commands_enabled=False, force_download=False)
+ except Exception as error:
+ errors.append(error)
+ finally:
+ returned.set()
+
+ monkeypatch.setattr(manager, "_init_vosk", initialize)
+ resuming = threading.Thread(
+ target=manager.reinitialize_after_resume, kwargs={"auto_resume": True}
+ )
+ caller = threading.Thread(target=toggle)
+ resuming.start()
+ try:
+ assert loading.wait(1)
+ request = manager._model_request
+ caller.start()
+ assert returned.wait(0.5)
+ assert not release.is_set()
+ assert manager.is_auto_paused
+ assert manager._model_request is request
+ finally:
+ release.set()
+ resuming.join(2)
+ if caller.ident is not None:
+ caller.join(2)
+ assert not resuming.is_alive()
+ assert not caller.is_alive()
+ assert errors == []
+ assert manager.model is resumed_model
+ assert manager.model_ready
+ assert not manager.is_auto_paused
diff --git a/tests/test_language_shortcuts.py b/tests/test_language_shortcuts.py
index 6d544cb..e53edd1 100644
--- a/tests/test_language_shortcuts.py
+++ b/tests/test_language_shortcuts.py
@@ -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
diff --git a/tests/test_model_missing_offer.py b/tests/test_model_missing_offer.py
index 6dfa258..f64e3a3 100644
--- a/tests/test_model_missing_offer.py
+++ b/tests/test_model_missing_offer.py
@@ -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."""
diff --git a/tests/test_pipewire_capture.py b/tests/test_pipewire_capture.py
index 6eada40..b9e0308 100644
--- a/tests/test_pipewire_capture.py
+++ b/tests/test_pipewire_capture.py
@@ -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
diff --git a/tests/test_playback_ducker.py b/tests/test_playback_ducker.py
index d24410c..813c78d 100644
--- a/tests/test_playback_ducker.py
+++ b/tests/test_playback_ducker.py
@@ -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()
diff --git a/tests/test_pulse_sources.py b/tests/test_pulse_sources.py
index 18cb899..c8c0a5c 100644
--- a/tests/test_pulse_sources.py
+++ b/tests/test_pulse_sources.py
@@ -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] = {}
diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py
index 2f53333..4422dd7 100644
--- a/tests/test_recognition_manager.py
+++ b/tests/test_recognition_manager.py
@@ -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()
diff --git a/tests/test_recognition_manager_advanced.py b/tests/test_recognition_manager_advanced.py
index 56a2009..33745b1 100644
--- a/tests/test_recognition_manager_advanced.py
+++ b/tests/test_recognition_manager_advanced.py
@@ -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:
diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py
index 5a45b12..cb50e70 100644
--- a/tests/test_recognition_manager_core.py
+++ b/tests/test_recognition_manager_core.py
@@ -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)
diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py
index f5250a9..e2f8ae3 100644
--- a/tests/test_recognition_manager_device_config.py
+++ b/tests/test_recognition_manager_device_config.py
@@ -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()
diff --git a/tests/test_recognition_manager_download_buffer.py b/tests/test_recognition_manager_download_buffer.py
index ff5e8ca..2428508 100644
--- a/tests/test_recognition_manager_download_buffer.py
+++ b/tests/test_recognition_manager_download_buffer.py
@@ -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)
diff --git a/tests/test_recognition_manager_downloads.py b/tests/test_recognition_manager_downloads.py
index a75f51b..7524ed8 100644
--- a/tests/test_recognition_manager_downloads.py
+++ b/tests/test_recognition_manager_downloads.py
@@ -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
diff --git a/tests/test_recognition_manager_helpers.py b/tests/test_recognition_manager_helpers.py
index 7cf3b48..1e39ed5 100644
--- a/tests/test_recognition_manager_helpers.py
+++ b/tests/test_recognition_manager_helpers.py
@@ -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()
diff --git a/tests/test_recognition_manager_silero.py b/tests/test_recognition_manager_silero.py
index 1679232..c29a566 100644
--- a/tests/test_recognition_manager_silero.py
+++ b/tests/test_recognition_manager_silero.py
@@ -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 (
diff --git a/tests/test_reconfigure_concurrency.py b/tests/test_reconfigure_concurrency.py
new file mode 100644
index 0000000..741f624
--- /dev/null
+++ b/tests/test_reconfigure_concurrency.py
@@ -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
diff --git a/tests/test_reconfigure_model_handoff.py b/tests/test_reconfigure_model_handoff.py
new file mode 100644
index 0000000..374fbbe
--- /dev/null
+++ b/tests/test_reconfigure_model_handoff.py
@@ -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
diff --git a/tests/test_reconfigure_worker_handoff.py b/tests/test_reconfigure_worker_handoff.py
new file mode 100644
index 0000000..5092b3e
--- /dev/null
+++ b/tests/test_reconfigure_worker_handoff.py
@@ -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
diff --git a/tests/test_record_during_reload.py b/tests/test_record_during_reload.py
index 868bff4..07d421c 100644
--- a/tests/test_record_during_reload.py
+++ b/tests/test_record_during_reload.py
@@ -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()
diff --git a/tests/test_settings_model_state.py b/tests/test_settings_model_state.py
index 5b4dcd2..2a28628 100644
--- a/tests/test_settings_model_state.py
+++ b/tests/test_settings_model_state.py
@@ -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",
diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py
index 94f2aac..5ddc678 100644
--- a/tests/test_settings_test_dictation.py
+++ b/tests/test_settings_test_dictation.py
@@ -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
diff --git a/tests/test_speech_recognition.py b/tests/test_speech_recognition.py
index 74329c2..c93e211 100644
--- a/tests/test_speech_recognition.py
+++ b/tests/test_speech_recognition.py
@@ -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()
diff --git a/tests/test_start_stop_race.py b/tests/test_start_stop_race.py
index 6f135fa..406f312 100644
--- a/tests/test_start_stop_race.py
+++ b/tests/test_start_stop_race.py
@@ -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():
diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py
index f69fe13..eddca7b 100644
--- a/tests/test_suspend_handler.py
+++ b/tests/test_suspend_handler.py
@@ -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()
diff --git a/tests/test_system_default_capture.py b/tests/test_system_default_capture.py
new file mode 100644
index 0000000..06ee3a7
--- /dev/null
+++ b/tests/test_system_default_capture.py
@@ -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()