feelin / podcast /builder.py
ArkenB's picture
Update podcast/builder.py
73a2e4b verified
Raw
History Blame Contribute Delete
5.63 kB
"""
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)}