[eric] voice: prefetch the dictation model at boot so the first press never waits (ENG-380)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp
This commit is contained in:
ciregenz
2026-08-20 22:22:48 -07:00
co-authored by Claude Fable 5
parent 6c14b2d65b
commit 3c24c10cbd
4 changed files with 53 additions and 11 deletions
+2
View File
@@ -2671,6 +2671,8 @@ app.whenReady().then(async () => {
// Warm the dictation model well after the window is up, so the first phrase transcribes at the
// steady-state speed instead of waiting out a cold model load under the user's keypress.
const warmDelay = setTimeout(() => {
const fetching = whisperService.prefetchModel(voiceResourceDir(), voiceUserDataDir());
if (fetching) console.log('[voice] prefetching the dictation model so the first press never waits on a download');
const started = whisperService.warmInBackground(voiceResourceDir(), voiceUserDataDir());
console.log(started ? '[voice] warming whisper in background' : '[voice] skipping boot warm (no model, or dictation never used on this install)');
}, 8000);
+2 -2
View File
@@ -19,14 +19,14 @@ const crypto = require('crypto');
const MODELS = [
{ id: 'tiny.en-q5_1', file: 'ggml-tiny.en-q5_1.bin', label: 'Tiny', note: 'Fastest, but gets lost past ~15s of speech', bytes: 32166155, sha256: 'c77c5766f1cef09b6b7d47f21b546cbddd4157886b3b5d6d4f709e91e66c7c2b' },
{ id: 'base.en-q5_1', file: 'ggml-base.en-q5_1.bin', label: 'Base (compact)', note: 'Base quality and speed, 90MB less to download and hold', bytes: 59721011, sha256: '4baf70dd0d7c4247ba2b81fafd9c01005ac77c2f9ef064e00dcf195d0e2fdd2f' },
{ id: 'base.en', file: 'ggml-base.en.bin', label: 'Base', note: 'Fast and light; the instant fallback while Small downloads', bytes: 147964211, sha256: 'a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002' },
{ id: 'base.en', file: 'ggml-base.en.bin', label: 'Base', note: 'Fast and light', bytes: 147964211, sha256: 'a03779c86df3323075f5e796cb2ce5029f00ec8869eee3fdfb897afe36c6d002' },
{ id: 'small.en-q5_1', file: 'ggml-small.en-q5_1.bin', label: 'Small', note: 'Most accurate, the default; steadier on accents and noise', bytes: 190098681, sha256: 'bfdff4894dcb76bbf647d56263ea2a96645423f1669176f4844a1bf8e478ad30' },
{ id: 'small-q5_1', file: 'ggml-small-q5_1.bin', label: 'Small (multilingual)', note: 'Auto-detects the spoken language; slightly less sharp on English', bytes: 190085487, sha256: 'ae85e4a935d7a567bd102fe55afc16bb595bdb618e11b2fc7591bc08120411bb' },
];
// Measured on an M2 (quiet machine, 3.5s/8.1s/26.4s utterances, median of 5):
// tiny 174/154/241ms base-q5_1 212/414/656ms base 208/398/734ms small 865/1108/1869ms
// small.en-q5_1 is the default (Eric's call, 2026-08-05: accuracy first): streaming partials hide
// most of its extra decode cost, and the bundled base.en serves instantly while it downloads.
// most of its extra decode cost. No model ships in the build; the default is prefetched at boot.
// large-v3-turbo is deliberately absent, it measured both slowest and least accurate here
// (4.1s on a 3.5s clip, and it fell apart on the long one).
+13 -9
View File
@@ -215,14 +215,8 @@ async function p_bootServer(resourceDir, userDataDir, extended = true) {
// settled-rejected promise forever so every later call kept throwing "model-downloading" even after
// the model finished. Clearing on rejection here lets the next call retry cleanly.
async function ensureServer(resourceDir, userDataDir) {
// The accuracy-first default may not be on disk yet: pull it in the background while the bundled
// fallback serves this dictation; the model-switch check below hot-swaps once it lands. Only runs
// when the user actually dictates, so an idle install never silently downloads 190MB.
if (!whisperModels.isInstalled(userDataDir, selectedModelId)
&& !(process.env.OPENSWARM_WHISPER_MODEL && fs.existsSync(process.env.OPENSWARM_WHISPER_MODEL))
&& !whisperModels.downloadStatus().downloading) {
whisperModels.downloadModel(userDataDir, selectedModelId);
}
// The user's pick may still be missing (boot prefetch running or failed): pull it now, let whatever IS installed serve this dictation, and the model-switch check below hot-swaps once it lands.
prefetchModel(resourceDir, userDataDir);
// A warm server is only reusable if it holds the file we would load now: a model switch, or the
// user's pick finishing its download while a fallback was serving, has to re-boot.
if (proc && port && resolveModel(resourceDir, userDataDir) !== loadedModelFile) stopServer();
@@ -257,6 +251,16 @@ async function transcribe(resourceDir, userDataDir, wavBuffer) {
return text;
}
// Shipped builds carry no model (build-whisper.sh stages only the server), so a fresh install used to pay the 190MB download under its first dictation press; pulled at boot instead, and refused where no engine could ever run it.
function prefetchModel(resourceDir, userDataDir) {
if (!fs.existsSync(resolveBinary(resourceDir))) return false;
if (process.env.OPENSWARM_WHISPER_MODEL && fs.existsSync(process.env.OPENSWARM_WHISPER_MODEL)) return false;
if (whisperModels.isInstalled(userDataDir, selectedModelId)) return false;
if (whisperModels.downloadStatus().downloading) return false;
whisperModels.downloadModel(userDataDir, selectedModelId);
return true;
}
// A user who has never dictated should not pay a model-resident whisper-server for 10 minutes
// after every boot; the marker appears on the first real transcription and unlocks boot-warm for
// every boot after. Their one cold load lands under the first-ever phrase, same as VoiceInk.
@@ -327,4 +331,4 @@ async function reprimeAfterWake() {
return true;
}
module.exports = { ensureServer, warmInBackground, reprimeAfterWake, transcribe, stopServer, isWarm, setModel, setDictionary, selectedModel, resolveBinary, resolveModel, modelStatus, markUsed, hasBeenUsed };
module.exports = { ensureServer, warmInBackground, prefetchModel, reprimeAfterWake, transcribe, stopServer, isWarm, setModel, setDictionary, selectedModel, resolveBinary, resolveModel, modelStatus, markUsed, hasBeenUsed };
+36
View File
@@ -50,3 +50,39 @@ test('marker alone does not defeat the no-model download guard', () => {
whisperService.markUsed(data);
assert.strictEqual(whisperService.warmInBackground(res, data), false);
});
// The boot prefetch is the other half: it may download, but only where a whisper-server can ever
// run the file, never over an env override, never twice, and never once the pick is already on disk.
test('prefetch refuses when no whisper-server binary is reachable', () => {
const { res, data } = freshDirs();
const saved = process.env.OPENSWARM_WHISPER_BIN;
delete process.env.OPENSWARM_WHISPER_BIN;
try {
if (fs.existsSync('/opt/homebrew/bin/whisper-server')) return; // a brew install on this machine is a reachable engine; the next test covers the decision
assert.strictEqual(whisperService.prefetchModel(res, data), false);
} finally {
if (saved !== undefined) process.env.OPENSWARM_WHISPER_BIN = saved;
}
});
test('prefetch skips an env-pinned model and an already-installed pick', () => {
const { res, data } = freshDirs();
const fakeBin = path.join(res, 'whisper-server');
fs.writeFileSync(fakeBin, '#!/bin/sh\n');
const fakeModel = path.join(res, 'fake-model.bin');
fs.writeFileSync(fakeModel, 'x');
process.env.OPENSWARM_WHISPER_MODEL = fakeModel;
try {
assert.strictEqual(whisperService.prefetchModel(res, data), false);
} finally {
delete process.env.OPENSWARM_WHISPER_MODEL;
}
const whisperModels = require('./voice/whisperModels');
const m = whisperModels.MODELS.find((x) => x.id === whisperModels.DEFAULT_MODEL_ID);
fs.mkdirSync(path.join(data, 'whisper'), { recursive: true });
const f = fs.openSync(path.join(data, 'whisper', m.file), 'w');
fs.ftruncateSync(f, m.bytes);
fs.closeSync(f);
assert.strictEqual(whisperModels.isInstalled(data, m.id), true);
assert.strictEqual(whisperService.prefetchModel(res, data), false);
});