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:
1 parent
eb72a2e646
commit
9f39a82982
38 files changed
+3857
-246
No files matched your search
+135
-23
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user