Skip to main content

agentmux_srv/server/
voice.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4//! Voice speech-to-text endpoint — `POST /api/v1/voice/transcribe`.
5//!
6//! The renderer captures mic audio (getUserMedia, gated by the CEF permission
7//! handler #1602) and POSTs each silence-bounded utterance here; we forward it
8//! to the configured Whisper backend and return the transcript. Keys/paths stay
9//! server-side — the renderer never sees them.
10//!
11//! Web Speech API can't transcribe in CEF (closed-source Google service), so
12//! this capture-and-send path is the real engine. See
13//! docs/specs/SPEC_VOICE_STT_ENGINE_2026_06_20.md and #1591.
14//!
15//! Backends (selected by the `voice:engine` setting):
16//!   * "groq" (default) — hosted whisper-large-v3-turbo; renderer sends webm.
17//!   * "whisper-local"  — offline whisper.cpp via a local `whisper-cli`
18//!                        subprocess; renderer sends 16 kHz mono WAV. Opt-in,
19//!                        configured via settings/env; needs a model file.
20
21#[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 type of the posted audio (e.g. "audio/webm"). Defaults to webm.
39    mime: Option<String>,
40    /// Optional BCP-47 language hint (e.g. "en"); improves accuracy + latency.
41    lang: Option<String>,
42}
43
44/// `POST /api/v1/voice/transcribe?mime=audio/webm&lang=en` — body is raw audio.
45/// → 200 `{ "text": "..." }` · 400 empty body · 501 no backend configured ·
46///   502 upstream/subprocess error.
47pub(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            // Mirrors the frontend's `service-not-allowed` UX (#1603): voice
79            // isn't usable in this build/config yet.
80            (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
89/// Transcription failure: distinguishes "not configured" (→ 501, surfaced as the
90/// frontend's "unavailable" guidance) from a real upstream/runtime error (→ 502).
91enum SttError {
92    NotConfigured(String),
93    Upstream(String),
94}
95use SttError::{NotConfigured, Upstream};
96
97// ── Config resolution (server-side only) ────────────────────────────────────
98
99/// Read settings.json once; `None` if absent/unparseable.
100fn 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
115/// `voice:engine` — "groq" (default) or "whisper-local". Env override:
116/// `AGENTMUX_VOICE_ENGINE`.
117fn 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
126/// Groq key: `AGENTMUX_GROQ_API_KEY` env, else settings.json `voice:groqApiKey`.
127fn 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
137/// Env-first, then settings.json, for a path-valued config key.
138fn 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
146// ── Groq backend (hosted) ───────────────────────────────────────────────────
147
148/// File extension matching the posted MIME, so Groq's content sniffing accepts
149/// the multipart `file` part.
150fn 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
169/// POST the audio to Groq's OpenAI-compatible transcription endpoint.
170async 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
217// ── Local whisper.cpp backend (offline, opt-in) ─────────────────────────────
218
219/// Transcribe via a local `whisper-cli` (whisper.cpp) subprocess. The renderer
220/// sends 16 kHz mono WAV for this engine, so we write the body to a temp `.wav`
221/// and run the CLI. Fully offline; nothing leaves the machine.
222///
223/// Config (env-first):
224///   * binary — `AGENTMUX_WHISPER_CLI` / settings `voice:whisperCliPath`
225///              (user-provided; auto-bundling is a follow-up — per-platform
226///              binary fetch is fragile)
227///   * model  — auto-downloaded on first use (default `base.en`, configurable
228///              via `voice:whisperModel`); override with an explicit
229///              `voice:whisperModelPath` / `AGENTMUX_WHISPER_MODEL`.
230///
231/// Missing binary → NotConfigured (501).
232/// Default GGML model name when none is configured. base.en (~142 MB) is a good
233/// accuracy/size balance for English; configurable via `voice:whisperModel`.
234const DEFAULT_WHISPER_MODEL: &str = "base.en";
235/// Cap the one-time model download so a stuck transfer can't wedge a request.
236const MODEL_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
237
238/// Resolve the GGML model path, downloading an auto-managed model on first use.
239///
240///   * Explicit `voice:whisperModelPath` / `AGENTMUX_WHISPER_MODEL` → used as-is
241///     (no download; error if missing).
242///   * Otherwise → `<config>/whisper-models/ggml-<name>.bin`, where `name` comes
243///     from `voice:whisperModel` (default `base.en`), fetched once from the
244///     whisper.cpp model repo. A global lock serializes concurrent first-uses.
245async 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    // Sanitize: model names are simple tokens — reject anything that could be a
260    // path or URL component (defends the format!-built path and URL below).
261    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    // Single global lock: serializes ALL first-use model downloads (not
272    // per-model), so one model's download blocks any other model's first-use
273    // for up to the 600s cap. Acceptable — first-use downloads are rare and
274    // models are typically not switched mid-session.
275    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); // another request finished the download while we waited
279    }
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    // Write to a temp file then rename so a partial download never looks valid.
309    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    // Resolve the model: an explicit path wins, otherwise an auto-managed model
334    // is downloaded once to the config dir (zero-config for the model file).
335    let model = ensure_local_model(settings).await?;
336
337    // Write the WAV body to a unique temp file. Timestamp alone can collide
338    // under concurrent requests (coarse Windows SystemTime granularity), so add
339    // a process-global atomic counter to guarantee uniqueness.
340    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    // whisper-cli: -nt no timestamps, -np no progress prints → transcript on
350    // stdout; logs go to stderr.
351    let mut cmd = tokio::process::Command::new(&cli);
352    // Third-party transcription CLI (whisper.cpp).
353    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    // Bound the run: a wedged CLI (corrupt model, pathological input) must not
359    // block the HTTP handler forever. kill_on_drop ensures the timed-out child
360    // is reaped when the timeout future drops it.
361    cmd.kill_on_drop(true);
362    // CREATE_NO_WINDOW: console-flash suppression, see crates/common/src/cli.rs
363    #[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); // best-effort cleanup
375
376    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"); // fallback
407    }
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}