From eb72a2e64644d4c8f63a0f7abd4f6b3657ca385e Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 9 Jul 2026 08:07:32 +0200 Subject: [PATCH] Agent completed work jubii --- Architecture.md | 131 ++++++++++++++ Decisions.md | 95 ++++++++++ WorkBreakdown.md | 101 +++++++++++ bun-env.d.ts | 19 ++ bun.lock | 184 +++++++++++++++++++ bunfig.toml | 2 + package.json | 21 +++ src/App.tsx | 162 +++++++++++++++++ src/components/FileDropzone.tsx | 68 +++++++ src/components/ModelLoadProgress.tsx | 49 +++++ src/components/TranscriptView.tsx | 74 ++++++++ src/frontend.tsx | 20 +++ src/index.css | 258 +++++++++++++++++++++++++++ src/index.html | 16 ++ src/index.ts | 43 +++++ src/lib/audio.ts | 120 +++++++++++++ src/lib/engine.ts | 11 ++ src/lib/models.ts | 20 +++ src/pwa/manifest.webmanifest | 7 + src/pwa/sw.ts | 10 ++ src/server/auth.ts | 11 ++ src/server/db.ts | 9 + src/server/transcribe.ts | 10 ++ src/worker/inference.worker.ts | 142 +++++++++++++++ src/worker/protocol.ts | 189 ++++++++++++++++++++ tests/audio.test.ts | 93 ++++++++++ tests/protocol.test.ts | 142 +++++++++++++++ tests/smoke.test.ts | 6 + tsconfig.json | 38 ++++ 29 files changed, 2051 insertions(+) create mode 100644 Architecture.md create mode 100644 Decisions.md create mode 100644 WorkBreakdown.md create mode 100644 bun-env.d.ts create mode 100644 bun.lock create mode 100644 bunfig.toml create mode 100644 package.json create mode 100644 src/App.tsx create mode 100644 src/components/FileDropzone.tsx create mode 100644 src/components/ModelLoadProgress.tsx create mode 100644 src/components/TranscriptView.tsx create mode 100644 src/frontend.tsx create mode 100644 src/index.css create mode 100644 src/index.html create mode 100644 src/index.ts create mode 100644 src/lib/audio.ts create mode 100644 src/lib/engine.ts create mode 100644 src/lib/models.ts create mode 100644 src/pwa/manifest.webmanifest create mode 100644 src/pwa/sw.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/db.ts create mode 100644 src/server/transcribe.ts create mode 100644 src/worker/inference.worker.ts create mode 100644 src/worker/protocol.ts create mode 100644 tests/audio.test.ts create mode 100644 tests/protocol.test.ts create mode 100644 tests/smoke.test.ts create mode 100644 tsconfig.json diff --git a/Architecture.md b/Architecture.md new file mode 100644 index 0000000..a948d7d --- /dev/null +++ b/Architecture.md @@ -0,0 +1,131 @@ +# Architecture + +> Companion to `CLAUDE.md` (product spec & tooling rules) and `Decisions.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](https://github.com/huggingface/transformers) +(`@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) │ +│ ├─ TranscriptView (streaming partial results) │ +│ └─ EngineToggle (device / server — logged-in only) │ +│ │ postMessage protocol │ +│ ▼ │ +│ Inference Web Worker │ +│ ├─ Transformers.js ASR pipeline (Whisper) │ +│ ├─ Device selection: 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: @huggingface/transformers (Node/Bun runtime) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## 2. Runtime & tooling + +- **Runtime / bundler / test runner:** Bun only (`bun --hot src/index.ts` for dev, + `bun build src/index.html` for production, `bun test`). No vite/webpack/jest. +- **Frontend:** React 19, mounted from `src/index.html` via Bun HTML imports. +- **Server:** `Bun.serve()` with the `routes` option. 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, FileDropzone, TranscriptView, EngineToggle, AuthForms + lib/ + audio.ts # decode + resample to 16 kHz mono Float32Array (Web Audio API) + models.ts # model catalog: ids, sizes, dtypes, languages + engine.ts # engine abstraction: BrowserEngine | ServerEngine + worker/ + inference.worker.ts # Transformers.js pipeline, WebGPU/WASM selection + protocol.ts # typed postMessage messages (shared with UI) + pwa/ + sw.ts # service worker (app-shell precache) + manifest.webmanifest + server/ + auth.ts # Passport.js wiring + session middleware adapter + db.ts # bun:sqlite schema + queries + transcribe.ts # server-side ASR endpoint +tests/ # bun test +``` + +(Current state: M0–M1 built — browser transcription MVP with one hardcoded model +(`onnx-community/whisper-tiny.en`, WASM). `lib/models.ts`, `lib/engine.ts`, +`pwa/`, and `server/` are TODO stubs for M2–M5; see `WorkBreakdown.md`.) + +## 4. Key flows + +### 4.1 Browser transcription (anonymous, default) +1. User picks a model in `ModelPicker`; UI posts `{type:"load", modelId, dtype, device}` + to the inference worker. +2. Worker creates the Transformers.js `automatic-speech-recognition` pipeline. + `device: "webgpu"` if `navigator.gpu` adapter is available, else `"wasm"`. + Download progress events are forwarded to the UI. +3. 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. +4. User drops an audio/video file. Main thread decodes it with + `AudioContext.decodeAudioData`, downmixes to mono, resamples to 16 kHz, + transfers the `Float32Array` to the worker. +5. Worker runs the pipeline (chunked, with timestamps), streaming partial results + back via `{type:"partial"}` messages; final result as `{type:"result"}`. + +### 4.2 Offline (PWA) +- Service worker precaches the built app shell (HTML/JS/CSS/icons) on install. +- Model weights live in the Cache API via Transformers.js — independent of the SW. +- 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. + +### 4.3 Server engine (logged-in only) +1. `EngineToggle` is visible only when a session exists. +2. `ServerEngine.transcribe()` uploads the decoded (or original) audio to + `POST /api/transcribe` with the chosen model id. +3. Server runs the same Transformers.js pipeline under Bun (ONNX Runtime, CPU), + streams progress/results back (chunked response or WebSocket — see Decisions.md + D-011 once settled). +4. Both engines implement the same `Engine` interface in `src/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`/`Response` to 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 inference codebase:** Transformers.js on both sides; model catalog shared. +- **Worker isolation:** inference never runs on the main thread. diff --git a/Decisions.md b/Decisions.md new file mode 100644 index 0000000..edd46f9 --- /dev/null +++ b/Decisions.md @@ -0,0 +1,95 @@ +# 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 (mandated by CLAUDE.md) +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. diff --git a/WorkBreakdown.md b/WorkBreakdown.md new file mode 100644 index 0000000..0aa4827 --- /dev/null +++ b/WorkBreakdown.md @@ -0,0 +1,101 @@ +# Work Breakdown + +Milestones are ordered so that **every milestone ends in a demoable, working app**. +Within a milestone, tasks are roughly in dependency order. Check items off as they +land; add follow-up tasks where they belong rather than in a separate backlog. + +Legend: `[ ]` todo · `[x]` done · `[~]` in progress + +--- + +## M0 — Scaffold cleanup & docs (small) +Current state is the raw `bun init` React template. + +- [x] `Architecture.md`, `Decisions.md`, `WorkBreakdown.md` created +- [x] Remove template cruft: `APITester.tsx`, hello routes in `src/index.ts`, + Bun/React logo branding in `App.tsx` / `index.html` +- [x] Add `@huggingface/transformers` dependency +- [x] Target directory layout from `Architecture.md` §3 (empty modules with TODOs are fine) +- [x] Smoke test in `tests/` proving `bun test` runs + +## M1 — Browser transcription MVP (core) +Anonymous user can transcribe an uploaded file with one hardcoded model. **This is the +riskiest milestone — do it first, learn from it.** + +- [x] `src/worker/protocol.ts` — typed messages: load / load-progress / transcribe / + partial / result / error +- [x] `src/worker/inference.worker.ts` — Transformers.js ASR pipeline + (`onnx-community/whisper-tiny.en` hardcoded, WASM only for now), chunked + with timestamps, partials streamed via `WhisperTextStreamer` +- [x] `src/lib/audio.ts` — decode upload → 16 kHz mono Float32Array (D-012) +- [x] `FileDropzone` component (drag-drop + file picker, audio/* and video/*) +- [x] `TranscriptView` component — streaming partials, final text, copy button +- [x] Model download progress UI (bytes / %, per-file) +- [x] Wire into `App.tsx`; delete remaining template UI +- [x] Unit tests for audio resampling and protocol handling + +Notes (M1): +- Bun 1.3 does not bundle `new Worker(new URL(...))` graphs (verified against + both `bun build` and the dev server), so `src/index.ts` bundles the worker + with `Bun.build` at startup and serves it at `/inference.worker.js`; the UI + spawns `new Worker("/inference.worker.js", { type: "module" })`. Revisit if + Bun gains native worker bundling. +- onnxruntime-web fetches its `.wasm` binaries from the jsdelivr CDN + (Transformers.js default). Fine for M1; M3 (offline) must serve/cache them + locally. + +## M2 — Model choice + WebGPU (core) +- [ ] `src/lib/models.ts` — curated catalog with sizes and dtype defaults (D-006) +- [ ] `ModelPicker` component — size estimates, English-only vs multilingual, + downloaded-state badge (query Cache API) +- [ ] WebGPU detection in worker, `device` selection with WASM fallback (D-004); + surface active backend in UI +- [ ] Handle model switching (dispose old pipeline, load new) +- [ ] Language / task options for multilingual models (transcribe vs translate) +- [ ] Verify quantized variants (q4/q8/fp16) on both backends; record working + combinations in `models.ts` + +## M3 — PWA & offline (core) +- [ ] `manifest.webmanifest` + icons; installability +- [ ] `src/pwa/sw.ts` — app-shell precache, cache-first static, network-only API (D-007) +- [ ] Build step: emit precache manifest from `bun build` output into the SW +- [ ] SW registration + update flow (new-version toast) +- [ ] Offline verification: airplane-mode transcription with a previously + downloaded model; document the manual test procedure in `tests/offline.md` +- [ ] "Available offline" indicator per model in `ModelPicker` + +## M4 — Accounts (Passport.js) +- [ ] `src/server/db.ts` — bun:sqlite schema: users, sessions (D-009) +- [ ] Connect-compat adapter + Passport local strategy in `src/server/auth.ts` (D-008) +- [ ] Routes: `POST /api/auth/register|login|logout`, `GET /api/auth/me` +- [ ] Password hashing via `Bun.password` (Argon2) +- [ ] Frontend: login/register forms, session-aware header, logged-out ⇒ no + behavioral change (anonymous-first invariant) +- [ ] Auth tests (register/login/logout, session expiry, wrong password) + +## M5 — Server engine + toggle +- [ ] Server-side pipeline in `src/server/transcribe.ts` (Transformers.js under Bun, + CPU; reuse the shared model catalog) +- [ ] `POST /api/transcribe` — auth-guarded, streamed JSON-lines response (D-011), + upload size limit, request abort handling +- [ ] `src/lib/engine.ts` — `Engine` interface; `BrowserEngine` (wraps worker) and + `ServerEngine` (wraps fetch stream) (D-010) +- [ ] `EngineToggle` component — visible only with a session; persists choice +- [ ] Server model caching/warm-up strategy (don't reload weights per request) +- [ ] Integration test: same fixture audio through both engines, transcripts agree + +## M6 — Hardening & polish +- [ ] Error taxonomy: unsupported file, out-of-memory (suggest smaller model), + WebGPU device-lost, network loss mid-download (resume or clear retry) +- [ ] Cancel in-flight transcription (both engines) +- [ ] Export transcript: .txt and .srt (timestamps already available) +- [ ] Storage management UI: list cached models, per-model delete +- [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript) +- [ ] Production build + `bun start` deployment check; README for deployers + +--- + +## Deliberately out of scope (for now) +Microphone/live transcription, arbitrary Hub model ids, OAuth providers, +multi-user transcript storage, whisper.cpp server backend, i18n of the UI. +Promote to a milestone via a new entry in `Decisions.md` first. diff --git a/bun-env.d.ts b/bun-env.d.ts new file mode 100644 index 0000000..3111ba5 --- /dev/null +++ b/bun-env.d.ts @@ -0,0 +1,19 @@ +// Generated by `bun init` + +declare module "*.svg" { + /** + * A path to the SVG file + */ + const path: `${string}.svg`; + export = path; +} + +declare module "*.css" {} + +declare module "*.module.css" { + /** + * A record of class names to their corresponding CSS module classes + */ + const classes: { readonly [key: string]: string }; + export = classes; +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5521797 --- /dev/null +++ b/bun.lock @@ -0,0 +1,184 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "bun-react-template", + "dependencies": { + "@huggingface/transformers": "^4.2.0", + "react": "^19", + "react-dom": "^19", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + }, + }, + }, + "packages": { + "@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], + + "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], + + "@huggingface/transformers": ["@huggingface/transformers@4.2.0", "", { "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", "onnxruntime-node": "1.24.3", "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c", "sharp": "^0.34.5" } }, "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], + + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node": ["detect-node@2.1.0", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="], + + "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "onnxruntime-common": ["onnxruntime-common@1.24.3", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="], + + "onnxruntime-node": ["onnxruntime-node@1.24.3", "", { "dependencies": { "adm-zip": "^0.5.16", "global-agent": "^3.0.0", "onnxruntime-common": "1.24.3" }, "os": [ "linux", "win32", "darwin", ] }, "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg=="], + + "onnxruntime-web": ["onnxruntime-web@1.26.0-dev.20260416-b7804b056c", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.24.0-dev.20251116-b39e144322", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw=="], + + "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], + + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], + + "roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "serialize-error": ["serialize-error@7.0.1", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "onnxruntime-web/onnxruntime-common": ["onnxruntime-common@1.24.0-dev.20251116-b39e144322", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9819bf6 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[serve.static] +env = "BUN_PUBLIC_*" \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..34e7fbc --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "au_whisper_web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --hot src/index.ts", + "build": "bun build src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'", + "start": "NODE_ENV=production bun src/index.ts" + }, + "dependencies": { + "@huggingface/transformers": "^4.2.0", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/bun": "latest" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..bead1a3 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from "react"; +import "./index.css"; + +import { FileDropzone } from "./components/FileDropzone"; +import { ModelLoadProgress } from "./components/ModelLoadProgress"; +import { TranscriptView } from "./components/TranscriptView"; +import { decodeAudioFile } from "./lib/audio"; +import { DEFAULT_DEVICE, DEFAULT_MODEL_ID } from "./lib/models"; +import { + applyLoadProgress, + initialLoadProgress, + isWorkerToUiMessage, + type TranscriptChunk, + type UiToWorkerMessage, +} from "./worker/protocol"; + +interface TranscriptResult { + text: string; + chunks: TranscriptChunk[]; +} + +export function App() { + const workerRef = useRef(null); + + const [busy, setBusy] = useState(false); + const [decoding, setDecoding] = useState(false); + const [loadStarted, setLoadStarted] = useState(false); + const [loadProgress, setLoadProgress] = useState(initialLoadProgress); + const [partialText, setPartialText] = useState(""); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + // Terminate the worker when the app unmounts (also keeps HMR tidy). + useEffect(() => { + return () => { + workerRef.current?.terminate(); + workerRef.current = null; + }; + }, []); + + function ensureWorker(): Worker { + if (workerRef.current) return workerRef.current; + // Inference always runs in a dedicated Web Worker (D-003), never on the + // main thread (Architecture.md §5). The worker script is bundled by the + // server at startup and served at a stable URL (see src/index.ts) because + // Bun's bundler does not follow `new Worker(new URL(...))` graphs yet. + const worker = new Worker("/inference.worker.js", { type: "module" }); + worker.addEventListener("message", (event: MessageEvent) => { + const message = event.data; + if (!isWorkerToUiMessage(message)) return; + switch (message.type) { + case "load-progress": + setLoadProgress((state) => applyLoadProgress(state, message)); + break; + case "partial": + setPartialText((text) => text + message.text); + break; + case "result": + setResult({ text: message.text, chunks: message.chunks }); + setBusy(false); + break; + case "error": + setError(message.message); + setBusy(false); + break; + } + }); + worker.addEventListener("error", (event) => { + setError(event.message || "The inference worker crashed unexpectedly."); + setBusy(false); + }); + workerRef.current = worker; + return worker; + } + + function postToWorker(worker: Worker, message: UiToWorkerMessage, transfer?: Transferable[]) { + worker.postMessage(message, transfer ?? []); + } + + async function handleFile(file: File) { + if (busy) return; + setBusy(true); + setError(null); + setPartialText(""); + setResult(null); + setFileName(file.name); + + // Kick off the (idempotent) model load immediately so the download runs + // while we decode the audio locally. + const worker = ensureWorker(); + postToWorker(worker, { + type: "load", + modelId: DEFAULT_MODEL_ID, + device: DEFAULT_DEVICE, + }); + setLoadStarted(true); + + setDecoding(true); + try { + // Decoded entirely on-device via Web Audio (D-012); the audio never + // touches the network (Architecture.md §5). + const audio = await decodeAudioFile(file); + // Transfer the buffer to the worker — zero copy. + postToWorker(worker, { type: "transcribe", audio }, [audio.buffer as ArrayBuffer]); + } catch (decodeError) { + setError(decodeError instanceof Error ? decodeError.message : String(decodeError)); + setBusy(false); + } finally { + setDecoding(false); + } + } + + const modelReady = loadProgress.ready; + const showModelProgress = loadStarted && !modelReady && !error; + const transcribing = busy && !decoding && modelReady; + + return ( +
+
+

Whisper Web

+

+ Speech-to-text that runs entirely in your browser. Your audio never + leaves this device. +

+
+ + + + {fileName && ( +

+ File: {fileName} + {decoding && " — decoding audio…"} +

+ )} + + {error && ( +

+ {error} +

+ )} + + {showModelProgress && ( + + )} + + + +
+

+ Model: {DEFAULT_MODEL_ID} · Engine: WASM (on-device) +

+
+
+ ); +} + +export default App; diff --git a/src/components/FileDropzone.tsx b/src/components/FileDropzone.tsx new file mode 100644 index 0000000..6411f2c --- /dev/null +++ b/src/components/FileDropzone.tsx @@ -0,0 +1,68 @@ +import { useRef, useState, type DragEvent, type KeyboardEvent } from "react"; + +interface FileDropzoneProps { + onFile: (file: File) => void; + disabled?: boolean; +} + +/** Drag-and-drop target + file picker for audio/video uploads. */ +export function FileDropzone({ onFile, disabled = false }: FileDropzoneProps) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + function submit(file: File | undefined | null) { + if (file && !disabled) onFile(file); + } + + function onDrop(event: DragEvent) { + event.preventDefault(); + setDragOver(false); + submit(event.dataTransfer.files.item(0)); + } + + function onDragOver(event: DragEvent) { + event.preventDefault(); // required to allow dropping + if (!disabled) setDragOver(true); + } + + function openPicker() { + if (!disabled) inputRef.current?.click(); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openPicker(); + } + } + + return ( +
setDragOver(false)} + > + { + submit(event.target.files?.item(0)); + event.target.value = ""; // allow re-selecting the same file + }} + /> +

+ {disabled ? "Working…" : "Drop an audio or video file here"} +

+

or click to choose a file

+
+ ); +} diff --git a/src/components/ModelLoadProgress.tsx b/src/components/ModelLoadProgress.tsx new file mode 100644 index 0000000..cb7c081 --- /dev/null +++ b/src/components/ModelLoadProgress.tsx @@ -0,0 +1,49 @@ +import type { LoadProgressState } from "../worker/protocol"; + +interface ModelLoadProgressProps { + modelId: string; + progress: LoadProgressState; +} + +function formatBytes(bytes: number): string { + if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`; + if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} kB`; + return `${bytes} B`; +} + +/** Per-file model download progress (bytes and %), driven by load-progress messages. */ +export function ModelLoadProgress({ modelId, progress }: ModelLoadProgressProps) { + const files = Object.values(progress.files); + return ( +
+

+ Downloading model {modelId} +

+

+ This happens once — the weights are cached in your browser afterwards. +

+ {files.length === 0 &&

Preparing…

} +
    + {files.map((file) => { + const percent = + file.done ? 100 : file.total > 0 ? (file.loaded / file.total) * 100 : 0; + return ( +
  • +
    + {file.file} + + {file.total > 0 + ? `${formatBytes(file.loaded)} / ${formatBytes(file.total)} (${percent.toFixed(0)}%)` + : file.done + ? "done" + : "…"} + +
    + +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/TranscriptView.tsx b/src/components/TranscriptView.tsx new file mode 100644 index 0000000..a3dc009 --- /dev/null +++ b/src/components/TranscriptView.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from "react"; +import type { TranscriptChunk } from "../worker/protocol"; + +interface TranscriptViewProps { + /** Text streamed so far (accumulated partial messages). */ + partialText: string; + /** Final transcript, once available. */ + result: { text: string; chunks: TranscriptChunk[] } | null; + /** True while a transcription is in flight. */ + transcribing: boolean; +} + +function formatTimestamp(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds - m * 60; + return `${String(m).padStart(2, "0")}:${s.toFixed(1).padStart(4, "0")}`; +} + +/** Streaming transcript: live partials while running, final text + copy button after. */ +export function TranscriptView({ partialText, result, transcribing }: TranscriptViewProps) { + const [copied, setCopied] = useState(false); + const text = result?.text ?? partialText; + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + if (!transcribing && !text) return null; + + async function copy() { + if (!text) return; + await navigator.clipboard.writeText(text); + setCopied(true); + } + + return ( +
+
+

{result ? "Transcript" : "Transcribing…"}

+ +
+

+ {text || "Listening for the first words…"} + {transcribing &&

+ {result && result.chunks.length > 0 && ( +
+ Timestamps ({result.chunks.length} segments) +
    + {result.chunks.map((chunk, i) => ( +
  1. + + {formatTimestamp(chunk.timestamp[0])} + {chunk.timestamp[1] !== null && + ` – ${formatTimestamp(chunk.timestamp[1])}`} + + {chunk.text} +
  2. + ))} +
+
+ )} +
+ ); +} diff --git a/src/frontend.tsx b/src/frontend.tsx new file mode 100644 index 0000000..9153d45 --- /dev/null +++ b/src/frontend.tsx @@ -0,0 +1,20 @@ +/** + * This file is the entry point for the React app, it sets up the root + * element and renders the App component to the DOM. + * + * It is included in `src/index.html`. + */ + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.tsx"; + +const elem = document.getElementById("root")!; +const app = ( + + + +); + +// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data +(import.meta.hot.data.root ??= createRoot(elem)).render(app); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..e927f22 --- /dev/null +++ b/src/index.css @@ -0,0 +1,258 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + color-scheme: light dark; + color: #e8e8e8; + background-color: #16181d; + --panel: #1f232b; + --border: #343a46; + --accent: #7aa2f7; + --muted: #9aa3b2; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +.app { + max-width: 720px; + margin: 0 auto; + padding: 2.5rem 1.25rem 3rem; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.app-header h1 { + margin: 0 0 0.25rem; + font-size: 2.2rem; + line-height: 1.15; +} + +.tagline { + margin: 0; + color: var(--muted); +} + +/* --- FileDropzone --- */ + +.dropzone { + border: 2px dashed var(--border); + border-radius: 14px; + background: var(--panel); + padding: 2.5rem 1.5rem; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background-color 0.15s; +} + +.dropzone:hover, +.dropzone:focus-visible { + border-color: var(--accent); + outline: none; +} + +.dropzone-active { + border-color: var(--accent); + background: #232a38; +} + +.dropzone-disabled { + cursor: default; + opacity: 0.6; +} + +.dropzone-title { + margin: 0 0 0.25rem; + font-size: 1.1rem; + font-weight: 600; +} + +.dropzone-hint { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.file-name { + margin: 0; + color: var(--muted); + font-size: 0.95rem; +} + +.error { + margin: 0; + padding: 0.75rem 1rem; + border-radius: 10px; + border: 1px solid #8b3a3a; + background: #2a1c1e; + color: #f2b8b5; +} + +/* --- ModelLoadProgress --- */ + +.model-progress { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem 1.25rem; +} + +.model-progress h2 { + margin: 0 0 0.25rem; + font-size: 1rem; +} + +.model-progress-hint { + margin: 0 0 0.75rem; + color: var(--muted); + font-size: 0.85rem; +} + +.model-progress ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.model-progress-label { + display: flex; + justify-content: space-between; + gap: 1rem; + font-size: 0.85rem; + margin-bottom: 0.2rem; +} + +.model-progress-name { + font-family: ui-monospace, monospace; + overflow-wrap: anywhere; +} + +.model-progress-bytes { + color: var(--muted); + white-space: nowrap; +} + +.model-progress progress { + width: 100%; + height: 6px; + accent-color: var(--accent); +} + +/* --- TranscriptView --- */ + +.transcript { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 1rem 1.25rem; +} + +.transcript-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.transcript-header h2 { + margin: 0; + font-size: 1rem; +} + +.copy-button { + border: 1px solid var(--border); + background: transparent; + color: inherit; + border-radius: 8px; + padding: 0.3rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; + transition: border-color 0.15s; +} + +.copy-button:hover:enabled { + border-color: var(--accent); +} + +.copy-button:disabled { + opacity: 0.5; + cursor: default; +} + +.transcript-text { + margin: 0.75rem 0 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.cursor { + display: inline-block; + width: 0.55em; + height: 1em; + margin-left: 2px; + vertical-align: text-bottom; + background: var(--accent); + animation: blink 1s steps(2, start) infinite; +} + +@keyframes blink { + to { + visibility: hidden; + } +} + +.transcript-chunks { + margin-top: 1rem; + font-size: 0.9rem; +} + +.transcript-chunks summary { + cursor: pointer; + color: var(--muted); +} + +.transcript-chunks ol { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.chunk-time { + font-family: ui-monospace, monospace; + color: var(--accent); + margin-right: 0.75rem; + white-space: nowrap; +} + +.app-footer { + color: var(--muted); + font-size: 0.85rem; +} + +.app-footer p { + margin: 0; +} + +code { + font-family: ui-monospace, monospace; + background: #262b34; + padding: 0.1em 0.35em; + border-radius: 5px; +} + +@media (prefers-reduced-motion) { + *, + ::before, + ::after { + animation: none !important; + } +} diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..d73660c --- /dev/null +++ b/src/index.html @@ -0,0 +1,16 @@ + + + + + + + Whisper Web — on-device speech-to-text + + +
+ + + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c62dd25 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,43 @@ +import { serve } from "bun"; +import index from "./index.html"; + +// Bun's bundler does not currently follow `new Worker(...)` graphs (verified +// with Bun 1.3: both `bun build` and the dev server leave the call untouched), +// so the inference worker is bundled here at startup and served from a stable +// URL. The UI spawns it with `new Worker("/inference.worker.js")`. +const workerBuild = await Bun.build({ + entrypoints: [ + Bun.fileURLToPath(new URL("./worker/inference.worker.ts", import.meta.url)), + ], + target: "browser", + format: "esm", + minify: process.env.NODE_ENV === "production", +}); +const workerArtifact = workerBuild.outputs[0]; +if (!workerArtifact) { + throw new Error("Failed to bundle src/worker/inference.worker.ts"); +} +const workerScript = await workerArtifact.text(); + +// Minimal Bun.serve entry (Architecture.md §2). API routes (auth, server +// transcription) arrive in M4/M5 — see src/server/. +const server = serve({ + routes: { + "/inference.worker.js": new Response(workerScript, { + headers: { "Content-Type": "text/javascript; charset=utf-8" }, + }), + + // Serve the bundled React app for all other routes. + "/*": index, + }, + + development: process.env.NODE_ENV !== "production" && { + // Enable browser hot reloading in development + hmr: true, + + // Echo console logs from the browser to the server + console: true, + }, +}); + +console.log(`Server running at ${server.url}`); diff --git a/src/lib/audio.ts b/src/lib/audio.ts new file mode 100644 index 0000000..1f85a02 --- /dev/null +++ b/src/lib/audio.ts @@ -0,0 +1,120 @@ +/** + * Audio decoding for the browser engine (Decisions.md D-012). + * + * Uploaded audio/video File → `AudioContext.decodeAudioData` → mono downmix → + * resample to 16 kHz via `OfflineAudioContext` → `Float32Array` (transferred + * to the inference worker, zero-copy). + * + * Browser-engine audio never touches the network (Architecture.md §5): all + * work happens locally with Web Audio APIs. + * + * `downmixToMono` / `resampleLinear` are pure and unit-testable under + * `bun test`; the browser path uses `OfflineAudioContext` (higher-quality + * resampler) and only needs the pure helpers as a documented fallback. + * + * No ffmpeg.wasm so far — `decodeAudioData` has covered every container we + * have tried. Note here if we hit a real gap (per D-012). + */ + +/** Whisper models consume 16 kHz mono PCM. */ +export const TARGET_SAMPLE_RATE = 16_000; + +/** + * Average N channels into one. Pure — safe to call anywhere. + * Channels of unequal length are truncated to the shortest. + */ +export function downmixToMono(channels: readonly Float32Array[]): Float32Array { + if (channels.length === 0) { + throw new Error("downmixToMono: need at least one channel"); + } + if (channels.length === 1) { + return Float32Array.from(channels[0]!); + } + const length = Math.min(...channels.map((c) => c.length)); + const out = new Float32Array(length); + for (let i = 0; i < length; i++) { + let sum = 0; + for (const channel of channels) { + sum += channel[i]!; + } + out[i] = sum / channels.length; + } + return out; +} + +/** + * Linear-interpolation resampler. Pure — safe to call anywhere. + * The browser path prefers `OfflineAudioContext` (D-012); this exists as a + * testable reference implementation and non-browser fallback. + */ +export function resampleLinear( + input: Float32Array, + fromRate: number, + toRate: number, +): Float32Array { + if (!(fromRate > 0) || !(toRate > 0)) { + throw new Error("resampleLinear: sample rates must be positive"); + } + if (fromRate === toRate) { + return Float32Array.from(input); + } + if (input.length === 0) { + return new Float32Array(0); + } + const outLength = Math.max(1, Math.round((input.length * toRate) / fromRate)); + const out = new Float32Array(outLength); + const step = fromRate / toRate; + const last = input.length - 1; + for (let i = 0; i < outLength; i++) { + const position = Math.min(i * step, last); + const index = Math.floor(position); + const nextIndex = Math.min(index + 1, last); + const fraction = position - index; + out[i] = input[index]! * (1 - fraction) + input[nextIndex]! * fraction; + } + return out; +} + +/** + * Downmix + resample a decoded `AudioBuffer` to 16 kHz mono using + * `OfflineAudioContext` (D-012). Browser-only. + */ +export async function audioBufferToModelInput( + buffer: AudioBuffer, +): Promise { + if (buffer.sampleRate === TARGET_SAMPLE_RATE && buffer.numberOfChannels === 1) { + return buffer.getChannelData(0); + } + const length = Math.max(1, Math.ceil(buffer.duration * TARGET_SAMPLE_RATE)); + // Single-channel destination: the OfflineAudioContext performs the mono + // downmix and the (windowed-sinc quality) resample in one render pass. + const offline = new OfflineAudioContext(1, length, TARGET_SAMPLE_RATE); + const source = offline.createBufferSource(); + source.buffer = buffer; + source.connect(offline.destination); + source.start(0); + const rendered = await offline.startRendering(); + return rendered.getChannelData(0); +} + +/** + * Decode an uploaded audio or video file to 16 kHz mono `Float32Array`, + * ready to transfer to the inference worker. Browser-only (Web Audio API). + */ +export async function decodeAudioFile(file: Blob): Promise { + const bytes = await file.arrayBuffer(); + // decodeAudioData handles most audio containers and audio-in-video (D-012). + // A suspended AudioContext (no user gesture) can still decode. + const context = new AudioContext(); + try { + const decoded = await context.decodeAudioData(bytes); + return await audioBufferToModelInput(decoded); + } catch (error) { + throw new Error( + "Could not decode this file. Make sure it is an audio or video file " + + `in a format your browser supports. (${error instanceof Error ? error.message : String(error)})`, + ); + } finally { + void context.close(); + } +} diff --git a/src/lib/engine.ts b/src/lib/engine.ts new file mode 100644 index 0000000..34f1a3e --- /dev/null +++ b/src/lib/engine.ts @@ -0,0 +1,11 @@ +/** + * Engine abstraction (Decisions.md D-010). + * + * TODO(M5): define the `Engine` interface — `{ load(model), transcribe(audio), + * onProgress }` — plus `BrowserEngine` (wraps the inference worker) and + * `ServerEngine` (wraps the streamed `POST /api/transcribe` fetch, D-011). + * + * Until then the UI talks to the worker directly via src/worker/protocol.ts. + */ + +export {}; diff --git a/src/lib/models.ts b/src/lib/models.ts new file mode 100644 index 0000000..3595548 --- /dev/null +++ b/src/lib/models.ts @@ -0,0 +1,20 @@ +/** + * Model catalog (Decisions.md D-006). + * + * M1 ships a single hardcoded model. The curated catalog — tiny → large, + * English-only vs multilingual, dtype defaults per device, size estimates — + * lands in M2. + * + * TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006). + */ + +import type { InferenceDevice } from "../worker/protocol"; + +/** + * The one model M1 supports. `onnx-community` is the recommended org for + * Transformers.js v3+ ONNX conversions. + */ +export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en"; + +/** M1 runs WASM only; WebGPU detection arrives in M2 (D-004). */ +export const DEFAULT_DEVICE: InferenceDevice = "wasm"; diff --git a/src/pwa/manifest.webmanifest b/src/pwa/manifest.webmanifest new file mode 100644 index 0000000..776cfc6 --- /dev/null +++ b/src/pwa/manifest.webmanifest @@ -0,0 +1,7 @@ +{ + "$comment": "TODO(M3): finalize PWA manifest — icons, theme colors, installability (D-007).", + "name": "Whisper Web", + "short_name": "Whisper", + "start_url": "/", + "display": "standalone" +} diff --git a/src/pwa/sw.ts b/src/pwa/sw.ts new file mode 100644 index 0000000..bdf340e --- /dev/null +++ b/src/pwa/sw.ts @@ -0,0 +1,10 @@ +/** + * Service worker (Decisions.md D-007) — TODO(M3). + * + * Plan: precache the built app shell (hashed assets emitted by `bun build` + * into a generated manifest), cache-first for static assets, network-only for + * `/api/*`. Model weights are NOT cached here — Transformers.js keeps them in + * the browser Cache API itself (D-005). + */ + +export {}; diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..d6b6f03 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,11 @@ +/** + * Auth — TODO(M4). + * + * Passport.js local strategy behind a connect-compat adapter over Bun.serve + * (Decisions.md D-008): wrap Bun's Request in a minimal req/res shim, run the + * passport authenticate chain, translate back to a Response. Sessions in + * bun:sqlite with HttpOnly cookies (D-009). Routes: + * POST /api/auth/register|login|logout, GET /api/auth/me. + */ + +export {}; diff --git a/src/server/db.ts b/src/server/db.ts new file mode 100644 index 0000000..831c07f --- /dev/null +++ b/src/server/db.ts @@ -0,0 +1,9 @@ +/** + * Database — TODO(M4). + * + * bun:sqlite (Decisions.md D-009). Tables: users (id, username, password_hash + * via Bun.password Argon2), sessions (id, user_id, expires_at). DB path from + * env, default ./data/app.db. + */ + +export {}; diff --git a/src/server/transcribe.ts b/src/server/transcribe.ts new file mode 100644 index 0000000..7812464 --- /dev/null +++ b/src/server/transcribe.ts @@ -0,0 +1,10 @@ +/** + * Server-side transcription engine — TODO(M5). + * + * POST /api/transcribe: auth-guarded, runs the same Transformers.js pipeline + * under Bun (ONNX Runtime, CPU), streams progress + partial transcripts as a + * chunked JSON-lines response (Decisions.md D-011). Reuses the shared model + * catalog in src/lib/models.ts; keep pipelines warm across requests. + */ + +export {}; diff --git a/src/worker/inference.worker.ts b/src/worker/inference.worker.ts new file mode 100644 index 0000000..ac1948a --- /dev/null +++ b/src/worker/inference.worker.ts @@ -0,0 +1,142 @@ +/** + * Inference Web Worker (Decisions.md D-003 — inference never runs on the main + * thread). Hosts the Transformers.js `automatic-speech-recognition` pipeline. + * + * M1: hardcoded-model, WASM-only. WebGPU detection + fallback lands in M2 + * (D-004); model switching/disposal also lands in M2. + */ + +import { + pipeline, + WhisperTextStreamer, + type AutomaticSpeechRecognitionPipeline, + type ProgressInfo, +} from "@huggingface/transformers"; +import { + isUiToWorkerMessage, + type LoadMessage, + type ResultMessage, + type TranscriptChunk, + type WorkerToUiMessage, +} from "./protocol"; + +// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use. +interface DedicatedWorkerScope { + postMessage(message: WorkerToUiMessage): void; + addEventListener( + type: "message", + listener: (event: MessageEvent) => void, + ): void; +} +const scope = self as unknown as DedicatedWorkerScope; + +function post(message: WorkerToUiMessage): void { + scope.postMessage(message); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Forward Transformers.js progress events to the UI as load-progress messages. */ +function forwardProgress(info: ProgressInfo): void { + switch (info.status) { + case "initiate": + case "download": + case "done": + post({ type: "load-progress", status: info.status, file: info.file }); + break; + case "progress": + post({ + type: "load-progress", + status: "progress", + file: info.file, + loaded: info.loaded, + total: info.total, + progress: info.progress, + }); + break; + case "ready": + post({ type: "load-progress", status: "ready" }); + break; + default: + // e.g. "progress_total" — the UI derives totals per-file itself. + break; + } +} + +let asrPromise: Promise | null = null; + +function load(message: LoadMessage): Promise { + // Idempotent for M1's single hardcoded model: repeated "load" messages + // reuse the in-flight/completed pipeline. + asrPromise ??= pipeline("automatic-speech-recognition", message.modelId, { + device: message.device, + progress_callback: forwardProgress, + }); + return asrPromise; +} + +async function transcribe(audio: Float32Array): Promise { + if (asrPromise === null) { + post({ + type: "error", + stage: "transcribe", + message: "No model loaded — send a \"load\" message first.", + }); + return; + } + const asr = await asrPromise; + + const streamer = new WhisperTextStreamer( + // The ASR pipeline for Whisper models always carries a WhisperTokenizer. + asr.tokenizer as ConstructorParameters[0], + { + skip_prompt: true, + callback_function: (text: string) => post({ type: "partial", text }), + }, + ); + + const output = await asr(audio, { + chunk_length_s: 30, + stride_length_s: 5, + return_timestamps: true, + streamer, + }); + + const single = (Array.isArray(output) ? output[0] : output) as { + text?: string; + chunks?: TranscriptChunk[]; + }; + const result: ResultMessage = { + type: "result", + text: (single.text ?? "").trim(), + chunks: single.chunks ?? [], + }; + post(result); +} + +scope.addEventListener("message", (event) => { + const data = event.data; + if (!isUiToWorkerMessage(data)) { + post({ + type: "error", + stage: "transcribe", + message: "Malformed message sent to inference worker.", + }); + return; + } + switch (data.type) { + case "load": + load(data).catch((error: unknown) => { + asrPromise = null; // allow retry after a failed download + post({ type: "error", stage: "load", message: errorMessage(error) }); + }); + break; + case "transcribe": + transcribe(data.audio).catch((error: unknown) => { + post({ type: "error", stage: "transcribe", message: errorMessage(error) }); + }); + break; + } +}); diff --git a/src/worker/protocol.ts b/src/worker/protocol.ts new file mode 100644 index 0000000..004ab46 --- /dev/null +++ b/src/worker/protocol.ts @@ -0,0 +1,189 @@ +/** + * Typed postMessage protocol between the UI and the inference Web Worker + * (Decisions.md D-003). Shared by `src/worker/inference.worker.ts` and the UI. + * + * Everything in this file must stay free of browser/worker globals so it can + * be unit-tested with `bun test`. + */ + +/** Devices supported by the worker. M1 is WASM-only; M2 adds "webgpu" (D-004). */ +export type InferenceDevice = "wasm"; + +// --------------------------------------------------------------------------- +// UI → worker +// --------------------------------------------------------------------------- + +export interface LoadMessage { + type: "load"; + /** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */ + modelId: string; + device: InferenceDevice; +} + +export interface TranscribeMessage { + type: "transcribe"; + /** + * Mono PCM at 16 kHz (see src/lib/audio.ts). The underlying ArrayBuffer + * should be passed as a transferable for zero-copy handoff. + */ + audio: Float32Array; +} + +export type UiToWorkerMessage = LoadMessage | TranscribeMessage; + +// --------------------------------------------------------------------------- +// Worker → UI +// --------------------------------------------------------------------------- + +/** + * Model download / initialization progress. Mirrors the Transformers.js + * `progress_callback` events we care about; `status: "ready"` marks the end + * of a successful load. + */ +export type LoadProgressMessage = + | { type: "load-progress"; status: "initiate"; file: string } + | { type: "load-progress"; status: "download"; file: string } + | { + type: "load-progress"; + status: "progress"; + file: string; + /** Bytes downloaded so far. */ + loaded: number; + /** Total bytes for this file. */ + total: number; + /** Percentage in [0, 100]. */ + progress: number; + } + | { type: "load-progress"; status: "done"; file: string } + | { type: "load-progress"; status: "ready" }; + +/** Incremental transcript fragment — append to the text streamed so far. */ +export interface PartialMessage { + type: "partial"; + text: string; +} + +/** One timestamped segment of the final transcript. */ +export interface TranscriptChunk { + text: string; + /** [start, end] in seconds; end may be null if Whisper never closed the segment. */ + timestamp: [number, number | null]; +} + +export interface ResultMessage { + type: "result"; + text: string; + chunks: TranscriptChunk[]; +} + +export interface ErrorMessage { + type: "error"; + stage: "load" | "transcribe"; + message: string; +} + +export type WorkerToUiMessage = + | LoadProgressMessage + | PartialMessage + | ResultMessage + | ErrorMessage; + +// --------------------------------------------------------------------------- +// Runtime guards (postMessage data arrives untyped) +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isUiToWorkerMessage(value: unknown): value is UiToWorkerMessage { + if (!isRecord(value)) return false; + switch (value.type) { + case "load": + return typeof value.modelId === "string" && value.device === "wasm"; + case "transcribe": + return value.audio instanceof Float32Array; + default: + return false; + } +} + +export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage { + if (!isRecord(value)) return false; + switch (value.type) { + case "load-progress": + switch (value.status) { + case "initiate": + case "download": + case "done": + return typeof value.file === "string"; + case "progress": + return ( + typeof value.file === "string" && + typeof value.loaded === "number" && + typeof value.total === "number" && + typeof value.progress === "number" + ); + case "ready": + return true; + default: + return false; + } + case "partial": + return typeof value.text === "string"; + case "result": + return typeof value.text === "string" && Array.isArray(value.chunks); + case "error": + return ( + typeof value.message === "string" && + (value.stage === "load" || value.stage === "transcribe") + ); + default: + return false; + } +} + +// --------------------------------------------------------------------------- +// Load-progress accumulation (pure; drives the download progress UI) +// --------------------------------------------------------------------------- + +export interface FileProgress { + file: string; + loaded: number; + total: number; + done: boolean; +} + +export interface LoadProgressState { + /** Per-file progress, keyed by file name, in first-seen order. */ + files: Record; + /** True once the pipeline reported ready. */ + ready: boolean; +} + +export const initialLoadProgress: LoadProgressState = { files: {}, ready: false }; + +/** Fold one load-progress message into the accumulated state. Pure. */ +export function applyLoadProgress( + state: LoadProgressState, + message: LoadProgressMessage, +): LoadProgressState { + if (message.status === "ready") { + return { ...state, ready: true }; + } + const previous = state.files[message.file]; + const next: FileProgress = { + file: message.file, + loaded: previous?.loaded ?? 0, + total: previous?.total ?? 0, + done: previous?.done ?? false, + }; + if (message.status === "progress") { + next.loaded = message.loaded; + next.total = message.total; + } else if (message.status === "done") { + next.done = true; + if (next.total > 0) next.loaded = next.total; + } + return { ...state, files: { ...state.files, [message.file]: next } }; +} diff --git a/tests/audio.test.ts b/tests/audio.test.ts new file mode 100644 index 0000000..6190de1 --- /dev/null +++ b/tests/audio.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect } from "bun:test"; +import { + TARGET_SAMPLE_RATE, + downmixToMono, + resampleLinear, +} from "../src/lib/audio"; + +// Pure helpers only — no browser APIs, no network, no model weights. + +describe("downmixToMono", () => { + test("throws on zero channels", () => { + expect(() => downmixToMono([])).toThrow(); + }); + + test("single channel is copied, not aliased", () => { + const channel = Float32Array.from([0.1, -0.5, 1]); + const mono = downmixToMono([channel]); + expect(Array.from(mono)).toEqual(Array.from(channel)); + mono[0] = 42; + expect(channel[0]).toBeCloseTo(0.1); + }); + + test("averages stereo channels", () => { + const left = Float32Array.from([1, 0, -1, 0.5]); + const right = Float32Array.from([0, 0, 1, 0.5]); + const mono = downmixToMono([left, right]); + expect(Array.from(mono)).toEqual([0.5, 0, 0, 0.5]); + }); + + test("truncates to the shortest channel", () => { + const a = Float32Array.from([1, 1, 1]); + const b = Float32Array.from([1, 1]); + expect(downmixToMono([a, b]).length).toBe(2); + }); +}); + +describe("resampleLinear", () => { + test("rejects non-positive rates", () => { + const input = new Float32Array(4); + expect(() => resampleLinear(input, 0, 16_000)).toThrow(); + expect(() => resampleLinear(input, 44_100, -1)).toThrow(); + }); + + test("identity when rates match (copy, not alias)", () => { + const input = Float32Array.from([0.25, -0.25]); + const out = resampleLinear(input, 16_000, 16_000); + expect(Array.from(out)).toEqual([0.25, -0.25]); + out[0] = 9; + expect(input[0]).toBeCloseTo(0.25); + }); + + test("empty input stays empty", () => { + expect(resampleLinear(new Float32Array(0), 44_100, 16_000).length).toBe(0); + }); + + test("output length scales with the rate ratio", () => { + const oneSecondAt48k = new Float32Array(48_000); + const out = resampleLinear(oneSecondAt48k, 48_000, TARGET_SAMPLE_RATE); + expect(out.length).toBe(TARGET_SAMPLE_RATE); + }); + + test("preserves a constant (DC) signal", () => { + const input = new Float32Array(1000).fill(0.7); + const out = resampleLinear(input, 44_100, 16_000); + for (const sample of out) { + expect(sample).toBeCloseTo(0.7, 5); + } + }); + + test("downsampling a linear ramp stays a linear ramp", () => { + const n = 800; + const ramp = new Float32Array(n); + for (let i = 0; i < n; i++) ramp[i] = i / (n - 1); + const out = resampleLinear(ramp, 32_000, 16_000); + // Linear interpolation reproduces a linear function exactly at every + // sampled position: out[i] ≈ (i * step) / (n - 1). + const step = 32_000 / 16_000; + for (let i = 0; i < out.length; i++) { + const expected = Math.min((i * step) / (n - 1), 1); + expect(out[i]!).toBeCloseTo(expected, 5); + } + }); + + test("upsampling interpolates between neighbours", () => { + const input = Float32Array.from([0, 1]); + const out = resampleLinear(input, 1, 2); + expect(out.length).toBe(4); + expect(out[0]).toBeCloseTo(0); + expect(out[1]).toBeCloseTo(0.5); + expect(out[2]).toBeCloseTo(1); // clamped at the last sample + expect(out[3]).toBeCloseTo(1); + }); +}); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts new file mode 100644 index 0000000..8945998 --- /dev/null +++ b/tests/protocol.test.ts @@ -0,0 +1,142 @@ +import { describe, test, expect } from "bun:test"; +import { + applyLoadProgress, + initialLoadProgress, + isUiToWorkerMessage, + isWorkerToUiMessage, + type LoadProgressMessage, + type UiToWorkerMessage, + type WorkerToUiMessage, +} from "../src/worker/protocol"; + +// Protocol handling only — no worker, no network, no model weights. + +describe("isUiToWorkerMessage", () => { + test("accepts valid load and transcribe messages", () => { + const load: UiToWorkerMessage = { + type: "load", + modelId: "onnx-community/whisper-tiny.en", + device: "wasm", + }; + const transcribe: UiToWorkerMessage = { + type: "transcribe", + audio: new Float32Array(8), + }; + expect(isUiToWorkerMessage(load)).toBe(true); + expect(isUiToWorkerMessage(transcribe)).toBe(true); + }); + + test("rejects malformed messages", () => { + expect(isUiToWorkerMessage(null)).toBe(false); + expect(isUiToWorkerMessage("load")).toBe(false); + expect(isUiToWorkerMessage({ type: "load" })).toBe(false); + expect(isUiToWorkerMessage({ type: "load", modelId: "x", device: "cuda" })).toBe(false); + expect(isUiToWorkerMessage({ type: "transcribe", audio: [1, 2, 3] })).toBe(false); + expect(isUiToWorkerMessage({ type: "result", text: "", chunks: [] })).toBe(false); + }); +}); + +describe("isWorkerToUiMessage", () => { + test("accepts every worker → UI message shape", () => { + const messages: WorkerToUiMessage[] = [ + { type: "load-progress", status: "initiate", file: "model.onnx" }, + { type: "load-progress", status: "download", file: "model.onnx" }, + { + type: "load-progress", + status: "progress", + file: "model.onnx", + loaded: 10, + total: 100, + progress: 10, + }, + { type: "load-progress", status: "done", file: "model.onnx" }, + { type: "load-progress", status: "ready" }, + { type: "partial", text: " hello" }, + { type: "result", text: "hello world", chunks: [{ text: "hello world", timestamp: [0, 1.2] }] }, + { type: "error", stage: "load", message: "boom" }, + ]; + for (const message of messages) { + expect(isWorkerToUiMessage(message)).toBe(true); + } + }); + + test("rejects malformed messages", () => { + expect(isWorkerToUiMessage(undefined)).toBe(false); + expect(isWorkerToUiMessage({})).toBe(false); + expect(isWorkerToUiMessage({ type: "load-progress", status: "progress", file: "f" })).toBe(false); + expect(isWorkerToUiMessage({ type: "partial" })).toBe(false); + expect(isWorkerToUiMessage({ type: "result", text: "x", chunks: "nope" })).toBe(false); + expect(isWorkerToUiMessage({ type: "error", stage: "decode", message: "m" })).toBe(false); + expect(isWorkerToUiMessage({ type: "load", modelId: "x", device: "wasm" })).toBe(false); + }); +}); + +describe("applyLoadProgress", () => { + test("tracks per-file byte progress without mutating previous state", () => { + const p1: LoadProgressMessage = { type: "load-progress", status: "initiate", file: "a.onnx" }; + const p2: LoadProgressMessage = { + type: "load-progress", + status: "progress", + file: "a.onnx", + loaded: 50, + total: 200, + progress: 25, + }; + + const s1 = applyLoadProgress(initialLoadProgress, p1); + const s2 = applyLoadProgress(s1, p2); + + expect(initialLoadProgress.files).toEqual({}); + expect(s1.files["a.onnx"]).toEqual({ file: "a.onnx", loaded: 0, total: 0, done: false }); + expect(s1.files["a.onnx"]!.done).toBe(false); // s1 untouched by s2 + expect(s2.files["a.onnx"]).toEqual({ file: "a.onnx", loaded: 50, total: 200, done: false }); + expect(s2.ready).toBe(false); + }); + + test("tracks multiple files independently", () => { + let state = initialLoadProgress; + state = applyLoadProgress(state, { + type: "load-progress", + status: "progress", + file: "a.onnx", + loaded: 10, + total: 100, + progress: 10, + }); + state = applyLoadProgress(state, { + type: "load-progress", + status: "progress", + file: "b.json", + loaded: 5, + total: 5, + progress: 100, + }); + expect(Object.keys(state.files)).toEqual(["a.onnx", "b.json"]); + expect(state.files["a.onnx"]!.loaded).toBe(10); + expect(state.files["b.json"]!.total).toBe(5); + }); + + test("done snaps a file's loaded bytes to its total", () => { + let state = applyLoadProgress(initialLoadProgress, { + type: "load-progress", + status: "progress", + file: "a.onnx", + loaded: 199, + total: 200, + progress: 99.5, + }); + state = applyLoadProgress(state, { type: "load-progress", status: "done", file: "a.onnx" }); + expect(state.files["a.onnx"]).toEqual({ file: "a.onnx", loaded: 200, total: 200, done: true }); + }); + + test("ready flips the ready flag and keeps file state", () => { + let state = applyLoadProgress(initialLoadProgress, { + type: "load-progress", + status: "done", + file: "a.onnx", + }); + state = applyLoadProgress(state, { type: "load-progress", status: "ready" }); + expect(state.ready).toBe(true); + expect(state.files["a.onnx"]!.done).toBe(true); + }); +}); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts new file mode 100644 index 0000000..af3799b --- /dev/null +++ b/tests/smoke.test.ts @@ -0,0 +1,6 @@ +import { test, expect } from "bun:test"; + +// M0 smoke test: proves `bun test` runs in this repo. +test("bun test runs", () => { + expect(1 + 1).toBe(2); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..81e3600 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun", "react"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "paths": { + "@/*": ["./src/*"] + }, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + + "exclude": ["dist", + "node_modules" + ] +}