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
16const ALLOWED_VOICES: &[&str] = &[
18 "alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse",
19];
20
21const MAX_SEGMENTS: usize = 40;
22
23fn nonempty(s: Option<&str>) -> Option<String> {
25 s.map(str::trim)
26 .filter(|s| !s.is_empty())
27 .map(str::to_string)
28}
29
30pub 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 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
87struct 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 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 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 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 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 let mut list = String::new();
251 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 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
391fn concat_entry(path: &Path) -> String {
394 let p = path.to_string_lossy().replace('\'', "'\\''");
395 format!("file '{p}'\n")
396}
397
398async 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
415async 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 #[tokio::test]
550 #[ignore]
551 async fn live_generate_episode() {
552 use std::sync::Arc;
553 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}