minhahwang Copilot commited on
Commit
c4b102f
Β·
1 Parent(s): d3a755c

refactor: remove dead UI elements, tighten model integration

Browse files

Front-end cleanup:
- Remove narrative_subtitles (defined but never used)
- Remove loading_spinner (always hidden, never shown)
- Remove 5 unused chime WAV files (willow/sky/deep/baker/letters)
- Remove empty_state_sim debug checkbox from library tab
- Remove dead Profile tab checkboxes (denoising, sleep wave, transitions)
- Remove sandbox outage simulation (debug-only feature)
- Remove system_error_modal and offline_toggle handler
- Rename html variable to avoid shadowing html module
- Fix html.escape on voice names and book titles in library/clone cards
- Fix XSS in library card JS onclick (escape quotes in titles)

Integration tightening:
- Timeline slider now auto-advances during TTS playback
- Story text highlight syncs with current playback chunk
- Story-finished panel shown when last chunk completes
- Player shows 'Your Cloned Voice' when voice profile active
- Resume button now shows Play (ready state) instead of fake PLAYING
- Profile tab updated with actual model pipeline info

Net: -38 lines (1011 -> 973), 18 tests still pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Files changed (1) hide show
  1. app.py +99 -136
app.py CHANGED
@@ -56,19 +56,10 @@ def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
56
  packed_val = struct.pack('<h', int(val * 16384))
57
  wav_file.writeframes(packed_val)
58
 
59
- # Instantiate the audio files immediately so they are hot in HF Spaces cache
60
- create_sound_library = {
61
- "willow": ("sample_sounds/willow.wav", 120, "lullaby"),
62
- "sky": ("sample_sounds/sky.wav", 180, "adventure"),
63
- "deep": ("sample_sounds/deep.wav", 240, "adventure"),
64
- "baker": ("sample_sounds/baker.wav", 98, "lullaby"),
65
- "letters": ("sample_sounds/letters.wav", 156, "classic"),
66
- "cloned_preview": ("sample_sounds/cloned_preview.wav", 15, "lullaby")
67
- }
68
-
69
- for key, (path, dur, mel) in create_sound_library.items():
70
- if not os.path.exists(path):
71
- generate_chimes_wav(path, duration=dur, melody_type=mel)
72
 
73
  COVERS_DIR = Path(__file__).parent / "assets" / "covers"
74
 
@@ -142,15 +133,6 @@ mock_voices = [
142
  }
143
  ]
144
 
145
- narrative_subtitles = [
146
- "Deep within the heart of Whispering Woods stands an ancient willow tree...",
147
- "It was rumored to hold the forgotten memories of the valley.",
148
- "When a young cartographer is sent to map the region...",
149
- "She starts hearing a gentle, familiar voice whispering from the emerald leaves.",
150
- "The voice seemed to know her name, calling softly in the early autumn breeze...",
151
- "And that was when she realized, she was not alone in the valley."
152
- ]
153
-
154
  with open(os.path.join(os.path.dirname(__file__), "static", "style.css"), encoding="utf-8") as _f:
155
  css_code = _f.read()
156
 
@@ -263,12 +245,14 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
263
  </div>
264
  """
265
 
266
- html = """<div class="book-shelf-grid">"""
267
  for bk in filtered:
268
- html += f"""
 
 
269
  <div class="shelf-card group" style="flex-direction: column; gap: 0; padding: 0; overflow: hidden; cursor: pointer;" onclick="
270
  var radios = document.querySelectorAll('.library-book-selector input[type=radio]');
271
- radios.forEach(function(r) {{ if(r.value === '{bk['title']}') {{ r.click(); }} }});
272
  ">
273
  <div style="position: relative; aspect-ratio: 3/4; overflow: hidden; height: 200px;">
274
  <img src="{bk['cover_url']}" style="width: 100%; height: 100%; object-fit: cover;" referrerPolicy="no-referrer" />
@@ -277,7 +261,7 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
277
  </div>
278
  <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;">
279
  <span style="font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 700; color: #f5841f;">{bk['category']}</span>
280
- <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>
281
  </div>
282
  </div>
283
  <div style="padding: 16px; display: flex; flex-direction: column; gap: 10px;">
@@ -294,8 +278,8 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
294
  </div>
295
  </div>
296
  """
297
- html += "</div>"
298
- return html
299
 
300
  def load_paragraphs(story_path: str) -> list:
301
  try:
@@ -320,25 +304,27 @@ def render_story_text(paragraphs: list, current_idx: int) -> str:
320
  return result
321
 
322
  def render_cloned_voices_html(voices_list):
323
- html = """<div class="book-shelf-grid">"""
324
  for v in voices_list:
325
  status_color = "#16a34a" if v["status"] == "ready" else "#d97706"
326
  status_bg = "#f0fdf4" if v["status"] == "ready" else "#fef3c7"
 
 
327
 
328
- html += f"""
329
  <div class="shelf-card" style="align-items: center;">
330
  <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;">
331
  {v['avatar_url']}
332
  </div>
333
  <div style="min-width: 0; flex: 1;">
334
  <div style="display: flex; justify-content: space-between; align-items: center; gap: 6px;">
335
- <h5 class="serif-header" style="font-size: 14.5px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{v['name']}</h5>
336
  <span style="font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; color: {status_color}; background: {status_bg};">
337
  {v['status']}
338
  </span>
339
  </div>
340
  <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;">
341
- {v['description']}
342
  </p>
343
  <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;">
344
  <span>Gender: {v['gender']}</span>
@@ -347,8 +333,8 @@ def render_cloned_voices_html(voices_list):
347
  </div>
348
  </div>
349
  """
350
- html += "</div>"
351
- return html
352
 
353
 
354
  # Gradio Application Core setup
@@ -395,8 +381,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
395
  search_input = gr.Textbox(placeholder="Search by title, author, or voice...", label="Filter", show_label=False)
396
  with gr.Column(scale=1):
397
  category_filter = gr.Radio(["All", "In Progress", "Completed"], value="All", label="Progress", show_label=False)
398
- with gr.Column(scale=1):
399
- empty_state_sim = gr.Checkbox(label="Simulate Empty", value=False)
400
 
401
  shelf_grid = gr.HTML(value=generate_library_html(mock_books))
402
 
@@ -527,7 +511,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
527
  </div>
528
  """)
529
 
530
- loading_spinner = gr.HTML(visible=False)
531
  voice_cloning_success_panel = gr.HTML(visible=False)
532
  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)
533
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
@@ -539,8 +522,8 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
539
  """)
540
  cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices))
541
 
542
- # TAB 4: Profile & Sandbox Settings
543
- with gr.TabItem("βš™οΈ Profile & Sandbox Settings") as profile_tab:
544
  with gr.Row():
545
  with gr.Column(scale=1, elem_classes="card-container"):
546
  gr.HTML("""
@@ -548,8 +531,8 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
548
  <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;">
549
  πŸ‘©β€πŸ’»
550
  </div>
551
- <h3 class="serif-header" style="font-size: 18px; margin: 0;">Sarah Jenkins</h3>
552
- <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">Premium Member since Summer 2026</p>
553
  </div>
554
  """)
555
 
@@ -558,90 +541,70 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
558
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
559
  <span style="font-size: 18px; display: block;">πŸ“š</span>
560
  <strong style="font-size: 15px; display: block; color: #1c1c19;">5</strong>
561
- <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Bookshelf</span>
562
  </div>
563
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
564
- <span style="font-size: 18px; display: block;">⏱️</span>
565
- <strong style="font-size: 15px; display: block; color: #1c1c19;">14.6h</strong>
566
- <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Listen Time</span>
567
  </div>
568
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
569
  <span style="font-size: 18px; display: block;">πŸŽ™οΈ</span>
570
- <strong style="font-size: 15px; display: block; color: #1c1c19;">3</strong>
571
- <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Cloned Cast</span>
572
  </div>
573
  </div>
574
  """)
575
 
576
- gr.HTML("""<div style="margin-top: 24px;"><h4 class="serif-header" style="font-size: 14px; margin-bottom: 8px;">Acoustic Signal Processing Config</h4></div>""")
577
- gr.Checkbox(label="Enable Client Denoising Filter", value=True)
578
- gr.Checkbox(label="Enable Brownian Dynamic Sleep Wave", value=False)
579
- gr.Checkbox(label="Automatic Chapter Transitions", value=True)
580
-
581
  with gr.Column(scale=1, elem_classes="card-container"):
582
  gr.HTML("""
583
- <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Boundary Connection Error Diagnostics</h3>
584
- <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Test robust offline error conditions gracefully.</p>
585
  """)
586
 
587
- offline_toggle = gr.Checkbox(label="Simulate Sandbox Connection Outage", value=False)
588
-
589
  gr.HTML("""
590
- <div style="margin-top: 16px; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
591
- <span style="font-size:10px; font-weight:700; text-transform:uppercase; color:#6f6257; display:block; margin-bottom:6px;">Sandbox local metric stores:</span>
592
- <div style="font-size: 11.5px; color: #1c1c19;">
593
- πŸ’Ύ Voice Weights Space Cache: 24.8 MB / 512 MB<br>
594
- πŸ“¦ Cached offline items: 182 MB (12%)
 
595
  </div>
596
  </div>
597
  """)
598
 
599
- error_sim_view = gr.HTML("""
600
- <div style="margin-top: 16px; padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; display: none;" id="error-sim-panel">
601
- <span style="font-size: 20px; display: block; margin-bottom: 4px;">⚠️ Connection Interruption Layout</span>
602
- <strong style="color: #991b1b; font-size:14px; display:block;">Can't reach the story right now</strong>
603
- <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>
604
- </div>
605
- """, elem_id="error-sim-block")
606
-
607
- system_error_modal = gr.HTML("""
608
- <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">
609
- <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;">
610
- ⚠️
611
- </div>
612
- <span style="font-size: 10px; font-weight: 700; color: #dc2626; text-transform: uppercase; letter-spacing: 2px;">Offline Connection Interruption</span>
613
- <h2 class="serif-header" style="font-size: 24px; margin: 8px 0;">Can't reach the story right now</h2>
614
- <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>
615
- <div style="background: #ede8e3; padding: 10px; border-radius: 10px; font-size: 11px; color:#6f6257; font-family: monospace;">
616
- Boundary Sandbox Mode Enabled. Uncheck settings switch to resume.
617
- </div>
618
- </div>
619
- """, visible=False)
620
-
621
  # REACTIVE LOGIC
622
 
623
  # 1. Search / filter shelf
624
- def filter_books_shelf(query, category, empty_mode_active, current_books):
625
- if empty_mode_active:
626
- return generate_library_html(current_books, empty_mode=True)
627
  return generate_library_html(current_books, query=query, category_filt=category)
628
 
629
- search_input.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
630
- category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
631
- empty_state_sim.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
632
 
633
  # 2. Book card click β†’ load into player
634
- def handle_book_select(title_chosen, current_inventory):
635
  if not title_chosen:
636
  return [gr.update()] * 12
637
  selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
638
 
 
 
 
 
 
 
 
 
 
 
 
639
  player_html = f"""
640
  <div style="text-align: center; margin-bottom: 16px; margin-top: 8px;">
641
  <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;" />
642
- <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 20px; margin:0; color:#FAF7F2;">{selected['title']}</h3>
643
- <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p>
644
- <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>
645
  </div>
646
  """
647
  paras = load_paragraphs(selected["story_path"])
@@ -672,7 +635,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
672
 
673
  book_selector.change(
674
  handle_book_select,
675
- inputs=[book_selector, books_state],
676
  outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
677
  play_btn, pause_btn, ask_btn, story_finished_panel,
678
  paragraphs_state, tts_chunks_state, story_text_display, timeline_slider]
@@ -686,7 +649,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
686
  Please provide a comforting nickname for your cloning candidate.
687
  </div>""",
688
  gr.HTML(visible=False),
689
- gr.HTML(visible=False),
690
  gr.Audio(visible=False),
691
  gr.Button(visible=False),
692
  None,
@@ -697,7 +659,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
697
  Microphone recording sample missing. Speak some script lines!
698
  </div>""",
699
  gr.HTML(visible=False),
700
- gr.HTML(visible=False),
701
  gr.Audio(visible=False),
702
  gr.Button(visible=False),
703
  None,
@@ -742,7 +703,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
742
  """
743
 
744
  return (
745
- gr.HTML(visible=False),
746
  gr.HTML(visible=False),
747
  gr.HTML(value=cloned_card_html, visible=True),
748
  gr.Audio(value=preview_path, visible=preview_path is not None),
@@ -753,7 +713,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
753
  extract_btn.click(
754
  animate_cloning_pipeline,
755
  inputs=[new_voice_name, new_voice_gender, mic_recorder],
756
- outputs=[cloning_progress_msg, loading_spinner, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn, voice_profile_state]
757
  )
758
 
759
  # 4. Add cloned voice to inventory
@@ -774,7 +734,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
774
  render_cloned_voices_html(updated),
775
  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>"""),
776
  gr.HTML(visible=False),
777
- gr.HTML(visible=False),
778
  gr.Audio(visible=False),
779
  gr.Button(visible=False),
780
  gr.Textbox(value="")
@@ -783,7 +742,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
783
  add_to_library_btn.click(
784
  save_new_cloned_voice,
785
  inputs=[new_voice_name, new_voice_gender, voices_state],
786
- 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]
787
  )
788
 
789
  # 5. Play button β€” streams TTS audio chunk by chunk
@@ -801,29 +760,50 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
801
  </div>
802
  """
803
 
 
 
804
  if not tts_chunks:
805
- 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)
 
 
 
 
806
  return
807
 
808
  n = len(tts_chunks)
809
- # Immediately show PLAYING state
810
- 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)
 
 
 
811
 
812
  for sample_rate, wav, i, total, err in generate_audio_stream(tts_chunks, voice_profile_id=profile_id):
813
  if err:
814
- 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)
 
 
815
  return
 
 
816
  next_label = f"chunk {i+2} / {total}" if i + 1 < total else "last chunk"
817
  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>"
818
- yield (sample_rate, wav), _status_playing, chunk_html, gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True)
 
 
 
 
819
 
820
  done_html = f"<div style='text-align:center;font-size:10px;color:#94a3b8;font-family:monospace;margin-top:8px;'>βœ… {n} chunks complete</div>"
821
- yield None, _status_done, done_html, gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False)
 
 
 
822
 
823
  play_btn.click(
824
  stream_tts,
825
  inputs=[tts_chunks_state, paragraphs_state, voice_profile_state],
826
- outputs=[player_audio_control, player_status_bar, chunk_status, play_btn, pause_btn, ask_btn]
 
827
  )
828
 
829
  # 6. Pause button β€” PAUSED state
@@ -962,25 +942,25 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
962
  outputs=[answer_display, answer_audio]
963
  )
964
 
965
- # 10. Resume button β€” back to PLAYING
966
  def enter_resume_state():
967
  status_html = """
968
  <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
969
- <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
970
- <span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING</span>
971
  </div>
972
  """
973
  return (
974
  status_html,
975
- gr.Button(visible=False),
976
- gr.Button(visible=True),
977
- gr.Button(visible=True),
978
- gr.Button(visible=False),
979
- gr.HTML(visible=False),
980
- gr.Group(visible=False),
981
- gr.HTML(visible=False),
982
- gr.Audio(visible=False),
983
- gr.Textbox(value=""),
984
  )
985
 
986
  resume_btn.click(
@@ -989,22 +969,5 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
989
  outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio, question_text]
990
  )
991
 
992
- # 11. Story-finished check
993
- def check_story_finished(slider_val, paras):
994
- if paras and int(slider_val) >= len(paras) - 1:
995
- return gr.HTML(visible=True)
996
- return gr.HTML(visible=False)
997
-
998
- timeline_slider.change(check_story_finished, inputs=[timeline_slider, paragraphs_state], outputs=[story_finished_panel])
999
-
1000
- # 12. Sandbox outage toggle
1001
- def toggle_outage_sandbox(checked):
1002
- if checked:
1003
- return gr.HTML(visible=True), gr.Tabs(visible=False)
1004
- else:
1005
- return gr.HTML(visible=False), gr.Tabs(visible=True)
1006
-
1007
- offline_toggle.change(toggle_outage_sandbox, inputs=[offline_toggle], outputs=[system_error_modal, main_tabs])
1008
-
1009
  if __name__ == "__main__":
1010
  demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")])
 
56
  packed_val = struct.pack('<h', int(val * 16384))
57
  wav_file.writeframes(packed_val)
58
 
59
+ # Generate a short placeholder chime for the preview widget's initial value
60
+ _preview_chime_path = "sample_sounds/cloned_preview.wav"
61
+ if not os.path.exists(_preview_chime_path):
62
+ generate_chimes_wav(_preview_chime_path, duration=15, melody_type="lullaby")
 
 
 
 
 
 
 
 
 
63
 
64
  COVERS_DIR = Path(__file__).parent / "assets" / "covers"
65
 
 
133
  }
134
  ]
135
 
 
 
 
 
 
 
 
 
 
136
  with open(os.path.join(os.path.dirname(__file__), "static", "style.css"), encoding="utf-8") as _f:
137
  css_code = _f.read()
138
 
 
245
  </div>
246
  """
247
 
248
+ html_out = """<div class="book-shelf-grid">"""
249
  for bk in filtered:
250
+ safe_title = html.escape(bk['title'])
251
+ js_safe_title = bk['title'].replace("'", "\\'").replace('"', '\\"')
252
+ html_out += f"""
253
  <div class="shelf-card group" style="flex-direction: column; gap: 0; padding: 0; overflow: hidden; cursor: pointer;" onclick="
254
  var radios = document.querySelectorAll('.library-book-selector input[type=radio]');
255
+ radios.forEach(function(r) {{ if(r.value === '{js_safe_title}') {{ r.click(); }} }});
256
  ">
257
  <div style="position: relative; aspect-ratio: 3/4; overflow: hidden; height: 200px;">
258
  <img src="{bk['cover_url']}" style="width: 100%; height: 100%; object-fit: cover;" referrerPolicy="no-referrer" />
 
261
  </div>
262
  <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;">
263
  <span style="font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 700; color: #f5841f;">{bk['category']}</span>
264
+ <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>
265
  </div>
266
  </div>
267
  <div style="padding: 16px; display: flex; flex-direction: column; gap: 10px;">
 
278
  </div>
279
  </div>
280
  """
281
+ html_out += "</div>"
282
+ return html_out
283
 
284
  def load_paragraphs(story_path: str) -> list:
285
  try:
 
304
  return result
305
 
306
  def render_cloned_voices_html(voices_list):
307
+ markup = """<div class="book-shelf-grid">"""
308
  for v in voices_list:
309
  status_color = "#16a34a" if v["status"] == "ready" else "#d97706"
310
  status_bg = "#f0fdf4" if v["status"] == "ready" else "#fef3c7"
311
+ safe_name = html.escape(v['name'])
312
+ safe_desc = html.escape(v['description'])
313
 
314
+ markup += f"""
315
  <div class="shelf-card" style="align-items: center;">
316
  <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;">
317
  {v['avatar_url']}
318
  </div>
319
  <div style="min-width: 0; flex: 1;">
320
  <div style="display: flex; justify-content: space-between; align-items: center; gap: 6px;">
321
+ <h5 class="serif-header" style="font-size: 14.5px; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{safe_name}</h5>
322
  <span style="font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; color: {status_color}; background: {status_bg};">
323
  {v['status']}
324
  </span>
325
  </div>
326
  <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;">
327
+ {safe_desc}
328
  </p>
329
  <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;">
330
  <span>Gender: {v['gender']}</span>
 
333
  </div>
334
  </div>
335
  """
336
+ markup += "</div>"
337
+ return markup
338
 
339
 
340
  # Gradio Application Core setup
 
381
  search_input = gr.Textbox(placeholder="Search by title, author, or voice...", label="Filter", show_label=False)
382
  with gr.Column(scale=1):
383
  category_filter = gr.Radio(["All", "In Progress", "Completed"], value="All", label="Progress", show_label=False)
 
 
384
 
385
  shelf_grid = gr.HTML(value=generate_library_html(mock_books))
386
 
 
511
  </div>
512
  """)
513
 
 
514
  voice_cloning_success_panel = gr.HTML(visible=False)
515
  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)
516
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
 
522
  """)
523
  cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices))
524
 
525
+ # TAB 4: Profile
526
+ with gr.TabItem("βš™οΈ Profile") as profile_tab:
527
  with gr.Row():
528
  with gr.Column(scale=1, elem_classes="card-container"):
529
  gr.HTML("""
 
531
  <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;">
532
  πŸ‘©β€πŸ’»
533
  </div>
534
+ <h3 class="serif-header" style="font-size: 18px; margin: 0;">Your Profile</h3>
535
+ <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">ReadBookMom β€” Hugging Face Hackathon 2026</p>
536
  </div>
537
  """)
538
 
 
541
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
542
  <span style="font-size: 18px; display: block;">πŸ“š</span>
543
  <strong style="font-size: 15px; display: block; color: #1c1c19;">5</strong>
544
+ <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Stories</span>
545
  </div>
546
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
547
+ <span style="font-size: 18px; display: block;">🧠</span>
548
+ <strong style="font-size: 15px; display: block; color: #1c1c19;">3</strong>
549
+ <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Models</span>
550
  </div>
551
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
552
  <span style="font-size: 18px; display: block;">πŸŽ™οΈ</span>
553
+ <strong style="font-size: 15px; display: block; color: #1c1c19;">Qwen3</strong>
554
+ <span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">TTS Engine</span>
555
  </div>
556
  </div>
557
  """)
558
 
 
 
 
 
 
559
  with gr.Column(scale=1, elem_classes="card-container"):
560
  gr.HTML("""
561
+ <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Model Pipeline</h3>
562
+ <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Active AI models powering the app.</p>
563
  """)
564
 
 
 
565
  gr.HTML("""
566
+ <div style="padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
567
+ <div style="font-size: 11.5px; color: #1c1c19; line-height: 2;">
568
+ πŸŽ™οΈ <strong>Voice Cloning:</strong> Qwen3-TTS-1.7B-Base (bfloat16)<br>
569
+ πŸ—£οΈ <strong>Fallback TTS:</strong> Supertonic ONNX<br>
570
+ πŸ‘‚ <strong>ASR:</strong> Whisper-small<br>
571
+ 🧠 <strong>Q&A:</strong> Qwen2.5-3B-Instruct
572
  </div>
573
  </div>
574
  """)
575
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  # REACTIVE LOGIC
577
 
578
  # 1. Search / filter shelf
579
+ def filter_books_shelf(query, category, current_books):
 
 
580
  return generate_library_html(current_books, query=query, category_filt=category)
581
 
582
+ search_input.change(filter_books_shelf, inputs=[search_input, category_filter, books_state], outputs=shelf_grid)
583
+ category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, books_state], outputs=shelf_grid)
 
584
 
585
  # 2. Book card click β†’ load into player
586
+ def handle_book_select(title_chosen, current_inventory, profile_id):
587
  if not title_chosen:
588
  return [gr.update()] * 12
589
  selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
590
 
591
+ # Show cloned voice name if profile exists
592
+ from voice_clone import has_profile
593
+ if profile_id and has_profile(profile_id):
594
+ narrator_label = "πŸŽ™οΈ Your Cloned Voice"
595
+ narrator_color = "#4ade80"
596
+ else:
597
+ narrator_label = f"πŸ”Š {selected['voice_name']} (stock)"
598
+ narrator_color = "#ffd3a6"
599
+
600
+ safe_title = html.escape(selected['title'])
601
+ safe_author = html.escape(selected['author'])
602
  player_html = f"""
603
  <div style="text-align: center; margin-bottom: 16px; margin-top: 8px;">
604
  <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;" />
605
+ <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 20px; margin:0; color:#FAF7F2;">{safe_title}</h3>
606
+ <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {safe_author}</p>
607
+ <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>
608
  </div>
609
  """
610
  paras = load_paragraphs(selected["story_path"])
 
635
 
636
  book_selector.change(
637
  handle_book_select,
638
+ inputs=[book_selector, books_state, voice_profile_state],
639
  outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
640
  play_btn, pause_btn, ask_btn, story_finished_panel,
641
  paragraphs_state, tts_chunks_state, story_text_display, timeline_slider]
 
649
  Please provide a comforting nickname for your cloning candidate.
650
  </div>""",
651
  gr.HTML(visible=False),
 
652
  gr.Audio(visible=False),
653
  gr.Button(visible=False),
654
  None,
 
659
  Microphone recording sample missing. Speak some script lines!
660
  </div>""",
661
  gr.HTML(visible=False),
 
662
  gr.Audio(visible=False),
663
  gr.Button(visible=False),
664
  None,
 
703
  """
704
 
705
  return (
 
706
  gr.HTML(visible=False),
707
  gr.HTML(value=cloned_card_html, visible=True),
708
  gr.Audio(value=preview_path, visible=preview_path is not None),
 
713
  extract_btn.click(
714
  animate_cloning_pipeline,
715
  inputs=[new_voice_name, new_voice_gender, mic_recorder],
716
+ outputs=[cloning_progress_msg, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn, voice_profile_state]
717
  )
718
 
719
  # 4. Add cloned voice to inventory
 
734
  render_cloned_voices_html(updated),
735
  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>"""),
736
  gr.HTML(visible=False),
 
737
  gr.Audio(visible=False),
738
  gr.Button(visible=False),
739
  gr.Textbox(value="")
 
742
  add_to_library_btn.click(
743
  save_new_cloned_voice,
744
  inputs=[new_voice_name, new_voice_gender, voices_state],
745
+ 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]
746
  )
747
 
748
  # 5. Play button β€” streams TTS audio chunk by chunk
 
760
  </div>
761
  """
762
 
763
+ n_paras = len(paras) if paras else 1
764
+
765
  if not tts_chunks:
766
+ yield (None, _status_done,
767
+ "<div style='text-align:center;font-size:10px;color:#64748b;font-family:monospace;margin-top:8px;'>No content loaded</div>",
768
+ gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False),
769
+ gr.Slider(value=0), render_story_text(paras, 0) if paras else "",
770
+ gr.HTML(visible=False))
771
  return
772
 
773
  n = len(tts_chunks)
774
+ yield (None, _status_playing,
775
+ f"<div style='text-align:center;font-size:10px;color:#4ade80;font-family:monospace;margin-top:8px;'>Generating chunk 1 / {n}…</div>",
776
+ gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=False),
777
+ gr.Slider(value=0), render_story_text(paras, 0) if paras else "",
778
+ gr.HTML(visible=False))
779
 
780
  for sample_rate, wav, i, total, err in generate_audio_stream(tts_chunks, voice_profile_id=profile_id):
781
  if err:
782
+ yield (None, f"<div style='color:#ef4444;font-size:11px;'>Error on chunk {i+1}: {err}</div>", "",
783
+ gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False),
784
+ gr.Slider(value=0), gr.update(), gr.HTML(visible=False))
785
  return
786
+ # Map chunk index to paragraph index (approximate: chunks β‰ˆ sentences, paragraphs β‰ˆ groups)
787
+ para_idx = min(int(i * n_paras / total), n_paras - 1) if total > 0 else 0
788
  next_label = f"chunk {i+2} / {total}" if i + 1 < total else "last chunk"
789
  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>"
790
+ is_last = (i + 1 >= total)
791
+ yield ((sample_rate, wav), _status_playing, chunk_html,
792
+ gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True),
793
+ gr.Slider(value=para_idx), render_story_text(paras, para_idx),
794
+ gr.HTML(visible=is_last))
795
 
796
  done_html = f"<div style='text-align:center;font-size:10px;color:#94a3b8;font-family:monospace;margin-top:8px;'>βœ… {n} chunks complete</div>"
797
+ yield (None, _status_done, done_html,
798
+ gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False),
799
+ gr.Slider(value=max(n_paras - 1, 0)), render_story_text(paras, max(n_paras - 1, 0)),
800
+ gr.HTML(visible=True))
801
 
802
  play_btn.click(
803
  stream_tts,
804
  inputs=[tts_chunks_state, paragraphs_state, voice_profile_state],
805
+ outputs=[player_audio_control, player_status_bar, chunk_status, play_btn, pause_btn, ask_btn,
806
+ timeline_slider, story_text_display, story_finished_panel]
807
  )
808
 
809
  # 6. Pause button β€” PAUSED state
 
942
  outputs=[answer_display, answer_audio]
943
  )
944
 
945
+ # 10. Resume button β€” dismiss Q&A panel, show Play to continue
946
  def enter_resume_state():
947
  status_html = """
948
  <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
949
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
950
+ <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY β€” Tap Play to continue</span>
951
  </div>
952
  """
953
  return (
954
  status_html,
955
+ gr.Button(visible=True), # play_btn β€” user can tap Play to resume TTS
956
+ gr.Button(visible=False), # pause_btn
957
+ gr.Button(visible=False), # ask_btn
958
+ gr.Button(visible=False), # resume_btn
959
+ gr.HTML(visible=False), # qa_panel
960
+ gr.Group(visible=False), # qa_input_group
961
+ gr.HTML(visible=False), # answer_display
962
+ gr.Audio(visible=False), # answer_audio
963
+ gr.Textbox(value=""), # question_text
964
  )
965
 
966
  resume_btn.click(
 
969
  outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio, question_text]
970
  )
971
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
972
  if __name__ == "__main__":
973
  demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")])