Enforce high-bitrate MP3 format across audiobook pipeline, UI downloads, and Colab generation
4e04f3a Download app.py from ranaspark/voice: direct link, hf CLI and curl.
- Browser
- Download file 46.6 kB
-
https://huggingface.co/spaces/ranaspark/voice/resolve/main/app.py
- Command line
-
hf download hf://spaces/ranaspark/voice/app.py
-
curl -L -o app.py https://huggingface.co/spaces/ranaspark/voice/resolve/main/app.py
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") | |
| 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) | |