15 KiB
Architecture
Companion to
../PRODUCT.md(product spec),../AGENTS.md(agent & tooling rules) andDecisions.md(why things are the way they are). This file describes how the system is structured. Update it whenever the structure changes.
1. System overview
Browser-first speech-to-text app. Whisper inference runs in the browser by default
via Transformers.js
(@huggingface/transformers); no audio ever leaves the device for anonymous users.
Logged-in users may optionally route inference to the server.
┌──────────────────────────── Browser ────────────────────────────┐
│ │
│ React UI (App shell) │
│ ├─ ModelPicker (tiny → large, .en, quantized variants) │
│ ├─ FileDropzone (audio/video upload) │
│ ├─ LiveControls (microphone start/stop — D-016) │
│ ├─ TranscriptView (streaming partial results) │
│ └─ EngineToggle (device / server — logged-in only) │
│ │ postMessage protocol │
│ ▼ │
│ Inference Web Worker │
│ ├─ Transformers.js ASR pipeline (Whisper) │
│ ├─ Device selection: WebNN → WebGPU → WASM fallback │
│ └─ Model weights cached via browser Cache API │
│ │
│ Service Worker (PWA) │
│ ├─ Precached app shell (offline-first) │
│ └─ Works with zero network once installed + model cached │
└──────────────────┬───────────────────────────────────────────────┘
│ HTTPS (only when online / server engine chosen)
▼
┌──────────────────────────── Bun server ─────────────────────────┐
│ Bun.serve() — routes + HTML imports (no vite, no express) │
│ ├─ / → src/index.html (bundled React app) │
│ ├─ /inference.worker.js → worker bundle (Bun.build at startup, │
│ │ since Bun doesn't bundle `new Worker` graphs) │
│ ├─ /api/auth/* → Passport.js (local strategy) + sessions │
│ ├─ /api/transcribe → server engine (auth required) │
│ └─ /manifest.webmanifest, /sw.js → PWA assets │
│ bun:sqlite → users, sessions │
│ Server inference: faster-whisper (CTranslate2) Python sidecar, │
│ spawned & supervised by Bun (D-013) │
└──────────────────────────────────────────────────────────────────┘
2. Runtime & tooling
- Runtime / bundler / test runner: Bun only (
bun --hot src/index.tsfor dev,bun build src/index.htmlfor production,bun test). No vite/webpack/jest. - Frontend: React 19, mounted from
src/index.htmlvia Bun HTML imports. - Server:
Bun.serve()with theroutesoption. No express. - DB:
bun:sqlite. No ORM planned initially.
3. Directory layout (target)
src/
index.ts # Bun.serve entry — routes, static, API
index.html # HTML entry, imports frontend.tsx
frontend.tsx # React root
App.tsx # top-level UI composition
components/ # ModelPicker, TranscriptionOptions, FileDropzone, LiveControls, TranscriptView, EngineToggle, AuthForms
lib/
audio.ts # decode + resample to 16 kHz mono Float32Array (Web Audio API)
live.ts # live-mode segment loop: RMS gate, LiveSegmenter, LiveTranscriber (pure, D-016)
mic.ts # microphone capture: AudioWorklet chunker (Blob URL) + fallback (D-016)
models.ts # model catalog: ids, sizes, dtype defaults per device, languages
modelCache.ts # "downloaded?" per model via Cache Storage (D-005; reused by M3 offline indicator)
engine.ts # engine abstraction: BrowserEngine | ServerEngine
worker/
inference.worker.ts # Transformers.js pipeline, WebNN/WebGPU/WASM selection
protocol.ts # typed postMessage messages (shared with UI)
pwa/
sw.ts # service worker (app-shell precache, /ort runtime cache)
precache.ts # placeholder-injection helpers shared by build + dev serve
register.ts # SW registration + update-toast flow
manifest.webmanifest
icons/ # generated by scripts/generate-icons.ts (checked in; `bun run icons` regenerates)
fonts/ # self-hosted Inter, copied from node_modules by scripts/fonts.ts (gitignored, D-037)
server/
auth.ts # Passport.js wiring + session middleware adapter
db.ts # bun:sqlite schema + queries
transcribe.ts # server-side ASR endpoint (bridges the sidecar, M5)
live.ts # server-engine live mic over WebSocket (D-019, M7)
server-py/ # faster-whisper sidecar (Python, D-013; M5)
tests/ # bun test
(Current state: M0–M6 built — browser transcription with the curated model
catalog, WebNN→WebGPU→WASM auto-selection (D-017), model switching,
language/task options, the installable offline PWA (pwa/,
scripts/build.ts production build, dist/ serving in src/index.ts;
browser-level offline behavior pending the manual run in tests/offline.md),
M4 accounts (server/db.ts, server/auth.ts, /api/auth/*, AuthForms),
and the M5 server engine end to end — server-py/sidecar.py,
server/transcribe.ts (supervisor + /api/transcribe + /status),
lib/engine.ts (both engines, D-010), EngineToggle, and the catalog
mapping in lib/models.ts — with graceful degradation when
Python/faster-whisper is absent. M7 live microphone transcription is built
for both engines: browser (lib/live.ts, lib/mic.ts, LiveControls,
D-016) and server (server/live.ts, D-019); in-browser manual verification
of both is still pending per tests/live.md (no real browser in the dev
environment). See WorkBreakdown.md for the itemized checklist.)
4. Key flows
4.1 Browser transcription (anonymous, default)
- User picks a model in
ModelPicker(curated catalog fromlib/models.ts, with size estimates and a Cache-API-backed "downloaded" badge). Selection alone downloads nothing (D-020): the UI posts{type:"load", modelId, device}when a browser-engine transcription starts (file drop / live start) or when the user clicks the explicit Download button on the selected model; server-engine runs never post a load.deviceis a preference ("auto" | "webnn" | "webgpu" | "wasm", default"auto"). An explicitdtypeoverride is expressible in the message but normally omitted. - Worker resolves the device (D-004, D-017): for
"auto"it probesnavigator.ml(WebNN) first, thennavigator.gpu?.requestAdapter()(WebGPU), and picks in priority order WebNN → WebGPU → WASM; if pipeline creation on the picked device throws, it retries down the same chain (WASM always last). An explicit"webnn"/"webgpu"preference falls back straight to WASM. Two D-025 refinements: WebGPU is skipped up front when the dtype it would use needs fp16 but the adapter lacksshader-f16(Firefox), and a WASM fallback after a failed accelerator attempt does not run in-place — the failed attempt pinned ort's thread pool at 1 — but postsload-restart, and the UI respawns the worker with an explicitwasmload (transcribe posts wait on a ready gate inApp.tsx, so none are lost). It then resolves the catalog's dtype default for the device actually used and creates the Transformers.jsautomatic-speech-recognitionpipeline. Download progress events are forwarded to the UI; the terminal{status:"ready", device}message reports the backend actually selected, which the UI surfaces. Loading a different model/device disposes the old pipeline (pipeline.dispose()) before creating the new one. - Weights are fetched from the Hugging Face Hub once; Transformers.js stores them
in the browser Cache API, so subsequent loads (including offline) hit cache
(
lib/modelCache.tsreads that cache for the downloaded badges). - User drops an audio/video file. Main thread decodes it with
AudioContext.decodeAudioData, downmixes to mono, resamples to 16 kHz, transfers theFloat32Arrayto the worker. For multilingual models thetranscribemessage also carrieslanguage(or auto-detect) andtask(transcribe | translate — translate only for models withcanTranslate, D-WS4a). - Worker runs the pipeline (chunked, with timestamps), streaming partial results
back via
{type:"partial"}messages; final result as{type:"result"}.
4.1b Live microphone transcription (anonymous, browser engine — D-016)
LiveControlsstarts a session:getUserMedia→AudioContextat the device's native rate → AudioWorklet chunker (module from a Blob URL;ScriptProcessorNodefallback) posts ~100 ms Float32 frames; the main thread resamples each to 16 kHz (lib/mic.ts).LiveTranscriber(lib/live.ts, pure) feeds frames through an RMS energy gate: segments start at speech onset (short pre-roll), commit on trailing silence or at ~25 s; pure silence is never transcribed.- The current segment is re-transcribed through the normal inference worker
on a rolling basis (throttled, single-flight, latest-snapshot-wins) as
tentative text; a committed segment gets one final pass and freezes.
When inference runs slower than speech, queued committed segments are
batched into merged final passes (one Whisper window each, D-024) so the
backlog stays bounded.
requestIdon the protocol messages correlates responses. - Stopping flushes the segmenter, drains pending final passes (merged, with
a "N remaining segments" countdown in the UI), and yields a normal
transcript (text + absolute-timestamped chunks) in
TranscriptView. Everything runs on-device; works offline like the file flow. - Server-engine live (logged in, D-019). Same
LiveControlsUI, butApp.tsxopens aGET /api/transcribe/liveWebSocket instead of running the segmenter locally: the browser only captures/resamples mic audio (lib/mic.ts) and streams raw PCM frames over the socket; the same pureLiveTranscriber/segmenter (lib/live.ts) runs server-side (server/live.ts), writing each segment to a temp file and bridging it to the faster-whisper sidecar (D-013) the same wayPOST /api/transcribedoes.{type:"update"}frames carry committed/tentative text back;{type:"stop"}drains and closes with a{type:"result"}frame. Selected only when signed in with the server engine available (EngineToggle); anonymous/offline users always get the browser-engine path above.
4.2 Offline (PWA)
- Service worker (
pwa/sw.ts, hand-rolled — D-007) precaches the built app shell (hashed JS/CSS,/, worker bundle, manifest, icons,/fonts/*Inter subsets) on install intowhisper-shell-<version>, cache-first thereafter; unknown navigations fall back to the cached shell.scripts/build.tsinjects the precache list + a content-hash version intodist/sw.jsviapwa/precache.ts; the dev server injects an empty list ("dev"), making the dev shell network-first so HMR works. - onnxruntime's
.wasm/.mjsbinaries are served from our origin at/ort/*(never the jsdelivr CDN default) and runtime-cached cache-first intowhisper-runtime-<version>— they are required for offline inference but too large to precache eagerly. - Model weights live in the Cache API (
transformers-cache) via Transformers.js — independent of the SW, which never touches that cache (D-005)./api/*is network-only. - Updates:
pwa/register.tssurfaces a waiting SW as a toast; accepting postsSKIP_WAITING, activation drops oldwhisper-*caches, and the page reloads. - Result: with the app installed and at least one model previously loaded, the full
transcription flow works with no network and with our server down (manual
verification procedure:
tests/offline.md).
4.3 Server engine (logged-in only)
EngineToggleis visible only when a session exists.ServerEngine.transcribe()uploads the decoded (or original) audio toPOST /api/transcribewith the chosen model id.- Server inference runs on faster-whisper (CTranslate2) in a Python sidecar
(
server-py/) that Bun spawns and supervises (D-013; faster-whisper has no browser/WASM build, so it is server-only — the browser stays on Transformers.js).src/server/transcribe.tsbridges the sidecar's segment stream to the streamed JSON-lines HTTP response (D-011). The shared catalog maps model ids toSystran/faster-whisper-*CTranslate2 conversions. If the sidecar is absent, the server engine reports unavailable; the browser/anonymous flow is unaffected. - Both engines implement the same
Engineinterface insrc/lib/engine.ts, so the UI is engine-agnostic.
4.4 Auth
- Passport.js local strategy behind a thin adapter that bridges Bun's
Request/Responseto Passport's connect-style API (see Decisions.md D-008). - Sessions in SQLite; session id in an HttpOnly cookie.
- Anonymous users are never blocked from browser inference — auth only unlocks the server engine.
5. Invariants
- Privacy: browser-engine audio and transcripts never touch the network.
- Anonymous-first: every feature except the server engine works logged out.
- Offline-first: app shell + previously-downloaded models must work offline.
- One browser inference codebase: Transformers.js in the browser; the server engine runs faster-whisper (D-013). The model catalog stays shared across both.
- Worker isolation: inference never runs on the main thread.