From 138a233892d67a3f74ffdb133286e32849cbed2e Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Wed, 21 Oct 2026 01:33:58 -0700 Subject: [PATCH] feat(recognition): cancel an in-progress transcription with the recording hotkey (#1004) * feat(recognition): cancel in-progress transcription with hotkey Adds per-session cancellation across the manager, tray, and Test Dictation. Fixes #991 * fix(settings): keep Test Dictation cancellation responsive Fixes #991 * fix(recognition): keep late workers cancellable (#991) * test(tray): avoid early tray import in cancellation tests * fix(recognition): keep a cancelled capture thread out of the next session A cancel returns to IDLE without joining the capture thread, so a thread still in its reconnection backoff could wake inside a newer recording, reopen that session's device and write into its buffer. Tie the capture loop and the reconnection to the session's cancel token: a cancelled or replaced session stops reading, skips the reopen and exits without raising an error. --- src/vocalinux/common_types.py | 8 + .../speech_recognition/recognition_manager.py | 350 +++++++-- src/vocalinux/ui/settings_dialog.py | 121 +++- src/vocalinux/ui/tray_indicator.py | 65 +- tests/test_auto_pause_monitor.py | 2 + tests/test_cancel_transcription.py | 685 ++++++++++++++++++ tests/test_common_types_enums_protocols.py | 4 +- tests/test_language_shortcuts.py | 1 + tests/test_playback_ducker.py | 2 + tests/test_recognition_manager.py | 1 + tests/test_recognition_manager_core.py | 2 + tests/test_settings_dialog.py | 15 +- tests/test_settings_test_dictation.py | 141 +++- tests/test_suspend_handler.py | 70 ++ tests/test_tray_indicator.py | 125 +++- 15 files changed, 1485 insertions(+), 107 deletions(-) create mode 100644 tests/test_cancel_transcription.py diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index 777abec..35f7eb4 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -94,6 +94,14 @@ class SpeechRecognitionManagerProtocol(Protocol): """Start recognition in ``language`` for this utterance only (#805).""" ... + def is_transcribing(self) -> bool: + """Whether a stopped dictation session is still being transcribed.""" + ... + + def cancel_transcription(self) -> bool: + """Cancel the active dictation session and discard its result.""" + ... + def stop_recognition(self) -> None: """Stop the speech recognition process.""" ... diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 9f13124..794562e 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -121,6 +121,7 @@ class _AudioSegment(list): chunks: list[bytes], started_at: Optional[float], language: Optional[str] = None, + cancel_event: Optional[threading.Event] = None, release_gate: Optional[_ReleaseGate] = None, ) -> None: super().__init__(chunks) @@ -129,6 +130,7 @@ class _AudioSegment(list): # outlives its session must not transcribe a stale segment in a newer # session's language. self.language = language + self.cancel_event = cancel_event self.release_gate = release_gate @@ -239,6 +241,20 @@ def resolve_language_preference(language: str) -> str: logger = logging.getLogger(__name__) +_WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {} + + +def _supports_whispercpp_abort_callback(model_class: type) -> bool: + """Return whether a whisper.cpp model class accepts abort_callback.""" + if model_class not in _WHISPERCPP_ABORT_CALLBACK_SUPPORT: + try: + _WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class] = ( + "abort_callback" in inspect.signature(getattr(model_class, "transcribe")).parameters + ) + except (TypeError, ValueError): + _WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class] = False + return _WHISPERCPP_ABORT_CALLBACK_SUPPORT[model_class] + def _filter_non_speech(text: str) -> str: """ @@ -414,6 +430,10 @@ class SpeechRecognitionManager: speech recognition engines (VOSK and Whisper). """ + _STOP_WORKER_JOIN_TIMEOUT_S: float = 5.0 + _STOP_WORKER_FINAL_JOIN_TIMEOUT_S: float = 1.0 + _idle_on_worker_exit: Optional[threading.Event] = None + def __init__( self, engine: str = "vosk", @@ -454,6 +474,8 @@ class SpeechRecognitionManager: self.state = RecognitionState.IDLE self.audio_thread: Optional[threading.Thread] = None self.recognition_thread: Optional[threading.Thread] = None + self._worker_exited: Optional[threading.Event] = None + self._idle_on_worker_exit: Optional[threading.Event] = None self._recognition_workers: list[threading.Thread] = [] self._recognition_workers_lock = threading.Lock() self.model: Any = None @@ -551,6 +573,7 @@ class SpeechRecognitionManager: # Recording control flags self.should_record = False + self._session_cancel = threading.Event() self._recognition_mode = "toggle" # "toggle" or "push_to_talk" self.audio_buffer: list[bytes] = [] # Monotonic time the current buffer's first chunk was captured, @@ -1940,6 +1963,11 @@ class SpeechRecognitionManager: """ import time + cancel_event = getattr(audio_buffer, "cancel_event", None) + if cancel_event is not None and cancel_event.is_set(): + logger.debug("Skipping whisper.cpp transcription for a cancelled session") + return "" + try: import numpy as np @@ -1972,6 +2000,10 @@ class SpeechRecognitionManager: logger.warning("Model is None during transcription, returning empty result") return "" + if cancel_event is not None and cancel_event.is_set(): + logger.debug("Skipping whisper.cpp language detection for a cancelled session") + return "" + if lang is None and self.whispercpp_language_candidates: detected, lang_probs = self.model.auto_detect_language( audio_float, @@ -2021,10 +2053,14 @@ class SpeechRecognitionManager: # Transcribe with whisper.cpp # pywhispercpp expects audio as numpy array transcribe_start = time.time() - transcribe_kwargs = {"language": lang} + transcribe_kwargs: dict[str, Any] = {"language": lang} # pywhispercpp reuses native parameter state. Passing an empty # value explicitly clears a prompt that was active last segment. transcribe_kwargs["initial_prompt"] = self._get_whispercpp_prompt() or "" + if cancel_event is not None and _supports_whispercpp_abort_callback( + type(self.model) + ): + transcribe_kwargs["abort_callback"] = cancel_event.is_set segments = self.model.transcribe(audio_float, **transcribe_kwargs) transcribe_duration = time.time() - transcribe_start @@ -3339,6 +3375,10 @@ class SpeechRecognitionManager: self._session_language = self.language logger.info("Starting speech recognition") + with self._buffer_lock: + self._session_cancel = threading.Event() + self._worker_exited = threading.Event() + self._idle_on_worker_exit = None self._update_state(RecognitionState.LISTENING) # Play the start sound @@ -3367,13 +3407,60 @@ class SpeechRecognitionManager: self._arm_playback_duck() return True - def stop_recognition(self): + def is_transcribing(self) -> bool: + """True while a stopped session's audio is still being transcribed.""" + return self.state == RecognitionState.PROCESSING and not self.should_record + + def cancel_transcription(self) -> bool: + """Abort the current dictation session and discard its text. + + 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: + if ( + self._session_cancel is not cancel + or self.state not in (RecognitionState.LISTENING, RecognitionState.PROCESSING) + or cancel.is_set() + ): + return 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() + + while True: + try: + segment_queue.get_nowait() + except queue.Empty: + break + + self._signal_recognition_stop() + + if self.should_record: + self.stop_recognition() + elif not buffered: + self._recognition_mode = "toggle" + self._update_state(RecognitionState.IDLE) + return True + + def stop_recognition(self) -> None: """Stop the speech recognition process.""" if self.state == RecognitionState.IDLE: return 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) logger.info("Stopping speech recognition") # Mark recording over before touching the duck so a concurrent arm @@ -3387,8 +3474,8 @@ class SpeechRecognitionManager: # 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 self.audio_thread and self.audio_thread.is_alive(): - self.audio_thread.join(timeout=2.0) + if audio_thread and audio_thread.is_alive(): + audio_thread.join(timeout=2.0) self.release_playback_duck() @@ -3396,34 +3483,53 @@ class SpeechRecognitionManager: # Kept before buffer processing so the cue still feels immediate. play_stop_sound() - # Trim only a small tail to avoid the stop sound without clipping the user's final word. - with self._buffer_lock: - 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._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.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 getattr(self, "_buffered_reload_session", False): # Keep the session busy until its queued audio has been transcribed. @@ -3434,16 +3540,38 @@ class SpeechRecognitionManager: return # Wake up recognition thread so it can drain queued segments and stop - self._signal_recognition_stop() + if not session_cancel.is_set(): + self._signal_recognition_stop() - if self.recognition_thread and self.recognition_thread.is_alive(): - self.recognition_thread.join(timeout=5.0) # Increased timeout for transcription - 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 self.recognition_thread and self.recognition_thread.is_alive(): - self.recognition_thread.join(timeout=1.0) + if recognition_thread and recognition_thread.is_alive(): + recognition_thread.join(timeout=self._STOP_WORKER_FINAL_JOIN_TIMEOUT_S) - self._recognition_mode = "toggle" + with self._buffer_lock: + if self._session_cancel is not session_cancel: + return + worker_still_running = ( + recognition_thread is not None + 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 self._update_state(RecognitionState.IDLE) def _track_recognition_worker(self, worker: threading.Thread) -> None: @@ -3580,6 +3708,7 @@ class SpeechRecognitionManager: def _record_audio(self) -> None: """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 @@ -3641,7 +3770,12 @@ class SpeechRecognitionManager: logger.error("This may indicate a problem with the audio device or permissions.") # Attempt reconnection - if not self._attempt_audio_reconnection(audio): + 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() @@ -3676,10 +3810,13 @@ class SpeechRecognitionManager: if self._silero_vad is not None: self._silero_vad.reset() - while self.should_record: + while self._capture_session_current(session_cancel): try: # Check buffer size and enforce limits (with lock for thread safety) 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: + break if len(self.audio_buffer) >= self._max_buffer_size: if getattr(self, "_reload_pending", False): raise RuntimeError("Audio buffer filled while reloading the model") @@ -3692,6 +3829,8 @@ class SpeechRecognitionManager: logger.info(f"Buffer trimmed by {remove_count} chunks") data = source.read_chunk() + 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 @@ -3838,6 +3977,9 @@ class SpeechRecognitionManager: ), ) except (IOError, OSError) as e: + if not self._capture_session_current(session_cancel): + logger.info("Audio capture stopped: its session was cancelled or replaced") + break current_time = time.time() logger.error(f"Audio device error: {e}") @@ -3847,13 +3989,18 @@ class SpeechRecognitionManager: ): # Prevent rapid reconnection attempts self._last_audio_error_time = current_time - if self._attempt_audio_reconnection(audio): + if self._attempt_audio_reconnection(audio, session_cancel): 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 continue # Continue recording with the reopened source + elif not self._capture_session_current(session_cancel): + logger.info( + "Audio capture stopped: its session was cancelled or replaced" + ) + break else: logger.error("Audio reconnection failed, stopping recording") capture_failed = True @@ -3976,12 +4123,26 @@ class SpeechRecognitionManager: self, gate: Optional[_ReleaseGate], force: bool = False, + cancel: Optional[threading.Event] = None, ) -> None: - """Deliver queued transcripts once their push-to-talk gate opens.""" - if gate is None or (not gate.is_set() and not force): + """Deliver queued transcripts once their push-to-talk gate opens. + + Held text from a cancelled session is dropped instead of delivered. + """ + if gate is None: + return + if cancel is not None and cancel.is_set(): + if gate.held: + logger.info("Discarding held push-to-talk text from a cancelled session") + gate.held.clear() + return + if not gate.is_set() and not force: return while gate.held: + if cancel is not None and cancel.is_set(): + gate.held.clear() + return transcript = gate.held.pop(0) self._deliver_transcript( transcript.text, @@ -3998,6 +4159,10 @@ class SpeechRecognitionManager: it was queued (#805); None falls back to the segment's own stamp and then to the live session language. """ + cancel_event = getattr(audio_buffer, "cancel_event", None) + 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: return if language is None: @@ -4023,10 +4188,13 @@ class SpeechRecognitionManager: logger.error(f"Unknown engine: {self.engine}") return text = transcriber() + if cancel_event is not None and cancel_event.is_set(): + logger.info("Discarding transcription from a cancelled session") + return if text is None: gate = getattr(audio_buffer, "release_gate", None) if gate is not None and gate.is_set(): - self._release_held_transcripts(gate) + self._release_held_transcripts(gate, cancel=cancel_event) return # Process text - either with voice commands or pass through directly @@ -4045,6 +4213,10 @@ class SpeechRecognitionManager: processed_text = text.strip() actions = [] + if cancel_event is not None and cancel_event.is_set(): + logger.info("Discarding transcription from a cancelled session") + return + gate = getattr(audio_buffer, "release_gate", None) if gate is not None and not gate.is_set(): if processed_text or actions: @@ -4058,7 +4230,7 @@ class SpeechRecognitionManager: logger.info("Holding transcribed text until the push-to-talk key is released") return elif gate is not None: - self._release_held_transcripts(gate) + self._release_held_transcripts(gate, cancel=cancel_event) self._deliver_transcript( processed_text, @@ -4070,23 +4242,37 @@ class SpeechRecognitionManager: """Perform speech recognition in real-time.""" gate = self._key_release_gate logger.debug("_perform_recognition thread started") + cancel = self._session_cancel + segment_queue = self._segment_queue + worker_exited = getattr(self, "_worker_exited", None) try: while True: + if cancel.is_set(): + logger.debug("Recognition loop exiting for a cancelled session") + break logger.debug( - f"Recognition loop - should_record={self.should_record}, queue_empty={self._segment_queue.empty()}" + f"Recognition loop - should_record={self.should_record}, queue_empty={segment_queue.empty()}" ) try: - queued = self._segment_queue.get(timeout=0.1) + queued = segment_queue.get(timeout=0.1) + if cancel.is_set(): + logger.debug("Recognition loop discarded an item for a cancelled session") + break except queue.Empty: - self._release_held_transcripts(gate) + self._release_held_transcripts(gate, cancel=cancel) # Only exit if we're not recording AND queue is empty - if not self.should_record and self._segment_queue.empty(): + if not self.should_record and segment_queue.empty(): logger.debug( "Recognition loop - not recording and queue empty, checking for final items..." ) # Give a brief moment for any final items to be enqueued try: - queued = self._segment_queue.get(timeout=0.5) + queued = segment_queue.get(timeout=0.5) + if cancel.is_set(): + logger.debug( + "Recognition loop discarded an item for a cancelled session" + ) + break except queue.Empty: logger.debug("Recognition loop - no more items, exiting") break @@ -4097,9 +4283,17 @@ class SpeechRecognitionManager: if queued is None: logger.debug("Recognition loop - got None signal, draining remaining items...") # Drain any remaining items before exiting - while not self._segment_queue.empty(): + while not segment_queue.empty(): + if cancel.is_set(): + logger.debug("Recognition loop drain stopped for a cancelled session") + break try: - remaining = self._segment_queue.get_nowait() + remaining = segment_queue.get_nowait() + if cancel.is_set(): + logger.debug( + "Recognition loop discarded a drained item for a cancelled session" + ) + break if remaining is not None: remaining_segment, remaining_language = remaining logger.debug( @@ -4107,7 +4301,7 @@ class SpeechRecognitionManager: ) if self.should_record: self._update_state(RecognitionState.PROCESSING) - self._release_held_transcripts(gate) + self._release_held_transcripts(gate, cancel=cancel) self._process_audio_buffer(remaining_segment, remaining_language) except queue.Empty: break @@ -4118,18 +4312,28 @@ class SpeechRecognitionManager: logger.debug(f"Recognition loop - processing segment with {len(segment)} chunks") if self.should_record: self._update_state(RecognitionState.PROCESSING) - self._release_held_transcripts(gate) + self._release_held_transcripts(gate, cancel=cancel) self._process_audio_buffer(segment, segment_language) - if self.should_record: + if self.should_record and self._session_cancel is cancel: self._update_state(RecognitionState.LISTENING) finally: logger.debug("_perform_recognition thread exiting") - self._release_held_transcripts(gate, force=True) - # Do not force IDLE here. stop_recognition() already does that, - # and a leftover worker can finish after the user has started - # again (LISTENING, should_record still false during the start - # sound). Forcing IDLE would paint the tray idle and make the - # next Alt+F12 start a second session (#739). + self._release_held_transcripts(gate, force=True, cancel=cancel) + hand_off = False + if worker_exited is not None: + with self._buffer_lock: + 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" + self._update_state(RecognitionState.IDLE) def _enqueue_audio_segment( self, @@ -4140,6 +4344,7 @@ class SpeechRecognitionManager: segment = _AudioSegment( audio_buffer.copy(), self._segment_started_at, + cancel_event=self._session_cancel, release_gate=release_gate, ) if not segment: @@ -4467,12 +4672,29 @@ class SpeechRecognitionManager: if source.audio is not None: self._pyaudio_instance = source.audio - def _attempt_audio_reconnection(self, audio_instance) -> bool: + def _capture_session_current(self, session_cancel: threading.Event) -> bool: + """Whether a capture thread's session is still recording and not cancelled. + + A cancel returns to IDLE without joining the capture thread, so a + thread still sleeping in reconnection must not touch a newer session. + """ + return ( + self.should_record + and self._session_cancel is session_cancel + and not session_cancel.is_set() + ) + + def _attempt_audio_reconnection( + self, audio_instance: Any, session_cancel: Optional[threading.Event] = None + ) -> bool: """ Attempt to reconnect to the audio device. Args: audio_instance: The PyAudio instance to use for reconnection + session_cancel: The capture thread's session token. When given, a + session cancelled or replaced during the backoff gives up + without reopening the device. Returns: bool: True if reconnection was successful, False otherwise @@ -4494,6 +4716,10 @@ class SpeechRecognitionManager: # Wait before attempting reconnection time.sleep(delay) + 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 + 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). diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index ff83c47..07fd7e1 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2523,6 +2523,8 @@ class SettingsDialog(AboutSectionMixin): None ) self._test_active = False + self._test_token = 0 + self._test_cancelled = False self._test_result = "" self._initializing = True # Flag to prevent auto-apply during initialization self._populating_models = False # Flag to prevent model change handler during population @@ -5925,12 +5927,22 @@ class SettingsDialog(AboutSectionMixin): self.recognition_audio_level = self.audio_level_bar footer.pack_start(self.audio_level_bar, False, False, 0) + test_button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + self.test_button = Gtk.Button(label="Test Dictation") self.test_button.set_tooltip_text( "Record 3 seconds of speech and show the transcription here" ) self.test_button.connect("clicked", self._on_test_clicked) - footer.pack_start(self.test_button, False, False, 0) + test_button_row.pack_start(self.test_button, False, False, 0) + + self.test_cancel_button = Gtk.Button(label="Cancel") + self.test_cancel_button.set_tooltip_text("Stop the test and discard its result") + self.test_cancel_button.connect("clicked", self._on_test_cancel_clicked) + self.test_cancel_button.set_no_show_all(True) + self.test_cancel_button.hide() + test_button_row.pack_start(self.test_cancel_button, False, False, 0) + footer.pack_start(test_button_row, False, False, 0) # One shared status line (audio test results and recognition info) self.progress_info_label = Gtk.Label(label="", use_markup=True, xalign=0) @@ -9166,7 +9178,7 @@ class SettingsDialog(AboutSectionMixin): return settings - def _on_test_clicked(self, widget): + def _on_test_clicked(self, widget: Any) -> None: """Handle click on the test button.""" if self._test_active: logger.warning("Test already in progress.") @@ -9249,9 +9261,13 @@ class SettingsDialog(AboutSectionMixin): return self._test_active = True + self._test_token += 1 + self._test_cancelled = False self._test_timeout_cancel_attempted = False self.test_button.set_sensitive(False) self.test_button.set_label("Testing… Speak Now!") + self.test_cancel_button.set_sensitive(True) + self.test_cancel_button.show() self.test_output_revealer.set_reveal_child(True) self.test_buffer.set_text("") self._test_result = "" @@ -9259,13 +9275,54 @@ class SettingsDialog(AboutSectionMixin): # Enough room for one utterance plus the configured silence window. delay = float(selected_settings.get("silence_timeout", 1.2)) + 2.0 - threading.Thread(target=self._stop_test_after_delay, args=(delay,)).start() + threading.Thread(target=self._stop_test_after_delay, args=(delay, self._test_token)).start() - def _test_text_callback(self, text: str): + def _on_test_cancel_clicked(self, widget: Any) -> None: + """Cancel the test and discard any text already captured.""" + if not self._test_active or self._test_cancelled: + return + + self._test_cancelled = True + self.test_button.set_sensitive(False) + self.test_cancel_button.set_sensitive(False) + self.test_button.set_label("Cancelling…") + + def cancel_test() -> None: + try: + self.speech_engine.cancel_transcription() + except (AttributeError, OSError, RuntimeError, TypeError): + logger.exception("Test Dictation cancellation failed") + GLib.idle_add(self._finish_test_after_user_cancel) + + threading.Thread( + target=cancel_test, + daemon=True, + name="vocalinux-test-dictation-cancel", + ).start() + + def _finish_test_after_user_cancel(self) -> bool: + """Restore test UI and callbacks without checking for captured speech.""" + self._test_active = False + self._test_timeout_cancel_attempted = False + self.test_button.set_sensitive(True) + self.test_button.set_label("Test Dictation") + self.test_cancel_button.hide() + self.test_cancel_button.set_sensitive(True) + self.update_recognition_progress("Idle") + if hasattr(self, "_saved_text_callbacks"): + self.speech_engine.set_text_callbacks(self._saved_text_callbacks) + del self._saved_text_callbacks + self.speech_engine.test_capture_ceiling = time.monotonic() + self.test_buffer.set_text("(Test cancelled)") + return False + + def _test_text_callback(self, text: str) -> None: """Callback specifically for the test recognition.""" GLib.idle_add(self._append_test_result, text) - def _append_test_result(self, text: str): + def _append_test_result(self, text: str) -> bool: + if getattr(self, "_test_cancelled", False) is True: + return False current_text = self.test_buffer.get_text( self.test_buffer.get_start_iter(), self.test_buffer.get_end_iter(), False ) @@ -9275,10 +9332,10 @@ class SettingsDialog(AboutSectionMixin): self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0) return False - def _stop_test_after_delay(self, delay: float): + def _stop_test_after_delay(self, delay: float, token: Optional[int] = None) -> None: """Stops the recognition test after a specified delay.""" time.sleep(delay) - GLib.idle_add(self._finalize_test) + GLib.idle_add(self._finalize_test, token) def _test_engine_busy(self) -> bool: """Return whether recognition can still deliver text to the test callback.""" @@ -9290,32 +9347,36 @@ class SettingsDialog(AboutSectionMixin): or (callable(has_pending_transcription) and has_pending_transcription() is True) ) - def _finalize_test(self) -> bool: + def _finalize_test(self, token: Optional[int] = None) -> bool: """Finalize the test state and UI updates.""" - if not self._test_active: + if (token is not None and token != getattr(self, "_test_token", 0)) or not getattr( + self, "_test_active", False + ): + return False + if getattr(self, "_test_cancelled", False): return False - self.speech_engine.stop_recognition() + self.test_button.set_sensitive(False) + self.test_button.set_label("Processing…") + self.test_cancel_button.set_sensitive(True) + self.test_cancel_button.show() + self.update_recognition_progress("Processing") - # Keep test callbacks installed until no worker can deliver the test - # utterance. Use GLib polling rather than joining on the GTK thread. - if self._test_engine_busy(): - self.test_button.set_sensitive(False) - self.test_button.set_label("Processing…") - self.update_recognition_progress("Processing") - self._test_idle_wait_ticks = 0 - self._test_timeout_cancel_attempted = False - GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks) - return False + def stop_test() -> None: + try: + self.speech_engine.stop_recognition() + except (AttributeError, OSError, RuntimeError, TypeError): + logger.exception("Test Dictation stop failed") - # Briefly settle for any GLib.idle_add text delivery already in flight. - GLib.timeout_add(500, self._restore_callbacks_and_check_result) + threading.Thread( + target=stop_test, + daemon=True, + name="vocalinux-test-dictation-stop", + ).start() - self._test_active = False - self.test_button.set_sensitive(True) - self.test_button.set_label("Test Dictation") - - self.update_recognition_progress("Idle") + self._test_idle_wait_ticks = 0 + self._test_timeout_cancel_attempted = False + GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks) return False @@ -9333,10 +9394,14 @@ class SettingsDialog(AboutSectionMixin): test callbacks and ``_test_active`` so live injectors are not restored early and later dictation cannot be hijacked into the test buffer. """ + if getattr(self, "_test_cancelled", False) is True: + return False + if not hasattr(self, "_saved_text_callbacks"): self._test_active = False self.test_button.set_sensitive(True) self.test_button.set_label("Test Dictation") + self.test_cancel_button.hide() return False if self._test_engine_busy(): @@ -9430,6 +9495,8 @@ class SettingsDialog(AboutSectionMixin): self._test_timeout_cancel_attempted = False self.test_button.set_sensitive(True) self.test_button.set_label("Test Dictation") + self.test_cancel_button.hide() + self.test_cancel_button.set_sensitive(True) self.update_recognition_progress("Idle") return self._restore_callbacks_and_check_result() diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index c805e1a..77366f7 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -134,6 +134,7 @@ class TrayIndicator: # Tests build TrayIndicator stubs with __new__ that skip __init__; keep a # class-level default so teardown paths like _quit() still find the list. _language_shortcut_managers: list = [] + _indicator_title: str = "Vocalinux" #: Same for the push-to-talk owner slot: a __new__-built stub must answer #: "no binding owns the live session" instead of raising on release paths. @@ -235,6 +236,7 @@ class TrayIndicator: # IconThemePath so gray→red→gray→red actually turns red again. self._last_icon_key: Optional[str] = None self._icon_theme_nudge = False + self._indicator_title = "Vocalinux" # Register for speech recognition state changes self.speech_engine.register_state_callback(self._on_recognition_state_changed) @@ -557,6 +559,7 @@ class TrayIndicator: self.indicator.set_icon_theme_path(ICON_DIR) self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) self.indicator.set_title("Vocalinux") + self._indicator_title = "Vocalinux" except Exception as e: logger.error(f"Failed to create AppIndicator: {e}") GLib.idle_add(self._show_appindicator_error_dialog, str(e)) @@ -576,6 +579,11 @@ class TrayIndicator: # Add menu items self._add_menu_item("Start Voice Typing", self._on_start_clicked) self._add_menu_item("Stop Voice Typing", self._on_stop_clicked) + self._cancel_menu_item = self._add_menu_item( + "Cancel Transcription", self._on_cancel_transcription_clicked + ) + self._cancel_menu_item.set_no_show_all(True) + self._cancel_menu_item.hide() self._add_menu_item("Transcribe Audio File\u2026", self._on_transcribe_file_clicked) self._add_menu_separator() @@ -706,6 +714,8 @@ class TrayIndicator: def _toggle_recognition(self) -> None: """Toggle the recognition state between IDLE and LISTENING.""" + if self._cancel_if_transcribing(): + return with self._ptt_lock: self._ptt_owner = None if self.speech_engine.state == RecognitionState.IDLE: @@ -715,11 +725,23 @@ class TrayIndicator: def _start_recognition(self) -> None: """Start voice recognition (for push-to-talk mode).""" + if self._cancel_if_transcribing(): + return self._push_to_talk_press( self.shortcut_manager, lambda: self.speech_engine.start_recognition(mode="push_to_talk"), ) + def _cancel_if_transcribing(self) -> bool: + """Cancel a running transcription; the recording hotkey doubles as cancel (#991).""" + if self.speech_engine.state != RecognitionState.PROCESSING: + return False + is_transcribing = getattr(self.speech_engine, "is_transcribing", None) + if not callable(is_transcribing) or is_transcribing() is not True: + return False + cancel = getattr(self.speech_engine, "cancel_transcription", None) + return callable(cancel) and cancel() is True + def _push_to_talk_press( self, manager: Optional[KeyboardShortcutManager], start: Callable[[], bool] ) -> None: @@ -760,6 +782,8 @@ class TrayIndicator: dictating it stops, matching the main toggle's behaviour regardless of which language the current utterance is in. """ + if self._cancel_if_transcribing(): + return with self._ptt_lock: self._ptt_owner = None if self.speech_engine.state == RecognitionState.IDLE: @@ -771,6 +795,8 @@ class TrayIndicator: self, language: str, manager: Optional[KeyboardShortcutManager] = None ) -> None: """Push-to-talk start for a per-language shortcut (#805).""" + if self._cancel_if_transcribing(): + return self._push_to_talk_press( manager, lambda: self.speech_engine.start_recognition_with_language( @@ -1158,7 +1184,13 @@ class TrayIndicator: self.indicator.set_icon_full(icon_key, description) self._last_icon_key = icon_key - def _update_ui(self, state: RecognitionState): + def _set_indicator_title(self, title: str) -> None: + if getattr(self, "_indicator_title", "Vocalinux") == title: + return + self.indicator.set_title(title) + self._indicator_title = title + + def _update_ui(self, state: RecognitionState) -> bool: """ Update the UI based on the recognition state. @@ -1181,19 +1213,36 @@ class TrayIndicator: if not hasattr(self, "indicator"): return False + is_transcribing = getattr(self.speech_engine, "is_transcribing", None) + transcribing = callable(is_transcribing) and is_transcribing() is True + show_cancel = state == RecognitionState.PROCESSING and transcribing + self._set_cancel_menu_item_visible(show_cancel) + if state == RecognitionState.IDLE: + self._set_indicator_title("Vocalinux") self._set_indicator_icon(self._icon_keys["default"], "Microphone off") self._set_menu_item_enabled("Start Voice Typing", True) self._set_menu_item_enabled("Stop Voice Typing", False) elif state == RecognitionState.LISTENING: + self._set_indicator_title("Vocalinux") self._set_indicator_icon(self._icon_keys["active"], "Microphone on") self._set_menu_item_enabled("Start Voice Typing", False) self._set_menu_item_enabled("Stop Voice Typing", True) elif state == RecognitionState.PROCESSING: - self._set_indicator_icon(self._icon_keys["processing"], "Processing speech") + if transcribing: + shortcut_manager = getattr(self, "shortcut_manager", None) + name = getattr(shortcut_manager, "shortcut_display_name", None) + if not isinstance(name, str) or not name: + name = "the recording shortcut" + description = f"Transcribing, press {name} again to cancel" + else: + description = "Processing speech" + self._set_indicator_title(description if transcribing else "Vocalinux") + self._set_indicator_icon(self._icon_keys["processing"], description) self._set_menu_item_enabled("Start Voice Typing", False) self._set_menu_item_enabled("Stop Voice Typing", True) elif state == RecognitionState.ERROR: + self._set_indicator_title("Vocalinux") self._set_indicator_icon(self._icon_keys["default"], "Error") self._set_menu_item_enabled("Start Voice Typing", True) self._set_menu_item_enabled("Stop Voice Typing", False) @@ -1236,6 +1285,13 @@ class TrayIndicator: item.set_sensitive(enabled) break + def _set_cancel_menu_item_visible(self, visible: bool) -> None: + item = getattr(self, "_cancel_menu_item", None) + if item is None or getattr(self, "menu", None) is None: + return + item.set_sensitive(visible) + item.set_visible(visible) + def _on_start_clicked(self, widget): """Handle click on the Start Voice Typing menu item.""" logger.debug("Start Voice Typing clicked") @@ -1246,6 +1302,11 @@ class TrayIndicator: logger.debug("Stop Voice Typing clicked") self.speech_engine.stop_recognition() + def _on_cancel_transcription_clicked(self, widget: Any) -> None: + """Cancel an in-progress transcription from the tray menu.""" + logger.debug("Cancel Transcription clicked") + self.speech_engine.cancel_transcription() + def _refresh_history_menu(self) -> bool: """Rebuild the Recent Snippets submenu from the current history.""" if self._history_menu_item is None or self.transcription_history is None: diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py index a1d329b..93f8788 100644 --- a/tests/test_auto_pause_monitor.py +++ b/tests/test_auto_pause_monitor.py @@ -458,6 +458,8 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase): mgr._model_lock = MagicMock() mgr._model_lock.__enter__ = MagicMock(return_value=None) mgr._model_lock.__exit__ = MagicMock(return_value=False) + mgr._buffer_lock = threading.Lock() + mgr._session_cancel = threading.Event() mgr._http_session = None mgr._faster_whisper_engine = None mgr._reconnection_attempts = 0 diff --git a/tests/test_cancel_transcription.py b/tests/test_cancel_transcription.py new file mode 100644 index 0000000..579ddde --- /dev/null +++ b/tests/test_cancel_transcription.py @@ -0,0 +1,685 @@ +"""Tests for cancelling an active speech recognition session.""" + +import queue +import threading +import time +from typing import Callable, Optional +from unittest.mock import MagicMock, patch + +import vocalinux.speech_recognition.recognition_manager as recognition_module +from vocalinux.common_types import RecognitionState +from vocalinux.speech_recognition.recognition_manager import ( + SpeechRecognitionManager, + _AudioSegment, + _ReleaseGate, +) + + +def _make_manager() -> SpeechRecognitionManager: + duck = MagicMock() + duck.enabled.return_value = False + with patch.object(SpeechRecognitionManager, "_init_whispercpp"): + manager = SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="en-us", + playback_duck=duck, + ) + manager.model = MagicMock() + manager._model_initialized = True + return manager + + +def _start_session(manager: SpeechRecognitionManager, record_audio: Callable[[], None]) -> None: + manager._record_audio = record_audio + with patch.object(recognition_module, "play_start_sound"): + with patch.object(manager, "_arm_playback_duck"): + assert manager.start_recognition() + + +def _stop_session(manager: SpeechRecognitionManager) -> None: + with patch.object(recognition_module, "play_stop_sound"): + manager.stop_recognition() + + +def _no_audio_capture() -> None: + return None + + +def _stop_with_short_worker_joins(manager: SpeechRecognitionManager) -> None: + with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.05): + with patch.object(SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.01): + _stop_session(manager) + + +def _wait_for_state(manager: SpeechRecognitionManager, state: RecognitionState) -> None: + deadline = time.monotonic() + 2.0 + while manager.state is not state and time.monotonic() < deadline: + time.sleep(0.005) + assert manager.state is state + + +def _record_until_stopped( + manager: SpeechRecognitionManager, capture_started: threading.Event +) -> None: + manager._recording_segment_has_speech = False + capture_started.set() + while manager.should_record: + time.sleep(0.005) + + +def test_processing_cancel_is_immediate_and_new_session_remains_isolated() -> None: + manager = _make_manager() + capture_started = threading.Event() + transcriber_started = threading.Event() + release_old_transcriber = threading.Event() + new_transcription_finished = threading.Event() + delivered: list[str] = [] + segments: list[str] = [] + transcriptions: list[str] = [] + + def record_audio() -> None: + _record_until_stopped(manager, capture_started) + + def transcribe(audio_buffer: list[bytes], language: Optional[str] = None) -> str: + del audio_buffer, language + if not transcriptions: + transcriptions.append("old") + transcriber_started.set() + release_old_transcriber.wait(timeout=5.0) + return "old session" + transcriptions.append("new") + new_transcription_finished.set() + return "new session" + + manager._transcribe_with_whispercpp = transcribe + manager._voice_commands_enabled = False + manager.register_text_callback(delivered.append) + manager.register_segment_callback(lambda text, _started_at: segments.append(text)) + capture_started.clear() + _start_session(manager, record_audio) + assert capture_started.wait(timeout=1.0) + old_cancel = manager._session_cancel + with manager._buffer_lock: + manager.audio_buffer = [b"old audio"] + manager._recording_segment_has_speech = True + + stop_thread = threading.Thread(target=_stop_session, args=(manager,)) + stop_thread.start() + assert transcriber_started.wait(timeout=2.0) + assert manager.is_transcribing() + old_queue = manager._segment_queue + + cancel_started = time.monotonic() + assert manager.cancel_transcription() + assert time.monotonic() - cancel_started < 0.5 + assert manager.state is RecognitionState.IDLE + assert old_queue.get_nowait() is None + assert old_queue.empty() + + capture_started.clear() + _start_session(manager, record_audio) + assert capture_started.wait(timeout=1.0) + new_cancel = manager._session_cancel + assert new_cancel is not old_cancel + assert not new_cancel.is_set() + manager._enqueue_audio_segment([b"new audio"]) + assert new_transcription_finished.wait(timeout=2.0) + assert delivered == ["new session"] + assert segments == ["new session"] + + release_old_transcriber.set() + stop_thread.join(timeout=2.0) + assert not stop_thread.is_alive() + assert manager.state is RecognitionState.LISTENING + assert delivered == ["new session"] + + _stop_session(manager) + assert manager.state is RecognitionState.IDLE + + +def test_cancel_while_listening_discards_final_buffer() -> None: + manager = _make_manager() + capture_started = threading.Event() + manager._voice_commands_enabled = False + + def record_audio() -> None: + _record_until_stopped(manager, capture_started) + + _start_session(manager, record_audio) + assert capture_started.wait(timeout=1.0) + with manager._buffer_lock: + manager.audio_buffer = [b"final audio"] + manager._recording_segment_has_speech = True + + with patch.object(manager, "_enqueue_audio_segment") as enqueue: + assert manager.cancel_transcription() + + assert manager.state is RecognitionState.IDLE + assert manager._session_cancel.is_set() + assert not manager.audio_buffer + enqueue.assert_not_called() + + +def test_cancel_when_idle_returns_false() -> None: + manager = _make_manager() + + assert not manager.cancel_transcription() + assert manager.state is RecognitionState.IDLE + + +def test_is_transcribing_requires_stopped_processing_state() -> None: + manager = _make_manager() + manager.state = RecognitionState.PROCESSING + manager.should_record = False + assert manager.is_transcribing() + + manager.should_record = True + assert not manager.is_transcribing() + manager.should_record = False + manager.state = RecognitionState.LISTENING + assert not manager.is_transcribing() + + +def test_cancel_rejects_an_already_cancelled_session() -> None: + manager = _make_manager() + manager.state = RecognitionState.PROCESSING + manager._session_cancel.set() + + assert not manager.cancel_transcription() + assert manager.state is RecognitionState.PROCESSING + + +def test_cancel_buffered_reload_sets_flag_and_leaves_processing_state() -> None: + manager = _make_manager() + manager.state = RecognitionState.PROCESSING + manager._buffered_reload_session = True + + assert manager.cancel_transcription() + assert manager._session_cancel.is_set() + assert manager._cancel_buffered_session.is_set() + assert manager.state is RecognitionState.PROCESSING + assert manager._segment_queue.get_nowait() is None + + +def test_enqueue_stamps_segment_with_current_session_cancel_event() -> None: + manager = _make_manager() + manager._enqueue_audio_segment([b"audio"]) + + segment, _language = manager._segment_queue.get_nowait() + assert isinstance(segment, _AudioSegment) + assert segment.cancel_event is manager._session_cancel + + +def test_cancelled_transcriber_result_is_discarded_before_callbacks() -> None: + manager = _make_manager() + manager._voice_commands_enabled = True + cancel_event = threading.Event() + segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event) + transcriber_started = threading.Event() + release_transcriber = threading.Event() + callbacks: list[str] = [] + actions: list[str] = [] + manager.command_processor.process_text = MagicMock(return_value=("discard me", ["action"])) + manager.action_callbacks.append(actions.append) + + def transcribe(audio_buffer: list[bytes], language: Optional[str] = None) -> str: + del audio_buffer, language + transcriber_started.set() + release_transcriber.wait(timeout=2.0) + return "discard me" + + manager._transcribe_with_whispercpp = transcribe + manager.register_text_callback(callbacks.append) + manager.register_segment_callback(lambda text, _started_at: callbacks.append(text)) + worker = threading.Thread(target=manager._process_audio_buffer, args=(segment,)) + worker.start() + assert transcriber_started.wait(timeout=1.0) + cancel_event.set() + release_transcriber.set() + worker.join(timeout=2.0) + + assert not worker.is_alive() + assert not callbacks + assert not actions + manager.command_processor.process_text.assert_not_called() + + +def test_cancelled_buffer_is_not_transcribed() -> None: + manager = _make_manager() + cancel_event = threading.Event() + cancel_event.set() + segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event) + manager._transcribe_with_whispercpp = MagicMock(return_value="must not run") + + manager._process_audio_buffer(segment) + + manager._transcribe_with_whispercpp.assert_not_called() + + +def test_perform_recognition_worker_keeps_its_original_queue() -> None: + manager = _make_manager() + manager.should_record = True + old_cancel = manager._session_cancel + old_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue() + new_queue: queue.Queue[tuple[_AudioSegment, str]] = queue.Queue() + manager._segment_queue = old_queue + process_started = threading.Event() + release_process = threading.Event() + processed: list[str] = [] + + def process(segment: list[bytes], _language: Optional[str] = None) -> None: + processed.append(segment[0].decode()) + process_started.set() + release_process.wait(timeout=2.0) + + manager._process_audio_buffer = process + old_queue.put((_AudioSegment([b"old"], None), "en-us")) + worker = threading.Thread(target=manager._perform_recognition) + worker.start() + assert process_started.wait(timeout=1.0) + + old_cancel.set() + manager._session_cancel = threading.Event() + manager._segment_queue = new_queue + new_queue.put((_AudioSegment([b"new"], None), "en-us")) + release_process.set() + worker.join(timeout=2.0) + + assert not worker.is_alive() + assert processed == ["old"] + assert new_queue.qsize() == 1 + + +def test_whispercpp_abort_callback_is_passed_and_tracks_cancellation() -> None: + class FakeAudioArray: + def astype(self, _dtype: object) -> "FakeAudioArray": + return self + + def __truediv__(self, _divisor: float) -> "FakeAudioArray": + return self + + def __len__(self) -> int: + return 16000 + + class FakeNumpy: + int16 = object() + float32 = object() + + @staticmethod + def frombuffer(_audio: bytes, dtype: object) -> FakeAudioArray: + del dtype + return FakeAudioArray() + + class SupportedModel: + def __init__(self) -> None: + self.abort_callback: Optional[Callable[[], bool]] = None + + def transcribe( + self, + _audio: FakeAudioArray, + language: Optional[str] = None, + initial_prompt: str = "", + abort_callback: Optional[Callable[[], bool]] = None, + ) -> list[object]: + del language, initial_prompt + self.abort_callback = abort_callback + return [] + + manager = _make_manager() + model = SupportedModel() + manager.model = model + cancel_event = threading.Event() + segment = _AudioSegment([b"\x00\x00"], None, cancel_event=cancel_event) + + with patch.dict("sys.modules", {"numpy": FakeNumpy}): + assert manager._transcribe_with_whispercpp(segment) == "" + + assert model.abort_callback is not None + assert not model.abort_callback() + cancel_event.set() + assert model.abort_callback() + + +def test_whispercpp_without_abort_callback_signature_still_transcribes() -> None: + class FakeAudioArray: + def astype(self, _dtype: object) -> "FakeAudioArray": + return self + + def __truediv__(self, _divisor: float) -> "FakeAudioArray": + return self + + def __len__(self) -> int: + return 16000 + + class FakeNumpy: + int16 = object() + float32 = object() + + @staticmethod + def frombuffer(_audio: bytes, dtype: object) -> FakeAudioArray: + del dtype + return FakeAudioArray() + + class UnsupportedModel: + def __init__(self) -> None: + self.called = False + + def transcribe( + self, + _audio: FakeAudioArray, + language: Optional[str] = None, + initial_prompt: str = "", + ) -> list[object]: + del language, initial_prompt + self.called = True + return [] + + manager = _make_manager() + model = UnsupportedModel() + manager.model = model + segment = _AudioSegment([b"\x00\x00"], None, cancel_event=threading.Event()) + + with patch.dict("sys.modules", {"numpy": FakeNumpy}): + assert manager._transcribe_with_whispercpp(segment) == "" + + assert model.called + + +def test_whispercpp_skips_language_detection_when_cancelled() -> None: + manager = _make_manager() + manager.whispercpp_language_candidates = ["en"] + manager.model.auto_detect_language = MagicMock() + cancel_event = threading.Event() + cancel_event.set() + segment = _AudioSegment([b"\x00\x00"], None, cancel_event=cancel_event) + + assert manager._transcribe_with_whispercpp(segment) == "" + manager.model.auto_detect_language.assert_not_called() + + +def test_late_worker_handoff_keeps_processing_until_transcription_finishes() -> None: + manager = _make_manager() + transcriber_started = threading.Event() + release_transcriber = threading.Event() + delivered: list[str] = [] + + def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str: + transcriber_started.set() + assert release_transcriber.wait(timeout=3.0) + return "late text" + + manager._transcribe_with_whispercpp = transcribe + manager._voice_commands_enabled = False + manager.register_text_callback(delivered.append) + _start_session(manager, _no_audio_capture) + manager._enqueue_audio_segment([b"slow audio"]) + + assert transcriber_started.wait(timeout=1.0) + worker_thread = manager.recognition_thread + _stop_with_short_worker_joins(manager) + + assert manager.state is RecognitionState.PROCESSING + assert manager.is_transcribing() + + release_transcriber.set() + _wait_for_state(manager, RecognitionState.IDLE) + assert delivered == ["late text"] + assert manager._worker_exited is not None + assert manager._worker_exited.is_set() + assert worker_thread is not None + worker_thread.join(timeout=1.0) + assert not worker_thread.is_alive() + + +def test_cancel_late_worker_discards_text_and_next_session_succeeds() -> None: + manager = _make_manager() + transcriber_started = threading.Event() + release_transcriber = threading.Event() + next_transcription_finished = threading.Event() + next_text_delivered = threading.Event() + delivered: list[str] = [] + transcribe_calls = 0 + + def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str: + nonlocal transcribe_calls + transcribe_calls += 1 + if transcribe_calls == 1: + transcriber_started.set() + assert release_transcriber.wait(timeout=3.0) + return "cancelled text" + next_transcription_finished.set() + return "next session" + + def on_text(text: str) -> None: + delivered.append(text) + if text == "next session": + next_text_delivered.set() + + manager._transcribe_with_whispercpp = transcribe + manager._voice_commands_enabled = False + manager.register_text_callback(on_text) + _start_session(manager, _no_audio_capture) + manager._enqueue_audio_segment([b"slow audio"]) + + assert transcriber_started.wait(timeout=1.0) + worker_exited = manager._worker_exited + assert worker_exited is not None + worker_thread = manager.recognition_thread + _stop_with_short_worker_joins(manager) + assert manager.is_transcribing() + + assert manager.cancel_transcription() + assert manager.state is RecognitionState.IDLE + release_transcriber.set() + assert worker_exited.wait(timeout=2.0) + assert worker_thread is not None + worker_thread.join(timeout=1.0) + assert not worker_thread.is_alive() + assert manager.state is RecognitionState.IDLE + assert delivered == [] + + _start_session(manager, _no_audio_capture) + manager._enqueue_audio_segment([b"next audio"]) + assert next_transcription_finished.wait(timeout=1.0) + assert next_text_delivered.wait(timeout=1.0) + assert delivered == ["next session"] + _stop_session(manager) + assert manager.state is RecognitionState.IDLE + + +def test_cancelled_old_worker_does_not_change_new_session_state() -> None: + manager = _make_manager() + old_transcriber_started = threading.Event() + release_old_transcriber = threading.Event() + new_transcription_finished = threading.Event() + new_text_delivered = threading.Event() + delivered: list[str] = [] + transcribe_calls = 0 + + def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str: + nonlocal transcribe_calls + transcribe_calls += 1 + if transcribe_calls == 1: + old_transcriber_started.set() + assert release_old_transcriber.wait(timeout=3.0) + return "old text" + new_transcription_finished.set() + return "new text" + + def on_text(text: str) -> None: + delivered.append(text) + if text == "new text": + new_text_delivered.set() + + manager._transcribe_with_whispercpp = transcribe + manager._voice_commands_enabled = False + manager.register_text_callback(on_text) + _start_session(manager, _no_audio_capture) + manager._enqueue_audio_segment([b"old audio"]) + + assert old_transcriber_started.wait(timeout=1.0) + old_worker_exited = manager._worker_exited + assert old_worker_exited is not None + old_worker_thread = manager.recognition_thread + _stop_with_short_worker_joins(manager) + old_cancel = manager._session_cancel + assert manager._idle_on_worker_exit is old_cancel + assert manager.cancel_transcription() + assert manager._idle_on_worker_exit is None + + _start_session(manager, _no_audio_capture) + new_cancel = manager._session_cancel + assert new_cancel is not old_cancel + manager._enqueue_audio_segment([b"new audio"]) + assert new_transcription_finished.wait(timeout=1.0) + assert new_text_delivered.wait(timeout=1.0) + assert delivered == ["new text"] + + release_old_transcriber.set() + assert old_worker_thread is not None + old_worker_thread.join(timeout=2.0) + assert not old_worker_thread.is_alive() + assert old_worker_exited.is_set() + _wait_for_state(manager, RecognitionState.LISTENING) + assert manager._session_cancel is new_cancel + assert not new_cancel.is_set() + assert delivered == ["new text"] + + _stop_session(manager) + assert manager.state is RecognitionState.IDLE + + +def test_fast_worker_still_returns_to_idle_from_stop() -> None: + manager = _make_manager() + transcriber_finished = threading.Event() + + def transcribe(_audio: list[bytes], _language: Optional[str] = None) -> str: + transcriber_finished.set() + return "quick text" + + manager._transcribe_with_whispercpp = transcribe + manager._voice_commands_enabled = False + _start_session(manager, _no_audio_capture) + manager._enqueue_audio_segment([b"quick audio"]) + + assert transcriber_finished.wait(timeout=1.0) + _stop_session(manager) + + assert manager.state is RecognitionState.IDLE + assert manager._worker_exited is not None + assert manager._worker_exited.is_set() + + +def test_cancel_drops_push_to_talk_text_held_until_release() -> None: + manager = _make_manager() + manager._voice_commands_enabled = False + delivered: list[str] = [] + manager.register_text_callback(delivered.append) + cancel_event = threading.Event() + gate = _ReleaseGate() + segment = _AudioSegment([b"audio"], None, cancel_event=cancel_event, release_gate=gate) + manager._transcribe_with_whispercpp = MagicMock(return_value="held words") + + manager._process_audio_buffer(segment) + assert len(gate.held) == 1 + + cancel_event.set() + gate.set() + manager._release_held_transcripts(gate, force=True, cancel=cancel_event) + + assert not gate.held + assert not delivered + + +def test_cancelled_worker_exit_does_not_force_release_held_text() -> None: + manager = _make_manager() + delivered: list[str] = [] + manager.register_text_callback(delivered.append) + manager.should_record = False + gate = _ReleaseGate() + manager._key_release_gate = gate + manager._session_cancel.set() + gate.held.append(recognition_module._HeldTranscript("held words", [], None)) + + manager._perform_recognition() + + assert not gate.held + assert not delivered + + +def test_uncancelled_release_still_delivers_held_text() -> None: + manager = _make_manager() + delivered: list[str] = [] + manager.register_text_callback(delivered.append) + gate = _ReleaseGate() + gate.held.append(recognition_module._HeldTranscript("held words", [], None)) + gate.set() + + manager._release_held_transcripts(gate, cancel=threading.Event()) + + assert delivered == ["held words"] + + +def test_capture_session_current_tracks_cancel_and_replacement() -> None: + manager = _make_manager() + manager.should_record = True + session = manager._session_cancel + assert manager._capture_session_current(session) + + manager._session_cancel = threading.Event() + assert not manager._capture_session_current(session) + + manager._session_cancel = session + session.set() + assert not manager._capture_session_current(session) + + +def test_stale_capture_reconnection_does_not_touch_new_session_device() -> None: + manager = _make_manager() + manager.should_record = True + old_session = manager._session_cancel + new_source = MagicMock() + manager._capture_source = new_source + manager._capture_source_for_session = MagicMock(return_value=new_source) + + def new_session_starts(_delay: float) -> None: + old_session.set() + manager._session_cancel = threading.Event() + + with patch.object(recognition_module.time, "sleep", side_effect=new_session_starts): + assert manager._attempt_audio_reconnection(MagicMock(), old_session) is False + + manager._capture_source_for_session.assert_not_called() + new_source.reopen.assert_not_called() + + +def test_cancelled_capture_thread_does_not_write_into_new_session_buffer() -> None: + manager = _make_manager() + manager._silero_vad = None + manager.should_record = True + old_session = manager._session_cancel + reads = 0 + + source = MagicMock() + source.requires_pyaudio = False + source.sample_rate = 16000 + + def read_chunk() -> bytes: + nonlocal reads + reads += 1 + if reads == 1: + old_session.set() + manager._session_cancel = threading.Event() + manager.audio_buffer = [b"new-session"] + return b"\x00\x00" * 1024 + + source.read_chunk.side_effect = read_chunk + manager._new_capture_source = MagicMock(return_value=source) + with patch.object(recognition_module, "play_error_sound", create=True): + manager._record_audio() + + assert reads == 1 + assert manager.audio_buffer == [b"new-session"] + assert manager.state is not RecognitionState.ERROR + source.close.assert_called_once() diff --git a/tests/test_common_types_enums_protocols.py b/tests/test_common_types_enums_protocols.py index 39332dc..8284e28 100644 --- a/tests/test_common_types_enums_protocols.py +++ b/tests/test_common_types_enums_protocols.py @@ -299,7 +299,7 @@ class TestEnumImports(unittest.TestCase): class TestProtocolImports(unittest.TestCase): """Test that all protocols are importable.""" - def test_protocol_imports(self): + def test_protocol_imports(self) -> None: """Test that protocols can be imported.""" from vocalinux.common_types import ( SpeechRecognitionManagerProtocol, @@ -309,6 +309,8 @@ class TestProtocolImports(unittest.TestCase): # Verify protocols are types with expected methods self.assertTrue(hasattr(SpeechRecognitionManagerProtocol, "start_recognition")) self.assertTrue(hasattr(SpeechRecognitionManagerProtocol, "stop_recognition")) + self.assertTrue(hasattr(SpeechRecognitionManagerProtocol, "is_transcribing")) + self.assertTrue(hasattr(SpeechRecognitionManagerProtocol, "cancel_transcription")) self.assertTrue(hasattr(SpeechRecognitionManagerProtocol, "register_state_callback")) self.assertTrue(hasattr(TextInjectorProtocol, "inject_text")) diff --git a/tests/test_language_shortcuts.py b/tests/test_language_shortcuts.py index 1a28699..6d544cb 100644 --- a/tests/test_language_shortcuts.py +++ b/tests/test_language_shortcuts.py @@ -183,6 +183,7 @@ def _manager_stub( manager._pending_language_override = None manager._oneshot_language_restore = None manager._segment_started_at = None + manager._session_cancel = threading.Event() manager._key_release_gate = _ReleaseGate() manager._key_release_gate.set() return manager diff --git a/tests/test_playback_ducker.py b/tests/test_playback_ducker.py index 6b37096..d24410c 100644 --- a/tests/test_playback_ducker.py +++ b/tests/test_playback_ducker.py @@ -729,6 +729,8 @@ def test_recognition_hook_schedules_after_start_and_restores_before_stop_cue( ): thread_cls.return_value = MagicMock() assert manager.start_recognition() is True + assert manager._worker_exited is not None + manager._worker_exited.set() manager.stop_recognition() start_at = timeline.index("start_sound") diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py index 162eb66..2f53333 100644 --- a/tests/test_recognition_manager.py +++ b/tests/test_recognition_manager.py @@ -214,6 +214,7 @@ class TestSpeechRecognition(unittest.TestCase): # Stop recognition manager.audio_thread = self.threadInstance manager.recognition_thread = self.threadInstance + manager._worker_exited.set() manager.stop_recognition() # Verify stopped state diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py index 5695faf..5a45b12 100644 --- a/tests/test_recognition_manager_core.py +++ b/tests/test_recognition_manager_core.py @@ -504,6 +504,7 @@ class TestStartStopRecognition(unittest.TestCase): manager.start_recognition() manager.audio_thread = self.threadInstance manager.recognition_thread = self.threadInstance + manager._worker_exited.set() manager.stop_recognition() @@ -587,6 +588,7 @@ class TestPushToTalkMode(unittest.TestCase): manager.audio_thread = self.threadInstance manager.recognition_thread = self.threadInstance + manager._worker_exited.set() manager.stop_recognition() self.assertEqual(manager._recognition_mode, "toggle") diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index a98aaeb..59ef164 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1254,9 +1254,22 @@ class TestSettingsNavigation(unittest.TestCase): footer_body = self.source_code.split("def _build_sidebar_footer")[1].split("\n def ")[0] box_pack = footer_body.index("footer.pack_start(self.test_output_revealer") status_pack = footer_body.index("footer.pack_start(status_row") - button_pack = footer_body.index("footer.pack_start(self.test_button") + button_pack = footer_body.index("footer.pack_start(test_button_row") + test_button = footer_body.index("test_button_row.pack_start(self.test_button") + cancel_button = footer_body.index("test_button_row.pack_start(self.test_cancel_button") self.assertLess(box_pack, status_pack) self.assertLess(status_pack, button_pack) + self.assertLess(test_button, cancel_button) + + def test_test_dictation_stop_runs_off_gtk_thread(self) -> None: + """Finalization keeps the Cancel button usable while recognition stops.""" + finalize_body = self.source_code.split("def _finalize_test")[1].split("\n def ")[0] + self.assertIn("def stop_test", finalize_body) + self.assertIn('name="vocalinux-test-dictation-stop"', finalize_body) + self.assertIn( + "GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)", + finalize_body, + ) def test_test_dictation_scrolled_window_has_no_shadow(self) -> None: """GTK3 ScrolledWindow default shadow is a left stripe on GNOME/Wayland (#847).""" diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py index 08941fb..94f2aac 100644 --- a/tests/test_settings_test_dictation.py +++ b/tests/test_settings_test_dictation.py @@ -68,8 +68,11 @@ def _dialog_for_test( ) -> Mock: dialog = Mock() dialog._test_active = False + dialog._test_token = 0 + dialog._test_cancelled = False dialog._applying_settings = False dialog.test_button = Mock() + dialog.test_cancel_button = Mock() dialog.test_output_revealer = Mock() dialog.test_buffer = _text_buffer() dialog.test_textview = Mock() @@ -184,12 +187,60 @@ def test_test_dictation_started_arms_listen_timer() -> None: SettingsDialog._on_test_clicked(dialog, None) thread_cls.assert_called_once() - assert thread_cls.call_args.kwargs["args"] == (4.0,) + assert thread_cls.call_args.kwargs["args"] == (4.0, 1) assert dialog._test_active is True + assert dialog._test_cancelled is False dialog.test_button.set_label.assert_called_with("Testing… Speak Now!") + dialog.test_cancel_button.show.assert_called_once() dialog.apply_settings.assert_not_called() +def test_test_dictation_cancel_runs_off_thread_and_restores_callbacks() -> None: + dialog = _dialog_for_test(start_return=True) + with ( + patch.object(settings_dialog.threading, "Thread"), + patch.object(settings_dialog, "_undownloaded_model_info", return_value=None), + ): + SettingsDialog._on_test_clicked(dialog, None) + live_callbacks = dialog.speech_engine.get_text_callbacks.return_value + dialog.speech_engine.cancel_transcription.side_effect = RuntimeError("cancel failed") + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_cancel_clicked(dialog, None) + + assert dialog._test_cancelled is True + dialog.test_button.set_sensitive.assert_called_with(False) + dialog.test_cancel_button.set_sensitive.assert_called_with(False) + dialog.test_button.set_label.assert_called_with("Cancelling…") + thread_cls.assert_called_once() + assert thread_cls.call_args.kwargs["daemon"] is True + assert thread_cls.call_args.kwargs["name"] == "vocalinux-test-dictation-cancel" + + with patch.object(settings_dialog, "GLib") as glib: + thread_cls.call_args.kwargs["target"]() + glib.idle_add.assert_called_once_with(dialog._finish_test_after_user_cancel) + result = SettingsDialog._finish_test_after_user_cancel(dialog) + assert result is False + glib.timeout_add.assert_not_called() + + dialog.speech_engine.cancel_transcription.assert_called_once() + dialog.speech_engine.set_text_callbacks.assert_called_with(live_callbacks) + assert dialog.speech_engine.test_capture_ceiling is not None + assert dialog.test_buffer._text == "(Test cancelled)" + assert dialog._test_active is False + dialog.test_cancel_button.hide.assert_called() + dialog.update_recognition_progress.assert_called_with("Idle") + + +def test_cancelled_test_ignores_a_queued_text_callback() -> None: + dialog = Mock() + dialog._test_cancelled = True + dialog.test_buffer = _text_buffer() + + assert SettingsDialog._append_test_result(dialog, "late result") is False + assert dialog.test_buffer._text == "" + + def test_test_dictation_reconfigures_when_live_engine_differs_from_ui() -> None: """UI matches the file but the live manager is still on another engine.""" dialog = _dialog_for_test(start_return=True, model_ready=True) @@ -257,9 +308,12 @@ def _dialog_for_finalize( """Minimal dialog wired for _finalize_test / idle-wait helpers.""" dialog = Mock() dialog._test_active = True + dialog._test_token = 2 + dialog._test_cancelled = False dialog._test_idle_wait_ticks = 0 dialog._test_timeout_cancel_attempted = False dialog.test_button = Mock() + dialog.test_cancel_button = Mock() dialog.test_buffer = _text_buffer() dialog.update_recognition_progress = Mock() dialog._saved_text_callbacks = ( @@ -282,6 +336,9 @@ def _dialog_for_finalize( ) dialog._keep_waiting_after_timeout = SettingsDialog._keep_waiting_after_timeout.__get__(dialog) dialog._finish_test_restore_ui = SettingsDialog._finish_test_restore_ui.__get__(dialog) + dialog._finish_test_after_user_cancel = SettingsDialog._finish_test_after_user_cancel.__get__( + dialog + ) dialog._test_engine_busy = SettingsDialog._test_engine_busy.__get__(dialog) dialog._check_test_result = Mock(return_value=False) @@ -308,6 +365,15 @@ def _dialog_for_finalize( return dialog +def test_stale_finalize_token_does_not_stop_new_test() -> None: + dialog = _dialog_for_finalize(state=RecognitionState.LISTENING) + + assert SettingsDialog._finalize_test(dialog, token=1) is False + + dialog.speech_engine.stop_recognition.assert_not_called() + assert dialog._test_active is True + + def test_open_dictation_pad_row_invokes_callback_on_click() -> None: callback = Mock() dialog = Mock() @@ -363,19 +429,29 @@ def test_dictation_pad_switch_persists_routing_preference() -> None: def test_finalize_test_buffered_reload_waits_for_idle_before_restore() -> None: - """stop_recognition may return while PROCESSING; keep test callbacks.""" + """Stop recognition runs off the GTK thread while test callbacks remain installed.""" dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True) live = dialog._saved_text_callbacks[0] - with patch.object(settings_dialog, "GLib") as glib: + with ( + patch.object(settings_dialog, "GLib") as glib, + patch.object(settings_dialog.threading, "Thread") as thread_cls, + ): SettingsDialog._finalize_test(dialog) + dialog.speech_engine.stop_recognition.assert_not_called() + thread_cls.assert_called_once() + assert thread_cls.call_args.kwargs["daemon"] is True + assert thread_cls.call_args.kwargs["name"] == "vocalinux-test-dictation-stop" + thread_cls.call_args.kwargs["target"]() dialog.speech_engine.stop_recognition.assert_called_once() # Must not restore live injector yet — worker still holds the utterance. dialog.speech_engine.set_text_callbacks.assert_not_called() assert dialog._saved_text_callbacks == [live] assert dialog._test_active is True dialog.test_button.set_label.assert_called_with("Processing…") + dialog.test_cancel_button.set_sensitive.assert_called_with(True) + dialog.test_cancel_button.show.assert_called_once() glib.timeout_add.assert_called_once_with(100, dialog._wait_for_idle_then_restore_callbacks) @@ -411,21 +487,57 @@ def test_wait_for_idle_treats_buffered_flag_as_busy_even_if_state_idle() -> None dialog.speech_engine.set_text_callbacks.assert_not_called() -def test_finalize_test_idle_path_uses_settle_delay() -> None: - """When no work remains, keep the legacy 500ms settle.""" +def test_finalize_test_idle_path_still_polls_after_threaded_stop() -> None: + """Even an already-idle session uses the off-thread stop and idle poll.""" dialog = _dialog_for_finalize(state=RecognitionState.IDLE, buffered_reload=False) - with patch.object(settings_dialog, "GLib") as glib: + with ( + patch.object(settings_dialog, "GLib") as glib, + patch.object(settings_dialog.threading, "Thread") as thread_cls, + ): SettingsDialog._finalize_test(dialog) - dialog.speech_engine.stop_recognition.assert_called_once() - assert dialog._test_active is False - dialog.test_button.set_label.assert_called_with("Test Dictation") - glib.timeout_add.assert_called_once_with(500, dialog._restore_callbacks_and_check_result) - # Live callbacks still saved until the settle timeout fires. + dialog.speech_engine.stop_recognition.assert_not_called() + assert dialog._test_active is True + dialog.test_button.set_label.assert_called_with("Processing…") + dialog.test_cancel_button.show.assert_called_once() + thread_cls.assert_called_once() + glib.timeout_add.assert_called_once_with(100, dialog._wait_for_idle_then_restore_callbacks) assert hasattr(dialog, "_saved_text_callbacks") +def test_cancel_during_processing_restores_once_without_result_check() -> None: + """A queued poll after cancellation must not restore callbacks a second time.""" + dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=False) + live = dialog._saved_text_callbacks[0] + + with ( + patch.object(settings_dialog, "GLib") as glib, + patch.object(settings_dialog.threading, "Thread") as thread_cls, + ): + SettingsDialog._finalize_test(dialog) + + assert thread_cls.call_args.kwargs["name"] == "vocalinux-test-dictation-stop" + dialog._test_cancelled = True + assert SettingsDialog._finish_test_after_user_cancel(dialog) is False + assert dialog.test_buffer._text == "(Test cancelled)" + dialog.speech_engine.set_text_callbacks.assert_called_once_with([live]) + + assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is False + dialog.speech_engine.set_text_callbacks.assert_called_once_with([live]) + dialog._check_test_result.assert_not_called() + assert glib.timeout_add.call_count == 1 + + +def test_idle_poll_does_not_restore_after_cancel_flag() -> None: + dialog = _dialog_for_finalize(state=RecognitionState.IDLE, buffered_reload=False) + dialog._test_cancelled = True + + assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is False + dialog.speech_engine.set_text_callbacks.assert_not_called() + dialog._check_test_result.assert_not_called() + + def test_finalize_waits_for_pending_transcription_and_keeps_test_callback() -> None: """Late recognition text stays on the test path after state becomes IDLE.""" live_text: list[str] = [] @@ -435,11 +547,14 @@ def test_finalize_waits_for_pending_transcription_and_keeps_test_callback() -> N dialog = _dialog_for_finalize(state=RecognitionState.IDLE) dialog._saved_text_callbacks = [live_callback] - dialog.speech_engine.has_pending_transcription = Mock(side_effect=[True, True, False]) + dialog.speech_engine.has_pending_transcription = Mock(side_effect=[True, False]) dialog._test_text_callback = SettingsDialog._test_text_callback.__get__(dialog) dialog._append_test_result = SettingsDialog._append_test_result.__get__(dialog) - with patch.object(settings_dialog, "GLib") as glib: + with ( + patch.object(settings_dialog, "GLib") as glib, + patch.object(settings_dialog.threading, "Thread"), + ): glib.idle_add.side_effect = lambda callback, *args: callback(*args) SettingsDialog._finalize_test(dialog) diff --git a/tests/test_suspend_handler.py b/tests/test_suspend_handler.py index c106c63..f69fe13 100644 --- a/tests/test_suspend_handler.py +++ b/tests/test_suspend_handler.py @@ -6,6 +6,7 @@ SpeechRecognitionManager. """ import sys +import threading import unittest from unittest.mock import MagicMock, patch @@ -491,6 +492,75 @@ class TestTrayIndicatorResumeFlow(unittest.TestCase): mock_glib.source_remove.assert_not_called() + def test_tray_hotkey_cancels_late_worker_after_stop_join_timeout(self) -> None: + import vocalinux.speech_recognition.recognition_manager as recognition_module + from vocalinux.speech_recognition.recognition_manager import ( + SpeechRecognitionManager, + ) + + duck = MagicMock() + duck.enabled.return_value = False + with patch.object(SpeechRecognitionManager, "_init_whispercpp"): + manager = SpeechRecognitionManager( + engine="whisper_cpp", + model_size="small", + language="en-us", + playback_duck=duck, + ) + manager.model = MagicMock() + manager._model_initialized = True + manager._voice_commands_enabled = False + + transcriber_started = threading.Event() + release_transcriber = threading.Event() + delivered: list[str] = [] + + def record_audio() -> None: + return None + + def transcribe(_audio: list[bytes], _language: str | None = None) -> str: + transcriber_started.set() + release_transcriber.wait(timeout=3.0) + return "cancelled text" + + manager._record_audio = record_audio + manager._transcribe_with_whispercpp = transcribe + manager.register_text_callback(delivered.append) + with patch.object(recognition_module, "play_start_sound"): + with patch.object(manager, "_arm_playback_duck"): + assert manager.start_recognition() + manager._enqueue_audio_segment([b"slow audio"]) + assert transcriber_started.wait(timeout=1.0) + + worker_exited = manager._worker_exited + assert worker_exited is not None + worker_thread = manager.recognition_thread + assert worker_thread is not None + try: + with patch.object(SpeechRecognitionManager, "_STOP_WORKER_JOIN_TIMEOUT_S", 0.05): + with patch.object( + SpeechRecognitionManager, "_STOP_WORKER_FINAL_JOIN_TIMEOUT_S", 0.01 + ): + with patch.object(recognition_module, "play_stop_sound"): + manager.stop_recognition() + + assert manager.state is RecognitionState.PROCESSING + assert manager.is_transcribing() + tray = self._make_tray_indicator() + tray.speech_engine = manager + tray._ptt_lock = threading.Lock() + tray._ptt_owner = None + tray._toggle_recognition() + assert manager.state is RecognitionState.IDLE + finally: + release_transcriber.set() + worker_thread.join(timeout=2.0) + + assert not worker_thread.is_alive() + assert worker_exited.is_set() + assert manager.state is RecognitionState.IDLE + assert delivered == [] + class TestTrayAutoPauseWiring(unittest.TestCase): """TrayIndicator auto-pause config + pause/resume handlers. diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index 97454e9..99218d6 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -7,7 +7,9 @@ The tests focus on the business logic of the TrayIndicator class. import os import sys +import threading import unittest +from threading import Thread from types import SimpleNamespace from unittest.mock import MagicMock, PropertyMock, patch @@ -190,12 +192,133 @@ class TestTrayIndicator(unittest.TestCase): self.mock_speech_engine.stop_recognition.assert_called_once() self.mock_speech_engine.start_recognition.assert_not_called() - def test_toggle_recognition_from_processing(self): + def test_toggle_recognition_from_processing(self) -> None: """Test toggling recognition state from PROCESSING.""" self.mock_speech_engine.state = self.RecognitionState.PROCESSING self.tray_indicator._toggle_recognition() self.mock_speech_engine.stop_recognition.assert_called_once() self.mock_speech_engine.start_recognition.assert_not_called() + self.mock_speech_engine.cancel_transcription.assert_not_called() + + def test_toggle_recognition_cancels_transcription_before_lock(self) -> None: + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + self.mock_speech_engine.cancel_transcription.return_value = True + + self.tray_indicator._toggle_recognition() + + self.mock_speech_engine.cancel_transcription.assert_called_once() + self.mock_speech_engine.stop_recognition.assert_not_called() + self.mock_speech_engine.start_recognition.assert_not_called() + + def test_push_to_talk_cancel_clears_owner_and_release_is_noop(self) -> None: + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + self.mock_speech_engine.cancel_transcription.return_value = True + + self.tray_indicator._start_recognition() + self.tray_indicator._release_main_push_to_talk() + + self.mock_speech_engine.cancel_transcription.assert_called_once() + self.mock_speech_engine.start_recognition.assert_not_called() + self.mock_speech_engine.stop_recognition.assert_not_called() + + def test_language_hotkeys_cancel_transcription(self) -> None: + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + self.mock_speech_engine.cancel_transcription.return_value = True + + self.tray_indicator._toggle_recognition_in_language("fr") + self.tray_indicator._start_recognition_in_language("fr", self.mock_ksm) + + self.assertEqual(self.mock_speech_engine.cancel_transcription.call_count, 2) + self.mock_speech_engine.start_recognition_with_language.assert_not_called() + self.mock_speech_engine.stop_recognition.assert_not_called() + + def test_cancel_hotkey_does_not_wait_for_ptt_lock(self) -> None: + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + self.mock_speech_engine.cancel_transcription.return_value = True + lock_held = threading.Event() + release_lock = threading.Event() + hotkey_finished = threading.Event() + + def hold_lock() -> None: + with self.tray_indicator._ptt_lock: + lock_held.set() + release_lock.wait(timeout=2.0) + + def press_hotkey() -> None: + self.tray_indicator._toggle_recognition() + hotkey_finished.set() + + holder = Thread(target=hold_lock) + holder.start() + self.assertTrue(lock_held.wait(timeout=1.0)) + hotkey = Thread(target=press_hotkey) + hotkey.start() + try: + self.assertTrue(hotkey_finished.wait(timeout=0.5)) + finally: + release_lock.set() + holder.join(timeout=1.0) + hotkey.join(timeout=1.0) + + self.mock_speech_engine.cancel_transcription.assert_called_once() + + def test_cancel_menu_visibility_and_transcription_tooltip(self) -> None: + self.tray_indicator.indicator = MagicMock() + self.tray_indicator.menu = MagicMock() + self.tray_indicator._cancel_menu_item = MagicMock() + self.tray_indicator._icon_keys = { + "default": "off", + "active": "on", + "processing": "processing", + } + self.tray_indicator.shortcut_manager.shortcut_display_name = "Ctrl+Alt+V" + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + + with patch.object(self.tray_indicator, "_set_indicator_icon"): + with patch.object(self.tray_indicator, "_set_menu_item_enabled"): + with patch.object(self.tray_indicator, "_update_overlay"): + self.tray_indicator._update_ui(self.RecognitionState.PROCESSING) + self.assertEqual( + self.tray_indicator.indicator.set_title.call_args.args[0], + "Transcribing, press Ctrl+Alt+V again to cancel", + ) + self.tray_indicator._cancel_menu_item.set_sensitive.assert_called_with(True) + self.tray_indicator._cancel_menu_item.set_visible.assert_called_with(True) + + self.mock_speech_engine.state = self.RecognitionState.LISTENING + self.tray_indicator._update_ui(self.RecognitionState.LISTENING) + + self.tray_indicator._cancel_menu_item.set_sensitive.assert_called_with(False) + self.tray_indicator._cancel_menu_item.set_visible.assert_called_with(False) + self.tray_indicator.indicator.set_title.assert_called_with("Vocalinux") + + def test_cancel_tooltip_uses_fallback_without_shortcut_name(self) -> None: + self.tray_indicator.indicator = MagicMock() + self.tray_indicator.menu = MagicMock() + self.tray_indicator._cancel_menu_item = MagicMock() + self.tray_indicator._icon_keys = {"default": "off", "active": "on", "processing": "busy"} + self.tray_indicator.shortcut_manager = MagicMock() + self.mock_speech_engine.state = self.RecognitionState.PROCESSING + self.mock_speech_engine.is_transcribing.return_value = True + + with patch.object(self.tray_indicator, "_set_indicator_icon") as set_icon: + with patch.object(self.tray_indicator, "_set_menu_item_enabled"): + with patch.object(self.tray_indicator, "_update_overlay"): + self.tray_indicator._update_ui(self.RecognitionState.PROCESSING) + + set_icon.assert_called_once_with( + "busy", "Transcribing, press the recording shortcut again to cancel" + ) + + def test_cancel_menu_callback_calls_manager_directly(self) -> None: + self.tray_indicator._on_cancel_transcription_clicked(None) + + self.mock_speech_engine.cancel_transcription.assert_called_once() def test_on_start_clicked(self): """Test start button click handler."""