Spaces:
Paused
Paused
Commit ·
fa6ebf7
1
Parent(s): e942657
perf: reduce TTS latency - shorter chunks + sub-segment streaming
Browse files- Split long sentences at clause boundaries (~120 char max per chunk)
- Add synthesize_custom_voice_streaming() for partial audio yields
- Shorter chunks = faster generation per chunk = smoother playback
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- tts.py +46 -7
- voice_clone.py +24 -0
tts.py
CHANGED
|
@@ -20,13 +20,44 @@ import numpy as np
|
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
_SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
|
|
|
|
| 23 |
_SENTINEL = object()
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def split_into_chunks(text: str) -> list[str]:
|
| 27 |
-
"""Split text into
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
|
| 32 |
def generate_audio_stream(
|
|
@@ -93,13 +124,21 @@ def _start_qwen_worker(chunks, profile_id, chunk_q):
|
|
| 93 |
|
| 94 |
|
| 95 |
def _start_custom_voice_worker(chunks, speaker, chunk_q):
|
| 96 |
-
"""Background thread: synthesize chunks with Qwen3-TTS CustomVoice (0.6B).
|
|
|
|
| 97 |
def _worker():
|
| 98 |
-
from voice_clone import
|
|
|
|
| 99 |
for i, stmt in enumerate(chunks):
|
| 100 |
try:
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
except Exception as exc:
|
| 104 |
logger.exception("CustomVoice synthesis failed on chunk %d", i)
|
| 105 |
chunk_q.put((i, None, str(exc)))
|
|
|
|
| 20 |
logger = logging.getLogger(__name__)
|
| 21 |
|
| 22 |
_SENTENCE_RE = re.compile(r'(?<=[.!?;])\s+')
|
| 23 |
+
_CLAUSE_RE = re.compile(r'(?<=[,;:\u2014])\s+|(?<=\.)\s+|(?<=[!?])\s+')
|
| 24 |
_SENTINEL = object()
|
| 25 |
|
| 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.
|
| 32 |
+
|
| 33 |
+
Splits by sentence first, then further splits long sentences by clause
|
| 34 |
+
boundaries (commas, semicolons, em-dashes) to keep each chunk under
|
| 35 |
+
~120 chars for faster TTS generation.
|
| 36 |
+
"""
|
| 37 |
+
sentences = _SENTENCE_RE.split(text.strip())
|
| 38 |
+
chunks = []
|
| 39 |
+
for sent in sentences:
|
| 40 |
+
sent = sent.strip()
|
| 41 |
+
if not sent:
|
| 42 |
+
continue
|
| 43 |
+
if len(sent) <= _MAX_CHUNK_CHARS:
|
| 44 |
+
chunks.append(sent)
|
| 45 |
+
else:
|
| 46 |
+
# Split long sentence into clauses
|
| 47 |
+
clauses = _CLAUSE_RE.split(sent)
|
| 48 |
+
current = ""
|
| 49 |
+
for clause in clauses:
|
| 50 |
+
clause = clause.strip()
|
| 51 |
+
if not clause:
|
| 52 |
+
continue
|
| 53 |
+
if current and len(current) + len(clause) + 1 > _MAX_CHUNK_CHARS:
|
| 54 |
+
chunks.append(current.strip())
|
| 55 |
+
current = clause
|
| 56 |
+
else:
|
| 57 |
+
current = f"{current} {clause}".strip() if current else clause
|
| 58 |
+
if current.strip():
|
| 59 |
+
chunks.append(current.strip())
|
| 60 |
+
return [c for c in chunks if c]
|
| 61 |
|
| 62 |
|
| 63 |
def generate_audio_stream(
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
def _start_custom_voice_worker(chunks, speaker, chunk_q):
|
| 127 |
+
"""Background thread: synthesize chunks with Qwen3-TTS CustomVoice (0.6B).
|
| 128 |
+
Uses sub-segment streaming for lower latency — yields partial audio as generated."""
|
| 129 |
def _worker():
|
| 130 |
+
from voice_clone import synthesize_custom_voice_streaming
|
| 131 |
+
import numpy as np
|
| 132 |
for i, stmt in enumerate(chunks):
|
| 133 |
try:
|
| 134 |
+
segments = []
|
| 135 |
+
for seg, _sr in synthesize_custom_voice_streaming(stmt, speaker=speaker):
|
| 136 |
+
segments.append(seg)
|
| 137 |
+
if segments:
|
| 138 |
+
wav = np.concatenate(segments)
|
| 139 |
+
chunk_q.put((i, wav, None))
|
| 140 |
+
else:
|
| 141 |
+
chunk_q.put((i, np.zeros(0, dtype=np.float32), None))
|
| 142 |
except Exception as exc:
|
| 143 |
logger.exception("CustomVoice synthesis failed on chunk %d", i)
|
| 144 |
chunk_q.put((i, None, str(exc)))
|
voice_clone.py
CHANGED
|
@@ -390,6 +390,30 @@ def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
|
|
| 390 |
return wav, sample_rate
|
| 391 |
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
def synthesize_custom_voice(
|
| 394 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 395 |
) -> tuple[np.ndarray, int]:
|
|
|
|
| 390 |
return wav, sample_rate
|
| 391 |
|
| 392 |
|
| 393 |
+
def synthesize_custom_voice_streaming(
|
| 394 |
+
text: str, speaker: str = "vivian", language: str = "english"
|
| 395 |
+
):
|
| 396 |
+
"""
|
| 397 |
+
Synthesize text with the 0.6B CustomVoice model, yielding partial audio segments
|
| 398 |
+
as they become available for lower latency.
|
| 399 |
+
Yields (wav_segment, sample_rate) tuples.
|
| 400 |
+
"""
|
| 401 |
+
model = get_custom_voice_model()
|
| 402 |
+
|
| 403 |
+
audio_list, sample_rate = model.generate_custom_voice(
|
| 404 |
+
text=text,
|
| 405 |
+
speaker=speaker,
|
| 406 |
+
language=language,
|
| 407 |
+
non_streaming_mode=False,
|
| 408 |
+
**GENERATION_PARAMS,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
# Yield each segment as it was generated
|
| 412 |
+
for segment in audio_list:
|
| 413 |
+
if segment is not None and len(segment) > 0:
|
| 414 |
+
yield segment, sample_rate
|
| 415 |
+
|
| 416 |
+
|
| 417 |
def synthesize_custom_voice(
|
| 418 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 419 |
) -> tuple[np.ndarray, int]:
|