Spaces:
Paused
feat: integrate Supertonic TTS with sentence-level chunk streaming
Browse files- Add tts.py: lazy singleton TTS loader, sentence-level text splitter,
and background-threaded generator that synthesizes chunks into a
bounded queue and yields (sample_rate, wav, idx, total, err) per chunk
- Update app.py: add tts_chunks_state; on book select, split full story
text into TTS sentence chunks stored in state
- Switch player_audio_control to streaming=True / type=numpy / autoplay=True
- Replace play_btn handler with stream_tts generator that synthesizes and
streams audio chunks one by one with live chunk progress in the status bar
- Add test_modules: supertonic demo, clone demo, and TTS module spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chore: add .gitattributes for Git LFS binary tracking
Track *.png, *.jpg, *.jpeg, *.wav, *.mp3, *.gif, *.webp via Git LFS
so HuggingFace Space accepts the push.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app.py +44 -21
- docs/tts-module-spec.md +21 -0
- test_modules/app_supertonic_tts_demo.py +243 -0
- test_modules/supertonic_clone_demo.py +34 -0
- tts.py +62 -0
|
@@ -6,6 +6,8 @@ import gradio as gr
|
|
| 6 |
import time
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
|
|
|
| 9 |
# Create directories for sample audio files
|
| 10 |
os.makedirs("sample_sounds", exist_ok=True)
|
| 11 |
|
|
@@ -352,6 +354,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 352 |
books_state = gr.State(mock_books)
|
| 353 |
voices_state = gr.State(mock_voices)
|
| 354 |
paragraphs_state = gr.State([])
|
|
|
|
| 355 |
|
| 356 |
gr.HTML("""
|
| 357 |
<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;">
|
|
@@ -423,7 +426,10 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 423 |
|
| 424 |
player_audio_control = gr.Audio(
|
| 425 |
visible=False, interactive=False,
|
| 426 |
-
label="Audio Stream",
|
|
|
|
|
|
|
|
|
|
| 427 |
)
|
| 428 |
|
| 429 |
player_status_bar = gr.HTML("""
|
|
@@ -623,7 +629,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 623 |
# 2. Book card click → load into player
|
| 624 |
def handle_book_select(title_chosen, current_inventory):
|
| 625 |
if not title_chosen:
|
| 626 |
-
return [gr.update()] *
|
| 627 |
selected = next((b for b in current_inventory if b["title"] == title_chosen), current_inventory[0])
|
| 628 |
|
| 629 |
player_html = f"""
|
|
@@ -636,18 +642,18 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 636 |
"""
|
| 637 |
paras = load_paragraphs(selected["story_path"])
|
| 638 |
total = max(len(paras) - 1, 0)
|
| 639 |
-
|
|
|
|
| 640 |
status_html = """
|
| 641 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 642 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #94a3b8; display: inline-block;"></span>
|
| 643 |
<span style="font-size: 11px; color: #94a3b8; font-family: monospace;">READY</span>
|
| 644 |
</div>
|
| 645 |
"""
|
| 646 |
-
audio = selected["audio_path"] or "sample_sounds/willow.wav"
|
| 647 |
story_html = render_story_text(paras, 0)
|
| 648 |
return (
|
| 649 |
player_html,
|
| 650 |
-
gr.Audio(
|
| 651 |
status_html,
|
| 652 |
chunk_html,
|
| 653 |
gr.Button(visible=True), # play_btn
|
|
@@ -655,6 +661,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 655 |
gr.Button(visible=False), # ask_btn
|
| 656 |
gr.HTML(visible=False), # story_finished_panel
|
| 657 |
paras, # paragraphs_state
|
|
|
|
| 658 |
gr.HTML(value=story_html, visible=True), # story_text_display
|
| 659 |
gr.Slider(minimum=0, maximum=total, step=1, value=0, visible=True), # timeline_slider
|
| 660 |
)
|
|
@@ -664,7 +671,7 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 664 |
inputs=[book_selector, books_state],
|
| 665 |
outputs=[player_title_info, player_audio_control, player_status_bar, chunk_status,
|
| 666 |
play_btn, pause_btn, ask_btn, story_finished_panel,
|
| 667 |
-
paragraphs_state, story_text_display, timeline_slider]
|
| 668 |
)
|
| 669 |
|
| 670 |
# 3. Extract vocal weights trigger
|
|
@@ -763,28 +770,44 @@ with gr.Blocks(css=css_code, title="VoiceBook Gradio Hub") as demo:
|
|
| 763 |
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]
|
| 764 |
)
|
| 765 |
|
| 766 |
-
# 5. Play button —
|
| 767 |
-
def
|
| 768 |
-
|
| 769 |
-
chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #4ade80; font-family: monospace; text-align: center;">Paragraph {int(slider_val) + 1} / {total} — streaming</div>"""
|
| 770 |
-
status_html = """
|
| 771 |
<div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
|
| 772 |
<span style="width: 8px; height: 8px; border-radius: 50%; background: #4ade80; display: inline-block;"></span>
|
| 773 |
<span style="font-size: 11px; color: #4ade80; font-family: monospace;">PLAYING</span>
|
| 774 |
</div>
|
| 775 |
"""
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
|
| 784 |
play_btn.click(
|
| 785 |
-
|
| 786 |
-
inputs=[
|
| 787 |
-
outputs=[player_status_bar, chunk_status, play_btn, pause_btn, ask_btn]
|
| 788 |
)
|
| 789 |
|
| 790 |
# 6. Pause button — PAUSED state
|
|
|
|
| 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 |
|
|
|
|
| 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;">
|
|
|
|
| 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("""
|
|
|
|
| 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"""
|
|
|
|
| 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
|
|
|
|
| 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 |
)
|
|
|
|
| 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
|
|
|
|
| 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
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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")
|
|
@@ -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
|