microclaw_engine/
voice.rs1use std::path::PathBuf;
15
16use crate::config::Config;
17
18pub 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
29pub 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
76pub fn format_voice_inbound(sender_name: &str, transcription: &str) -> String {
79 format!("[voice message from {sender_name}]: {transcription}")
80}
81
82pub fn format_voice_inbound_error(sender_name: &str, error: &str) -> String {
84 format!("[voice message from {sender_name}]: [transcription failed: {error}]")
85}
86
87pub fn round_trip_enabled(config: &Config) -> bool {
91 config.voice_round_trip && config.media.tts.enabled
92}
93
94pub 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 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 assert!(!round_trip_enabled(&cfg));
174
175 cfg.voice_round_trip = true;
176 assert!(!round_trip_enabled(&cfg));
179
180 cfg.media.tts.enabled = true;
181 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}