Huggingface_Hack / voice_clone.py
minhahwang's picture
fix: Play produces no sound — switch to streaming audio + system deps
373219a
Raw
History Blame Contribute Delete
15.4 kB
"""
Voice cloning module — wraps Qwen3-TTS for zero-shot voice cloning.
Supports two backends:
- Base 1.7B: zero-shot voice cloning from reference audio
- CustomVoice 0.6B: fast predefined speakers (no cloning, lower latency)
Voice profiles are:
- Cached in-memory for fast access during a session
- Persisted to Voice_Profile/ folder as .pt files for reuse across restarts
- Auto-loaded on startup if saved profiles exist
Latency optimizations applied:
- bfloat16 / float16 precision (auto-detected per GPU arch)
- FlashAttention-2 when available
- Streaming mode (non_streaming_mode=False)
- Reduced sampling params (top_k=20, temperature=0.7)
- Capped max_new_tokens=1024
- torch.set_float32_matmul_precision('high')
- Reference audio trimmed to 3-5s for faster embedding extraction
Usage:
profile_id = create_voice_profile(ref_audio_path, voice_name="Mom")
wav, sr = synthesize_cloned(text, profile_id)
"""
from __future__ import annotations
import json
import logging
import os
import uuid
import threading
from pathlib import Path
import numpy as np
import soundfile as sf
import torch
from runtime_config import GPU_INFERENCE_LOCK, VOICE_PROFILE_DIR
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Global PyTorch optimizations
# ---------------------------------------------------------------------------
torch.set_float32_matmul_precision("high")
# ---------------------------------------------------------------------------
# Model configuration
# ---------------------------------------------------------------------------
# Base model for zero-shot voice cloning (1.7B) — used for BOTH cloned and stock voice
BASE_MODEL_ID = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
# Stock voice reference audio (pre-generated "vivian" sample)
VIVIAN_REF_PATH = Path(__file__).parent / "assets" / "vivian_reference.wav"
# Profile ID for the built-in stock voice
STOCK_VOICE_PROFILE_ID = "__stock_vivian__"
# Optimized generation parameters (reduced from defaults: top_k=50, temp=0.9, max=2048)
GENERATION_PARAMS = dict(
top_k=20,
temperature=0.7,
subtalker_top_k=20,
subtalker_temperature=0.7,
max_new_tokens=1024,
)
# Reference audio limits (seconds) — 3-5s is optimal for Qwen3-TTS
REF_AUDIO_MIN_SEC = 3.0
REF_AUDIO_MAX_SEC = 10.0
REF_AUDIO_TARGET_SR = 24000
# ---------------------------------------------------------------------------
# Voice profile persistence
# ---------------------------------------------------------------------------
VOICE_PROFILE_DIR.mkdir(exist_ok=True)
# Default profile ID — used when no cloned voice exists
DEFAULT_PROFILE_ID = "__default__"
# ---------------------------------------------------------------------------
# Server-side cache: { profile_id -> VoiceClonePromptItem list }
# ---------------------------------------------------------------------------
_PROFILE_CACHE: dict[str, list] = {}
_cache_lock = threading.Lock()
_qwen_tts_model = None
_model_lock = threading.Lock()
def _select_dtype() -> torch.dtype:
"""Pick optimal dtype based on GPU architecture."""
if not torch.cuda.is_available():
return torch.float32
cap = torch.cuda.get_device_capability()
# bfloat16 requires compute capability >= 8.0 (Ampere+)
if cap[0] >= 8:
return torch.bfloat16
return torch.float16
def _select_attn_impl() -> str:
"""Use FlashAttention-2 if available, else SDPA (PyTorch native)."""
try:
import flash_attn # noqa: F401
return "flash_attention_2"
except ImportError:
logger.info("flash-attn not installed — using SDPA attention.")
return "sdpa"
def get_qwen_tts():
"""Lazy-load Qwen3-TTS Base 1.7B model for cloning. Thread-safe."""
global _qwen_tts_model
if _qwen_tts_model is None:
with _model_lock:
if _qwen_tts_model is None:
from qwen_tts import Qwen3TTSModel
model_id = BASE_MODEL_ID
device = "cuda" if torch.cuda.is_available() else "cpu"
logger.info("Loading %s on %s (cloning model)...", model_id, device)
attn_impl = _select_attn_impl()
_qwen_tts_model = Qwen3TTSModel.from_pretrained(
model_id,
device_map=device,
attn_implementation=attn_impl,
)
logger.info("Qwen3-TTS Base loaded on %s (attn=%s).", device, attn_impl)
return _qwen_tts_model
def _try_torch_compile(wrapper):
"""Best-effort torch.compile on model submodules. Disabled for stability."""
# torch.compile can cause CUDA asserts on some GPU architectures (T4/Turing)
# Disable for now in favor of stability
return
def _trim_reference_audio(audio_path: str) -> str:
"""
Trim reference audio to REF_AUDIO_MAX_SEC seconds if longer.
Returns path to trimmed file (or original if already short enough).
"""
try:
info = sf.info(audio_path)
duration = info.duration
if duration <= REF_AUDIO_MAX_SEC:
return audio_path
logger.info(
"Reference audio %.1fs exceeds %.0fs limit — trimming.",
duration, REF_AUDIO_MAX_SEC,
)
data, sr = sf.read(audio_path)
max_samples = int(REF_AUDIO_MAX_SEC * sr)
trimmed = data[:max_samples]
trimmed_path = audio_path + ".trimmed.wav"
sf.write(trimmed_path, trimmed, sr)
return trimmed_path
except Exception as exc:
logger.warning("Could not trim reference audio: %s", exc)
return audio_path
# ---------------------------------------------------------------------------
# Profile persistence (disk ↔ memory)
# ---------------------------------------------------------------------------
def save_profile_to_disk(profile_id: str, voice_name: str = "Cloned Voice") -> Path:
"""
Save a cached voice profile to Voice_Profile/ as a .pt file + metadata JSON.
Returns the path to the saved .pt file.
"""
with _cache_lock:
prompt_items = _PROFILE_CACHE.get(profile_id)
if prompt_items is None:
raise ValueError(f"Profile '{profile_id}' not found in cache.")
profile_dir = VOICE_PROFILE_DIR / profile_id
profile_dir.mkdir(exist_ok=True)
# Serialize VoiceClonePromptItem fields
serializable = []
for item in prompt_items:
serializable.append({
"ref_code": item.ref_code.cpu() if item.ref_code is not None else None,
"ref_spk_embedding": item.ref_spk_embedding.cpu(),
"x_vector_only_mode": item.x_vector_only_mode,
"icl_mode": item.icl_mode,
"ref_text": item.ref_text,
})
pt_path = profile_dir / "profile.pt"
torch.save(serializable, pt_path)
# Save metadata
meta = {"profile_id": profile_id, "voice_name": voice_name}
meta_path = profile_dir / "metadata.json"
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2)
logger.info("Voice profile '%s' (%s) saved to %s", profile_id, voice_name, profile_dir)
return pt_path
def load_profile_from_disk(profile_id: str) -> bool:
"""
Load a voice profile from Voice_Profile/<profile_id>/profile.pt into memory cache.
Returns True if loaded successfully, False otherwise.
"""
profile_dir = VOICE_PROFILE_DIR / profile_id
pt_path = profile_dir / "profile.pt"
if not pt_path.exists():
logger.warning("Profile file not found: %s", pt_path)
return False
try:
from qwen_tts.inference.qwen3_tts_model import VoiceClonePromptItem
raw_items = torch.load(pt_path, map_location="cpu", weights_only=False)
prompt_items = []
for item_dict in raw_items:
prompt_items.append(VoiceClonePromptItem(
ref_code=item_dict["ref_code"],
ref_spk_embedding=item_dict["ref_spk_embedding"],
x_vector_only_mode=item_dict["x_vector_only_mode"],
icl_mode=item_dict["icl_mode"],
ref_text=item_dict.get("ref_text"),
))
with _cache_lock:
_PROFILE_CACHE[profile_id] = prompt_items
logger.info("Voice profile '%s' loaded from disk.", profile_id)
return True
except Exception as exc:
logger.exception("Failed to load profile '%s': %s", profile_id, exc)
return False
def list_saved_profiles() -> list[dict]:
"""
List all saved voice profiles from Voice_Profile/ directory.
Returns list of {profile_id, voice_name, path} dicts, newest first.
"""
profiles = []
if not VOICE_PROFILE_DIR.exists():
return profiles
for entry in VOICE_PROFILE_DIR.iterdir():
if not entry.is_dir():
continue
pt_path = entry / "profile.pt"
meta_path = entry / "metadata.json"
if not pt_path.exists():
continue
voice_name = "Cloned Voice"
if meta_path.exists():
try:
with open(meta_path, encoding="utf-8") as f:
meta = json.load(f)
voice_name = meta.get("voice_name", voice_name)
except Exception:
pass
profiles.append({
"profile_id": entry.name,
"voice_name": voice_name,
"path": str(entry),
"mtime": pt_path.stat().st_mtime,
})
# Newest first
profiles.sort(key=lambda p: p["mtime"], reverse=True)
return profiles
def load_default_profile() -> str | None:
"""
Load the most recently saved voice profile from Voice_Profile/ into memory.
Returns the profile_id if found, None if no saved profiles exist.
This is called on app startup to restore the last cloned voice.
"""
saved = list_saved_profiles()
if not saved:
logger.info("No saved voice profiles found — using stock voice as default.")
return None
newest = saved[0]
profile_id = newest["profile_id"]
if load_profile_from_disk(profile_id):
logger.info(
"Default voice profile loaded: '%s' (%s)",
profile_id, newest["voice_name"],
)
return profile_id
return None
# ---------------------------------------------------------------------------
# Core API
# ---------------------------------------------------------------------------
def create_voice_profile(ref_audio_path: str, voice_name: str = "Cloned Voice", profile_id_override: str | None = None) -> str:
"""
Extract speaker embedding from reference audio, cache it, and save to disk.
Returns a profile_id string for later synthesis.
Reference audio is trimmed to 3-10s for optimal latency.
"""
trimmed_path = _trim_reference_audio(ref_audio_path)
model = get_qwen_tts()
logger.info("Creating voice profile from %s...", ref_audio_path)
with GPU_INFERENCE_LOCK:
prompt_items = model.create_voice_clone_prompt(
ref_audio=trimmed_path,
x_vector_only_mode=True,
)
profile_id = profile_id_override or uuid.uuid4().hex[:12]
with _cache_lock:
_PROFILE_CACHE[profile_id] = prompt_items
# Persist to disk
try:
save_profile_to_disk(profile_id, voice_name=voice_name)
except Exception as exc:
logger.warning("Failed to save profile to disk: %s", exc)
logger.info("Voice profile %s created and saved.", profile_id)
return profile_id
def synthesize_cloned(text: str, profile_id: str) -> tuple[np.ndarray, int]:
"""
Synthesize text using a cached voice profile.
Returns (wav_array, sample_rate).
"""
with _cache_lock:
prompt_items = _PROFILE_CACHE.get(profile_id)
if prompt_items is None:
# Try loading from disk if not in memory
if load_profile_from_disk(profile_id):
with _cache_lock:
prompt_items = _PROFILE_CACHE.get(profile_id)
if prompt_items is None:
raise ValueError(f"Voice profile '{profile_id}' not found. Record voice first.")
model = get_qwen_tts()
with GPU_INFERENCE_LOCK:
audio_list, sample_rate = model.generate_voice_clone(
text=text,
language="english",
voice_clone_prompt=prompt_items,
non_streaming_mode=False,
**GENERATION_PARAMS,
)
wav = np.concatenate(audio_list) if audio_list else np.zeros(0, dtype=np.float32)
return wav, sample_rate
def _ensure_stock_voice_profile():
"""Ensure the stock vivian voice profile is loaded (uses Base 1.7B with reference audio)."""
with _cache_lock:
if STOCK_VOICE_PROFILE_ID in _PROFILE_CACHE:
return
# Create profile from vivian reference audio
if not VIVIAN_REF_PATH.exists():
logger.error("Stock voice reference not found at %s", VIVIAN_REF_PATH)
raise FileNotFoundError(f"Stock voice reference not found: {VIVIAN_REF_PATH}")
create_voice_profile(str(VIVIAN_REF_PATH), voice_name="Vivian (Stock)", profile_id_override=STOCK_VOICE_PROFILE_ID)
logger.info("Stock vivian voice profile created from reference audio.")
def synthesize_custom_voice_streaming(
text: str, speaker: str = "vivian", language: str = "english"
):
"""
Synthesize text with the Base 1.7B model using stock vivian reference.
Yields (wav_segment, sample_rate) tuples.
"""
_ensure_stock_voice_profile()
with _cache_lock:
prompt_items = _PROFILE_CACHE.get(STOCK_VOICE_PROFILE_ID)
if prompt_items is None:
raise RuntimeError(
"Stock voice profile could not be created. "
"Check that assets/vivian_reference.wav exists and the TTS model loaded correctly."
)
model = get_qwen_tts()
with GPU_INFERENCE_LOCK:
audio_list, sample_rate = model.generate_voice_clone(
text=text,
language=language,
voice_clone_prompt=prompt_items,
non_streaming_mode=False,
**GENERATION_PARAMS,
)
for segment in audio_list:
if segment is not None and len(segment) > 0:
yield segment, sample_rate
def synthesize_custom_voice(
text: str, speaker: str = "vivian", language: str = "english"
) -> tuple[np.ndarray, int]:
"""
Synthesize text with the Base 1.7B model using stock vivian reference.
Returns (wav_array, sample_rate).
"""
segments = list(synthesize_custom_voice_streaming(text, speaker, language))
if segments:
wav = np.concatenate([s for s, _ in segments])
sr = segments[0][1]
return wav, sr
# Fallback: empty audio
return np.zeros(0, dtype=np.float32), 24000
def synthesize_cloned_preview(profile_id: str) -> tuple[np.ndarray, int]:
"""Short preview sentence to verify the clone sounds right."""
return synthesize_cloned(
"Hello! I'm ready to read a bedtime story for you tonight.",
profile_id,
)
def has_profile(profile_id: str | None) -> bool:
"""Check if a voice profile exists in cache or on disk."""
if not profile_id:
return False
with _cache_lock:
if profile_id in _PROFILE_CACHE:
return True
# Check disk
pt_path = VOICE_PROFILE_DIR / profile_id / "profile.pt"
return pt_path.exists()