Files
whisper_web/tests/live.test.ts
T

398 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});