Spaces:
Sleeping
Sleeping
| import os | |
| import math | |
| import struct | |
| import wave | |
| import gradio as gr | |
| import time | |
| from pathlib import Path | |
| from tts import split_into_chunks, generate_audio_stream | |
| # 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) | |
| # Instantiate the audio files immediately so they are hot in HF Spaces cache | |
| create_sound_library = { | |
| "willow": ("sample_sounds/willow.wav", 120, "lullaby"), | |
| "sky": ("sample_sounds/sky.wav", 180, "adventure"), | |
| "deep": ("sample_sounds/deep.wav", 240, "adventure"), | |
| "baker": ("sample_sounds/baker.wav", 98, "lullaby"), | |
| "letters": ("sample_sounds/letters.wav", 156, "classic"), | |
| "cloned_preview": ("sample_sounds/cloned_preview.wav", 15, "lullaby") | |
| } | |
| for key, (path, dur, mel) in create_sound_library.items(): | |
| if not os.path.exists(path): | |
| generate_chimes_wav(path, duration=dur, melody_type=mel) | |
| 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" | |
| } | |
| ] | |
| narrative_subtitles = [ | |
| "Deep within the heart of Whispering Woods stands an ancient willow tree...", | |
| "It was rumored to hold the forgotten memories of the valley.", | |
| "When a young cartographer is sent to map the region...", | |
| "She starts hearing a gentle, familiar voice whispering from the emerald leaves.", | |
| "The voice seemed to know her name, calling softly in the early autumn breeze...", | |
| "And that was when she realized, she was not alone in the valley." | |
| ] | |
| 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:] | |
| 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;"> | |
| <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: | |
| html += f""" | |
| <div class="shelf-card"> | |
| <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 = """<div class="book-shelf-grid">""" | |
| for bk in filtered: | |
| html += 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 === '{bk['title']}') {{ r.click(); }} }}); | |
| "> | |
| <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;">{bk['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 += "</div>" | |
| return html | |
| 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 "" | |
| html = """<div style="max-height: 420px; overflow-y: auto; padding: 4px 2px; margin-top: 12px;">""" | |
| for i, para in enumerate(paragraphs): | |
| if i == current_idx: | |
| html += 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;">{para}</p>""" | |
| else: | |
| html += f"""<p style="font-family: 'Playfair Display', Georgia, serif; font-size: 13px; line-height: 1.8; color: #94a3b8; padding: 4px 12px; margin: 4px 0;">{para}</p>""" | |
| html += "</div>" | |
| return html | |
| def render_cloned_voices_html(voices_list): | |
| html = """<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" | |
| html += f""" | |
| <div class="shelf-card" style="align-items: center;"> | |
| <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;">{v['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;"> | |
| {v['description']} | |
| </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> | |
| """ | |
| html += "</div>" | |
| return html | |
| # Gradio Application Core setup | |
| with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo: | |
| books_state = gr.State(mock_books) | |
| voices_state = gr.State(mock_voices) | |
| paragraphs_state = gr.State([]) | |
| tts_chunks_state = gr.State([]) | |
| 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) | |
| with gr.Column(scale=1): | |
| empty_state_sim = gr.Checkbox(label="Simulate Empty", value=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=True, | |
| 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_text = gr.Textbox( | |
| placeholder="Type your question about the story here...", | |
| label="Your Question", lines=2, show_label=False | |
| ) | |
| question_audio = gr.Audio( | |
| sources=["microphone"], type="filepath", | |
| label="Or speak your question", show_label=True | |
| ) | |
| submit_question_btn = gr.Button("π Get Answer in Cloned Voice", variant="primary") | |
| answer_display = gr.HTML(visible=False) | |
| answer_audio = gr.Audio(label="Answer in Narrator's Voice", interactive=False, visible=False) | |
| # 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> | |
| """) | |
| loading_spinner = gr.HTML(visible=False) | |
| 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> | |
| </div> | |
| """) | |
| cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices)) | |
| # TAB 4: Profile & Sandbox Settings | |
| with gr.TabItem("βοΈ Profile & Sandbox Settings") 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;">Sarah Jenkins</h3> | |
| <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">Premium Member since Summer 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;">Bookshelf</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;">14.6h</strong> | |
| <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Listen Time</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;">Cloned Cast</span> | |
| </div> | |
| </div> | |
| """) | |
| gr.HTML("""<div style="margin-top: 24px;"><h4 class="serif-header" style="font-size: 14px; margin-bottom: 8px;">Acoustic Signal Processing Config</h4></div>""") | |
| gr.Checkbox(label="Enable Client Denoising Filter", value=True) | |
| gr.Checkbox(label="Enable Brownian Dynamic Sleep Wave", value=False) | |
| gr.Checkbox(label="Automatic Chapter Transitions", value=True) | |
| with gr.Column(scale=1, elem_classes="card-container"): | |
| gr.HTML(""" | |
| <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Boundary Connection Error Diagnostics</h3> | |
| <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Test robust offline error conditions gracefully.</p> | |
| """) | |
| offline_toggle = gr.Checkbox(label="Simulate Sandbox Connection Outage", value=False) | |
| gr.HTML(""" | |
| <div style="margin-top: 16px; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;"> | |
| <span style="font-size:10px; font-weight:700; text-transform:uppercase; color:#6f6257; display:block; margin-bottom:6px;">Sandbox local metric stores:</span> | |
| <div style="font-size: 11.5px; color: #1c1c19;"> | |
| πΎ Voice Weights Space Cache: 24.8 MB / 512 MB<br> | |
| π¦ Cached offline items: 182 MB (12%) | |
| </div> | |
| </div> | |
| """) | |
| error_sim_view = gr.HTML(""" | |
| <div style="margin-top: 16px; padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; display: none;" id="error-sim-panel"> | |
| <span style="font-size: 20px; display: block; margin-bottom: 4px;">β οΈ Connection Interruption Layout</span> | |
| <strong style="color: #991b1b; font-size:14px; display:block;">Can't reach the story right now</strong> | |
| <p style="color: #7f1d1d; font-size: 11.5px; margin-top:4px; line-height:1.4;">The signal to Whispering Woods took a brief tumble. Verify your client network link or refresh the bookshelf items.</p> | |
| </div> | |
| """, elem_id="error-sim-block") | |
| system_error_modal = gr.HTML(""" | |
| <div style="display: none; background-color: #FAF7F2; padding: 48px 16px; text-align: center; border-radius: 20px; border: 2px solid #fecaca; max-width: 460px; margin: 40px auto;" id="global-sandbox-error"> | |
| <div style="width: 72px; height: 72px; border-radius: 16px; background: #fff5f5; border: 1px solid #fecaca; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px auto; font-size: 36px;"> | |
| β οΈ | |
| </div> | |
| <span style="font-size: 10px; font-weight: 700; color: #dc2626; text-transform: uppercase; letter-spacing: 2px;">Offline Connection Interruption</span> | |
| <h2 class="serif-header" style="font-size: 24px; margin: 8px 0;">Can't reach the story right now</h2> | |
| <p style="font-size: 13.5px; color: #6f6257; line-height: 1.5; margin-bottom: 24px;">The signal to Whispering Woods took a brief tumble. Verify your client network link or try refreshing.</p> | |
| <div style="background: #ede8e3; padding: 10px; border-radius: 10px; font-size: 11px; color:#6f6257; font-family: monospace;"> | |
| Boundary Sandbox Mode Enabled. Uncheck settings switch to resume. | |
| </div> | |
| </div> | |
| """, visible=False) | |
| # REACTIVE LOGIC | |
| # 1. Search / filter shelf | |
| def filter_books_shelf(query, category, empty_mode_active, current_books): | |
| if empty_mode_active: | |
| return generate_library_html(current_books, empty_mode=True) | |
| return generate_library_html(current_books, query=query, category_filt=category) | |
| search_input.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid) | |
| category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid) | |
| empty_state_sim.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid) | |
| # 2. Book card click β load into player | |
| def handle_book_select(title_chosen, current_inventory): | |
| if not title_chosen: | |
| return [gr.update()] * 12 | |
| selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0]) | |
| 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;">{selected['title']}</h3> | |
| <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p> | |
| <span style="display: inline-block; background: rgba(245, 132, 31, 0.15); color: #ffd3a6; font-size: 10.5px; font-weight: 700; padding: 3px 8px; border-radius: 999px; margin-top: 6px;">Active Narrator: {selected['voice_name']}</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], | |
| 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.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False) | |
| ) | |
| 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.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Button(visible=False) | |
| ) | |
| progress(0, desc="Extracting speech samples patterns...") | |
| time.sleep(1.0) | |
| progress(0.3, desc="Filtering ambient air-con noise frequencies...") | |
| time.sleep(1.2) | |
| progress(0.65, desc="Mapping vocal vocal tracts formant spaces & frequencies...") | |
| time.sleep(1.5) | |
| progress(0.9, desc="Validating speech prosody & standard conversational matching...") | |
| time.sleep(1.0) | |
| preview_text = f"“Hello! I have created my brand new clone under '{v_name}'. Ready to narrate any classical story inside your library shelves.”" | |
| 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;">{v_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 β’ Calibrated for Classical Audiobooks</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;"> | |
| {preview_text} | |
| </div> | |
| </div> | |
| """ | |
| return ( | |
| gr.HTML(visible=False), | |
| gr.HTML(visible=False), | |
| gr.HTML(value=cloned_card_html, visible=True), | |
| gr.Audio(visible=True), | |
| gr.Button(visible=True) | |
| ) | |
| extract_btn.click( | |
| animate_cloning_pipeline, | |
| inputs=[new_voice_name, new_voice_gender, mic_recorder], | |
| outputs=[cloning_progress_msg, loading_spinner, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn] | |
| ) | |
| # 4. Add cloned voice to inventory | |
| def save_new_cloned_voice(v_name, v_gender, list_voices): | |
| 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" | |
| } | |
| 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.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], | |
| outputs=[voices_state, cloned_voices_list_grid, cloning_progress_msg, loading_spinner, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn, new_voice_name] | |
| ) | |
| # 5. Play button β streams TTS audio chunk by chunk | |
| def stream_tts(tts_chunks, paras): | |
| _status_playing = """ | |
| <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</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> | |
| """ | |
| if not tts_chunks: | |
| yield None, _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) | |
| return | |
| n = len(tts_chunks) | |
| # Immediately show PLAYING state | |
| yield None, _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) | |
| for sample_rate, wav, i, total, err in generate_audio_stream(tts_chunks): | |
| if err: | |
| yield None, 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) | |
| return | |
| 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>" | |
| yield (sample_rate, wav), _status_playing, chunk_html, gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True) | |
| done_html = f"<div style='text-align:center;font-size:10px;color:#94a3b8;font-family:monospace;margin-top:8px;'>β {n} chunks complete</div>" | |
| yield None, _status_done, done_html, gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False) | |
| play_btn.click( | |
| stream_tts, | |
| inputs=[tts_chunks_state, paragraphs_state], | |
| outputs=[player_audio_control, player_status_bar, chunk_status, play_btn, pause_btn, ask_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] | |
| ) | |
| # 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] | |
| ) | |
| # 9. Submit question | |
| def handle_question_submit(question_txt, question_audio_path): | |
| if not question_txt.strip() and question_audio_path is None: | |
| 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 answer_html, gr.Audio(visible=False) | |
| q_text = question_txt.strip() if question_txt.strip() else "(spoken question)" | |
| mock_answer = "That's a great question! The story tells us something very special about that. Keep listening to discover the full answer as the adventure unfolds." | |
| 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;"> | |
| “{mock_answer}” | |
| </div> | |
| <div style="margin-top: 8px; font-size: 10px; color: #94a3b8;"> | |
| Q: <em>{q_text}</em> | |
| </div> | |
| </div> | |
| """ | |
| return answer_html, gr.Audio(value="sample_sounds/cloned_preview.wav", visible=True) | |
| submit_question_btn.click( | |
| handle_question_submit, | |
| inputs=[question_text, question_audio], | |
| outputs=[answer_display, answer_audio] | |
| ) | |
| # 10. Resume button β back to PLAYING | |
| 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: #4ade80; display: inline-block;"></span> | |
| <span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING</span> | |
| </div> | |
| """ | |
| return ( | |
| status_html, | |
| gr.Button(visible=False), | |
| gr.Button(visible=True), | |
| gr.Button(visible=True), | |
| gr.Button(visible=False), | |
| gr.HTML(visible=False), | |
| gr.Group(visible=False), | |
| gr.HTML(visible=False), | |
| gr.Audio(visible=False), | |
| gr.Textbox(value=""), | |
| ) | |
| 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] | |
| ) | |
| # 11. Story-finished check | |
| def check_story_finished(slider_val, paras): | |
| if paras and int(slider_val) >= len(paras) - 1: | |
| return gr.HTML(visible=True) | |
| return gr.HTML(visible=False) | |
| timeline_slider.change(check_story_finished, inputs=[timeline_slider, paragraphs_state], outputs=[story_finished_panel]) | |
| # 12. Sandbox outage toggle | |
| def toggle_outage_sandbox(checked): | |
| if checked: | |
| return gr.HTML(visible=True), gr.Tabs(visible=False) | |
| else: | |
| return gr.HTML(visible=False), gr.Tabs(visible=True) | |
| offline_toggle.change(toggle_outage_sandbox, inputs=[offline_toggle], outputs=[system_error_modal, main_tabs]) | |
| if __name__ == "__main__": | |
| demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")]) | |