From 8464f0e49298fb9b33b2c8688498ab838ef88565 Mon Sep 17 00:00:00 2001
From: Lucas Winther
Date: Tue, 14 Jul 2026 20:35:03 +0200
Subject: [PATCH] Better model download management
---
docs/Architecture.md | 7 +++++--
docs/Decisions.md | 18 ++++++++++++++++++
docs/WorkBreakdown.md | 5 +++++
src/App.tsx | 21 +++++++++++++++++----
src/components/ModelPicker.tsx | 19 ++++++++++++++++++-
src/index.css | 23 +++++++++++++++++++++++
6 files changed, 86 insertions(+), 7 deletions(-)
diff --git a/docs/Architecture.md b/docs/Architecture.md
index d822182..bfb3734 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -107,8 +107,11 @@ are the remaining M5 work; see `WorkBreakdown.md`.)
### 4.1 Browser transcription (anonymous, default)
1. User picks a model in `ModelPicker` (curated catalog from `lib/models.ts`, with
- size estimates and a Cache-API-backed "downloaded" badge); UI posts
- `{type:"load", modelId, device}` where `device` is a preference
+ size estimates and a Cache-API-backed "downloaded" badge). Selection alone
+ downloads nothing (D-020): the UI posts `{type:"load", modelId, device}` when a
+ browser-engine transcription starts (file drop / live start) or when the user
+ clicks the explicit **Download** button on the selected model; server-engine
+ runs never post a load. `device` is a preference
(`"auto" | "webnn" | "webgpu" | "wasm"`, default `"auto"`). An explicit `dtype`
override is expressible in the message but normally omitted.
2. Worker resolves the device (D-004, D-017): for `"auto"` it probes
diff --git a/docs/Decisions.md b/docs/Decisions.md
index a84670b..1e0e81b 100644
--- a/docs/Decisions.md
+++ b/docs/Decisions.md
@@ -272,3 +272,21 @@ and its loaded pipeline are left untouched in that path.
**Why:** M7 future want. Enables logged-in users on weak devices to transcribe live microphone audio using the server engine.
**Shape:** The client opens a WebSocket connection to `/api/transcribe/live`. The client streams 16kHz Float32 PCM chunks (from `MicCapture`) over the WebSocket. The server runs `LiveTranscriber` and `LiveSegmenter` (from `src/lib/live.ts`, which is pure and runs perfectly in Node/Bun). For each segment/pass, the server's `LiveTranscriber` writes the audio to a temporary file and invokes the Python sidecar. The server sends `LiveUpdate` objects back to the client over the WebSocket.
**Consequence:** The client does zero ML and minimal buffering, acting only as a mic-streamer and UI-renderer for live passes on the server.
+
+## D-020 — Model downloads are user-initiated, never a side effect of selection
+**Status:** Accepted (2026-07-14)
+Selecting a model in `ModelPicker` (or changing the backend preference) no longer
+starts the weight download. Weights load exactly when they are needed or asked for:
+- **Lazy:** a file drop or live start on the **browser** engine posts the
+ (idempotent) load, as both paths already did — the download overlaps decoding /
+ mic startup.
+- **Explicit:** a **Download** button on the selected, not-yet-cached model in
+ `ModelPicker` starts the load immediately — this keeps D-005's "prepare for
+ offline use" story alive now that selection alone caches nothing.
+- **Server engine:** server runs never touch the browser pipeline, so with the
+ toggle on "server" nothing downloads. (Previously the eager selection load
+ downloaded browser weights even when every transcription would run server-side.)
+**Consequence:** model/backend switches just reset the load UI state
+(`loadStarted`, progress); the next load starts from clean progress. The explicit
+download is a full pipeline load, so it also warms up inference and surfaces the
+"Running on …" backend badge.
diff --git a/docs/WorkBreakdown.md b/docs/WorkBreakdown.md
index f00c950..0e4ed7b 100644
--- a/docs/WorkBreakdown.md
+++ b/docs/WorkBreakdown.md
@@ -242,6 +242,11 @@ Notes (M5):
models, versioned/cleaned by the SW itself) is deliberately not
surfaced — deleting it per-model makes no sense and the SW would
just re-fetch it.
+- [x] Model downloads are user-initiated (D-020): selecting a model/backend no
+ longer auto-downloads weights; the load starts on browser-engine
+ transcription start (file drop / live start) or via an explicit
+ per-model Download button in `ModelPicker` — and never when the engine
+ toggle is set to "server".
- [ ] Basic a11y pass (keyboard, focus, aria on progress/live transcript)
- [ ] Production build + `bun start` deployment check; README for deployers
diff --git a/src/App.tsx b/src/App.tsx
index 8a876bb..e448c9e 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -283,16 +283,27 @@ export function App() {
setPartialText("");
setTranscribeProgress(null);
setResult(null);
- startLoad(nextModelId, devicePreference);
+ // Selection never downloads (D-020): the load starts when a transcription
+ // needs it (file drop / live start on the browser engine) or when the
+ // user explicitly clicks Download. Reset the load state so the next load
+ // reports fresh progress for this model.
+ setLoadStarted(false);
+ setLoadProgress(initialLoadProgress);
}
function handleDevicePreference(preference: DevicePreference) {
if (busy || liveStatus !== "idle" || 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);
+ // No eager reload (D-020) — the next load picks up the new preference.
+ setLoadStarted(false);
+ setLoadProgress(initialLoadProgress);
+ }
+
+ /** Explicit model download (D-020) — pre-caches weights for offline use. */
+ function handleDownloadModel() {
+ if (busy || liveStatus !== "idle" || loadStarted) return;
+ startLoad(modelId, devicePreference);
}
/**
@@ -631,6 +642,8 @@ export function App() {
downloaded={downloaded}
offlineReady={swControlled}
disabled={busy || liveActive}
+ onDownloadSelected={handleDownloadModel}
+ downloading={loadStarted && !modelReady}
/>
{model?.multilingual && (
diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx
index a51db1f..3771972 100644
--- a/src/components/ModelPicker.tsx
+++ b/src/components/ModelPicker.tsx
@@ -19,6 +19,10 @@ interface ModelPickerProps {
offlineReady: boolean;
/** True while a transcription is running — switching is disabled then. */
disabled?: boolean;
+ /** Explicitly download the selected model's weights (D-020). */
+ onDownloadSelected: () => void;
+ /** True while a model load/download is in flight. */
+ downloading: boolean;
}
const DEVICE_OPTIONS: readonly { value: DevicePreference; label: string }[] = [
@@ -50,6 +54,8 @@ export function ModelPicker({
downloaded,
offlineReady,
disabled = false,
+ onDownloadSelected,
+ downloading,
}: 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
@@ -116,7 +122,7 @@ export function ModelPicker({
{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."
+ : "The app is cached for offline use. Download a model (or run one on-device transcription) 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)."}