Files
au_whisper_web/tests/live.test.ts
T

314 lines
11 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 } = {}) {
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("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");
});
});