irongit

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.

216 lines6.3 KBRust
1use futures_util::StreamExt;
2use serde::Serialize;
3use std::fs;
4use std::path::PathBuf;
5use tauri::{AppHandle, Emitter, Manager};
6
7pub 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
16const HF_BASE: &str = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main";
17
18pub 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)]
102pub 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)]
113pub struct DownloadProgress {
114 pub id: String,
115 pub downloaded: u64,
116 pub total: u64,
117 pub done: bool,
118}
119
120pub 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
130pub 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
138pub 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
154pub 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
210pub 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}