import { describe, test, expect } from "bun:test"; import { LiveSegmenter, LiveTranscriber, rms, type LiveTranscribeResult, type LiveUpdate, } from "../src/lib/live"; // Pure segment-loop logic only (D-016) — no mic, no worker, no model. Audio // is synthetic: "speech" is a ±0.1 square wave (RMS 0.1, far above the 0.015 // gate), "silence" is zeros. const RATE = 16_000; const FRAME = 512; // default frameSeconds 0.032 × 16 kHz function speech(seconds: number): Float32Array { const out = new Float32Array(Math.round(seconds * RATE)); for (let i = 0; i < out.length; i++) out[i] = i % 2 === 0 ? 0.1 : -0.1; return out; } function silence(seconds: number): Float32Array { return new Float32Array(Math.round(seconds * RATE)); } /** Let pending promise chains (and a macrotask) settle. */ function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } describe("rms", () => { test("zero for empty and silent blocks, level for a square wave", () => { expect(rms(new Float32Array(0))).toBe(0); expect(rms(new Float32Array(100))).toBe(0); expect(rms(speech(0.1))).toBeCloseTo(0.1, 5); }); }); describe("LiveSegmenter", () => { test("pure silence never produces a segment", () => { const segmenter = new LiveSegmenter(); expect(segmenter.push(silence(3))).toEqual([]); expect(segmenter.currentSegmentId).toBeNull(); expect(segmenter.currentSegmentSamples).toBe(0); expect(segmenter.currentSegment()).toBeNull(); expect(segmenter.flush()).toBeNull(); }); test("speech followed by trailing silence commits with pre-roll", () => { const segmenter = new LiveSegmenter(); // 16 frames silence, 32 frames speech, then silence until commit. expect(segmenter.push(silence(0.512))).toEqual([]); expect(segmenter.push(speech(1.024))).toEqual([]); expect(segmenter.currentSegmentId).not.toBeNull(); const committed = segmenter.push(silence(1.024)); expect(committed).toHaveLength(1); const segment = committed[0]!; expect(segment.reason).toBe("silence"); // Pre-roll keeps 10 frames (0.3 s / 0.032 s) of the leading silence. expect(segment.startSample).toBe((16 - 10) * FRAME); // 10 pre-roll + 32 speech + 24 trailing-silence frames (ceil(0.75/0.032)). expect(segment.audio.length).toBe((10 + 32 + 24) * FRAME); // Back to idle after the commit. expect(segmenter.currentSegmentId).toBeNull(); }); test("near-silent blips are dropped, not committed", () => { const segmenter = new LiveSegmenter(); // 4 speech frames = 0.128 s < minSpeechSeconds 0.25 s. expect(segmenter.push(speech(0.128))).toEqual([]); expect(segmenter.currentSegmentId).not.toBeNull(); expect(segmenter.push(silence(2))).toEqual([]); expect(segmenter.currentSegmentId).toBeNull(); expect(segmenter.flush()).toBeNull(); }); test("continuous speech commits at the max segment length and restarts", () => { const segmenter = new LiveSegmenter({ maxSegmentSeconds: 2 }); const committed = segmenter.push(speech(3)); expect(committed).toHaveLength(1); expect(committed[0]!.reason).toBe("max-length"); expect(committed[0]!.startSample).toBe(0); // 2 s cap → first frame count at/over 32 000 samples. expect(committed[0]!.audio.length).toBe(Math.ceil((2 * RATE) / FRAME) * FRAME); // The rest of the speech opened a fresh segment. expect(segmenter.currentSegmentId).not.toBeNull(); expect(segmenter.currentSegmentSamples).toBeGreaterThan(0); }); test("flush commits the in-progress segment, including the sub-frame tail", () => { const segmenter = new LiveSegmenter(); // 1 s + 100 samples: the 100-sample tail is smaller than one frame. segmenter.push(speech(1)); segmenter.push(speech(100 / RATE)); const flushed = segmenter.flush(); expect(flushed).not.toBeNull(); expect(flushed!.reason).toBe("flush"); expect(flushed!.audio.length).toBe(Math.round(1 * RATE) + 100); expect(segmenter.currentSegmentId).toBeNull(); }); test("currentSegment snapshots the in-progress audio", () => { const segmenter = new LiveSegmenter(); segmenter.push(speech(1.024)); expect(segmenter.currentSegmentSamples).toBe(32 * FRAME); const snapshot = segmenter.currentSegment(); expect(snapshot).not.toBeNull(); expect(snapshot!.audio.length).toBe(32 * FRAME); expect(snapshot!.startSample).toBe(0); expect(rms(snapshot!.audio)).toBeCloseTo(0.1, 5); }); test("frame splitting is independent of push block sizes", () => { const bulk = new LiveSegmenter(); const dribble = new LiveSegmenter(); const audio = speech(1.024); bulk.push(audio); for (let i = 0; i < audio.length; i += 100) { dribble.push(audio.slice(i, Math.min(i + 100, audio.length))); } expect(dribble.currentSegmentSamples).toBe(bulk.currentSegmentSamples); }); }); // --------------------------------------------------------------------------- // LiveTranscriber — fake engine with manually resolved passes // --------------------------------------------------------------------------- interface FakeCall { audio: Float32Array; resolve: (result: LiveTranscribeResult) => void; reject: (error: Error) => void; } function fakeEngine() { const calls: FakeCall[] = []; const updates: LiveUpdate[] = []; let now = 0; return { calls, updates, advance(ms: number) { now += ms; }, make( options: { tentativeIntervalMs?: number; minTentativeSeconds?: number; segmenter?: ConstructorParameters[0]; } = {}, ) { return new LiveTranscriber({ transcribe: (audio) => new Promise((resolve, reject) => { calls.push({ audio, resolve, reject }); }), onUpdate: (update) => updates.push(update), now: () => now, ...options, }); }, last() { return updates[updates.length - 1]!; }, }; } describe("LiveTranscriber", () => { test("runs a throttled tentative pass over the growing segment", async () => { const engine = fakeEngine(); const live = engine.make(); live.push(speech(1.5)); expect(engine.calls).toHaveLength(1); // ≥ minTentativeSeconds → first pass expect(engine.calls[0]!.audio.length).toBeGreaterThanOrEqual(1.4 * RATE); // Single-flight: more audio while a pass is running starts nothing new. live.push(speech(0.5)); expect(engine.calls).toHaveLength(1); engine.calls[0]!.resolve({ text: " hello", chunks: [] }); await tick(); expect(engine.last().tentativeText).toBe(" hello"); expect(engine.last().committedText).toBe(""); // Same segment, interval not elapsed → still no second pass on push. live.push(speech(0.2)); expect(engine.calls).toHaveLength(1); // Interval elapsed + new samples → refresh. engine.advance(2000); live.push(speech(0.2)); expect(engine.calls).toHaveLength(2); }); test("committed segments get a final pass that freezes their text", async () => { const engine = fakeEngine(); const live = engine.make(); live.push(speech(1.5)); engine.calls[0]!.resolve({ text: " helo", chunks: [] }); await tick(); // Trailing silence commits the segment; tentative text turns provisional. live.push(silence(1.5)); expect(engine.last().committedText).toBe("helo"); expect(engine.last().pendingFinalPasses).toBe(1); expect(engine.calls).toHaveLength(2); // final pass started immediately engine.calls[1]!.resolve({ text: " hello world", chunks: [{ text: " hello world", timestamp: [0, 1.4] }], }); await tick(); expect(engine.last().committedText).toBe("hello world"); expect(engine.last().tentativeText).toBe(""); expect(engine.last().pendingFinalPasses).toBe(0); // Speech started at sample 0 → no offset on the chunk timestamps. expect(engine.last().chunks[0]!.timestamp[0]).toBeCloseTo(0, 5); expect(engine.last().chunks[0]!.timestamp[1]!).toBeCloseTo(1.4, 5); }); test("chunk timestamps are offset by the segment's session position", async () => { const engine = fakeEngine(); const live = engine.make({ minTentativeSeconds: 9999 }); // finals only live.push(silence(0.512)); // 16 frames; pre-roll keeps the last 10 live.push(speech(1)); const stopping = live.stop(); await tick(); expect(engine.calls).toHaveLength(1); engine.calls[0]!.resolve({ text: " offset", chunks: [{ text: " offset", timestamp: [0, 1] }], }); const result = await stopping; const offset = ((16 - 10) * FRAME) / RATE; expect(result.text).toBe("offset"); expect(result.chunks[0]!.timestamp[0]).toBeCloseTo(offset, 5); expect(result.chunks[0]!.timestamp[1]!).toBeCloseTo(1 + offset, 5); expect(result.chunks[0]!.timestamp[1]).not.toBeNull(); }); test("stop flushes, drains pending passes, and joins the transcript", async () => { const engine = fakeEngine(); const live = engine.make({ minTentativeSeconds: 9999 }); live.push(speech(1)); live.push(silence(1.5)); // segment 1 commits → final pass 1 live.push(speech(1)); // segment 2 in progress const stopping = live.stop(); // flush commits segment 2 let resolved = false; void stopping.then(() => { resolved = true; }); await tick(); expect(resolved).toBe(false); // still draining engine.calls[0]!.resolve({ text: " one.", chunks: [] }); await tick(); engine.calls[1]!.resolve({ text: " two.", chunks: [] }); const result = await stopping; expect(result.text).toBe("one. two."); // The session is over: further audio is ignored. live.push(speech(2)); expect(engine.calls).toHaveLength(2); }); test("a failing pass keeps the provisional text and the session alive", async () => { const engine = fakeEngine(); const live = engine.make(); live.push(speech(1.5)); engine.calls[0]!.resolve({ text: " provisional", chunks: [] }); await tick(); live.push(silence(1.5)); // commit → final pass engine.calls[1]!.reject(new Error("inference exploded")); await tick(); expect(engine.last().error).toBe("inference exploded"); expect(engine.last().committedText).toBe("provisional"); expect(engine.last().pendingFinalPasses).toBe(0); // Later segments still work. engine.advance(5000); live.push(speech(1.5)); expect(engine.calls).toHaveLength(3); const stopping = live.stop(); await tick(); engine.calls[2]!.resolve({ text: " ignored (stale tentative)", chunks: [] }); await tick(); engine.calls[3]!.resolve({ text: " recovered", chunks: [] }); const result = await stopping; expect(result.text).toBe("provisional recovered"); }); test("queued final passes are batched into one merged pass", async () => { const engine = fakeEngine(); const live = engine.make({ minTentativeSeconds: 9999 }); // finals only // Segment 1 commits and its final pass starts (single flight). While it // is in flight, segments 2 and 3 commit and queue behind it. live.push(speech(1)); live.push(silence(1.5)); expect(engine.calls).toHaveLength(1); live.push(speech(1)); live.push(silence(1.5)); live.push(speech(1)); live.push(silence(1.5)); expect(engine.last().pendingFinalPasses).toBe(3); expect(engine.calls).toHaveLength(1); // still just the in-flight pass engine.calls[0]!.resolve({ text: " one.", chunks: [] }); await tick(); // The queue drains as ONE merged pass carrying both segments' audio // (each ≈ pre-roll + 1 s speech + 0.75 s commit silence). expect(engine.calls).toHaveLength(2); const merged = engine.calls[1]!; expect(merged.audio.length).toBeGreaterThanOrEqual(3.4 * RATE); expect(engine.last().pendingFinalPasses).toBe(2); // Chunk timestamps map piecewise back to absolute session time: segment 2 // starts after segment 1 (> 1.5 s into the session), and a chunk in the // second half of the merged audio lands even later, staying monotone. const secondHalf = merged.audio.length / 2 / RATE; merged.resolve({ text: " two. three.", chunks: [ { text: " two.", timestamp: [0, 0.8] }, { text: " three.", timestamp: [secondHalf, secondHalf + 0.8] }, ], }); await tick(); expect(engine.last().committedText).toBe("one. two. three."); expect(engine.last().pendingFinalPasses).toBe(0); const [first, second] = engine.last().chunks; expect(first!.timestamp[0]).toBeGreaterThan(1.5); expect(second!.timestamp[0]).toBeGreaterThan(first!.timestamp[1]!); const result = await live.stop(); expect(result.text).toBe("one. two. three."); }); test("merged passes stay under the segment length cap", async () => { const engine = fakeEngine(); // Tiny cap: segments are ~2 s each, so two of them exceed 3 s and must // NOT merge into one pass. const live = engine.make({ minTentativeSeconds: 9999, segmenter: { maxSegmentSeconds: 3 }, }); live.push(speech(1)); live.push(silence(1.5)); // segment 1 commits → pass 1 in flight live.push(speech(1)); live.push(silence(1.5)); // segment 2 queues live.push(speech(1)); live.push(silence(1.5)); // segment 3 queues expect(engine.calls).toHaveLength(1); engine.calls[0]!.resolve({ text: " one.", chunks: [] }); await tick(); expect(engine.calls).toHaveLength(2); // segment 2 alone (cap reached) expect(engine.calls[1]!.audio.length).toBeLessThanOrEqual(3 * RATE); engine.calls[1]!.resolve({ text: " two.", chunks: [] }); await tick(); expect(engine.calls).toHaveLength(3); engine.calls[2]!.resolve({ text: " three.", chunks: [] }); const result = await live.stop(); expect(result.text).toBe("one. two. three."); }); test("a tentative result for an already-committed segment is discarded", async () => { const engine = fakeEngine(); const live = engine.make(); live.push(speech(1.5)); // tentative pass in flight live.push(silence(1.5)); // segment commits while the pass runs expect(engine.last().committedText).toBe(""); // no text known yet engine.calls[0]!.resolve({ text: " stale", chunks: [] }); await tick(); // Stale text was not applied as tentative; the final pass owns the text. expect(engine.last().tentativeText).toBe(""); expect(engine.calls).toHaveLength(2); engine.calls[1]!.resolve({ text: " final", chunks: [] }); await tick(); expect(engine.last().committedText).toBe("final"); }); });