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()