fix(dictation-pad): keep Test Dictation results and move pad routing to Settings (#1005)

* fix(dictation-pad): keep Test Dictation results and move pad routing to Settings

Keep late recognition callbacks on the test path.
Move Dictation Pad routing to Settings.

Fixes #983
Fixes #986

* fix(recognition): ignore stop sentinels in pending transcription

Ignore stop signals when checking queued transcription work.
Add regression coverage for a real stop path leaving a sentinel behind.

Fixes #983

* fix(recognition): don't count orphaned queued audio as pending transcription

* fix(recognition): count superseded workers as pending transcription

* test(auto-pause): give the bare manager its worker-tracking state
This commit is contained in:
Jatin K Malik
2026-10-21 01:20:14 -07:00
committed by GitHub
parent 3e4230ffc2
commit 9b5cdf686f
10 changed files with 462 additions and 86 deletions
+4
View File
@@ -98,6 +98,10 @@ class SpeechRecognitionManagerProtocol(Protocol):
"""Stop the speech recognition process."""
...
def has_pending_transcription(self) -> bool:
"""Return True while the recognition worker may still deliver text to text callbacks."""
...
def register_state_callback(self, callback: Callable[[RecognitionState], None]) -> None:
"""Register a callback for state changes."""
...
@@ -454,6 +454,8 @@ class SpeechRecognitionManager:
self.state = RecognitionState.IDLE
self.audio_thread: Optional[threading.Thread] = None
self.recognition_thread: Optional[threading.Thread] = None
self._recognition_workers: list[threading.Thread] = []
self._recognition_workers_lock = threading.Lock()
self.model: Any = None
self.recognizer = None # Added for VOSK
self.command_processor = CommandProcessor(language=self.language)
@@ -3359,6 +3361,7 @@ class SpeechRecognitionManager:
target = self._reload_and_recognize if reload_in_background else self._perform_recognition
self.recognition_thread = threading.Thread(target=target)
self.recognition_thread.daemon = True
self._track_recognition_worker(self.recognition_thread)
self.recognition_thread.start()
# After the threads exist: a start that returned early must not duck.
self._arm_playback_duck()
@@ -3443,6 +3446,28 @@ class SpeechRecognitionManager:
self._recognition_mode = "toggle"
self._update_state(RecognitionState.IDLE)
def _track_recognition_worker(self, worker: threading.Thread) -> None:
"""Remember a recognition worker until it exits so late text is still pending."""
with self._recognition_workers_lock:
self._recognition_workers = [w for w in self._recognition_workers if w.is_alive()]
self._recognition_workers.append(worker)
def has_pending_transcription(self) -> bool:
"""Return True while any recognition worker may still deliver text to text_callbacks.
A worker from an earlier session can outlive stop_recognition()'s bounded
joins and still be transcribing after a new session replaced
``recognition_thread``. Audio left in the queue after every worker exited
has no consumer outside a buffered reload (tracked by
``_buffered_reload_session``), so it never counts.
"""
with self._recognition_workers_lock:
workers = list(self._recognition_workers)
if self.recognition_thread is not None:
workers.append(self.recognition_thread)
current = threading.current_thread()
return any(worker is not current and worker.is_alive() for worker in workers)
def _reload_and_recognize(self) -> None:
"""Reload an idle model while the recording thread captures speech."""
try:
+3 -45
View File
@@ -209,10 +209,8 @@ class DictationPad:
self._window: Any = None
self._textview: Any = None
self._buffer: Any = None
self._capture_check: Any = None
self._copy_button: Any = None
self._gtk_ready = False
self._syncing_capture_check = False
self._syncing_widget = False
# Compositor-side shelving state. _window_iconified is tracked from
# window-state-event (X11 minimize); _surface_shelved is detected by
@@ -281,18 +279,6 @@ class DictationPad:
outer.set_margin_bottom(12)
window.add(outer)
self._capture_check = Gtk.CheckButton(
label="Dictate into this window instead of injecting into other apps"
)
self._capture_check.set_tooltip_text(
"Fallback for desktops where text injection does not work "
"(e.g. Wayland): dictated text lands in this box, then you copy "
"it out by hand."
)
self._capture_check.set_active(self.controller.enabled)
self._capture_check.connect("toggled", self._on_capture_toggled)
outer.pack_start(self._capture_check, False, False, 0)
scrolled = Gtk.ScrolledWindow()
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
scrolled.set_hexpand(True)
@@ -369,10 +355,10 @@ class DictationPad:
return self.controller.last_segment
def show_pad(self) -> None:
"""Show the pad window, resyncing capture state from live config."""
"""Show the pad window, refreshing its routing state from live config."""
if not self._gtk_ready or self._window is None:
return
self._sync_capture_check()
self._capture_enabled()
if not self._window.get_visible():
self._window.show_all()
elif self._surface_shelved:
@@ -400,9 +386,8 @@ class DictationPad:
pass
def set_capture_enabled(self, enabled: bool) -> None:
"""Programmatically flip capture mode (keeps the checkbox in sync)."""
"""Update the controller's routing state without persisting it."""
self.controller.set_enabled(enabled)
self._sync_capture_check()
def destroy(self) -> None:
"""Tear down the window and any pending feedback timer."""
@@ -628,19 +613,6 @@ class DictationPad:
self.controller.set_enabled(enabled)
return enabled
def _sync_capture_check(self) -> None:
"""Mirror the persisted capture setting onto the checkbox."""
if self._capture_check is None:
return
try:
enabled = self._capture_enabled()
self._syncing_capture_check = True
self._capture_check.set_active(enabled)
except _ui_errors() as e:
logger.debug("Could not sync dictation pad toggle: %s", e)
finally:
self._syncing_capture_check = False
def _on_delete_event(self, *_args: Any) -> bool:
"""Hide on close instead of destroying — the buffer is session-scoped."""
if self._window is not None:
@@ -694,20 +666,6 @@ class DictationPad:
logger.debug("Could not repaint dictation pad: %s", e)
return False
def _on_capture_toggled(self, widget: Any) -> None:
"""Persist the pad's own capture checkbox."""
if self._syncing_capture_check:
return
enabled = bool(widget.get_active())
self.controller.set_enabled(enabled)
if self._config_manager is not None:
try:
self._config_manager.set("text_injection", "dictate_to_pad", enabled)
self._config_manager.save_settings()
except (AttributeError, TypeError) as e:
logger.warning("Could not save dictation pad setting: %s", e)
logger.info("Dictation pad capture %s", "enabled" if enabled else "disabled")
def _on_copy_all_clicked(self, *_args: Any) -> None:
"""Copy the entire buffer to the clipboard and flash a confirmation."""
# The widget's text may include the user's own in-pad edits; fall back
+58 -31
View File
@@ -2494,6 +2494,7 @@ class SettingsDialog(AboutSectionMixin):
language_shortcuts_update_callback: Optional[Callable[[], None]] = None,
history_update_callback: Optional[Callable[[], None]] = None,
history_clear_callback: Optional[Callable[[], None]] = None,
dictation_pad_open_callback: Optional[Callable[[], None]] = None,
) -> None:
super().__init__(title="Vocalinux Settings", transient_for=parent, flags=0)
# Force window decorations (title-bar close) on all WMs. An in-window
@@ -2513,6 +2514,7 @@ class SettingsDialog(AboutSectionMixin):
self.language_shortcuts_update_callback = language_shortcuts_update_callback
self.history_update_callback = history_update_callback
self.history_clear_callback = history_clear_callback
self.dictation_pad_open_callback = dictation_pad_open_callback
# Per-language shortcut rows (#805): a dict of row widgets per binding,
# populated by _build_language_shortcuts_section.
self._language_shortcut_rows: list[dict] = []
@@ -4347,6 +4349,29 @@ class SettingsDialog(AboutSectionMixin):
logger.info(f"Dictate to pad {'enabled' if enabled else 'disabled'}")
return False
def _add_dictation_pad_open_row(self, group: PreferencesGroup) -> None:
"""Add the optional Dictation Pad launcher row."""
self.dictation_pad_open_button: Optional[Gtk.Button] = None
if self.dictation_pad_open_callback is None:
return
button = Gtk.Button(label="Open")
button.connect("clicked", self._on_dictation_pad_open_clicked)
group.add_row(
PreferenceRow(
title="Open Dictation Pad",
subtitle="Show the Dictation Pad window",
widget=button,
keywords=("pad", "window", "open"),
)
)
self.dictation_pad_open_button = button
def _on_dictation_pad_open_clicked(self, _button: Gtk.Button) -> None:
"""Open the Dictation Pad when its optional launcher is available."""
callback = self.dictation_pad_open_callback
if callback is not None:
callback()
def _on_auto_capitalize_toggled(self, widget, state):
"""Handle toggle of the auto-capitalize switch."""
if _handlers_suppressed(self):
@@ -4921,16 +4946,16 @@ class SettingsDialog(AboutSectionMixin):
self.dictation_pad_switch = _add_switch_row(
output_group,
title="Dictation Pad",
subtitle="Capture dictation in an in-app text box instead of other apps",
title="Dictate into Dictation Pad",
subtitle="Send dictation to the Dictation Pad window instead of other apps",
keywords=("wayland", "pad", "fallback", "text box"),
tooltip=(
"Type dictation into Vocalinux's own Dictation Pad window instead "
"of injecting it into other apps. The pad opens from the tray menu; "
"copy text out of it by hand. Useful on Wayland, where injecting "
"Send dictation to the Dictation Pad instead of injecting it into "
"other apps. Copy text out by hand. Useful on Wayland, where injecting "
"keystrokes into other windows is restricted."
),
)
self._add_dictation_pad_open_row(output_group)
self.append_trailing_space_switch = _add_switch_row(
output_group,
@@ -9255,21 +9280,26 @@ class SettingsDialog(AboutSectionMixin):
time.sleep(delay)
GLib.idle_add(self._finalize_test)
def _finalize_test(self):
def _test_engine_busy(self) -> bool:
"""Return whether recognition can still deliver text to the test callback."""
state = getattr(self.speech_engine, "state", RecognitionState.IDLE)
has_pending_transcription = getattr(self.speech_engine, "has_pending_transcription", None)
return (
state in (RecognitionState.LISTENING, RecognitionState.PROCESSING)
or getattr(self.speech_engine, "_buffered_reload_session", False)
or (callable(has_pending_transcription) and has_pending_transcription() is True)
)
def _finalize_test(self) -> bool:
"""Finalize the test state and UI updates."""
if not self._test_active:
return False
self.speech_engine.stop_recognition()
# Buffered reload's stop_recognition() returns while PROCESSING; the
# worker may still deliver the test utterance via current text_callbacks.
# Keep the test callbacks installed (and block auto-apply / re-test)
# until recognition reaches IDLE so we never restore the live injector
# early. Use GLib polling — never join the worker on the GTK thread.
state = getattr(self.speech_engine, "state", RecognitionState.IDLE)
buffered = getattr(self.speech_engine, "_buffered_reload_session", False)
if state in (RecognitionState.LISTENING, RecognitionState.PROCESSING) or buffered:
# 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")
@@ -9278,8 +9308,7 @@ class SettingsDialog(AboutSectionMixin):
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
return False
# Non-buffered stop already joined to IDLE; brief settle for any
# GLib.idle_add text delivery still in flight.
# Briefly settle for any GLib.idle_add text delivery already in flight.
GLib.timeout_add(500, self._restore_callbacks_and_check_result)
self._test_active = False
@@ -9293,12 +9322,12 @@ class SettingsDialog(AboutSectionMixin):
def _wait_for_idle_then_restore_callbacks(self) -> bool:
"""Poll until recognition is idle, then restore callbacks / check result.
Returns True so GLib.timeout_add keeps scheduling while the buffered
reload worker is still PROCESSING.
Returns True so GLib.timeout_add keeps scheduling while recognition
may still deliver text.
On the ~3 minute timeout, never restore live injector callbacks while a
buffered reload worker may still deliver text (privacy-adjacent Test
Dictation inject-into-app race). Prefer cancelling via
recognition worker may still deliver text (privacy-adjacent Test
Dictation inject-into-app race). Prefer cancelling a buffered reload via
``_cancel_reload_recording`` (off the GTK thread — it joins) and only
then restoring. If cancel is unavailable or fails, keep polling with
test callbacks and ``_test_active`` so live injectors are not restored
@@ -9310,13 +9339,7 @@ class SettingsDialog(AboutSectionMixin):
self.test_button.set_label("Test Dictation")
return False
state = getattr(self.speech_engine, "state", RecognitionState.IDLE)
still_busy = state in (
RecognitionState.LISTENING,
RecognitionState.PROCESSING,
) or getattr(self.speech_engine, "_buffered_reload_session", False)
if still_busy:
if self._test_engine_busy():
self._test_idle_wait_ticks = getattr(self, "_test_idle_wait_ticks", 0) + 1
# ~3 minutes at 100ms — generous for cold large-model reload.
if self._test_idle_wait_ticks < 1800:
@@ -9333,7 +9356,8 @@ class SettingsDialog(AboutSectionMixin):
def _on_test_idle_wait_timeout(self) -> bool:
"""Handle idle-wait timeout without restoring live injectors early."""
cancel = getattr(self.speech_engine, "_cancel_reload_recording", None)
if callable(cancel):
buffered_reload = getattr(self.speech_engine, "_buffered_reload_session", False) is True
if buffered_reload and callable(cancel):
logger.warning(
"Test Dictation: timed out waiting for recognition IDLE; "
"cancelling buffered reload before restoring callbacks"
@@ -9349,8 +9373,8 @@ class SettingsDialog(AboutSectionMixin):
return False
logger.error(
"Test Dictation: timed out waiting for recognition IDLE with no "
"cancel API; keeping test callbacks until IDLE"
"Test Dictation: timed out waiting for recognition IDLE with no safe "
"buffered cancel; keeping test callbacks until idle"
)
self._keep_waiting_after_timeout(
"(Timed out waiting for recognition to finish. "
@@ -9385,7 +9409,10 @@ class SettingsDialog(AboutSectionMixin):
return False
def _finish_test_after_buffered_cancel(self) -> bool:
"""Restore live callbacks only after cancel has discarded the session."""
"""Resume waiting if recognition can still deliver text after cancellation."""
if self._test_engine_busy():
GLib.timeout_add(100, self._wait_for_idle_then_restore_callbacks)
return False
return self._finish_test_restore_ui()
def _keep_waiting_after_timeout(self, message: str) -> None:
+4 -1
View File
@@ -1319,7 +1319,7 @@ class TrayIndicator:
logger.debug("Settings clicked")
self._show_settings_page(None)
def _show_settings_page(self, page_name: Optional[str]):
def _show_settings_page(self, page_name: Optional[str]) -> None:
"""Open settings, optionally focused on a specific sidebar page."""
dialog = self._settings_dialog
if dialog is not None:
@@ -1349,6 +1349,9 @@ class TrayIndicator:
language_shortcuts_update_callback=self.refresh_language_shortcuts,
history_update_callback=self._apply_history_settings,
history_clear_callback=self.clear_transcription_history,
dictation_pad_open_callback=(
self.dictation_pad.show_pad if self.dictation_pad is not None else None
),
)
dialog.connect("response", self._on_settings_dialog_response)
dialog.connect("destroy", self._on_settings_dialog_destroyed)