Spaces:
Running
Running
| """ | |
| 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)} | |