From 52107f814fe1f5349ccb5084af86f5cdc326939b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 16:39:04 +0000 Subject: [PATCH 1/2] [#175] Wire the #94 source-language setting through resolveStartConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #94 added a user "Spoken language" setting (start message carries sourceLanguageCode), but resolveStartConfig ignored it and hardcoded MEETING_LANGUAGE="English" / SOURCE_LANG_CODE="EN" — the comment even claimed the setting didn't exist. So picking Spoken=Japanese, Translate into=Korean saved "EN → KO" in every archive header and dashboard row: wrong metadata, permanently. Derive both the archive header source label and the reply/quick-translate meeting language from message.sourceLanguageCode via languageByCode(...). The "auto" sentinel (whisper per-utterance detection, the default) — and a blank value from an older shell — is special-cased explicitly: it has no fixed spoken language, so it maps to a deliberately-neutral "AUTO" header label (documented; never a phantom languageByCode("auto")="AUTO"/languageByCode("")=ko) and keeps the pre-#94 English reply/quick-translate fallback. Stale comment replaced. Whisper's own source-language handling (Rust pipeline) is untouched, so per-utterance auto-detect is preserved. Tests: Korean source → header "KO" + meetingLanguage "Korean"; arbitrary BCP-47 (ja) → "JA"/"Japanese"; auto → "AUTO"/"English"; blank → auto. Full app suite 158 green; typecheck, lint, build clean. No new deps; no caption content logged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/host/start-config.ts | 39 ++++++++++++++++++++++++++++--------- test/start-config.test.ts | 41 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/host/start-config.ts b/src/host/start-config.ts index 8422094..1539846 100644 --- a/src/host/start-config.ts +++ b/src/host/start-config.ts @@ -6,17 +6,21 @@ import { DEFAULT_EXTRAS_BUDGET_USD } from "@livecap/engine"; -import { languageByCode } from "../languages.ts"; +import { languageByCode, SOURCE_AUTO_CODE } from "../languages.ts"; import type { EnginePref, HostInbound } from "../protocol.ts"; type StartMessage = Extract; -/** The meeting itself is spoken English (PROPOSAL positioning): reply - * suggestions and quick translate output INTO the meeting, and the archive - * header's source label reflects it. Captions themselves are auto-detected - * per sentence by whisper — there is no source-language setting (§8.6). */ -const MEETING_LANGUAGE = "English"; -const SOURCE_LANG_CODE = "EN"; +/** Reply suggestions and quick-translate output go INTO the meeting's spoken + * language (§8.5), and the archive header's source label reflects it. #94 lets + * the user pick that spoken language; when they leave it on "auto" (per-utterance + * whisper detection, the default) there is no single spoken language, so reply/ + * quick-translate falls back to English — the pre-#94 behavior. */ +const AUTO_MEETING_LANGUAGE = "English"; +/** Archive header source label (§8.9) for auto-detect (#94/#175): a neutral + * "AUTO" — detection is per-utterance, so no one language is the source. A + * PICKED source language uses its own archiveLabel (e.g. "KO"). */ +const AUTO_SOURCE_LABEL = "AUTO"; export interface ResolvedStartConfig { /** Translation target for the engine system prompt, e.g. "Korean". */ @@ -45,11 +49,12 @@ export interface ResolvedStartConfig { export function resolveStartConfig(message: StartMessage): ResolvedStartConfig { const language = languageByCode(message.targetLanguageCode); + const source = resolveSourceLanguage(message.sourceLanguageCode); return { targetLanguage: language.name, summaryLanguage: language.name, - meetingLanguage: MEETING_LANGUAGE, - sourceLangCode: SOURCE_LANG_CODE, + meetingLanguage: source.meetingLanguage, + sourceLangCode: source.archiveLabel, targetLangCode: language.archiveLabel, enginePref: message.enginePref === "local" ? "local" : "cli", poolUsd: Number.isFinite(message.poolUsd) && message.poolUsd > 0 ? message.poolUsd : 20, @@ -65,6 +70,22 @@ export function resolveStartConfig(message: StartMessage): ResolvedStartConfig { }; } +/** Resolve the start message's spoken/source language (#94/#175) into the two + * things the host needs from it: the reply/quick-translate output language and + * the archive header source label. The "auto" sentinel — and an absent/blank + * value from an older shell — keeps per-utterance whisper detection (no fixed + * spoken language), so it maps to the English reply fallback + the neutral + * "AUTO" label; any picked tag resolves to its own language name + archive + * label (e.g. Korean → "Korean" / "KO"). */ +function resolveSourceLanguage(code: string): { meetingLanguage: string; archiveLabel: string } { + const normalized = code.trim().toLowerCase(); + if (normalized === SOURCE_AUTO_CODE || normalized === "") { + return { meetingLanguage: AUTO_MEETING_LANGUAGE, archiveLabel: AUTO_SOURCE_LABEL }; + } + const source = languageByCode(code); + return { meetingLanguage: source.name, archiveLabel: source.archiveLabel }; +} + /** Only an explicit false disables a channel (older shells omit the field); * both-off cannot reach the host (the shell sanitizes it away). */ function resolveChannelsNote(captureSystem: boolean, captureMic: boolean): string | null { diff --git a/test/start-config.test.ts b/test/start-config.test.ts index 24751b9..7761902 100644 --- a/test/start-config.test.ts +++ b/test/start-config.test.ts @@ -34,7 +34,7 @@ describe("resolveStartConfig — language plumbing (#12 goal 4)", () => { expect(resolved.targetLanguage).toBe("Korean"); // (a) translation system prompt expect(resolved.summaryLanguage).toBe("Korean"); // (b) extras/summary output expect(resolved.targetLangCode).toBe("KO"); // (c) archive header meta - expect(resolved.sourceLangCode).toBe("EN"); + expect(resolved.sourceLangCode).toBe("AUTO"); // default source is auto-detect (#94) }); it("supports EN as a target and arbitrary BCP-47 tags", () => { @@ -54,6 +54,45 @@ describe("resolveStartConfig — language plumbing (#12 goal 4)", () => { }); }); +describe("resolveStartConfig — source (spoken) language wiring (#94/#175)", () => { + it("wires a picked source language to the archive header AND the meeting/quick-translate language", () => { + // Spoken Korean, translate into English: the archive header is KO → EN, and + // reply/quick-translate output goes into Korean (the meeting's language). + const resolved = resolveStartConfig( + startMessage({ sourceLanguageCode: "ko", targetLanguageCode: "en" }), + ); + expect(resolved.sourceLangCode).toBe("KO"); // was hardcoded "EN" before #175 + expect(resolved.meetingLanguage).toBe("Korean"); // quick-translate/reply output + expect(resolved.targetLangCode).toBe("EN"); + expect(resolved.summaryLanguage).toBe("English"); // summary stays in the target + }); + + it("carries arbitrary BCP-47 source tags through to the header + meeting language", () => { + const resolved = resolveStartConfig( + startMessage({ sourceLanguageCode: "ja", targetLanguageCode: "ko" }), + ); + expect(resolved.sourceLangCode).toBe("JA"); + expect(resolved.meetingLanguage).toBe("Japanese"); + }); + + it("keeps auto-detect neutral: header label AUTO, reply falls back to English", () => { + const resolved = resolveStartConfig( + startMessage({ sourceLanguageCode: "auto", targetLanguageCode: "ko" }), + ); + // The spoken language is detected per-utterance by whisper, so no fixed source + // language: a neutral "AUTO" header (never a phantom language) and the pre-#94 + // English reply fallback are preserved. + expect(resolved.sourceLangCode).toBe("AUTO"); + expect(resolved.meetingLanguage).toBe("English"); + }); + + it("treats a blank source (older shell) as auto — never languageByCode('') = the default", () => { + const resolved = resolveStartConfig(startMessage({ sourceLanguageCode: "" })); + expect(resolved.sourceLangCode).toBe("AUTO"); + expect(resolved.meetingLanguage).toBe("English"); + }); +}); + describe("resolveStartConfig — gauge + router mapping", () => { it("passes pool/reset-day through and clamps invalid values", () => { const ok = resolveStartConfig(startMessage({ poolUsd: 100, resetDay: 15 })); From 3b20c7b23cdb95604e109a136b26ab162c5f9857 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 16:43:26 +0000 Subject: [PATCH 2/2] [#175] Inline the single-use source-language resolution per RE1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the one-caller `resolveSourceLanguage` helper into resolveStartConfig (RE1 review on PR #185). Behavior is unchanged: auto/blank → English reply fallback + neutral "AUTO" header; a picked tag → its own name + archiveLabel. The AUTO_MEETING_LANGUAGE / AUTO_SOURCE_LABEL constants (documented) stay. 11 start-config tests green; typecheck, lint, build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/host/start-config.ts | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/src/host/start-config.ts b/src/host/start-config.ts index 1539846..019484d 100644 --- a/src/host/start-config.ts +++ b/src/host/start-config.ts @@ -49,12 +49,22 @@ export interface ResolvedStartConfig { export function resolveStartConfig(message: StartMessage): ResolvedStartConfig { const language = languageByCode(message.targetLanguageCode); - const source = resolveSourceLanguage(message.sourceLanguageCode); + // Spoken/source language (#94/#175): "auto" — and a blank value from an older + // shell — keeps per-utterance whisper detection (no fixed spoken language), so + // reply/quick-translate falls back to English and the archive header shows a + // neutral "AUTO". A picked tag uses its own name + archive label (Korean → + // "Korean" / "KO"). (`languageByCode("")` would be the `ko` default, hence the + // explicit blank check.) + const sourceCode = message.sourceLanguageCode.trim().toLowerCase(); + const source = + sourceCode === SOURCE_AUTO_CODE || sourceCode === "" + ? null + : languageByCode(message.sourceLanguageCode); return { targetLanguage: language.name, summaryLanguage: language.name, - meetingLanguage: source.meetingLanguage, - sourceLangCode: source.archiveLabel, + meetingLanguage: source ? source.name : AUTO_MEETING_LANGUAGE, + sourceLangCode: source ? source.archiveLabel : AUTO_SOURCE_LABEL, targetLangCode: language.archiveLabel, enginePref: message.enginePref === "local" ? "local" : "cli", poolUsd: Number.isFinite(message.poolUsd) && message.poolUsd > 0 ? message.poolUsd : 20, @@ -70,22 +80,6 @@ export function resolveStartConfig(message: StartMessage): ResolvedStartConfig { }; } -/** Resolve the start message's spoken/source language (#94/#175) into the two - * things the host needs from it: the reply/quick-translate output language and - * the archive header source label. The "auto" sentinel — and an absent/blank - * value from an older shell — keeps per-utterance whisper detection (no fixed - * spoken language), so it maps to the English reply fallback + the neutral - * "AUTO" label; any picked tag resolves to its own language name + archive - * label (e.g. Korean → "Korean" / "KO"). */ -function resolveSourceLanguage(code: string): { meetingLanguage: string; archiveLabel: string } { - const normalized = code.trim().toLowerCase(); - if (normalized === SOURCE_AUTO_CODE || normalized === "") { - return { meetingLanguage: AUTO_MEETING_LANGUAGE, archiveLabel: AUTO_SOURCE_LABEL }; - } - const source = languageByCode(code); - return { meetingLanguage: source.name, archiveLabel: source.archiveLabel }; -} - /** Only an explicit false disables a channel (older shells omit the field); * both-off cannot reach the host (the shell sanitizes it away). */ function resolveChannelsNote(captureSystem: boolean, captureMic: boolean): string | null {