From e66bd218d993921c47adcc60dee9653badcca51e Mon Sep 17 00:00:00 2001 From: Michael <37483994+neuroborus@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:25:50 +0100 Subject: [PATCH] fix(audio): prevent dictation hangs on stalled microphones (#974) * fix(audio): bound capture reads and support cancellation * fix(recognition): bound and serialize capture shutdown * fix(recognition): cancel retries and report stalled microphones * fix(audio): prefer pulse when the ALSA default is unavailable * fix(recognition): guard late callbacks and finalize capture tests * docs(audio): explain input selection and stall recovery * fix(speech): enforce auto-pause across buffered reload timeouts * fix(speech): recover auto-resume after reload timeout * test(speech): fix auto-pause follow-up test regressions * fix(speech): retain auto-resume after reconfigure timeout * fix(tray): preserve auto-pause recovery across suspend * test(capture-shutdown): give the bare tray the suspend path's shortcut managers _on_system_suspend now releases the main and per-language hotkey listeners before sleep (#1025); the fixture builds TrayIndicator without __init__, so it needs the two attributes present. * fix(tray): release hotkey listeners on suspend even while auto-paused Merging #1025's suspend cleanup with this branch's auto-pause handoff left an early return that skipped shortcut_manager.stop() whenever a configured game was active, keeping EVIOCGRABs held through sleep. The paused branch now only owns the model-lifecycle work; listener release and stopping active recognition run for every suspend. * fix(tray): hold the apply lock only around the download's verify and save Wrapping the whole recommended-model download in _apply_settings_lock blocked every GTK-thread settings apply (e.g. Test Dictation) for the length of the transfer. The engine already serializes reconfigures, so the lock only needs to cover verifying the download still owns the receipt and saving it; an apply that lands mid-download now proceeds instead of freezing the app. --------- Co-authored-by: Jatin K Malik --- docs/TROUBLESHOOTING.md | 11 + docs/USER_GUIDE.md | 14 + src/vocalinux/audio/capture.py | 201 +- src/vocalinux/audio/errors.py | 5 + src/vocalinux/audio/pipewire.py | 53 +- src/vocalinux/common_types.py | 14 +- .../speech_recognition/recognition_manager.py | 1839 ++++++++++----- src/vocalinux/ui/settings_dialog.py | 57 +- src/vocalinux/ui/tray_indicator.py | 139 +- tests/test_auto_pause_monitor.py | 11 +- tests/test_cancel_transcription.py | 266 ++- tests/test_capture_cancellation.py | 362 +++ tests/test_capture_recovery.py | 387 ++++ tests/test_capture_shutdown.py | 2021 +++++++++++++++++ tests/test_language_shortcuts.py | 6 + tests/test_model_missing_offer.py | 268 +++ tests/test_pipewire_capture.py | 2 +- tests/test_playback_ducker.py | 1 + tests/test_pulse_sources.py | 1 + tests/test_recognition_manager.py | 5 + tests/test_recognition_manager_advanced.py | 13 +- tests/test_recognition_manager_core.py | 5 + .../test_recognition_manager_device_config.py | 7 + ...est_recognition_manager_download_buffer.py | 112 + tests/test_recognition_manager_downloads.py | 11 +- tests/test_recognition_manager_helpers.py | 2 + tests/test_recognition_manager_silero.py | 19 +- tests/test_reconfigure_concurrency.py | 559 +++++ tests/test_reconfigure_model_handoff.py | 292 +++ tests/test_reconfigure_worker_handoff.py | 190 ++ tests/test_record_during_reload.py | 4 +- tests/test_settings_model_state.py | 2 +- tests/test_settings_test_dictation.py | 63 + tests/test_speech_recognition.py | 4 + tests/test_start_stop_race.py | 12 +- tests/test_suspend_handler.py | 122 +- tests/test_system_default_capture.py | 223 ++ 37 files changed, 6636 insertions(+), 667 deletions(-) create mode 100644 src/vocalinux/audio/errors.py create mode 100644 tests/test_capture_cancellation.py create mode 100644 tests/test_capture_recovery.py create mode 100644 tests/test_capture_shutdown.py create mode 100644 tests/test_reconfigure_concurrency.py create mode 100644 tests/test_reconfigure_model_handoff.py create mode 100644 tests/test_reconfigure_worker_handoff.py create mode 100644 tests/test_system_default_capture.py 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()