""" podcast/builder.py — Script → TTS → SFX → MP3 pipeline """ from __future__ import annotations import os import re import time from pathlib import Path """ BASE_DIR = Path(__file__).parent.parent SFX_DIR = BASE_DIR / "sfx" POD_DIR = BASE_DIR / "static" / "podcasts" SEG_DIR = BASE_DIR / "static" / "segments" POD_DIR.mkdir(parents=True, exist_ok=True) SEG_DIR.mkdir(parents=True, exist_ok=True) """ from agents.tools import ( build_podcast_script, parse_script, strip_emotion_tags, synthesize_segment, get_segment_cache_path, PERSONAS, ) from data.db import (get_top_posts_for_podcast, save_podcast , SEG_DIR, POD_DIR, BASE_DIR, SFX_DIR) SFX_TAG_PATTERN = re.compile(r"<(laugh|gasp|sigh|cry)/>") SFX_MAP = { "laugh": "laugh.wav", "gasp": "gasp.wav", "sigh": "sigh.wav", "cry": "violin_sting.wav", } def _load_sfx(filename: str): """Load a SFX wav file. Returns AudioSegment or None.""" try: from pydub import AudioSegment path = SFX_DIR / filename if path.exists(): return AudioSegment.from_wav(str(path)) else: print(f"[SFX] File not found: {path}") except Exception as e: print(f"[SFX load error] {filename}: {e}") return None def mix_episode(sections: dict[str, str], segment_paths: dict[str, Path], category: str) -> Path: """Mix TTS segments + SFX into a final MP3.""" from pydub import AudioSegment final = AudioSegment.empty() silence_short = AudioSegment.silent(duration=350) silence_long = AudioSegment.silent(duration=750) persona = PERSONAS.get(category, {}) extra_sfx_files = persona.get("sfx", []) for key, raw_text in sections.items(): seg_path = segment_paths.get(key) if seg_path and seg_path.exists(): try: seg = AudioSegment.from_file(str(seg_path)) change = -20.0 - seg.dBFS # normalize to -20 dBFS seg = seg.apply_gain(change) final += seg except Exception as e: print(f"[Mix error] segment {key}: {e}") continue # Inject SFX from emotion tags in raw text for tag in SFX_TAG_PATTERN.findall(raw_text): sfx_file = SFX_MAP.get(tag) if sfx_file: sfx = _load_sfx(sfx_file) if sfx: sfx = sfx.apply_gain(-24 - sfx.dBFS) final += silence_short + sfx + silence_short final += silence_long # Category-specific intro SFX if extra_sfx_files: sfx = _load_sfx(extra_sfx_files[0]) if sfx: sfx = sfx.apply_gain(-26 - sfx.dBFS) final = sfx + silence_short + final ts = int(time.time()) out_path = POD_DIR / f"podcast_{category}_{ts}.mp3" final.export(str(out_path), format="mp3", bitrate="128k") return out_path def _fallback_copy(segment_paths: dict[str, Path], category: str) -> Path: """If pydub fails entirely, copy the first segment as the episode.""" first_path = next(iter(segment_paths.values())) ts = int(time.time()) out_path = POD_DIR / f"podcast_{category}_{ts}.wav" shutil.copy(str(first_path), str(out_path)) print(f"[Podcast] Fallback copy used: {out_path}") return out_path def generate_podcast(category: str) -> dict: """Full podcast generation pipeline.""" print(f"[Podcast] Generating episode for: {category}") # 1 — top posts posts = get_top_posts_for_podcast(category, limit=10) if not posts: print(f"[Podcast] No qualifying posts for {category}") return {"status": "error", "message": "Not enough posts yet."} # 2 — build script print(f"[Podcast] Writing script with {len(posts)} posts...") script = build_podcast_script(category, posts) # 3 — parse script sections = parse_script(script) if not sections: return {"status": "error", "message": "Script generation failed."} voice = PERSONAS[category]["voice"] # 4 — TTS each section (with caching) segment_paths: dict[str, Path] = {} for key, raw_text in sections.items(): clean_text = strip_emotion_tags(raw_text) if not clean_text.strip(): continue cache_path = get_segment_cache_path(category, clean_text) if cache_path.exists(): print(f"[Podcast] Cache hit: {key}") segment_paths[key] = cache_path else: print(f"[Podcast] Synthesizing: {key} ({len(clean_text)} chars)") audio_bytes = synthesize_segment(clean_text, voice=voice, category=category) if audio_bytes: cache_path.write_bytes(audio_bytes) segment_paths[key] = cache_path else: print(f"[Podcast] TTS failed for section: {key} — skipping") if not segment_paths: return {"status": "error", "message": "TTS synthesis failed for all sections."} # 5 — mix episode print(f"[Podcast] Mixing {len(segment_paths)} segments...") try: out_path = mix_episode(sections, segment_paths, category) except Exception as e: print(f"[Podcast] Mix error: {e}") out_path = _fallback_copy(segment_paths, category) # 6 — save to DB relative_path = str(out_path.relative_to(BASE_DIR)) save_podcast(category, script, relative_path, len(posts)) print(f"[Podcast] Done: {out_path}") return {"status": "ready", "audio_path": relative_path, "post_count": len(posts)}