398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
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<void> {
|
||
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<typeof LiveSegmenter>[0];
|
||
} = {},
|
||
) {
|
||
return new LiveTranscriber({
|
||
transcribe: (audio) =>
|
||
new Promise<LiveTranscribeResult>((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");
|
||
});
|
||
});
|