Files
voice_copy/engine.py
voice_copy bot a34f16123f Fix clone API: drop no-op instruct, serialize generation, clearer errors
- Remove the 'instruct' style field: Qwen3-TTS Base generate_voice_clone
  silently ignores it, so the UI option did nothing.
- Serialize generations with a lock so concurrent requests queue instead
  of doubling RAM/VRAM on one shared model.
- Validate empty text/ref_text ourselves so the API returns the Korean
  400 messages instead of FastAPI's generic 422.
- UI: explain that mic recording needs https/localhost instead of a raw
  TypeError on plain-http LAN access.
- README: document mic/https limit, queueing, and measured CPU speed.

Verified on CPU (Python 3.12, torch 2.14 CPU, 0.6B Base): wav and mp3
references, language ko/auto, two concurrent requests -> all 200 and
Whisper transcripts match the requested text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:11:39 +09:00

153 lines
4.8 KiB
Python

"""
Zero-shot voice cloning engine.
Extracted and simplified from jamiepine/voicebox's Qwen3-TTS "Base" backend
(backend/backends/pytorch_backend.py). Only the voice-cloning path is kept:
give a short reference clip + its transcript, then synthesize any text in
that cloned voice.
Model: Qwen/Qwen3-TTS-12Hz-{1.7B,0.6B}-Base via the `qwen-tts` package.
Weights download automatically from HuggingFace on first use.
"""
from __future__ import annotations
import logging
import threading
from typing import Optional, Tuple
import numpy as np
logger = logging.getLogger("voice_copy.engine")
# Qwen3-TTS speaks these languages. "auto" lets the model detect it.
LANGUAGE_CODE_TO_NAME = {
"auto": "auto",
"ko": "korean",
"en": "english",
"zh": "chinese",
"ja": "japanese",
"de": "german",
"fr": "french",
"ru": "russian",
"pt": "portuguese",
"es": "spanish",
"it": "italian",
}
HF_MODEL_MAP = {
"1.7B": "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
"0.6B": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
}
def pick_device() -> str:
"""Best available torch device: CUDA GPU if present, else CPU."""
try:
import torch
except Exception:
return "cpu"
if torch.cuda.is_available():
return "cuda"
# Apple Silicon
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
return "mps"
return "cpu"
class VoiceCloner:
"""Lazily-loaded Qwen3-TTS voice-clone wrapper.
The heavy model is only loaded on the first `clone()` call, so the web
server can start instantly and the (multi-GB) download happens on demand.
"""
def __init__(self, model_size: str = "1.7B", device: Optional[str] = None):
if model_size not in HF_MODEL_MAP:
raise ValueError(f"Unknown model size {model_size!r}; choose one of {list(HF_MODEL_MAP)}")
self.model = None
self.model_size = model_size
self.device = device or pick_device()
self._lock = threading.Lock()
# One generation at a time: the model is shared and a second concurrent
# run would just double VRAM/RAM use (and can OOM a small GPU).
self._gen_lock = threading.Lock()
@property
def loaded(self) -> bool:
return self.model is not None
def load(self) -> None:
"""Load the model (thread-safe, idempotent)."""
if self.model is not None:
return
with self._lock:
if self.model is not None:
return
import torch
from qwen_tts import Qwen3TTSModel
model_path = HF_MODEL_MAP[self.model_size]
logger.info("Loading %s on %s (first run downloads weights)...", model_path, self.device)
if self.device == "cpu":
self.model = Qwen3TTSModel.from_pretrained(
model_path,
torch_dtype=torch.float32,
low_cpu_mem_usage=False,
)
else:
self.model = Qwen3TTSModel.from_pretrained(
model_path,
device_map=self.device,
torch_dtype=torch.bfloat16,
)
logger.info("Model loaded.")
def clone(
self,
ref_audio_path: str,
ref_text: str,
text: str,
language: str = "auto",
seed: Optional[int] = None,
) -> Tuple[np.ndarray, int]:
"""Synthesize `text` in the voice of `ref_audio_path`.
Args:
ref_audio_path: Path to a short reference clip (a few seconds is enough).
ref_text: Exact transcript of the reference clip.
text: The text to speak in the cloned voice.
language: Language code from LANGUAGE_CODE_TO_NAME ("auto" to detect).
seed: Optional random seed for reproducible output.
Returns:
(audio_float32_mono, sample_rate)
"""
self.load()
import torch
with self._gen_lock:
if seed is not None:
torch.manual_seed(seed)
if self.device == "cuda":
torch.cuda.manual_seed_all(seed)
# Build the speaker prompt from the reference clip + its transcript.
voice_prompt = self.model.create_voice_clone_prompt(
ref_audio=str(ref_audio_path),
ref_text=ref_text,
x_vector_only_mode=False,
)
wavs, sample_rate = self.model.generate_voice_clone(
text=text,
voice_clone_prompt=voice_prompt,
language=LANGUAGE_CODE_TO_NAME.get(language, "auto"),
)
audio = wavs[0]
if isinstance(audio, torch.Tensor):
audio = audio.squeeze().cpu().numpy()
return np.asarray(audio, dtype=np.float32), int(sample_rate)