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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user