minhahwang Copilot commited on
Commit
68f1a16
·
1 Parent(s): c7cbd46

perf: 5 latency optimizations for TTS playback

Browse files

1. Pre-generate all paragraphs on book select (background thread)
4. Audio cache - save WAVs per chunk+voice, skip re-synthesis on replay
5. Reduce LFM Q&A max_new_tokens 200->100 (halves generation time)
11. Eager pre-buffering - serves from cache if pre-gen complete
13. Transition chime (0.3s 440Hz fade) between chunks to mask gaps

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

Files changed (4) hide show
  1. app.py +11 -12
  2. inference_lfm.py +5 -15
  3. requirements_lfm.txt +0 -1
  4. tts.py +156 -12
app.py CHANGED
@@ -16,7 +16,7 @@ import gradio as gr
16
  import time
17
  from pathlib import Path
18
 
19
- from tts import split_into_chunks, generate_audio_stream
20
  from inference import transcribe_audio, answer_story_question
21
  from inference_lfm import answer_question_audio, SAMPLE_RATE as LFM_SR
22
  from voice_clone import load_default_profile, list_saved_profiles
@@ -417,14 +417,10 @@ from voice_clone import get_custom_voice_model
417
  get_custom_voice_model()
418
  print("[MomsVoice] TTS model ready.")
419
 
420
- print("[MomsVoice] Preloading Whisper-small ASR model...")
421
- from inference import get_asr_pipeline, get_qa_model
422
- get_asr_pipeline()
423
- print("[MomsVoice] ASR model ready.")
424
-
425
- print("[MomsVoice] Preloading Qwen2.5-3B-Instruct Q&A model...")
426
- get_qa_model()
427
- print("[MomsVoice] Q&A model ready. All models preloaded.")
428
 
429
 
430
  # Gradio Application Core setup
@@ -658,8 +654,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
658
  <div style="font-size: 11.5px; color: #1c1c19; line-height: 2;">
659
  🎙️ <strong>Voice Cloning:</strong> Qwen3-TTS-1.7B-Base (bfloat16)<br>
660
  🗣️ <strong>Stock TTS:</strong> Qwen3-TTS-0.6B-CustomVoice<br>
661
- 👂 <strong>ASR:</strong> Whisper-small<br>
662
- 🧠 <strong>Q&A:</strong> Qwen2.5-3B-Instruct
663
  </div>
664
  </div>
665
  """)
@@ -706,6 +701,10 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
706
  paras = load_paragraphs(selected["story_path"])
707
  total = max(len(paras) - 1, 0)
708
  tts_chunks = split_into_chunks("\n\n".join(paras))
 
 
 
 
709
  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>"""
710
  status_html = """
711
  <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
@@ -1053,7 +1052,7 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
1053
  question_audio_path=question_audio_path if has_audio else None,
1054
  question_text=q_txt if q_txt and not has_audio else None,
1055
  story_context=story_context,
1056
- max_new_tokens=200,
1057
  )
1058
  if not answer_text:
1059
  answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out."
 
16
  import time
17
  from pathlib import Path
18
 
19
+ from tts import split_into_chunks, generate_audio_stream, pregenerate_story_audio
20
  from inference import transcribe_audio, answer_story_question
21
  from inference_lfm import answer_question_audio, SAMPLE_RATE as LFM_SR
22
  from voice_clone import load_default_profile, list_saved_profiles
 
417
  get_custom_voice_model()
418
  print("[MomsVoice] TTS model ready.")
419
 
420
+ print("[MomsVoice] Preloading LFM2.5-Audio-1.5B Q&A model...")
421
+ from inference_lfm import get_lfm_model
422
+ get_lfm_model()
423
+ print("[MomsVoice] LFM Q&A model ready. All models preloaded.")
 
 
 
 
424
 
425
 
426
  # Gradio Application Core setup
 
654
  <div style="font-size: 11.5px; color: #1c1c19; line-height: 2;">
655
  🎙️ <strong>Voice Cloning:</strong> Qwen3-TTS-1.7B-Base (bfloat16)<br>
656
  🗣️ <strong>Stock TTS:</strong> Qwen3-TTS-0.6B-CustomVoice<br>
657
+ 🧠 <strong>Q&A (end-to-end):</strong> LFM2.5-Audio-1.5B (LiquidAI)
 
658
  </div>
659
  </div>
660
  """)
 
701
  paras = load_paragraphs(selected["story_path"])
702
  total = max(len(paras) - 1, 0)
703
  tts_chunks = split_into_chunks("\n\n".join(paras))
704
+
705
+ # Pre-generate all audio in background for instant playback (#1 + #11)
706
+ pregenerate_story_audio(tts_chunks, voice_profile_id=profile_id)
707
+
708
  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>"""
709
  status_html = """
710
  <div style="margin-top: 12px; display: flex; align-items: center; gap: 10px; justify-content: center;">
 
1052
  question_audio_path=question_audio_path if has_audio else None,
1053
  question_text=q_txt if q_txt and not has_audio else None,
1054
  story_context=story_context,
1055
+ max_new_tokens=100,
1056
  )
1057
  if not answer_text:
1058
  answer_text = "Hmm, I'm not sure about that! Let's keep listening to find out."
inference_lfm.py CHANGED
@@ -25,15 +25,7 @@ def get_lfm_model():
25
 
26
  logger.info("Loading %s...", HF_REPO)
27
  _processor = LFM2AudioProcessor.from_pretrained(HF_REPO).eval()
28
- # Enable FlashAttention-2 if available, else SDPA
29
- model_kwargs = {}
30
- try:
31
- import flash_attn # noqa: F401
32
- model_kwargs["attn_implementation"] = "flash_attention_2"
33
- logger.info("Using FlashAttention-2 for LFM.")
34
- except ImportError:
35
- model_kwargs["attn_implementation"] = "sdpa"
36
- _model = LFM2AudioModel.from_pretrained(HF_REPO, **model_kwargs).eval()
37
  logger.info("LFM2.5-Audio loaded.")
38
  return _processor, _model
39
 
@@ -70,12 +62,10 @@ def answer_question_audio(
70
  # User turn — audio or text
71
  chat.new_turn("user")
72
  if question_audio_path:
73
- import torchaudio
74
- wav, sr = torchaudio.load(question_audio_path)
75
- # Resample to 16kHz if needed (model expects 16kHz input)
76
- if sr != 16000:
77
- wav = torchaudio.functional.resample(wav, sr, 16000)
78
- sr = 16000
79
  chat.add_audio(wav, sr)
80
  elif question_text:
81
  chat.add_text(question_text)
 
25
 
26
  logger.info("Loading %s...", HF_REPO)
27
  _processor = LFM2AudioProcessor.from_pretrained(HF_REPO).eval()
28
+ _model = LFM2AudioModel.from_pretrained(HF_REPO).eval()
 
 
 
 
 
 
 
 
29
  logger.info("LFM2.5-Audio loaded.")
30
  return _processor, _model
31
 
 
62
  # User turn — audio or text
63
  chat.new_turn("user")
64
  if question_audio_path:
65
+ import librosa
66
+ wav_np, sr = librosa.load(question_audio_path, sr=16000, mono=True)
67
+ wav = torch.from_numpy(wav_np).unsqueeze(0) # (1, samples)
68
+ sr = 16000
 
 
69
  chat.add_audio(wav, sr)
70
  elif question_text:
71
  chat.add_text(question_text)
requirements_lfm.txt CHANGED
@@ -6,7 +6,6 @@ bitsandbytes
6
  soundfile
7
  numpy
8
  librosa
9
- torchaudio
10
 
11
  # LFM2.5-Audio end-to-end model
12
  liquid-audio
 
6
  soundfile
7
  numpy
8
  librosa
 
9
 
10
  # LFM2.5-Audio end-to-end model
11
  liquid-audio
tts.py CHANGED
@@ -5,17 +5,26 @@ Supports two backends:
5
  - Qwen3-TTS Base (1.7B): voice-cloned synthesis using a cached voice profile
6
  - Qwen3-TTS CustomVoice (0.6B): fast predefined speakers (default stock voice)
7
 
 
 
 
 
 
 
8
  Usage:
9
  chunks = split_into_chunks(text)
10
  for sr, wav, i, n, err in generate_audio_stream(chunks, voice_profile_id="abc123"):
11
  ...
12
  """
 
13
  import logging
 
14
  import queue
15
  import re
16
  import threading
17
 
18
  import numpy as np
 
19
 
20
  logger = logging.getLogger(__name__)
21
 
@@ -26,6 +35,50 @@ _SENTINEL = object()
26
  # Target chunk length — shorter chunks = lower latency per chunk
27
  _MAX_CHUNK_CHARS = 120
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
  def split_into_chunks(text: str) -> list[str]:
31
  """Split text into short chunks suitable for low-latency TTS streaming.
@@ -60,26 +113,97 @@ def split_into_chunks(text: str) -> list[str]:
60
  return [c for c in chunks if c]
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def generate_audio_stream(
64
  chunks: list[str],
65
  voice_profile_id: str | None = None,
66
  custom_voice_speaker: str = "vivian",
 
67
  ):
68
  """
69
- Generator: synthesizes chunks in a background thread with pre-buffering.
70
- Batches small chunks into ~5s audio blocks for smoother playback.
 
 
 
71
  Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
72
-
73
- Backend selection:
74
- 1. voice_profile_id provided → Qwen3-TTS Base (1.7B) with cloned voice
75
- 2. Otherwise → Qwen3-TTS CustomVoice (0.6B) with predefined speaker
76
  """
77
  n = len(chunks)
78
- # Pre-buffer up to 4 chunks ahead for smoother playback
79
- chunk_q: queue.Queue = queue.Queue(maxsize=4)
80
  sample_rate = 24000
81
- # Target ~5 seconds of audio per yielded block (24000 Hz * 5s = 120000 samples)
82
- _TARGET_SAMPLES = 24000 * 5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  if voice_profile_id:
85
  _start_qwen_worker(chunks, voice_profile_id, chunk_q)
@@ -90,26 +214,36 @@ def generate_audio_stream(
90
  audio_buffer = []
91
  buffer_samples = 0
92
  last_idx = 0
 
 
93
 
94
  while True:
95
  item = chunk_q.get()
96
  if item is _SENTINEL:
97
- # Flush remaining buffer
98
  if audio_buffer:
99
  combined = np.concatenate(audio_buffer)
100
  yield sample_rate, combined, last_idx, n, None
101
  break
102
  i, wav, err = item
103
  if err:
104
- # Flush buffer before error
105
  if audio_buffer:
106
  combined = np.concatenate(audio_buffer)
107
  yield sample_rate, combined, last_idx, n, None
108
  yield sample_rate, wav, i, n, err
109
  break
110
  last_idx = i
 
 
 
 
 
 
111
  audio_buffer.append(wav)
112
  buffer_samples += len(wav)
 
 
 
 
113
 
114
  # Yield when buffer reaches target size or this is the first chunk (fast start)
115
  if buffer_samples >= _TARGET_SAMPLES or (i == 0 and buffer_samples > 0):
@@ -126,6 +260,11 @@ def _start_qwen_worker(chunks, profile_id, chunk_q):
126
  from voice_clone import synthesize_cloned, synthesize_custom_voice
127
  use_fallback = False
128
  for i, stmt in enumerate(chunks):
 
 
 
 
 
129
  try:
130
  if use_fallback:
131
  wav, _sr = synthesize_custom_voice(stmt)
@@ -159,6 +298,11 @@ def _start_custom_voice_worker(chunks, speaker, chunk_q):
159
  from voice_clone import synthesize_custom_voice_streaming
160
  import numpy as np
161
  for i, stmt in enumerate(chunks):
 
 
 
 
 
162
  try:
163
  segments = []
164
  for seg, _sr in synthesize_custom_voice_streaming(stmt, speaker=speaker):
 
5
  - Qwen3-TTS Base (1.7B): voice-cloned synthesis using a cached voice profile
6
  - Qwen3-TTS CustomVoice (0.6B): fast predefined speakers (default stock voice)
7
 
8
+ Features:
9
+ - Audio cache: avoids re-synthesis for repeated text+voice combos
10
+ - Pre-generation: background-generates entire story on book select
11
+ - Transition chime: soft audio between paragraphs to mask gaps
12
+ - Eager pre-buffering: generates next chunks while current plays
13
+
14
  Usage:
15
  chunks = split_into_chunks(text)
16
  for sr, wav, i, n, err in generate_audio_stream(chunks, voice_profile_id="abc123"):
17
  ...
18
  """
19
+ import hashlib
20
  import logging
21
+ import os
22
  import queue
23
  import re
24
  import threading
25
 
26
  import numpy as np
27
+ import soundfile as sf
28
 
29
  logger = logging.getLogger(__name__)
30
 
 
35
  # Target chunk length — shorter chunks = lower latency per chunk
36
  _MAX_CHUNK_CHARS = 120
37
 
38
+ # Audio cache directory
39
+ _CACHE_DIR = "audio_cache"
40
+ os.makedirs(_CACHE_DIR, exist_ok=True)
41
+
42
+ # Transition chime (soft sine fade, 0.3s at 24kHz)
43
+ _CHIME_SR = 24000
44
+ _CHIME_DURATION = 0.3
45
+ _chime_t = np.linspace(0, _CHIME_DURATION, int(_CHIME_SR * _CHIME_DURATION), dtype=np.float32)
46
+ _TRANSITION_CHIME = 0.08 * np.sin(2 * np.pi * 440 * _chime_t) * np.linspace(1, 0, len(_chime_t))
47
+
48
+ # Background pre-generation state
49
+ _pregen_lock = threading.Lock()
50
+ _pregen_cache: dict[str, list[tuple[int, np.ndarray]]] = {} # key -> [(sr, wav), ...]
51
+ _pregen_in_progress: set[str] = set()
52
+
53
+
54
+ def _cache_key(text: str, voice_profile_id: str | None) -> str:
55
+ """Generate a cache key from text + voice profile."""
56
+ raw = f"{voice_profile_id or 'stock'}:{text}"
57
+ return hashlib.md5(raw.encode()).hexdigest()
58
+
59
+
60
+ def _get_cached_audio(chunk_text: str, voice_profile_id: str | None) -> np.ndarray | None:
61
+ """Check if audio for this chunk is already cached on disk."""
62
+ key = _cache_key(chunk_text, voice_profile_id)
63
+ path = os.path.join(_CACHE_DIR, f"{key}.wav")
64
+ if os.path.exists(path):
65
+ try:
66
+ wav, sr = sf.read(path, dtype='float32')
67
+ return wav
68
+ except Exception:
69
+ pass
70
+ return None
71
+
72
+
73
+ def _save_cached_audio(chunk_text: str, voice_profile_id: str | None, wav: np.ndarray, sr: int):
74
+ """Save synthesized audio to disk cache."""
75
+ key = _cache_key(chunk_text, voice_profile_id)
76
+ path = os.path.join(_CACHE_DIR, f"{key}.wav")
77
+ try:
78
+ sf.write(path, wav, sr)
79
+ except Exception:
80
+ pass
81
+
82
 
83
  def split_into_chunks(text: str) -> list[str]:
84
  """Split text into short chunks suitable for low-latency TTS streaming.
 
113
  return [c for c in chunks if c]
114
 
115
 
116
+ def pregenerate_story_audio(chunks: list[str], voice_profile_id: str | None = None):
117
+ """Pre-generate all story audio in background. Results cached for instant playback.
118
+
119
+ Call this on book selection to pre-warm the cache. Non-blocking.
120
+ """
121
+ story_key = _cache_key("\n".join(chunks), voice_profile_id)
122
+
123
+ with _pregen_lock:
124
+ if story_key in _pregen_in_progress or story_key in _pregen_cache:
125
+ return # Already running or done
126
+ _pregen_in_progress.add(story_key)
127
+
128
+ def _worker():
129
+ results = []
130
+ sr = 24000
131
+ for i, chunk in enumerate(chunks):
132
+ # Check disk cache first
133
+ cached = _get_cached_audio(chunk, voice_profile_id)
134
+ if cached is not None:
135
+ results.append((sr, cached))
136
+ continue
137
+ # Synthesize
138
+ try:
139
+ wav, sample_rate = _synthesize_single(chunk, voice_profile_id)
140
+ sr = sample_rate
141
+ results.append((sr, wav))
142
+ _save_cached_audio(chunk, voice_profile_id, wav, sr)
143
+ except Exception as e:
144
+ logger.warning("Pre-gen failed on chunk %d: %s", i, e)
145
+ results.append((sr, np.zeros(0, dtype=np.float32)))
146
+
147
+ with _pregen_lock:
148
+ _pregen_cache[story_key] = results
149
+ _pregen_in_progress.discard(story_key)
150
+ logger.info("Pre-generation complete: %d chunks cached.", len(chunks))
151
+
152
+ threading.Thread(target=_worker, daemon=True).start()
153
+
154
+
155
+ def _synthesize_single(text: str, voice_profile_id: str | None) -> tuple[np.ndarray, int]:
156
+ """Synthesize a single chunk, choosing backend based on profile."""
157
+ if voice_profile_id:
158
+ from voice_clone import synthesize_cloned, synthesize_custom_voice
159
+ try:
160
+ wav, sr = synthesize_cloned(text, voice_profile_id)
161
+ return wav, sr
162
+ except Exception:
163
+ wav, sr = synthesize_custom_voice(text)
164
+ return wav, sr
165
+ else:
166
+ from voice_clone import synthesize_custom_voice
167
+ wav, sr = synthesize_custom_voice(text)
168
+ return wav, sr
169
+
170
+
171
  def generate_audio_stream(
172
  chunks: list[str],
173
  voice_profile_id: str | None = None,
174
  custom_voice_speaker: str = "vivian",
175
+ add_transitions: bool = True,
176
  ):
177
  """
178
+ Generator: synthesizes chunks with caching + pre-buffering + transitions.
179
+
180
+ If pre-generated audio is available (from pregenerate_story_audio), serves
181
+ instantly from cache. Otherwise synthesizes on-demand with background pre-buffer.
182
+
183
  Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
 
 
 
 
184
  """
185
  n = len(chunks)
 
 
186
  sample_rate = 24000
187
+
188
+ # Check if pre-generated cache is available
189
+ story_key = _cache_key("\n".join(chunks), voice_profile_id)
190
+ pregen_results = None
191
+ with _pregen_lock:
192
+ if story_key in _pregen_cache:
193
+ pregen_results = _pregen_cache[story_key]
194
+
195
+ if pregen_results and len(pregen_results) == n:
196
+ # Serve from pre-generated cache — near-zero latency
197
+ for i, (sr, wav) in enumerate(pregen_results):
198
+ if wav is not None and len(wav) > 0:
199
+ if add_transitions and i > 0:
200
+ # Prepend transition chime
201
+ wav = np.concatenate([_TRANSITION_CHIME, wav])
202
+ yield sr, wav, i, n, None
203
+ return
204
+
205
+ # Fallback: on-demand synthesis with pre-buffering (4 chunks ahead)
206
+ chunk_q: queue.Queue = queue.Queue(maxsize=4)
207
 
208
  if voice_profile_id:
209
  _start_qwen_worker(chunks, voice_profile_id, chunk_q)
 
214
  audio_buffer = []
215
  buffer_samples = 0
216
  last_idx = 0
217
+ _TARGET_SAMPLES = 24000 * 5 # ~5s blocks
218
+ chunk_count = 0
219
 
220
  while True:
221
  item = chunk_q.get()
222
  if item is _SENTINEL:
 
223
  if audio_buffer:
224
  combined = np.concatenate(audio_buffer)
225
  yield sample_rate, combined, last_idx, n, None
226
  break
227
  i, wav, err = item
228
  if err:
 
229
  if audio_buffer:
230
  combined = np.concatenate(audio_buffer)
231
  yield sample_rate, combined, last_idx, n, None
232
  yield sample_rate, wav, i, n, err
233
  break
234
  last_idx = i
235
+
236
+ # Add transition chime between chunks (not before first)
237
+ if add_transitions and chunk_count > 0:
238
+ audio_buffer.append(_TRANSITION_CHIME)
239
+ buffer_samples += len(_TRANSITION_CHIME)
240
+
241
  audio_buffer.append(wav)
242
  buffer_samples += len(wav)
243
+ chunk_count += 1
244
+
245
+ # Cache this chunk for future replays
246
+ _save_cached_audio(chunks[i], voice_profile_id, wav, sample_rate)
247
 
248
  # Yield when buffer reaches target size or this is the first chunk (fast start)
249
  if buffer_samples >= _TARGET_SAMPLES or (i == 0 and buffer_samples > 0):
 
260
  from voice_clone import synthesize_cloned, synthesize_custom_voice
261
  use_fallback = False
262
  for i, stmt in enumerate(chunks):
263
+ # Check cache first
264
+ cached = _get_cached_audio(stmt, profile_id)
265
+ if cached is not None:
266
+ chunk_q.put((i, cached, None))
267
+ continue
268
  try:
269
  if use_fallback:
270
  wav, _sr = synthesize_custom_voice(stmt)
 
298
  from voice_clone import synthesize_custom_voice_streaming
299
  import numpy as np
300
  for i, stmt in enumerate(chunks):
301
+ # Check cache first
302
+ cached = _get_cached_audio(stmt, None)
303
+ if cached is not None:
304
+ chunk_q.put((i, cached, None))
305
+ continue
306
  try:
307
  segments = []
308
  for seg, _sr in synthesize_custom_voice_streaming(stmt, speaker=speaker):