diff --git a/src/main/main.ts b/src/main/main.ts index fbc2c53f..543bbbb0 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -161,8 +161,27 @@ function getNativeBinaryPath(name: string): string { } const WHISPERCPP_FRAMEWORK_VERSION = 'v1.8.3'; -const WHISPERCPP_MODEL_NAME = 'base'; -const WHISPERCPP_MODEL_URL = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${WHISPERCPP_MODEL_NAME}.bin`; +const WHISPERCPP_DEFAULT_MODEL_SIZE = 'base'; +const WHISPERCPP_MODEL_SIZES = ['tiny', 'base', 'small', 'medium', 'large'] as const; +let whisperCppModelSizeCache: string | null = null; + +function getWhisperCppModelSize(): string { + if (whisperCppModelSizeCache) return whisperCppModelSizeCache; + try { + const s = loadSettings(); + const size = (s as any).ai?.whisperCppModelSize; + if (size && WHISPERCPP_MODEL_SIZES.includes(size)) { + whisperCppModelSizeCache = size; + return size; + } + } catch {} + whisperCppModelSizeCache = WHISPERCPP_DEFAULT_MODEL_SIZE; + return WHISPERCPP_DEFAULT_MODEL_SIZE; +} + +function getWhisperCppModelUrl(size: string): string { + return `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${size}.bin`; +} let whisperCppModelEnsurePromise: Promise | null = null; type WhisperCppModelStatus = { @@ -799,7 +818,8 @@ function getWhisperCppTranscriberBinaryPath(): string { } function getWhisperCppModelPath(): string { - return path.join(app.getPath('userData'), 'whispercpp', 'models', `ggml-${WHISPERCPP_MODEL_NAME}.bin`); + const size = getWhisperCppModelSize(); + return path.join(app.getPath('userData'), 'whispercpp', 'models', `ggml-${size}.bin`); } function getWhisperCppModelStatus(): WhisperCppModelStatus { @@ -809,7 +829,7 @@ function getWhisperCppModelStatus(): WhisperCppModelStatus { const stats = fs.statSync(modelPath); whisperCppModelStatus = { state: 'downloaded', - modelName: WHISPERCPP_MODEL_NAME, + modelName: getWhisperCppModelSize(), path: modelPath, bytesDownloaded: Math.max(0, Number(stats.size) || 0), totalBytes: Math.max(0, Number(stats.size) || 0), @@ -821,7 +841,7 @@ function getWhisperCppModelStatus(): WhisperCppModelStatus { if (whisperCppModelStatus?.state === 'downloading') { return { ...whisperCppModelStatus, - modelName: WHISPERCPP_MODEL_NAME, + modelName: getWhisperCppModelSize(), path: modelPath, }; } @@ -829,14 +849,14 @@ function getWhisperCppModelStatus(): WhisperCppModelStatus { if (whisperCppModelStatus?.state === 'error') { return { ...whisperCppModelStatus, - modelName: WHISPERCPP_MODEL_NAME, + modelName: getWhisperCppModelSize(), path: modelPath, }; } whisperCppModelStatus = { state: 'not-downloaded', - modelName: WHISPERCPP_MODEL_NAME, + modelName: getWhisperCppModelSize(), path: modelPath, bytesDownloaded: 0, totalBytes: null, @@ -955,12 +975,13 @@ async function downloadFileWithRedirects( } async function ensureWhisperCppModelDownloaded(): Promise { + const size = getWhisperCppModelSize(); const modelPath = getWhisperCppModelPath(); try { if (fs.existsSync(modelPath)) { whisperCppModelStatus = { state: 'downloaded', - modelName: WHISPERCPP_MODEL_NAME, + modelName: size, path: modelPath, bytesDownloaded: Math.max(0, Number(fs.statSync(modelPath).size) || 0), totalBytes: Math.max(0, Number(fs.statSync(modelPath).size) || 0), @@ -979,18 +1000,18 @@ async function ensureWhisperCppModelDownloaded(): Promise { fs.mkdirSync(modelDir, { recursive: true }); whisperCppModelStatus = { state: 'downloading', - modelName: WHISPERCPP_MODEL_NAME, + modelName: size, path: modelPath, bytesDownloaded: 0, totalBytes: null, }; try { - console.log(`[Whisper][whisper.cpp] Downloading ${WHISPERCPP_MODEL_NAME} model`); - await downloadFileWithRedirects(WHISPERCPP_MODEL_URL, tempPath, 5, (bytesDownloaded, totalBytes) => { + console.log(`[Whisper][whisper.cpp] Downloading ${size} model`); + await downloadFileWithRedirects(getWhisperCppModelUrl(size), tempPath, 5, (bytesDownloaded, totalBytes) => { whisperCppModelStatus = { state: 'downloading', - modelName: WHISPERCPP_MODEL_NAME, + modelName: size, path: modelPath, bytesDownloaded, totalBytes, @@ -1000,7 +1021,7 @@ async function ensureWhisperCppModelDownloaded(): Promise { const finalSize = Math.max(0, Number(fs.statSync(modelPath).size) || 0); whisperCppModelStatus = { state: 'downloaded', - modelName: WHISPERCPP_MODEL_NAME, + modelName: size, path: modelPath, bytesDownloaded: finalSize, totalBytes: finalSize, @@ -1011,7 +1032,7 @@ async function ensureWhisperCppModelDownloaded(): Promise { try { fs.unlinkSync(tempPath); } catch {} whisperCppModelStatus = { state: 'error', - modelName: WHISPERCPP_MODEL_NAME, + modelName: size, path: modelPath, bytesDownloaded: 0, totalBytes: null, @@ -11095,6 +11116,10 @@ app.whenReady().then(async () => { if (patch.openAtLogin !== undefined) { applyOpenAtLogin(Boolean(patch.openAtLogin)); } + // Invalidate cached model size when it changes + if (patch.ai?.whisperCppModelSize !== undefined) { + whisperCppModelSizeCache = null; + } // When onboarding completes: hide dock, then start services that were // deferred to avoid triggering permission dialogs during onboarding. if (patch.hasSeenOnboarding === true) { @@ -13579,7 +13604,7 @@ if let tiff = image?.tiffRepresentation { // Parse speechToTextModel to a concrete provider/model pair. let provider: 'parakeet' | 'qwen3' | 'whispercpp' | 'openai' | 'elevenlabs' = 'whispercpp'; - let model = `ggml-${WHISPERCPP_MODEL_NAME}`; + let model = `ggml-${getWhisperCppModelSize()}`; const sttModel = s.ai.speechToTextModel || ''; if (sttModel === 'parakeet') { provider = 'parakeet'; @@ -13589,7 +13614,7 @@ if let tiff = image?.tiffRepresentation { model = 'qwen3-asr-0.6b'; } else if (!sttModel || sttModel === 'default' || sttModel === 'whispercpp') { provider = 'whispercpp'; - model = `ggml-${WHISPERCPP_MODEL_NAME}`; + model = `ggml-${getWhisperCppModelSize()}`; } else if (sttModel === 'native') { // Renderer should not call cloud transcription in native mode. // Return empty transcript instead of surfacing an IPC error. diff --git a/src/main/settings-store.ts b/src/main/settings-store.ts index 709967cc..d91f8ac9 100644 --- a/src/main/settings-store.ts +++ b/src/main/settings-store.ts @@ -30,6 +30,7 @@ export interface AISettings { enabled: boolean; llmEnabled: boolean; whisperEnabled: boolean; + whisperCppModelSize: string; readEnabled: boolean; openaiCompatibleBaseUrl: string; openaiCompatibleApiKey: string; @@ -125,6 +126,7 @@ const DEFAULT_AI_SETTINGS: AISettings = { enabled: true, llmEnabled: true, whisperEnabled: true, + whisperCppModelSize: 'base', readEnabled: true, openaiCompatibleBaseUrl: '', openaiCompatibleApiKey: '', diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 813b6c58..2ce6e928 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -343,6 +343,16 @@ "elevenlabsWarning": "ElevenLabs STT selected. {action} ElevenLabs API key in API Keys.", "elevenlabsReady": "ElevenLabs STT selected. Cloud transcription will use your ElevenLabs key.", "recognitionLanguage": "Recognition Language", + "modelSize": { + "label": "Model Size", + "tiny": "Tiny (~39 MB) — Fastest, lower accuracy", + "base": "Base (~140 MB) — Good balance (default)", + "small": "Small (~466 MB) — Better accuracy", + "medium": "Medium (~1.5 GB) — High accuracy", + "large": "Large (~2.9 GB) — Best accuracy, slowest", + "notDownloaded": "This model is not downloaded yet. Download it below before using dictation.", + "hint": "Larger models are more accurate but use more memory and take longer to transcribe. Changing the model size requires re-downloading." + }, "providerInfo": { "parakeet": "Offline on-device transcription via Parakeet TDT v3. Runs on Apple Neural Engine. Requires model warmup on first use. Download the model below before using dictation.", "qwen3": "Offline on-device transcription via Qwen3 ASR. Supports 30+ languages, requires macOS 15+, and warms up on first use.", diff --git a/src/renderer/src/settings/AITab.tsx b/src/renderer/src/settings/AITab.tsx index b4f39294..e8045ef5 100644 --- a/src/renderer/src/settings/AITab.tsx +++ b/src/renderer/src/settings/AITab.tsx @@ -1299,6 +1299,34 @@ const AITab: React.FC = () => { )} + {whisperModelValue === 'whispercpp' && ( +
+ + + {whisperCppModelStatus && whisperCppModelStatus.state !== 'downloaded' && whisperCppModelStatus.state !== 'downloading' && ( +

{t('settings.ai.whisper.modelSize.notDownloaded')}

+ )} +

{t('settings.ai.whisper.modelSize.hint')}

+
+ )} + {whisperModelValue === 'whispercpp' && (
diff --git a/src/renderer/types/electron.d.ts b/src/renderer/types/electron.d.ts index 6d12fa39..cbd5986b 100644 --- a/src/renderer/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -175,6 +175,7 @@ export interface AISettings { enabled: boolean; llmEnabled: boolean; whisperEnabled: boolean; + whisperCppModelSize: string; readEnabled: boolean; openaiCompatibleBaseUrl: string; openaiCompatibleApiKey: string;