Skip to content

Preload speech profile models at startup - #257

Merged
peters merged 5 commits into
mainfrom
feat/speech-preload
Jul 31, 2026
Merged

Preload speech profile models at startup#257
peters merged 5 commits into
mainfrom
feat/speech-preload

Conversation

@peters

@peters peters commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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-profile preload: true: the worker loads its model at startup instead of on the first job. Default remains lazy.
  • Preloads reuse the existing serialized-load path: concurrent preloads queue on the global load lock, wait out retiring workers on live config rebuilds, and profiles sharing a model path share the cached load (e.g. two whisper-large-v3 profiles preload one copy).
  • Generation 0 is reserved for preloads (PRELOAD_GENERATION; real recordings start at 1): a preload's ModelLoaded publishes the backend to settings immediately, and a new PreloadFailed event 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.
  • README documents the field in both YAML examples.

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-cuda build.

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
Copilot AI review requested due to automatic review settings July 28, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds opt-in startup speech-model preloading to avoid first-dictation latency.

Changes:

  • Adds flat and per-profile preload configuration.
  • 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.

Comment thread crates/horizon-ui/src/app/speech/worker.rs
…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.
Copilot AI review requested due to automatic review settings July 28, 2026 07:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • PreloadFailed is emitted while the speech system is Idle, but sending on this channel does not wake egui. handle_speech_input only schedules speech polling while is_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 });

Copilot AI review requested due to automatic review settings July 31, 2026 08:33
@peters

peters commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 separate SpeechEvent::Error, but inject_speech_events stores only one speech_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_input has already performed this frame's speech poll. In the external-config path, poll_config_reload runs later in process_frame_inputs, and an update after more than 500 ms idle has no FrameStats follow-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 preload field (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

Copilot AI review requested due to automatic review settings July 31, 2026 08:44
@peters

peters commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_inputs calls handle_speech_input here before poll_config_reload (lifecycle.rs:303), and an empty board does not schedule periodic frames in finalize_frame; therefore a reload detected on a one-off input frame can leave ModelLoaded/PreloadFailed queued 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_events calls show_speech_notice for each and that replaces the single speech_notice value (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}"
                )));

@peters

peters commented Jul 31, 2026

Copy link
Copy Markdown
Owner Author

SMOKE-TEST REPORT (macOS/Metal)

  • Commit 522e02d on macOS 26.5.2 (25F84), exact PID/Quartz-window scoped: pass
  • Lazy preload=false control: pass — no model initialization before F9
  • Valid startup preload: pass — model ready and published as active backend MTL0 without input; Settings showed active: MTL0
  • Idle empty-board success/failure delivery: pass — wake occurred without pointer, resize, or terminal output
  • Startup chooser failure delivery: pass — error rendered over the live-session chooser without interaction
  • Loading-view early return: pass — dedicated egui update test retained the bootstrap receiver and rendered the injected failure
  • Mixed profiles and runtime replacement: pass — valid profile succeeded, named Missing profile failed once, then config replacement rebuilt and preloaded both valid profiles
  • Repaint/duplicate-load control: pass — idle CPU samples settled at 0.0-1.9%; opening Settings did not add a second preload event
  • First dictation path: partial — model was already initialized and capture started immediately; this host has no usable CoreAudio input device, and the expected capture error rendered without a crash
  • No-speech-feature launch and ordinary shell startup: pass
  • Live finding fixed: long preload failures overflowed the 880 px startup window; 522e02d wraps the notice, adds a narrow-viewport render-bound regression, and was rerun successfully on empty-board and chooser views
  • Evidence: /tmp/horizon-pr257-smoke.gKUOvB/evidence; model SHA-256 72cfa8ee436a635a5b6fb373cc056a828b9efe96d32d6eb8769ed3cc5b429719
    Summary: all automatable macOS/Metal lanes pass on the pushed revision; microphone audio transcription is unavailable because CoreAudio exposes no usable input device.
    SMOKE-TEST: DONE

Copilot AI review requested due to automatic review settings July 31, 2026 09:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_events then calls show_speech_notice for each event, and that method replaces the single speech_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}"
                )));

@peters
peters merged commit bbea69d into main Jul 31, 2026
14 checks passed
@peters
peters deleted the feat/speech-preload branch July 31, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants