voiceapp2 / app.py
aniketsirsikar's picture
Upload 8 files
da8f7ce verified
Raw
History Blame Contribute Delete
24.6 kB
import gradio as gr
import os, tempfile, traceback
from pathlib import Path
from rag_pipeline import RAGPipeline
from script_generator import ScriptGenerator
from voice_generator import (VoiceGenerator, SPEAKER_NAMES,
HOST_A_DEFAULT, HOST_B_DEFAULT, EMOTION_PRESETS)
import utils
rag = RAGPipeline()
script_gen = ScriptGenerator()
voice_gen = VoiceGenerator()
# ---------------------------------------------------------------------------
# Core functions
# ---------------------------------------------------------------------------
def process_document(file, url_input, progress=gr.Progress()):
try:
progress(0.1, desc="Reading document...")
if file is not None:
text, doc_name = utils.extract_text_from_file(file.name)
elif url_input and url_input.strip():
text, doc_name = utils.extract_text_from_url(url_input.strip())
else:
return "❌ Please upload a file or enter a URL.", gr.update(interactive=False), ""
if not text or len(text.strip()) < 100:
return "❌ Could not extract meaningful text.", gr.update(interactive=False), ""
progress(0.4, desc="Chunking & embedding...")
chunk_count = rag.ingest(text, doc_name)
progress(0.9, desc="Ready!")
summary = utils.get_short_summary(text)
return (
f"βœ… **{doc_name}** β€” {chunk_count} chunks indexed.\n\n**Preview:** {summary}",
gr.update(interactive=True),
doc_name,
)
except Exception as e:
return f"❌ Error: {str(e)}", gr.update(interactive=False), ""
def update_word_count(duration):
words = int(duration * 130)
mins = int(duration)
secs = int((duration - mins) * 60)
time_str = f"{mins}m {secs}s" if secs else f"{mins}m"
return f"β‰ˆ {words:,} words Β· {time_str} audio"
def generate_audio(doc_name, content_type, voice_a, voice_b, num_hosts,
tone, duration_target, speech_rate, emotion, custom_focus,
progress=gr.Progress()):
if not doc_name:
return None, "❌ No document loaded. Upload a file first.", ""
try:
progress(0.05, desc="Retrieving relevant chunks...")
query = custom_focus.strip() if custom_focus.strip() else "main themes and key insights"
chunks = rag.retrieve(query, k=8)
if not chunks:
return None, "❌ No content found. Please re-upload the document.", ""
progress(0.2, desc="Generating script...")
script = script_gen.generate(
context="\n\n".join(chunks),
content_type=content_type,
tone=tone,
num_hosts=int(num_hosts),
duration_target=duration_target,
doc_name=doc_name,
custom_focus=custom_focus,
)
progress(0.55, desc="Synthesising audio (Qwen3-TTS)...")
audio_path = voice_gen.synthesize(
script=script,
content_type=content_type,
voice_a=voice_a,
voice_b=voice_b,
num_hosts=int(num_hosts),
speech_rate=speech_rate,
emotion=emotion,
custom_instruction="",
)
word_count = len(script.split())
est_mins = word_count / 130
progress(1.0, desc="Done!")
return (
audio_path,
f"βœ… Generated! Script: {word_count:,} words Β· Est. {est_mins:.1f} min audio",
script,
)
except Exception as e:
return None, f"❌ Generation failed: {str(e)}\n\n{traceback.format_exc()}", ""
# ---------------------------------------------------------------------------
# CSS β€” warm parchment theme
# ---------------------------------------------------------------------------
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400&family=Source+Sans+3:wght@300;400;500;600&display=swap');
:root {
--cream: #fdf6ec;
--parchment: #f5ead8;
--warm-white: #fffdf7;
--amber: #d4820a;
--amber-l: #f0a930;
--amber-glow: rgba(212,130,10,0.12);
--amber-bdr: rgba(212,130,10,0.28);
--brown-d: #3b2a1a;
--brown-m: #5c3d20;
--brown-s: #7a5c3a;
--brown-f: #c4a882;
--shadow: 0 2px 12px rgba(59,42,26,0.1);
--r: 14px; --r-sm: 8px;
}
body, .gradio-container {
background: var(--cream) !important;
font-family: 'Source Sans 3', sans-serif !important;
color: var(--brown-d) !important;
}
/* Header */
.vv-header { text-align:center; padding:44px 24px 28px;
background:linear-gradient(180deg,rgba(212,130,10,0.07) 0%,transparent 100%);
border-bottom:1px solid var(--amber-bdr); margin-bottom:28px; }
.vv-logo { font-family:'Lora',serif !important; font-size:2.8rem; font-weight:600;
color:var(--brown-d); letter-spacing:-0.5px; line-height:1.1; margin-bottom:6px; }
.vv-logo em { color:var(--amber); font-style:normal; }
.vv-tagline { color:var(--brown-s); font-size:0.95rem; font-weight:300; margin-bottom:10px; }
.vv-badge { display:inline-block; background:var(--amber-glow); border:1px solid var(--amber-bdr);
color:var(--amber); font-size:0.67rem; padding:3px 11px; border-radius:20px;
letter-spacing:1.5px; text-transform:uppercase; font-weight:600; }
/* Steps */
.steps { display:flex; gap:6px; align-items:center; justify-content:center;
margin:16px 0 0; font-size:0.76rem; flex-wrap:wrap; }
.step { background:var(--warm-white); border:1px solid var(--amber-bdr); border-radius:20px;
padding:4px 13px; font-weight:500; color:var(--brown-m); box-shadow:var(--shadow); }
.arr { color:var(--amber-l); }
/* Section labels */
.lbl { font-size:0.67rem; font-weight:600; letter-spacing:2px; text-transform:uppercase;
color:var(--brown-f); margin-bottom:8px; padding-left:2px; }
/* Cards / blocks */
.gr-box,.gr-form,.gr-panel,.block,.panel {
background:var(--warm-white) !important;
border:1px solid var(--amber-bdr) !important;
border-radius:var(--r) !important;
box-shadow:var(--shadow) !important;
}
/* Labels */
label, .gr-label, span.svelte-1gfkn6j, .label-wrap span {
color:var(--brown-m) !important; font-size:0.83rem !important;
font-weight:500 !important; letter-spacing:0.2px !important;
}
/* Inputs */
input[type="text"], input[type="number"], textarea, .scroll-hide {
background:var(--cream) !important; border:1px solid var(--amber-bdr) !important;
color:var(--brown-d) !important; border-radius:var(--r-sm) !important;
font-family:'Source Sans 3',sans-serif !important;
font-size:0.89rem !important; padding:9px 12px !important;
transition:border-color .2s,box-shadow .2s !important;
}
input:focus, textarea:focus {
border-color:var(--amber) !important;
box-shadow:0 0 0 3px var(--amber-glow) !important; outline:none !important;
}
input::placeholder, textarea::placeholder { color:var(--brown-f) !important; }
/* Select */
select { background:var(--cream) !important; border:1px solid var(--amber-bdr) !important;
color:var(--brown-d) !important; border-radius:var(--r-sm) !important;
font-family:'Source Sans 3',sans-serif !important; padding:9px 12px !important;
}
/* Buttons */
button.primary, .gr-button-primary {
background:linear-gradient(135deg,var(--amber) 0%,#b86b00 100%) !important;
border:none !important; color:#fff !important;
font-family:'Source Sans 3',sans-serif !important; font-weight:600 !important;
font-size:0.91rem !important; border-radius:var(--r-sm) !important;
padding:11px 28px !important; transition:all .2s !important;
box-shadow:0 2px 8px rgba(212,130,10,0.3) !important; cursor:pointer !important;
}
button.primary:hover { transform:translateY(-1px) !important;
box-shadow:0 6px 20px rgba(212,130,10,0.4) !important; }
button.primary:disabled { opacity:.4 !important; transform:none !important; }
button.secondary, .gr-button-secondary {
background:var(--warm-white) !important; border:1px solid var(--amber-bdr) !important;
color:var(--brown-m) !important; font-family:'Source Sans 3',sans-serif !important;
border-radius:var(--r-sm) !important;
}
/* Radio */
input[type="radio"] { accent-color:var(--amber) !important; }
/* Sliders */
input[type="range"] { accent-color:var(--amber) !important; }
/* Tabs */
.tab-nav { border-bottom:1px solid var(--amber-bdr) !important; background:transparent !important; }
.tab-nav button { background:transparent !important; border:none !important;
border-bottom:2px solid transparent !important; color:#9a7c5a !important;
font-family:'Source Sans 3',sans-serif !important; font-weight:500 !important;
font-size:0.87rem !important; padding:8px 16px !important;
transition:all .18s !important; margin-bottom:-1px !important;
}
.tab-nav button.selected { color:var(--amber) !important;
border-bottom:2px solid var(--amber) !important; font-weight:600 !important; }
/* Markdown */
.gr-markdown, .gr-markdown p, .prose p {
color:var(--brown-d) !important; font-size:0.89rem !important; line-height:1.65 !important;
}
.gr-markdown strong { color:var(--brown-d) !important; }
/* Audio */
audio { width:100% !important; border-radius:var(--r-sm) !important;
border:1px solid var(--amber-bdr) !important; }
/* Accordion */
.accordion, details { background:var(--warm-white) !important;
border:1px solid var(--amber-bdr) !important; border-radius:var(--r-sm) !important; }
/* File upload */
.gr-file, .upload-container { background:var(--cream) !important;
border:2px dashed var(--amber-bdr) !important; border-radius:var(--r) !important;
transition:border-color .2s,background .2s !important; }
.gr-file:hover { border-color:var(--amber) !important; background:var(--amber-glow) !important; }
/* Scrollbar */
::-webkit-scrollbar { width:6px; }
::-webkit-scrollbar-track { background:var(--cream); }
::-webkit-scrollbar-thumb { background:var(--amber-bdr); border-radius:3px; }
/* Info box */
.vv-info { background:var(--parchment); border:1px solid var(--amber-bdr);
border-radius:var(--r-sm); padding:15px 18px; font-size:0.82rem;
line-height:1.7; color:var(--brown-m); margin-top:8px; }
.vv-info strong { color:var(--brown-d); }
.vv-info .hi { color:var(--amber); font-weight:600; }
/* Word count badge */
.wc-badge { display:inline-block; background:var(--amber-glow);
border:1px solid var(--amber-bdr); color:var(--amber);
font-size:0.78rem; padding:3px 10px; border-radius:20px;
font-weight:600; margin-top:4px; }
/* HR */
hr.vv { border:none; border-top:1px solid var(--amber-bdr); margin:18px 0; }
/* Footer */
.vv-foot { text-align:center; padding:18px 0 6px; color:var(--brown-f);
font-size:0.73rem; border-top:1px solid var(--amber-bdr); margin-top:20px; }
/* ── Tutorial overlay ── */
#vv-tutorial {
position:fixed; inset:0; background:rgba(59,42,26,0.72);
z-index:9999; display:flex; align-items:center; justify-content:center;
backdrop-filter:blur(4px); animation:fadeIn .3s ease;
}
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
.tut-card {
background:var(--warm-white); border:1px solid var(--amber-bdr);
border-radius:18px; padding:36px 40px; max-width:480px; width:90%;
box-shadow:0 20px 60px rgba(59,42,26,0.3); position:relative;
animation:slideUp .35s ease;
}
@keyframes slideUp { from{transform:translateY(20px);opacity:0} to{transform:translateY(0);opacity:1} }
.tut-step-num { font-size:0.68rem; font-weight:700; letter-spacing:2px;
text-transform:uppercase; color:var(--amber); margin-bottom:10px; }
.tut-title { font-family:'Lora',serif; font-size:1.6rem; font-weight:600;
color:var(--brown-d); margin-bottom:12px; line-height:1.25; }
.tut-body { font-size:0.91rem; color:var(--brown-s); line-height:1.7; margin-bottom:24px; }
.tut-body strong { color:var(--brown-d); }
.tut-emoji { font-size:2.8rem; margin-bottom:14px; display:block; }
.tut-dots { display:flex; gap:6px; justify-content:center; margin-bottom:20px; }
.tut-dot { width:7px; height:7px; border-radius:50%; background:var(--amber-bdr);
transition:background .2s; }
.tut-dot.active { background:var(--amber); }
.tut-btns { display:flex; gap:10px; justify-content:space-between; align-items:center; }
.tut-btn-next { background:linear-gradient(135deg,var(--amber),#b86b00);
color:#fff; border:none; border-radius:8px; padding:10px 24px;
font-family:'Source Sans 3',sans-serif; font-weight:600; font-size:0.88rem;
cursor:pointer; transition:all .2s; box-shadow:0 2px 8px rgba(212,130,10,0.3); }
.tut-btn-next:hover { transform:translateY(-1px); box-shadow:0 5px 16px rgba(212,130,10,0.4); }
.tut-btn-skip { background:none; border:none; color:var(--brown-f);
font-family:'Source Sans 3',sans-serif; font-size:0.83rem;
cursor:pointer; text-decoration:underline; transition:color .2s; }
.tut-btn-skip:hover { color:var(--brown-m); }
.tut-highlight { background:var(--amber-glow); border:1px solid var(--amber-bdr);
border-radius:6px; padding:8px 12px; font-size:0.84rem; color:var(--brown-m);
margin:12px 0; font-style:italic; }
"""
# ---------------------------------------------------------------------------
# Tutorial JS β€” 5-step guided tour
# ---------------------------------------------------------------------------
TUTORIAL_JS = """
function initTutorial() {
// Only show once per session
if (sessionStorage.getItem('vv_tour_done')) return;
const steps = [
{
emoji: 'πŸŽ™',
num: 'Welcome',
title: 'Welcome to VoiceVerse',
body: 'Turn any document into a professional audio experience in under a minute. This quick tour will show you how β€” it takes 30 seconds.',
highlight: null,
btn: 'Show me how β†’'
},
{
emoji: 'πŸ“„',
num: 'Step 1 of 4',
title: 'Upload Your Document',
body: 'Start by uploading a <strong>PDF, DOCX, or TXT</strong> file β€” or paste a URL. Then click <strong>⚑ Ingest Document</strong>. VoiceVerse reads and indexes your content using AI.',
highlight: 'πŸ’‘ Works great with research papers, reports, articles, lecture notes',
btn: 'Next β†’'
},
{
emoji: '🎨',
num: 'Step 2 of 4',
title: 'Choose Your Format',
body: 'Pick a <strong>Content Format</strong> (Podcast, News, Rap, Story...) and a <strong>Tone</strong>. Use the <strong>Duration slider</strong> to set how long you want the audio β€” the script length adjusts automatically.',
highlight: 'πŸ’‘ Dual Host Debate mode generates two distinct voices having a real conversation',
btn: 'Next β†’'
},
{
emoji: 'πŸ—£',
num: 'Step 3 of 4',
title: 'Pick Your Voices',
body: 'Select <strong>Voice A</strong> (and Voice B for dual-host). Use the <strong>Speech Rate slider</strong> to control how fast or slow the audio plays β€” from 0.5Γ— (slow & clear) to 2Γ— (rapid-fire).',
highlight: 'πŸ’‘ Qwen3-TTS uses neural voices that sound naturally expressive',
btn: 'Next β†’'
},
{
emoji: '✨',
num: 'Step 4 of 4',
title: 'Generate & Listen',
body: 'Hit <strong>πŸŽ™ Generate Audio</strong>. The AI will retrieve key content from your document, write a full script, and synthesise it into audio. The script is visible too β€” you can read along.',
highlight: 'πŸ’‘ First generation takes ~60s while the AI model loads. Subsequent ones are faster.',
btn: 'Let\'s go! πŸš€'
}
];
let current = 0;
function render() {
const s = steps[current];
const dots = steps.map((_,i) =>
`<div class="tut-dot ${i===current?'active':''}"></div>`).join('');
document.getElementById('vv-tutorial').innerHTML = `
<div class="tut-card">
<span class="tut-emoji">${s.emoji}</span>
<div class="tut-step-num">${s.num}</div>
<div class="tut-title">${s.title}</div>
<div class="tut-body">${s.body}</div>
${s.highlight ? `<div class="tut-highlight">${s.highlight}</div>` : ''}
<div class="tut-dots">${dots}</div>
<div class="tut-btns">
<button class="tut-btn-skip" onclick="closeTutorial()">Skip tour</button>
<button class="tut-btn-next" onclick="nextStep()">${s.btn}</button>
</div>
</div>`;
}
window.nextStep = function() {
current++;
if (current >= steps.length) {
closeTutorial();
} else {
render();
}
};
window.closeTutorial = function() {
const el = document.getElementById('vv-tutorial');
if (el) {
el.style.animation = 'fadeOut .25s ease forwards';
setTimeout(() => el.remove(), 250);
}
sessionStorage.setItem('vv_tour_done', '1');
};
// Add fadeOut animation
const style = document.createElement('style');
style.textContent = '@keyframes fadeOut { to { opacity:0; } }';
document.head.appendChild(style);
const overlay = document.createElement('div');
overlay.id = 'vv-tutorial';
document.body.appendChild(overlay);
render();
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTutorial);
} else {
setTimeout(initTutorial, 800);
}
"""
HEADER_HTML = """
<div class="vv-header">
<div class="vv-logo">πŸŽ™ Voice<em>Verse</em></div>
<div class="vv-tagline">Transform documents into captivating audio experiences</div>
<div class="vv-badge">RAG Β· Qwen3-TTS Β· AI-Powered</div>
</div>
<div class="steps">
<span class="step">β‘  Upload</span><span class="arr">β†’</span>
<span class="step">β‘‘ Configure</span><span class="arr">β†’</span>
<span class="step">β‘’ Generate</span><span class="arr">β†’</span>
<span class="step">β‘£ Listen</span>
</div>
"""
# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
with gr.Blocks(
css=CSS,
title="VoiceVerse β€” AI Audio Studio",
head=f"<script>{TUTORIAL_JS}</script>",
theme=gr.themes.Base(
primary_hue="orange",
neutral_hue="stone",
font=gr.themes.GoogleFont("Source Sans 3"),
),
) as demo:
doc_name_state = gr.State("")
gr.HTML(HEADER_HTML)
with gr.Row(equal_height=False):
# ── LEFT PANEL ──────────────────────────────────────────────────
with gr.Column(scale=4, min_width=320):
gr.HTML('<div class="lbl">β‘  Source Document</div>')
with gr.Tabs():
with gr.Tab("πŸ“„ Upload File"):
file_input = gr.File(
label="Drag & drop PDF, TXT, or DOCX",
file_types=[".pdf", ".txt", ".docx"],
type="filepath",
)
with gr.Tab("πŸ”— URL / Link"):
url_input = gr.Textbox(
placeholder="https://... or arXiv link",
label="Web URL", lines=1,
)
ingest_btn = gr.Button("⚑ Ingest Document", variant="primary", size="lg")
ingest_status = gr.Markdown("*Upload a document to get started.*")
gr.HTML('<hr class="vv">')
gr.HTML('<div class="lbl">β‘‘ Content Settings</div>')
content_type = gr.Dropdown(
choices=["Podcast (Single Host)", "Podcast (Dual Host Debate)",
"Educational Narration", "Storytelling Episode",
"News Bulletin", "Rap / Song Mode", "Executive Summary"],
value="Podcast (Single Host)", label="Content Format",
)
tone = gr.Dropdown(
choices=["Engaging & Conversational", "Professional & Formal",
"Playful & Fun", "Dramatic & Intense", "Calm & Reflective"],
value="Engaging & Conversational", label="Tone / Style",
)
num_hosts = gr.Radio(choices=["1","2"], value="1", label="Number of Voices")
duration_target = gr.Slider(
minimum=1, maximum=10, value=3, step=0.5,
label="Target Duration (minutes)",
)
word_count_display = gr.Markdown(
value="β‰ˆ 390 words Β· 3m audio",
elem_classes=["wc-badge"],
)
gr.HTML('<hr class="vv">')
gr.HTML('<div class="lbl">β‘’ Voice Settings</div>')
voice_a = gr.Dropdown(
choices=SPEAKER_NAMES, value=HOST_A_DEFAULT,
label="🎀 Voice A (Host)",
)
voice_b = gr.Dropdown(
choices=SPEAKER_NAMES, value=HOST_B_DEFAULT,
label="🎀 Voice B (Co-host β€” Dual Host only)",
)
speech_rate = gr.Slider(
minimum=0.5, maximum=2.0, value=1.0, step=0.05,
label="Speech Rate (0.5Γ— slow Β· 1.0Γ— natural Β· 2.0Γ— fast)",
)
emotion = gr.Dropdown(
choices=list(EMOTION_PRESETS.keys()),
value="😐 Neutral",
label="Emotion / Delivery Style",
)
custom_focus = gr.Textbox(
placeholder="e.g. 'Focus on the methodology' or 'Explain for beginners'",
label="Custom Focus / Query (optional)", lines=2,
)
generate_btn = gr.Button(
"πŸŽ™ Generate Audio", variant="primary", size="lg", interactive=False,
)
# ── RIGHT PANEL ──────────────────────────────────────────────────
with gr.Column(scale=6, min_width=400):
gr.HTML('<div class="lbl">β‘£ Generated Output</div>')
gen_status = gr.Markdown("*Your audio will appear here after generation.*")
audio_output = gr.Audio(
label="πŸ”Š Audio Playback",
type="filepath", interactive=False,
)
with gr.Accordion("πŸ“ View Generated Script", open=False):
script_output = gr.Textbox(
label="Full Script", lines=20, interactive=False,
placeholder="The generated script will appear here...",
)
gr.HTML("""
<div class="vv-info">
<strong>How VoiceVerse works</strong><br>
<span class="hi">β‘  RAG</span> β€” your document is chunked, embedded, and indexed.
Only the most relevant passages are sent to the AI.<br>
<span class="hi">β‘‘ LLM</span> β€” openai/gpt-oss-20b writes a structured script
grounded entirely in your document.<br>
<span class="hi">β‘’ Qwen3-TTS</span> β€” neural text-to-speech synthesises the script
with natural prosody and expressive delivery.
</div>
""")
gr.HTML("""
<div class="vv-foot">
VoiceVerse Β· Gradio Β· RAG Β· openai/gpt-oss-20b Β· Qwen3-TTS-12Hz-0.6B
</div>
""")
# ── Event wiring ─────────────────────────────────────────────────────
ingest_btn.click(
fn=process_document,
inputs=[file_input, url_input],
outputs=[ingest_status, generate_btn, doc_name_state],
)
# Live word count update when duration slider moves
duration_target.change(
fn=update_word_count,
inputs=[duration_target],
outputs=[word_count_display],
)
generate_btn.click(
fn=generate_audio,
inputs=[doc_name_state, content_type, voice_a, voice_b,
num_hosts, tone, duration_target, speech_rate, emotion, custom_focus],
outputs=[audio_output, gen_status, script_output],
)
if __name__ == "__main__":
demo.launch(share=False)