diff --git a/CHANGELOG.md b/CHANGELOG.md
index c142b010..61faaa59 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,7 @@ First 0.8.1 beta. Rolls up everything through 0.8.0 (below) plus the features, f
### Enhancements
+- **Recover the offline speech engine with one click.** The private, on-device speech engine (whisper.cpp) ships in the installer, but if it is ever missing — for example on an older install — **Tools > Speech > Download Offline Speech Engine...** now fetches QUILL's own checksum-verified copy (about 8 MB, cancelable progress; disabled in Safe Mode) and sets it up, so dictation and offline transcription work without re-running the installer. (First step of the AI footprint/optimization plan: a pinned, SHA-256-verified release-asset acquisition path.)
- **Hear how deep your indentation is.** Press **Tab** or **Shift+Tab** and QUILL can now speak the new indentation depth — "4 spaces", "8 spaces", "1 tab" — instead of just "Indented lines", so you always know where the line sits. It honours your tabs-vs-spaces and width choices (**Insert tab characters instead of spaces** and **Number of spaces per indent level** in Settings). Prefer the terse message? Turn off **Announce indentation depth on Tab** (Settings, Accessibility).
- **Quieter dialogs, your choice.** A new **Announce entering and leaving dialogs** setting (Settings, Accessibility) turns off the spoken "Entered / Exited *name* dialog" cues for people whose screen reader already announces dialogs. On by default.
- **Jump straight to an open document.** With several documents open, press **Alt+1** through **Alt+9** (and **Alt+0** for the tenth) to go directly to that document by its position, instead of cycling with Ctrl+Tab. If nothing is open at that position, QUILL tells you and stays put. The bindings are remappable in the Keymap Editor.
diff --git a/docs/Product Requirement Documents and Specifications/QUILL-PRD.epub b/docs/Product Requirement Documents and Specifications/QUILL-PRD.epub
index 1a16add6..c299dc4b 100644
Binary files a/docs/Product Requirement Documents and Specifications/QUILL-PRD.epub and b/docs/Product Requirement Documents and Specifications/QUILL-PRD.epub differ
diff --git a/docs/Product Requirement Documents and Specifications/QUILL-PRD.html b/docs/Product Requirement Documents and Specifications/QUILL-PRD.html
index fb675b8f..c9bae18c 100644
--- a/docs/Product Requirement Documents and Specifications/QUILL-PRD.html
+++ b/docs/Product Requirement Documents and Specifications/QUILL-PRD.html
@@ -3822,6 +3822,23 @@
5.25c Offline speech and
Whisperer suite (locked off, core.bw_whisperer) is captured
in docs/planning/deferred-locked-features.md; this section
describes only what ships today.
+
On-demand engine recovery (release-asset
+acquisition). The whisper.cpp engine ships in the installer, so
+offline speech works out of the box. As a recovery/optional path — and
+the first concrete step of the AI footprint/optimization plan
+(docs/planning/QUILL-AI-Optimization-PRD.md §10.2.4) —
+Tools -> Speech -> Download Offline Speech Engine...
+fetches QUILL's own copy of the engine when it is missing. Acquisition
+is quill/core/release_assets.py (wx-free): a
+pinned, SHA-256-verified download from
+QUILL's controlled GitHub release asset (assets-v1),
+HTTPS-only, with retry/resumable download, atomic verified install, and
+a clean error on failure. It is gated by an explicit user action, the
+GATE-9 network-egress audit, and Safe Mode; the bundled copy means
+capability never depends on the download. Only components QUILL is
+licensed to redistribute are hosted this way (whisper.cpp is MIT);
+license-unclear engines are not re-hosted, and ffmpeg is never re-hosted
+(it stays user-installed).
Cloud providers ship as Quillins, not core. Per the
consolidation plan (#669), the cloud provider matrix is delivered as
extensions rather than baked into core. A Quillin declares a provider
diff --git a/docs/Product Requirement Documents and Specifications/QUILL-PRD.md b/docs/Product Requirement Documents and Specifications/QUILL-PRD.md
index a1d950a5..c1070351 100644
--- a/docs/Product Requirement Documents and Specifications/QUILL-PRD.md
+++ b/docs/Product Requirement Documents and Specifications/QUILL-PRD.md
@@ -1931,6 +1931,19 @@ and runs entirely on the user's machine. The detailed rollout/provider-center su
the deferred BITS Whisperer suite (locked off, `core.bw_whisperer`) is captured in
`docs/planning/deferred-locked-features.md`; this section describes only what ships today.
+**On-demand engine recovery (release-asset acquisition).** The whisper.cpp engine ships
+in the installer, so offline speech works out of the box. As a recovery/optional path —
+and the first concrete step of the AI footprint/optimization plan
+(`docs/planning/QUILL-AI-Optimization-PRD.md` §10.2.4) — `Tools -> Speech -> Download
+Offline Speech Engine...` fetches QUILL's own copy of the engine when it is missing.
+Acquisition is `quill/core/release_assets.py` (wx-free): a **pinned**, **SHA-256-verified**
+download from QUILL's controlled GitHub release asset (`assets-v1`), HTTPS-only, with
+retry/resumable download, atomic verified install, and a clean error on failure. It is
+gated by an explicit user action, the GATE-9 network-egress audit, and Safe Mode; the
+bundled copy means capability never depends on the download. Only components QUILL is
+licensed to redistribute are hosted this way (whisper.cpp is MIT); license-unclear
+engines are not re-hosted, and ffmpeg is never re-hosted (it stays user-installed).
+
**Cloud providers ship as Quillins, not core.** Per the consolidation plan (#669), the cloud
provider matrix is delivered as extensions rather than baked into core. A Quillin declares a
provider through the **`transcription_providers`** manifest contribution; QUILL's host implements
diff --git a/docs/planning/roadmap.epub b/docs/planning/roadmap.epub
index 9455bd7b..4112e3a5 100644
Binary files a/docs/planning/roadmap.epub and b/docs/planning/roadmap.epub differ
diff --git a/docs/planning/roadmap.html b/docs/planning/roadmap.html
index ee823237..6de654f7 100644
--- a/docs/planning/roadmap.html
+++ b/docs/planning/roadmap.html
@@ -276,6 +276,17 @@
1.1 Verbosity ✅
href="eleven-labs.md">eleven-labs.md — not
started (the SDK-in-gateway approach is decided). Dictation's
larger later-phase capabilities are 2.0 (§5).
+
AI footprint & optimization is its own
+measurement-first plan, tracked in QUILL-AI-Optimization-PRD.md.
+Its first concrete slice shipped in 0.8.1 Beta 1: a
+pinned, SHA-256-verified path for fetching redistributable components
+from QUILL's own GitHub release assets
+(quill/core/release_assets.py), wired as Tools >
+Speech > Download Offline Speech Engine... (recovery for the
+bundled whisper.cpp engine). It establishes the reliable-acquisition
+foundation (PRD §10.2.3–10.2.4) that any future unbundling builds on;
+the engine stays bundled, so capability never depends on a download.
The detailed planning spec was retired once the platform shipped; the
diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md
index ba8f6098..dd29f979 100644
--- a/docs/planning/roadmap.md
+++ b/docs/planning/roadmap.md
@@ -82,6 +82,15 @@ The **ElevenLabs / ElevenDesk** premium-cloud-TTS integration is its own workstr
tracked in [`eleven-labs.md`](eleven-labs.md) — **not started** (the SDK-in-gateway
approach is decided). Dictation's larger later-phase capabilities are **2.0** (§5).
+**AI footprint & optimization** is its own measurement-first plan, tracked in
+[`QUILL-AI-Optimization-PRD.md`](QUILL-AI-Optimization-PRD.md). Its first concrete
+slice **shipped in 0.8.1 Beta 1**: a pinned, SHA-256-verified path for fetching
+redistributable components from QUILL's own GitHub release assets
+(`quill/core/release_assets.py`), wired as **Tools > Speech > Download Offline Speech
+Engine...** (recovery for the bundled whisper.cpp engine). It establishes the
+reliable-acquisition foundation (PRD §10.2.3–10.2.4) that any future unbundling builds
+on; the engine stays bundled, so capability never depends on a download.
+
### 1.3 Agentic AI platform (shipped in 0.8.1 Beta 1)
The detailed planning spec was retired once the platform shipped; the AI suite is
diff --git a/docs/release notes/release0.8.1-beta1.epub b/docs/release notes/release0.8.1-beta1.epub
index 4bf2672a..fc40940e 100644
Binary files a/docs/release notes/release0.8.1-beta1.epub and b/docs/release notes/release0.8.1-beta1.epub differ
diff --git a/docs/release notes/release0.8.1-beta1.html b/docs/release notes/release0.8.1-beta1.html
index 9c451624..93929544 100644
--- a/docs/release notes/release0.8.1-beta1.html
+++ b/docs/release notes/release0.8.1-beta1.html
@@ -409,6 +409,15 @@
Fixes
Enhancements
+- Recover the offline speech engine with one click.
+The private, on-device speech engine (whisper.cpp) ships in the
+installer; if it is ever missing, Tools > Speech >
+Download Offline Speech Engine... now fetches QUILL's own
+checksum-verified copy (about 8 MB, cancelable; disabled in Safe Mode)
+so dictation and offline transcription work without re-running the
+installer. This is the first piece of the AI footprint/optimization plan
+— a pinned, SHA-256-verified path for fetching redistributable
+components from QUILL's own GitHub release assets.
- Hear how deep your indentation is. Tab / Shift+Tab
can now speak the new indentation depth — "4 spaces", "8 spaces", "1
tab" — instead of "Indented lines", honouring your tabs-vs-spaces and
diff --git a/docs/release notes/release0.8.1-beta1.md b/docs/release notes/release0.8.1-beta1.md
index fdb4e278..4576e0d2 100644
--- a/docs/release notes/release0.8.1-beta1.md
+++ b/docs/release notes/release0.8.1-beta1.md
@@ -203,6 +203,14 @@ keys line up again.
### Enhancements
+- **Recover the offline speech engine with one click.** The private, on-device
+ speech engine (whisper.cpp) ships in the installer; if it is ever missing,
+ **Tools > Speech > Download Offline Speech Engine...** now fetches QUILL's own
+ checksum-verified copy (about 8 MB, cancelable; disabled in Safe Mode) so
+ dictation and offline transcription work without re-running the installer. This
+ is the first piece of the AI footprint/optimization plan — a pinned,
+ SHA-256-verified path for fetching redistributable components from QUILL's own
+ GitHub release assets.
- **Hear how deep your indentation is.** Tab / Shift+Tab can now speak the new
indentation depth — "4 spaces", "8 spaces", "1 tab" — instead of "Indented
lines", honouring your tabs-vs-spaces and indent-width settings. Toggle with
diff --git a/docs/user guide/userguide.epub b/docs/user guide/userguide.epub
index 0a36c5f1..1497606d 100644
Binary files a/docs/user guide/userguide.epub and b/docs/user guide/userguide.epub differ
diff --git a/docs/user guide/userguide.html b/docs/user guide/userguide.html
index 2ac3a588..0c849320 100644
--- a/docs/user guide/userguide.html
+++ b/docs/user guide/userguide.html
@@ -1786,6 +1786,14 @@
+- Download Offline Speech Engine... The private,
+on-device speech engine (whisper.cpp) ships with QUILL, so dictation and
+offline transcription work out of the box. If it is ever missing — for
+example on an older install that pre-dated bundling — Tools >
+Speech > Download Offline Speech Engine... fetches QUILL's
+own verified copy (about 8 MB, checksum-verified, with a cancelable
+progress bar; disabled in Safe Mode) and sets it up. Most people never
+need this; it is a one-click recovery.
- Speaker attribution. If you download the "Small
English with speaker detection" model (in Manage Speech Models), QUILL
marks who is speaking when — each turn is labelled
diff --git a/docs/user guide/userguide.md b/docs/user guide/userguide.md
index 3f7d8d42..a44eb37e 100644
--- a/docs/user guide/userguide.md
+++ b/docs/user guide/userguide.md
@@ -956,6 +956,13 @@ not need to enable Artificial Intelligence to use these features. They live unde
never bundles or redistributes it. Without ffmpeg, the whisper.cpp engine needs
a 16 kHz mono WAV, while the Faster Whisper engine handles the other formats on
its own.
+- **Download Offline Speech Engine...** The private, on-device speech engine
+ (whisper.cpp) ships with QUILL, so dictation and offline transcription work out
+ of the box. If it is ever missing — for example on an older install that
+ pre-dated bundling — **Tools > Speech > Download Offline Speech Engine...**
+ fetches QUILL's own verified copy (about 8 MB, checksum-verified, with a
+ cancelable progress bar; disabled in Safe Mode) and sets it up. Most people
+ never need this; it is a one-click recovery.
- **Speaker attribution.** If you download the "Small English with speaker
detection" model (in Manage Speech Models), QUILL marks **who is speaking when**
— each turn is labelled "Speaker 1", "Speaker 2", and so on in the transcript
diff --git a/quill/core/release_assets.py b/quill/core/release_assets.py
new file mode 100644
index 00000000..7bfabc0b
--- /dev/null
+++ b/quill/core/release_assets.py
@@ -0,0 +1,201 @@
+"""On-demand fetch of redistributable runtime components from QUILL's own GitHub
+release assets — the controlled, pinned, SHA-256-verified store (PRD 10.2.4).
+
+Components QUILL is licensed to redistribute are uploaded to a Community-Access
+release tag and pinned here by SHA-256. The app can download + verify + unpack one
+on demand (e.g. to recover a missing offline speech engine), as a **supplement**
+to the installer bundling — capability never depends on a download succeeding, so
+the bundled copy remains the default and this is the recovery/optional path.
+
+Reliability (PRD 10.2.3): pinned URL + SHA-256 (moving refs / placeholder hashes
+refused), HTTPS enforced, retry-with-backoff and **resumable** download (HTTP
+Range), atomic verified install (download to a temp dir, verify, then copy in),
+and a clean error on any failure so the caller can degrade gracefully.
+
+GATE-9 / network-egress: the only outbound call site is ``_download_resumable``;
+it runs on an explicit user action and is blocked in Safe Mode. No ``wx`` imports.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import os
+import shutil
+import tempfile
+import time
+import urllib.request
+import zipfile
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+# (fraction 0.0-1.0, human message) — same shape as the speech ProgressCallback.
+ProgressCallback = Callable[[float, str], None]
+
+# Assets live on a dedicated, not-latest release tag so asset churn never touches
+# the product release or the autoupdate feed (the tag does not match ``v*``).
+_RELEASE_BASE = "https://github.com/Community-Access/quill/releases/download"
+
+_CHUNK = 1024 * 1024
+
+
+class ReleaseAssetError(Exception):
+ """A redistributable component could not be fetched/verified/installed."""
+
+
+@dataclass(frozen=True, slots=True)
+class ReleaseAsset:
+ """One pinned, SHA-256-verified component hosted on a QUILL release tag."""
+
+ component: str
+ tag: str
+ filename: str
+ sha256: str
+ expect_member: str = "" # a file the unpacked archive must contain
+ license: str = ""
+
+ @property
+ def url(self) -> str:
+ return f"{_RELEASE_BASE}/{self.tag}/{self.filename}"
+
+
+# Pinned manifest. Add an entry only for a component we are licensed to
+# redistribute (e.g. MIT). Each is verified by SHA-256 before use, so the host is
+# never blindly trusted. License-unclear components (DECtalk, eSpeak GPL) are NOT
+# listed until redistribution is cleared (PRD 10.2.4 open questions).
+ASSETS: dict[str, ReleaseAsset] = {
+ "whispercpp": ReleaseAsset(
+ component="whispercpp",
+ tag="assets-v1",
+ filename="whisper-bin-x64.zip",
+ sha256="7d8be46ecd31828e1eb7a2ecdd0d6b314feafd82163038ab6092594b0a063539",
+ expect_member="whisper-cli.exe",
+ license="MIT (ggml-org/whisper.cpp v1.9.1)",
+ ),
+}
+
+
+def is_pinned(asset: ReleaseAsset) -> bool:
+ """True only when the asset is safely pinned: a real 64-hex SHA-256 and a URL
+ that is not a moving ref (``latest``/``head``/``main``/``master``)."""
+ sha = (asset.sha256 or "").strip().lower()
+ if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
+ return False
+ low = asset.url.lower()
+ return not any(seg in low for seg in ("/latest/", "/head/", "/main/", "/master/"))
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(_CHUNK), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _download_resumable(
+ url: str,
+ dest: Path,
+ progress: ProgressCallback | None,
+ *,
+ retries: int = 4,
+ timeout: float = 60.0,
+) -> None:
+ """Download ``url`` to ``dest`` with retry/backoff, resuming a partial file via
+ HTTP Range. HTTPS-only. Raises :class:`ReleaseAssetError` after exhausting retries.
+
+ GATE-9: this is the module's only network egress; callers gate it on an explicit
+ user action and Safe Mode.
+ """
+ if not url.lower().startswith("https://"):
+ raise ReleaseAssetError("Refusing a non-HTTPS download URL.")
+ last_error: Exception | None = None
+ for attempt in range(retries):
+ try:
+ have = dest.stat().st_size if dest.exists() else 0
+ request = urllib.request.Request(url)
+ if have:
+ request.add_header("Range", f"bytes={have}-")
+ with urllib.request.urlopen(request, timeout=timeout) as resp: # noqa: S310 - HTTPS enforced
+ status = getattr(resp, "status", 200)
+ # If the server ignored Range (200 not 206), restart from zero.
+ append = bool(have) and status == 206
+ if not append:
+ have = 0
+ total = have + int(resp.headers.get("Content-Length") or 0)
+ downloaded = have
+ with dest.open("ab" if append else "wb") as out:
+ while True:
+ chunk = resp.read(_CHUNK)
+ if not chunk:
+ break
+ out.write(chunk)
+ downloaded += len(chunk)
+ if progress is not None and total > 0:
+ progress(
+ min(downloaded / total, 0.99),
+ "Downloading offline speech engine...",
+ )
+ return
+ except Exception as exc: # noqa: BLE001 - retry transient network errors
+ last_error = exc
+ time.sleep(min(2**attempt, 8))
+ raise ReleaseAssetError(f"Download failed after {retries} attempts: {last_error}")
+
+
+def fetch_component(
+ component: str, target_dir: Path, *, progress: ProgressCallback | None = None
+) -> Path:
+ """Download, verify (SHA-256), and unpack ``component`` into ``target_dir``.
+
+ Atomic: everything happens in a temp dir; the verified files are copied into
+ ``target_dir`` only after the checksum passes, so a partial/failed download
+ never leaves a half-installed engine. Returns ``target_dir``. Raises
+ :class:`ReleaseAssetError` (Safe Mode, unknown/unpinned component, network,
+ checksum mismatch, or a malformed archive) so the caller can degrade cleanly.
+ """
+ if os.environ.get("QUILL_SAFE_MODE") == "1":
+ raise ReleaseAssetError("Downloading components is disabled in Safe Mode.")
+ asset = ASSETS.get(component)
+ if asset is None:
+ raise ReleaseAssetError(f"Unknown component: {component!r}")
+ if not is_pinned(asset):
+ raise ReleaseAssetError(
+ f"Refusing to fetch an unpinned/placeholder asset for {component!r}."
+ )
+
+ target = Path(target_dir)
+ tmp = Path(tempfile.mkdtemp(prefix="quill-asset-"))
+ try:
+ archive = tmp / asset.filename
+ if progress is not None:
+ progress(0.0, "Downloading offline speech engine...")
+ _download_resumable(asset.url, archive, progress)
+
+ actual = _sha256_file(archive)
+ if actual.lower() != asset.sha256.lower():
+ raise ReleaseAssetError(
+ f"Checksum mismatch for {asset.filename} "
+ f"(expected {asset.sha256[:12]}..., got {actual[:12]}...)."
+ )
+
+ if progress is not None:
+ progress(0.99, "Installing...")
+ extract = tmp / "extract"
+ with zipfile.ZipFile(archive) as zf:
+ zf.extractall(extract)
+
+ source_dir = extract
+ if asset.expect_member:
+ hits = list(extract.rglob(asset.expect_member))
+ if not hits:
+ raise ReleaseAssetError(f"{asset.filename} did not contain {asset.expect_member}.")
+ source_dir = hits[0].parent
+
+ target.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(source_dir, target, dirs_exist_ok=True)
+ if progress is not None:
+ progress(1.0, "Done.")
+ return target
+ finally:
+ shutil.rmtree(tmp, ignore_errors=True)
diff --git a/quill/tools/module_size_budgets.json b/quill/tools/module_size_budgets.json
index 062d8d38..b4e43a82 100644
--- a/quill/tools/module_size_budgets.json
+++ b/quill/tools/module_size_budgets.json
@@ -25,6 +25,7 @@
"_rebaseline_2026_06_26_warm_settings_and_kokoro": "read_aloud.py 1258->1299, settings.py 1201->1209, main_frame.py 26142->26145, main_frame_speech.py 1277->1300, and a new main_frame_dictation_hotkeys.py entry at 601 for: (1) the live voice-preview phrase now matches the recorded samples (the phrase.txt tagline) instead of the quick-brown-fox line; (2) warm_dictation_model / warm_kokoro_model settings (default on) so users can disable background model warm-up; (3) bundled-Kokoro discovery (default_kokoro_model_dir prefers a user copy, else {QUILL_APP_ROOT}/kokoro-models) so Kokoro can ship in the installer without a download; (4) warm_kokoro_onnx() + prewarm_kokoro_model() background warm gated on the setting. Budgets re-baselined.",
"_rebaseline_2026_06_26_dictation_transcribe_dialog": "main_frame_dictation_hotkeys.py 601->621 (+20) for the dictation transcription progress dialog (#700): the transcribe path now shows the same AIProgressDialog (percentage + Minimize to status bar, quiet mirroring via _set_status_quiet) as the audio/video file path, then closes on completion and hands the text to the controller (which inserts and announces the word count). Replaces the bare _run_background_task path. Budget re-baselined.",
"_rebaseline_2026_06_26_transcribe_progress_dialog": "main_frame_speech.py 1300->1337 (+37) for the audio/video transcription progress dialog (#700): transcribe_audio_offline() now shows an AIProgressDialog with a real percentage (Faster Whisper reports segment-based progress), a \"Minimize to status bar\" button, and quiet status mirroring (_set_status_quiet) so a minimized run is not chatty; on completion it closes (clearing its status line) and opens the transcript, which announces the word count once. Replaces the bare _run_background_task status-bar path. Budget re-baselined.",
+ "_rebaseline_2026_06_29_on_demand_speech_engine": "main_frame_speech.py 1344->1423 (+79) and main_frame_menu.py 4075->4087 (+12) for the on-demand offline speech engine (whisper.cpp) download (PRD 10.2.4): download_offline_speech_engine() worker (mirrors the existing download_ffmpeg/download_faster_whisper handlers, which already live here) + a 'Download Offline Speech Engine...' item in Tools > Speech with its id and EVT_MENU binding. All acquisition logic is in the new wx-free quill/core/release_assets.py (pinned + SHA-256-verified fetch from QUILL's own GitHub release asset; under the 600 default cap). The engine still ships bundled in the installer; this is the recovery/optional path. Budgets re-baselined to current size.",
"_rebaseline_2026_06_28_speech_download_progress_throttle": "main_frame_speech.py 1337->1344 (+7) for #748: the model-download progress callback (_on_chunk) now throttles UI updates to whole-percent changes (a last_percent gate). Faster Whisper downloads via huggingface_hub's snapshot_download, whose tqdm fires the callback per chunk -- thousands of times -- and AIProgressDialog.set_progress marshals each update to the UI thread via wx.CallAfter; the unthrottled flood starved the UI thread, so the download froze at the first painted value (2%) and the app crashed. The pure clamp helper lives in the wx-free quill/core/speech/provider.py (download_progress_percent, unit-tested); only the throttle gate + a 4-line comment land in the UI file. Budget re-baselined to current size.",
"_rebaseline_2026_06_26_dictation_provider_cache": "main_frame_speech.py 1234->1277 (+43) for the dictation model warm-cache (#700): _speech_registry() builds a fresh provider per call, so the Faster Whisper model was reloaded on every dictation. Added _dictation_provider() (caches the provider, keyed by chosen engine, so the loaded model persists), invalidate_dictation_provider(), and prewarm_dictation_model() (a background thread that calls provider.warm() a few seconds after startup so the first dictation is fast). Paired with FasterWhisperProvider.warm() and a CallLater trigger in the dictation mixin. Budget re-baselined.",
"_rebaseline_2026_06_26_dictation_download_ux": "main_frame_speech.py 1223->1234 (+11) for the magical dictation download UX (#700): the speech-model download now ends with a clear OK button (progress.switch_to_ok, 'ready to use for dictation') instead of silently closing -- the silent close read as 'nothing happened' even on success -- and every speech/voice/engine download dialog (FFmpeg, Faster Whisper, Vosk, DECtalk, Piper, eSpeak-NG, Kokoro ONNX, model download) gained a 'Minimize to status bar' button via status_fn=self._set_status. Budget re-baselined to current size.",
@@ -314,13 +315,13 @@
"quill/ui/main_frame_braille.py": 775,
"quill/ui/main_frame_copy_tray.py": 344,
"quill/ui/main_frame_image.py": 598,
- "quill/ui/main_frame_menu.py": 4075,
+ "quill/ui/main_frame_menu.py": 4087,
"quill/ui/main_frame_power_tools.py": 1299,
"quill/ui/main_frame_power_tools_menu.py": 792,
"quill/ui/main_frame_quill_key.py": 753,
"quill/ui/main_frame_quillins.py": 1001,
"quill/ui/main_frame_simple_open.py": 200,
- "quill/ui/main_frame_speech.py": 1344,
+ "quill/ui/main_frame_speech.py": 1423,
"quill/ui/main_frame_dictation_hotkeys.py": 623,
"quill/ui/main_frame_statusbar.py": 968,
"quill/ui/prompt_library_dialog.py": 514,
diff --git a/quill/tools/network_egress_audit.py b/quill/tools/network_egress_audit.py
index ab4f8caa..21cab40b 100644
--- a/quill/tools/network_egress_audit.py
+++ b/quill/tools/network_egress_audit.py
@@ -105,6 +105,14 @@
"context, visible progress, blocked in Safe Mode, sha256-verified when a hash "
"is known. No silent background downloads."
),
+ "core/release_assets.py::_download_resumable": (
+ "User-initiated on-demand fetch of a redistributable runtime component "
+ "(currently the MIT whisper.cpp engine) from QUILL's own pinned, "
+ "SHA-256-verified GitHub release asset (PRD 10.2.4). HTTPS enforced "
+ "(refuses non-https), retry/resumable, bytes verified by SHA-256 before "
+ "use, visible progress, blocked in Safe Mode. Supplements the installer "
+ "bundling; capability never depends on it."
+ ),
"core/speech/ffmpeg_install.py::_download_zip": (
"User-initiated optional ffmpeg download (#617) from the official Gyan.dev "
"Windows build linked by ffmpeg.org; HTTPS enforced (refuses non-https), "
diff --git a/quill/ui/main_frame_menu.py b/quill/ui/main_frame_menu.py
index 607b2394..7a990e6f 100644
--- a/quill/ui/main_frame_menu.py
+++ b/quill/ui/main_frame_menu.py
@@ -1475,6 +1475,7 @@ def _build_menu(self) -> None:
self._id_dictation_settings = wx.NewIdRef()
self._id_dictation_history = wx.NewIdRef()
self._id_speech_ffmpeg = wx.NewIdRef()
+ self._id_speech_offline_engine = wx.NewIdRef()
self._id_speech_engine_dl = wx.NewIdRef()
self._id_speech_hf_token = wx.NewIdRef()
self._id_speech_export_audio = wx.NewIdRef()
@@ -1769,6 +1770,12 @@ def _build_menu(self) -> None:
self._id_speech_ffmpeg,
self._menu_label(_("Download &FFmpeg..."), "tools.speech_ffmpeg"),
)
+ speech_menu.Append(
+ self._id_speech_offline_engine,
+ self._menu_label(
+ _("Download &Offline Speech Engine..."), "tools.speech_offline_engine"
+ ),
+ )
speech_menu.Append(
self._id_speech_engine_dl,
self._menu_label(
@@ -2894,6 +2901,11 @@ def _toggle_experience(_event: object) -> None:
lambda _e: self.download_ffmpeg(),
id=self._id_speech_ffmpeg,
)
+ self.frame.Bind(
+ wx.EVT_MENU,
+ lambda _e: self.download_offline_speech_engine(),
+ id=self._id_speech_offline_engine,
+ )
self.frame.Bind(
wx.EVT_MENU,
lambda _e: self.download_faster_whisper(),
diff --git a/quill/ui/main_frame_speech.py b/quill/ui/main_frame_speech.py
index 0b378238..abf64a66 100644
--- a/quill/ui/main_frame_speech.py
+++ b/quill/ui/main_frame_speech.py
@@ -249,6 +249,85 @@ def _run() -> None:
target=_run, daemon=True
).start()
+ def download_offline_speech_engine(self) -> None:
+ """Fetch the offline whisper.cpp engine from QUILL's verified release asset.
+
+ The engine ships in the installer, so this is the recovery / optional path
+ (e.g. an older install that pre-dated bundling, #742). The download is
+ pinned + SHA-256-verified (quill.core.release_assets), runs on a worker
+ thread behind a cancelable percentage, and is blocked in Safe Mode. The
+ bundled copy is never required to be absent for this to be useful.
+ """
+ import threading
+
+ from quill.core.release_assets import ReleaseAssetError, fetch_component
+ from quill.core.speech import models
+ from quill.core.speech.providers.whispercpp import resolve_whisper_executable
+ from quill.ui.ai_transcribe_dialog import AIProgressDialog
+
+ wx = self._wx
+ if resolve_whisper_executable() is not None:
+ again = self._show_message_box(
+ "The offline speech engine is already installed. Download QUILL's "
+ "verified copy again anyway?",
+ "Download Offline Speech Engine",
+ wx.ICON_QUESTION | wx.YES_NO,
+ )
+ if again != wx.YES:
+ return
+ confirm = self._show_message_box(
+ "Download the offline speech engine (whisper.cpp, about 8 MB) from "
+ "QUILL's own verified release? It powers private, on-device dictation and "
+ "transcription, and the download is checksum-verified.",
+ "Download Offline Speech Engine",
+ wx.ICON_QUESTION | wx.YES_NO,
+ )
+ if confirm != wx.YES:
+ return
+ cancel = threading.Event()
+ progress = AIProgressDialog(
+ self.frame,
+ "Downloading Offline Speech Engine",
+ "Preparing to download the offline speech engine...",
+ on_cancel=cancel.set,
+ status_fn=self._set_status,
+ )
+ progress.show()
+ self._announce("Downloading the offline speech engine.")
+ last_percent = {"value": -1}
+
+ def _on_progress(fraction: float, message: str) -> None:
+ if cancel.is_set():
+ raise ReleaseAssetError("Download cancelled.")
+ percent = int(max(0.0, min(1.0, fraction)) * 100)
+ if percent == last_percent["value"]:
+ return # throttle UI updates to whole-percent changes (#748)
+ last_percent["value"] = percent
+ progress.set_progress(percent, f"{message} {percent}%")
+
+ target = models.app_data_dir() / "speech-engine"
+
+ def _run() -> None:
+ try:
+ fetch_component("whispercpp", target, progress=_on_progress)
+ except Exception as exc: # noqa: BLE001 - surface a clean message
+ wx.CallAfter(progress.close)
+ if cancel.is_set():
+ wx.CallAfter(self._set_status, "Speech engine download cancelled.")
+ wx.CallAfter(self._announce, "Speech engine download cancelled.")
+ else:
+ wx.CallAfter(self._set_status, f"Could not install the speech engine: {exc}")
+ wx.CallAfter(self._announce, f"Could not install the speech engine. {exc}")
+ return
+ wx.CallAfter(progress.close)
+ done = "Offline speech engine installed. Dictation and transcription are ready."
+ wx.CallAfter(self._set_status, done)
+ wx.CallAfter(self._announce, done)
+
+ threading.Thread( # GATE-40-OK: speech engine download worker.
+ target=_run, daemon=True
+ ).start()
+
def download_faster_whisper(self) -> None:
"""Install the optional Faster Whisper engine on demand (#669 follow-up).
diff --git a/tests/unit/core/test_release_assets.py b/tests/unit/core/test_release_assets.py
new file mode 100644
index 00000000..a71a5cb7
--- /dev/null
+++ b/tests/unit/core/test_release_assets.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import zipfile
+
+import pytest
+
+from quill.core import release_assets as ra
+
+
+def test_is_pinned_accepts_real_rejects_placeholder_and_moving_ref() -> None:
+ assert ra.is_pinned(ra.ASSETS["whispercpp"]) is True
+ # Placeholder SHA -> not pinned.
+ assert ra.is_pinned(ra.ReleaseAsset("x", "assets-v1", "f.zip", "")) is False
+ # Non-hex / wrong length -> not pinned.
+ assert ra.is_pinned(ra.ReleaseAsset("x", "assets-v1", "f.zip", "zz")) is False
+ # Moving ref in the URL -> not pinned even with a real-looking SHA.
+ assert ra.is_pinned(ra.ReleaseAsset("x", "latest", "f.zip", "a" * 64)) is False
+
+
+def test_safe_mode_blocks_fetch(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.setenv("QUILL_SAFE_MODE", "1")
+ with pytest.raises(ra.ReleaseAssetError, match="Safe Mode"):
+ ra.fetch_component("whispercpp", tmp_path / "d")
+
+
+def test_unknown_component_raises(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.delenv("QUILL_SAFE_MODE", raising=False)
+ with pytest.raises(ra.ReleaseAssetError, match="Unknown component"):
+ ra.fetch_component("does-not-exist", tmp_path / "d")
+
+
+def test_unpinned_asset_refused(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.delenv("QUILL_SAFE_MODE", raising=False)
+ monkeypatch.setitem(
+ ra.ASSETS, "ph", ra.ReleaseAsset("ph", "assets-v1", "p.zip", "")
+ )
+ with pytest.raises(ra.ReleaseAssetError, match="unpinned"):
+ ra.fetch_component("ph", tmp_path / "d")
+
+
+def test_checksum_mismatch_raises(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
+ monkeypatch.delenv("QUILL_SAFE_MODE", raising=False)
+ monkeypatch.setitem(ra.ASSETS, "t", ra.ReleaseAsset("t", "assets-v1", "t.zip", "0" * 64))
+ monkeypatch.setattr(
+ ra,
+ "_download_resumable",
+ lambda url, dest, progress, **k: dest.write_bytes(b"not-the-bytes"),
+ )
+ with pytest.raises(ra.ReleaseAssetError, match="[Cc]hecksum mismatch"):
+ ra.fetch_component("t", tmp_path / "d")
+
+
+def test_fetch_verifies_then_unpacks_expected_member(
+ monkeypatch: pytest.MonkeyPatch, tmp_path
+) -> None:
+ monkeypatch.delenv("QUILL_SAFE_MODE", raising=False)
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w") as zf:
+ zf.writestr("Release/whisper-cli.exe", b"exe")
+ zf.writestr("Release/whisper.dll", b"dll")
+ data = buf.getvalue()
+ sha = hashlib.sha256(data).hexdigest()
+ monkeypatch.setitem(
+ ra.ASSETS,
+ "t",
+ ra.ReleaseAsset("t", "assets-v1", "t.zip", sha, expect_member="whisper-cli.exe"),
+ )
+ monkeypatch.setattr(
+ ra, "_download_resumable", lambda url, dest, progress, **k: dest.write_bytes(data)
+ )
+
+ dest = tmp_path / "speech-engine"
+ out = ra.fetch_component("t", dest)
+ assert (out / "whisper-cli.exe").is_file() # the engine binary lands flat in the target
+ assert (out / "whisper.dll").is_file() # alongside its deps
diff --git a/tests/unit/ui/fixtures/main_frame_public_surface.json b/tests/unit/ui/fixtures/main_frame_public_surface.json
index 21fea132..dff5968d 100644
--- a/tests/unit/ui/fixtures/main_frame_public_surface.json
+++ b/tests/unit/ui/fixtures/main_frame_public_surface.json
@@ -123,6 +123,7 @@
"download_faster_whisper",
"download_ffmpeg",
"download_kokoro_engine",
+ "download_offline_speech_engine",
"download_piper_exe",
"download_vosk",
"duplicate_line",