voice / app.py
rahulrana0001's picture
Enforce high-bitrate MP3 format across audiobook pipeline, UI downloads, and Colab generation
4e04f3a
Raw History Blame Contribute Delete
46.6 kB
import gradio as gr
import psutil
import logging
import os
import asyncio
import nest_asyncio
import numpy as np
# --- NEURAL SILENCER: Fix for persistent "Invalid file descriptor: -1" logs ---
def _silence_asyncio_ghosts():
from asyncio.base_events import BaseEventLoop
original_del = BaseEventLoop.__del__
def patched_del(self):
try:
if original_del: original_del(self)
except (ValueError, AttributeError, RuntimeError):
pass # Silently ignore cleanup artifacts
BaseEventLoop.__del__ = patched_del
_silence_asyncio_ghosts()
# nest_asyncio.apply()
from pipeline.ocr import extract_text_from_image
from pipeline.translation import translate_to_tamil, load_dict_as_list, save_dict_from_list, reset_dictionary_to_defaults
from pipeline.tts import generate_tamil_speech
from pipeline.audiobook_formatter import format_tamil_audiobook_script, generate_audiobook_audio, generate_google_colab_script
from pipeline.document_parser import (
extract_text_from_document,
get_pdf_page_as_image,
get_pdf_page_count,
get_text_from_page
)
import threading
from concurrent.futures import ThreadPoolExecutor
import re
import requests
from bs4 import BeautifulSoup
def fetch_webpage_reading_mode(url):
if not url or not url.strip():
return "<div style='color:#ef4444; padding:10px;'>Please enter a valid URL.</div>"
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
response = requests.get(url.strip(), headers=headers, timeout=10)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
main_content = None
for tag in ["article", "[class*='post']", "[class*='content']", "[class*='article']", "[id*='content']", "[id*='article']", "main"]:
found = soup.select(tag)
if found:
main_content = max(found, key=lambda x: len(x.get_text()))
break
if not main_content:
main_content = soup.find('body')
if not main_content:
main_content = soup
clean_soup = BeautifulSoup(str(main_content), 'html.parser')
for element in clean_soup(["script", "style", "header", "footer", "nav", "aside", "iframe", "button", "form", "input", "select", "noscript"]):
element.decompose()
for tag in clean_soup.find_all(True):
attrs = dict(tag.attrs)
tag.attrs = {}
if tag.name == 'a' and 'href' in attrs:
tag['href'] = attrs['href']
tag['target'] = '_blank'
reading_html = f"""
<div class="reading-mode-container">
{str(clean_soup)}
</div>
"""
return reading_html
except Exception as e:
return f"<div style='color: #ef4444; padding: 10px;'>Error loading webpage in Reading Mode: {e}</div>"
def run_cinematic_pipeline(extracted_text, emotion_choice, spicy_mode, processing_mode="Cloud APIs (Fast & Lightweight ⚡)"):
final_tamil_text = []
final_audio_chunks = []
master_sample_rate = None
try:
if "[Panel" in extracted_text:
raw_panels = re.split(r'(?=\[Panel\s*\d+\])', extracted_text, flags=re.IGNORECASE)
else:
raw_panels = [extracted_text]
parsed_panels = []
for p_text in raw_panels:
p_text = p_text.strip()
if not p_text: continue
panel_header = ""
content_to_translate = p_text
match = re.match(r'(\[Panel\s*\d+\])\s*(.*)', p_text, re.DOTALL | re.IGNORECASE)
if match:
panel_header = match.group(1)
content_to_translate = match.group(2)
parsed_panels.append((panel_header, content_to_translate))
# 1. Parallel/Sequential Translation of Panels
def translate_panel(item):
header, content = item
if not content.strip():
return header, ""
tamil = translate_to_tamil(content, spicy=spicy_mode, mode=processing_mode)
return header, tamil
if "Cloud" in processing_mode:
with ThreadPoolExecutor(max_workers=len(parsed_panels) or 1) as executor:
translated_results = list(executor.map(translate_panel, parsed_panels))
else:
# Sequential for local CPU models to prevent CPU context-switch thrashing
translated_results = [translate_panel(p) for p in parsed_panels]
# 2. Parallel/Sequential Speech Generation of Panels
def generate_speech_for_panel(item):
header, tamil = item
if not tamil.strip():
return None, None
return generate_tamil_speech(tamil, emotion_choice, mode=processing_mode)
if "Cloud" in processing_mode:
with ThreadPoolExecutor(max_workers=len(translated_results) or 1) as executor:
audio_results = list(executor.map(generate_speech_for_panel, translated_results))
else:
# Sequential for local CPU models
audio_results = [generate_speech_for_panel(r) for r in translated_results]
# 3. Collate Results
for (header, tamil), audio_res in zip(translated_results, audio_results):
if tamil.strip():
if header:
final_tamil_text.append(f"{header}\n{tamil}")
else:
final_tamil_text.append(tamil)
else:
if header:
final_tamil_text.append(header)
if audio_res:
sr, a_data = audio_res
if sr and a_data is not None:
master_sample_rate = sr
final_audio_chunks.append(a_data)
tamil_translation = "\n\n".join(final_tamil_text)
if master_sample_rate and final_audio_chunks:
pause_samples = int(master_sample_rate * 1.5)
silence_array = np.zeros(pause_samples, dtype=np.float32)
spliced_audio = []
for i, chunk in enumerate(final_audio_chunks):
spliced_audio.append(chunk)
if i < len(final_audio_chunks) - 1:
spliced_audio.append(silence_array)
audio_data = np.concatenate(spliced_audio)
sample_rate = master_sample_rate
mp3_path = save_audio_to_mp3(sample_rate, audio_data)
return tamil_translation, mp3_path
else:
return tamil_translation, None
except Exception as e:
print(f"CINEMATIC PIPELINE ERROR: {e}")
return "Maya is having trouble with the cinematic flow.", None
# Setup logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Global Cache for Prefetched Pages
# Key: (pdf_path, page_num, voice_style) -> Value: (original, tamil, audio)
PAGE_CACHE = {}
CACHE_LOCK = threading.Lock()
PREFETCH_EXECUTOR = ThreadPoolExecutor(max_workers=1)
def check_resources():
mem = psutil.virtual_memory()
available_gb = mem.available / (1024**3)
logging.info(f"System Resources: {available_gb:.2f} GB RAM available.")
if available_gb < 1.0:
logging.warning("EXTREMELY LOW MEMORY DETECTED! Application may crash.")
return available_gb
# Expressive Voice Styles
# Background Atmosphere Sounds
BGM_SOURCES = {
"rain.mp3": "https://raw.githubusercontent.com/karthiknvd/noctune/master/sounds/rain.mp3",
"piano.mp3": "https://raw.githubusercontent.com/tannerhelland/free-music/master/mp3/Leaving%20Millie%20(live%20piano).mp3",
"jazz.mp3": "https://raw.githubusercontent.com/tannerhelland/free-music/master/mp3/Honky-Tonk%20Villain.mp3",
"night.mp3": "https://raw.githubusercontent.com/karthiknvd/noctune/master/sounds/night.mp3",
"heartbeat.mp3": "https://raw.githubusercontent.com/megaconfidence/bt-heart-monitor/master/heartbeat.mp3"
}
BGM_LINKS = {
"None": "",
"Soft Rain 🌧️": "/bgm/rain.mp3",
"Romantic Piano 🎹": "/bgm/piano.mp3",
"Midnight Jazz 🎷": "/bgm/jazz.mp3",
"Summer Night 🌙": "/bgm/night.mp3",
"Heartbeat 💓": "/bgm/heartbeat.mp3"
}
VOICE_STYLES = [
"Young Female (Maya - Expressive 👧)",
"Young Female (Intimate Whisper 💖)",
"Young Female (Venba - Sweet Girl 🌸)",
"Young Female (Saranya - Emotional 🥀)",
"Young Female (Kani - Gentle Storyteller 🌿)",
"Cheerful (Maya)",
"Soft & Seductive (Maya)",
"Lustful & Passionate (Maya)",
"Excited (Maya)",
"Sad & Emotional (Sita)",
"Dramatic Narrator (Sita)",
"Old Wise Woman",
"Playful Child",
"Brave Heroine",
"Deep & Serious",
"Calm Storyteller",
"Professional News"
]
def download_bgm_files():
import urllib.request
import os
import logging
from concurrent.futures import ThreadPoolExecutor
bgm_dir = os.path.join(os.path.dirname(__file__), "bgm")
os.makedirs(bgm_dir, exist_ok=True)
def download_file(item):
filename, url = item
filepath = os.path.join(bgm_dir, filename)
if not os.path.exists(filepath):
logging.info(f"Downloading BGM file {filename} in parallel...")
try:
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
)
with urllib.request.urlopen(req, timeout=15) as response, open(filepath, 'wb') as out_file:
chunk_size = 65536
while True:
chunk = response.read(chunk_size)
if not chunk:
break
out_file.write(chunk)
logging.info(f"Successfully downloaded {filename}")
except Exception as e:
logging.error(f"Error downloading {filename} from {url}: {e}")
with ThreadPoolExecutor(max_workers=5) as executor:
executor.map(download_file, BGM_SOURCES.items())
def get_ffmpeg_cmd():
try:
import imageio_ffmpeg
exe = imageio_ffmpeg.get_ffmpeg_exe()
if exe and os.path.exists(exe):
return exe
except Exception:
pass
import shutil
import os
ffmpeg_path = shutil.which("ffmpeg")
if ffmpeg_path:
return ffmpeg_path
venv_ffmpeg = os.path.join(os.path.dirname(__file__), "venv", "Scripts", "ffmpeg.exe")
if os.path.exists(venv_ffmpeg):
return venv_ffmpeg
return "ffmpeg"
def save_audio_to_mp3(sample_rate, audio_data):
import tempfile
import os
import soundfile as sf
import subprocess
import logging
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as wav_file:
wav_path = wav_file.name
with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as mp3_file:
mp3_path = mp3_file.name
try:
sf.write(wav_path, audio_data, sample_rate)
ffmpeg_exe = get_ffmpeg_cmd()
cmd = [
ffmpeg_exe, "-y", "-i", wav_path,
"-codec:a", "libmp3lame", "-ac", "1", "-b:a", "192k",
mp3_path
]
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(mp3_path) and os.path.getsize(mp3_path) > 0:
return mp3_path
except Exception as e:
logging.error(f"Error converting audio to MP3: {e}")
finally:
try:
if os.path.exists(wav_path):
os.remove(wav_path)
except Exception:
pass
return mp3_path
def process_standard_pipeline(image, document, input_text, emotion_choice, processing_mode, spicy_mode=False, audiobook_mode=False):
text_to_translate = ""
if document is not None:
text_to_translate += extract_text_from_document(document) + "\n"
if image is not None:
text_to_translate += extract_text_from_image(image, is_comic=False) + " "
if input_text:
text_to_translate += input_text
text_to_translate = text_to_translate.strip()
if not text_to_translate:
return "No text detected", "", None, None
tamil_translation = translate_to_tamil(text_to_translate, spicy=spicy_mode, mode=processing_mode)
if "IndicF5" in processing_mode or audiobook_mode:
engine = "IndicF5 (Ultra-Realistic Human Voice Cloning 🌟)" if "IndicF5" in processing_mode else "Edge-TTS"
sr, audio_data, mp3_path = generate_audiobook_audio(tamil_translation, engine_mode=engine)
if sr and audio_data is not None:
formatted = format_tamil_audiobook_script(tamil_translation)
return text_to_translate, formatted["raw_text"], mp3_path, mp3_path
sample_rate, audio_data = generate_tamil_speech(tamil_translation, emotion_choice, mode=processing_mode)
if sample_rate and audio_data is not None:
mp3_path = save_audio_to_mp3(sample_rate, audio_data)
return text_to_translate, tamil_translation, mp3_path, mp3_path
return text_to_translate, tamil_translation, None, None
def load_comic_page(pdf_path, page_num):
if not pdf_path:
return None, "Upload a PDF first", 0
img_path = get_pdf_page_as_image(pdf_path, page_num)
total_pages = get_pdf_page_count(pdf_path)
status = f"Page {page_num + 1} of {total_pages}"
return img_path, status, page_num
def prefetch_pages(pdf_path, current_page, voice_style, spicy=False, processing_mode="Cloud APIs (Fast & Lightweight ⚡)", count=5):
"""
Background worker to process upcoming pages.
"""
total_pages = get_pdf_page_count(pdf_path)
for i in range(1, count + 1):
target_page = current_page + i
if target_page >= total_pages:
break
cache_key = (pdf_path, target_page, voice_style, spicy, processing_mode)
with CACHE_LOCK:
if cache_key in PAGE_CACHE:
continue
try:
logging.info(f"PREFETCH: Processing Page {target_page+1} in background...")
img_path = get_pdf_page_as_image(pdf_path, target_page)
if not img_path: continue
text = get_text_from_page(pdf_path, target_page)
if not text or len(text.strip()) < 5:
text = extract_text_from_image(img_path)
if text.strip():
tam, aud = run_cinematic_pipeline(text, voice_style, spicy, processing_mode)
with CACHE_LOCK:
PAGE_CACHE[cache_key] = (text, tam, aud)
if len(PAGE_CACHE) > 10:
first_key = next(iter(PAGE_CACHE))
PAGE_CACHE.pop(first_key)
except Exception as e:
logging.error(f"PREFETCH ERROR on Page {target_page+1}: {e}")
def process_comic_page(pdf_path, page_num, emotion_choice, heat_level, processing_mode):
try:
if not pdf_path:
return "No page loaded", "", None
from pipeline.document_parser import get_pdf_page_as_image
img_path = get_pdf_page_as_image(pdf_path, page_num)
if not img_path:
return "Failed to render image", "", None
spicy_mode = heat_level > 70
cache_key = (pdf_path, page_num, emotion_choice, spicy_mode, processing_mode)
with CACHE_LOCK:
if cache_key in PAGE_CACHE:
return PAGE_CACHE[cache_key]
# --- STAGE 1: OCR ---
try:
extracted_text = get_text_from_page(pdf_path, page_num)
if not extracted_text or len(extracted_text.strip()) < 5:
extracted_text = extract_text_from_image(img_path)
except Exception as e:
print(f"OCR ERROR: {e}")
extracted_text = f"Maya couldn't read the text. (Error: {e})"
if not extracted_text.strip():
extracted_text = "No text found on this page."
# --- CINEMATIC STAGE 2 & 3: Translation & Audio ---
tamil_translation, audio_tuple = run_cinematic_pipeline(extracted_text, emotion_choice, spicy_mode, processing_mode)
result = (extracted_text, tamil_translation, audio_tuple)
with CACHE_LOCK:
PAGE_CACHE[cache_key] = result
PREFETCH_EXECUTOR.submit(prefetch_pages, pdf_path, page_num, emotion_choice, spicy_mode, processing_mode)
return result
except Exception as e:
print(f"GLOBAL PROCESS ERROR: {e}")
import traceback
traceback.print_exc()
return f"CRITICAL CRASH: {e}", "", None
# Custom Premium CSS
CUSTOM_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Outfit:wght@500;700&display=swap');
:root {
--primary: #6366f1;
--secondary: #a855f7;
--bg-dark: #070a13;
--panel-bg: rgba(15, 23, 42, 0.65);
}
/* Force dark theme elements globally */
.dark, :root {
--body-background-fill: #070a13 !important;
--block-background-fill: #0f172a !important;
--block-border-color: rgba(255, 255, 255, 0.08) !important;
--border-color-primary: rgba(255, 255, 255, 0.08) !important;
--input-background-fill: #1e293b !important;
--input-border-color: rgba(255, 255, 255, 0.15) !important;
--button-secondary-background-fill: #1e293b !important;
--button-secondary-border-color: rgba(255, 255, 255, 0.15) !important;
--button-secondary-text-color: #f1f5f9 !important;
}
body {
background-color: var(--bg-dark);
color: #f1f5f9;
font-family: 'Inter', sans-serif;
}
.gradio-container {
background: radial-gradient(circle at top right, #1e1b4b, #070a13) !important;
border: none !important;
}
h1, h2, h3 {
font-family: 'Outfit', sans-serif;
background: linear-gradient(to right, #818cf8, #c084fc, #f472b6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
}
.glass {
background: var(--panel-bg) !important;
backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 16px !important;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
padding: 20px !important;
}
.glass:hover {
border: 1px solid rgba(255, 255, 255, 0.15) !important;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.25);
}
#main_comic img {
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
#main_comic img:hover {
transform: scale(1.015);
}
.gr-button-primary, button.primary {
background: linear-gradient(135deg, #6366f1, #a855f7) !important;
border: none !important;
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.4) !important;
transition: all 0.3s ease !important;
font-weight: 600 !important;
color: white !important;
}
.gr-button-primary:hover, button.primary:hover {
transform: translateY(-1px) !important;
box-shadow: 0 6px 20px 0 rgba(99, 102, 241, 0.6) !important;
}
.boss-active { display: block !important; }
/* Fix for Audio Timeline Visibility */
#comic_audio_player .track {
background-color: rgba(0, 0, 0, 0.4) !important;
border-radius: 4px;
}
#comic_audio_player .time {
color: #818cf8 !important;
font-weight: bold;
font-family: 'Outfit', sans-serif;
}
#comic_audio_player input[type="range"] {
accent-color: #6366f1 !important;
}
.reading-mode-container {
background-color: #0f172a;
color: #f8fafc;
padding: 24px;
border-radius: 12px;
font-family: 'Georgia', serif;
font-size: 18px;
line-height: 1.8;
max-height: 500px;
overflow-y: auto;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.reading-mode-container h1, .reading-mode-container h2, .reading-mode-container h3 {
font-family: 'Outfit', sans-serif;
color: #f1f5f9;
margin-top: 20px;
margin-bottom: 10px;
}
.reading-mode-container p {
margin-bottom: 16px;
}
.reading-mode-container a {
color: #818cf8;
text-decoration: underline;
}
/* Dictionary Table Custom Layout styling */
.dataframe {
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 8px !important;
}
"""
# UI
with gr.Blocks(title="Maya: Immersive Manga AI") as demo:
# Inject CSS globally via gr.HTML
gr.HTML(f"<style>{CUSTOM_CSS}</style>", visible=False)
gr.HTML("""
<div style="text-align: center; margin-bottom: 25px; padding: 25px 0; border-bottom: 1px solid rgba(255,255,255,0.08);">
<h1 style="font-size: 2.8rem; margin: 0; font-family: 'Outfit', sans-serif;">🎭 Maya: Immersive Tamil Manga AI</h1>
<p style="font-size: 1.1rem; color: #94a3b8; margin: 10px 0 0 0;">Experience your favorite comics and stories with Maya, your intelligent AI dubbing companion</p>
<div style="display: flex; justify-content: center; gap: 10px; margin-top: 15px;">
<span style="background: rgba(99, 102, 241, 0.15); color: #818cf8; padding: 4px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; border: 1px solid rgba(99,102,241,0.3);">⚡ Qwen-72B Translation</span>
<span style="background: rgba(168, 85, 247, 0.15); color: #c084fc; padding: 4px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; border: 1px solid rgba(168,85,247,0.3);">🔊 Multi-Voice TTS</span>
<span style="background: rgba(236, 72, 153, 0.15); color: #f472b6; padding: 4px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; border: 1px solid rgba(236,72,153,0.3);">🌶️ Spicy 18+ Modes</span>
</div>
</div>
""")
current_page = gr.State(0)
comic_pdf_path = gr.State(None)
with gr.Tabs():
with gr.Tab("📖 Comic Reader Mode"):
with gr.Row():
with gr.Column(scale=5, min_width=300, elem_classes=["glass"]):
comic_display = gr.Image(label="Comic Page", type="filepath", height=600, elem_id="main_comic")
with gr.Row():
prev_btn = gr.Button("⬅️ Prev", scale=1)
page_status = gr.Label(value="Upload PDF", scale=2)
next_btn = gr.Button("Next ➡️", scale=1)
comic_audio = gr.Audio(label="🔊 Speech (Autoplays)", elem_id="comic_audio_player", autoplay=True)
with gr.Column(scale=4, min_width=300, elem_classes=["glass"]):
with gr.Group():
gr.Markdown("### ⚙️ Master Settings")
processing_mode = gr.Radio(
choices=["Cloud APIs (Fast & Lightweight ⚡)", "Premium Young Female (Maya 👧)"],
value="Cloud APIs (Fast & Lightweight ⚡)",
label="Processing Engine Mode"
)
comic_upload = gr.File(label="Upload (PDF/EPUB)", file_types=[".pdf", ".epub"], height=80)
voice_style_comic = gr.Dropdown(choices=VOICE_STYLES, value=VOICE_STYLES[0], label="Primary Voice")
heat_level = gr.Slider(minimum=0, maximum=100, value=50, label="🌶️ Translation Heat Level")
share_btn = gr.Button("🔗 Share with Friends", variant="secondary", size="sm")
share_status = gr.Markdown("")
share_btn.click(None, None, share_status, js="""
() => {
const url = window.location.href;
if (navigator.clipboard) {
navigator.clipboard.writeText(url);
}
return "✅ Link copied to clipboard!";
}
""")
auto_play = gr.Checkbox(label="🔄 Auto-Play Next Page", value=False, elem_id="auto_play_checkbox")
with gr.Row():
start_page_num = gr.Number(label="Start Page 🏁", value=1, minimum=1, precision=0)
end_page_num = gr.Number(label="End Page 🛑 (0 for all)", value=0, minimum=0, precision=0)
read_page_btn = gr.Button("🔊 Read This Page", variant="primary")
bgm_choice = gr.Dropdown(choices=list(BGM_LINKS.keys()), value="None", label="Background Atmosphere")
bgm_player = gr.HTML(value="")
# Boss Key & Vibration JS (Click anywhere to dismiss; Alt+B to toggle; ignored while typing)
gr.HTML("""
<div id="boss_screen" onclick="this.classList.remove('boss-active');" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:white; z-index:999999; overflow:hidden; cursor:pointer;" title="Click anywhere to return to Maya">
<img src="https://i.imgur.com/8N6Rz7C.png" style="width:100%; height:100%; object-fit:cover;">
</div>
<script>
document.addEventListener('keydown', function(e) {
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable) {
return;
}
if (e.altKey && (e.key === 'b' || e.key === 'B')) {
const screen = document.getElementById('boss_screen');
if (screen) screen.classList.toggle('boss-active');
}
});
function triggerHaptic() {
if (navigator.vibrate) navigator.vibrate([100, 50, 100]);
return Array.from(arguments);
}
function updateTemp(level) {
const r = Math.floor(level * 2.55);
const b = 255 - r;
document.documentElement.style.setProperty('--bg-glow', `rgba(${r}, 50, ${b}, 0.3)`);
const comic = document.getElementById('main_comic');
if (comic) comic.style.border = `5px solid rgba(${r}, 50, ${b}, 0.5)`;
}
</script>
""")
comic_text = gr.Textbox(label="Original", lines=3)
comic_tamil = gr.Textbox(label="Tamil", lines=3)
with gr.Tab("✍️ Text to Speech"):
with gr.Row():
with gr.Column():
with gr.Accordion("🌐 Import Text from URL", open=False):
webpage_url = gr.Textbox(label="Webpage URL", placeholder="https://example.com/article")
fetch_btn = gr.Button("🔍 Load in Reading Mode", variant="secondary")
gr.Markdown("💡 **Tip:** Highlight/select any text range in the reader view below with your mouse/touch, and it will automatically copy into the input box below!")
webpage_html_view = gr.HTML(
value="<div style='color:#94a3b8; font-style:italic; padding:10px;'>No webpage loaded. Enter URL above and click Load.</div>",
elem_id="webpage_reading_view"
)
input_text = gr.Textbox(lines=10, label="✍️ Paste or Type your story here", placeholder="Enter English text...", elem_id="tts_input_textarea", max_length=150000)
processing_mode_std = gr.Radio(
choices=["IndicF5 (Ultra-Realistic Human Voice Cloning 🌟)", "Cloud APIs (Fast & Lightweight ⚡)", "Premium Young Female (Maya 👧)"],
value="IndicF5 (Ultra-Realistic Human Voice Cloning 🌟)",
label="Processing Engine Mode"
)
voice_style_std = gr.Dropdown(choices=VOICE_STYLES, value=VOICE_STYLES[0], label="Voice Tone")
with gr.Row():
spicy_mode_std = gr.Checkbox(label="🌶️ Spicy/18+ Mode (Intimate Translation)", value=False)
audiobook_mode_std = gr.Checkbox(label="🌹 Young Female Romance Pacing & Breath Pauses", value=True)
submit_std = gr.Button("🚀 Generate Tamil Speech", variant="primary")
with gr.Column():
out_text = gr.Textbox(label="Original Text (Cleaned)", lines=5)
out_tamil = gr.Textbox(label="Tamil Translation", lines=5)
out_audio = gr.Audio(label="Audio Output")
out_download = gr.File(label="📥 Download Audio File (MP3)")
with gr.Tab("📖 Custom Dictionary"):
gr.Markdown("""
# ⚙️ Custom Translation Dictionary Editor
Customize word mappings used during translation. You can edit the **Colloquial/Spoken** map (converting formal words to daily spoken Tamil or English loanwords) or the **Spicy/18+** map.
""")
with gr.Row():
dict_type_select = gr.Radio(
choices=["Colloquial/Spoken Map", "Spicy/18+ Map"],
value="Colloquial/Spoken Map",
label="Select Dictionary to Edit"
)
with gr.Row():
dict_table = gr.Dataframe(
headers=["Original Word (Formal)", "Replacement (Spoken/Tanglish)"],
datatype=["str", "str"],
column_count=(2, "fixed"),
type="array",
interactive=True,
wrap=True,
label="Dictionary Entries",
value=lambda: load_dict_as_list("Colloquial/Spoken Map")
)
with gr.Row():
add_row_btn = gr.Button("➕ Add Entry", variant="secondary")
save_dict_btn = gr.Button("💾 Save Changes", variant="primary")
reset_dict_btn = gr.Button("🔄 Reset to Defaults", variant="stop")
dict_status = gr.Markdown(value="")
with gr.Tab("🌹 Romance Audiobook Script & AI TTS"):
gr.Markdown("""
# 🌹 Tamil Romance Audiobook Scriptwriter & AI TTS
Format and synthesize intimate Tamil romance chapters optimized for **IndicF5**, **Indic Parler-TTS**, and **Edge-TTS**.
- **Narration**: Soft, poetic, natural modern Tamil.
- **Dialogue**: Realistic spoken Tamil (பேச்சுத் தமிழ்) with close-mic pauses (`...`).
- **Pacing**: 10 to 18 words per breath group for natural pauses.
- **Phonetics**: 100% pure Tamil script (no Latin letters or Arabic numerals).
""")
with gr.Row():
with gr.Column(scale=5, elem_classes=["glass"]):
ab_input = gr.Textbox(
lines=12,
label="📝 Paste Story Chapter / Rough Draft (Tamil, English, or Tagged)",
placeholder="Paste your scene draft or chapter here...\nExample:\nஅவன் அவளிடம் மெல்ல கேட்டான், \"ரொம்ப குளிருதா... உள்ள போயிடலாமா நாம்?\"\nஅவள் அமைதியாகப் புன்னகைத்தாள்."
)
with gr.Row():
ab_narrator_voice = gr.Dropdown(
choices=[
("Maya (Young Expressive Female - Pallavi 👧)", "ta-IN-PallaviNeural"),
("Venba (Sweet Young Girl - Singapore 🌸)", "ta-SG-VenbaNeural"),
("Saranya (Soft Youthful Female - Sri Lanka 💖)", "ta-LK-SaranyaNeural"),
("Kani (Gentle Young Storyteller - Malaysia 🌿)", "ta-MY-KaniNeural")
],
value="ta-IN-PallaviNeural",
label="🎙️ Narration Voice (Young Female Only 👧)"
)
ab_dialogue_voice = gr.Dropdown(
choices=[
("Maya (Intimate Close-Mic Young Female 👧)", "ta-IN-PallaviNeural"),
("Venba (Sweet Breathless Young Female 🌸)", "ta-SG-VenbaNeural"),
("Saranya (Emotional Whispering Young Female 💖)", "ta-LK-SaranyaNeural")
],
value="ta-IN-PallaviNeural",
label="💬 Dialogue Voice (Young Female Only 👧)"
)
ab_engine_mode = gr.Radio(
choices=["IndicF5 (Ultra-Realistic Human Voice Cloning 🌟)", "Edge-TTS (Lightweight ⚡)"],
value="IndicF5 (Ultra-Realistic Human Voice Cloning 🌟)",
label="🔥 Voice Model Engine"
)
ab_pause_slider = gr.Slider(minimum=0.4, maximum=2.0, value=0.8, step=0.1, label="⏱️ Breath & Emotional Pause Duration (seconds)")
with gr.Row():
ab_format_btn = gr.Button("✨ Format Script (Block 1 & 2)", variant="secondary")
ab_generate_audio_btn = gr.Button("🎙️ Generate Full Audiobook Audio", variant="primary")
with gr.Column(scale=5, elem_classes=["glass"]):
with gr.Tabs():
with gr.Tab("🏷️ Block 1: Tagged Script"):
ab_block1_out = gr.Textbox(lines=11, label="[Narration] & [Intimate Dialogue] Tagged Script")
with gr.Tab("📄 Block 2: Raw TTS Lines"):
ab_block2_out = gr.Textbox(lines=11, label="Raw Tamil Lines (One sentence per line for Colab / IndicF5)")
with gr.Tab("🚀 Google Colab Code (IndicF5 / Parler-TTS)"):
ab_colab_code_out = gr.Code(language="python", label="Copy-Ready Google Colab Script", lines=12)
ab_audio_player = gr.Audio(label="🎧 Master Audiobook Chapter Audio (Multi-Voice)")
ab_download_file = gr.File(label="📥 Download Chapter Audio (.mp3)")
# --- Dynamic Temperature & Heartbeat Speed Logic ---
def update_mood(level, bgm):
# JS to update color and potentially heartbeat speed if possible
return gr.update()
heat_level.change(None, inputs=[heat_level], js="updateTemp")
# --- BGM Logic ---
def update_bgm(choice, level):
link = BGM_LINKS.get(choice, "")
if not link:
return ""
# If heartbeat, adjust playback rate based on level
speed = 1.0 + (level / 100.0) # 1.0x to 2.0x speed
return f'<audio id="bgm_tag" autoplay loop><source src="{link}" type="audio/mpeg"></audio><script>const a=document.getElementById("bgm_tag"); a.volume=0.3; a.playbackRate={speed};</script>'
bgm_choice.change(update_bgm, inputs=[bgm_choice, heat_level], outputs=[bgm_player])
# Comic Logic
# Comic Logic
def start_comic(file):
if not file: return None, "No file", 0, None, 1, 0
img, status, page = load_comic_page(file.name, 0)
total_pages = get_pdf_page_count(file.name)
return img, status, page, file.name, 1, total_pages
comic_upload.change(
start_comic,
inputs=[comic_upload],
outputs=[comic_display, page_status, current_page, comic_pdf_path, start_page_num, end_page_num]
)
def go_next(pdf, page):
new_page = page + 1
img, status, p_num = load_comic_page(pdf, new_page)
return img, status, p_num, p_num + 1
def go_prev(pdf, page):
new_page = max(0, page - 1)
img, status, p_num = load_comic_page(pdf, new_page)
return img, status, p_num, p_num + 1
# Navigation logic...
next_btn.click(go_next, inputs=[comic_pdf_path, current_page], outputs=[comic_display, page_status, current_page, start_page_num])
prev_btn.click(go_prev, inputs=[comic_pdf_path, current_page], outputs=[comic_display, page_status, current_page, start_page_num])
read_page_btn.click(
process_comic_page,
inputs=[comic_pdf_path, current_page, voice_style_comic, heat_level, processing_mode],
outputs=[comic_text, comic_tamil, comic_audio]
)
# --- Auto-Play Logic (JS Listener) ---
hidden_auto_next = gr.Button("Auto Next", visible=False, elem_id="hidden_auto_next")
js_listener = """
function() {
// Force dark mode classes on html and body elements
document.documentElement.classList.add('dark');
document.body.classList.add('dark');
// --- 1. Audio Autoplay / Ended Event Listener ---
window.lastAudioSrc = window.lastAudioSrc || "";
setInterval(function() {
const audio = document.querySelector('#comic_audio_player audio');
if (audio) {
const autoPlayCheckbox = document.querySelector('#auto_play_checkbox input[type="checkbox"]');
const autoPlayEnabled = autoPlayCheckbox && autoPlayCheckbox.checked;
if (audio.src && audio.src !== window.lastAudioSrc) {
window.lastAudioSrc = audio.src;
if (autoPlayEnabled) {
audio.play().catch(err => console.log("Autoplay blocked or waiting:", err));
}
}
if (!audio.hasAutoPlayEndedListener) {
audio.hasAutoPlayEndedListener = true;
audio.addEventListener('ended', function() {
const autoPlayCheckbox = document.querySelector('#auto_play_checkbox input[type="checkbox"]');
const autoPlayEnabled = autoPlayCheckbox && autoPlayCheckbox.checked;
if (autoPlayEnabled) {
const btn = document.querySelector('button#hidden_auto_next');
if (btn) btn.click();
}
});
}
}
}, 1000);
// --- 2. Webpage Selection Capturer ---
const handleSelection = function() {
const container = document.querySelector('#webpage_reading_view');
if (container) {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText.length > 0) {
if (container.contains(selection.anchorNode)) {
const textarea = document.querySelector('#tts_input_textarea textarea');
if (textarea) {
textarea.value = selectedText;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
}
};
document.addEventListener('mouseup', handleSelection);
document.addEventListener('touchend', handleSelection);
}
"""
# Trigger the JS listener when audio is loaded
comic_audio.change(None, None, None, js=js_listener)
def handle_auto_play(is_enabled, pdf, page, voice, heat_level, end_page_val, processing_mode):
try:
if not is_enabled or not pdf:
return gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update()
# Check if we reached the limit before advancing
if end_page_val > 0 and (page + 1) >= end_page_val:
return gr.update(), "Reached End Page Limit", page, gr.update(), gr.update(), None, gr.update(value=False), gr.update()
# 1. Go to next page
new_page = page + 1
img, status, p_num = load_comic_page(pdf, new_page)
if not img: # End of book
return gr.update(), status, p_num, gr.update(), gr.update(), gr.update(), gr.update(value=False), gr.update()
# 2. Process the new page (Using Hybrid Mode)
txt, tam, aud = process_comic_page(pdf, p_num, voice, heat_level, processing_mode)
return img, status, p_num, txt, tam, aud, gr.update(), p_num + 1
except Exception as e:
print(f"AUTO-PLAY ERROR: {e}")
return gr.update(), f"Auto-Play Error: {e}", page, f"CRASH: {e}", "", None, gr.update(value=False), gr.update()
# The hidden button triggers the actual logic
hidden_auto_next.click(
handle_auto_play,
inputs=[auto_play, comic_pdf_path, current_page, voice_style_comic, heat_level, end_page_num, processing_mode],
outputs=[comic_display, page_status, current_page, comic_text, comic_tamil, comic_audio, auto_play, start_page_num]
)
# Jump to Page inputs
def jump_to_page(pdf, page_num_val):
if not pdf:
return gr.update(), "Upload a PDF first", 0
try:
val = int(page_num_val)
page_idx = max(0, val - 1)
except (ValueError, TypeError):
page_idx = 0
return load_comic_page(pdf, page_idx)
start_page_num.submit(
jump_to_page,
inputs=[comic_pdf_path, start_page_num],
outputs=[comic_display, page_status, current_page]
)
start_page_num.blur(
jump_to_page,
inputs=[comic_pdf_path, start_page_num],
outputs=[comic_display, page_status, current_page]
)
# Trigger JS listener on app start too
demo.load(None, None, None, js=js_listener)
# Standard Logic (Text Only)
submit_std.click(
process_standard_pipeline,
inputs=[gr.State(None), gr.State(None), input_text, voice_style_std, processing_mode_std, spicy_mode_std, audiobook_mode_std],
outputs=[out_text, out_tamil, out_audio, out_download]
)
# URL Import Logic
fetch_btn.click(
fetch_webpage_reading_mode,
inputs=[webpage_url],
outputs=[webpage_html_view]
)
# Custom Dictionary Editor Logic
def add_dictionary_row(current_data):
if not current_data:
current_data = []
current_data.append(["", ""])
return current_data
def reset_dictionary_action(dict_type):
msg = reset_dictionary_to_defaults()
data = load_dict_as_list(dict_type)
return data, msg
dict_type_select.change(
load_dict_as_list,
inputs=[dict_type_select],
outputs=[dict_table]
)
add_row_btn.click(
add_dictionary_row,
inputs=[dict_table],
outputs=[dict_table]
)
save_dict_btn.click(
save_dict_from_list,
inputs=[dict_type_select, dict_table],
outputs=[dict_status]
)
reset_dict_btn.click(
reset_dictionary_action,
inputs=[dict_type_select],
outputs=[dict_table, dict_status]
)
# --- Romance Audiobook Logic ---
def handle_format_script(text):
if not text or not text.strip():
return "", "", ""
res = format_tamil_audiobook_script(text)
colab_code = generate_google_colab_script(res["raw_lines"])
return res["tagged_script"], res["raw_text"], colab_code
def handle_generate_audiobook(text, narrator_voice, dialogue_voice, pause_dur, engine_mode):
if not text or not text.strip():
return "", "", "", None, None
res = format_tamil_audiobook_script(text)
colab_code = generate_google_colab_script(res["raw_lines"])
sr, audio_data, file_path = generate_audiobook_audio(
text,
narration_voice=narrator_voice,
dialogue_voice=dialogue_voice,
pause_duration=pause_dur,
engine_mode=engine_mode
)
return res["tagged_script"], res["raw_text"], colab_code, file_path, file_path
ab_format_btn.click(
handle_format_script,
inputs=[ab_input],
outputs=[ab_block1_out, ab_block2_out, ab_colab_code_out]
)
ab_generate_audio_btn.click(
handle_generate_audiobook,
inputs=[ab_input, ab_narrator_voice, ab_dialogue_voice, ab_pause_slider, ab_engine_mode],
outputs=[ab_block1_out, ab_block2_out, ab_colab_code_out, ab_audio_player, ab_download_file]
)
if __name__ == "__main__":
check_resources()
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
import os
import gradio as gr
app = FastAPI()
# Mount bgm folder as static files
bgm_dir = os.path.join(os.path.dirname(__file__), "bgm")
os.makedirs(bgm_dir, exist_ok=True)
app.mount("/bgm", StaticFiles(directory=bgm_dir), name="bgm")
@app.on_event("startup")
def startup_event():
import threading
threading.Thread(target=download_bgm_files, daemon=True).start()
# Enable Gradio queue for concurrent request management
demo.queue()
# Mount Gradio at root
app = gr.mount_gradio_app(app, demo, path="/")
if __name__ == "__main__":
import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=7860)