diff --git a/apps/web/src/components/record/CaptureControls.tsx b/apps/web/src/components/record/CaptureControls.tsx
index b8bef20..a4243bf 100644
--- a/apps/web/src/components/record/CaptureControls.tsx
+++ b/apps/web/src/components/record/CaptureControls.tsx
@@ -6,6 +6,7 @@ import { useEngine } from "../../lib/engine-context";
import { TARGETS, hearsOthers, load, save, translating, type Capture } from "../../lib/capture";
import type { Lane } from "../../lib/protocol";
import { ListeningIn } from "./ListeningIn";
+import { WarmUp } from "./WarmUp";
import { SpokenLanguage } from "./SpokenLanguage";
/**
@@ -50,6 +51,7 @@ export function CaptureControls() {
controls, because those are disabled mid-session and this one is the only thing on the
card that can still be acted on. */}
+
{t("record.audio_source")}
diff --git a/apps/web/src/components/record/WarmUp.tsx b/apps/web/src/components/record/WarmUp.tsx
new file mode 100644
index 0000000..e7693d0
--- /dev/null
+++ b/apps/web/src/components/record/WarmUp.tsx
@@ -0,0 +1,42 @@
+import { useCallback } from "react";
+
+import { useT } from "../../i18n/context";
+import { useEngine } from "../../lib/engine-context";
+import { readyNow, warmUp } from "../../lib/languages";
+import { useLoad } from "../../lib/use-load";
+
+/**
+ * Whether the next recording will start instantly, and making it so.
+ *
+ * Building a decoder costs about three and a half seconds — measured, on the released build — and
+ * until now that was paid at the start of every meeting, after the button was pressed, with nothing
+ * on screen to say why the transcript had not begun. The daemon can build one ahead of time; this
+ * asks it to, when the card is opened and nothing is recording.
+ *
+ * It says so quietly. A user who never notices this line is the point: it exists so that pressing
+ * record produces text immediately, not so that anyone reads about model loading.
+ */
+export function WarmUp() {
+ const { handshake, session } = useEngine();
+ const t = useT();
+
+ const state = useLoad(
+ useCallback(async () => {
+ // What is loaded already, then — only if nothing is — the request to load one. Asking first
+ // keeps a reopened card from rebuilding a decoder that is already sitting there.
+ const already = await readyNow(handshake);
+ // `undefined` is "this build cannot warm anything" — do not ask it to.
+ if (already === undefined || already || session.recording) return already ?? null;
+ return await warmUp(handshake);
+ }, [handshake, session.recording]),
+ [handshake, session.recording],
+ );
+
+ if (session.recording || !state.data) return null;
+
+ return (
+
+ {t("record.warm_ready", { model: state.data.model })}
+
+ );
+}
diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json
index 921bf76..37750f0 100644
--- a/apps/web/src/i18n/en.json
+++ b/apps/web/src/i18n/en.json
@@ -65,7 +65,8 @@
"listening_in": "Listening in {language}",
"listening_change": "Change",
"listening_note": "Changes take effect immediately; nothing already recorded is lost.",
- "spoken_multi": "Several languages (detected)"
+ "spoken_multi": "Several languages (detected)",
+ "warm_ready": "Ready to record — {model} is loaded."
},
"import": {
"title": "Import a recording",
diff --git a/apps/web/src/i18n/ja.json b/apps/web/src/i18n/ja.json
index 7292762..666338f 100644
--- a/apps/web/src/i18n/ja.json
+++ b/apps/web/src/i18n/ja.json
@@ -65,7 +65,8 @@
"listening_in": "{language} で聞き取り中",
"listening_change": "変更",
"listening_note": "その場で切り替わります。録音済みの内容は失われません。",
- "spoken_multi": "複数言語(自動判定)"
+ "spoken_multi": "複数言語(自動判定)",
+ "warm_ready": "すぐ録音できます — {model} を読み込み済み。"
},
"import": {
"title": "録音ファイルを取り込む",
diff --git a/apps/web/src/i18n/vi.json b/apps/web/src/i18n/vi.json
index c14e8cc..c99bc7b 100644
--- a/apps/web/src/i18n/vi.json
+++ b/apps/web/src/i18n/vi.json
@@ -65,7 +65,8 @@
"listening_in": "Đang nghe bằng {language}",
"listening_change": "Đổi",
"listening_note": "Đổi ngay giữa buổi, không mất phần đã ghi.",
- "spoken_multi": "Nhiều ngôn ngữ (tự động)"
+ "spoken_multi": "Nhiều ngôn ngữ (tự động)",
+ "warm_ready": "Sẵn sàng ghi ngay — {model} đã nạp."
},
"import": {
"title": "Nhập bản ghi có sẵn",
diff --git a/apps/web/src/i18n/zh.json b/apps/web/src/i18n/zh.json
index d868416..627b9d2 100644
--- a/apps/web/src/i18n/zh.json
+++ b/apps/web/src/i18n/zh.json
@@ -65,7 +65,8 @@
"listening_in": "正在以{language}聆听",
"listening_change": "更改",
"listening_note": "立即生效,已录制的内容不会丢失。",
- "spoken_multi": "多种语言(自动识别)"
+ "spoken_multi": "多种语言(自动识别)",
+ "warm_ready": "可立即录音——已加载 {model}。"
},
"import": {
"title": "导入录音",
diff --git a/apps/web/src/lib/languages.ts b/apps/web/src/lib/languages.ts
index 7ac7377..5af2a63 100644
--- a/apps/web/src/lib/languages.ts
+++ b/apps/web/src/lib/languages.ts
@@ -129,3 +129,43 @@ export async function rememberLanguage(handshake: Handshake, code: string): Prom
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
}
+
+/** What the daemon has loaded and ready right now. */
+export interface Ready {
+ model: string;
+ language: string | null;
+}
+
+/**
+ * Ask the daemon to build a decoder now.
+ *
+ * Answers when it is ready — about three and a half seconds — which is what lets the caller say
+ * "sẵn sàng" rather than "asked for". Called when the app opens and after a meeting ends; the
+ * daemon refills its own slot after a session too, so this is a nudge, never a requirement.
+ */
+export async function warmUp(handshake: Handshake): Promise {
+ const response = await fetch(url(handshake, "/models/warm"), { method: "POST" });
+ // A daemon built without recognition has no such route, and the interface is the same interface:
+ // the `-nomodels` build browses a vault and cannot record, so there is nothing to warm and
+ // nothing wrong. Anything else is a real failure worth surfacing.
+ if (response.status === 404 || response.status === 405) return null;
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const body = (await response.json()) as { ready: Ready | null };
+ return body.ready;
+}
+
+/**
+ * What is loaded, from the status endpoint the recording banner already reads.
+ *
+ * Three answers, and the third is the point. `undefined` means this daemon has no warming at all —
+ * a build without recognition does not carry the field — and asking it to warm would be a request
+ * the browser logs as a failed one on a screen where nothing is wrong. Handling the error is not
+ * enough: the console entry appears whatever the code does with the response, and the shell suite
+ * treats console errors as failures because they usually are.
+ */
+export async function readyNow(handshake: Handshake): Promise {
+ const response = await fetch(url(handshake, "/status"));
+ if (!response.ok) return undefined;
+ const body = (await response.json()) as { ready?: Ready | null };
+ return "ready" in body ? (body.ready ?? null) : undefined;
+}
diff --git a/apps/web/src/lib/protocol.ts b/apps/web/src/lib/protocol.ts
index 2a62dcf..bacecca 100644
--- a/apps/web/src/lib/protocol.ts
+++ b/apps/web/src/lib/protocol.ts
@@ -62,7 +62,8 @@ export interface SessionSpec {
export type Command =
| ({ cmd: "session_start" } & SessionSpec)
| { cmd: "session_stop" }
- | { cmd: "model_load"; id: string }
+ // Empty `id` means "whatever the settings resolve to", the same as `session_start`.
+ | { cmd: "model_load"; id?: string }
| { cmd: "model_pull"; id: string }
// Both fields optional on purpose: the interface changes a language and lets the daemon pick the
// model that hears it, while a client comparing two models names one and keeps the language.
diff --git a/crates/summo-engine/src/lib.rs b/crates/summo-engine/src/lib.rs
index 38b64d4..a49c4b5 100644
--- a/crates/summo-engine/src/lib.rs
+++ b/crates/summo-engine/src/lib.rs
@@ -45,6 +45,8 @@ pub mod state;
pub mod summarize;
pub mod translate;
pub mod voicebook;
+#[cfg(feature = "models")]
+pub mod warm;
pub use auth::SessionToken;
pub use protocol::{Command, SessionSpec, decode_frame, encode_frame};
diff --git a/crates/summo-engine/src/runner.rs b/crates/summo-engine/src/runner.rs
index 17cf291..4bfa6bf 100644
--- a/crates/summo-engine/src/runner.rs
+++ b/crates/summo-engine/src/runner.rs
@@ -75,16 +75,38 @@ impl SessionRunner {
/// inference state — so each lane loads its own. That doubles resident memory for a two-lane
/// session, which is the price of transcribing both sides of a call independently.
pub fn new(spec: &SessionSpec, store: &ModelStore, hw: &HwProfile) -> Result {
+ Self::with_warm(spec, store, hw, None)
+ }
+
+ /// The same, but allowed to take an already-built decoder out of the warm slot.
+ ///
+ /// Only the first lane can use it — a decoder holds mutable inference state, so two lanes
+ /// cannot share one — which is why this is a parameter rather than something `new` reaches for
+ /// on its own: the caller owns the slot and decides.
+ pub fn with_warm(
+ spec: &SessionSpec,
+ store: &ModelStore,
+ hw: &HwProfile,
+ warm: Option<&crate::warm::Warm>,
+ ) -> Result {
spec.validate()?;
let vad_model = resolve_vad(store)?;
let threads = hw.recommended_threads();
+ let key = crate::warm::Key::new(&spec.live_model, spec.language.clone(), threads);
+ // Taken, not borrowed: whoever gets it owns it, and the slot is refilled afterwards. That
+ // keeps every question about a killed recording holding a borrowed decoder from existing.
+ let mut ready = warm.and_then(|warm| warm.take(&key));
+
let mut lanes = HashMap::new();
for &lane in &spec.lanes {
let vad: Box = Box::new(SileroVad::load(&vad_model, 1)?);
let width = vad.frame_len();
- let decoder = load_decoder(&spec.live_model, spec.language.as_deref(), store, threads)?;
+ let decoder = match ready.take() {
+ Some(decoder) => decoder,
+ None => load_decoder(&spec.live_model, spec.language.as_deref(), store, threads)?,
+ };
let cfg = SessionConfig {
lane,
@@ -330,7 +352,7 @@ fn task_words(task: summo_models::Task) -> &'static str {
}
/// Load the speech model named by the session, choosing a runtime from its manifest.
-fn load_decoder(
+pub(crate) fn load_decoder(
id: &str,
language: Option<&str>,
store: &ModelStore,
diff --git a/crates/summo-engine/src/server.rs b/crates/summo-engine/src/server.rs
index 3ed7b2d..cd558cf 100644
--- a/crates/summo-engine/src/server.rs
+++ b/crates/summo-engine/src/server.rs
@@ -217,6 +217,14 @@ impl Server {
// single-page app's own routes reach it. Registering it earlier would shadow the API.
.fallback(interface)
.layer(middleware::from_fn_with_state(state.clone(), cors))
+ .with_state(state.clone());
+
+ // Added after the chain rather than inside it: the handler exists only in a build with
+ // recognition, and a `#[cfg]` on one line of a long builder chain is a line every future
+ // edit has to remember. A daemon without models simply does not have this path.
+ #[cfg(feature = "models")]
+ let app = app
+ .route("/models/warm", post(warm_model))
.with_state(state);
// Loopback only. Binding 0.0.0.0 would expose a user's microphone to their network.
@@ -393,6 +401,29 @@ async fn status(
if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
return rejection.into_response();
}
+ #[cfg(feature = "models")]
+ {
+ // `ready` is added *beside* the status fields, not wrapped around them. Every client reads
+ // `state` and `live_model` at the top level, and moving them under a key would have been a
+ // breaking change to save one line here.
+ let mut body = serde_json::to_value(state.engine.status()).unwrap_or_default();
+ if let Some(object) = body.as_object_mut() {
+ // What is loaded and instant right now. `null` means the next recording pays about
+ // three and a half seconds to build a decoder — worth saying rather than leaving as an
+ // unexplained pause after pressing record.
+ object.insert(
+ "ready".into(),
+ match state.engine.warm().ready() {
+ Some(key) => {
+ serde_json::json!({ "model": key.model, "language": key.language })
+ }
+ None => serde_json::Value::Null,
+ },
+ );
+ }
+ return Json(body).into_response();
+ }
+ #[cfg(not(feature = "models"))]
Json(state.engine.status()).into_response()
}
@@ -718,6 +749,11 @@ async fn remove_model(
)));
}
+ // Before the blobs go: a warm decoder holding a removed model is a crash waiting for the
+ // next recording.
+ #[cfg(feature = "models")]
+ state.engine.warm().clear();
+
let freed = state.engine.store().remove(&model_id)?;
Ok(serde_json::json!({ "removed": id, "freed_bytes": freed }))
})())
@@ -2059,6 +2095,67 @@ async fn set_language(
})())
}
+/// Build a decoder now, so the next recording does not wait for one.
+///
+/// Over HTTP rather than only as the `model_load` socket command, because the socket exists only
+/// while a session does — and the whole point of warming is to happen when nothing is recording.
+/// The interface calls this when it opens and after a meeting ends.
+///
+/// Synchronous: it answers when the model is ready, which is what lets the caller show "ready"
+/// rather than "asked for". Around three and a half seconds.
+#[cfg(feature = "models")]
+async fn warm_model(
+ State(state): State,
+ headers: HeaderMap,
+ Query(q): Query,
+) -> impl IntoResponse {
+ if let Err(rejection) = state.guard(&headers, q.token.as_deref()) {
+ return rejection.into_response();
+ }
+
+ // Not while recording: the session owns the decoder it is using, and building a second one
+ // would double resident memory in the middle of the meeting it would be trying to protect.
+ if state.engine.status().is_recording() {
+ return as_response(Ok::<_, summo_core::Error>(
+ serde_json::json!({ "ready": null, "skipped": "recording" }),
+ ));
+ }
+
+ let spec = resolve_models(&crate::protocol::SessionSpec::new(""), &state.engine);
+
+ // Nothing installed yet, so nothing to warm. Not an error: warming is a nudge the interface
+ // sends whenever it opens the record card, and answering 400 to it made a first run log a
+ // failed request on a screen where nothing is wrong — the browser suites caught exactly that.
+ if spec.live_model.trim().is_empty() {
+ return as_response(Ok::<_, summo_core::Error>(
+ serde_json::json!({ "ready": null, "skipped": "no model installed" }),
+ ));
+ }
+
+ let engine = state.engine.clone();
+
+ // On a blocking thread: building a decoder is seconds of CPU inside ONNX Runtime, and holding
+ // an async worker for that long starves every other request the daemon is serving.
+ let built = tokio::task::spawn_blocking(move || {
+ crate::warm::build(&spec, &engine.store(), engine.hardware()).map(|(key, decoder)| {
+ let described = serde_json::json!({ "model": key.model, "language": key.language });
+ engine.warm().put(key, decoder);
+ described
+ })
+ })
+ .await;
+
+ match built {
+ Ok(Ok(ready)) => as_response(Ok::<_, summo_core::Error>(
+ serde_json::json!({ "ready": ready }),
+ )),
+ Ok(Err(e)) => as_response(Err::(e)),
+ Err(e) => as_response(Err::(summo_core::Error::Other(
+ format!("warming panicked: {e}"),
+ ))),
+ }
+}
+
/// Every language this registry can recognise, and what would serve each one.
///
/// The screen this feeds replaces a guess. Setup used to recommend a model for whatever language
@@ -3252,6 +3349,9 @@ fn handle_command_with_models(
}
Command::SessionStop => {
let mut events = Vec::new();
+ // Kept before the session is consumed, so the slot can be refilled for whatever the
+ // *next* meeting will most likely be: the same model and language as this one.
+ let finished = session.as_ref().map(|active| active.spec.clone());
if let Some(mut active) = session {
match active.runner.flush() {
Ok(flushed) => {
@@ -3294,8 +3394,49 @@ fn handle_command_with_models(
}
engine.end();
events.push(Event::info("session stopped"));
+
+ // Refill the slot for the next meeting, off the socket. Rebuilding takes about three
+ // and a half seconds and this thread is the one carrying audio and events; doing it
+ // here would stall the stop the user just asked for.
+ if let Some(spec) = finished {
+ let engine = engine.clone();
+ std::thread::spawn(move || {
+ match crate::warm::build(&spec, &engine.store(), engine.hardware()) {
+ Ok((key, decoder)) => engine.warm().put(key, decoder),
+ // Nothing broken: the next recording loads its own decoder exactly as it
+ // did before this optimisation existed.
+ Err(e) => {
+ tracing::debug!(error = %e, "could not pre-load the next decoder")
+ }
+ }
+ });
+ }
(events, None)
}
+ // Build a decoder now, so the next recording does not wait for one.
+ //
+ // Answered with an error in every build until today: this arm fell through to the handler
+ // for daemons compiled *without* recognition, which says "rebuild with --features models"
+ // — in a build that has them. The comment on the command has promised this since it was
+ // written: "so the first recording is not delayed by it".
+ Command::ModelLoad { id } => {
+ let mut spec = crate::protocol::SessionSpec::new(id.trim());
+ spec.language = None;
+ let spec = resolve_models(&spec, engine);
+ match crate::warm::build(&spec, &engine.store(), engine.hardware()) {
+ Ok((key, decoder)) => {
+ let said = key.language.clone().unwrap_or_else(|| "auto".into());
+ let model = key.model.clone();
+ engine.warm().put(key, decoder);
+ (
+ vec![Event::info(format!("{model} ready ({said})"))],
+ session,
+ )
+ }
+ Err(e) => (vec![Event::error(&e)], session),
+ }
+ }
+
// Change the language, or the model, without ending the meeting.
//
// The file, the utterances already committed and the audio archive all continue; only the
@@ -3327,7 +3468,12 @@ fn handle_command_with_models(
// language and lets the daemon pick what hears it.
let spec = resolve_models(&spec, engine);
- match crate::runner::SessionRunner::new(&spec, &engine.store(), engine.hardware()) {
+ match crate::runner::SessionRunner::with_warm(
+ &spec,
+ &engine.store(),
+ engine.hardware(),
+ Some(engine.warm()),
+ ) {
Ok(runner) => {
let said = spec.language.clone().unwrap_or_else(|| "auto".into());
active.runner = runner;
@@ -3363,7 +3509,12 @@ fn start_session(
) -> summo_core::Result {
use time::OffsetDateTime;
- let runner = crate::runner::SessionRunner::new(spec, &engine.store(), engine.hardware())?;
+ let runner = crate::runner::SessionRunner::with_warm(
+ spec,
+ &engine.store(),
+ engine.hardware(),
+ Some(engine.warm()),
+ )?;
// Local time rather than UTC: a meeting belongs to the day it happened on where the user was.
let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
diff --git a/crates/summo-engine/src/state.rs b/crates/summo-engine/src/state.rs
index 9f6522b..8ed45d9 100644
--- a/crates/summo-engine/src/state.rs
+++ b/crates/summo-engine/src/state.rs
@@ -54,6 +54,9 @@ struct Inner {
status: RwLock,
imports: crate::imports::Imports,
installs: crate::install::Installs,
+ /// One speech model kept loaded, so pressing record does not wait 3.4 seconds for one.
+ #[cfg(feature = "models")]
+ warm: crate::warm::Warm,
}
impl EngineState {
@@ -66,10 +69,23 @@ impl EngineState {
status: RwLock::new(SessionStatus::Idle),
imports: crate::imports::Imports::new(),
installs: crate::install::Installs::new(),
+ #[cfg(feature = "models")]
+ warm: crate::warm::Warm::default(),
}),
})
}
+ /// The speech model kept loaded between recordings.
+ ///
+ /// On the state rather than inside the session, because its whole purpose is to exist when no
+ /// session does — a decoder built while nothing is recording is what makes the next recording
+ /// start immediately.
+ #[cfg(feature = "models")]
+ #[must_use]
+ pub fn warm(&self) -> &crate::warm::Warm {
+ &self.inner.warm
+ }
+
/// Imports running in this daemon. Shared, so a job started from one window is visible in
/// every other one and in the CLI.
#[must_use]
diff --git a/crates/summo-engine/src/warm.rs b/crates/summo-engine/src/warm.rs
new file mode 100644
index 0000000..f980cd2
--- /dev/null
+++ b/crates/summo-engine/src/warm.rs
@@ -0,0 +1,205 @@
+//! One speech model, kept loaded, so pressing record does not wait for it.
+//!
+//! Constructing a decoder costs about **3.4 seconds** on this machine — measured on the released
+//! build with `gipformer-65m`, from `session_start` to the daemon answering `session started`. It is
+//! not disk: the second construction in the same process takes the same time as the first, because
+//! what it spends is ONNX Runtime building the session, not reading 70 MB.
+//!
+//! And it was paid *per meeting*. Every recording rebuilt everything, so the three and a half
+//! seconds after pressing record — with no indication anything was happening — were a fixed tax on
+//! the one action the whole product is about.
+//!
+//! So one decoder is kept ready. Deliberately one, not a cache of many:
+//!
+//! * **One model.** A second warm model would double resident memory for the case where somebody
+//! switches languages between meetings, which is rarer than recording twice in the same one.
+//! * **One lane.** A two-lane session — microphone plus system audio — still constructs its second
+//! decoder, because a decoder holds mutable inference state and two lanes cannot share one.
+//! Half of a rare case is instant; all of the common case is.
+//! * **Given away, not lent.** A session takes the decoder out of the slot and owns it. The slot is
+//! refilled in the background afterwards, which keeps the code free of any question about what
+//! happens to a borrowed decoder when a recording is killed.
+//!
+//! Memory is the cost: `gipformer-65m` idles at about 150 MB. That is a real amount to hold for an
+//! app that is not recording, and it is why this is filled on demand — after an install, after a
+//! session, when the interface asks — rather than at startup regardless of whether anyone intends
+//! to record.
+
+use std::sync::Mutex;
+
+use summo_asr::Decoder;
+
+/// What a warm decoder was built for. A slot for the wrong language is a miss.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Key {
+ pub model: String,
+ /// `None` means the model's own detection, which is a different decoder from a named language.
+ pub language: Option,
+ pub threads: usize,
+}
+
+impl Key {
+ #[must_use]
+ pub fn new(model: impl Into, language: Option, threads: usize) -> Self {
+ Self {
+ model: model.into(),
+ language: language
+ .map(|l| l.trim().to_lowercase())
+ .filter(|l| !l.is_empty()),
+ threads,
+ }
+ }
+}
+
+/// The slot.
+#[derive(Default)]
+pub struct Warm {
+ slot: Mutex)>>,
+}
+
+impl Warm {
+ /// Take the decoder if it is the one being asked for.
+ ///
+ /// A miss is not an error and not a fallback to something similar: a decoder built for another
+ /// language would transcribe the meeting in that language, which is the failure this whole
+ /// area of the app exists to prevent.
+ pub fn take(&self, key: &Key) -> Option> {
+ let mut slot = self.slot.lock().ok()?;
+ match slot.as_ref() {
+ Some((held, _)) if held == key => slot.take().map(|(_, decoder)| decoder),
+ _ => None,
+ }
+ }
+
+ /// Put a freshly built decoder in the slot, replacing whatever was there.
+ ///
+ /// Replacing rather than keeping both: the newest request is the best guess at what the next
+ /// recording will want, and holding two models is the memory decision this module exists to
+ /// avoid.
+ pub fn put(&self, key: Key, decoder: Box) {
+ if let Ok(mut slot) = self.slot.lock() {
+ *slot = Some((key, decoder));
+ }
+ }
+
+ /// What is ready, for the interface to say so.
+ #[must_use]
+ pub fn ready(&self) -> Option {
+ self.slot.lock().ok()?.as_ref().map(|(key, _)| key.clone())
+ }
+
+ /// Drop whatever is held, freeing its memory.
+ ///
+ /// Called when the model it holds is removed: a warm decoder pointing at deleted blobs is a
+ /// crash waiting for the next recording.
+ pub fn clear(&self) {
+ if let Ok(mut slot) = self.slot.lock() {
+ *slot = None;
+ }
+ }
+}
+
+impl std::fmt::Debug for Warm {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ // The decoder itself has no useful `Debug`, and printing a model's weights is not what
+ // anybody wants from `{:?}` on the engine state.
+ f.debug_struct("Warm")
+ .field("ready", &self.ready())
+ .finish()
+ }
+}
+
+/// Build a decoder for a spec, ready to be put in the slot.
+///
+/// Uses the session's own loader, so a warm decoder is built by exactly the rules a session would
+/// use — same model resolution, same language, same thread count. Built differently, it would miss
+/// on every take and the slot would be a memory leak with no benefit.
+pub fn build(
+ spec: &crate::protocol::SessionSpec,
+ store: &summo_models::ModelStore,
+ hw: &summo_models::HwProfile,
+) -> summo_core::Result<(Key, Box)> {
+ let threads = hw.recommended_threads();
+ let decoder =
+ crate::runner::load_decoder(&spec.live_model, spec.language.as_deref(), store, threads)?;
+ Ok((
+ Key::new(&spec.live_model, spec.language.clone(), threads),
+ decoder,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use summo_asr::Transcript;
+
+ struct Fake(&'static str);
+
+ impl Decoder for Fake {
+ fn decode(&mut self, _pcm: &[f32]) -> summo_core::Result {
+ Ok(Transcript::default())
+ }
+ fn name(&self) -> &str {
+ self.0
+ }
+ }
+
+ #[test]
+ fn a_warm_decoder_is_handed_over_once() {
+ let warm = Warm::default();
+ let key = Key::new("gipformer-65m", Some("vi".into()), 8);
+ warm.put(key.clone(), Box::new(Fake("gipformer-65m")));
+
+ assert_eq!(warm.ready().as_ref(), Some(&key));
+ assert!(warm.take(&key).is_some(), "the first taker gets it");
+ assert!(warm.take(&key).is_none(), "and it is gone afterwards");
+ assert!(warm.ready().is_none());
+ }
+
+ /// The failure this prevents: a meeting in Japanese decoded by a decoder built for Vietnamese,
+ /// instantly and silently, because it happened to be the one already loaded.
+ #[test]
+ fn a_decoder_for_another_language_is_a_miss_not_a_substitute() {
+ let warm = Warm::default();
+ warm.put(
+ Key::new("whisper-tiny", Some("vi".into()), 8),
+ Box::new(Fake("whisper-tiny")),
+ );
+
+ assert!(
+ warm.take(&Key::new("whisper-tiny", Some("ja".into()), 8))
+ .is_none()
+ );
+ assert!(
+ warm.take(&Key::new("gipformer-65m", Some("vi".into()), 8))
+ .is_none()
+ );
+ // Detection is its own answer, not a wildcard that matches a named language.
+ assert!(warm.take(&Key::new("whisper-tiny", None, 8)).is_none());
+ assert!(
+ warm.take(&Key::new("whisper-tiny", Some("vi".into()), 8))
+ .is_some()
+ );
+ }
+
+ /// `""` and `None` both mean "the model decides", and a slot filled by one must be found by the
+ /// other — the interface sends an empty string, the daemon holds an `Option`.
+ #[test]
+ fn an_empty_language_is_the_same_request_as_none() {
+ let warm = Warm::default();
+ warm.put(
+ Key::new("whisper-tiny", Some(" ".into()), 8),
+ Box::new(Fake("w")),
+ );
+ assert!(warm.take(&Key::new("whisper-tiny", None, 8)).is_some());
+ }
+
+ #[test]
+ fn clearing_frees_the_slot() {
+ let warm = Warm::default();
+ let key = Key::new("m", None, 4);
+ warm.put(key.clone(), Box::new(Fake("m")));
+ warm.clear();
+ assert!(warm.take(&key).is_none());
+ }
+}