Skip to main content

microclaw_engine/tools/
generate_podcast.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::internal::channels::channel::deliver_and_store_bot_message;
7use crate::internal::channels::channel_adapter::ChannelRegistry;
8use crate::internal::storage::db::Database;
9use crate::internal::tool_runtime::media_client::{persist_output, MediaClient};
10use crate::internal::tool_runtime::runtime::auth_context_from_input;
11use microclaw_core::llm_types::ToolDefinition;
12
13use super::{schema_object, Tool, ToolResult};
14use crate::config::{Config, MediaConfig, PodcastConfig, TtsConfig};
15
16/// Voices accepted per segment — same set the standalone TTS tool exposes.
17const ALLOWED_VOICES: &[&str] = &[
18    "alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse",
19];
20
21const MAX_SEGMENTS: usize = 40;
22
23/// Trim and reject blank optional strings (e.g. an explicitly-empty config key).
24fn nonempty(s: Option<&str>) -> Option<String> {
25    s.map(str::trim)
26        .filter(|s| !s.is_empty())
27        .map(str::to_string)
28}
29
30/// Native "generate a podcast" tool.
31///
32/// Takes an ordered list of script segments (each with its own voice),
33/// synthesizes every segment via an OpenAI-compatible `/audio/speech`
34/// endpoint (reusing `media.tts` model/credentials), then stitches them into
35/// a single mp3 — with short silences between segments — by shelling out to
36/// `ffmpeg`. The finished episode is saved under the bot's data directory and,
37/// when the active channel supports attachments, delivered inline.
38///
39/// Disabled by default; requires operator opt-in via `media.podcast.enabled`.
40pub struct GeneratePodcastTool {
41    data_dir: PathBuf,
42    channels: Arc<ChannelRegistry>,
43    db: Arc<Database>,
44    cfg: PodcastConfig,
45    tts: TtsConfig,
46    media: MediaConfig,
47    openai_api_key: Option<String>,
48    openai_base_url: Option<String>,
49    timeout_secs: u64,
50}
51
52impl GeneratePodcastTool {
53    pub fn new(config: &Config, channels: Arc<ChannelRegistry>, db: Arc<Database>) -> Self {
54        Self {
55            data_dir: PathBuf::from(&config.data_dir),
56            channels,
57            db,
58            cfg: config.media.podcast.clone(),
59            tts: config.media.tts.clone(),
60            media: config.media.clone(),
61            openai_api_key: config.openai_api_key.clone(),
62            openai_base_url: config.openai_base_url.clone(),
63            timeout_secs: config.tool_timeout_secs("generate_podcast", 300),
64        }
65    }
66
67    fn client(&self) -> Result<MediaClient, String> {
68        // Resolution order: podcast-specific override, then the shared TTS
69        // override, then the shared media credentials / env / top-level.
70        let key = nonempty(self.cfg.api_key.as_deref())
71            .or_else(|| nonempty(self.tts.api_key.as_deref()))
72            .or_else(|| self.media.resolve_api_key(self.openai_api_key.as_deref()))
73            .ok_or_else(|| {
74                "generate_podcast requires an API key (media.podcast.api_key, \
75                 media.tts.api_key, media.api_key, MICROCLAW_OPENAI_API_KEY, \
76                 OPENAI_API_KEY, or top-level openai_api_key)."
77                    .to_string()
78            })?;
79        let base = nonempty(self.cfg.base_url.as_deref())
80            .or_else(|| nonempty(self.tts.base_url.as_deref()))
81            .map(|s| s.trim_end_matches('/').to_string())
82            .unwrap_or_else(|| self.media.resolve_base_url(self.openai_base_url.as_deref()));
83        MediaClient::new(base, key, self.timeout_secs)
84    }
85}
86
87/// One parsed script segment.
88struct Segment {
89    voice: String,
90    text: String,
91    pause_ms: u32,
92}
93
94#[async_trait]
95impl Tool for GeneratePodcastTool {
96    fn name(&self) -> &str {
97        "generate_podcast"
98    }
99
100    fn definition(&self) -> ToolDefinition {
101        ToolDefinition {
102            name: self.name().into(),
103            description: "Produce a single podcast-episode audio file from a script. \
104                Provide an ordered list of `segments`, each with its own `voice` and \
105                `text`; every segment is synthesized via text-to-speech and the pieces \
106                are concatenated (with short silences) into one mp3 that is saved under \
107                the bot's data directory and — when the active channel supports \
108                attachments — sent back inline. Use alternating voices for a two-host \
109                show. Disabled by default; requires operator opt-in via \
110                `media.podcast.enabled` (also needs `media.tts` credentials and an \
111                `ffmpeg` binary)."
112                .into(),
113            input_schema: schema_object(
114                json!({
115                    "title": {
116                        "type": "string",
117                        "description": "Episode title (used as the attachment caption)."
118                    },
119                    "segments": {
120                        "type": "array",
121                        "description": "Ordered script segments, spoken back-to-back.",
122                        "items": {
123                            "type": "object",
124                            "properties": {
125                                "voice": {
126                                    "type": "string",
127                                    "enum": ALLOWED_VOICES,
128                                    "description": "Voice for this segment. Defaults to media.podcast.default_voice."
129                                },
130                                "text": {
131                                    "type": "string",
132                                    "description": "What this voice says (max ~4096 chars)."
133                                },
134                                "pause_ms": {
135                                    "type": "integer",
136                                    "description": "Silence to add after this segment, in ms. Defaults to media.podcast.segment_pause_ms."
137                                }
138                            },
139                            "required": ["text"]
140                        }
141                    },
142                    "deliver": {
143                        "type": "boolean",
144                        "description": "Whether to attempt channel delivery (default true)."
145                    }
146                }),
147                &["title", "segments"],
148            ),
149        }
150    }
151
152    async fn execute(&self, input: Value) -> ToolResult {
153        if !self.cfg.enabled {
154            return ToolResult::error(
155                "generate_podcast is disabled. Set media.podcast.enabled=true to enable.".into(),
156            );
157        }
158        if !self.tts.enabled {
159            return ToolResult::error(
160                "generate_podcast needs text-to-speech. Set media.tts.enabled=true to enable."
161                    .into(),
162            );
163        }
164
165        let title = match input.get("title").and_then(|v| v.as_str()) {
166            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
167            _ => return ToolResult::error("Missing parameter: title".into()),
168        };
169
170        let segments = match parse_segments(&input, &self.cfg) {
171            Ok(s) => s,
172            Err(e) => return ToolResult::error(e),
173        };
174        let deliver = input
175            .get("deliver")
176            .and_then(|v| v.as_bool())
177            .unwrap_or(true);
178
179        // Fail fast with a clear message if ffmpeg is missing, rather than
180        // synthesizing all the (paid) audio first and then dying at concat.
181        if let Err(e) = ensure_ffmpeg(&self.cfg.ffmpeg_path).await {
182            return ToolResult::error(e);
183        }
184
185        let client = match self.client() {
186            Ok(c) => c,
187            Err(e) => return ToolResult::error(e),
188        };
189
190        // Scratch directory for the per-segment files + concat list.
191        let work = self
192            .data_dir
193            .join("media")
194            .join("tmp")
195            .join(uuid::Uuid::new_v4().to_string());
196        if let Err(e) = std::fs::create_dir_all(&work) {
197            return ToolResult::error(format!("failed to create work dir: {e}"));
198        }
199
200        let result = self.build_episode(&client, &work, &segments).await;
201        // Best-effort cleanup of scratch files regardless of outcome.
202        let _ = std::fs::remove_dir_all(&work);
203
204        let saved = match result {
205            Ok(p) => p,
206            Err(e) => return ToolResult::error(e),
207        };
208
209        let mut summary = format!(
210            "podcast '{}' ({} segments) -> {}",
211            title,
212            segments.len(),
213            saved.display()
214        );
215        if deliver {
216            if let Some(auth) = auth_context_from_input(&input) {
217                match deliver_attachment(
218                    &self.channels,
219                    self.db.clone(),
220                    auth.caller_chat_id,
221                    &saved,
222                    &title,
223                )
224                .await
225                {
226                    Ok(msg) => summary.push_str(&format!("; {msg}")),
227                    Err(e) => summary.push_str(&format!("; delivery skipped: {e}")),
228                }
229            }
230        }
231        ToolResult::success(summary).with_metadata(json!({
232            "path": saved.to_string_lossy(),
233            "segments": segments.len(),
234            "title": title,
235        }))
236    }
237}
238
239impl GeneratePodcastTool {
240    /// Synthesize each segment, render the inter-segment silences, concatenate
241    /// everything with ffmpeg, and persist the finished episode.
242    async fn build_episode(
243        &self,
244        client: &MediaClient,
245        work: &Path,
246        segments: &[Segment],
247    ) -> Result<PathBuf, String> {
248        let model = self.tts.model.clone();
249        // Concat-demuxer list; entries are quoted absolute paths.
250        let mut list = String::new();
251        // Cache silence clips by duration so we only render each length once.
252        let mut silence_paths: std::collections::HashMap<u32, PathBuf> =
253            std::collections::HashMap::new();
254
255        for (i, seg) in segments.iter().enumerate() {
256            let body = json!({
257                "model": model,
258                "voice": seg.voice,
259                "input": seg.text,
260                "response_format": "mp3",
261            });
262            let resp = client
263                .post_json("audio/speech")
264                .json(&body)
265                .send()
266                .await
267                .map_err(|e| format!("audio/speech request failed (segment {i}): {e}"))?;
268            if !resp.status().is_success() {
269                let status = resp.status();
270                let txt = resp.text().await.unwrap_or_default();
271                return Err(format!("audio/speech HTTP {status} (segment {i}): {txt}"));
272            }
273            let bytes = resp
274                .bytes()
275                .await
276                .map_err(|e| format!("audio body read failed (segment {i}): {e}"))?;
277            let seg_path = work.join(format!("seg_{i:03}.mp3"));
278            std::fs::write(&seg_path, &bytes)
279                .map_err(|e| format!("failed to write segment {i}: {e}"))?;
280            list.push_str(&concat_entry(&seg_path));
281
282            if seg.pause_ms > 0 && i + 1 < segments.len() {
283                let silence = match silence_paths.get(&seg.pause_ms) {
284                    Some(p) => p.clone(),
285                    None => {
286                        let p = work.join(format!("silence_{}.mp3", seg.pause_ms));
287                        render_silence(&self.cfg.ffmpeg_path, &p, seg.pause_ms).await?;
288                        silence_paths.insert(seg.pause_ms, p.clone());
289                        p
290                    }
291                };
292                list.push_str(&concat_entry(&silence));
293            }
294        }
295
296        let list_path = work.join("concat.txt");
297        std::fs::write(&list_path, &list)
298            .map_err(|e| format!("failed to write concat list: {e}"))?;
299        let out_path = work.join("episode.mp3");
300
301        // Re-encode (rather than `-c copy`) so segments + silence with slightly
302        // different mp3 framing concatenate cleanly into one continuous file.
303        let status = tokio::process::Command::new(&self.cfg.ffmpeg_path)
304            .args([
305                "-y",
306                "-f",
307                "concat",
308                "-safe",
309                "0",
310                "-i",
311                &list_path.to_string_lossy(),
312                "-c:a",
313                "libmp3lame",
314                "-q:a",
315                "4",
316                &out_path.to_string_lossy(),
317            ])
318            .output()
319            .await
320            .map_err(|e| format!("ffmpeg concat failed to start: {e}"))?;
321        if !status.status.success() {
322            let err = String::from_utf8_lossy(&status.stderr);
323            let tail: String = err
324                .chars()
325                .rev()
326                .take(500)
327                .collect::<String>()
328                .chars()
329                .rev()
330                .collect();
331            return Err(format!("ffmpeg concat failed: {tail}"));
332        }
333
334        let bytes = std::fs::read(&out_path)
335            .map_err(|e| format!("failed to read concatenated episode: {e}"))?;
336        persist_output(&self.data_dir, "audio", "mp3", &bytes)
337            .map_err(|e| format!("failed to save episode: {e}"))
338    }
339}
340
341fn parse_segments(input: &Value, cfg: &PodcastConfig) -> Result<Vec<Segment>, String> {
342    let arr = input
343        .get("segments")
344        .and_then(|v| v.as_array())
345        .ok_or_else(|| "Missing or invalid parameter: segments (expected an array)".to_string())?;
346    if arr.is_empty() {
347        return Err("segments must contain at least one entry".into());
348    }
349    if arr.len() > MAX_SEGMENTS {
350        return Err(format!(
351            "too many segments ({}); max is {MAX_SEGMENTS}",
352            arr.len()
353        ));
354    }
355    let mut out = Vec::with_capacity(arr.len());
356    for (i, seg) in arr.iter().enumerate() {
357        let text = match seg.get("text").and_then(|v| v.as_str()) {
358            Some(t) if !t.trim().is_empty() => t.trim().to_string(),
359            _ => return Err(format!("segment {i}: missing or empty 'text'")),
360        };
361        if text.chars().count() > 4096 {
362            return Err(format!(
363                "segment {i}: text exceeds 4096 characters — split it into more segments"
364            ));
365        }
366        let voice = seg
367            .get("voice")
368            .and_then(|v| v.as_str())
369            .map(|s| s.to_string())
370            .unwrap_or_else(|| cfg.default_voice.clone());
371        if !ALLOWED_VOICES.iter().any(|v| v == &voice.as_str()) {
372            return Err(format!(
373                "segment {i}: invalid voice '{voice}'. Allowed: {}",
374                ALLOWED_VOICES.join(", ")
375            ));
376        }
377        let pause_ms = seg
378            .get("pause_ms")
379            .and_then(|v| v.as_u64())
380            .map(|v| v.min(10_000) as u32)
381            .unwrap_or(cfg.segment_pause_ms);
382        out.push(Segment {
383            voice,
384            text,
385            pause_ms,
386        });
387    }
388    Ok(out)
389}
390
391/// A single `file '...'` line for ffmpeg's concat demuxer. Single quotes in the
392/// path are escaped per the concat format's `'\''` convention.
393fn concat_entry(path: &Path) -> String {
394    let p = path.to_string_lossy().replace('\'', "'\\''");
395    format!("file '{p}'\n")
396}
397
398/// Verify the ffmpeg binary is runnable; return a clear, actionable error if not.
399async fn ensure_ffmpeg(ffmpeg_path: &str) -> Result<(), String> {
400    match tokio::process::Command::new(ffmpeg_path)
401        .arg("-version")
402        .output()
403        .await
404    {
405        Ok(o) if o.status.success() => Ok(()),
406        Ok(_) => Err(format!(
407            "ffmpeg at '{ffmpeg_path}' exited with an error. Install ffmpeg or set media.podcast.ffmpeg_path."
408        )),
409        Err(_) => Err(format!(
410            "ffmpeg not found at '{ffmpeg_path}'. Install ffmpeg or set media.podcast.ffmpeg_path to its location."
411        )),
412    }
413}
414
415/// Render `pause_ms` of silence to `out` as mp3 via ffmpeg's anullsrc.
416async fn render_silence(ffmpeg_path: &str, out: &Path, pause_ms: u32) -> Result<(), String> {
417    let seconds = format!("{:.3}", pause_ms as f64 / 1000.0);
418    let res = tokio::process::Command::new(ffmpeg_path)
419        .args([
420            "-y",
421            "-f",
422            "lavfi",
423            "-i",
424            "anullsrc=r=24000:cl=mono",
425            "-t",
426            &seconds,
427            "-c:a",
428            "libmp3lame",
429            "-q:a",
430            "9",
431            &out.to_string_lossy(),
432        ])
433        .output()
434        .await
435        .map_err(|e| format!("ffmpeg silence render failed to start: {e}"))?;
436    if !res.status.success() {
437        let err = String::from_utf8_lossy(&res.stderr);
438        let tail: String = err
439            .chars()
440            .rev()
441            .take(300)
442            .collect::<String>()
443            .chars()
444            .rev()
445            .collect();
446        return Err(format!("ffmpeg silence render failed: {tail}"));
447    }
448    Ok(())
449}
450
451async fn deliver_attachment(
452    channels: &ChannelRegistry,
453    db: Arc<Database>,
454    chat_id: i64,
455    file: &Path,
456    caption: &str,
457) -> Result<String, String> {
458    let routing = crate::internal::channels::channel::get_required_chat_routing(
459        channels,
460        db.clone(),
461        chat_id,
462    )
463    .await?;
464    let Some(adapter) = channels.get(&routing.channel_name) else {
465        return Err(format!("no adapter for channel '{}'", routing.channel_name));
466    };
467    if adapter.is_local_only() {
468        return Ok(format!(
469            "channel '{}' is local-only, path retained at: {}",
470            routing.channel_name,
471            file.display()
472        ));
473    }
474    let external_chat_id = crate::internal::storage::db::call_blocking(db.clone(), move |d| {
475        d.get_chat_external_id(chat_id)
476    })
477    .await
478    .map_err(|e| e.to_string())?
479    .unwrap_or_else(|| chat_id.to_string());
480    let caption_short = caption.chars().take(120).collect::<String>();
481    match adapter
482        .send_attachment(&external_chat_id, file, Some(&caption_short))
483        .await
484    {
485        Ok(_) => {
486            let _ = deliver_and_store_bot_message(
487                channels,
488                db,
489                "bot",
490                chat_id,
491                &format!("[podcast attached: {}]", file.display()),
492            )
493            .await;
494            Ok(format!("delivered via channel '{}'", routing.channel_name))
495        }
496        Err(e) => Err(e),
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn rejects_empty_segments() {
506        let cfg = PodcastConfig::default();
507        let input = json!({"title": "t", "segments": []});
508        assert!(parse_segments(&input, &cfg).is_err());
509    }
510
511    #[test]
512    fn defaults_voice_and_pause() {
513        let cfg = PodcastConfig {
514            default_voice: "nova".into(),
515            segment_pause_ms: 750,
516            ..Default::default()
517        };
518        let input = json!({"segments": [{"text": "hello"}]});
519        let segs = parse_segments(&input, &cfg).unwrap();
520        assert_eq!(segs.len(), 1);
521        assert_eq!(segs[0].voice, "nova");
522        assert_eq!(segs[0].pause_ms, 750);
523    }
524
525    #[test]
526    fn rejects_bad_voice() {
527        let cfg = PodcastConfig::default();
528        let input = json!({"segments": [{"text": "hi", "voice": "bogus"}]});
529        assert!(parse_segments(&input, &cfg).is_err());
530    }
531
532    #[test]
533    fn nonempty_trims_and_rejects_blank() {
534        assert_eq!(nonempty(Some("  k  ")), Some("k".to_string()));
535        assert_eq!(nonempty(Some("   ")), None);
536        assert_eq!(nonempty(Some("")), None);
537        assert_eq!(nonempty(None), None);
538    }
539
540    #[test]
541    fn concat_entry_escapes_quotes() {
542        let p = Path::new("/tmp/a'b.mp3");
543        assert_eq!(concat_entry(p), "file '/tmp/a'\\''b.mp3'\n");
544    }
545
546    // Live end-to-end test against a real OpenAI-compatible TTS endpoint.
547    // #[ignore] — needs an API key + ffmpeg (costs ~a fraction of a cent).
548    // Run with: OPENAI_API_KEY=... cargo test --lib live_generate_episode -- --ignored --nocapture
549    #[tokio::test]
550    #[ignore]
551    async fn live_generate_episode() {
552        use std::sync::Arc;
553        // Standard credential env vars, same ones the tool itself resolves.
554        let key = std::env::var("OPENAI_API_KEY")
555            .or_else(|_| std::env::var("MICROCLAW_OPENAI_API_KEY"))
556            .expect("set OPENAI_API_KEY (or MICROCLAW_OPENAI_API_KEY) to run this live test");
557
558        let work =
559            std::env::temp_dir().join(format!("microclaw_podcast_live_{}", uuid::Uuid::new_v4()));
560        std::fs::create_dir_all(&work).unwrap();
561
562        let mut cfg = crate::config::Config::test_defaults();
563        cfg.data_dir = work.to_string_lossy().into_owned();
564        cfg.media.api_key = Some(key);
565        cfg.media.tts.enabled = true;
566        cfg.media.tts.model = "tts-1".into();
567        cfg.media.podcast.enabled = true;
568        if std::path::Path::new("/opt/homebrew/bin/ffmpeg").exists() {
569            cfg.media.podcast.ffmpeg_path = "/opt/homebrew/bin/ffmpeg".into();
570        }
571
572        let db_dir = work.join("db");
573        std::fs::create_dir_all(&db_dir).unwrap();
574        let db = Arc::new(Database::new(db_dir.to_str().unwrap()).unwrap());
575        let tool = GeneratePodcastTool::new(&cfg, Arc::new(ChannelRegistry::new()), db);
576
577        let input = json!({
578            "title": "MicroClaw Live Test",
579            "segments": [
580                {"voice": "nova", "text": "Welcome to the MicroClaw test podcast."},
581                {"voice": "onyx", "text": "This episode verifies text to speech and audio stitching from end to end."}
582            ],
583            "deliver": false
584        });
585        let res = tool.execute(input).await;
586        assert!(!res.is_error, "tool errored: {}", res.content);
587
588        let path = res
589            .metadata
590            .as_ref()
591            .and_then(|m| m.get("path"))
592            .and_then(|v| v.as_str())
593            .expect("metadata.path missing");
594        let bytes = std::fs::read(path).unwrap();
595        assert!(
596            bytes.len() > 5000,
597            "episode too small: {} bytes",
598            bytes.len()
599        );
600        let is_mp3 = bytes.starts_with(b"ID3") || (bytes[0] == 0xFF && (bytes[1] & 0xE0) == 0xE0);
601        assert!(
602            is_mp3,
603            "output is not mp3 (first bytes {:02X} {:02X})",
604            bytes[0], bytes[1]
605        );
606        eprintln!("OK: {} bytes -> {}", bytes.len(), path);
607    }
608}