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