Files
whisper_web/src/worker/protocol.ts
T

486 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Typed postMessage protocol between the UI and the inference Web Worker
* (Decisions.md D-003). Shared by `src/worker/inference.worker.ts` and the UI.
*
* Everything in this file must stay free of browser/worker globals so it can
* be unit-tested with `bun test`.
*/
/** Devices the worker can actually run on (D-004). */
export type InferenceDevice = "webgpu" | "wasm" | "webnn";
/**
* What the UI may request. "auto" (the default) lets the worker probe the
* available backends and pick in priority order WebNN → WebGPU → WASM
* (D-004/D-017), with automatic fallback down the same chain if pipeline
* creation fails on the picked device.
*/
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
// ---------------------------------------------------------------------------
export interface LoadMessage {
type: "load";
/** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */
modelId: string;
/** 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;
/**
* Load the encoder/decoder ONNX sessions one at a time instead of
* concurrently (D-027/D-029). Sequential halves the peak JS-heap/WASM
* memory a load needs (avoiding mobile tab crashes on larger models) at
* the cost of losing the CDN-concurrency benefit a fast connection would
* otherwise get from two simultaneous downloads — worth paying only where
* the memory-crash risk is real. The UI computes this (`isMobileLoadRisk`
* in App.tsx); omitted/false loads in parallel.
*/
sequentialSessionLoad?: boolean;
}
export interface TranscribeMessage {
type: "transcribe";
/**
* Mono PCM at 16 kHz (see src/lib/audio.ts). The underlying ArrayBuffer
* 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;
/**
* Optional correlation id, echoed on the partial/result/error messages this
* request produces (D-016). The live-transcription loop uses it to match
* responses to segment passes; the file flow omits it and is unchanged.
*/
requestId?: string;
/**
* Stream partial tokens and transcribe-progress for this request. Defaults
* to `requestId === undefined` (the historical rule: id-less file-flow
* passes stream, live segment passes resolve wholesale via "result").
* `BrowserEngine` sets it explicitly so a file pass routed through the
* engine (which correlates via requestId, D-010) still streams.
*/
stream?: boolean;
}
export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
// ---------------------------------------------------------------------------
// Worker → UI
// ---------------------------------------------------------------------------
/**
* Model download / initialization progress. Mirrors the Transformers.js
* `progress_callback` events we care about; `status: "ready"` marks the end
* of a successful load and reports the backend that actually got selected
* (after device detection / fallback, D-004, D-017).
*/
export type LoadProgressMessage =
| { type: "load-progress"; status: "initiate"; file: string }
| { type: "load-progress"; status: "download"; file: string }
| {
type: "load-progress";
status: "progress";
file: string;
/** Bytes downloaded so far. */
loaded: number;
/** Total bytes for this file. */
total: number;
/** Percentage in [0, 100]. */
progress: number;
}
| { type: "load-progress"; status: "done"; file: string }
| {
type: "load-progress";
status: "ready";
device: InferenceDevice;
/**
* WASM thread count onnxruntime actually ended up with (D-025) — ort
* clamps to 1 when cross-origin isolation is missing, so this is field
* observability for "is multithreading really on?". Accelerator devices
* report 1 (their WASM side must be single-threaded, D-023).
*/
threads?: number;
};
/** Incremental transcript fragment — append to the text streamed so far. */
export interface PartialMessage {
type: "partial";
text: string;
/** Echo of the originating transcribe request's id, if it carried one. */
requestId?: string;
}
/**
* How far a file-flow pass has progressed through the audio, in seconds.
* Posted once at pass start (0 / total) and then on every Whisper segment
* boundary, so slow models/backends show movement long before the first
* partial token. Values are monotone and clamped to [0, totalSeconds]
* (see TranscribeProgressTracker). Live passes (requestId set) don't report
* progress — the live UI has its own committed/tentative feedback (D-016).
*/
export interface TranscribeProgressMessage {
type: "transcribe-progress";
processedSeconds: number;
totalSeconds: number;
/** Echo of the originating transcribe request's id, if it carried one. */
requestId?: string;
}
/** One timestamped segment of the final transcript. */
export interface TranscriptChunk {
text: string;
/** [start, end] in seconds; end may be null if Whisper never closed the segment. */
timestamp: [number, number | null];
}
export interface ResultMessage {
type: "result";
text: string;
chunks: TranscriptChunk[];
/** Echo of the originating transcribe request's id, if it carried one. */
requestId?: string;
}
export interface ErrorMessage {
type: "error";
stage: "load" | "transcribe";
message: string;
/** Echo of the failing transcribe request's id, if it carried one. */
requestId?: string;
}
/**
* The worker cannot finish this load usefully in-place and asks the UI to
* respawn it (D-025). Posted when an accelerator attempt already initialized
* onnxruntime's WASM runtime — whose thread pool is fixed at first init —
* single-threaded, so the WASM fallback in *this* worker would run on one
* thread for the worker's whole life. The UI terminates the worker, spawns a
* fresh one, and re-issues the load with the explicit `device`, remembering
* `modelId`+`preference` so later loads skip the doomed attempt entirely.
*/
export interface LoadRestartMessage {
type: "load-restart";
/** Echo of the aborted load's model id. */
modelId: string;
/** Echo of the aborted load's device preference. */
preference: DevicePreference;
/** Explicit device the fresh worker should load with. */
device: InferenceDevice;
}
export type WorkerToUiMessage =
| LoadProgressMessage
| PartialMessage
| TranscribeProgressMessage
| ResultMessage
| ErrorMessage
| LoadRestartMessage;
// ---------------------------------------------------------------------------
// Device selection policy (D-004, D-017) — pure so it is unit-testable; the
// worker probes the browser capabilities and this decides the attempt order.
// ---------------------------------------------------------------------------
/** What the worker's capability probe found (see inference.worker.ts). */
export interface DeviceCapabilities {
/** `navigator.ml` exists (WebNN). */
webnn: boolean;
/** `navigator.gpu.requestAdapter()` returned an adapter (WebGPU). */
webgpu: boolean;
/**
* The WebGPU adapter supports the "shader-f16" feature. Firefox ships
* WebGPU without it, so fp16 weights cannot run there (D-025); the worker
* skips the WebGPU attempt when the resolved dtype needs fp16 and this is
* false. Optional so `deviceAttempts` callers that don't probe it (tests)
* stay valid.
*/
webgpuF16?: boolean;
}
/**
* Whether a dtype spec involves fp16 weights anywhere — such a model cannot
* run on a WebGPU adapter without the "shader-f16" feature (D-025).
*/
export function dtypeRequiresF16(dtype: DtypeSpec): boolean {
if (typeof dtype === "string") return dtype === "fp16";
return dtype.encoder_model === "fp16" || dtype.decoder_model_merged === "fp16";
}
/**
* Resolve a device preference against probed capabilities into the ordered
* list of devices to attempt pipeline creation on. The first entry is the
* selected device; later entries are automatic fallbacks for when creation
* throws (driver quirks, unsupported ops, out-of-memory). Never empty — WASM
* is always the last resort.
*
* - "auto": WebNN → WebGPU → WASM (D-017), skipping backends the probe ruled
* out, so an auto-detected WebNN that fails still tries WebGPU before WASM.
* - Explicit "webnn"/"webgpu": that device (if available) → WASM. An explicit
* choice deliberately does not detour through the other accelerator.
* - "wasm": WASM only.
*/
export function deviceAttempts(
preference: DevicePreference,
capabilities: DeviceCapabilities,
): InferenceDevice[] {
switch (preference) {
case "wasm":
return ["wasm"];
case "webgpu":
return capabilities.webgpu ? ["webgpu", "wasm"] : ["wasm"];
case "webnn":
return capabilities.webnn ? ["webnn", "wasm"] : ["wasm"];
case "auto": {
const attempts: InferenceDevice[] = [];
if (capabilities.webnn) attempts.push("webnn");
if (capabilities.webgpu) attempts.push("webgpu");
attempts.push("wasm");
return attempts;
}
}
}
// ---------------------------------------------------------------------------
// Runtime guards (postMessage data arrives untyped)
// ---------------------------------------------------------------------------
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isOptionalString(value: unknown): value is string | undefined {
return value === undefined || typeof value === "string";
}
const DEVICES: readonly string[] = ["webgpu", "wasm", "webnn"];
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" &&
isDevicePreference(value.device) &&
(value.dtype === undefined || isDtypeSpec(value.dtype))
);
case "transcribe":
return (
value.audio instanceof Float32Array &&
isOptionalString(value.language) &&
(value.task === undefined ||
value.task === "transcribe" ||
value.task === "translate") &&
isOptionalString(value.requestId) &&
(value.stream === undefined || typeof value.stream === "boolean")
);
default:
return false;
}
}
export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage {
if (!isRecord(value)) return false;
switch (value.type) {
case "load-progress":
switch (value.status) {
case "initiate":
case "download":
case "done":
return typeof value.file === "string";
case "progress":
return (
typeof value.file === "string" &&
typeof value.loaded === "number" &&
typeof value.total === "number" &&
typeof value.progress === "number"
);
case "ready":
return (
isInferenceDevice(value.device) &&
(value.threads === undefined || typeof value.threads === "number")
);
default:
return false;
}
case "partial":
return typeof value.text === "string" && isOptionalString(value.requestId);
case "transcribe-progress":
return (
typeof value.processedSeconds === "number" &&
typeof value.totalSeconds === "number" &&
isOptionalString(value.requestId)
);
case "result":
return (
typeof value.text === "string" &&
Array.isArray(value.chunks) &&
isOptionalString(value.requestId)
);
case "error":
return (
typeof value.message === "string" &&
(value.stage === "load" || value.stage === "transcribe") &&
isOptionalString(value.requestId)
);
case "load-restart":
return (
typeof value.modelId === "string" &&
isDevicePreference(value.preference) &&
isInferenceDevice(value.device)
);
default:
return false;
}
}
// ---------------------------------------------------------------------------
// Load-progress accumulation (pure; drives the download progress UI)
// ---------------------------------------------------------------------------
export interface FileProgress {
file: string;
loaded: number;
total: number;
done: boolean;
}
export interface LoadProgressState {
/** Per-file progress, keyed by file name, in first-seen order. */
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,
device: null,
};
/** Fold one load-progress message into the accumulated state. Pure. */
export function applyLoadProgress(
state: LoadProgressState,
message: LoadProgressMessage,
): LoadProgressState {
if (message.status === "ready") {
return { ...state, ready: true, device: message.device };
}
const previous = state.files[message.file];
const next: FileProgress = {
file: message.file,
loaded: previous?.loaded ?? 0,
total: previous?.total ?? 0,
done: previous?.done ?? false,
};
if (message.status === "progress") {
next.loaded = message.loaded;
next.total = message.total;
} else if (message.status === "done") {
next.done = true;
if (next.total > 0) next.loaded = next.total;
}
return { ...state, files: { ...state.files, [message.file]: next } };
}
// ---------------------------------------------------------------------------
// Transcription progress accumulation (pure; drives the in-progress UI)
// ---------------------------------------------------------------------------
/**
* Maps the streamer's window-relative timestamps to absolute audio progress.
*
* The chunked ASR pipeline slices audio into windows of `windowSeconds`,
* advancing by `jumpSeconds` (window − 2·stride) per window, and runs one
* generate pass per window. Whisper's timestamp tokens — surfaced via
* `WhisperTextStreamer`'s `on_chunk_start`/`on_chunk_end` — are relative to
* the current window's start, and `on_finalize` fires when a window's pass
* ends. This tracker turns those into absolute seconds that are monotone
* (windows overlap, so a later window's early timestamps can precede an
* earlier window's late ones — regressions are swallowed) and clamped to
* [0, totalSeconds].
*/
export class TranscribeProgressTracker {
private windowIndex = 0;
private reported = 0;
constructor(
private readonly totalSeconds: number,
private readonly windowSeconds: number,
private readonly jumpSeconds: number,
) {}
/**
* Absolute progress for a window-relative timestamp, or null when it does
* not advance past what was already reported.
*/
timestamp(withinWindowSeconds: number): number | null {
const clamped = Math.min(Math.max(withinWindowSeconds, 0), this.windowSeconds);
return this.advance(this.windowIndex * this.jumpSeconds + clamped);
}
/**
* The current window's generate pass finished; progress moves to the start
* of the next window (the amount of audio no later window revisits).
*/
finalizeWindow(): number | null {
this.windowIndex += 1;
return this.advance(this.windowIndex * this.jumpSeconds);
}
private advance(processedSeconds: number): number | null {
const clamped = Math.min(processedSeconds, this.totalSeconds);
if (clamped <= this.reported) return null;
this.reported = clamped;
return clamped;
}
}