Start on live transcription and python sidecar (faster-whisper)
This commit is contained in:
1 parent
033ea32968
commit
2d668b3fb8
11 files changed
+2174
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user