Skip to content
Merged
24 changes: 7 additions & 17 deletions crates/livecap-core/src/audio/mic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ use super::AudioChunk;
pub struct MicCapture {
stop_tx: std::sync::mpsc::Sender<()>,
join: Option<std::thread::JoinHandle<()>>,
device: AudioDevice,
sample_rate: u32,
}

impl MicCapture {
Expand All @@ -32,7 +30,7 @@ impl MicCapture {
};
info!("Starting microphone capture on '{}'", device.name);

let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<u32>>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<()>>();
let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();

let thread_device = device.clone();
Expand All @@ -52,7 +50,7 @@ impl MicCapture {
);
let stream = build_input_stream(&cpal_device, &config, out)?;
stream.play()?;
let _ = ready_tx.send(Ok(sample_rate));
let _ = ready_tx.send(Ok(()));
Ok(stream)
})();

Expand All @@ -69,27 +67,19 @@ impl MicCapture {
}
})?;

let sample_rate = ready_rx
// The ready channel propagates the capture thread's setup result (and
// blocks until it reports): the payload is unit — the native rate is not
// consumed by the pipeline, which resamples every AudioChunk by its own
// reported rate.
ready_rx
.recv()
.map_err(|_| anyhow!("Microphone capture thread exited before reporting status"))??;

Ok(Self {
stop_tx,
join: Some(join),
device,
sample_rate,
})
}

/// The device this capture is reading from.
pub fn device(&self) -> &AudioDevice {
&self.device
}

/// Native sample rate of the capture stream.
pub fn sample_rate(&self) -> u32 {
self.sample_rate
}
}

impl Drop for MicCapture {
Expand Down
33 changes: 11 additions & 22 deletions crates/livecap-core/src/audio/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ use crate::error::CoreError;

/// Handle to a running system-audio capture. Dropping it stops the capture.
pub struct SystemAudioCapture {
// Held only as an RAII guard: its `Drop` stops the tap thread when this
// handle is dropped. Never read directly (the initial rate it used to expose
// is no longer consumed), so `dead_code` is allowed for this field.
#[cfg(target_os = "macos")]
#[allow(dead_code)]
inner: macos::MacSystemAudioCapture,
}

Expand Down Expand Up @@ -68,18 +72,6 @@ impl SystemAudioCapture {
.into())
}
}

/// Initial sample rate reported by the capture device.
pub fn sample_rate(&self) -> u32 {
#[cfg(target_os = "macos")]
{
self.inner.sample_rate()
}
#[cfg(not(target_os = "macos"))]
{
0
}
}
}

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -140,35 +132,32 @@ mod macos {
pub(super) struct MacSystemAudioCapture {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
sample_rate: u32,
}

impl MacSystemAudioCapture {
pub(super) fn start(out: mpsc::UnboundedSender<AudioChunk>) -> Result<Self> {
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = stop.clone();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<u32>>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<()>>();

// All Core Audio objects (tap, aggregate device, IO proc context)
// are created, used, and destroyed on this thread.
let join = std::thread::Builder::new()
.name("livecap-system-audio".into())
.spawn(move || run_capture(thread_stop, out, ready_tx))?;

let sample_rate = ready_rx.recv().map_err(|_| {
// The ready channel propagates the capture thread's setup result (and
// blocks until it reports); the payload is unit — the tap's initial
// rate is not consumed, each AudioChunk carries its own current rate.
ready_rx.recv().map_err(|_| {
anyhow!("System-audio capture thread exited before reporting status")
})??;

Ok(Self {
stop,
join: Some(join),
sample_rate,
})
}

pub(super) fn sample_rate(&self) -> u32 {
self.sample_rate
}
}

impl Drop for MacSystemAudioCapture {
Expand Down Expand Up @@ -244,7 +233,7 @@ mod macos {
fn run_capture(
stop: Arc<AtomicBool>,
out: mpsc::UnboundedSender<AudioChunk>,
ready_tx: std::sync::mpsc::Sender<Result<u32>>,
ready_tx: std::sync::mpsc::Sender<Result<()>>,
) {
struct Devices {
tap_id: AudioObjectID,
Expand Down Expand Up @@ -444,7 +433,7 @@ mod macos {
"CoreAudio: system-audio capture running at {} Hz",
current_rate.load(Ordering::Acquire)
);
let _ = ready_tx.send(Ok(current_rate.load(Ordering::Acquire)));
let _ = ready_tx.send(Ok(()));

// Pump loop: drain the ring buffer into the pipeline channel and
// re-poll the device rate periodically (it changes when the default
Expand Down
21 changes: 0 additions & 21 deletions crates/livecap-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,27 +185,6 @@ impl CaptionPipeline {
))
}

/// Convenience: start both captures. `mic`/`system`: `Some(device)`
/// captures from that device, `None` skips the channel entirely. Use
/// [`Self::start_mic`] / [`Self::start_system`] for default-device
/// capture of a single channel.
pub fn start_capture(
&mut self,
mic: Option<AudioDevice>,
system: Option<AudioDevice>,
) -> Result<()> {
if mic.is_none() && system.is_none() {
return Err(anyhow!("At least one of mic/system must be provided"));
}
if let Some(mic_device) = mic {
self.start_mic(Some(mic_device))?;
}
if let Some(system_device) = system {
self.start_system(Some(&system_device))?;
}
Ok(())
}

/// Start microphone capture (`None` = system default input device) on
/// the [`Channel::Mic`] channel.
pub fn start_mic(&mut self, device: Option<AudioDevice>) -> Result<()> {
Expand Down
20 changes: 12 additions & 8 deletions crates/livecap-core/src/vad.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ use std::time::Duration;
/// Silero VAD requires 16 kHz input.
pub const VAD_SAMPLE_RATE: u32 = 16000;

/// Estimated confidence for a segment we cut/ended ourselves (force-cut on a
/// very long utterance, or a flush at capture stop) rather than one Silero
/// completed on its own. Named so the two forced sites stay in lockstep if these
/// heuristics are ever retuned.
const FORCED_SEGMENT_CONFIDENCE: f32 = 0.8;

/// Estimated confidence for a segment Silero completed via a natural SpeechEnd.
const VAD_SEGMENT_CONFIDENCE: f32 = 0.9;

/// Represents a complete speech segment detected by VAD.
#[derive(Debug, Clone)]
pub struct SpeechSegment {
Expand Down Expand Up @@ -130,11 +139,6 @@ impl ContinuousVadProcessor {
&self.current_speech
}

/// Milliseconds of (16 kHz) audio processed so far.
pub fn processed_ms(&self) -> f64 {
self.processed_samples as f64 / (VAD_SAMPLE_RATE as f64 / 1000.0)
}

/// Force-cut the utterance currently in progress and return it as a
/// segment, keeping the session in speech state. Used to bound utterance
/// length when someone talks for a very long time without pausing.
Expand All @@ -159,7 +163,7 @@ impl ContinuousVadProcessor {
samples: std::mem::take(&mut self.current_speech),
start_timestamp_ms: start_ms,
end_timestamp_ms: end_ms,
confidence: 0.8, // estimated confidence for a forced cut
confidence: FORCED_SEGMENT_CONFIDENCE,
};
self.speech_start_sample = self.processed_samples;
Some(segment)
Expand Down Expand Up @@ -240,7 +244,7 @@ impl ContinuousVadProcessor {
samples: std::mem::take(&mut self.current_speech),
start_timestamp_ms: start_ms,
end_timestamp_ms: end_ms,
confidence: 0.8, // estimated confidence for a forced end
confidence: FORCED_SEGMENT_CONFIDENCE,
});
self.in_speech = false;
}
Expand Down Expand Up @@ -332,7 +336,7 @@ impl ContinuousVadProcessor {
samples: speech_samples,
start_timestamp_ms: start_ms,
end_timestamp_ms: end_timestamp_ms as f64,
confidence: 0.9, // VAD confidence
confidence: VAD_SEGMENT_CONFIDENCE,
};

info!(
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
window.__lcBootError = kind + ": " + msg;
var el = document.getElementById("boot-error") || document.createElement("pre");
el.id = "boot-error";
el.style.cssText = "color:#e8b84b;font:11px/1.5 ui-monospace,monospace;padding:14px;white-space:pre-wrap;margin:0";
el.style.cssText = "color:var(--accent-live);font:11px/1.5 ui-monospace,monospace;padding:14px;white-space:pre-wrap;margin:0";
el.textContent = "LiveCap UI failed to start\n" + window.__lcBootError;
document.body.appendChild(el);
}
Expand Down
3 changes: 1 addition & 2 deletions packages/archive/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// dashboard aggregations stay NaN-free (cf. #88).

import { COACHING_ARROW, COACHING_CHANGE_SEP } from "./render";
import { WORKING_TITLE } from "./sanitize";
import type { BoardData, CaptionEntry, CoachingData, MetricsData, Speaker } from "./types";

/** Header metadata recovered from the `> …` meta line + the H1 title. */
Expand Down Expand Up @@ -52,8 +53,6 @@ export interface ParsedSession {
isRecording: boolean;
}

/** Working title the writer uses until finalize (mirrors writer.ts WORKING_TITLE). */
const WORKING_TITLE = "(recording)";

// The exact separators the renderer emits (render.ts) — kept as named constants
// so the inversion can never silently drift from the format it mirrors.
Expand Down
15 changes: 2 additions & 13 deletions packages/engine/src/claude-cli-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { randomUUID } from "node:crypto";

import { buildClaudeArgs } from "./args";
import { sanitizeChildEnv } from "./env";
import { parseBrief } from "./internal/brief";
import { AsyncChannel } from "./internal/channel";
import { Mutex } from "./internal/mutex";
import { stderrDigest } from "./internal/redact";
import { MAX_STDERR_TAIL, stderrDigest } from "./internal/redact";
import {
asTaskMessage,
buildGlossarySetupMessage,
Expand Down Expand Up @@ -93,7 +94,6 @@ export interface ClaudeCliEngineConfig {
}

/** Cap on the retained stderr tail used only to derive a non-content hash (chars). */
const MAX_STDERR_TAIL = 2000;
/** Default per-turn idle watchdog window — mirrors the local tier's 30s (#135). */
const DEFAULT_TURN_TIMEOUT = 30_000;
/** Consecutive turn failures before a `degraded` event drives auto-fallback (#135). */
Expand Down Expand Up @@ -649,14 +649,3 @@ export class ClaudeCliEngine implements TranslationEngine {
}
}
}

/** Split a summary response into the running paragraph and board lines. */
function parseBrief(text: string): { summary: string; board: string[] } {
const lines = text
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "");
const summary = lines.find((l) => !l.startsWith("[")) ?? "";
const board = lines.filter((l) => l.startsWith("["));
return { summary, board };
}
3 changes: 0 additions & 3 deletions packages/engine/src/credit-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
import type { Usage } from "./types";

/** Agent SDK monthly pool presets (PROPOSAL §6). */
export const POOL_PRESETS = { pro: 20, max5x: 100, max20x: 200 } as const;
export type PlanId = keyof typeof POOL_PRESETS;

/** Atomic-write filesystem surface (injected). */
export interface LedgerFs {
exists(path: string): boolean;
Expand Down
8 changes: 5 additions & 3 deletions packages/engine/src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@
// access are injected so this stays pure and Linux-headless testable — the
// package never resolves a binary path on its own (consumer injects it).

/** Default binary names to look for, in preference order. */
export const DEFAULT_CLI_NAMES: readonly string[] = ["claude", "codex"];
/** Default binary names to look for. Only the Claude CLI is supported —
* probeCapabilities/buildClaudeArgs emit Claude-CLI-specific argv, so handing a
* different vendor's binary to ClaudeCliEngine would fail unpredictably. */
export const DEFAULT_CLI_NAMES: readonly string[] = ["claude"];

export interface FindCliOptions {
/** The PATH string to scan (e.g. process.env.PATH). */
path: string;
/** Predicate: is this absolute path an executable file? */
isExecutable: (candidate: string) => boolean;
/** Binary names to look for; defaults to claude then codex. */
/** Binary names to look for; defaults to just "claude". */
names?: readonly string[];
/** Path entry separator; defaults to ":" (POSIX). */
pathSeparator?: string;
Expand Down
10 changes: 9 additions & 1 deletion packages/engine/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@ const BILLING_REDIRECT =
* per-token API billing (the #25 class), so it strips with the credentials
* under the same intentional-custom-endpoint exception (#145).
*/
export function sanitizeChildEnv(base: Record<string, string | undefined>): Record<string, string> {
/** Copy an env map, dropping keys whose value is `undefined`, so `spawn` gets a
* clean string→string env. The shared primitive under both the Claude-specific
* {@link sanitizeChildEnv} and the local engine's plain drop-undefined pass. */
export function dropUndefinedEnv(base: Record<string, string | undefined>): Record<string, string> {
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(base)) {
if (typeof value === "string") env[key] = value;
}
return env;
}

export function sanitizeChildEnv(base: Record<string, string | undefined>): Record<string, string> {
const env = dropUndefinedEnv(base);
env.MAX_THINKING_TOKENS = "0";
if (!env.ANTHROPIC_BASE_URL) {
for (const key of Object.keys(env)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/engine/src/extras-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildQuickTranslatePrompt,
buildReplyPrompt,
buildSummaryBoardPrompt,
DEFAULT_CONTEXT_CAPTIONS,
parseAnalyzeRespond,
parseCoachBatch,
parseCoachResult,
Expand Down Expand Up @@ -149,7 +150,7 @@ export class ExtrasPipeline {
this.engine = config.engine;
this.summaryLanguage = config.summaryLanguage;
this.meetingLanguage = config.meetingLanguage;
this.contextCaptions = config.contextCaptions ?? 10;
this.contextCaptions = config.contextCaptions ?? DEFAULT_CONTEXT_CAPTIONS;
this.budget = config.budget;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/engine/src/extras-prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const INTENT_INSTRUCTION: Record<ReplyIntent, string> = {
suggest: "offer a concrete suggestion",
};

const DEFAULT_CONTEXT_CAPTIONS = 10;
export const DEFAULT_CONTEXT_CAPTIONS = 10;

/** Build the summary+board request (one call feeds both — §8.4). */
export function buildSummaryBoardPrompt(
Expand Down
Loading
Loading