fix(tray): show the tray before the speech model loads (#51)
CI / security (push) Successful in 1m17s
CI / lint (push) Successful in 23s
CI / ai-review (push) Skipped

The first model load runs on a background thread after the tray is up. While it loads the tray shows the processing icon and 'Loading model…', and a dictation request gets a 'still loading' notification. A failed load leaves the tray in the error state instead of exiting.

Fixes #50
This commit was merged in pull request #51.
This commit is contained in:
2026-11-06 20:00:50 +00:00
parent 0c48db5b5e
commit 85cbc37842
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