Spaces:
Paused
Paused
Commit ·
af03300
1
Parent(s): b714046
refactor: consolidate to Base 1.7B only, remove CustomVoice
Browse files- Remove get_custom_voice_model() and _MODEL_MODE checks
- Add profile_id_override param to create_voice_profile()
- Update Profile page: 2 models, Base 1.7B as single TTS engine
- Upload vivian_reference.wav to HF Space
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- app.py +7 -39
- test_qa_audio_check.wav +3 -0
- voice_clone.py +40 -62
app.py
CHANGED
|
@@ -409,9 +409,9 @@ else:
|
|
| 409 |
print("[MomsVoice] No saved voice profile — using stock voice as default.")
|
| 410 |
|
| 411 |
# Preload models at startup for fast response
|
| 412 |
-
print("[MomsVoice] Preloading Qwen3-TTS
|
| 413 |
-
from voice_clone import
|
| 414 |
-
|
| 415 |
print("[MomsVoice] TTS model ready.")
|
| 416 |
|
| 417 |
print("[MomsVoice] Preloading LFM2.5-Audio-1.5B Q&A model...")
|
|
@@ -419,38 +419,6 @@ from inference_lfm import get_lfm_model
|
|
| 419 |
get_lfm_model()
|
| 420 |
print("[MomsVoice] LFM Q&A model ready. All models preloaded.")
|
| 421 |
|
| 422 |
-
# Pre-generate first paragraph audio for all stories (stock voice) for instant start
|
| 423 |
-
import threading
|
| 424 |
-
|
| 425 |
-
def _warmup_first_paragraphs():
|
| 426 |
-
"""Background: pre-generate first 5 paragraphs of each story with stock voice to disk cache."""
|
| 427 |
-
import logging as _log
|
| 428 |
-
from tts import _get_cached_audio, _save_cached_audio, _synthesize_single
|
| 429 |
-
_logger = _log.getLogger("warmup")
|
| 430 |
-
WARMUP_PARAS = 5
|
| 431 |
-
for book in mock_books:
|
| 432 |
-
try:
|
| 433 |
-
paras = load_paragraphs(book["story_path"])
|
| 434 |
-
if not paras:
|
| 435 |
-
continue
|
| 436 |
-
# Pre-generate first 5 paragraphs
|
| 437 |
-
target_paras = paras[:WARMUP_PARAS]
|
| 438 |
-
all_chunks = split_into_chunks("\n\n".join(target_paras))
|
| 439 |
-
generated = 0
|
| 440 |
-
for chunk in all_chunks:
|
| 441 |
-
if _get_cached_audio(chunk, None) is not None:
|
| 442 |
-
continue # Already cached
|
| 443 |
-
wav, sr = _synthesize_single(chunk, None)
|
| 444 |
-
_save_cached_audio(chunk, None, wav, sr)
|
| 445 |
-
generated += 1
|
| 446 |
-
_logger.info("Warmed: %s (%d chunks, %d new)", book["title"], len(all_chunks), generated)
|
| 447 |
-
except Exception as e:
|
| 448 |
-
_logger.warning("Warmup failed for %s: %s", book.get("title", "?"), e)
|
| 449 |
-
_logger.info("All stories warmed (first %d paragraphs each).", WARMUP_PARAS)
|
| 450 |
-
|
| 451 |
-
threading.Thread(target=_warmup_first_paragraphs, daemon=True).start()
|
| 452 |
-
print("[MomsVoice] Background warmup started: pre-generating first 5 paragraphs for all stories.")
|
| 453 |
-
|
| 454 |
|
| 455 |
# Gradio Application Core setup
|
| 456 |
with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
@@ -661,12 +629,12 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
| 661 |
</div>
|
| 662 |
<div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
|
| 663 |
<span style="font-size: 18px; display: block;">🧠</span>
|
| 664 |
-
<strong style="font-size: 15px; display: block; color: #1c1c19;">
|
| 665 |
<span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Models</span>
|
| 666 |
</div>
|
| 667 |
<div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
|
| 668 |
<span style="font-size: 18px; display: block;">🎙️</span>
|
| 669 |
-
<strong style="font-size: 15px; display: block; color: #1c1c19;">
|
| 670 |
<span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">TTS Engine</span>
|
| 671 |
</div>
|
| 672 |
</div>
|
|
@@ -681,8 +649,8 @@ with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
| 681 |
gr.HTML("""
|
| 682 |
<div style="padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
|
| 683 |
<div style="font-size: 11.5px; color: #1c1c19; line-height: 2;">
|
| 684 |
-
🎙️ <strong>
|
| 685 |
-
🗣️ <strong>Stock
|
| 686 |
🧠 <strong>Q&A (end-to-end):</strong> LFM2.5-Audio-1.5B (LiquidAI)
|
| 687 |
</div>
|
| 688 |
</div>
|
|
|
|
| 409 |
print("[MomsVoice] No saved voice profile — using stock voice as default.")
|
| 410 |
|
| 411 |
# Preload models at startup for fast response
|
| 412 |
+
print("[MomsVoice] Preloading Qwen3-TTS 1.7B Base model (cloning + stock voice)...")
|
| 413 |
+
from voice_clone import get_qwen_tts
|
| 414 |
+
get_qwen_tts()
|
| 415 |
print("[MomsVoice] TTS model ready.")
|
| 416 |
|
| 417 |
print("[MomsVoice] Preloading LFM2.5-Audio-1.5B Q&A model...")
|
|
|
|
| 419 |
get_lfm_model()
|
| 420 |
print("[MomsVoice] LFM Q&A model ready. All models preloaded.")
|
| 421 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
# Gradio Application Core setup
|
| 424 |
with gr.Blocks(title="MomsVoice", css=css_code) as demo:
|
|
|
|
| 629 |
</div>
|
| 630 |
<div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
|
| 631 |
<span style="font-size: 18px; display: block;">🧠</span>
|
| 632 |
+
<strong style="font-size: 15px; display: block; color: #1c1c19;">2</strong>
|
| 633 |
<span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">Models</span>
|
| 634 |
</div>
|
| 635 |
<div style="background: white; border: 1px solid #ebdccb; border-radius: 12px; padding: 10px;">
|
| 636 |
<span style="font-size: 18px; display: block;">🎙️</span>
|
| 637 |
+
<strong style="font-size: 15px; display: block; color: #1c1c19;">Base 1.7B</strong>
|
| 638 |
<span style="font-size: 9px; text-transform: uppercase; color: #6f6257;">TTS Engine</span>
|
| 639 |
</div>
|
| 640 |
</div>
|
|
|
|
| 649 |
gr.HTML("""
|
| 650 |
<div style="padding: 12px; background: white; border-radius: 12px; border: 1px solid #ebdccb;">
|
| 651 |
<div style="font-size: 11.5px; color: #1c1c19; line-height: 2;">
|
| 652 |
+
🎙️ <strong>TTS + Cloning:</strong> Qwen3-TTS-1.7B-Base (single model)<br>
|
| 653 |
+
🗣️ <strong>Stock Voice:</strong> Vivian (via reference audio)<br>
|
| 654 |
🧠 <strong>Q&A (end-to-end):</strong> LFM2.5-Audio-1.5B (LiquidAI)
|
| 655 |
</div>
|
| 656 |
</div>
|
test_qa_audio_check.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b0f03ec6d9134fcdf7114ed7d1795d35612ff20b4618d3701f95443d15fdfe7e
|
| 3 |
+
size 180524
|
voice_clone.py
CHANGED
|
@@ -46,13 +46,14 @@ torch.set_float32_matmul_precision("high")
|
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# Model configuration
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
-
# Base model for zero-shot voice cloning (1.7B)
|
| 50 |
BASE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
| 51 |
-
# CustomVoice model for fast predefined speakers (0.6B, ~3x faster)
|
| 52 |
-
CUSTOM_VOICE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice"
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
# Optimized generation parameters (reduced from defaults: top_k=50, temp=0.9, max=2048)
|
| 58 |
GENERATION_PARAMS = dict(
|
|
@@ -83,9 +84,7 @@ _PROFILE_CACHE: dict[str, list] = {}
|
|
| 83 |
_cache_lock = threading.Lock()
|
| 84 |
|
| 85 |
_qwen_tts_model = None
|
| 86 |
-
_custom_voice_model = None
|
| 87 |
_model_lock = threading.Lock()
|
| 88 |
-
_custom_model_lock = threading.Lock()
|
| 89 |
|
| 90 |
|
| 91 |
def _select_dtype() -> torch.dtype:
|
|
@@ -131,27 +130,6 @@ def get_qwen_tts():
|
|
| 131 |
return _qwen_tts_model
|
| 132 |
|
| 133 |
|
| 134 |
-
def get_custom_voice_model():
|
| 135 |
-
"""Lazy-load the 0.6B CustomVoice model for stock voice. Thread-safe."""
|
| 136 |
-
global _custom_voice_model
|
| 137 |
-
if _custom_voice_model is None:
|
| 138 |
-
with _custom_model_lock:
|
| 139 |
-
if _custom_voice_model is None:
|
| 140 |
-
from qwen_tts import Qwen3TTSModel
|
| 141 |
-
|
| 142 |
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 143 |
-
|
| 144 |
-
logger.info("Loading CustomVoice 0.6B on %s...", device)
|
| 145 |
-
attn_impl = _select_attn_impl()
|
| 146 |
-
_custom_voice_model = Qwen3TTSModel.from_pretrained(
|
| 147 |
-
CUSTOM_VOICE_MODEL_ID,
|
| 148 |
-
device_map=device,
|
| 149 |
-
attn_implementation=attn_impl,
|
| 150 |
-
)
|
| 151 |
-
logger.info("CustomVoice 0.6B loaded (attn=%s).", attn_impl)
|
| 152 |
-
return _custom_voice_model
|
| 153 |
-
|
| 154 |
-
|
| 155 |
def _try_torch_compile(wrapper):
|
| 156 |
"""Best-effort torch.compile on model submodules. Disabled for stability."""
|
| 157 |
# torch.compile can cause CUDA asserts on some GPU architectures (T4/Turing)
|
|
@@ -328,21 +306,13 @@ def load_default_profile() -> str | None:
|
|
| 328 |
# Core API
|
| 329 |
# ---------------------------------------------------------------------------
|
| 330 |
|
| 331 |
-
def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice") -> str:
|
| 332 |
"""
|
| 333 |
Extract speaker embedding from reference audio, cache it, and save to disk.
|
| 334 |
Returns a profile_id string for later synthesis.
|
| 335 |
|
| 336 |
Reference audio is trimmed to 3-10s for optimal latency.
|
| 337 |
-
Only supported with the Base model (1.7B).
|
| 338 |
"""
|
| 339 |
-
if _MODEL_MODE == "custom_voice":
|
| 340 |
-
raise ValueError(
|
| 341 |
-
"Voice cloning requires the Base model (1.7B). "
|
| 342 |
-
"The 0.6B CustomVoice model only supports predefined speakers. "
|
| 343 |
-
"Set QWEN_TTS_MODE=base or remove the env var."
|
| 344 |
-
)
|
| 345 |
-
|
| 346 |
trimmed_path = _trim_reference_audio(ref_audio_path)
|
| 347 |
model = get_qwen_tts()
|
| 348 |
|
|
@@ -353,7 +323,7 @@ def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice")
|
|
| 353 |
x_vector_only_mode=True,
|
| 354 |
)
|
| 355 |
|
| 356 |
-
profile_id = uuid.uuid4().hex[:12]
|
| 357 |
with _cache_lock:
|
| 358 |
_PROFILE_CACHE[profile_id] = prompt_items
|
| 359 |
|
|
@@ -397,26 +367,44 @@ def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
|
|
| 397 |
return wav, sample_rate
|
| 398 |
|
| 399 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
def synthesize_custom_voice_streaming(
|
| 401 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 402 |
):
|
| 403 |
"""
|
| 404 |
-
Synthesize text with the
|
| 405 |
-
as they become available for lower latency.
|
| 406 |
Yields (wav_segment, sample_rate) tuples.
|
| 407 |
"""
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
|
|
|
| 410 |
with GPU_INFERENCE_LOCK:
|
| 411 |
-
audio_list, sample_rate = model.
|
| 412 |
text=text,
|
| 413 |
-
speaker=speaker,
|
| 414 |
language=language,
|
|
|
|
| 415 |
non_streaming_mode=False,
|
| 416 |
**GENERATION_PARAMS,
|
| 417 |
)
|
| 418 |
|
| 419 |
-
# Yield each segment as it was generated
|
| 420 |
for segment in audio_list:
|
| 421 |
if segment is not None and len(segment) > 0:
|
| 422 |
yield segment, sample_rate
|
|
@@ -426,23 +414,16 @@ def synthesize_custom_voice(
|
|
| 426 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 427 |
) -> tuple[np.ndarray, int]:
|
| 428 |
"""
|
| 429 |
-
Synthesize text with the
|
| 430 |
-
Much faster than cloned synthesis but no voice cloning.
|
| 431 |
Returns (wav_array, sample_rate).
|
| 432 |
"""
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
non_streaming_mode=False,
|
| 441 |
-
**GENERATION_PARAMS,
|
| 442 |
-
)
|
| 443 |
-
|
| 444 |
-
wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
|
| 445 |
-
return wav, sample_rate
|
| 446 |
|
| 447 |
|
| 448 |
def synthesize_cloned_preview(profile_id: str) -> tuple[np.ndarray, int]:
|
|
@@ -465,6 +446,3 @@ def has_profile(profile_id: str | None) -> bool:
|
|
| 465 |
return pt_path.exists()
|
| 466 |
|
| 467 |
|
| 468 |
-
def get_model_mode() -> str:
|
| 469 |
-
"""Return the current model mode ('base' or 'custom_voice')."""
|
| 470 |
-
return _MODEL_MODE
|
|
|
|
| 46 |
# ---------------------------------------------------------------------------
|
| 47 |
# Model configuration
|
| 48 |
# ---------------------------------------------------------------------------
|
| 49 |
+
# Base model for zero-shot voice cloning (1.7B) — used for BOTH cloned and stock voice
|
| 50 |
BASE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
|
|
|
|
|
|
|
| 51 |
|
| 52 |
+
# Stock voice reference audio (pre-generated "vivian" sample)
|
| 53 |
+
VIVIAN_REF_PATH = Path(__file__).parent / "assets" / "vivian_reference.wav"
|
| 54 |
+
|
| 55 |
+
# Profile ID for the built-in stock voice
|
| 56 |
+
STOCK_VOICE_PROFILE_ID = "__stock_vivian__"
|
| 57 |
|
| 58 |
# Optimized generation parameters (reduced from defaults: top_k=50, temp=0.9, max=2048)
|
| 59 |
GENERATION_PARAMS = dict(
|
|
|
|
| 84 |
_cache_lock = threading.Lock()
|
| 85 |
|
| 86 |
_qwen_tts_model = None
|
|
|
|
| 87 |
_model_lock = threading.Lock()
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def _select_dtype() -> torch.dtype:
|
|
|
|
| 130 |
return _qwen_tts_model
|
| 131 |
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
def _try_torch_compile(wrapper):
|
| 134 |
"""Best-effort torch.compile on model submodules. Disabled for stability."""
|
| 135 |
# torch.compile can cause CUDA asserts on some GPU architectures (T4/Turing)
|
|
|
|
| 306 |
# Core API
|
| 307 |
# ---------------------------------------------------------------------------
|
| 308 |
|
| 309 |
+
def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice", profile_id_override: str | None = None) -> str:
|
| 310 |
"""
|
| 311 |
Extract speaker embedding from reference audio, cache it, and save to disk.
|
| 312 |
Returns a profile_id string for later synthesis.
|
| 313 |
|
| 314 |
Reference audio is trimmed to 3-10s for optimal latency.
|
|
|
|
| 315 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
trimmed_path = _trim_reference_audio(ref_audio_path)
|
| 317 |
model = get_qwen_tts()
|
| 318 |
|
|
|
|
| 323 |
x_vector_only_mode=True,
|
| 324 |
)
|
| 325 |
|
| 326 |
+
profile_id = profile_id_override or uuid.uuid4().hex[:12]
|
| 327 |
with _cache_lock:
|
| 328 |
_PROFILE_CACHE[profile_id] = prompt_items
|
| 329 |
|
|
|
|
| 367 |
return wav, sample_rate
|
| 368 |
|
| 369 |
|
| 370 |
+
def _ensure_stock_voice_profile():
|
| 371 |
+
"""Ensure the stock vivian voice profile is loaded (uses Base 1.7B with reference audio)."""
|
| 372 |
+
with _cache_lock:
|
| 373 |
+
if STOCK_VOICE_PROFILE_ID in _PROFILE_CACHE:
|
| 374 |
+
return
|
| 375 |
+
# Create profile from vivian reference audio
|
| 376 |
+
if VIVIAN_REF_PATH.exists():
|
| 377 |
+
try:
|
| 378 |
+
create_voice_profile(str(VIVIAN_REF_PATH), voice_name="Vivian (Stock)", profile_id_override=STOCK_VOICE_PROFILE_ID)
|
| 379 |
+
logger.info("Stock vivian voice profile created from reference audio.")
|
| 380 |
+
except Exception as e:
|
| 381 |
+
logger.warning("Failed to create stock voice profile: %s", e)
|
| 382 |
+
|
| 383 |
+
|
| 384 |
def synthesize_custom_voice_streaming(
|
| 385 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 386 |
):
|
| 387 |
"""
|
| 388 |
+
Synthesize text with the Base 1.7B model using stock vivian reference.
|
|
|
|
| 389 |
Yields (wav_segment, sample_rate) tuples.
|
| 390 |
"""
|
| 391 |
+
_ensure_stock_voice_profile()
|
| 392 |
+
with _cache_lock:
|
| 393 |
+
prompt_items = _PROFILE_CACHE.get(STOCK_VOICE_PROFILE_ID)
|
| 394 |
+
if prompt_items is None:
|
| 395 |
+
logger.warning("Stock voice profile not available, synthesizing without clone.")
|
| 396 |
+
return
|
| 397 |
|
| 398 |
+
model = get_qwen_tts()
|
| 399 |
with GPU_INFERENCE_LOCK:
|
| 400 |
+
audio_list, sample_rate = model.generate_voice_clone(
|
| 401 |
text=text,
|
|
|
|
| 402 |
language=language,
|
| 403 |
+
voice_clone_prompt=prompt_items,
|
| 404 |
non_streaming_mode=False,
|
| 405 |
**GENERATION_PARAMS,
|
| 406 |
)
|
| 407 |
|
|
|
|
| 408 |
for segment in audio_list:
|
| 409 |
if segment is not None and len(segment) > 0:
|
| 410 |
yield segment, sample_rate
|
|
|
|
| 414 |
text: str, speaker: str = "vivian", language: str = "english"
|
| 415 |
) -> tuple[np.ndarray, int]:
|
| 416 |
"""
|
| 417 |
+
Synthesize text with the Base 1.7B model using stock vivian reference.
|
|
|
|
| 418 |
Returns (wav_array, sample_rate).
|
| 419 |
"""
|
| 420 |
+
segments = list(synthesize_custom_voice_streaming(text, speaker, language))
|
| 421 |
+
if segments:
|
| 422 |
+
wav = np.concatenate([s for s, _ in segments])
|
| 423 |
+
sr = segments[0][1]
|
| 424 |
+
return wav, sr
|
| 425 |
+
# Fallback: empty audio
|
| 426 |
+
return np.zeros(0, dtype=np.float32), 24000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
|
| 428 |
|
| 429 |
def synthesize_cloned_preview(profile_id: str) -> tuple[np.ndarray, int]:
|
|
|
|
| 446 |
return pt_path.exists()
|
| 447 |
|
| 448 |
|
|
|
|
|
|
|
|
|