Files
patchy631--ai-engineering-hub/ai-podcast-generator/app.py
T
2026-07-13 12:37:47 +08:00

288 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import tempfile
import streamlit as st
from pathlib import Path
from pydub import AudioSegment
from scraper import WebScraper
from script_generator import ScriptGenerator
from tts_converter import TTSConverter
# Page config
st.set_page_config(
page_title="AI Podcast Generator",
page_icon="🎙️",
layout="wide"
)
# Custom CSS
st.markdown("""
<style>
.main-header {
font-size: 3rem;
font-weight: bold;
text-align: center;
margin-bottom: 1rem;
color: #ffffff;
}
.sub-header {
text-align: center;
color: #666;
margin-bottom: 2rem;
}
.status-box {
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.status-success {
background-color: #d4edda;
border-left: 4px solid #28a745;
}
.status-info {
background-color: #d1ecf1;
border-left: 4px solid #17a2b8;
}
.status-warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
}
</style>
""", unsafe_allow_html=True)
# Header
st.markdown('<div class="main-header">🎙️ AI Podcast Generator</div>', unsafe_allow_html=True)
# Powered by widget
st.markdown('''
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: flex; justify-content: center; align-items: center; gap: 8px; margin-bottom: 20px;">
<span style='color: #64748b; font-size: 16px; font-weight: 500;'>Powered by</span>
<div style="display: flex; align-items: center; gap: 25px; margin-left: 15px;">
<a href="https://platform.minimax.io/" target="_blank" style="display: inline-block; vertical-align: middle; text-decoration: none;">
<img src="http://largelanguagemodel.com.tr/api/uploads/2863cd18-0ce7-4b89-bf7d-702e01575400/MiniMaxLogo-Dark.png"
alt="Minimax" style="height: 40px;">
</a>
<a href="https://www.firecrawl.dev/" target="_blank" style="display: inline-block; vertical-align: middle; text-decoration: none;">
<img src="https://i.ibb.co/67jyMHfy/firecrawl-light-wordmark.png"
alt="Firecrawl" style="height: 28px;">
</a>
</div>
</div>
</div>
''', unsafe_allow_html=True)
st.markdown('<div class="sub-header">Transform any web article into an engaging podcast</div>', unsafe_allow_html=True)
# Sidebar
with st.sidebar:
st.header("⚙️ Configuration")
st.subheader("API Keys")
minimax_key = st.text_input("Minimax API Key", type="password", help="Get your key from platform.minimax.io")
firecrawl_key = st.text_input("Firecrawl API Key", type="password", help="Get your key from firecrawl.dev")
openrouter_key = st.text_input("OpenRouter API Key", type="password", help="Get your key from openrouter.ai")
st.divider()
st.subheader("📝 Input")
url = st.text_input("Article URL", placeholder="https://example.com/article")
st.divider()
generate_btn = st.button("🚀 Generate Podcast", type="primary", use_container_width=True)
# Main area
if generate_btn:
os.environ['FIRECRAWL_API_KEY'] = firecrawl_key
os.environ['OPENROUTER_API_KEY'] = openrouter_key
os.environ['MINIMAX_API_KEY'] = minimax_key
progress_container = st.container()
with progress_container:
# Step 1: Scraping
with st.status("🌐 Scraping content from URL...", expanded=True) as status:
try:
scraper = WebScraper()
content = scraper.scrape(url)
st.success(f"✅ Successfully scraped {len(content)} characters")
status.update(label="✅ Content scraped successfully!", state="complete")
except Exception as e:
st.error(f"❌ Scraping failed: {str(e)}")
st.stop()
# Step 2: Script Generation
with st.status("✍️ Generating podcast script...", expanded=True) as status:
try:
generator = ScriptGenerator()
script = generator.generate(content)
st.success("✅ Podcast script generated successfully")
status.update(label="✅ Script generated successfully!", state="complete")
except Exception as e:
st.error(f"❌ Script generation failed: {str(e)}")
st.stop()
# Step 3: Audio Generation
with st.status("🎙️ Converting script to audio...", expanded=True) as status:
try:
converter = TTSConverter()
# Parse script to get segment count
segments = converter._parse_script(script)
total_segments = len(segments)
st.info(f"Generating {total_segments} audio segments...")
progress_bar = st.progress(0)
progress_text = st.empty()
# Create persistent audio directory
audio_dir = tempfile.mkdtemp()
audio_files = []
for i, (speaker, text) in enumerate(segments, 1):
progress_text.text(f"Processing segment {i}/{total_segments} ...")
voice = converter.voices.get(speaker, converter.voices["male"])
audio_file = os.path.join(audio_dir, f"segment_{i:03d}.mp3")
converter._generate_and_save_speech(text, voice, audio_file)
# Verify file exists before adding to list
if os.path.exists(audio_file) and os.path.getsize(audio_file) > 0:
audio_files.append((speaker, audio_file))
else:
raise Exception(f"Failed to generate audio for segment {i}")
progress_bar.progress(i / total_segments)
progress_text.text("✅ All segments generated!")
status.update(label="✅ Audio generated successfully!", state="complete")
except Exception as e:
st.error(f"❌ Audio generation failed: {str(e)}")
st.stop()
# Step 4: Merge Audio
with st.status("🔗 Merging audio segments...", expanded=True) as status:
try:
# Verify all files exist and filter out corrupted ones
st.info(f"Verifying {len(audio_files)} audio files...")
valid_files = []
corrupted_segments = []
for i, (speaker, filepath) in enumerate(audio_files, 1):
if not os.path.exists(filepath):
corrupted_segments.append(i)
st.warning(f"⚠️ Segment {i} missing")
continue
file_size = os.path.getsize(filepath)
if file_size < 1024: # Less than 1KB is likely corrupted
corrupted_segments.append(i)
st.warning(f"⚠️ Segment {i} corrupted ({file_size} bytes)")
continue
# Verify it's a valid MP3
try:
test_audio = AudioSegment.from_mp3(filepath)
if len(test_audio) < 100: # Less than 100ms is suspicious
corrupted_segments.append(i)
st.warning(f"⚠️ Segment {i} too short")
continue
except Exception as e:
corrupted_segments.append(i)
st.warning(f"⚠️ Segment {i} invalid MP3: {str(e)}")
continue
valid_files.append((speaker, filepath))
st.text(f"✓ Segment {i} verified ({file_size} bytes)")
if corrupted_segments:
st.warning(f"⚠️ Skipping {len(corrupted_segments)} corrupted segments: {corrupted_segments}")
if len(valid_files) == 0:
raise Exception("No valid audio segments to merge")
st.info(f"Merging {len(valid_files)} valid audio segments...")
combined = AudioSegment.empty()
for i, (speaker, filepath) in enumerate(valid_files, 1):
st.text(f"Adding segment {i}/{len(valid_files)}...")
audio = AudioSegment.from_mp3(filepath)
combined += audio
# Export merged audio
output_path = os.path.join(audio_dir, "full_podcast.mp3")
st.info("Exporting final podcast...")
combined.export(output_path, format="mp3")
# Verify output file
if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
raise Exception("Failed to create merged podcast file")
st.success(f"✅ Podcast merged successfully ({os.path.getsize(output_path)} bytes)")
if corrupted_segments:
st.info(f"️ Note: {len(corrupted_segments)} segments were skipped due to corruption")
status.update(label="✅ Podcast ready!", state="complete")
# Update audio_files to only include valid ones
audio_files = valid_files
except Exception as e:
st.error(f"❌ Audio merging failed: {str(e)}")
st.write("Debug info:")
st.write(f"Audio directory: {audio_dir}")
st.write(f"Files in directory: {os.listdir(audio_dir) if os.path.exists(audio_dir) else 'Directory not found'}")
st.stop()
st.divider()
st.header("📊 Results")
tab1, tab2 = st.tabs(["🎧 Podcast", "📝 Script"])
with tab1:
st.subheader("Full Podcast")
with open(output_path, "rb") as f:
st.audio(f.read(), format="audio/mp3")
with open(output_path, "rb") as f:
st.download_button(
label="📥 Download Full Podcast",
data=f.read(),
file_name="podcast.mp3",
mime="audio/mp3",
use_container_width=True
)
with tab2:
st.subheader("Podcast Script")
st.text_area("", script, height=400)
st.download_button(
label="📥 Download Script",
data=script,
file_name="podcast_script.txt",
mime="text/plain",
use_container_width=True
)
st.balloons()
else:
st.info("👈 Get started by entering your API keys in the sidebar and providing an article URL")
with st.expander("️ How it works"):
st.markdown("""
1. **Scraping**: We use Firecrawl to extract clean content from any URL
2. **Script Generation**: Minimax-M2.1 transforms the content into an engaging dialogue
3. **Audio Synthesis**: Minimax Speech 2.6 creates natural-sounding voices for both hosts
4. **Merging**: All segments are combined into one seamless podcast
""")
with st.expander("🔑 Where to get API keys"):
st.markdown("""
- **Minimax**: [platform.minimax.io](https://platform.minimax.io)
- **Firecrawl**: [firecrawl.dev](https://firecrawl.dev)
- **OpenRouter**: [openrouter.ai](https://openrouter.ai)
""")