Skip to main content

microclaw_engine/
voice.rs

1//! Shared voice/audio inbound transcription used by channel adapters.
2//!
3//! Each platform exposes audio bytes differently (Telegram voice, Discord
4//! attachment, Slack file, Feishu audio message), but once we have raw bytes
5//! the dispatch into the configured STT provider — OpenAI Whisper or a local
6//! command — is the same. This module owns that dispatch and the format used
7//! when injecting transcription into the inbound text the agent sees.
8//!
9//! Also exposes the inverse path: when a turn was triggered by an inbound
10//! voice message and `voice_round_trip` is enabled, channels call
11//! [`synth_speech_to_temp`] to render the bot's reply as audio so the user
12//! can listen to it on the same surface they spoke into.
13
14use std::path::PathBuf;
15
16use crate::config::Config;
17
18/// Returns true if a transcription provider is configured. Channels can use
19/// this to decide between transcribing or surfacing a "voice not supported"
20/// notice to the sender.
21pub fn can_transcribe(config: &Config) -> bool {
22    if config.voice_provider == "local" {
23        config.voice_transcription_command.is_some()
24    } else {
25        config.openai_api_key.is_some()
26    }
27}
28
29/// Run audio bytes through the configured STT provider.
30pub async fn transcribe_audio(config: &Config, audio_bytes: &[u8]) -> Result<String, String> {
31    let provider = &config.voice_provider;
32
33    if provider == "local" {
34        let Some(ref command) = config.voice_transcription_command else {
35            return Err(
36                "Local voice transcription configured but voice_transcription_command not set"
37                    .into(),
38            );
39        };
40
41        let temp_dir = std::env::temp_dir();
42        let temp_file = temp_dir.join(format!("voice_{}.ogg", uuid::Uuid::new_v4()));
43        tokio::fs::write(&temp_file, audio_bytes)
44            .await
45            .map_err(|e| e.to_string())?;
46
47        let cmd = command.replace("{file}", temp_file.to_str().unwrap_or(""));
48
49        let output_result = tokio::process::Command::new("sh")
50            .arg("-c")
51            .arg(&cmd)
52            .output()
53            .await;
54
55        let _ = tokio::fs::remove_file(&temp_file).await;
56
57        let output =
58            output_result.map_err(|e| format!("Failed to run transcription command: {e}"))?;
59
60        if output.status.success() {
61            Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
62        } else {
63            Err(format!(
64                "Transcription command failed: {}",
65                String::from_utf8_lossy(&output.stderr)
66            ))
67        }
68    } else {
69        let Some(ref openai_key) = config.openai_api_key else {
70            return Err("Voice transcription requires openai_api_key".into());
71        };
72        crate::internal::app::transcribe::transcribe_audio(openai_key, audio_bytes).await
73    }
74}
75
76/// Standard inbound formatting so the agent always sees voice messages with
77/// the same shape regardless of platform.
78pub fn format_voice_inbound(sender_name: &str, transcription: &str) -> String {
79    format!("[voice message from {sender_name}]: {transcription}")
80}
81
82/// Standard error shape when transcription was attempted but failed.
83pub fn format_voice_inbound_error(sender_name: &str, error: &str) -> String {
84    format!("[voice message from {sender_name}]: [transcription failed: {error}]")
85}
86
87/// True when this deployment should reply with audio to voice-inbound turns.
88/// Requires both the operator opt-in (`voice_round_trip: true`) and the TTS
89/// layer to be enabled in `media.tts`.
90pub fn round_trip_enabled(config: &Config) -> bool {
91    config.voice_round_trip && config.media.tts.enabled
92}
93
94/// Synthesize `text` to a temporary audio file using the configured TTS
95/// provider, returning the on-disk path. Caller is responsible for sending
96/// it to the user and removing the file when done.
97///
98/// Bypasses the `text_to_speech` tool surface so channel adapters don't
99/// have to fabricate a tool-input shape just to play back a reply.
100pub async fn synth_speech_to_temp(config: &Config, text: &str) -> Result<PathBuf, String> {
101    if !round_trip_enabled(config) {
102        return Err("voice_round_trip is disabled".into());
103    }
104    let trimmed = text.trim();
105    if trimmed.is_empty() {
106        return Err("empty text".into());
107    }
108    // The OpenAI /audio/speech endpoint caps input at 4096 chars. Truncate
109    // rather than fail — partial audio is more useful than none.
110    let payload_text: String = trimmed.chars().take(4096).collect();
111
112    let media = &config.media;
113    let tts = &media.tts;
114    let api_key = media
115        .api_key
116        .as_deref()
117        .or(config.openai_api_key.as_deref())
118        .ok_or_else(|| "media.api_key (or openai_api_key) not set".to_string())?;
119    let base_url = media
120        .base_url
121        .clone()
122        .or_else(|| config.openai_base_url.clone())
123        .unwrap_or_else(|| "https://api.openai.com/v1".to_string());
124
125    let client = reqwest::Client::builder()
126        .timeout(std::time::Duration::from_secs(60))
127        .build()
128        .map_err(|e| format!("http client build failed: {e}"))?;
129    let url = format!("{}/audio/speech", base_url.trim_end_matches('/'));
130    let body = serde_json::json!({
131        "model": tts.model,
132        "voice": tts.default_voice,
133        "input": payload_text,
134        "response_format": tts.default_format,
135    });
136    let resp = client
137        .post(&url)
138        .bearer_auth(api_key)
139        .json(&body)
140        .send()
141        .await
142        .map_err(|e| format!("audio/speech request failed: {e}"))?;
143    if !resp.status().is_success() {
144        let status = resp.status();
145        let body = resp.text().await.unwrap_or_default();
146        return Err(format!("audio/speech HTTP {status}: {body}"));
147    }
148    let bytes = resp
149        .bytes()
150        .await
151        .map_err(|e| format!("audio body read failed: {e}"))?;
152
153    let temp_dir = std::env::temp_dir();
154    let path = temp_dir.join(format!(
155        "microclaw_reply_{}.{}",
156        uuid::Uuid::new_v4(),
157        tts.default_format
158    ));
159    tokio::fs::write(&path, &bytes)
160        .await
161        .map_err(|e| format!("failed to write temp audio: {e}"))?;
162    Ok(path)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn round_trip_requires_both_flags() {
171        let mut cfg = Config::test_defaults();
172        // Default state: TTS disabled and round_trip false → gate is closed.
173        assert!(!round_trip_enabled(&cfg));
174
175        cfg.voice_round_trip = true;
176        // Round-trip on but TTS still disabled → gate stays closed; we never
177        // want to surprise an operator who hasn't opted into TTS billing.
178        assert!(!round_trip_enabled(&cfg));
179
180        cfg.media.tts.enabled = true;
181        // Both flags on → gate opens.
182        assert!(round_trip_enabled(&cfg));
183    }
184
185    #[test]
186    fn synth_returns_err_when_round_trip_disabled() {
187        let cfg = Config::test_defaults();
188        let rt = tokio::runtime::Builder::new_current_thread()
189            .enable_all()
190            .build()
191            .unwrap();
192        let err = rt
193            .block_on(synth_speech_to_temp(&cfg, "hi"))
194            .expect_err("should refuse when disabled");
195        assert!(err.contains("disabled"));
196    }
197
198    #[test]
199    fn format_voice_inbound_uses_brackets_so_agent_can_distinguish() {
200        let s = format_voice_inbound("alice", "ship the patch");
201        assert_eq!(s, "[voice message from alice]: ship the patch");
202    }
203}