Better tests and more user feedback while doing transcription; WIP.
This commit is contained in:
+39
@@ -59,6 +59,11 @@ export function App() {
|
||||
);
|
||||
const nextLiveRequest = useRef(0);
|
||||
|
||||
// File-flow run counter (D-018). Cancel bumps it, so a decode that was in
|
||||
// flight when the user cancelled finds itself stale and never posts its
|
||||
// audio to the (fresh) worker. Decoding itself cannot be aborted.
|
||||
const fileRun = useRef(0);
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [decoding, setDecoding] = useState(false);
|
||||
const [loadStarted, setLoadStarted] = useState(false);
|
||||
@@ -213,8 +218,40 @@ export function App() {
|
||||
if (loadStarted) startLoad(modelId, preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the in-flight file transcription (D-018). Transformers.js has no
|
||||
* abort API for a running `asr()` call — and on the WASM backend the pass
|
||||
* blocks the worker's event loop, so even a "cancel" message would sit
|
||||
* unread until the pass finished. Terminating the worker is the only
|
||||
* reliable preemption; it also aborts an in-flight model download. The next
|
||||
* run spawns a fresh worker and reloads the pipeline — cheap, because the
|
||||
* weights stay in the browser Cache API (D-005).
|
||||
*/
|
||||
function cancelTranscription() {
|
||||
if (!busy) return;
|
||||
fileRun.current++; // any decode still running is now stale
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
// The file flow never has live passes pending, but if anything is in the
|
||||
// map (defensive), unblock it so nothing awaits a dead worker forever.
|
||||
const cancelled = new Error("Transcription cancelled.");
|
||||
for (const pending of pendingLive.current.values()) pending.reject(cancelled);
|
||||
pendingLive.current.clear();
|
||||
setBusy(false);
|
||||
setPartialText("");
|
||||
setResult(null);
|
||||
setFileName(null);
|
||||
setError(null);
|
||||
// The worker (and any pipeline / partial download) is gone — reset the
|
||||
// load state so the next transcription reloads from a clean slate.
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setActiveDevice(null);
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
if (busy || liveStatus !== "idle") return;
|
||||
const run = ++fileRun.current;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setPartialText("");
|
||||
@@ -237,6 +274,7 @@ export function App() {
|
||||
// Decoded entirely on-device via Web Audio (D-012); the audio never
|
||||
// touches the network (Architecture.md §5).
|
||||
const audio = await decodeAudioFile(file);
|
||||
if (fileRun.current !== run) return; // cancelled while decoding (D-018)
|
||||
// Transfer the buffer to the worker — zero copy. Language/task only
|
||||
// apply to multilingual models; `.en` variants take neither.
|
||||
postToWorker(
|
||||
@@ -247,6 +285,7 @@ export function App() {
|
||||
[audio.buffer as ArrayBuffer],
|
||||
);
|
||||
} catch (decodeError) {
|
||||
if (fileRun.current !== run) return; // this run was already cancelled
|
||||
setError(decodeError instanceof Error ? decodeError.message : String(decodeError));
|
||||
setBusy(false);
|
||||
} finally {
|
||||
|
||||
@@ -22,12 +22,19 @@ interface ModelPickerProps {
|
||||
}
|
||||
|
||||
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
|
||||
{ value: "auto", label: "Auto (WebGPU if available)" },
|
||||
{ value: "auto", label: "Auto (WebNN → WebGPU → WASM)" },
|
||||
{ value: "webgpu", label: "WebGPU" },
|
||||
{ value: "webnn", label: "WebNN" },
|
||||
{ value: "wasm", label: "WASM (CPU)" },
|
||||
];
|
||||
|
||||
/** Human-readable backend names, shared with the active-device badge. */
|
||||
export const DEVICE_LABELS: Record<InferenceDevice, string> = {
|
||||
webnn: "WebNN",
|
||||
webgpu: "WebGPU",
|
||||
wasm: "WASM",
|
||||
};
|
||||
|
||||
/**
|
||||
* Curated model list (D-006): size estimate before download, English-only vs
|
||||
* multilingual grouping, downloaded badge from the Cache API (D-005), and a
|
||||
@@ -86,6 +93,17 @@ export function ModelPicker({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{/* Which backend the worker actually selected (D-004/D-017) — the
|
||||
user's preference is only a preference, so show the outcome. */}
|
||||
{activeDevice && (
|
||||
<span
|
||||
className="model-badge device-active-badge"
|
||||
role="status"
|
||||
title="The inference backend the worker actually selected for this model."
|
||||
>
|
||||
Running on {DEVICE_LABELS[activeDevice]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{disabled && (
|
||||
<p className="model-picker-hint">
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ export function estimateDefaultDownloadBytes(
|
||||
|
||||
export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en";
|
||||
|
||||
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
|
||||
/** UI default: worker probes WebNN → WebGPU → WASM in order (D-004, D-017). */
|
||||
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
|
||||
|
||||
export const DEFAULT_TASK: WhisperTask = "transcribe";
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* Inference Web Worker (Decisions.md D-003 — inference never runs on the main
|
||||
* thread). Hosts the Transformers.js `automatic-speech-recognition` pipeline.
|
||||
*
|
||||
* 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.
|
||||
* M2: hardware backend detection with automatic fallback — "auto" resolves
|
||||
* WebNN → WebGPU → WASM (D-004, D-017) — catalog-driven dtype defaults
|
||||
* (D-006), model switching with pipeline disposal, and language/task options
|
||||
* for multilingual models.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
} from "@huggingface/transformers";
|
||||
import { resolveDefaultDtype } from "../lib/models";
|
||||
import {
|
||||
deviceAttempts,
|
||||
isUiToWorkerMessage,
|
||||
type DeviceCapabilities,
|
||||
type DevicePreference,
|
||||
type InferenceDevice,
|
||||
type LoadMessage,
|
||||
@@ -56,7 +59,7 @@ function errorMessage(error: unknown): string {
|
||||
* 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).
|
||||
* which also covers the device fallback retries (D-004, D-017).
|
||||
*/
|
||||
function forwardProgress(info: ProgressInfo): void {
|
||||
switch (info.status) {
|
||||
@@ -83,7 +86,9 @@ function forwardProgress(info: ProgressInfo): void {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device selection (D-004): WebGPU when available, automatic WASM fallback
|
||||
// Device selection (D-004, D-017): "auto" resolves WebNN → WebGPU → WASM.
|
||||
// The probe only answers "does the backend exist?"; the attempt ordering and
|
||||
// fallback policy live in the pure `deviceAttempts` (protocol.ts, unit-tested).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NavigatorWithHardware {
|
||||
@@ -91,21 +96,26 @@ interface NavigatorWithHardware {
|
||||
ml?: unknown;
|
||||
}
|
||||
|
||||
async function detectDevice(preference: DevicePreference): Promise<InferenceDevice> {
|
||||
if (preference === "wasm") return "wasm";
|
||||
if (preference === "webnn") {
|
||||
async function probeCapabilities(
|
||||
preference: DevicePreference,
|
||||
): Promise<DeviceCapabilities> {
|
||||
const capabilities: DeviceCapabilities = { webnn: false, webgpu: false };
|
||||
const hardware = navigator as NavigatorWithHardware;
|
||||
if (preference === "auto" || preference === "webnn") {
|
||||
try {
|
||||
return (navigator as NavigatorWithHardware).ml ? "webnn" : "wasm";
|
||||
capabilities.webnn = Boolean(hardware.ml);
|
||||
} catch {
|
||||
return "wasm";
|
||||
// Probing threw — treat WebNN as unavailable.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const adapter = await (navigator as NavigatorWithHardware).gpu?.requestAdapter();
|
||||
return adapter ? "webgpu" : "wasm";
|
||||
} catch {
|
||||
return "wasm";
|
||||
if (preference === "auto" || preference === "webgpu") {
|
||||
try {
|
||||
capabilities.webgpu = Boolean(await hardware.gpu?.requestAdapter());
|
||||
} catch {
|
||||
// Probing threw — treat WebGPU as unavailable.
|
||||
}
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -131,12 +141,14 @@ function loadKey(message: LoadMessage): string {
|
||||
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"] :
|
||||
detected === "webnn" ? ["webnn", "wasm"] : ["wasm"];
|
||||
// Ordered devices to try (D-004/D-017): the first is the selected device;
|
||||
// if pipeline creation throws there (driver quirks, unsupported ops,
|
||||
// out-of-memory), the next one is tried automatically. For "auto" the chain
|
||||
// is WebNN → WebGPU → WASM (filtered by the probe); WASM always terminates it.
|
||||
const attempts: InferenceDevice[] = deviceAttempts(
|
||||
message.device,
|
||||
await probeCapabilities(message.device),
|
||||
);
|
||||
|
||||
let lastError: unknown;
|
||||
for (const device of attempts) {
|
||||
|
||||
+52
-3
@@ -10,8 +10,10 @@
|
||||
export type InferenceDevice = "webgpu" | "wasm" | "webnn";
|
||||
|
||||
/**
|
||||
* What the UI may request. "auto" (the default) lets the worker probe
|
||||
* `navigator.gpu` and pick WebGPU with automatic WASM fallback (D-004).
|
||||
* 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";
|
||||
|
||||
@@ -79,7 +81,7 @@ 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 and reports the backend that actually got selected
|
||||
* (after WebGPU detection / fallback, D-004).
|
||||
* (after device detection / fallback, D-004, D-017).
|
||||
*/
|
||||
export type LoadProgressMessage =
|
||||
| { type: "load-progress"; status: "initiate"; file: string }
|
||||
@@ -135,6 +137,53 @@ export type WorkerToUiMessage =
|
||||
| ResultMessage
|
||||
| ErrorMessage;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user