Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 41 additions & 16 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | null = null;
type WhisperCppModelStatus = {
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
Expand All @@ -821,22 +841,22 @@ function getWhisperCppModelStatus(): WhisperCppModelStatus {
if (whisperCppModelStatus?.state === 'downloading') {
return {
...whisperCppModelStatus,
modelName: WHISPERCPP_MODEL_NAME,
modelName: getWhisperCppModelSize(),
path: modelPath,
};
}

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,
Expand Down Expand Up @@ -955,12 +975,13 @@ async function downloadFileWithRedirects(
}

async function ensureWhisperCppModelDownloaded(): Promise<string> {
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),
Expand All @@ -979,18 +1000,18 @@ async function ensureWhisperCppModelDownloaded(): Promise<string> {
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,
Expand All @@ -1000,7 +1021,7 @@ async function ensureWhisperCppModelDownloaded(): Promise<string> {
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,
Expand All @@ -1011,7 +1032,7 @@ async function ensureWhisperCppModelDownloaded(): Promise<string> {
try { fs.unlinkSync(tempPath); } catch {}
whisperCppModelStatus = {
state: 'error',
modelName: WHISPERCPP_MODEL_NAME,
modelName: size,
path: modelPath,
bytesDownloaded: 0,
totalBytes: null,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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';
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/main/settings-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface AISettings {
enabled: boolean;
llmEnabled: boolean;
whisperEnabled: boolean;
whisperCppModelSize: string;
readEnabled: boolean;
openaiCompatibleBaseUrl: string;
openaiCompatibleApiKey: string;
Expand Down Expand Up @@ -125,6 +126,7 @@ const DEFAULT_AI_SETTINGS: AISettings = {
enabled: true,
llmEnabled: true,
whisperEnabled: true,
whisperCppModelSize: 'base',
readEnabled: true,
openaiCompatibleBaseUrl: '',
openaiCompatibleApiKey: '',
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
28 changes: 28 additions & 0 deletions src/renderer/src/settings/AITab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,34 @@ const AITab: React.FC = () => {
</div>
)}

{whisperModelValue === 'whispercpp' && (
<div>
<label className="text-[0.75rem] text-[var(--text-muted)] mb-1 block">{t('settings.ai.whisper.modelSize.label')}</label>
<select
value={ai.whisperCppModelSize || 'base'}
onChange={(e) => {
updateAI({ whisperCppModelSize: e.target.value });
void refreshWhisperCppModelStatus();
}}
className={`w-full bg-[var(--ui-segment-bg)] border rounded-md px-2.5 py-2 text-sm text-[var(--text-secondary)] focus:outline-none focus:border-blue-500/50 ${
whisperCppModelStatus && whisperCppModelStatus.state !== 'downloaded' && whisperCppModelStatus.state !== 'downloading'
? 'border-red-500/60'
: 'border-[var(--ui-divider)]'
}`}
>
<option value="tiny">{t('settings.ai.whisper.modelSize.tiny')}</option>
<option value="base">{t('settings.ai.whisper.modelSize.base')}</option>
<option value="small">{t('settings.ai.whisper.modelSize.small')}</option>
<option value="medium">{t('settings.ai.whisper.modelSize.medium')}</option>
<option value="large">{t('settings.ai.whisper.modelSize.large')}</option>
</select>
{whisperCppModelStatus && whisperCppModelStatus.state !== 'downloaded' && whisperCppModelStatus.state !== 'downloading' && (
<p className="text-[0.6875rem] text-red-400 mt-1">{t('settings.ai.whisper.modelSize.notDownloaded')}</p>
)}
<p className="text-[0.75rem] text-[var(--text-muted)] mt-1">{t('settings.ai.whisper.modelSize.hint')}</p>
</div>
)}

{whisperModelValue === 'whispercpp' && (
<div>
<label className="text-[0.75rem] text-[var(--text-muted)] mb-1 block">{t('settings.ai.whisper.recognitionLanguage')}</label>
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export interface AISettings {
enabled: boolean;
llmEnabled: boolean;
whisperEnabled: boolean;
whisperCppModelSize: string;
readEnabled: boolean;
openaiCompatibleBaseUrl: string;
openaiCompatibleApiKey: string;
Expand Down