Spaces:
Build error
Build error
Upload 4 files
Browse files
app.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import time
|
| 3 |
+
import re
|
| 4 |
+
import os
|
| 5 |
+
import tempfile
|
| 6 |
+
import pypdf
|
| 7 |
+
from pydub import AudioSegment, effects
|
| 8 |
+
import difflib
|
| 9 |
+
|
| 10 |
+
#CORRECTED IMPORT
|
| 11 |
+
from utils import (
|
| 12 |
+
generate_script,
|
| 13 |
+
generate_audio_mp3,
|
| 14 |
+
mix_with_bg_music,
|
| 15 |
+
DialogueItem,
|
| 16 |
+
run_research_agent,
|
| 17 |
+
generate_report
|
| 18 |
+
)
|
| 19 |
+
from prompts import SYSTEM_PROMPT
|
| 20 |
+
from qa import transcribe_audio_deepgram, handle_qa_exchange
|
| 21 |
+
|
| 22 |
+
MAX_QA_QUESTIONS = 5 # up to 5 voice/text questions
|
| 23 |
+
|
| 24 |
+
def parse_user_edited_transcript(edited_text: str, host_name: str, guest_name: str):
|
| 25 |
+
pattern = r"\*\*(.+?)\*\*:\s*(.+)"
|
| 26 |
+
matches = re.findall(pattern, edited_text)
|
| 27 |
+
|
| 28 |
+
items = []
|
| 29 |
+
if not matches:
|
| 30 |
+
raw_name = host_name or "Jane"
|
| 31 |
+
text_line = edited_text.strip()
|
| 32 |
+
speaker = "Jane"
|
| 33 |
+
if raw_name.lower() == guest_name.lower():
|
| 34 |
+
speaker = "John"
|
| 35 |
+
item = DialogueItem(
|
| 36 |
+
speaker=speaker,
|
| 37 |
+
display_speaker=raw_name,
|
| 38 |
+
text=text_line
|
| 39 |
+
)
|
| 40 |
+
items.append(item)
|
| 41 |
+
return items
|
| 42 |
+
|
| 43 |
+
for (raw_name, text_line) in matches:
|
| 44 |
+
if raw_name.lower() == host_name.lower():
|
| 45 |
+
speaker = "Jane"
|
| 46 |
+
elif raw_name.lower() == guest_name.lower():
|
| 47 |
+
speaker = "John"
|
| 48 |
+
else:
|
| 49 |
+
speaker = "Jane"
|
| 50 |
+
item = DialogueItem(
|
| 51 |
+
speaker=speaker,
|
| 52 |
+
display_speaker=raw_name,
|
| 53 |
+
text=text_line
|
| 54 |
+
)
|
| 55 |
+
items.append(item)
|
| 56 |
+
return items
|
| 57 |
+
|
| 58 |
+
def regenerate_audio_from_dialogue(dialogue_items, custom_bg_music_path=None):
|
| 59 |
+
audio_segments = []
|
| 60 |
+
transcript = ""
|
| 61 |
+
crossfade_duration = 50 # ms
|
| 62 |
+
|
| 63 |
+
for item in dialogue_items:
|
| 64 |
+
audio_file = generate_audio_mp3(item.text, item.speaker)
|
| 65 |
+
seg = AudioSegment.from_file(audio_file, format="mp3")
|
| 66 |
+
audio_segments.append(seg)
|
| 67 |
+
transcript += f"**{item.display_speaker}**: {item.text}\n\n"
|
| 68 |
+
os.remove(audio_file)
|
| 69 |
+
|
| 70 |
+
if not audio_segments:
|
| 71 |
+
return None, "No audio segments were generated."
|
| 72 |
+
|
| 73 |
+
combined_spoken = audio_segments[0]
|
| 74 |
+
for seg in audio_segments[1:]:
|
| 75 |
+
combined_spoken = combined_spoken.append(seg, crossfade=crossfade_duration)
|
| 76 |
+
|
| 77 |
+
final_mix = mix_with_bg_music(combined_spoken, custom_bg_music_path)
|
| 78 |
+
|
| 79 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_audio:
|
| 80 |
+
final_mix.export(temp_audio.name, format="mp3")
|
| 81 |
+
final_mp3_path = temp_audio.name
|
| 82 |
+
|
| 83 |
+
with open(final_mp3_path, "rb") as f:
|
| 84 |
+
audio_bytes = f.read()
|
| 85 |
+
os.remove(final_mp3_path)
|
| 86 |
+
|
| 87 |
+
return audio_bytes, transcript
|
| 88 |
+
|
| 89 |
+
def generate_podcast(
|
| 90 |
+
research_topic_input,
|
| 91 |
+
tone,
|
| 92 |
+
length_minutes,
|
| 93 |
+
host_name,
|
| 94 |
+
host_desc,
|
| 95 |
+
guest_name,
|
| 96 |
+
guest_desc,
|
| 97 |
+
user_specs,
|
| 98 |
+
sponsor_content,
|
| 99 |
+
sponsor_style,
|
| 100 |
+
custom_bg_music_path
|
| 101 |
+
):
|
| 102 |
+
if not research_topic_input:
|
| 103 |
+
return None, "Please enter a topic to research for the podcast."
|
| 104 |
+
text = st.session_state.get("report_content", "") # Get report content
|
| 105 |
+
if not text:
|
| 106 |
+
return None, "Please generate a research report first, or enter a topic."
|
| 107 |
+
|
| 108 |
+
extra_instructions = []
|
| 109 |
+
if host_name or guest_name:
|
| 110 |
+
host_line = f"Host: {host_name or 'Jane'} - {host_desc or 'a curious host'}."
|
| 111 |
+
guest_line = f"Guest: {guest_name or 'John'} - {guest_desc or 'an expert'}."
|
| 112 |
+
extra_instructions.append(f"{host_line}\n{guest_line}")
|
| 113 |
+
|
| 114 |
+
if user_specs.strip():
|
| 115 |
+
extra_instructions.append(f"Additional User Instructions: {user_specs}")
|
| 116 |
+
|
| 117 |
+
if sponsor_content.strip():
|
| 118 |
+
extra_instructions.append(
|
| 119 |
+
f"Sponsor Content Provided (should be under ~30 seconds):\n{sponsor_content}"
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
combined_instructions = "\n\n".join(extra_instructions).strip()
|
| 123 |
+
full_prompt = SYSTEM_PROMPT
|
| 124 |
+
if combined_instructions:
|
| 125 |
+
full_prompt += f"\n\n# Additional Instructions\n{combined_instructions}\n"
|
| 126 |
+
|
| 127 |
+
# Add language-specific instructions
|
| 128 |
+
if st.session_state.get("language_selection") == "Hinglish":
|
| 129 |
+
full_prompt += "\n\nPlease generate the script in Romanized Hindi.\n"
|
| 130 |
+
# Add similar instruction here for Hindi
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
script = generate_script(
|
| 134 |
+
full_prompt,
|
| 135 |
+
text,
|
| 136 |
+
tone,
|
| 137 |
+
f"{length_minutes} Mins",
|
| 138 |
+
host_name=host_name or "Jane",
|
| 139 |
+
guest_name=guest_name or "John",
|
| 140 |
+
sponsor_style=sponsor_style,
|
| 141 |
+
sponsor_provided=bool(sponsor_content.strip())
|
| 142 |
+
)
|
| 143 |
+
# If language is Hinglish, transliterate script dialogues to IAST
|
| 144 |
+
if st.session_state.get("language_selection") == "Hinglish":
|
| 145 |
+
from indic_transliteration.sanscript import transliterate, DEVANAGARI, IAST
|
| 146 |
+
for dialogue_item in script.dialogue:
|
| 147 |
+
dialogue_item.text = transliterate(dialogue_item.text, DEVANAGARI, IAST)
|
| 148 |
+
except Exception as e:
|
| 149 |
+
return None, f"Error generating script: {str(e)}"
|
| 150 |
+
|
| 151 |
+
audio_segments = []
|
| 152 |
+
transcript = ""
|
| 153 |
+
crossfade_duration = 50
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
for item in script.dialogue:
|
| 157 |
+
language = st.session_state.get("language_selection", "English (American)")
|
| 158 |
+
if language in ["English (Indian)", "Hinglish", "Hindi"]:
|
| 159 |
+
tts_speaker = "John" if item.display_speaker.lower() == (guest_name or "John").lower() else "Jane"
|
| 160 |
+
else:
|
| 161 |
+
tts_speaker = item.speaker
|
| 162 |
+
|
| 163 |
+
audio_file = generate_audio_mp3(item.text, tts_speaker)
|
| 164 |
+
seg = AudioSegment.from_file(audio_file, format="mp3")
|
| 165 |
+
audio_segments.append(seg)
|
| 166 |
+
transcript += f"**{item.display_speaker}**: {item.text}\n\n"
|
| 167 |
+
os.remove(audio_file)
|
| 168 |
+
|
| 169 |
+
if not audio_segments:
|
| 170 |
+
return None, "No audio segments generated."
|
| 171 |
+
|
| 172 |
+
combined_spoken = audio_segments[0]
|
| 173 |
+
for seg in audio_segments[1:]:
|
| 174 |
+
combined_spoken = combined_spoken.append(seg, crossfade=crossfade_duration)
|
| 175 |
+
|
| 176 |
+
final_mix = mix_with_bg_music(combined_spoken, custom_bg_music_path)
|
| 177 |
+
|
| 178 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as temp_audio:
|
| 179 |
+
final_mix.export(temp_audio.name, format="mp3")
|
| 180 |
+
final_mp3_path = temp_audio.name
|
| 181 |
+
|
| 182 |
+
with open(final_mp3_path, "rb") as f:
|
| 183 |
+
audio_bytes = f.read()
|
| 184 |
+
os.remove(final_mp3_path)
|
| 185 |
+
|
| 186 |
+
return audio_bytes, transcript
|
| 187 |
+
except Exception as e:
|
| 188 |
+
return None, f"Error generating audio: {str(e)}"
|
| 189 |
+
|
| 190 |
+
def highlight_differences(original: str, edited: str) -> str:
|
| 191 |
+
matcher = difflib.SequenceMatcher(None, original.split(), edited.split())
|
| 192 |
+
highlighted = []
|
| 193 |
+
for opcode, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 194 |
+
if opcode == 'equal':
|
| 195 |
+
highlighted.extend(original.split()[i1:i2])
|
| 196 |
+
elif opcode in ('replace', 'insert'):
|
| 197 |
+
added_words = edited.split()[j1:j2]
|
| 198 |
+
highlighted.extend([f'<span style="color:red">{word}</span>' for word in added_words])
|
| 199 |
+
elif opcode == 'delete':
|
| 200 |
+
pass
|
| 201 |
+
return ' '.join(highlighted)
|
| 202 |
+
|
| 203 |
+
def main():
|
| 204 |
+
st.set_page_config(
|
| 205 |
+
page_title="MyPod v3: AI-Powered Podcast & Research",
|
| 206 |
+
layout="centered"
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
st.markdown("""
|
| 210 |
+
<style>
|
| 211 |
+
.stFileUploader>div>div>div {
|
| 212 |
+
transform: scale(0.9);
|
| 213 |
+
}
|
| 214 |
+
footer {
|
| 215 |
+
text-align: center;
|
| 216 |
+
padding: 1em 0;
|
| 217 |
+
font-size: 0.8em;
|
| 218 |
+
color: #888;
|
| 219 |
+
}
|
| 220 |
+
</style>
|
| 221 |
+
""", unsafe_allow_html=True)
|
| 222 |
+
|
| 223 |
+
logo_col, title_col = st.columns([1, 10])
|
| 224 |
+
with logo_col:
|
| 225 |
+
st.image("logomypod.jpg", width=70)
|
| 226 |
+
with title_col:
|
| 227 |
+
st.markdown("## MyPod v3: AI-Powered Podcast & Research")
|
| 228 |
+
|
| 229 |
+
st.markdown("""
|
| 230 |
+
Welcome to **MyPod**, your go-to AI-powered podcast generator and research report tool! 🎉
|
| 231 |
+
MyPod now offers two main functionalities:
|
| 232 |
+
|
| 233 |
+
1. **Generate Research Reports:** Provide a research topic, and MyPod will use its AI-powered research agent to create a comprehensive, well-structured research report in PDF format.
|
| 234 |
+
2. **Generate Podcasts:** Transform your research topic (or the generated report) into an engaging, human-sounding podcast.
|
| 235 |
+
|
| 236 |
+
Select your desired mode below and let the magic happen!
|
| 237 |
+
""")
|
| 238 |
+
|
| 239 |
+
with st.expander("How to Use"):
|
| 240 |
+
st.markdown("""
|
| 241 |
+
**For Research Reports:**
|
| 242 |
+
|
| 243 |
+
<ol style="font-size:18px;">
|
| 244 |
+
<li>Select "Generate Research Report".</li>
|
| 245 |
+
<li>Enter your research topic.</li>
|
| 246 |
+
<li>Click 'Generate Report'.</li>
|
| 247 |
+
<li>MyPod will use its AI agent to research the topic and create a PDF report.</li>
|
| 248 |
+
<li>Once generated, you can view and download the report.</li>
|
| 249 |
+
</ol>
|
| 250 |
+
|
| 251 |
+
**For Podcasts:**
|
| 252 |
+
|
| 253 |
+
<ol style="font-size:18px;">
|
| 254 |
+
<li>Select "Generate Podcast".</li>
|
| 255 |
+
<li>Enter the research topic (this will be used as the basis for the podcast). OR FIRST GENERATE A REPORT AND THEN SELECT PODCAST.</li>
|
| 256 |
+
<li>Choose the tone, language, and target duration.</li>
|
| 257 |
+
<li>Add custom names and descriptions for the speakers (optional).</li>
|
| 258 |
+
<li>Add sponsored content (optional).</li>
|
| 259 |
+
<li>Click 'Generate Podcast'.</li>
|
| 260 |
+
</ol>
|
| 261 |
+
|
| 262 |
+
""", unsafe_allow_html=True)
|
| 263 |
+
# --- Main Mode Selection ---
|
| 264 |
+
mode = st.radio("Choose a Mode:", ["Generate Research Report", "Generate Podcast"])
|
| 265 |
+
|
| 266 |
+
# --- Research Report Section ---
|
| 267 |
+
if mode == "Generate Research Report":
|
| 268 |
+
st.markdown("### Generate Research Report")
|
| 269 |
+
research_topic_input = st.text_input("Enter your research topic:")
|
| 270 |
+
report_button = st.button("Generate Report")
|
| 271 |
+
|
| 272 |
+
if report_button:
|
| 273 |
+
if not research_topic_input:
|
| 274 |
+
st.error("Please enter a research topic.")
|
| 275 |
+
else:
|
| 276 |
+
with st.spinner("Researching and generating report... This may take several minutes."):
|
| 277 |
+
try:
|
| 278 |
+
report_content = run_research_agent(research_topic_input)
|
| 279 |
+
st.session_state["report_content"] = report_content
|
| 280 |
+
|
| 281 |
+
# Display report (basic text for now)
|
| 282 |
+
st.markdown("### Generated Report Preview")
|
| 283 |
+
st.text_area("Report Content", value=report_content, height=300)
|
| 284 |
+
|
| 285 |
+
# Generate PDF and offer download
|
| 286 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmpfile:
|
| 287 |
+
pdf_path = tmpfile.name
|
| 288 |
+
generate_report(report_content, filename=pdf_path) # Generate PDF
|
| 289 |
+
|
| 290 |
+
with open(pdf_path, "rb") as f:
|
| 291 |
+
pdf_bytes = f.read()
|
| 292 |
+
os.remove(pdf_path) # Clean up temp file
|
| 293 |
+
|
| 294 |
+
st.download_button(
|
| 295 |
+
label="Download Report (PDF)",
|
| 296 |
+
data=pdf_bytes,
|
| 297 |
+
file_name=f"{research_topic_input}_report.pdf",
|
| 298 |
+
mime="application/pdf"
|
| 299 |
+
)
|
| 300 |
+
st.success("Report generated successfully!")
|
| 301 |
+
|
| 302 |
+
except Exception as e:
|
| 303 |
+
st.error(f"An error occurred: {e}")
|
| 304 |
+
|
| 305 |
+
# --- Podcast Generation Section ---
|
| 306 |
+
|
| 307 |
+
elif mode == "Generate Podcast":
|
| 308 |
+
st.markdown("### Generate Podcast")
|
| 309 |
+
|
| 310 |
+
research_topic_input = st.text_input("Enter research topic for the podcast (or use a generated report):")
|
| 311 |
+
tone = st.radio("Tone", ["Casual", "Formal", "Humorous", "Youthful"], index=0)
|
| 312 |
+
length_minutes = st.slider("Podcast Length (in minutes)", 1, 60, 3)
|
| 313 |
+
|
| 314 |
+
language = st.selectbox(
|
| 315 |
+
"Choose Language and Accent",
|
| 316 |
+
["English (American)", "English (Indian)", "Hinglish", "Hindi"],
|
| 317 |
+
index=0
|
| 318 |
+
)
|
| 319 |
+
st.session_state["language_selection"] = language
|
| 320 |
+
|
| 321 |
+
st.markdown("### Customize Your Podcast (Optional)")
|
| 322 |
+
|
| 323 |
+
with st.expander("Set Host & Guest Names/Descriptions (Optional)"):
|
| 324 |
+
host_name = st.text_input("Female Host Name (leave blank for 'Jane')")
|
| 325 |
+
host_desc = st.text_input("Female Host Description (Optional)")
|
| 326 |
+
guest_name = st.text_input("Male Guest Name (leave blank for 'John')")
|
| 327 |
+
guest_desc = st.text_input("Male Guest Description (Optional)")
|
| 328 |
+
|
| 329 |
+
user_specs = st.text_area("Any special instructions or prompts for the script? (Optional)", "")
|
| 330 |
+
sponsor_content = st.text_area("Sponsored Content / Ad (Optional)", "")
|
| 331 |
+
sponsor_style = st.selectbox("Sponsor Integration Style", ["Separate Break", "Blended"])
|
| 332 |
+
|
| 333 |
+
custom_bg_music_file = st.file_uploader("Upload Custom Background Music (Optional)", type=["mp3", "wav"])
|
| 334 |
+
custom_bg_music_path = None
|
| 335 |
+
if custom_bg_music_file:
|
| 336 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(custom_bg_music_file.name)[1]) as tmp:
|
| 337 |
+
tmp.write(custom_bg_music_file.read())
|
| 338 |
+
custom_bg_music_path = tmp.name
|
| 339 |
+
|
| 340 |
+
if "audio_bytes" not in st.session_state:
|
| 341 |
+
st.session_state["audio_bytes"] = None
|
| 342 |
+
if "transcript" not in st.session_state:
|
| 343 |
+
st.session_state["transcript"] = None
|
| 344 |
+
if "transcript_original" not in st.session_state:
|
| 345 |
+
st.session_state["transcript_original"] = None
|
| 346 |
+
if "qa_count" not in st.session_state:
|
| 347 |
+
st.session_state["qa_count"] = 0
|
| 348 |
+
if "conversation_history" not in st.session_state:
|
| 349 |
+
st.session_state["conversation_history"] = ""
|
| 350 |
+
|
| 351 |
+
generate_button = st.button("Generate Podcast")
|
| 352 |
+
|
| 353 |
+
if generate_button:
|
| 354 |
+
progress_bar = st.progress(0)
|
| 355 |
+
progress_text = st.empty()
|
| 356 |
+
|
| 357 |
+
progress_messages = [
|
| 358 |
+
"🔍 Analyzing your input...",
|
| 359 |
+
"📝 Crafting the perfect script...",
|
| 360 |
+
"🎙️ Generating high-quality audio...",
|
| 361 |
+
"🎶 Adding the finishing touches..."
|
| 362 |
+
]
|
| 363 |
+
|
| 364 |
+
progress_text.write(progress_messages[0])
|
| 365 |
+
progress_bar.progress(0)
|
| 366 |
+
time.sleep(1.0)
|
| 367 |
+
|
| 368 |
+
progress_text.write(progress_messages[1])
|
| 369 |
+
progress_bar.progress(25)
|
| 370 |
+
time.sleep(1.0)
|
| 371 |
+
|
| 372 |
+
progress_text.write(progress_messages[2])
|
| 373 |
+
progress_bar.progress(50)
|
| 374 |
+
time.sleep(1.0)
|
| 375 |
+
|
| 376 |
+
progress_text.write(progress_messages[3])
|
| 377 |
+
progress_bar.progress(75)
|
| 378 |
+
time.sleep(1.0)
|
| 379 |
+
|
| 380 |
+
audio_bytes, transcript = generate_podcast(
|
| 381 |
+
research_topic_input,
|
| 382 |
+
tone,
|
| 383 |
+
length_minutes,
|
| 384 |
+
host_name,
|
| 385 |
+
host_desc,
|
| 386 |
+
guest_name,
|
| 387 |
+
guest_desc,
|
| 388 |
+
user_specs,
|
| 389 |
+
sponsor_content,
|
| 390 |
+
sponsor_style,
|
| 391 |
+
custom_bg_music_path
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
progress_bar.progress(100)
|
| 395 |
+
progress_text.write("✅ Done!")
|
| 396 |
+
|
| 397 |
+
if audio_bytes is None:
|
| 398 |
+
st.error(transcript)
|
| 399 |
+
st.session_state["audio_bytes"] = None
|
| 400 |
+
st.session_state["transcript"] = None
|
| 401 |
+
st.session_state["transcript_original"] = None
|
| 402 |
+
else:
|
| 403 |
+
st.success("Podcast generated successfully!")
|
| 404 |
+
st.session_state["audio_bytes"] = audio_bytes
|
| 405 |
+
st.session_state["transcript"] = transcript
|
| 406 |
+
st.session_state["transcript_original"] = transcript
|
| 407 |
+
st.session_state["qa_count"] = 0
|
| 408 |
+
st.session_state["conversation_history"] = ""
|
| 409 |
+
|
| 410 |
+
if st.session_state.get("audio_bytes"):
|
| 411 |
+
st.audio(st.session_state["audio_bytes"], format='audio/mp3')
|
| 412 |
+
st.download_button(
|
| 413 |
+
label="Download Podcast (MP3)",
|
| 414 |
+
data=st.session_state["audio_bytes"],
|
| 415 |
+
file_name="my_podcast.mp3",
|
| 416 |
+
mime="audio/mpeg"
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
st.markdown("### Generated Transcript (Editable)")
|
| 420 |
+
edited_text = st.text_area(
|
| 421 |
+
"Feel free to tweak lines, fix errors, or reword anything.",
|
| 422 |
+
value=st.session_state["transcript"],
|
| 423 |
+
height=300
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
if st.session_state.get("transcript_original"):
|
| 427 |
+
highlighted_transcript = highlight_differences(
|
| 428 |
+
st.session_state["transcript_original"],
|
| 429 |
+
edited_text
|
| 430 |
+
)
|
| 431 |
+
st.markdown("### **Edited Transcript Highlights**", unsafe_allow_html=True)
|
| 432 |
+
st.markdown(highlighted_transcript, unsafe_allow_html=True)
|
| 433 |
+
|
| 434 |
+
if st.button("Regenerate Audio From Edited Text"):
|
| 435 |
+
regen_bar = st.progress(0)
|
| 436 |
+
regen_text = st.empty()
|
| 437 |
+
|
| 438 |
+
regen_text.write("🔄 Regenerating your podcast with the edits...")
|
| 439 |
+
regen_bar.progress(25)
|
| 440 |
+
time.sleep(1.0)
|
| 441 |
+
|
| 442 |
+
regen_text.write("🔧 Adjusting the script based on your changes...")
|
| 443 |
+
regen_bar.progress(50)
|
| 444 |
+
time.sleep(1.0)
|
| 445 |
+
|
| 446 |
+
dialogue_items = parse_user_edited_transcript(
|
| 447 |
+
edited_text,
|
| 448 |
+
host_name or "Jane",
|
| 449 |
+
guest_name or "John"
|
| 450 |
+
)
|
| 451 |
+
new_audio_bytes, new_transcript = regenerate_audio_from_dialogue(dialogue_items, custom_bg_music_path)
|
| 452 |
+
|
| 453 |
+
regen_bar.progress(75)
|
| 454 |
+
time.sleep(1.0)
|
| 455 |
+
|
| 456 |
+
if new_audio_bytes is None:
|
| 457 |
+
regen_bar.progress(100)
|
| 458 |
+
st.error(new_transcript)
|
| 459 |
+
else:
|
| 460 |
+
regen_bar.progress(100)
|
| 461 |
+
regen_text.write("✅ Regeneration complete!")
|
| 462 |
+
st.success("Regenerated audio below:")
|
| 463 |
+
|
| 464 |
+
st.session_state["audio_bytes"] = new_audio_bytes
|
| 465 |
+
st.session_state["transcript"] = new_transcript
|
| 466 |
+
st.session_state["transcript_original"] = new_transcript
|
| 467 |
+
|
| 468 |
+
st.audio(new_audio_bytes, format='audio/mp3')
|
| 469 |
+
st.download_button(
|
| 470 |
+
label="Download Edited Podcast (MP3)",
|
| 471 |
+
data=new_audio_bytes,
|
| 472 |
+
file_name="my_podcast_edited.mp3",
|
| 473 |
+
mime="audio/mpeg"
|
| 474 |
+
)
|
| 475 |
+
st.markdown("### Updated Transcript")
|
| 476 |
+
st.markdown(new_transcript)
|
| 477 |
+
|
| 478 |
+
st.markdown("## Post-Podcast Q&A")
|
| 479 |
+
used_questions = st.session_state.get("qa_count", 0)
|
| 480 |
+
remaining = MAX_QA_QUESTIONS - used_questions
|
| 481 |
+
|
| 482 |
+
if remaining > 0:
|
| 483 |
+
st.write(f"You can ask up to {remaining} more question(s).")
|
| 484 |
+
|
| 485 |
+
typed_q = st.text_input("Type your follow-up question:")
|
| 486 |
+
audio_q = st.audio_input("Or record an audio question (WAV)")
|
| 487 |
+
|
| 488 |
+
if st.button("Submit Q&A"):
|
| 489 |
+
if used_questions >= MAX_QA_QUESTIONS:
|
| 490 |
+
st.warning("You have reached the Q&A limit.")
|
| 491 |
+
else:
|
| 492 |
+
question_text = typed_q.strip()
|
| 493 |
+
if audio_q is not None:
|
| 494 |
+
suffix = ".wav"
|
| 495 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 496 |
+
tmp.write(audio_q.read())
|
| 497 |
+
local_audio_path = tmp.name
|
| 498 |
+
st.write("Transcribing your audio question...")
|
| 499 |
+
audio_transcript = transcribe_audio_deepgram(local_audio_path)
|
| 500 |
+
if audio_transcript:
|
| 501 |
+
question_text = audio_transcript
|
| 502 |
+
|
| 503 |
+
if not question_text:
|
| 504 |
+
st.warning("No question found (text or audio).")
|
| 505 |
+
else:
|
| 506 |
+
st.write("Generating an answer...")
|
| 507 |
+
ans_audio, ans_text = handle_qa_exchange(question_text)
|
| 508 |
+
if ans_audio:
|
| 509 |
+
st.audio(ans_audio, format='audio/mp3')
|
| 510 |
+
st.markdown(f"**John**: {ans_text}")
|
| 511 |
+
st.session_state["qa_count"] = used_questions + 1
|
| 512 |
+
else:
|
| 513 |
+
st.warning("No response could be generated.")
|
| 514 |
+
else:
|
| 515 |
+
st.write("You have used all 5 Q&A opportunities.")
|
| 516 |
+
|
| 517 |
+
st.markdown("<footer>©2025 MyPod. All rights reserved.</footer>", unsafe_allow_html=True)
|
| 518 |
+
|
| 519 |
+
if __name__ == "__main__":
|
| 520 |
+
main()
|
prompts.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# prompts.py
|
| 2 |
+
|
| 3 |
+
SYSTEM_PROMPT = (
|
| 4 |
+
"You are a skilled podcast producer tasked with transforming unstructured or messy input text into an engaging "
|
| 5 |
+
"and informative podcast script. Your goal is to extract the most interesting and insightful content for a "
|
| 6 |
+
"compelling podcast discussion. Critically, you must incorporate both established background information (e.g., "
|
| 7 |
+
"from an LLM knowledge base or Wikipedia) AND you must include any new or breaking news items found through RSS "
|
| 8 |
+
"feeds or other sources.\n\n"
|
| 9 |
+
|
| 10 |
+
"Steps to Follow:\n"
|
| 11 |
+
"1. **Analyze the Input:** Carefully examine the text, identifying key topics, points, recent developments, and "
|
| 12 |
+
"interesting facts or anecdotes that could drive an engaging podcast conversation. Disregard irrelevant or "
|
| 13 |
+
"duplicate information.\n"
|
| 14 |
+
"2. **Brainstorm Ideas:** Consider creative ways to present the key points in a lively, entertaining manner, "
|
| 15 |
+
"incorporating the latest news or any recently discovered updates.\n"
|
| 16 |
+
"3. **Craft the Dialogue:**\n"
|
| 17 |
+
" - **Warm Opening**: Have Jane (the host) welcome listeners, introduce the podcast name, and greet the guest. "
|
| 18 |
+
" Provide some quick background on John’s expertise or credentials.\n"
|
| 19 |
+
" - **Main Discussion**: Discuss the key points thoroughly, including new/breaking news items or any fresh "
|
| 20 |
+
" details from the topic’s latest developments. Jane asks thoughtful questions; John responds with "
|
| 21 |
+
" well-substantiated facts and relevant news. Be sure to highlight if there are significant changes, such "
|
| 22 |
+
" as a resignation or other major events.\n"
|
| 23 |
+
" - **Pleasant Conclusion**: End the episode in a friendly way, with Jane wrapping up and thanking the audience, "
|
| 24 |
+
" possibly directing them to future updates if the topic is ongoing.\n\n"
|
| 25 |
+
|
| 26 |
+
"**Rules for the Dialogue:**\n"
|
| 27 |
+
"- Jane always initiates the conversation and interviews John.\n"
|
| 28 |
+
"- Include thoughtful questions from Jane to guide the discussion.\n"
|
| 29 |
+
"- Incorporate natural speech patterns, including occasional verbal fillers (e.g., 'um,' 'well,' 'you know').\n"
|
| 30 |
+
"- Allow for natural interruptions and back-and-forth between Jane and John.\n"
|
| 31 |
+
"- If any new or updated info is found (e.g., a resignation), it must be mentioned and integrated into the flow.\n"
|
| 32 |
+
"- Ensure John's responses are on-topic and substantiated by the input text and any newly discovered or breaking "
|
| 33 |
+
" news.\n"
|
| 34 |
+
"- Maintain a PG-rated conversation appropriate for all audiences.\n"
|
| 35 |
+
"- Avoid any marketing or self-promotional content from John.\n"
|
| 36 |
+
"- Jane concludes the conversation in a pleasant manner, possibly teasing future updates if the topic is still "
|
| 37 |
+
" evolving.\n\n"
|
| 38 |
+
|
| 39 |
+
"**Stylistic Guidelines for Natural Dialogue:**\n"
|
| 40 |
+
"- The dialogue should sound natural and conversational between Jane and John.\n"
|
| 41 |
+
"- Use a mix of short, punchy sentences along with longer, reflective sentences to create a dynamic rhythm.\n"
|
| 42 |
+
"- Include natural pauses and breaks to mimic human speech, using ellipses (...) or sentence fragments where "
|
| 43 |
+
" appropriate.\n"
|
| 44 |
+
"- Vary sentence structures to avoid monotony; mix questions, statements, and exclamations.\n"
|
| 45 |
+
"- Inject humor or light-hearted comments to enhance relatability and keep the tone friendly.\n"
|
| 46 |
+
"- Predominantly use active voice to create a direct and engaging conversation.\n"
|
| 47 |
+
"- Add emotional inflections reflecting excitement, curiosity, or contemplation as needed.\n"
|
| 48 |
+
"- Occasionally include filler words like 'um' or 'you know' to enhance authenticity, but avoid overuse.\n"
|
| 49 |
+
"- Ensure Jane and John occasionally acknowledge each other with phrases like 'That's a great point!' or "
|
| 50 |
+
" 'I totally agree!' to simulate a real conversation.\n\n"
|
| 51 |
+
|
| 52 |
+
"The goal is to create an audio output that feels lively, relatable, and easy for listeners to follow.\n\n"
|
| 53 |
+
|
| 54 |
+
"# Additional Instruction for Interjections / Interruptions\n"
|
| 55 |
+
"Please include occasional, short interruptions or interjections where Jane or John might briefly cut in on "
|
| 56 |
+
"the other’s sentence (without overlapping audio). For example, they might say, 'Wait, wait...' or 'Hold on...' "
|
| 57 |
+
"to jump in, and then politely yield so the conversation remains understandable in sequence.\n"
|
| 58 |
+
)
|
qa.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# qa.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import requests
|
| 5 |
+
import json
|
| 6 |
+
import tempfile
|
| 7 |
+
import streamlit as st
|
| 8 |
+
|
| 9 |
+
from utils import generate_audio_mp3 # Reuse your existing TTS function
|
| 10 |
+
|
| 11 |
+
def transcribe_audio_deepgram(local_audio_path: str) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Sends a local audio file to Deepgram for STT.
|
| 14 |
+
Returns the transcript text if successful, or raises an error if failed.
|
| 15 |
+
"""
|
| 16 |
+
DEEPGRAM_API_KEY = os.environ.get("DEEPGRAM_API_KEY")
|
| 17 |
+
if not DEEPGRAM_API_KEY:
|
| 18 |
+
raise ValueError("Deepgram API key not found in environment variables.")
|
| 19 |
+
|
| 20 |
+
url = "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true"
|
| 21 |
+
headers = {
|
| 22 |
+
"Authorization": f"Token {DEEPGRAM_API_KEY}",
|
| 23 |
+
"Content-Type": "audio/wav"
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
with open(local_audio_path, "rb") as f:
|
| 27 |
+
response = requests.post(url, headers=headers, data=f)
|
| 28 |
+
response.raise_for_status()
|
| 29 |
+
|
| 30 |
+
data = response.json()
|
| 31 |
+
# Extract the transcript
|
| 32 |
+
transcript = data["results"]["channels"][0]["alternatives"][0].get("transcript", "")
|
| 33 |
+
return transcript
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def call_llm_for_qa(conversation_so_far: str, user_question: str) -> dict:
|
| 37 |
+
"""
|
| 38 |
+
Calls Groq LLM to answer a follow-up question.
|
| 39 |
+
Returns a Python dict: {"speaker": "John", "text": "..."}
|
| 40 |
+
"""
|
| 41 |
+
system_prompt = f"""
|
| 42 |
+
You are John, the guest speaker. The user is asking a follow-up question.
|
| 43 |
+
Conversation so far:
|
| 44 |
+
{conversation_so_far}
|
| 45 |
+
|
| 46 |
+
New user question:
|
| 47 |
+
{user_question}
|
| 48 |
+
|
| 49 |
+
Please respond in JSON with keys "speaker" and "text", e.g.:
|
| 50 |
+
{{ "speaker": "John", "text": "Sure, here's my answer..." }}
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
from utils import call_groq_api_for_qa # Import from utils
|
| 54 |
+
|
| 55 |
+
raw_json_response = call_groq_api_for_qa(system_prompt) # Corrected call
|
| 56 |
+
# Expect a JSON string: {"speaker": "John", "text": "some short answer"}
|
| 57 |
+
response_dict = json.loads(raw_json_response)
|
| 58 |
+
return response_dict
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def handle_qa_exchange(user_question: str) -> (bytes, str):
|
| 62 |
+
"""
|
| 63 |
+
1) Read conversation_so_far from session_state
|
| 64 |
+
2) Call the LLM for a short follow-up answer
|
| 65 |
+
3) Generate TTS audio
|
| 66 |
+
4) Return (audio_bytes, answer_text)
|
| 67 |
+
"""
|
| 68 |
+
conversation_so_far = st.session_state.get("conversation_history", "")
|
| 69 |
+
|
| 70 |
+
# Ask the LLM
|
| 71 |
+
response_dict = call_llm_for_qa(conversation_so_far, user_question)
|
| 72 |
+
answer_text = response_dict.get("text", "")
|
| 73 |
+
speaker = response_dict.get("speaker", "John")
|
| 74 |
+
|
| 75 |
+
# Update conversation
|
| 76 |
+
new_history = conversation_so_far + f"\nUser: {user_question}\n{speaker}: {answer_text}\n"
|
| 77 |
+
st.session_state["conversation_history"] = new_history
|
| 78 |
+
|
| 79 |
+
if not answer_text.strip():
|
| 80 |
+
return (None, "")
|
| 81 |
+
|
| 82 |
+
# TTS
|
| 83 |
+
audio_file_path = generate_audio_mp3(answer_text, "John") # always John
|
| 84 |
+
with open(audio_file_path, "rb") as f:
|
| 85 |
+
audio_bytes = f.read()
|
| 86 |
+
os.remove(audio_file_path)
|
| 87 |
+
|
| 88 |
+
return (audio_bytes, answer_text)
|
utils.py
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import json
|
| 4 |
+
import requests
|
| 5 |
+
import tempfile
|
| 6 |
+
import random
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
import time
|
| 10 |
+
|
| 11 |
+
from bs4 import BeautifulSoup
|
| 12 |
+
from typing import List, Literal, Optional
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
from pydub import AudioSegment, effects
|
| 15 |
+
from transformers import pipeline
|
| 16 |
+
import tiktoken
|
| 17 |
+
from groq import Groq
|
| 18 |
+
|
| 19 |
+
import streamlit as st # If you use Streamlit for session state
|
| 20 |
+
|
| 21 |
+
from report_structure import generate_report # Your PDF generator
|
| 22 |
+
from tavily import TavilyClient # For search
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
###############################################################################
|
| 26 |
+
# DATA MODELS
|
| 27 |
+
###############################################################################
|
| 28 |
+
|
| 29 |
+
class DialogueItem(BaseModel):
|
| 30 |
+
speaker: Literal["Jane", "John"]
|
| 31 |
+
display_speaker: str = "Jane"
|
| 32 |
+
text: str
|
| 33 |
+
|
| 34 |
+
class Dialogue(BaseModel):
|
| 35 |
+
dialogue: List[DialogueItem]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
###############################################################################
|
| 39 |
+
# HYBRID RATE-LIMIT HANDLER
|
| 40 |
+
###############################################################################
|
| 41 |
+
|
| 42 |
+
def call_llm_with_retry(groq_client, **payload):
|
| 43 |
+
"""
|
| 44 |
+
Wraps groq_client.chat.completions.create(**payload) in a retry loop
|
| 45 |
+
to catch 429 rate-limit errors. If we see “try again in XXs,” we parse
|
| 46 |
+
that wait time, sleep, then retry. We also do a short sleep (0.3s)
|
| 47 |
+
after each successful call to spread usage.
|
| 48 |
+
"""
|
| 49 |
+
max_retries = 3
|
| 50 |
+
for attempt in range(max_retries):
|
| 51 |
+
try:
|
| 52 |
+
print(f"[DEBUG] call_llm_with_retry attempt {attempt+1}")
|
| 53 |
+
response = groq_client.chat.completions.create(**payload)
|
| 54 |
+
# Short sleep to avoid bursting usage
|
| 55 |
+
time.sleep(0.3)
|
| 56 |
+
print("[DEBUG] LLM call succeeded, returning response.")
|
| 57 |
+
return response
|
| 58 |
+
except Exception as e:
|
| 59 |
+
err_str = str(e).lower()
|
| 60 |
+
print(f"[WARN] call_llm_with_retry attempt {attempt+1} failed: {e}")
|
| 61 |
+
if "rate_limit_exceeded" in err_str or "try again in" in err_str:
|
| 62 |
+
# parse recommended wait time
|
| 63 |
+
wait_time = 60.0
|
| 64 |
+
match = re.search(r'try again in (\d+(?:\.\d+)?)s', str(e), re.IGNORECASE)
|
| 65 |
+
if match:
|
| 66 |
+
wait_time = float(match.group(1)) + 1.0
|
| 67 |
+
print(f"[WARN] Rate limited. Sleeping for {wait_time:.1f}s, then retrying.")
|
| 68 |
+
time.sleep(wait_time)
|
| 69 |
+
else:
|
| 70 |
+
raise
|
| 71 |
+
raise RuntimeError("Exceeded max_retries due to repeated rate limit or other errors.")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
###############################################################################
|
| 75 |
+
# TRUNCATION
|
| 76 |
+
###############################################################################
|
| 77 |
+
|
| 78 |
+
def truncate_text_tokens(text: str, max_tokens: int) -> str:
|
| 79 |
+
"""
|
| 80 |
+
Truncates 'text' to 'max_tokens' tokens. Used for controlling maximum
|
| 81 |
+
total text size after scraping.
|
| 82 |
+
"""
|
| 83 |
+
tokenizer = tiktoken.get_encoding("cl100k_base")
|
| 84 |
+
tokens = tokenizer.encode(text)
|
| 85 |
+
if len(tokens) > max_tokens:
|
| 86 |
+
truncated = tokenizer.decode(tokens[:max_tokens])
|
| 87 |
+
print(f"[DEBUG] Truncating from {len(tokens)} tokens to {max_tokens} tokens.")
|
| 88 |
+
return truncated
|
| 89 |
+
return text
|
| 90 |
+
|
| 91 |
+
def truncate_text_for_llm(text: str, max_tokens: int = 1024) -> str:
|
| 92 |
+
"""
|
| 93 |
+
Typical truncation for partial merges or final calls.
|
| 94 |
+
"""
|
| 95 |
+
tokenizer = tiktoken.get_encoding("cl100k_base")
|
| 96 |
+
tokens = tokenizer.encode(text)
|
| 97 |
+
if len(tokens) > max_tokens:
|
| 98 |
+
truncated = tokenizer.decode(tokens[:max_tokens])
|
| 99 |
+
print(f"[DEBUG] Truncating text from {len(tokens)} to {max_tokens} tokens for LLM.")
|
| 100 |
+
return truncated
|
| 101 |
+
return text
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
###############################################################################
|
| 105 |
+
# PITCH SHIFT (Optional)
|
| 106 |
+
###############################################################################
|
| 107 |
+
|
| 108 |
+
def pitch_shift(audio: AudioSegment, semitones: int) -> AudioSegment:
|
| 109 |
+
print(f"[LOG] Shifting pitch by {semitones} semitones.")
|
| 110 |
+
new_sample_rate = int(audio.frame_rate * (2.0 ** (semitones / 12.0)))
|
| 111 |
+
shifted_audio = audio._spawn(audio.raw_data, overrides={'frame_rate': new_sample_rate})
|
| 112 |
+
return shifted_audio.set_frame_rate(audio.frame_rate)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
###############################################################################
|
| 116 |
+
# PODCAST SCRIPT GENERATION (Single Call)
|
| 117 |
+
###############################################################################
|
| 118 |
+
|
| 119 |
+
def generate_script(
|
| 120 |
+
system_prompt: str,
|
| 121 |
+
input_text: str,
|
| 122 |
+
tone: str,
|
| 123 |
+
target_length: str,
|
| 124 |
+
host_name: str = "Jane",
|
| 125 |
+
guest_name: str = "John",
|
| 126 |
+
sponsor_style: str = "Separate Break",
|
| 127 |
+
sponsor_provided=None
|
| 128 |
+
):
|
| 129 |
+
"""
|
| 130 |
+
If you do a single call to generate the entire script.
|
| 131 |
+
Uses DEEPSEEK_R1. Just ensure you parse the JSON.
|
| 132 |
+
"""
|
| 133 |
+
print("[LOG] Generating script with tone:", tone, "and length:", target_length)
|
| 134 |
+
|
| 135 |
+
language_selection = st.session_state.get("language_selection", "English (American)")
|
| 136 |
+
if (host_name == "Jane" or not host_name) and language_selection in ["English (Indian)", "Hinglish", "Hindi"]:
|
| 137 |
+
host_name = "Isha"
|
| 138 |
+
if (guest_name == "John" or not guest_name) and language_selection in ["English (Indian)", "Hinglish", "Hindi"]:
|
| 139 |
+
guest_name = "Aarav"
|
| 140 |
+
|
| 141 |
+
words_per_minute = 150
|
| 142 |
+
numeric_minutes = 3
|
| 143 |
+
match = re.search(r"(\d+)", target_length)
|
| 144 |
+
if match:
|
| 145 |
+
numeric_minutes = int(match.group(1))
|
| 146 |
+
|
| 147 |
+
min_words = max(50, numeric_minutes * 100)
|
| 148 |
+
max_words = numeric_minutes * words_per_minute
|
| 149 |
+
|
| 150 |
+
tone_map = {
|
| 151 |
+
"Humorous": "funny and exciting, makes people chuckle",
|
| 152 |
+
"Formal": "business-like, well-structured, professional",
|
| 153 |
+
"Casual": "like a conversation between close friends, relaxed and informal",
|
| 154 |
+
"Youthful": "like how teenagers might chat, energetic and lively"
|
| 155 |
+
}
|
| 156 |
+
chosen_tone = tone_map.get(tone, "casual")
|
| 157 |
+
|
| 158 |
+
if sponsor_provided:
|
| 159 |
+
if sponsor_style == "Separate Break":
|
| 160 |
+
sponsor_instructions = (
|
| 161 |
+
"If sponsor content is provided, include it in a separate ad break (~30 seconds). "
|
| 162 |
+
"Use 'Now a word from our sponsor...' and end with 'Back to the show', etc."
|
| 163 |
+
)
|
| 164 |
+
else:
|
| 165 |
+
sponsor_instructions = (
|
| 166 |
+
"If sponsor content is provided, blend it naturally (~30 seconds) into conversation. "
|
| 167 |
+
"Avoid abrupt transitions."
|
| 168 |
+
)
|
| 169 |
+
else:
|
| 170 |
+
sponsor_instructions = ""
|
| 171 |
+
|
| 172 |
+
prompt = (
|
| 173 |
+
f"{system_prompt}\n"
|
| 174 |
+
f"TONE: {chosen_tone}\n"
|
| 175 |
+
f"TARGET LENGTH: {target_length} (~{min_words}-{max_words} words)\n"
|
| 176 |
+
f"INPUT TEXT: {input_text}\n\n"
|
| 177 |
+
f"# Sponsor Style Instruction:\n{sponsor_instructions}\n\n"
|
| 178 |
+
"Please provide the output in the following JSON format without any extra text:\n"
|
| 179 |
+
"{\n"
|
| 180 |
+
' "dialogue": [\n'
|
| 181 |
+
' { "speaker": "Jane", "text": "..." },\n'
|
| 182 |
+
' { "speaker": "John", "text": "..." }\n'
|
| 183 |
+
" ]\n"
|
| 184 |
+
"}"
|
| 185 |
+
)
|
| 186 |
+
if language_selection == "Hinglish":
|
| 187 |
+
prompt += "\n\nPlease generate the script in Romanized Hindi.\n"
|
| 188 |
+
elif language_selection == "Hindi":
|
| 189 |
+
prompt += "\n\nPlease generate the script exclusively in Hindi.\n"
|
| 190 |
+
|
| 191 |
+
print("[LOG] Sending script generation prompt to LLM.")
|
| 192 |
+
try:
|
| 193 |
+
headers = {
|
| 194 |
+
"Authorization": f"Bearer {os.environ.get('DEEPSEEK_API_KEY')}",
|
| 195 |
+
"Content-Type": "application/json"
|
| 196 |
+
}
|
| 197 |
+
data = {
|
| 198 |
+
"model": "deepseek/deepseek-r1",
|
| 199 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 200 |
+
"max_tokens": 2048,
|
| 201 |
+
"temperature": 0.7
|
| 202 |
+
}
|
| 203 |
+
resp = requests.post("https://openrouter.ai/api/v1/chat/completions",
|
| 204 |
+
headers=headers, data=json.dumps(data))
|
| 205 |
+
resp.raise_for_status()
|
| 206 |
+
raw_content = resp.json()["choices"][0]["message"]["content"].strip()
|
| 207 |
+
except Exception as e:
|
| 208 |
+
print("[ERROR] LLM error generating script:", e)
|
| 209 |
+
raise ValueError(f"Error generating script: {str(e)}")
|
| 210 |
+
|
| 211 |
+
start_idx = raw_content.find("{")
|
| 212 |
+
end_idx = raw_content.rfind("}")
|
| 213 |
+
if start_idx == -1 or end_idx == -1:
|
| 214 |
+
raise ValueError("No JSON found in LLM response for script generation.")
|
| 215 |
+
|
| 216 |
+
json_str = raw_content[start_idx:end_idx+1]
|
| 217 |
+
try:
|
| 218 |
+
data_js = json.loads(json_str)
|
| 219 |
+
dialogue_list = data_js.get("dialogue", [])
|
| 220 |
+
|
| 221 |
+
# Adjust speaker names if they match
|
| 222 |
+
for d in dialogue_list:
|
| 223 |
+
raw_speaker = d.get("speaker", "Jane")
|
| 224 |
+
if raw_speaker.lower() == host_name.lower():
|
| 225 |
+
d["speaker"] = "Jane"
|
| 226 |
+
d["display_speaker"] = host_name
|
| 227 |
+
elif raw_speaker.lower() == guest_name.lower():
|
| 228 |
+
d["speaker"] = "John"
|
| 229 |
+
d["display_speaker"] = guest_name
|
| 230 |
+
else:
|
| 231 |
+
d["speaker"] = "Jane"
|
| 232 |
+
d["display_speaker"] = raw_speaker
|
| 233 |
+
|
| 234 |
+
new_dialogue_items = []
|
| 235 |
+
for d in dialogue_list:
|
| 236 |
+
if "display_speaker" not in d:
|
| 237 |
+
d["display_speaker"] = d["speaker"]
|
| 238 |
+
new_dialogue_items.append(DialogueItem(**d))
|
| 239 |
+
|
| 240 |
+
return Dialogue(dialogue=new_dialogue_items)
|
| 241 |
+
|
| 242 |
+
except json.JSONDecodeError as e:
|
| 243 |
+
print("[ERROR] JSON decoding failed for script generation:", e)
|
| 244 |
+
raise ValueError(f"Script parse error: {str(e)}")
|
| 245 |
+
except Exception as e:
|
| 246 |
+
print("[ERROR] Unknown error parsing script JSON:", e)
|
| 247 |
+
raise ValueError(f"Script parse error: {str(e)}")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
###############################################################################
|
| 251 |
+
# YOUTUBE TRANSCRIPTION (RAPIDAPI)
|
| 252 |
+
###############################################################################
|
| 253 |
+
|
| 254 |
+
def transcribe_youtube_video(video_url: str) -> str:
|
| 255 |
+
print("[LOG] Transcribing YouTube video:", video_url)
|
| 256 |
+
match = re.search(r"(?:v=|/)([0-9A-Za-z_-]{11})", video_url)
|
| 257 |
+
if not match:
|
| 258 |
+
raise ValueError(f"Invalid YouTube URL: {video_url}, cannot extract video ID.")
|
| 259 |
+
video_id = match.group(1)
|
| 260 |
+
print("[LOG] Extracted video ID:", video_id)
|
| 261 |
+
|
| 262 |
+
base_url = "https://youtube-transcriptor.p.rapidapi.com/transcript"
|
| 263 |
+
params = {"video_id": video_id, "lang": "en"}
|
| 264 |
+
headers = {
|
| 265 |
+
"x-rapidapi-host": "youtube-transcriptor.p.rapidapi.com",
|
| 266 |
+
"x-rapidapi-key": os.environ.get("RAPIDAPI_KEY")
|
| 267 |
+
}
|
| 268 |
+
try:
|
| 269 |
+
resp = requests.get(base_url, headers=headers, params=params, timeout=30)
|
| 270 |
+
resp.raise_for_status()
|
| 271 |
+
data = resp.json()
|
| 272 |
+
if not isinstance(data, list) or not data:
|
| 273 |
+
raise ValueError(f"Unexpected transcript format or empty transcript: {data}")
|
| 274 |
+
|
| 275 |
+
transcript_as_text = data[0].get("transcriptionAsText", "").strip()
|
| 276 |
+
if not transcript_as_text:
|
| 277 |
+
raise ValueError("transcriptionAsText missing or empty in RapidAPI response.")
|
| 278 |
+
|
| 279 |
+
print("[LOG] Transcript retrieval successful. Sample:", transcript_as_text[:200], "...")
|
| 280 |
+
return transcript_as_text
|
| 281 |
+
except Exception as e:
|
| 282 |
+
print("[ERROR] YouTube transcription error:", e)
|
| 283 |
+
raise ValueError(f"Error transcribing YouTube video: {str(e)}")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
###############################################################################
|
| 287 |
+
# AUDIO GENERATION (TTS) AND BG MUSIC MIX
|
| 288 |
+
###############################################################################
|
| 289 |
+
|
| 290 |
+
def _preprocess_text_for_tts(text: str, speaker: str) -> str:
|
| 291 |
+
text = re.sub(r"\bNo\.\b", "Number", text, flags=re.IGNORECASE)
|
| 292 |
+
text = re.sub(r"\b(?i)SaaS\b", "sass", text)
|
| 293 |
+
|
| 294 |
+
abbreviations_as_words = {"NASA", "NATO", "UNESCO"}
|
| 295 |
+
def insert_periods_for_abbrev(m):
|
| 296 |
+
abbr = m.group(0)
|
| 297 |
+
if abbr in abbreviations_as_words:
|
| 298 |
+
return abbr
|
| 299 |
+
return ".".join(list(abbr)) + "."
|
| 300 |
+
|
| 301 |
+
text = re.sub(r"\b([A-Z]{2,})\b", insert_periods_for_abbrev, text)
|
| 302 |
+
text = re.sub(r"\.\.", ".", text)
|
| 303 |
+
|
| 304 |
+
def remove_periods_for_tts(m):
|
| 305 |
+
return m.group().replace(".", " ").strip()
|
| 306 |
+
|
| 307 |
+
text = re.sub(r"[A-Z]\.[A-Z](?:\.[A-Z])*\.", remove_periods_for_tts, text)
|
| 308 |
+
text = re.sub(r"-", " ", text)
|
| 309 |
+
text = re.sub(r"\b(ha(ha)?|heh|lol)\b", "(* laughs *)", text, flags=re.IGNORECASE)
|
| 310 |
+
text = re.sub(r"\bsigh\b", "(* sighs *)", text, flags=re.IGNORECASE)
|
| 311 |
+
text = re.sub(r"\b(groan|moan)\b", "(* groans *)", text, flags=re.IGNORECASE)
|
| 312 |
+
|
| 313 |
+
if speaker != "Jane":
|
| 314 |
+
def insert_thinking_pause(m):
|
| 315 |
+
wd = m.group(1)
|
| 316 |
+
if random.random() < 0.3:
|
| 317 |
+
filler = random.choice(["hmm,", "well,", "let me see,"])
|
| 318 |
+
return f"{wd}..., {filler}"
|
| 319 |
+
else:
|
| 320 |
+
return f"{wd}...,"
|
| 321 |
+
keywords_pattern = r"\b(important|significant|crucial|point|topic)\b"
|
| 322 |
+
text = re.sub(keywords_pattern, insert_thinking_pause, text, flags=re.IGNORECASE)
|
| 323 |
+
conj_pattern = r"\b(and|but|so|because|however)\b"
|
| 324 |
+
text = re.sub(conj_pattern, lambda m: f"{m.group()}...", text, flags=re.IGNORECASE)
|
| 325 |
+
|
| 326 |
+
text = re.sub(r"\b(uh|um|ah)\b", "", text, flags=re.IGNORECASE)
|
| 327 |
+
|
| 328 |
+
def capitalize_after_sentence(m):
|
| 329 |
+
return m.group().upper()
|
| 330 |
+
|
| 331 |
+
text = re.sub(r'(^\s*\w)|([.!?]\s*\w)', capitalize_after_sentence, text)
|
| 332 |
+
return text.strip()
|
| 333 |
+
|
| 334 |
+
def generate_audio_mp3(text: str, speaker: str) -> str:
|
| 335 |
+
"""
|
| 336 |
+
Uses Deepgram (English) or Murf (Indian/Hinglish/Hindi) for TTS.
|
| 337 |
+
"""
|
| 338 |
+
print(f"[LOG] Generating TTS for speaker={speaker}")
|
| 339 |
+
language_selection = st.session_state.get("language_selection", "English (American)")
|
| 340 |
+
try:
|
| 341 |
+
if language_selection == "English (American)":
|
| 342 |
+
print("[LOG] Using Deepgram for American English TTS.")
|
| 343 |
+
processed_text = text if speaker in ["Jane", "John"] else _preprocess_text_for_tts(text, speaker)
|
| 344 |
+
deepgram_api_url = "https://api.deepgram.com/v1/speak"
|
| 345 |
+
params = {"model": "aura-asteria-en"} if speaker != "John" else {"model": "aura-zeus-en"}
|
| 346 |
+
headers = {
|
| 347 |
+
"Accept": "audio/mpeg",
|
| 348 |
+
"Content-Type": "application/json",
|
| 349 |
+
"Authorization": f"Token {os.environ.get('DEEPGRAM_API_KEY')}"
|
| 350 |
+
}
|
| 351 |
+
body = {"text": processed_text}
|
| 352 |
+
r = requests.post(deepgram_api_url, params=params, headers=headers, json=body, stream=True)
|
| 353 |
+
r.raise_for_status()
|
| 354 |
+
|
| 355 |
+
content_type = r.headers.get("Content-Type", "")
|
| 356 |
+
if "audio/mpeg" not in content_type:
|
| 357 |
+
raise ValueError("Unexpected content-type from Deepgram TTS.")
|
| 358 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as mp3_file:
|
| 359 |
+
for chunk in r.iter_content(chunk_size=8192):
|
| 360 |
+
if chunk:
|
| 361 |
+
mp3_file.write(chunk)
|
| 362 |
+
mp3_path = mp3_file.name
|
| 363 |
+
|
| 364 |
+
audio_seg = AudioSegment.from_file(mp3_path, format="mp3")
|
| 365 |
+
audio_seg = effects.normalize(audio_seg)
|
| 366 |
+
final_mp3_path = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3").name
|
| 367 |
+
audio_seg.export(final_mp3_path, format="mp3")
|
| 368 |
+
if os.path.exists(mp3_path):
|
| 369 |
+
os.remove(mp3_path)
|
| 370 |
+
return final_mp3_path
|
| 371 |
+
|
| 372 |
+
else:
|
| 373 |
+
print("[LOG] Using Murf API for TTS. Language=", language_selection)
|
| 374 |
+
from indic_transliteration.sanscript import transliterate, DEVANAGARI, IAST
|
| 375 |
+
if language_selection == "Hinglish":
|
| 376 |
+
text = transliterate(text, DEVANAGARI, IAST)
|
| 377 |
+
api_key = os.environ.get("MURF_API_KEY")
|
| 378 |
+
headers = {
|
| 379 |
+
"Content-Type": "application/json",
|
| 380 |
+
"Accept": "application/json",
|
| 381 |
+
"api-key": api_key
|
| 382 |
+
}
|
| 383 |
+
multi_native_locale = "hi-IN" if language_selection in ["Hinglish", "Hindi"] else "en-IN"
|
| 384 |
+
if language_selection == "English (Indian)":
|
| 385 |
+
voice_id = "en-IN-aarav" if speaker == "John" else "en-IN-isha"
|
| 386 |
+
elif language_selection in ["Hindi", "Hinglish"]:
|
| 387 |
+
voice_id = "hi-IN-kabir" if speaker == "John" else "hi-IN-shweta"
|
| 388 |
+
else:
|
| 389 |
+
voice_id = "en-IN-aarav" if speaker == "John" else "en-IN-isha"
|
| 390 |
+
|
| 391 |
+
payload = {
|
| 392 |
+
"audioDuration": 0,
|
| 393 |
+
"channelType": "MONO",
|
| 394 |
+
"encodeAsBase64": False,
|
| 395 |
+
"format": "WAV",
|
| 396 |
+
"modelVersion": "GEN2",
|
| 397 |
+
"multiNativeLocale": multi_native_locale,
|
| 398 |
+
"pitch": 0,
|
| 399 |
+
"pronunciationDictionary": {},
|
| 400 |
+
"rate": 0,
|
| 401 |
+
"sampleRate": 48000,
|
| 402 |
+
"style": "Conversational",
|
| 403 |
+
"text": text,
|
| 404 |
+
"variation": 1,
|
| 405 |
+
"voiceId": voice_id
|
| 406 |
+
}
|
| 407 |
+
r = requests.post("https://api.murf.ai/v1/speech/generate", headers=headers, json=payload)
|
| 408 |
+
r.raise_for_status()
|
| 409 |
+
j = r.json()
|
| 410 |
+
audio_url = j.get("audioFile")
|
| 411 |
+
if not audio_url:
|
| 412 |
+
raise ValueError("No audioFile URL from Murf API.")
|
| 413 |
+
audio_resp = requests.get(audio_url)
|
| 414 |
+
audio_resp.raise_for_status()
|
| 415 |
+
|
| 416 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as wav_file:
|
| 417 |
+
wav_file.write(audio_resp.content)
|
| 418 |
+
wav_path = wav_file.name
|
| 419 |
+
|
| 420 |
+
audio_seg = AudioSegment.from_file(wav_path, format="wav")
|
| 421 |
+
audio_seg = effects.normalize(audio_seg)
|
| 422 |
+
final_mp3_path = tempfile.NamedTemporaryFile(delete=False, suffix=".mp3").name
|
| 423 |
+
audio_seg.export(final_mp3_path, format="mp3")
|
| 424 |
+
os.remove(wav_path)
|
| 425 |
+
return final_mp3_path
|
| 426 |
+
except Exception as e:
|
| 427 |
+
print("[ERROR] TTS generation error:", e)
|
| 428 |
+
raise ValueError(f"Error generating TTS audio: {str(e)}")
|
| 429 |
+
|
| 430 |
+
def mix_with_bg_music(spoken: AudioSegment, custom_music_path=None) -> AudioSegment:
|
| 431 |
+
"""
|
| 432 |
+
Overlays 'spoken' with background music, offset by ~2s, volume lowered.
|
| 433 |
+
"""
|
| 434 |
+
if custom_music_path:
|
| 435 |
+
music_path = custom_music_path
|
| 436 |
+
else:
|
| 437 |
+
music_path = "bg_music.mp3"
|
| 438 |
+
|
| 439 |
+
try:
|
| 440 |
+
bg_music = AudioSegment.from_file(music_path, format="mp3")
|
| 441 |
+
except Exception as e:
|
| 442 |
+
print("[ERROR] Failed to load background music:", e)
|
| 443 |
+
return spoken
|
| 444 |
+
|
| 445 |
+
bg_music = bg_music - 18.0
|
| 446 |
+
total_length_ms = len(spoken) + 2000
|
| 447 |
+
looped_music = AudioSegment.empty()
|
| 448 |
+
while len(looped_music) < total_length_ms:
|
| 449 |
+
looped_music += bg_music
|
| 450 |
+
looped_music = looped_music[:total_length_ms]
|
| 451 |
+
final_mix = looped_music.overlay(spoken, position=2000)
|
| 452 |
+
return final_mix
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
###############################################################################
|
| 456 |
+
# Q&A UTILITY (POST-PODCAST)
|
| 457 |
+
###############################################################################
|
| 458 |
+
|
| 459 |
+
def call_groq_api_for_qa(system_prompt: str) -> str:
|
| 460 |
+
"""
|
| 461 |
+
Single-step Q&A for post-podcast. Usually short usage => minimal tokens.
|
| 462 |
+
"""
|
| 463 |
+
try:
|
| 464 |
+
headers = {
|
| 465 |
+
"Authorization": f"Bearer {os.environ.get('GROQ_API_KEY')}",
|
| 466 |
+
"Content-Type": "application/json",
|
| 467 |
+
"Accept": "application/json"
|
| 468 |
+
}
|
| 469 |
+
data = {
|
| 470 |
+
"model": "deepseek-r1-distill-llama-70b",
|
| 471 |
+
"messages": [{"role": "user", "content": system_prompt}],
|
| 472 |
+
"max_tokens": 512,
|
| 473 |
+
"temperature": 0.7
|
| 474 |
+
}
|
| 475 |
+
r = requests.post("https://api.groq.com/openai/v1/chat/completions", headers=headers, data=json.dumps(data))
|
| 476 |
+
r.raise_for_status()
|
| 477 |
+
return r.json()["choices"][0]["message"]["content"].strip()
|
| 478 |
+
except Exception as e:
|
| 479 |
+
print("[ERROR] Groq QA error:", e)
|
| 480 |
+
fallback = {"speaker": "John", "text": "Sorry, I'm having trouble answering now."}
|
| 481 |
+
return json.dumps(fallback)
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
###############################################################################
|
| 485 |
+
# LOW-CALL RESEARCH AGENT (Minimizing LLM Calls)
|
| 486 |
+
###############################################################################
|
| 487 |
+
|
| 488 |
+
MODEL_SUMMARIZATION = "llama-3.1-8b-instant"
|
| 489 |
+
MODEL_COMBINATION = "deepseek-r1-distill-llama-70b"
|
| 490 |
+
|
| 491 |
+
def run_research_agent(
|
| 492 |
+
topic: str,
|
| 493 |
+
report_type: str = "research_report",
|
| 494 |
+
max_results: int = 20
|
| 495 |
+
) -> str:
|
| 496 |
+
"""
|
| 497 |
+
Low-Call approach:
|
| 498 |
+
1) Tavily search (up to 20 URLs).
|
| 499 |
+
2) Firecrawl scrape => combined text
|
| 500 |
+
3) Truncate to 12k tokens total
|
| 501 |
+
4) Split => at most 2 x 6k chunks => Summarize each chunk once => summaries
|
| 502 |
+
5) Single final merge => final PDF
|
| 503 |
+
=> 2 or 3 total LLM calls => drastically fewer calls => less chance of 429
|
| 504 |
+
|
| 505 |
+
Logs at each step for clarity.
|
| 506 |
+
"""
|
| 507 |
+
print(f"[LOG] Starting LOW-CALL research agent for topic: {topic}")
|
| 508 |
+
|
| 509 |
+
try:
|
| 510 |
+
# Step 1: Tavily search
|
| 511 |
+
print("[LOG] Step 1: Searching with Tavily for relevant URLs (max_results=20).")
|
| 512 |
+
tavily_client = TavilyClient(api_key=os.environ.get("TAVILY_API_KEY"))
|
| 513 |
+
search_data = tavily_client.search(query=topic, max_results=max_results)
|
| 514 |
+
search_results = search_data.get("results", [])
|
| 515 |
+
print(f"[LOG] Tavily provided {len(search_results)} results. Proceeding to Step 2.")
|
| 516 |
+
if not search_results:
|
| 517 |
+
print("[LOG] No relevant search results found by Tavily.")
|
| 518 |
+
return "No relevant search results found."
|
| 519 |
+
|
| 520 |
+
references_list = [r["url"] for r in search_results if "url" in r]
|
| 521 |
+
|
| 522 |
+
# Step 2: Firecrawl scraping
|
| 523 |
+
print("[LOG] Step 2: Scraping each URL with Firecrawl.")
|
| 524 |
+
combined_content = ""
|
| 525 |
+
for result in search_results:
|
| 526 |
+
url = result["url"]
|
| 527 |
+
print(f"[LOG] Firecrawl scraping: {url}")
|
| 528 |
+
headers = {'Authorization': f'Bearer {os.environ.get("FIRECRAWL_API_KEY")}'}
|
| 529 |
+
payload = {"url": url, "formats": ["markdown"], "onlyMainContent": True}
|
| 530 |
+
try:
|
| 531 |
+
resp = requests.post("https://api.firecrawl.dev/v1/scrape", headers=headers, json=payload)
|
| 532 |
+
resp.raise_for_status()
|
| 533 |
+
data = resp.json()
|
| 534 |
+
if data.get("success") and "markdown" in data.get("data", {}):
|
| 535 |
+
combined_content += data["data"]["markdown"] + "\n\n"
|
| 536 |
+
else:
|
| 537 |
+
print(f"[WARNING] Firecrawl scrape failed or no markdown for {url}: {data.get('error')}")
|
| 538 |
+
except requests.RequestException as e:
|
| 539 |
+
print(f"[ERROR] Firecrawl error for {url}: {e}")
|
| 540 |
+
continue
|
| 541 |
+
|
| 542 |
+
if not combined_content:
|
| 543 |
+
print("[LOG] Could not retrieve content from any search results. Exiting.")
|
| 544 |
+
return "Could not retrieve content from any of the search results."
|
| 545 |
+
|
| 546 |
+
# Step 3: Truncate to 12k tokens total
|
| 547 |
+
print("[LOG] Step 3: Truncating combined text to 12,000 tokens if needed.")
|
| 548 |
+
combined_content = truncate_text_tokens(combined_content, max_tokens=12000)
|
| 549 |
+
|
| 550 |
+
# Step 4: At most 2 chunks => Summaries
|
| 551 |
+
print("[LOG] Step 4: Splitting text into up to 2 chunks (6,000 tokens each). Summarizing each chunk.")
|
| 552 |
+
tokenizer = tiktoken.get_encoding("cl100k_base")
|
| 553 |
+
tokens = tokenizer.encode(combined_content)
|
| 554 |
+
chunk_size = 6000
|
| 555 |
+
|
| 556 |
+
groq_client = Groq(api_key=os.environ.get("GROQ_API_KEY"))
|
| 557 |
+
summaries = []
|
| 558 |
+
start = 0
|
| 559 |
+
chunk_index = 1
|
| 560 |
+
while start < len(tokens):
|
| 561 |
+
end = min(start + chunk_size, len(tokens))
|
| 562 |
+
chunk_text = tokenizer.decode(tokens[start:end])
|
| 563 |
+
print(f"[LOG] Summarizing chunk {chunk_index} with ~{len(tokens[start:end])} tokens.")
|
| 564 |
+
prompt = f"""
|
| 565 |
+
You are a specialized summarization engine. Summarize the following text
|
| 566 |
+
for a professional research report. Provide accurate details but do not
|
| 567 |
+
include chain-of-thought or internal reasoning. Keep it concise, but
|
| 568 |
+
include key data points and context:
|
| 569 |
+
|
| 570 |
+
{chunk_text}
|
| 571 |
+
"""
|
| 572 |
+
data = {
|
| 573 |
+
"model": MODEL_SUMMARIZATION,
|
| 574 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 575 |
+
"temperature": 0.2,
|
| 576 |
+
"max_tokens": 768
|
| 577 |
+
}
|
| 578 |
+
response = call_llm_with_retry(groq_client, **data)
|
| 579 |
+
summary_text = response.choices[0].message.content.strip()
|
| 580 |
+
summaries.append(summary_text)
|
| 581 |
+
|
| 582 |
+
start = end
|
| 583 |
+
chunk_index += 1
|
| 584 |
+
# Because chunk_size=6000, only 2 chunks max
|
| 585 |
+
if chunk_index > 2:
|
| 586 |
+
break
|
| 587 |
+
|
| 588 |
+
# Step 5: Single final merge call
|
| 589 |
+
print("[LOG] Step 5: Doing one final merge of chunk summaries.")
|
| 590 |
+
references_text = "\n".join(f"- {url}" for url in references_list) if references_list else "None"
|
| 591 |
+
truncated_summaries = [truncate_text_for_llm(s, max_tokens=1000) for s in summaries]
|
| 592 |
+
merged_input = "\n\n".join(truncated_summaries)
|
| 593 |
+
|
| 594 |
+
final_prompt = f"""
|
| 595 |
+
IMPORTANT: Do NOT include chain-of-thought or hidden planning.
|
| 596 |
+
Produce a long, academic-style research paper with the following structure:
|
| 597 |
+
- Title Page (concise descriptive title)
|
| 598 |
+
- Table of Contents
|
| 599 |
+
- Executive Summary
|
| 600 |
+
- Introduction
|
| 601 |
+
- Historical or Contextual Background
|
| 602 |
+
- Multiple Thematic Sections (with subheadings)
|
| 603 |
+
- Detailed Analysis (multi-paragraph sections)
|
| 604 |
+
- Footnotes or inline citations referencing the URLs
|
| 605 |
+
- Conclusion
|
| 606 |
+
- References / Bibliography (list these URLs at the end)
|
| 607 |
+
|
| 608 |
+
Requirements:
|
| 609 |
+
- Minimal bullet points, prefer multi-paragraph
|
| 610 |
+
- Each section at least 2-3 paragraphs
|
| 611 |
+
- Aim for 1500+ words if possible
|
| 612 |
+
- Under 6000 tokens total
|
| 613 |
+
- Professional, academic tone
|
| 614 |
+
|
| 615 |
+
Partial Summaries:
|
| 616 |
+
{merged_input}
|
| 617 |
+
|
| 618 |
+
References (URLs):
|
| 619 |
+
{references_text}
|
| 620 |
+
|
| 621 |
+
Now, merge these partial summaries into one thoroughly expanded research paper:
|
| 622 |
+
"""
|
| 623 |
+
final_data = {
|
| 624 |
+
"model": MODEL_COMBINATION,
|
| 625 |
+
"messages": [{"role": "user", "content": final_prompt}],
|
| 626 |
+
"temperature": 0.3,
|
| 627 |
+
"max_tokens": 2048
|
| 628 |
+
}
|
| 629 |
+
final_response = call_llm_with_retry(groq_client, **final_data)
|
| 630 |
+
final_text = final_response.choices[0].message.content.strip()
|
| 631 |
+
|
| 632 |
+
# Step 6: PDF generation
|
| 633 |
+
print("[LOG] Step 6: Generating final PDF from the merged text.")
|
| 634 |
+
final_report = generate_report(final_text)
|
| 635 |
+
|
| 636 |
+
print("[LOG] Done! Returning PDF from run_research_agent (low-call).")
|
| 637 |
+
return final_report
|
| 638 |
+
|
| 639 |
+
except Exception as e:
|
| 640 |
+
print(f"[ERROR] Error in run_research_agent: {e}")
|
| 641 |
+
return f"Sorry, encountered an error: {str(e)}"
|