Add typecheck + CI checks, light theme with self-hosted Inter, fix real-model test

- Typecheck: typescript 7 devDependency, `bun run typecheck`; CI gains a
  `check` stage (install, typecheck, fast tests) gating image builds (D-038).
- Light theme: every colour is a token; prefers-color-scheme: light
  overrides them. Inter Variable self-hosted from @fontsource-variable/inter,
  kept out of Bun's CSS inlining and precached (scripts/fonts.ts, D-037).
  Backend select no longer overflows on narrow phones.
- tests/live-real-model.test.ts: time budget scales with clip length and
  en_alien gets a measured WER bar; it had failed since July (D-039).
- Delete the temporary isolate_wer2.ts; fix icon "not checked in" comments
  and the README's model-weights location.
- Agent docs: branch naming, typecheck, CI, Bun CSS gotcha, permissions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-29 20:52:13 +01:00
co-authored by Claude Opus 5.5
parent 0ece4b3a2a
commit 4942461f79
21 changed files with 477 additions and 113 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"allow": [
"Bash(git status:*)", "Bash(git diff:*)", "Bash(git log:*)", "Bash(git show:*)",
"Bash(git branch --list:*)", "Bash(git rev-parse:*)", "Bash(git add:*)", "Bash(git commit:*)",
"Bash(bun install --frozen-lockfile:*)", "Bash(bun test:*)", "Bash(bun run build:*)",
"Bash(bun install --frozen-lockfile:*)", "Bash(bun test:*)", "Bash(bun run typecheck:*)",
"Bash(bun run build:*)",
"Bash(bun run icons:*)", "Bash(bun dev:*)"
],
"ask": [
+1 -1
View File
@@ -5,7 +5,7 @@
"run_shell_command(git show)", "run_shell_command(git branch --list)",
"run_shell_command(git rev-parse)", "run_shell_command(git add)", "run_shell_command(git commit)",
"run_shell_command(bun install --frozen-lockfile)", "run_shell_command(bun test)",
"run_shell_command(bun run build)", "run_shell_command(bun run icons)", "run_shell_command(bun dev)"
"run_shell_command(bun run typecheck)", "run_shell_command(bun run build)", "run_shell_command(bun run icons)", "run_shell_command(bun dev)"
],
"exclude": [
"run_shell_command(git push --force)", "run_shell_command(git push -f)",
+3
View File
@@ -43,3 +43,6 @@ __pycache__/
CLAUDE.local.md
.claude/settings.local.json
.claude/worktrees/
# self-hosted Inter, copied from node_modules by scripts/fonts.ts
src/fonts/
+14
View File
@@ -1,6 +1,20 @@
stages:
- check
- build
# Typecheck + the fast test suite gate the image build. The real-model
# accuracy test (tests/live-real-model.test.ts) is excluded: it downloads
# ~600 MB of Whisper weights and runs for many minutes on CPU.
check:
stage: check
image: oven/bun:1
script:
- bun install --frozen-lockfile
- bun run typecheck
- bun test --path-ignore-patterns='tests/live-real-model.test.ts'
rules:
- if: $CI_COMMIT_BRANCH
# Builds BOTH production image flavors (see Dockerfile) and pushes them to this
# project's GitLab Container Registry:
# - default target `runtime` → :<sha> / :latest (Bun-only)
+21 -12
View File
@@ -13,18 +13,21 @@ Install: `bun install --frozen-lockfile`
Dev: `bun dev`
Test one file: `bun test tests/export.test.ts` | One case: `bun test tests/models.test.ts -t "dtype"`
Test (fast, ~6 s): `bun test --path-ignore-patterns='tests/live-real-model.test.ts'`
Test all: `bun test` — ~17 min: `tests/live-real-model.test.ts` downloads and runs real Whisper
weights whenever huggingface.co is reachable. Run it only when touching the live pipeline or decoding.
Currently 1 known failure there (`en_alien` timeout — see WorkBreakdown "Noticed, not fixed").
Test all: `bun test` — ~19 min: `tests/live-real-model.test.ts` downloads and runs real Whisper
weights whenever huggingface.co is reachable. Run it when touching the live pipeline or decoding.
Build: `bun run build` (rewrites `dist/`)
Opt-in real sidecar test (needs `pip install -r server-py/requirements.txt`): `FW_INTEGRATION=1 bun test tests/transcribe.test.ts`
Lint / typecheck: none configured (`typescript` is not installed). TODO(maintainer): confirm whether one is wanted.
Typecheck: `bun run typecheck` (tsc 7, < 1 s). No linter configured.
Browser-only behaviour (offline PWA, mic, WebGPU) has no automated coverage and there is no browser in
the dev environment: follow `tests/offline.md`, `tests/live.md`, `tests/server-engine.md` and say
plainly when a manual check is still pending — never claim it was verified.
Browser-only behaviour (offline PWA, mic, WebGPU) has no automated coverage: follow `tests/offline.md`,
`tests/live.md`, `tests/server-engine.md` and say plainly when a manual check is still pending — never
claim it was verified. Headless Chrome can screenshot the UI: `bun run build`, serve with
`PORT=3118 NODE_ENV=production bun src/index.ts`, then `google-chrome --headless=new --no-sandbox
--disable-gpu --virtual-time-budget=6000 --window-size=390,1100 --screenshot=/tmp/shot.png
http://localhost:3118/` (add `--force-dark-mode` for the dark theme).
CI (`.gitlab-ci.yml`) only builds Docker images — nothing else runs your tests.
CI (`.gitlab-ci.yml`) runs install, typecheck, and the fast suite, then builds Docker images. Keep it in
step with the commands above, in the same commit.
## Rules
@@ -39,6 +42,10 @@ CI (`.gitlab-ci.yml`) only builds Docker images — nothing else runs your tests
Docker image stays small (D-031). Upgrading or moving it means redoing the patch: ask first.
- **No cross-origin subresources.** Every response carries COOP/COEP `require-corp` (D-023,
`src/index.ts`); a CDN script/style/font will be blocked. Self-host it (onnxruntime is served from `/ort/`).
- **Bun inlines every CSS `url()` asset as base64** in the shell build, ignoring `loader`. Anything
large goes `external` + copied into `dist/`, like the fonts (`scripts/fonts.ts`, D-037).
- **Colours are tokens.** `src/index.css` has a dark set and a `prefers-color-scheme: light` set; never
add a raw colour outside them (DESIGN.md).
- **Selecting a model never downloads it (D-020).** Only a transcription start or the explicit Download
button posts `load` to the worker.
- **Server modules take dependencies by injection** (`createTranscribeHandlers(deps)` pattern) so tests
@@ -98,12 +105,14 @@ Same commit as the code, not "later":
## Git
Commit subjects: plain imperative sentence describing the change (e.g. "Show \"Loading model from
cache\" instead of \"Downloading\" when weights are already cached"). Small, each leaving tests green.
TODO(maintainer): confirm branch naming and whether work lands directly on `main`.
Branches: `feat/<slug>`, `fix/<slug>`, `chore/<slug>`, `docs/<slug>`, `refactor/<slug>`, `test/<slug>`
off `main`; never commit directly to `main`. Commit subjects: plain imperative sentence describing the
change (e.g. "Show \"Loading model from cache\" instead of \"Downloading\" when weights are already
cached"). Small, each leaving tests green.
## Done means verified
Before saying done: relevant tests run *in this session* and seen passing · `bun run build` succeeds
Before saying done: relevant tests run *in this session* and seen passing · `bun run typecheck` clean ·
`bun run build` succeeds
if `src/` or `scripts/` changed · matches the approved spec · docs duties complete · `git status` clean
and the diff is only what you intended.
+14 -13
View File
@@ -5,21 +5,25 @@ exists, not an aspiration. Update in the same commit as visual changes.
## Foundations
- **Theme:** dark only. `:root` sets `color: #e8e8e8; background-color: #16181d` (also the PWA
`theme_color`). `color-scheme: light dark` is declared but there is no light palette.
- **Tokens (`:root`):** `--panel #1f232b` · `--border #343a46` · `--accent #7aa2f7` · `--muted #9aa3b2`.
Everything else is a hard-coded hex.
- **Semantic colours in use (hard-coded):** success/ready text `#9ad6ae` (bg `#1d2b23`, border `#3d6b4f`);
error text `#f2b8b5` (bg `#2a1c1e`, border `#8b3a3a`); live/recording `#e0605e`; raised surfaces
`#262b34` / `#232a38`.
- **Type:** `Inter, system-ui, …` stack — Inter is not loaded, so it renders in the system font unless
installed locally. Body line-height 1.5; `h1` 2.2rem / 1.15. Any webfont must be self-hosted (COEP,
see AGENTS.md).
- **Theme:** dark by default, light when the OS prefers it (`@media (prefers-color-scheme: light)`
overrides the tokens; no in-app toggle). `<meta name="theme-color">` has a variant per scheme. The
PWA manifest and icons stay dark (`#16181d`).
- **Tokens (`:root`, dark → light):** `--bg #16181d → #f5f6f8` · `--fg #e8e8e8 → #1a1d23` ·
`--muted #9aa3b2 → #5b6372` · `--panel #1f232b → #ffffff` · `--border #343a46 → #d3d8e0` ·
`--surface-raised` (inputs, code) · `--surface-selected` (selected/drag-active) ·
`--accent #7aa2f7 → #2f5bd3` + `--on-accent` · `--success` / `-bg` / `-border` ·
`--danger` / `-muted` / `-bg` / `-border` + `--on-danger` · `--live` (recording) · `--shadow`.
**No raw colours outside the token block** — add a token (with both values) instead.
- **Contrast:** every text/background token pair is ≥ 4.5:1 in both themes (checked when the light
theme landed, D-037). Re-check any new pair.
- **Type:** Inter Variable, self-hosted (`@fontsource-variable/inter`, 7 unicode-range subsets served
from `/fonts/`, precached; see `scripts/fonts.ts`, D-037). Body line-height 1.5; `h1` 2.2rem / 1.15.
- **Layout:** single centred column, `.app { max-width: 720px; padding: 2.5rem 1.25rem 3rem; gap: 1.5rem }`,
`body { min-width: 320px }`.
- **Radius:** 8px for controls (most common); 10–14px for panels/cards; 999px for pills/badges.
- **Motion:** `0.15s` colour/border transitions; `pulse` (live dot) and `blink` (cursor) animations,
disabled under `prefers-reduced-motion`.
- **Browser surfaces:** `accent-color` and `::selection` come from `--accent`.
## Components
@@ -27,6 +31,3 @@ One BEM-ish class block per React component in `src/components/` (`.model-picker
`.live-controls`, `.engine-toggle`, `.auth-panel`, `.update-toast`, …), with modifier classes like
`-selected`, `-active`, `-disabled`. Interactive states use `:hover:enabled` and `:focus-visible`.
`.visually-hidden` exists for screen-reader-only text.
TODO(maintainer): confirm whether the dark-only theme is intentional, and whether hard-coded semantic
colours should become tokens.
+7 -5
View File
@@ -19,7 +19,8 @@ for commands, conventions and boundaries (Bun everywhere — no npm/node).
```sh
bun install
bun dev # dev server with HMR at http://localhost:3000
bun test # unit tests
bun test # all tests (slow: includes a real-model test, see AGENTS.md)
bun run typecheck
```
## Production build (bare metal)
@@ -73,10 +74,11 @@ The server engine is optional by design (D-013):
`POST /api/transcribe` answers `503` with the same reason. The UI simply
doesn't offer the server engine.
- **with-server-engine image:** `status` reports `available: true` and
logged-in users get the server/device toggle. The first request for a model
downloads its CTranslate2 weights from the Hugging Face Hub into
`FASTER_WHISPER_DOWNLOAD_ROOT` (inside the data volume, so restarts don't
re-download); the last-used model stays loaded between requests.
logged-in users get the server/device toggle. Both server-selectable models
(`large-v3-turbo`, `medium.en`) are baked into the image at
`FASTER_WHISPER_DOWNLOAD_ROOT` (`/opt/faster-whisper/models`, D-031), so
there is no first-request download; the last-used model stays loaded
between requests.
### Runtime environment variables
+46
View File
@@ -11,6 +11,7 @@
"react-dom": "^19",
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@huggingface/transformers": "^4.2.0",
"@types/bun": "latest",
"@types/passport": "^1.0.17",
@@ -18,6 +19,7 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"ffmpeg-static": "^5.3.0",
"typescript": "^7.0.2",
},
},
},
@@ -32,6 +34,8 @@
"@emnapi/runtime": ["@emnapi/[email protected]", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
"@fontsource-variable/inter": ["@fontsource-variable/[email protected]", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="],
"@huggingface/jinja": ["@huggingface/[email protected]", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
"@huggingface/tokenizers": ["@huggingface/[email protected]", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="],
@@ -138,6 +142,46 @@
"@types/serve-static": ["@types/[email protected]", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/[email protected]", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/[email protected]", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/[email protected]", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/[email protected]", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/[email protected]", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/[email protected]", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"adm-zip": ["[email protected]", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="],
"agent-base": ["[email protected]", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
@@ -256,6 +300,8 @@
"typedarray": ["[email protected]", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
"typescript": ["[email protected]", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["[email protected]", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"util-deprecate": ["[email protected]", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+3 -2
View File
@@ -79,7 +79,8 @@ src/
precache.ts # placeholder-injection helpers shared by build + dev serve
register.ts # SW registration + update-toast flow
manifest.webmanifest
icons/ # generated by scripts/generate-icons.ts (not checked in)
icons/ # generated by scripts/generate-icons.ts (checked in; `bun run icons` regenerates)
fonts/ # self-hosted Inter, copied from node_modules by scripts/fonts.ts (gitignored, D-037)
server/
auth.ts # Passport.js wiring + session middleware adapter
db.ts # bun:sqlite schema + queries
@@ -177,7 +178,7 @@ environment). See `WorkBreakdown.md` for the itemized checklist.)
### 4.2 Offline (PWA)
- Service worker (`pwa/sw.ts`, hand-rolled — D-007) precaches the built app shell
(hashed JS/CSS, `/`, worker bundle, manifest, icons) on install into
(hashed JS/CSS, `/`, worker bundle, manifest, icons, `/fonts/*` Inter subsets) on install into
`whisper-shell-<version>`, cache-first thereafter; unknown navigations fall back
to the cached shell. `scripts/build.ts` injects the precache list + a
content-hash version into `dist/sw.js` via `pwa/precache.ts`; the dev server
+73
View File
@@ -1151,3 +1151,76 @@ as the one backlog (its own rule: no separate backlog), rather than adding a
**Trade-off:** `AGENTS.md` is kept short (<150 lines) on purpose — rationale
goes here, not there. Changes to it redirect every agent session, so review
them like CI config.
## D-037 — Light theme via `prefers-color-scheme`; Inter self-hosted outside Bun's CSS bundler
**Status:** Accepted (2026-10-29)
The UI was dark-only by accident: `:root` declared `color-scheme: light
dark` but every colour was a hard-coded dark value, so light-mode OS users
got native light form controls on a dark page. The font stack also named
`Inter` first without ever loading it — everyone saw the system font.
**Theme:** every colour is now a token on `:root` (dark values unchanged,
so the dark theme renders identically); `@media (prefers-color-scheme:
light)` overrides the tokens with a mirrored light palette (same blue
accent family, darkened for contrast). No in-app toggle — follow the OS.
All text/background pairs measured ≥ 4.5:1 in both themes. The PWA
manifest/icons stay dark (a manifest has one `theme_color`); the
`<meta name="theme-color">` tags follow the scheme.
**Font:** maintainer chose to self-host Inter (over dropping it from the
stack). Source: `@fontsource-variable/inter` (OFL-1.1), a devDependency
like Transformers.js (D-031) because only the build reads it. COEP
`require-corp` (D-023) rules out a font CDN anyway. Two Bun 1.3.14
behaviours shaped the wiring (`scripts/fonts.ts`):
1. Bun's HTML-entrypoint CSS bundler **inlines every resolvable `url()`
asset as base64** — `loader: { ".woff2": "file" }` is ignored. Bundled
normally, the 7 subsets turned the render-blocking stylesheet from
14 KB into 305 KB and defeated `unicode-range` lazy loading. So the
production build marks `*.woff2` `external`, copies the subsets to
`dist/fonts/`, and precaches them (and hashes their bytes into the SW
cache version). The dev server has no `external` option, so
`ensureFonts()` copies them into gitignored `src/fonts/` where the dev
bundler can resolve (and inline) them.
2. Bun's minifier rewrites `format('woff2-variations')` to the unquoted
`format(woff2-variations)`, which is not a valid keyword — Chrome then
drops the whole `src` and registers **no** faces. Use `format("woff2")`
(variable woff2 needs nothing more).
Verified in headless Chrome: 7 faces registered, only the latin subset
fetched for English UI text.
## D-038 — Typecheck with TypeScript 7; CI gates image builds on typecheck + fast tests
**Status:** Accepted (2026-10-29)
`tsconfig.json` was already strict, but `typescript` was not installed and
Bun strips types without checking them, so nothing ever type-checked.
**Decision:** `typescript` (7.x, the native compiler — a full check of all
57 project files takes < 1 s) is a devDependency; `bun run typecheck`
runs `tsc --noEmit`. The code base was already clean. `.gitlab-ci.yml`
gains a `check` stage before `build`: `bun install --frozen-lockfile`,
`bun run typecheck`, and the fast test suite (`bun test
--path-ignore-patterns='tests/live-real-model.test.ts'`, ~6 s). The
real-model accuracy test stays local-only: it downloads ~600 MB of
weights and runs for many minutes on CPU.
**Consequence:** a type error or failing unit test now blocks image
pushes.
## D-039 — Real-model accuracy test: time budget scales with clip length; per-clip WER bars only with a measurement
**Status:** Accepted (2026-10-29)
`tests/live-real-model.test.ts` had failed since `en_alien` (the 15-min
D-021 long-file fixture) was added on 2026-07-15: the test auto-enrolls
every clip in `tests/audio_files/`, but its 900 s timeout and absolute
`MAX_WER = 0.25` were calibrated on 14–21 s clean clips.
**Investigation (instrumented run, no timeout):** no hang — 124 transcribe
passes, 1189 s total (whole-file pass 420 s for 888 s of audio; live
passes re-transcribe overlapping segments, 3452 s of audio in total).
With the time available it then failed on accuracy instead: live WER
0.251, and the whole-file baseline itself 0.261 — the clip (gaming
livestream: music, game voices, crosstalk) is simply harder than the
bar, independent of the live pipeline. The relative check (live ≤ file +
0.1) passed.
**Decision:** each clip's timeout is `900 s (download headroom) + 3 s ×
clip seconds` (duration from an ffmpeg header probe; ≈ 2× the measured
1.34 s/s). `MAX_WER_BY_CLIP` gives `en_alien` an absolute bar of 0.35 with
the measurement recorded beside it. **Rejected:** deriving the absolute
bar from the whole-file WER — a broken model would then pass whenever its
whole-file output is equally bad, which is exactly what the absolute bar
exists to catch.
**Consequence:** full `bun test` ≈ 19 min on CPU (en_alien ≈ 20 min of it
on a cold run); the fast suite excludes this file.
+5 -3
View File
@@ -14,13 +14,15 @@ Run on 2026-10-29 (Bun 1.3.14). Only `worked` rows may appear in a tier, a tool
| `bun test tests/export.test.ts` | worked — 28 pass |
| `bun test tests/models.test.ts -t "dtype"` | worked — 5 pass, 14 filtered |
| `bun test --path-ignore-patterns='tests/live-real-model.test.ts'` | worked — 239 pass, 1 skip, ~6 s |
| `bun test` | ran — 242 pass, 1 skip, 1 fail in ~17 min. The fail is pre-existing: `tests/live-real-model.test.ts` › `en_alien` hits its 900 s timeout (reproduced in isolation). Allowed anyway: the command itself is the verified dev loop. |
| `bun test` | worked — 243 pass, 1 skip (opt-in `FW_INTEGRATION`), ~19 min (after D-039) |
| `bun run build` | worked |
| `bun run icons` | worked |
| `bun dev` | worked — HTTP 200 on `/` |
| `FW_INTEGRATION=1 bun test tests/transcribe.test.ts` | not run — faster-whisper not installed here |
| `bun start` | not run — documented in README |
| Typecheck / lint | not run — `typescript` not installed, no linter configured |
| `bun run typecheck` | worked — 0 errors (tsc 7.0.2, 57 files); exits 1 on a planted type error |
| `google-chrome --headless=new --no-sandbox --disable-gpu --screenshot=… <url>` | worked (UI screenshots; not in any tier — ad hoc) |
| Lint | not run — no linter configured |
| `docker build .` | not run — CI-only; in Ask tier |
TODO(maintainer): confirm every row above that is not worked.
@@ -29,7 +31,7 @@ TODO(maintainer): confirm every row above that is not worked.
| Tier | Commands | Why |
|---|---|---|
| Allow | `git status`, `git diff`, `git log`, `git show`, `git branch --list`, `git rev-parse`, `git add`, `git commit`; `bun install --frozen-lockfile`, `bun test`, `bun run build`, `bun run icons`, `bun dev` | Inspection and the verified dev loop; no prompt needed. |
| Allow | `git status`, `git diff`, `git log`, `git show`, `git branch --list`, `git rev-parse`, `git add`, `git commit`; `bun install --frozen-lockfile`, `bun test`, `bun run typecheck`, `bun run build`, `bun run icons`, `bun dev` | Inspection and the verified dev loop; no prompt needed. |
| Ask | `git push`, `git merge`, `git rebase`, `git switch`, `git restore`, `git commit --amend`, `rm`; `bun add`/`remove`/`update`/`upgrade`/`patch`, `bunx`, `bun x`; `docker`; `pip install`, `python3 -m pip install` | Leaves the machine, rewrites work, changes dependencies or the transformers patch, or touches deployment — a human decides. |
| Deny | `git push --force`/`-f`/`--force-with-lease`, `git reset --hard`, `git clean`, `git add -f`/`--force`, `git commit --no-verify`/`-n`, `rm -r`/`-R`/`-rf`/`-fr`/`-Rf`/`-fR`/`--recursive`, `sudo`; reading `.env`, `.env.*`, `data/**` (local SQLite users + sessions) | Destroys work, skips checks, or exposes secrets/user data. |
+18 -12
View File
@@ -349,22 +349,28 @@ Promote to a milestone via a new entry in `Decisions.md` first.
---
## Noticed, not fixed
Found while bootstrapping agent docs (D-036); docs-only pass, so no code changed.
- [ ] `bun test tests/live-real-model.test.ts` fails: the `en_alien (en)` case
Found while bootstrapping agent docs (D-036); all fixed on 2026-10-29.
- [x] `bun test tests/live-real-model.test.ts` fails: the `en_alien (en)` case
times out at its 900 s limit (2026-10-29, Linux CPU/WASM, reproduced in
isolation; `da_festuge` and `en_aiproblem` pass).
- [ ] `isolate_wer2.ts` (repo root) is marked "TEMP (delete after use)" and
writes to a stale `/tmp/claude-1000/...` scratchpad path.
- [ ] `docs/Architecture.md` §3 and `scripts/build.ts` say `src/pwa/icons/`
isolation; `da_festuge` and `en_aiproblem` pass). — Not a hang; budget
and WER bar were calibrated for short clips. Fixed (D-039).
- [x] `isolate_wer2.ts` (repo root) is marked "TEMP (delete after use)" and
writes to a stale `/tmp/claude-1000/...` scratchpad path. — Deleted.
- [x] `docs/Architecture.md` §3 and `scripts/build.ts` say `src/pwa/icons/`
is "not checked in", but `icon-192.png`/`icon-512.png` are tracked.
- [ ] No typecheck or lint: `tsconfig.json` is strict but `typescript` is not
installed, so nothing checks types (`bun` strips them).
- [ ] `.gitlab-ci.yml` only builds Docker images; `bun test` never runs in CI.
- [ ] README "Graceful degradation" says server-engine weights download into
— Kept tracked (`ensureIcons()` is idempotent); comments + docs fixed.
- [x] No typecheck or lint: `tsconfig.json` is strict but `typescript` is not
installed, so nothing checks types (`bun` strips them). — `bun run
typecheck` (D-038). Still no linter.
- [x] `.gitlab-ci.yml` only builds Docker images; `bun test` never runs in CI.
— `check` stage: typecheck + fast suite gate the build (D-038).
- [x] README "Graceful degradation" says server-engine weights download into
the data volume; the env table, Dockerfile and D-031 say they are baked
into the image at `/opt/faster-whisper/models`.
- [ ] `src/index.css` declares `color-scheme: light dark` but only has a dark
into the image at `/opt/faster-whisper/models`. — README fixed.
- [x] `src/index.css` declares `color-scheme: light dark` but only has a dark
palette; `Inter` is listed first in the font stack but never loaded.
— Light theme via tokens + self-hosted Inter (D-037). Also fixed: the
Backend select overflowed its panel on 390 px-wide phones.
## Recurring
- [ ] Maintain `AGENTS.md`:
-15
View File
@@ -1,15 +0,0 @@
// TEMP (delete after use): which component's q4 hurts — encoder or decoder?
import { pipeline } from "@huggingface/transformers";
const audio = new Float32Array(await Bun.file(process.argv[2]!).arrayBuffer());
const S = "/tmp/claude-1000/-home-user-whisper-web/b192bada-c438-4a43-a29d-c2969ef71f6c/scratchpad";
async function run(label: string, dtype: any) {
const asr = await pipeline("automatic-speech-recognition", "onnx-community/whisper-large-v3-turbo", { dtype, progress_callback: () => {} });
const t0 = performance.now();
const out = await asr(audio, { chunk_length_s: 30, stride_length_s: 5, return_timestamps: true, max_new_tokens: 440, language: "en", task: "transcribe" });
const single = (Array.isArray(out) ? out[0] : out) as { text?: string };
console.log(`${label}: ${((performance.now() - t0) / 1000).toFixed(0)}s`);
await Bun.write(`${S}/out-${label}.txt`, (single.text ?? "").trim());
await asr.dispose();
}
await run("encq8-decq4", { encoder_model: "q8", decoder_model_merged: "q4" });
await run("encq4-decq8", { encoder_model: "q4", decoder_model_merged: "q8" });
+4 -1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "bun --hot src/index.ts",
"build": "bun scripts/build.ts",
"typecheck": "tsc --noEmit",
"icons": "bun scripts/generate-icons.ts",
"start": "NODE_ENV=production bun src/index.ts"
},
@@ -16,13 +17,15 @@
"react-dom": "^19"
},
"devDependencies": {
"@fontsource-variable/inter": "^5.3.0",
"@huggingface/transformers": "^4.2.0",
"@types/bun": "latest",
"@types/passport": "^1.0.17",
"@types/passport-local": "^1.0.38",
"@types/react": "^19",
"@types/react-dom": "^19",
"ffmpeg-static": "^5.3.0"
"ffmpeg-static": "^5.3.0",
"typescript": "^7.0.2"
},
"trustedDependencies": [
"ffmpeg-static"
+17 -4
View File
@@ -15,6 +15,7 @@
import { cp, readdir, rm } from "node:fs/promises";
import { join, relative } from "node:path";
import { injectPrecacheManifest, precachePathsFromOutputs } from "../src/pwa/precache.ts";
import { ensureFonts, FONTS_DIR } from "./fonts.ts";
import { ensureIcons, ICON_SIZES, ICONS_DIR } from "./generate-icons.ts";
const ROOT = join(import.meta.dir, "..");
@@ -23,8 +24,11 @@ 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.
// HTML loader resolves the file at bundle time). They are checked in, but
// regenerate any that are missing.
await ensureIcons(ICONS_DIR);
// Self-hosted Inter must exist for index.css to resolve (scripts/fonts.ts).
const fontFiles = await ensureFonts();
// 1. App shell (hashed assets).
const shellBuild = await Bun.build({
@@ -35,6 +39,8 @@ const shellBuild = await Bun.build({
sourcemap: "linked",
define: { "process.env.NODE_ENV": '"production"' },
env: "BUN_PUBLIC_*",
// Self-hosted fonts stay as plain url(fonts/...) files — see scripts/fonts.ts.
external: ["*.woff2"],
});
// 2. Inference worker at its stable URL (Bun does not follow `new Worker`
@@ -63,10 +69,13 @@ const ortFiles = (await readdir(ortDist)).filter((name) =>
for (const name of ortFiles) {
await cp(join(ortDist, name), join(DIST, "ort", name));
}
for (const name of fontFiles) {
await cp(join(FONTS_DIR, name), join(DIST, "fonts", 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).
// manifest, the worker, and fonts 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",
@@ -82,10 +91,14 @@ const precachePaths = precachePathsFromOutputs(
"inference.worker.js",
"manifest.webmanifest",
...ICON_SIZES.map((size) => `icons/icon-${size}.png`),
...fontFiles.map((name) => `fonts/${name}`),
],
);
const fontBytes = await Promise.all(
fontFiles.map((name) => Bun.file(join(FONTS_DIR, name)).bytes()),
);
const version = Bun.hash(
JSON.stringify(precachePaths) + swSource + workerScript,
JSON.stringify(precachePaths) + swSource + workerScript + fontBytes.map((b) => Bun.hash(b)).join(),
).toString(36);
await Bun.write(join(DIST, "sw.js"), injectPrecacheManifest(swSource, precachePaths, version));
+43
View File
@@ -0,0 +1,43 @@
/**
* Self-hosted Inter (variable, upright) from @fontsource-variable/inter.
*
* src/index.css declares one @font-face per unicode-range subset with a
* relative `url(fonts/<file>)`, which resolves to src/fonts/ — a generated,
* gitignored copy of the package's files (`ensureFonts()`; Bun's CSS bundler
* must be able to resolve every url()).
*
* - Dev (`bun dev`): the HTML-import bundler inlines them as base64. Fine
* locally.
* - Production (scripts/build.ts): `external: ["*.woff2"]` keeps them out of
* the bundle — Bun inlines every resolvable CSS url() asset, which would put
* ~220 KB into the render-blocking stylesheet and defeat unicode-range lazy
* loading. The build copies them to dist/fonts/ and precaches them instead.
*
* Same-origin either way, so COEP `require-corp` (D-023) allows them.
*/
import { copyFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
const PACKAGE_FILES = join(import.meta.dir, "../node_modules/@fontsource-variable/inter/files");
const CSS_PATH = join(import.meta.dir, "../src/index.css");
export const FONTS_DIR = join(import.meta.dir, "../src/fonts");
/** The font files a stylesheet references via `url(fonts/<file>)`. */
export function fontFilesInCss(css: string): string[] {
const files = [...css.matchAll(/url\(["']?fonts\/([^"')]+)["']?\)/g)].map((match) => match[1]!);
return [...new Set(files)];
}
/**
* Copy every font src/index.css references into src/fonts/ (idempotent).
* Always overwrites (~220 KB), so a package upgrade can't leave stale copies.
*/
export async function ensureFonts(): Promise<string[]> {
const files = fontFilesInCss(await Bun.file(CSS_PATH).text());
await mkdir(FONTS_DIR, { recursive: true });
for (const name of files) {
await copyFile(join(PACKAGE_FILES, name), join(FONTS_DIR, name));
}
return files;
}
+159 -37
View File
@@ -1,13 +1,132 @@
/* Inter Variable, self-hosted (OFL-1.1, @fontsource-variable/inter). Served
from /fonts/ rather than bundled — see scripts/fonts.ts. */
/* inter-cyrillic-ext-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-cyrillic-ext-wght-normal.woff2) format("woff2");
unicode-range: U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F;
}
/* inter-cyrillic-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-cyrillic-wght-normal.woff2) format("woff2");
unicode-range: U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116;
}
/* inter-greek-ext-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-greek-ext-wght-normal.woff2) format("woff2");
unicode-range: U+1F00-1FFF;
}
/* inter-greek-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-greek-wght-normal.woff2) format("woff2");
unicode-range: U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF;
}
/* inter-vietnamese-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-vietnamese-wght-normal.woff2) format("woff2");
unicode-range: U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB;
}
/* inter-latin-ext-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-latin-ext-wght-normal.woff2) format("woff2");
unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;
}
/* inter-latin-wght-normal */
@font-face {
font-family: 'Inter Variable';
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(fonts/inter-latin-wght-normal.woff2) format("woff2");
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
/* Theme tokens. Dark is the default; light follows the OS setting
(prefers-color-scheme) — there is no in-app toggle. */
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: "Inter Variable", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
color-scheme: light dark;
color: #e8e8e8;
background-color: #16181d;
color: var(--fg);
background-color: var(--bg);
accent-color: var(--accent);
--bg: #16181d;
--fg: #e8e8e8;
--muted: #9aa3b2;
--panel: #1f232b;
--border: #343a46;
--surface-raised: #262b34; /* inputs, selects, inline code */
--surface-selected: #232a38; /* selected / drag-active rows */
--accent: #7aa2f7;
--muted: #9aa3b2;
--on-accent: #16181d;
--success: #9ad6ae;
--success-bg: #1d2b23;
--success-border: #3d6b4f;
--danger: #f2b8b5;
--danger-muted: #d99a97;
--danger-bg: #2a1c1e;
--danger-border: #8b3a3a;
--on-danger: #2a1c1e;
--live: #e0605e;
--shadow: rgba(0, 0, 0, 0.5);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--fg: #1a1d23;
--muted: #5b6372;
--panel: #ffffff;
--border: #d3d8e0;
--surface-raised: #eef0f4;
--surface-selected: #e8eefc;
--accent: #2f5bd3;
--on-accent: #ffffff;
--success: #1d6a3c;
--success-bg: #e6f4eb;
--success-border: #9ccdae;
--danger: #a12e2b;
--danger-muted: #a3403c;
--danger-bg: #fcecea;
--danger-border: #e2a4a1;
--on-danger: #ffffff;
--live: #cf3a37;
--shadow: rgba(20, 24, 33, 0.14);
}
}
::selection {
background: color-mix(in srgb, var(--accent) 35%, transparent);
}
body {
@@ -103,7 +222,7 @@ body {
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
box-shadow: 0 4px 16px var(--shadow);
z-index: 10;
}
@@ -131,7 +250,7 @@ body {
.auth-tab-selected,
.auth-tab-selected:hover:enabled {
border-color: var(--accent);
background: #232a38;
background: var(--surface-selected);
color: inherit;
}
@@ -150,8 +269,8 @@ body {
}
.auth-panel input {
background: #262b34;
color: #e8e8e8;
background: var(--surface-raised);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35rem 0.5rem;
@@ -172,12 +291,12 @@ body {
.auth-error {
margin: 0;
font-size: 0.85rem;
color: #f2b8b5;
color: var(--danger);
}
.auth-submit {
background: var(--accent);
color: #16181d;
color: var(--on-accent);
border: none;
border-radius: 8px;
padding: 0.4rem 0.8rem;
@@ -221,7 +340,7 @@ body {
/* Server engine active — no model download needed on this device (D-020). */
.server-engine-hint {
color: #9ad6ae;
color: var(--success);
}
.device-select,
@@ -231,11 +350,14 @@ body {
gap: 0.5rem;
font-size: 0.85rem;
color: var(--muted);
max-width: 100%;
}
.model-picker select,
.transcription-options select {
background: #262b34;
min-width: 0; /* shrink inside narrow (mobile) panels instead of overflowing */
max-width: 100%;
background: var(--surface-raised);
color: inherit;
border: 1px solid var(--border);
border-radius: 8px;
@@ -285,7 +407,7 @@ body {
.model-option-selected,
.model-group:not(:disabled) .model-option-selected:hover {
border-color: var(--accent);
background: #232a38;
background: var(--surface-selected);
}
.model-group:disabled .model-option {
@@ -314,9 +436,9 @@ body {
}
.model-badge {
border: 1px solid #3d6b4f;
background: #1d2b23;
color: #9ad6ae;
border: 1px solid var(--success-border);
background: var(--success-bg);
color: var(--success);
border-radius: 999px;
padding: 0.1rem 0.55rem;
font-size: 0.75rem;
@@ -337,8 +459,8 @@ body {
}
.model-download-button:hover:enabled {
border-color: #3d6b4f;
color: #9ad6ae;
border-color: var(--success-border);
color: var(--success);
}
.model-download-button:disabled {
@@ -354,7 +476,7 @@ body {
}
.offline-status-ready {
color: #9ad6ae;
color: var(--success);
}
.update-toast {
@@ -369,13 +491,13 @@ body {
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.6rem 1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
box-shadow: 0 4px 16px var(--shadow);
z-index: 10;
}
.update-toast button {
background: var(--accent);
color: #16181d;
color: var(--on-accent);
border: none;
border-radius: 6px;
padding: 0.3rem 0.8rem;
@@ -449,7 +571,7 @@ body {
.dropzone-active {
border-color: var(--accent);
background: #232a38;
background: var(--surface-selected);
}
.dropzone-disabled {
@@ -500,7 +622,7 @@ body {
.live-button-active,
.live-button-active:hover:enabled {
border-color: #e0605e;
border-color: var(--live);
}
.live-dot {
@@ -508,7 +630,7 @@ body {
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
background: #e0605e;
background: var(--live);
animation: pulse 1.2s ease-in-out infinite;
}
@@ -545,17 +667,17 @@ body {
}
.cancel-button:hover {
border-color: #8b3a3a;
color: #f2b8b5;
border-color: var(--danger-border);
color: var(--danger);
}
.error {
margin: 0;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #8b3a3a;
background: #2a1c1e;
color: #f2b8b5;
border: 1px solid var(--danger-border);
background: var(--danger-bg);
color: var(--danger);
}
.error-message {
@@ -565,14 +687,14 @@ body {
.error-suggestion {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: #d99a97;
color: var(--danger-muted);
}
.error-retry-button {
margin-top: 0.6rem;
border: 1px solid #f2b8b5;
border: 1px solid var(--danger);
background: transparent;
color: #f2b8b5;
color: var(--danger);
border-radius: 8px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
@@ -581,8 +703,8 @@ body {
}
.error-retry-button:hover {
background: #f2b8b5;
color: #2a1c1e;
background: var(--danger);
color: var(--on-danger);
}
/* --- ModelLoadProgress --- */
@@ -772,7 +894,7 @@ body {
code {
font-family: ui-monospace, monospace;
background: #262b34;
background: var(--surface-raised);
padding: 0.1em 0.35em;
border-radius: 5px;
}
@@ -868,8 +990,8 @@ code {
}
.storage-delete:hover:enabled {
border-color: #8b3a3a;
color: #f2b8b5;
border-color: var(--danger-border);
color: var(--danger);
}
.storage-delete:disabled {
+2 -1
View File
@@ -12,7 +12,8 @@
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" />
<meta name="theme-color" content="#16181d" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f5f6f8" media="(prefers-color-scheme: light)" />
<link rel="apple-touch-icon" href="./pwa/icons/icon-192.png" />
<title>Whisper Web — on-device speech-to-text</title>
</head>
+5 -2
View File
@@ -1,6 +1,7 @@
import { serve } from "bun";
import { tmpdir } from "node:os";
import { join, normalize, sep } from "node:path";
import { ensureFonts } from "../scripts/fonts.ts";
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons.ts";
import { injectPrecacheManifest } from "./pwa/precache.ts";
import { auth } from "./server/auth.ts";
@@ -240,9 +241,11 @@ async function bundleEntrypoint(relativePath: string): Promise<string> {
}
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.
// src/pwa/icons/*.png are generated (and checked in); index.html links them,
// so regenerate any missing ones before the HTML import bundles it.
await ensureIcons();
// Likewise src/fonts/ (self-hosted Inter, scripts/fonts.ts) for index.css.
await ensureFonts();
const { default: index } = await import("./index.html");
const workerScript = await bundleEntrypoint("./worker/inference.worker.ts");
+35 -2
View File
@@ -50,6 +50,27 @@ const RATE = 16_000;
*/
const MAX_WER = 0.25;
/**
* Per-clip absolute WER bars for recordings where even whole-file mode with
* this model cannot reach MAX_WER, so the bar would measure the clip, not
* the pipeline. Keep these rare and record the measurement that justifies
* them; the relative "live ≤ file + 0.1" check still applies to every clip.
*
* - en_alien: 15-min gaming livestream (music, game voices, crosstalk) added
* as the D-021 long-file fixture. Measured 2026-10-29: whole-file 0.261,
* live 0.251 (tiny.en whole-file: 0.315, D-021).
*/
const MAX_WER_BY_CLIP: Record<string, number> = { en_alien: 0.35 };
/**
* Time budget: headroom for the first run's ~600 MB weight download plus
* time proportional to the clip. Large v3 Turbo on CPU measured ≈ 1.34 s per
* second of audio for the whole-file + live passes combined (en_alien,
* 888 s of audio → 1189 s); 3 s per second leaves ~2× margin.
*/
const DOWNLOAD_BUDGET_MS = 900_000;
const MS_PER_AUDIO_SECOND = 3_000;
// ---------------------------------------------------------------------------
// Clip discovery: every reference transcript must have audio, and vice versa
// ---------------------------------------------------------------------------
@@ -64,6 +85,17 @@ interface Clip {
transcriptFile: string;
}
/** Clip length from ffmpeg's header probe (no decode); sizes the time budget. */
function probeDurationSeconds(path: string): number {
const { stderr } = Bun.spawnSync(
[ffmpegPath as unknown as string, "-hide_banner", "-i", path],
{ stdout: "ignore", stderr: "pipe" },
);
const match = /Duration: (\d+):(\d+):(\d+(?:\.\d+)?)/.exec(stderr.toString());
if (!match) throw new Error(`ffmpeg could not read the duration of ${path}`);
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
}
function discoverClips(): { clips: Clip[]; problems: string[] } {
const entries = readdirSync(AUDIO_DIR, { withFileTypes: true })
.filter((entry) => entry.isFile())
@@ -254,7 +286,7 @@ describe("live pipeline accuracy (real model, real recordings)", () => {
const result = await live.stop();
const liveWer = wordErrorRate(normalize(reference), normalize(result.text));
expect(liveWer).toBeLessThanOrEqual(MAX_WER);
expect(liveWer).toBeLessThanOrEqual(MAX_WER_BY_CLIP[clip.base] ?? MAX_WER);
// Segmentation must not cost accuracy relative to whole-file mode.
expect(liveWer).toBeLessThanOrEqual(fileWer + 0.1);
@@ -273,7 +305,8 @@ describe("live pipeline accuracy (real model, real recordings)", () => {
durationSeconds + 1,
);
},
900_000, // first run downloads ~600 MB of weights
DOWNLOAD_BUDGET_MS +
probeDurationSeconds(join(AUDIO_DIR, clip.audioFile)) * MS_PER_AUDIO_SECOND,
);
}
});
+5 -2
View File
@@ -8,8 +8,11 @@ with real Whisper weights and real speech is regression-tested too:
`tests/live-real-model.test.ts` runs **every** recording in
`tests/audio_files/` through the full mic path with `whisper-large-v3-turbo`
(q4) and holds live WER against the reference transcript (≤ 0.25; measured
≈ 0.11 for `da_festuge`, ≈ 0.06 for `en_aiproblem`) and against whole-file
mode (it skips itself when the model is neither cached nor downloadable).
≈ 0.11 for `da_festuge`, ≈ 0.06 for `en_aiproblem`; the noisy 15-min
`en_alien` stream has its own measured bar of 0.35 in `MAX_WER_BY_CLIP`,
D-039) and against whole-file mode (it skips itself when the model is
neither cached nor downloadable). Each clip's time budget scales with its
length, so `en_alien` alone takes ~20 min on CPU.
To cover a new recording, drop `<lang>_<name>.<m4a|mp3|wav|…>` plus its
reference transcript `<lang>_<name>.txt` into `tests/audio_files/` — the test
discovers it, decodes it with ffmpeg-static, pins `<lang>` as the Whisper