From af267b7d917d4fc599342f18508bc03f9c40fdbb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 18:33:48 +0000 Subject: [PATCH 1/9] [#178] device: strip the (input)/(output) suffix case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AudioDevice::from_name detected the type with a case-insensitive check (name.to_lowercase().ends_with("(input)")) but stripped with an exact-case trim_end_matches("(input)") on the ORIGINAL name — so "(Input)"/"(OUTPUT)" passed detection yet left the suffix stuck on the returned name, which would then never match cpal's exact device.name() (spurious DeviceNotFound). Latent today (only the crate's lowercase Display impl calls from_name), but the contract is wrong. Strip by the matched suffix's byte length (ASCII → len == char count in any casing). Regression test covers (Input)/(INPUT)/(Output). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/livecap-core/src/audio/device.rs | 29 +++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/livecap-core/src/audio/device.rs b/crates/livecap-core/src/audio/device.rs index 45005b3..cbfd226 100644 --- a/crates/livecap-core/src/audio/device.rs +++ b/crates/livecap-core/src/audio/device.rs @@ -33,14 +33,20 @@ impl AudioDevice { return Err(anyhow!("Device name cannot be empty")); } - let (name, device_type) = if name.to_lowercase().ends_with("(input)") { + // Detect and strip on the SAME case-insensitive basis (#178): the suffix + // is ASCII, so its char-length equals its byte-length in any casing, and + // slicing the ORIGINAL by that length removes the exact matched bytes — + // whereas `trim_end_matches("(input)")` on the original would silently + // leave a differently-cased "(Input)"/"(OUTPUT)" suffix stuck on. + let lower = name.to_lowercase(); + let (name, device_type) = if lower.ends_with("(input)") { ( - name.trim_end_matches("(input)").trim().to_string(), + name[..name.len() - "(input)".len()].trim().to_string(), DeviceType::Input, ) - } else if name.to_lowercase().ends_with("(output)") { + } else if lower.ends_with("(output)") { ( - name.trim_end_matches("(output)").trim().to_string(), + name[..name.len() - "(output)".len()].trim().to_string(), DeviceType::Output, ) } else { @@ -164,6 +170,21 @@ mod tests { assert_eq!(d.device_type, DeviceType::Output); } + #[test] + fn strips_the_suffix_regardless_of_case() { + // #178: detection is case-insensitive, so stripping must be too — a + // differently-cased suffix must not be left stuck on the device name (which + // would fail the exact-match lookup in get_device_and_config). + for raw in ["Studio Mic (Input)", "Studio Mic (INPUT)", "Studio Mic (input)"] { + let d = AudioDevice::from_name(raw).unwrap(); + assert_eq!(d.name, "Studio Mic", "case variant {raw:?} must strip cleanly"); + assert_eq!(d.device_type, DeviceType::Input); + } + let d = AudioDevice::from_name("Big Speaker (Output)").unwrap(); + assert_eq!(d.name, "Big Speaker"); + assert_eq!(d.device_type, DeviceType::Output); + } + #[test] fn rejects_unqualified_names() { assert!(AudioDevice::from_name("Just A Device").is_err()); From 4efdc82c1151886527665bee42799d337b38ee08 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 18:34:38 +0000 Subject: [PATCH 2/9] [#178] pipeline: preserve "auto-translate" through with_source_language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit with_source_language reduced every non-auto tag to its primary subtag, so the documented special value "auto-translate" (per-utterance detect + translate to English, matched exactly by whisper/engine.rs set_translate) was split to "auto" and silently downgraded to plain auto-detect — the translate-to-English mode was unreachable through the only production setter. Special-case it before the subtag split. Regression test covers "auto-translate" and a cased/spaced variant. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/livecap-core/src/pipeline.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/livecap-core/src/pipeline.rs b/crates/livecap-core/src/pipeline.rs index 21d38b2..9b53bcd 100644 --- a/crates/livecap-core/src/pipeline.rs +++ b/crates/livecap-core/src/pipeline.rs @@ -81,13 +81,18 @@ impl PipelineConfig { /// Set the spoken/source transcription language (#94). A BCP-47 / ISO-639-1 /// code forces whisper to that language; `"auto"` (or empty) keeps the - /// current per-utterance auto-detection (`language = None`). Whisper only - /// understands a primary subtag, so a tag like `"pt-br"` is reduced to its + /// current per-utterance auto-detection (`language = None`). The special value + /// `"auto-translate"` (per-utterance detect + translate to English) is passed + /// through verbatim (#178) — the engine matches it exactly (`whisper/engine.rs` + /// `set_translate`); without this it would hit the subtag reduction below and + /// be mangled to `"auto"`, silently disabling translation. Whisper otherwise + /// only understands a primary subtag, so a tag like `"pt-br"` is reduced to its /// primary subtag (`"pt"`) before being handed to the engine. pub fn with_source_language(mut self, code: &str) -> Self { let normalized = code.trim().to_lowercase(); self.language = match normalized.as_str() { "" | "auto" => None, + "auto-translate" => Some("auto-translate".to_string()), other => Some(other.split('-').next().unwrap_or(other).to_string()), }; self @@ -775,6 +780,22 @@ mod tests { assert_eq!(PipelineConfig::new(&dir).with_source_language(" AUTO ").language, None); } + #[test] + fn source_language_preserves_auto_translate() { + // #178: "auto-translate" (detect + translate to English) must survive the + // setter verbatim — the subtag split would otherwise mangle it to "auto" + // and silently disable translation. The engine matches the exact string. + let dir = std::path::PathBuf::from("/tmp/livecap-models"); + assert_eq!( + PipelineConfig::new(&dir).with_source_language("auto-translate").language, + Some("auto-translate".to_string()) + ); + assert_eq!( + PipelineConfig::new(&dir).with_source_language(" Auto-Translate ").language, + Some("auto-translate".to_string()) + ); + } + #[test] fn with_model_overrides_the_default() { // #110: the Settings pick lands in PipelineConfig.model; the default From 7ea13d83a211c621180d342748fe4629846d3554 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 18:36:12 +0000 Subject: [PATCH 3/9] [#178] fallback-router: reset routing state even if an engine's stop() rejects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stop() used Promise.all, so if either engine's stop() rejected, the two state-reset lines (active = primary; usingFallback = false) were skipped — leaving usingFallback stuck true, so the NEXT session's start() would silently route to the stopped fallback tier even after credit recovered. Switch to Promise.allSettled (await both stops), reset routing UNCONDITIONALLY, then rethrow the first error so the caller still sees the failure. Not reachable with today's engines (neither stop() rejects) but the TranslationEngine contract doesn't forbid it. Regression test: a fallback whose stop() throws still leaves the router routing to primary next session, and the error propagates. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/engine/src/fallback-router.ts | 15 ++++++++--- packages/engine/test/fallback-router.test.ts | 26 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/engine/src/fallback-router.ts b/packages/engine/src/fallback-router.ts index 7aee618..f1c01ba 100644 --- a/packages/engine/src/fallback-router.ts +++ b/packages/engine/src/fallback-router.ts @@ -70,12 +70,19 @@ export class FallbackRouter implements TranslationEngine { // returned by onUsage() — NOT cleared here. Both engines keep their listeners // across stop(), so accounting (e.g. accountant.attach(router) once) keeps // working after a stop/start cycle (#38). - // Stop both; the fallback may have been started on a switch. - await Promise.all([this.primary.stop(), this.fallback.stop()]); - // Reset routing so the NEXT session begins on the primary again — otherwise - // a Stop/Start after an auto-fallback would route to the stopped fallback. + // Stop both; the fallback may have been started on a switch. `allSettled` so + // ONE engine's stop() rejecting can't skip the state reset below (#178): with + // `Promise.all`, a rejection stranded `usingFallback = true`, and the next + // session's start() would then silently route to the (stopped) fallback tier + // even after credit recovered. Neither shipped engine's stop() rejects today, + // but the TranslationEngine contract does not forbid it. + const results = await Promise.allSettled([this.primary.stop(), this.fallback.stop()]); + // Reset routing UNCONDITIONALLY so the NEXT session begins on the primary + // again, regardless of a partial stop() failure. this.active = this.primary; this.usingFallback = false; + const rejected = results.find((r) => r.status === "rejected"); + if (rejected) throw (rejected as PromiseRejectedResult).reason; } /** Synchronous force-kill of both tiers' OS children (#66 process teardown). */ diff --git a/packages/engine/test/fallback-router.test.ts b/packages/engine/test/fallback-router.test.ts index ed1d4b1..c1c43ca 100644 --- a/packages/engine/test/fallback-router.test.ts +++ b/packages/engine/test/fallback-router.test.ts @@ -132,6 +132,32 @@ describe("FallbackRouter", () => { expect((await collect(router.translate(batch, { pairs: [] }))).at(-1)?.text).toBe("fallback:final"); }); + it("resets routing even when one engine's stop() rejects (#178)", async () => { + // A partial stop() failure must not strand the router on the fallback: the + // NEXT session must still begin on the primary. The error still propagates. + const primary = new StubEngine("primary"); + class FailStopEngine extends StubEngine { + override async stop(): Promise { + throw new Error("fallback stop failed"); + } + } + const fallback = new FailStopEngine("fallback"); + const router = new FallbackRouter({ primary, fallback }); + + await router.start(); + await router.switchToFallback(); + expect(router.onFallback).toBe(true); + + // stop() rejects (fallback.stop threw) — but routing state is reset anyway. + await expect(router.stop()).rejects.toThrow("fallback stop failed"); + expect(router.onFallback).toBe(false); + + // The next session starts on the PRIMARY, not the stopped fallback. + await router.start(); + expect(router.onFallback).toBe(false); + expect((await collect(router.translate(batch, { pairs: [] }))).at(-1)?.text).toBe("primary:final"); + }); + it("begins on the fallback when startOnFallback() is true at launch (restart-while-below)", async () => { const primary = new StubEngine("primary"); const fallback = new StubEngine("fallback"); From e18d5300e3a0c242656eb2a4e3ccdf4c9e626c4f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 14 Jul 2026 18:39:26 +0000 Subject: [PATCH 4/9] [#178] archive: a finalized title can never be the "(recording)" sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sanitizeTitle had no guard against producing exactly WORKING_TITLE "(recording)". An LLM final title that sanitized to it made finalize() write ` — (recording).md` — byte-identical to the in-progress working-file grammar — so retention/adopt (which key off the ` — (recording)` suffix, not content) misread the finalized session as a crashed orphan: exempt from the retention sweep, then silently re-titled from its first summary line. Guard in sanitizeTitle (covers finalize AND adopt's rename): a sanitized title equal to the sentinel falls back to FALLBACK_TITLE. Moved WORKING_TITLE into sanitize.ts (re-exported from writer.ts, so retention.ts/index.ts imports are unchanged) so the guard needs no circular import. Regression test: "(recording)" and whitespace variants → FALLBACK_TITLE; a title merely containing the word is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/archive/src/sanitize.ts | 14 +++++++++++++- packages/archive/src/writer.ts | 12 +++++++----- packages/archive/test/sanitize.test.ts | 19 ++++++++++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/archive/src/sanitize.ts b/packages/archive/src/sanitize.ts index 6486206..462a0cc 100644 --- a/packages/archive/src/sanitize.ts +++ b/packages/archive/src/sanitize.ts @@ -31,6 +31,13 @@ function truncateToByteBudget(text: string, maxBytes: number): string { /** Fallback when a title sanitizes to nothing. */ export const FALLBACK_TITLE = "Untitled session"; +/** The in-progress working-file title sentinel — a finalized archive must never + * reuse it as a title, or its ` — (recording).md` filename would be + * misread as a crashed recording by retention/adopt (#178). Defined here (not in + * writer.ts) so [`sanitizeTitle`] can guard against it without a circular import; + * re-exported from writer.ts for the retention sweep's marker. */ +export const WORKING_TITLE = "(recording)"; + // Characters that must never reach a filename: // - the NUL/C0 control range and DEL // - ASCII path separators @@ -66,7 +73,12 @@ export function sanitizeTitle(raw: string): string { // Truncate by UTF-8 bytes (not chars) at a code-point boundary (#32), then // re-trim any trailing space the cut may have exposed. title = truncateToByteBudget(title, MAX_TITLE_BYTES).replace(/[.\s]+$/, ""); - return title === "" ? FALLBACK_TITLE : title; + // A finalized title must never BE the in-progress sentinel (#178): otherwise the + // finalized file ` — (recording).md` collides with the working-file + // grammar and retention/adopt misread this real session as a crashed orphan + // (exempt from the sweep, then re-titled from its first summary line). + if (title === "" || title === WORKING_TITLE) return FALLBACK_TITLE; + return title; } /** Build the archive filename for a sanitized title: `.md`. */ diff --git a/packages/archive/src/writer.ts b/packages/archive/src/writer.ts index bb656fe..5f26326 100644 --- a/packages/archive/src/writer.ts +++ b/packages/archive/src/writer.ts @@ -16,9 +16,15 @@ import type { ArchiveFs } from "./fs"; import { uniquePath } from "./paths"; import { renderDocument, renderEntryAppend, type ArchiveModel } from "./render"; -import { sanitizeTitle } from "./sanitize"; +import { sanitizeTitle, WORKING_TITLE } from "./sanitize"; import type { BriefUpdate, CaptionEntry, CoachingData, FinalBrief, SessionMeta } from "./types"; +/** The in-progress working-file title sentinel (`<prefix> — (recording).md`), + * re-exported so the retention sweep can recognize — and never reap — an + * unfinalized recording (#63). Defined in sanitize.ts so [`sanitizeTitle`] can + * refuse to produce it as a finalized title (#178). */ +export { WORKING_TITLE }; + export interface SessionArchiveWriterOptions { fs: ArchiveFs; /** Destination folder (user data). The writer never writes outside it. */ @@ -28,10 +34,6 @@ export interface SessionArchiveWriterOptions { workingTitle?: string; } -/** Title shown in the in-progress working file's name (`<prefix> — (recording).md`) - * until the session finalizes. Exported so the retention sweep can recognize — - * and never reap — an unfinalized recording (#63). */ -export const WORKING_TITLE = "(recording)"; export class SessionArchiveWriter { private readonly fs: ArchiveFs; diff --git a/packages/archive/test/sanitize.test.ts b/packages/archive/test/sanitize.test.ts index 08fb2bd..89d35ea 100644 --- a/packages/archive/test/sanitize.test.ts +++ b/packages/archive/test/sanitize.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; -import { sanitizeTitle, archiveFileName, MAX_TITLE_BYTES, FALLBACK_TITLE } from "../src/sanitize"; +import { + sanitizeTitle, + archiveFileName, + MAX_TITLE_BYTES, + FALLBACK_TITLE, + WORKING_TITLE, +} from "../src/sanitize"; const utf8 = (s: string) => new TextEncoder().encode(s).length; @@ -75,6 +81,17 @@ describe("sanitizeTitle — path traversal defense (SECURITY.md)", () => { it("preserves ordinary unicode titles (e.g. Korean)", () => { expect(sanitizeTitle("주간 회의 요약")).toBe("주간 회의 요약"); }); + + it("never produces the in-progress sentinel as a finalized title (#178)", () => { + // A final title of "(recording)" would make `<prefix> — (recording).md` + // collide with the working-file grammar and be misread as a crashed orphan. + expect(WORKING_TITLE).toBe("(recording)"); + expect(sanitizeTitle("(recording)")).toBe(FALLBACK_TITLE); + // Whitespace/case that normalizes onto the sentinel is also refused. + expect(sanitizeTitle(" (recording) ")).toBe(FALLBACK_TITLE); + // A title merely CONTAINING the word stays intact (only the exact sentinel is refused). + expect(sanitizeTitle("Notes on (recording) laws")).toBe("Notes on (recording) laws"); + }); }); describe("archiveFileName", () => { From 0e4fc9439b753e18d8286a02f12d81f8c2f24509 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi <cyh76507707@gmail.com> Date: Tue, 14 Jul 2026 18:43:34 +0000 Subject: [PATCH 5/9] =?UTF-8?q?[#178]=20archive:=20a=20board=20item=20cont?= =?UTF-8?q?aining=20"=20=C2=B7=20"=20no=20longer=20fragments=20on=20round-?= =?UTF-8?q?trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderBoard joined board items with " · " and parse.ts split them back on it, so a single decision/action/question that itself contained " · " (natural writing; "·" is a common Korean bullet in this EN/KO product) round-tripped as TWO items — silently fracturing the meeting board on the dashboard. Neutralize an item-internal " · " by swapping its U+00B7 middot for a visually-similar U+2027 (‧) before joining, so it can no longer match the U+00B7 separator; a bare "·" is left alone. Regression: a one-decision "Ship v2 · notify design team" stays one item. Golden output unchanged (no fixture item contains the separator). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/archive/src/render.ts | 19 ++++++++++++++++--- packages/archive/test/parse.test.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/archive/src/render.ts b/packages/archive/src/render.ts index be675d3..b00d0e4 100644 --- a/packages/archive/src/render.ts +++ b/packages/archive/src/render.ts @@ -34,11 +34,24 @@ function renderMetaLine(m: ArchiveModel): string { ); } +/** Board items are joined with " · " and split back on it by parse.ts, so a + * single item that itself contains " · " (natural writing; "·" is also a common + * Korean bullet) would round-trip as TWO items. Neutralize an item-internal + * " · " by swapping its U+00B7 middot for a visually-similar U+2027 (‧) so it no + * longer matches the U+00B7 separator (#178). Only the exact " <U+00B7> " + * sequence is touched — a bare "·" is left alone. The parser never unescapes, + * so the item reads back with the look-alike (a near-invisible change) instead + * of being fragmented. */ +function escapeBoardItem(item: string): string { + return item.split(" · ").join(" ‧ "); +} + function renderBoard(board: BoardData): string { + const join = (items: string[]): string => items.map(escapeBoardItem).join(" · "); let out = ""; - if (board.decisions.length > 0) out += `**Decisions** — ${board.decisions.join(" · ")}\n`; - if (board.actionItems.length > 0) out += `**Action items** — ${board.actionItems.join(" · ")}\n`; - if (board.openQuestions.length > 0) out += `**Open questions** — ${board.openQuestions.join(" · ")}\n`; + if (board.decisions.length > 0) out += `**Decisions** — ${join(board.decisions)}\n`; + if (board.actionItems.length > 0) out += `**Action items** — ${join(board.actionItems)}\n`; + if (board.openQuestions.length > 0) out += `**Open questions** — ${join(board.openQuestions)}\n`; return out; } diff --git a/packages/archive/test/parse.test.ts b/packages/archive/test/parse.test.ts index 2d8271f..ab111af 100644 --- a/packages/archive/test/parse.test.ts +++ b/packages/archive/test/parse.test.ts @@ -106,6 +106,23 @@ describe("parseSession — round-trips the real writer output", () => { expect(parsed.entries).toEqual(entries); }); + it("does not fragment a board item that itself contains the ' · ' separator (#178)", () => { + const final: FinalBrief = { + ...FINAL, + board: { + decisions: ["Ship v2 · notify design team"], // ONE decision with a middot + actionItems: [], + openQuestions: [], + }, + }; + const parsed = parseSession(writeSession(META, ENTRIES, final)); + // Stays ONE decision — pre-fix it split into "Ship v2" + "notify design team". + expect(parsed.board.decisions).toHaveLength(1); + // The middot is swapped for a look-alike U+2027 so it can't be mistaken for + // the separator; the item is otherwise intact. + expect(parsed.board.decisions[0]).toBe("Ship v2 ‧ notify design team"); + }); + it("preserves a source line that itself contains an em-dash separator", () => { const entries: CaptionEntry[] = [ { speaker: "them", timestamp: "09:00", source: "it works — mostly — for now", target: "대체로 작동" }, From 383ce51c4bf7d5b7c6a39fd24f890bc50966a342 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi <cyh76507707@gmail.com> Date: Tue, 14 Jul 2026 18:45:30 +0000 Subject: [PATCH 6/9] [#178] archive: a caption ending in " (?)" is no longer misread as low-confidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderEntryBody appends " (?)" for low-confidence and parse.ts strips a trailing " (?)" from ANY source, so a caption that legitimately ends in " (?)" (STT punctuation / a spoken aside) parsed back as lowConfidence=true with its last four characters deleted — corrupting the dashboard/review text, and a write→parse→amend cycle persisted it. Defuse a source that itself ends with the marker by swapping the trailing ASCII "?" for a look-alike U+FF1F (?) after sanitizeInline, so the suffix is no longer the exact marker; visually near-identical, read back verbatim. Regression: a non-low-confidence "…ok? (?)" stays full text + not flagged; a low-confidence "…what? (?)" keeps its own marker after the appended one is stripped. Golden output unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/archive/src/render.ts | 22 ++++++++++++++++++++-- packages/archive/test/parse.test.ts | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/archive/src/render.ts b/packages/archive/src/render.ts index b00d0e4..a219bd4 100644 --- a/packages/archive/src/render.ts +++ b/packages/archive/src/render.ts @@ -96,12 +96,30 @@ export function sanitizeBlock(text: string): string { .join("\n"); } +/** The trailing low-confidence marker appended to a source line; parse.ts strips + * it back off (its LOW_CONFIDENCE_SUFFIX). */ +const LOW_CONF_MARKER = " (?)"; + +/** Defuse a source that ITSELF ends with the low-confidence marker " (?)" (STT + * punctuation or a spoken aside) so the parser can't mistake it for the appended + * marker and both truncate the caption AND falsely flag it low-confidence (#178). + * Swap the trailing ASCII "?" for a look-alike U+FF1F (?) so the suffix is no + * longer the exact marker; visually near-identical, and the parser reads it back + * verbatim instead of stripping it. Runs AFTER sanitizeInline (which never adds a + * trailing " (?)"), so it only ever fires on genuine caption text. */ +function defuseLowConfMarker(source: string): string { + return source.endsWith(LOW_CONF_MARKER) + ? `${source.slice(0, -LOW_CONF_MARKER.length)} (?)` + : source; +} + /** Render one entry's two lines (header line + `>` translation), trailing \n. */ export function renderEntryBody(e: CaptionEntry): string { const pin = e.pinned ? "📌 " : ""; const speaker = e.speaker === "me" ? "Me" : "Them"; - const confidence = e.lowConfidence ? " (?)" : ""; - return `${pin}**${speaker}** (${e.timestamp}) — ${sanitizeInline(e.source)}${confidence}\n> ${sanitizeInline(e.target)}\n`; + const confidence = e.lowConfidence ? LOW_CONF_MARKER : ""; + const source = defuseLowConfMarker(sanitizeInline(e.source)); + return `${pin}**${speaker}** (${e.timestamp}) — ${source}${confidence}\n> ${sanitizeInline(e.target)}\n`; } /** diff --git a/packages/archive/test/parse.test.ts b/packages/archive/test/parse.test.ts index ab111af..e233c8a 100644 --- a/packages/archive/test/parse.test.ts +++ b/packages/archive/test/parse.test.ts @@ -106,6 +106,25 @@ describe("parseSession — round-trips the real writer output", () => { expect(parsed.entries).toEqual(entries); }); + it("does not misread a source that itself ends with ' (?)' as low-confidence (#178)", () => { + const entries: CaptionEntry[] = [ + // NOT low-confidence, but the caption text ends with the marker glyphs. + { speaker: "them", timestamp: "09:00", source: "is that ok? (?)", target: "괜찮아요?" }, + // Low-confidence AND ends with the marker glyphs. + { speaker: "me", timestamp: "09:01", source: "wait what? (?)", target: "뭐?", lowConfidence: true }, + ]; + const parsed = parseSession(writeSession(META, entries, FINAL)); + // (1) not flagged low-confidence and not truncated — the trailing "?" is a + // look-alike, so the parser doesn't strip it as the marker (pre-fix this + // parsed as lowConfidence=true with the last four chars deleted). + expect(parsed.entries[0]?.lowConfidence).toBeFalsy(); + expect(parsed.entries[0]?.source).toBe("is that ok? (?)"); + // (2) correctly low-confidence, appended marker stripped, the caption's own + // trailing marker preserved (as the look-alike). + expect(parsed.entries[1]?.lowConfidence).toBe(true); + expect(parsed.entries[1]?.source).toBe("wait what? (?)"); + }); + it("does not fragment a board item that itself contains the ' · ' separator (#178)", () => { const final: FinalBrief = { ...FINAL, From cfe0f90188aad68aa1867d3c150eee513e056e77 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi <cyh76507707@gmail.com> Date: Tue, 14 Jul 2026 18:48:26 +0000 Subject: [PATCH 7/9] [#178] overlay: epoch-guard the transition clear-timer against rapid mode changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_mode set `transitioning = true` and spawned a 250ms timer that unconditionally cleared it — with no generation guard (unlike drag_generation). Two apply_mode calls within 250ms (Alt+Shift+Space key-repeat, rapid Mode-menu clicks) let the FIRST timer clear `transitioning` mid-way through the SECOND transition, so record_geometry went live again and persisted the in-flight animated rect as the remembered per-mode geometry — corrupting the saved window position with no visible error. Add a `transition_generation` epoch (mirroring drag_generation): begin_transition() bumps it and returns the epoch; end_transition_if_current() clears the flag only if its epoch is still current, so a superseded transition's late timer is a no-op. Unit tests cover the stale-timer no-op and the single-transition clear (run under app-macos, like session.rs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src-tauri/src/overlay.rs | 83 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 37ddc9c..2f86fbc 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -44,6 +44,11 @@ pub struct Shell { pub click_through: AtomicBool, pub pinned: AtomicBool, drag_generation: AtomicU64, + /// Epoch for the `transitioning` clear-timer (#178): each apply_mode bumps it, + /// and a spawned clear-timer only clears `transitioning` if its captured epoch + /// is still current — so a rapid second apply_mode's timer can't clear the flag + /// mid-way through the first (mirrors `drag_generation`). + transition_generation: AtomicU64, dirty: AtomicBool, transitioning: AtomicBool, ignoring_cursor: AtomicBool, @@ -61,6 +66,7 @@ impl Shell { click_through: AtomicBool::new(click_through), pinned: AtomicBool::new(pinned), drag_generation: AtomicU64::new(0), + transition_generation: AtomicU64::new(0), dirty: AtomicBool::new(false), // Starts true so window-creation Moved/Resized events cannot // record geometry before the launch restore in `apply_mode` @@ -78,6 +84,28 @@ impl Shell { self.dirty.store(true, Ordering::Relaxed); } + /// Begin a mode transition (#178): set `transitioning` and return a fresh + /// epoch. The caller hands this epoch to the clear-timer. + pub fn begin_transition(&self) -> u64 { + let generation = self.transition_generation.fetch_add(1, Ordering::SeqCst) + 1; + self.transitioning.store(true, Ordering::Relaxed); + generation + } + + /// Clear `transitioning` ONLY if `generation` is still the current epoch — + /// so a stale timer from a superseded transition can't reopen geometry + /// recording mid-way through a newer one (#178). + pub fn end_transition_if_current(&self, generation: u64) { + if self.transition_generation.load(Ordering::SeqCst) == generation { + self.transitioning.store(false, Ordering::Relaxed); + } + } + + #[cfg(test)] + fn is_transitioning(&self) -> bool { + self.transitioning.load(Ordering::Relaxed) + } + pub fn save_now(&self) { let snapshot = self.config.lock().expect("config lock").clone(); if let Err(e) = config::save_atomic(&self.config_path, &snapshot) { @@ -212,7 +240,7 @@ pub fn apply_mode(app: &AppHandle, new_mode: Mode) { if shell.mode() != new_mode { record_geometry(&window, shell); } - shell.transitioning.store(true, Ordering::Relaxed); + let generation = shell.begin_transition(); *shell.mode.lock().expect("mode lock") = new_mode; let display = display_key(&window); @@ -257,13 +285,15 @@ pub fn apply_mode(app: &AppHandle, new_mode: Mode) { let _ = window.emit(EVENT_MODE, shell_state(shell)); crate::tray::sync_mode(app, new_mode); - // Let the Moved/Resized events from this transition drain before - // geometry recording resumes. + // Let the Moved/Resized events from this transition drain before geometry + // recording resumes. Guard the clear by epoch (#178): if a second apply_mode + // ran within 250ms, this timer's epoch is stale and it leaves `transitioning` + // set for the newer transition instead of clobbering its geometry. let app2 = app.clone(); std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(250)); let shell = app2.state::<Shell>(); - shell.transitioning.store(false, Ordering::Relaxed); + shell.end_transition_if_current(generation); }); } @@ -491,3 +521,48 @@ pub fn spawn_config_saver(app: AppHandle) { } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_shell() -> Shell { + Shell::new(PathBuf::from("/tmp/livecap-test.json"), ShellConfig::default(), Mode::Panel) + } + + #[test] + fn a_stale_transition_timer_does_not_clear_the_flag() { + // #178: two apply_mode calls within 250ms. The FIRST call's clear-timer + // must NOT clear `transitioning` while the SECOND transition is in flight, + // or record_geometry would persist the in-flight (animated) rect. + let shell = test_shell(); + + let g1 = shell.begin_transition(); + assert!(shell.is_transitioning()); + + // A rapid second transition supersedes the first (new epoch). + let g2 = shell.begin_transition(); + assert_ne!(g1, g2, "each transition gets a fresh epoch"); + assert!(shell.is_transitioning()); + + // The FIRST timer fires late with its now-stale epoch → must be a no-op. + shell.end_transition_if_current(g1); + assert!( + shell.is_transitioning(), + "a superseded transition's timer must not clear the flag mid-transition" + ); + + // The SECOND (current) timer fires → clears cleanly. + shell.end_transition_if_current(g2); + assert!(!shell.is_transitioning()); + } + + #[test] + fn a_single_transition_timer_clears_the_flag() { + let shell = test_shell(); + let g = shell.begin_transition(); + assert!(shell.is_transitioning()); + shell.end_transition_if_current(g); + assert!(!shell.is_transitioning()); + } +} From 37a1ea562d8effb2c138cece58d34671afb996d8 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi <cyh76507707@gmail.com> Date: Tue, 14 Jul 2026 18:50:30 +0000 Subject: [PATCH 8/9] [#178] resample: keep the intentional old-rate residue drop; make it non-silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding A (resampler drops buffered audio on a rate change) is a documented behavior-DECISION, not a defect (Batch-36 amendment): the sub-CHUNK_IN residue was captured at the OLD rate, so it can neither be fed through the freshly-rebuilt new-rate resampler (a rate mismatch would corrupt the output) nor emitted as new-rate pass-through — dropping it is correct. So the "flush the residual" fix-direction is deliberately NOT taken. Instead do the fix-direction's stated minimum: log the dropped sample COUNT (never audio, #23-safe) at debug level so the ~21ms loss at a mid-meeting device switch is observable rather than silent. Behavior unchanged (existing resample tests green); the drop is now documented as the deliberate decision it is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- crates/livecap-core/src/resample.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/livecap-core/src/resample.rs b/crates/livecap-core/src/resample.rs index 0e81e71..9b5232a 100644 --- a/crates/livecap-core/src/resample.rs +++ b/crates/livecap-core/src/resample.rs @@ -42,7 +42,7 @@ impl StreamResampler { // Pass-through; drop any residue from a previous rate. self.inner = None; self.from_rate = from_rate; - self.input_buf.clear(); + self.drop_residue(); return Ok(samples.to_vec()); } @@ -60,7 +60,7 @@ impl StreamResampler { 1, )?); self.from_rate = from_rate; - self.input_buf.clear(); + self.drop_residue(); } self.input_buf.extend_from_slice(samples); @@ -74,6 +74,24 @@ impl StreamResampler { } Ok(out) } + + /// Discard the sub-`CHUNK_IN` residue buffered at the OLD rate on a rate change + /// (#178). This is a DELIBERATE, correct drop, NOT a bug to "fix": those + /// samples were captured at the old rate, so they can neither be fed through + /// the freshly-rebuilt new-rate resampler (a rate mismatch would itself corrupt + /// the output) nor emitted as new-rate pass-through. The loss is bounded (at + /// most CHUNK_IN-1 samples, ~21ms at 48kHz) and only occurs at a mid-meeting + /// device/rate switch. The count (never any audio) is logged so the drop is + /// observable rather than silent — the one thing the finding actually asks for. + fn drop_residue(&mut self) { + if !self.input_buf.is_empty() { + log::debug!( + "Resampler: dropping {} buffered old-rate sample(s) at a rate change (#178)", + self.input_buf.len() + ); + self.input_buf.clear(); + } + } } #[cfg(test)] From 7210fba3e9d5223222d35a9d3e74eab0e5ea1438 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi <cyh76507707@gmail.com> Date: Tue, 14 Jul 2026 19:02:01 +0000 Subject: [PATCH 9/9] [#178] archive: inline the two one-call-site render helpers (RE1/RE2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per RE1 #1010 / RE2 #1012: fold escapeBoardItem (sole caller renderBoard) and defuseLowConfMarker (sole caller renderEntryBody) into their callers, keeping the rationale as inline comments. Behavior identical — the C/D regressions (board middot U+2027, trailing ? U+FF1F) still pass; golden output unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/archive/src/render.ts | 45 ++++++++++++++-------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/packages/archive/src/render.ts b/packages/archive/src/render.ts index a219bd4..23973ab 100644 --- a/packages/archive/src/render.ts +++ b/packages/archive/src/render.ts @@ -34,20 +34,15 @@ function renderMetaLine(m: ArchiveModel): string { ); } -/** Board items are joined with " · " and split back on it by parse.ts, so a - * single item that itself contains " · " (natural writing; "·" is also a common - * Korean bullet) would round-trip as TWO items. Neutralize an item-internal - * " · " by swapping its U+00B7 middot for a visually-similar U+2027 (‧) so it no - * longer matches the U+00B7 separator (#178). Only the exact " <U+00B7> " - * sequence is touched — a bare "·" is left alone. The parser never unescapes, - * so the item reads back with the look-alike (a near-invisible change) instead - * of being fragmented. */ -function escapeBoardItem(item: string): string { - return item.split(" · ").join(" ‧ "); -} - function renderBoard(board: BoardData): string { - const join = (items: string[]): string => items.map(escapeBoardItem).join(" · "); + // Items are joined with " · " and split back on it by parse.ts, so an item that + // itself contains " · " (natural writing; "·" is also a common Korean bullet) + // would round-trip as TWO items. Swap an item-internal " · " middot (U+00B7) for + // a look-alike U+2027 (‧) before joining so it no longer matches the separator + // (#178); a bare "·" is untouched, and the parser never unescapes, so the item + // reads back with the look-alike (near-invisible) instead of being fragmented. + const join = (items: string[]): string => + items.map((item) => item.split(" · ").join(" ‧ ")).join(" · "); let out = ""; if (board.decisions.length > 0) out += `**Decisions** — ${join(board.decisions)}\n`; if (board.actionItems.length > 0) out += `**Action items** — ${join(board.actionItems)}\n`; @@ -100,25 +95,21 @@ export function sanitizeBlock(text: string): string { * it back off (its LOW_CONFIDENCE_SUFFIX). */ const LOW_CONF_MARKER = " (?)"; -/** Defuse a source that ITSELF ends with the low-confidence marker " (?)" (STT - * punctuation or a spoken aside) so the parser can't mistake it for the appended - * marker and both truncate the caption AND falsely flag it low-confidence (#178). - * Swap the trailing ASCII "?" for a look-alike U+FF1F (?) so the suffix is no - * longer the exact marker; visually near-identical, and the parser reads it back - * verbatim instead of stripping it. Runs AFTER sanitizeInline (which never adds a - * trailing " (?)"), so it only ever fires on genuine caption text. */ -function defuseLowConfMarker(source: string): string { - return source.endsWith(LOW_CONF_MARKER) - ? `${source.slice(0, -LOW_CONF_MARKER.length)} (?)` - : source; -} - /** Render one entry's two lines (header line + `>` translation), trailing \n. */ export function renderEntryBody(e: CaptionEntry): string { const pin = e.pinned ? "📌 " : ""; const speaker = e.speaker === "me" ? "Me" : "Them"; const confidence = e.lowConfidence ? LOW_CONF_MARKER : ""; - const source = defuseLowConfMarker(sanitizeInline(e.source)); + // Defuse a source that ITSELF ends with the marker " (?)" (STT punctuation / a + // spoken aside) so parse.ts can't mistake it for the appended marker and both + // truncate the caption AND falsely flag it low-confidence (#178): swap the + // trailing ASCII "?" for a look-alike U+FF1F (?). Runs after sanitizeInline + // (which never adds a trailing " (?)"), so it only fires on genuine text; the + // parser reads it back verbatim instead of stripping it. + const sanitized = sanitizeInline(e.source); + const source = sanitized.endsWith(LOW_CONF_MARKER) + ? `${sanitized.slice(0, -LOW_CONF_MARKER.length)} (?)` + : sanitized; return `${pin}**${speaker}** (${e.timestamp}) — ${source}${confidence}\n> ${sanitizeInline(e.target)}\n`; }