Preload speech profile models at startup - #257
Conversation
First use of a profile pays a multi-second model load — exactly when a new user concludes the hotkey is broken (the Busy notice explains it now, but instant is better than explained). `preload: true` on a profile (or the flat config) loads its model on the worker thread at startup: - the preload runs through the same serialized-load path as a lazy load, so concurrent preloads queue on the global load lock and wait out retiring workers; profiles sharing a model path share the cached load - success publishes the backend immediately (generation 0 is reserved for preloads; real recordings start at 1), so the settings backend display is truthful before the first dictation - failure surfaces as a speech error at launch — a broken model path is visible immediately instead of on the first press — and the next dictation retries the load as before
There was a problem hiding this comment.
Pull request overview
Adds opt-in startup speech-model preloading to avoid first-dictation latency.
Changes:
- Adds flat and per-profile
preloadconfiguration. - Preloads models in speech workers and reports success/failure.
- Documents configuration and adds coverage for preload behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
README.md |
Documents preload configuration. |
crates/horizon-core/src/speech_config.rs |
Defines and resolves preload settings. |
crates/horizon-ui/src/app/speech/worker.rs |
Implements worker startup preloading. |
crates/horizon-ui/src/app/speech/engine.rs |
Handles preload events. |
crates/horizon-ui/src/app/speech/engine/tests.rs |
Tests engine preload handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ment An assignment's RHS runs before the old value drops, so `self.speech = SpeechSystem::from_config(...)` spawned the new workers while the old ones were not yet registered as retiring. Lazy loads never noticed (nothing loads until a job arrives, long after the drop), but a preloaded profile starts loading immediately and could sail past the retirement guard — allocating beside a still-resident multi-GB model, exactly the OOM the guard exists to prevent. Both rebuild sites now drop (and thereby register) the old system first.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
crates/horizon-ui/src/app/speech/worker.rs:349
PreloadFailedis emitted while the speech system isIdle, but sending on this channel does not wake egui.handle_speech_inputonly schedules speech polling whileis_active()(app/lifecycle.rs:447-451), and the startup chooser returns before speech is polled (app/mod.rs:534-537). Consequently, on the chooser or an empty board, a preload failure can remain queued indefinitely instead of appearing at launch. Please arrange a repaint/poll wake-up for pending preloads (and ensure the chooser path drains it).
let _ = event_tx.send(WorkerEvent::PreloadFailed { message });
|
SMOKE-TEST REQUEST macOS/Metal — plan: docs/testing/2026-07-31-macos-speech-preload-smoke.md — scope: startup preload success/failure delivery, idle wake, startup chooser, profile replacement, and baseline terminal behavior |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/horizon-ui/src/app/speech/engine.rs:444
- Multiple profiles can preload concurrently, so several failures commonly arrive in one
poll(). Each becomes a separateSpeechEvent::Error, butinject_speech_eventsstores only onespeech_notice; later errors in the same frame overwrite earlier ones before rendering. Consequently, only the last broken profile is visible at launch. Aggregate simultaneous preload failures or queue notices so every failing profile is surfaced.
events.push(SpeechEvent::Error(format!(
"preloading the `{label}` speech model failed: {message}"
)));
crates/horizon-ui/src/app/settings/mod.rs:249
- A speech runtime rebuilt here starts preload workers after
handle_speech_inputhas already performed this frame's speech poll. In the external-config path,poll_config_reloadruns later inprocess_frame_inputs, and an update after more than 500 ms idle has noFrameStatsfollow-up repaint. If the user makes no further input, the new preload can finish but its backend/error event remains undrained indefinitely. Schedule a repaint when installing a runtime with pending preloads, or move/add polling after runtime replacement.
self.speech = None;
self.speech = super::speech::SpeechSystem::from_config(&config.features.speech);
README.md:341
- The paragraph immediately below this example says Settings exposes all speech options, but the settings UI has no control for the new flat or per-profile
preloadfield (named-profile editing only exposes hotkeys). Either add the setting or document that preload is YAML-only so users are not directed to a UI that cannot configure it.
preload: false # true = load the model at startup; first dictation becomes instant
|
SMOKE-TEST REQUEST macOS/Metal — plan: docs/testing/2026-07-31-macos-speech-preload-smoke.md — scope: rerun exact 522e02d after fixing the live-discovered narrow-window notice overflow; recheck preload success/failure, idle wake, chooser, and baseline terminal behavior |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (2)
crates/horizon-ui/src/app/lifecycle.rs:455
- A live config reload can create the replacement preload after this frame's only speech drain.
process_frame_inputscallshandle_speech_inputhere beforepoll_config_reload(lifecycle.rs:303), and an empty board does not schedule periodic frames infinalize_frame; therefore a reload detected on a one-off input frame can leaveModelLoaded/PreloadFailedqueued until another user event. Schedule a repaint whenever a runtime rebuild creates pending preloads, or perform a final speech poll after config reloads.
self.poll_speech_runtime(ctx, events);
crates/horizon-ui/src/app/speech/engine.rs:444
- Multiple preload failures drained in one poll are not all surfaced: this pushes one error per profile, but
inject_speech_eventscallsshow_speech_noticefor each and that replaces the singlespeech_noticevalue (lifecycle.rs:497-529). With several invalid preloaded profiles, the fast failures can all queue before the 100 ms drain, so only the last profile's error is visible. Aggregate preload failures or queue notices so every broken profile is reported.
events.push(SpeechEvent::Error(format!(
"preloading the `{label}` speech model failed: {message}"
)));
|
SMOKE-TEST REPORT (macOS/Metal)
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
crates/horizon-ui/src/app/speech/engine.rs:444
- If multiple profiles fail preloading before the same poll (as missing paths typically will), this appends one error per profile.
inject_speech_eventsthen callsshow_speech_noticefor each event, and that method replaces the singlespeech_notice, so only the final profile's launch error is visible. Aggregate concurrent preload failures into one notice or queue notices so every broken preloaded profile is surfaced.
events.push(SpeechEvent::Error(format!(
"preloading the `{label}` speech model failed: {message}"
)));
Follow-up to #244. First use of a dictation profile pays the multi-second model load — the exact moment a user concludes the hotkey is broken. The Busy notice from #244 explains the wait; this removes it.
What changed
features.speech.preload(flat) and per-profilepreload: true: the worker loads its model at startup instead of on the first job. Default remains lazy.PRELOAD_GENERATION; real recordings start at 1): a preload'sModelLoadedpublishes the backend to settings immediately, and a newPreloadFailedevent surfaces a broken model path at launch as a speech error (via the Opt-in speech input and minimap navigation #244 notice overlay) instead of on the first press. The next dictation retries the load as before.Tests: config serde/default + flat-to-profile synthesis; engine handling of preload success (backend published while Idle, not dropped as stale) and failure (error names the profile); a real-worker test proving a missing-model preload reports before any job. Gates: fmt, blocking/strict/pedantic clippy (speech), both test tiers, release
speech-cudabuild.