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.

mutterbox/src-tauri/src/pipeline.rs
253 lines7.3 KBRust
1use crate::{audio, inject, llm, models, settings, transcribe};
2use serde::Serialize;
3use std::sync::atomic::AtomicU64;
4use std::sync::Mutex;
5use tauri::{AppHandle, Emitter, Manager};
6use tauri_plugin_notification::NotificationExt;
7
8/// Anything shorter than this (0.5 s at 16 kHz) is treated as an accidental tap.
9const MIN_SAMPLES: usize = 8_000;
10
11pub struct AppState {
12 pub recorder: Mutex<audio::Recorder>,
13 pub settings: Mutex<settings::Settings>,
14 pub whisper: transcribe::WhisperCache,
15 /// Generation counter used to debounce X11 key autorepeat in hold mode.
16 pub hold_gen: AtomicU64,
17}
18
19#[derive(Serialize, Clone)]
20struct StatePayload {
21 state: &'static str,
22}
23
24#[derive(Serialize, Clone)]
25struct TranscriptPayload {
26 raw: String,
27 text: String,
28 enhanced: bool,
29}
30
31#[derive(Serialize, Clone)]
32struct ErrorPayload {
33 message: String,
34}
35
36fn set_state(app: &AppHandle, state: &'static str) {
37 let _ = app.emit("recording-state", StatePayload { state });
38}
39
40fn notify(app: &AppHandle, message: &str) {
41 let _ = app
42 .notification()
43 .builder()
44 .title("mutterbox")
45 .body(message)
46 .show();
47 let _ = app.emit(
48 "pipeline-error",
49 ErrorPayload {
50 message: message.to_string(),
51 },
52 );
53}
54
55pub fn show_main(app: &AppHandle) {
56 if let Some(window) = app.get_webview_window("main") {
57 let _ = window.show();
58 let _ = window.set_focus();
59 }
60}
61
62#[cfg(target_os = "linux")]
63fn hyprctl_ok(args: &[&str]) -> bool {
64 std::process::Command::new("hyprctl")
65 .args(args)
66 .output()
67 .map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).trim() == "ok")
68 .unwrap_or(false)
69}
70
71/// Injects windowrules into a running Hyprland session so the overlay pill can
72/// never steal focus from the field being dictated into. Re-applied on every
73/// show so the rules survive `hyprctl reload`.
74#[cfg(target_os = "linux")]
75fn apply_hyprland_overlay_rules() {
76 if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_none() {
77 return;
78 }
79 // Hyprland 0.53+ rule engine syntax
80 if hyprctl_ok(&[
81 "keyword",
82 "windowrule",
83 "match:title ^(mutterbox overlay)$, no_focus 1, float 1, pin 1",
84 ]) {
85 return;
86 }
87 // Older Hyprland releases: one rule per effect, best effort
88 for keyword in ["windowrule", "windowrulev2"] {
89 for rule in [
90 "nofocus, title:^(mutterbox overlay)$",
91 "float, title:^(mutterbox overlay)$",
92 "pin, title:^(mutterbox overlay)$",
93 ] {
94 let _ = hyprctl_ok(&["keyword", keyword, rule]);
95 }
96 }
97}
98
99fn show_overlay(app: &AppHandle) {
100 #[cfg(target_os = "linux")]
101 apply_hyprland_overlay_rules();
102 if let Some(window) = app.get_webview_window("overlay") {
103 if let (Ok(Some(monitor)), Ok(size)) = (window.primary_monitor(), window.outer_size()) {
104 let msize = monitor.size();
105 let mpos = monitor.position();
106 let x = mpos.x + (msize.width as i32 - size.width as i32) / 2;
107 let y = mpos.y + msize.height as i32 - size.height as i32 - 48;
108 let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
109 }
110 let _ = window.show();
111 }
112}
113
114fn hide_overlay(app: &AppHandle) {
115 if let Some(window) = app.get_webview_window("overlay") {
116 let _ = window.hide();
117 }
118}
119
120/// Hotkey (toggle mode), tray, `--toggle`, and the UI button land here.
121pub fn toggle(app: &AppHandle) {
122 let state = app.state::<AppState>();
123 let recording = state.recorder.lock().unwrap().is_recording();
124 if recording {
125 stop(app);
126 } else {
127 start(app);
128 }
129}
130
131/// Begins recording. No-op when already recording.
132pub fn start(app: &AppHandle) {
133 let state = app.state::<AppState>();
134 let mut recorder = state.recorder.lock().unwrap();
135 if recorder.is_recording() {
136 return;
137 }
138
139 let cfg = state.settings.lock().unwrap().clone();
140 let model_ready = models::model_path(app, &cfg.dictation_model)
141 .map(|p| p.exists())
142 .unwrap_or(false);
143 if !model_ready {
144 drop(recorder);
145 notify(
146 app,
147 "No dictation model installed. Open mutterbox and download one under Models.",
148 );
149 show_main(app);
150 return;
151 }
152 match recorder.start() {
153 Ok(()) => {
154 drop(recorder);
155 set_state(app, "recording");
156 show_overlay(app);
157 }
158 Err(e) => {
159 drop(recorder);
160 notify(app, &format!("could not start recording: {e}"));
161 }
162 }
163}
164
165/// Stops recording and kicks off transcription. No-op when not recording.
166pub fn stop(app: &AppHandle) {
167 let state = app.state::<AppState>();
168 let mut recorder = state.recorder.lock().unwrap();
169 if !recorder.is_recording() {
170 return;
171 }
172 let samples = recorder.stop();
173 drop(recorder);
174 match samples {
175 Ok(samples) if samples.len() >= MIN_SAMPLES => {
176 set_state(app, "processing");
177 process(app.clone(), samples);
178 }
179 Ok(_) => {
180 set_state(app, "idle");
181 hide_overlay(app);
182 }
183 Err(e) => {
184 set_state(app, "idle");
185 hide_overlay(app);
186 notify(app, &e);
187 }
188 }
189}
190
191fn process(app: AppHandle, samples: Vec<f32>) {
192 tauri::async_runtime::spawn(async move {
193 if let Err(e) = process_inner(&app, samples).await {
194 set_state(&app, "idle");
195 hide_overlay(&app);
196 notify(&app, &e);
197 }
198 });
199}
200
201async fn process_inner(app: &AppHandle, samples: Vec<f32>) -> Result<(), String> {
202 let cfg = app.state::<AppState>().settings.lock().unwrap().clone();
203 let model_path = models::model_path(app, &cfg.dictation_model)?;
204
205 let task_app = app.clone();
206 let language = cfg.language.clone();
207 let raw = tauri::async_runtime::spawn_blocking(move || {
208 let state = task_app.state::<AppState>();
209 state.whisper.transcribe(&model_path, &samples, &language)
210 })
211 .await
212 .map_err(|e| format!("transcription task: {e}"))??;
213
214 if raw.is_empty() {
215 set_state(app, "idle");
216 hide_overlay(app);
217 notify(app, "Didn't catch anything.");
218 return Ok(());
219 }
220
221 let (text, enhanced) = if cfg.enhance.enabled {
222 match cfg.enhance.active() {
223 Some(profile) => match llm::enhance(profile, &raw).await {
224 Ok(cleaned) => (cleaned, true),
225 Err(e) => {
226 notify(app, &format!("Enhancement failed, using raw transcript. {e}"));
227 (raw.clone(), false)
228 }
229 },
230 None => {
231 notify(app, "Enhancement is on but no profile exists. Using raw transcript.");
232 (raw.clone(), false)
233 }
234 }
235 } else {
236 (raw.clone(), false)
237 };
238
239 let task_app = app.clone();
240 let inject_text = text.clone();
241 let mode = cfg.injection_mode.clone();
242 let restore = cfg.restore_clipboard;
243 tauri::async_runtime::spawn_blocking(move || {
244 inject::inject(&task_app, &inject_text, &mode, restore)
245 })
246 .await
247 .map_err(|e| format!("injection task: {e}"))??;
248
249 set_state(app, "idle");
250 hide_overlay(app);
251 let _ = app.emit("transcript", TranscriptPayload { raw, text, enhanced });
252 Ok(())
253}