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.
This commit is contained in:
Jatin K Malik
2026-10-21 01:33:58 -07:00
committed by GitHub
parent 9b5cdf686f
commit 138a233892
15 changed files with 1485 additions and 107 deletions
+8
View File
@@ -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."""
...
@@ -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).
+94 -27
View File
@@ -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()
+63 -2
View File
@@ -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: