WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper

- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5 committed 2026-07-10 04:02:28 +02:00
1 parent eb72a2e646
commit 9f39a82982
38 files changed
+3857 -246

No files matched your search

+135 -23
View File
@@ -2,24 +2,38 @@
* Inference Web Worker (Decisions.md D-003 — inference never runs on the main
* thread). Hosts the Transformers.js `automatic-speech-recognition` pipeline.
*
* M1: hardcoded-model, WASM-only. WebGPU detection + fallback lands in M2
* (D-004); model switching/disposal also lands in M2.
* M2: WebGPU detection with automatic WASM fallback (D-004), catalog-driven
* dtype defaults (D-006), model switching with pipeline disposal, and
* language/task options for multilingual models.
*/
import {
env,
pipeline,
WhisperTextStreamer,
type AutomaticSpeechRecognitionPipeline,
type ProgressInfo,
} from "@huggingface/transformers";
import { resolveDefaultDtype } from "../lib/models";
import {
isUiToWorkerMessage,
type DevicePreference,
type InferenceDevice,
type LoadMessage,
type ResultMessage,
type TranscribeMessage,
type TranscriptChunk,
type WorkerToUiMessage,
} from "./protocol";
// Load onnxruntime's .wasm binaries from our own origin instead of the
// jsdelivr CDN default — required for offline use (M3). The server exposes
// them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts)
// and the service worker runtime-caches them cache-first (src/pwa/sw.ts).
if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = "/ort/";
}
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
interface DedicatedWorkerScope {
postMessage(message: WorkerToUiMessage): void;
@@ -38,7 +52,12 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Forward Transformers.js progress events to the UI as load-progress messages. */
/**
* Forward Transformers.js progress events to the UI as load-progress messages.
* The library's own "ready" event is swallowed — the worker posts its own
* `ready` (with the selected device) once the pipeline actually resolved,
* which also covers the WebGPU → WASM fallback retry (D-004).
*/
function forwardProgress(info: ProgressInfo): void {
switch (info.status) {
case "initiate":
@@ -56,29 +75,113 @@ function forwardProgress(info: ProgressInfo): void {
progress: info.progress,
});
break;
case "ready":
post({ type: "load-progress", status: "ready" });
break;
default:
// e.g. "progress_total" — the UI derives totals per-file itself.
// "ready" (posted by the worker itself, with the device) and
// "progress_total" — the UI derives totals per-file itself.
break;
}
}
let asrPromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
// ---------------------------------------------------------------------------
// Device selection (D-004): WebGPU when available, automatic WASM fallback
// ---------------------------------------------------------------------------
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
// Idempotent for M1's single hardcoded model: repeated "load" messages
// reuse the in-flight/completed pipeline.
asrPromise ??= pipeline("automatic-speech-recognition", message.modelId, {
device: message.device,
progress_callback: forwardProgress,
});
return asrPromise;
interface NavigatorWithGpu {
gpu?: { requestAdapter(): Promise<unknown | null> };
}
async function transcribe(audio: Float32Array): Promise<void> {
if (asrPromise === null) {
async function detectDevice(preference: DevicePreference): Promise<InferenceDevice> {
if (preference === "wasm") return "wasm";
try {
const adapter = await (navigator as NavigatorWithGpu).gpu?.requestAdapter();
return adapter ? "webgpu" : "wasm";
} catch {
return "wasm";
}
}
// ---------------------------------------------------------------------------
// Pipeline lifecycle (load, switch, dispose)
// ---------------------------------------------------------------------------
interface ActivePipeline {
/** Identity of this load request; a different key means "switch models". */
key: string;
promise: Promise<AutomaticSpeechRecognitionPipeline>;
}
let active: ActivePipeline | null = null;
function loadKey(message: LoadMessage): string {
return JSON.stringify({
modelId: message.modelId,
device: message.device,
dtype: message.dtype ?? null,
});
}
async function createPipeline(
message: LoadMessage,
): Promise<AutomaticSpeechRecognitionPipeline> {
const detected = await detectDevice(message.device);
// If WebGPU was selected but pipeline creation throws (driver quirks,
// unsupported ops, out-of-memory), fall back to WASM automatically (D-004).
const attempts: InferenceDevice[] =
detected === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
let lastError: unknown;
for (const device of attempts) {
const dtype = message.dtype ?? resolveDefaultDtype(message.modelId, device);
try {
const asr = await pipeline("automatic-speech-recognition", message.modelId, {
device,
dtype,
progress_callback: forwardProgress,
});
post({ type: "load-progress", status: "ready", device });
return asr;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
const key = loadKey(message);
// Idempotent: repeated "load" messages for the same model/device/dtype reuse
// the in-flight or completed pipeline.
if (active?.key === key) return active.promise;
const previous = active;
const promise = (async () => {
if (previous) {
// Dispose the old pipeline (frees WASM memory / GPU buffers) before
// creating the new one, so both never coexist.
try {
const old = await previous.promise;
await old.dispose();
} catch {
// The previous load itself failed — nothing to dispose.
}
}
return createPipeline(message);
})();
active = { key, promise };
promise.catch(() => {
// Allow a clean retry after a failed load; ignore stale failures that
// were already superseded by a newer load request.
if (active?.key === key) active = null;
});
return promise;
}
// ---------------------------------------------------------------------------
// Transcription
// ---------------------------------------------------------------------------
async function transcribe(message: TranscribeMessage): Promise<void> {
if (active === null) {
post({
type: "error",
stage: "transcribe",
@@ -86,7 +189,7 @@ async function transcribe(audio: Float32Array): Promise<void> {
});
return;
}
const asr = await asrPromise;
const asr = await active.promise;
const streamer = new WhisperTextStreamer(
// The ASR pipeline for Whisper models always carries a WhisperTokenizer.
@@ -97,12 +200,22 @@ async function transcribe(audio: Float32Array): Promise<void> {
},
);
const output = await asr(audio, {
const options: Record<string, unknown> = {
chunk_length_s: 30,
stride_length_s: 5,
return_timestamps: true,
streamer,
});
};
// Only meaningful for multilingual models; the UI omits these for `.en`
// variants. "auto" / undefined lets Whisper detect the language.
if (message.language !== undefined && message.language !== "auto") {
options.language = message.language;
}
if (message.task !== undefined) {
options.task = message.task;
}
const output = await asr(message.audio, options);
const single = (Array.isArray(output) ? output[0] : output) as {
text?: string;
@@ -129,12 +242,11 @@ scope.addEventListener("message", (event) => {
switch (data.type) {
case "load":
load(data).catch((error: unknown) => {
asrPromise = null; // allow retry after a failed download
post({ type: "error", stage: "load", message: errorMessage(error) });
});
break;
case "transcribe":
transcribe(data.audio).catch((error: unknown) => {
transcribe(data).catch((error: unknown) => {
post({ type: "error", stage: "transcribe", message: errorMessage(error) });
});
break;
+83 -10
View File
@@ -6,8 +6,29 @@
* be unit-tested with `bun test`.
*/
/** Devices supported by the worker. M1 is WASM-only; M2 adds "webgpu" (D-004). */
export type InferenceDevice = "wasm";
/** Devices the worker can actually run on (D-004). */
export type InferenceDevice = "webgpu" | "wasm";
/**
* What the UI may request. "auto" (the default) lets the worker probe
* `navigator.gpu` and pick WebGPU with automatic WASM fallback (D-004).
*/
export type DevicePreference = InferenceDevice | "auto";
/** Quantization levels used by the curated catalog (D-006). */
export type WhisperDtype = "fp32" | "fp16" | "q8" | "q4";
/**
* Either one dtype for the whole model, or per-part dtypes — the shape
* Transformers.js accepts. Whisper on WebGPU commonly needs a mixed setup
* (e.g. fp16 encoder + q4 merged decoder), so per-part must be expressible.
*/
export type DtypeSpec =
| WhisperDtype
| { encoder_model: WhisperDtype; decoder_model_merged: WhisperDtype };
/** Whisper generation task (multilingual models only). */
export type WhisperTask = "transcribe" | "translate";
// ---------------------------------------------------------------------------
// UI → worker
@@ -17,7 +38,13 @@ export interface LoadMessage {
type: "load";
/** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */
modelId: string;
device: InferenceDevice;
/** Device preference; the worker resolves "auto" and reports the outcome. */
device: DevicePreference;
/**
* Optional dtype override. When omitted the worker resolves the catalog
* default for the device it actually selected (src/lib/models.ts).
*/
dtype?: DtypeSpec;
}
export interface TranscribeMessage {
@@ -27,6 +54,13 @@ export interface TranscribeMessage {
* should be passed as a transferable for zero-copy handoff.
*/
audio: Float32Array;
/**
* ISO-ish Whisper language code (e.g. "en", "da"). Omit (or "auto") to let
* Whisper detect the language. Only meaningful for multilingual models.
*/
language?: string;
/** Only meaningful for multilingual models. Defaults to "transcribe". */
task?: WhisperTask;
}
export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
@@ -38,7 +72,8 @@ export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
/**
* Model download / initialization progress. Mirrors the Transformers.js
* `progress_callback` events we care about; `status: "ready"` marks the end
* of a successful load.
* of a successful load and reports the backend that actually got selected
* (after WebGPU detection / fallback, D-004).
*/
export type LoadProgressMessage =
| { type: "load-progress"; status: "initiate"; file: string }
@@ -55,7 +90,7 @@ export type LoadProgressMessage =
progress: number;
}
| { type: "load-progress"; status: "done"; file: string }
| { type: "load-progress"; status: "ready" };
| { type: "load-progress"; status: "ready"; device: InferenceDevice };
/** Incremental transcript fragment — append to the text streamed so far. */
export interface PartialMessage {
@@ -96,13 +131,45 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const DEVICES: readonly string[] = ["webgpu", "wasm"];
const DTYPES: readonly string[] = ["fp32", "fp16", "q8", "q4"];
export function isInferenceDevice(value: unknown): value is InferenceDevice {
return typeof value === "string" && DEVICES.includes(value);
}
export function isDevicePreference(value: unknown): value is DevicePreference {
return value === "auto" || isInferenceDevice(value);
}
export function isDtypeSpec(value: unknown): value is DtypeSpec {
if (typeof value === "string") return DTYPES.includes(value);
return (
isRecord(value) &&
typeof value.encoder_model === "string" &&
DTYPES.includes(value.encoder_model) &&
typeof value.decoder_model_merged === "string" &&
DTYPES.includes(value.decoder_model_merged)
);
}
export function isUiToWorkerMessage(value: unknown): value is UiToWorkerMessage {
if (!isRecord(value)) return false;
switch (value.type) {
case "load":
return typeof value.modelId === "string" && value.device === "wasm";
return (
typeof value.modelId === "string" &&
isDevicePreference(value.device) &&
(value.dtype === undefined || isDtypeSpec(value.dtype))
);
case "transcribe":
return value.audio instanceof Float32Array;
return (
value.audio instanceof Float32Array &&
(value.language === undefined || typeof value.language === "string") &&
(value.task === undefined ||
value.task === "transcribe" ||
value.task === "translate")
);
default:
return false;
}
@@ -125,7 +192,7 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
typeof value.progress === "number"
);
case "ready":
return true;
return isInferenceDevice(value.device);
default:
return false;
}
@@ -159,9 +226,15 @@ export interface LoadProgressState {
files: Record<string, FileProgress>;
/** True once the pipeline reported ready. */
ready: boolean;
/** Backend the worker actually selected; null until ready (D-004). */
device: InferenceDevice | null;
}
export const initialLoadProgress: LoadProgressState = { files: {}, ready: false };
export const initialLoadProgress: LoadProgressState = {
files: {},
ready: false,
device: null,
};
/** Fold one load-progress message into the accumulated state. Pure. */
export function applyLoadProgress(
@@ -169,7 +242,7 @@ export function applyLoadProgress(
message: LoadProgressMessage,
): LoadProgressState {
if (message.status === "ready") {
return { ...state, ready: true };
return { ...state, ready: true, device: message.device };
}
const previous = state.files[message.file];
const next: FileProgress = {