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:
co-authored by
Claude Opus 5.5
parent
0ece4b3a2a
commit
4942461f79
@@ -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": [
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`:
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user