Add cancel + progress UI, EngineToggle, and Docker deployment

This commit is contained in:
Lucas Winther
2026-07-14 19:32:10 +02:00
parent be82ecfe57
commit 0084e0435a
16 changed files with 972 additions and 60 deletions
+77
View File
@@ -77,6 +77,44 @@ describe("BrowserEngine", () => {
expect("modelId" in message).toBe(false);
});
test("asks the worker to stream only when a consumer listens", () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
void engine.transcribe(new Float32Array(4));
void engine.transcribe(new Float32Array(4), { onPartial: () => {} });
void engine.transcribe(new Float32Array(4), { onProgress: () => {} });
expect("stream" in worker.posted[0]!.message).toBe(false);
expect(worker.posted[1]!.message.stream).toBe(true);
expect(worker.posted[2]!.message.stream).toBe(true);
});
test("routes transcribe-progress to onProgress, ignoring other ids", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
const updates: { processedSeconds: number; totalSeconds: number }[] = [];
const promise = engine.transcribe(new Float32Array(4), {
onProgress: (progress) => updates.push(progress),
});
const requestId = worker.lastRequestId();
worker.emit({ type: "transcribe-progress", processedSeconds: 5, totalSeconds: 60 });
worker.emit({
type: "transcribe-progress",
processedSeconds: 7,
totalSeconds: 60,
requestId: "other",
});
worker.emit({ type: "transcribe-progress", processedSeconds: 0, totalSeconds: 90, requestId });
worker.emit({ type: "transcribe-progress", processedSeconds: 30, totalSeconds: 90, requestId });
worker.emit({ type: "result", text: "done", chunks: [], requestId });
expect((await promise).text).toBe("done");
expect(updates).toEqual([
{ processedSeconds: 0, totalSeconds: 90 },
{ processedSeconds: 30, totalSeconds: 90 },
]);
});
test("resolves on the matching result and routes partials to onPartial", async () => {
const worker = new FakeWorker();
const engine = new BrowserEngine(worker);
@@ -265,6 +303,45 @@ describe("ServerEngine", () => {
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toThrow(/could not reach the server/i);
});
test("passes the abort signal to fetch and rethrows AbortError as-is", async () => {
// Cancel (D-018 amendment): the UI aborts the controller; the rejection
// must stay an AbortError, not be rewrapped as a network failure.
let seenSignal: AbortSignal | undefined;
const engine = new ServerEngine(async (_url, init) => {
seenSignal = init?.signal ?? undefined;
throw new DOMException("The user aborted a request.", "AbortError");
});
const controller = new AbortController();
const promise = engine.transcribe(new Float32Array(4), {
modelId: "onnx-community/whisper-tiny",
signal: controller.signal,
});
expect(promise).rejects.toMatchObject({ name: "AbortError" });
await promise.catch(() => {});
expect(seenSignal).toBe(controller.signal);
});
test("an abort mid-stream surfaces as the stream's rejection", () => {
// Once the 200 arrived, an abort tears down the body stream; whatever
// error the reader throws must propagate out of transcribe().
const body = new ReadableStream<Uint8Array>({
start(streamController) {
streamController.enqueue(
new TextEncoder().encode(
`${JSON.stringify({ type: "segment", text: " a", start: 0, end: 1 })}\n`,
),
);
streamController.error(
new DOMException("The user aborted a request.", "AbortError"),
);
},
});
const engine = new ServerEngine(async () => new Response(body, { status: 200 }));
expect(
engine.transcribe(new Float32Array(4), { modelId: "onnx-community/whisper-tiny" }),
).rejects.toMatchObject({ name: "AbortError" });
});
});
// ---------------------------------------------------------------------------
+72
View File
@@ -5,6 +5,7 @@ import {
initialLoadProgress,
isUiToWorkerMessage,
isWorkerToUiMessage,
TranscribeProgressTracker,
type LoadProgressMessage,
type UiToWorkerMessage,
type WorkerToUiMessage,
@@ -31,6 +32,8 @@ describe("isUiToWorkerMessage", () => {
{ type: "transcribe", audio: new Float32Array(8), language: "da", task: "transcribe" },
{ type: "transcribe", audio: new Float32Array(8), language: "auto", task: "translate" },
{ type: "transcribe", audio: new Float32Array(8), requestId: "live-3" },
{ type: "transcribe", audio: new Float32Array(8), requestId: "engine-1-1", stream: true },
{ type: "transcribe", audio: new Float32Array(8), stream: false },
];
for (const message of [...loads, ...transcribes]) {
expect(isUiToWorkerMessage(message)).toBe(true);
@@ -58,6 +61,9 @@ describe("isUiToWorkerMessage", () => {
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), requestId: 7 }),
).toBe(false);
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), stream: "yes" }),
).toBe(false);
expect(isUiToWorkerMessage({ type: "result", text: "", chunks: [] })).toBe(false);
});
});
@@ -80,6 +86,8 @@ describe("isWorkerToUiMessage", () => {
{ type: "load-progress", status: "ready", device: "webgpu" },
{ type: "partial", text: " hello" },
{ type: "partial", text: " hello", requestId: "live-1" },
{ type: "transcribe-progress", processedSeconds: 0, totalSeconds: 90 },
{ type: "transcribe-progress", processedSeconds: 42.5, totalSeconds: 90, requestId: "live-1" },
{ type: "result", text: "hello world", chunks: [{ text: "hello world", timestamp: [0, 1.2] }] },
{ type: "result", text: "hello world", chunks: [], requestId: "live-1" },
{ type: "error", stage: "load", message: "boom" },
@@ -98,6 +106,18 @@ describe("isWorkerToUiMessage", () => {
expect(isWorkerToUiMessage({ type: "load-progress", status: "ready", device: "cuda" })).toBe(false);
expect(isWorkerToUiMessage({ type: "partial" })).toBe(false);
expect(isWorkerToUiMessage({ type: "partial", text: "x", requestId: 1 })).toBe(false);
expect(isWorkerToUiMessage({ type: "transcribe-progress", processedSeconds: 1 })).toBe(false);
expect(
isWorkerToUiMessage({ type: "transcribe-progress", processedSeconds: "1", totalSeconds: 2 }),
).toBe(false);
expect(
isWorkerToUiMessage({
type: "transcribe-progress",
processedSeconds: 1,
totalSeconds: 2,
requestId: 9,
}),
).toBe(false);
expect(isWorkerToUiMessage({ type: "result", text: "x", chunks: "nope" })).toBe(false);
expect(isWorkerToUiMessage({ type: "error", stage: "decode", message: "m" })).toBe(false);
expect(isWorkerToUiMessage({ type: "load", modelId: "x", device: "wasm" })).toBe(false);
@@ -155,6 +175,58 @@ describe("deviceAttempts (D-004, D-017)", () => {
});
});
describe("TranscribeProgressTracker", () => {
// Production shape: 30 s windows advancing by 20 s (30 − 2·5, the worker's
// chunk/stride settings), like the chunked ASR pipeline.
const WINDOW = 30;
const JUMP = 20;
test("maps window-relative timestamps to absolute seconds per window", () => {
const tracker = new TranscribeProgressTracker(90, WINDOW, JUMP);
expect(tracker.timestamp(0)).toBeNull(); // 0 does not advance past 0
expect(tracker.timestamp(4.2)).toBe(4.2);
expect(tracker.timestamp(29.5)).toBe(29.5);
expect(tracker.finalizeWindow()).toBeNull(); // 1·20 < 29.5 already seen
// Second window: timestamps are relative again, offset by one jump.
expect(tracker.timestamp(3)).toBeNull(); // 20 + 3 < 29.5
expect(tracker.timestamp(12)).toBe(32);
});
test("is monotone across overlapping windows", () => {
const tracker = new TranscribeProgressTracker(90, WINDOW, JUMP);
expect(tracker.timestamp(28)).toBe(28);
tracker.finalizeWindow();
// The next window revisits overlapped audio — earlier absolute times
// than what was already reported are swallowed, never regressions.
expect(tracker.timestamp(5)).toBeNull(); // 20 + 5 = 25 < 28
expect(tracker.timestamp(10)).toBe(30);
});
test("clamps within the window and to the total duration", () => {
const tracker = new TranscribeProgressTracker(35, WINDOW, JUMP);
// A stray timestamp beyond the window length clamps to the window end.
expect(tracker.timestamp(45)).toBe(30);
expect(tracker.timestamp(-1)).toBeNull(); // negative clamps to window start
tracker.finalizeWindow();
// Second (final) window: 20 + 16 = 36 clamps to the 35 s total.
expect(tracker.timestamp(16)).toBe(35);
expect(tracker.timestamp(17)).toBeNull(); // already at the end
});
test("finalizeWindow advances to the next window start", () => {
const tracker = new TranscribeProgressTracker(90, WINDOW, JUMP);
expect(tracker.finalizeWindow()).toBe(20);
expect(tracker.finalizeWindow()).toBe(40);
});
test("single-window audio never reports past its duration", () => {
const tracker = new TranscribeProgressTracker(8, WINDOW, JUMP);
expect(tracker.timestamp(6)).toBe(6);
expect(tracker.timestamp(8)).toBe(8);
expect(tracker.finalizeWindow()).toBeNull(); // 20 clamps to 8, not an advance
});
});
describe("applyLoadProgress", () => {
test("tracks per-file byte progress without mutating previous state", () => {
const p1: LoadProgressMessage = { type: "load-progress", status: "initiate", file: "a.onnx" };