Spaces:
Paused
perf: add TTS latency optimizations
Browse filesOptimizations applied to voice_clone.py and tts.py:
- Auto-select bfloat16 (Ampere+) or float16 per GPU architecture
- FlashAttention-2 when available, SDPA fallback
- Streaming mode (non_streaming_mode=False)
- Reduced sampling: top_k=20, temperature=0.7, max_new_tokens=1024
- torch.set_float32_matmul_precision('high')
- torch.compile on decoder/predictor submodules (best-effort)
- Reference audio trimmed to 10s max for faster embedding extraction
- 0.6B CustomVoice model as fast non-cloned fallback (env: QWEN_TTS_MODE)
- Added librosa to requirements.txt
- FlashAttention-2 install instructions in requirements.txt
- 18-test suite for all optimization configs (test_latency_optimizations.py)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- requirements.txt +5 -0
- test_modules/test_latency_optimizations.py +189 -0
- tts.py +32 -7
- voice_clone.py +168 -7
|
@@ -5,6 +5,7 @@ accelerate
|
|
| 5 |
bitsandbytes
|
| 6 |
soundfile
|
| 7 |
numpy
|
|
|
|
| 8 |
|
| 9 |
# Supertonic TTS (fallback voice)
|
| 10 |
supertonic>=1.3.1
|
|
@@ -13,3 +14,7 @@ huggingface-hub>=0.23.0
|
|
| 13 |
|
| 14 |
# Qwen3-TTS (voice cloning)
|
| 15 |
qwen-tts>=0.1.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
bitsandbytes
|
| 6 |
soundfile
|
| 7 |
numpy
|
| 8 |
+
librosa
|
| 9 |
|
| 10 |
# Supertonic TTS (fallback voice)
|
| 11 |
supertonic>=1.3.1
|
|
|
|
| 14 |
|
| 15 |
# Qwen3-TTS (voice cloning)
|
| 16 |
qwen-tts>=0.1.1
|
| 17 |
+
|
| 18 |
+
# FlashAttention-2 — install separately on GPU machines:
|
| 19 |
+
# pip install flash-attn --no-build-isolation
|
| 20 |
+
# Falls back to SDPA (PyTorch native) if not available.
|
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test latency optimizations in voice_clone.py and tts.py.
|
| 3 |
+
|
| 4 |
+
Tests configuration logic, dtype selection, attention selection,
|
| 5 |
+
audio trimming, and generation parameter values — without loading
|
| 6 |
+
full models (no GPU required).
|
| 7 |
+
"""
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import tempfile
|
| 11 |
+
import importlib
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import soundfile as sf
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
| 18 |
+
|
| 19 |
+
PASS = 0
|
| 20 |
+
FAIL = 0
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def check(label, condition, detail=""):
|
| 24 |
+
global PASS, FAIL
|
| 25 |
+
if condition:
|
| 26 |
+
PASS += 1
|
| 27 |
+
print(f" [OK] {label}")
|
| 28 |
+
else:
|
| 29 |
+
FAIL += 1
|
| 30 |
+
print(f" [FAIL] {label} -- {detail}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_global_matmul_precision():
|
| 34 |
+
print("\n=== torch.set_float32_matmul_precision ===")
|
| 35 |
+
import voice_clone # noqa: F401 — importing sets precision
|
| 36 |
+
# PyTorch doesn't expose a getter, but the call should not raise
|
| 37 |
+
check("Module imported without error (precision set)", True)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_dtype_selection():
|
| 41 |
+
print("\n=== Dtype selection ===")
|
| 42 |
+
from voice_clone import _select_dtype
|
| 43 |
+
|
| 44 |
+
dtype = _select_dtype()
|
| 45 |
+
if torch.cuda.is_available():
|
| 46 |
+
cap = torch.cuda.get_device_capability()
|
| 47 |
+
if cap[0] >= 8:
|
| 48 |
+
check("Ampere+ GPU -> bfloat16", dtype == torch.bfloat16,
|
| 49 |
+
f"got {dtype}")
|
| 50 |
+
else:
|
| 51 |
+
check("Pre-Ampere GPU -> float16", dtype == torch.float16,
|
| 52 |
+
f"got {dtype}")
|
| 53 |
+
else:
|
| 54 |
+
check("CPU -> float32", dtype == torch.float32, f"got {dtype}")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_attn_selection():
|
| 58 |
+
print("\n=== Attention implementation selection ===")
|
| 59 |
+
from voice_clone import _select_attn_impl
|
| 60 |
+
|
| 61 |
+
impl = _select_attn_impl()
|
| 62 |
+
try:
|
| 63 |
+
import flash_attn # noqa: F401
|
| 64 |
+
check("flash-attn installed -> flash_attention_2",
|
| 65 |
+
impl == "flash_attention_2", f"got {impl}")
|
| 66 |
+
except ImportError:
|
| 67 |
+
check("flash-attn not installed -> sdpa fallback",
|
| 68 |
+
impl == "sdpa", f"got {impl}")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_generation_params():
|
| 72 |
+
print("\n=== Generation parameters ===")
|
| 73 |
+
from voice_clone import GENERATION_PARAMS
|
| 74 |
+
|
| 75 |
+
check("top_k reduced to 20",
|
| 76 |
+
GENERATION_PARAMS["top_k"] == 20,
|
| 77 |
+
f"got {GENERATION_PARAMS['top_k']}")
|
| 78 |
+
check("temperature reduced to 0.7",
|
| 79 |
+
GENERATION_PARAMS["temperature"] == 0.7,
|
| 80 |
+
f"got {GENERATION_PARAMS['temperature']}")
|
| 81 |
+
check("max_new_tokens capped at 1024",
|
| 82 |
+
GENERATION_PARAMS["max_new_tokens"] == 1024,
|
| 83 |
+
f"got {GENERATION_PARAMS['max_new_tokens']}")
|
| 84 |
+
check("subtalker_top_k reduced to 20",
|
| 85 |
+
GENERATION_PARAMS["subtalker_top_k"] == 20,
|
| 86 |
+
f"got {GENERATION_PARAMS['subtalker_top_k']}")
|
| 87 |
+
check("subtalker_temperature reduced to 0.7",
|
| 88 |
+
GENERATION_PARAMS["subtalker_temperature"] == 0.7,
|
| 89 |
+
f"got {GENERATION_PARAMS['subtalker_temperature']}")
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_ref_audio_trimming():
|
| 93 |
+
print("\n=== Reference audio trimming ===")
|
| 94 |
+
from voice_clone import _trim_reference_audio, REF_AUDIO_MAX_SEC
|
| 95 |
+
|
| 96 |
+
# Create a 20-second test WAV
|
| 97 |
+
sr = 24000
|
| 98 |
+
long_audio = np.random.randn(int(20 * sr)).astype(np.float32) * 0.1
|
| 99 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
| 100 |
+
sf.write(f.name, long_audio, sr)
|
| 101 |
+
long_path = f.name
|
| 102 |
+
|
| 103 |
+
# Create a 4-second test WAV (under limit)
|
| 104 |
+
short_audio = np.random.randn(int(4 * sr)).astype(np.float32) * 0.1
|
| 105 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
| 106 |
+
sf.write(f.name, short_audio, sr)
|
| 107 |
+
short_path = f.name
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
# Long audio should be trimmed
|
| 111 |
+
trimmed = _trim_reference_audio(long_path)
|
| 112 |
+
check("Long audio (20s) is trimmed", trimmed != long_path)
|
| 113 |
+
if trimmed != long_path:
|
| 114 |
+
info = sf.info(trimmed)
|
| 115 |
+
check(f"Trimmed to <= {REF_AUDIO_MAX_SEC}s",
|
| 116 |
+
info.duration <= REF_AUDIO_MAX_SEC + 0.1,
|
| 117 |
+
f"got {info.duration:.1f}s")
|
| 118 |
+
os.unlink(trimmed)
|
| 119 |
+
|
| 120 |
+
# Short audio should NOT be trimmed
|
| 121 |
+
result = _trim_reference_audio(short_path)
|
| 122 |
+
check("Short audio (4s) is NOT trimmed", result == short_path)
|
| 123 |
+
finally:
|
| 124 |
+
os.unlink(long_path)
|
| 125 |
+
os.unlink(short_path)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def test_model_mode_env():
|
| 129 |
+
print("\n=== Model mode env var ===")
|
| 130 |
+
from voice_clone import get_model_mode, BASE_MODEL_ID, CUSTOM_VOICE_MODEL_ID
|
| 131 |
+
|
| 132 |
+
mode = get_model_mode()
|
| 133 |
+
check("Default mode is 'base'", mode == "base", f"got '{mode}'")
|
| 134 |
+
check("BASE_MODEL_ID is 1.7B", "1.7B" in BASE_MODEL_ID, BASE_MODEL_ID)
|
| 135 |
+
check("CUSTOM_VOICE_MODEL_ID is 0.6B", "0.6B" in CUSTOM_VOICE_MODEL_ID,
|
| 136 |
+
CUSTOM_VOICE_MODEL_ID)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_custom_voice_clone_blocked():
|
| 140 |
+
print("\n=== CustomVoice clone attempt blocked ===")
|
| 141 |
+
# Simulate QWEN_TTS_MODE=custom_voice by patching
|
| 142 |
+
import voice_clone
|
| 143 |
+
original = voice_clone._MODEL_MODE
|
| 144 |
+
voice_clone._MODEL_MODE = "custom_voice"
|
| 145 |
+
try:
|
| 146 |
+
voice_clone.create_voice_profile("dummy.wav")
|
| 147 |
+
check("Should have raised ValueError", False)
|
| 148 |
+
except ValueError as e:
|
| 149 |
+
check("create_voice_profile raises ValueError for custom_voice mode",
|
| 150 |
+
"Base model" in str(e), str(e))
|
| 151 |
+
except Exception as e:
|
| 152 |
+
check("Unexpected error type", False, str(e))
|
| 153 |
+
finally:
|
| 154 |
+
voice_clone._MODEL_MODE = original
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def test_tts_module_imports():
|
| 158 |
+
print("\n=== TTS module structure ===")
|
| 159 |
+
from tts import generate_audio_stream, split_into_chunks
|
| 160 |
+
import inspect
|
| 161 |
+
|
| 162 |
+
sig = inspect.signature(generate_audio_stream)
|
| 163 |
+
params = list(sig.parameters.keys())
|
| 164 |
+
check("generate_audio_stream has 'use_custom_voice' param",
|
| 165 |
+
"use_custom_voice" in params, f"params: {params}")
|
| 166 |
+
check("generate_audio_stream has 'custom_voice_speaker' param",
|
| 167 |
+
"custom_voice_speaker" in params, f"params: {params}")
|
| 168 |
+
check("generate_audio_stream has 'voice_profile_id' param",
|
| 169 |
+
"voice_profile_id" in params, f"params: {params}")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
print("=" * 60)
|
| 174 |
+
print("Latency Optimization Tests")
|
| 175 |
+
print("=" * 60)
|
| 176 |
+
|
| 177 |
+
test_global_matmul_precision()
|
| 178 |
+
test_dtype_selection()
|
| 179 |
+
test_attn_selection()
|
| 180 |
+
test_generation_params()
|
| 181 |
+
test_ref_audio_trimming()
|
| 182 |
+
test_model_mode_env()
|
| 183 |
+
test_custom_voice_clone_blocked()
|
| 184 |
+
test_tts_module_imports()
|
| 185 |
+
|
| 186 |
+
print("\n" + "=" * 60)
|
| 187 |
+
print(f"Results: {PASS} passed, {FAIL} failed")
|
| 188 |
+
print("=" * 60)
|
| 189 |
+
sys.exit(1 if FAIL > 0 else 0)
|
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
TTS module — unified interface for text-to-speech synthesis.
|
| 3 |
|
| 4 |
-
Supports
|
| 5 |
-
- Qwen3-TTS: voice-cloned synthesis using a cached voice profile
|
| 6 |
-
-
|
|
|
|
| 7 |
|
| 8 |
Usage:
|
| 9 |
chunks = split_into_chunks(text)
|
|
@@ -44,20 +45,27 @@ def generate_audio_stream(
|
|
| 44 |
chunks: list[str],
|
| 45 |
voice_profile_id: str | None = None,
|
| 46 |
voice_name: str = "F1",
|
|
|
|
|
|
|
| 47 |
):
|
| 48 |
"""
|
| 49 |
Generator: synthesizes chunks in a background thread (maxsize=2 buffer).
|
| 50 |
Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
"""
|
| 55 |
n = len(chunks)
|
| 56 |
chunk_q: queue.Queue = queue.Queue(maxsize=2)
|
| 57 |
|
| 58 |
if voice_profile_id:
|
| 59 |
_start_qwen_worker(chunks, voice_profile_id, chunk_q)
|
| 60 |
-
sample_rate = 24000
|
|
|
|
|
|
|
|
|
|
| 61 |
else:
|
| 62 |
sample_rate = _start_supertonic_worker(chunks, voice_name, chunk_q)
|
| 63 |
|
|
@@ -70,7 +78,7 @@ def generate_audio_stream(
|
|
| 70 |
|
| 71 |
|
| 72 |
def _start_qwen_worker(chunks, profile_id, chunk_q):
|
| 73 |
-
"""Background thread: synthesize chunks with Qwen3-TTS voice clone."""
|
| 74 |
def _worker():
|
| 75 |
from voice_clone import synthesize_cloned
|
| 76 |
for i, stmt in enumerate(chunks):
|
|
@@ -86,6 +94,23 @@ def _start_qwen_worker(chunks, profile_id, chunk_q):
|
|
| 86 |
threading.Thread(target=_worker, daemon=True).start()
|
| 87 |
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
def _start_supertonic_worker(chunks, voice_name, chunk_q):
|
| 90 |
"""Background thread: synthesize chunks with Supertonic (stock voice)."""
|
| 91 |
tts = _get_supertonic()
|
|
|
|
| 1 |
"""
|
| 2 |
TTS module — unified interface for text-to-speech synthesis.
|
| 3 |
|
| 4 |
+
Supports three backends:
|
| 5 |
+
- Qwen3-TTS Base (1.7B): voice-cloned synthesis using a cached voice profile
|
| 6 |
+
- Qwen3-TTS CustomVoice (0.6B): fast predefined speakers (no cloning, lower latency)
|
| 7 |
+
- Supertonic: fast stock voice fallback (ONNX, no cloning)
|
| 8 |
|
| 9 |
Usage:
|
| 10 |
chunks = split_into_chunks(text)
|
|
|
|
| 45 |
chunks: list[str],
|
| 46 |
voice_profile_id: str | None = None,
|
| 47 |
voice_name: str = "F1",
|
| 48 |
+
use_custom_voice: bool = False,
|
| 49 |
+
custom_voice_speaker: str = "Chelsie",
|
| 50 |
):
|
| 51 |
"""
|
| 52 |
Generator: synthesizes chunks in a background thread (maxsize=2 buffer).
|
| 53 |
Yields (sample_rate, wav_array, chunk_idx, total_chunks, error_msg).
|
| 54 |
|
| 55 |
+
Backend selection:
|
| 56 |
+
1. voice_profile_id provided → Qwen3-TTS Base (1.7B) with cloned voice
|
| 57 |
+
2. use_custom_voice=True → Qwen3-TTS CustomVoice (0.6B) with predefined speaker
|
| 58 |
+
3. Otherwise → Supertonic with given voice_name
|
| 59 |
"""
|
| 60 |
n = len(chunks)
|
| 61 |
chunk_q: queue.Queue = queue.Queue(maxsize=2)
|
| 62 |
|
| 63 |
if voice_profile_id:
|
| 64 |
_start_qwen_worker(chunks, voice_profile_id, chunk_q)
|
| 65 |
+
sample_rate = 24000
|
| 66 |
+
elif use_custom_voice:
|
| 67 |
+
_start_custom_voice_worker(chunks, custom_voice_speaker, chunk_q)
|
| 68 |
+
sample_rate = 24000
|
| 69 |
else:
|
| 70 |
sample_rate = _start_supertonic_worker(chunks, voice_name, chunk_q)
|
| 71 |
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
def _start_qwen_worker(chunks, profile_id, chunk_q):
|
| 81 |
+
"""Background thread: synthesize chunks with Qwen3-TTS voice clone (Base 1.7B)."""
|
| 82 |
def _worker():
|
| 83 |
from voice_clone import synthesize_cloned
|
| 84 |
for i, stmt in enumerate(chunks):
|
|
|
|
| 94 |
threading.Thread(target=_worker, daemon=True).start()
|
| 95 |
|
| 96 |
|
| 97 |
+
def _start_custom_voice_worker(chunks, speaker, chunk_q):
|
| 98 |
+
"""Background thread: synthesize chunks with Qwen3-TTS CustomVoice (0.6B)."""
|
| 99 |
+
def _worker():
|
| 100 |
+
from voice_clone import synthesize_custom_voice
|
| 101 |
+
for i, stmt in enumerate(chunks):
|
| 102 |
+
try:
|
| 103 |
+
wav, _sr = synthesize_custom_voice(stmt, speaker=speaker)
|
| 104 |
+
chunk_q.put((i, wav, None))
|
| 105 |
+
except Exception as exc:
|
| 106 |
+
logger.exception("CustomVoice synthesis failed on chunk %d", i)
|
| 107 |
+
chunk_q.put((i, None, str(exc)))
|
| 108 |
+
return
|
| 109 |
+
chunk_q.put(_SENTINEL)
|
| 110 |
+
|
| 111 |
+
threading.Thread(target=_worker, daemon=True).start()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
def _start_supertonic_worker(chunks, voice_name, chunk_q):
|
| 115 |
"""Background thread: synthesize chunks with Supertonic (stock voice)."""
|
| 116 |
tts = _get_supertonic()
|
|
@@ -1,57 +1,189 @@
|
|
| 1 |
"""
|
| 2 |
Voice cloning module — wraps Qwen3-TTS for zero-shot voice cloning.
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
Usage:
|
| 5 |
profile_id = create_voice_profile(ref_audio_path)
|
| 6 |
wav, sr = synthesize_cloned(text, profile_id)
|
| 7 |
"""
|
| 8 |
import logging
|
|
|
|
| 9 |
import uuid
|
| 10 |
import threading
|
| 11 |
|
| 12 |
import numpy as np
|
|
|
|
| 13 |
import torch
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# Server-side cache: { profile_id -> VoiceClonePromptItem list }
|
|
|
|
| 18 |
_PROFILE_CACHE: dict[str, list] = {}
|
| 19 |
_cache_lock = threading.Lock()
|
| 20 |
|
| 21 |
_qwen_tts_model = None
|
| 22 |
_model_lock = threading.Lock()
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
def get_qwen_tts():
|
| 28 |
-
"""Lazy-load Qwen3-TTS model. Thread-safe
|
| 29 |
global _qwen_tts_model
|
| 30 |
if _qwen_tts_model is None:
|
| 31 |
with _model_lock:
|
| 32 |
if _qwen_tts_model is None:
|
| 33 |
from qwen_tts import Qwen3TTSModel
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
_qwen_tts_model = Qwen3TTSModel.from_pretrained(
|
| 37 |
-
|
| 38 |
device_map="cuda" if torch.cuda.is_available() else "cpu",
|
| 39 |
-
torch_dtype=
|
|
|
|
| 40 |
)
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
return _qwen_tts_model
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
def create_voice_profile(ref_audio_path: str) -> str:
|
| 46 |
"""
|
| 47 |
Extract speaker embedding from reference audio and cache it.
|
| 48 |
Returns a profile_id string for later synthesis.
|
|
|
|
|
|
|
|
|
|
| 49 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
model = get_qwen_tts()
|
| 51 |
|
| 52 |
logger.info("Creating voice profile from %s...", ref_audio_path)
|
| 53 |
prompt_items = model.create_voice_clone_prompt(
|
| 54 |
-
ref_audio=
|
| 55 |
x_vector_only_mode=True,
|
| 56 |
)
|
| 57 |
|
|
@@ -79,6 +211,30 @@ def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
|
|
| 79 |
text=text,
|
| 80 |
language="english",
|
| 81 |
voice_clone_prompt=prompt_items,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
)
|
| 83 |
|
| 84 |
wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
|
|
@@ -99,3 +255,8 @@ def has_profile(profile_id: str | None) -> bool:
|
|
| 99 |
return False
|
| 100 |
with _cache_lock:
|
| 101 |
return profile_id in _PROFILE_CACHE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
Voice cloning module — wraps Qwen3-TTS for zero-shot voice cloning.
|
| 3 |
|
| 4 |
+
Supports two backends:
|
| 5 |
+
- Base 1.7B: zero-shot voice cloning from reference audio
|
| 6 |
+
- CustomVoice 0.6B: fast predefined speakers (no cloning, lower latency)
|
| 7 |
+
|
| 8 |
+
Latency optimizations applied:
|
| 9 |
+
- bfloat16 / float16 precision (auto-detected per GPU arch)
|
| 10 |
+
- FlashAttention-2 when available
|
| 11 |
+
- Streaming mode (non_streaming_mode=False)
|
| 12 |
+
- Reduced sampling params (top_k=20, temperature=0.7)
|
| 13 |
+
- Capped max_new_tokens=1024
|
| 14 |
+
- torch.set_float32_matmul_precision('high')
|
| 15 |
+
- Reference audio trimmed to 3-5s for faster embedding extraction
|
| 16 |
+
|
| 17 |
Usage:
|
| 18 |
profile_id = create_voice_profile(ref_audio_path)
|
| 19 |
wav, sr = synthesize_cloned(text, profile_id)
|
| 20 |
"""
|
| 21 |
import logging
|
| 22 |
+
import os
|
| 23 |
import uuid
|
| 24 |
import threading
|
| 25 |
|
| 26 |
import numpy as np
|
| 27 |
+
import soundfile as sf
|
| 28 |
import torch
|
| 29 |
|
| 30 |
logger = logging.getLogger(__name__)
|
| 31 |
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Global PyTorch optimizations
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
torch.set_float32_matmul_precision("high")
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Model configuration
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# Base model for zero-shot voice cloning (1.7B)
|
| 41 |
+
BASE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
| 42 |
+
# CustomVoice model for fast predefined speakers (0.6B, ~3x faster)
|
| 43 |
+
CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
|
| 44 |
+
|
| 45 |
+
# Choose model via env var: "base" (default) or "custom_voice"
|
| 46 |
+
_MODEL_MODE = os.environ.get("QWEN_TTS_MODE", "base").lower()
|
| 47 |
+
|
| 48 |
+
# Optimized generation parameters (reduced from defaults: top_k=50, temp=0.9, max=2048)
|
| 49 |
+
GENERATION_PARAMS = dict(
|
| 50 |
+
top_k=20,
|
| 51 |
+
temperature=0.7,
|
| 52 |
+
subtalker_top_k=20,
|
| 53 |
+
subtalker_temperature=0.7,
|
| 54 |
+
max_new_tokens=1024,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
# Reference audio limits (seconds) — 3-5s is optimal for Qwen3-TTS
|
| 58 |
+
REF_AUDIO_MIN_SEC = 3.0
|
| 59 |
+
REF_AUDIO_MAX_SEC = 10.0
|
| 60 |
+
REF_AUDIO_TARGET_SR = 24000
|
| 61 |
+
|
| 62 |
+
# ---------------------------------------------------------------------------
|
| 63 |
# Server-side cache: { profile_id -> VoiceClonePromptItem list }
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
_PROFILE_CACHE: dict[str, list] = {}
|
| 66 |
_cache_lock = threading.Lock()
|
| 67 |
|
| 68 |
_qwen_tts_model = None
|
| 69 |
_model_lock = threading.Lock()
|
| 70 |
|
| 71 |
+
|
| 72 |
+
def _select_dtype() -> torch.dtype:
|
| 73 |
+
"""Pick optimal dtype based on GPU architecture."""
|
| 74 |
+
if not torch.cuda.is_available():
|
| 75 |
+
return torch.float32
|
| 76 |
+
cap = torch.cuda.get_device_capability()
|
| 77 |
+
# bfloat16 requires compute capability >= 8.0 (Ampere+)
|
| 78 |
+
if cap[0] >= 8:
|
| 79 |
+
return torch.bfloat16
|
| 80 |
+
return torch.float16
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _select_attn_impl() -> str:
|
| 84 |
+
"""Use FlashAttention-2 if available, else default."""
|
| 85 |
+
try:
|
| 86 |
+
import flash_attn # noqa: F401
|
| 87 |
+
return "flash_attention_2"
|
| 88 |
+
except ImportError:
|
| 89 |
+
logger.info("flash-attn not installed — using default attention.")
|
| 90 |
+
return "sdpa"
|
| 91 |
|
| 92 |
|
| 93 |
def get_qwen_tts():
|
| 94 |
+
"""Lazy-load Qwen3-TTS model with latency optimizations. Thread-safe."""
|
| 95 |
global _qwen_tts_model
|
| 96 |
if _qwen_tts_model is None:
|
| 97 |
with _model_lock:
|
| 98 |
if _qwen_tts_model is None:
|
| 99 |
from qwen_tts import Qwen3TTSModel
|
| 100 |
|
| 101 |
+
model_id = CUSTOM_VOICE_MODEL_ID if _MODEL_MODE == "custom_voice" else BASE_MODEL_ID
|
| 102 |
+
dtype = _select_dtype()
|
| 103 |
+
attn_impl = _select_attn_impl()
|
| 104 |
+
|
| 105 |
+
logger.info(
|
| 106 |
+
"Loading %s (dtype=%s, attn=%s)...",
|
| 107 |
+
model_id, dtype, attn_impl,
|
| 108 |
+
)
|
| 109 |
_qwen_tts_model = Qwen3TTSModel.from_pretrained(
|
| 110 |
+
model_id,
|
| 111 |
device_map="cuda" if torch.cuda.is_available() else "cpu",
|
| 112 |
+
torch_dtype=dtype,
|
| 113 |
+
attn_implementation=attn_impl,
|
| 114 |
)
|
| 115 |
+
|
| 116 |
+
# Attempt torch.compile on decoder/predictor for extra speed
|
| 117 |
+
_try_torch_compile(_qwen_tts_model)
|
| 118 |
+
|
| 119 |
+
logger.info("Qwen3-TTS loaded (%s mode).", _MODEL_MODE)
|
| 120 |
return _qwen_tts_model
|
| 121 |
|
| 122 |
|
| 123 |
+
def _try_torch_compile(wrapper):
|
| 124 |
+
"""Best-effort torch.compile on model submodules."""
|
| 125 |
+
if not hasattr(torch, "compile"):
|
| 126 |
+
return
|
| 127 |
+
model = wrapper.model
|
| 128 |
+
for name in ("decoder", "predictor", "speech_tokenizer"):
|
| 129 |
+
submod = getattr(model, name, None)
|
| 130 |
+
if submod is not None and isinstance(submod, torch.nn.Module):
|
| 131 |
+
try:
|
| 132 |
+
compiled = torch.compile(submod, mode="reduce-overhead")
|
| 133 |
+
setattr(model, name, compiled)
|
| 134 |
+
logger.info("torch.compile applied to model.%s", name)
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
logger.debug("torch.compile skipped for %s: %s", name, exc)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _trim_reference_audio(audio_path: str) -> str:
|
| 140 |
+
"""
|
| 141 |
+
Trim reference audio to REF_AUDIO_MAX_SEC seconds if longer.
|
| 142 |
+
Returns path to trimmed file (or original if already short enough).
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
info = sf.info(audio_path)
|
| 146 |
+
duration = info.duration
|
| 147 |
+
if duration <= REF_AUDIO_MAX_SEC:
|
| 148 |
+
return audio_path
|
| 149 |
+
|
| 150 |
+
logger.info(
|
| 151 |
+
"Reference audio %.1fs exceeds %.0fs limit — trimming.",
|
| 152 |
+
duration, REF_AUDIO_MAX_SEC,
|
| 153 |
+
)
|
| 154 |
+
data, sr = sf.read(audio_path)
|
| 155 |
+
max_samples = int(REF_AUDIO_MAX_SEC * sr)
|
| 156 |
+
trimmed = data[:max_samples]
|
| 157 |
+
|
| 158 |
+
trimmed_path = audio_path + ".trimmed.wav"
|
| 159 |
+
sf.write(trimmed_path, trimmed, sr)
|
| 160 |
+
return trimmed_path
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
logger.warning("Could not trim reference audio: %s", exc)
|
| 163 |
+
return audio_path
|
| 164 |
+
|
| 165 |
+
|
| 166 |
def create_voice_profile(ref_audio_path: str) -> str:
|
| 167 |
"""
|
| 168 |
Extract speaker embedding from reference audio and cache it.
|
| 169 |
Returns a profile_id string for later synthesis.
|
| 170 |
+
|
| 171 |
+
Reference audio is trimmed to 3-10s for optimal latency.
|
| 172 |
+
Only supported with the Base model (1.7B).
|
| 173 |
"""
|
| 174 |
+
if _MODEL_MODE == "custom_voice":
|
| 175 |
+
raise ValueError(
|
| 176 |
+
"Voice cloning requires the Base model (1.7B). "
|
| 177 |
+
"The 0.6B CustomVoice model only supports predefined speakers. "
|
| 178 |
+
"Set QWEN_TTS_MODE=base or remove the env var."
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
trimmed_path = _trim_reference_audio(ref_audio_path)
|
| 182 |
model = get_qwen_tts()
|
| 183 |
|
| 184 |
logger.info("Creating voice profile from %s...", ref_audio_path)
|
| 185 |
prompt_items = model.create_voice_clone_prompt(
|
| 186 |
+
ref_audio=trimmed_path,
|
| 187 |
x_vector_only_mode=True,
|
| 188 |
)
|
| 189 |
|
|
|
|
| 211 |
text=text,
|
| 212 |
language="english",
|
| 213 |
voice_clone_prompt=prompt_items,
|
| 214 |
+
non_streaming_mode=False,
|
| 215 |
+
**GENERATION_PARAMS,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
|
| 219 |
+
return wav, sample_rate
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def synthesize_custom_voice(
|
| 223 |
+
text: str, speaker: str = "Chelsie", language: str = "english"
|
| 224 |
+
) -> tuple[np.ndarray, int]:
|
| 225 |
+
"""
|
| 226 |
+
Synthesize text with the 0.6B CustomVoice model (predefined speakers).
|
| 227 |
+
Much faster than cloned synthesis but no voice cloning.
|
| 228 |
+
Returns (wav_array, sample_rate).
|
| 229 |
+
"""
|
| 230 |
+
model = get_qwen_tts()
|
| 231 |
+
|
| 232 |
+
audio_list, sample_rate = model.generate_custom_voice(
|
| 233 |
+
text=text,
|
| 234 |
+
speaker=speaker,
|
| 235 |
+
language=language,
|
| 236 |
+
non_streaming_mode=False,
|
| 237 |
+
**GENERATION_PARAMS,
|
| 238 |
)
|
| 239 |
|
| 240 |
wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
|
|
|
|
| 255 |
return False
|
| 256 |
with _cache_lock:
|
| 257 |
return profile_id in _PROFILE_CACHE
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def get_model_mode() -> str:
|
| 261 |
+
"""Return the current model mode ('base' or 'custom_voice')."""
|
| 262 |
+
return _MODEL_MODE
|