Prefer WASM on firefox cause WebGPU is incredibly slow there.
This commit is contained in:
1 parent
74858f63fc
commit
f6019b3a42
7 files changed
+486
-27
No files matched your search
+161
-11
@@ -78,6 +78,22 @@ export function App() {
|
||||
// audio to the (fresh) worker. Decoding itself cannot be aborted.
|
||||
const fileRun = useRef(0);
|
||||
|
||||
// Accelerator failures learned this session (D-025), keyed
|
||||
// "modelId::preference" → the explicit device to load with instead, so
|
||||
// repeat loads skip the doomed accelerator attempt (and its restart)
|
||||
// entirely. Session-only on purpose: a browser update may fix the backend.
|
||||
const deviceOverrides = useRef(new Map<string, InferenceDevice>());
|
||||
|
||||
// Resolves when the current browser-engine load reports ready (rejects on
|
||||
// load failure/cancel). Transcribe posts await it, so no request is ever
|
||||
// queued inside a worker that a load-restart (D-025) is about to terminate
|
||||
// — its audio buffer would die with the worker.
|
||||
const loadGate = useRef<{
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
} | null>(null);
|
||||
|
||||
// Engine plumbing (M5, D-010). The BrowserEngine is bound to the worker's
|
||||
// lifetime (recreated together on cancel); the ServerEngine is stateless
|
||||
// and lives for the app. A server run's fetch is cancelled via abortRef.
|
||||
@@ -113,6 +129,8 @@ export function App() {
|
||||
DEFAULT_DEVICE_PREFERENCE,
|
||||
);
|
||||
const [activeDevice, setActiveDevice] = useState<InferenceDevice | null>(null);
|
||||
// WASM thread count ort actually got (D-025 observability); null until ready.
|
||||
const [activeThreads, setActiveThreads] = useState<number | null>(null);
|
||||
const [language, setLanguage] = useState(AUTO_LANGUAGE);
|
||||
const [task, setTask] = useState<WhisperTask>(DEFAULT_TASK);
|
||||
const [downloaded, setDownloaded] = useState<ReadonlySet<string>>(new Set());
|
||||
@@ -173,6 +191,36 @@ export function App() {
|
||||
};
|
||||
}, [auth.user]);
|
||||
|
||||
function overrideKey(forModelId: string, preference: DevicePreference): string {
|
||||
return `${forModelId}::${preference}`;
|
||||
}
|
||||
|
||||
/** Device to actually load with — the preference unless D-025 learned better. */
|
||||
function effectiveDevice(
|
||||
forModelId: string,
|
||||
preference: DevicePreference,
|
||||
): DevicePreference {
|
||||
return deviceOverrides.current.get(overrideKey(forModelId, preference)) ?? preference;
|
||||
}
|
||||
|
||||
function newLoadGate(): void {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
// Waiters attach their own handlers; this swallows the "unhandled
|
||||
// rejection" a gate rejected with nobody waiting would otherwise raise.
|
||||
promise.catch(() => {});
|
||||
loadGate.current = { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/** Wait until the browser pipeline is ready (or its load failed/cancelled). */
|
||||
function browserModelReady(): Promise<void> {
|
||||
return loadGate.current?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
function takeLiveRequest(requestId: string | undefined) {
|
||||
if (requestId === undefined) return undefined;
|
||||
const pending = pendingLive.current.get(requestId);
|
||||
@@ -197,7 +245,10 @@ export function App() {
|
||||
// Surface the backend the worker actually selected (D-004) and
|
||||
// refresh the downloaded badges now that weights are cached.
|
||||
setActiveDevice(message.device);
|
||||
setActiveThreads(message.threads ?? null);
|
||||
refreshDownloaded();
|
||||
// Release any transcribe posts waiting on this load (D-025).
|
||||
loadGate.current?.resolve();
|
||||
}
|
||||
break;
|
||||
case "partial":
|
||||
@@ -235,6 +286,9 @@ export function App() {
|
||||
} else if (message.requestId === undefined) {
|
||||
// Load-stage failures and legacy id-less passes. Engine-routed
|
||||
// file passes carry an id and surface through the engine promise.
|
||||
if (message.stage === "load") {
|
||||
loadGate.current?.reject(new Error(message.message));
|
||||
}
|
||||
setError(message.message);
|
||||
setTranscribeProgress(null);
|
||||
setBusy(false);
|
||||
@@ -242,6 +296,42 @@ export function App() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "load-restart": {
|
||||
// The accelerator attempt failed after it had already initialized
|
||||
// onnxruntime's WASM runtime single-threaded (D-025). This worker
|
||||
// can never run multithreaded WASM again, so: remember the outcome
|
||||
// for this session, replace the worker, and reload with the
|
||||
// explicit fallback device. The load gate stays pending — the fresh
|
||||
// worker's "ready" releases the waiting transcribe posts, which
|
||||
// then flow to the new worker.
|
||||
deviceOverrides.current.set(
|
||||
overrideKey(message.modelId, message.preference),
|
||||
message.device,
|
||||
);
|
||||
// Defensive: the gate means nothing should be in flight, but never
|
||||
// leave a promise awaiting a worker we are about to terminate.
|
||||
const restart = new Error(
|
||||
"Inference restarted to use all CPU cores — this pass was skipped.",
|
||||
);
|
||||
browserEngineRef.current?.failAll(restart);
|
||||
for (const pendingPass of pendingLive.current.values()) {
|
||||
pendingPass.reject(restart);
|
||||
}
|
||||
pendingLive.current.clear();
|
||||
workerRef.current?.terminate();
|
||||
workerRef.current = null;
|
||||
browserEngineRef.current = null;
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
const fresh = ensureWorker();
|
||||
postToWorker(fresh, {
|
||||
type: "load",
|
||||
modelId: message.modelId,
|
||||
device: message.device,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
worker.addEventListener("error", (event) => {
|
||||
@@ -251,6 +341,7 @@ export function App() {
|
||||
// Unblock any live passes so a live session can drain and stop.
|
||||
for (const pending of pendingLive.current.values()) pending.reject(failure);
|
||||
pendingLive.current.clear();
|
||||
loadGate.current?.reject(failure);
|
||||
setError(failure.message);
|
||||
setBusy(false);
|
||||
});
|
||||
@@ -275,7 +366,12 @@ export function App() {
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setLoadStarted(true);
|
||||
setError(null);
|
||||
postToWorker(worker, { type: "load", modelId: nextModelId, device: preference });
|
||||
newLoadGate();
|
||||
postToWorker(worker, {
|
||||
type: "load",
|
||||
modelId: nextModelId,
|
||||
device: effectiveDevice(nextModelId, preference),
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
@@ -283,6 +379,7 @@ export function App() {
|
||||
if (busy || liveStatus !== "idle" || nextModelId === modelId) return;
|
||||
setModelId(nextModelId);
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
setPartialText("");
|
||||
setTranscribeProgress(null);
|
||||
setResult(null);
|
||||
@@ -292,15 +389,18 @@ export function App() {
|
||||
// reports fresh progress for this model.
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
loadGate.current = null;
|
||||
}
|
||||
|
||||
function handleDevicePreference(preference: DevicePreference) {
|
||||
if (busy || liveStatus !== "idle" || preference === devicePreference) return;
|
||||
setDevicePreference(preference);
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
// No eager reload (D-020) — the next load picks up the new preference.
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
loadGate.current = null;
|
||||
}
|
||||
|
||||
/** Explicit model download (D-020) — pre-caches weights for offline use. */
|
||||
@@ -334,9 +434,13 @@ export function App() {
|
||||
workerRef.current = null;
|
||||
// The worker (and any pipeline / partial download) is gone — reset the
|
||||
// load state so the next transcription reloads from a clean slate.
|
||||
// Rejecting the gate unblocks a transcribe post still waiting on it.
|
||||
loadGate.current?.reject(cancelled);
|
||||
loadGate.current = null;
|
||||
setLoadStarted(false);
|
||||
setLoadProgress(initialLoadProgress);
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(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.
|
||||
@@ -368,13 +472,15 @@ export function App() {
|
||||
setResult(null);
|
||||
setFileName(file.name);
|
||||
|
||||
let engine: Engine;
|
||||
let engine: Engine | null = null;
|
||||
if (useServer) {
|
||||
engine = serverEngineRef.current ??= new ServerEngine();
|
||||
} else {
|
||||
// Kick off the (idempotent) model load immediately so the download runs
|
||||
// while we decode the audio locally. Server runs skip this — they don't
|
||||
// need the browser pipeline.
|
||||
// need the browser pipeline. The engine itself is resolved after the
|
||||
// load gate below: a load-restart (D-025) may replace the worker (and
|
||||
// its BrowserEngine) while the audio decodes.
|
||||
ensureWorker();
|
||||
if (!loadStarted) {
|
||||
startLoad(modelId, devicePreference);
|
||||
@@ -382,10 +488,9 @@ export function App() {
|
||||
postToWorker(workerRef.current!, {
|
||||
type: "load",
|
||||
modelId,
|
||||
device: devicePreference,
|
||||
device: effectiveDevice(modelId, devicePreference),
|
||||
});
|
||||
}
|
||||
engine = browserEngineRef.current!;
|
||||
}
|
||||
|
||||
const multilingual = getModel(modelId)?.multilingual ?? false;
|
||||
@@ -411,6 +516,24 @@ export function App() {
|
||||
}
|
||||
if (fileRun.current !== run) return; // cancelled while decoding (D-018)
|
||||
|
||||
if (engine === null) {
|
||||
// Browser run: wait for the pipeline before posting the pass, so the
|
||||
// audio never sits queued in a worker a load-restart may terminate
|
||||
// (D-025). The worker serialized the pass on the load anyway, so this
|
||||
// changes nothing about when inference actually starts.
|
||||
try {
|
||||
await browserModelReady();
|
||||
} catch (loadError) {
|
||||
if (fileRun.current !== run) return; // cancelled while loading
|
||||
setError(loadError instanceof Error ? loadError.message : String(loadError));
|
||||
setBusy(false);
|
||||
setRunEngine(null);
|
||||
return;
|
||||
}
|
||||
if (fileRun.current !== run) return;
|
||||
engine = browserEngineRef.current!;
|
||||
}
|
||||
|
||||
// Server runs cancel by aborting the fetch (D-018 amendment); browser
|
||||
// runs cancel by terminating the worker, which rejects the promise via
|
||||
// BrowserEngine.failAll.
|
||||
@@ -457,11 +580,17 @@ export function App() {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** One inference pass over a live segment, correlated via requestId. */
|
||||
function transcribeLiveSegment(
|
||||
worker: Worker,
|
||||
async function transcribeLiveSegment(
|
||||
audio: Float32Array,
|
||||
options: { language?: string; task?: WhisperTask },
|
||||
): Promise<LiveTranscribeResult> {
|
||||
// Wait for the pipeline before posting: a load-restart (D-025) may swap
|
||||
// the worker while the model loads, and a pass queued in the old worker
|
||||
// would die with it (its audio buffer is transferred, so it cannot be
|
||||
// re-posted). The worker is re-read after the wait for the same reason.
|
||||
await browserModelReady();
|
||||
const worker = workerRef.current;
|
||||
if (!worker) throw new Error("The inference worker is not running.");
|
||||
const requestId = `live-${++nextLiveRequest.current}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingLive.current.set(requestId, { resolve, reject });
|
||||
@@ -561,17 +690,21 @@ export function App() {
|
||||
}
|
||||
|
||||
// Idempotent model load, like the file flow — a pending download runs
|
||||
// while the mic opens; segment passes queue in the worker until ready.
|
||||
// while the mic opens; segment passes wait on the load gate until ready.
|
||||
const worker = ensureWorker();
|
||||
if (!loadStarted) {
|
||||
startLoad(modelId, devicePreference);
|
||||
} else {
|
||||
postToWorker(worker, { type: "load", modelId, device: devicePreference });
|
||||
postToWorker(worker, {
|
||||
type: "load",
|
||||
modelId,
|
||||
device: effectiveDevice(modelId, devicePreference),
|
||||
});
|
||||
}
|
||||
|
||||
const transcriber = new LiveTranscriber({
|
||||
...LIVE_TUNING,
|
||||
transcribe: (audio) => transcribeLiveSegment(worker, audio, options),
|
||||
transcribe: (audio) => transcribeLiveSegment(audio, options),
|
||||
onUpdate: (update) => {
|
||||
setLiveCommitted(update.committedText);
|
||||
setLiveTentative(update.tentativeText);
|
||||
@@ -639,6 +772,7 @@ export function App() {
|
||||
if (!allowed.includes(modelId) && !busy && liveStatus === "idle") {
|
||||
setModelId("onnx-community/whisper-large-v3-turbo");
|
||||
setActiveDevice(null);
|
||||
setActiveThreads(null);
|
||||
setPartialText("");
|
||||
setTranscribeProgress(null);
|
||||
setResult(null);
|
||||
@@ -713,6 +847,18 @@ export function App() {
|
||||
disabled={busy}
|
||||
/>
|
||||
|
||||
{/* Live on the WASM backend cannot keep up with real time for the big
|
||||
models — every pass pads to a full 30 s Whisper window (D-024), so
|
||||
tentative text lags and the stop drain grows. Say so instead of
|
||||
looking frozen. */}
|
||||
{liveActive && activeDevice === "wasm" && (model?.encoderParamsM ?? 0) >= 300 && (
|
||||
<p className="live-hint" role="status">
|
||||
{model?.displayName} runs slower than real time on the WASM backend, so
|
||||
live text will lag behind speech. A smaller model (Tiny, Base or Small)
|
||||
keeps up better on this device.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fileName && (
|
||||
<p className="file-name">
|
||||
File: <strong>{fileName}</strong>
|
||||
@@ -797,7 +943,11 @@ export function App() {
|
||||
: activeDevice === "webnn"
|
||||
? "WebNN (on-device)"
|
||||
: activeDevice === "wasm"
|
||||
? "WASM (on-device)"
|
||||
? `WASM (on-device${
|
||||
activeThreads !== null
|
||||
? `, ${activeThreads} thread${activeThreads === 1 ? "" : "s"}`
|
||||
: ""
|
||||
})`
|
||||
: "on-device — backend selected on first load"}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
Reference in new issue
Block a user