minhahwang Claude Sonnet 4.6 commited on
Commit
addb9c9
·
2 Parent(s): 59ea01423ba92f

Merge HuggingFace_Hack/main: real TTS pipeline + asset overhaul

Browse files

app.py: dynamic story loading from stories/, CSS from static/style.css,
integrated library+player tab, real TTS streaming via tts.py
requirements.txt: pin gradio==5.35.0, add supertonic/onnxruntime/hf-hub
README.md: prepend HF Space YAML header, keep full project README
New: tts.py, static/style.css, assets/covers/, SPEC.md, docs/, test_modules/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.wav filter=lfs diff=lfs merge=lfs -text
5
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
6
+ *.gif filter=lfs diff=lfs merge=lfs -text
7
+ *.webp filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,3 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  # ReadBookMom
2
 
3
  **A parent records their voice. A bedtime story plays in that voice. The child can ask questions and hear answers — all in mom or dad's voice.**
 
1
+ ---
2
+ title: ReadBookMom
3
+ emoji: 📖
4
+ colorFrom: yellow
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: "5.35.0"
8
+ python_version: "3.10"
9
+ app_file: app.py
10
+ pinned: false
11
+ ---
12
+
13
  # ReadBookMom
14
 
15
  **A parent records their voice. A bedtime story plays in that voice. The child can ask questions and hear answers — all in mom or dad's voice.**
SPEC.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ReadBookMom
2
+
3
+ **A parent records their voice. A bedtime story plays in that voice. The child can ask questions and hear answers — all in mom or dad's voice.**
4
+
5
+ Built for the Hugging Face Hackathon. Runs entirely on local models inside a Gradio app on Hugging Face Spaces — no external APIs, no data leaves the server.
6
+
7
+ ## Demo
8
+
9
+ ```
10
+ 🎤 Record 15s of your voice → 📖 Pick a story → ▶️ Story plays in your voice
11
+
12
+ ❓ Child taps Ask → Story pauses
13
+
14
+ Child asks a question → Hears answer in your voice
15
+
16
+ ▶️ Story resumes
17
+ ```
18
+
19
+ ## How It Works
20
+
21
+ | Step | What Happens | Model |
22
+ |---|---|---|
23
+ | **Clone** | Parent records or uploads 15–30s of audio. Voice representation is computed and cached. | QWEN-TTS-0.6B |
24
+ | **Listen** | Story plays in the cloned voice as interruptible paragraph chunks. | QWEN-TTS-0.6B |
25
+ | **Ask** | Child taps Ask, narration pauses, child types or speaks a question. | Whisper-small (ASR, on-demand) |
26
+ | **Answer** | A short grounded answer is generated from the story context and spoken in the cloned voice. | Qwen2.5-3B-Instruct + QWEN-TTS-0.6B |
27
+ | **Resume** | Story continues from where it left off. | Cached chunks |
28
+
29
+ ## Models
30
+
31
+ | Model | Role | Size |
32
+ |---|---|---|
33
+ | [QWEN-TTS-0.6B](https://huggingface.co/Qwen/Qwen-TTS-0.6B) | Voice cloning + TTS | 0.6B params |
34
+ | [Qwen2.5-3B-Instruct](https://huggingface.co/Qwen/Qwen2.5-3B-Instruct) | Story Q&A | 3B params (4-bit on T4) |
35
+ | [Whisper-small](https://huggingface.co/openai/whisper-small) | Child speech-to-text | 244M params (loaded on demand) |
36
+
37
+ ## Tech Stack
38
+
39
+ - **UI:** Gradio 5.x + gr.Server (custom CSS/JS for Google Stitch-inspired design)
40
+ - **Runtime:** Single `app.py` process on HF Spaces (T4 or A10G GPU)
41
+ - **Stories:** 10 public domain `.txt` files (downloaded and cleaned from Project Gutenberg)
42
+ - **Storage:** In-memory session cache (no database)
43
+
44
+ ## Quick Start (Local Dev)
45
+
46
+ ```bash
47
+ git clone https://github.com/MomsVoiceAI/HuggingFace_Hack.git
48
+ cd HuggingFace_Hack
49
+ pip install gradio transformers torch accelerate bitsandbytes soundfile numpy
50
+ python app.py
51
+ # → http://localhost:7860
52
+ ```
53
+
54
+ ## Project Structure
55
+
56
+ ```
57
+ ├── app.py # Main Gradio app
58
+ ├── requirements.txt # Python deps
59
+ ├── stories/ # 10 cleaned public domain story texts (TTS-ready)
60
+ ├── story_downloader/ # Story acquisition & cleaning pipeline
61
+ │ ├── gutenberg_downloader.py # Reusable Project Gutenberg downloader/parser
62
+ │ ├── download_stories.py # Downloads 10 children's stories
63
+ │ └── clean_stories.py # Strips Gutenberg boilerplate for TTS
64
+ ├── static/ # Custom CSS/JS for Stitch-style UI
65
+ ├── assets/covers/ # Story cover images
66
+ ├── mission.md # Product vision
67
+ ├── sprint.md # 2-day hackathon sprint plan
68
+ ├── tech_stack.md # Technical architecture
69
+ └── future_mobile_app_considerations.md # Mobile deployment guidance
70
+ ```
71
+
72
+ ## Stories Included
73
+
74
+ | Story | Words | Source |
75
+ |---|---|---|
76
+ | The Tale of Peter Rabbit | 948 | Beatrix Potter |
77
+ | The Tale of Benjamin Bunny | 1,118 | Beatrix Potter |
78
+ | The Tale of Jemima Puddle-Duck | 1,245 | Beatrix Potter |
79
+ | The Tale of Tom Kitten | 691 | Beatrix Potter |
80
+ | The History of Tom Thumb | 2,912 | Traditional |
81
+ | The Story of the Three Little Pigs | 956 | Traditional |
82
+ | The Little Red Hen | 1,295 | Traditional |
83
+ | The Little Gingerbread Man | 1,823 | Traditional |
84
+ | The Sleeping Beauty | 1,783 | Traditional |
85
+ | The Adventures of Puss in Boots | 503 | Traditional (verse) |
86
+
87
+ All stories are public domain from [Project Gutenberg](https://www.gutenberg.org/). Each file uses a simple format: title on line 1, blank line, then story prose — ready for direct TTS consumption.
88
+
89
+ ## Key Design Decisions
90
+
91
+ - **All local inference** — voice, Q&A, and ASR run on the Space GPU. No external APIs.
92
+ - **Interruptible chunked streaming** — paragraphs synthesized and played one at a time for fast start and clean pause/resume.
93
+ - **Pre-generated Q&A** — anticipated questions are generated in the background during narration for sub-1s response on cache hits.
94
+ - **Button-based interruption** — tap Ask to pause. No always-listening mic (privacy + complexity).
95
+ - **Privacy-first** — no audio leaves the server, no user accounts, no database.
96
+
97
+ ## Privacy
98
+
99
+ All inference runs on the Hugging Face Space GPU. Voice samples, story text, and generated audio stay on the server runtime and are not persisted after the session ends. No external APIs are called.
100
+
101
+ ## License
102
+
103
+ Hackathon project. Stories are public domain.
app.py CHANGED
@@ -4,21 +4,24 @@ import struct
4
  import wave
5
  import gradio as gr
6
  import time
 
 
 
7
 
8
  # Create directories for sample audio files
9
  os.makedirs("sample_sounds", exist_ok=True)
10
 
11
- # Generate relaxing audio chime waveforms procedurally
12
  # This runs fully client/server-side with standard Python, eliminating mock limits or Torch wait times
13
  def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
14
  sample_rate = 16000
15
  n_samples = int(duration * sample_rate)
16
-
17
  with wave.open(filename, 'wb') as wav_file:
18
  wav_file.setnchannels(1)
19
  wav_file.setsampwidth(2)
20
  wav_file.setframerate(sample_rate)
21
-
22
  # Pentatonic cozy scales
23
  if melody_type == "lullaby":
24
  notes = [261.63, 293.66, 329.63, 392.00, 440.00] # C4, D4, E4, G4, A4
@@ -32,7 +35,7 @@ def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
32
  note_speed = sample_rate * 1.5 if melody_type == "lullaby" else sample_rate * 1.2
33
  note_idx = int((i / note_speed) % len(notes))
34
  freq = notes[note_idx]
35
-
36
  # envelope to prevent crackles
37
  sample_in_note = i % int(note_speed)
38
  envelope = 1.0
@@ -41,13 +44,13 @@ def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
41
  else: # decay
42
  decay_length = note_speed - 1200
43
  envelope = max(0.0, 1.0 - (sample_in_note - 1200) / decay_length)
44
-
45
  # Synthesize voice-harmonic chime
46
  val = math.sin(2 * math.pi * freq * i / sample_rate)
47
  val += 0.45 * math.sin(2 * math.pi * (freq * 1.5) * i / sample_rate)
48
  val += 0.25 * math.sin(2 * math.pi * (freq * 2.0) * i / sample_rate)
49
  val = val / 1.7 * envelope
50
-
51
  packed_val = struct.pack('<h', int(val * 16384))
52
  wav_file.writeframes(packed_val)
53
 
@@ -65,79 +68,47 @@ for key, (path, dur, mel) in create_sound_library.items():
65
  if not os.path.exists(path):
66
  generate_chimes_wav(path, duration=dur, melody_type=mel)
67
 
68
- # Initial dataset of audiobooks
69
- mock_books = [
70
- {
71
- "id": "1",
72
- "title": "The Enchanted Willow",
73
- "author": "Elena S. Thorne",
74
- "cover_url": "https://images.unsplash.com/photo-1544947950-fa07a98d237f?auto=format&fit=crop&q=80&w=400",
75
- "voice_name": "Mom's Voice",
76
- "duration": 1200,
77
- "elapsed_time": 456,
78
- "synopsis": "Deep within the heart of Whispering Woods stands an ancient willow tree, 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 rustling emerald leaves.",
79
- "category": "Fantasy",
80
- "is_cloned": True,
81
- "percentage": 38,
82
- "audio_path": "sample_sounds/willow.wav"
83
- },
84
- {
85
- "id": "2",
86
- "title": "The Clockwork Sky",
87
- "author": "Arthur Pendelton",
88
- "cover_url": "https://images.unsplash.com/photo-1512820790803-83ca734da794?auto=format&fit=crop&q=80&w=400",
89
- "voice_name": "Folk Storyteller",
90
- "duration": 1800,
91
- "elapsed_time": 0,
92
- "synopsis": "In a world where the heavens are driven by complex brass gears and clockwork stars, a young apprentice discovers an unaccounted gear grinding in the midnight sky—a secret that the High Astrologers have hidden for a millennium.",
93
- "category": "Sci-Fi & Steampunk",
94
- "is_cloned": False,
95
- "percentage": 0,
96
- "audio_path": "sample_sounds/sky.wav"
97
- },
98
- {
99
- "id": "3",
100
- "title": "Echoes of the Deep",
101
- "author": "Mariana Vance",
102
- "cover_url": "https://images.unsplash.com/photo-1476275466078-4007374efbbe?auto=format&fit=crop&q=80&w=400",
103
- "voice_name": "Grandpa Joseph",
104
- "duration": 2400,
105
- "elapsed_time": 1200,
106
- "synopsis": "A quiet oceanographer working in a remote marine sanctuary begins to trace an unusual, rhythmic hum coming from the Marianas Trench. The signal grows stronger, mimicking a lullaby his grandfather used to whistle.",
107
- "category": "Adventure",
108
- "is_cloned": True,
109
- "percentage": 50,
110
- "audio_path": "sample_sounds/deep.wav"
111
- },
112
- {
113
- "id": "4",
114
- "title": "The Midnight Baker",
115
- "author": "Clara Dupont",
116
- "cover_url": "https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?auto=format&fit=crop&q=80&w=400",
117
- "voice_name": "Mom's Voice",
118
- "duration": 980,
119
- "elapsed_time": 980,
120
- "synopsis": "Every night at midnight, Clara bakes bread that infuses the sleeping town with warmth. Customers don’t buy her pastries to satisfy hunger; they eat them to relive the comforting memories of childhood summers.",
121
- "category": "Slice of Life",
122
- "is_cloned": True,
123
- "percentage": 100,
124
- "audio_path": "sample_sounds/baker.wav"
125
- },
126
- {
127
- "id": "5",
128
- "title": "Letters from Paris",
129
- "author": "Julian Mercer",
130
- "cover_url": "https://images.unsplash.com/photo-1492052722242-2554d0e99e3a?auto=format&fit=crop&q=80&w=400",
131
- "voice_name": "Grandpa Joseph",
132
- "duration": 1560,
133
- "elapsed_time": 0,
134
- "synopsis": "A bundle of yellowed, ribbon-bound letters hidden inside an attic chest on Rue de l’Odeon unravels a heartbreaking secret of courage and hidden artwork dating back to the winter of 1944.",
135
- "category": "Historical Fiction",
136
- "is_cloned": True,
137
- "percentage": 0,
138
- "audio_path": "sample_sounds/letters.wav"
139
- }
140
- ]
141
 
142
  mock_voices = [
143
  {
@@ -178,167 +149,19 @@ narrative_subtitles = [
178
  "And that was when she realized, she was not alone in the valley."
179
  ]
180
 
181
- css_code = """
182
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&display=swap');
183
- /* Main layouts and colors matching VoiceBook cream and amber theme */
184
- body, .gradio-container {
185
- background-color: #FAF7F2 !important;
186
- font-family: 'Inter', system-ui, sans-serif !important;
187
- }
188
- .gradio-container {
189
- max-width: 1200px !important;
190
- margin: 0 auto !important;
191
- }
192
- /* Serif headers */
193
- .serif-header {
194
- font-family: 'Playfair Display', Georgia, serif !important;
195
- color: #1c1c19 !important;
196
- font-weight: 700 !important;
197
- }
198
- .cream-bg {
199
- background-color: #FAF7F2 !important;
200
- }
201
- .card-container {
202
- background-color: #f6f3ee !important;
203
- border: 1px solid #ebdccb !important;
204
- border-radius: 16px !important;
205
- padding: 24px !important;
206
- box-shadow: 0 2px 8px rgba(148, 74, 0, 0.03) !important;
207
- }
208
- .accent-border {
209
- border-color: #ddc1b0 !important;
210
- }
211
- .highlight-orange {
212
- color: #f5841f !important;
213
- }
214
- .highlight-amber {
215
- color: #944a00 !important;
216
- }
217
- .dark-card-container {
218
- background-color: #122116 !important;
219
- color: #FAF7F2 !important;
220
- border: 1px solid #1e3422 !important;
221
- border-radius: 16px !important;
222
- padding: 24px !important;
223
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
224
- }
225
- /* Customized HTML grids matching identical React typography */
226
- .hero-spotlight {
227
- background: linear-gradient(135deg, #1e3422 0%, #121f15 100%) !important;
228
- color: #FFFFFF !important;
229
- border-radius: 24px !important;
230
- padding: 32px !important;
231
- position: relative;
232
- overflow: hidden;
233
- }
234
- .badge-featured {
235
- background: rgba(245, 132, 31, 0.2);
236
- color: #f5841f;
237
- border: 1px solid rgba(245, 132, 31, 0.3);
238
- border-radius: 9999px;
239
- padding: 4px 10px;
240
- font-size: 11px;
241
- font-weight: 700;
242
- text-transform: uppercase;
243
- display: inline-block;
244
- }
245
- .book-shelf-grid {
246
- display: grid;
247
- grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
248
- gap: 20px;
249
- }
250
- .shelf-card {
251
- background: #f6f3ee;
252
- border: 1px solid #eadacc;
253
- border-radius: 16px;
254
- padding: 16px;
255
- display: flex;
256
- gap: 16px;
257
- transition: all 0.3s ease;
258
- cursor: pointer;
259
- }
260
- .shelf-card:hover {
261
- border-color: #f5841f;
262
- transform: translateY(-2px);
263
- box-shadow: 0 4px 12px rgba(148, 74, 0, 0.06);
264
- }
265
- .cover-image {
266
- width: 80px;
267
- height: 110px;
268
- object-fit: cover;
269
- border-radius: 8px;
270
- box-shadow: 0 2px 6px rgba(0,0,0,0.1);
271
- }
272
- .progress-bar-bg {
273
- width: 100%;
274
- height: 6px;
275
- background: #ebdccb;
276
- border-radius: 999px;
277
- overflow: hidden;
278
- }
279
- .progress-bar-fill {
280
- height: 100%;
281
- background: #f5841f;
282
- }
283
- /* Pulsing rings for microphone recording */
284
- @keyframes pulse-ring {
285
- 0% { transform: scale(0.85); opacity: 0.6; }
286
- 50% { transform: scale(1.15); opacity: 0.25; }
287
- 100% { transform: scale(1.4); opacity: 0; }
288
- }
289
- .pulse-circle {
290
- position: relative;
291
- width: 120px;
292
- height: 120px;
293
- border-radius: 50%;
294
- background: #944a00;
295
- color: white;
296
- display: flex;
297
- flex-direction: column;
298
- align-items: center;
299
- justify-content: center;
300
- margin: 0 auto;
301
- }
302
- .recording-active {
303
- background: #ef4444 !important;
304
- }
305
- /* Subtitle dialog ticker */
306
- .dialog-synced {
307
- background: rgba(0,0,0,0.15);
308
- border: 1px solid rgba(255,166,0,0.1);
309
- color: #ffd3a6;
310
- font-family: 'Playfair Display', Georgia, serif;
311
- font-size: 1.25rem;
312
- font-style: italic;
313
- padding: 24px;
314
- border-radius: 16px;
315
- text-align: center;
316
- min-height: 100px;
317
- display: flex;
318
- align-items: center;
319
- justify-content: center;
320
- }
321
- /* Tab button custom styles */
322
- .tabs .tab-nav button {
323
- border-bottom: 2px solid transparent !important;
324
- }
325
- .tabs .tab-nav button.selected {
326
- color: #944a00 !important;
327
- border-bottom-color: #944a00 !important;
328
- font-weight: 700 !important;
329
- }
330
- """
331
 
332
  def generate_dashboard_html(books_list):
333
  featured = books_list[0]
334
  others = books_list[1:]
335
-
336
  html = f"""
337
  <div style="margin-bottom: 24px;">
338
  <span style="font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 600;">Welcome Back, Sarah</span>
339
  <h2 class="serif-header" style="font-size: 28px; margin-top: 4px; margin-bottom: 16px;">Explore Your Warm Audiobook Shelves</h2>
340
  </div>
341
-
342
  <!-- Hero Spotlight Section -->
343
  <div class="hero-spotlight" style="display: flex; flex-direction: row; gap: 32px; align-items: center; margin-bottom: 32px; flex-wrap: wrap;">
344
  <div style="flex: 1; min-width: 200px; max-width: 280px; text-align: center;">
@@ -351,7 +174,7 @@ def generate_dashboard_html(books_list):
351
  <p style="color: #6f6257;">by <span style="font-style: italic; color: #ddc1b0;">{featured['author']}</span></p>
352
  <p style="color: #cbd5e1; font-size: 13.5px; line-height: 1.6; margin-top: 8px;">{featured['synopsis']}</p>
353
  </div>
354
-
355
  <div style="display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: #e2e8f0;">
356
  <span style="background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 8px;">⏱️ 20 Min Remaining</span>
357
  <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>
@@ -359,12 +182,12 @@ def generate_dashboard_html(books_list):
359
  </div>
360
  </div>
361
  </div>
362
-
363
  <div>
364
  <h4 class="serif-header" style="font-size: 20px; margin-bottom: 16px;">Continue Reading with Channeled Voices</h4>
365
  <div class="book-shelf-grid">
366
  """
367
-
368
  for bk in books_list:
369
  html += f"""
370
  <div class="shelf-card">
@@ -391,7 +214,7 @@ def generate_dashboard_html(books_list):
391
  </div>
392
  </div>
393
  """
394
-
395
  html += """
396
  </div>
397
  </div>
@@ -401,7 +224,6 @@ def generate_dashboard_html(books_list):
401
 
402
  def generate_library_html(books_list, query="", category_filt="All", empty_mode=False):
403
  if empty_mode:
404
- # Compliance state: Line View empty shelf visual specifications
405
  return """
406
  <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;">
407
  <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;">
@@ -422,12 +244,12 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
422
  for bk in books_list:
423
  match_query = (query.lower() in bk["title"].lower()) or (query.lower() in bk["author"].lower()) or (query.lower() in bk["voice_name"].lower())
424
  match_cat = True
425
-
426
  if category_filt == "In Progress":
427
  match_cat = 0 < bk["percentage"] < 100
428
  elif category_filt == "Completed":
429
  match_cat = bk["percentage"] == 100
430
-
431
  if match_query and match_cat:
432
  filtered.append(bk)
433
 
@@ -442,7 +264,10 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
442
  html = """<div class="book-shelf-grid">"""
443
  for bk in filtered:
444
  html += f"""
445
- <div class="shelf-card group" style="flex-direction: column; gap: 0; padding: 0; overflow: hidden;">
 
 
 
446
  <div style="position: relative; aspect-ratio: 3/4; overflow: hidden; height: 200px;">
447
  <img src="{bk['cover_url']}" style="width: 100%; height: 100%; object-fit: cover;" referrerPolicy="no-referrer" />
448
  <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;">
@@ -461,9 +286,8 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
461
  <div class="progress-bar-bg">
462
  <div class="progress-bar-fill" style="width: {bk['percentage']}%"></div>
463
  </div>
464
- <div style="display: flex; justify-content: space-between; font-size: 11px; color: #6f6257; align-items: center; padding-top: 4px;">
465
- <span>{math.floor(bk['duration'] / 60)} mins length</span>
466
- <span style="font-weight: 700; color: #f5841f;">Select below to Listen 🎧</span>
467
  </div>
468
  </div>
469
  </div>
@@ -471,12 +295,33 @@ def generate_library_html(books_list, query="", category_filt="All", empty_mode=
471
  html += "</div>"
472
  return html
473
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
  def render_cloned_voices_html(voices_list):
475
  html = """<div class="book-shelf-grid">"""
476
  for v in voices_list:
477
  status_color = "#16a34a" if v["status"] == "ready" else "#d97706"
478
  status_bg = "#f0fdf4" if v["status"] == "ready" else "#fef3c7"
479
-
480
  html += f"""
481
  <div class="shelf-card" style="align-items: center;">
482
  <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;">
@@ -489,7 +334,7 @@ def render_cloned_voices_html(voices_list):
489
  {v['status']}
490
  </span>
491
  </div>
492
- <p style="font-size: 11.5px; color: #6f6257; margin: 4px 0 0 0; line-clamp: 2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">
493
  {v['description']}
494
  </p>
495
  <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;">
@@ -505,12 +350,12 @@ def render_cloned_voices_html(voices_list):
505
 
506
  # Gradio Application Core setup
507
  with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
508
-
509
- # Internal variables mapping user-session states
510
  books_state = gr.State(mock_books)
511
  voices_state = gr.State(mock_voices)
512
-
513
- # Simple navigation bar custom markup
 
514
  gr.HTML("""
515
  <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;">
516
  <div style="display: flex; align-items: center; gap: 12px;">
@@ -519,7 +364,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
519
  </div>
520
  <div>
521
  <h1 class="serif-header" style="font-size: 22px; margin: 0; line-height: 1;">VoiceBook</h1>
522
- <span style="font-size: 10px; color: #f5841f; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px;">AI Companion Companion</span>
523
  </div>
524
  </div>
525
  <div style="display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: #6f6257; font-family: monospace;">
@@ -528,36 +373,120 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
528
  </div>
529
  </div>
530
  """)
531
-
532
- # Core Application Tabs Layout (Dashboard, Library, Clone, Player, Settings)
533
  with gr.Tabs() as main_tabs:
534
-
535
  # TAB 1: Explore Dashboard
536
  with gr.TabItem("🏛️ Explore Space") as explore_tab:
537
  dashboard_container = gr.HTML(value=generate_dashboard_html(mock_books))
538
-
539
- gr.HTML("""<div style="margin-top: 24px; text-align: center;"><span style="font-size: 12px; color:#6f6257;">🎧 Head over to the <b>My Bookshelf</b> or <b>Companion Player</b> tabs to stream these books with cloned pitch audio!</span></div>""")
540
-
541
- # TAB 2: My Bookshelf
542
- with gr.TabItem("📚 My Bookshelf") as shelf_tab:
543
  with gr.Row():
 
544
  with gr.Column(scale=3):
545
- search_input = gr.Textbox(placeholder="Search by title, author, or voice...", label="Filter Library bookshelf contents", show_label=False)
546
- with gr.Column(scale=1):
547
- category_filter = gr.Radio(["All", "In Progress", "Completed"], value="All", label="Progress Segment", show_label=False)
548
- with gr.Column(scale=1):
549
- empty_state_sim = gr.Checkbox(label="Simulate Empty State", value=False)
550
-
551
- shelf_grid = gr.HTML(value=generate_library_html(mock_books))
552
-
553
- gr.HTML("""<div style="margin-top: 16px; border-bottom: 1px solid #ebdccb; padding-bottom: 12px;"><h4 class="serif-header" style="font-size: 16px;">Quick Mount Audiobook to Companion Player:</h4></div>""")
554
-
555
- # Interactive launcher list so they can easily key into player
556
- book_titles_list = [b["title"] for b in mock_books]
557
- book_mount_selector = gr.Dropdown(choices=book_titles_list, value=book_titles_list[0], label="Choose an audiobook from your current inventory shelf", show_label=True)
558
- mount_play_btn = gr.Button("🎧 Mount & Launch Inside Companion Player", variant="primary")
559
-
560
- # TAB 3: Clone Voice Studio Custom Page
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  with gr.TabItem("🎙️ Clone Voice Studio") as clone_tab:
562
  with gr.Row():
563
  with gr.Column(scale=1, elem_classes="card-container"):
@@ -565,10 +494,10 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
565
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px;">🧙 Voice Details Form</h3>
566
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 20px;">Provide a comforting nickname and details below to prepare vocal weights matching.</p>
567
  """)
568
-
569
  new_voice_name = gr.Textbox(placeholder="Enter a descriptive nickname (e.g., Grandma Judy, Dad)...", label="Voice Nickname")
570
  new_voice_gender = gr.Radio(["Female", "Male"], value="Female", label="Voice Gender Avatar")
571
-
572
  gr.HTML("""
573
  <div style="margin: 16px 0; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb; font-size: 12px;">
574
  <strong style="color: #6f6257; text-transform: uppercase; font-size: 10px; display: block; margin-bottom: 4px;">Story Script to read aloud:</strong>
@@ -577,148 +506,36 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
577
  </p>
578
  </div>
579
  """)
580
-
581
- # Record mic audio natively inside Spaces container
582
  mic_recorder = gr.Audio(sources=["microphone"], type="filepath", label="Record speaking story prompt (Min 10 seconds sample)")
583
-
584
  extract_btn = gr.Button("🪄 Analyze & Synthesize Customized Vocal Profile", variant="primary")
585
-
586
  with gr.Column(scale=1, elem_classes="card-container"):
587
  gr.HTML("""
588
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Status Pipeline Visualizer</h3>
589
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Track extraction, de-noising, and pitch tuning.</p>
590
  """)
591
-
592
- # Simulated progres bars inside Gradio
593
  cloning_progress_msg = gr.HTML("""
594
  <div style="text-align: center; padding: 32px 0;">
595
  <span style="font-size: 42px;">🎙️</span>
596
  <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>
597
  </div>
598
  """)
599
-
600
  loading_spinner = gr.HTML(visible=False)
601
  voice_cloning_success_panel = gr.HTML(visible=False)
602
-
603
- # Mini test player for procedural sample generator preview
604
  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)
605
-
606
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
607
-
608
- # Bottom section detailing installed models
609
  gr.HTML("""
610
  <div style="margin-top: 32px; border-top: 1px solid #ebdccb; padding-top: 24px;">
611
- <h4 class="serif-header" style="font-size: 18px; margin-bottom: 16px;">Currently Available Companion Voices</h4>
612
  </div>
613
  """)
614
  cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices))
615
 
616
- # TAB 4: Active Companion Player
617
- with gr.TabItem("🎧 Companion Player", id="player") as player_tab:
618
-
619
- with gr.Row():
620
- with gr.Column(scale=2, elem_classes="dark-card-container"):
621
- gr.HTML("""
622
- <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;">
623
- <span style="font-size: 10px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 700;">Spatial Stream Receiver Active</span>
624
- <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>
625
- </div>
626
- """)
627
-
628
- player_title_info = gr.HTML("""
629
- <div style="text-align: center; margin-bottom: 24px;">
630
- <img src="https://images.unsplash.com/photo-1544947950-fa07a98d237f?auto=format&fit=crop&q=80&w=400" style="width: 130px; height: 180px; object-fit: cover; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); margin-bottom: 16px;" />
631
- <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 22px; margin:0; color:#FAF7F2;">The Enchanted Willow</h3>
632
- <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by Elena S. Thorne</p>
633
- <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: 8px;">Active Narrator: Mom's Voice</span>
634
- </div>
635
- """)
636
-
637
- player_audio_control = gr.Audio(visible=True, interactive=False, label="VoiceBook Audiobook Audio Stream Channel", value="sample_sounds/willow.wav", autoplay=False)
638
-
639
- gr.HTML("""
640
- <div style="margin-top: 16px; background: rgba(255, 255, 255, 0.05); padding: 12px; border-radius: 12px; text-align: center; font-size: 11px; color:#cbd5e1;">
641
- 💡 <b>Standard Player Controls:</b> Click the standard play trigger on the audio player block above to listen to custom generated pitch chimes.
642
- </div>
643
- """)
644
-
645
- # Ask / Resume / Finish controls
646
- player_status_bar = gr.HTML("""
647
- <div style="margin-top: 16px; display: flex; align-items: center; gap: 10px; justify-content: center;">
648
- <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
649
- <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">PLAYING</span>
650
- </div>
651
- """)
652
-
653
- with gr.Row():
654
- ask_btn = gr.Button("❓ Ask a Question", variant="primary", scale=2)
655
- resume_btn = gr.Button("▶️ Resume Story", variant="secondary", scale=2, visible=False)
656
-
657
- # Story-finished prompt (hidden until playback ends)
658
- story_finished_panel = gr.HTML("""
659
- <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;">
660
- <span style="font-size: 28px;">🎉</span>
661
- <h4 style="font-family: 'Playfair Display', Georgia, serif; color: #ffd3a6; margin: 8px 0 4px;">Story Complete!</h4>
662
- <p style="font-size: 12px; color: #94a3b8; margin-bottom: 12px;">Head to <b>My Bookshelf</b> to pick another adventure.</p>
663
- </div>
664
- """, visible=False)
665
-
666
- with gr.Column(scale=3, elem_classes="card-container"):
667
- gr.HTML("""
668
- <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Dynamic Synced Script Transcriptions</h3>
669
- <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">The synced transcript lines match progress in real-time. Use the timeline slider below to tick forward.</p>
670
- """)
671
-
672
- # Dialogue sync script box
673
- synced_subtitle_ticker = gr.HTML("""
674
- <div class="dialog-synced">
675
- &ldquo;Deep within the heart of Whispering Woods stands an ancient willow tree...&rdquo;
676
- </div>
677
- """)
678
-
679
- # Simulation scrub mechanism
680
- timeline_slider = gr.Slider(minimum=0, maximum=5, step=1, value=0, label="Synced Script Excerpt Position (Story timeline coordinate index)")
681
-
682
- gr.HTML("""
683
- <div style="margin-top: 24px; padding-top: 16px; border-top: 1px solid #ebdccb;">
684
- <h4 class="serif-header" style="font-size: 14px; margin-bottom: 12px;">Active Audiobook Metadata Details</h4>
685
- </div>
686
- """)
687
-
688
- m_synopsis = gr.Textbox(interactive=False, label="Selected Story Synopsis", lines=4, value=mock_books[0]["synopsis"])
689
- m_category = gr.Label(label="Story Category Shelf Mapping", value="Fantasy")
690
-
691
- # --- Q&A Panel (hidden while playing, shown after Ask) ---
692
- qa_panel = gr.HTML("""
693
- <div style="margin-top: 20px; border-top: 1px solid #ebdccb; padding-top: 16px;">
694
- <h4 class="serif-header" style="font-size: 14px; margin-bottom: 12px;">❓ Ask About the Story</h4>
695
- </div>
696
- """, visible=False)
697
-
698
- with gr.Group(visible=False) as qa_input_group:
699
- question_text = gr.Textbox(
700
- placeholder="Type your question about the story here...",
701
- label="Your Question",
702
- lines=2,
703
- show_label=False
704
- )
705
- question_audio = gr.Audio(
706
- sources=["microphone"],
707
- type="filepath",
708
- label="Or speak your question",
709
- show_label=True
710
- )
711
- submit_question_btn = gr.Button("🔍 Get Answer in Cloned Voice", variant="primary")
712
-
713
- # Answer output area
714
- answer_display = gr.HTML(visible=False)
715
- answer_audio = gr.Audio(
716
- label="Answer in Narrator's Voice",
717
- interactive=False,
718
- visible=False
719
- )
720
-
721
- # TAB 5: Profile & Connection Sandbox
722
  with gr.TabItem("⚙️ Profile & Sandbox Settings") as profile_tab:
723
  with gr.Row():
724
  with gr.Column(scale=1, elem_classes="card-container"):
@@ -731,7 +548,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
731
  <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">Premium Member since Summer 2026</p>
732
  </div>
733
  """)
734
-
735
  gr.HTML("""
736
  <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center;">
737
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
@@ -751,24 +568,20 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
751
  </div>
752
  </div>
753
  """)
754
-
755
- gr.HTML("""
756
- <div style="margin-top: 24px;">
757
- <h4 class="serif-header" style="font-size: 14px; margin-bottom: 8px;">Acoustic Signal Processing Config</h4>
758
- </div>
759
- """)
760
  gr.Checkbox(label="Enable Client Denoising Filter", value=True)
761
  gr.Checkbox(label="Enable Brownian Dynamic Sleep Wave", value=False)
762
  gr.Checkbox(label="Automatic Chapter Transitions", value=True)
763
-
764
  with gr.Column(scale=1, elem_classes="card-container"):
765
  gr.HTML("""
766
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Boundary Connection Error Diagnostics</h3>
767
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Test robust offline error conditions gracefully.</p>
768
  """)
769
-
770
  offline_toggle = gr.Checkbox(label="Simulate Sandbox Connection Outage", value=False)
771
-
772
  gr.HTML("""
773
  <div style="margin-top: 16px; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
774
  <span style="font-size:10px; font-weight:700; text-transform:uppercase; color:#6f6257; display:block; margin-bottom:6px;">Sandbox local metric stores:</span>
@@ -778,7 +591,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
778
  </div>
779
  </div>
780
  """)
781
-
782
  error_sim_view = gr.HTML("""
783
  <div style="margin-top: 16px; padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; display: none;" id="error-sim-panel">
784
  <span style="font-size: 20px; display: block; margin-bottom: 4px;">⚠️ Connection Interruption Layout</span>
@@ -787,7 +600,6 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
787
  </div>
788
  """, elem_id="error-sim-block")
789
 
790
- # Offline outage container modal overlay representation
791
  system_error_modal = gr.HTML("""
792
  <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">
793
  <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;">
@@ -802,20 +614,67 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
802
  </div>
803
  """, visible=False)
804
 
805
- # REACTIVE LOGIC TRIGGERS & Callbacks
806
-
807
- # 1. Search, filter & simulate empty state on shelf
808
  def filter_books_shelf(query, category, empty_mode_active, current_books):
809
  if empty_mode_active:
810
- # Render special formatted zero-state visual
811
  return generate_library_html(current_books, empty_mode=True)
812
  return generate_library_html(current_books, query=query, category_filt=category)
813
-
814
  search_input.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
815
  category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
816
  empty_state_sim.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
817
 
818
- # 2. Extract vocal weights trigger (wizard pipeline progress bar matching)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  def animate_cloning_pipeline(v_name, v_gender, recorder_data, progress=gr.Progress()):
820
  if not v_name.strip():
821
  return (
@@ -837,7 +696,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
837
  gr.Audio(visible=False),
838
  gr.Button(visible=False)
839
  )
840
-
841
  progress(0, desc="Extracting speech samples patterns...")
842
  time.sleep(1.0)
843
  progress(0.3, desc="Filtering ambient air-con noise frequencies...")
@@ -846,11 +705,10 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
846
  time.sleep(1.5)
847
  progress(0.9, desc="Validating speech prosody & standard conversational matching...")
848
  time.sleep(1.0)
849
-
850
  preview_text = f"&ldquo;Hello! I have created my brand new clone under '{v_name}'. Ready to narrate any classical story inside your library shelves.&rdquo;"
851
-
852
  avatar = "👩" if v_gender == "Female" else "👨"
853
-
854
  cloned_card_html = f"""
855
  <div style="background: white; border: 1px solid #ebdccb; padding: 24px; border-radius: 16px; margin: 16px 0;">
856
  <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 12px;">
@@ -858,7 +716,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
858
  {avatar}
859
  </div>
860
  <div>
861
- <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; uppercase: text-transform; font-weight:700;">Cloned successfully</span></h4>
862
  <p style="font-size: 11px; color:#6f6257; margin-top:2px;">Synthesized today • Calibrated for Classical Audiobooks</p>
863
  </div>
864
  </div>
@@ -867,7 +725,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
867
  </div>
868
  </div>
869
  """
870
-
871
  return (
872
  gr.HTML(visible=False),
873
  gr.HTML(visible=False),
@@ -875,14 +733,14 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
875
  gr.Audio(visible=True),
876
  gr.Button(visible=True)
877
  )
878
-
879
  extract_btn.click(
880
  animate_cloning_pipeline,
881
  inputs=[new_voice_name, new_voice_gender, mic_recorder],
882
  outputs=[cloning_progress_msg, loading_spinner, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn]
883
  )
884
-
885
- # 3. Add successfully cloned voice to inventory
886
  def save_new_cloned_voice(v_name, v_gender, list_voices):
887
  avatar = "👩" if v_gender == "Female" else "👨"
888
  new_voice_item = {
@@ -895,96 +753,122 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
895
  "status": "ready"
896
  }
897
  updated = [new_voice_item] + list_voices
898
-
899
- # We also need to map this voice option dynamically into the books dropdown so they can narrate books with it!
900
  return (
901
  updated,
902
  render_cloned_voices_html(updated),
903
- 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! Synthesize another one on the left if you'd like.</p></div>"""),
904
  gr.HTML(visible=False),
905
  gr.HTML(visible=False),
906
  gr.Audio(visible=False),
907
  gr.Button(visible=False),
908
  gr.Textbox(value="")
909
  )
910
-
911
  add_to_library_btn.click(
912
  save_new_cloned_voice,
913
  inputs=[new_voice_name, new_voice_gender, voices_state],
914
  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]
915
  )
916
 
917
- # 4. Mount audiobook metadata details into player layout
918
- def handle_book_mounting(title_chosen, current_inventory):
919
- # find book
920
- selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
921
-
922
- player_html_markup = f"""
923
- <div style="text-align: center; margin-bottom: 24px;">
924
- <img src="{selected['cover_url']}" style="width: 130px; height: 180px; object-fit: cover; border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.4); margin-bottom: 16px;" />
925
- <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 22px; margin:0; color:#FAF7F2;">{selected['title']}</h3>
926
- <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p>
927
- <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: 8px;">Active Narrator: {selected['voice_name']}</span>
928
- </div>
929
  """
930
-
931
- # update player tabs active
932
- return (
933
- player_html_markup,
934
- selected["audio_path"],
935
- selected["synopsis"],
936
- selected["category"],
937
- gr.Tabs(selected="player") # Select player tab
938
- )
939
-
940
- mount_play_btn.click(
941
- handle_book_mounting,
942
- inputs=[book_mount_selector, books_state],
943
- outputs=[player_title_info, player_audio_control, m_synopsis, m_category, main_tabs]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
944
  )
945
 
946
- # 5. Playback timeline slider sync transcript subtitle lyrics
947
- def update_synced_subtitles(percentage_index_choice):
948
- idx = int(percentage_index_choice)
949
- txt_excerpt = narrative_subtitles[idx % len(narrative_subtitles)]
950
- html_markup = f"""
951
- <div class="dialog-synced">
952
- &ldquo;{txt_excerpt}&rdquo;
953
- </div>
 
954
  """
955
- return html_markup
956
-
957
- timeline_slider.change(
958
- update_synced_subtitles,
959
- inputs=[timeline_slider],
960
- outputs=[synced_subtitle_ticker]
 
 
 
 
 
961
  )
962
 
963
- # 6. Ask button pauses story, reveals Q&A panel
 
 
 
 
 
 
 
 
 
964
  def enter_asking_state():
965
  status_html = """
966
- <div style="margin-top: 16px; display: flex; align-items: center; gap: 10px; justify-content: center;">
967
  <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block; animation: pulse 1s infinite;"></span>
968
  <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED — ASKING</span>
969
  </div>
970
  """
971
  return (
972
- status_html, # player_status_bar
973
- gr.Button(visible=False), # ask_btn hide
974
- gr.Button(visible=True), # resume_btn show
975
- gr.HTML(visible=True), # qa_panel show
976
- gr.Group(visible=True), # qa_input_group show
977
- gr.HTML(visible=False), # answer_display reset
978
- gr.Audio(visible=False), # answer_audio reset
 
 
979
  )
980
 
981
  ask_btn.click(
982
  enter_asking_state,
983
  inputs=[],
984
- outputs=[player_status_bar, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio]
985
  )
986
 
987
- # 7. Submit question — generate mock answer (placeholder for real Qwen call)
988
  def handle_question_submit(question_txt, question_audio_path):
989
  if not question_txt.strip() and question_audio_path is None:
990
  answer_html = """
@@ -995,22 +879,19 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
995
  return answer_html, gr.Audio(visible=False)
996
 
997
  q_text = question_txt.strip() if question_txt.strip() else "(spoken question)"
998
-
999
- # Placeholder: real Qwen2.5-3B-Instruct call goes here
1000
- mock_answer = f"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."
1001
 
1002
  answer_html = f"""
1003
- <div style="margin-top: 12px; padding: 16px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 14px;">
1004
- <div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #16a34a; margin-bottom: 6px;">Answer in Narrator's Voice</div>
1005
- <div style="font-family: 'Playfair Display', Georgia, serif; font-style: italic; color: #1c1c19; font-size: 13px; line-height: 1.6;">
1006
  &ldquo;{mock_answer}&rdquo;
1007
  </div>
1008
- <div style="margin-top: 8px; font-size: 10px; color: #6f6257;">
1009
  Q: <em>{q_text}</em>
1010
  </div>
1011
  </div>
1012
  """
1013
- # answer_audio will be wired to real TTS output; for now surfaces the preview chime
1014
  return answer_html, gr.Audio(value="sample_sounds/cloned_preview.wav", visible=True)
1015
 
1016
  submit_question_btn.click(
@@ -1019,62 +900,49 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
1019
  outputs=[answer_display, answer_audio]
1020
  )
1021
 
1022
- # 8. Resume button — restores playing state, hides Q&A panel
1023
  def enter_resume_state():
1024
  status_html = """
1025
- <div style="margin-top: 16px; display: flex; align-items: center; gap: 10px; justify-content: center;">
1026
  <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
1027
- <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">PLAYING</span>
1028
  </div>
1029
  """
1030
  return (
1031
- status_html, # player_status_bar
1032
- gr.Button(visible=True), # ask_btn show
1033
- gr.Button(visible=False), # resume_btn hide
1034
- gr.HTML(visible=False), # qa_panel hide
1035
- gr.Group(visible=False), # qa_input_group hide
1036
- gr.HTML(visible=False), # answer_display hide
1037
- gr.Audio(visible=False), # answer_audio hide
1038
- gr.Textbox(value=""), # question_text clear
 
 
1039
  )
1040
 
1041
  resume_btn.click(
1042
  enter_resume_state,
1043
  inputs=[],
1044
- outputs=[player_status_bar, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio, question_text]
1045
  )
1046
 
1047
- # 9. Story-finished trigger — slider reaches end (index 5 = last subtitle)
1048
- def check_story_finished(slider_val):
1049
- if int(slider_val) >= 5:
1050
  return gr.HTML(visible=True)
1051
  return gr.HTML(visible=False)
1052
 
1053
- timeline_slider.change(
1054
- check_story_finished,
1055
- inputs=[timeline_slider],
1056
- outputs=[story_finished_panel]
1057
- )
1058
 
1059
- # 10. Global Sandbox connection error boundary state toggle
1060
  def toggle_outage_sandbox(checked):
1061
  if checked:
1062
- return (
1063
- gr.HTML(visible=True), # Show error simulated overlay
1064
- gr.Tabs(visible=False) # Hide whole actual menu tab structure
1065
- )
1066
  else:
1067
- return (
1068
- gr.HTML(visible=False), # Hide
1069
- gr.Tabs(visible=True) # Full tabs display
1070
- )
1071
-
1072
- offline_toggle.change(
1073
- toggle_outage_sandbox,
1074
- inputs=[offline_toggle],
1075
- outputs=[system_error_modal, main_tabs]
1076
- )
1077
 
1078
- # Launch parameters configured for standard port and auto binding
1079
  if __name__ == "__main__":
1080
- demo.queue().launch()
 
4
  import wave
5
  import gradio as gr
6
  import time
7
+ from pathlib import Path
8
+
9
+ from tts import split_into_chunks, generate_audio_stream
10
 
11
  # Create directories for sample audio files
12
  os.makedirs("sample_sounds", exist_ok=True)
13
 
14
+ # Generate relaxing audio chime waveforms procedurally
15
  # This runs fully client/server-side with standard Python, eliminating mock limits or Torch wait times
16
  def generate_chimes_wav(filename, duration=120, melody_type="lullaby"):
17
  sample_rate = 16000
18
  n_samples = int(duration * sample_rate)
19
+
20
  with wave.open(filename, 'wb') as wav_file:
21
  wav_file.setnchannels(1)
22
  wav_file.setsampwidth(2)
23
  wav_file.setframerate(sample_rate)
24
+
25
  # Pentatonic cozy scales
26
  if melody_type == "lullaby":
27
  notes = [261.63, 293.66, 329.63, 392.00, 440.00] # C4, D4, E4, G4, A4
 
35
  note_speed = sample_rate * 1.5 if melody_type == "lullaby" else sample_rate * 1.2
36
  note_idx = int((i / note_speed) % len(notes))
37
  freq = notes[note_idx]
38
+
39
  # envelope to prevent crackles
40
  sample_in_note = i % int(note_speed)
41
  envelope = 1.0
 
44
  else: # decay
45
  decay_length = note_speed - 1200
46
  envelope = max(0.0, 1.0 - (sample_in_note - 1200) / decay_length)
47
+
48
  # Synthesize voice-harmonic chime
49
  val = math.sin(2 * math.pi * freq * i / sample_rate)
50
  val += 0.45 * math.sin(2 * math.pi * (freq * 1.5) * i / sample_rate)
51
  val += 0.25 * math.sin(2 * math.pi * (freq * 2.0) * i / sample_rate)
52
  val = val / 1.7 * envelope
53
+
54
  packed_val = struct.pack('<h', int(val * 16384))
55
  wav_file.writeframes(packed_val)
56
 
 
68
  if not os.path.exists(path):
69
  generate_chimes_wav(path, duration=dur, melody_type=mel)
70
 
71
+ COVERS_DIR = Path(__file__).parent / "assets" / "covers"
72
+
73
+ def cover_path(slug: str) -> str:
74
+ p = COVERS_DIR / f"{slug}.png"
75
+ return f"/gradio_api/file={p.resolve().as_posix()}"
76
+
77
+ def load_books_from_stories(stories_dir="stories"):
78
+ books = []
79
+ story_files = sorted(
80
+ f for f in os.listdir(stories_dir) if f.endswith(".txt")
81
+ ) if os.path.isdir(stories_dir) else []
82
+
83
+ for i, filename in enumerate(story_files):
84
+ title = filename.replace(".txt", "").replace("_", " ")
85
+ slug = filename.replace(".txt", "")
86
+ synopsis = ""
87
+ try:
88
+ with open(os.path.join(stories_dir, filename), encoding="utf-8") as f:
89
+ lines = [l.strip() for l in f.readlines() if l.strip()]
90
+ synopsis = " ".join(lines[1:4]) if len(lines) > 1 else lines[0] if lines else ""
91
+ except Exception:
92
+ pass
93
+
94
+ books.append({
95
+ "id": str(i + 1),
96
+ "title": title,
97
+ "author": "Public Domain",
98
+ "cover_url": cover_path(slug),
99
+ "voice_name": "Mom's Voice",
100
+ "duration": 0,
101
+ "elapsed_time": 0,
102
+ "synopsis": synopsis,
103
+ "category": "Children's Story",
104
+ "is_cloned": False,
105
+ "percentage": 0,
106
+ "audio_path": None,
107
+ "story_path": os.path.join(stories_dir, filename),
108
+ })
109
+ return books
110
+
111
+ mock_books = load_books_from_stories()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
  mock_voices = [
114
  {
 
149
  "And that was when she realized, she was not alone in the valley."
150
  ]
151
 
152
+ with open(os.path.join(os.path.dirname(__file__), "static", "style.css"), encoding="utf-8") as _f:
153
+ css_code = _f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  def generate_dashboard_html(books_list):
156
  featured = books_list[0]
157
  others = books_list[1:]
158
+
159
  html = f"""
160
  <div style="margin-bottom: 24px;">
161
  <span style="font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 600;">Welcome Back, Sarah</span>
162
  <h2 class="serif-header" style="font-size: 28px; margin-top: 4px; margin-bottom: 16px;">Explore Your Warm Audiobook Shelves</h2>
163
  </div>
164
+
165
  <!-- Hero Spotlight Section -->
166
  <div class="hero-spotlight" style="display: flex; flex-direction: row; gap: 32px; align-items: center; margin-bottom: 32px; flex-wrap: wrap;">
167
  <div style="flex: 1; min-width: 200px; max-width: 280px; text-align: center;">
 
174
  <p style="color: #6f6257;">by <span style="font-style: italic; color: #ddc1b0;">{featured['author']}</span></p>
175
  <p style="color: #cbd5e1; font-size: 13.5px; line-height: 1.6; margin-top: 8px;">{featured['synopsis']}</p>
176
  </div>
177
+
178
  <div style="display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: #e2e8f0;">
179
  <span style="background: rgba(255,255,255,0.1); padding: 4px 10px; border-radius: 8px;">⏱️ 20 Min Remaining</span>
180
  <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>
 
182
  </div>
183
  </div>
184
  </div>
185
+
186
  <div>
187
  <h4 class="serif-header" style="font-size: 20px; margin-bottom: 16px;">Continue Reading with Channeled Voices</h4>
188
  <div class="book-shelf-grid">
189
  """
190
+
191
  for bk in books_list:
192
  html += f"""
193
  <div class="shelf-card">
 
214
  </div>
215
  </div>
216
  """
217
+
218
  html += """
219
  </div>
220
  </div>
 
224
 
225
  def generate_library_html(books_list, query="", category_filt="All", empty_mode=False):
226
  if empty_mode:
 
227
  return """
228
  <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;">
229
  <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;">
 
244
  for bk in books_list:
245
  match_query = (query.lower() in bk["title"].lower()) or (query.lower() in bk["author"].lower()) or (query.lower() in bk["voice_name"].lower())
246
  match_cat = True
247
+
248
  if category_filt == "In Progress":
249
  match_cat = 0 < bk["percentage"] < 100
250
  elif category_filt == "Completed":
251
  match_cat = bk["percentage"] == 100
252
+
253
  if match_query and match_cat:
254
  filtered.append(bk)
255
 
 
264
  html = """<div class="book-shelf-grid">"""
265
  for bk in filtered:
266
  html += f"""
267
+ <div class="shelf-card group" style="flex-direction: column; gap: 0; padding: 0; overflow: hidden; cursor: pointer;" onclick="
268
+ var radios = document.querySelectorAll('.library-book-selector input[type=radio]');
269
+ radios.forEach(function(r) {{ if(r.value === '{bk['title']}') {{ r.click(); }} }});
270
+ ">
271
  <div style="position: relative; aspect-ratio: 3/4; overflow: hidden; height: 200px;">
272
  <img src="{bk['cover_url']}" style="width: 100%; height: 100%; object-fit: cover;" referrerPolicy="no-referrer" />
273
  <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;">
 
286
  <div class="progress-bar-bg">
287
  <div class="progress-bar-fill" style="width: {bk['percentage']}%"></div>
288
  </div>
289
+ <div style="text-align: center; padding: 6px 0; font-size: 12px; font-weight: 700; color: #944a00;">
290
+ ▶️ Tap to Play
 
291
  </div>
292
  </div>
293
  </div>
 
295
  html += "</div>"
296
  return html
297
 
298
+ def load_paragraphs(story_path: str) -> list:
299
+ try:
300
+ with open(story_path, encoding="utf-8") as f:
301
+ raw = f.read()
302
+ paras = [p.strip() for p in raw.split("\n\n") if p.strip()]
303
+ return paras
304
+ except Exception:
305
+ return []
306
+
307
+ def render_story_text(paragraphs: list, current_idx: int) -> str:
308
+ if not paragraphs:
309
+ return ""
310
+ html = """<div style="max-height: 420px; overflow-y: auto; padding: 4px 2px; margin-top: 12px;">"""
311
+ for i, para in enumerate(paragraphs):
312
+ if i == current_idx:
313
+ 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>"""
314
+ else:
315
+ 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>"""
316
+ html += "</div>"
317
+ return html
318
+
319
  def render_cloned_voices_html(voices_list):
320
  html = """<div class="book-shelf-grid">"""
321
  for v in voices_list:
322
  status_color = "#16a34a" if v["status"] == "ready" else "#d97706"
323
  status_bg = "#f0fdf4" if v["status"] == "ready" else "#fef3c7"
324
+
325
  html += f"""
326
  <div class="shelf-card" style="align-items: center;">
327
  <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;">
 
334
  {v['status']}
335
  </span>
336
  </div>
337
+ <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;">
338
  {v['description']}
339
  </p>
340
  <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;">
 
350
 
351
  # Gradio Application Core setup
352
  with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
353
+
 
354
  books_state = gr.State(mock_books)
355
  voices_state = gr.State(mock_voices)
356
+ paragraphs_state = gr.State([])
357
+ tts_chunks_state = gr.State([])
358
+
359
  gr.HTML("""
360
  <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;">
361
  <div style="display: flex; align-items: center; gap: 12px;">
 
364
  </div>
365
  <div>
366
  <h1 class="serif-header" style="font-size: 22px; margin: 0; line-height: 1;">VoiceBook</h1>
367
+ <span style="font-size: 10px; color: #f5841f; font-weight: 700; text-transform: uppercase; letter-spacing: 1.5px;">AI Bedtime Story Narrator</span>
368
  </div>
369
  </div>
370
  <div style="display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: #6f6257; font-family: monospace;">
 
373
  </div>
374
  </div>
375
  """)
376
+
 
377
  with gr.Tabs() as main_tabs:
378
+
379
  # TAB 1: Explore Dashboard
380
  with gr.TabItem("🏛️ Explore Space") as explore_tab:
381
  dashboard_container = gr.HTML(value=generate_dashboard_html(mock_books))
382
+ 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>""")
383
+
384
+ # TAB 2: Library + Player (통합)
385
+ with gr.TabItem("📚 My Library") as library_tab:
 
386
  with gr.Row():
387
+ # Left: book grid
388
  with gr.Column(scale=3):
389
+ with gr.Row():
390
+ with gr.Column(scale=3):
391
+ search_input = gr.Textbox(placeholder="Search by title, author, or voice...", label="Filter", show_label=False)
392
+ with gr.Column(scale=1):
393
+ category_filter = gr.Radio(["All", "In Progress", "Completed"], value="All", label="Progress", show_label=False)
394
+ with gr.Column(scale=1):
395
+ empty_state_sim = gr.Checkbox(label="Simulate Empty", value=False)
396
+
397
+ shelf_grid = gr.HTML(value=generate_library_html(mock_books))
398
+
399
+ # Hidden radio book cards click this via JS onclick
400
+ book_titles_list = [b["title"] for b in mock_books]
401
+ book_selector = gr.Radio(
402
+ choices=book_titles_list,
403
+ value=None,
404
+ label="",
405
+ show_label=False,
406
+ elem_classes="library-book-selector",
407
+ visible=False,
408
+ )
409
+
410
+ # Right: player panel
411
+ with gr.Column(scale=2, elem_classes="dark-card-container"):
412
+ gr.HTML("""
413
+ <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;">
414
+ <span style="font-size: 10px; text-transform: uppercase; letter-spacing: 2px; color: #f5841f; font-weight: 700;">Now Playing</span>
415
+ <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>
416
+ </div>
417
+ """)
418
+
419
+ player_title_info = gr.HTML("""
420
+ <div style="text-align: center; margin-bottom: 24px; margin-top: 16px; padding: 32px 0;">
421
+ <span style="font-size: 48px;">📖</span>
422
+ <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 18px; margin: 12px 0 4px; color:#FAF7F2;">Select a story</h3>
423
+ <p style="font-size: 12px; color: #94a3b8; font-style: italic;">Tap any book on the left to begin</p>
424
+ </div>
425
+ """)
426
+
427
+ player_audio_control = gr.Audio(
428
+ visible=False, interactive=False,
429
+ label="Audio Stream",
430
+ type="numpy",
431
+ streaming=True,
432
+ autoplay=True,
433
+ )
434
+
435
+ player_status_bar = gr.HTML("""
436
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
437
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
438
+ <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">SELECT A STORY</span>
439
+ </div>
440
+ """)
441
+
442
+ with gr.Row():
443
+ play_btn = gr.Button("▶️ Play", variant="primary", scale=1, visible=False)
444
+ pause_btn = gr.Button("⏸ Pause", variant="secondary", scale=1, visible=False)
445
+ resume_btn = gr.Button("↩️ Resume Story", variant="secondary", scale=2, visible=False)
446
+
447
+ with gr.Row():
448
+ ask_btn = gr.Button("❓ Ask a Question", variant="primary", scale=2, visible=False)
449
+
450
+ chunk_status = gr.HTML("""
451
+ <div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">
452
+ Chunk 0 / 0
453
+ </div>
454
+ """)
455
+
456
+ story_finished_panel = gr.HTML("""
457
+ <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;">
458
+ <span style="font-size: 28px;">🎉</span>
459
+ <h4 style="font-family: 'Playfair Display', Georgia, serif; color: #ffd3a6; margin: 8px 0 4px;">Story Complete!</h4>
460
+ <p style="font-size: 12px; color: #94a3b8; margin-bottom: 12px;">Tap another book to start a new story.</p>
461
+ </div>
462
+ """, visible=False)
463
+
464
+ # Story text display + slider
465
+ story_text_display = gr.HTML(visible=False)
466
+ timeline_slider = gr.Slider(minimum=0, maximum=100, step=1, value=0, label="📍 Story position (paragraph)", visible=False)
467
+
468
+ # Q&A Panel
469
+ qa_panel = gr.HTML("""
470
+ <div style="margin-top: 20px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 16px;">
471
+ <h4 style="font-family: 'Playfair Display', Georgia, serif; font-size: 14px; margin-bottom: 12px; color: #FAF7F2;">❓ Ask About the Story</h4>
472
+ </div>
473
+ """, visible=False)
474
+
475
+ with gr.Group(visible=False) as qa_input_group:
476
+ question_text = gr.Textbox(
477
+ placeholder="Type your question about the story here...",
478
+ label="Your Question", lines=2, show_label=False
479
+ )
480
+ question_audio = gr.Audio(
481
+ sources=["microphone"], type="filepath",
482
+ label="Or speak your question", show_label=True
483
+ )
484
+ submit_question_btn = gr.Button("🔍 Get Answer in Cloned Voice", variant="primary")
485
+
486
+ answer_display = gr.HTML(visible=False)
487
+ answer_audio = gr.Audio(label="Answer in Narrator's Voice", interactive=False, visible=False)
488
+
489
+ # TAB 3: Clone Voice Studio
490
  with gr.TabItem("🎙️ Clone Voice Studio") as clone_tab:
491
  with gr.Row():
492
  with gr.Column(scale=1, elem_classes="card-container"):
 
494
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px;">🧙 Voice Details Form</h3>
495
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 20px;">Provide a comforting nickname and details below to prepare vocal weights matching.</p>
496
  """)
497
+
498
  new_voice_name = gr.Textbox(placeholder="Enter a descriptive nickname (e.g., Grandma Judy, Dad)...", label="Voice Nickname")
499
  new_voice_gender = gr.Radio(["Female", "Male"], value="Female", label="Voice Gender Avatar")
500
+
501
  gr.HTML("""
502
  <div style="margin: 16px 0; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb; font-size: 12px;">
503
  <strong style="color: #6f6257; text-transform: uppercase; font-size: 10px; display: block; margin-bottom: 4px;">Story Script to read aloud:</strong>
 
506
  </p>
507
  </div>
508
  """)
509
+
 
510
  mic_recorder = gr.Audio(sources=["microphone"], type="filepath", label="Record speaking story prompt (Min 10 seconds sample)")
 
511
  extract_btn = gr.Button("🪄 Analyze & Synthesize Customized Vocal Profile", variant="primary")
512
+
513
  with gr.Column(scale=1, elem_classes="card-container"):
514
  gr.HTML("""
515
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Status Pipeline Visualizer</h3>
516
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Track extraction, de-noising, and pitch tuning.</p>
517
  """)
518
+
 
519
  cloning_progress_msg = gr.HTML("""
520
  <div style="text-align: center; padding: 32px 0;">
521
  <span style="font-size: 42px;">🎙️</span>
522
  <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>
523
  </div>
524
  """)
525
+
526
  loading_spinner = gr.HTML(visible=False)
527
  voice_cloning_success_panel = gr.HTML(visible=False)
 
 
528
  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)
 
529
  add_to_library_btn = gr.Button("✨ Synced successfully! Bind Companion Voice to Inventory", visible=False)
530
+
 
531
  gr.HTML("""
532
  <div style="margin-top: 32px; border-top: 1px solid #ebdccb; padding-top: 24px;">
533
+ <h4 class="serif-header" style="font-size: 18px; margin-bottom: 16px;">Currently Available Voices</h4>
534
  </div>
535
  """)
536
  cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices))
537
 
538
+ # TAB 4: Profile & Sandbox Settings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  with gr.TabItem("⚙️ Profile & Sandbox Settings") as profile_tab:
540
  with gr.Row():
541
  with gr.Column(scale=1, elem_classes="card-container"):
 
548
  <p style="font-size: 11.5px; color: #6f6257; margin-top: 2px;">Premium Member since Summer 2026</p>
549
  </div>
550
  """)
551
+
552
  gr.HTML("""
553
  <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; text-align: center;">
554
  <div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
 
568
  </div>
569
  </div>
570
  """)
571
+
572
+ gr.HTML("""<div style="margin-top: 24px;"><h4 class="serif-header" style="font-size: 14px; margin-bottom: 8px;">Acoustic Signal Processing Config</h4></div>""")
 
 
 
 
573
  gr.Checkbox(label="Enable Client Denoising Filter", value=True)
574
  gr.Checkbox(label="Enable Brownian Dynamic Sleep Wave", value=False)
575
  gr.Checkbox(label="Automatic Chapter Transitions", value=True)
576
+
577
  with gr.Column(scale=1, elem_classes="card-container"):
578
  gr.HTML("""
579
  <h3 class="serif-header" style="font-size: 18px; margin-bottom: 4px;">Boundary Connection Error Diagnostics</h3>
580
  <p style="font-size: 12px; color: #6f6257; margin-bottom: 16px;">Test robust offline error conditions gracefully.</p>
581
  """)
582
+
583
  offline_toggle = gr.Checkbox(label="Simulate Sandbox Connection Outage", value=False)
584
+
585
  gr.HTML("""
586
  <div style="margin-top: 16px; padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
587
  <span style="font-size:10px; font-weight:700; text-transform:uppercase; color:#6f6257; display:block; margin-bottom:6px;">Sandbox local metric stores:</span>
 
591
  </div>
592
  </div>
593
  """)
594
+
595
  error_sim_view = gr.HTML("""
596
  <div style="margin-top: 16px; padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; display: none;" id="error-sim-panel">
597
  <span style="font-size: 20px; display: block; margin-bottom: 4px;">⚠️ Connection Interruption Layout</span>
 
600
  </div>
601
  """, elem_id="error-sim-block")
602
 
 
603
  system_error_modal = gr.HTML("""
604
  <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">
605
  <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;">
 
614
  </div>
615
  """, visible=False)
616
 
617
+ # REACTIVE LOGIC
618
+
619
+ # 1. Search / filter shelf
620
  def filter_books_shelf(query, category, empty_mode_active, current_books):
621
  if empty_mode_active:
 
622
  return generate_library_html(current_books, empty_mode=True)
623
  return generate_library_html(current_books, query=query, category_filt=category)
624
+
625
  search_input.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
626
  category_filter.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
627
  empty_state_sim.change(filter_books_shelf, inputs=[search_input, category_filter, empty_state_sim, books_state], outputs=shelf_grid)
628
 
629
+ # 2. Book card click load into player
630
+ def handle_book_select(title_chosen, current_inventory):
631
+ if not title_chosen:
632
+ return [gr.update()] * 12
633
+ selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
634
+
635
+ player_html = f"""
636
+ <div style="text-align: center; margin-bottom: 16px; margin-top: 8px;">
637
+ <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;" />
638
+ <h3 style="font-family: 'Playfair Display', Georgia, serif; font-size: 20px; margin:0; color:#FAF7F2;">{selected['title']}</h3>
639
+ <p style="font-size: 12px; color: #94a3b8; font-style: italic; margin-top:2px;">by {selected['author']}</p>
640
+ <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>
641
+ </div>
642
+ """
643
+ paras = load_paragraphs(selected["story_path"])
644
+ total = max(len(paras) - 1, 0)
645
+ tts_chunks = split_into_chunks("\n\n".join(paras))
646
+ 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>"""
647
+ status_html = """
648
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
649
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
650
+ <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY</span>
651
+ </div>
652
+ """
653
+ story_html = render_story_text(paras, 0)
654
+ return (
655
+ player_html,
656
+ gr.Audio(visible=True),
657
+ status_html,
658
+ chunk_html,
659
+ gr.Button(visible=True), # play_btn
660
+ gr.Button(visible=False), # pause_btn
661
+ gr.Button(visible=False), # ask_btn
662
+ gr.HTML(visible=False), # story_finished_panel
663
+ paras, # paragraphs_state
664
+ tts_chunks, # tts_chunks_state
665
+ gr.HTML(value=story_html, visible=True), # story_text_display
666
+ gr.Slider(minimum=0, maximum=total, step=1, value=0, visible=True), # timeline_slider
667
+ )
668
+
669
+ book_selector.change(
670
+ handle_book_select,
671
+ inputs=[book_selector, books_state],
672
+ outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
673
+ play_btn, pause_btn, ask_btn, story_finished_panel,
674
+ paragraphs_state, tts_chunks_state, story_text_display, timeline_slider]
675
+ )
676
+
677
+ # 3. Extract vocal weights trigger
678
  def animate_cloning_pipeline(v_name, v_gender, recorder_data, progress=gr.Progress()):
679
  if not v_name.strip():
680
  return (
 
696
  gr.Audio(visible=False),
697
  gr.Button(visible=False)
698
  )
699
+
700
  progress(0, desc="Extracting speech samples patterns...")
701
  time.sleep(1.0)
702
  progress(0.3, desc="Filtering ambient air-con noise frequencies...")
 
705
  time.sleep(1.5)
706
  progress(0.9, desc="Validating speech prosody & standard conversational matching...")
707
  time.sleep(1.0)
708
+
709
  preview_text = f"&ldquo;Hello! I have created my brand new clone under '{v_name}'. Ready to narrate any classical story inside your library shelves.&rdquo;"
 
710
  avatar = "👩" if v_gender == "Female" else "👨"
711
+
712
  cloned_card_html = f"""
713
  <div style="background: white; border: 1px solid #ebdccb; padding: 24px; border-radius: 16px; margin: 16px 0;">
714
  <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 12px;">
 
716
  {avatar}
717
  </div>
718
  <div>
719
+ <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>
720
  <p style="font-size: 11px; color:#6f6257; margin-top:2px;">Synthesized today • Calibrated for Classical Audiobooks</p>
721
  </div>
722
  </div>
 
725
  </div>
726
  </div>
727
  """
728
+
729
  return (
730
  gr.HTML(visible=False),
731
  gr.HTML(visible=False),
 
733
  gr.Audio(visible=True),
734
  gr.Button(visible=True)
735
  )
736
+
737
  extract_btn.click(
738
  animate_cloning_pipeline,
739
  inputs=[new_voice_name, new_voice_gender, mic_recorder],
740
  outputs=[cloning_progress_msg, loading_spinner, voice_cloning_success_panel, voice_sample_preview_widget, add_to_library_btn]
741
  )
742
+
743
+ # 4. Add cloned voice to inventory
744
  def save_new_cloned_voice(v_name, v_gender, list_voices):
745
  avatar = "👩" if v_gender == "Female" else "👨"
746
  new_voice_item = {
 
753
  "status": "ready"
754
  }
755
  updated = [new_voice_item] + list_voices
 
 
756
  return (
757
  updated,
758
  render_cloned_voices_html(updated),
759
+ 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>"""),
760
  gr.HTML(visible=False),
761
  gr.HTML(visible=False),
762
  gr.Audio(visible=False),
763
  gr.Button(visible=False),
764
  gr.Textbox(value="")
765
  )
766
+
767
  add_to_library_btn.click(
768
  save_new_cloned_voice,
769
  inputs=[new_voice_name, new_voice_gender, voices_state],
770
  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]
771
  )
772
 
773
+ # 5. Play button streams TTS audio chunk by chunk
774
+ def stream_tts(tts_chunks, paras):
775
+ _status_playing = """
776
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
777
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
778
+ <span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING</span>
779
+ </div>
 
 
 
 
 
780
  """
781
+ _status_done = """
782
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
783
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
784
+ <span style="font-size: 11px; color: #94a3b8; font-family: monospace;">DONE</span>
785
+ </div>
786
+ """
787
+
788
+ if not tts_chunks:
789
+ 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)
790
+ return
791
+
792
+ n = len(tts_chunks)
793
+ # Immediately show PLAYING state
794
+ 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)
795
+
796
+ for sample_rate, wav, i, total, err in generate_audio_stream(tts_chunks):
797
+ if err:
798
+ 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)
799
+ return
800
+ next_label = f"chunk {i+2} / {total}" if i + 1 < total else "last chunk"
801
+ 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>"
802
+ yield (sample_rate, wav), _status_playing, chunk_html, gr.Button(visible=False), gr.Button(visible=True), gr.Button(visible=True)
803
+
804
+ done_html = f"<div style='text-align:center;font-size:10px;color:#94a3b8;font-family:monospace;margin-top:8px;'>✅ {n} chunks complete</div>"
805
+ yield None, _status_done, done_html, gr.Button(visible=True), gr.Button(visible=False), gr.Button(visible=False)
806
+
807
+ play_btn.click(
808
+ stream_tts,
809
+ inputs=[tts_chunks_state, paragraphs_state],
810
+ outputs=[player_audio_control, player_status_bar, chunk_status, play_btn, pause_btn, ask_btn]
811
  )
812
 
813
+ # 6. Pause button PAUSED state
814
+ def enter_paused_state(slider_val, paras):
815
+ total = len(paras) if paras else 1
816
+ 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>"""
817
+ status_html = """
818
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
819
+ <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block;"></span>
820
+ <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED</span>
821
+ </div>
822
  """
823
+ return (
824
+ status_html,
825
+ chunk_html,
826
+ gr.Button(visible=True),
827
+ gr.Button(visible=False),
828
+ )
829
+
830
+ pause_btn.click(
831
+ enter_paused_state,
832
+ inputs=[timeline_slider, paragraphs_state],
833
+ outputs=[player_status_bar, chunk_status, play_btn, pause_btn]
834
  )
835
 
836
+ # 7. Timeline slider highlight current paragraph
837
+ def update_story_highlight(slider_val, paras):
838
+ idx = int(slider_val)
839
+ total = len(paras)
840
+ chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">Paragraph {idx + 1} / {total}</div>"""
841
+ return render_story_text(paras, idx), chunk_html
842
+
843
+ timeline_slider.change(update_story_highlight, inputs=[timeline_slider, paragraphs_state], outputs=[story_text_display, chunk_status])
844
+
845
+ # 8. Ask button — PAUSED-ASKING state
846
  def enter_asking_state():
847
  status_html = """
848
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
849
  <span style="width: 8px; height: 8px; border-radius: 50%; background: #f59e0b; display: inline-block; animation: pulse 1s infinite;"></span>
850
  <span style="font-size: 11px; color: #f59e0b; font-family: monospace;">PAUSED — ASKING</span>
851
  </div>
852
  """
853
  return (
854
+ status_html,
855
+ gr.Button(visible=False),
856
+ gr.Button(visible=False),
857
+ gr.Button(visible=False),
858
+ gr.Button(visible=True),
859
+ gr.HTML(visible=True),
860
+ gr.Group(visible=True),
861
+ gr.HTML(visible=False),
862
+ gr.Audio(visible=False),
863
  )
864
 
865
  ask_btn.click(
866
  enter_asking_state,
867
  inputs=[],
868
+ outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio]
869
  )
870
 
871
+ # 9. Submit question
872
  def handle_question_submit(question_txt, question_audio_path):
873
  if not question_txt.strip() and question_audio_path is None:
874
  answer_html = """
 
879
  return answer_html, gr.Audio(visible=False)
880
 
881
  q_text = question_txt.strip() if question_txt.strip() else "(spoken question)"
882
+ 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."
 
 
883
 
884
  answer_html = f"""
885
+ <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;">
886
+ <div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #4ade80; margin-bottom: 6px;">Answer in Narrator's Voice</div>
887
+ <div style="font-family: 'Playfair Display', Georgia, serif; font-style: italic; color: #FAF7F2; font-size: 13px; line-height: 1.6;">
888
  &ldquo;{mock_answer}&rdquo;
889
  </div>
890
+ <div style="margin-top: 8px; font-size: 10px; color: #94a3b8;">
891
  Q: <em>{q_text}</em>
892
  </div>
893
  </div>
894
  """
 
895
  return answer_html, gr.Audio(value="sample_sounds/cloned_preview.wav", visible=True)
896
 
897
  submit_question_btn.click(
 
900
  outputs=[answer_display, answer_audio]
901
  )
902
 
903
+ # 10. Resume button — back to PLAYING
904
  def enter_resume_state():
905
  status_html = """
906
+ <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
907
  <span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
908
+ <span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING</span>
909
  </div>
910
  """
911
  return (
912
+ status_html,
913
+ gr.Button(visible=False),
914
+ gr.Button(visible=True),
915
+ gr.Button(visible=True),
916
+ gr.Button(visible=False),
917
+ gr.HTML(visible=False),
918
+ gr.Group(visible=False),
919
+ gr.HTML(visible=False),
920
+ gr.Audio(visible=False),
921
+ gr.Textbox(value=""),
922
  )
923
 
924
  resume_btn.click(
925
  enter_resume_state,
926
  inputs=[],
927
+ outputs=[player_status_bar, play_btn, pause_btn, ask_btn, resume_btn, qa_panel, qa_input_group, answer_display, answer_audio, question_text]
928
  )
929
 
930
+ # 11. Story-finished check
931
+ def check_story_finished(slider_val, paras):
932
+ if paras and int(slider_val) >= len(paras) - 1:
933
  return gr.HTML(visible=True)
934
  return gr.HTML(visible=False)
935
 
936
+ timeline_slider.change(check_story_finished, inputs=[timeline_slider, paragraphs_state], outputs=[story_finished_panel])
 
 
 
 
937
 
938
+ # 12. Sandbox outage toggle
939
  def toggle_outage_sandbox(checked):
940
  if checked:
941
+ return gr.HTML(visible=True), gr.Tabs(visible=False)
 
 
 
942
  else:
943
+ return gr.HTML(visible=False), gr.Tabs(visible=True)
944
+
945
+ offline_toggle.change(toggle_outage_sandbox, inputs=[offline_toggle], outputs=[system_error_modal, main_tabs])
 
 
 
 
 
 
 
946
 
 
947
  if __name__ == "__main__":
948
+ demo.queue().launch(allowed_paths=[str(Path(__file__).parent / "assets")])
docs/tts-module-spec.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ refer to test_modules/app_supertonic_tts_demo.py
2
+
3
+ - use supertonic tts library
4
+ - in this demo, input text is generated to speech data using supertonic.TTS
5
+ - text is firstly divided into multiple chunk text by statements and stored in buffer list
6
+ - and TTS generation is running for the list of text chunks
7
+ - this is to generate audio quickly for long text book.
8
+
9
+ TODO
10
+ - make tts.py and make module refering to app_supertonic_tts_demo.py
11
+ - In library menu, "Tap to play" button is clicked and the book text is chuncked and stored in buffer list.
12
+ - user click the play button and the text chunk list is converted to audio one by one.
13
+
14
+
15
+ package dependencies
16
+
17
+ # Supertonic TTS
18
+ supertonic>=1.3.1
19
+ soundfile>=0.12.0
20
+ onnxruntime>=1.18.0
21
+ huggingface-hub>=0.23.0
requirements.txt CHANGED
@@ -1,7 +1,14 @@
1
- gradio>=5.0
2
  transformers
3
  torch
4
  accelerate
5
  bitsandbytes
6
  soundfile
7
  numpy
 
 
 
 
 
 
 
 
1
+ gradio==5.35.0
2
  transformers
3
  torch
4
  accelerate
5
  bitsandbytes
6
  soundfile
7
  numpy
8
+
9
+
10
+ # Supertonic TTS
11
+ supertonic>=1.3.1
12
+ soundfile>=0.12.0
13
+ onnxruntime>=1.18.0
14
+ huggingface-hub>=0.23.0
static/style.css ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,600;0,700;1,400&display=swap');
2
+
3
+ /* ── Base ── */
4
+ body, .gradio-container {
5
+ background-color: #FAF7F2 !important;
6
+ font-family: 'Inter', system-ui, sans-serif !important;
7
+ }
8
+ .gradio-container {
9
+ max-width: 1200px !important;
10
+ margin: 0 auto !important;
11
+ }
12
+
13
+ /* ── Typography ── */
14
+ .serif-header {
15
+ font-family: 'Playfair Display', Georgia, serif !important;
16
+ color: #1c1c19 !important;
17
+ font-weight: 700 !important;
18
+ }
19
+ .cream-bg { background-color: #FAF7F2 !important; }
20
+ .highlight-orange { color: #f5841f !important; }
21
+ .highlight-amber { color: #944a00 !important; }
22
+
23
+ /* ── Cards ── */
24
+ .card-container {
25
+ background-color: #f6f3ee !important;
26
+ border: 1px solid #ebdccb !important;
27
+ border-radius: 16px !important;
28
+ padding: 24px !important;
29
+ box-shadow: 0 2px 8px rgba(148, 74, 0, 0.03) !important;
30
+ }
31
+ .accent-border { border-color: #ddc1b0 !important; }
32
+
33
+ .dark-card-container {
34
+ background-color: #122116 !important;
35
+ color: #FAF7F2 !important;
36
+ border: 1px solid #1e3422 !important;
37
+ border-radius: 16px !important;
38
+ padding: 24px !important;
39
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
40
+ }
41
+
42
+ /* ── Hero ── */
43
+ .hero-spotlight {
44
+ background: linear-gradient(135deg, #1e3422 0%, #121f15 100%) !important;
45
+ color: #FFFFFF !important;
46
+ border-radius: 24px !important;
47
+ padding: 32px !important;
48
+ position: relative;
49
+ overflow: hidden;
50
+ }
51
+ .badge-featured {
52
+ background: rgba(245, 132, 31, 0.2);
53
+ color: #f5841f;
54
+ border: 1px solid rgba(245, 132, 31, 0.3);
55
+ border-radius: 9999px;
56
+ padding: 4px 10px;
57
+ font-size: 11px;
58
+ font-weight: 700;
59
+ text-transform: uppercase;
60
+ display: inline-block;
61
+ }
62
+
63
+ /* ── Book grid & cards ── */
64
+ .book-shelf-grid {
65
+ display: grid;
66
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
67
+ gap: 20px;
68
+ }
69
+ .shelf-card {
70
+ background: #f6f3ee;
71
+ border: 1px solid #eadacc;
72
+ border-radius: 16px;
73
+ padding: 16px;
74
+ display: flex;
75
+ gap: 16px;
76
+ transition: all 0.3s ease;
77
+ cursor: pointer;
78
+ }
79
+ .shelf-card:hover {
80
+ border-color: #f5841f;
81
+ transform: translateY(-2px);
82
+ box-shadow: 0 4px 12px rgba(148, 74, 0, 0.06);
83
+ }
84
+ .cover-image {
85
+ width: 80px;
86
+ height: 110px;
87
+ object-fit: cover;
88
+ border-radius: 8px;
89
+ box-shadow: 0 2px 6px rgba(0,0,0,0.1);
90
+ }
91
+
92
+ /* ── Progress bar ── */
93
+ .progress-bar-bg {
94
+ width: 100%;
95
+ height: 6px;
96
+ background: #ebdccb;
97
+ border-radius: 999px;
98
+ overflow: hidden;
99
+ }
100
+ .progress-bar-fill {
101
+ height: 100%;
102
+ background: #f5841f;
103
+ }
104
+
105
+ /* ── Recording UX ── */
106
+ @keyframes pulse-ring {
107
+ 0% { transform: scale(0.85); opacity: 0.6; }
108
+ 50% { transform: scale(1.15); opacity: 0.25; }
109
+ 100% { transform: scale(1.4); opacity: 0; }
110
+ }
111
+ @keyframes pulse {
112
+ 0%, 100% { opacity: 1; }
113
+ 50% { opacity: 0.4; }
114
+ }
115
+ .pulse-circle {
116
+ position: relative;
117
+ width: 120px;
118
+ height: 120px;
119
+ border-radius: 50%;
120
+ background: #944a00;
121
+ color: white;
122
+ display: flex;
123
+ flex-direction: column;
124
+ align-items: center;
125
+ justify-content: center;
126
+ margin: 0 auto;
127
+ }
128
+ .pulse-circle::before,
129
+ .pulse-circle::after {
130
+ content: '';
131
+ position: absolute;
132
+ inset: 0;
133
+ border-radius: 50%;
134
+ border: 3px solid #944a00;
135
+ animation: pulse-ring 1.8s ease-out infinite;
136
+ }
137
+ .pulse-circle::after { animation-delay: 0.6s; }
138
+ .recording-active { background: #ef4444 !important; }
139
+ .recording-active::before,
140
+ .recording-active::after { border-color: #ef4444; }
141
+
142
+ /* ── Subtitle / dialog ticker ── */
143
+ .dialog-synced {
144
+ background: rgba(0,0,0,0.15);
145
+ border: 1px solid rgba(255,166,0,0.1);
146
+ color: #ffd3a6;
147
+ font-family: 'Playfair Display', Georgia, serif;
148
+ font-size: 1.25rem;
149
+ font-style: italic;
150
+ padding: 24px;
151
+ border-radius: 16px;
152
+ text-align: center;
153
+ min-height: 100px;
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ }
158
+
159
+ /* ── Tabs ── */
160
+ .tabs .tab-nav button {
161
+ border-bottom: 2px solid transparent !important;
162
+ }
163
+ .tabs .tab-nav button.selected {
164
+ color: #944a00 !important;
165
+ border-bottom-color: #944a00 !important;
166
+ font-weight: 700 !important;
167
+ }
168
+
169
+ /* ── Mobile responsive ── */
170
+ @media (max-width: 768px) {
171
+ .gradio-container { max-width: 100% !important; padding: 0 12px !important; }
172
+ .book-shelf-grid { grid-template-columns: 1fr !important; }
173
+ .hero-spotlight { flex-direction: column !important; padding: 20px !important; }
174
+ .shelf-card { flex-direction: column; }
175
+ .card-container, .dark-card-container { padding: 16px !important; }
176
+ }
177
+ @media (max-width: 480px) {
178
+ .book-shelf-grid { grid-template-columns: 1fr !important; gap: 12px; }
179
+ }
test_modules/app_supertonic_tts_demo.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supertonic TTS Gradio Demo — concurrent chunk streaming with playback sync
3
+ Splits text into statements, synthesizes in a background thread, streams
4
+ audio chunk-by-chunk. A JS timeupdate listener tracks audio.currentTime
5
+ to bold exactly the chunk currently playing in the browser.
6
+
7
+ Usage:
8
+ python app.py
9
+ """
10
+ import json
11
+ import logging
12
+ import queue
13
+ import re
14
+ import threading
15
+ from pathlib import Path
16
+
17
+ import gradio as gr
18
+ import numpy as np
19
+
20
+ from supertonic import TTS, AVAILABLE_LANGUAGES
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ CLONED_VOICE_JSON = Path(__file__).resolve().parent / "recorded_voice_supertonic-3.json"
25
+ CLONED_VOICE_LABEL = "Custom (recorded_voice_supertonic-3.json)"
26
+
27
+ print("Loading Supertonic TTS model...")
28
+ tts = TTS(auto_download=True)
29
+
30
+ builtin_voices = tts.voice_style_names # M1-M5, F1-F5
31
+ all_voices = ([CLONED_VOICE_LABEL] + builtin_voices) if CLONED_VOICE_JSON.exists() else builtin_voices
32
+
33
+ _SENTENCE_SPLIT_RE = re.compile(r'(?<=[.!?;])\s+')
34
+ _SENTINEL = object()
35
+
36
+ # JavaScript injected once at page load.
37
+ # Watches for changes to the hidden timing component, then attaches a
38
+ # timeupdate listener that bolds whichever .chunk-text span is currently
39
+ # playing based on audio.currentTime.
40
+ _SYNC_JS = """
41
+ () => {
42
+ function attachAudioSync(chunks) {
43
+ // Find the streaming audio element — Gradio wraps it inside a
44
+ // data-testid="audio" block; fall back to first <audio> on the page.
45
+ const container = document.querySelector('[data-testid="audio"]') ||
46
+ document.querySelector('.audio-container');
47
+ const audio = container ? container.querySelector('audio')
48
+ : document.querySelector('audio');
49
+ if (!audio) {
50
+ // Audio element not yet in DOM — retry shortly.
51
+ setTimeout(() => attachAudioSync(chunks), 150);
52
+ return;
53
+ }
54
+
55
+ // Remove any previous listener we registered.
56
+ if (audio._ttsSyncHandler) {
57
+ audio.removeEventListener('timeupdate', audio._ttsSyncHandler);
58
+ }
59
+
60
+ audio._ttsSyncHandler = () => {
61
+ const t = audio.currentTime;
62
+ document.querySelectorAll('.chunk-item').forEach((item, i) => {
63
+ const textEl = item.querySelector('.chunk-text');
64
+ if (!textEl || i >= chunks.length) return;
65
+ const active = t >= chunks[i].start && t < chunks[i].end;
66
+ textEl.style.fontWeight = active ? '700' : 'normal';
67
+ });
68
+ };
69
+
70
+ audio.addEventListener('timeupdate', audio._ttsSyncHandler);
71
+ }
72
+
73
+ // Observe the hidden timing textbox for value changes.
74
+ const observer = new MutationObserver(() => {
75
+ const box = document.querySelector('#tts-timing-box textarea');
76
+ if (!box || !box.value) return;
77
+ let chunks;
78
+ try { chunks = JSON.parse(box.value); } catch(e) { return; }
79
+ if (chunks.length) attachAudioSync(chunks);
80
+ });
81
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true });
82
+ }
83
+ """
84
+
85
+
86
+ def split_into_statements(text: str) -> list[str]:
87
+ parts = _SENTENCE_SPLIT_RE.split(text.strip())
88
+ return [p.strip() for p in parts if p.strip()]
89
+
90
+
91
+ def _chunks_html(statements: list[str], playing_idx: int, generating_idx: int) -> str:
92
+ rows = []
93
+ for i, stmt in enumerate(statements):
94
+ if i < playing_idx:
95
+ icon, color = "✅", "#16a34a"
96
+ elif i == playing_idx:
97
+ icon, color = "▶️", "#2563eb"
98
+ elif i == generating_idx:
99
+ icon, color = "⏳", "#d97706"
100
+ else:
101
+ icon, color = "○", "#9ca3af"
102
+
103
+ rows.append(
104
+ f'<div class="chunk-item" style="display:flex;gap:8px;padding:3px 0;">'
105
+ f'<span style="color:{color};min-width:18px;font-size:13px;">{icon}</span>'
106
+ f'<span class="chunk-text" style="font-size:13px;color:#374151;line-height:1.4;">{stmt}</span>'
107
+ f'</div>'
108
+ )
109
+ return '<div style="margin-top:8px;">' + "".join(rows) + "</div>"
110
+
111
+
112
+ def generate_audio(text: str, voice_name: str, language: str, speed: float, steps: int):
113
+ text = (text or "").strip()
114
+ if not text:
115
+ yield gr.update(), "Please enter some text.", "", "[]"
116
+ return
117
+
118
+ try:
119
+ style = (
120
+ tts.get_voice_style_from_path(CLONED_VOICE_JSON)
121
+ if voice_name == CLONED_VOICE_LABEL
122
+ else tts.get_voice_style(voice_name)
123
+ )
124
+
125
+ statements = split_into_statements(text) or [text]
126
+ n = len(statements)
127
+
128
+ chunk_q: queue.Queue = queue.Queue(maxsize=2)
129
+
130
+ def _worker():
131
+ for i, stmt in enumerate(statements):
132
+ try:
133
+ wav, _ = tts.synthesize(
134
+ stmt,
135
+ voice_style=style,
136
+ lang=language,
137
+ speed=speed,
138
+ total_steps=int(steps),
139
+ )
140
+ chunk_q.put((i, wav.squeeze()))
141
+ except Exception as exc:
142
+ chunk_q.put((i, exc))
143
+ return
144
+ chunk_q.put(_SENTINEL)
145
+
146
+ worker = threading.Thread(target=_worker, daemon=True)
147
+ worker.start()
148
+
149
+ yield gr.update(), f"Generating chunk 1/{n}…", _chunks_html(statements, -1, 0), "[]"
150
+
151
+ timing: list[dict] = [] # [{start, end}, …] — grows as chunks arrive
152
+ cumulative = 0.0
153
+
154
+ while True:
155
+ item = chunk_q.get()
156
+ if item is _SENTINEL:
157
+ break
158
+
159
+ i, payload = item
160
+ if isinstance(payload, Exception):
161
+ yield gr.update(), f"Generation failed on chunk {i + 1}: {payload}", "", json.dumps(timing)
162
+ return
163
+
164
+ wav = payload
165
+ dur = len(wav) / tts.sample_rate
166
+ timing.append({"start": round(cumulative, 4), "end": round(cumulative + dur, 4)})
167
+ cumulative += dur
168
+
169
+ next_gen = i + 1 if i + 1 < n else -1
170
+ status = (
171
+ f"Playing {i + 1}/{n}, generating {next_gen + 1}/{n}…"
172
+ if next_gen >= 0
173
+ else f"Playing {i + 1}/{n}"
174
+ )
175
+
176
+ yield (
177
+ (tts.sample_rate, wav),
178
+ status,
179
+ _chunks_html(statements, i, next_gen),
180
+ json.dumps(timing), # JS picks this up via MutationObserver
181
+ )
182
+
183
+ worker.join()
184
+ total_dur = cumulative
185
+ yield (
186
+ gr.update(),
187
+ f"Done — {n} chunks, {total_dur:.1f}s total",
188
+ _chunks_html(statements, n, -1),
189
+ json.dumps(timing),
190
+ )
191
+
192
+ except Exception as e:
193
+ logger.exception("Audio generation failed")
194
+ yield gr.update(), f"Generation failed: {type(e).__name__}: {e}", "", "[]"
195
+
196
+
197
+ with gr.Blocks(title="Supertonic TTS Demo", js=_SYNC_JS) as demo:
198
+ gr.Markdown(f"# Supertonic TTS Demo\n**Model:** `supertonic-3` · **Voices:** {len(all_voices)}")
199
+
200
+ with gr.Row():
201
+ with gr.Column(scale=2):
202
+ text_input = gr.Textbox(
203
+ label="Text",
204
+ placeholder="Enter text to synthesize…",
205
+ lines=5,
206
+ )
207
+
208
+ with gr.Row():
209
+ lang_input = gr.Dropdown(
210
+ label="Language",
211
+ choices=AVAILABLE_LANGUAGES,
212
+ value="en",
213
+ interactive=True,
214
+ )
215
+ voice_input = gr.Dropdown(
216
+ label="Voice Style",
217
+ choices=all_voices,
218
+ value=all_voices[0],
219
+ interactive=True,
220
+ )
221
+
222
+ with gr.Row():
223
+ speed_input = gr.Slider(label="Speed", minimum=0.7, maximum=2.0, value=1.05, step=0.05)
224
+ steps_input = gr.Slider(label="Quality Steps", minimum=1, maximum=20, value=8, step=1)
225
+
226
+ play_btn = gr.Button("Play", variant="primary", size="lg")
227
+
228
+ with gr.Column(scale=3):
229
+ # streaming=True: each yielded chunk is appended to the playback buffer
230
+ audio_out = gr.Audio(label="Generated Speech", type="numpy", autoplay=True, streaming=True)
231
+ status_out = gr.Textbox(label="Status", lines=1, interactive=False)
232
+ chunks_out = gr.HTML()
233
+ # Hidden component — carries [{start, end}] timing per chunk to JS
234
+ timing_out = gr.Textbox(visible=False, elem_id="tts-timing-box")
235
+
236
+ play_btn.click(
237
+ generate_audio,
238
+ inputs=[text_input, voice_input, lang_input, speed_input, steps_input],
239
+ outputs=[audio_out, status_out, chunks_out, timing_out],
240
+ )
241
+
242
+ if __name__ == "__main__":
243
+ demo.queue().launch()
test_modules/supertonic_clone_demo.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from supertonic import TTS
2
+ from supertonic import pipeline
3
+ import numpy as np
4
+ from pathlib import Path
5
+
6
+ tts = TTS(auto_download=True)
7
+
8
+ # Load a locally downloaded custom voice style JSON.
9
+ json_path = Path(__file__).resolve().parent / "recorded_voice_supertonic-3.json"
10
+ style = pipeline.load_voice_style_from_json_file(str(json_path))
11
+
12
+ # text = "A gentle breeze moved through the open window while everyone listened to the story."
13
+ # text = "Hello, Bay Area K group Vibe coding members. This is generated by Supertonic model on my local machine"
14
+ # text = "To clarify if this model is locally inferenced, I turned off wifi and generated this audio"
15
+ # text = "Testing my clone model voice TTS in English"
16
+ text = """
17
+ Long before the advent of the printing press or the digital screen, humanity’s fundamental drive to communicate birthed the earliest systems of recorded thought.
18
+ It began with pictographs etched into the cool, unyielding stone of ancient caves, where early storytellers captured the essence of their daily lives, spiritual beliefs, and conquests.
19
+ These simple drawings were not mere decoration;
20
+ they were the very first seeds of human collaboration, allowing knowledge to transcend the boundaries of a single lifetime.
21
+ As human societies grew in complexity, so did their methods of encoding information.
22
+ The transition from crude cave art to structured written systems marked a monumental leap in cognitive evolution.
23
+ In the fertile crescent of Mesopotamia, the need to track agricultural surplus and administrative records led to the development of cuneiform, a system of wedge-shaped marks pressed into damp clay tablets.
24
+ This was the dawn of history—the moment when human thought could be permanently preserved, retrieved, and scrutinized by others.
25
+ """
26
+ wav, duration = tts.synthesize(text, voice_style=style, lang="en")
27
+
28
+ # text = "한국어 보이스 클로닝 테스트. 하나둘셋 하나둘셋. 동해물과 백두산이 마르고 닳도록"
29
+ # wav, duration = tts.synthesize(text, voice_style=style, lang="ko")
30
+
31
+
32
+ tts.save_audio(wav, "clone_output_04.wav")
33
+ duration_sec = float(np.asarray(duration).reshape(-1)[0])
34
+ print(f"Generated {duration_sec:.2f}s of audio")
tts.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TTS module — wraps Supertonic TTS with sentence-level chunking and
3
+ background-threaded streaming generation.
4
+ """
5
+ import logging
6
+ import queue
7
+ import re
8
+ import threading
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
13
+ _SENTINEL = object()
14
+
15
+ _tts_instance = None
16
+
17
+
18
+ def get_tts():
19
+ global _tts_instance
20
+ if _tts_instance is None:
21
+ from supertonic import TTS
22
+ logger.info("Loading Supertonic TTS model…")
23
+ _tts_instance = TTS(auto_download=True)
24
+ return _tts_instance
25
+
26
+
27
+ def split_into_chunks(text: str) -> list[str]:
28
+ """Split text into sentence-level chunks suitable for TTS streaming."""
29
+ parts = _SENTENCE_RE.split(text.strip())
30
+ return [p.strip() for p in parts if p.strip()]
31
+
32
+
33
+ def generate_audio_stream(chunks: list[str], voice_name: str = "F1"):
34
+ """
35
+ Generator: synthesizes chunks in a background thread (maxsize=2 buffer).
36
+ Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
37
+ wav_array is None on error.
38
+ """
39
+ tts = get_tts()
40
+ style = tts.get_voice_style(voice_name)
41
+ n = len(chunks)
42
+ chunk_q: queue.Queue = queue.Queue(maxsize=2)
43
+
44
+ def _worker():
45
+ for i, stmt in enumerate(chunks):
46
+ try:
47
+ wav, _ = tts.synthesize(stmt, voice_style=style)
48
+ chunk_q.put((i, wav.squeeze(), None))
49
+ except Exception as exc:
50
+ logger.exception("TTS synthesis failed on chunk %d", i)
51
+ chunk_q.put((i, None, str(exc)))
52
+ return
53
+ chunk_q.put(_SENTINEL)
54
+
55
+ threading.Thread(target=_worker, daemon=True).start()
56
+
57
+ while True:
58
+ item = chunk_q.get()
59
+ if item is _SENTINEL:
60
+ break
61
+ i, wav, err = item
62
+ yield tts.sample_rate, wav, i, n, err