Add cancel + progress UI, EngineToggle, and Docker deployment
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user