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
21use 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 type of the posted audio (e.g. "audio/webm"). Defaults to webm.
37    mime: Option<String>,
38    /// Optional BCP-47 language hint (e.g. "en"); improves accuracy + latency.
39    lang: Option<String>,
40}
41
42/// `POST /api/v1/voice/transcribe?mime=audio/webm&lang=en` — body is raw audio.
43/// → 200 `{ "text": "..." }` · 400 empty body · 501 no backend configured ·
44///   502 upstream/subprocess error.
45pub(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            // Mirrors the frontend's `service-not-allowed` UX (#1603): voice
77            // isn't usable in this build/config yet.
78            (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
87/// Transcription failure: distinguishes "not configured" (→ 501, surfaced as the
88/// frontend's "unavailable" guidance) from a real upstream/runtime error (→ 502).
89enum SttError {
90    NotConfigured(String),
91    Upstream(String),
92}
93use SttError::{NotConfigured, Upstream};
94
95// ── Config resolution (server-side only) ────────────────────────────────────
96
97/// Read settings.json once; `None` if absent/unparseable.
98fn 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
113/// `voice:engine` — "groq" (default) or "whisper-local". Env override:
114/// `AGENTMUX_VOICE_ENGINE`.
115fn 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
124/// Groq key: `AGENTMUX_GROQ_API_KEY` env, else settings.json `voice:groqApiKey`.
125fn 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
135/// Env-first, then settings.json, for a path-valued config key.
136fn 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
144// ── Groq backend (hosted) ───────────────────────────────────────────────────
145
146/// File extension matching the posted MIME, so Groq's content sniffing accepts
147/// the multipart `file` part.
148fn 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
167/// POST the audio to Groq's OpenAI-compatible transcription endpoint.
168async 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
215// ── Local whisper.cpp backend (offline, opt-in) ─────────────────────────────
216
217/// Transcribe via a local `whisper-cli` (whisper.cpp) subprocess. The renderer
218/// sends 16 kHz mono WAV for this engine, so we write the body to a temp `.wav`
219/// and run the CLI. Fully offline; nothing leaves the machine.
220///
221/// Config (env-first):
222///   * binary — `AGENTMUX_WHISPER_CLI` / settings `voice:whisperCliPath`
223///              (user-provided; auto-bundling is a follow-up — per-platform
224///              binary fetch is fragile)
225///   * model  — auto-downloaded on first use (default `base.en`, configurable
226///              via `voice:whisperModel`); override with an explicit
227///              `voice:whisperModelPath` / `AGENTMUX_WHISPER_MODEL`.
228///
229/// Missing binary → NotConfigured (501).
230/// Default GGML model name when none is configured. base.en (~142 MB) is a good
231/// accuracy/size balance for English; configurable via `voice:whisperModel`.
232const DEFAULT_WHISPER_MODEL: &str = "base.en";
233/// Cap the one-time model download so a stuck transfer can't wedge a request.
234const MODEL_DOWNLOAD_TIMEOUT_SECS: u64 = 600;
235
236/// Resolve the GGML model path, downloading an auto-managed model on first use.
237///
238///   * Explicit `voice:whisperModelPath` / `AGENTMUX_WHISPER_MODEL` → used as-is
239///     (no download; error if missing).
240///   * Otherwise → `<config>/whisper-models/ggml-<name>.bin`, where `name` comes
241///     from `voice:whisperModel` (default `base.en`), fetched once from the
242///     whisper.cpp model repo. A global lock serializes concurrent first-uses.
243async 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    // Sanitize: model names are simple tokens — reject anything that could be a
258    // path or URL component (defends the format!-built path and URL below).
259    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    // Single global lock: serializes ALL first-use model downloads (not
270    // per-model), so one model's download blocks any other model's first-use
271    // for up to the 600s cap. Acceptable — first-use downloads are rare and
272    // models are typically not switched mid-session.
273    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); // another request finished the download while we waited
277    }
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    // Write to a temp file then rename so a partial download never looks valid.
307    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    // Resolve the model: an explicit path wins, otherwise an auto-managed model
332    // is downloaded once to the config dir (zero-config for the model file).
333    let model = ensure_local_model(settings).await?;
334
335    // Write the WAV body to a unique temp file. Timestamp alone can collide
336    // under concurrent requests (coarse Windows SystemTime granularity), so add
337    // a process-global atomic counter to guarantee uniqueness.
338    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    // whisper-cli: -nt no timestamps, -np no progress prints → transcript on
348    // stdout; logs go to stderr.
349    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    // Bound the run: a wedged CLI (corrupt model, pathological input) must not
355    // block the HTTP handler forever. kill_on_drop ensures the timed-out child
356    // is reaped when the timeout future drops it.
357    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); // best-effort cleanup
366
367    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"); // fallback
398    }
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}