#!/usr/bin/env python3 """ AI Talk Show — TTS Pipeline Renders a transcript to audio using edge-tts with two distinct voices. Sequential rendering with retry for reliability. """ import json import asyncio import subprocess import sys import time import edge_tts from pathlib import Path # ── Config ────────────────────────────────────────────────────────── VOICES = { "Leonard": "en-US-GuyNeural", # Warm, mid-range, conversational "Charlie": "en-US-EricNeural", # Deeper, more measured/rational } RATE_ADJUST = "-8%" PROJECT_DIR = Path(__file__).parent.parent OUTPUT_DIR = PROJECT_DIR / "outputs" SEGMENTS_DIR = OUTPUT_DIR / "segments" SEGMENTS_DIR.mkdir(exist_ok=True) MAX_RETRIES = 3 RETRY_DELAY = 2 def load_transcript(path: Path) -> dict: return json.loads(path.read_text()) async def speak(text: str, agent: str, turn_num: int, output_path: Path) -> None: """Generate TTS for one turn with retry.""" voice = VOICES[agent] for attempt in range(MAX_RETRIES): try: communicate = edge_tts.Communicate(text, voice, rate=RATE_ADJUST) await communicate.save(str(output_path)) # Verify output is non-empty if output_path.stat().st_size > 0: print(f" ✓ {agent} turn {turn_num}: {output_path.name} ({output_path.stat().st_size} bytes)") return else: print(f" ⚠ Empty output, retry {attempt+1}/{MAX_RETRIES}") time.sleep(RETRY_DELAY) except Exception as e: print(f" ✗ {agent} turn {turn_num}: {e} (attempt {attempt+1}/{MAX_RETRIES})") time.sleep(RETRY_DELAY * (attempt + 1)) raise RuntimeError(f"Failed TTS for {agent} turn {turn_num} after {MAX_RETRIES} retries") def stitch_segments(episode_id: str, topic: str) -> Path: segment_files = sorted(SEGMENTS_DIR.glob(f"ep{episode_id}_{topic}_seg_*.mp3")) if not segment_files: raise FileNotFoundError(f"No segment files found for ep{episode_id}_{topic}") output_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_audio.mp3" concat_list = SEGMENTS_DIR / "concat.txt" with open(concat_list, "w") as f: for seg in segment_files: f.write(f"file '{seg.absolute()}'\n") subprocess.run([ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-c", "copy", str(output_path) ], check=True, capture_output=True) print(f"✅ Stitched audio: {output_path} ({output_path.stat().st_size:,} bytes)") return output_path def main(): if len(sys.argv) < 3: print("Usage: python tts_pipeline.py ") sys.exit(1) episode_id = sys.argv[1] topic = sys.argv[2] transcript_path = OUTPUT_DIR / f"ep{episode_id}_{topic}_transcript.json" if not transcript_path.exists(): print(f"❌ Transcript not found: {transcript_path}") sys.exit(1) data = load_transcript(transcript_path) conversation = data["conversation"] print(f"=== TTS: Episode {episode_id} — {topic} ===\n", flush=True) # Sequential rendering (reliable) async def generate_all(): for turn in conversation: agent = turn["agent"] num = turn["turn"] seg_path = SEGMENTS_DIR / f"ep{episode_id}_{topic}_seg_{num:02d}.mp3" await speak(turn["content"], agent, num, seg_path) asyncio.run(generate_all()) # Stitch audio_path = stitch_segments(episode_id, topic) # Summary total_words = sum(t.get("word_count", len(t["content"].split())) for t in conversation) estimated_minutes = total_words / 150 print(f"\n📊 {total_words} words, ~{estimated_minutes:.1f} min estimated") print(f"🎧 Audio: {audio_path}") if __name__ == "__main__": main()