Spaces:
Paused
Paused
| import os | |
| import html | |
| import math | |
| import re | |
| import struct | |
| import wave | |
| import gradio as gr | |
| import time | |
| from pathlib import Path | |
| from tts import split_into_chunks, generate_audio_stream | |
| from inference import transcribe_audio, answer_story_question | |
| from voice_clone import load_default_profile, list_saved_profiles | |
| # Create directories for sample audio files | |
| os.makedirs("sample_sounds", exist_ok=True) | |
| # Generate relaxing audio chime waveforms procedurally | |
| # This runs fully client/server-side with standard Python, eliminating mock limits or Torch wait times | |
| def generate_chimes_wav(filename, duration=120, melody_type="lullaby"): | |
| sample_rate = 16000 | |
| n_samples = int(duration * sample_rate) | |
| with wave.open(filename, 'wb') as wav_file: | |
| wav_file.setnchannels(1) | |
| wav_file.setsampwidth(2) | |
| wav_file.setframerate(sample_rate) | |
| # Pentatonic cozy scales | |
| if melody_type == "lullaby": | |
| notes = [261.63, 293.66, 329.63, 392.00, 440.00] # C4, D4, E4, G4, A4 | |
| elif melody_type == "adventure": | |
| notes = [196.00, 246.94, 293.63, 329.63, 392.00] # G3, B3, D4, E4, G4 | |
| else: | |
| notes = [220.00, 261.63, 293.66, 329.63, 392.00] # A3, C4, D4, E4, G4 | |
| for i in range(n_samples): | |
| # cycle notes based on melody speed | |
| note_speed = sample_rate * 1.5 if melody_type == "lullaby" else sample_rate * 1.2 | |
| note_idx = int((i / note_speed) % len(notes)) | |
| freq = notes[note_idx] | |
| # envelope to prevent crackles | |
| sample_in_note = i % int(note_speed) | |
| envelope = 1.0 | |
| if sample_in_note < 1200: # attack | |
| envelope = sample_in_note / 1200 | |
| else: # decay | |
| decay_length = note_speed - 1200 | |
| envelope = max(0.0, 1.0 - (sample_in_note - 1200) / decay_length) | |
| # Synthesize voice-harmonic chime | |
| val = math.sin(2 * math.pi * freq * i / sample_rate) | |
| val += 0.45 * math.sin(2 * math.pi * (freq * 1.5) * i / sample_rate) | |
| val += 0.25 * math.sin(2 * math.pi * (freq * 2.0) * i / sample_rate) | |
| val = val / 1.7 * envelope | |
| packed_val = struct.pack('<h', int(val * 16384)) | |
| wav_file.writeframes(packed_val) | |
| # Generate a short placeholder chime for the preview widget's initial value | |
| _preview_chime_path = "sample_sounds/cloned_preview.wav" | |
| if not os.path.exists(_preview_chime_path): | |
| generate_chimes_wav(_preview_chime_path, duration=15, melody_type="lullaby") | |
| COVERS_DIR = Path(__file__).parent / "assets" / "covers" | |
| def cover_path(slug: str) -> str: | |
| p = COVERS_DIR / f"{slug}.png" | |
| return f"/gradio_api/file={p.resolve().as_posix()}" | |
| def load_books_from_stories(stories_dir="stories"): | |
| books = [] | |
| story_files = sorted( | |
| f for f in os.listdir(stories_dir) if f.endswith(".txt") | |
| ) if os.path.isdir(stories_dir) else [] | |
| for i, filename in enumerate(story_files): | |
| title = filename.replace(".txt", "").replace("_", " ") | |
| slug = filename.replace(".txt", "") | |
| synopsis = "" | |
| try: | |
| with open(os.path.join(stories_dir, filename), encoding="utf-8") as f: | |
| lines = [l.strip() for l in f.readlines() if l.strip()] | |
| synopsis = " ".join(lines[1:4]) if len(lines) > 1 else lines[0] if lines else "" | |
| except Exception: | |
| pass | |
| books.append({ | |
| "id": str(i + 1), | |
| "title": title, | |
| "author": "Public Domain", | |
| "cover_url": cover_path(slug), | |
| "voice_name": "Mom's Voice", | |
| "duration": 0, | |
| "elapsed_time": 0, | |
| "synopsis": synopsis, | |
| "category": "Children's Story", | |
| "is_cloned": False, | |
| "percentage": 0, | |
| "audio_path": None, | |
| "story_path": os.path.join(stories_dir, filename), | |
| }) | |
| return books | |
| mock_books = load_books_from_stories() | |
| mock_voices = [ | |
| { | |
| "id": "v1", | |
| "name": "Mom's Voice", | |
| "avatar_url": "π©βπ¦³", | |
| "description": "Warm, soft, with a natural soothing cadence. Best for bedtime stories.", | |
| "gender": "Female", | |
| "created_date": "2026-06-01", | |
| "status": "ready" | |
| }, | |
| { | |
| "id": "v2", | |
| "name": "Grandpa Joseph", | |
| "avatar_url": "π΄", | |
| "description": "Deep, resonant, with an occasional comforting chuckle. Perfect for classic adventure stories.", | |
| "gender": "Male", | |
| "created_date": "2026-06-03", | |
| "status": "ready" | |
| }, | |
| { | |
| "id": "v3", | |
| "name": "Aunt Sarah", | |
| "avatar_url": "π©", | |
| "description": "Bubbly, expressive, brilliant at acting out funny character voices.", | |
| "gender": "Female", | |
| "created_date": "2026-06-06", | |
| "status": "processing" | |
| } | |
| ] | |
| with open(os.path.join(os.path.dirname(__file__), "static", "style.css"), encoding="utf-8") as _f: | |
| css_code = _f.read() | |
| def generate_dashboard_html(books_list): | |
| featured = books_list[0] | |
| others = books_list[1:] | |
| featured_js_title = featured['title'].replace("'", "\\'").replace('"', '\\"') | |
| html = f""" | |
| <div style="margin-bottom: 24px;"> | |
| <span style="font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 600;">Welcome Back, Sarah</span> | |
| <h2 class="serif-header" style="font-size: 28px; margin-top: 4px; margin-bottom: 16px;">Explore Your Warm Audiobook Shelves</h2> | |
| </div> | |
| <!-- Hero Spotlight Section --> | |
| <div class="hero-spotlight" style="display: flex; flex-direction: row; gap: 32px; align-items: center; margin-bottom: 32px; flex-wrap: wrap; cursor: pointer;" onclick=" | |
| var tabs = document.querySelectorAll('button[role=tab]'); | |
| if (tabs.length > 1) {{ tabs[1].click(); }} | |
| setTimeout(function() {{ | |
| var radios = document.querySelectorAll('.library-book-selector input[type=radio]'); | |
| radios.forEach(function(r) {{ | |
| if(r.value === '{featured_js_title}') {{ | |
| r.checked = true; | |
| r.dispatchEvent(new Event('input', {{bubbles: true}})); | |
| r.dispatchEvent(new Event('change', {{bubbles: true}})); | |
| }} | |
| }}); | |
| }}, 300); | |
| "> | |
| <div style="flex: 1; min-width: 200px; max-width: 280px; text-align: center;"> | |
| <img src="{featured['cover_url']}" style="width: 180px; height: 260px; object-fit: cover; border-radius: 16px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); transform: rotate(-2deg);" referrerPolicy="no-referrer" /> | |
| </div> | |
| <div style="flex: 2; min-width: 300px; display: flex; flex-direction: column; justify-content: space-between; gap: 16px;"> | |
| <div> | |
| <span class="badge-featured" style="margin-bottom: 8px;">Featured Clone</span> | |
| <h3 class="serif-header" style="color: white !important; font-size: 28px; margin: 4px 0;">{featured['title']}</h3> | |
| <p style="color: #6f6257;">by <span style="font-style: italic; color: #ddc1b0;">{featured['author']}</span></p> | |
| <p style="color: #cbd5e1; font-size: 13.5px; line-height: 1.6; margin-top: 8px;">{featured['synopsis']}</p> | |
| </div> | |
| <div style="display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: #e2e8f0;"> | |
| <span style="background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 8px;">β±οΈ 20 Min Remaining</span> | |
| <span style="background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 8px; font-weight: 600; color: #ffd3a6;">ποΈ Narrator: {featured['voice_name']}</span> | |
| <span style="background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 8px;">β Editorial Spotlight</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div> | |
| <h4 class="serif-header" style="font-size: 20px; margin-bottom: 16px;">Continue Reading with Channeled Voices</h4> | |
| <div class="book-shelf-grid"> | |
| """ | |
| for bk in books_list: | |
| js_safe_title = bk['title'].replace("'", "\\'").replace('"', '\\"') | |
| html += f""" | |
| <div class="shelf-card" style="cursor: pointer;" onclick=" | |
| // Switch to My Library tab (2nd tab button) | |
| var tabs = document.querySelectorAll('button[role=tab]'); | |
| if (tabs.length > 1) {{ tabs[1].click(); }} | |
| // Select book in hidden radio | |
| setTimeout(function() {{ | |
| var radios = document.querySelectorAll('.library-book-selector input[type=radio]'); | |
| radios.forEach(function(r) {{ | |
| if(r.value === '{js_safe_title}') {{ | |
| r.checked = true; | |
| r.dispatchEvent(new Event('input', {{bubbles: true}})); | |
| r.dispatchEvent(new Event('change', {{bubbles: true}})); | |
| }} | |
| }}); | |
| }}, 300); | |
| "> | |
| <div> | |
| <img src="{bk['cover_url']}" class="cover-image" referrerPolicy="no-referrer" /> | |
| </div> | |
| <div style="display: flex; flex-direction: column; justify-content: space-between; flex: 1; min-width: 0;"> | |
| <div> | |
| <div style="display: flex; justify-content: space-between; font-size: 10px; font-weight: 700; color: #f5841f;"> | |
| <span>{bk['category']}</span> | |
| <span>{bk['percentage']}%</span> | |
| </div> | |
| <h5 class="serif-header" style="font-size: 14px; margin: 4px 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{bk['title']}</h5> | |
| <p style="font-size: 11.5px; color: #6f6257; margin-bottom: 8px;">by {bk['author']}</p> | |
| </div> | |
| <div> | |
| <div class="progress-bar-bg" style="margin-bottom: 6px;"> | |
| <div class="progress-bar-fill" style="width: {bk['percentage']}%"></div> | |
| </div> | |
| <div style="display: flex; justify-content: space-between; font-size: 11px; color: #6f6257;"> | |
| <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">ποΈ {bk['voice_name']}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| html += """ | |
| </div> | |
| </div> | |
| """ | |
| return html | |
| def generate_library_html(books_list, query="", category_filt="All", empty_mode=False): | |
| if empty_mode: | |
| return """ | |
| <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 16px; text-align: center; max-width: 440px; margin: 0 auto;"> | |
| <div style="position: relative; width: 90px; height: 90px; border-radius: 50%; background: #ede8e3; border: 1px dashed #ddc1b0; display: flex; align-items: center; justify-content: center; margin-bottom: 24px;"> | |
| <span style="font-size: 32px;">π</span> | |
| <span style="position: absolute; font-size: 20px; bottom: -4px; right: -4px;">ποΈ</span> | |
| </div> | |
| <h3 class="serif-header" style="font-size: 22px; margin-bottom: 8px;">Your Bookshelf is Waiting to be Voiced</h3> | |
| <p style="font-size: 13.5px; color: #6f6257; line-height: 1.5; margin-bottom: 24px;"> | |
| Choose a comforting companion, clone their warm voice, and hear any classical or modern tale come to life immediately. | |
| </p> | |
| <div style="display: flex; gap: 12px; width: 100%;"> | |
| <span style="flex: 1; padding: 12px; background: #944a00; color: white; border-radius: 12px; font-weight: 700; font-size: 13px;">Use 'Clone Voice' tab to craft a companion</span> | |
| </div> | |
| </div> | |
| """ | |
| filtered = [] | |
| for bk in books_list: | |
| match_query = (query.lower() in bk["title"].lower()) or (query.lower() in bk["author"].lower()) or (query.lower() in bk["voice_name"].lower()) | |
| match_cat = True | |
| if category_filt == "In Progress": | |
| match_cat = 0 < bk["percentage"] < 100 | |
| elif category_filt == "Completed": | |
| match_cat = bk["percentage"] == 100 | |
| if match_query and match_cat: | |
| filtered.append(bk) | |
| if len(filtered) == 0: | |
| return """ | |
| <div style="text-align: center; padding: 48px; border: 1px dashed #ebdccb; border-radius: 16px;"> | |
| <span style="font-size: 28px;">π</span> | |
| <p style="font-size: 14px; color: #6f6257; margin-top: 8px;">No audiobooks match your search criteria.</p> | |
| </div> | |
| """ | |
| html_out = """<div class="book-shelf-grid">""" | |
| for bk in filtered: | |
| safe_title = html.escape(bk['title']) | |
| js_safe_title = bk['title'].replace("'", "\\'").replace('"', '\\"') | |
| html_out += f""" | |
| <div class="shelf-card group" style="flex-direction: column; gap: 0; padding: 0; overflow: hidden; cursor: pointer;" onclick=" | |
| var radios = document.querySelectorAll('.library-book-selector input[type=radio]'); | |
| radios.forEach(function(r) {{ | |
| if(r.value === '{js_safe_title}') {{ | |
| r.checked = true; | |
| r.dispatchEvent(new Event('input', {{bubbles: true}})); | |
| r.dispatchEvent(new Event('change', {{bubbles: true}})); | |
| }} | |
| }}); | |
| "> | |
| <div style="position: relative; aspect-ratio: 3/4; overflow: hidden; height: 200px;"> | |
| <img src="{bk['cover_url']}" style="width: 100%; height: 100%; object-fit: cover;" referrerPolicy="no-referrer" /> | |
| <div style="position: absolute; top: 12px; right: 12px; background: rgba(255,255,255,0.95); padding: 4px 10px; border-radius: 8px; font-weight: 700; font-size: 11px; border: 1px solid #ebdccb; color: #1c1c19;"> | |
| {bk['percentage']}% | |
| </div> | |
| <div style="position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(0deg, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0) 100%); padding: 12px; color: white;"> | |
| <span style="font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 700; color: #f5841f;">{bk['category']}</span> | |
| <h5 class="serif-header" style="color: white !important; font-size: 15px; margin: 2px 0 0 0; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{safe_title}</h5> | |
| </div> | |
| </div> | |
| <div style="padding: 16px; display: flex; flex-direction: column; gap: 10px;"> | |
| <div style="display: flex; justify-content: space-between; font-size: 11px; align-items: center;"> | |
| <span style="color: #6f6257;">Narrator:</span> | |
| <span style="font-weight: 600; color: #944a00;">π {bk['voice_name']}</span> | |
| </div> | |
| <div class="progress-bar-bg"> | |
| <div class="progress-bar-fill" style="width: {bk['percentage']}%"></div> | |
| </div> | |
| <div style="text-align: center; padding: 6px 0; font-size: 12px; font-weight: 700; color: #944a00;"> | |
| βΆοΈ Tap to Play | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| html_out += "</div>" | |
| return html_out | |
| def load_paragraphs(story_path: str) -> list: | |
| try: | |
| with open(story_path, encoding="utf-8") as f: | |
| raw = f.read() | |
| paras = [p.strip() for p in raw.split("\n\n") if p.strip()] | |
| return paras | |
| except Exception: | |
| return [] | |
| def render_story_text(paragraphs: list, current_idx: int) -> str: | |
| if not paragraphs: | |
| return "" | |
| result = """<div style="max-height: 420px; overflow-y: auto; padding: 4px 2px; margin-top: 12px;">""" | |
| for i, para in enumerate(paragraphs): | |
| safe_para = html.escape(para) | |
| if i == current_idx: | |
| result += f"""<p style="font-family: 'Playfair Display', Georgia, serif; font-size: 13px; line-height: 1.8; color: #FAF7F2; background: rgba(245,132,31,0.18); border-left: 3px solid #f5841f; padding: 8px 12px; border-radius: 6px; margin: 6px 0;">{safe_para}</p>""" | |
| else: | |
| result += f"""<p style="font-family: 'Playfair Display', Georgia, serif; font-size: 13px; line-height: 1.8; color: #94a3b8; padding: 4px 12px; margin: 4px 0;">{safe_para}</p>""" | |
| result += "</div>" | |
| return result | |
| def render_cloned_voices_html(voices_list): | |
| markup = """<div class="book-shelf-grid">""" | |
| for v in voices_list: | |
| status_color = "#16a34a" if v["status"] == "ready" else "#d97706" | |
| status_bg = "#f0fdf4" if v["status"] == "ready" else "#fef3c7" | |
| safe_name = html.escape(v['name']) | |
| safe_desc = html.escape(v['description']) | |
| voice_id = html.escape(v.get('profile_id', v['id'])) | |
| onclick = f"""onclick=" | |
| const sel = document.querySelector('#voice_selector textarea, #voice_selector input'); | |
| if (sel) {{ | |
| sel.value = '{voice_id}'; | |
| sel.dispatchEvent(new Event('input', {{bubbles: true}})); | |
| sel.dispatchEvent(new Event('change', {{bubbles: true}})); | |
| }} | |
| document.querySelectorAll('.voice-card-selected').forEach(c => c.classList.remove('voice-card-selected')); | |
| this.classList.add('voice-card-selected'); | |
| " """ | |
| markup += f""" | |
| <div class="shelf-card" style="align-items: center; cursor: pointer;" {onclick}> | |
| <div style="width: 48px; height: 48px; border-radius: 50%; background: #ede8e3; border: 1px solid #ddc1b0; font-size: 24px; display: flex; align-items: center; justify-content: center; shrink-0: 0;"> | |
| {v['avatar_url']} | |
| </div> | |
| <div style="min-width: 0; flex: 1;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; gap: 6px;"> | |
| <h5 class="serif-header" style="font-size: 14.5px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{safe_name}</h5> | |
| <span style="font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; color: {status_color}; background: {status_bg};"> | |
| {v['status']} | |
| </span> | |
| </div> | |
| <p style="font-size: 11.5px; color: #6f6257; margin: 4px 0 0 0; clamp: 2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;"> | |
| {safe_desc} | |
| </p> | |
| <div style="border-top: 1px solid rgba(148,74,0,0.08); padding-top: 6px; margin-top: 6px; display: flex; justify-content: space-between; font-size: 10px; color: #6f6257;"> | |
| <span>Gender: {v['gender']}</span> | |
| <span>Created: {v['created_date']}</span> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| markup += "</div>" | |
| return markup | |
| # Load default voice profile from Voice_Profile/ (if any saved from previous sessions) | |
| _default_profile_id = load_default_profile() | |
| _saved_profiles = list_saved_profiles() | |
| if _default_profile_id: | |
| _default_voice_name = next( | |
| (p["voice_name"] for p in _saved_profiles if p["profile_id"] == _default_profile_id), | |
| "Cloned Voice" | |
| ) | |
| print(f"[MomsVoice] Default voice profile loaded: {_default_profile_id} ({_default_voice_name})") | |
| else: | |
| _default_voice_name = None | |
| print("[MomsVoice] No saved voice profile β using stock voice as default.") | |
| # Preload models at startup for fast response | |
| print("[MomsVoice] Preloading Qwen3-TTS 0.6B CustomVoice model...") | |
| from voice_clone import get_custom_voice_model | |
| get_custom_voice_model() | |
| print("[MomsVoice] TTS model ready.") | |
| print("[MomsVoice] Preloading Whisper-small ASR model...") | |
| from inference import get_asr_pipeline, get_qa_model | |
| get_asr_pipeline() | |
| print("[MomsVoice] ASR model ready.") | |
| print("[MomsVoice] Preloading Qwen2.5-3B-Instruct Q&A model...") | |
| get_qa_model() | |
| print("[MomsVoice] Q&A model ready. All models preloaded.") | |
| # Gradio Application Core setup | |
| with gr.Blocks(title="MomsVoice", css=css_code) as demo: | |
| books_state = gr.State(mock_books) | |
| voices_state = gr.State(mock_voices) | |
| paragraphs_state = gr.State([]) | |
| tts_chunks_state = gr.State([]) | |
| voice_profile_state = gr.State(_default_profile_id) | |
| gr.HTML(""" | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid #ebdccb; margin-bottom: 24px; flex-wrap: wrap; gap: 16px;"> | |
| <div style="display: flex; align-items: center; gap: 12px;"> | |
| <div style="width: 42px; height: 42px; background-color: #944a00; border-radius: 12px; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 20px;"> | |
| πΏ | |
| </div> | |
| <div> | |
| <h1 class="serif-header" style="font-size: 22px; margin: 0; line-height: 1;">VoiceBook</h1> | |
| <span style="font-size: 10px; color: #f5841f; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px;">AI Bedtime Story Narrator</span> | |
| </div> | |
| </div> | |
| <div style="display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: #6f6257; font-family: monospace;"> | |
| <span>Hugging Face Space Node:</span> | |
| <span style="color: #16a34a; font-weight: 700;">β Active</span> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Tabs() as main_tabs: | |
| # TAB 1: Explore Dashboard | |
| with gr.TabItem("ποΈ Explore Space") as explore_tab: | |
| dashboard_container = gr.HTML(value=generate_dashboard_html(mock_books)) | |
| gr.HTML("""<div style="margin-top: 24px; text-align: center;"><span style="font-size: 12px; color:#6f6257;">π§ Head over to <b>My Library</b> and tap any book to start listening!</span></div>""") | |
| # TAB 2: Library + Player (ν΅ν©) | |
| with gr.TabItem("π My Library") as library_tab: | |
| with gr.Row(): | |
| # Left: book grid | |
| with gr.Column(scale=3): | |
| with gr.Row(): | |
| with gr.Column(scale=3): | |
| search_input = gr.Textbox(placeholder="Search by title, author, or voice...", label="Filter", show_label=False) | |
| with gr.Column(scale=1): | |
| category_filter = gr.Radio(["All", "In Progress", "Completed"], value="All", label="Progress", show_label=False) | |
| shelf_grid = gr.HTML(value=generate_library_html(mock_books)) | |
| # Hidden radio β book cards click this via JS onclick | |
| book_titles_list = [b["title"] for b in mock_books] | |
| book_selector = gr.Radio( | |
| choices=book_titles_list, | |
| value=None, | |
| label="", | |
| show_label=False, | |
| elem_classes="library-book-selector", | |
| visible=False, | |
| ) | |
| # Right: player panel | |
| with gr.Column(scale=2, elem_classes="dark-card-container"): | |
| gr.HTML(""" | |
| <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px dashed rgba(255,255,255,0.15); padding-bottom: 12px; margin-bottom: 16px;"> | |
| <span style="font-size: 10px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 700;">Now Playing</span> | |
| <span style="background: rgba(22, 163, 74, 0.2); padding: 2px 6px; border-radius: 6px; font-size: 9px; font-family: monospace; color: #4ade80;">SYNCHRONIZED PITCH</span> | |
| </div> | |
| """) | |
| player_title_info = gr.HTML(""" | |
| <div style="text-align: center; margin-bottom: 24px; margin-top: 16px; padding: 32px 0;"> | |
| <span style="font-size: 48px;">π</span> | |
| <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 18px; margin: 12px 0 4px; color:#FAF7F2;">Select a story</h3> | |
| <p style="font-size: 12px; color: #94a3b8; font-style: italic;">Tap any book on the left to begin</p> | |
| </div> | |
| """) | |
| player_audio_control = gr.Audio( | |
| visible=False, interactive=False, | |
| label="Audio Stream", | |
| type="numpy", | |
| streaming=False, | |
| autoplay=True, | |
| ) | |
| player_status_bar = gr.HTML(""" | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">SELECT A STORY</span> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| play_btn = gr.Button("βΆοΈ Play", variant="primary", scale=1, visible=False) | |
| pause_btn = gr.Button("βΈ Pause", variant="secondary", scale=1, visible=False) | |
| resume_btn = gr.Button("β©οΈ Resume Story", variant="secondary", scale=2, visible=False) | |
| with gr.Row(): | |
| ask_btn = gr.Button("β Ask a Question", variant="primary", scale=2, visible=False) | |
| chunk_status = gr.HTML(""" | |
| <div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;"> | |
| Chunk 0 / 0 | |
| </div> | |
| """) | |
| story_finished_panel = gr.HTML(""" | |
| <div style="margin-top: 12px; padding: 16px; background: rgba(245,132,31,0.1); border: 1px solid rgba(245,132,31,0.3); border-radius: 14px; text-align: center;"> | |
| <span style="font-size: 28px;">π</span> | |
| <h4 style="font-family: 'Playfair Display', Georgia, serif; color: #ffd3a6; margin: 8px 0 4px;">Story Complete!</h4> | |
| <p style="font-size: 12px; color: #94a3b8; margin-bottom: 12px;">Tap another book to start a new story.</p> | |
| </div> | |
| """, visible=False) | |
| # Story text display + slider | |
| story_text_display = gr.HTML(visible=False) | |
| timeline_slider = gr.Slider(minimum=0, maximum=100, step=1, value=0, label="π Story position (paragraph)", visible=False) | |
| # Q&A Panel | |
| qa_panel = gr.HTML(""" | |
| <div style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;"> | |
| <h4 style="font-family: 'Playfair Display', Georgia, serif; font-size: 14px; margin-bottom: 12px; color: #FAF7F2;">β Ask About the Story</h4> | |
| </div> | |
| """, visible=False) | |
| with gr.Group(visible=False) as qa_input_group: | |
| question_audio = gr.Audio( | |
| sources=["microphone"], type="filepath", | |
| label="π€ Ask your question β answer generates when you stop recording", | |
| show_label=True, streaming=False, | |
| elem_id="qa_audio_input", | |
| ) | |
| question_text = gr.Textbox(visible=False) | |
| submit_question_btn = gr.Button("π Get Answer in Narrator's Voice", variant="primary", elem_id="qa_submit_btn") | |
| answer_display = gr.HTML(visible=False) | |
| answer_audio = gr.Audio(label="Answer in Narrator's Voice", interactive=False, visible=False, autoplay=True) | |
| # TAB 3: Clone Voice Studio | |
| with gr.TabItem("ποΈ Clone Voice Studio") as clone_tab: | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes="card-container"): | |
| gr.HTML(""" | |
| <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px;">π§ Voice Details Form</h3> | |
| <p style="font-size: 12px; color: #6f6257; margin-bottom: 20px;">Provide a comforting nickname and details below to prepare vocal weights matching.</p> | |
| """) | |
| new_voice_name = gr.Textbox(placeholder="Enter a descriptive nickname (e.g., Grandma Judy, Dad)...", label="Voice Nickname") | |
| new_voice_gender = gr.Radio(["Female", "Male"], value="Female", label="Voice Gender Avatar") | |
| gr.HTML(""" | |
| <div style="margin: 16px 0; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb; font-size: 12px;"> | |
| <strong style="color: #6f6257; text-transform: uppercase; font-size: 10px; display: block; margin-bottom: 4px;">Story Script to read aloud:</strong> | |
| <p style="font-family: Georgia, serif; font-style: italic; color: #1c1c19; margin: 0; line-height: 1.4;"> | |
| "Deep within the ancient forest, the giant oak tree stood. Its roots whispered stories of forgotten eras. 'Close your eyes,' the wind hummed softly, 'for our beautiful journey begins tonight.'" | |
| </p> | |
| </div> | |
| """) | |
| mic_recorder = gr.Audio(sources=["microphone"], type="filepath", label="Record speaking story prompt (Min 10 seconds sample)") | |
| extract_btn = gr.Button("πͺ Analyze & Synthesize Customized Vocal Profile", variant="primary") | |
| with gr.Column(scale=1, elem_classes="card-container"): | |
| gr.HTML(""" | |
| <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Status Pipeline Visualizer</h3> | |
| <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Track extraction, de-noising, and pitch tuning.</p> | |
| """) | |
| cloning_progress_msg = gr.HTML(""" | |
| <div style="text-align: center; padding: 32px 0;"> | |
| <span style="font-size: 42px;">ποΈ</span> | |
| <p style="margin-top: 12px; font-size: 14px; color: #6f6257;">Fill parameters on the left and read the story script aloud to begin extraction.</p> | |
| </div> | |
| """) | |
| voice_cloning_success_panel = gr.HTML(visible=False) | |
| voice_sample_preview_widget = gr.Audio(value="sample_sounds/cloned_preview.wav", visible=False, label="Pre-listening synthesized vocal pitch weights match preview", interactive=False) | |
| add_to_library_btn = gr.Button("β¨ Synced successfully! Bind Companion Voice to Inventory", visible=False) | |
| gr.HTML(""" | |
| <div style="margin-top: 32px; border-top: 1px solid #ebdccb; padding-top: 24px;"> | |
| <h4 class="serif-header" style="font-size: 18px; margin-bottom: 16px;">Currently Available Voices</h4> | |
| <p style="font-size: 11px; color: #6f6257; margin-bottom: 12px;">Click a voice card to select it as the narrator.</p> | |
| </div> | |
| """) | |
| cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices)) | |
| voice_selector = gr.Textbox(visible=False, elem_id="voice_selector") | |
| # TAB 4: Profile | |
| with gr.TabItem("βοΈ Profile") as profile_tab: | |
| with gr.Row(): | |
| with gr.Column(scale=1, elem_classes="card-container"): | |
| gr.HTML(""" | |
| <div style="text-align: center; margin-bottom: 24px;"> | |
| <div style="width: 72px; height: 72px; border-radius: 50%; background: #944a00; color: white; font-size: 36px; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px auto;"> | |
| π©βπ» | |
| </div> | |
| <h3 class="serif-header" style="font-size: 18px; margin: 0;">Your Profile</h3> | |
| <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">ReadBookMom β Hugging Face Hackathon 2026</p> | |
| </div> | |
| """) | |
| gr.HTML(""" | |
| <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center;"> | |
| <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;"> | |
| <span style="font-size: 18px; display: block;">π</span> | |
| <strong style="font-size: 15px; display: block; color: #1c1c19;">5</strong> | |
| <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Stories</span> | |
| </div> | |
| <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;"> | |
| <span style="font-size: 18px; display: block;">π§ </span> | |
| <strong style="font-size: 15px; display: block; color: #1c1c19;">3</strong> | |
| <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Models</span> | |
| </div> | |
| <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;"> | |
| <span style="font-size: 18px; display: block;">ποΈ</span> | |
| <strong style="font-size: 15px; display: block; color: #1c1c19;">Qwen3</strong> | |
| <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">TTS Engine</span> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Column(scale=1, elem_classes="card-container"): | |
| gr.HTML(""" | |
| <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Model Pipeline</h3> | |
| <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Active AI models powering the app.</p> | |
| """) | |
| gr.HTML(""" | |
| <div style="padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;"> | |
| <div style="font-size: 11.5px; color: #1c1c19; line-height: 2;"> | |
| ποΈ <strong>Voice Cloning:</strong> Qwen3-TTS-1.7B-Base (bfloat16)<br> | |
| π£οΈ <strong>Stock TTS:</strong> Qwen3-TTS-0.6B-CustomVoice<br> | |
| π <strong>ASR:</strong> Whisper-small<br> | |
| π§ <strong>Q&A:</strong> Qwen2.5-3B-Instruct | |
| </div> | |
| </div> | |
| """) | |
| # REACTIVE LOGIC | |
| # 1. Search / filter shelf | |
| def filter_books_shelf(query, category, current_books): | |
| return generate_library_html(current_books, query=query, category_filt=category) | |
| search_input.change(filter_books_shelf, inputs=[search_input, category_filter, books_state], outputs=shelf_grid) | |
| category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, books_state], outputs=shelf_grid) | |
| # 2. Book card click β load into player | |
| def handle_book_select(title_chosen, current_inventory, profile_id): | |
| if not title_chosen: | |
| return [gr.update()] * 12 | |
| selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0]) | |
| # Show cloned voice name if profile exists | |
| from voice_clone import has_profile, list_saved_profiles as _list_profiles | |
| if profile_id and has_profile(profile_id): | |
| saved = _list_profiles() | |
| voice_label = next( | |
| (p["voice_name"] for p in saved if p["profile_id"] == profile_id), | |
| "Cloned Voice" | |
| ) | |
| narrator_label = f"ποΈ {voice_label}" | |
| narrator_color = "#4ade80" | |
| else: | |
| narrator_label = f"π {selected['voice_name']} (stock)" | |
| narrator_color = "#ffd3a6" | |
| safe_title = html.escape(selected['title']) | |
| safe_author = html.escape(selected['author']) | |
| player_html = f""" | |
| <div style="text-align: center; margin-bottom: 16px; margin-top: 8px;"> | |
| <img src="{selected['cover_url']}" style="width: 110px; height: 155px; object-fit: cover; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); margin-bottom: 12px;" /> | |
| <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 20px; margin:0; color:#FAF7F2;">{safe_title}</h3> | |
| <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {safe_author}</p> | |
| <span style="display: inline-block; background: rgba(245, 132, 31, 0.15); color: {narrator_color}; font-size: 10.5px; font-weight: 700; padding: 3px 8px; border-radius: 999px; margin-top: 6px;">Active Narrator: {narrator_label}</span> | |
| </div> | |
| """ | |
| paras = load_paragraphs(selected["story_path"]) | |
| total = max(len(paras) - 1, 0) | |
| tts_chunks = split_into_chunks("\n\n".join(paras)) | |
| chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">{len(tts_chunks)} chunks ready β tap Play</div>""" | |
| status_html = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY</span> | |
| </div> | |
| """ | |
| story_html = render_story_text(paras, 0) | |
| return ( | |
| player_html, | |
| gr.Audio(visible=True), | |
| status_html, | |
| chunk_html, | |
| gr.Button(visible=True), # play_btn | |
| gr.Button(visible=False), # pause_btn | |
| gr.Button(visible=False), # ask_btn | |
| gr.HTML(visible=False), # story_finished_panel | |
| paras, # paragraphs_state | |
| tts_chunks, # tts_chunks_state | |
| gr.HTML(value=story_html, visible=True), # story_text_display | |
| gr.Slider(minimum=0, maximum=total, step=1, value=0, visible=True), # timeline_slider | |
| ) | |
| book_selector.change( | |
| handle_book_select, | |
| inputs=[book_selector, books_state, voice_profile_state], | |
| outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status, | |
| play_btn, pause_btn, ask_btn, story_finished_panel, | |
| paragraphs_state, tts_chunks_state, story_text_display, timeline_slider] | |
| ) | |
| # 3. Extract vocal weights trigger | |
| def animate_cloning_pipeline(v_name, v_gender, recorder_data, progress=gr.Progress()): | |
| if not v_name.strip(): | |
| return ( | |
| """<div style="padding: 12px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 12px; font-size: 12px; font-weight: 600;"> | |
| Please provide a comforting nickname for your cloning candidate. | |
| </div>""", | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False), | |
| None, | |
| ) | |
| if recorder_data is None: | |
| return ( | |
| """<div style="padding: 12px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 12px; font-size: 12px; font-weight: 600;"> | |
| Microphone recording sample missing. Speak some script lines! | |
| </div>""", | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False), | |
| None, | |
| ) | |
| from voice_clone import create_voice_profile, synthesize_cloned_preview | |
| import soundfile as sf | |
| progress(0.1, desc="Extracting speaker embedding from recording...") | |
| try: | |
| profile_id = create_voice_profile(recorder_data, voice_name=v_name.strip()) | |
| except Exception as e: | |
| import logging, traceback | |
| logging.getLogger(__name__).exception("Voice cloning failed") | |
| return ( | |
| f"""<div style="padding: 12px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 12px; font-size: 12px; font-weight: 600;"> | |
| Voice cloning failed: {html.escape(str(e))} | |
| </div>""", | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False), | |
| None, | |
| ) | |
| progress(0.7, desc="Generating voice preview...") | |
| try: | |
| preview_wav, preview_sr = synthesize_cloned_preview(profile_id) | |
| preview_path = f"sample_sounds/clone_preview_{profile_id}.wav" | |
| sf.write(preview_path, preview_wav, preview_sr) | |
| except Exception as e: | |
| import logging | |
| logging.getLogger(__name__).exception("Preview synthesis failed") | |
| preview_path = None | |
| progress(1.0, desc="Voice cloned successfully!") | |
| safe_name = html.escape(v_name) | |
| avatar = "π©" if v_gender == "Female" else "π¨" | |
| cloned_card_html = f""" | |
| <div style="background: white; border: 1px solid #ebdccb; padding: 24px; border-radius: 16px; margin: 16px 0;"> | |
| <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 12px;"> | |
| <div style="width: 48px; height: 48px; border-radius: 50%; background: #944a00; color: white; display: flex; align-items: center; justify-content: center; font-size: 24px;"> | |
| {avatar} | |
| </div> | |
| <div> | |
| <h4 class="serif-header" style="font-size: 16px; margin: 0;">{safe_name} <span style="font-size: 9px; background: #f0fdf4; color: #16a34a; padding: 2px 6px; border-radius: 4px; font-weight:700;">Cloned successfully</span></h4> | |
| <p style="font-size: 11px; color:#6f6257; margin-top:2px;">Synthesized today using Qwen3-TTS voice cloning</p> | |
| </div> | |
| </div> | |
| <div style="background:#FAF7F2; border-left: 3px solid #f5841f; padding: 10px; font-family: Georgia, serif; font-style: italic; font-size: 12px; color: #1c1c19;"> | |
| Voice profile ready. Select a story and tap Play to hear narration in this voice. | |
| </div> | |
| </div> | |
| """ | |
| return ( | |
| gr.HTML(visible=False), | |
| gr.HTML(value=cloned_card_html, visible=True), | |
| gr.Audio(value=preview_path, visible=preview_path is not None), | |
| gr.Button(visible=True), | |
| profile_id, | |
| ) | |
| extract_btn.click( | |
| animate_cloning_pipeline, | |
| inputs=[new_voice_name, new_voice_gender, mic_recorder], | |
| outputs=[cloning_progress_msg, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn, voice_profile_state] | |
| ) | |
| # 4. Add cloned voice to inventory | |
| def save_new_cloned_voice(v_name, v_gender, list_voices, current_profile_id): | |
| avatar = "π©" if v_gender == "Female" else "π¨" | |
| new_voice_item = { | |
| "id": f"v-{time.time()}", | |
| "name": v_name, | |
| "avatar_url": avatar, | |
| "description": f"Custom cloned {v_gender.lower()} companion voice. Highly optimized.", | |
| "gender": v_gender, | |
| "created_date": "2026-06-06", | |
| "status": "ready", | |
| "profile_id": current_profile_id, | |
| } | |
| updated = [new_voice_item] + list_voices | |
| return ( | |
| updated, | |
| render_cloned_voices_html(updated), | |
| gr.HTML(visible=True, value="""<div style="text-align: center; padding: 32px 0;"><span style="font-size:42px;">ποΈ</span><p style="margin-top:12px; font-size:14px; color:#6f6257;">Vocal profile saved successfully!</p></div>"""), | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False), | |
| gr.Textbox(value="") | |
| ) | |
| add_to_library_btn.click( | |
| save_new_cloned_voice, | |
| inputs=[new_voice_name, new_voice_gender, voices_state, voice_profile_state], | |
| outputs=[voices_state, cloned_voices_list_grid, cloning_progress_msg, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn, new_voice_name] | |
| ) | |
| # Voice card selection β update voice_profile_state when a card is clicked | |
| def handle_voice_select(selected_id, voices): | |
| """Map selected voice ID to profile_id for TTS.""" | |
| if not selected_id: | |
| return gr.update() | |
| # Check if it matches a saved profile ID directly | |
| from voice_clone import has_profile | |
| if has_profile(selected_id): | |
| return selected_id | |
| # Check if it matches a voice in the list with a profile_id | |
| for v in voices: | |
| if v.get('profile_id') == selected_id or v.get('id') == selected_id: | |
| pid = v.get('profile_id') | |
| if pid and has_profile(pid): | |
| return pid | |
| # No valid profile β use stock voice (None) | |
| return None | |
| voice_selector.change( | |
| handle_voice_select, | |
| inputs=[voice_selector, voices_state], | |
| outputs=[voice_profile_state] | |
| ) | |
| # 5. Play button β streams TTS audio chunk by chunk | |
| def stream_tts(tts_chunks, paras, profile_id): | |
| import logging as _log | |
| _log.getLogger(__name__).info("stream_tts called with profile_id=%s", profile_id) | |
| voice_mode = "ποΈ Cloned Voice" if profile_id else "π Stock Voice" | |
| _status_playing = f""" | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING β {voice_mode}</span> | |
| </div> | |
| """ | |
| _status_done = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">DONE</span> | |
| </div> | |
| """ | |
| n_paras = len(paras) if paras else 1 | |
| if not tts_chunks: | |
| yield (gr.update(), _status_done, | |
| "<div style='text-align:center;font-size:10px;color:#64748b;font-family:monospace;margin-top:8px;'>No content loaded</div>", | |
| gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False), | |
| gr.Slider(value=0), render_story_text(paras, 0) if paras else "", | |
| gr.HTML(visible=False), | |
| gr.HTML(visible=False), gr.Group(visible=False), gr.Button(visible=False)) | |
| return | |
| n = len(tts_chunks) | |
| yield (gr.update(), _status_playing, | |
| f"<div style='text-align:center;font-size:10px;color:#4ade80;font-family:monospace;margin-top:8px;'>Generating chunk 1 / {n}β¦</div>", | |
| gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=False), | |
| gr.Slider(value=0), render_story_text(paras, 0) if paras else "", | |
| gr.HTML(visible=False), | |
| gr.HTML(visible=False), gr.Group(visible=False), gr.Button(visible=False)) | |
| for sample_rate, wav, i, total, err in generate_audio_stream(tts_chunks, voice_profile_id=profile_id): | |
| # Map chunk index to paragraph index | |
| para_idx = min(int(i * n_paras / total), n_paras - 1) if total > 0 else 0 | |
| if err: | |
| yield (gr.update(), f"<div style='color:#ef4444;font-size:11px;'>Error on chunk {i+1}: {err}</div>", "", | |
| gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False), | |
| gr.Slider(value=para_idx), gr.update(), gr.HTML(visible=False), | |
| gr.HTML(visible=False), gr.Group(visible=False), gr.Button(visible=False)) | |
| return | |
| para_idx = min(int(i * n_paras / total), n_paras - 1) if total > 0 else 0 | |
| next_label = f"chunk {i+2} / {total}" if i + 1 < total else "last chunk" | |
| chunk_html = f"<div style='text-align:center;font-size:10px;color:#4ade80;font-family:monospace;margin-top:8px;'>βΆ Playing chunk {i+1} / {total} β generating {next_label}</div>" | |
| is_last = (i + 1 >= total) | |
| yield ((sample_rate, wav), _status_playing, chunk_html, | |
| gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True), | |
| gr.Slider(value=para_idx), render_story_text(paras, para_idx), | |
| gr.HTML(visible=is_last), | |
| gr.HTML(visible=False), gr.Group(visible=False), gr.Button(visible=False)) | |
| done_html = f"<div style='text-align:center;font-size:10px;color:#94a3b8;font-family:monospace;margin-top:8px;'>β {n} chunks complete</div>" | |
| yield (gr.update(), _status_done, done_html, | |
| gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False), | |
| gr.Slider(value=max(n_paras - 1, 0)), render_story_text(paras, max(n_paras - 1, 0)), | |
| gr.HTML(visible=True), | |
| gr.HTML(visible=False), gr.Group(visible=False), gr.Button(visible=False)) | |
| play_event = play_btn.click( | |
| stream_tts, | |
| inputs=[tts_chunks_state, paragraphs_state, voice_profile_state], | |
| outputs=[player_audio_control, player_status_bar, chunk_status, play_btn, pause_btn, ask_btn, | |
| timeline_slider, story_text_display, story_finished_panel, | |
| qa_panel, qa_input_group, resume_btn] | |
| ) | |
| # 6. Pause button β PAUSED state | |
| def enter_paused_state(slider_val, paras): | |
| total = len(paras) if paras else 1 | |
| chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #f59e0b; font-family: monospace; text-align: center;">Paragraph {int(slider_val) + 1} / {total} β paused</div>""" | |
| status_html = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED</span> | |
| </div> | |
| """ | |
| return ( | |
| status_html, | |
| chunk_html, | |
| gr.Button(visible=True), | |
| gr.Button(visible=False), | |
| ) | |
| pause_btn.click( | |
| enter_paused_state, | |
| inputs=[timeline_slider, paragraphs_state], | |
| outputs=[player_status_bar, chunk_status, play_btn, pause_btn], | |
| cancels=[play_event] | |
| ) | |
| # 7. Timeline slider β highlight current paragraph | |
| def update_story_highlight(slider_val, paras): | |
| idx = int(slider_val) | |
| total = len(paras) | |
| chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">Paragraph {idx + 1} / {total}</div>""" | |
| return render_story_text(paras, idx), chunk_html | |
| timeline_slider.change(update_story_highlight, inputs=[timeline_slider, paragraphs_state], outputs=[story_text_display, chunk_status]) | |
| # 8. Ask button β PAUSED-ASKING state | |
| def enter_asking_state(): | |
| status_html = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block; animation: pulse 1s infinite;"></span> | |
| <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED β ASKING</span> | |
| </div> | |
| """ | |
| return ( | |
| status_html, | |
| gr.Button(visible=False), | |
| gr.Button(visible=False), | |
| gr.Button(visible=False), | |
| gr.Button(visible=True), | |
| gr.HTML(visible=True), | |
| gr.Group(visible=True), | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| ) | |
| ask_btn.click( | |
| enter_asking_state, | |
| inputs=[], | |
| outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio], | |
| cancels=[play_event], | |
| js="""() => { | |
| // Auto-start microphone recording after a brief delay for UI to render | |
| setTimeout(() => { | |
| const recordBtn = document.querySelector('#qa_audio_input button[aria-label="Record"], #qa_audio_input button.record-button, .qa-audio-group button[aria-label="Record"]'); | |
| if (recordBtn) { recordBtn.click(); } | |
| else { | |
| // Fallback: find any record button inside the audio component | |
| const btns = document.querySelectorAll('button'); | |
| for (const b of btns) { | |
| if (b.getAttribute('aria-label') === 'Record' || b.textContent.includes('Record')) { | |
| b.click(); break; | |
| } | |
| } | |
| } | |
| }, 500); | |
| }""" | |
| ) | |
| # 9. Submit question β always auto-submits when recording stops | |
| def handle_question_submit(question_txt, question_audio_path, paragraphs, slider_val, profile_id): | |
| q_txt = (question_txt or "").strip() | |
| has_audio = question_audio_path is not None and question_audio_path != "" | |
| if not q_txt and not has_audio: | |
| answer_html = """ | |
| <div style="padding: 10px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 10px; font-size: 12px; color: #991b1b;"> | |
| Please type a question or record one with the microphone. | |
| </div> | |
| """ | |
| return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False) | |
| # Step 9: Transcribe audio if text is empty (on-demand ASR) | |
| if q_txt: | |
| q_text = q_txt | |
| else: | |
| try: | |
| q_text = transcribe_audio(question_audio_path) | |
| if not q_text: | |
| q_text = "(could not understand audio)" | |
| except Exception as e: | |
| q_text = f"(transcription failed: {e})" | |
| # Step 10: Generate grounded answer from story context using Qwen | |
| current_idx = int(slider_val) if slider_val else 0 | |
| try: | |
| answer_text = answer_story_question(q_text, paragraphs, current_idx) | |
| if not answer_text: | |
| answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out." | |
| except Exception as e: | |
| answer_text = f"Oops, I couldn't think of an answer right now. Let's keep reading! ({type(e).__name__})" | |
| import logging | |
| logging.getLogger(__name__).exception("Q&A failed: %s", e) | |
| # Synthesize answer in cloned voice via TTS | |
| answer_audio_path = None | |
| try: | |
| from tts import split_into_chunks as _split, generate_audio_stream as _gen_stream | |
| import soundfile as sf | |
| import numpy as np | |
| import uuid | |
| chunks = _split(answer_text) | |
| audio_segments = [] | |
| sample_rate = 16000 | |
| for sr, wav, idx, total, err in _gen_stream(chunks, voice_profile_id=profile_id): | |
| if wav is not None: | |
| audio_segments.append(wav) | |
| sample_rate = sr | |
| if audio_segments: | |
| full_audio = np.concatenate(audio_segments) | |
| answer_audio_path = f"sample_sounds/qa_answer_{uuid.uuid4().hex[:8]}.wav" | |
| sf.write(answer_audio_path, full_audio, sample_rate) | |
| except Exception: | |
| # Fall back to no audio if TTS fails | |
| pass | |
| safe_answer = html.escape(answer_text) | |
| safe_question = html.escape(q_text) | |
| answer_html = f""" | |
| <div style="margin-top: 12px; padding: 16px; background: rgba(240,253,244,0.1); border: 1px solid rgba(187,247,208,0.3); border-radius: 14px;"> | |
| <div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #4ade80; margin-bottom: 6px;">Answer in Narrator's Voice</div> | |
| <div style="font-family: 'Playfair Display', Georgia, serif; font-style: italic; color: #FAF7F2; font-size: 13px; line-height: 1.6;"> | |
| “{safe_answer}” | |
| </div> | |
| <div style="margin-top: 8px; font-size: 10px; color: #94a3b8;"> | |
| Q: <em>{safe_question}</em> | |
| </div> | |
| </div> | |
| """ | |
| if answer_audio_path: | |
| return gr.HTML(value=answer_html, visible=True), gr.Audio(value=answer_audio_path, visible=True) | |
| return gr.HTML(value=answer_html, visible=True), gr.Audio(visible=False) | |
| submit_question_btn.click( | |
| handle_question_submit, | |
| inputs=[question_text, question_audio, paragraphs_state, timeline_slider, voice_profile_state], | |
| outputs=[answer_display, answer_audio] | |
| ) | |
| # Auto-submit when voice recording completes | |
| # In Gradio 5.35, .stop_recording may not pass audio value reliably. | |
| # Use .stop_recording with a small delay via JS to click the submit button. | |
| question_audio.stop_recording( | |
| fn=None, | |
| inputs=None, | |
| outputs=None, | |
| js="""() => { | |
| // Wait for audio value to be committed, then click submit | |
| setTimeout(() => { | |
| const btn = document.querySelector('#qa_submit_btn button, #qa_submit_btn'); | |
| if (btn) btn.click(); | |
| }, 500); | |
| }""" | |
| ) | |
| # .change() as primary Python handler β fires after audio value is committed | |
| def auto_submit_on_audio(audio_path, question_txt, paragraphs, slider_val, profile_id): | |
| """Auto-submit when a recording is available.""" | |
| if not audio_path: | |
| return gr.update(), gr.update() | |
| return handle_question_submit(question_txt, audio_path, paragraphs, slider_val, profile_id) | |
| question_audio.change( | |
| auto_submit_on_audio, | |
| inputs=[question_audio, question_text, paragraphs_state, timeline_slider, voice_profile_state], | |
| outputs=[answer_display, answer_audio] | |
| ) | |
| # 10. Resume button β dismiss Q&A panel, show Play to continue | |
| def enter_resume_state(): | |
| status_html = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY β Tap Play to continue</span> | |
| </div> | |
| """ | |
| return ( | |
| status_html, | |
| gr.Button(visible=True), # play_btn | |
| gr.Button(visible=False), # pause_btn | |
| gr.Button(visible=True), # ask_btn β keep visible so user can ask again | |
| gr.Button(visible=False), # resume_btn | |
| gr.HTML(visible=False), # qa_panel | |
| gr.Group(visible=False), # qa_input_group | |
| gr.HTML(visible=False), # answer_display | |
| gr.Audio(visible=False), # answer_audio | |
| gr.Textbox(value=""), # question_text β clear previous question | |
| None, # question_audio β clear previous recording | |
| ) | |
| resume_btn.click( | |
| enter_resume_state, | |
| inputs=[], | |
| outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio, question_text, question_audio] | |
| ) | |
| # 11. Pause playback when switching to other tabs | |
| def pause_on_tab_switch(): | |
| status_html = """ | |
| <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;"> | |
| <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED</span> | |
| </div> | |
| """ | |
| return status_html, gr.Button(visible=True), gr.Button(visible=False) | |
| explore_tab.select( | |
| pause_on_tab_switch, | |
| inputs=[], | |
| outputs=[player_status_bar, play_btn, pause_btn], | |
| cancels=[play_event] | |
| ) | |
| clone_tab.select( | |
| pause_on_tab_switch, | |
| inputs=[], | |
| outputs=[player_status_bar, play_btn, pause_btn], | |
| cancels=[play_event] | |
| ) | |
| if __name__ == "__main__": | |
| demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")]) | |