diff --git a/apps/web/src/components/record/ListeningIn.tsx b/apps/web/src/components/record/ListeningIn.tsx
new file mode 100644
index 0000000..8502e2f
--- /dev/null
+++ b/apps/web/src/components/record/ListeningIn.tsx
@@ -0,0 +1,142 @@
+import { useCallback, useState } from "react";
+
+import { useI18n } from "../../i18n/context";
+import { useEngine } from "../../lib/engine-context";
+import { url } from "../../lib/library";
+import {
+ AUTO,
+ autoAvailable,
+ fetchLanguages,
+ languageName,
+ quality,
+ ready,
+} from "../../lib/languages";
+import { useLoad } from "../../lib/use-load";
+
+/**
+ * What the recording is listening for, said out loud while it records.
+ *
+ * A default is not enough, and this is the reason: a language preference is right most of the time
+ * and wrong exactly when it matters — the customer call in English, the standup that switched. The
+ * old shape asked once, at install, and then never mentioned it again, so a meeting recorded in the
+ * wrong language looked identical to one recorded in the right one until somebody read the
+ * transcript.
+ *
+ * So while recording, the app says which language it is hearing and offers to change it. Changing
+ * is not a restart: the daemon rebuilds the decoder under the running session, and the file, the
+ * timing and everything already transcribed continue. That is what makes this honest to show at
+ * second three rather than as a question before the first word.
+ *
+ * It is deliberately not a modal. The promise is that pressing record records — anything that
+ * stands between the press and the capture is a bug, including a dialog asking to confirm what the
+ * user already chose.
+ */
+export function ListeningIn() {
+ const { session, retune } = useEngine();
+ const { t, locale } = useI18n();
+ const [open, setOpen] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+ // Bumped after a change, to re-read what the daemon now says rather than what this component
+ // asked for — the two differ when a swap fails and the old pipeline keeps running.
+ const [generation, setGeneration] = useState(0);
+ const { handshake } = useEngine();
+
+ const probe = useLoad(
+ useCallback(async () => fetchLanguages(handshake), [handshake]),
+ [handshake],
+ );
+ const languages = probe.data?.languages ?? [];
+
+ // What the *daemon* resolved, not what this browser last chose. A session started without naming
+ // a language falls back to the settings file, and a banner reading the local preference would
+ // announce "detecting automatically" while the daemon confidently decoded Vietnamese — the exact
+ // class of quiet mismatch this banner exists to end. Re-read after every change, and while
+ // recording, because a swap makes the previous answer wrong.
+ const live = useLoad(
+ useCallback(async () => {
+ const response = await fetch(url(handshake, "/status"));
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ // Tagged by `state`, flattened — `{"state":"recording","live_model":…}` — not an
+ // externally tagged `{"Recording":{…}}`. Reading the wrong shape is how this banner spent its
+ // first run insisting every meeting was being detected automatically.
+ return (await response.json()) as {
+ state?: string;
+ live_model?: string;
+ language?: string;
+ };
+ }, [handshake]),
+ [handshake, session.recording, generation],
+ );
+
+ if (!session.recording || dismissed) return null;
+
+ const recording = live.data?.state === "recording" ? live.data : undefined;
+
+ // A session that named no language is not "detecting" — it is running whatever the model does.
+ // For a single-language model that is its language, and saying "automatic" there would be the
+ // same lie in the other direction: gipformer hears Vietnamese and nothing else, whatever the
+ // session forgot to specify.
+ const named = recording?.language;
+ const covered = languages.filter(
+ (language) => language.model === recording?.live_model && !language.multilingual_only,
+ );
+ const spoken = named ?? (covered.length === 1 ? (covered[0]?.code ?? AUTO) : AUTO);
+ const current = languages.find((language) => language.code === spoken);
+ const auto = spoken === AUTO;
+ const label = auto ? t("record.spoken_auto") : languageName(spoken, locale);
+
+ // Only languages that can be used right now. Mid-meeting is the wrong moment to start a 153 MB
+ // download, and offering one would be offering to lose the next four minutes of the call.
+ const usable = languages.filter((language) => ready(language));
+ const canAuto = autoAvailable(languages);
+
+ return (
+
+
+ {t("record.listening_in", { language: label })}
+ {recording?.live_model ? ` · ${recording.live_model}` : ""}
+ {current && quality(current) === "poor" ? ` · ${t("record.spoken_poor")}` : ""}
+
+
+ {!open ? (
+ <>
+ setOpen(true)} className="font-medium underline">
+ {t("record.listening_change")}
+
+ setDismissed(true)}
+ className="text-fg-faint ms-auto"
+ aria-label={t("common.dismiss")}
+ >
+ ✕
+
+ >
+ ) : (
+
+ {t("record.spoken")}
+ {
+ retune(event.target.value);
+ setOpen(false);
+ // The daemon rebuilds the decoder before it answers; a moment later `/status` is the
+ // truth about whether it worked.
+ window.setTimeout(() => setGeneration((n) => n + 1), 600);
+ }}
+ className="border-line bg-bg-soft text-fg h-7 rounded-[var(--radius-card)] border px-2 text-sm"
+ >
+ {canAuto && {t("record.spoken_auto")} }
+ {usable.map((language) => (
+
+ {languageName(language.code, locale)}
+
+ ))}
+
+ {t("record.listening_note")}
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/record/SpokenLanguage.tsx b/apps/web/src/components/record/SpokenLanguage.tsx
index 3fad98e..72e8e67 100644
--- a/apps/web/src/components/record/SpokenLanguage.tsx
+++ b/apps/web/src/components/record/SpokenLanguage.tsx
@@ -40,6 +40,14 @@ import { Button } from "../ui";
* multilingual model on disk; it also costs accuracy and can flip mid-meeting, so it is never the
* default.
*/
+/**
+ * The value of the "several languages" entry.
+ *
+ * Not a language code, and not the empty string it resolves to: a `
` needs a value that is
+ * distinct from every option beside it, and the empty string is already taken by "as configured".
+ */
+const MULTI = "__multi__";
+
export function SpokenLanguage({
value,
onChange,
@@ -94,6 +102,12 @@ export function SpokenLanguage({
// which is right for a wire format and wrong for a list a person scrolls: in Vietnamese, `af`
// renders as "Tiếng Hà Lan (Nam Phi)" and sits between English and Vietnamese for no reason a
// reader can see.
+ // The multilingual entry: one model that covers everything, detecting per utterance. Offered
+ // whether or not it is installed, because "this meeting is in two languages" is a thing a user
+ // knows before they own a model for it — and choosing it should start the download, exactly as
+ // choosing a language does.
+ const multilingual = languages.find((language) => language.multilingual_only && language.model);
+
const options = languages
.filter((language) => language.model)
.sort((a, b) => {
@@ -110,7 +124,12 @@ export function SpokenLanguage({
value={value}
aria-label={t("record.spoken")}
onChange={(event) => {
- const code = event.target.value;
+ const raw = event.target.value;
+ // `MULTI` is not a language, it is a request for one model and no language. It resolves
+ // to the same empty code the daemon and sherpa-onnx already mean by "detect".
+ const code = raw === MULTI ? AUTO : raw;
+ if (raw === MULTI && multilingual && !multilingual.installed)
+ void install(multilingual);
onChange(code);
// Written through to the daemon so the choice survives this browser. A failure here is
// deliberately swallowed: the recording still has the language, and a preference that
@@ -121,13 +140,26 @@ export function SpokenLanguage({
>
{/* Detection first when it is possible, because somebody who does not know what will be
spoken is exactly who needs it. */}
- {auto && {t("record.spoken_auto")} }
+ {/* First, always, whichever it is: the option that describes what the control is doing
+ *now*. With detection available that is "automatic"; without it, "as configured" —
+ and without either the browser would show the first language in the list, so a
+ control meaning "whatever the settings say" silently claimed to be recording English,
+ the one wrong answer that never announces itself. */}
+ {auto ? (
+ {t("record.spoken_auto")}
+ ) : (
+ value === AUTO && {t("record.spoken_default")}
+ )}
- {/* When nothing has been chosen and detection is not available, the empty value has to be
- an option of its own. Without it the browser shows the first entry in the list, so a
- control that means "whatever the settings say" silently claimed to be recording
- English — the one wrong answer that never announces itself. */}
- {!auto && value === AUTO && {t("record.spoken_default")} }
+ {/* Then the multilingual entry: one model that hears everything, detecting per utterance.
+ Offered whether or not it is installed, because "this meeting is in two languages" is
+ something a user knows before they own a model for it. */}
+ {!auto && multilingual && (
+
+ {t("record.spoken_multi")}
+ {multilingual.installed ? "" : ` · ${megabytes(multilingual.size_bytes)}`}
+
+ )}
{options.map((language) => (
{languageName(language.code, locale)}
diff --git a/apps/web/src/components/shell/RootLayout.tsx b/apps/web/src/components/shell/RootLayout.tsx
index 3679ce6..39a0b68 100644
--- a/apps/web/src/components/shell/RootLayout.tsx
+++ b/apps/web/src/components/shell/RootLayout.tsx
@@ -29,6 +29,7 @@ import { useEngine } from "../../lib/engine-context";
import { deviceWarning } from "../../lib/session";
import { useErrorText } from "../../lib/errors";
import { RecordButton } from "../RecordButton";
+import { ListeningIn } from "../record/ListeningIn";
import { StatusBar } from "../StatusBar";
import { Waveform } from "../Waveform";
import { motion } from "motion/react";
@@ -246,6 +247,13 @@ export function RootLayout({ children }: { children: ReactNode }) {
+ {/* What the running recording is hearing, on every screen — because a recording survives
+ navigation, and "this is in English actually" is realised while looking at something
+ else. Renders nothing when idle. */}
+
+
+
+
{/* A refused microphone is the one failure with a repair path, so it gets a link to the
panel that repairs it. Every other failure gets the sentence alone: a button that leads
somewhere unhelpful is worse than no button. */}
diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json
index 5052c4b..921bf76 100644
--- a/apps/web/src/i18n/en.json
+++ b/apps/web/src/i18n/en.json
@@ -61,7 +61,11 @@
"spoken_installing": "Downloading… {pct}%",
"spoken_wait": "Recording works as soon as it finishes.",
"spoken_missing": "No model for this language yet.",
- "spoken_default": "As configured"
+ "spoken_default": "As configured",
+ "listening_in": "Listening in {language}",
+ "listening_change": "Change",
+ "listening_note": "Changes take effect immediately; nothing already recorded is lost.",
+ "spoken_multi": "Several languages (detected)"
},
"import": {
"title": "Import a recording",
@@ -361,7 +365,8 @@
"close": "Close",
"retry": "Retry",
"loading": "Loading…",
- "saving": "Saving…"
+ "saving": "Saving…",
+ "dismiss": "Dismiss"
},
"setup": {
"title": "Welcome to Summo",
diff --git a/apps/web/src/i18n/ja.json b/apps/web/src/i18n/ja.json
index 162543c..7292762 100644
--- a/apps/web/src/i18n/ja.json
+++ b/apps/web/src/i18n/ja.json
@@ -61,7 +61,11 @@
"spoken_installing": "ダウンロード中… {pct}%",
"spoken_wait": "完了したらすぐ録音できます。",
"spoken_missing": "この言語のモデルはまだありません。",
- "spoken_default": "設定に従う"
+ "spoken_default": "設定に従う",
+ "listening_in": "{language} で聞き取り中",
+ "listening_change": "変更",
+ "listening_note": "その場で切り替わります。録音済みの内容は失われません。",
+ "spoken_multi": "複数言語(自動判定)"
},
"import": {
"title": "録音ファイルを取り込む",
@@ -361,7 +365,8 @@
"close": "閉じる",
"retry": "やり直す",
"loading": "読み込み中…",
- "saving": "保存中…"
+ "saving": "保存中…",
+ "dismiss": "閉じる"
},
"setup": {
"title": "Summoへようこそ",
diff --git a/apps/web/src/i18n/vi.json b/apps/web/src/i18n/vi.json
index c146c9a..c14e8cc 100644
--- a/apps/web/src/i18n/vi.json
+++ b/apps/web/src/i18n/vi.json
@@ -61,7 +61,11 @@
"spoken_installing": "Đang tải… {pct}%",
"spoken_wait": "Ghi được ngay khi tải xong.",
"spoken_missing": "Chưa có model cho ngôn ngữ này.",
- "spoken_default": "Theo cài đặt"
+ "spoken_default": "Theo cài đặt",
+ "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)"
},
"import": {
"title": "Nhập bản ghi có sẵn",
@@ -361,7 +365,8 @@
"close": "Đóng",
"retry": "Thử lại",
"loading": "Đang tải…",
- "saving": "Đang lưu…"
+ "saving": "Đang lưu…",
+ "dismiss": "Đóng"
},
"setup": {
"title": "Chào mừng tới Summo",
diff --git a/apps/web/src/i18n/zh.json b/apps/web/src/i18n/zh.json
index f324ddb..d868416 100644
--- a/apps/web/src/i18n/zh.json
+++ b/apps/web/src/i18n/zh.json
@@ -61,7 +61,11 @@
"spoken_installing": "下载中… {pct}%",
"spoken_wait": "下载完成即可开始录音。",
"spoken_missing": "还没有适用于此语言的模型。",
- "spoken_default": "按设置"
+ "spoken_default": "按设置",
+ "listening_in": "正在以{language}聆听",
+ "listening_change": "更改",
+ "listening_note": "立即生效,已录制的内容不会丢失。",
+ "spoken_multi": "多种语言(自动识别)"
},
"import": {
"title": "导入录音",
@@ -361,7 +365,8 @@
"close": "关闭",
"retry": "重试",
"loading": "加载中…",
- "saving": "保存中…"
+ "saving": "保存中…",
+ "dismiss": "关闭"
},
"setup": {
"title": "欢迎使用 Summo",
diff --git a/apps/web/src/lib/engine-context.tsx b/apps/web/src/lib/engine-context.tsx
index d069cde..4f1e554 100644
--- a/apps/web/src/lib/engine-context.tsx
+++ b/apps/web/src/lib/engine-context.tsx
@@ -36,6 +36,8 @@ export interface EngineValue {
start: () => Promise;
stop: () => void;
toggle: () => void;
+ /** Change the language mid-meeting, without ending it. */
+ retune: (language: string) => void;
}
export const EngineContext = createContext(null);
diff --git a/apps/web/src/lib/engine-provider.tsx b/apps/web/src/lib/engine-provider.tsx
index 5aa793c..f658920 100644
--- a/apps/web/src/lib/engine-provider.tsx
+++ b/apps/web/src/lib/engine-provider.tsx
@@ -11,7 +11,7 @@ import { DEV_HANDSHAKE, EngineContext, IDLE, type EngineValue, type Stat } from
import { LibraryClient } from "./library";
import { PeopleClient } from "./people";
import type { Event } from "./protocol";
-import { load as loadCapture } from "./capture";
+import { load as loadCapture, save as saveCapture } from "./capture";
import { Session, handshakeFromLocation, type SessionState } from "./session";
import { apply, empty, type TranscriptState } from "./transcript";
@@ -86,6 +86,14 @@ export function EngineProvider({ children }: { children: ReactNode }) {
});
}, []);
+ // Mid-meeting, and it also updates what the *next* meeting starts from: somebody who corrects
+ // the language during a call has told us the setting was wrong, not only this recording.
+ const retune = useCallback((language: string) => {
+ const current = loadCapture();
+ saveCapture({ ...current, spoken: language });
+ controller.current?.retune(language);
+ }, []);
+
const stop = useCallback(() => {
if (timer.current !== null) window.clearInterval(timer.current);
timer.current = null;
@@ -130,6 +138,7 @@ export function EngineProvider({ children }: { children: ReactNode }) {
start,
stop,
toggle,
+ retune,
}),
[
library,
@@ -144,6 +153,7 @@ export function EngineProvider({ children }: { children: ReactNode }) {
start,
stop,
toggle,
+ retune,
],
);
diff --git a/apps/web/src/lib/protocol.ts b/apps/web/src/lib/protocol.ts
index d36bf25..2a62dcf 100644
--- a/apps/web/src/lib/protocol.ts
+++ b/apps/web/src/lib/protocol.ts
@@ -64,7 +64,9 @@ export type Command =
| { cmd: "session_stop" }
| { cmd: "model_load"; id: string }
| { cmd: "model_pull"; id: string }
- | { cmd: "model_swap"; 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.
+ | { cmd: "model_swap"; id?: string; language?: string }
| { cmd: "ping" };
/** Whether an event carries transcript text. */
diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts
index 183455b..7106977 100644
--- a/apps/web/src/lib/session.ts
+++ b/apps/web/src/lib/session.ts
@@ -69,7 +69,21 @@ export class Session {
this.update({ error: null });
this.client = new EngineClient(this.handshake, {
- onEvent: this.callbacks.onEvent,
+ onEvent: (event) => {
+ // A refusal from the daemon ends the recording *here* too. Without this the app kept its
+ // timer running, its button red and its banner up while the daemon sat idle — and the
+ // failure was only visible to somebody who read the transcript that never appeared. A
+ // transient error is different: the pipeline is still running and will catch up.
+ if (event.kind === "error" && !event.transient && this.state.recording) {
+ this.microphone?.stop();
+ this.microphone = null;
+ this.update({
+ recording: false,
+ error: { code: "session_refused", error: event.message },
+ });
+ }
+ this.callbacks.onEvent(event);
+ },
onState: (connection) => this.update({ connection }),
});
this.client.connect();
@@ -114,6 +128,19 @@ export class Session {
});
}
+ /**
+ * Change what is listening, without ending the meeting.
+ *
+ * A meeting is not always in the language the settings say, and that is discovered *during* it —
+ * usually in the first sentence. Stopping and starting again costs exactly the part where
+ * somebody noticed, so this leaves the recording, the file and everything transcribed alone and
+ * rebuilds only the decoder.
+ */
+ retune(language: string): void {
+ if (!this.state.recording) return;
+ this.client?.send({ cmd: "model_swap", language });
+ }
+
stop(): void {
this.microphone?.stop();
this.microphone = null;
diff --git a/crates/summo-engine/src/protocol.rs b/crates/summo-engine/src/protocol.rs
index cd8bcde..efe4947 100644
--- a/crates/summo-engine/src/protocol.rs
+++ b/crates/summo-engine/src/protocol.rs
@@ -23,8 +23,22 @@ pub enum Command {
ModelLoad { id: String },
/// Fetch and install a model from the registry.
ModelPull { id: String },
- /// Swap the live model mid-recording. The open utterance is re-decoded by the new model.
- ModelSwap { id: String },
+ /// Change what is listening, without ending the meeting.
+ ///
+ /// Both fields are optional and mean "leave this as it is": a user who realises the call is in
+ /// English changes the language, and the model follows from it; a user comparing two models
+ /// changes the model and keeps the language. The recording, the file and everything already
+ /// transcribed are untouched — only the next utterance is decoded differently.
+ ///
+ /// This exists because the alternative is stopping and starting again, which costs the part of
+ /// the meeting where somebody noticed. A meeting is not always in the language its owner's
+ /// settings say, and finding that out is something that happens *during* it.
+ ModelSwap {
+ #[serde(default)]
+ id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ language: Option,
+ },
/// Keepalive. Some proxies drop an idle WebSocket, and a dropped socket mid-meeting is data loss.
Ping,
}
@@ -161,7 +175,14 @@ mod tests {
Command::SessionStop,
Command::ModelLoad { id: "x".into() },
Command::ModelPull { id: "x".into() },
- Command::ModelSwap { id: "x".into() },
+ Command::ModelSwap {
+ id: "x".into(),
+ language: None,
+ },
+ Command::ModelSwap {
+ id: String::new(),
+ language: Some("en".into()),
+ },
Command::Ping,
];
for cmd in cases {
diff --git a/crates/summo-engine/src/server.rs b/crates/summo-engine/src/server.rs
index 5059e9b..3ed7b2d 100644
--- a/crates/summo-engine/src/server.rs
+++ b/crates/summo-engine/src/server.rs
@@ -644,22 +644,44 @@ fn resolve_models(
}
let settings = summo_core::Settings::load(&engine.paths().settings()).unwrap_or_default();
+ if spec.language.is_none() {
+ spec.language = settings.models.language.clone();
+ }
+
if let Some(chosen) = settings.models.live.filter(|m| !m.trim().is_empty()) {
spec.live_model = chosen;
- } else {
- let speech: Vec<_> = engine
- .store()
- .list()
- .into_iter()
- .filter(|m| m.task == summo_models::Task::Asr)
- .collect();
- if let [only] = speech.as_slice() {
- spec.live_model = only.id.to_string();
- }
+ return spec;
}
- if spec.language.is_none() {
- spec.language = settings.models.language;
+ let speech: Vec<_> = engine
+ .store()
+ .list()
+ .into_iter()
+ .filter(|m| m.task == summo_models::Task::Asr)
+ .collect();
+
+ // One model is not a choice.
+ if let [only] = speech.as_slice() {
+ spec.live_model = only.id.to_string();
+ return spec;
+ }
+
+ // More than one, and nothing in the settings says which. This used to give up — and giving up
+ // means `session needs a live model`, so installing a *second* speech model broke recording
+ // until the user went and picked one. The language is the thing that decides, and by now the
+ // app knows it: rank the installed models for it and take the best, which is the same answer
+ // the model picker would give.
+ //
+ // With no language either, the multilingual models are the honest default: a model that only
+ // speaks Vietnamese is the wrong guess for a meeting nobody has described.
+ let language = spec.language.clone().unwrap_or_else(|| "*".into());
+ let ranked = summo_models::recommend(&speech, engine.hardware(), &language);
+ if let Some(best) = ranked.best() {
+ spec.live_model = best.id.clone();
+ } else if let Some(first) = speech.first() {
+ // Nothing covers the language. Recording in the wrong language beats refusing to record:
+ // the transcript is visibly wrong and fixable, and the meeting is not repeatable.
+ spec.live_model = first.id.to_string();
}
spec
}
@@ -3156,7 +3178,7 @@ fn handle_command(text: &str, engine: &EngineState) -> Vec {
transient: false,
}]
}
- Command::ModelLoad { id } | Command::ModelSwap { id } => vec![Event::Error {
+ Command::ModelLoad { id } | Command::ModelSwap { id, .. } => vec![Event::Error {
message: format!(
"cannot load `{id}`: this binary was built without recognition support. \
Rebuild with `--features models`."
@@ -3169,6 +3191,9 @@ fn handle_command(text: &str, engine: &EngineState) -> Vec {
/// A running recording: the pipeline, the file it is being written into, and when it started.
#[cfg(feature = "models")]
struct ActiveSession {
+ /// What this session was started with, so a mid-meeting change is an edit of it rather than a
+ /// new set of assumptions.
+ spec: crate::protocol::SessionSpec,
runner: crate::runner::SessionRunner,
recorder: crate::recorder::Recorder,
archive: crate::archive::AudioArchive,
@@ -3271,6 +3296,58 @@ fn handle_command_with_models(
events.push(Event::info("session stopped"));
(events, None)
}
+ // Change the language, or the model, without ending the meeting.
+ //
+ // The file, the utterances already committed and the audio archive all continue; only the
+ // decoder is rebuilt, so the next utterance is heard by the new one. The open utterance is
+ // lost rather than re-decoded — its audio lives inside the pipeline being replaced, and
+ // half a sentence transcribed twice is worse than half a sentence missing.
+ Command::ModelSwap { id, language } => {
+ let Some(mut active) = session else {
+ // Not an error worth failing on: a client that swaps before recording is asking for
+ // the setting, and the setting is an HTTP call away.
+ return (
+ vec![Event::error(&summo_core::Error::Config(
+ "no recording to change; set the model or language in settings instead"
+ .into(),
+ ))],
+ None,
+ );
+ };
+
+ let mut spec = active.spec.clone();
+ if !id.trim().is_empty() {
+ spec.live_model = id.trim().to_string();
+ }
+ if let Some(language) = language {
+ let language = language.trim().to_lowercase();
+ spec.language = (!language.is_empty()).then_some(language);
+ }
+ // An empty model with a new language is the common case — the interface names a
+ // 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()) {
+ Ok(runner) => {
+ let said = spec.language.clone().unwrap_or_else(|| "auto".into());
+ active.runner = runner;
+ active.spec = spec.clone();
+ // So `/status` — and the banner reading it — says what is true now.
+ engine.retuned(&spec);
+ (
+ vec![Event::info(format!(
+ "now listening with {} in {said}",
+ spec.live_model
+ ))],
+ Some(active),
+ )
+ }
+ // The old pipeline is still in `active` and still working, so a failed swap leaves
+ // the meeting recording rather than ending it. Losing a meeting because a model
+ // would not load is the worst possible answer to "change the language".
+ Err(e) => (vec![Event::error(&e)], Some(active)),
+ }
+ }
other => {
let events = handle_command(&serde_json::to_string(&other).unwrap_or_default(), engine);
(events, session)
@@ -3338,6 +3415,7 @@ fn start_session(
};
Ok(ActiveSession {
+ spec: spec.clone(),
runner,
recorder,
archive,
@@ -3466,6 +3544,45 @@ mod resolve_tests {
assert_eq!(resolved.language, None);
}
+ /// Installing a second speech model used to break recording. With nothing named in the
+ /// settings the resolver only handled "exactly one", so the moment a user added a model for
+ /// another language, every recording failed with `session needs a live model` — and the
+ /// interface, which deliberately names none, could not start one at all.
+ #[test]
+ fn a_second_installed_model_does_not_break_recording() {
+ let tmp = tempfile::tempdir().unwrap();
+ let engine = engine(tmp.path());
+
+ // Two speech models on disk. `list()` reads the manifest directory, so writing manifests is
+ // what "installed" means here — no blobs are needed to choose between them.
+ std::fs::create_dir_all(engine.paths().manifests()).unwrap();
+ for (id, langs) in [("gipformer-65m", r#"["vi"]"#), ("whisper-tiny", r#"["*"]"#)] {
+ std::fs::write(
+ engine.paths().manifests().join(format!("{id}.json")),
+ format!(
+ r#"{{"schema":1,"id":"{id}","name":"{id}","task":"asr","mode":"live",
+ "runtime":"test","langs":{langs},"license":"MIT","size_bytes":1,
+ "profile":{{"rtf":{{"cpu_x86_avx512vnni_8t":0.02}},
+ "quality":{{"wer_fleurs_vi":0.09}}}},
+ "files":[{{"name":"m.onnx","sha256":"{sha}","size":1,
+ "url":"https://example.invalid/m"}}]}}"#,
+ sha = "a".repeat(64)
+ ),
+ )
+ .unwrap();
+ }
+
+ let mut settings = summo_core::Settings::default();
+ settings.models.language = Some("vi".into());
+ settings.save(&engine.paths().settings()).unwrap();
+
+ let resolved = resolve_models(&crate::protocol::SessionSpec::new(""), &engine);
+ assert_eq!(
+ resolved.live_model, "gipformer-65m",
+ "the language decides between them"
+ );
+ }
+
/// A client that knows which model it wants keeps it. The import job names one on purpose.
#[test]
fn a_named_model_is_left_alone() {
@@ -5203,6 +5320,44 @@ ATTENDEE:mailto:b@x\r\nEND:VEVENT\r\n",
));
}
+ /// Changing the language mid-meeting is only useful if the meeting survives it. Without a
+ /// session there is nothing to change, and saying so beats loading a model nobody asked for.
+ #[cfg(feature = "models")]
+ #[test]
+ fn a_swap_with_no_recording_says_where_the_setting_lives() {
+ let (_tmp, engine) = engine();
+ let swap = serde_json::to_string(&Command::ModelSwap {
+ id: String::new(),
+ language: Some("en".into()),
+ })
+ .unwrap();
+ // `handle_command_with_models` and not `handle_command`: the swap belongs to the half of
+ // the protocol that owns a pipeline, and the other half answers "built without recognition
+ // support" for every model command, which is true of that build and not of this one.
+ let (events, session) = handle_command_with_models(&swap, &engine, None);
+ assert!(
+ matches!(&events[0], Event::Error { message, .. } if message.contains("settings")),
+ "{events:?}"
+ );
+ assert!(
+ session.is_none(),
+ "a failed swap must not invent a recording"
+ );
+ }
+
+ /// The wire form matters as much as the behaviour: the interface sends a language and no model,
+ /// and a `serde` default that made `id` mandatory would reject exactly that message.
+ #[test]
+ fn a_swap_may_name_a_language_without_naming_a_model() {
+ let parsed: Command =
+ serde_json::from_str(r#"{"cmd":"model_swap","language":"en"}"#).expect("parses");
+ assert!(matches!(
+ parsed,
+ Command::ModelSwap { ref id, ref language }
+ if id.is_empty() && language.as_deref() == Some("en")
+ ));
+ }
+
#[test]
fn session_start_and_stop_move_the_engine_state() {
let (_tmp, engine) = engine();
diff --git a/crates/summo-engine/src/state.rs b/crates/summo-engine/src/state.rs
index 0b949e0..9f6522b 100644
--- a/crates/summo-engine/src/state.rs
+++ b/crates/summo-engine/src/state.rs
@@ -20,6 +20,14 @@ pub enum SessionStatus {
live_model: String,
#[serde(skip_serializing_if = "Option::is_none")]
refine_model: Option,
+ /// The language this session resolved to, or `None` for the model's own detection.
+ ///
+ /// Here because the interface has to be able to *say* what it is hearing, and its own copy
+ /// of the preference is not that: a session started with no language named resolves to the
+ /// daemon's setting, and a banner reading it from the browser would announce "detecting"
+ /// while the daemon confidently decoded Vietnamese.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ language: Option,
/// Utterances committed so far.
segments: u64,
},
@@ -109,11 +117,31 @@ impl EngineState {
elapsed_s: 0.0,
live_model: spec.live_model.clone(),
refine_model: spec.refine_model.clone(),
+ language: spec.language.clone(),
segments: 0,
};
Ok(())
}
+ /// Say what a running session is listening with now, after a mid-meeting change.
+ ///
+ /// Silently ignored when nothing is recording: a swap that arrives as the meeting ends is a
+ /// race, not a mistake, and there is no state left to correct.
+ pub fn retuned(&self, spec: &SessionSpec) {
+ let mut status = self.inner.status.write();
+ if let SessionStatus::Recording {
+ live_model,
+ refine_model,
+ language,
+ ..
+ } = &mut *status
+ {
+ live_model.clone_from(&spec.live_model);
+ refine_model.clone_from(&spec.refine_model);
+ language.clone_from(&spec.language);
+ }
+ }
+
/// Record progress, for the status endpoint and the performance HUD.
pub fn advance(&self, added_s: f64, added_segments: u64) {
let mut status = self.inner.status.write();