Spaces:
Runtime error
Runtime error
Merge HuggingFace_Hack/main: real TTS pipeline + asset overhaul
Browse filesapp.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 +7 -0
- README.md +12 -0
- SPEC.md +103 -0
- app.py +389 -521
- docs/tts-module-spec.md +21 -0
- requirements.txt +8 -1
- static/style.css +179 -0
- test_modules/app_supertonic_tts_demo.py +243 -0
- test_modules/supertonic_clone_demo.py +34 -0
- tts.py +62 -0
.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 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
"
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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 |
-
|
| 182 |
-
|
| 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="
|
| 465 |
-
|
| 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;
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
with gr.TabItem("📚 My Bookshelf") as shelf_tab:
|
| 543 |
with gr.Row():
|
|
|
|
| 544 |
with gr.Column(scale=3):
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 612 |
</div>
|
| 613 |
""")
|
| 614 |
cloned_voices_list_grid = gr.HTML(value=render_cloned_voices_html(mock_voices))
|
| 615 |
|
| 616 |
-
# TAB 4:
|
| 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 |
-
“Deep within the heart of Whispering Woods stands an ancient willow tree...”
|
| 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
|
| 806 |
-
|
| 807 |
-
# 1. Search
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"“Hello! I have created my brand new clone under '{v_name}'. Ready to narrate any classical story inside your library shelves.”"
|
| 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;
|
| 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 |
-
#
|
| 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!
|
| 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 |
-
#
|
| 918 |
-
def
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 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 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
)
|
| 945 |
|
| 946 |
-
#
|
| 947 |
-
def
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
|
|
|
| 954 |
"""
|
| 955 |
-
return
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
)
|
| 962 |
|
| 963 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
def enter_asking_state():
|
| 965 |
status_html = """
|
| 966 |
-
<div style="margin-top:
|
| 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,
|
| 973 |
-
gr.Button(visible=False),
|
| 974 |
-
gr.Button(visible=
|
| 975 |
-
gr.
|
| 976 |
-
gr.
|
| 977 |
-
gr.HTML(visible=
|
| 978 |
-
gr.
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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:
|
| 1004 |
-
<div style="font-size: 10px; font-weight: 700; text-transform: uppercase; color: #
|
| 1005 |
-
<div style="font-family: 'Playfair Display', Georgia, serif; font-style: italic; color: #
|
| 1006 |
“{mock_answer}”
|
| 1007 |
</div>
|
| 1008 |
-
<div style="margin-top: 8px; font-size: 10px; color: #
|
| 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 |
-
#
|
| 1023 |
def enter_resume_state():
|
| 1024 |
status_html = """
|
| 1025 |
-
<div style="margin-top:
|
| 1026 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
|
| 1027 |
-
<span style="font-size: 11px; color: #
|
| 1028 |
</div>
|
| 1029 |
"""
|
| 1030 |
return (
|
| 1031 |
-
status_html,
|
| 1032 |
-
gr.Button(visible=
|
| 1033 |
-
gr.Button(visible=
|
| 1034 |
-
gr.
|
| 1035 |
-
gr.
|
| 1036 |
-
gr.HTML(visible=False),
|
| 1037 |
-
gr.
|
| 1038 |
-
gr.
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1048 |
-
def check_story_finished(slider_val):
|
| 1049 |
-
if int(slider_val) >=
|
| 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 |
-
#
|
| 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 |
-
|
| 1069 |
-
|
| 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"“Hello! I have created my brand new clone under '{v_name}'. Ready to narrate any classical story inside your library shelves.”"
|
|
|
|
| 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 |
“{mock_answer}”
|
| 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
|
| 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
|