399 lines
26 KiB
Markdown
399 lines
26 KiB
Markdown
# Decisions
|
||
|
||
Lightweight ADR log. One entry per decision that shapes the codebase. Statuses:
|
||
**Accepted** (act on it), **Proposed** (default choice, revisit before building on it),
|
||
**Superseded** (kept for history, points to replacement).
|
||
|
||
Add new entries at the bottom with the next `D-###` id. Never rewrite an Accepted
|
||
entry to mean something else — supersede it.
|
||
|
||
---
|
||
|
||
## D-001 — Bun as the only runtime and toolchain
|
||
**Status:** Accepted (mandated by CLAUDE.md)
|
||
Bun replaces Node, vite/webpack (via `bun build` + HTML imports), jest/vitest
|
||
(`bun test`), express (`Bun.serve`), better-sqlite3 (`bun:sqlite`), and dotenv.
|
||
**Consequence:** no vite plugin ecosystem; PWA assets (service worker, manifest) are
|
||
wired by hand instead of via a plugin.
|
||
|
||
## D-002 — Transformers.js (`@huggingface/transformers`) for all Whisper inference
|
||
**Status:** Accepted for browser inference; the server side is **superseded by D-013**
|
||
(faster-whisper).
|
||
Same library in the browser (WebGPU/WASM) and on the server (ONNX Runtime under Bun).
|
||
**Why:** one model catalog, one pipeline API, no native whisper.cpp build step.
|
||
**Trade-off:** server CPU inference is slower than whisper.cpp; acceptable — the server
|
||
engine is a convenience for logged-in users, not the primary path.
|
||
|
||
## D-003 — Inference runs in a dedicated Web Worker
|
||
**Status:** Accepted
|
||
Model load + transcription block for seconds-to-minutes; running them on the main
|
||
thread would freeze the UI. Communication via a small typed postMessage protocol
|
||
(`src/worker/protocol.ts`) shared between UI and worker.
|
||
|
||
## D-004 — WebGPU with automatic WASM fallback
|
||
**Status:** Accepted (amended by D-017 — "auto" now probes WebNN first)
|
||
Detect via `navigator.gpu?.requestAdapter()` inside the worker; pass
|
||
`device: "webgpu" | "wasm"` to the pipeline. The active backend is surfaced in the UI
|
||
but users don't have to configure anything. Encoder-heavy Whisper benefits ~10-40× from
|
||
WebGPU where available (Chromium); WASM keeps Firefox/Safari working.
|
||
|
||
## D-005 — Model weights cached via the browser Cache API (Transformers.js default)
|
||
**Status:** Accepted
|
||
Transformers.js already persists Hub downloads in the Cache API (`useBrowserCache`).
|
||
We do **not** re-cache weights in the service worker — the SW precaches only the app
|
||
shell. **Consequence:** offline = installed PWA + at least one previously loaded model;
|
||
the UI must show per-model "downloaded / not downloaded" state (query
|
||
`caches` for hub URLs) so users can prepare for offline use.
|
||
|
||
## D-006 — Quantized model catalog, curated not exhaustive
|
||
**Status:** Proposed
|
||
Expose a curated list (`src/lib/models.ts`): `whisper-tiny(.en)`, `base(.en)`,
|
||
`small(.en)`, `medium(.en)`, `large-v3-turbo`, each with sensible dtype defaults
|
||
(e.g. q4/q8 on WASM, fp16/q4 on WebGPU) and a size estimate shown before download.
|
||
**Why not free-form model ids:** predictable UX, tested combinations, honest
|
||
size/VRAM warnings. Revisit if users ask for arbitrary Hub ids.
|
||
|
||
## D-007 — PWA: hand-rolled service worker, app-shell precache
|
||
**Status:** Accepted
|
||
No Workbox/vite-plugin-pwa (conflicts with D-001 tooling). `bun build` emits hashed
|
||
assets to `dist/`; a small build step writes the precache manifest into `sw.ts`.
|
||
Strategy: precache app shell, cache-first for static assets, network-only for `/api/*`.
|
||
|
||
## D-008 — Passport.js via a connect-compat adapter over Bun.serve
|
||
**Status:** Accepted (Passport mandated by CLAUDE.md; adapter is the how)
|
||
Passport expects connect/express-style `(req, res, next)` and `req.login()`. We are
|
||
not allowed to use express (D-001), so `src/server/auth.ts` provides a thin adapter:
|
||
wrap Bun's `Request` in a minimal req/res shim, invoke the passport authenticate
|
||
chain, translate the outcome back to a `Response`. Local (username/password)
|
||
strategy first; other strategies plug into the same adapter later.
|
||
**Alternative rejected:** dropping Passport for hand-rolled auth — CLAUDE.md names
|
||
Passport explicitly.
|
||
|
||
## D-009 — Sessions + users in `bun:sqlite`
|
||
**Status:** Accepted
|
||
Single-file SQLite DB (path from env, default `./data/app.db`). Tables: `users`
|
||
(id, username, password_hash — Argon2 via `Bun.password`), `sessions`
|
||
(id, user_id, expires_at). Cookie: HttpOnly, SameSite=Lax, Secure in production.
|
||
No Redis/Postgres until there's a scaling reason (would be `Bun.redis` / `Bun.sql`).
|
||
|
||
## D-010 — Engine abstraction: `BrowserEngine` and `ServerEngine` behind one interface
|
||
**Status:** Accepted
|
||
`src/lib/engine.ts` defines `Engine { load(model), transcribe(audio), onProgress }`.
|
||
The UI never branches on engine type beyond the toggle. Keeps the server engine
|
||
(milestone M5) from leaking into UI code built in M1–M4.
|
||
|
||
## D-011 — Server engine transport: streaming HTTP response first, WebSocket if needed
|
||
**Status:** Accepted (implemented in M5, 2026-07-10)
|
||
`POST /api/transcribe` returns a streamed (chunked JSON-lines) response with progress
|
||
and partial transcripts. Simpler than WebSockets (no connection lifecycle, works with
|
||
plain fetch + ReadableStream). Switch to `Bun.serve` websockets only if we need
|
||
bidirectional control (e.g. cancel mid-run) that abort signals can't cover.
|
||
**As implemented:** `application/x-ndjson` of `segment`* then `result` | `error`
|
||
lines (wire types + guards shared client/server via `src/lib/engine.ts`); the
|
||
request body is raw float32 mono 16 kHz PCM — the decoded form the browser
|
||
already has — so the server never decodes containers. Cancel-mid-run needed no
|
||
WebSocket: `fetch`'s AbortSignal reaches `request.signal`, which forwards a
|
||
`cancel` to the sidecar (it checks between segments).
|
||
|
||
## D-012 — Audio decoding on the main thread via Web Audio API
|
||
**Status:** Accepted
|
||
`decodeAudioData` (handles most audio and audio-in-video containers) → mono downmix →
|
||
resample to 16 kHz → transfer Float32Array to worker (zero-copy). No ffmpeg.wasm
|
||
unless we hit real container-support gaps; note in `src/lib/audio.ts` if we do.
|
||
|
||
## D-013 — Server engine runs faster-whisper (Python sidecar)
|
||
**Status:** Accepted (user requirement, 2026-07-10; supersedes the server half of D-002)
|
||
Server-side inference (M5) uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
|
||
(CTranslate2) instead of Transformers.js under Bun. Browser inference is untouched —
|
||
faster-whisper **cannot** run in a browser (checked 2026-07): it is Python over native
|
||
C++ CTranslate2, which has no WASM build (OpenNMT/CTranslate2#528 open since 2021, no
|
||
progress) and relies on native SIMD/threading kernels. So D-002 stays Accepted for the
|
||
browser, where Transformers.js remains the engine.
|
||
**Shape:** Bun stays the only web runtime. `src/server/transcribe.ts` talks to a small
|
||
Python sidecar (`server-py/`, faster-whisper behind a minimal stdio or localhost-HTTP
|
||
interface) that `Bun.spawn` starts and supervises. The `POST /api/transcribe` contract
|
||
(D-011: streamed JSON-lines) is unchanged — faster-whisper's segment generator maps
|
||
naturally onto it.
|
||
**Consequence:** the "one inference codebase" invariant narrows to "one *browser*
|
||
inference codebase + one shared model catalog"; `src/lib/models.ts` needs a mapping
|
||
from catalog ids to faster-whisper/CTranslate2 model names (`Systran/faster-whisper-*`);
|
||
deployment gains a Python ≥3.9 dependency (server engine only — the anonymous/browser
|
||
path still deploys with Bun alone, and the app must degrade gracefully when the
|
||
sidecar is absent).
|
||
**Why:** CTranslate2 CPU inference is several times faster and more memory-efficient
|
||
than ONNX Runtime under Bun; the server engine becomes genuinely useful rather than a
|
||
slow convenience.
|
||
|
||
## D-014 — WASM sessions run at graphOptimizationLevel "basic"
|
||
**Status:** Accepted (bug workaround, 2026-07-10)
|
||
On the WASM backend, the inference worker passes
|
||
`session_options: { graphOptimizationLevel: "basic" }` to the Transformers.js
|
||
pipeline. The onnxruntime-web build bundled with Transformers.js 4.2
|
||
(1.26.0-dev.20260416) has a broken level-2 QDQ graph transform
|
||
(`qdq_actions.cc` `TransposeDQWeightsForMatMulNBits`) that fails session
|
||
creation for the 8-bit Whisper decoders (`_quantized`/`_int8`/`_uint8`), whose
|
||
tied `embed_tokens` weight is both gathered (embedding) and matmul'd (lm_head):
|
||
"Missing required scale: model.decoder.embed_tokens.weight_merged_0_scale".
|
||
Reproduced outside the browser against `decoder_model_merged_quantized.onnx`
|
||
of `onnx-community/whisper-tiny`; levels "all"/"extended" fail, "basic"/
|
||
"disabled" load fine, and onnxruntime-node 1.24.3 loads the same file at every
|
||
level (regression in the ort-web dev build, not the model).
|
||
**Scope:** WASM only — WebGPU keeps the default level. The q4 conversions use
|
||
native `MatMulNBits` ops (no QDQ), so skipping level-2 costs the larger WASM
|
||
models nothing.
|
||
**Revisit:** when Transformers.js bumps its onnxruntime-web dependency past
|
||
1.26.0-dev.20260416, retest q8-on-WASM at the default level and drop this.
|
||
|
||
## D-015 — Runtime feature flags via server env vars; first flag gates auth
|
||
**Status:** Accepted (2026-07-10)
|
||
Feature flags are read from the **server's** process environment at startup
|
||
(`src/server/flags.ts`, `envFlag()`: unset/empty → the flag's default;
|
||
`false`/`0`/`no`/`off` any case → off; anything else → on). Runtime-only by
|
||
design: one Docker image serves every configuration — flags are flipped in
|
||
docker-compose.yml / `docker run -e`, never at build time, so the client
|
||
bundle stays identical across deployments.
|
||
**First flag:** `AUTH_ENABLED` (default **on**) gates the M4 login/register
|
||
feature. Off, the server 404s every `/api/auth/*` route. The client is never
|
||
told separately: `fetchAuthStatus` (the `GET /api/auth/me` probe `useAuth`
|
||
already makes on mount) reads 404 as "feature off" and `<AuthForms />` renders
|
||
nothing. The Sign in button therefore only appears after the probe confirms
|
||
the endpoints exist — a disabled deployment never flashes auth UI, and an
|
||
offline/unreachable server also hides it (signing in would be impossible
|
||
anyway), preserving the anonymous-first invariant (no error surfaced).
|
||
**Consequence:** any future flag that must reach the browser needs a
|
||
server-derived signal (an endpoint the client can probe, like /me here) —
|
||
`BUN_PUBLIC_*` build-time inlining is not used for feature flags.
|
||
|
||
## D-016 — Live microphone transcription via segment re-transcription (browser engine)
|
||
**Status:** Accepted (2026-07-10; promotes "live transcription" out of the
|
||
WorkBreakdown out-of-scope list → milestone M7)
|
||
Live (microphone) transcription runs on the **browser engine only** — same
|
||
privacy story as file uploads: audio never touches the network, works for
|
||
anonymous users, works offline. Whisper is not a streaming model (it consumes
|
||
up-to-30 s windows), so "live" is approximated by a **segment loop**:
|
||
|
||
- **Capture (main thread):** `getUserMedia` → `AudioContext` at the device's
|
||
native rate → an `AudioWorklet` chunker posts ~100 ms Float32 frames to the
|
||
main thread, which resamples them to 16 kHz with the pure, stateful
|
||
`StreamingResampler` (windowed-sinc low-pass; `resampleLinear` has no
|
||
anti-aliasing filter, so 48 kHz → 16 kHz decimation folded everything above
|
||
8 kHz into the speech band and measurably hurt accuracy — see
|
||
`tests/resampler.test.ts`). The worklet module is loaded from a **Blob URL**
|
||
built from an inline code string — no extra served file, no
|
||
build/service-worker changes, and it keeps working offline. Where
|
||
`audioWorklet`/Blob modules are unavailable (older Safari), fall back to a
|
||
`ScriptProcessorNode`. The context is *not* forced to 16 kHz because Firefox
|
||
cannot connect a `MediaStreamAudioSourceNode` across sample rates.
|
||
- **Segmentation (pure, unit-tested):** a simple RMS energy gate
|
||
(`src/lib/live.ts`) splits speech into segments: a segment starts at speech
|
||
onset (with a short pre-roll), commits after a run of trailing silence or at
|
||
a max length (under Whisper's 30 s window), and pure-silence audio is
|
||
dropped entirely — Whisper hallucinates on silence, so never transcribe it.
|
||
Two accuracy refinements (2026-07-14): the gate has **hysteresis** (once a
|
||
segment is open, frames stay "speech" down to half the onset threshold, so
|
||
quiet phonemes don't fragment utterances), and a max-length commit is a
|
||
**soft cut** — it splits at the last sub-threshold frame in the trailing
|
||
window instead of mid-word at the hard cap, seeding the next segment with
|
||
the remainder (a mid-word cut corrupts the boundary word in both segments).
|
||
Dropped (near-silent) segments also clear any tentative text they produced,
|
||
so a hallucinated tentative pass can neither linger on screen nor be adopted
|
||
as the next segment's provisional text. Production tuning lives in
|
||
`LIVE_TUNING` (`src/lib/live.ts`), shared by `src/App.tsx` and the accuracy
|
||
harness (`tests/live-accuracy.test.ts` — tone-word oracle engine + WER, so
|
||
segmentation/scheduling/resampling accuracy is regression-tested under
|
||
`bun test` without a model).
|
||
No ML VAD (e.g. Silero) until the energy gate proves insufficient in real use.
|
||
- **Inference (existing worker, serialized):** the current segment is
|
||
re-transcribed through the **existing** inference worker/pipeline on a
|
||
rolling basis (tentative text, replaced each pass); a committed segment gets
|
||
one final pass and its text freezes. At most one inference is in flight;
|
||
final passes take priority over tentative refreshes, which are throttled and
|
||
always use the latest audio snapshot (intermediate snapshots are skipped, so
|
||
the loop degrades gracefully when inference is slower than real time).
|
||
- **Protocol:** UI↔worker messages gain an optional `requestId` echoed on
|
||
`partial`/`result`/`error`, so the live controller can correlate responses
|
||
and coexist with the file flow. The file flow keeps working without ids.
|
||
|
||
**Alternatives rejected:** streaming into the worker with a custom
|
||
chunk-stitching decoder (large fork of Transformers.js internals for marginal
|
||
latency); MediaRecorder + re-decoding compressed blobs (adds codec latency and
|
||
`decodeAudioData` round-trips for no benefit over raw PCM taps).
|
||
**Consequence:** live quality/latency is bounded by model speed on the user's
|
||
device — the UI must keep tentative text visibly distinct from committed text.
|
||
Server-engine live input (WebSocket streaming to faster-whisper) is a separate
|
||
future decision; D-011's "WebSocket if needed" note is the hook.
|
||
|
||
## D-017 — "Auto" device order: WebNN → WebGPU → WASM
|
||
**Status:** Accepted (2026-07-14; amends D-004, which only knew WebGPU → WASM)
|
||
With WebNN support added (commit "WebNN support initial"), the `"auto"` device
|
||
preference resolves in priority order **WebNN → WebGPU → WASM**: the worker
|
||
probes `navigator.ml` (WebNN) first, then `navigator.gpu.requestAdapter()`
|
||
(WebGPU), and always keeps WASM as the last resort. The probe/ordering policy
|
||
is pure and unit-tested (`deviceAttempts` in `src/worker/protocol.ts`,
|
||
`tests/protocol.test.ts`); the worker only supplies the capability probe.
|
||
Pipeline-creation failures walk the same chain — an auto-detected WebNN that
|
||
fails still tries WebGPU before WASM. An **explicit** `"webnn"` or `"webgpu"`
|
||
preference deliberately falls back straight to WASM (no detour through the
|
||
other accelerator — the user asked for a specific backend, so honor it or take
|
||
the universal fallback). The selected backend stays user-visible: the worker's
|
||
`{status:"ready", device}` message drives a "Running on …" badge next to the
|
||
Backend select (`ModelPicker`) and the engine line in the footer (D-004's
|
||
"surfaced in the UI" requirement).
|
||
|
||
## D-018 — Cancel a file transcription by terminating the worker
|
||
**Status:** Accepted (2026-07-14)
|
||
Users can cancel an in-flight file run (decode, model download kicked off by
|
||
the drop, or the transcription pass itself) via a Cancel button next to the
|
||
file name. Transformers.js exposes no abort API for a running `asr()` call,
|
||
and on the WASM backend the pass blocks the worker's event loop — a "cancel"
|
||
postMessage would sit unread until the pass finished anyway. So cancellation
|
||
**terminates the inference worker** outright; it is the only reliable
|
||
preemption and also aborts an in-flight weight download. A `fileRun` counter
|
||
in `App.tsx` marks the decode that was running when the user cancelled as
|
||
stale (`decodeAudioData` itself cannot be aborted), so it never posts audio to
|
||
the fresh worker. The next run respawns the worker and reloads the pipeline —
|
||
cheap, because the weights stay in the browser Cache API (D-005); the UI
|
||
resets `loadStarted`/`loadProgress` so the reload starts from a clean slate.
|
||
Live mode is untouched: it has its own stop/drain semantics (D-016), and a
|
||
live session never coexists with a file run (mutually exclusive in the UI).
|
||
**Alternatives rejected:** a cancel message handled between chunks (dead on
|
||
WASM, where the event loop is blocked; and still seconds of latency
|
||
elsewhere); `AbortSignal` plumbed into Transformers.js (no upstream support).
|
||
**Consequence:** cancel throws away the loaded pipeline even when only the
|
||
transcription was unwanted — an accepted cost since reloading from cache takes
|
||
a few seconds.
|
||
**Amendment (2026-07-14, EngineToggle landing):** a server-engine run cancels
|
||
by aborting the `/api/transcribe` fetch instead (`AbortController`; the server
|
||
cancels the sidecar pass between segments, D-011/D-013) — the inference worker
|
||
and its loaded pipeline are left untouched in that path.
|
||
|
||
## D-019 — Server-based live microphone transcription
|
||
**Status:** Accepted
|
||
**Why:** M7 future want. Enables logged-in users on weak devices to transcribe live microphone audio using the server engine.
|
||
**Shape:** The client opens a WebSocket connection to `/api/transcribe/live`. The client streams 16kHz Float32 PCM chunks (from `MicCapture`) over the WebSocket. The server runs `LiveTranscriber` and `LiveSegmenter` (from `src/lib/live.ts`, which is pure and runs perfectly in Node/Bun). For each segment/pass, the server's `LiveTranscriber` writes the audio to a temporary file and invokes the Python sidecar. The server sends `LiveUpdate` objects back to the client over the WebSocket.
|
||
**Consequence:** The client does zero ML and minimal buffering, acting only as a mic-streamer and UI-renderer for live passes on the server.
|
||
|
||
## D-020 — Model downloads are user-initiated, never a side effect of selection
|
||
**Status:** Accepted (2026-07-14)
|
||
Selecting a model in `ModelPicker` (or changing the backend preference) no longer
|
||
starts the weight download. Weights load exactly when they are needed or asked for:
|
||
- **Lazy:** a file drop or live start on the **browser** engine posts the
|
||
(idempotent) load, as both paths already did — the download overlaps decoding /
|
||
mic startup.
|
||
- **Explicit:** a **Download** button on the selected, not-yet-cached model in
|
||
`ModelPicker` starts the load immediately — this keeps D-005's "prepare for
|
||
offline use" story alive now that selection alone caches nothing.
|
||
- **Server engine:** server runs never touch the browser pipeline, so with the
|
||
toggle on "server" nothing downloads. (Previously the eager selection load
|
||
downloaded browser weights even when every transcription would run server-side.)
|
||
**Consequence:** model/backend switches just reset the load UI state
|
||
(`loadStarted`, progress); the next load starts from clean progress. The explicit
|
||
download is a full pipeline load, so it also warms up inference and surfaces the
|
||
"Running on …" backend badge.
|
||
**Amendment (2026-07-14):** while the engine toggle is on "server" (signed in,
|
||
available), `ModelPicker` states explicitly that nothing is downloaded to the
|
||
device; the Download button stays (with a matching tooltip) for on-device /
|
||
offline preparation.
|
||
|
||
## D-021 — Long-file hardening: no idle timeout on the transcribe stream, heartbeats, single-pass Whisper generate
|
||
**Status:** Accepted (2026-07-15)
|
||
Long uploads (tested: 15-min Opus, `tests/audio_files/en_alien.opus`) hung or died
|
||
on both engines. Three independent causes, all length-dependent:
|
||
1. **Bun's default 10 s `idleTimeout` reset `/api/transcribe` mid-stream.** Segment
|
||
gaps legitimately exceed 10 s (sidecar spawn + model load/download before the
|
||
first segment; slow decode windows — 30 s+ gaps observed with large-v3-turbo on
|
||
CPU). The stream then ended without a `result` line, so the client reported
|
||
"connection was lost" after minutes of apparent hang. Fix: `src/index.ts` calls
|
||
`server.timeout(request, 0)` for `POST /api/transcribe` (and 90 s for
|
||
`GET /api/transcribe/status`, whose first probe waits up to 60 s for the sidecar
|
||
import), and `src/server/transcribe.ts` enqueues a blank ndjson line every 5 s as
|
||
a heartbeat — reverse proxies get liveness, dead connections surface as enqueue
|
||
failures, and `ServerEngine` already skips blank lines.
|
||
2. **Bun's default 128 MB `maxRequestBodySize` rejected uploads > ~35 min** (raw
|
||
f32 PCM is 3.84 MB/min) before the route's own 512 MB limit ever applied. Fix:
|
||
`maxRequestBodySize` is set to the shared `MAX_UPLOAD_BYTES` export.
|
||
3. **Browser engine: Transformers.js 4.2's Whisper `generate()` takes a seek-loop
|
||
path when `return_timestamps` is set without `max_new_tokens`** — 1–3 full decode
|
||
passes per window, redundant under the pipeline's own 30 s/5 s chunking. Measured
|
||
on the 15-min clip (tiny.en, onnxruntime-node): 80 generate passes for 44 windows,
|
||
1.4× slower, WER 44.7 % vs 31.5 % (duplicated boundary text), and the streamer's
|
||
`on_finalize` fired per pass instead of per window, so `TranscribeProgressTracker`
|
||
saturated the progress bar at ~60 % of the run and the UI looked frozen for the
|
||
remainder (on WASM: many minutes). A window whose last boundary timestamp is 0 can
|
||
even loop forever. Fix: the worker passes `max_new_tokens: 440`, which selects the
|
||
classic single-pass-per-window path (440 ≈ 448 decoder positions minus prompt).
|
||
**Revisit:** when Transformers.js changes the seek-loop condition (currently
|
||
`return_timestamps && !max_new_tokens`, `src/models/whisper/modeling_whisper.js`),
|
||
retest whether the explicit `max_new_tokens` is still needed.
|
||
|
||
## D-022 — Medium models never default to fp16 in the browser
|
||
**Status:** Accepted (2026-07-15)
|
||
Reported symptom: transcribing `en_aiproblem` in-browser with Whisper Medium
|
||
printed just "the". The medium catalog entries defaulted to fp16 on WebNN
|
||
(whole model) and WebGPU (encoder) — and D-017's "auto" order now tries WebNN
|
||
first, so browsers exposing `navigator.ml` silently moved medium onto fp16.
|
||
Two problems with those fp16 conversions
|
||
(`onnx-community/whisper-medium(.en_timestamped|-ONNX)`):
|
||
1. The fp16 ONNX files crash onnxruntime's graph optimizer at session
|
||
creation (`SimplifiedLayerNormFusion` fails on their
|
||
`InsertedPrecisionFreeCast_*` nodes) — reproduced on both onnxruntime-node
|
||
and onnxruntime-web 1.26.0-dev (wasm EP). EPs whose optimizer pipeline
|
||
skips that fusion can load the model and hit problem 2 instead.
|
||
2. Whisper medium/large encoder activations are the classic fp16 overflow
|
||
case — degenerate output with no error raised.
|
||
Meanwhile q4 medium was verified correct end-to-end on the real browser
|
||
runtime (onnxruntime-web wasm EP driven under Bun via the
|
||
`Symbol.for("onnxruntime")` override, `graphOptimizationLevel: "basic"` as in
|
||
D-014, `max_new_tokens: 440` as in D-021; both the `.en` and multilingual
|
||
conversions, auto language detect included). **Fix:** medium's `webgpu` and
|
||
`webnn` defaults are q4 (`ACCEL_Q4` in `src/lib/models.ts`). A backend that
|
||
cannot execute q4 (`MatMulNBits`) throws at pipeline creation and falls down
|
||
the D-017 device chain — strictly better than silently emitting garbage.
|
||
**Scope:** medium only — that's where load-failure evidence exists.
|
||
tiny/base/small keep fp16 on WebNN and small keeps fp16+q4 on WebGPU;
|
||
large-v3-turbo keeps fp16+q4 on WebGPU (the widely-used Transformers.js demo
|
||
configuration). If a one-word/garbage transcript ever shows up on those, suspect
|
||
their fp16 encoders and extend this decision.
|
||
**Revisit:** if the onnx-community medium conversions are re-exported with
|
||
working fp16 files, or once WebNN/WebGPU runs can be exercised in CI.
|
||
|
||
## D-023 — Cross-origin isolation (COOP/COEP) so WASM inference is multithreaded
|
||
**Status:** Accepted (2026-07-15)
|
||
Reported symptom: after D-022 moved medium to q4, a 14 s clip transcribed
|
||
correctly in-browser but "took ages" while native whisper needs seconds.
|
||
Cause: without `crossOriginIsolated` there is no SharedArrayBuffer, and
|
||
onnxruntime-web silently runs its (SIMD-threaded build!) WASM backend on
|
||
**one** thread. Measured on the exact browser runtime (ort-web wasm EP,
|
||
medium q4, the 14.4 s en_aiproblem clip): 52.1 s at 1 thread, 15.3 s at 4
|
||
(ort's default cap once isolation exists), 9.5 s at 12 — a 5.5× speedup.
|
||
**Fix:** `src/index.ts` sends `Cross-Origin-Opener-Policy: same-origin` +
|
||
`Cross-Origin-Embedder-Policy: require-corp` on every response in both serve
|
||
modes (`withIsolationHeaders`). Bun cannot attach headers to an HTMLBundle
|
||
route, so in development the shell moved to an internal `/__shell` route and
|
||
`fetch()` re-serves `/` (and SPA fallbacks) with the headers stamped on. The
|
||
inference worker raises `env.backends.onnx.wasm.numThreads` to
|
||
`min(hardwareConcurrency − 1, 12)` when `crossOriginIsolated` is true (ort
|
||
would otherwise cap at 4; without isolation the default is left alone since
|
||
ort clamps to 1 anyway).
|
||
**Consequences:** COEP `require-corp` blocks cross-origin subresources that
|
||
are neither CORS- nor CORP-approved. Everything the app loads today survives:
|
||
Hub model downloads use `fetch()` in CORS mode, all other assets are
|
||
same-origin, and the service worker preserves the headers it cached, so the
|
||
installed/offline app stays isolated too. Any future cross-origin `<img>`,
|
||
font, or iframe must ship CORS/CORP headers or be proxied same-origin.
|
||
**Verified:** headers present on `/`, worker, sw.js, `/ort/*` in both dev and
|
||
production serve modes; dev HMR asset routes unaffected by the `/__shell`
|
||
indirection. Real-browser thread scaling still deserves a spot check (the
|
||
5.5× number is ort-web under Bun, same runtime but not a browser tab).
|
||
**Amendment (2026-07-15, same day):** threads are per-device now. With
|
||
SharedArrayBuffer newly available, onnxruntime-web's *own* default thread
|
||
count becomes ~4 — and the JSEP accelerator EPs (WebGPU, WebNN) are not
|
||
thread-safe: real-browser WebGPU runs failed at OrtRun with "WebGPU
|
||
validation failed. Buffer with '' label is invalid". The worker now sets
|
||
`numThreads` right before each device attempt (`configureWasmThreads`):
|
||
`min(cores − 1, 12)` for the pure `wasm` device, **explicitly 1** for
|
||
`webgpu`/`webnn` (relying on the default is what broke). onnxruntime-web
|
||
fixes its thread pool at first-session init, so the first attempt in a
|
||
worker decides; a wasm fallback after a failed accelerator attempt may run
|
||
single-threaded until the next worker respawn — correct over fast.
|