Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/components/record/CaptureControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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. */}
<ListeningIn />
<WarmUp />

<fieldset disabled={busy} className="mt-2 disabled:opacity-60">
<legend className="sr-only">{t("record.audio_source")}</legend>
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/components/record/WarmUp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p className="text-fg-faint text-micro mb-2">
{t("record.warm_ready", { model: state.data.model })}
</p>
);
}
3 changes: 2 additions & 1 deletion apps/web/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"listening_in": "{language} で聞き取り中",
"listening_change": "変更",
"listening_note": "その場で切り替わります。録音済みの内容は失われません。",
"spoken_multi": "複数言語(自動判定)"
"spoken_multi": "複数言語(自動判定)",
"warm_ready": "すぐ録音できます — {model} を読み込み済み。"
},
"import": {
"title": "録音ファイルを取り込む",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/vi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"listening_in": "正在以{language}聆听",
"listening_change": "更改",
"listening_note": "立即生效,已录制的内容不会丢失。",
"spoken_multi": "多种语言(自动识别)"
"spoken_multi": "多种语言(自动识别)",
"warm_ready": "可立即录音——已加载 {model}。"
},
"import": {
"title": "导入录音",
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/lib/languages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ready | null> {
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<Ready | null | undefined> {
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;
}
3 changes: 2 additions & 1 deletion apps/web/src/lib/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions crates/summo-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
26 changes: 24 additions & 2 deletions crates/summo-engine/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
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<Self> {
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<dyn Vad> = 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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading