140 lines
8.1 KiB
Markdown
140 lines
8.1 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
|
||
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:** Proposed
|
||
`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.
|
||
|
||
## 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.
|