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)
+1 -1
View File
@@ -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
+8
View File
@@ -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
+9 -15
View File
@@ -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() == []
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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()
@@ -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
@@ -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
+208
View File
@@ -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"