From c5a57cd33a6d56e5a2f5cbffe6d3c91adb46ee62 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 21:51:26 +0100 Subject: [PATCH] Keep Parakeet on WASM unless WebGPU is chosen explicitly, and never offer it on phones Co-Authored-By: Claude Opus 5.5 --- src/lib/mobileMemoryGuard.ts | 25 +++++++++++++++++++++++++ src/worker/protocol.ts | 19 +++++++++++++++++++ tests/mobileMemoryGuard.test.ts | 32 +++++++++++++++++++++++++++++++- tests/protocol.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/lib/mobileMemoryGuard.ts b/src/lib/mobileMemoryGuard.ts index 10095c2..ae8429f 100644 --- a/src/lib/mobileMemoryGuard.ts +++ b/src/lib/mobileMemoryGuard.ts @@ -19,6 +19,8 @@ * advance, so a benchmark probe would cost more than it saves. */ +import type { WhisperModel } from "./models"; + export interface MobileMemoryHint { /** `navigator.userAgent`, or undefined outside a browser (tests, SSR). */ userAgent: string | undefined; @@ -86,3 +88,26 @@ export function shouldWarnBeforeLoad(hint: MobileMemoryHint, peakBytes: number): } return peakBytes > MOBILE_FALLBACK_RISK_THRESHOLD_BYTES; } + +/** + * Desktop-only models (D-WS7a): Parakeet's ~1.3 GB load peak (2 × the + * 652 MB int8 encoder, D-027's arithmetic) exceeds every phone budget the + * D-029 table considers safe, even at deviceMemory 8 (800 MB). Unlike the + * Whisper models — where a confirm lets the user judge their device — it is + * not offered at all on phones and tablets: no amount of RAM a phone reports + * makes it safe, so a "Continue anyway?" would only invite the crash. + */ +export function isModelAllowedOnDevice( + model: Pick, + hint: Pick, +): boolean { + return model.family !== "parakeet" || !isLikelyMobile(hint); +} + +/** The catalog as offered on this device (order preserved). */ +export function modelsForDevice>( + models: readonly T[], + hint: Pick, +): T[] { + return models.filter((model) => isModelAllowedOnDevice(model, hint)); +} diff --git a/src/worker/protocol.ts b/src/worker/protocol.ts index 688333a..59b8d33 100644 --- a/src/worker/protocol.ts +++ b/src/worker/protocol.ts @@ -267,6 +267,25 @@ export function deviceAttempts( } } +/** + * Device attempts for the Parakeet engine path (D-WS7a). Narrower than + * `deviceAttempts`: "auto" always means WASM int8 — the only measured path — + * because WebGPU int8 is reported broken and WebGPU needs the separate + * 1.24 GB fp16 encoder, so auto-selecting it would silently double the + * download on an unverified backend. An explicit WebGPU choice is honoured + * only where shader-f16 exists (fp16 encoder), still falling back to WASM; + * there is no WebNN path. + */ +export function parakeetDeviceAttempts( + preference: DevicePreference, + capabilities: DeviceCapabilities, +): InferenceDevice[] { + if (preference === "webgpu" && capabilities.webgpu && capabilities.webgpuF16) { + return ["webgpu", "wasm"]; + } + return ["wasm"]; +} + // --------------------------------------------------------------------------- // Runtime guards (postMessage data arrives untyped) // --------------------------------------------------------------------------- diff --git a/tests/mobileMemoryGuard.test.ts b/tests/mobileMemoryGuard.test.ts index 2772467..859cbd4 100644 --- a/tests/mobileMemoryGuard.test.ts +++ b/tests/mobileMemoryGuard.test.ts @@ -1,5 +1,11 @@ import { describe, test, expect } from "bun:test"; -import { isLikelyMobile, shouldWarnBeforeLoad } from "../src/lib/mobileMemoryGuard"; +import { + isLikelyMobile, + isModelAllowedOnDevice, + modelsForDevice, + shouldWarnBeforeLoad, +} from "../src/lib/mobileMemoryGuard"; +import { MODELS, PARAKEET_MODEL_ID } from "../src/lib/models"; // Pure UA/heuristic logic (D-027) — no browser, no navigator. @@ -99,3 +105,27 @@ describe("shouldWarnBeforeLoad", () => { ).toBe(false); }); }); + +describe("desktop-only models (D-WS7a)", () => { + const parakeet = MODELS.find((model) => model.id === PARAKEET_MODEL_ID)!; + const phone = { userAgent: ANDROID_UA, maxTouchPoints: 5, deviceMemoryGB: 8 }; + const ipad = { userAgent: IPAD_DESKTOP_UA, maxTouchPoints: 5, deviceMemoryGB: undefined }; + const desktop = { userAgent: DESKTOP_WINDOWS_UA, maxTouchPoints: 0, deviceMemoryGB: 8 }; + + test("Parakeet is never offered on phones or tablets, even high-memory ones", () => { + expect(isModelAllowedOnDevice(parakeet, phone)).toBe(false); + expect(isModelAllowedOnDevice(parakeet, ipad)).toBe(false); + expect(isModelAllowedOnDevice(parakeet, desktop)).toBe(true); + }); + + test("Whisper models stay allowed everywhere (they get the D-027 confirm instead)", () => { + for (const model of MODELS.filter((m) => m.id !== PARAKEET_MODEL_ID)) { + expect(isModelAllowedOnDevice(model, phone)).toBe(true); + } + }); + + test("modelsForDevice filters the list, keeping order", () => { + expect(modelsForDevice(MODELS, phone)).toEqual(MODELS.filter((m) => m !== parakeet)); + expect(modelsForDevice(MODELS, desktop)).toEqual([...MODELS]); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 43bf74c..c7d05c2 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"; import { applyLoadProgress, deviceAttempts, + parakeetDeviceAttempts, dtypeRequiresF16, initialLoadProgress, isUiToWorkerMessage, @@ -224,6 +225,29 @@ describe("deviceAttempts (D-004, D-017)", () => { }); }); +describe("parakeetDeviceAttempts (D-WS7a)", () => { + const everything = { webnn: true, webgpu: true, webgpuF16: true }; + + test("auto never picks an accelerator for Parakeet", () => { + expect(parakeetDeviceAttempts("auto", everything)).toEqual(["wasm"]); + }); + + test("explicit WebGPU is tried (then WASM) only with shader-f16", () => { + expect(parakeetDeviceAttempts("webgpu", everything)).toEqual(["webgpu", "wasm"]); + expect(parakeetDeviceAttempts("webgpu", { ...everything, webgpuF16: false })).toEqual([ + "wasm", + ]); + expect(parakeetDeviceAttempts("webgpu", { ...everything, webgpu: false })).toEqual([ + "wasm", + ]); + }); + + test("WebNN and WASM preferences run WASM", () => { + expect(parakeetDeviceAttempts("webnn", everything)).toEqual(["wasm"]); + expect(parakeetDeviceAttempts("wasm", everything)).toEqual(["wasm"]); + }); +}); + describe("TranscribeProgressTracker", () => { // Production shape: 30 s windows advancing by 20 s (30 − 2·5, the worker's // chunk/stride settings), like the chunked ASR pipeline.