Agent completed work jubii
This commit is contained in:
+131
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Vendored
+19
@@ -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;
|
||||
}
|
||||
@@ -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/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@huggingface/jinja": ["@huggingface/[email protected]", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
|
||||
|
||||
"@huggingface/tokenizers": ["@huggingface/[email protected]", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
|
||||
|
||||
"@huggingface/transformers": ["@huggingface/[email protected]", "", { "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/[email protected]", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/[email protected]", "", { "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/[email protected]", "", { "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/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/[email protected]", "", { "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/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/[email protected]", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/[email protected]", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/[email protected]", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/[email protected]", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/[email protected]", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/[email protected]", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/[email protected]", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/[email protected]", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/[email protected]", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/[email protected]", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/[email protected]", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/[email protected]", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
|
||||
"@types/react": ["@types/[email protected]", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/[email protected]", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"adm-zip": ["[email protected]", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
|
||||
|
||||
"boolean": ["[email protected]", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"define-data-property": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"detect-libc": ["[email protected]", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node": ["[email protected]", "", {}, "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="],
|
||||
|
||||
"es-define-property": ["[email protected]", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["[email protected]", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es6-error": ["[email protected]", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="],
|
||||
|
||||
"escape-string-regexp": ["[email protected]", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"flatbuffers": ["[email protected]", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||
|
||||
"global-agent": ["[email protected]", "", { "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": ["[email protected]", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["[email protected]", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"guid-typescript": ["[email protected]", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"has-property-descriptors": ["[email protected]", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"json-stringify-safe": ["[email protected]", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
"long": ["[email protected]", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"matcher": ["[email protected]", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||
|
||||
"object-keys": ["[email protected]", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"onnxruntime-common": ["[email protected]", "", {}, "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA=="],
|
||||
|
||||
"onnxruntime-node": ["[email protected]", "", { "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": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"protobufjs": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="],
|
||||
|
||||
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"roarr": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"semver-compare": ["[email protected]", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"serialize-error": ["[email protected]", "", { "dependencies": { "type-fest": "^0.13.1" } }, "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw=="],
|
||||
|
||||
"sharp": ["[email protected]", "", { "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": ["[email protected]", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"tslib": ["[email protected]", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["[email protected]", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"onnxruntime-web/onnxruntime-common": ["[email protected]", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[serve.static]
|
||||
env = "BUN_PUBLIC_*"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+162
@@ -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<Worker | null>(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<TranscriptResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(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<unknown>) => {
|
||||
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 (
|
||||
<main className="app">
|
||||
<header className="app-header">
|
||||
<h1>Whisper Web</h1>
|
||||
<p className="tagline">
|
||||
Speech-to-text that runs entirely in your browser. Your audio never
|
||||
leaves this device.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<FileDropzone onFile={handleFile} disabled={busy} />
|
||||
|
||||
{fileName && (
|
||||
<p className="file-name">
|
||||
File: <strong>{fileName}</strong>
|
||||
{decoding && " — decoding audio…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showModelProgress && (
|
||||
<ModelLoadProgress modelId={DEFAULT_MODEL_ID} progress={loadProgress} />
|
||||
)}
|
||||
|
||||
<TranscriptView
|
||||
partialText={partialText}
|
||||
result={result}
|
||||
transcribing={transcribing}
|
||||
/>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>
|
||||
Model: <code>{DEFAULT_MODEL_ID}</code> · Engine: WASM (on-device)
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
function submit(file: File | undefined | null) {
|
||||
if (file && !disabled) onFile(file);
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setDragOver(false);
|
||||
submit(event.dataTransfer.files.item(0));
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault(); // required to allow dropping
|
||||
if (!disabled) setDragOver(true);
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (!disabled) inputRef.current?.click();
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openPicker();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dropzone${dragOver ? " dropzone-active" : ""}${disabled ? " dropzone-disabled" : ""}`}
|
||||
role="button"
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled}
|
||||
aria-label="Upload an audio or video file"
|
||||
onClick={openPicker}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="audio/*,video/*"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
submit(event.target.files?.item(0));
|
||||
event.target.value = ""; // allow re-selecting the same file
|
||||
}}
|
||||
/>
|
||||
<p className="dropzone-title">
|
||||
{disabled ? "Working…" : "Drop an audio or video file here"}
|
||||
</p>
|
||||
<p className="dropzone-hint">or click to choose a file</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="model-progress" aria-label="Model download progress">
|
||||
<h2>
|
||||
Downloading model <code>{modelId}</code>
|
||||
</h2>
|
||||
<p className="model-progress-hint">
|
||||
This happens once — the weights are cached in your browser afterwards.
|
||||
</p>
|
||||
{files.length === 0 && <p className="model-progress-hint">Preparing…</p>}
|
||||
<ul>
|
||||
{files.map((file) => {
|
||||
const percent =
|
||||
file.done ? 100 : file.total > 0 ? (file.loaded / file.total) * 100 : 0;
|
||||
return (
|
||||
<li key={file.file} className="model-progress-file">
|
||||
<div className="model-progress-label">
|
||||
<span className="model-progress-name">{file.file}</span>
|
||||
<span className="model-progress-bytes">
|
||||
{file.total > 0
|
||||
? `${formatBytes(file.loaded)} / ${formatBytes(file.total)} (${percent.toFixed(0)}%)`
|
||||
: file.done
|
||||
? "done"
|
||||
: "…"}
|
||||
</span>
|
||||
</div>
|
||||
<progress max={100} value={percent} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section className="transcript">
|
||||
<div className="transcript-header">
|
||||
<h2>{result ? "Transcript" : "Transcribing…"}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="copy-button"
|
||||
onClick={copy}
|
||||
disabled={!text}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="transcript-text" aria-live="polite">
|
||||
{text || "Listening for the first words…"}
|
||||
{transcribing && <span className="cursor" aria-hidden="true" />}
|
||||
</p>
|
||||
{result && result.chunks.length > 0 && (
|
||||
<details className="transcript-chunks">
|
||||
<summary>Timestamps ({result.chunks.length} segments)</summary>
|
||||
<ol>
|
||||
{result.chunks.map((chunk, i) => (
|
||||
<li key={i}>
|
||||
<span className="chunk-time">
|
||||
{formatTimestamp(chunk.timestamp[0])}
|
||||
{chunk.timestamp[1] !== null &&
|
||||
` – ${formatTimestamp(chunk.timestamp[1])}`}
|
||||
</span>
|
||||
<span className="chunk-text">{chunk.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
// https://bun.com/docs/bundler/hot-reloading#import-meta-hot-data
|
||||
(import.meta.hot.data.root ??= createRoot(elem)).render(app);
|
||||
+258
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%EF%B8%8F%3C/text%3E%3C/svg%3E"
|
||||
/>
|
||||
<title>Whisper Web — on-device speech-to-text</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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}`);
|
||||
@@ -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<Float32Array> {
|
||||
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<Float32Array> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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 {};
|
||||
@@ -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 {};
|
||||
@@ -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 {};
|
||||
@@ -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<unknown>) => 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<AutomaticSpeechRecognitionPipeline> | null = null;
|
||||
|
||||
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||
// 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<void> {
|
||||
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<typeof WhisperTextStreamer>[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;
|
||||
}
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, FileProgress>;
|
||||
/** 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 } };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user