sshtel Claude Sonnet 4.6 commited on
Commit
5f8f265
·
1 Parent(s): f7f3c86

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 CHANGED
@@ -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", value="sample_sounds/willow.wav", autoplay=False
 
 
 
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()] * 11
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
- chunk_html = f"""<div style="margin-top: 8px; font-size: 10px; color: #64748b; font-family: monospace; text-align: center;">Paragraph 1 / {len(paras)} — ready to play</div>"""
 
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(value=audio, visible=True),
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 — PLAYING state
767
- def enter_playing_state(slider_val, paras):
768
- total = len(paras) if paras else 1
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
- return (
777
- status_html,
778
- chunk_html,
779
- gr.Button(visible=False),
780
- gr.Button(visible=True),
781
- gr.Button(visible=True),
782
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
 
784
  play_btn.click(
785
- enter_playing_state,
786
- inputs=[timeline_slider, paragraphs_state],
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
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
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