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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5 committed 2026-07-10 04:02:28 +02:00
1 parent eb72a2e646
commit 9f39a82982
38 files changed
+3857 -246

No files matched your search

+153 -16
View File
@@ -1,17 +1,32 @@
import { useEffect, useRef, useState } from "react";
import "./index.css";
import { AuthForms } from "./components/AuthForms";
import { FileDropzone } from "./components/FileDropzone";
import { ModelLoadProgress } from "./components/ModelLoadProgress";
import { ModelPicker } from "./components/ModelPicker";
import { TranscriptView } from "./components/TranscriptView";
import { TranscriptionOptions } from "./components/TranscriptionOptions";
import { decodeAudioFile } from "./lib/audio";
import { DEFAULT_DEVICE, DEFAULT_MODEL_ID } from "./lib/models";
import { getDownloadedModels } from "./lib/modelCache";
import { registerServiceWorker } from "./pwa/register";
import {
AUTO_LANGUAGE,
DEFAULT_DEVICE_PREFERENCE,
DEFAULT_MODEL_ID,
DEFAULT_TASK,
MODELS,
getModel,
} from "./lib/models";
import {
applyLoadProgress,
initialLoadProgress,
isWorkerToUiMessage,
type DevicePreference,
type InferenceDevice,
type TranscriptChunk,
type UiToWorkerMessage,
type WhisperTask,
} from "./worker/protocol";
interface TranscriptResult {
@@ -19,6 +34,8 @@ interface TranscriptResult {
chunks: TranscriptChunk[];
}
const MODEL_IDS = MODELS.map((model) => model.id);
export function App() {
const workerRef = useRef<Worker | null>(null);
@@ -31,8 +48,33 @@ export function App() {
const [error, setError] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [modelId, setModelId] = useState(DEFAULT_MODEL_ID);
const [devicePreference, setDevicePreference] = useState<DevicePreference>(
DEFAULT_DEVICE_PREFERENCE,
);
const [activeDevice, setActiveDevice] = useState<InferenceDevice | null>(null);
const [language, setLanguage] = useState(AUTO_LANGUAGE);
const [task, setTask] = useState<WhisperTask>(DEFAULT_TASK);
const [downloaded, setDownloaded] = useState<ReadonlySet<string>>(new Set());
// PWA state (M3, D-007): is a service worker controlling this page (app
// shell available offline), and is an updated version waiting?
const [swControlled, setSwControlled] = useState(false);
const [applyUpdate, setApplyUpdate] = useState<(() => void) | null>(null);
function refreshDownloaded() {
// Downloaded badges (D-005): query Cache Storage for cached weights.
getDownloadedModels(MODEL_IDS).then(setDownloaded, () => {});
}
// Terminate the worker when the app unmounts (also keeps HMR tidy).
useEffect(() => {
refreshDownloaded();
// Registration is idempotent, so the StrictMode double-invoke is fine.
registerServiceWorker({
onControlled: () => setSwControlled(true),
onUpdateReady: (apply) => setApplyUpdate(() => apply),
});
return () => {
workerRef.current?.terminate();
workerRef.current = null;
@@ -52,6 +94,12 @@ export function App() {
switch (message.type) {
case "load-progress":
setLoadProgress((state) => applyLoadProgress(state, message));
if (message.status === "ready") {
// Surface the backend the worker actually selected (D-004) and
// refresh the downloaded badges now that weights are cached.
setActiveDevice(message.device);
refreshDownloaded();
}
break;
case "partial":
setPartialText((text) => text + message.text);
@@ -78,6 +126,38 @@ export function App() {
worker.postMessage(message, transfer ?? []);
}
/**
* Post a load request and reset per-load UI state so the progress panel
* restarts cleanly on model/device switches. Idempotent in the worker for
* an unchanged model+device; a change disposes the old pipeline first.
*/
function startLoad(nextModelId: string, preference: DevicePreference): Worker {
const worker = ensureWorker();
setLoadProgress(initialLoadProgress);
setLoadStarted(true);
setError(null);
postToWorker(worker, { type: "load", modelId: nextModelId, device: preference });
return worker;
}
function handleModelSelect(nextModelId: string) {
if (busy || nextModelId === modelId) return;
setModelId(nextModelId);
setActiveDevice(null);
setPartialText("");
setResult(null);
startLoad(nextModelId, devicePreference);
}
function handleDevicePreference(preference: DevicePreference) {
if (busy || preference === devicePreference) return;
setDevicePreference(preference);
setActiveDevice(null);
// Only reload eagerly if a load was already in motion; otherwise the next
// file drop picks up the new preference.
if (loadStarted) startLoad(modelId, preference);
}
async function handleFile(file: File) {
if (busy) return;
setBusy(true);
@@ -89,20 +169,28 @@ export function App() {
// Kick off the (idempotent) model load immediately so the download runs
// while we decode the audio locally.
const worker = ensureWorker();
postToWorker(worker, {
type: "load",
modelId: DEFAULT_MODEL_ID,
device: DEFAULT_DEVICE,
});
setLoadStarted(true);
if (!loadStarted) {
startLoad(modelId, devicePreference);
} else {
postToWorker(worker, { type: "load", modelId, device: devicePreference });
}
const multilingual = getModel(modelId)?.multilingual ?? false;
setDecoding(true);
try {
// Decoded entirely on-device via Web Audio (D-012); the audio never
// touches the network (Architecture.md §5).
const audio = await decodeAudioFile(file);
// Transfer the buffer to the worker — zero copy.
postToWorker(worker, { type: "transcribe", audio }, [audio.buffer as ArrayBuffer]);
// Transfer the buffer to the worker — zero copy. Language/task only
// apply to multilingual models; `.en` variants take neither.
postToWorker(
worker,
multilingual
? { type: "transcribe", audio, language, task }
: { type: "transcribe", audio },
[audio.buffer as ArrayBuffer],
);
} catch (decodeError) {
setError(decodeError instanceof Error ? decodeError.message : String(decodeError));
setBusy(false);
@@ -111,6 +199,7 @@ export function App() {
}
}
const model = getModel(modelId);
const modelReady = loadProgress.ready;
const showModelProgress = loadStarted && !modelReady && !error;
const transcribing = busy && !decoding && modelReady;
@@ -118,13 +207,40 @@ export function App() {
return (
<main className="app">
<header className="app-header">
<h1>Whisper Web</h1>
<p className="tagline">
Speech-to-text that runs entirely in your browser. Your audio never
leaves this device.
</p>
<div>
<h1>Whisper Web</h1>
<p className="tagline">
Speech-to-text that runs entirely in your browser. Your audio never
leaves this device.
</p>
</div>
{/* Session UI (M4). Anonymous-first: logged out, this is only an
unobtrusive "Sign in" button — everything else is unchanged. */}
<AuthForms />
</header>
<ModelPicker
models={MODELS}
selectedId={modelId}
onSelect={handleModelSelect}
devicePreference={devicePreference}
onDevicePreferenceChange={handleDevicePreference}
activeDevice={activeDevice}
downloaded={downloaded}
offlineReady={swControlled}
disabled={busy}
/>
{model?.multilingual && (
<TranscriptionOptions
language={language}
onLanguageChange={setLanguage}
task={task}
onTaskChange={setTask}
disabled={busy}
/>
)}
<FileDropzone onFile={handleFile} disabled={busy} />
{fileName && (
@@ -141,7 +257,7 @@ export function App() {
)}
{showModelProgress && (
<ModelLoadProgress modelId={DEFAULT_MODEL_ID} progress={loadProgress} />
<ModelLoadProgress modelId={modelId} progress={loadProgress} />
)}
<TranscriptView
@@ -150,9 +266,30 @@ export function App() {
transcribing={transcribing}
/>
{applyUpdate && (
<div className="update-toast" role="status">
<span>A new version of Whisper Web is available.</span>
<button type="button" onClick={applyUpdate}>
Reload
</button>
<button
type="button"
className="update-toast-dismiss"
onClick={() => setApplyUpdate(null)}
>
Later
</button>
</div>
)}
<footer className="app-footer">
<p>
Model: <code>{DEFAULT_MODEL_ID}</code> · Engine: WASM (on-device)
Model: <code>{modelId}</code> · Engine:{" "}
{activeDevice === "webgpu"
? "WebGPU (on-device)"
: activeDevice === "wasm"
? "WASM (on-device)"
: "on-device — backend selected on first load"}
</p>
</footer>
</main>
+192
View File
@@ -0,0 +1,192 @@
import { useState, type FormEvent, type KeyboardEvent } from "react";
import { validatePassword, validateUsername } from "../lib/authClient";
import { useAuth } from "../lib/useAuth";
type Mode = "login" | "register";
/**
* Session-aware header widget (M4): an unobtrusive "Sign in" affordance while
* anonymous — the app works fully without an account (Architecture.md §5) —
* that opens a compact login/register popover; once signed in, the username
* and a "Sign out" button.
*
* Validation mirrors the server rules for instant feedback, but the server
* stays authoritative: its `{ error }` messages are shown verbatim on 4xx.
*/
export function AuthForms() {
const { user, login, register, logout } = useAuth();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
function switchMode(next: Mode) {
if (pending || next === mode) return;
setMode(next);
setError(null); // stale errors from the other form would mislead
}
function togglePanel() {
setOpen((wasOpen) => !wasOpen);
setError(null);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (pending) return;
// Client-side pre-flight: full rules for register; login only needs
// non-empty fields (the server answers 401 for anything wrong).
if (mode === "register") {
const problem = validateUsername(username) ?? validatePassword(password);
if (problem) {
setError(problem);
return;
}
} else if (!username || !password) {
setError("Enter a username and password.");
return;
}
setPending(true);
setError(null);
try {
if (mode === "register") {
await register(username, password);
} else {
await login(username, password);
}
// Success — the hook now holds the user; reset the panel for next time.
setOpen(false);
setUsername("");
setPassword("");
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : String(submitError),
);
} finally {
setPending(false);
}
}
async function handleLogout() {
if (pending) return;
setPending(true);
try {
await logout(); // always resolves; state is cleared even when offline
} finally {
setPending(false);
}
}
function onPanelKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Escape") setOpen(false);
}
if (user) {
return (
<div className="auth">
<span className="auth-username" title="Signed in">
{user.username}
</span>
<button
type="button"
className="auth-button"
onClick={handleLogout}
disabled={pending}
>
Sign out
</button>
</div>
);
}
return (
<div className="auth">
<button
type="button"
className="auth-button"
aria-expanded={open}
aria-haspopup="dialog"
onClick={togglePanel}
>
Sign in
</button>
{open && (
<div
className="auth-panel"
role="dialog"
aria-label="Sign in or create an account"
onKeyDown={onPanelKeyDown}
>
<div className="auth-tabs" role="tablist" aria-label="Account">
<button
type="button"
role="tab"
aria-selected={mode === "login"}
className={`auth-tab${mode === "login" ? " auth-tab-selected" : ""}`}
onClick={() => switchMode("login")}
>
Log in
</button>
<button
type="button"
role="tab"
aria-selected={mode === "register"}
className={`auth-tab${mode === "register" ? " auth-tab-selected" : ""}`}
onClick={() => switchMode("register")}
>
Register
</button>
</div>
<form onSubmit={handleSubmit}>
<label>
Username
<input
type="text"
value={username}
autoComplete="username"
autoFocus
disabled={pending}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={password}
autoComplete={
mode === "register" ? "new-password" : "current-password"
}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{mode === "register" && (
<p className="auth-hint">
3–32 characters (letters, digits, _ . -); password ≥ 8
characters.
</p>
)}
{error && (
<p className="auth-error" role="alert">
{error}
</p>
)}
<button type="submit" className="auth-submit" disabled={pending}>
{pending
? "Working…"
: mode === "register"
? "Create account"
: "Log in"}
</button>
</form>
</div>
)}
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import {
estimateDefaultDownloadBytes,
formatModelSize,
type WhisperModel,
} from "../lib/models";
import type { DevicePreference, InferenceDevice } from "../worker/protocol";
interface ModelPickerProps {
models: readonly WhisperModel[];
selectedId: string;
onSelect: (modelId: string) => void;
devicePreference: DevicePreference;
onDevicePreferenceChange: (preference: DevicePreference) => void;
/** Backend the worker actually selected; null until the first load (D-004). */
activeDevice: InferenceDevice | null;
/** Model ids with weights already in the browser cache (D-005). */
downloaded: ReadonlySet<string>;
/** True when the service worker controls the page — app shell cached (M3). */
offlineReady: boolean;
/** True while a transcription is running — switching is disabled then. */
disabled?: boolean;
}
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
{ value: "auto", label: "Auto (WebGPU if available)" },
{ value: "webgpu", label: "WebGPU" },
{ value: "wasm", label: "WASM (CPU)" },
];
/**
* Curated model list (D-006): size estimate before download, English-only vs
* multilingual grouping, downloaded badge from the Cache API (D-005), and a
* device preference select (default "auto", D-004).
*/
export function ModelPicker({
models,
selectedId,
onSelect,
devicePreference,
onDevicePreferenceChange,
activeDevice,
downloaded,
offlineReady,
disabled = false,
}: ModelPickerProps) {
// Size estimates depend on the dtype the worker will pick, which depends on
// the device. Until the worker has reported one, assume WASM unless the
// user explicitly asked for WebGPU.
const estimateDevice: InferenceDevice =
activeDevice ?? (devicePreference === "webgpu" ? "webgpu" : "wasm");
const groups: { title: string; items: WhisperModel[] }[] = [
{
title: "English-only",
items: models.filter((model) => !model.multilingual),
},
{
title: "Multilingual",
items: models.filter((model) => model.multilingual),
},
];
return (
<section className="model-picker" aria-label="Model selection">
<div className="model-picker-header">
<h2>Model</h2>
<label className="device-select">
Backend
<select
value={devicePreference}
disabled={disabled}
onChange={(event) =>
onDevicePreferenceChange(event.target.value as DevicePreference)
}
>
{DEVICE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{disabled && (
<p className="model-picker-hint">
Model switching is disabled while a transcription is running.
</p>
)}
<p
className={`offline-status${offlineReady && downloaded.size > 0 ? " offline-status-ready" : ""}`}
>
{offlineReady
? downloaded.size > 0
? "Offline ready — the app and the models marked “available offline” work without a network connection."
: "The app is cached for offline use. Load a model once to make it available offline too."
: "Offline support is not active yet (the service worker is still installing, or this browser does not support it)."}
</p>
{groups.map((group) => (
<fieldset key={group.title} className="model-group" disabled={disabled}>
<legend>{group.title}</legend>
<ul>
{group.items.map((model) => {
const size = formatModelSize(
estimateDefaultDownloadBytes(model, estimateDevice),
);
const isDownloaded = downloaded.has(model.id);
const isSelected = model.id === selectedId;
return (
<li key={model.id}>
<label
className={`model-option${isSelected ? " model-option-selected" : ""}`}
>
<input
type="radio"
name="model"
value={model.id}
checked={isSelected}
disabled={disabled}
onChange={() => onSelect(model.id)}
/>
<span className="model-option-name">{model.displayName}</span>
<span className="model-option-meta">
{isDownloaded && (
<span
className="model-badge"
title="Weights are cached in this browser — this model loads and transcribes without a network connection."
>
available offline
</span>
)}
<span className="model-option-size">~{size}</span>
</span>
</label>
</li>
);
})}
</ul>
</fieldset>
))}
</section>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { AUTO_LANGUAGE, WHISPER_LANGUAGES } from "../lib/models";
import type { WhisperTask } from "../worker/protocol";
interface TranscriptionOptionsProps {
language: string;
onLanguageChange: (language: string) => void;
task: WhisperTask;
onTaskChange: (task: WhisperTask) => void;
disabled?: boolean;
}
/**
* Language + task controls for multilingual models. The parent renders this
* only when the selected model is multilingual — `.en` models take neither.
*/
export function TranscriptionOptions({
language,
onLanguageChange,
task,
onTaskChange,
disabled = false,
}: TranscriptionOptionsProps) {
return (
<div className="transcription-options">
<label>
Language
<select
value={language}
disabled={disabled}
onChange={(event) => onLanguageChange(event.target.value)}
>
<option value={AUTO_LANGUAGE}>Auto-detect</option>
{WHISPER_LANGUAGES.map((entry) => (
<option key={entry.code} value={entry.code}>
{entry.label}
</option>
))}
</select>
</label>
<label>
Task
<select
value={task}
disabled={disabled}
onChange={(event) => onTaskChange(event.target.value as WhisperTask)}
>
<option value="transcribe">Transcribe</option>
<option value="translate">Translate to English</option>
</select>
</label>
</div>
);
}
+321
View File
@@ -25,6 +25,14 @@ body {
gap: 1.5rem;
}
.app-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.app-header h1 {
margin: 0 0 0.25rem;
font-size: 2.2rem;
@@ -36,6 +44,319 @@ body {
color: var(--muted);
}
/* --- AuthForms (M4) --- */
.auth {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.6rem;
/* Roughly align the button with the h1 baseline. */
margin-top: 0.35rem;
}
.auth-username {
color: var(--muted);
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.auth-button {
border: 1px solid var(--border);
background: transparent;
color: inherit;
border-radius: 8px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s;
}
.auth-button:hover:enabled {
border-color: var(--accent);
}
.auth-button:disabled {
opacity: 0.5;
cursor: default;
}
.auth-panel {
position: absolute;
right: 0;
top: calc(100% + 0.5rem);
width: min(17rem, calc(100vw - 2.5rem));
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.auth-tabs {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.auth-tab {
flex: 1;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
border-radius: 8px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
cursor: pointer;
}
.auth-tab:hover:enabled {
border-color: var(--border);
}
.auth-tab-selected,
.auth-tab-selected:hover:enabled {
border-color: var(--accent);
background: #232a38;
color: inherit;
}
.auth-panel form {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.auth-panel label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--muted);
}
.auth-panel input {
background: #262b34;
color: #e8e8e8;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.35rem 0.5rem;
font-size: 0.9rem;
}
.auth-panel input:focus-visible {
border-color: var(--accent);
outline: none;
}
.auth-hint {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
}
.auth-error {
margin: 0;
font-size: 0.85rem;
color: #f2b8b5;
}
.auth-submit {
background: var(--accent);
color: #16181d;
border: none;
border-radius: 8px;
padding: 0.4rem 0.8rem;
font: inherit;
font-size: 0.9rem;
cursor: pointer;
}
.auth-submit:disabled {
opacity: 0.6;
cursor: default;
}
/* --- ModelPicker --- */
.model-picker {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1rem 1.25rem;
}
.model-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.model-picker-header h2 {
margin: 0;
font-size: 1rem;
}
.model-picker-hint {
margin: 0.5rem 0 0;
color: var(--muted);
font-size: 0.85rem;
}
.device-select,
.transcription-options label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--muted);
}
.model-picker select,
.transcription-options select {
background: #262b34;
color: inherit;
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.3rem 0.5rem;
font-size: 0.85rem;
}
.model-group {
border: none;
margin: 0.75rem 0 0;
padding: 0;
}
.model-group legend {
padding: 0;
margin-bottom: 0.35rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.model-group ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.model-option {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border: 1px solid transparent;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
}
.model-group:not(:disabled) .model-option:hover {
border-color: var(--border);
}
.model-option-selected,
.model-group:not(:disabled) .model-option-selected:hover {
border-color: var(--accent);
background: #232a38;
}
.model-group:disabled .model-option {
cursor: default;
opacity: 0.6;
}
.model-option input {
accent-color: var(--accent);
}
.model-option-name {
flex: 1;
}
.model-option-meta {
display: flex;
align-items: center;
gap: 0.6rem;
}
.model-option-size {
color: var(--muted);
font-size: 0.85rem;
white-space: nowrap;
}
.model-badge {
border: 1px solid #3d6b4f;
background: #1d2b23;
color: #9ad6ae;
border-radius: 999px;
padding: 0.1rem 0.55rem;
font-size: 0.75rem;
white-space: nowrap;
}
/* --- PWA / offline (M3) --- */
.offline-status {
margin: 0.5rem 0 0.75rem;
font-size: 0.85rem;
color: var(--muted);
}
.offline-status-ready {
color: #9ad6ae;
}
.update-toast {
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 8px;
padding: 0.6rem 1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
z-index: 10;
}
.update-toast button {
background: var(--accent);
color: #16181d;
border: none;
border-radius: 6px;
padding: 0.3rem 0.8rem;
font: inherit;
cursor: pointer;
}
.update-toast button.update-toast-dismiss {
background: transparent;
color: var(--muted);
border: 1px solid var(--border);
}
/* --- TranscriptionOptions --- */
.transcription-options {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
/* --- FileDropzone --- */
.dropzone {
+7
View File
@@ -7,6 +7,13 @@
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%EF%B8%8F%3C/text%3E%3C/svg%3E"
/>
<!-- Relative hrefs: Bun's HTML loader must resolve every <link href> to a
real file (absolute URLs fail the build); it hashes + rewrites them.
The manifest's own icon URLs stay at the fixed /icons/ paths, which
src/index.ts (dev) and scripts/build.ts (production) serve. -->
<link rel="manifest" href="./pwa/manifest.webmanifest" />
<meta name="theme-color" content="#16181d" />
<link rel="apple-touch-icon" href="./pwa/icons/icon-192.png" />
<title>Whisper Web — on-device speech-to-text</title>
</head>
<body>
+186 -35
View File
@@ -1,43 +1,194 @@
import { serve } from "bun";
import index from "./index.html";
import { join, normalize, sep } from "node:path";
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons";
import { injectPrecacheManifest } from "./pwa/precache";
import { auth } from "./server/auth";
// Bun's bundler does not currently follow `new Worker(...)` graphs (verified
// with Bun 1.3: both `bun build` and the dev server leave the call untouched),
// so the inference worker is bundled here at startup and served from a stable
// URL. The UI spawns it with `new Worker("/inference.worker.js")`.
const workerBuild = await Bun.build({
entrypoints: [
Bun.fileURLToPath(new URL("./worker/inference.worker.ts", import.meta.url)),
],
target: "browser",
format: "esm",
minify: process.env.NODE_ENV === "production",
});
const workerArtifact = workerBuild.outputs[0];
if (!workerArtifact) {
throw new Error("Failed to bundle src/worker/inference.worker.ts");
const ROOT = join(import.meta.dir, "..");
const IS_PRODUCTION = process.env.NODE_ENV === "production";
// ---------------------------------------------------------------------------
// Shared static helpers
// ---------------------------------------------------------------------------
const NOT_FOUND = () => new Response("Not Found", { status: 404 });
// onnxruntime-web fetches its .wasm binaries at runtime — from the jsdelivr
// CDN by default, which would break offline. The worker points
// `env.backends.onnx.wasm.wasmPaths` at /ort/ instead (see
// src/worker/inference.worker.ts), we serve the files from node_modules
// here (production: copied into dist/ort by scripts/build.ts), and the
// service worker runtime-caches them cache-first.
const ORT_DIST = join(ROOT, "node_modules/onnxruntime-web/dist");
export const ORT_FILE_RE = /^ort-wasm-simd-threaded(\.jsep|\.jspi|\.asyncify)?\.(wasm|mjs)$/;
const CONTENT_TYPES: Record<string, string> = {
".wasm": "application/wasm",
".mjs": "text/javascript; charset=utf-8",
".webmanifest": "application/manifest+json",
};
function contentTypeFor(path: string): string | undefined {
const dot = path.lastIndexOf(".");
return dot === -1 ? undefined : CONTENT_TYPES[path.slice(dot)];
}
const workerScript = await workerArtifact.text();
// Minimal Bun.serve entry (Architecture.md §2). API routes (auth, server
// transcription) arrive in M4/M5 — see src/server/.
const server = serve({
routes: {
"/inference.worker.js": new Response(workerScript, {
headers: { "Content-Type": "text/javascript; charset=utf-8" },
}),
async function serveFile(path: string, cacheControl?: string): Promise<Response> {
const file = Bun.file(path);
if (!(await file.exists())) return NOT_FOUND();
const headers = new Headers();
const type = contentTypeFor(path);
if (type) headers.set("Content-Type", type);
if (cacheControl) headers.set("Cache-Control", cacheControl);
return new Response(file, { headers });
}
// Serve the bundled React app for all other routes.
"/*": index,
},
async function serveOrtFile(dir: string, name: string): Promise<Response> {
if (!ORT_FILE_RE.test(name)) return NOT_FOUND();
return serveFile(join(dir, name));
}
development: process.env.NODE_ENV !== "production" && {
// Enable browser hot reloading in development
hmr: true,
// ---------------------------------------------------------------------------
// API dispatcher — shared by development and production so /api/* behaves
// identically in both serve modes (the SW treats it as network-only, D-007).
// Auth endpoints are M4 (src/server/auth.ts); /api/transcribe joins in M5.
// ---------------------------------------------------------------------------
// Echo console logs from the browser to the server
console: true,
},
});
async function handleApiRequest(request: Request): Promise<Response> {
const { pathname } = new URL(request.url);
switch (`${request.method} ${pathname}`) {
case "POST /api/auth/register":
return auth.register(request);
case "POST /api/auth/login":
return auth.login(request);
case "POST /api/auth/logout":
return auth.logout(request);
case "GET /api/auth/me":
return auth.me(request);
default:
return NOT_FOUND();
}
}
console.log(`Server running at ${server.url}`);
// ---------------------------------------------------------------------------
// Production: serve the prebuilt dist/ directory (created by `bun run build`)
// so the URLs match the hashed asset paths precached by dist/sw.js (D-007).
// ---------------------------------------------------------------------------
type ServeOptions = Parameters<typeof serve>[0];
async function productionServeOptions(): Promise<ServeOptions> {
const dist = join(ROOT, "dist");
if (!(await Bun.file(join(dist, "sw.js")).exists())) {
throw new Error("dist/ is missing or incomplete — run `bun run build` first.");
}
return {
development: false,
async fetch(request) {
const url = new URL(request.url);
// API routes are dynamic (M4/M5) and network-only in the SW (D-007).
if (url.pathname.startsWith("/api/")) return handleApiRequest(request);
const relative = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
const path = normalize(join(dist, relative));
if (!path.startsWith(dist + sep)) return NOT_FOUND(); // traversal guard
const response = await serveFile(
path,
// Hashed chunks are immutable; everything else must revalidate so the
// SW update flow sees new versions.
/(^|\/)(chunk|index)-[a-z0-9]+\./.test(relative)
? "public, max-age=31536000, immutable"
: "no-cache",
);
if (response.status !== 404) return response;
// SPA fallback: serve the app shell for unknown navigations.
return serveFile(join(dist, "index.html"), "no-cache");
},
};
}
// ---------------------------------------------------------------------------
// Development: HTML imports + on-the-fly bundles
// ---------------------------------------------------------------------------
/**
* Bun's bundler does not currently follow `new Worker(...)` graphs (verified
* with Bun 1.3: both `bun build` and the dev server leave the call untouched),
* so worker-like entrypoints are bundled at startup and served from stable
* URLs. The UI spawns `new Worker("/inference.worker.js")`; the service
* worker registers as "/sw.js".
*/
async function bundleEntrypoint(relativePath: string): Promise<string> {
const build = await Bun.build({
entrypoints: [join(import.meta.dir, relativePath)],
target: "browser",
format: "esm",
// Never minify: sw.ts relies on its placeholder string literals surviving
// bundling verbatim (src/pwa/precache.ts), and dev builds favor debuggability.
minify: false,
});
const artifact = build.outputs[0];
if (!artifact) throw new Error(`Failed to bundle src/${relativePath}`);
return artifact.text();
}
async function developmentServeOptions(): Promise<ServeOptions> {
// src/pwa/icons/*.png are generated, not checked in; index.html links them,
// so they must exist before the HTML import bundles it.
await ensureIcons();
const { default: index } = await import("./index.html");
const workerScript = await bundleEntrypoint("./worker/inference.worker.ts");
// Dev SW: empty precache list — assets are unhashed here, so the shell is
// runtime-cached network-first instead (see src/pwa/sw.ts).
const swScript = injectPrecacheManifest(
await bundleEntrypoint("./pwa/sw.ts"),
[],
"dev",
);
const scriptHeaders = { "Content-Type": "text/javascript; charset=utf-8" };
return {
routes: {
"/inference.worker.js": new Response(workerScript, { headers: scriptHeaders }),
"/sw.js": new Response(swScript, {
// The browser's SW update check must always see fresh bytes.
headers: { ...scriptHeaders, "Cache-Control": "no-cache" },
}),
"/manifest.webmanifest": () =>
serveFile(join(import.meta.dir, "pwa/manifest.webmanifest")),
"/icons/:file": (request) => {
const name = request.params.file ?? "";
return /^icon-\d+\.png$/.test(name)
? serveFile(join(ICONS_DIR, name))
: NOT_FOUND();
},
"/ort/:file": (request) => serveOrtFile(ORT_DIST, request.params.file ?? ""),
// API routes go through the same dispatcher production uses.
"/api/*": handleApiRequest,
// Serve the bundled React app for all other routes.
"/*": index,
},
development: {
// Enable browser hot reloading in development
hmr: true,
// Echo console logs from the browser to the server
console: true,
},
};
}
// ---------------------------------------------------------------------------
const server = serve(
IS_PRODUCTION ? await productionServeOptions() : await developmentServeOptions(),
);
console.log(
`Server running at ${server.url} (${IS_PRODUCTION ? "production, serving dist/" : "development"})`,
);
+174
View File
@@ -0,0 +1,174 @@
/**
* Auth API client (M4). Deliberately framework-free: validation is pure and
* the fetch layer is injectable, so `bun test` can exercise everything without
* a DOM or a running server (tests/auth-client.test.ts). React state lives in
* `useAuth` (src/lib/useAuth.ts).
*
* Contract (mirrored by src/server): all endpoints exchange JSON and errors
* arrive as `{ error: string }`. The session cookie is HttpOnly, so
* `GET /api/auth/me` is the ONLY way to learn the current auth state.
*/
export interface AuthUser {
id: number | string;
username: string;
}
/** Minimal fetch signature so tests can inject a mock. */
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
// Bound lazily via globalThis: bare `fetch` references passed around as values
// can throw "Illegal invocation" in some browsers.
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init);
// --- Client-side validation ---------------------------------------------
// Mirrors the server rules for instant feedback; the server stays
// authoritative and its `{ error }` messages are surfaced on 4xx regardless.
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
export const PASSWORD_MIN_LENGTH = 8;
/** Returns a human-readable problem with the username, or null when valid. */
export function validateUsername(username: string): string | null {
if (
username.length < USERNAME_MIN_LENGTH ||
username.length > USERNAME_MAX_LENGTH
) {
return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`;
}
if (!USERNAME_PATTERN.test(username)) {
return "Username may only contain letters, digits, and _ . -";
}
return null;
}
/** Returns a human-readable problem with the password, or null when valid. */
export function validatePassword(password: string): string | null {
if (password.length < PASSWORD_MIN_LENGTH) {
return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
}
return null;
}
// --- API requests ---------------------------------------------------------
function isAuthUser(value: unknown): value is AuthUser {
if (typeof value !== "object" || value === null) return false;
const candidate = value as { id?: unknown; username?: unknown };
return (
(typeof candidate.id === "number" || typeof candidate.id === "string") &&
typeof candidate.username === "string"
);
}
/** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */
async function readErrorMessage(
response: Response,
fallback: string,
): Promise<string> {
try {
const body: unknown = await response.json();
if (
typeof body === "object" &&
body !== null &&
typeof (body as { error?: unknown }).error === "string"
) {
return (body as { error: string }).error;
}
} catch {
// Non-JSON error body (proxy page, empty 500) — use the fallback.
}
return fallback;
}
/**
* POST credentials to an auth endpoint and return the signed-in user.
* Throws an `Error` whose message is safe to show in the UI.
*/
async function requestUser(
fetchFn: FetchLike,
path: string,
credentials: { username: string; password: string },
): Promise<AuthUser> {
let response: Response;
try {
response = await fetchFn(path, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
} catch {
throw new Error("Could not reach the server. Check your connection.");
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Request failed (${response.status}).`),
);
}
const payload: unknown = await response.json().catch(() => null);
const user = (payload as { user?: unknown } | null)?.user;
if (!isAuthUser(user)) {
throw new Error("The server returned an unexpected response.");
}
return user;
}
/** POST /api/auth/login → user, or throws with the server's error message. */
export function login(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/login", { username, password });
}
/** POST /api/auth/register → user (auto signed in), or throws (400/409). */
export function register(
username: string,
password: string,
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser> {
return requestUser(fetchFn, "/api/auth/register", { username, password });
}
/**
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
* the app is offline-first and /api/* is network-only in the service worker,
* so 401s, network failures, and malformed bodies all resolve to anonymous
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
*/
export async function fetchCurrentUser(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthUser | null> {
try {
const response = await fetchFn("/api/auth/me", {
method: "GET",
credentials: "same-origin",
});
if (!response.ok) return null;
const payload: unknown = await response.json();
const user = (payload as { user?: unknown } | null)?.user;
return isAuthUser(user) ? user : null;
} catch {
return null;
}
}
/**
* POST /api/auth/logout (→ 204). Swallows failures: the caller clears local
* state either way, and an unreachable server just means the cookie expires
* on its own.
*/
export async function logout(fetchFn: FetchLike = defaultFetch): Promise<void> {
try {
await fetchFn("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} catch {
// Offline — nothing to do.
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* "Is this model already downloaded?" helper (Decisions.md D-005).
*
* Transformers.js v3 persists Hub downloads in the browser Cache API (Cache
* Storage, cache name "transformers-cache"). We inspect the cached request
* URLs and match them against catalog model ids. Used by `ModelPicker` for the
* downloaded badge (M2) and reused by the offline indicator (M3).
*
* `matchCachedModels` is pure so it can be unit-tested; only
* `getDownloadedModels` touches the `caches` global.
*/
/** Cache name used by Transformers.js v3 (`env.cacheName` default). */
export const TRANSFORMERS_CACHE_NAME = "transformers-cache";
/**
* Pure matcher: which of `modelIds` have at least one cached ONNX weight file?
*
* Cached URLs look like
* `https://huggingface.co/<modelId>/resolve/main/onnx/<file>.onnx`. We require
* an `.onnx` hit (not just tokenizer/config JSON) so a model whose weights
* never finished downloading does not show as available.
*/
export function matchCachedModels(
urls: Iterable<string>,
modelIds: readonly string[],
): Set<string> {
const downloaded = new Set<string>();
for (const url of urls) {
if (!url.split("?")[0]?.endsWith(".onnx")) continue;
for (const modelId of modelIds) {
if (!downloaded.has(modelId) && url.includes(`/${modelId}/`)) {
downloaded.add(modelId);
}
}
}
return downloaded;
}
/**
* Query the browser Cache API for models with cached weights. Returns an
* empty set where Cache Storage is unavailable (non-secure contexts, tests).
*/
export async function getDownloadedModels(
modelIds: readonly string[],
): Promise<Set<string>> {
if (typeof caches === "undefined") return new Set();
try {
const urls: string[] = [];
// Match any transformers-owned cache, in case the name is ever versioned.
for (const name of await caches.keys()) {
if (!name.includes("transformers")) continue;
const cache = await caches.open(name);
for (const request of await cache.keys()) urls.push(request.url);
}
return matchCachedModels(urls, modelIds);
} catch {
return new Set();
}
}
+260 -12
View File
@@ -1,20 +1,268 @@
/**
* Model catalog (Decisions.md D-006).
* Curated Whisper model catalog (Decisions.md D-006).
*
* M1 ships a single hardcoded model. The curated catalog — tiny → large,
* English-only vs multilingual, dtype defaults per device, size estimates —
* lands in M2.
* Pure, typed data — no browser globals — so it is unit-testable with
* `bun test` and shareable with the server engine (M5). ONNX conversions come
* from the `onnx-community` Hub org (the recommended source for
* Transformers.js v3+).
*
* TODO(M2): curated `MODELS` catalog with sizes/dtypes/languages (D-006).
* dtype defaults per device follow the community-known-good combinations:
* - WASM: q8 for small models, q4 for the large ones (download size).
* - WebGPU: fp16 encoder + q4 merged decoder for the big models; fp32 for
* tiny/base where fp16 has known accuracy issues.
* `verified` is only true for combinations we have actually run in a browser;
* the rest are pending manual testing (WorkBreakdown M2).
*/
import type { InferenceDevice } from "../worker/protocol";
import type {
DevicePreference,
DtypeSpec,
InferenceDevice,
WhisperDtype,
WhisperTask,
} from "../worker/protocol";
// ---------------------------------------------------------------------------
// Catalog types
// ---------------------------------------------------------------------------
/** Dtype default for one device, with verification status (D-006). */
export interface DtypeDefault {
dtype: DtypeSpec;
/** True only if this exact combination has been run in a real browser. */
verified: boolean;
/** Why this combination was chosen / caveats. */
note: string;
}
export interface WhisperModel {
/** Hugging Face Hub id, e.g. "onnx-community/whisper-tiny.en". */
id: string;
/** Human-readable name for the picker. */
displayName: string;
/** False for `.en` (English-only) variants. */
multilingual: boolean;
/** Encoder parameter count, in millions (drives size estimates). */
encoderParamsM: number;
/** Decoder parameter count, in millions (drives size estimates). */
decoderParamsM: number;
/** Per-device dtype defaults (D-004 / D-006). */
defaults: Record<InferenceDevice, DtypeDefault>;
}
// ---------------------------------------------------------------------------
// Size estimation (approximate, shown before download — D-006)
// ---------------------------------------------------------------------------
/** Approximate stored bytes per parameter for each quantization level. */
export const BYTES_PER_PARAM: Record<WhisperDtype, number> = {
fp32: 4,
fp16: 2,
q8: 1.05, // int8 weights + scales/zero-points overhead
q4: 0.6, // 4-bit blocks + per-block scales; some tensors stay higher precision
};
/** Tokenizer + configs + preprocessor, roughly constant per model. */
const AUX_FILES_BYTES = 3_000_000;
/** Approximate total download size for a model at a given dtype spec. */
export function estimateDownloadBytes(
model: WhisperModel,
dtype: DtypeSpec,
): number {
const encDtype = typeof dtype === "string" ? dtype : dtype.encoder_model;
const decDtype = typeof dtype === "string" ? dtype : dtype.decoder_model_merged;
return Math.round(
model.encoderParamsM * 1e6 * BYTES_PER_PARAM[encDtype] +
model.decoderParamsM * 1e6 * BYTES_PER_PARAM[decDtype] +
AUX_FILES_BYTES,
);
}
/** "487 MB" / "1.6 GB" style formatting for size estimates. */
export function formatModelSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
return `${Math.round(bytes / 1e6)} MB`;
}
// ---------------------------------------------------------------------------
// dtype defaults (community-known-good; see file header)
// ---------------------------------------------------------------------------
const WASM_Q8: DtypeDefault = {
dtype: "q8",
verified: false,
note: "q8 is the Transformers.js WASM default; good quality/size trade-off.",
};
const WASM_Q4: DtypeDefault = {
dtype: "q4",
verified: false,
note: "q4 keeps the download manageable for larger models on WASM.",
};
/** fp16 has known accuracy issues for the small Whisper encoders on WebGPU. */
const WEBGPU_FP32: DtypeDefault = {
dtype: { encoder_model: "fp32", decoder_model_merged: "fp32" },
verified: false,
note: "fp32 both parts: fp16 encoders are known-bad for tiny/base on WebGPU.",
};
const WEBGPU_FP16_Q4: DtypeDefault = {
dtype: { encoder_model: "fp16", decoder_model_merged: "q4" },
verified: false,
note: "fp16 encoder + q4 merged decoder — the common working WebGPU setup.",
};
// ---------------------------------------------------------------------------
// The catalog (D-006: curated, not exhaustive)
// ---------------------------------------------------------------------------
export const MODELS: readonly WhisperModel[] = [
{
id: "onnx-community/whisper-tiny.en",
displayName: "Whisper Tiny (English)",
multilingual: false,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: {
// Verified in-browser during M1 (WASM, Transformers.js default q8).
wasm: { ...WASM_Q8, verified: true },
webgpu: WEBGPU_FP32,
},
},
{
id: "onnx-community/whisper-tiny",
displayName: "Whisper Tiny",
multilingual: true,
encoderParamsM: 8,
decoderParamsM: 31,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base.en",
displayName: "Whisper Base (English)",
multilingual: false,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-base",
displayName: "Whisper Base",
multilingual: true,
encoderParamsM: 20,
decoderParamsM: 54,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP32 },
},
{
id: "onnx-community/whisper-small.en",
displayName: "Whisper Small (English)",
multilingual: false,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-small",
displayName: "Whisper Small",
multilingual: true,
encoderParamsM: 88,
decoderParamsM: 156,
defaults: { wasm: WASM_Q8, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium.en",
displayName: "Whisper Medium (English)",
multilingual: false,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-medium",
displayName: "Whisper Medium",
multilingual: true,
encoderParamsM: 306,
decoderParamsM: 463,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
{
id: "onnx-community/whisper-large-v3-turbo",
displayName: "Whisper Large v3 Turbo",
multilingual: true,
encoderParamsM: 635,
decoderParamsM: 174,
defaults: { wasm: WASM_Q4, webgpu: WEBGPU_FP16_Q4 },
},
];
export function getModel(id: string): WhisperModel | undefined {
return MODELS.find((model) => model.id === id);
}
/** Default dtype for a model on a given device. Unknown ids fall back to q8. */
export function resolveDefaultDtype(
modelId: string,
device: InferenceDevice,
): DtypeSpec {
return getModel(modelId)?.defaults[device].dtype ?? "q8";
}
/** Approximate download size using the model's default dtype for `device`. */
export function estimateDefaultDownloadBytes(
model: WhisperModel,
device: InferenceDevice,
): number {
return estimateDownloadBytes(model, model.defaults[device].dtype);
}
// ---------------------------------------------------------------------------
// Defaults & language list
// ---------------------------------------------------------------------------
/**
* The one model M1 supports. `onnx-community` is the recommended org for
* Transformers.js v3+ ONNX conversions.
*/
export const DEFAULT_MODEL_ID = "onnx-community/whisper-tiny.en";
/** M1 runs WASM only; WebGPU detection arrives in M2 (D-004). */
export const DEFAULT_DEVICE: InferenceDevice = "wasm";
/** UI default: let the worker probe WebGPU and fall back to WASM (D-004). */
export const DEFAULT_DEVICE_PREFERENCE: DevicePreference = "auto";
export const DEFAULT_TASK: WhisperTask = "transcribe";
/** Sentinel for "let Whisper detect the language". */
export const AUTO_LANGUAGE = "auto";
/**
* Curated language list for multilingual models (Whisper language codes).
* Not exhaustive — Whisper supports ~100; extend on demand.
*/
export const WHISPER_LANGUAGES: readonly { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "da", label: "Danish" },
{ code: "de", label: "German" },
{ code: "es", label: "Spanish" },
{ code: "fr", label: "French" },
{ code: "it", label: "Italian" },
{ code: "nl", label: "Dutch" },
{ code: "no", label: "Norwegian" },
{ code: "sv", label: "Swedish" },
{ code: "fi", label: "Finnish" },
{ code: "is", label: "Icelandic" },
{ code: "pl", label: "Polish" },
{ code: "pt", label: "Portuguese" },
{ code: "ro", label: "Romanian" },
{ code: "cs", label: "Czech" },
{ code: "el", label: "Greek" },
{ code: "hu", label: "Hungarian" },
{ code: "uk", label: "Ukrainian" },
{ code: "ru", label: "Russian" },
{ code: "tr", label: "Turkish" },
{ code: "ar", label: "Arabic" },
{ code: "he", label: "Hebrew" },
{ code: "hi", label: "Hindi" },
{ code: "id", label: "Indonesian" },
{ code: "vi", label: "Vietnamese" },
{ code: "th", label: "Thai" },
{ code: "zh", label: "Chinese" },
{ code: "ja", label: "Japanese" },
{ code: "ko", label: "Korean" },
];
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useState } from "react";
import {
fetchCurrentUser,
login as loginRequest,
logout as logoutRequest,
register as registerRequest,
type AuthUser,
} from "./authClient";
export interface AuthState {
/** The signed-in user, or null while anonymous (the default). */
user: AuthUser | null;
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
login: (username: string, password: string) => Promise<void>;
/** Rejects with a UI-safe Error message on failure (400/409). */
register: (username: string, password: string) => Promise<void>;
/** Always resolves; local state is cleared even if the server is down. */
logout: () => Promise<void>;
}
/**
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
* only source of truth is GET /api/auth/me, probed once on mount.
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
* down) both resolve to anonymous, keeping the anonymous-first invariant:
* a failing /me must not surface any error.
*/
export function useAuth(): AuthState {
const [user, setUser] = useState<AuthUser | null>(null);
useEffect(() => {
let cancelled = false;
fetchCurrentUser().then((current) => {
// Guard against StrictMode double-mounts / fast unmounts.
if (!cancelled && current) setUser(current);
});
return () => {
cancelled = true;
};
}, []);
const login = useCallback(async (username: string, password: string) => {
setUser(await loginRequest(username, password));
}, []);
const register = useCallback(async (username: string, password: string) => {
// Register signs the user in immediately (the 201 sets the cookie).
setUser(await registerRequest(username, password));
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
return { user, login, register, logout };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+22 -4
View File
@@ -1,7 +1,25 @@
{
"$comment": "TODO(M3): finalize PWA manifest — icons, theme colors, installability (D-007).",
"name": "Whisper Web",
"short_name": "Whisper",
"name": "Whisper Web — on-device speech-to-text",
"short_name": "Whisper Web",
"description": "Speech-to-text that runs entirely in your browser via OpenAI Whisper. Works offline once installed with a downloaded model.",
"id": "/",
"start_url": "/",
"display": "standalone"
"scope": "/",
"display": "standalone",
"theme_color": "#16181d",
"background_color": "#16181d",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Precache-manifest helpers (Decisions.md D-007).
*
* `src/pwa/sw.ts` ships with two string placeholders; these helpers turn a
* `bun build` output listing into URL paths and splice them into the bundled
* service-worker source. Pure functions so they are unit-testable
* (tests/precache.test.ts); used by `scripts/build.ts` (production, real
* asset list) and `src/index.ts` (dev, empty list + "dev" version).
*/
/** Literal that `sw.ts` JSON.parses; replaced with the asset-list JSON. */
export const PRECACHE_PLACEHOLDER = '"__PRECACHE_MANIFEST__"';
/** Literal inside the SW's cache-name string; replaced with a build version. */
export const VERSION_PLACEHOLDER = "__CACHE_VERSION__";
/**
* Map bundler output paths (relative to the output dir, e.g. `index.html`,
* `chunk-abc123.js`) plus extra fixed routes to the URL paths the service
* worker should precache. Sourcemaps are excluded; `index.html` becomes `/`
* (the URL the app shell is actually served and navigated to).
*/
export function precachePathsFromOutputs(
outputPaths: Iterable<string>,
extraPaths: readonly string[] = [],
): string[] {
const paths = new Set<string>();
for (const outputPath of outputPaths) {
const normalized = outputPath.replaceAll("\\", "/").replace(/^\.?\//, "");
if (normalized.endsWith(".map")) continue;
paths.add(normalized === "index.html" ? "/" : `/${normalized}`);
}
for (const extra of extraPaths) paths.add(extra);
return [...paths].sort();
}
/**
* Replace the placeholders in bundled service-worker source with the actual
* precache list and cache version. Throws if a placeholder is missing (guards
* against the bundler mangling the literals) or if the version would produce
* an unusable cache name.
*/
export function injectPrecacheManifest(
swSource: string,
assetPaths: readonly string[],
version: string,
): string {
if (!/^[A-Za-z0-9._-]+$/.test(version)) {
throw new Error(`Invalid cache version "${version}"`);
}
if (!swSource.includes(PRECACHE_PLACEHOLDER)) {
throw new Error(`Service worker source is missing ${PRECACHE_PLACEHOLDER}`);
}
if (!swSource.includes(VERSION_PLACEHOLDER)) {
throw new Error(`Service worker source is missing ${VERSION_PLACEHOLDER}`);
}
return swSource
// Double-stringify: the placeholder is a *string literal* in the SW that
// gets JSON.parsed at runtime, so the replacement must be a JS string
// literal whose contents are the JSON array.
.replace(PRECACHE_PLACEHOLDER, JSON.stringify(JSON.stringify(assetPaths)))
.replace(VERSION_PLACEHOLDER, version);
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Service-worker registration + update flow (Decisions.md D-007).
*
* Registered in both dev and production — the dev SW is network-first so HMR
* is unaffected. When an updated SW finishes installing while an old one is
* controlling the page, `onUpdateReady` fires with an `applyUpdate` callback;
* the UI shows a toast, and invoking the callback tells the waiting worker to
* skipWaiting and reloads once it takes control. The first-ever install
* (clients.claim with no prior controller) never triggers a reload.
*/
export interface RegisterOptions {
/** A new version is installed and waiting; call `applyUpdate` to activate + reload. */
onUpdateReady: (applyUpdate: () => void) => void;
/** The page is (or becomes) controlled by an active service worker. */
onControlled?: () => void;
}
export function registerServiceWorker(options: RegisterOptions): void {
if (!("serviceWorker" in navigator)) return;
const container = navigator.serviceWorker;
let userInitiatedUpdate = false;
container.addEventListener("controllerchange", () => {
// Only reload when the user accepted the update toast — the claim() on
// first install also fires this event and must not reload the page.
if (userInitiatedUpdate) location.reload();
else options.onControlled?.();
});
if (container.controller) options.onControlled?.();
container
.register("/sw.js")
.then((registration) => {
const notify = (worker: ServiceWorker) => {
options.onUpdateReady(() => {
userInitiatedUpdate = true;
worker.postMessage({ type: "SKIP_WAITING" });
});
};
// A version may already be waiting from a previous visit.
if (registration.waiting && container.controller) notify(registration.waiting);
registration.addEventListener("updatefound", () => {
const installing = registration.installing;
if (!installing) return;
installing.addEventListener("statechange", () => {
if (installing.state === "installed" && container.controller) {
notify(installing);
}
});
});
})
.catch((error: unknown) => {
console.warn("Service worker registration failed:", error);
});
}
+131 -5
View File
@@ -1,10 +1,136 @@
/**
* Service worker (Decisions.md D-007) — TODO(M3).
* Service worker (Decisions.md D-007) — hand-rolled, no Workbox.
*
* Plan: precache the built app shell (hashed assets emitted by `bun build`
* into a generated manifest), cache-first for static assets, network-only for
* `/api/*`. Model weights are NOT cached here — Transformers.js keeps them in
* the browser Cache API itself (D-005).
* Strategy:
* - App shell: precached on install (production; list injected by
* `scripts/build.ts` via the placeholders below), cache-first thereafter.
* In dev the list is empty and the shell is served network-first with a
* cache fallback, so HMR keeps working while offline still functions.
* - `/ort/*` (onnxruntime .wasm/.mjs, served from our origin — see
* src/index.ts): runtime-cached, cache-first. These are required for
* offline inference but too large to precache eagerly.
* - `/api/*`: network-only — never cached (auth/server engine, M4/M5).
* - Model weights are NOT cached here (D-005): Transformers.js persists Hub
* downloads in its own `transformers-cache`, which we must never touch.
*/
// Placeholders replaced by injectPrecacheManifest (src/pwa/precache.ts).
// In dev src/index.ts injects an empty list + "dev"; in production
// scripts/build.ts injects the hashed dist asset list + a content hash.
const PRECACHE: string[] = JSON.parse("__PRECACHE_MANIFEST__") as string[];
const VERSION = "__CACHE_VERSION__";
const SHELL_CACHE = `whisper-shell-${VERSION}`;
const RUNTIME_CACHE = `whisper-runtime-${VERSION}`;
/** Caches owned by this SW — anything else (e.g. transformers-cache) is kept. */
const OWN_CACHE_PREFIXES = ["whisper-shell-", "whisper-runtime-"];
// `lib: ["DOM"]` types `self` as Window; declare the worker-scope shape we
// use (same approach as src/worker/inference.worker.ts).
interface ExtendableEventLike extends Event {
waitUntil(promise: Promise<unknown>): void;
}
interface FetchEventLike extends ExtendableEventLike {
readonly request: Request;
respondWith(response: Promise<Response> | Response): void;
}
interface ServiceWorkerScope {
location: Location;
skipWaiting(): Promise<void>;
clients: { claim(): Promise<void> };
addEventListener(type: "install" | "activate", listener: (event: ExtendableEventLike) => void): void;
addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void;
addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
}
const sw = self as unknown as ServiceWorkerScope;
sw.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
if (PRECACHE.length === 0) return; // dev — runtime caching only
const cache = await caches.open(SHELL_CACHE);
await cache.addAll(PRECACHE);
})(),
);
});
sw.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Drop caches from previous SW versions; never touch transformers-cache.
const stale = (await caches.keys()).filter(
(name) =>
OWN_CACHE_PREFIXES.some((prefix) => name.startsWith(prefix)) &&
name !== SHELL_CACHE &&
name !== RUNTIME_CACHE,
);
await Promise.all(stale.map((name) => caches.delete(name)));
// Control already-open pages so /ort/* caching works without a reload.
await sw.clients.claim();
})(),
);
});
// The update toast posts this after user consent (src/pwa/register.ts).
sw.addEventListener("message", (event) => {
const data = event.data as { type?: string } | null;
if (data?.type === "SKIP_WAITING") void sw.skipWaiting();
});
/** Serve from `cacheName`, populating it from the network on first miss. */
async function cacheFirst(cacheName: string, request: Request): Promise<Response> {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.status === 200) await cache.put(request, response.clone());
return response;
}
/**
* Fetch, mirroring successes into the shell cache; on network failure fall
* back to the cache (and `fallbackPath`, e.g. "/" for navigations).
*/
async function networkFirst(request: Request, fallbackPath: string | null): Promise<Response> {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request);
if (response.status === 200) await cache.put(request, response.clone());
return response;
} catch (error) {
const hit = await cache.match(request);
if (hit) return hit;
if (fallbackPath) {
const fallback = await cache.match(fallbackPath);
if (fallback) return fallback;
}
throw error;
}
}
sw.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
// Cross-origin (Hugging Face Hub model weights, etc.): hands off —
// Transformers.js caches those itself in transformers-cache (D-005).
if (url.origin !== sw.location.origin) return;
// API is network-only (D-007) — auth and the server engine must never be
// served stale from cache.
if (url.pathname.startsWith("/api/")) return;
if (url.pathname.startsWith("/ort/")) {
event.respondWith(cacheFirst(RUNTIME_CACHE, request));
return;
}
if (PRECACHE.includes(url.pathname)) {
event.respondWith(cacheFirst(SHELL_CACHE, request));
return;
}
// Anything else (dev's unhashed shell, navigations to deep links):
// network-first with cache fallback; navigations fall back to the shell.
event.respondWith(networkFirst(request, request.mode === "navigate" ? "/" : null));
});
export {};
+287 -7
View File
@@ -1,11 +1,291 @@
/**
* Auth — TODO(M4).
* Auth: Passport.js local strategy behind a connect-compat adapter over
* Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite
* (D-009).
*
* Passport.js local strategy behind a connect-compat adapter over Bun.serve
* (Decisions.md D-008): wrap Bun's Request in a minimal req/res shim, run the
* passport authenticate chain, translate back to a Response. Sessions in
* bun:sqlite with HttpOnly cookies (D-009). Routes:
* POST /api/auth/register|login|logout, GET /api/auth/me.
* Passport expects connect/express-style middleware, which Bun.serve does not
* have. The adapter here is deliberately minimal and honest: it shims only
* what `passport.authenticate("local", callback)` actually touches — the
* strategy reads `req.body`/`req.query` and, because we pass a custom
* callback with `session: false`, never writes to `res`. Session issuance,
* cookies, and serialization are driven by us around that call rather than by
* passport's own connect middleware.
*
* Routes implemented (wired in src/index.ts, shared by dev + production):
* POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409
* POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401
* POST /api/auth/logout → 204 (always) + cookie cleared
* GET /api/auth/me → 200 {user} | 401
*
* Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days;
* Secure when NODE_ENV=production. Expiry is enforced server-side via
* sessions.expires_at — a replayed cookie past that instant is rejected even
* though the browser would still send it.
*/
export {};
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import { type AppDb, type User, getDb } from "./db";
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/** The safe-to-serialize projection of a user (never the password hash). */
export interface SessionUser {
id: number;
username: string;
}
export interface AuthHandlers {
register(request: Request): Promise<Response>;
login(request: Request): Promise<Response>;
logout(request: Request): Promise<Response>;
me(request: Request): Promise<Response>;
/**
* Resolves the user behind a request's `sid` cookie, or null. Exported so
* the M5 transcribe endpoint can guard itself with the same session logic.
*/
getSessionUser(request: Request): SessionUser | null;
}
// ---------------------------------------------------------------------------
// Constants + small helpers
// ---------------------------------------------------------------------------
const COOKIE_NAME = "sid";
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MIN_PASSWORD_LENGTH = 8;
function json(body: unknown, status: number, headers?: HeadersInit): Response {
const h = new Headers(headers);
h.set("Content-Type", "application/json");
return new Response(JSON.stringify(body), { status, headers: h });
}
/** Serializes the session cookie per the contract in the module doc comment. */
function sessionCookie(sid: string, maxAgeSeconds: number): string {
const attributes = [
`${COOKIE_NAME}=${sid}`,
"HttpOnly",
"SameSite=Lax",
"Path=/",
`Max-Age=${maxAgeSeconds}`,
];
// Checked per-call (not at module load) so tests can flip NODE_ENV.
if (process.env.NODE_ENV === "production") attributes.push("Secure");
return attributes.join("; ");
}
/** Max-Age=0 tells the browser to drop the cookie immediately. */
const clearedCookie = (): string => sessionCookie("", 0);
/** Extracts the `sid` cookie value from a request, if any. */
function readSessionId(request: Request): string | null {
const header = request.headers.get("cookie");
if (!header) return null;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
if (part.slice(0, eq).trim() === COOKIE_NAME) {
const value = part.slice(eq + 1).trim();
return value.length > 0 ? value : null;
}
}
return null;
}
const toSessionUser = (user: User): SessionUser => ({
id: user.id,
username: user.username,
});
interface Credentials {
username: string;
password: string;
}
/**
* Parses a JSON `{username, password}` body. Returns null for anything that
* is not that shape (bad JSON, wrong types) — callers answer 400.
*/
async function parseCredentials(request: Request): Promise<Credentials | null> {
let body: unknown;
try {
body = await request.json();
} catch {
return null;
}
if (typeof body !== "object" || body === null) return null;
const { username, password } = body as Record<string, unknown>;
if (typeof username !== "string" || typeof password !== "string") return null;
return { username, password };
}
/**
* Argon2id hash used to burn comparable time when the username does not
* exist, so login latency doesn't reveal which usernames are registered.
* Computed lazily (hashing is deliberately slow) and cached.
*/
let dummyHash: Promise<string> | undefined;
const getDummyHash = (): Promise<string> =>
(dummyHash ??= Bun.password.hash("dummy-password-for-constant-time"));
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Builds the four route handlers plus `getSessionUser` against a database
* source. Taking a getter (not an instance) keeps the default export lazy:
* importing this module never opens ./data/app.db, and tests inject
* `() => createDb(":memory:")` databases without touching disk.
*/
export function createAuth(getDatabase: () => AppDb): AuthHandlers {
// A private Authenticator instance (not the module-level singleton) so two
// createAuth() calls — e.g. app + tests — can't clobber each other's
// strategy registration.
const authenticator = new passport.Passport();
authenticator.use(
new LocalStrategy((username, password, done) => {
// passport-local's verify is callback-style; bridge to async/await and
// route thrown errors into done() so they surface as 500s, not hangs.
void (async () => {
const user = getDatabase().findUserByUsername(username);
if (!user) {
await Bun.password.verify(password, await getDummyHash());
return done(null, false, { message: "invalid credentials" });
}
const ok = await Bun.password.verify(password, user.passwordHash);
return ok
? done(null, user)
: done(null, false, { message: "invalid credentials" });
})().catch(done);
}),
);
/**
* The connect-compat adapter (D-008): run passport's authenticate chain
* against a shim of exactly the surface passport-local reads. With
* `session: false` and a custom callback, passport calls back with
* (err, user|false) and never touches `res`, so an empty object suffices.
*/
function authenticateLocal(credentials: Credentials): Promise<User | false> {
return new Promise((resolve, reject) => {
const req = {
body: credentials,
query: {},
headers: {},
method: "POST",
url: "/api/auth/login",
};
const middleware = authenticator.authenticate(
"local",
{ session: false },
(err: unknown, user: unknown) =>
err ? reject(err) : resolve((user as User | false) || false),
) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
// next() only fires on internal errors here (the callback intercepts
// success and failure), so treat any call as a failure path.
middleware(req, {}, (err?: unknown) =>
err ? reject(err) : resolve(false),
);
});
}
/** Issues a fresh session row + Set-Cookie header for `user`. */
function issueSession(user: User): { user: SessionUser; cookie: string } {
const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS);
return {
user: toSessionUser(user),
cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000),
};
}
function getSessionUser(request: Request): SessionUser | null {
const sid = readSessionId(request);
if (!sid) return null;
const db = getDatabase();
const session = db.findValidSession(sid);
if (!session) return null;
const user = db.findUserById(session.userId);
return user ? toSessionUser(user) : null;
}
return {
async register(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
if (!USERNAME_RE.test(credentials.username)) {
return json(
{ error: "username must be 3-32 characters (letters, digits, _ . -)" },
400,
);
}
if (credentials.password.length < MIN_PASSWORD_LENGTH) {
return json(
{ error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` },
400,
);
}
// Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4.
const passwordHash = await Bun.password.hash(credentials.password);
const user = getDatabase().createUser(credentials.username, passwordHash);
// createUser lets the UNIQUE constraint arbitrate, so concurrent
// registrations of the same name can't both succeed.
if (!user) return json({ error: "username taken" }, 409);
const issued = issueSession(user);
return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie });
},
async login(request) {
const credentials = await parseCredentials(request);
if (!credentials) {
return json({ error: "expected JSON body with username and password" }, 400);
}
const user = await authenticateLocal(credentials);
// One message for both unknown-user and wrong-password: no username
// enumeration via response text (timing handled by the dummy hash).
if (!user) return json({ error: "invalid credentials" }, 401);
const issued = issueSession(user);
return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie });
},
async logout(request) {
const sid = readSessionId(request);
if (sid) getDatabase().deleteSession(sid);
// Idempotent by contract: 204 whether or not a session existed. No body
// on 204, but always clear the cookie so stale clients converge.
return new Response(null, {
status: 204,
headers: { "Set-Cookie": clearedCookie() },
});
},
async me(request) {
const user = getSessionUser(request);
return user
? json({ user }, 200)
: json({ error: "not authenticated" }, 401);
},
getSessionUser,
};
}
// ---------------------------------------------------------------------------
// Default instance (the real app database) + convenience re-export for M5
// ---------------------------------------------------------------------------
export const auth: AuthHandlers = createAuth(getDb);
/** Session lookup against the app database — see AuthHandlers.getSessionUser. */
export function getSessionUser(request: Request): SessionUser | null {
return auth.getSessionUser(request);
}
+202 -5
View File
@@ -1,9 +1,206 @@
/**
* Database — TODO(M4).
* Users + sessions in bun:sqlite (Decisions.md D-009).
*
* bun:sqlite (Decisions.md D-009). Tables: users (id, username, password_hash
* via Bun.password Argon2), sessions (id, user_id, expires_at). DB path from
* env, default ./data/app.db.
* Single-file database; path from env `DB_PATH`, default ./data/app.db. All
* access goes through the typed helpers on {@link AppDb} — no raw SQL leaks
* out of this module, so the (deliberately tiny) schema can evolve in one
* place.
*
* Testability: `createDb(path)` is a pure factory — tests pass ":memory:" and
* never touch the on-disk default. The shared instance is created lazily by
* `getDb()`, so merely importing this module has no filesystem side effects.
*/
export {};
import { Database } from "bun:sqlite";
import { randomBytes } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
// ---------------------------------------------------------------------------
// Domain types (camelCase; the snake_case row shapes stay private below)
// ---------------------------------------------------------------------------
export interface User {
id: number;
username: string;
/** Argon2id hash produced by `Bun.password.hash` — never the plaintext. */
passwordHash: string;
/** Milliseconds since epoch. */
createdAt: number;
}
export interface Session {
/** 256-bit random id (base64url); doubles as the `sid` cookie value. */
id: string;
userId: number;
/** Milliseconds since epoch; sessions past this instant are invalid. */
expiresAt: number;
}
export interface AppDb {
/** Returns the new user, or null when the username is already taken. */
createUser(username: string, passwordHash: string): User | null;
findUserByUsername(username: string): User | null;
findUserById(id: number): User | null;
/** Creates a session for `userId`; also sweeps expired rows opportunistically. */
createSession(userId: number, expiresAt: number): Session;
/**
* Returns the session only when it exists AND has not expired — expiry is
* enforced here, server-side, regardless of what the cookie claims.
*/
findValidSession(id: string, now?: number): Session | null;
deleteSession(id: string): void;
/** Removes all sessions with expires_at <= now; returns the count removed. */
deleteExpiredSessions(now?: number): number;
close(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
interface UserRow {
id: number;
username: string;
password_hash: string;
created_at: number;
}
interface SessionRow {
id: string;
user_id: number;
expires_at: number;
}
const toUser = (row: UserRow): User => ({
id: row.id,
username: row.username,
passwordHash: row.password_hash,
createdAt: row.created_at,
});
const toSession = (row: SessionRow): Session => ({
id: row.id,
userId: row.user_id,
expiresAt: row.expires_at,
});
/**
* Session ids come from the CSPRNG: 32 random bytes = 256 bits, well past the
* ~128-bit unguessability floor, encoded base64url so the value is safe in a
* cookie without escaping.
*/
export function newSessionId(): string {
return randomBytes(32).toString("base64url");
}
/** Opens (creating if needed) a database at `path` and applies the schema. */
export function createDb(path: string): AppDb {
if (path !== ":memory:") {
// bun:sqlite creates the file but not its parent directory.
mkdirSync(dirname(path), { recursive: true });
}
const db = new Database(path, { create: true, strict: true });
// WAL lets reads proceed during writes; harmless no-op for ":memory:".
db.exec("PRAGMA journal_mode = WAL;");
// Enforce sessions.user_id → users.id (off by default in SQLite).
db.exec("PRAGMA foreign_keys = ON;");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
`);
const insertUser = db.query<UserRow, [string, string, number]>(
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?) RETURNING *",
);
const userByUsername = db.query<UserRow, [string]>(
"SELECT * FROM users WHERE username = ?",
);
const userById = db.query<UserRow, [number]>("SELECT * FROM users WHERE id = ?");
const insertSession = db.query<SessionRow, [string, number, number]>(
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?) RETURNING *",
);
const sessionById = db.query<SessionRow, [string, number]>(
"SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
);
const removeSession = db.query("DELETE FROM sessions WHERE id = ?");
const removeExpired = db.query("DELETE FROM sessions WHERE expires_at <= ?");
return {
createUser(username, passwordHash) {
try {
return toUser(insertUser.get(username, passwordHash, Date.now())!);
} catch (error) {
// The UNIQUE constraint is the source of truth for "username taken" —
// checking first would race concurrent registrations.
if (
error instanceof Error &&
(error as { code?: string }).code?.startsWith("SQLITE_CONSTRAINT")
) {
return null;
}
throw error;
}
},
findUserByUsername(username) {
const row = userByUsername.get(username);
return row ? toUser(row) : null;
},
findUserById(id) {
const row = userById.get(id);
return row ? toUser(row) : null;
},
createSession(userId, expiresAt) {
// Piggyback garbage collection on the write path so stale rows never
// accumulate unboundedly without needing a background timer.
removeExpired.run(Date.now());
return toSession(insertSession.get(newSessionId(), userId, expiresAt)!);
},
findValidSession(id, now = Date.now()) {
const row = sessionById.get(id, now);
return row ? toSession(row) : null;
},
deleteSession(id) {
removeSession.run(id);
},
deleteExpiredSessions(now = Date.now()) {
return removeExpired.run(now).changes;
},
close() {
db.close();
},
};
}
// ---------------------------------------------------------------------------
// Lazily-created default instance (the real app database)
// ---------------------------------------------------------------------------
export const DEFAULT_DB_PATH = "./data/app.db";
let defaultDb: AppDb | undefined;
/** The application database (env `DB_PATH`, default ./data/app.db — gitignored). */
export function getDb(): AppDb {
defaultDb ??= createDb(process.env.DB_PATH ?? DEFAULT_DB_PATH);
return defaultDb;
}
+135 -23
View File
@@ -2,24 +2,38 @@
* Inference Web Worker (Decisions.md D-003 — inference never runs on the main
* thread). Hosts the Transformers.js `automatic-speech-recognition` pipeline.
*
* M1: hardcoded-model, WASM-only. WebGPU detection + fallback lands in M2
* (D-004); model switching/disposal also lands in M2.
* M2: WebGPU detection with automatic WASM fallback (D-004), catalog-driven
* dtype defaults (D-006), model switching with pipeline disposal, and
* language/task options for multilingual models.
*/
import {
env,
pipeline,
WhisperTextStreamer,
type AutomaticSpeechRecognitionPipeline,
type ProgressInfo,
} from "@huggingface/transformers";
import { resolveDefaultDtype } from "../lib/models";
import {
isUiToWorkerMessage,
type DevicePreference,
type InferenceDevice,
type LoadMessage,
type ResultMessage,
type TranscribeMessage,
type TranscriptChunk,
type WorkerToUiMessage,
} from "./protocol";
// Load onnxruntime's .wasm binaries from our own origin instead of the
// jsdelivr CDN default — required for offline use (M3). The server exposes
// them at /ort/* (src/index.ts; copied into dist/ort by scripts/build.ts)
// and the service worker runtime-caches them cache-first (src/pwa/sw.ts).
if (env.backends.onnx.wasm) {
env.backends.onnx.wasm.wasmPaths = "/ort/";
}
// `lib: ["DOM"]` types `self` as Window; give it the worker-scope shape we use.
interface DedicatedWorkerScope {
postMessage(message: WorkerToUiMessage): void;
@@ -38,7 +52,12 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** Forward Transformers.js progress events to the UI as load-progress messages. */
/**
* Forward Transformers.js progress events to the UI as load-progress messages.
* The library's own "ready" event is swallowed — the worker posts its own
* `ready` (with the selected device) once the pipeline actually resolved,
* which also covers the WebGPU → WASM fallback retry (D-004).
*/
function forwardProgress(info: ProgressInfo): void {
switch (info.status) {
case "initiate":
@@ -56,29 +75,113 @@ function forwardProgress(info: ProgressInfo): void {
progress: info.progress,
});
break;
case "ready":
post({ type: "load-progress", status: "ready" });
break;
default:
// e.g. "progress_total" — the UI derives totals per-file itself.
// "ready" (posted by the worker itself, with the device) and
// "progress_total" — the UI derives totals per-file itself.
break;
}
}
let asrPromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
// ---------------------------------------------------------------------------
// Device selection (D-004): WebGPU when available, automatic WASM fallback
// ---------------------------------------------------------------------------
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
// Idempotent for M1's single hardcoded model: repeated "load" messages
// reuse the in-flight/completed pipeline.
asrPromise ??= pipeline("automatic-speech-recognition", message.modelId, {
device: message.device,
progress_callback: forwardProgress,
});
return asrPromise;
interface NavigatorWithGpu {
gpu?: { requestAdapter(): Promise<unknown | null> };
}
async function transcribe(audio: Float32Array): Promise<void> {
if (asrPromise === null) {
async function detectDevice(preference: DevicePreference): Promise<InferenceDevice> {
if (preference === "wasm") return "wasm";
try {
const adapter = await (navigator as NavigatorWithGpu).gpu?.requestAdapter();
return adapter ? "webgpu" : "wasm";
} catch {
return "wasm";
}
}
// ---------------------------------------------------------------------------
// Pipeline lifecycle (load, switch, dispose)
// ---------------------------------------------------------------------------
interface ActivePipeline {
/** Identity of this load request; a different key means "switch models". */
key: string;
promise: Promise<AutomaticSpeechRecognitionPipeline>;
}
let active: ActivePipeline | null = null;
function loadKey(message: LoadMessage): string {
return JSON.stringify({
modelId: message.modelId,
device: message.device,
dtype: message.dtype ?? null,
});
}
async function createPipeline(
message: LoadMessage,
): Promise<AutomaticSpeechRecognitionPipeline> {
const detected = await detectDevice(message.device);
// If WebGPU was selected but pipeline creation throws (driver quirks,
// unsupported ops, out-of-memory), fall back to WASM automatically (D-004).
const attempts: InferenceDevice[] =
detected === "webgpu" ? ["webgpu", "wasm"] : ["wasm"];
let lastError: unknown;
for (const device of attempts) {
const dtype = message.dtype ?? resolveDefaultDtype(message.modelId, device);
try {
const asr = await pipeline("automatic-speech-recognition", message.modelId, {
device,
dtype,
progress_callback: forwardProgress,
});
post({ type: "load-progress", status: "ready", device });
return asr;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
function load(message: LoadMessage): Promise<AutomaticSpeechRecognitionPipeline> {
const key = loadKey(message);
// Idempotent: repeated "load" messages for the same model/device/dtype reuse
// the in-flight or completed pipeline.
if (active?.key === key) return active.promise;
const previous = active;
const promise = (async () => {
if (previous) {
// Dispose the old pipeline (frees WASM memory / GPU buffers) before
// creating the new one, so both never coexist.
try {
const old = await previous.promise;
await old.dispose();
} catch {
// The previous load itself failed — nothing to dispose.
}
}
return createPipeline(message);
})();
active = { key, promise };
promise.catch(() => {
// Allow a clean retry after a failed load; ignore stale failures that
// were already superseded by a newer load request.
if (active?.key === key) active = null;
});
return promise;
}
// ---------------------------------------------------------------------------
// Transcription
// ---------------------------------------------------------------------------
async function transcribe(message: TranscribeMessage): Promise<void> {
if (active === null) {
post({
type: "error",
stage: "transcribe",
@@ -86,7 +189,7 @@ async function transcribe(audio: Float32Array): Promise<void> {
});
return;
}
const asr = await asrPromise;
const asr = await active.promise;
const streamer = new WhisperTextStreamer(
// The ASR pipeline for Whisper models always carries a WhisperTokenizer.
@@ -97,12 +200,22 @@ async function transcribe(audio: Float32Array): Promise<void> {
},
);
const output = await asr(audio, {
const options: Record<string, unknown> = {
chunk_length_s: 30,
stride_length_s: 5,
return_timestamps: true,
streamer,
});
};
// Only meaningful for multilingual models; the UI omits these for `.en`
// variants. "auto" / undefined lets Whisper detect the language.
if (message.language !== undefined && message.language !== "auto") {
options.language = message.language;
}
if (message.task !== undefined) {
options.task = message.task;
}
const output = await asr(message.audio, options);
const single = (Array.isArray(output) ? output[0] : output) as {
text?: string;
@@ -129,12 +242,11 @@ scope.addEventListener("message", (event) => {
switch (data.type) {
case "load":
load(data).catch((error: unknown) => {
asrPromise = null; // allow retry after a failed download
post({ type: "error", stage: "load", message: errorMessage(error) });
});
break;
case "transcribe":
transcribe(data.audio).catch((error: unknown) => {
transcribe(data).catch((error: unknown) => {
post({ type: "error", stage: "transcribe", message: errorMessage(error) });
});
break;
+83 -10
View File
@@ -6,8 +6,29 @@
* be unit-tested with `bun test`.
*/
/** Devices supported by the worker. M1 is WASM-only; M2 adds "webgpu" (D-004). */
export type InferenceDevice = "wasm";
/** Devices the worker can actually run on (D-004). */
export type InferenceDevice = "webgpu" | "wasm";
/**
* What the UI may request. "auto" (the default) lets the worker probe
* `navigator.gpu` and pick WebGPU with automatic WASM fallback (D-004).
*/
export type DevicePreference = InferenceDevice | "auto";
/** Quantization levels used by the curated catalog (D-006). */
export type WhisperDtype = "fp32" | "fp16" | "q8" | "q4";
/**
* Either one dtype for the whole model, or per-part dtypes — the shape
* Transformers.js accepts. Whisper on WebGPU commonly needs a mixed setup
* (e.g. fp16 encoder + q4 merged decoder), so per-part must be expressible.
*/
export type DtypeSpec =
| WhisperDtype
| { encoder_model: WhisperDtype; decoder_model_merged: WhisperDtype };
/** Whisper generation task (multilingual models only). */
export type WhisperTask = "transcribe" | "translate";
// ---------------------------------------------------------------------------
// UI → worker
@@ -17,7 +38,13 @@ export interface LoadMessage {
type: "load";
/** Hugging Face Hub model id, e.g. "onnx-community/whisper-tiny.en". */
modelId: string;
device: InferenceDevice;
/** Device preference; the worker resolves "auto" and reports the outcome. */
device: DevicePreference;
/**
* Optional dtype override. When omitted the worker resolves the catalog
* default for the device it actually selected (src/lib/models.ts).
*/
dtype?: DtypeSpec;
}
export interface TranscribeMessage {
@@ -27,6 +54,13 @@ export interface TranscribeMessage {
* should be passed as a transferable for zero-copy handoff.
*/
audio: Float32Array;
/**
* ISO-ish Whisper language code (e.g. "en", "da"). Omit (or "auto") to let
* Whisper detect the language. Only meaningful for multilingual models.
*/
language?: string;
/** Only meaningful for multilingual models. Defaults to "transcribe". */
task?: WhisperTask;
}
export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
@@ -38,7 +72,8 @@ export type UiToWorkerMessage = LoadMessage | TranscribeMessage;
/**
* Model download / initialization progress. Mirrors the Transformers.js
* `progress_callback` events we care about; `status: "ready"` marks the end
* of a successful load.
* of a successful load and reports the backend that actually got selected
* (after WebGPU detection / fallback, D-004).
*/
export type LoadProgressMessage =
| { type: "load-progress"; status: "initiate"; file: string }
@@ -55,7 +90,7 @@ export type LoadProgressMessage =
progress: number;
}
| { type: "load-progress"; status: "done"; file: string }
| { type: "load-progress"; status: "ready" };
| { type: "load-progress"; status: "ready"; device: InferenceDevice };
/** Incremental transcript fragment — append to the text streamed so far. */
export interface PartialMessage {
@@ -96,13 +131,45 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
const DEVICES: readonly string[] = ["webgpu", "wasm"];
const DTYPES: readonly string[] = ["fp32", "fp16", "q8", "q4"];
export function isInferenceDevice(value: unknown): value is InferenceDevice {
return typeof value === "string" && DEVICES.includes(value);
}
export function isDevicePreference(value: unknown): value is DevicePreference {
return value === "auto" || isInferenceDevice(value);
}
export function isDtypeSpec(value: unknown): value is DtypeSpec {
if (typeof value === "string") return DTYPES.includes(value);
return (
isRecord(value) &&
typeof value.encoder_model === "string" &&
DTYPES.includes(value.encoder_model) &&
typeof value.decoder_model_merged === "string" &&
DTYPES.includes(value.decoder_model_merged)
);
}
export function isUiToWorkerMessage(value: unknown): value is UiToWorkerMessage {
if (!isRecord(value)) return false;
switch (value.type) {
case "load":
return typeof value.modelId === "string" && value.device === "wasm";
return (
typeof value.modelId === "string" &&
isDevicePreference(value.device) &&
(value.dtype === undefined || isDtypeSpec(value.dtype))
);
case "transcribe":
return value.audio instanceof Float32Array;
return (
value.audio instanceof Float32Array &&
(value.language === undefined || typeof value.language === "string") &&
(value.task === undefined ||
value.task === "transcribe" ||
value.task === "translate")
);
default:
return false;
}
@@ -125,7 +192,7 @@ export function isWorkerToUiMessage(value: unknown): value is WorkerToUiMessage
typeof value.progress === "number"
);
case "ready":
return true;
return isInferenceDevice(value.device);
default:
return false;
}
@@ -159,9 +226,15 @@ export interface LoadProgressState {
files: Record<string, FileProgress>;
/** True once the pipeline reported ready. */
ready: boolean;
/** Backend the worker actually selected; null until ready (D-004). */
device: InferenceDevice | null;
}
export const initialLoadProgress: LoadProgressState = { files: {}, ready: false };
export const initialLoadProgress: LoadProgressState = {
files: {},
ready: false,
device: null,
};
/** Fold one load-progress message into the accumulated state. Pure. */
export function applyLoadProgress(
@@ -169,7 +242,7 @@ export function applyLoadProgress(
message: LoadProgressMessage,
): LoadProgressState {
if (message.status === "ready") {
return { ...state, ready: true };
return { ...state, ready: true, device: message.device };
}
const previous = state.files[message.file];
const next: FileProgress = {