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
+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" };