diff --git a/electron/main.js b/electron/main.js index d03630ff..2889ce39 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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); diff --git a/electron/voice/whisperModels.js b/electron/voice/whisperModels.js index d9e9844b..6d547784 100644 --- a/electron/voice/whisperModels.js +++ b/electron/voice/whisperModels.js @@ -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). diff --git a/electron/voice/whisperService.js b/electron/voice/whisperService.js index 59bd5992..3a1e9b16 100644 --- a/electron/voice/whisperService.js +++ b/electron/voice/whisperService.js @@ -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 }; diff --git a/electron/whisperBootWarm.test.js b/electron/whisperBootWarm.test.js index b9e9d95c..aa3a8c82 100644 --- a/electron/whisperBootWarm.test.js +++ b/electron/whisperBootWarm.test.js @@ -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); +});