From 4942461f798e418df018e0ec8accdf5385173530 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 29 Oct 2026 20:52:05 +0100 Subject: [PATCH] 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 --- .claude/settings.json | 3 +- .gemini/settings.json | 2 +- .gitignore | 3 + .gitlab-ci.yml | 14 +++ AGENTS.md | 33 +++--- DESIGN.md | 27 ++--- README.md | 12 ++- bun.lock | 46 ++++++++ docs/Architecture.md | 5 +- docs/Decisions.md | 73 +++++++++++++ docs/PERMISSIONS.md | 8 +- docs/WorkBreakdown.md | 30 +++--- isolate_wer2.ts | 15 --- package.json | 5 +- scripts/build.ts | 21 +++- scripts/fonts.ts | 43 ++++++++ src/index.css | 196 +++++++++++++++++++++++++++------- src/index.html | 3 +- src/index.ts | 7 +- tests/live-real-model.test.ts | 37 ++++++- tests/live.md | 7 +- 21 files changed, 477 insertions(+), 113 deletions(-) delete mode 100644 isolate_wer2.ts create mode 100644 scripts/fonts.ts diff --git a/.claude/settings.json b/.claude/settings.json index aaa0a20..134e6ae 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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": [ diff --git a/.gemini/settings.json b/.gemini/settings.json index c676954..85da212 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -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)", diff --git a/.gitignore b/.gitignore index 3afd280..050afd3 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 31c76cb..23e9eb2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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` → : / :latest (Bun-only) diff --git a/AGENTS.md b/AGENTS.md index 13abf3f..de0e81b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`, `fix/`, `chore/`, `docs/`, `refactor/`, `test/` +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. diff --git a/DESIGN.md b/DESIGN.md index b820a2d..e6dd7c9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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). `` 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. diff --git a/README.md b/README.md index bda05a6..94ea7fb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index ba418d5..5059fc1 100644 --- a/bun.lock +++ b/bun.lock @@ -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/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.3.0", "", {}, "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA=="], + "@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="], "@huggingface/tokenizers": ["@huggingface/tokenizers@0.1.3", "", {}, "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA=="], @@ -138,6 +142,46 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "adm-zip": ["adm-zip@0.5.18", "", {}, "sha512-ufJnssQGbxzLNS1Ho9bCtX4rQKCCvoVuDLHoJyc3F9dOGDB4BkWs2Ci0kv53lqocAEQ/Cbi+I2XCsNYGqVYqng=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], @@ -256,6 +300,8 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@7.0.2", "", { "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": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], diff --git a/docs/Architecture.md b/docs/Architecture.md index 49a45b2..ea5fc28 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -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-`, 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 diff --git a/docs/Decisions.md b/docs/Decisions.md index 12c23cb..5dc8f08 100644 --- a/docs/Decisions.md +++ b/docs/Decisions.md @@ -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 +`` 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. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 4364505..1ec622d 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -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=… ` | 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. | diff --git a/docs/WorkBreakdown.md b/docs/WorkBreakdown.md index 0199edd..336b0b4 100644 --- a/docs/WorkBreakdown.md +++ b/docs/WorkBreakdown.md @@ -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`: diff --git a/isolate_wer2.ts b/isolate_wer2.ts deleted file mode 100644 index e02ebbf..0000000 --- a/isolate_wer2.ts +++ /dev/null @@ -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" }); diff --git a/package.json b/package.json index 98844f6..7389e07 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/build.ts b/scripts/build.ts index 7a6fa9c..702ed38 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -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)); diff --git a/scripts/fonts.ts b/scripts/fonts.ts new file mode 100644 index 0000000..1784141 --- /dev/null +++ b/scripts/fonts.ts @@ -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/)`, 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/)`. */ +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 { + 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; +} diff --git a/src/index.css b/src/index.css index 0a499bd..999d03c 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/index.html b/src/index.html index 5151adb..24135c1 100644 --- a/src/index.html +++ b/src/index.html @@ -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. --> - + + Whisper Web — on-device speech-to-text diff --git a/src/index.ts b/src/index.ts index c5b7837..328d7f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { } async function developmentServeOptions(): Promise { - // 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"); diff --git a/tests/live-real-model.test.ts b/tests/live-real-model.test.ts index 77c1150..3003937 100644 --- a/tests/live-real-model.test.ts +++ b/tests/live-real-model.test.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 = { 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, ); } }); diff --git a/tests/live.md b/tests/live.md index 61d3de6..01cf2e6 100644 --- a/tests/live.md +++ b/tests/live.md @@ -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 `_.` plus its reference transcript `_.txt` into `tests/audio_files/` — the test discovers it, decodes it with ffmpeg-static, pins `` as the Whisper