Start on live transcription and python sidecar (faster-whisper)

This commit is contained in:
Lucas Winther
2026-07-10 09:41:02 +02:00
parent 033ea32968
commit 2d668b3fb8
11 changed files with 2174 additions and 0 deletions
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, test } from "bun:test";
import {
BrowserEngine,
ENGINE_CHOICE_KEY,
ServerEngine,
fetchServerEngineStatus,
isTranscribeStreamLine,
loadEngineChoice,
saveEngineChoice,
type StorageLike,
type WorkerLike,
} from "../src/lib/engine";
import type { FetchLike } from "../src/lib/authClient";
import type { TranscriptChunk } from "../src/worker/protocol";
// D-010: both engines behind one interface, testable without a DOM/worker/server.
// ---------------------------------------------------------------------------
// BrowserEngine — requestId correlation against a fake worker
// ---------------------------------------------------------------------------
class FakeWorker implements WorkerLike {
posted: { message: Record<string, unknown>; transfer?: Transferable[] }[] = [];
private listeners = { message: [] as ((e: { data: unknown }) => void)[], error: [] as ((e: { message?: string }) => void)[] };
postMessage(message: unknown, transfer?: Transferable[]): void {
this.posted.push({ message: message as Record<string, unknown>, transfer });
}
addEventListener(
type: "message" | "error",
listener: (event: { data?: unknown; message?: string }) => void,
): void {
this.listeners[type].push(listener);
}
emit(data: unknown): void {
for (const listener of this.listeners.message) listener({ data });
}
emitError(message: string): void {
for (const listener of this.listeners.error) listener({ message });
}
lastRequestId(): string {
return this.posted.at(-1)!.message.requestId as string;
}
}
const CHUNKS: TranscriptChunk[] = [{ text: " hi", timestamp: [0, 1] }];
describe("BrowserEngine", () => {
test("posts a transcribe message with a requestId and transfers the buffer", () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const audio = new Float32Array([0.1, 0.2]);
void engine.transcribe(audio, { language: "da", task: "translate" });
expect(worker.posted).toHaveLength(1);
const { message, transfer } = worker.posted[0]!;
expect(message.type).toBe("transcribe");
expect(message.language).toBe("da");
expect(message.task).toBe("translate");
expect(typeof message.requestId).toBe("string");
expect(transfer).toEqual([audio.buffer as ArrayBuffer]);
});
test('omits language "auto" and the modelId (worker holds the model)', () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
void engine.transcribe(new Float32Array(4), {
language: "auto",
modelId: "onnx-community/whisper-tiny.en",
});
const { message } = worker.posted[0]!;
expect("language" in message).toBe(false);
expect("modelId" in message).toBe(false);
});
test("resolves on the matching result and routes partials to onPartial", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const fragments: string[] = [];
const promise = engine.transcribe(new Float32Array(4), {
onPartial: (fragment) => fragments.push(fragment),
});
const requestId = worker.lastRequestId();
// Noise the engine must ignore: other ids, id-less file-flow messages,
// load progress.
worker.emit({ type: "partial", text: "IGNORED", requestId: "other" });
worker.emit({ type: "partial", text: "IGNORED" });
worker.emit({ type: "load-progress", status: "ready", device: "wasm" });
worker.emit({ type: "partial", text: "Hel", requestId });
worker.emit({ type: "partial", text: "lo", requestId });
worker.emit({ type: "result", text: "Hello", chunks: CHUNKS, requestId });
worker.emit({ type: "result", text: "STALE", chunks: [], requestId: "other" });
expect(await promise).toEqual({ text: "Hello", chunks: CHUNKS });
expect(fragments).toEqual(["Hel", "lo"]);
});
test("rejects on a matching error message", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const promise = engine.transcribe(new Float32Array(4));
worker.emit({
type: "error",
stage: "transcribe",
message: "out of memory",
requestId: worker.lastRequestId(),
});
expect(promise).rejects.toThrow("out of memory");
});
test("a worker crash rejects every in-flight request", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const first = engine.transcribe(new Float32Array(4));
const second = engine.transcribe(new Float32Array(4));
worker.emitError("worker exploded");
expect(first).rejects.toThrow("worker exploded");
expect(second).rejects.toThrow("worker exploded");
});
test("two engines can share one worker without stealing responses", async () => {
const worker = new FakeWorker();
const engineA = new BrowserEngine(worker);
const engineB = new BrowserEngine(worker);
const promiseA = engineA.transcribe(new Float32Array(4));
const promiseB = engineB.transcribe(new Float32Array(4));
const [idA, idB] = worker.posted.map(
(post) => post.message.requestId as string,
);
expect(idA).not.toBe(idB);
worker.emit({ type: "result", text: "for A", chunks: [], requestId: idA });
worker.emit({ type: "result", text: "for B", chunks: [], requestId: idB });
expect((await promiseA).text).toBe("for A");
expect((await promiseB).text).toBe("for B");
});
});
// ---------------------------------------------------------------------------
// ServerEngine — ndjson stream over an injected fetch
// ---------------------------------------------------------------------------
function ndjsonResponse(lines: unknown[], status = 200): Response {
const body = lines.map((line) => `${JSON.stringify(line)}\n`).join("");
return new Response(body, {
status,
headers: { "Content-Type": "application/x-ndjson" },
});
}
describe("ServerEngine", () => {
test("streams segments to onPartial and resolves with the result line", async () => {
const calls: { url: string; init?: RequestInit }[] = [];
const fetchFn: FetchLike = async (url, init) => {
calls.push({ url, init });
return ndjsonResponse([
{ type: "segment", text: " Hello", start: 0, end: 1.5 },
{ type: "segment", text: " world.", start: 1.5, end: 3 },
{ type: "result", text: "Hello world.", chunks: CHUNKS },
]);
};
const engine = new ServerEngine(fetchFn);
const fragments: string[] = [];
const audio = new Float32Array([0.5, -0.5, 0.25]);
const result = await engine.transcribe(audio, {
modelId: "onnx-community/whisper-tiny.en",
language: "en",
task: "transcribe",
onPartial: (fragment) => fragments.push(fragment),
});
expect(result).toEqual({ text: "Hello world.", chunks: CHUNKS });
expect(fragments).toEqual([" Hello", " world."]);
const call = calls[0]!;
const url = new URL(call.url, "http://localhost");
expect(url.pathname).toBe("/api/transcribe");
expect(url.searchParams.get("model")).toBe("onnx-community/whisper-tiny.en");
expect(url.searchParams.get("language")).toBe("en");
expect(url.searchParams.get("task")).toBe("transcribe");
expect(call.init?.method).toBe("POST");
expect(call.init?.credentials).toBe("same-origin");
// The body must be the raw PCM bytes of the Float32Array.
const sent = new Float32Array((call.init?.body as Uint8Array).buffer.slice(0));
expect([...sent]).toEqual([...audio]);
});
test('drops language "auto" from the query', async () => {
let url = "";
const engine = new ServerEngine(async (input) => {
url = input;
return ndjsonResponse([{ type: "result", text: "", chunks: [] }]);
});
await engine.transcribe(new Float32Array(4), {
modelId: "onnx-community/whisper-tiny",
language: "auto",
});
expect(url).not.toContain("language");
});
test("requires a modelId", () => {
const engine = new ServerEngine(async () => ndjsonResponse([]));
expect(engine.transcribe(new Float32Array(4))).rejects.toThrow(/model id/);
});
test("rejects with the in-stream error line's message", () => {
const engine = new ServerEngine(async () =>
ndjsonResponse([
{ type: "segment", text: " partial", start: 0, end: 1 },
{ type: "error", message: "sidecar exploded" },
]),
);
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow("sidecar exploded");
});
test("maps HTTP statuses to UI-safe messages", async () => {
const cases: [number, RegExp][] = [
[401, /sign in/i],
[404, /not available/i],
[413, /too large|limit/i],
[503, /unavailable/i],
];
for (const [status, pattern] of cases) {
const engine = new ServerEngine(async () => new Response("{}", { status }));
expect(
engine.transcribe(new Float32Array(4), {
modelId: "onnx-community/whisper-tiny",
}),
).rejects.toThrow(pattern);
}
});
test("surfaces the server's {error} detail on 503", () => {
const engine = new ServerEngine(async () =>
new Response(JSON.stringify({ error: "faster-whisper is not installed" }), {
status: 503,
}),
);
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow(/faster-whisper is not installed/);
});
test("a stream that ends without a result line is a lost connection", () => {
const engine = new ServerEngine(async () =>
ndjsonResponse([{ type: "segment", text: " cut", start: 0, end: 1 }]),
);
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow(/connection was lost/);
});
test("network failure gets a friendly message", () => {
const engine = new ServerEngine(async () => {
throw new TypeError("fetch failed");
});
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow(/could not reach the server/i);
});
});
// ---------------------------------------------------------------------------
// Wire-format guard
// ---------------------------------------------------------------------------
describe("isTranscribeStreamLine", () => {
test("accepts the three line shapes", () => {
expect(
isTranscribeStreamLine({ type: "segment", text: " a", start: 0, end: 1 }),
).toBe(true);
expect(isTranscribeStreamLine({ type: "result", text: "a", chunks: [] })).toBe(true);
expect(isTranscribeStreamLine({ type: "error", message: "x" })).toBe(true);
});
test("rejects malformed lines", () => {
expect(isTranscribeStreamLine(null)).toBe(false);
expect(isTranscribeStreamLine({ type: "segment", text: " a" })).toBe(false);
expect(isTranscribeStreamLine({ type: "result", text: "a" })).toBe(false);
expect(isTranscribeStreamLine({ type: "unknown" })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Availability probe
// ---------------------------------------------------------------------------
describe("fetchServerEngineStatus", () => {
test("passes through an available status", async () => {
const status = await fetchServerEngineStatus(async () =>
new Response(JSON.stringify({ available: true }), { status: 200 }),
);
expect(status).toEqual({ available: true });
});
test("passes through unavailable + reason", async () => {
const status = await fetchServerEngineStatus(async () =>
new Response(
JSON.stringify({ available: false, reason: "sidecar missing" }),
{ status: 200 },
),
);
expect(status).toEqual({ available: false, reason: "sidecar missing" });
});
test("404 (deployment without the feature) resolves unavailable", async () => {
const status = await fetchServerEngineStatus(async () =>
new Response("Not Found", { status: 404 }),
);
expect(status.available).toBe(false);
});
test("never rejects on network failure", async () => {
const status = await fetchServerEngineStatus(async () => {
throw new TypeError("offline");
});
expect(status.available).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Engine choice persistence
// ---------------------------------------------------------------------------
function fakeStorage(initial: Record<string, string> = {}): StorageLike & {
data: Record<string, string>;
} {
const data = { ...initial };
return {
data,
getItem: (key) => (key in data ? data[key]! : null),
setItem: (key, value) => {
data[key] = value;
},
};
}
describe("engine choice persistence", () => {
test("round-trips through storage", () => {
const storage = fakeStorage();
saveEngineChoice("server", storage);
expect(storage.data[ENGINE_CHOICE_KEY]).toBe("server");
expect(loadEngineChoice(storage)).toBe("server");
});
test('defaults to "browser" for missing or garbage values', () => {
expect(loadEngineChoice(fakeStorage())).toBe("browser");
expect(
loadEngineChoice(fakeStorage({ [ENGINE_CHOICE_KEY]: "cloud" })),
).toBe("browser");
expect(loadEngineChoice(null)).toBe("browser");
});
test("throwing storage is survived", () => {
const storage: StorageLike = {
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
};
expect(loadEngineChoice(storage)).toBe("browser");
expect(() => saveEngineChoice("server", storage)).not.toThrow();
});
});
+58
View File
@@ -0,0 +1,58 @@
"""Protocol-faithful stand-in for server-py/sidecar.py (no faster-whisper).
Used by tests/transcribe.test.ts to exercise the real Bun-side supervisor
(createSidecar) against an actual child process. Behavior is keyed off the
requested model name:
"ok" two segments (the second reports the audio byte count,
language and task for pass-through assertions), then done
"boom" an error line
"die" exit(1) mid-request (crash simulation)
"wait-for-cancel" one segment, then block until the next stdin line
(the cancel), then done {cancelled: true}
"""
import json
import os
import sys
def emit(message):
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
emit({"type": "ready", "version": "fake"})
for line in sys.stdin:
line = line.strip()
if not line:
continue
message = json.loads(line)
kind = message.get("type")
if kind == "shutdown":
break
if kind != "transcribe":
continue
request_id = message.get("id")
model = message.get("model")
if model == "boom":
emit({"type": "error", "id": request_id, "message": "kaboom"})
elif model == "die":
sys.exit(1)
elif model == "wait-for-cancel":
emit({"type": "segment", "id": request_id, "start": 0.0, "end": 1.0,
"text": " before cancel"})
next_line = json.loads(sys.stdin.readline())
cancelled = (next_line.get("type") == "cancel"
and next_line.get("id") == request_id)
emit({"type": "done", "id": request_id, "cancelled": cancelled})
else:
size = os.path.getsize(message["audio_path"])
emit({"type": "segment", "id": request_id, "start": 0.0, "end": 1.5,
"text": " Hello"})
emit({"type": "segment", "id": request_id, "start": 1.5, "end": 3.0,
"text": " world. [model=%s bytes=%d lang=%s task=%s]"
% (model, size, message.get("language"), message.get("task"))})
emit({"type": "done", "id": request_id, "cancelled": False})
+77
View File
@@ -0,0 +1,77 @@
# Manual live-transcription verification (M7, D-016)
The segment loop (`src/lib/live.ts`) and the protocol changes are covered by
unit tests; microphone capture, the AudioWorklet path, and end-to-end live
quality can only be exercised in a real browser. This is that procedure.
Re-run it after any change to `src/lib/live.ts`, `src/lib/mic.ts`,
`src/components/LiveControls.tsx`, or the live wiring in `src/App.tsx`.
## Prerequisites
- A machine with a working microphone.
- Chrome/Edge (WebGPU) and ideally also Firefox and Safari — the three differ
exactly where mic capture is fragile (context sample rates, AudioWorklet
Blob-URL modules; Safari exercises the ScriptProcessorNode fallback if the
worklet path fails there).
- `getUserMedia` requires a secure context; `http://localhost` qualifies:
```sh
bun dev # or: bun run build && bun run start
```
## 1. Permission flow
1. Open the app, pick `whisper-tiny.en`, click **Start live transcription**.
2. The browser prompts for the microphone. **Deny** it: the button must return
to idle and the error "Microphone access was denied…" must appear; the app
stays usable (file flow unaffected).
3. Click again and **allow**: button becomes **Stop** with a pulsing red dot;
the browser's mic indicator turns on. If the model wasn't downloaded yet,
the download progress panel appears and live text simply starts once it is
ready (segment passes queue in the worker).
## 2. Live transcription behavior
1. Speak a few sentences with natural pauses. Expect:
- While speaking: dimmed italic *tentative* text appears within a couple of
seconds (tiny.en; slower models lag proportionally) and refreshes.
- After a ~1 s pause: the sentence commits — the text turns from italic to
regular ("committed") and no longer changes.
- Silence alone (say nothing for 30 s) must produce **no** text — the
energy gate drops silent segments, so no hallucinated "thank you."-style
output.
2. DevTools → Network: **zero** requests while speaking (weights may download
at the start; the audio itself never leaves the device).
3. During a live session, ModelPicker / options / dropzone are disabled.
4. Model choice sanity: repeat briefly with a multilingual model + a fixed
language and confirm the language/task options are honored.
## 3. Stop & result
1. Click **Stop** mid-sentence. The button shows "Finishing…" while the last
segment gets its final pass, then the normal **Transcript** panel appears:
full text, Copy button, and a Timestamps section whose segment times are
**absolute** (a sentence spoken ~10 s in must not be timestamped 0:00).
2. The browser's mic indicator must turn off immediately on Stop.
3. Start a new live session afterwards, then also transcribe a file — both
must still work (session cleanup, request correlation).
## 4. Quality/latency knobs (only if needed)
If commits are too eager/lazy in real rooms, tune `LiveSegmenterOptions`
(`speechRms` gate 0.015, `commitSilenceSeconds` 0.75, `maxSegmentSeconds` 25,
`minSpeechSeconds` 0.25) and the `LiveTranscriber` throttle
(`tentativeIntervalMs` 1500) — all injected from `src/App.tsx` defaults, all
unit-tested in `tests/live.test.ts`. Record any tuned values in D-016.
## 5. Offline live transcription
With the PWA installed and a model cached (see `tests/offline.md`), go
offline (airplane mode) and run a live session — it must work identically:
capture, worklet (Blob URL, no network), inference, stop/transcript.
## Recording the result
When this passes in a real browser, flip the `[~]` manual-verification item
in `WorkBreakdown.md` M7 to `[x]` and note browser + version here.
- [ ] Verified on: _browser/version, date_
+313
View File
@@ -0,0 +1,313 @@
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");
});
});
+51
View File
@@ -0,0 +1,51 @@
# Manual verification: server engine (M5, D-013)
The dev environment has no Python package manager and no browser, so — like
`tests/offline.md` and `tests/live.md` — the real-inference path needs a
manual run on a machine with Python ≥ 3.9. Everything protocol- and
HTTP-level is already covered by `bun test` (`tests/transcribe.test.ts`
exercises the real `Bun.spawn` supervisor against
`tests/fixtures/fake_sidecar.py`, plus the degradation paths against the real
`server-py/sidecar.py`).
## 1. Install the sidecar dependencies
```sh
python3 -m pip install -r server-py/requirements.txt
```
## 2. Opt-in automated pass (no browser needed)
```sh
FW_INTEGRATION=1 bun test tests/transcribe.test.ts
```
Runs the real sidecar end to end (spawn → ready handshake → `tiny.en` load →
transcribe → done). First run downloads ~75 MB of weights from the Hub.
- [ ] The `real faster-whisper sidecar` describe block passes.
## 3. In-browser run (both engines, same audio)
1. `bun run dev`, open the app, register/sign in.
2. `GET /api/transcribe/status` (with the session cookie) returns
`{"available": true}` — e.g. check the Network tab.
3. Pick `Whisper Tiny (English)`; transcribe a short clean English clip
(e.g. the JFK "ask not…" sample) **on device**. Note the transcript.
4. Switch the engine toggle to **Server** (visible only while signed in;
requires the M5 UI wiring — until that lands, drive it manually:
`curl -X POST --data-binary @audio.f32 -b "sid=…" \
"http://localhost:3000/api/transcribe?model=onnx-community/whisper-tiny.en"`
where `audio.f32` is raw float32 mono 16 kHz PCM).
5. Transcribe the same clip through the server engine.
- [ ] Segments stream in as JSON lines (not one blob at the end).
- [ ] Both transcripts agree in content (minor punctuation/casing drift
between Transformers.js and faster-whisper is expected).
- [ ] Signed out, the toggle is gone and `POST /api/transcribe` answers 401.
- [ ] Second request on the same model is visibly faster (no reload —
warm-up strategy).
- [ ] `AUTH_ENABLED=false bun run dev`: `/api/transcribe*` answers 404 and
the anonymous browser flow is unaffected.
When this passes, check off the M5 integration item in `WorkBreakdown.md`.
+448
View File
@@ -0,0 +1,448 @@
import { afterAll, describe, expect, test } from "bun:test";
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createSidecar,
createTranscribeHandlers,
type Sidecar,
type SidecarSegment,
type SidecarTranscribeRequest,
} from "../src/server/transcribe";
import { isTranscribeStreamLine, type TranscribeStreamLine } from "../src/lib/engine";
import type { SessionUser } from "../src/server/auth";
// M5 server engine: HTTP handlers against a fake sidecar, and the real
// Bun.spawn supervisor against a protocol-faithful Python stand-in
// (tests/fixtures/fake_sidecar.py) — no faster-whisper needed. The end-to-end
// run with real inference is manual/opt-in: see tests/server-engine.md.
const FIXTURE_SIDECAR = join(import.meta.dir, "fixtures/fake_sidecar.py");
const REAL_SIDECAR = join(import.meta.dir, "../server-py/sidecar.py");
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "transcribe-test-"));
tempDirs.push(dir);
return dir;
}
afterAll(async () => {
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })));
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const USER: SessionUser = { id: 1, username: "tester" };
const sessionFromCookie = (request: Request): SessionUser | null =>
request.headers.get("cookie") === "sid=ok" ? USER : null;
interface FakeSidecar extends Sidecar {
requests: SidecarTranscribeRequest[];
/** Bytes of each request's audio file, captured before cleanup. */
audioPayloads: Uint8Array[];
}
function fakeSidecar(
behavior: {
available?: boolean;
reason?: string;
segments?: SidecarSegment[];
error?: string;
waitForAbort?: boolean;
} = {},
): FakeSidecar {
const segments = behavior.segments ?? [
{ start: 0, end: 1.5, text: " Hello" },
{ start: 1.5, end: 3, text: " world." },
];
const sidecar: FakeSidecar = {
requests: [],
audioPayloads: [],
async status() {
return behavior.available === false
? { available: false, reason: behavior.reason ?? "nope" }
: { available: true };
},
async transcribe(request, onSegment, signal) {
sidecar.requests.push(request);
sidecar.audioPayloads.push(
new Uint8Array(await Bun.file(request.audioPath).arrayBuffer()),
);
if (behavior.error) throw new Error(behavior.error);
onSegment(segments[0]!);
if (behavior.waitForAbort) {
await new Promise<void>((resolve) => {
if (signal?.aborted) return resolve();
signal?.addEventListener("abort", () => resolve(), { once: true });
});
return { cancelled: true };
}
for (const segment of segments.slice(1)) onSegment(segment);
return { cancelled: false };
},
};
return sidecar;
}
function pcmBody(samples: number[]): Uint8Array<ArrayBuffer> {
const audio = new Float32Array(samples);
return new Uint8Array(audio.buffer as ArrayBuffer);
}
function postRequest(
query: string,
body: BodyInit | null,
init: { cookie?: string; signal?: AbortSignal; contentLength?: boolean } = {},
): Request {
const headers: Record<string, string> = {
"Content-Type": "application/octet-stream",
};
if (init.cookie !== undefined) headers.cookie = init.cookie;
return new Request(`http://localhost/api/transcribe${query}`, {
method: "POST",
headers,
body,
signal: init.signal,
});
}
async function readStreamLines(response: Response): Promise<TranscribeStreamLine[]> {
const text = await response.text();
return text
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => {
const parsed: unknown = JSON.parse(line);
if (!isTranscribeStreamLine(parsed)) throw new Error(`bad line: ${line}`);
return parsed;
});
}
// ---------------------------------------------------------------------------
// HTTP handlers (fake sidecar)
// ---------------------------------------------------------------------------
describe("GET /api/transcribe/status", () => {
test("401 without a session", async () => {
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar(),
getSessionUser: sessionFromCookie,
});
const response = await handlers.status(
new Request("http://localhost/api/transcribe/status"),
);
expect(response.status).toBe(401);
});
test("passes the sidecar's availability through", async () => {
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar({ available: false, reason: "python3 missing" }),
getSessionUser: sessionFromCookie,
});
const response = await handlers.status(
new Request("http://localhost/api/transcribe/status", {
headers: { cookie: "sid=ok" },
}),
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
available: false,
reason: "python3 missing",
});
});
});
describe("POST /api/transcribe", () => {
test("401 without a session — the server engine is logged-in only", async () => {
const sidecar = fakeSidecar();
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
});
const response = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1])),
);
expect(response.status).toBe(401);
expect(sidecar.requests).toHaveLength(0);
});
test("400 for missing/unknown model, bad language, bad task", async () => {
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar(),
getSessionUser: sessionFromCookie,
tempDir: await makeTempDir(),
});
const cases = [
"",
"?model=someone/other-model",
"?model=onnx-community/whisper-tiny&language=Danish!",
"?model=onnx-community/whisper-tiny&task=summarize",
];
for (const query of cases) {
const response = await handlers.post(
postRequest(query, pcmBody([0.1]), { cookie: "sid=ok" }),
);
expect(response.status).toBe(400);
}
});
test("503 with the reason when the sidecar is unavailable", async () => {
const sidecar = fakeSidecar({ available: false, reason: "faster-whisper is not installed" });
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
});
const response = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
cookie: "sid=ok",
}),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: "faster-whisper is not installed" });
expect(sidecar.requests).toHaveLength(0);
});
test("413 over the upload limit; 400 for empty or non-float32 bodies", async () => {
const tempDir = await makeTempDir();
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar(),
getSessionUser: sessionFromCookie,
maxUploadBytes: 16,
tempDir,
});
const over = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([1, 2, 3, 4, 5]), {
cookie: "sid=ok",
}),
);
expect(over.status).toBe(413);
const empty = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(0), {
cookie: "sid=ok",
}),
);
expect(empty.status).toBe(400);
const misaligned = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", new Uint8Array(6), {
cookie: "sid=ok",
}),
);
expect(misaligned.status).toBe(400);
// Rejected uploads must not leave temp files behind.
expect(await readdir(tempDir)).toEqual([]);
});
test("streams segments then the result, maps the model, cleans up", async () => {
const tempDir = await makeTempDir();
const sidecar = fakeSidecar();
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
tempDir,
});
const audio = [0.25, -0.25, 0.5, -0.5];
const response = await handlers.post(
postRequest(
"?model=onnx-community/whisper-base&language=da&task=translate",
pcmBody(audio),
{ cookie: "sid=ok" },
),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/x-ndjson");
const lines = await readStreamLines(response);
expect(lines).toEqual([
{ type: "segment", text: " Hello", start: 0, end: 1.5 },
{ type: "segment", text: " world.", start: 1.5, end: 3 },
{
type: "result",
text: "Hello world.",
chunks: [
{ text: " Hello", timestamp: [0, 1.5] },
{ text: " world.", timestamp: [1.5, 3] },
],
},
]);
// Catalog id → faster-whisper name (D-013), options passed through.
expect(sidecar.requests).toEqual([
expect.objectContaining({ model: "base", language: "da", task: "translate" }),
]);
// The sidecar saw exactly the uploaded PCM bytes…
expect([...new Float32Array(sidecar.audioPayloads[0]!.buffer.slice(0))]).toEqual(
audio,
);
// …and the temp file is gone once the stream finished.
expect(await readdir(tempDir)).toEqual([]);
});
test("sidecar errors travel in-stream after the 200 header", async () => {
const tempDir = await makeTempDir();
const handlers = createTranscribeHandlers({
sidecar: fakeSidecar({ error: "model load failed" }),
getSessionUser: sessionFromCookie,
tempDir,
});
const response = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
cookie: "sid=ok",
}),
);
expect(response.status).toBe(200);
const lines = await readStreamLines(response);
expect(lines).toEqual([{ type: "error", message: "model load failed" }]);
expect(await readdir(tempDir)).toEqual([]);
});
test("an aborted request cancels the sidecar run", async () => {
const tempDir = await makeTempDir();
const sidecar = fakeSidecar({ waitForAbort: true });
const handlers = createTranscribeHandlers({
sidecar,
getSessionUser: sessionFromCookie,
tempDir,
});
const controller = new AbortController();
const response = await handlers.post(
postRequest("?model=onnx-community/whisper-tiny.en", pcmBody([0.1]), {
cookie: "sid=ok",
signal: controller.signal,
}),
);
expect(response.status).toBe(200);
controller.abort();
// Cancelled runs still terminate the stream with the partial result.
const lines = await readStreamLines(response);
expect(lines.at(-1)?.type).toBe("result");
expect(await readdir(tempDir)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// createSidecar — the real Bun.spawn supervisor against a Python child
// ---------------------------------------------------------------------------
describe("createSidecar (fake python sidecar)", () => {
test("handshake, segment streaming, and request/option pass-through", async () => {
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
expect(await sidecar.status()).toEqual({ available: true });
const audioPath = join(await makeTempDir(), "audio.f32");
await Bun.write(audioPath, pcmBody([0.1, 0.2, 0.3]));
const segments: SidecarSegment[] = [];
const outcome = await sidecar.transcribe(
{ model: "ok", audioPath, language: "da", task: "translate" },
(segment) => segments.push(segment),
);
expect(outcome).toEqual({ cancelled: false });
expect(segments).toHaveLength(2);
expect(segments[0]).toEqual({ start: 0, end: 1.5, text: " Hello" });
// The fixture echoes what it received (audio bytes, language, task).
expect(segments[1]!.text).toContain("model=ok bytes=12 lang=da task=translate");
});
test("sidecar 'error' lines reject that request only", async () => {
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
const audioPath = join(await makeTempDir(), "audio.f32");
await Bun.write(audioPath, pcmBody([0.1]));
expect(
sidecar.transcribe({ model: "boom", audioPath }, () => {}),
).rejects.toThrow("kaboom");
// The process survived; the next request works.
const outcome = await sidecar.transcribe({ model: "ok", audioPath }, () => {});
expect(outcome).toEqual({ cancelled: false });
});
test("a crash rejects in-flight requests and the next request respawns", async () => {
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
const audioPath = join(await makeTempDir(), "audio.f32");
await Bun.write(audioPath, pcmBody([0.1]));
expect(
sidecar.transcribe({ model: "die", audioPath }, () => {}),
).rejects.toThrow(/exited unexpectedly/);
// Supervision: a fresh process serves the next request.
const segments: SidecarSegment[] = [];
const outcome = await sidecar.transcribe(
{ model: "ok", audioPath },
(segment) => segments.push(segment),
);
expect(outcome).toEqual({ cancelled: false });
expect(segments).toHaveLength(2);
});
test("abort sends a cancel and resolves {cancelled: true}", async () => {
const sidecar = createSidecar(["python3", FIXTURE_SIDECAR]);
const audioPath = join(await makeTempDir(), "audio.f32");
await Bun.write(audioPath, pcmBody([0.1]));
const controller = new AbortController();
const outcome = await sidecar.transcribe(
{ model: "wait-for-cancel", audioPath },
() => controller.abort(), // abort as soon as the first segment arrives
controller.signal,
);
expect(outcome).toEqual({ cancelled: true });
});
});
describe("createSidecar (degradation, D-013)", () => {
test("the real sidecar without faster-whisper reports a permanent reason", async () => {
// -S/-I keep site-packages off sys.path, so this behaves like a
// Bun-only deployment even on machines that have faster-whisper.
const sidecar = createSidecar(["python3", "-S", "-I", REAL_SIDECAR]);
const first = await sidecar.status();
expect(first.available).toBe(false);
expect(first.reason).toContain("faster-whisper is not installed");
// Remembered as permanent — no respawn storm, same answer immediately.
const second = await sidecar.status();
expect(second).toEqual(first);
});
test("a missing python binary reports unavailable", async () => {
const sidecar = createSidecar(["definitely-not-a-python-binary"]);
const status = await sidecar.status();
expect(status.available).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Opt-in: real faster-whisper end to end (needs python3 + requirements.txt
// installed and network/cached weights). See tests/server-engine.md.
// FW_INTEGRATION=1 bun test tests/transcribe.test.ts
// ---------------------------------------------------------------------------
describe.skipIf(!process.env.FW_INTEGRATION)("real faster-whisper sidecar", () => {
test(
"transcribes PCM through the real sidecar",
async () => {
const sidecar = createSidecar();
const status = await sidecar.status();
expect(status).toEqual({ available: true });
// One second of a 440 Hz tone — not speech, but exercises model load,
// numpy decode, and the full protocol round-trip.
const samples = new Float32Array(16_000);
for (let i = 0; i < samples.length; i++) {
samples[i] = 0.1 * Math.sin((2 * Math.PI * 440 * i) / 16_000);
}
const audioPath = join(await makeTempDir(), "tone.f32");
await Bun.write(audioPath, new Uint8Array(samples.buffer));
const outcome = await sidecar.transcribe(
{ model: "tiny.en", audioPath },
() => {},
);
expect(outcome.cancelled).toBe(false);
},
{ timeout: 300_000 },
);
});