From d8ff7052cdabfffdfa4f46c60683a337910b28cf Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 18 Aug 2026 17:59:57 -0700 Subject: [PATCH] fix(settings): stop Test Dictation calling a missing model "no speech" (#702) * fix(settings): stop Test Dictation calling a missing model "no speech" start_recognition now returns whether listening actually began. Test Dictation checks that before starting its timer, so a missing or auto-paused model gets a real error instead of "No speech detected". * fix(settings): reconfigure Test Dictation against the live engine UI matching config.json is not enough when speech_engine is still on another engine or model size. Compare those too before the listen timer. * fix(tests): keep settings_dialog lookups on one module _load_settings_dialog() restored sys.modules but left vocalinux.ui.settings_dialog pointing at the private reload. Python 3.9/3.10 mock follows that attribute, so test_whisper_list_and_delete patched a different copy than it imported and the expanduser stub hid the real cache. Point the package attribute back at the restored module, and patch the imported module object in the deletion test. --- src/vocalinux/common_types.py | 4 +- .../speech_recognition/recognition_manager.py | 18 +- src/vocalinux/ui/settings_dialog.py | 52 ++++- tests/test_auto_pause_monitor.py | 2 +- tests/test_download_error_reporting.py | 8 + tests/test_model_deletion.py | 24 +- tests/test_recognition_manager.py | 2 +- tests/test_recognition_manager_core.py | 4 +- .../test_recognition_manager_device_config.py | 6 +- ...est_recognition_manager_download_buffer.py | 6 +- tests/test_settings_test_dictation.py | 208 ++++++++++++++++++ 11 files changed, 289 insertions(+), 45 deletions(-) create mode 100644 tests/test_settings_test_dictation.py diff --git a/src/vocalinux/common_types.py b/src/vocalinux/common_types.py index 2affdf1..c684190 100644 --- a/src/vocalinux/common_types.py +++ b/src/vocalinux/common_types.py @@ -21,8 +21,8 @@ class SpeechRecognitionManagerProtocol(Protocol): state: RecognitionState - def start_recognition(self, mode: str = "toggle") -> None: - """Start the speech recognition process.""" + def start_recognition(self, mode: str = "toggle") -> bool: + """Start the speech recognition process. Returns True if listening began.""" ... def stop_recognition(self) -> None: diff --git a/src/vocalinux/speech_recognition/recognition_manager.py b/src/vocalinux/speech_recognition/recognition_manager.py index 11edca5..69bffec 100644 --- a/src/vocalinux/speech_recognition/recognition_manager.py +++ b/src/vocalinux/speech_recognition/recognition_manager.py @@ -2470,11 +2470,16 @@ class SpeechRecognitionManager: chunk_duration_ms = (1024 / 16000) * 1000 return int(guard_ms / chunk_duration_ms) - def start_recognition(self, mode: str = "toggle"): - """Start the speech recognition process.""" + def start_recognition(self, mode: str = "toggle") -> bool: + """Start the speech recognition process. + + Returns: + True if recognition actually started (state is LISTENING), False if + blocked (wrong state, auto-paused, or model not ready). + """ if self.state != RecognitionState.IDLE: logger.warning(f"Cannot start recognition in current state: {self.state}") - return + return False if self._auto_paused: logger.warning( @@ -2487,7 +2492,7 @@ class SpeechRecognitionManager: "Close that app or remove it from Auto-Pause settings to resume.", "dialog-information", ) - return + return False # Check if model is ready (lazy-reload after idle keep-alive unload) if not self.model_ready: @@ -2501,7 +2506,7 @@ class SpeechRecognitionManager: "Open Settings to check your engine and try again.", "dialog-warning", ) - return + return False else: logger.warning( "Cannot start recognition: model not downloaded. " @@ -2514,7 +2519,7 @@ class SpeechRecognitionManager: "to use dictation.", "dialog-warning", ) - return + return False logger.info("Starting speech recognition") self._update_state(RecognitionState.LISTENING) @@ -2537,6 +2542,7 @@ class SpeechRecognitionManager: self.recognition_thread = threading.Thread(target=self._perform_recognition) self.recognition_thread.daemon = True self.recognition_thread.start() + return True def stop_recognition(self): """Stop the speech recognition process.""" diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index c8ba4aa..89a3274 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -4962,13 +4962,22 @@ class SettingsDialog(Gtk.Dialog): current_config = self.config_manager.get_settings().get("speech_recognition", {}) selected_settings = self.get_selected_settings() + selected_engine = selected_settings.get("engine") + selected_model = selected_settings.get("model_size") + # Compare to the live engine too: UI can match the file while the + # in-memory manager is still on another engine/size (sidecar, pending apply). + live_engine = getattr(self.speech_engine, "engine", None) + live_model = getattr(self.speech_engine, "model_size", None) settings_differ = False - if current_config.get("engine") != selected_settings.get("engine") or current_config.get( - "model_size" - ) != selected_settings.get("model_size"): + if ( + current_config.get("engine") != selected_engine + or current_config.get("model_size") != selected_model + or live_engine != selected_engine + or live_model != selected_model + ): settings_differ = True - elif selected_settings.get("engine") == "vosk": + elif selected_engine == "vosk": if current_config.get("vad_sensitivity") != selected_settings.get( "vad_sensitivity" ) or current_config.get("silence_timeout") != selected_settings.get("silence_timeout"): @@ -4981,21 +4990,40 @@ class SettingsDialog(Gtk.Dialog): return self.test_buffer.set_text("Settings applied. Starting test...") + self.connect_to_recognition_manager() + + self._saved_text_callbacks = self.speech_engine.get_text_callbacks() + self.speech_engine.set_text_callbacks([self._test_text_callback]) + + if not self.speech_engine.start_recognition(): + self.speech_engine.set_text_callbacks(self._saved_text_callbacks) + del self._saved_text_callbacks + self.test_output_revealer.set_reveal_child(True) + if getattr(self.speech_engine, "is_auto_paused", False): + self.test_buffer.set_text( + "Dictation is paused. Close the listed app or remove it from " + "Auto-Pause settings." + ) + elif not getattr(self.speech_engine, "model_ready", True): + self.test_buffer.set_text( + "No speech model downloaded. Open the Speech Model page and " + "download a model to use Test Dictation." + ) + else: + self.test_buffer.set_text("Could not start recognition test.") + return + self._test_active = True self.test_button.set_sensitive(False) self.test_button.set_label("Testing… Speak Now!") self.test_output_revealer.set_reveal_child(True) self.test_buffer.set_text("") self._test_result = "" - - self.connect_to_recognition_manager() self.update_recognition_progress("Listening", info="Starting recognition test...") - self._saved_text_callbacks = self.speech_engine.get_text_callbacks() - self.speech_engine.set_text_callbacks([self._test_text_callback]) - - self.speech_engine.start_recognition() - threading.Thread(target=self._stop_test_after_delay, args=(3,)).start() + # Enough room for one utterance plus the configured silence window. + delay = float(selected_settings.get("silence_timeout", 2.0)) + 2.0 + threading.Thread(target=self._stop_test_after_delay, args=(delay,)).start() def _test_text_callback(self, text: str): """Callback specifically for the test recognition.""" @@ -5011,7 +5039,7 @@ class SettingsDialog(Gtk.Dialog): self.test_textview.scroll_to_mark(mark, 0.0, True, 0.0, 1.0) return False - def _stop_test_after_delay(self, delay: int): + def _stop_test_after_delay(self, delay: float): """Stops the recognition test after a specified delay.""" time.sleep(delay) GLib.idle_add(self._finalize_test) diff --git a/tests/test_auto_pause_monitor.py b/tests/test_auto_pause_monitor.py index 628ea9f..971c3ff 100644 --- a/tests/test_auto_pause_monitor.py +++ b/tests/test_auto_pause_monitor.py @@ -523,7 +523,7 @@ class TestUnloadModelAndAutoPauseBlock(unittest.TestCase): patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"), patch("vocalinux.speech_recognition.recognition_manager._show_notification"), ): - mgr.start_recognition() + self.assertFalse(mgr.start_recognition()) self.assertEqual(mgr.state, RecognitionState.IDLE) # Should not have transitioned to listening diff --git a/tests/test_download_error_reporting.py b/tests/test_download_error_reporting.py index 3c7942c..88c25bf 100644 --- a/tests/test_download_error_reporting.py +++ b/tests/test_download_error_reporting.py @@ -14,7 +14,12 @@ def _load_settings_dialog(): the module as a mock and makes its methods unreachable. Handing the three bases the module subclasses a real class keeps the classes intact, while the rest of GTK stays mocked. + + Restore both sys.modules and the vocalinux.ui.settings_dialog attribute so + 3.9/3.10 mock and later imports see the same module object. """ + import vocalinux.ui as ui_pkg + repository = sys.modules["gi.repository"] bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} saved = sys.modules.pop("vocalinux.ui.settings_dialog", None) @@ -25,6 +30,9 @@ def _load_settings_dialog(): sys.modules.pop("vocalinux.ui.settings_dialog", None) if saved is not None: sys.modules["vocalinux.ui.settings_dialog"] = saved + ui_pkg.settings_dialog = saved + else: + ui_pkg.__dict__.pop("settings_dialog", None) return module diff --git a/tests/test_model_deletion.py b/tests/test_model_deletion.py index d94bc38..6b70984 100644 --- a/tests/test_model_deletion.py +++ b/tests/test_model_deletion.py @@ -92,23 +92,17 @@ def test_whisper_list_and_delete(tmp_path): tiny.write_bytes(b"weights") missing_default = tmp_path / "missing-whisper-cache" - from vocalinux.ui.settings_dialog import ( - _delete_whisper_model, - _list_downloaded_whisper_models, - ) + import vocalinux.ui.settings_dialog as sd + # Patch the same module object we call. String-target patch() on 3.9/3.10 + # follows vocalinux.ui.settings_dialog, which can be a different copy than + # sys.modules after a test-time reload. with ( - patch( - "vocalinux.ui.settings_dialog._get_whisper_cache_dir", - return_value=str(cache), - ), - patch( - "vocalinux.ui.settings_dialog.os.path.expanduser", - return_value=str(missing_default), - ), + patch.object(sd, "_get_whisper_cache_dir", return_value=str(cache)), + patch.object(sd.os.path, "expanduser", return_value=str(missing_default)), ): - assert _list_downloaded_whisper_models() == ["tiny"] - deleted = _delete_whisper_model("tiny") + assert sd._list_downloaded_whisper_models() == ["tiny"] + deleted = sd._delete_whisper_model("tiny") assert deleted == [str(tiny)] assert not tiny.exists() - assert _list_downloaded_whisper_models() == [] + assert sd._list_downloaded_whisper_models() == [] diff --git a/tests/test_recognition_manager.py b/tests/test_recognition_manager.py index 92d0ef6..2d912cd 100644 --- a/tests/test_recognition_manager.py +++ b/tests/test_recognition_manager.py @@ -475,7 +475,7 @@ class TestSpeechRecognition(unittest.TestCase): manager.model = None # Should not start and should play error sound - manager.start_recognition() + self.assertFalse(manager.start_recognition()) self.assertEqual(manager.state, RecognitionState.IDLE) mock_audio_feedback.play_error_sound.assert_called() diff --git a/tests/test_recognition_manager_core.py b/tests/test_recognition_manager_core.py index 1a13918..1261404 100644 --- a/tests/test_recognition_manager_core.py +++ b/tests/test_recognition_manager_core.py @@ -474,7 +474,7 @@ class TestStartStopRecognition(unittest.TestCase): """Test successful recognition start.""" manager = SpeechRecognitionManager(engine="vosk") - manager.start_recognition() + self.assertTrue(manager.start_recognition()) self.assertEqual(manager.state, RecognitionState.LISTENING) self.assertTrue(manager.should_record) @@ -492,7 +492,7 @@ class TestStartStopRecognition(unittest.TestCase): # Second start should not create new threads manager.audio_thread = self.threadInstance manager.recognition_thread = self.threadInstance - manager.start_recognition() + self.assertFalse(manager.start_recognition()) # No new threads created self.threadMock.assert_not_called() diff --git a/tests/test_recognition_manager_device_config.py b/tests/test_recognition_manager_device_config.py index dd3736f..c695117 100644 --- a/tests/test_recognition_manager_device_config.py +++ b/tests/test_recognition_manager_device_config.py @@ -1774,7 +1774,7 @@ class TestStartStopRecognition(unittest.TestCase): manager.state = RecognitionState.LISTENING with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"): - manager.start_recognition() + assert manager.start_recognition() is False # Should return early without starting threads def test_start_recognition_model_not_ready(self): @@ -1785,7 +1785,7 @@ class TestStartStopRecognition(unittest.TestCase): with patch("vocalinux.speech_recognition.recognition_manager.play_error_sound"): with patch("vocalinux.speech_recognition.recognition_manager._show_notification"): - manager.start_recognition() + assert manager.start_recognition() is False assert manager.state == RecognitionState.IDLE # Should not change def test_start_recognition_success(self): @@ -1798,7 +1798,7 @@ class TestStartStopRecognition(unittest.TestCase): with patch("vocalinux.speech_recognition.recognition_manager.play_start_sound"): with patch.object(manager, "_record_audio"): with patch.object(manager, "_perform_recognition"): - manager.start_recognition() + assert manager.start_recognition() is True assert manager.state == RecognitionState.LISTENING assert manager.should_record is True diff --git a/tests/test_recognition_manager_download_buffer.py b/tests/test_recognition_manager_download_buffer.py index 0ac4f05..13ad7ec 100644 --- a/tests/test_recognition_manager_download_buffer.py +++ b/tests/test_recognition_manager_download_buffer.py @@ -584,7 +584,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = True manager.state = RecognitionState.IDLE - manager.start_recognition() + assert manager.start_recognition() is True assert manager.state == RecognitionState.LISTENING assert manager.should_record is True @@ -606,7 +606,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = False manager.state = RecognitionState.IDLE - manager.start_recognition() + assert manager.start_recognition() is False # Should stay idle if model not ready assert manager.state == RecognitionState.IDLE @@ -627,7 +627,7 @@ class TestStartStopRecognition(unittest.TestCase): manager._model_initialized = True manager.state = RecognitionState.LISTENING - manager.start_recognition() + assert manager.start_recognition() is False # Should still be listening (no-op) assert manager.state == RecognitionState.LISTENING diff --git a/tests/test_settings_test_dictation.py b/tests/test_settings_test_dictation.py new file mode 100644 index 0000000..a4399ad --- /dev/null +++ b/tests/test_settings_test_dictation.py @@ -0,0 +1,208 @@ +"""Tests for Settings → Test Dictation start failure vs real no-speech.""" + +import importlib +import sys +from unittest.mock import MagicMock, Mock, patch + +from vocalinux.common_types import RecognitionState + + +def _load_settings_dialog(): + """Import settings_dialog with real bases so unbound methods stay callable. + + conftest swaps gi for a MagicMock, which leaves every ``class X(Gtk.Y)`` as + a mock. Handing the three bases a real class keeps the methods intact. + + Restore both sys.modules and the vocalinux.ui.settings_dialog attribute. + Python 3.9/3.10 mock follows the package attribute, so leaving a private + copy there makes later tests patch a different module than they imported. + """ + import vocalinux.ui as ui_pkg + + repository = sys.modules["gi.repository"] + bases = {name: type(name, (), {}) for name in ("Box", "ListBoxRow", "Dialog")} + saved = sys.modules.pop("vocalinux.ui.settings_dialog", None) + try: + with patch.object(repository, "Gtk", MagicMock(**bases)): + module = importlib.import_module("vocalinux.ui.settings_dialog") + finally: + sys.modules.pop("vocalinux.ui.settings_dialog", None) + if saved is not None: + sys.modules["vocalinux.ui.settings_dialog"] = saved + ui_pkg.settings_dialog = saved + else: + ui_pkg.__dict__.pop("settings_dialog", None) + return module + + +settings_dialog = _load_settings_dialog() +SettingsDialog = settings_dialog.SettingsDialog + + +def _text_buffer(): + """Minimal stand-in for Gtk.TextBuffer used by the test output pane.""" + buf = Mock() + buf._text = "" + + def set_text(text): + buf._text = text + + def get_text(*_args, **_kwargs): + return buf._text + + def insert(_iter, text): + buf._text += text + + buf.set_text.side_effect = set_text + buf.get_text.side_effect = get_text + buf.insert.side_effect = insert + buf.get_start_iter.return_value = Mock() + buf.get_end_iter.return_value = Mock() + buf.get_insert.return_value = Mock() + return buf + + +def _dialog_for_test(*, start_return, model_ready=True, is_auto_paused=False): + dialog = Mock() + dialog._test_active = False + dialog.test_button = Mock() + dialog.test_output_revealer = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_textview = Mock() + dialog.config_manager = Mock() + dialog.config_manager.get_settings.return_value = { + "speech_recognition": { + "engine": "whisper_cpp", + "model_size": "tiny", + "silence_timeout": 2.0, + "vad_sensitivity": 3, + } + } + dialog.get_selected_settings = Mock( + return_value={ + "engine": "whisper_cpp", + "model_size": "tiny", + "silence_timeout": 2.0, + "vad_sensitivity": 3, + } + ) + dialog.apply_settings = Mock(return_value=True) + dialog.connect_to_recognition_manager = Mock() + dialog.update_recognition_progress = Mock() + dialog._test_text_callback = Mock() + dialog._stop_test_after_delay = SettingsDialog._stop_test_after_delay.__get__(dialog) + + engine = Mock() + engine.state = RecognitionState.IDLE + engine.engine = "whisper_cpp" + engine.model_size = "tiny" + engine.start_recognition = Mock(return_value=start_return) + engine.stop_recognition = Mock() + engine.get_text_callbacks = Mock(return_value=[]) + engine.set_text_callbacks = Mock() + engine.model_ready = model_ready + engine.is_auto_paused = is_auto_paused + dialog.speech_engine = engine + return dialog + + +def test_test_dictation_missing_model_skips_listen_timer(): + dialog = _dialog_for_test(start_return=False, model_ready=False) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert dialog._test_active is False + assert "Speech Model page" in dialog.test_buffer._text + assert "No speech detected" not in dialog.test_buffer._text + dialog.speech_engine.set_text_callbacks.assert_any_call([]) + dialog.test_button.set_label.assert_not_called() + dialog.test_output_revealer.set_reveal_child.assert_called_with(True) + + +def test_test_dictation_auto_paused_message(): + dialog = _dialog_for_test(start_return=False, is_auto_paused=True) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert "paused" in dialog.test_buffer._text.lower() + assert "No speech detected" not in dialog.test_buffer._text + + +def test_test_dictation_start_failure_generic_message(): + dialog = _dialog_for_test(start_return=False, model_ready=True, is_auto_paused=False) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_not_called() + assert dialog.test_buffer._text == "Could not start recognition test." + + +def test_test_dictation_started_arms_listen_timer(): + dialog = _dialog_for_test(start_return=True) + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + thread_cls.assert_called_once() + assert thread_cls.call_args.kwargs["args"] == (4.0,) + assert dialog._test_active is True + dialog.test_button.set_label.assert_called_with("Testing… Speak Now!") + dialog.apply_settings.assert_not_called() + + +def test_test_dictation_reconfigures_when_live_engine_differs_from_ui(): + """UI matches the file but the live manager is still on another engine.""" + dialog = _dialog_for_test(start_return=True, model_ready=True) + dialog.speech_engine.engine = "vosk" + dialog.speech_engine.model_size = "small" + dialog.speech_engine.model_ready = False + + def _apply_and_sync(): + dialog.speech_engine.engine = "whisper_cpp" + dialog.speech_engine.model_size = "tiny" + dialog.speech_engine.model_ready = True + return True + + dialog.apply_settings.side_effect = _apply_and_sync + + with patch.object(settings_dialog.threading, "Thread") as thread_cls: + SettingsDialog._on_test_clicked(dialog, None) + + dialog.apply_settings.assert_called_once() + thread_cls.assert_called_once() + assert dialog._test_active is True + + +def test_check_test_result_empty_buffer_is_no_speech(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_buffer.set_text("") + + SettingsDialog._check_test_result(dialog) + + assert dialog.test_buffer._text == "(No speech detected during test)" + + +def test_check_test_result_keeps_captured_text(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_buffer.set_text("hello world") + + SettingsDialog._check_test_result(dialog) + + assert dialog.test_buffer._text == "hello world" + + +def test_append_test_result_shows_callback_text(): + dialog = Mock() + dialog.test_buffer = _text_buffer() + dialog.test_textview = Mock() + + SettingsDialog._append_test_result(dialog, "hello") + + assert dialog.test_buffer._text == "hello"