Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import type { ModelSourceMode } from "./model-source.js";
import { applyContextOverride, buildWebLLMAppConfig, TRANSFORMERS_CACHE_KEY } from "./model-source.js";
import { verifyTransformersProxy, verifyWebLLMProxy } from "./proxy-verify.js";
import type { HardwareSnapshot } from "./hardware.js";
import { collectHardwareSnapshot } from "./hardware.js";
import { collectHardwareSnapshot, isChromiumBased } from "./hardware.js";
import type { CacheCleanupResult, CachedModelStatus, IndividualModelCleanupResult, ModelCacheTarget, StorageEstimateSnapshot } from "./model-cache.js";
import {
deleteOneModelArtifactsFromBrowserStorage,
Expand Down Expand Up @@ -261,11 +261,16 @@ export class BrowserAI extends TypedEmitter<BrowserAIEvents> {
try {
this.#hardware = await collectHardwareSnapshot();
let device: MultimodalDevice = "webgpu";
const wasmCapable = !!preset.mmRuntime && WASM_CAPABLE_RUNTIMES.has(preset.mmRuntime);
if (!this.#hardware.webgpuSupported) {
// Only the runtimes whose loaders honor a wasm device (ASR pipelines, TTS) can fall back;
// VLM/Gemma/audio-LLM recipes hardcode WebGPU, and text engines require it outright.
if (preset.mmRuntime && WASM_CAPABLE_RUNTIMES.has(preset.mmRuntime)) device = "wasm";
if (wasmCapable) device = "wasm";
else throw new WebGPUUnavailableError(this.#hardware.webgpuReason);
} else if (wasmCapable && !isChromiumBased()) {
// Firefox and Safari now pass the WebGPU probe, but onnxruntime-web's WebGPU backend only
// reliably runs on Chromium (shader miscompiles / quantized-session failures elsewhere).
device = "wasm";
}

// Free any loaded models occupying the slots this one needs, waiting out their in-flight runs.
Expand Down
15 changes: 15 additions & 0 deletions src/hardware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export type HardwareSnapshot = {
platform?: string;
};

/**
* Whether this is a Chromium-based browser (Chrome, Edge, Opera, Brave, …), per the UA-Client-Hints
* brand list. onnxruntime-web's WebGPU backend is developed and tested against Chromium; Firefox's
* and Safari's newer WebGPU implementations still fail on some of its generated WGSL and its
* quantized-weight session transforms (errors that only surface at load/inference time), so
* wasm-capable runtimes prefer wasm off-Chromium even when the WebGPU probe passes.
* navigator.userAgentData is itself Chromium-only, and every Chromium new enough for WebGPU (113+)
* ships it, so the brand list is a reliable signal.
*/
export function isChromiumBased(): boolean {
if (typeof navigator === "undefined") return false;
const nav = navigator as Navigator & { userAgentData?: { brands?: Array<{ brand: string }> } };
return nav.userAgentData?.brands?.some((entry) => entry.brand === "Chromium") ?? false;
}

const LIMIT_NAMES = [
"maxBufferSize",
"maxStorageBufferBindingSize",
Expand Down
11 changes: 10 additions & 1 deletion src/multimodal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,16 @@ export async function loadMultimodal(
(device === "webgpu"
? { encoder_model: "fp32", decoder_model_merged: "q4" }
: { encoder_model: "fp32", decoder_model_merged: "q8" });
const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, { device, dtype, progress_callback });
const transcriber = await transformers.pipeline("automatic-speech-recognition", preset.id, {
device,
dtype,
progress_callback,
// onnxruntime-web 1.26-dev's extended graph optimizations fail at session creation for the
// quantized ASR decoders on the wasm EP (qdq_actions.cc: "Missing required scale …
// TransposeDQWeightsForMatMulNBits"). Capping at "basic" skips the broken QDQ rewrites —
// verified to load and transcribe. The WebGPU path keeps the default level.
...(device === "wasm" ? { session_options: { graphOptimizationLevel: "basic" as const } } : {}),
});
return { kind: "stt", transcriber };
}
case "tts-pipeline": {
Expand Down
15 changes: 14 additions & 1 deletion test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
/* Module mocks: fake WebGPU probe + fake engines */
/* ------------------------------------------------------------------ */

const hardwareState = { webgpuSupported: true };
const hardwareState = { webgpuSupported: true, chromiumBased: true };

vi.mock("../src/hardware.js", () => ({
isChromiumBased: () => hardwareState.chromiumBased,
collectHardwareSnapshot: async () => ({
webgpuSupported: hardwareState.webgpuSupported,
webgpuReason: hardwareState.webgpuSupported ? "ok" : "no adapter",
Expand Down Expand Up @@ -99,6 +100,7 @@ import { UnknownModelError, WebGPUUnavailableError } from "../src/errors.js";

beforeEach(() => {
hardwareState.webgpuSupported = true;
hardwareState.chromiumBased = true;
created.length = 0;
transformersCalls.length = 0;
activeGenerations = 0;
Expand Down Expand Up @@ -168,6 +170,17 @@ describe("BrowserAI slot manager", () => {
expect(call?.options.device).toBe("wasm");
});

it("prefers wasm for ASR/TTS presets off-Chromium even when the WebGPU probe passes", async () => {
hardwareState.chromiumBased = false;
const ai = new BrowserAI();
await ai.load("onnx-community/whisper-base");
const call = transformersCalls.find((entry) => entry.task === "automatic-speech-recognition");
expect(call?.options.device).toBe("wasm");
// The wasm EP must cap graph optimization: ORT 1.26-dev's extended QDQ rewrites break the
// quantized ASR decoders at session creation ("Missing required scale" / MatMulNBits).
expect(call?.options.session_options).toEqual({ graphOptimizationLevel: "basic" });
});

it("does NOT offer a wasm fallback for VLM/Gemma/audio-LLM presets (their recipes hardcode WebGPU)", async () => {
hardwareState.webgpuSupported = false;
const ai = new BrowserAI();
Expand Down
Loading