WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper

- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-07-10 04:02:28 +02:00
co-authored by Claude Fable 5
parent eb72a2e646
commit 9f39a82982
38 changed files with 3857 additions and 246 deletions
+39
View File
@@ -0,0 +1,39 @@
.idea
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# local SQLite database (users + sessions, D-009)
data/
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+25 -12
View File
@@ -61,10 +61,11 @@ src/
index.html # HTML entry, imports frontend.tsx
frontend.tsx # React root
App.tsx # top-level UI composition
components/ # ModelPicker, FileDropzone, TranscriptView, EngineToggle, AuthForms
components/ # ModelPicker, TranscriptionOptions, 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
models.ts # model catalog: ids, sizes, dtype defaults per device, languages
modelCache.ts # "downloaded?" per model via Cache Storage (D-005; reused by M3 offline indicator)
engine.ts # engine abstraction: BrowserEngine | ServerEngine
worker/
inference.worker.ts # Transformers.js pipeline, WebGPU/WASM selection
@@ -79,23 +80,35 @@ src/
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`.)
(Current state: M0–M2 built — browser transcription with the curated model
catalog, WebGPU→WASM auto-selection, model switching, and language/task options.
`lib/engine.ts`, `pwa/`, and `server/` are TODO stubs for M3–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.
1. User picks a model in `ModelPicker` (curated catalog from `lib/models.ts`, with
size estimates and a Cache-API-backed "downloaded" badge); UI posts
`{type:"load", modelId, device}` where `device` is a preference
(`"auto" | "webgpu" | "wasm"`, default `"auto"`). An explicit `dtype` override
is expressible in the message but normally omitted.
2. Worker resolves the device: `navigator.gpu?.requestAdapter()` → `"webgpu"` if an
adapter exists (and pipeline creation succeeds — otherwise it retries on WASM),
else `"wasm"` (D-004). It then resolves the catalog's dtype default for that
device and creates the Transformers.js `automatic-speech-recognition` pipeline.
Download progress events are forwarded to the UI; the terminal
`{status:"ready", device}` message reports the backend actually selected, which
the UI surfaces. Loading a different model/device disposes the old pipeline
(`pipeline.dispose()`) before creating the new one.
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.
in the browser Cache API, so subsequent loads (including offline) hit cache
(`lib/modelCache.ts` reads that cache for the downloaded badges).
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.
transfers the `Float32Array` to the worker. For multilingual models the
`transcribe` message also carries `language` (or auto-detect) and `task`
(transcribe | translate).
5. Worker runs the pipeline (chunked, with timestamps), streaming partial results
back via `{type:"partial"}` messages; final result as `{type:"result"}`.
+128
View File
@@ -0,0 +1,128 @@
# CLAUDE.md — Read this first, every session
This is the single source of truth for any agent working in this repo. If anything
you're told conflicts with this file, this file wins.
## 1. What we are building
A browser-first speech-to-text web app powered by OpenAI Whisper, running through
[Transformers.js](https://github.com/huggingface/transformers) (`@huggingface/transformers`).
**Anonymous users (no login) can:**
- Run Whisper models fully in the browser (no data leaves the device).
- Choose which Whisper model to use (tiny → large, English-only and multilingual, quantized variants).
- Keep working when the network — or our own server — is offline (installable PWA + cached model weights).
- Accelerate inference with WebGPU, falling back to WASM automatically.
- Transcribe from an uploaded audio/video file.
**Logged-in users (Passport.js) get all of the above, plus:**
- A toggle to choose where inference runs: **their own device** (browser engine) or **the server** the app is deployed on (server engine).
## 2 How to run things
Default to using Bun instead of Node.js.
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
- Use `bun test` instead of `jest` or `vitest`
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
- Use `bunx <package> <command>` instead of `npx <package> <command>`
- Bun automatically loads .env, so don't use dotenv.
## APIs
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
- `Bun.redis` for Redis. Don't use `ioredis`.
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
- `WebSocket` is built-in. Don't use `ws`.
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
- Bun.$`ls` instead of execa.
## Testing
Use `bun test` to run tests.
```ts#index.test.ts
import { test, expect } from "bun:test";
test("hello world", () => {
expect(1).toBe(1);
});
```
## Frontend
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
Server:
```ts#index.ts
import index from "./index.html"
Bun.serve({
routes: {
"/": index,
"/api/users/:id": {
GET: (req) => {
return new Response(JSON.stringify({ id: req.params.id }));
},
},
},
// optional websocket support
websocket: {
open: (ws) => {
ws.send("Hello, world!");
},
message: (ws, message) => {
ws.send(message);
},
close: (ws) => {
// handle close
}
},
development: {
hmr: true,
console: true,
}
})
```
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
```html#index.html
<html>
<body>
<h1>Hello, world!</h1>
<script type="module" src="./frontend.tsx"></script>
</body>
</html>
```
With the following `frontend.tsx`:
```tsx#frontend.tsx
import React from "react";
import { createRoot } from "react-dom/client";
// import .css files directly and it works
import './index.css';
const root = createRoot(document.body);
export default function Frontend() {
return <h1>Hello, world!</h1>;
}
root.render(<Frontend />);
```
Then, run index.ts
```sh
bun --hot ./index.ts
```
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+25 -1
View File
@@ -17,7 +17,8 @@ Bun replaces Node, vite/webpack (via `bun build` + HTML imports), jest/vitest
wired by hand instead of via a plugin.
## D-002 — Transformers.js (`@huggingface/transformers`) for all Whisper inference
**Status:** Accepted (mandated by CLAUDE.md)
**Status:** Accepted for browser inference; the server side is **superseded by D-013**
(faster-whisper).
Same library in the browser (WebGPU/WASM) and on the server (ONNX Runtime under Bun).
**Why:** one model catalog, one pipeline API, no native whisper.cpp build step.
**Trade-off:** server CPU inference is slower than whisper.cpp; acceptable — the server
@@ -93,3 +94,26 @@ bidirectional control (e.g. cancel mid-run) that abort signals can't cover.
`decodeAudioData` (handles most audio and audio-in-video containers) → mono downmix →
resample to 16 kHz → transfer Float32Array to worker (zero-copy). No ffmpeg.wasm
unless we hit real container-support gaps; note in `src/lib/audio.ts` if we do.
## D-013 — Server engine runs faster-whisper (Python sidecar)
**Status:** Accepted (user requirement, 2026-07-10; supersedes the server half of D-002)
Server-side inference (M5) uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
(CTranslate2) instead of Transformers.js under Bun. Browser inference is untouched —
faster-whisper **cannot** run in a browser (checked 2026-07): it is Python over native
C++ CTranslate2, which has no WASM build (OpenNMT/CTranslate2#528 open since 2021, no
progress) and relies on native SIMD/threading kernels. So D-002 stays Accepted for the
browser, where Transformers.js remains the engine.
**Shape:** Bun stays the only web runtime. `src/server/transcribe.ts` talks to a small
Python sidecar (`server-py/`, faster-whisper behind a minimal stdio or localhost-HTTP
interface) that `Bun.spawn` starts and supervises. The `POST /api/transcribe` contract
(D-011: streamed JSON-lines) is unchanged — faster-whisper's segment generator maps
naturally onto it.
**Consequence:** the "one inference codebase" invariant narrows to "one *browser*
inference codebase + one shared model catalog"; `src/lib/models.ts` needs a mapping
from catalog ids to faster-whisper/CTranslate2 model names (`Systran/faster-whisper-*`);
deployment gains a Python ≥3.9 dependency (server engine only — the anonymous/browser
path still deploys with Bun alone, and the app must degrade gracefully when the
sidecar is absent).
**Why:** CTranslate2 CPU inference is several times faster and more memory-efficient
than ONNX Runtime under Bun; the server engine becomes genuinely useful rather than a
slow convenience.
-93
View File
@@ -1,93 +0,0 @@
# au_whisper_web
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin https://gitlab.au.dk/au-research-infrastructure-support/au_whisper_web.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
* [Set up project integrations](https://gitlab.au.dk/au-research-infrastructure-support/au_whisper_web/-/settings/integrations)
## Collaborate with your team
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
+18 -6
View File
@@ -45,16 +45,28 @@ Notes (M1):
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,
- [x] `src/lib/models.ts` — curated catalog with sizes and dtype defaults (D-006)
- [x] `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);
- [x] 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
- [x] Handle model switching (dispose old pipeline, load new)
- [x] Language / task options for multilingual models (transcribe vs translate)
- [~] Verify quantized variants (q4/q8/fp16) on both backends; record working
combinations in `models.ts`
Notes (M2):
- Quantized-variant verification is pending manual in-browser testing (no real
browser in the dev environment). `models.ts` encodes the community-known-good
combinations — WASM: q8 (tiny→small) / q4 (medium, large-v3-turbo); WebGPU:
fp32 encoder+decoder for tiny/base (fp16 encoders known-bad there), fp16
encoder + q4 merged decoder for small/medium/large-v3-turbo — each with a
`verified` flag and note. Only `whisper-tiny.en @ wasm/q8` (exercised in M1)
is marked `verified: true`; flip the flags as manual testing confirms each.
- Downloaded badge (D-005): `src/lib/modelCache.ts` inspects Cache Storage for
transformers-cache entries with `.onnx` hits per model id; M3's offline
indicator should reuse `getDownloadedModels`.
## M3 — PWA & offline (core)
- [ ] `manifest.webmanifest` + icons; installability
- [ ] `src/pwa/sw.ts` — app-shell precache, cache-first static, network-only API (D-007)
+38
View File
@@ -6,11 +6,15 @@
"name": "bun-react-template",
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^19",
"react-dom": "^19",
},
"devDependencies": {
"@types/bun": "latest",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/react": "^19",
"@types/react-dom": "^19",
},
@@ -93,14 +97,38 @@
"@protobufjs/utf8": ["@protobufjs/[email protected]", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
"@types/body-parser": ["@types/[email protected]", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/connect": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
"@types/express": ["@types/[email protected]", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
"@types/express-serve-static-core": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg=="],
"@types/http-errors": ["@types/[email protected]", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
"@types/passport": ["@types/[email protected]", "", { "dependencies": { "@types/express": "*" } }, "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg=="],
"@types/passport-local": ["@types/[email protected]", "", { "dependencies": { "@types/express": "*", "@types/passport": "*", "@types/passport-strategy": "*" } }, "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg=="],
"@types/passport-strategy": ["@types/[email protected]", "", { "dependencies": { "@types/express": "*", "@types/passport": "*" } }, "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA=="],
"@types/qs": ["@types/[email protected]", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
"@types/range-parser": ["@types/[email protected]", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
"@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=="],
"@types/send": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
"@types/serve-static": ["@types/[email protected]", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"adm-zip": ["[email protected]", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
"boolean": ["[email protected]", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
@@ -151,6 +179,14 @@
"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=="],
"passport": ["[email protected]", "", { "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", "utils-merge": "^1.0.1" } }, "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ=="],
"passport-local": ["[email protected]", "", { "dependencies": { "passport-strategy": "1.x.x" } }, "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow=="],
"passport-strategy": ["[email protected]", "", {}, "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA=="],
"pause": ["[email protected]", "", {}, "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="],
"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=="],
@@ -179,6 +215,8 @@
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"utils-merge": ["[email protected]", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"onnxruntime-web/onnxruntime-common": ["[email protected]", "", {}, "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw=="],
}
}
+8 -3
View File
@@ -5,17 +5,22 @@
"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_*'",
"build": "bun scripts/build.ts",
"icons": "bun scripts/generate-icons.ts",
"start": "NODE_ENV=production bun src/index.ts"
},
"dependencies": {
"@huggingface/transformers": "^4.2.0",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/bun": "latest",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
"@types/react-dom": "^19"
}
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Production build (`bun run build`) — Decisions.md D-007.
*
* Emits a self-contained `dist/`:
* 1. `bun build src/index.html` → hashed app-shell assets
* 2. inference worker bundle at the stable /inference.worker.js URL
* 3. manifest + icons + onnxruntime .wasm/.mjs (dist/ort/, offline inference)
* 4. sw.js with the precache manifest + a content-hash version injected
*
* `bun run start` (NODE_ENV=production bun src/index.ts) then serves dist/
* verbatim, so the URLs the service worker precached are exactly the URLs
* the server responds to.
*/
import { cp, readdir, rm } from "node:fs/promises";
import { join, relative } from "node:path";
import { injectPrecacheManifest, precachePathsFromOutputs } from "../src/pwa/precache";
import { ensureIcons, ICON_SIZES, ICONS_DIR } from "./generate-icons";
const ROOT = join(import.meta.dir, "..");
const DIST = join(ROOT, "dist");
await rm(DIST, { recursive: true, force: true });
// Icons before the shell build — index.html references src/pwa/icons/ (the
// HTML loader resolves the file at bundle time) and they aren't checked in.
await ensureIcons(ICONS_DIR);
// 1. App shell (hashed assets).
const shellBuild = await Bun.build({
entrypoints: [join(ROOT, "src/index.html")],
outdir: DIST,
target: "browser",
minify: true,
sourcemap: "linked",
define: { "process.env.NODE_ENV": '"production"' },
env: "BUN_PUBLIC_*",
});
// 2. Inference worker at its stable URL (Bun does not follow `new Worker`
// graphs — same mechanism as the dev server in src/index.ts).
const workerBuild = await Bun.build({
entrypoints: [join(ROOT, "src/worker/inference.worker.ts")],
target: "browser",
format: "esm",
minify: true,
});
const workerScript = await workerBuild.outputs[0]!.text();
await Bun.write(join(DIST, "inference.worker.js"), workerScript);
// 3. Static PWA assets + onnxruntime wasm binaries (offline inference — the
// worker loads them from /ort/ instead of the jsdelivr CDN). The manifest and
// icons are also emitted as hashed shell assets (index.html links them), but
// the manifest's internal icon URLs point at these fixed /icons/ paths.
await cp(join(ROOT, "src/pwa/manifest.webmanifest"), join(DIST, "manifest.webmanifest"));
for (const size of ICON_SIZES) {
await cp(join(ICONS_DIR, `icon-${size}.png`), join(DIST, `icons/icon-${size}.png`));
}
const ortDist = join(ROOT, "node_modules/onnxruntime-web/dist");
const ortFiles = (await readdir(ortDist)).filter((name) =>
/^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/.test(name),
);
for (const name of ortFiles) {
await cp(join(ortDist, name), join(DIST, "ort", name));
}
// 4. Service worker with the injected precache manifest (D-007). Icons,
// manifest, and the worker are unhashed, so the cache version hashes their
// contents (the hashed shell assets are covered by their names).
const swBuild = await Bun.build({
entrypoints: [join(ROOT, "src/pwa/sw.ts")],
target: "browser",
format: "esm",
// Placeholder string literals must survive verbatim (src/pwa/precache.ts).
minify: false,
});
const swSource = await swBuild.outputs[0]!.text();
const precachePaths = precachePathsFromOutputs(
shellBuild.outputs.map((output) => relative(DIST, output.path)),
[
"/inference.worker.js",
"/manifest.webmanifest",
...ICON_SIZES.map((size) => `/icons/icon-${size}.png`),
],
);
const version = Bun.hash(
JSON.stringify(precachePaths) + swSource + workerScript,
).toString(36);
await Bun.write(join(DIST, "sw.js"), injectPrecacheManifest(swSource, precachePaths, version));
console.log(`Built dist/ (cache version ${version}); precached app shell:`);
for (const path of precachePaths) console.log(` ${path}`);
console.log(` + ${ortFiles.length} onnxruntime files in /ort/ (runtime-cached)`);
+114
View File
@@ -0,0 +1,114 @@
/**
* PWA icon generator — writes simple solid-background PNGs (192 & 512 px)
* with a hand-rolled PNG encoder (no image dependencies, D-001). The glyph —
* a ring on the app's dark background — stays well inside the maskable safe
* zone, so the icons declare `purpose: "any maskable"` in the manifest.
*
* Run directly (`bun scripts/generate-icons.ts`) or via `ensureIcons()` from
* src/index.ts (dev) and scripts/build.ts (production).
*/
import { deflateSync } from "node:zlib";
import { join } from "node:path";
export const ICON_SIZES = [192, 512] as const;
// App theme (src/index.css / manifest.webmanifest).
const BACKGROUND: readonly [number, number, number] = [0x16, 0x18, 0x1d];
const ACCENT: readonly [number, number, number] = [0x7a, 0xa2, 0xf7];
// --- Minimal PNG encoding (8-bit RGBA, filter 0) -------------------------
const CRC_TABLE = new Uint32Array(256).map((_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Uint8Array): Uint8Array {
const chunk = new Uint8Array(12 + data.length);
const view = new DataView(chunk.buffer);
view.setUint32(0, data.length);
for (let i = 0; i < 4; i++) chunk[4 + i] = type.charCodeAt(i);
chunk.set(data, 8);
view.setUint32(8 + data.length, crc32(chunk.subarray(4, 8 + data.length)));
return chunk;
}
/** Encode an RGBA pixel buffer as a PNG file. */
function encodePng(size: number, rgba: Uint8Array): Uint8Array {
const ihdr = new Uint8Array(13);
const ihdrView = new DataView(ihdr.buffer);
ihdrView.setUint32(0, size);
ihdrView.setUint32(4, size);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type: RGBA
// compression 0, filter 0, interlace 0
// Scanlines, each prefixed with filter byte 0.
const raw = new Uint8Array(size * (size * 4 + 1));
for (let y = 0; y < size; y++) {
raw.set(rgba.subarray(y * size * 4, (y + 1) * size * 4), y * (size * 4 + 1) + 1);
}
const signature = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const idat = new Uint8Array(deflateSync(raw));
return Uint8Array.from([
...signature,
...pngChunk("IHDR", ihdr),
...pngChunk("IDAT", idat),
...pngChunk("IEND", new Uint8Array(0)),
]);
}
// --- Icon artwork ---------------------------------------------------------
/** 0→1 coverage across a ~1px band, for cheap antialiasing. */
function edge(distance: number): number {
return Math.min(1, Math.max(0, distance + 0.5));
}
export function encodeIcon(size: number): Uint8Array {
const rgba = new Uint8Array(size * size * 4);
const center = (size - 1) / 2;
const outer = size * 0.3;
const inner = size * 0.17;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const d = Math.hypot(x - center, y - center);
// Ring: inside outer radius but outside inner radius.
const cover = edge(outer - d) * edge(d - inner);
const offset = (y * size + x) * 4;
for (let c = 0; c < 3; c++) {
rgba[offset + c] = Math.round(
BACKGROUND[c]! + (ACCENT[c]! - BACKGROUND[c]!) * cover,
);
}
rgba[offset + 3] = 255; // opaque — maskable icons need a full background
}
}
return encodePng(size, rgba);
}
/** Directory the manifest's icon URLs are served from in dev. */
export const ICONS_DIR = join(import.meta.dir, "../src/pwa/icons");
/** Generate any missing icons into `dir` (idempotent). */
export async function ensureIcons(dir: string = ICONS_DIR): Promise<void> {
for (const size of ICON_SIZES) {
const path = join(dir, `icon-${size}.png`);
if (await Bun.file(path).exists()) continue;
await Bun.write(path, encodeIcon(size));
console.log(`Wrote ${path}`);
}
}
if (import.meta.main) {
await ensureIcons();
}
+153 -16
View File
@@ -1,17 +1,32 @@
import { useEffect, useRef, useState } from "react";
import "./index.css";
import { AuthForms } from "./components/AuthForms";
import { FileDropzone } from "./components/FileDropzone";
import { ModelLoadProgress } from "./components/ModelLoadProgress";
import { ModelPicker } from "./components/ModelPicker";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio";
import { DEFAULT_DEVICE, DEFAULT_MODEL_ID } from "./lib/models";
import { getDownloadedModels } from "./lib/modelCache";
import { registerServiceWorker } from "./pwa/register";
import {
AUTO_LANGUAGE,
DEFAULT_DEVICE_PREFERENCE,
DEFAULT_MODEL_ID,
DEFAULT_TASK,
MODELS,
getModel,
} from "./lib/models";
import {
applyLoadProgress,
initialLoadProgress,
isWorkerToUiMessage,
type DevicePreference,
type InferenceDevice,
type TranscriptChunk,
type UiToWorkerMessage,
type WhisperTask,
} from "./worker/protocol";
interface TranscriptResult {
@@ -19,6 +34,8 @@ interface TranscriptResult {
chunks: TranscriptChunk[];
}
const MODEL_IDS = MODELS.map((model) => model.id);
export function App() {
const workerRef = useRef<Worker | null>(null);
@@ -31,8 +48,33 @@ export function App() {
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
const [devicePreference, setDevicePreference] = useState<DevicePreference>(
DEFAULT_DEVICE_PREFERENCE,
);
const [activeDevice, setActiveDevice] = useState<InferenceDevice | null>(null);
const [language, setLanguage] = useState(AUTO_LANGUAGE);
const [task, setTask] = useState<WhisperTask>(DEFAULT_TASK);
const [downloaded, setDownloaded] = useState<ReadonlySet<string>>(new Set());
// PWA state (M3, D-007): is a service worker controlling this page (app
// shell available offline), and is an updated version waiting?
const [swControlled, setSwControlled] = useState(false);
const [applyUpdate, setApplyUpdate] = useState<(() => void) | null>(null);
function refreshDownloaded() {
// Downloaded badges (D-005): query Cache Storage for cached weights.
getDownloadedModels(MODEL_IDS).then(setDownloaded, () => {});
}
// Terminate the worker when the app unmounts (also keeps HMR tidy).
useEffect(() => {
refreshDownloaded();
// Registration is idempotent, so the StrictMode double-invoke is fine.
registerServiceWorker({
onControlled: () => setSwControlled(true),
onUpdateReady: (apply) => setApplyUpdate(() => apply),
});
return () => {
workerRef.current?.terminate();
workerRef.current = null;
@@ -52,6 +94,12 @@ export function App() {
switch (message.type) {
case "load-progress":
setLoadProgress((state) => applyLoadProgress(state, message));
if (message.status === "ready") {
// Surface the backend the worker actually selected (D-004) and
// refresh the downloaded badges now that weights are cached.
setActiveDevice(message.device);
refreshDownloaded();
}
break;
case "partial":
setPartialText((text) => text + message.text);
@@ -78,6 +126,38 @@ export function App() {
worker.postMessage(message, transfer ?? []);
}
/**
* Post a load request and reset per-load UI state so the progress panel
* restarts cleanly on model/device switches. Idempotent in the worker for
* an unchanged model+device; a change disposes the old pipeline first.
*/
function startLoad(nextModelId: string, preference: DevicePreference): Worker {
const worker = ensureWorker();
setLoadProgress(initialLoadProgress);
setLoadStarted(true);
setError(null);
postToWorker(worker, { type: "load", modelId: nextModelId, device: preference });
return worker;
}
function handleModelSelect(nextModelId: string) {
if (busy || nextModelId === modelId) return;
setModelId(nextModelId);
setActiveDevice(null);
setPartialText("");
setResult(null);
startLoad(nextModelId, devicePreference);
}
function handleDevicePreference(preference: DevicePreference) {
if (busy || preference === devicePreference) return;
setDevicePreference(preference);
setActiveDevice(null);
// Only reload eagerly if a load was already in motion; otherwise the next
// file drop picks up the new preference.
if (loadStarted) startLoad(modelId, preference);
}
async function handleFile(file: File) {
if (busy) return;
setBusy(true);
@@ -89,20 +169,28 @@ export function App() {
// 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);
if (!loadStarted) {
startLoad(modelId, devicePreference);
} else {
postToWorker(worker, { type: "load", modelId, device: devicePreference });
}
const multilingual = getModel(modelId)?.multilingual ?? false;
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]);
// Transfer the buffer to the worker — zero copy. Language/task only
// apply to multilingual models; `.en` variants take neither.
postToWorker(
worker,
multilingual
? { type: "transcribe", audio, language, task }
: { type: "transcribe", audio },
[audio.buffer as ArrayBuffer],
);
} catch (decodeError) {
setError(decodeError instanceof Error ? decodeError.message : String(decodeError));
setBusy(false);
@@ -111,6 +199,7 @@ export function App() {
}
}
const model = getModel(modelId);
const modelReady = loadProgress.ready;
const showModelProgress = loadStarted && !modelReady && !error;
const transcribing = busy && !decoding && modelReady;
@@ -118,13 +207,40 @@ export function App() {
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>
<div>
<h1>Whisper Web</h1>
<p className="tagline">
Speech-to-text that runs entirely in your browser. Your audio never
leaves this device.
</p>
</div>
{/* Session UI (M4). Anonymous-first: logged out, this is only an
unobtrusive "Sign in" button — everything else is unchanged. */}
<AuthForms />
</header>
<ModelPicker
models={MODELS}
selectedId={modelId}
onSelect={handleModelSelect}
devicePreference={devicePreference}
onDevicePreferenceChange={handleDevicePreference}
activeDevice={activeDevice}
downloaded={downloaded}
offlineReady={swControlled}
disabled={busy}
/>
{model?.multilingual && (
<TranscriptionOptions
language={language}
onLanguageChange={setLanguage}
task={task}
onTaskChange={setTask}
disabled={busy}
/>
)}
<FileDropzone onFile={handleFile} disabled={busy} />
{fileName && (
@@ -141,7 +257,7 @@ export function App() {
)}
{showModelProgress && (
<ModelLoadProgress modelId={DEFAULT_MODEL_ID} progress={loadProgress} />
<ModelLoadProgress modelId={modelId} progress={loadProgress} />
)}
<TranscriptView
@@ -150,9 +266,30 @@ export function App() {
transcribing={transcribing}
/>
{applyUpdate && (
<div className="update-toast" role="status">
<span>A new version of Whisper Web is available.</span>
<button type="button" onClick={applyUpdate}>
Reload
</button>
<button
type="button"
className="update-toast-dismiss"
onClick={() => setApplyUpdate(null)}
>
Later
</button>
</div>
)}
<footer className="app-footer">
<p>
Model: <code>{DEFAULT_MODEL_ID}</code> · Engine: WASM (on-device)
Model: <code>{modelId}</code> · Engine:{" "}
{activeDevice === "webgpu"
? "WebGPU (on-device)"
: activeDevice === "wasm"
? "WASM (on-device)"
: "on-device — backend selected on first load"}
</p>
</footer>
</main>
+192
View File
@@ -0,0 +1,192 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
import { validatePassword, validateUsername } from "../lib/authClient";
import { useAuth } from "../lib/useAuth";
type Mode = "login" | "register";
/**
* Session-aware header widget (M4): an unobtrusive "Sign in" affordance while
* anonymous — the app works fully without an account (Architecture.md §5) —
* that opens a compact login/register popover; once signed in, the username
* and a "Sign out" button.
*
* Validation mirrors the server rules for instant feedback, but the server
* stays authoritative: its `{ error }` messages are shown verbatim on 4xx.
*/
export function AuthForms() {
const { user, login, register, logout } = useAuth();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
function switchMode(next: Mode) {
if (pending || next === mode) return;
setMode(next);
setError(null); // stale errors from the other form would mislead
}
function togglePanel() {
setOpen((wasOpen) => !wasOpen);
setError(null);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (pending) return;
// Client-side pre-flight: full rules for register; login only needs
// non-empty fields (the server answers 401 for anything wrong).
if (mode === "register") {
const problem = validateUsername(username) ?? validatePassword(password);
if (problem) {
setError(problem);
return;
}
} else if (!username || !password) {
setError("Enter a username and password.");
return;
}
setPending(true);
setError(null);
try {
if (mode === "register") {
await register(username, password);
} else {
await login(username, password);
}
// Success — the hook now holds the user; reset the panel for next time.
setOpen(false);
setUsername("");
setPassword("");
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : String(submitError),
);
} finally {
setPending(false);
}
}
async function handleLogout() {
if (pending) return;
setPending(true);
try {
await logout(); // always resolves; state is cleared even when offline
} finally {
setPending(false);
}
}
function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") setOpen(false);
}
if (user) {
return (
<div className="auth">
<span className="auth-username" title="Signed in">
{user.username}
</span>
<button
type="button"
className="auth-button"
onClick={handleLogout}
disabled={pending}
>
Sign out
</button>
</div>
);
}
return (
<div className="auth">
<button
type="button"
className="auth-button"
aria-expanded={open}
aria-haspopup="dialog"
onClick={togglePanel}
>
Sign in
</button>
{open && (
<div
className="auth-panel"
role="dialog"
aria-label="Sign in or create an account"
onKeyDown={onPanelKeyDown}
>
<div className="auth-tabs" role="tablist" aria-label="Account">
<button
type="button"
role="tab"
aria-selected={mode === "login"}
className={`auth-tab${mode === "login" ? " auth-tab-selected" : ""}`}
onClick={() => switchMode("login")}
>
Log in
</button>
<button
type="button"
role="tab"
aria-selected={mode === "register"}
className={`auth-tab${mode === "register" ? " auth-tab-selected" : ""}`}
onClick={() => switchMode("register")}
>
Register
</button>
</div>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
autoComplete="username"
autoFocus
disabled={pending}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password}
autoComplete={
mode === "register" ? "new-password" : "current-password"
}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{mode === "register" && (
<p className="auth-hint">
3–32 characters (letters, digits, _ . -); password ≥ 8
characters.
</p>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<button type="submit" className="auth-submit" disabled={pending}>
{pending
? "Working…"
: mode === "register"
? "Create account"
: "Log in"}
</button>
</form>
</div>
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import {
estimateDefaultDownloadBytes,
formatModelSize,
type WhisperModel,
} from "../lib/models";
import type { DevicePreference, InferenceDevice } from "../worker/protocol";
interface ModelPickerProps {
models: readonly WhisperModel[];
selectedId: string;
onSelect: (modelId: string) => void;
devicePreference: DevicePreference;
onDevicePreferenceChange: (preference: DevicePreference) => void;
/** Backend the worker actually selected; null until the first load (D-004). */
activeDevice: InferenceDevice | null;
/** Model ids with weights already in the browser cache (D-005). */
downloaded: ReadonlySet<string>;
/** True when the service worker controls the page — app shell cached (M3). */
offlineReady: boolean;
/** True while a transcription is running — switching is disabled then. */
disabled?: boolean;
}
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
{ value: "auto", label: "Auto (WebGPU if available)" },
{ value: "webgpu", label: "WebGPU" },
{ value: "wasm", label: "WASM (CPU)" },
];
/**
* Curated model list (D-006): size estimate before download, English-only vs
* multilingual grouping, downloaded badge from the Cache API (D-005), and a
* device preference select (default "auto", D-004).
*/
export function ModelPicker({
models,
selectedId,
onSelect,
devicePreference,
onDevicePreferenceChange,
activeDevice,
downloaded,
offlineReady,
disabled = false,
}: ModelPickerProps) {
// Size estimates depend on the dtype the worker will pick, which depends on
// the device. Until the worker has reported one, assume WASM unless the
// user explicitly asked for WebGPU.
const estimateDevice: InferenceDevice =
activeDevice ?? (devicePreference === "webgpu" ? "webgpu" : "wasm");
const groups: { title: string; items: WhisperModel[] }[] = [
{
title: "English-only",
items: models.filter((model) => !model.multilingual),
},
{
title: "Multilingual",
items: models.filter((model) => model.multilingual),
},
];
return (
<section className="model-picker" aria-label="Model selection">
<div className="model-picker-header">
<h2>Model</h2>
<label className="device-select">
Backend
<select
value={devicePreference}
disabled={disabled}
onChange={(event) =>
onDevicePreferenceChange(event.target.value as DevicePreference)
}
>
{DEVICE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{disabled && (
<p className="model-picker-hint">
Model switching is disabled while a transcription is running.
</p>
)}
<p
className={`offline-status${offlineReady && downloaded.size > 0 ? " offline-status-ready" : ""}`}
>
{offlineReady
? downloaded.size > 0
? "Offline ready — the app and the models marked “available offline” work without a network connection."
: "The app is cached for offline use. Load a model once to make it available offline too."
: "Offline support is not active yet (the service worker is still installing, or this browser does not support it)."}
</p>
{groups.map((group) => (
<fieldset key={group.title} className="model-group" disabled={disabled}>
<legend>{group.title}</legend>
<ul>
{group.items.map((model) => {
const size = formatModelSize(
estimateDefaultDownloadBytes(model, estimateDevice),
);
const isDownloaded = downloaded.has(model.id);
const isSelected = model.id === selectedId;
return (
<li key={model.id}>
<label
className={`model-option${isSelected ? " model-option-selected" : ""}`}
>
<input
type="radio"
name="model"
value={model.id}
checked={isSelected}
disabled={disabled}
onChange={() => onSelect(model.id)}
/>
<span className="model-option-name">{model.displayName}</span>
<span className="model-option-meta">
{isDownloaded && (
<span
className="model-badge"
title="Weights are cached in this browser — this model loads and transcribes without a network connection."
>
available offline
</span>
)}
<span className="model-option-size">~{size}</span>
</span>
</label>
</li>
);
})}
</ul>
</fieldset>
))}
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { AUTO_LANGUAGE, WHISPER_LANGUAGES } from "../lib/models";
import type { WhisperTask } from "../worker/protocol";
interface TranscriptionOptionsProps {
language: string;
onLanguageChange: (language: string) => void;
task: WhisperTask;
onTaskChange: (task: WhisperTask) => void;
disabled?: boolean;
}
/**
* Language + task controls for multilingual models. The parent renders this
* only when the selected model is multilingual — `.en` models take neither.
*/
export function TranscriptionOptions({
language,
onLanguageChange,
task,
onTaskChange,
disabled = false,
}: TranscriptionOptionsProps) {
return (
<div className="transcription-options">
<label>
Language
<select
value={language}
disabled={disabled}
onChange={(event) => onLanguageChange(event.target.value)}
>
<option value={AUTO_LANGUAGE}>Auto-detect</option>
{WHISPER_LANGUAGES.map((entry) => (
<option key={entry.code} value={entry.code}>
{entry.label}
</option>
))}
</select>
</label>
<label>
Task
<select
value={task}
disabled={disabled}
onChange={(event) => onTaskChange(event.target.value as WhisperTask)}
>
<option value="transcribe">Transcribe</option>
<option value="translate">Translate to English</option>
</select>
</label>
</div>
);
}
+321
View File
@@ -25,6 +25,14 @@ body {
gap: 1.5rem;
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.app-header h1 {
margin: 0 0 0.25rem;
font-size: 2.2rem;
@@ -36,6 +44,319 @@ body {
color: var(--muted);
}
/* --- AuthForms (M4) --- */
.auth {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.6rem;
/* Roughly align the button with the h1 baseline. */
margin-top: 0.35rem;
}
.auth-username {
color: var(--muted);
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.auth-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;
}
.auth-button:hover:enabled {
border-color: var(--accent);
}
.auth-button:disabled {
opacity: 0.5;
cursor: default;
}
.auth-panel {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
width: min(17rem, calc(100vw - 2.5rem));
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.auth-tabs {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.auth-tab {
flex: 1;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
border-radius: 8px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
cursor: pointer;
}
.auth-tab:hover:enabled {
border-color: var(--border);
}
.auth-tab-selected,
.auth-tab-selected:hover:enabled {
border-color: var(--accent);
background: #232a38;
color: inherit;
}
.auth-panel form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.auth-panel label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.auth-panel input {
background: #262b34;
color: #e8e8e8;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
}
.auth-panel input:focus-visible {
border-color: var(--accent);
outline: none;
}
.auth-hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
}
.auth-error {
margin: 0;
font-size: 0.85rem;
color: #f2b8b5;
}
.auth-submit {
background: var(--accent);
color: #16181d;
border: none;
border-radius: 8px;
padding: 0.4rem 0.8rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.auth-submit:disabled {
opacity: 0.6;
cursor: default;
}
/* --- ModelPicker --- */
.model-picker {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
}
.model-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.model-picker-header h2 {
margin: 0;
font-size: 1rem;
}
.model-picker-hint {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.device-select,
.transcription-options label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
.model-picker select,
.transcription-options select {
background: #262b34;
color: inherit;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
}
.model-group {
border: none;
margin: 0.75rem 0 0;
padding: 0;
}
.model-group legend {
padding: 0;
margin-bottom: 0.35rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.model-group ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.model-option {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
}
.model-group:not(:disabled) .model-option:hover {
border-color: var(--border);
}
.model-option-selected,
.model-group:not(:disabled) .model-option-selected:hover {
border-color: var(--accent);
background: #232a38;
}
.model-group:disabled .model-option {
cursor: default;
opacity: 0.6;
}
.model-option input {
accent-color: var(--accent);
}
.model-option-name {
flex: 1;
}
.model-option-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
.model-option-size {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.model-badge {
border: 1px solid #3d6b4f;
background: #1d2b23;
color: #9ad6ae;
border-radius: 999px;
padding: 0.1rem 0.55rem;
font-size: 0.75rem;
white-space: nowrap;
}
/* --- PWA / offline (M3) --- */
.offline-status {
margin: 0.5rem 0 0.75rem;
font-size: 0.85rem;
color: var(--muted);
}
.offline-status-ready {
color: #9ad6ae;
}
.update-toast {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.6rem 1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.update-toast button {
background: var(--accent);
color: #16181d;
border: none;
border-radius: 6px;
padding: 0.3rem 0.8rem;
font: inherit;
cursor: pointer;
}
.update-toast button.update-toast-dismiss {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
/* --- TranscriptionOptions --- */
.transcription-options {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
/* --- FileDropzone --- */
.dropzone {
+7
View File
@@ -7,6 +7,13 @@
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"
/>
<!-- Relative hrefs: Bun's HTML loader must resolve every <link href> to a
real file (absolute URLs fail the build); it hashes + rewrites them.
The manifest's own icon URLs stay at the fixed /icons/ paths, which
src/index.ts (dev) and scripts/build.ts (production) serve. -->
<link rel="manifest" href="./pwa/manifest.webmanifest" />
<meta name="theme-color" content="#16181d" />
<link rel="apple-touch-icon" href="./pwa/icons/icon-192.png" />
<title>Whisper Web — on-device speech-to-text</title>
</head>
<body>
+186 -35
View File
@@ -1,43 +1,194 @@
import { serve } from "bun";
import index from "./index.html";
import { join, normalize, sep } from "node:path";
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons";
import { injectPrecacheManifest } from "./pwa/precache";
import { auth } from "./server/auth";
// 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 ROOT = join(import.meta.dir, "..");
const IS_PRODUCTION = process.env.NODE_ENV === "production";
// ---------------------------------------------------------------------------
// Shared static helpers
// ---------------------------------------------------------------------------
const NOT_FOUND = () => new Response("Not Found", { status: 404 });
// onnxruntime-web fetches its .wasm binaries at runtime — from the jsdelivr
// CDN by default, which would break offline. The worker points
// `env.backends.onnx.wasm.wasmPaths` at /ort/ instead (see
// src/worker/inference.worker.ts), we serve the files from node_modules
// here (production: copied into dist/ort by scripts/build.ts), and the
// service worker runtime-caches them cache-first.
const ORT_DIST = join(ROOT, "node_modules/onnxruntime-web/dist");
export const ORT_FILE_RE = /^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/;
const CONTENT_TYPES: Record<string, string> = {
".wasm": "application/wasm",
".mjs": "text/javascript; charset=utf-8",
".webmanifest": "application/manifest+json",
};
function contentTypeFor(path: string): string | undefined {
const dot = path.lastIndexOf(".");
return dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)];
}
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" },
}),
async function serveFile(path: string, cacheControl?: string): Promise<Response> {
const file = Bun.file(path);
if (!(await file.exists())) return NOT_FOUND();
const headers = new Headers();
const type = contentTypeFor(path);
if (type) headers.set("Content-Type", type);
if (cacheControl) headers.set("Cache-Control", cacheControl);
return new Response(file, { headers });
}
// Serve the bundled React app for all other routes.
"/*": index,
},
async function serveOrtFile(dir: string, name: string): Promise<Response> {
if (!ORT_FILE_RE.test(name)) return NOT_FOUND();
return serveFile(join(dir, name));
}
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// ---------------------------------------------------------------------------
// API dispatcher — shared by development and production so /api/* behaves
// identically in both serve modes (the SW treats it as network-only, D-007).
// Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5.
// ---------------------------------------------------------------------------
// Echo console logs from the browser to the server
console: true,
},
});
async function handleApiRequest(request: Request): Promise<Response> {
const { pathname } = new URL(request.url);
switch (`${request.method} ${pathname}`) {
case "POST /api/auth/register":
return auth.register(request);
case "POST /api/auth/login":
return auth.login(request);
case "POST /api/auth/logout":
return auth.logout(request);
case "GET /api/auth/me":
return auth.me(request);
default:
return NOT_FOUND();
}
}
console.log(`Server running at ${server.url}`);
// ---------------------------------------------------------------------------
// Production: serve the prebuilt dist/ directory (created by `bun run build`)
// so the URLs match the hashed asset paths precached by dist/sw.js (D-007).
// ---------------------------------------------------------------------------
type ServeOptions = Parameters<typeof serve>[0];
async function productionServeOptions(): Promise<ServeOptions> {
const dist = join(ROOT, "dist");
if (!(await Bun.file(join(dist, "sw.js")).exists())) {
throw new Error("dist/ is missing or incomplete — run `bun run build` first.");
}
return {
development: false,
async fetch(request) {
const url = new URL(request.url);
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
if (url.pathname.startsWith("/api/")) return handleApiRequest(request);
const relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
const path = normalize(join(dist, relative));
if (!path.startsWith(dist + sep)) return NOT_FOUND(); // traversal guard
const response = await serveFile(
path,
// Hashed chunks are immutable; everything else must revalidate so the
// SW update flow sees new versions.
/(^|\/)(chunk|index)-[a-z0-9]+\./.test(relative)
? "public, max-age=31536000, immutable"
: "no-cache",
);
if (response.status !== 404) return response;
// SPA fallback: serve the app shell for unknown navigations.
return serveFile(join(dist, "index.html"), "no-cache");
},
};
}
// ---------------------------------------------------------------------------
// Development: HTML imports + on-the-fly bundles
// ---------------------------------------------------------------------------
/**
* 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 worker-like entrypoints are bundled at startup and served from stable
* URLs. The UI spawns `new Worker("/inference.worker.js")`; the service
* worker registers as "/sw.js".
*/
async function bundleEntrypoint(relativePath: string): Promise<string> {
const build = await Bun.build({
entrypoints: [join(import.meta.dir, relativePath)],
target: "browser",
format: "esm",
// Never minify: sw.ts relies on its placeholder string literals surviving
// bundling verbatim (src/pwa/precache.ts), and dev builds favor debuggability.
minify: false,
});
const artifact = build.outputs[0];
if (!artifact) throw new Error(`Failed to bundle src/${relativePath}`);
return artifact.text();
}
async function developmentServeOptions(): Promise<ServeOptions> {
// src/pwa/icons/*.png are generated, not checked in; index.html links them,
// so they must exist before the HTML import bundles it.
await ensureIcons();
const { default: index } = await import("./index.html");
const workerScript = await bundleEntrypoint("./worker/inference.worker.ts");
// Dev SW: empty precache list — assets are unhashed here, so the shell is
// runtime-cached network-first instead (see src/pwa/sw.ts).
const swScript = injectPrecacheManifest(
await bundleEntrypoint("./pwa/sw.ts"),
[],
"dev",
);
const scriptHeaders = { "Content-Type": "text/javascript; charset=utf-8" };
return {
routes: {
"/inference.worker.js": new Response(workerScript, { headers: scriptHeaders }),
"/sw.js": new Response(swScript, {
// The browser's SW update check must always see fresh bytes.
headers: { ...scriptHeaders, "Cache-Control": "no-cache" },
}),
"/manifest.webmanifest": () =>
serveFile(join(import.meta.dir, "pwa/manifest.webmanifest")),
"/icons/:file": (request) => {
const name = request.params.file ?? "";
return /^icon-\d+\.png$/.test(name)
? serveFile(join(ICONS_DIR, name))
: NOT_FOUND();
},
"/ort/:file": (request) => serveOrtFile(ORT_DIST, request.params.file ?? ""),
// API routes go through the same dispatcher production uses.
"/api/*": handleApiRequest,
// Serve the bundled React app for all other routes.
"/*": index,
},
development: {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
};
}
// ---------------------------------------------------------------------------
const server = serve(
IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions(),
);
console.log(
`Server running at ${server.url} (${IS_PRODUCTION ? "production, serving dist/" : "development"})`,
);
+174
View File
@@ -0,0 +1,174 @@
/**
* Auth API client (M4). Deliberately framework-free: validation is pure and
* the fetch layer is injectable, so `bun test` can exercise everything without
* a DOM or a running server (tests/auth-client.test.ts). React state lives in
* `useAuth` (src/lib/useAuth.ts).
*
* Contract (mirrored by src/server): all endpoints exchange JSON and errors
* arrive as `{ error: string }`. The session cookie is HttpOnly, so
* `GET /api/auth/me` is the ONLY way to learn the current auth state.
*/
export interface AuthUser {
id: number | string;
username: string;
}
/** Minimal fetch signature so tests can inject a mock. */
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
// Bound lazily via globalThis: bare `fetch` references passed around as values
// can throw "Illegal invocation" in some browsers.
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
// --- Client-side validation ---------------------------------------------
// Mirrors the server rules for instant feedback; the server stays
// authoritative and its `{ error }` messages are surfaced on 4xx regardless.
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
export const PASSWORD_MIN_LENGTH = 8;
/** Returns a human-readable problem with the username, or null when valid. */
export function validateUsername(username: string): string | null {
if (
username.length < USERNAME_MIN_LENGTH ||
username.length > USERNAME_MAX_LENGTH
) {
return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`;
}
if (!USERNAME_PATTERN.test(username)) {
return "Username may only contain letters, digits, and _ . -";
}
return null;
}
/** Returns a human-readable problem with the password, or null when valid. */
export function validatePassword(password: string): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
}
return null;
}
// --- API requests ---------------------------------------------------------
function isAuthUser(value: unknown): value is AuthUser {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { id?: unknown; username?: unknown };
return (
(typeof candidate.id === "number" || typeof candidate.id === "string") &&
typeof candidate.username === "string"
);
}
/** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */
async function readErrorMessage(
response: Response,
fallback: string,
): Promise<string> {
try {
const body: unknown = await response.json();
if (
typeof body === "object" &&
body !== null &&
typeof (body as { error?: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch {
// Non-JSON error body (proxy page, empty 500) — use the fallback.
}
return fallback;
}
/**
* POST credentials to an auth endpoint and return the signed-in user.
* Throws an `Error` whose message is safe to show in the UI.
*/
async function requestUser(
fetchFn: FetchLike,
path: string,
credentials: { username: string; password: string },
): Promise<AuthUser> {
let response: Response;
try {
response = await fetchFn(path, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
} catch {
throw new Error("Could not reach the server. Check your connection.");
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Request failed (${response.status}).`),
);
}
const payload: unknown = await response.json().catch(() => null);
const user = (payload as { user?: unknown } | null)?.user;
if (!isAuthUser(user)) {
throw new Error("The server returned an unexpected response.");
}
return user;
}
/** POST /api/auth/login → user, or throws with the server's error message. */
export function login(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/login", { username, password });
}
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */
export function register(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/register", { username, password });
}
/**
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
* the app is offline-first and /api/* is network-only in the service worker,
* so 401s, network failures, and malformed bodies all resolve to anonymous
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
*/
export async function fetchCurrentUser(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser | null> {
try {
const response = await fetchFn("/api/auth/me", {
method: "GET",
credentials: "same-origin",
});
if (!response.ok) return null;
const payload: unknown = await response.json();
const user = (payload as { user?: unknown } | null)?.user;
return isAuthUser(user) ? user : null;
} catch {
return null;
}
}
/**
* POST /api/auth/logout (→ 204). Swallows failures: the caller clears local
* state either way, and an unreachable server just means the cookie expires
* on its own.
*/
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
try {
await fetchFn("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} catch {
// Offline — nothing to do.
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* "Is this model already downloaded?" helper (Decisions.md D-005).
*
* Transformers.js v3 persists Hub downloads in the browser Cache API (Cache
* Storage, cache name "transformers-cache"). We inspect the cached request
* URLs and match them against catalog model ids. Used by `ModelPicker` for the
* downloaded badge (M2) and reused by the offline indicator (M3).
*
* `matchCachedModels` is pure so it can be unit-tested; only
* `getDownloadedModels` touches the `caches` global.
*/
/** Cache name used by Transformers.js v3 (`env.cacheName` default). */
export const TRANSFORMERS_CACHE_NAME = "transformers-cache";
/**
* Pure matcher: which of `modelIds` have at least one cached ONNX weight file?
*
* Cached URLs look like
* `https://huggingface.co/<modelId>/resolve/main/onnx/<file>.onnx`. We require
* an `.onnx` hit (not just tokenizer/config JSON) so a model whose weights
* never finished downloading does not show as available.
*/
export function matchCachedModels(
urls: Iterable<string>,
modelIds: readonly string[],
): Set<string> {
const downloaded = new Set<string>();
for (const url of urls) {
if (!url.split("?")[0]?.endsWith(".onnx")) continue;
for (const modelId of modelIds) {
if (!downloaded.has(modelId) && url.includes(`/${modelId}/`)) {
downloaded.add(modelId);
}
}
}
return downloaded;
}
/**
* Query the browser Cache API for models with cached weights. Returns an
* empty set where Cache Storage is unavailable (non-secure contexts, tests).
*/
export async function getDownloadedModels(
modelIds: readonly string[],
): Promise<Set<string>> {
if (typeof caches === "undefined") return new Set();
try {
const urls: string[] = [];
// Match any transformers-owned cache, in case the name is ever versioned.
for (const name of await caches.keys()) {
if (!name.includes("transformers")) continue;
const cache = await caches.open(name);
for (const request of await cache.keys()) urls.push(request.url);
}
return matchCachedModels(urls, modelIds);
} catch {
return new Set();
}
}
+260 -12
View File
@@ -1,20 +1,268 @@
/**
* Model catalog (Decisions.md D-006).
* Curated Whisper 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.
* Pure, typed data — no browser globals — so it is unit-testable with
* `bun test` and shareable with the server engine (M5). ONNX conversions come
* from the `onnx-community` Hub org (the recommended source for
* Transformers.js v3+).
*
* TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006).
* dtype defaults per device follow the community-known-good combinations:
* - WASM: q8 for small models, q4 for the large ones (download size).
* - WebGPU: fp16 encoder + q4 merged decoder for the big models; fp32 for
* tiny/base where fp16 has known accuracy issues.
* `verified` is only true for combinations we have actually run in a browser;
* the rest are pending manual testing (WorkBreakdown M2).
*/
import type { InferenceDevice } from "../worker/protocol";
import type {
DevicePreference,
DtypeSpec,
InferenceDevice,
WhisperDtype,
WhisperTask,
} from "../worker/protocol";
// ---------------------------------------------------------------------------
// Catalog types
// ---------------------------------------------------------------------------
/** Dtype default for one device, with verification status (D-006). */
export interface DtypeDefault {
dtype: DtypeSpec;
/** True only if this exact combination has been run in a real browser. */
verified: boolean;
/** Why this combination was chosen / caveats. */
note: string;
}
export interface WhisperModel {
/** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */
id: string;
/** Human-readable name for the picker. */
displayName: string;
/** False for `.en` (English-only) variants. */
multilingual: boolean;
/** Encoder parameter count, in millions (drives size estimates). */
encoderParamsM: number;
/** Decoder parameter count, in millions (drives size estimates). */
decoderParamsM: number;
/** Per-device dtype defaults (D-004 / D-006). */
defaults: Record<InferenceDevice, DtypeDefault>;
}
// ---------------------------------------------------------------------------
// Size estimation (approximate, shown before download — D-006)
// ---------------------------------------------------------------------------
/** Approximate stored bytes per parameter for each quantization level. */
export const BYTES_PER_PARAM: Record<WhisperDtype, number> = {
fp32: 4,
fp16: 2,
q8: 1.05, // int8 weights + scales/zero-points overhead
q4: 0.6, // 4-bit blocks + per-block scales; some tensors stay higher precision
};
/** Tokenizer + configs + preprocessor, roughly constant per model. */
const AUX_FILES_BYTES = 3_000_000;
/** Approximate total download size for a model at a given dtype spec. */
export function estimateDownloadBytes(
model: WhisperModel,
dtype: DtypeSpec,
): number {
const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model;
const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged;
return Math.round(
model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] +
model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] +
AUX_FILES_BYTES,
);
}
/** "487 MB" / "1.6 GB" style formatting for size estimates. */
export function formatModelSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.round(bytes / 1e6)} MB`;
}
// ---------------------------------------------------------------------------
// dtype defaults (community-known-good; see file header)
// ---------------------------------------------------------------------------
const WASM_Q8: DtypeDefault = {
dtype: "q8",
verified: false,
note: "q8 is the Transformers.js WASM default; good quality/size trade-off.",
};
const WASM_Q4: DtypeDefault = {
dtype: "q4",
verified: false,
note: "q4 keeps the download manageable for larger models on WASM.",
};
/** fp16 has known accuracy issues for the small Whisper encoders on WebGPU. */
const WEBGPU_FP32: DtypeDefault = {
dtype: { encoder_model: "fp32", decoder_model_merged: "fp32" },
verified: false,
note: "fp32 both parts: fp16 encoders are known-bad for tiny/base on WebGPU.",
};
const WEBGPU_FP16_Q4: DtypeDefault = {
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
verified: false,
note: "fp16 encoder + q4 merged decoder — the common working WebGPU setup.",
};
// ---------------------------------------------------------------------------
// The catalog (D-006: curated, not exhaustive)
// ---------------------------------------------------------------------------
export const MODELS: readonly WhisperModel[] = [
{
id: "onnx-community/whisper-tiny.en",
displayName: "Whisper Tiny (English)",
multilingual: false,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: {
// Verified in-browser during M1 (WASM, Transformers.js default q8).
wasm: { ...WASM_Q8, verified: true },
webgpu: WEBGPU_FP32,
},
},
{
id: "onnx-community/whisper-tiny",
displayName: "Whisper Tiny",
multilingual: true,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base.en",
displayName: "Whisper Base (English)",
multilingual: false,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base",
displayName: "Whisper Base",
multilingual: true,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-small.en",
displayName: "Whisper Small (English)",
multilingual: false,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-small",
displayName: "Whisper Small",
multilingual: true,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium.en",
displayName: "Whisper Medium (English)",
multilingual: false,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium",
displayName: "Whisper Medium",
multilingual: true,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-large-v3-turbo",
displayName: "Whisper Large v3 Turbo",
multilingual: true,
encoderParamsM: 635,
decoderParamsM: 174,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
];
export function getModel(id: string): WhisperModel | undefined {
return MODELS.find((model) => model.id === id);
}
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
export function resolveDefaultDtype(
modelId: string,
device: InferenceDevice,
): DtypeSpec {
return getModel(modelId)?.defaults[device].dtype ?? "q8";
}
/** Approximate download size using the model's default dtype for `device`. */
export function estimateDefaultDownloadBytes(
model: WhisperModel,
device: InferenceDevice,
): number {
return estimateDownloadBytes(model, model.defaults[device].dtype);
}
// ---------------------------------------------------------------------------
// Defaults & language list
// ---------------------------------------------------------------------------
/**
* 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";
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
export const DEFAULT_TASK: WhisperTask = "transcribe";
/** Sentinel for "let Whisper detect the language". */
export const AUTO_LANGUAGE = "auto";
/**
* Curated language list for multilingual models (Whisper language codes).
* Not exhaustive — Whisper supports ~100; extend on demand.
*/
export const WHISPER_LANGUAGES: readonly { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "da", label: "Danish" },
{ code: "de", label: "German" },
{ code: "es", label: "Spanish" },
{ code: "fr", label: "French" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "no", label: "Norwegian" },
{ code: "sv", label: "Swedish" },
{ code: "fi", label: "Finnish" },
{ code: "is", label: "Icelandic" },
{ code: "pl", label: "Polish" },
{ code: "pt", label: "Portuguese" },
{ code: "ro", label: "Romanian" },
{ code: "cs", label: "Czech" },
{ code: "el", label: "Greek" },
{ code: "hu", label: "Hungarian" },
{ code: "uk", label: "Ukrainian" },
{ code: "ru", label: "Russian" },
{ code: "tr", label: "Turkish" },
{ code: "ar", label: "Arabic" },
{ code: "he", label: "Hebrew" },
{ code: "hi", label: "Hindi" },
{ code: "id", label: "Indonesian" },
{ code: "vi", label: "Vietnamese" },
{ code: "th", label: "Thai" },
{ code: "zh", label: "Chinese" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
];
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import {
fetchCurrentUser,
login as loginRequest,
logout as logoutRequest,
register as registerRequest,
type AuthUser,
} from "./authClient";
export interface AuthState {
/** The signed-in user, or null while anonymous (the default). */
user: AuthUser | null;
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
login: (username: string, password: string) => Promise<void>;
/** Rejects with a UI-safe Error message on failure (400/409). */
register: (username: string, password: string) => Promise<void>;
/** Always resolves; local state is cleared even if the server is down. */
logout: () => Promise<void>;
}
/**
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
* only source of truth is GET /api/auth/me, probed once on mount.
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
* down) both resolve to anonymous, keeping the anonymous-first invariant:
* a failing /me must not surface any error.
*/
export function useAuth(): AuthState {
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
let cancelled = false;
fetchCurrentUser().then((current) => {
// Guard against StrictMode double-mounts / fast unmounts.
if (!cancelled && current) setUser(current);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (username: string, password: string) => {
setUser(await loginRequest(username, password));
}, []);
const register = useCallback(async (username: string, password: string) => {
// Register signs the user in immediately (the 201 sets the cookie).
setUser(await registerRequest(username, password));
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
return { user, login, register, logout };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+22 -4
View File
@@ -1,7 +1,25 @@
{
"$comment": "TODO(M3): finalize PWA manifest — icons, theme colors, installability (D-007).",
"name": "Whisper Web",
"short_name": "Whisper",
"name": "Whisper Web — on-device speech-to-text",
"short_name": "Whisper Web",
"description": "Speech-to-text that runs entirely in your browser via OpenAI Whisper. Works offline once installed with a downloaded model.",
"id": "/",
"start_url": "/",
"display": "standalone"
"scope": "/",
"display": "standalone",
"theme_color": "#16181d",
"background_color": "#16181d",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Precache-manifest helpers (Decisions.md D-007).
*
* `src/pwa/sw.ts` ships with two string placeholders; these helpers turn a
* `bun build` output listing into URL paths and splice them into the bundled
* service-worker source. Pure functions so they are unit-testable
* (tests/precache.test.ts); used by `scripts/build.ts` (production, real
* asset list) and `src/index.ts` (dev, empty list + "dev" version).
*/
/** Literal that `sw.ts` JSON.parses; replaced with the asset-list JSON. */
export const PRECACHE_PLACEHOLDER = '"__PRECACHE_MANIFEST__"';
/** Literal inside the SW's cache-name string; replaced with a build version. */
export const VERSION_PLACEHOLDER = "__CACHE_VERSION__";
/**
* Map bundler output paths (relative to the output dir, e.g. `index.html`,
* `chunk-abc123.js`) plus extra fixed routes to the URL paths the service
* worker should precache. Sourcemaps are excluded; `index.html` becomes `/`
* (the URL the app shell is actually served and navigated to).
*/
export function precachePathsFromOutputs(
outputPaths: Iterable<string>,
extraPaths: readonly string[] = [],
): string[] {
const paths = new Set<string>();
for (const outputPath of outputPaths) {
const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, "");
if (normalized.endsWith(".map")) continue;
paths.add(normalized === "index.html" ? "/" : `/${normalized}`);
}
for (const extra of extraPaths) paths.add(extra);
return [...paths].sort();
}
/**
* Replace the placeholders in bundled service-worker source with the actual
* precache list and cache version. Throws if a placeholder is missing (guards
* against the bundler mangling the literals) or if the version would produce
* an unusable cache name.
*/
export function injectPrecacheManifest(
swSource: string,
assetPaths: readonly string[],
version: string,
): string {
if (!/^[A-Za-z0-9._-]+$/.test(version)) {
throw new Error(`Invalid cache version "${version}"`);
}
if (!swSource.includes(PRECACHE_PLACEHOLDER)) {
throw new Error(`Service worker source is missing ${PRECACHE_PLACEHOLDER}`);
}
if (!swSource.includes(VERSION_PLACEHOLDER)) {
throw new Error(`Service worker source is missing ${VERSION_PLACEHOLDER}`);
}
return swSource
// Double-stringify: the placeholder is a *string literal* in the SW that
// gets JSON.parsed at runtime, so the replacement must be a JS string
// literal whose contents are the JSON array.
.replace(PRECACHE_PLACEHOLDER, JSON.stringify(JSON.stringify(assetPaths)))
.replace(VERSION_PLACEHOLDER, version);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Service-worker registration + update flow (Decisions.md D-007).
*
* Registered in both dev and production — the dev SW is network-first so HMR
* is unaffected. When an updated SW finishes installing while an old one is
* controlling the page, `onUpdateReady` fires with an `applyUpdate` callback;
* the UI shows a toast, and invoking the callback tells the waiting worker to
* skipWaiting and reloads once it takes control. The first-ever install
* (clients.claim with no prior controller) never triggers a reload.
*/
export interface RegisterOptions {
/** A new version is installed and waiting; call `applyUpdate` to activate + reload. */
onUpdateReady: (applyUpdate: () => void) => void;
/** The page is (or becomes) controlled by an active service worker. */
onControlled?: () => void;
}
export function registerServiceWorker(options: RegisterOptions): void {
if (!("serviceWorker" in navigator)) return;
const container = navigator.serviceWorker;
let userInitiatedUpdate = false;
container.addEventListener("controllerchange", () => {
// Only reload when the user accepted the update toast — the claim() on
// first install also fires this event and must not reload the page.
if (userInitiatedUpdate) location.reload();
else options.onControlled?.();
});
if (container.controller) options.onControlled?.();
container
.register("/sw.js")
.then((registration) => {
const notify = (worker: ServiceWorker) => {
options.onUpdateReady(() => {
userInitiatedUpdate = true;
worker.postMessage({ type: "SKIP_WAITING" });
});
};
// A version may already be waiting from a previous visit.
if (registration.waiting && container.controller) notify(registration.waiting);
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
if (installing.state === "installed" && container.controller) {
notify(installing);
}
});
});
})
.catch((error: unknown) => {
console.warn("Service worker registration failed:", error);
});
}
+131 -5
View File
@@ -1,10 +1,136 @@
/**
* Service worker (Decisions.md D-007) — TODO(M3).
* Service worker (Decisions.md D-007) — hand-rolled, no Workbox.
*
* 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).
* Strategy:
* - App shell: precached on install (production; list injected by
* `scripts/build.ts` via the placeholders below), cache-first thereafter.
* In dev the list is empty and the shell is served network-first with a
* cache fallback, so HMR keeps working while offline still functions.
* - `/ort/*` (onnxruntime .wasm/.mjs, served from our origin — see
* src/index.ts): runtime-cached, cache-first. These are required for
* offline inference but too large to precache eagerly.
* - `/api/*`: network-only — never cached (auth/server engine, M4/M5).
* - Model weights are NOT cached here (D-005): Transformers.js persists Hub
* downloads in its own `transformers-cache`, which we must never touch.
*/
// Placeholders replaced by injectPrecacheManifest (src/pwa/precache.ts).
// In dev src/index.ts injects an empty list + "dev"; in production
// scripts/build.ts injects the hashed dist asset list + a content hash.
const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[];
const VERSION = "__CACHE_VERSION__";
const SHELL_CACHE = `whisper-shell-${VERSION}`;
const RUNTIME_CACHE = `whisper-runtime-${VERSION}`;
/** Caches owned by this SW — anything else (e.g. transformers-cache) is kept. */
const OWN_CACHE_PREFIXES = ["whisper-shell-", "whisper-runtime-"];
// `lib: ["DOM"]` types `self` as Window; declare the worker-scope shape we
// use (same approach as src/worker/inference.worker.ts).
interface ExtendableEventLike extends Event {
waitUntil(promise: Promise<unknown>): void;
}
interface FetchEventLike extends ExtendableEventLike {
readonly request: Request;
respondWith(response: Promise<Response> | Response): void;
}
interface ServiceWorkerScope {
location: Location;
skipWaiting(): Promise<void>;
clients: { claim(): Promise<void> };
addEventListener(type: "install" | "activate", listener: (event: ExtendableEventLike) => void): void;
addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void;
addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
}
const sw = self as unknown as ServiceWorkerScope;
sw.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
if (PRECACHE.length === 0) return; // dev — runtime caching only
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(PRECACHE);
})(),
);
});
sw.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Drop caches from previous SW versions; never touch transformers-cache.
const stale = (await caches.keys()).filter(
(name) =>
OWN_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)) &&
name !== SHELL_CACHE &&
name !== RUNTIME_CACHE,
);
await Promise.all(stale.map((name) => caches.delete(name)));
// Control already-open pages so /ort/* caching works without a reload.
await sw.clients.claim();
})(),
);
});
// The update toast posts this after user consent (src/pwa/register.ts).
sw.addEventListener("message", (event) => {
const data = event.data as { type?: string } | null;
if (data?.type === "SKIP_WAITING") void sw.skipWaiting();
});
/** Serve from `cacheName`, populating it from the network on first miss. */
async function cacheFirst(cacheName: string, request: Request): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.status === 200) await cache.put(request, response.clone());
return response;
}
/**
* Fetch, mirroring successes into the shell cache; on network failure fall
* back to the cache (and `fallbackPath`, e.g. "/" for navigations).
*/
async function networkFirst(request: Request, fallbackPath: string | null): Promise<Response> {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request);
if (response.status === 200) await cache.put(request, response.clone());
return response;
} catch (error) {
const hit = await cache.match(request);
if (hit) return hit;
if (fallbackPath) {
const fallback = await cache.match(fallbackPath);
if (fallback) return fallback;
}
throw error;
}
}
sw.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Cross-origin (Hugging Face Hub model weights, etc.): hands off —
// Transformers.js caches those itself in transformers-cache (D-005).
if (url.origin !== sw.location.origin) return;
// API is network-only (D-007) — auth and the server engine must never be
// served stale from cache.
if (url.pathname.startsWith("/api/")) return;
if (url.pathname.startsWith("/ort/")) {
event.respondWith(cacheFirst(RUNTIME_CACHE, request));
return;
}
if (PRECACHE.includes(url.pathname)) {
event.respondWith(cacheFirst(SHELL_CACHE, request));
return;
}
// Anything else (dev's unhashed shell, navigations to deep links):
// network-first with cache fallback; navigations fall back to the shell.
event.respondWith(networkFirst(request, request.mode === "navigate" ? "/" : null));
});
export {};
+287 -7
View File
@@ -1,11 +1,291 @@
/**
* Auth — TODO(M4).
* Auth: Passport.js local strategy behind a connect-compat adapter over
* Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite
* (D-009).
*
* 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.
* Passport expects connect/express-style middleware, which Bun.serve does not
* have. The adapter here is deliberately minimal and honest: it shims only
* what `passport.authenticate("local", callback)` actually touches — the
* strategy reads `req.body`/`req.query` and, because we pass a custom
* callback with `session: false`, never writes to `res`. Session issuance,
* cookies, and serialization are driven by us around that call rather than by
* passport's own connect middleware.
*
* Routes implemented (wired in src/index.ts, shared by dev + production):
* POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409
* POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401
* POST /api/auth/logout → 204 (always) + cookie cleared
* GET /api/auth/me → 200 {user} | 401
*
* Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days;
* Secure when NODE_ENV=production. Expiry is enforced server-side via
* sessions.expires_at — a replayed cookie past that instant is rejected even
* though the browser would still send it.
*/
export {};
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { type AppDb, type User, getDb } from "./db";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** The safe-to-serialize projection of a user (never the password hash). */
export interface SessionUser {
id: number;
username: string;
}
export interface AuthHandlers {
register(request: Request): Promise<Response>;
login(request: Request): Promise<Response>;
logout(request: Request): Promise<Response>;
me(request: Request): Promise<Response>;
/**
* Resolves the user behind a request's `sid` cookie, or null. Exported so
* the M5 transcribe endpoint can guard itself with the same session logic.
*/
getSessionUser(request: Request): SessionUser | null;
}
// ---------------------------------------------------------------------------
// Constants + small helpers
// ---------------------------------------------------------------------------
const COOKIE_NAME = "sid";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MIN_PASSWORD_LENGTH = 8;
function json(body: unknown, status: number, headers?: HeadersInit): Response {
const h = new Headers(headers);
h.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), { status, headers: h });
}
/** Serializes the session cookie per the contract in the module doc comment. */
function sessionCookie(sid: string, maxAgeSeconds: number): string {
const attributes = [
`${COOKIE_NAME}=${sid}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${maxAgeSeconds}`,
];
// Checked per-call (not at module load) so tests can flip NODE_ENV.
if (process.env.NODE_ENV === "production") attributes.push("Secure");
return attributes.join("; ");
}
/** Max-Age=0 tells the browser to drop the cookie immediately. */
const clearedCookie = (): string => sessionCookie("", 0);
/** Extracts the `sid` cookie value from a request, if any. */
function readSessionId(request: Request): string | null {
const header = request.headers.get("cookie");
if (!header) return null;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === COOKIE_NAME) {
const value = part.slice(eq + 1).trim();
return value.length > 0 ? value : null;
}
}
return null;
}
const toSessionUser = (user: User): SessionUser => ({
id: user.id,
username: user.username,
});
interface Credentials {
username: string;
password: string;
}
/**
* Parses a JSON `{username, password}` body. Returns null for anything that
* is not that shape (bad JSON, wrong types) — callers answer 400.
*/
async function parseCredentials(request: Request): Promise<Credentials | null> {
let body: unknown;
try {
body = await request.json();
} catch {
return null;
}
if (typeof body !== "object" || body === null) return null;
const { username, password } = body as Record<string, unknown>;
if (typeof username !== "string" || typeof password !== "string") return null;
return { username, password };
}
/**
* Argon2id hash used to burn comparable time when the username does not
* exist, so login latency doesn't reveal which usernames are registered.
* Computed lazily (hashing is deliberately slow) and cached.
*/
let dummyHash: Promise<string> | undefined;
const getDummyHash = (): Promise<string> =>
(dummyHash ??= Bun.password.hash("dummy-password-for-constant-time"));
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Builds the four route handlers plus `getSessionUser` against a database
* source. Taking a getter (not an instance) keeps the default export lazy:
* importing this module never opens ./data/app.db, and tests inject
* `() => createDb(":memory:")` databases without touching disk.
*/
export function createAuth(getDatabase: () => AppDb): AuthHandlers {
// A private Authenticator instance (not the module-level singleton) so two
// createAuth() calls — e.g. app + tests — can't clobber each other's
// strategy registration.
const authenticator = new passport.Passport();
authenticator.use(
new LocalStrategy((username, password, done) => {
// passport-local's verify is callback-style; bridge to async/await and
// route thrown errors into done() so they surface as 500s, not hangs.
void (async () => {
const user = getDatabase().findUserByUsername(username);
if (!user) {
await Bun.password.verify(password, await getDummyHash());
return done(null, false, { message: "invalid credentials" });
}
const ok = await Bun.password.verify(password, user.passwordHash);
return ok
? done(null, user)
: done(null, false, { message: "invalid credentials" });
})().catch(done);
}),
);
/**
* The connect-compat adapter (D-008): run passport's authenticate chain
* against a shim of exactly the surface passport-local reads. With
* `session: false` and a custom callback, passport calls back with
* (err, user|false) and never touches `res`, so an empty object suffices.
*/
function authenticateLocal(credentials: Credentials): Promise<User | false> {
return new Promise((resolve, reject) => {
const req = {
body: credentials,
query: {},
headers: {},
method: "POST",
url: "/api/auth/login",
};
const middleware = authenticator.authenticate(
"local",
{ session: false },
(err: unknown, user: unknown) =>
err ? reject(err) : resolve((user as User | false) || false),
) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
// next() only fires on internal errors here (the callback intercepts
// success and failure), so treat any call as a failure path.
middleware(req, {}, (err?: unknown) =>
err ? reject(err) : resolve(false),
);
});
}
/** Issues a fresh session row + Set-Cookie header for `user`. */
function issueSession(user: User): { user: SessionUser; cookie: string } {
const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS);
return {
user: toSessionUser(user),
cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000),
};
}
function getSessionUser(request: Request): SessionUser | null {
const sid = readSessionId(request);
if (!sid) return null;
const db = getDatabase();
const session = db.findValidSession(sid);
if (!session) return null;
const user = db.findUserById(session.userId);
return user ? toSessionUser(user) : null;
}
return {
async register(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
if (!USERNAME_RE.test(credentials.username)) {
return json(
{ error: "username must be 3-32 characters (letters, digits, _ . -)" },
400,
);
}
if (credentials.password.length < MIN_PASSWORD_LENGTH) {
return json(
{ error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` },
400,
);
}
// Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4.
const passwordHash = await Bun.password.hash(credentials.password);
const user = getDatabase().createUser(credentials.username, passwordHash);
// createUser lets the UNIQUE constraint arbitrate, so concurrent
// registrations of the same name can't both succeed.
if (!user) return json({ error: "username taken" }, 409);
const issued = issueSession(user);
return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie });
},
async login(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
const user = await authenticateLocal(credentials);
// One message for both unknown-user and wrong-password: no username
// enumeration via response text (timing handled by the dummy hash).
if (!user) return json({ error: "invalid credentials" }, 401);
const issued = issueSession(user);
return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie });
},
async logout(request) {
const sid = readSessionId(request);
if (sid) getDatabase().deleteSession(sid);
// Idempotent by contract: 204 whether or not a session existed. No body
// on 204, but always clear the cookie so stale clients converge.
return new Response(null, {
status: 204,
headers: { "Set-Cookie": clearedCookie() },
});
},
async me(request) {
const user = getSessionUser(request);
return user
? json({ user }, 200)
: json({ error: "not authenticated" }, 401);
},
getSessionUser,
};
}
// ---------------------------------------------------------------------------
// Default instance (the real app database) + convenience re-export for M5
// ---------------------------------------------------------------------------
export const auth: AuthHandlers = createAuth(getDb);
/** Session lookup against the app database — see AuthHandlers.getSessionUser. */
export function getSessionUser(request: Request): SessionUser | null {
return auth.getSessionUser(request);
}
+202 -5
View File
@@ -1,9 +1,206 @@
/**
* Database — TODO(M4).
* Users + sessions in bun:sqlite (Decisions.md D-009).
*
* 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.
* Single-file database; path from env `DB_PATH`, default ./data/app.db. All
* access goes through the typed helpers on {@link AppDb} — no raw SQL leaks
* out of this module, so the (deliberately tiny) schema can evolve in one
* place.
*
* Testability: `createDb(path)` is a pure factory — tests pass ":memory:" and
* never touch the on-disk default. The shared instance is created lazily by
* `getDb()`, so merely importing this module has no filesystem side effects.
*/
export {};
import { Database } from "bun:sqlite";
import { randomBytes } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
// ---------------------------------------------------------------------------
// Domain types (camelCase; the snake_case row shapes stay private below)
// ---------------------------------------------------------------------------
export interface User {
id: number;
username: string;
/** Argon2id hash produced by `Bun.password.hash` — never the plaintext. */
passwordHash: string;
/** Milliseconds since epoch. */
createdAt: number;
}
export interface Session {
/** 256-bit random id (base64url); doubles as the `sid` cookie value. */
id: string;
userId: number;
/** Milliseconds since epoch; sessions past this instant are invalid. */
expiresAt: number;
}
export interface AppDb {
/** Returns the new user, or null when the username is already taken. */
createUser(username: string, passwordHash: string): User | null;
findUserByUsername(username: string): User | null;
findUserById(id: number): User | null;
/** Creates a session for `userId`; also sweeps expired rows opportunistically. */
createSession(userId: number, expiresAt: number): Session;
/**
* Returns the session only when it exists AND has not expired — expiry is
* enforced here, server-side, regardless of what the cookie claims.
*/
findValidSession(id: string, now?: number): Session | null;
deleteSession(id: string): void;
/** Removes all sessions with expires_at <= now; returns the count removed. */
deleteExpiredSessions(now?: number): number;
close(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
interface UserRow {
id: number;
username: string;
password_hash: string;
created_at: number;
}
interface SessionRow {
id: string;
user_id: number;
expires_at: number;
}
const toUser = (row: UserRow): User => ({
id: row.id,
username: row.username,
passwordHash: row.password_hash,
createdAt: row.created_at,
});
const toSession = (row: SessionRow): Session => ({
id: row.id,
userId: row.user_id,
expiresAt: row.expires_at,
});
/**
* Session ids come from the CSPRNG: 32 random bytes = 256 bits, well past the
* ~128-bit unguessability floor, encoded base64url so the value is safe in a
* cookie without escaping.
*/
export function newSessionId(): string {
return randomBytes(32).toString("base64url");
}
/** Opens (creating if needed) a database at `path` and applies the schema. */
export function createDb(path: string): AppDb {
if (path !== ":memory:") {
// bun:sqlite creates the file but not its parent directory.
mkdirSync(dirname(path), { recursive: true });
}
const db = new Database(path, { create: true, strict: true });
// WAL lets reads proceed during writes; harmless no-op for ":memory:".
db.exec("PRAGMA journal_mode = WAL;");
// Enforce sessions.user_id → users.id (off by default in SQLite).
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
`);
const insertUser = db.query<UserRow, [string, string, number]>(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?) RETURNING *",
);
const userByUsername = db.query<UserRow, [string]>(
"SELECT * FROM users WHERE username = ?",
);
const userById = db.query<UserRow, [number]>("SELECT * FROM users WHERE id = ?");
const insertSession = db.query<SessionRow, [string, number, number]>(
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?) RETURNING *",
);
const sessionById = db.query<SessionRow, [string, number]>(
"SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
);
const removeSession = db.query("DELETE FROM sessions WHERE id = ?");
const removeExpired = db.query("DELETE FROM sessions WHERE expires_at <= ?");
return {
createUser(username, passwordHash) {
try {
return toUser(insertUser.get(username, passwordHash, Date.now())!);
} catch (error) {
// The UNIQUE constraint is the source of truth for "username taken" —
// checking first would race concurrent registrations.
if (
error instanceof Error &&
(error as { code?: string }).code?.startsWith("SQLITE_CONSTRAINT")
) {
return null;
}
throw error;
}
},
findUserByUsername(username) {
const row = userByUsername.get(username);
return row ? toUser(row) : null;
},
findUserById(id) {
const row = userById.get(id);
return row ? toUser(row) : null;
},
createSession(userId, expiresAt) {
// Piggyback garbage collection on the write path so stale rows never
// accumulate unboundedly without needing a background timer.
removeExpired.run(Date.now());
return toSession(insertSession.get(newSessionId(), userId, expiresAt)!);
},
findValidSession(id, now = Date.now()) {
const row = sessionById.get(id, now);
return row ? toSession(row) : null;
},
deleteSession(id) {
removeSession.run(id);
},
deleteExpiredSessions(now = Date.now()) {
return removeExpired.run(now).changes;
},
close() {
db.close();
},
};
}
// ---------------------------------------------------------------------------
// Lazily-created default instance (the real app database)
// ---------------------------------------------------------------------------
export const DEFAULT_DB_PATH = "./data/app.db";
let defaultDb: AppDb | undefined;
/** The application database (env `DB_PATH`, default ./data/app.db — gitignored). */
export function getDb(): AppDb {
defaultDb ??= createDb(process.env.DB_PATH ?? DEFAULT_DB_PATH);
return defaultDb;
}
+135 -23
View File
@@ -2,24 +2,38 @@
* 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.
* M2: WebGPU detection with automatic WASM fallback (D-004), catalog-driven
* dtype defaults (D-006), model switching with pipeline disposal, and
* language/task options for multilingual models.
*/
import {
env,
pipeline,
WhisperTextStreamer,
type AutomaticSpeechRecognitionPipeline,
type ProgressInfo,
} from "@huggingface/transformers";
import { resolveDefaultDtype } from "../lib/models";
import {
isUiToWorkerMessage,
type DevicePreference,
type InferenceDevice,
type LoadMessage,
type ResultMessage,
type TranscribeMessage,
type TranscriptChunk,
type WorkerToUiMessage,
} from "./protocol";
// Load onnxruntime's .wasm binaries from our own origin instead of the
// jsdelivr CDN default — required for offline use (M3). The server exposes
// them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts)
// and the service worker runtime-caches them cache-first (src/pwa/sw.ts).
if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = "/ort/";
}
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
interface DedicatedWorkerScope {
postMessage(message: WorkerToUiMessage): void;
@@ -38,7 +52,12 @@ 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. */
/**
* Forward Transformers.js progress events to the UI as load-progress messages.
* The library's own "ready" event is swallowed — the worker posts its own
* `ready` (with the selected device) once the pipeline actually resolved,
* which also covers the WebGPU → WASM fallback retry (D-004).
*/
function forwardProgress(info: ProgressInfo): void {
switch (info.status) {
case "initiate":
@@ -56,29 +75,113 @@ function forwardProgress(info: ProgressInfo): void {
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.
// "ready" (posted by the worker itself, with the device) and
// "progress_total" — the UI derives totals per-file itself.
break;
}
}
let asrPromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
// ---------------------------------------------------------------------------
// Device selection (D-004): WebGPU when available, automatic WASM fallback
// ---------------------------------------------------------------------------
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;
interface NavigatorWithGpu {
gpu?: { requestAdapter(): Promise<unknown | null> };
}
async function transcribe(audio: Float32Array): Promise<void> {
if (asrPromise === null) {
async function detectDevice(preference: DevicePreference): Promise<InferenceDevice> {
if (preference === "wasm") return "wasm";
try {
const adapter = await (navigator as NavigatorWithGpu).gpu?.requestAdapter();
return adapter ? "webgpu" : "wasm";
} catch {
return "wasm";
}
}
// ---------------------------------------------------------------------------
// Pipeline lifecycle (load, switch, dispose)
// ---------------------------------------------------------------------------
interface ActivePipeline {
/** Identity of this load request; a different key means "switch models". */
key: string;
promise: Promise<AutomaticSpeechRecognitionPipeline>;
}
let active: ActivePipeline | null = null;
function loadKey(message: LoadMessage): string {
return JSON.stringify({
modelId: message.modelId,
device: message.device,
dtype: message.dtype ?? null,
});
}
async function createPipeline(
message: LoadMessage,
): Promise<AutomaticSpeechRecognitionPipeline> {
const detected = await detectDevice(message.device);
// If WebGPU was selected but pipeline creation throws (driver quirks,
// unsupported ops, out-of-memory), fall back to WASM automatically (D-004).
const attempts: InferenceDevice[] =
detected === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
let lastError: unknown;
for (const device of attempts) {
const dtype = message.dtype ?? resolveDefaultDtype(message.modelId, device);
try {
const asr = await pipeline("automatic-speech-recognition", message.modelId, {
device,
dtype,
progress_callback: forwardProgress,
});
post({ type: "load-progress", status: "ready", device });
return asr;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
const key = loadKey(message);
// Idempotent: repeated "load" messages for the same model/device/dtype reuse
// the in-flight or completed pipeline.
if (active?.key === key) return active.promise;
const previous = active;
const promise = (async () => {
if (previous) {
// Dispose the old pipeline (frees WASM memory / GPU buffers) before
// creating the new one, so both never coexist.
try {
const old = await previous.promise;
await old.dispose();
} catch {
// The previous load itself failed — nothing to dispose.
}
}
return createPipeline(message);
})();
active = { key, promise };
promise.catch(() => {
// Allow a clean retry after a failed load; ignore stale failures that
// were already superseded by a newer load request.
if (active?.key === key) active = null;
});
return promise;
}
// ---------------------------------------------------------------------------
// Transcription
// ---------------------------------------------------------------------------
async function transcribe(message: TranscribeMessage): Promise<void> {
if (active === null) {
post({
type: "error",
stage: "transcribe",
@@ -86,7 +189,7 @@ async function transcribe(audio: Float32Array): Promise<void> {
});
return;
}
const asr = await asrPromise;
const asr = await active.promise;
const streamer = new WhisperTextStreamer(
// The ASR pipeline for Whisper models always carries a WhisperTokenizer.
@@ -97,12 +200,22 @@ async function transcribe(audio: Float32Array): Promise<void> {
},
);
const output = await asr(audio, {
const options: Record<string, unknown> = {
chunk_length_s: 30,
stride_length_s: 5,
return_timestamps: true,
streamer,
});
};
// Only meaningful for multilingual models; the UI omits these for `.en`
// variants. "auto" / undefined lets Whisper detect the language.
if (message.language !== undefined && message.language !== "auto") {
options.language = message.language;
}
if (message.task !== undefined) {
options.task = message.task;
}
const output = await asr(message.audio, options);
const single = (Array.isArray(output) ? output[0] : output) as {
text?: string;
@@ -129,12 +242,11 @@ scope.addEventListener("message", (event) => {
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) => {
transcribe(data).catch((error: unknown) => {
post({ type: "error", stage: "transcribe", message: errorMessage(error) });
});
break;
+83 -10
View File
@@ -6,8 +6,29 @@
* be unit-tested with `bun test`.
*/
/** Devices supported by the worker. M1 is WASM-only; M2 adds "webgpu" (D-004). */
export type InferenceDevice = "wasm";
/** Devices the worker can actually run on (D-004). */
export type InferenceDevice = "webgpu" | "wasm";
/**
* What the UI may request. "auto" (the default) lets the worker probe
* `navigator.gpu` and pick WebGPU with automatic WASM fallback (D-004).
*/
export type DevicePreference = InferenceDevice | "auto";
/** Quantization levels used by the curated catalog (D-006). */
export type WhisperDtype = "fp32" | "fp16" | "q8" | "q4";
/**
* Either one dtype for the whole model, or per-part dtypes — the shape
* Transformers.js accepts. Whisper on WebGPU commonly needs a mixed setup
* (e.g. fp16 encoder + q4 merged decoder), so per-part must be expressible.
*/
export type DtypeSpec =
| WhisperDtype
| { encoder_model: WhisperDtype; decoder_model_merged: WhisperDtype };
/** Whisper generation task (multilingual models only). */
export type WhisperTask = "transcribe" | "translate";
// ---------------------------------------------------------------------------
// UI → worker
@@ -17,7 +38,13 @@ export interface LoadMessage {
type: "load";
/** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */
modelId: string;
device: InferenceDevice;
/** Device preference; the worker resolves "auto" and reports the outcome. */
device: DevicePreference;
/**
* Optional dtype override. When omitted the worker resolves the catalog
* default for the device it actually selected (src/lib/models.ts).
*/
dtype?: DtypeSpec;
}
export interface TranscribeMessage {
@@ -27,6 +54,13 @@ export interface TranscribeMessage {
* should be passed as a transferable for zero-copy handoff.
*/
audio: Float32Array;
/**
* ISO-ish Whisper language code (e.g. "en", "da"). Omit (or "auto") to let
* Whisper detect the language. Only meaningful for multilingual models.
*/
language?: string;
/** Only meaningful for multilingual models. Defaults to "transcribe". */
task?: WhisperTask;
}
export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
@@ -38,7 +72,8 @@ export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
/**
* Model download / initialization progress. Mirrors the Transformers.js
* `progress_callback` events we care about; `status: "ready"` marks the end
* of a successful load.
* of a successful load and reports the backend that actually got selected
* (after WebGPU detection / fallback, D-004).
*/
export type LoadProgressMessage =
| { type: "load-progress"; status: "initiate"; file: string }
@@ -55,7 +90,7 @@ export type LoadProgressMessage =
progress: number;
}
| { type: "load-progress"; status: "done"; file: string }
| { type: "load-progress"; status: "ready" };
| { type: "load-progress"; status: "ready"; device: InferenceDevice };
/** Incremental transcript fragment — append to the text streamed so far. */
export interface PartialMessage {
@@ -96,13 +131,45 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const DEVICES: readonly string[] = ["webgpu", "wasm"];
const DTYPES: readonly string[] = ["fp32", "fp16", "q8", "q4"];
export function isInferenceDevice(value: unknown): value is InferenceDevice {
return typeof value === "string" && DEVICES.includes(value);
}
export function isDevicePreference(value: unknown): value is DevicePreference {
return value === "auto" || isInferenceDevice(value);
}
export function isDtypeSpec(value: unknown): value is DtypeSpec {
if (typeof value === "string") return DTYPES.includes(value);
return (
isRecord(value) &&
typeof value.encoder_model === "string" &&
DTYPES.includes(value.encoder_model) &&
typeof value.decoder_model_merged === "string" &&
DTYPES.includes(value.decoder_model_merged)
);
}
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";
return (
typeof value.modelId === "string" &&
isDevicePreference(value.device) &&
(value.dtype === undefined || isDtypeSpec(value.dtype))
);
case "transcribe":
return value.audio instanceof Float32Array;
return (
value.audio instanceof Float32Array &&
(value.language === undefined || typeof value.language === "string") &&
(value.task === undefined ||
value.task === "transcribe" ||
value.task === "translate")
);
default:
return false;
}
@@ -125,7 +192,7 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
typeof value.progress === "number"
);
case "ready":
return true;
return isInferenceDevice(value.device);
default:
return false;
}
@@ -159,9 +226,15 @@ export interface LoadProgressState {
files: Record<string, FileProgress>;
/** True once the pipeline reported ready. */
ready: boolean;
/** Backend the worker actually selected; null until ready (D-004). */
device: InferenceDevice | null;
}
export const initialLoadProgress: LoadProgressState = { files: {}, ready: false };
export const initialLoadProgress: LoadProgressState = {
files: {},
ready: false,
device: null,
};
/** Fold one load-progress message into the accumulated state. Pure. */
export function applyLoadProgress(
@@ -169,7 +242,7 @@ export function applyLoadProgress(
message: LoadProgressMessage,
): LoadProgressState {
if (message.status === "ready") {
return { ...state, ready: true };
return { ...state, ready: true, device: message.device };
}
const previous = state.files[message.file];
const next: FileProgress = {
+178
View File
@@ -0,0 +1,178 @@
import { describe, test, expect } from "bun:test";
import {
fetchCurrentUser,
login,
logout,
register,
validatePassword,
validateUsername,
type FetchLike,
} from "../src/lib/authClient";
// The auth client is framework-free with an injectable fetch, so we can test
// validation, the request contract, and the fail-silent /me behaviour without
// a DOM or a running server (the M4 backend is exercised separately).
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** Mock fetch returning a fixed response and recording the call. */
function mockFetch(response: Response | Error): {
fetch: FetchLike;
calls: { input: string; init?: RequestInit }[];
} {
const calls: { input: string; init?: RequestInit }[] = [];
const fetch: FetchLike = (input, init) => {
calls.push({ input, init });
return response instanceof Error
? Promise.reject(response)
: Promise.resolve(response);
};
return { fetch, calls };
}
const USER = { id: 1, username: "alice" };
describe("validateUsername", () => {
test("accepts the allowed charset within 3–32 chars", () => {
expect(validateUsername("abc")).toBeNull(); // lower bound
expect(validateUsername("a".repeat(32))).toBeNull(); // upper bound
expect(validateUsername("User_1.name-x")).toBeNull();
});
test("rejects usernames that are too short or too long", () => {
expect(validateUsername("")).not.toBeNull();
expect(validateUsername("ab")).not.toBeNull();
expect(validateUsername("a".repeat(33))).not.toBeNull();
});
test("rejects characters outside [a-zA-Z0-9_.-]", () => {
expect(validateUsername("has space")).not.toBeNull();
expect(validateUsername("email@host")).not.toBeNull();
expect(validateUsername("émile")).not.toBeNull();
});
});
describe("validatePassword", () => {
test("requires at least 8 characters", () => {
expect(validatePassword("1234567")).not.toBeNull();
expect(validatePassword("12345678")).toBeNull();
});
});
describe("fetchCurrentUser (fail-silent /me)", () => {
test("returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await fetchCurrentUser(fetch)).toEqual(USER);
// The cookie is HttpOnly — it must ride along via same-origin credentials.
expect(calls[0]?.input).toBe("/api/auth/me");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves to anonymous (null) on 401", async () => {
const { fetch } = mockFetch(jsonResponse(401, { error: "unauthorized" }));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous on network failure (offline-first)", async () => {
// /api/* is network-only in the service worker, so offline /me rejects.
// That must NOT throw or surface an error — just stay anonymous.
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(await fetchCurrentUser(fetch)).toBeNull();
});
test("resolves to anonymous on a malformed body", async () => {
const { fetch } = mockFetch(
new Response("<html>proxy error</html>", { status: 200 }),
);
expect(await fetchCurrentUser(fetch)).toBeNull();
});
});
describe("login", () => {
test("POSTs JSON credentials and returns the user on 200", async () => {
const { fetch, calls } = mockFetch(jsonResponse(200, { user: USER }));
expect(await login("alice", "hunter22", fetch)).toEqual(USER);
const call = calls[0];
expect(call?.input).toBe("/api/auth/login");
expect(call?.init?.method).toBe("POST");
expect(call?.init?.credentials).toBe("same-origin");
expect(
(call?.init?.headers as Record<string, string>)["Content-Type"],
).toBe("application/json");
expect(JSON.parse(call?.init?.body as string)).toEqual({
username: "alice",
password: "hunter22",
});
});
test("surfaces the server's error message on 401", async () => {
const { fetch } = mockFetch(
jsonResponse(401, { error: "invalid credentials" }),
);
expect(login("alice", "wrong-pass", fetch)).rejects.toThrow(
"invalid credentials",
);
});
test("throws a UI-safe message on network failure", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/could not reach the server/i,
);
});
test("throws on an unexpected success body", async () => {
const { fetch } = mockFetch(jsonResponse(200, { nope: true }));
expect(login("alice", "hunter22", fetch)).rejects.toThrow(
/unexpected response/i,
);
});
});
describe("register", () => {
test("returns the user on 201", async () => {
const { fetch, calls } = mockFetch(jsonResponse(201, { user: USER }));
expect(await register("alice", "hunter22", fetch)).toEqual(USER);
expect(calls[0]?.input).toBe("/api/auth/register");
});
test("surfaces {error} on 409 (username taken)", async () => {
const { fetch } = mockFetch(jsonResponse(409, { error: "username taken" }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"username taken",
);
});
test("surfaces {error} on 400 (server-side validation)", async () => {
const { fetch } = mockFetch(jsonResponse(400, { error: "invalid input" }));
expect(register("a", "short", fetch)).rejects.toThrow("invalid input");
});
test("falls back to a generic message when the error body is not JSON", async () => {
const { fetch } = mockFetch(new Response("boom", { status: 500 }));
expect(register("alice", "hunter22", fetch)).rejects.toThrow(
"Request failed (500).",
);
});
});
describe("logout", () => {
test("POSTs to /api/auth/logout with credentials", async () => {
const { fetch, calls } = mockFetch(new Response(null, { status: 204 }));
await logout(fetch);
expect(calls[0]?.input).toBe("/api/auth/logout");
expect(calls[0]?.init?.method).toBe("POST");
expect(calls[0]?.init?.credentials).toBe("same-origin");
});
test("resolves even when the server is unreachable", async () => {
const { fetch } = mockFetch(new TypeError("Failed to fetch"));
await logout(fetch); // must not throw — local state is cleared regardless
});
});
+217
View File
@@ -0,0 +1,217 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { createDb, type AppDb } from "../src/server/db";
import { createAuth, type AuthHandlers } from "../src/server/auth";
/**
* M4 auth tests (WorkBreakdown M4): exercised through the exported handlers
* against an in-memory bun:sqlite database — hermetic, no server socket, and
* the on-disk default (./data/app.db) is never touched because createAuth
* takes an injected database getter.
*/
let db: AppDb;
let auth: AuthHandlers;
beforeAll(() => {
db = createDb(":memory:");
auth = createAuth(() => db);
});
afterAll(() => db.close());
// --- Request helpers --------------------------------------------------------
const BASE = "http://localhost";
function postJson(path: string, body: unknown, cookie?: string): Request {
return new Request(`${BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
}
function get(path: string, cookie?: string): Request {
return new Request(`${BASE}${path}`, {
headers: cookie ? { cookie } : {},
});
}
/** Extracts "sid=<value>" from a Set-Cookie header for replay in later requests. */
function sidCookie(response: Response): string {
const header = response.headers.get("set-cookie");
expect(header).toBeTruthy();
const match = /(?:^|;\s*)sid=([^;]+)/.exec(header!);
expect(match).toBeTruthy();
return `sid=${match![1]}`;
}
async function registerUser(username: string, password = "hunter2secret") {
const response = await auth.register(postJson("/api/auth/register", { username, password }));
expect(response.status).toBe(201);
return response;
}
// --- Register ----------------------------------------------------------------
describe("POST /api/auth/register", () => {
test("201 with user body and session cookie", async () => {
const response = await registerUser("alice");
expect(response.headers.get("content-type")).toStartWith("application/json");
const body = await response.json();
expect(body.user.username).toBe("alice");
expect(typeof body.user.id).toBe("number");
// Password material must never leak into the response.
expect(JSON.stringify(body)).not.toContain("hash");
const cookie = response.headers.get("set-cookie")!;
expect(cookie).toContain("sid=");
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
expect(cookie).toContain("Path=/");
expect(cookie).toContain("Max-Age=604800");
// Not production in tests, so no Secure attribute.
expect(cookie).not.toContain("Secure");
});
test("password is stored as an Argon2 hash, not plaintext", () => {
const user = db.findUserByUsername("alice");
expect(user).not.toBeNull();
expect(user!.passwordHash).toStartWith("$argon2");
});
test("409 for a duplicate username", async () => {
const response = await auth.register(
postJson("/api/auth/register", { username: "alice", password: "hunter2secret" }),
);
expect(response.status).toBe(409);
expect((await response.json()).error).toBeString();
});
test.each([
["short username", { username: "ab", password: "hunter2secret" }],
["long username", { username: "x".repeat(33), password: "hunter2secret" }],
["bad username characters", { username: "not ok!", password: "hunter2secret" }],
["short password", { username: "bob", password: "1234567" }],
["missing password", { username: "bob" }],
["non-string fields", { username: 42, password: 12345678 }],
])("400 for %s", async (_label, body) => {
const response = await auth.register(postJson("/api/auth/register", body));
expect(response.status).toBe(400);
expect((await response.json()).error).toBeString();
});
test("400 for a non-JSON body", async () => {
const response = await auth.register(
new Request(`${BASE}/api/auth/register`, { method: "POST", body: "not json" }),
);
expect(response.status).toBe(400);
});
});
// --- Login --------------------------------------------------------------------
describe("POST /api/auth/login", () => {
test("200 with user body and fresh session cookie", async () => {
const response = await auth.login(
postJson("/api/auth/login", { username: "alice", password: "hunter2secret" }),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toStartWith("application/json");
expect((await response.json()).user.username).toBe("alice");
expect(sidCookie(response)).toStartWith("sid=");
});
test("401 with the same message for wrong password and unknown user", async () => {
const wrongPassword = await auth.login(
postJson("/api/auth/login", { username: "alice", password: "wrong-password" }),
);
const unknownUser = await auth.login(
postJson("/api/auth/login", { username: "nobody", password: "hunter2secret" }),
);
expect(wrongPassword.status).toBe(401);
expect(unknownUser.status).toBe(401);
// Identical bodies: responses must not reveal which part was wrong.
expect(await wrongPassword.json()).toEqual({ error: "invalid credentials" });
expect(await unknownUser.json()).toEqual({ error: "invalid credentials" });
});
});
// --- Session lifecycle (/me, logout, expiry) -----------------------------------
describe("GET /api/auth/me + logout + expiry", () => {
test("200 with a valid session cookie", async () => {
const registered = await registerUser("carol");
const cookie = sidCookie(registered);
const response = await auth.me(get("/api/auth/me", cookie));
expect(response.status).toBe(200);
expect((await response.json()).user.username).toBe("carol");
});
test("401 without a cookie", async () => {
const response = await auth.me(get("/api/auth/me"));
expect(response.status).toBe(401);
expect((await response.json()).error).toBeString();
});
test("401 with a garbage session id", async () => {
const response = await auth.me(get("/api/auth/me", "sid=definitely-not-a-session"));
expect(response.status).toBe(401);
});
test("logout deletes the session and clears the cookie; /me then 401s", async () => {
const registered = await registerUser("dave");
const cookie = sidCookie(registered);
expect((await auth.me(get("/api/auth/me", cookie))).status).toBe(200);
const logout = await auth.logout(postJson("/api/auth/logout", {}, cookie));
expect(logout.status).toBe(204);
expect(logout.headers.get("set-cookie")).toContain("Max-Age=0");
// The session row is gone server-side, so replaying the old cookie fails.
const me = await auth.me(get("/api/auth/me", cookie));
expect(me.status).toBe(401);
});
test("logout without a session is still 204 (idempotent)", async () => {
const response = await auth.logout(postJson("/api/auth/logout", {}));
expect(response.status).toBe(204);
});
test("expired sessions are rejected server-side", async () => {
await registerUser("erin");
const user = db.findUserByUsername("erin")!;
// Forge a session that expired a minute ago, exactly as a stale row would
// look — the browser would happily still send this cookie.
const expired = db.createSession(user.id, Date.now() - 60_000);
const response = await auth.me(get("/api/auth/me", `sid=${expired.id}`));
expect(response.status).toBe(401);
});
test("deleteExpiredSessions sweeps stale rows and keeps live ones", async () => {
const user = db.findUserByUsername("erin")!;
// Live first: createSession itself sweeps expired rows opportunistically,
// so creating the stale row last keeps it alive until the explicit sweep.
const live = db.createSession(user.id, Date.now() + 60_000);
const stale = db.createSession(user.id, Date.now() - 1);
expect(db.deleteExpiredSessions()).toBeGreaterThanOrEqual(1);
expect(db.findValidSession(stale.id)).toBeNull();
expect(db.findValidSession(live.id)).not.toBeNull();
});
});
// --- getSessionUser (reused by the M5 transcribe guard) ------------------------
describe("getSessionUser", () => {
test("returns {id, username} for a valid cookie and null otherwise", async () => {
const registered = await registerUser("frank");
const cookie = sidCookie(registered);
const user = auth.getSessionUser(get("/anything", cookie));
expect(user).toEqual({ id: expect.any(Number), username: "frank" });
expect(auth.getSessionUser(get("/anything"))).toBeNull();
expect(auth.getSessionUser(get("/anything", "sid=nope"))).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, test, expect } from "bun:test";
import { matchCachedModels } from "../src/lib/modelCache";
// Pure URL matching (D-005 downloaded badge); the `caches` wrapper is
// browser-only and exercised manually.
const TINY = "onnx-community/whisper-tiny.en";
const BASE = "onnx-community/whisper-base";
function hubUrl(modelId: string, file: string): string {
return `https://huggingface.co/${modelId}/resolve/main/${file}`;
}
describe("matchCachedModels", () => {
test("matches models with cached .onnx weights", () => {
const urls = [
hubUrl(TINY, "onnx/encoder_model_quantized.onnx"),
hubUrl(TINY, "onnx/decoder_model_merged_quantized.onnx"),
hubUrl(TINY, "tokenizer.json"),
];
expect(matchCachedModels(urls, [TINY, BASE])).toEqual(new Set([TINY]));
});
test("configs/tokenizer alone do not count as downloaded", () => {
const urls = [hubUrl(BASE, "tokenizer.json"), hubUrl(BASE, "config.json")];
expect(matchCachedModels(urls, [TINY, BASE])).toEqual(new Set());
});
test("ignores query strings when checking the .onnx extension", () => {
const urls = [`${hubUrl(BASE, "onnx/encoder_model_q4.onnx")}?download=true`];
expect(matchCachedModels(urls, [BASE])).toEqual(new Set([BASE]));
});
test("does not confuse '.en' variants with their multilingual sibling", () => {
// "onnx-community/whisper-tiny.en/..." contains the substring
// "whisper-tiny" but must only match the .en id thanks to the trailing /.
const urls = [hubUrl(TINY, "onnx/encoder_model_quantized.onnx")];
expect(
matchCachedModels(urls, [TINY, "onnx-community/whisper-tiny"]),
).toEqual(new Set([TINY]));
});
test("empty inputs yield an empty set", () => {
expect(matchCachedModels([], [TINY])).toEqual(new Set());
expect(matchCachedModels([hubUrl(TINY, "onnx/encoder_model.onnx")], [])).toEqual(
new Set(),
);
});
});
+143
View File
@@ -0,0 +1,143 @@
import { describe, test, expect } from "bun:test";
import {
DEFAULT_MODEL_ID,
MODELS,
WHISPER_LANGUAGES,
estimateDefaultDownloadBytes,
estimateDownloadBytes,
formatModelSize,
getModel,
resolveDefaultDtype,
} from "../src/lib/models";
// Pure catalog data (D-006) — no browser, no network.
describe("MODELS catalog", () => {
test("contains the curated D-006 lineup from the onnx-community org", () => {
const ids = MODELS.map((model) => model.id);
expect(ids).toEqual([
"onnx-community/whisper-tiny.en",
"onnx-community/whisper-tiny",
"onnx-community/whisper-base.en",
"onnx-community/whisper-base",
"onnx-community/whisper-small.en",
"onnx-community/whisper-small",
"onnx-community/whisper-medium.en",
"onnx-community/whisper-medium",
"onnx-community/whisper-large-v3-turbo",
]);
expect(new Set(ids).size).toBe(ids.length);
});
test(".en variants are English-only; the rest are multilingual", () => {
for (const model of MODELS) {
expect(model.multilingual).toBe(!model.id.endsWith(".en"));
}
});
test("every model has dtype defaults with notes for both devices", () => {
for (const model of MODELS) {
for (const device of ["wasm", "webgpu"] as const) {
const entry = model.defaults[device];
expect(entry.dtype).toBeDefined();
expect(entry.note.length).toBeGreaterThan(0);
expect(typeof entry.verified).toBe("boolean");
}
}
});
test("only the M1-tested combination is marked verified", () => {
const verified = MODELS.flatMap((model) =>
(["wasm", "webgpu"] as const)
.filter((device) => model.defaults[device].verified)
.map((device) => `${model.id}@${device}`),
);
expect(verified).toEqual(["onnx-community/whisper-tiny.en@wasm"]);
});
test("the default model exists in the catalog", () => {
expect(getModel(DEFAULT_MODEL_ID)).toBeDefined();
expect(getModel("onnx-community/whisper-nonexistent")).toBeUndefined();
});
});
describe("resolveDefaultDtype", () => {
test("WASM defaults: q8 for small models, q4 for large ones", () => {
expect(resolveDefaultDtype("onnx-community/whisper-tiny.en", "wasm")).toBe("q8");
expect(resolveDefaultDtype("onnx-community/whisper-small", "wasm")).toBe("q8");
expect(resolveDefaultDtype("onnx-community/whisper-medium", "wasm")).toBe("q4");
expect(resolveDefaultDtype("onnx-community/whisper-large-v3-turbo", "wasm")).toBe("q4");
});
test("WebGPU defaults: fp32 for tiny/base (fp16 known-bad), fp16+q4 above", () => {
expect(resolveDefaultDtype("onnx-community/whisper-tiny", "webgpu")).toEqual({
encoder_model: "fp32",
decoder_model_merged: "fp32",
});
expect(resolveDefaultDtype("onnx-community/whisper-base.en", "webgpu")).toEqual({
encoder_model: "fp32",
decoder_model_merged: "fp32",
});
expect(
resolveDefaultDtype("onnx-community/whisper-large-v3-turbo", "webgpu"),
).toEqual({ encoder_model: "fp16", decoder_model_merged: "q4" });
});
test("unknown model ids fall back to q8", () => {
expect(resolveDefaultDtype("someone/some-model", "wasm")).toBe("q8");
});
});
describe("size estimates", () => {
const tiny = getModel("onnx-community/whisper-tiny.en")!;
const turbo = getModel("onnx-community/whisper-large-v3-turbo")!;
test("scale with quantization level", () => {
const fp32 = estimateDownloadBytes(tiny, "fp32");
const fp16 = estimateDownloadBytes(tiny, "fp16");
const q8 = estimateDownloadBytes(tiny, "q8");
const q4 = estimateDownloadBytes(tiny, "q4");
expect(fp32).toBeGreaterThan(fp16);
expect(fp16).toBeGreaterThan(q8);
expect(q8).toBeGreaterThan(q4);
});
test("per-part dtypes are priced per part", () => {
const mixed = estimateDownloadBytes(turbo, {
encoder_model: "fp16",
decoder_model_merged: "q4",
});
expect(mixed).toBeLessThan(estimateDownloadBytes(turbo, "fp16"));
expect(mixed).toBeGreaterThan(estimateDownloadBytes(turbo, "q4"));
});
test("default-dtype estimates are in a plausible range", () => {
// tiny.en on WASM (q8, ~39M params) — tens of MB.
const tinyWasm = estimateDefaultDownloadBytes(tiny, "wasm");
expect(tinyWasm).toBeGreaterThan(20e6);
expect(tinyWasm).toBeLessThan(100e6);
// large-v3-turbo on WebGPU (fp16 encoder + q4 decoder) — around 1.4 GB.
const turboWebgpu = estimateDefaultDownloadBytes(turbo, "webgpu");
expect(turboWebgpu).toBeGreaterThan(1e9);
expect(turboWebgpu).toBeLessThan(2e9);
});
test("formatModelSize renders MB below 1 GB and GB above", () => {
expect(formatModelSize(42_000_000)).toBe("42 MB");
expect(formatModelSize(1_400_000_000)).toBe("1.4 GB");
});
});
describe("WHISPER_LANGUAGES", () => {
test("codes are unique, lowercase, and include common languages", () => {
const codes = WHISPER_LANGUAGES.map((entry) => entry.code);
expect(new Set(codes).size).toBe(codes.length);
for (const code of codes) expect(code).toMatch(/^[a-z]{2,3}$/);
expect(codes).toContain("en");
expect(codes).toContain("da");
});
test("does not contain the auto-detect sentinel (UI adds it)", () => {
expect(WHISPER_LANGUAGES.some((entry) => entry.code === "auto")).toBe(false);
});
});
+91
View File
@@ -0,0 +1,91 @@
# Manual offline verification (M3)
The service worker, precache injection, and production serving are covered by
unit tests and curl smoke tests; actual offline behavior (SW lifecycle, Cache
Storage, airplane mode) can only be exercised in a real browser. This is that
procedure. Re-run it after any change to `src/pwa/*`, `scripts/build.ts`, or
the serve paths in `src/index.ts`.
## Prerequisites
- Chrome or Edge (WebGPU + full PWA support; Firefox works for the WASM/offline
parts but not WebGPU as of mid-2026).
- Production build served locally:
```sh
bun run build
bun run start # NODE_ENV=production bun src/index.ts → http://localhost:3000
```
Service workers require a secure context; `http://localhost` qualifies.
## 1. First visit — install + precache
1. Open `http://localhost:3000/` in a fresh profile (or after
DevTools → Application → Storage → "Clear site data").
2. DevTools → Application → Service Workers: `/sw.js` should be **activated and
running**. No reload prompt should appear on this first install
(`register.ts` only reloads after the user accepts the update toast).
3. Application → Cache Storage: expect
- `whisper-shell-<version>` — the precached app shell: `/`, the hashed
`chunk-*.js` / `chunk-*.css`, `/inference.worker.js`,
`/manifest.webmanifest` (+ a hashed copy), `/icons/icon-192.png`,
`/icons/icon-512.png` (+ a hashed 192 copy). The `<version>` matches the
"cache version" printed by `bun run build`.
- `whisper-runtime-<version>` appears only after step 2 below (it holds
`/ort/*` fetched during model load).
4. The ModelPicker status line should read "The app is cached for offline use.
Load a model once to make it available offline too."
5. Installability: the browser should offer an install icon in the address bar
(manifest + SW + icons). Optional: install and confirm standalone display.
## 2. Load a model once (online)
1. Select a model (tiny.en is fastest) and let it load fully.
2. Application → Cache Storage now also contains:
- `transformers-cache` — Whisper weights (`.onnx`, config/tokenizer JSON),
written by Transformers.js itself, never touched by our SW (D-005).
- `whisper-runtime-<version>` — `/ort/ort-wasm-simd-threaded*.{wasm,mjs}`
(onnxruntime-web loads one variant per backend, e.g. the `.jsep` build
for WebGPU; the worker points `wasmPaths` at `/ort/` so nothing hits the
jsdelivr CDN).
3. The model now shows the **"available offline"** badge in ModelPicker, and
the status line switches to "Offline ready — …".
## 3. Go offline and transcribe
1. DevTools → Network → throttling **Offline** (or OS airplane mode — stronger,
also proves nothing relies on localhost). Optionally also stop
`bun run start` to prove the app survives our server being down.
2. Reload the page. The app shell must render normally (served from
`whisper-shell-*`, cache-first). Also try a deep link like
`http://localhost:3000/foo` — the SW falls back to the cached shell for
navigations.
3. Load the previously-downloaded model. It must become ready with **no
network** (weights from `transformers-cache`, ort runtime from
`whisper-runtime-*`). The DevTools Network tab should show every request
answered by the service worker / cache, zero failed fetches escaping to the
network.
4. Drop a local audio file and confirm a complete transcription offline.
5. Still offline, confirm a **not**-downloaded model fails gracefully (error
surfaced, app usable) rather than hanging.
## 4. Update flow (toast)
1. Back online, with the old tab still open: make any visible change (e.g.
edit a string in `src/App.tsx`), then `bun run build` and restart
`bun run start`. The cache version in `sw.js` changes.
2. Reload the tab once so the browser re-checks `/sw.js` (served
`Cache-Control: no-cache`). When the new SW reaches "installed/waiting",
the toast "A new version of Whisper Web is available." appears.
3. "Later" dismisses it; the old version keeps running.
4. "Reload" posts `SKIP_WAITING`, the new SW activates, and the page reloads
itself once. After activation, Cache Storage must contain only the **new**
`whisper-shell-*` / `whisper-runtime-*` pair — old versions deleted —
while `transformers-cache` is left intact (model still "available
offline").
## Recording the result
When this passes in a real browser, flip the `[~]` offline-verification item
in `WorkBreakdown.md` M3 to `[x]` and note browser + version here.
- [ ] Verified on: _browser/version, date_
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import {
injectPrecacheManifest,
PRECACHE_PLACEHOLDER,
precachePathsFromOutputs,
VERSION_PLACEHOLDER,
} from "../src/pwa/precache";
describe("precachePathsFromOutputs", () => {
test("maps index.html to /, prefixes assets, drops sourcemaps", () => {
expect(
precachePathsFromOutputs(
["index.html", "chunk-abc123.js", "chunk-abc123.js.map", "./index-def456.css"],
["/inference.worker.js"],
),
).toEqual(["/", "/chunk-abc123.js", "/index-def456.css", "/inference.worker.js"]);
});
test("deduplicates and sorts", () => {
expect(precachePathsFromOutputs(["a.js", "a.js"], ["/a.js", "/"])).toEqual([
"/",
"/a.js",
]);
});
});
describe("injectPrecacheManifest", () => {
const swSource = `const PRECACHE = JSON.parse(${PRECACHE_PLACEHOLDER});\nconst CACHE = "shell-${VERSION_PLACEHOLDER}";`;
test("injects a JSON-parseable asset list and the version", () => {
const result = injectPrecacheManifest(swSource, ["/", "/chunk-abc.js"], "v1abc");
expect(result).toContain('"shell-v1abc"');
expect(result).not.toContain("__PRECACHE_MANIFEST__");
// The injected literal must survive a JS string round-trip back to the array.
const literal = /JSON\.parse\((".*")\)/.exec(result)?.[1];
expect(literal).toBeDefined();
expect(JSON.parse(JSON.parse(literal!))).toEqual(["/", "/chunk-abc.js"]);
});
test("throws if a placeholder is missing (bundler mangled the source)", () => {
expect(() => injectPrecacheManifest("const x = 1;", [], "v1")).toThrow(
/missing/,
);
expect(() =>
injectPrecacheManifest(`JSON.parse(${PRECACHE_PLACEHOLDER})`, [], "v1"),
).toThrow(/missing/);
});
test("rejects versions that would break the cache name", () => {
expect(() => injectPrecacheManifest(swSource, [], "bad version!")).toThrow(
/Invalid cache version/,
);
});
test("the real sw.ts source contains both placeholders", async () => {
const source = await Bun.file(
new URL("../src/pwa/sw.ts", import.meta.url).pathname,
).text();
expect(source).toContain(PRECACHE_PLACEHOLDER);
expect(source).toContain(VERSION_PLACEHOLDER);
});
});
+44 -14
View File
@@ -13,17 +13,26 @@ import {
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);
const loads: UiToWorkerMessage[] = [
{ type: "load", modelId: "onnx-community/whisper-tiny.en", device: "wasm" },
{ type: "load", modelId: "onnx-community/whisper-tiny", device: "webgpu" },
{ type: "load", modelId: "onnx-community/whisper-base", device: "auto" },
{ type: "load", modelId: "onnx-community/whisper-base", device: "auto", dtype: "q4" },
{
type: "load",
modelId: "onnx-community/whisper-large-v3-turbo",
device: "webgpu",
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
},
];
const transcribes: UiToWorkerMessage[] = [
{ type: "transcribe", audio: new Float32Array(8) },
{ type: "transcribe", audio: new Float32Array(8), language: "da", task: "transcribe" },
{ type: "transcribe", audio: new Float32Array(8), language: "auto", task: "translate" },
];
for (const message of [...loads, ...transcribes]) {
expect(isUiToWorkerMessage(message)).toBe(true);
}
});
test("rejects malformed messages", () => {
@@ -31,7 +40,19 @@ describe("isUiToWorkerMessage", () => {
expect(isUiToWorkerMessage("load")).toBe(false);
expect(isUiToWorkerMessage({ type: "load" })).toBe(false);
expect(isUiToWorkerMessage({ type: "load", modelId: "x", device: "cuda" })).toBe(false);
expect(isUiToWorkerMessage({ type: "load", modelId: "x", device: "auto", dtype: "int3" })).toBe(false);
expect(
isUiToWorkerMessage({
type: "load",
modelId: "x",
device: "auto",
dtype: { encoder_model: "fp16" },
}),
).toBe(false);
expect(isUiToWorkerMessage({ type: "transcribe", audio: [1, 2, 3] })).toBe(false);
expect(
isUiToWorkerMessage({ type: "transcribe", audio: new Float32Array(4), task: "detect" }),
).toBe(false);
expect(isUiToWorkerMessage({ type: "result", text: "", chunks: [] })).toBe(false);
});
});
@@ -50,7 +71,8 @@ describe("isWorkerToUiMessage", () => {
progress: 10,
},
{ type: "load-progress", status: "done", file: "model.onnx" },
{ type: "load-progress", status: "ready" },
{ type: "load-progress", status: "ready", device: "wasm" },
{ type: "load-progress", status: "ready", device: "webgpu" },
{ type: "partial", text: " hello" },
{ type: "result", text: "hello world", chunks: [{ text: "hello world", timestamp: [0, 1.2] }] },
{ type: "error", stage: "load", message: "boom" },
@@ -64,6 +86,8 @@ describe("isWorkerToUiMessage", () => {
expect(isWorkerToUiMessage(undefined)).toBe(false);
expect(isWorkerToUiMessage({})).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "progress", file: "f" })).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "ready" })).toBe(false);
expect(isWorkerToUiMessage({ type: "load-progress", status: "ready", device: "cuda" })).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);
@@ -129,14 +153,20 @@ describe("applyLoadProgress", () => {
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", () => {
test("ready flips the ready flag, records the device, 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.device).toBeNull();
state = applyLoadProgress(state, {
type: "load-progress",
status: "ready",
device: "webgpu",
});
expect(state.ready).toBe(true);
expect(state.device).toBe("webgpu");
expect(state.files["a.onnx"]!.done).toBe(true);
});
});