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)
|
||||
|
||||
@@ -7,6 +7,7 @@ callbacks — not a parallel reimplementation of the match logic.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -461,6 +462,8 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase):
|
||||
mgr._faster_whisper_engine = None
|
||||
mgr._reconnection_attempts = 0
|
||||
mgr._update_state = MagicMock()
|
||||
mgr._recognition_workers = []
|
||||
mgr._recognition_workers_lock = threading.Lock()
|
||||
mgr._init_vosk = MagicMock()
|
||||
mgr._init_whisper = MagicMock()
|
||||
mgr._init_whispercpp = MagicMock()
|
||||
|
||||
@@ -294,22 +294,45 @@ class TestDictationPadFacade(unittest.TestCase):
|
||||
finally:
|
||||
pad.destroy()
|
||||
|
||||
def test_capture_checkbox_persists_config(self) -> None:
|
||||
"""The pad checkbox writes dictate_to_pad via the config manager."""
|
||||
def test_pad_actions_never_write_config(self) -> None:
|
||||
"""The pad receives edits but never persists the routing setting."""
|
||||
config_manager = MagicMock()
|
||||
with patch.object(
|
||||
DictationPad,
|
||||
"_init_gtk_window",
|
||||
side_effect=RuntimeError("gtk disabled in unit tests"),
|
||||
):
|
||||
pad = DictationPad(enabled=False, config_manager=config_manager)
|
||||
pad = DictationPad(enabled=True, config_manager=config_manager)
|
||||
try:
|
||||
widget = MagicMock()
|
||||
widget.get_active.return_value = True
|
||||
pad._on_capture_toggled(widget)
|
||||
config_manager.set.assert_called_once_with("text_injection", "dictate_to_pad", True)
|
||||
config_manager.save_settings.assert_called_once()
|
||||
pad.append_text("hello world")
|
||||
pad.handle_action("undo")
|
||||
pad.handle_action("redo")
|
||||
pad.delete_last_chars(5)
|
||||
pad.set_capture_enabled(False)
|
||||
pad._on_clear_clicked()
|
||||
|
||||
config_manager.set.assert_not_called()
|
||||
config_manager.save_settings.assert_not_called()
|
||||
finally:
|
||||
pad.destroy()
|
||||
|
||||
def test_controller_routing_state_tracks_external_config_changes(self) -> None:
|
||||
"""The pad reads Settings changes and syncs its controller without writing."""
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_bool.side_effect = [False, True]
|
||||
with patch.object(
|
||||
DictationPad,
|
||||
"_init_gtk_window",
|
||||
side_effect=RuntimeError("gtk disabled in unit tests"),
|
||||
):
|
||||
pad = DictationPad(enabled=True, config_manager=config_manager)
|
||||
try:
|
||||
self.assertFalse(pad._capture_enabled())
|
||||
self.assertFalse(pad.controller.enabled)
|
||||
self.assertTrue(pad._capture_enabled())
|
||||
self.assertTrue(pad.controller.enabled)
|
||||
config_manager.set.assert_not_called()
|
||||
config_manager.save_settings.assert_not_called()
|
||||
finally:
|
||||
pad.destroy()
|
||||
|
||||
|
||||
@@ -1096,5 +1096,194 @@ class TestVoiceCommandsProperty(unittest.TestCase):
|
||||
self.assertFalse(mgr._resolve_voice_commands_enabled())
|
||||
|
||||
|
||||
class TestPendingTranscription(unittest.TestCase):
|
||||
def test_live_non_current_worker_is_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def worker() -> None:
|
||||
started.set()
|
||||
release.wait(timeout=2.0)
|
||||
|
||||
thread = threading.Thread(target=worker, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
manager.recognition_thread = thread
|
||||
self.assertTrue(manager.has_pending_transcription())
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(timeout=1.0)
|
||||
|
||||
def test_superseded_worker_is_still_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def worker_a() -> None:
|
||||
started.set()
|
||||
release.wait(timeout=2.0)
|
||||
|
||||
def worker_b() -> None:
|
||||
return
|
||||
|
||||
first = threading.Thread(target=worker_a, daemon=True)
|
||||
manager._track_recognition_worker(first)
|
||||
first.start()
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
|
||||
second = threading.Thread(target=worker_b, daemon=True)
|
||||
second.start()
|
||||
second.join(timeout=1.0)
|
||||
manager.recognition_thread = second
|
||||
|
||||
try:
|
||||
self.assertTrue(manager.has_pending_transcription())
|
||||
finally:
|
||||
release.set()
|
||||
first.join(timeout=1.0)
|
||||
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
def test_track_recognition_worker_prunes_finished_workers(self) -> None:
|
||||
manager = _make_manager()
|
||||
|
||||
def finished_worker() -> None:
|
||||
return
|
||||
|
||||
finished = threading.Thread(target=finished_worker, daemon=True)
|
||||
finished.start()
|
||||
finished.join(timeout=1.0)
|
||||
manager._track_recognition_worker(finished)
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def active_worker() -> None:
|
||||
started.set()
|
||||
release.wait(timeout=2.0)
|
||||
|
||||
active = threading.Thread(target=active_worker, daemon=True)
|
||||
manager._track_recognition_worker(active)
|
||||
active.start()
|
||||
|
||||
try:
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
self.assertEqual(manager._recognition_workers, [active])
|
||||
finally:
|
||||
release.set()
|
||||
active.join(timeout=1.0)
|
||||
|
||||
def test_start_recognition_tracks_worker(self) -> None:
|
||||
manager = _make_manager()
|
||||
manager._model_initialized = True
|
||||
manager.model = MagicMock()
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def record_audio() -> None:
|
||||
return
|
||||
|
||||
def perform_recognition() -> None:
|
||||
started.set()
|
||||
release.wait(timeout=2.0)
|
||||
|
||||
manager._record_audio = record_audio
|
||||
manager._perform_recognition = perform_recognition
|
||||
|
||||
try:
|
||||
with (
|
||||
patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"),
|
||||
patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"),
|
||||
patch.object(manager, "_arm_playback_duck"),
|
||||
):
|
||||
self.assertTrue(manager.start_recognition())
|
||||
worker = manager.recognition_thread
|
||||
self.assertIsNotNone(worker)
|
||||
self.assertIn(worker, manager._recognition_workers)
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
release.set()
|
||||
manager.stop_recognition()
|
||||
finally:
|
||||
release.set()
|
||||
if manager.recognition_thread is not None:
|
||||
manager.recognition_thread.join(timeout=1.0)
|
||||
|
||||
def test_queued_audio_without_live_worker_is_not_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
manager._segment_queue.put(([], "en"))
|
||||
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
def test_idle_manager_without_queued_audio_is_not_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
def test_stop_recognition_signal_is_not_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
manager.state = RecognitionState.LISTENING
|
||||
manager.should_record = True
|
||||
started = threading.Event()
|
||||
|
||||
def worker() -> None:
|
||||
started.set()
|
||||
manager._perform_recognition()
|
||||
|
||||
thread = threading.Thread(target=worker, daemon=True)
|
||||
manager.recognition_thread = thread
|
||||
thread.start()
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
|
||||
with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"):
|
||||
manager.stop_recognition()
|
||||
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
def test_orphaned_final_segment_is_not_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
manager.state = RecognitionState.LISTENING
|
||||
manager.should_record = True
|
||||
started = threading.Event()
|
||||
audio_release = threading.Event()
|
||||
|
||||
def worker() -> None:
|
||||
started.set()
|
||||
manager._perform_recognition()
|
||||
|
||||
def audio_worker() -> None:
|
||||
audio_release.wait(timeout=1.0)
|
||||
|
||||
recognition_thread = threading.Thread(target=worker, daemon=True)
|
||||
manager.recognition_thread = recognition_thread
|
||||
recognition_thread.start()
|
||||
self.assertTrue(started.wait(timeout=1.0))
|
||||
|
||||
audio_thread = threading.Thread(target=audio_worker, daemon=True)
|
||||
manager.audio_thread = audio_thread
|
||||
audio_thread.start()
|
||||
manager.audio_buffer = [b"\x00\x00"] * 8
|
||||
manager._recording_segment_has_speech = True
|
||||
|
||||
try:
|
||||
with patch("vocalinux.speech_recognition.recognition_manager.play_stop_sound"):
|
||||
manager.stop_recognition()
|
||||
finally:
|
||||
audio_release.set()
|
||||
audio_thread.join(timeout=1.0)
|
||||
recognition_thread.join(timeout=1.0)
|
||||
|
||||
with manager._segment_queue.mutex:
|
||||
self.assertTrue(any(item is not None for item in manager._segment_queue.queue))
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
def test_worker_does_not_count_itself_as_pending(self) -> None:
|
||||
manager = _make_manager()
|
||||
manager.recognition_thread = threading.current_thread()
|
||||
|
||||
self.assertFalse(manager.has_pending_transcription())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -252,6 +252,7 @@ def _dialog_for_finalize(
|
||||
buffered_reload: bool = False,
|
||||
saved_callbacks: Optional[list[Any]] = None,
|
||||
has_cancel: bool = True,
|
||||
pending_transcription: bool = False,
|
||||
) -> Mock:
|
||||
"""Minimal dialog wired for _finalize_test / idle-wait helpers."""
|
||||
dialog = Mock()
|
||||
@@ -281,6 +282,7 @@ 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._test_engine_busy = SettingsDialog._test_engine_busy.__get__(dialog)
|
||||
dialog._check_test_result = Mock(return_value=False)
|
||||
|
||||
if has_cancel:
|
||||
@@ -294,16 +296,72 @@ def _dialog_for_finalize(
|
||||
"_buffered_reload_session",
|
||||
"stop_recognition",
|
||||
"set_text_callbacks",
|
||||
"has_pending_transcription",
|
||||
]
|
||||
)
|
||||
engine.state = state
|
||||
engine._buffered_reload_session = buffered_reload
|
||||
engine.has_pending_transcription = Mock(return_value=pending_transcription)
|
||||
engine.stop_recognition = Mock()
|
||||
engine.set_text_callbacks = Mock()
|
||||
dialog.speech_engine = engine
|
||||
return dialog
|
||||
|
||||
|
||||
def test_open_dictation_pad_row_invokes_callback_on_click() -> None:
|
||||
callback = Mock()
|
||||
dialog = Mock()
|
||||
dialog.dictation_pad_open_callback = callback
|
||||
dialog._on_dictation_pad_open_clicked = SettingsDialog._on_dictation_pad_open_clicked.__get__(
|
||||
dialog
|
||||
)
|
||||
group = Mock()
|
||||
button = Mock()
|
||||
row = Mock()
|
||||
|
||||
with (
|
||||
patch.object(settings_dialog.Gtk, "Button", return_value=button),
|
||||
patch.object(settings_dialog, "PreferenceRow", return_value=row) as row_factory,
|
||||
):
|
||||
SettingsDialog._add_dictation_pad_open_row(dialog, group)
|
||||
|
||||
group.add_row.assert_called_once_with(row)
|
||||
row_factory.assert_called_once_with(
|
||||
title="Open Dictation Pad",
|
||||
subtitle="Show the Dictation Pad window",
|
||||
widget=button,
|
||||
keywords=("pad", "window", "open"),
|
||||
)
|
||||
click_callback = button.connect.call_args.args[1]
|
||||
click_callback(button)
|
||||
callback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_open_dictation_pad_row_is_absent_without_callback() -> None:
|
||||
dialog = Mock()
|
||||
dialog.dictation_pad_open_callback = None
|
||||
group = Mock()
|
||||
|
||||
with patch.object(settings_dialog.Gtk, "Button") as button_factory:
|
||||
SettingsDialog._add_dictation_pad_open_row(dialog, group)
|
||||
|
||||
button_factory.assert_not_called()
|
||||
group.add_row.assert_not_called()
|
||||
assert dialog.dictation_pad_open_button is None
|
||||
|
||||
|
||||
def test_dictation_pad_switch_persists_routing_preference() -> None:
|
||||
dialog = Mock()
|
||||
dialog._initializing = False
|
||||
dialog._applying_settings = False
|
||||
dialog.config_manager = Mock()
|
||||
|
||||
assert SettingsDialog._on_dictation_pad_toggled(dialog, Mock(), True) is False
|
||||
|
||||
dialog.config_manager.set.assert_called_once_with("text_injection", "dictate_to_pad", True)
|
||||
dialog.config_manager.save_settings.assert_called_once()
|
||||
|
||||
|
||||
def test_finalize_test_buffered_reload_waits_for_idle_before_restore() -> None:
|
||||
"""stop_recognition may return while PROCESSING; keep test callbacks."""
|
||||
dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True)
|
||||
@@ -354,7 +412,7 @@ def test_wait_for_idle_treats_buffered_flag_as_busy_even_if_state_idle() -> None
|
||||
|
||||
|
||||
def test_finalize_test_idle_path_uses_settle_delay() -> None:
|
||||
"""Non-buffered stop already reached IDLE — keep the legacy 500ms settle."""
|
||||
"""When no work remains, keep the legacy 500ms settle."""
|
||||
dialog = _dialog_for_finalize(state=RecognitionState.IDLE, buffered_reload=False)
|
||||
|
||||
with patch.object(settings_dialog, "GLib") as glib:
|
||||
@@ -368,6 +426,40 @@ def test_finalize_test_idle_path_uses_settle_delay() -> None:
|
||||
assert hasattr(dialog, "_saved_text_callbacks")
|
||||
|
||||
|
||||
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] = []
|
||||
|
||||
def live_callback(text: str) -> None:
|
||||
live_text.append(text)
|
||||
|
||||
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._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:
|
||||
glib.idle_add.side_effect = lambda callback, *args: callback(*args)
|
||||
SettingsDialog._finalize_test(dialog)
|
||||
|
||||
dialog.speech_engine.set_text_callbacks.assert_not_called()
|
||||
assert dialog._test_active is True
|
||||
|
||||
dialog._test_text_callback("hello world")
|
||||
assert dialog.test_buffer._text == "hello world"
|
||||
assert live_text == []
|
||||
|
||||
assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is True
|
||||
dialog.speech_engine.set_text_callbacks.assert_not_called()
|
||||
assert SettingsDialog._wait_for_idle_then_restore_callbacks(dialog) is False
|
||||
|
||||
dialog.speech_engine.set_text_callbacks.assert_called_once_with([live_callback])
|
||||
assert live_text == []
|
||||
assert not hasattr(dialog, "_saved_text_callbacks")
|
||||
glib.timeout_add.assert_any_call(100, dialog._wait_for_idle_then_restore_callbacks)
|
||||
|
||||
|
||||
def test_timeout_does_not_restore_live_while_buffered_starts_cancel() -> None:
|
||||
"""At ~3min timeout while buffered, cancel off-thread; do not restore yet."""
|
||||
dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True)
|
||||
@@ -387,6 +479,26 @@ def test_timeout_does_not_restore_live_while_buffered_starts_cancel() -> None:
|
||||
assert thread_cls.call_args.kwargs["daemon"] is True
|
||||
|
||||
|
||||
def test_nonbuffered_timeout_keeps_waiting_for_pending_transcription() -> None:
|
||||
"""Do not invoke buffered cancellation for a non-buffered active worker."""
|
||||
dialog = _dialog_for_finalize(
|
||||
state=RecognitionState.IDLE,
|
||||
pending_transcription=True,
|
||||
)
|
||||
dialog._test_idle_wait_ticks = 1799
|
||||
|
||||
with patch.object(settings_dialog.threading, "Thread") as thread_cls:
|
||||
result = SettingsDialog._wait_for_idle_then_restore_callbacks(dialog)
|
||||
|
||||
assert result is True
|
||||
thread_cls.assert_not_called()
|
||||
dialog.speech_engine._cancel_reload_recording.assert_not_called()
|
||||
dialog.speech_engine.set_text_callbacks.assert_not_called()
|
||||
assert dialog._test_active is True
|
||||
assert dialog._test_idle_wait_ticks == 1800
|
||||
assert dialog._test_timeout_cancel_attempted is True
|
||||
|
||||
|
||||
def test_timeout_without_cancel_keeps_waiting_active() -> None:
|
||||
"""If cancel API is missing, keep polling with test callbacks / _test_active."""
|
||||
dialog = _dialog_for_finalize(
|
||||
@@ -429,6 +541,21 @@ def test_finish_after_buffered_cancel_restores_live_callbacks() -> None:
|
||||
glib.timeout_add.assert_called_once_with(300, dialog._check_test_result)
|
||||
|
||||
|
||||
def test_finish_after_buffered_cancel_resumes_waiting_if_work_remains() -> None:
|
||||
dialog = _dialog_for_finalize(
|
||||
state=RecognitionState.IDLE,
|
||||
pending_transcription=True,
|
||||
)
|
||||
|
||||
with patch.object(settings_dialog, "GLib") as glib:
|
||||
result = SettingsDialog._finish_test_after_buffered_cancel(dialog)
|
||||
|
||||
assert result is False
|
||||
dialog.speech_engine.set_text_callbacks.assert_not_called()
|
||||
assert dialog._test_active is True
|
||||
glib.timeout_add.assert_called_once_with(100, dialog._wait_for_idle_then_restore_callbacks)
|
||||
|
||||
|
||||
def test_cancel_buffered_reload_then_restore_schedules_finish() -> None:
|
||||
dialog = _dialog_for_finalize(state=RecognitionState.PROCESSING, buffered_reload=True)
|
||||
|
||||
|
||||
@@ -1024,6 +1024,23 @@ class TestTrayIndicator(unittest.TestCase):
|
||||
self.tray_indicator.set_overlay_enabled,
|
||||
)
|
||||
|
||||
def test_settings_dialog_receives_dictation_pad_open_callback(self) -> None:
|
||||
"""Settings receives the live pad's show callback when one exists."""
|
||||
import vocalinux.ui.tray_indicator as tray_module
|
||||
|
||||
pad = MagicMock()
|
||||
self.tray_indicator.dictation_pad = pad
|
||||
self.tray_indicator._settings_dialog = None
|
||||
mock_dialog_class = MagicMock(return_value=MagicMock())
|
||||
|
||||
with patch.object(tray_module, "SettingsDialog", mock_dialog_class):
|
||||
self.tray_indicator._on_settings_clicked(None)
|
||||
|
||||
self.assertIs(
|
||||
mock_dialog_class.call_args.kwargs["dictation_pad_open_callback"],
|
||||
pad.show_pad,
|
||||
)
|
||||
|
||||
def test_quit_destroys_overlay(self):
|
||||
mock_overlay = MagicMock()
|
||||
self.tray_indicator.overlay = mock_overlay
|
||||
|
||||
Reference in New Issue
Block a user