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