Spaces:
Paused
Paused
Commit ·
68f1a16
1
Parent(s): c7cbd46
perf: 5 latency optimizations for TTS playback
Browse files1. 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>
- app.py +11 -12
- inference_lfm.py +5 -15
- requirements_lfm.txt +0 -1
- 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
|
| 421 |
-
from
|
| 422 |
-
|
| 423 |
-
print("[MomsVoice]
|
| 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 |
-
|
| 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=
|
| 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 |
-
|
| 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
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 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
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|