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.
This commit is contained in:
Jatin K Malik
2026-08-18 17:59:57 -07:00
committed by GitHub
parent fd23ef0d9d
commit d8ff7052cd
11 changed files with 289 additions and 45 deletions
+2 -2
View File
@@ -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:
@@ -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."""
+40 -12
View File
@@ -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)