Files
au_whisper_web/docs/Decisions.md
T

297 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.