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
29 changes: 25 additions & 4 deletions crates/livecap-core/src/audio/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
25 changes: 23 additions & 2 deletions crates/livecap-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions crates/livecap-core/src/resample.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

Expand All @@ -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);
Expand All @@ -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)]
Expand Down
32 changes: 27 additions & 5 deletions packages/archive/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ function renderMetaLine(m: ArchiveModel): string {
}

function renderBoard(board: BoardData): string {
// 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** — ${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;
}

Expand Down Expand Up @@ -83,12 +91,26 @@ 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 = " (?)";

/** 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 : "";
// 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`;
}

/**
Expand Down
14 changes: 13 additions & 1 deletion packages/archive/src/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<prefix> — (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
Expand Down Expand Up @@ -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 `<prefix> — (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: `<prefix> — <title>.md`. */
Expand Down
12 changes: 7 additions & 5 deletions packages/archive/src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions packages/archive/test/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,42 @@ 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,
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: "대체로 작동" },
Expand Down
19 changes: 18 additions & 1 deletion packages/archive/test/sanitize.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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", () => {
Expand Down
15 changes: 11 additions & 4 deletions packages/engine/src/fallback-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
26 changes: 26 additions & 0 deletions packages/engine/test/fallback-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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");
Expand Down
Loading
Loading