agentmux_srv/server/
voice.rs1#[cfg(windows)]
22use agentmux_common::win32::NoWindow;
23use axum::{
24 body::Bytes,
25 extract::{Query, State},
26 http::StatusCode,
27 response::{IntoResponse, Json},
28};
29use serde_json::json;
30
31use super::AppState;
32
33const GROQ_TRANSCRIBE_URL: &str = "https://api.groq.com/openai/v1/audio/transcriptions";
34const GROQ_MODEL: &str = "whisper-large-v3-turbo";
35
36#[derive(serde::Deserialize)]
37pub(super) struct TranscribeQuery {
38 mime: Option<String>,
40 lang: Option<String>,
42}
43
44pub(super) async fn handle_voice_transcribe(
48 State(_state): State<AppState>,
49 Query(q): Query<TranscribeQuery>,
50 body: Bytes,
51) -> impl IntoResponse {
52 if body.is_empty() {
53 return (StatusCode::BAD_REQUEST, Json(json!({ "error": "empty audio body" })))
54 .into_response();
55 }
56
57 let mime = q.mime.unwrap_or_else(|| "audio/webm".to_string());
58 let lang = q.lang.filter(|l| !l.trim().is_empty());
59 let settings = read_settings_json();
60 let engine = resolve_engine(&settings);
61
62 let result = if engine == "whisper-local" {
63 transcribe_local_whisper(body, lang.as_deref(), &settings).await
64 } else {
65 match resolve_groq_key(&settings) {
66 Some(key) => transcribe_groq(&key, body, &mime, lang.as_deref()).await,
67 None => Err(NotConfigured(
68 "Groq backend not configured — set voice:groqApiKey in settings.json \
69 or the AGENTMUX_GROQ_API_KEY env var"
70 .to_string(),
71 )),
72 }
73 };
74
75 match result {
76 Ok(text) => Json(json!({ "text": text })).into_response(),
77 Err(NotConfigured(msg)) => {
78 (StatusCode::NOT_IMPLEMENTED, Json(json!({ "error": msg }))).into_response()
81 }
82 Err(Upstream(msg)) => {
83 tracing::warn!(target: "voice", "transcription failed: {msg}");
84 (StatusCode::BAD_GATEWAY, Json(json!({ "error": msg }))).into_response()
85 }
86 }
87}
88
89enum SttError {
92 NotConfigured(String),
93 Upstream(String),
94}
95use SttError::{NotConfigured, Upstream};
96
97fn read_settings_json() -> Option<serde_json::Value> {
101 let path = crate::backend::base::get_mux_config_dir().join("settings.json");
102 let content = std::fs::read_to_string(path).ok()?;
103 serde_json::from_str(&content).ok()
104}
105
106fn settings_str(settings: &Option<serde_json::Value>, key: &str) -> Option<String> {
107 settings
108 .as_ref()?
109 .get(key)
110 .and_then(|x| x.as_str())
111 .map(|s| s.trim().to_string())
112 .filter(|s| !s.is_empty())
113}
114
115fn resolve_engine(settings: &Option<serde_json::Value>) -> String {
118 std::env::var("AGENTMUX_VOICE_ENGINE")
119 .ok()
120 .map(|s| s.trim().to_string())
121 .filter(|s| !s.is_empty())
122 .or_else(|| settings_str(settings, "voice:engine"))
123 .unwrap_or_else(|| "groq".to_string())
124}
125
126fn resolve_groq_key(settings: &Option<serde_json::Value>) -> Option<String> {
128 if let Ok(k) = std::env::var("AGENTMUX_GROQ_API_KEY") {
129 let k = k.trim().to_string();
130 if !k.is_empty() {
131 return Some(k);
132 }
133 }
134 settings_str(settings, "voice:groqApiKey")
135}
136
137fn resolve_path(settings: &Option<serde_json::Value>, env: &str, key: &str) -> Option<String> {
139 std::env::var(env)
140 .ok()
141 .map(|s| s.trim().to_string())
142 .filter(|s| !s.is_empty())
143 .or_else(|| settings_str(settings, key))
144}
145
146fn mime_ext(mime: &str) -> &'static str {
151 let m = mime.to_ascii_lowercase();
152 if m.contains("webm") {
153 "webm"
154 } else if m.contains("ogg") {
155 "ogg"
156 } else if m.contains("wav") {
157 "wav"
158 } else if m.contains("mp4") || m.contains("m4a") {
159 "m4a"
160 } else if m.contains("mpeg") || m.contains("mp3") {
161 "mp3"
162 } else if m.contains("flac") {
163 "flac"
164 } else {
165 "webm"
166 }
167}
168
169async fn transcribe_groq(
171 key: &str,
172 audio: Bytes,
173 mime: &str,
174 lang: Option<&str>,
175) -> Result<String, SttError> {
176 let part = reqwest::multipart::Part::bytes(audio.to_vec())
177 .file_name(format!("audio.{}", mime_ext(mime)))
178 .mime_str(mime)
179 .map_err(|e| Upstream(format!("invalid mime: {e}")))?;
180
181 let mut form = reqwest::multipart::Form::new()
182 .text("model", GROQ_MODEL)
183 .text("response_format", "json")
184 .part("file", part);
185 if let Some(l) = lang {
186 form = form.text("language", l.to_string());
187 }
188
189 let resp = reqwest::Client::new()
190 .post(GROQ_TRANSCRIBE_URL)
191 .bearer_auth(key)
192 .multipart(form)
193 .send()
194 .await
195 .map_err(|e| Upstream(format!("request failed: {e}")))?;
196
197 let status = resp.status();
198 let text_body = resp
199 .text()
200 .await
201 .map_err(|e| Upstream(format!("reading response: {e}")))?;
202
203 if !status.is_success() {
204 let snippet: String = text_body.chars().take(300).collect();
205 return Err(Upstream(format!("groq {}: {}", status.as_u16(), snippet)));
206 }
207
208 let v: serde_json::Value =
209 serde_json::from_str(&text_body).map_err(|e| Upstream(format!("parsing response: {e}")))?;
210 Ok(v.get("text")
211 .and_then(|t| t.as_str())
212 .unwrap_or_default()
213 .trim()
214 .to_string())
215}
216
217const DEFAULT_WHISPER_MODEL: &str = "base.en";
235const MODEL_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
237
238async fn ensure_local_model(
246 settings: &Option<serde_json::Value>,
247) -> Result<std::path::PathBuf, SttError> {
248 if let Some(p) = resolve_path(settings, "AGENTMUX_WHISPER_MODEL", "voice:whisperModelPath") {
249 let pb = std::path::PathBuf::from(&p);
250 return if pb.exists() {
251 Ok(pb)
252 } else {
253 Err(NotConfigured(format!("whisper model not found at {p}")))
254 };
255 }
256
257 let name = settings_str(settings, "voice:whisperModel")
258 .unwrap_or_else(|| DEFAULT_WHISPER_MODEL.to_string());
259 if !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
262 return Err(NotConfigured(format!("invalid voice:whisperModel name: {name}")));
263 }
264
265 let dir = crate::backend::base::get_mux_config_dir().join("whisper-models");
266 let path = dir.join(format!("ggml-{name}.bin"));
267 if path.exists() {
268 return Ok(path);
269 }
270
271 static DL_LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
276 let _guard = DL_LOCK.get_or_init(|| tokio::sync::Mutex::new(())).lock().await;
277 if path.exists() {
278 return Ok(path); }
280
281 std::fs::create_dir_all(&dir).map_err(|e| Upstream(format!("create models dir: {e}")))?;
282 let url =
283 format!("https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-{name}.bin");
284 tracing::info!(target: "voice", "downloading whisper model '{name}' from {url}");
285
286 let bytes = tokio::time::timeout(
287 std::time::Duration::from_secs(MODEL_DOWNLOAD_TIMEOUT_SECS),
288 async {
289 let resp = reqwest::Client::new()
290 .get(&url)
291 .send()
292 .await
293 .map_err(|e| Upstream(format!("model download request: {e}")))?;
294 if !resp.status().is_success() {
295 return Err(Upstream(format!(
296 "model download {} for '{name}'",
297 resp.status().as_u16()
298 )));
299 }
300 resp.bytes()
301 .await
302 .map_err(|e| Upstream(format!("model download body: {e}")))
303 },
304 )
305 .await
306 .map_err(|_| Upstream(format!("model download timed out after {MODEL_DOWNLOAD_TIMEOUT_SECS}s")))??;
307
308 let tmp = dir.join(format!("ggml-{name}.bin.part"));
310 std::fs::write(&tmp, &bytes).map_err(|e| Upstream(format!("model write: {e}")))?;
311 std::fs::rename(&tmp, &path).map_err(|e| Upstream(format!("model finalize: {e}")))?;
312 tracing::info!(target: "voice", "whisper model '{name}' ready ({} bytes)", bytes.len());
313 Ok(path)
314}
315
316async fn transcribe_local_whisper(
317 audio: Bytes,
318 lang: Option<&str>,
319 settings: &Option<serde_json::Value>,
320) -> Result<String, SttError> {
321 let cli = resolve_path(settings, "AGENTMUX_WHISPER_CLI", "voice:whisperCliPath").ok_or_else(
322 || {
323 NotConfigured(
324 "local whisper not configured — set voice:whisperCliPath (path to whisper-cli) \
325 in settings.json or AGENTMUX_WHISPER_CLI"
326 .to_string(),
327 )
328 },
329 )?;
330 if !std::path::Path::new(&cli).exists() {
331 return Err(NotConfigured(format!("whisper-cli not found at {cli}")));
332 }
333 let model = ensure_local_model(settings).await?;
336
337 static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
341 let ts = std::time::SystemTime::now()
342 .duration_since(std::time::UNIX_EPOCH)
343 .map(|d| d.as_nanos())
344 .unwrap_or(0);
345 let seq = TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
346 let wav_path = std::env::temp_dir().join(format!("agentmux-voice-{ts}-{seq}.wav"));
347 std::fs::write(&wav_path, &audio).map_err(|e| Upstream(format!("temp write: {e}")))?;
348
349 let mut cmd = tokio::process::Command::new(&cli);
352 crate::backend::pane_env::sanitize_external_command(&mut cmd);
354 cmd.arg("-m").arg(&model).arg("-f").arg(&wav_path).arg("-nt").arg("-np");
355 if let Some(l) = lang {
356 cmd.arg("-l").arg(l);
357 }
358 cmd.kill_on_drop(true);
362 #[cfg(windows)]
364 {
365 cmd.no_window();
366 }
367 const WHISPER_TIMEOUT_SECS: u64 = 120;
368
369 let output = tokio::time::timeout(
370 std::time::Duration::from_secs(WHISPER_TIMEOUT_SECS),
371 cmd.output(),
372 )
373 .await;
374 let _ = std::fs::remove_file(&wav_path); let output = match output {
377 Err(_) => {
378 return Err(Upstream(format!(
379 "whisper-cli timed out after {WHISPER_TIMEOUT_SECS}s"
380 )))
381 }
382 Ok(r) => r.map_err(|e| Upstream(format!("spawn whisper-cli: {e}")))?,
383 };
384 if !output.status.success() {
385 let err: String = String::from_utf8_lossy(&output.stderr).chars().take(300).collect();
386 return Err(Upstream(format!(
387 "whisper-cli exited {}: {}",
388 output.status.code().unwrap_or(-1),
389 err
390 )));
391 }
392 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn mime_ext_maps_common_types() {
401 assert_eq!(mime_ext("audio/webm;codecs=opus"), "webm");
402 assert_eq!(mime_ext("audio/ogg"), "ogg");
403 assert_eq!(mime_ext("audio/wav"), "wav");
404 assert_eq!(mime_ext("audio/mp4"), "m4a");
405 assert_eq!(mime_ext("audio/mpeg"), "mp3");
406 assert_eq!(mime_ext("application/octet-stream"), "webm"); }
408
409 #[test]
410 fn engine_defaults_to_groq() {
411 assert_eq!(resolve_engine(&None), "groq");
412 let s = serde_json::json!({ "voice:engine": "whisper-local" });
413 assert_eq!(resolve_engine(&Some(s)), "whisper-local");
414 }
415}