Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.
| 1 | use futures_util::StreamExt; |
| 2 | use serde::Serialize; |
| 3 | use std::fs; |
| 4 | use std::path::PathBuf; |
| 5 | use tauri::{AppHandle, Emitter, Manager}; |
| 6 | |
| 7 | pub struct CatalogEntry { |
| 8 | pub id: &'static str, |
| 9 | pub label: &'static str, |
| 10 | pub filename: &'static str, |
| 11 | pub size_mb: u32, |
| 12 | pub multilingual: bool, |
| 13 | pub description: &'static str, |
| 14 | } |
| 15 | |
| 16 | const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"; |
| 17 | |
| 18 | pub const CATALOG: &[CatalogEntry] = &[ |
| 19 | CatalogEntry { |
| 20 | id: "tiny.en", |
| 21 | label: "Tiny (English)", |
| 22 | filename: "ggml-tiny.en.bin", |
| 23 | size_mb: 75, |
| 24 | multilingual: false, |
| 25 | description: "Fastest, lowest accuracy. Good for quick notes on weak hardware.", |
| 26 | }, |
| 27 | CatalogEntry { |
| 28 | id: "base.en", |
| 29 | label: "Base (English)", |
| 30 | filename: "ggml-base.en.bin", |
| 31 | size_mb: 142, |
| 32 | multilingual: false, |
| 33 | description: "Fast with decent accuracy. A sensible default for English dictation.", |
| 34 | }, |
| 35 | CatalogEntry { |
| 36 | id: "small.en", |
| 37 | label: "Small (English)", |
| 38 | filename: "ggml-small.en.bin", |
| 39 | size_mb: 466, |
| 40 | multilingual: false, |
| 41 | description: "Noticeably better accuracy, still responsive on modern CPUs.", |
| 42 | }, |
| 43 | CatalogEntry { |
| 44 | id: "tiny", |
| 45 | label: "Tiny (Multilingual)", |
| 46 | filename: "ggml-tiny.bin", |
| 47 | size_mb: 75, |
| 48 | multilingual: true, |
| 49 | description: "Fastest multilingual model.", |
| 50 | }, |
| 51 | CatalogEntry { |
| 52 | id: "base", |
| 53 | label: "Base (Multilingual)", |
| 54 | filename: "ggml-base.bin", |
| 55 | size_mb: 142, |
| 56 | multilingual: true, |
| 57 | description: "Fast multilingual dictation.", |
| 58 | }, |
| 59 | CatalogEntry { |
| 60 | id: "small", |
| 61 | label: "Small (Multilingual)", |
| 62 | filename: "ggml-small.bin", |
| 63 | size_mb: 466, |
| 64 | multilingual: true, |
| 65 | description: "Good multilingual accuracy for daily use.", |
| 66 | }, |
| 67 | CatalogEntry { |
| 68 | id: "medium", |
| 69 | label: "Medium (Multilingual)", |
| 70 | filename: "ggml-medium.bin", |
| 71 | size_mb: 1530, |
| 72 | multilingual: true, |
| 73 | description: "High accuracy, slower. Best with a strong CPU.", |
| 74 | }, |
| 75 | CatalogEntry { |
| 76 | id: "large-v3-turbo-q5_0", |
| 77 | label: "Large v3 Turbo Q5", |
| 78 | filename: "ggml-large-v3-turbo-q5_0.bin", |
| 79 | size_mb: 574, |
| 80 | multilingual: true, |
| 81 | description: "Quantized large-v3-turbo. Excellent accuracy for the size, the sweet spot for CPU inference.", |
| 82 | }, |
| 83 | CatalogEntry { |
| 84 | id: "large-v3-turbo", |
| 85 | label: "Large v3 Turbo", |
| 86 | filename: "ggml-large-v3-turbo.bin", |
| 87 | size_mb: 1620, |
| 88 | multilingual: true, |
| 89 | description: "Near large-v3 accuracy at much higher speed.", |
| 90 | }, |
| 91 | CatalogEntry { |
| 92 | id: "large-v3", |
| 93 | label: "Large v3", |
| 94 | filename: "ggml-large-v3.bin", |
| 95 | size_mb: 3100, |
| 96 | multilingual: true, |
| 97 | description: "Best accuracy whisper.cpp offers. Heavy; expect slower turnaround on CPU.", |
| 98 | }, |
| 99 | ]; |
| 100 | |
| 101 | #[derive(Serialize, Clone)] |
| 102 | pub struct ModelInfo { |
| 103 | pub id: String, |
| 104 | pub label: String, |
| 105 | pub filename: String, |
| 106 | pub size_mb: u32, |
| 107 | pub multilingual: bool, |
| 108 | pub description: String, |
| 109 | pub installed: bool, |
| 110 | } |
| 111 | |
| 112 | #[derive(Serialize, Clone)] |
| 113 | pub struct DownloadProgress { |
| 114 | pub id: String, |
| 115 | pub downloaded: u64, |
| 116 | pub total: u64, |
| 117 | pub done: bool, |
| 118 | } |
| 119 | |
| 120 | pub fn models_dir(app: &AppHandle) -> Result<PathBuf, String> { |
| 121 | let dir = app |
| 122 | .path() |
| 123 | .app_data_dir() |
| 124 | .map_err(|e| format!("no data dir: {e}"))? |
| 125 | .join("models"); |
| 126 | fs::create_dir_all(&dir).map_err(|e| format!("create models dir: {e}"))?; |
| 127 | Ok(dir) |
| 128 | } |
| 129 | |
| 130 | pub fn model_path(app: &AppHandle, id: &str) -> Result<PathBuf, String> { |
| 131 | let entry = CATALOG |
| 132 | .iter() |
| 133 | .find(|m| m.id == id) |
| 134 | .ok_or_else(|| format!("unknown model id: {id}"))?; |
| 135 | Ok(models_dir(app)?.join(entry.filename)) |
| 136 | } |
| 137 | |
| 138 | pub fn list(app: &AppHandle) -> Result<Vec<ModelInfo>, String> { |
| 139 | let dir = models_dir(app)?; |
| 140 | Ok(CATALOG |
| 141 | .iter() |
| 142 | .map(|m| ModelInfo { |
| 143 | id: m.id.into(), |
| 144 | label: m.label.into(), |
| 145 | filename: m.filename.into(), |
| 146 | size_mb: m.size_mb, |
| 147 | multilingual: m.multilingual, |
| 148 | description: m.description.into(), |
| 149 | installed: dir.join(m.filename).exists(), |
| 150 | }) |
| 151 | .collect()) |
| 152 | } |
| 153 | |
| 154 | pub async fn download(app: AppHandle, id: String) -> Result<(), String> { |
| 155 | let entry = CATALOG |
| 156 | .iter() |
| 157 | .find(|m| m.id == id) |
| 158 | .ok_or_else(|| format!("unknown model id: {id}"))?; |
| 159 | let dest = models_dir(&app)?.join(entry.filename); |
| 160 | let part = dest.with_extension("bin.part"); |
| 161 | let url = format!("{HF_BASE}/{}", entry.filename); |
| 162 | |
| 163 | let resp = reqwest::get(&url) |
| 164 | .await |
| 165 | .map_err(|e| format!("download failed: {e}"))? |
| 166 | .error_for_status() |
| 167 | .map_err(|e| format!("download failed: {e}"))?; |
| 168 | |
| 169 | let total = resp.content_length().unwrap_or(0); |
| 170 | let mut file = tokio::fs::File::create(&part) |
| 171 | .await |
| 172 | .map_err(|e| format!("create file: {e}"))?; |
| 173 | |
| 174 | let mut stream = resp.bytes_stream(); |
| 175 | let mut downloaded: u64 = 0; |
| 176 | let mut last_emit: u64 = 0; |
| 177 | while let Some(chunk) = stream.next().await { |
| 178 | let chunk = chunk.map_err(|e| format!("download interrupted: {e}"))?; |
| 179 | tokio::io::AsyncWriteExt::write_all(&mut file, &chunk) |
| 180 | .await |
| 181 | .map_err(|e| format!("write failed: {e}"))?; |
| 182 | downloaded += chunk.len() as u64; |
| 183 | if downloaded - last_emit > 2_000_000 { |
| 184 | last_emit = downloaded; |
| 185 | let _ = app.emit( |
| 186 | "model-progress", |
| 187 | DownloadProgress { |
| 188 | id: id.clone(), |
| 189 | downloaded, |
| 190 | total, |
| 191 | done: false, |
| 192 | }, |
| 193 | ); |
| 194 | } |
| 195 | } |
| 196 | drop(file); |
| 197 | fs::rename(&part, &dest).map_err(|e| format!("finalize download: {e}"))?; |
| 198 | let _ = app.emit( |
| 199 | "model-progress", |
| 200 | DownloadProgress { |
| 201 | id: id.clone(), |
| 202 | downloaded, |
| 203 | total, |
| 204 | done: true, |
| 205 | }, |
| 206 | ); |
| 207 | Ok(()) |
| 208 | } |
| 209 | |
| 210 | pub fn delete(app: &AppHandle, id: &str) -> Result<(), String> { |
| 211 | let path = model_path(app, id)?; |
| 212 | if path.exists() { |
| 213 | fs::remove_file(path).map_err(|e| format!("delete model: {e}"))?; |
| 214 | } |
| 215 | Ok(()) |
| 216 | } |