fix(tray): show the tray before the speech model loads
CI / security (pull_request) Successful in 1m12s
CI / lint (pull_request) Successful in 24s
CI / ai-review (pull_request) Successful in 8s

The model load ran inside SpeechRecognitionManager's constructor, so the
tray was not built until the model was in memory and the app looked dead
for the whole load. The manager can now defer its first load
(defer_model_load). main() builds the tray first, then starts the load on
a background thread from the GTK main loop.

While the model loads the tray shows the processing icon, a "Loading
model…" title, and disables Start/Stop Voice Typing. Dictation attempted
during the load is refused with an error sound and a "Speech model
loading" notification; it is not queued. A failed load leaves the tray in
the Error state and shows a notification instead of exiting. Keep-alive
does not unload while the first load runs. If an auto-pause is already
active when the load would start, the load is skipped and auto-resume
loads the model later.

The model ladder, legacy import, D-Bus service and single-instance lock
are unchanged.

Fixes #50
This commit is contained in:
2026-11-07 01:28:25 +05:30
parent 0c48db5b5e
commit 9b91b8b177
7 changed files with 516 additions and 16 deletions
+7
View File
@@ -621,6 +621,8 @@ def main():
remote_api_key=saved_settings.get("remote_api_key", ""),
remote_api_endpoint=saved_settings.get("remote_api_endpoint", "/inference"),
remote_api_model=saved_settings.get("remote_api_model", "whisper-1"),
# The model loads in the background so the tray can appear first (#50).
defer_model_load=True,
)
# Initialize text injection system
@@ -1382,6 +1384,11 @@ def main():
dictation_pad=dictation_pad,
)
# Start the model load from the GTK main loop, after the tray's own idle
# callback has put the icon up. The load runs on its own thread, so the
# tray is usable while it loads (#50).
GLib.idle_add(speech_engine.load_model_in_background)
# Start the GTK main loop
indicator.run()
@@ -288,6 +288,9 @@ def resolve_language_preference(language: str) -> str:
logger = logging.getLogger(__name__)
# Engine names the constructor accepts; checked up front when the load is deferred (#50).
_SUPPORTED_ENGINES = ("vosk", "whisper", "whisper_cpp", "parakeet", "faster_whisper", "remote_api")
_WHISPERCPP_ABORT_CALLBACK_SUPPORT: dict[type, bool] = {}
@@ -570,6 +573,12 @@ class SpeechRecognitionManager:
# cancel-and-retry cannot stack unaccounted request threads.
self._download_openers: set[threading.Thread] = set()
self._defer_download = defer_download
# Startup may defer the first model load to a background thread (#50):
# the engine reports model_loading until that load has finished.
self._model_loading = bool(kwargs.get("defer_model_load", False))
self._model_load_started = False
self._model_load_thread: Optional[threading.Thread] = None
self._model_load_callbacks: list[Callable[[], None]] = []
self._model_initialized = False
# True while a configured app/game blocks dictation, including pending unloads
self._auto_paused = False
@@ -690,21 +699,14 @@ class SpeechRecognitionManager:
f"Initializing speech recognition with {engine} engine, {self.language} language and {model_size} model"
)
# Initialize the selected speech recognition engine
if engine == "vosk":
self._init_vosk()
elif engine == "whisper":
self._init_whisper()
elif engine == "whisper_cpp":
self._init_whispercpp()
elif engine == "parakeet":
self._init_parakeet()
elif engine == "faster_whisper":
self._init_faster_whisper()
elif engine == "remote_api":
self._init_remote_api()
# Initialize the selected speech recognition engine. A deferred load is
# started later by load_model_in_background(); the engine name is still
# checked now so a bad name fails at construction, as before.
if self._model_loading:
if engine not in _SUPPORTED_ENGINES:
raise ValueError(f"Unsupported speech recognition engine: {engine}")
else:
raise ValueError(f"Unsupported speech recognition engine: {engine}")
self._init_selected_engine()
@staticmethod
def _normalize_language_candidates(value: str | list[str] | None) -> list[str]:
@@ -3300,6 +3302,83 @@ class SpeechRecognitionManager:
for callback in self.state_callbacks:
callback(new_state)
@property
def model_loading(self) -> bool:
"""True from a deferred construction until its background load has finished (#50)."""
return bool(getattr(self, "_model_loading", False))
def register_model_load_callback(self, callback: Callable[[], None]) -> None:
"""Call ``callback()`` on the loader thread once a deferred load has finished.
Fires whether the load succeeded, failed or was skipped. Consumers marshal
onto the GTK main loop themselves (#50).
"""
self._model_load_callbacks.append(callback)
def load_model_in_background(self) -> None:
"""Start the deferred first model load on a daemon thread (#50).
A no-op unless the manager was built with ``defer_model_load=True``, and
only the first call starts a load. The request token is taken here, on
the caller's thread, so an auto-pause that lands before the loader gets
the model lock supersedes the load instead of racing it.
"""
if not self._model_loading or self._model_load_started:
return
self._model_load_started = True
request = self._new_model_request()
self._model_load_thread = threading.Thread(
target=self._run_background_model_load,
args=(request,),
name="voculinux-model-load",
daemon=True,
)
self._model_load_thread.start()
def _run_background_model_load(self, request: object) -> None:
"""Initialize the configured engine on the loader thread (#50).
Shares the model lock and request ownership with resume, but skips the
resume-only bookkeeping: a startup load publishes the model and nothing
else. The engine's own loading ladder and fallbacks run unchanged.
"""
started = time.monotonic()
try:
with self._model_lock:
# An auto-pause that is already active (a listed app is running)
# must keep the model out of memory; auto-resume loads it later.
if self._auto_paused or not self._is_current_model_request(request):
logger.info("Speech model load skipped: auto-paused or superseded")
else:
with self._model_request_context(request):
self._init_selected_engine()
logger.info("Speech model loaded in %.2fs", time.monotonic() - started)
except Exception as error:
logger.error("Speech model failed to load", exc_info=True)
if self._is_current_model_request(request):
self._update_state(RecognitionState.ERROR, model_request=request)
_show_notification(
"Speech model failed to load",
f"{error}. Restart Voculinux to try again.",
"dialog-error",
)
finally:
self._model_loading = False
self._notify_model_load_callbacks()
def _is_current_model_request(self, request: object) -> bool:
"""Whether no auto-pause or reconfigure has superseded ``request``."""
with getattr(self, "_model_request_lock", nullcontext()):
return self._model_request is request
def _notify_model_load_callbacks(self) -> None:
"""Run each load-state callback; one failing consumer must not stop the rest."""
for callback in list(self._model_load_callbacks):
try:
callback()
except Exception:
logger.debug("Model load callback failed", exc_info=True)
@property
def model_ready(self) -> bool:
"""Check if the model is initialized and ready for recognition."""
@@ -3501,6 +3580,17 @@ class SpeechRecognitionManager:
return False
reload_in_background = False
# The first load (#50) is still running. Nothing can be dictated yet, and
# the missing-model offer below would be wrong, so say that instead.
if self.model_loading and not self.model_ready:
logger.info("Cannot start recognition: speech model is still loading")
play_error_sound()
_show_notification(
"Speech model loading",
"Dictation will be available once the speech model has finished loading.",
"dialog-information",
)
return False
# Check if model is ready (lazy-reload after idle keep-alive unload)
if not self.model_ready:
if self._idle_unloaded:
@@ -3932,7 +4022,13 @@ class SpeechRecognitionManager:
# select it.
self._refuse_language_override(language)
return False
if target != self.language and not self._serving_model_supports_language_switch():
# While the first load runs there is no serving model to judge yet; the
# start itself is refused with the loading notice.
if (
target != self.language
and not self.model_loading
and not self._serving_model_supports_language_switch()
):
self._refuse_language_override(language)
return False
+4
View File
@@ -9346,6 +9346,10 @@ class SettingsDialog(AboutSectionMixin):
"Dictation is paused. Close the listed app or remove it from "
"Auto-Pause settings."
)
elif getattr(self.speech_engine, "model_loading", False) is True:
self.test_buffer.set_text(
"The speech model is still loading. Try Test Dictation again in a moment."
)
elif not getattr(self.speech_engine, "model_ready", True):
self.test_buffer.set_text(
"No speech model downloaded. Open the Speech Model page and "
+36 -1
View File
@@ -161,6 +161,9 @@ _INPUT_SETTLE_SECONDS = 2
_INPUT_MONITOR_CAP_SECONDS = 10
_FALLBACK_KEYBOARD_RESTART_SECONDS = 6
# Tray title while the speech model loads in the background (#50).
LOADING_TITLE = "Loading model…"
class TrayIndicator:
"""
@@ -282,6 +285,11 @@ class TrayIndicator:
# Register for speech recognition state changes
self.speech_engine.register_state_callback(self._on_recognition_state_changed)
# The model may still be loading in the background (#50); re-render when
# that load finishes.
register_load_callback = getattr(self.speech_engine, "register_model_load_callback", None)
if callable(register_load_callback):
register_load_callback(self._on_model_load_changed)
# Offer the download instead of only telling the user a model is missing
self._model_download_active = False
@@ -913,6 +921,25 @@ class TrayIndicator:
if isinstance(indicator, GMenuIndicator):
indicator.refresh_menu()
def _engine_model_loading(self) -> bool:
"""True only while the engine reports its first model load in progress (#50)."""
return getattr(self.speech_engine, "model_loading", False) is True
def _on_model_load_changed(self) -> None:
"""Model load finished. Runs on the loader thread, so marshal it (#50)."""
GLib.idle_add(self._apply_model_load_state)
def _apply_model_load_state(self) -> bool:
"""Re-render after a model load changed state (GTK main loop only, #50)."""
if not self._engine_model_loading():
# Keep-alive refuses to unload while the load runs; restart its
# idle timer now that the model is in memory.
keepalive = getattr(self, "_model_keepalive", None)
if keepalive is not None:
keepalive.bump()
self._update_ui(getattr(self.speech_engine, "state", RecognitionState.IDLE))
return False # One-shot idle callback
def _on_recognition_state_changed(self, state: RecognitionState):
"""
Handle changes in the speech recognition state.
@@ -1323,7 +1350,12 @@ class TrayIndicator:
show_cancel = state == RecognitionState.PROCESSING and transcribing
self._set_cancel_menu_item_visible(show_cancel)
if state == RecognitionState.IDLE:
if state == RecognitionState.IDLE and self._engine_model_loading():
self._set_indicator_title(LOADING_TITLE)
self._set_indicator_icon(self._icon_keys["processing"], "Loading speech model")
self._set_menu_item_enabled("Start Voice Typing", False)
self._set_menu_item_enabled("Stop Voice Typing", False)
elif state == RecognitionState.IDLE:
self._set_indicator_title(TRAY_TITLE)
self._set_indicator_icon(self._icon_keys["default"], "Microphone off")
self._set_menu_item_enabled("Start Voice Typing", True)
@@ -1714,6 +1746,9 @@ class TrayIndicator:
def _is_safe_for_keepalive_unload(self) -> bool:
"""Keep-alive may unload only when idle and not under auto-pause."""
if self._engine_model_loading():
# The first load (#50) is still putting the model in memory.
return False
if getattr(self.speech_engine, "is_auto_paused", False) is True:
return False
if (
+64
View File
@@ -257,6 +257,7 @@ class TestMainModule(unittest.TestCase):
remote_api_key="",
remote_api_endpoint="/inference",
remote_api_model="whisper-1",
defer_model_load=True,
)
mock_text.assert_called_once_with(wayland_mode=True)
mock_action_handler.assert_called_once_with(mock_text_instance)
@@ -286,6 +287,69 @@ class TestMainModule(unittest.TestCase):
# Verify the tray indicator was started
mock_tray_instance.run.assert_called_once()
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.ui.dictation_pad.DictationPad")
@patch("vocalinux.ui.action_handler.ActionHandler")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@patch("vocalinux.text_injection.text_injector.TextInjector")
@patch("vocalinux.ui.tray_indicator.TrayIndicator")
@patch("vocalinux.main.logging")
@patch("vocalinux.ui.config_manager.get_shared_config_manager")
@patch("vocalinux.ui.logging_manager.initialize_logging")
def test_tray_is_shown_before_the_model_load_is_scheduled(
self,
mock_init_logging,
mock_config_manager,
mock_logging,
mock_tray,
mock_text,
mock_speech,
mock_action_handler,
mock_dictation_pad,
mock_check_deps,
):
"""The tray is built first, then the model load is queued, then the loop runs (#50)."""
mock_check_deps.return_value = True
mock_config_instance = MagicMock()
mock_config_instance.get_bool.return_value = True
mock_config_instance.get_settings.return_value = {
"speech_recognition": {},
"general": {"first_run": False},
}
mock_config_instance.get_model_size_for_engine.return_value = "medium"
mock_config_instance.get_str.return_value = ""
mock_config_instance.is_dictate_to_pad_enabled.return_value = False
mock_config_manager.return_value = mock_config_instance
mock_speech_instance = MagicMock()
mock_speech.return_value = mock_speech_instance
mock_tray_instance = MagicMock()
mock_tray.return_value = mock_tray_instance
order = MagicMock()
order.attach_mock(mock_tray, "tray")
order.attach_mock(mock_tray_instance.run, "run")
with (
patch("vocalinux.main.parse_arguments") as mock_parse,
patch("gi.repository.GLib.idle_add") as mock_idle_add,
):
order.attach_mock(mock_idle_add, "idle_add")
mock_args = MagicMock()
mock_args.debug = False
mock_args.model = "medium"
mock_args.engine = "whisper_cpp"
mock_args.language = "en-us"
mock_args.wayland = False
mock_parse.return_value = mock_args
main()
mock_speech.assert_called_once()
self.assertIs(mock_speech.call_args.kwargs["defer_model_load"], True)
mock_idle_add.assert_called_once_with(mock_speech_instance.load_model_in_background)
self.assertEqual([c[0] for c in order.mock_calls], ["tray", "idle_add", "run"])
@patch("vocalinux.main.check_dependencies")
@patch("vocalinux.speech_recognition.recognition_manager.SpeechRecognitionManager")
@patch("vocalinux.text_injection.text_injector.TextInjector")
+185
View File
@@ -0,0 +1,185 @@
"""Startup model load runs on a background thread so the tray can show first (#50).
Seams covered here, all at the SpeechRecognitionManager public surface:
the deferred-load constructor flag, ``load_model_in_background`` (thread,
loading flag, callbacks, failure reporting), the dictation guard while the
model is loading, supersession by an auto-pause, and the language-shortcut
path while loading.
"""
import threading
from unittest.mock import patch
import pytest
MANAGER_MODULE = "vocalinux.speech_recognition.recognition_manager"
@pytest.fixture
def init_whispercpp():
"""Stand in for the whisper.cpp load so no model file is read."""
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with patch.object(SpeechRecognitionManager, "_init_whispercpp") as init:
yield init
def _deferred_manager():
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
return SpeechRecognitionManager(
engine="whisper_cpp",
model_size="small",
language="en-us",
defer_model_load=True,
)
class TestDeferredConstruction:
def test_construction_does_not_load_the_engine(self, init_whispercpp):
manager = _deferred_manager()
init_whispercpp.assert_not_called()
assert manager.model_loading is True
assert manager.model_ready is False
def test_default_construction_still_loads_synchronously(self, init_whispercpp):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
manager = SpeechRecognitionManager(
engine="whisper_cpp", model_size="small", language="en-us"
)
init_whispercpp.assert_called_once_with()
assert manager.model_loading is False
def test_unknown_engine_still_fails_at_construction(self, init_whispercpp):
from vocalinux.speech_recognition.recognition_manager import SpeechRecognitionManager
with pytest.raises(ValueError, match="Unsupported speech recognition engine"):
SpeechRecognitionManager(
engine="no-such-engine",
model_size="small",
language="en-us",
defer_model_load=True,
)
class TestBackgroundLoad:
def test_load_runs_on_a_daemon_thread_and_returns_at_once(self, init_whispercpp):
entered = threading.Event()
release = threading.Event()
seen_threads = []
def slow_load(*_args, **_kwargs):
seen_threads.append(threading.current_thread())
entered.set()
release.wait(5)
init_whispercpp.side_effect = slow_load
manager = _deferred_manager()
manager.load_model_in_background()
assert entered.wait(5), "the load never started"
assert manager.model_loading is True
assert seen_threads[0] is not threading.main_thread()
assert seen_threads[0].daemon is True
release.set()
manager._model_load_thread.join(5)
def test_loading_flag_clears_and_callbacks_fire_after_success(self, init_whispercpp):
manager = _deferred_manager()
flags_seen_by_callback = []
manager.register_model_load_callback(
lambda: flags_seen_by_callback.append(manager.model_loading)
)
manager.load_model_in_background()
manager._model_load_thread.join(5)
init_whispercpp.assert_called_once_with()
assert manager.model_loading is False
assert flags_seen_by_callback, "the load-finished callback never ran"
assert flags_seen_by_callback[-1] is False
def test_failed_load_reports_error_instead_of_raising(self, init_whispercpp):
from vocalinux.common_types import RecognitionState
init_whispercpp.side_effect = RuntimeError("model file is corrupt")
manager = _deferred_manager()
states = []
manager.register_state_callback(states.append)
with patch(f"{MANAGER_MODULE}._show_notification") as notify:
manager.load_model_in_background()
manager._model_load_thread.join(5)
assert manager.model_loading is False
assert manager.state == RecognitionState.ERROR
assert states[-1] == RecognitionState.ERROR
notify.assert_called_once()
assert "failed to load" in notify.call_args.args[0].lower()
def test_auto_pause_already_active_at_startup_skips_the_load(self, init_whispercpp):
manager = _deferred_manager()
# A configured app is already running when the tray comes up. Its
# auto-pause runs before the load is scheduled, so the load must not
# keep the model resident; auto-resume loads it when the app exits.
manager._new_model_request(auto_pause=True)
manager.load_model_in_background()
manager._model_load_thread.join(5)
init_whispercpp.assert_not_called()
assert manager.is_auto_paused is True
assert manager.model_loading is False
def test_auto_pause_arriving_before_the_load_supersedes_it(self, init_whispercpp):
manager = _deferred_manager()
# The loader must wait for the model lock; an auto-pause takes a newer
# request while the lock is still held, so the load must not run.
with manager._model_lock:
manager.load_model_in_background()
manager._new_model_request(auto_pause=True)
manager._model_load_thread.join(5)
init_whispercpp.assert_not_called()
assert manager.is_auto_paused is True
assert manager.model_loading is False
class TestDictationWhileLoading:
def test_start_is_refused_with_a_loading_notice(self, init_whispercpp):
from vocalinux.common_types import RecognitionState
manager = _deferred_manager()
with (
patch(f"{MANAGER_MODULE}._show_notification") as notify,
patch(f"{MANAGER_MODULE}.play_error_sound") as error_sound,
patch.object(manager, "_notify_model_missing") as model_missing_offer,
):
started = manager.start_recognition()
assert started is False
assert manager.state == RecognitionState.IDLE
model_missing_offer.assert_not_called()
error_sound.assert_called_once()
assert "loading" in notify.call_args.args[0].lower()
init_whispercpp.assert_not_called()
def test_language_shortcut_during_load_reports_loading_not_unsupported(self, init_whispercpp):
manager = _deferred_manager()
with (
patch.object(manager, "_refuse_language_override") as refuse,
patch(f"{MANAGER_MODULE}._show_notification") as notify,
patch(f"{MANAGER_MODULE}.play_error_sound"),
):
started = manager.start_recognition_with_language("de")
assert started is False
refuse.assert_not_called()
assert "loading" in notify.call_args.args[0].lower()
+109
View File
@@ -0,0 +1,109 @@
"""Tray rendering while the speech model loads in the background (#50).
The tray is built before the model load starts, so it must show a loading
state, re-render on the GTK main loop when the load finishes, and keep the
idle keep-alive from unloading a model that is still being loaded.
"""
from unittest.mock import MagicMock, patch
from vocalinux.common_types import RecognitionState
from vocalinux.ui import tray_indicator
LOADING_TITLE = "Loading model…"
ICONS = {
"default": "default-icon",
"active": "active-icon",
"processing": "processing-icon",
}
def _engine(state=RecognitionState.IDLE, loading=False):
engine = MagicMock()
engine.state = state
engine.model_loading = loading
engine.is_auto_paused = False
return engine
def _make_tray(engine):
"""Build a TrayIndicator without running GTK setup; only rendering helpers are real."""
tray = tray_indicator.TrayIndicator.__new__(tray_indicator.TrayIndicator)
tray.speech_engine = engine
tray.indicator = MagicMock()
tray._icon_keys = dict(ICONS)
tray._indicator_title = tray_indicator.TRAY_TITLE
tray._language_shortcuts_refresh_pending = False
tray.overlay = None
tray.shortcut_manager = MagicMock()
tray._model_keepalive = MagicMock()
tray._auto_pause_monitor = MagicMock(paused=False)
tray._set_indicator_icon = MagicMock()
tray._set_indicator_title = MagicMock()
tray._set_menu_item_enabled = MagicMock()
tray._set_cancel_menu_item_visible = MagicMock()
return tray
def test_loading_shows_processing_icon_and_loading_title_while_idle():
tray = _make_tray(_engine(loading=True))
tray._update_ui(RecognitionState.IDLE)
tray._set_indicator_icon.assert_called_with(ICONS["processing"], "Loading speech model")
tray._set_indicator_title.assert_called_with(LOADING_TITLE)
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", False)
tray._set_menu_item_enabled.assert_any_call("Stop Voice Typing", False)
def test_finished_load_returns_to_idle_icon_and_enables_start():
tray = _make_tray(_engine(loading=False))
tray._update_ui(RecognitionState.IDLE)
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
tray._set_menu_item_enabled.assert_any_call("Start Voice Typing", True)
def test_loading_state_does_not_mask_an_active_session():
tray = _make_tray(_engine(state=RecognitionState.LISTENING, loading=True))
tray._update_ui(RecognitionState.LISTENING)
tray._set_indicator_icon.assert_called_with(ICONS["active"], "Microphone on")
def test_load_change_is_marshalled_onto_the_gtk_main_loop():
tray = _make_tray(_engine(loading=False))
with patch.object(tray_indicator, "GLib") as glib:
tray._on_model_load_changed()
glib.idle_add.assert_called_once_with(tray._apply_model_load_state)
tray._set_indicator_icon.assert_not_called()
def test_finishing_the_load_restarts_the_keep_alive_timer():
engine = _engine(loading=False)
tray = _make_tray(engine)
assert tray._apply_model_load_state() is False
tray._model_keepalive.bump.assert_called_once_with()
tray._set_indicator_icon.assert_called_with(ICONS["default"], "Microphone off")
def test_loading_in_progress_does_not_restart_the_keep_alive_timer():
tray = _make_tray(_engine(loading=True))
tray._apply_model_load_state()
tray._model_keepalive.bump.assert_not_called()
def test_keep_alive_never_unloads_while_the_model_is_loading():
loading = _make_tray(_engine(loading=True))
loaded = _make_tray(_engine(loading=False))
assert loading._is_safe_for_keepalive_unload() is False
assert loaded._is_safe_for_keepalive_unload() is True