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()); + } +}