fix(tray): show the tray before the speech model loads #51

Merged
xavierk merged 1 commits from fix/tray-before-model into main 2026-11-06 20:00:50 +00:00
Owner

Description

The model load ran inside SpeechRecognitionManager.__init__, which runs before the tray is built, so for the whole load the app showed no icon and looked dead. This PR builds the tray first and loads the model on a background thread.

  • SpeechRecognitionManager(defer_model_load=True) skips the load in the constructor. load_model_in_background() starts it on a daemon thread. main() calls it with GLib.idle_add after the tray is constructed, so the tray's own idle callback runs first.
  • Loading uses the same _model_lock and request token as resume, but not the resume-only bookkeeping. The model ladder and fallbacks run unchanged.
  • While loading the tray shows the processing icon, the title "Loading model…", and disables Start/Stop Voice Typing. Keep-alive does not unload during the first load and restarts its timer when the load finishes.
  • Dictation during the load (hotkey, toggle, --start/--toggle over D-Bus, per-language shortcut) is refused with an error sound and a "Speech model loading" notification. It is not queued, which is the simplest honest behaviour.
  • A failed load leaves the tray in the Error state and shows "Speech model failed to load" with a restart hint, instead of sys.exit(1). Dictation stays refused until restart. I did not verify whether a successful Settings apply clears ERROR.
  • If an auto-pause is already active when the load would start (a listed app is running at login), the load is skipped. Auto-resume loads the model later.
  • Settings Test Dictation during the load says the model is still loading, instead of "No speech model downloaded".
  • The D-Bus service still reports idle during the load, because no new RecognitionState was added.

Measurements (same arguments before and after: --engine whisper_cpp --model large-v3-turbo-q5_0)

Run Tray registered (before → after) Model ready (before → after)
Real model, warm cache, RTX 2050 (Vulkan) 3.1–3.6 s → 0.8–1.5 s 2.2 s → 2.2–2.8 s
Simulated 18 s load (time.sleep in the loader) 20.8 s → 0.9 s 19.9 s → 20.4 s

The ticket's ~18 s is not reproduced on this host, where the model loads in 0.27 s from the page cache. The simulated run stands in for a cold load. The model becomes usable about 0.7 s later on a warm cache, because the load now starts after the tray is shown.

Verified over D-Bus on the real machine during a simulated load: the tray item had IconName voculinux-microphone-process.svg and Title Loading model…. After the load it had voculinux-microphone-off.svg and Title Voculinux. The tooltip property stayed empty, which is expected because the title is what the tray sets.

Related issue

Fixes #50

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Refactor
  • Tests
  • Packaging / CI

Checklist

  • Code follows project style (Black, isort, flake8). Pinned black 26.10.1, isort 8.0.1 and flake8 7.3.0 pass on the gate tree. mypy 2.3.1 passes on src/.
  • Documentation updated. No install or config change, so none is needed; the loading behaviour is described above.
  • Tests added or updated (see below)
  • Local tests pass. Related files pass when run alone; see notes.
  • Conventional commit message style

Notes for reviewers

Pre-agreed seams, as listed in the ticket work, all tested without GTK:

  1. SpeechRecognitionManager (tests/test_model_load_in_background.py, 10 tests): constructor flag, background thread, loading flag and callbacks, failure reporting, auto-pause superseding the load, auto-pause active at startup, dictation refused during load, language shortcut during load.
  2. TrayIndicator (tests/test_tray_model_loading.py, 7 tests): loading and finished rendering, active session not masked, GLib marshalling, keep-alive timer, keep-alive never unloading during the first load.
  3. main() ordering (tests/test_main.py::test_tray_is_shown_before_the_model_load_is_scheduled): tray constructed, then load scheduled, then run. Also updated test_main_initializes_components for defer_model_load=True.

Related files run alone: test_main, test_recognition_manager_init, test_recognition_manager_core, test_model_ladder, test_model_missing_offer, test_reconfigure_concurrency, test_reconfigure_worker_handoff, test_auto_pause_monitor, test_record_during_reload, test_start_stop_race, test_tray_indicator, test_tray_indicator_ext, test_suspend_handler, test_integration_branches, test_cancel_transcription, test_dbus_activation, test_capture_shutdown all pass. test_recognition_manager.py, test_speech_recognition.py and test_language_shortcuts.py fail the same way on main. test_settings_model_state.py shows 72 order-dependent failures when run after other files, identical on main.

Open points, not covered by tests:

  • GIL: the real whisper.cpp load was too short here (0.27 s) to show whether it releases the GIL. The simulation used time.sleep, which does. The load starts from an idle callback after the tray is visible, so the icon appears either way, but a GIL-holding native load would still stall the GTK loop until it finishes. Worth checking on a cold cache.
  • Resume or Settings apply during the first load supersedes the load request. I read that path but did not exercise it.
## Description The model load ran inside `SpeechRecognitionManager.__init__`, which runs before the tray is built, so for the whole load the app showed no icon and looked dead. This PR builds the tray first and loads the model on a background thread. - `SpeechRecognitionManager(defer_model_load=True)` skips the load in the constructor. `load_model_in_background()` starts it on a daemon thread. `main()` calls it with `GLib.idle_add` after the tray is constructed, so the tray's own idle callback runs first. - Loading uses the same `_model_lock` and request token as resume, but not the resume-only bookkeeping. The model ladder and fallbacks run unchanged. - While loading the tray shows the processing icon, the title "Loading model…", and disables Start/Stop Voice Typing. Keep-alive does not unload during the first load and restarts its timer when the load finishes. - Dictation during the load (hotkey, toggle, `--start`/`--toggle` over D-Bus, per-language shortcut) is refused with an error sound and a "Speech model loading" notification. It is not queued, which is the simplest honest behaviour. - A failed load leaves the tray in the Error state and shows "Speech model failed to load" with a restart hint, instead of `sys.exit(1)`. Dictation stays refused until restart. I did not verify whether a successful Settings apply clears ERROR. - If an auto-pause is already active when the load would start (a listed app is running at login), the load is skipped. Auto-resume loads the model later. - Settings Test Dictation during the load says the model is still loading, instead of "No speech model downloaded". - The D-Bus service still reports idle during the load, because no new `RecognitionState` was added. ### Measurements (same arguments before and after: `--engine whisper_cpp --model large-v3-turbo-q5_0`) | Run | Tray registered (before → after) | Model ready (before → after) | |---|---|---| | Real model, warm cache, RTX 2050 (Vulkan) | 3.1–3.6 s → 0.8–1.5 s | 2.2 s → 2.2–2.8 s | | Simulated 18 s load (`time.sleep` in the loader) | 20.8 s → 0.9 s | 19.9 s → 20.4 s | The ticket's ~18 s is not reproduced on this host, where the model loads in 0.27 s from the page cache. The simulated run stands in for a cold load. The model becomes usable about 0.7 s later on a warm cache, because the load now starts after the tray is shown. Verified over D-Bus on the real machine during a simulated load: the tray item had `IconName` `voculinux-microphone-process.svg` and `Title` `Loading model…`. After the load it had `voculinux-microphone-off.svg` and `Title` `Voculinux`. The tooltip property stayed empty, which is expected because the title is what the tray sets. ## Related issue Fixes #50 ## Type of change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [ ] Documentation - [ ] Refactor - [x] Tests - [ ] Packaging / CI ## Checklist - [x] Code follows project style (Black, isort, flake8). Pinned black 26.10.1, isort 8.0.1 and flake8 7.3.0 pass on the gate tree. mypy 2.3.1 passes on `src/`. - [ ] Documentation updated. No install or config change, so none is needed; the loading behaviour is described above. - [x] Tests added or updated (see below) - [x] Local tests pass. Related files pass when run alone; see notes. - [x] Conventional commit message style ## Notes for reviewers Pre-agreed seams, as listed in the ticket work, all tested without GTK: 1. `SpeechRecognitionManager` (`tests/test_model_load_in_background.py`, 10 tests): constructor flag, background thread, loading flag and callbacks, failure reporting, auto-pause superseding the load, auto-pause active at startup, dictation refused during load, language shortcut during load. 2. `TrayIndicator` (`tests/test_tray_model_loading.py`, 7 tests): loading and finished rendering, active session not masked, GLib marshalling, keep-alive timer, keep-alive never unloading during the first load. 3. `main()` ordering (`tests/test_main.py::test_tray_is_shown_before_the_model_load_is_scheduled`): tray constructed, then load scheduled, then run. Also updated `test_main_initializes_components` for `defer_model_load=True`. Related files run alone: `test_main`, `test_recognition_manager_init`, `test_recognition_manager_core`, `test_model_ladder`, `test_model_missing_offer`, `test_reconfigure_concurrency`, `test_reconfigure_worker_handoff`, `test_auto_pause_monitor`, `test_record_during_reload`, `test_start_stop_race`, `test_tray_indicator`, `test_tray_indicator_ext`, `test_suspend_handler`, `test_integration_branches`, `test_cancel_transcription`, `test_dbus_activation`, `test_capture_shutdown` all pass. `test_recognition_manager.py`, `test_speech_recognition.py` and `test_language_shortcuts.py` fail the same way on `main`. `test_settings_model_state.py` shows 72 order-dependent failures when run after other files, identical on `main`. Open points, not covered by tests: - GIL: the real whisper.cpp load was too short here (0.27 s) to show whether it releases the GIL. The simulation used `time.sleep`, which does. The load starts from an idle callback after the tray is visible, so the icon appears either way, but a GIL-holding native load would still stall the GTK loop until it finishes. Worth checking on a cold cache. - Resume or Settings apply during the first load supersedes the load request. I read that path but did not exercise it.
xavierk added 1 commit 2026-11-06 19:58:51 +00:00
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
9b91b8b177
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
xavierk merged commit 85cbc37842 into main 2026-11-06 20:00:50 +00:00
Sign in to join this conversation.