ground-zero / src /tts /voice_cloner.py
jefffffff9
Add multi-user speaker profiles, collective voice, and mode toggle
49910a9
Raw
History Blame Contribute Delete
8.38 kB
"""
VoiceCloner — OpenVoice V2 tone-color converter.
Sits downstream of WaxalTTSEngine: takes the base VITS audio and reshapes it
to match a target speaker's tone color.
Usage:
cloner = VoiceCloner()
cloner.preload() # background thread
# After WaxalTTS produces (audio_np, sr) …
se = cloner.extract_se(audio_np, sr) # extract SE from user's mic audio
result = cloner.convert(audio_np, sr, se) # returns (cloned_audio, sr) or None
The OpenVoice V2 checkpoint is downloaded from myshell-ai/openvoice-v2 on
HuggingFace Hub at first use (cached in data/openvoice_v2/).
Falls back gracefully (returns None) if openvoice is not installed or the
checkpoint download fails — in that case the caller uses the raw VITS output.
"""
from __future__ import annotations
import logging
import os
import tempfile
import threading
from pathlib import Path
from typing import Optional
import numpy as np
logger = logging.getLogger(__name__)
OV_HF_REPO = "myshell-ai/openvoice-v2"
OV_CKPT_DIR = Path("data/openvoice_v2")
HF_TOKEN = os.environ.get("HF_TOKEN")
class VoiceCloner:
"""
Thin wrapper around OpenVoice V2 ToneColorConverter.
Thread-safety: convert() holds _lock so parallel calls are serialised;
the lock is released while waiting for subprocess/IO.
"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._converter = None
self._src_se = None # cached base-TTS source SE (computed on first convert)
self._ready = False
self._error: Optional[str] = None
def preload(self) -> None:
threading.Thread(target=self._load, daemon=True).start()
def get_status(self) -> str:
if self._ready: return "ready"
if self._error: return f"error: {self._error}"
return "loading…"
# ── Loading ───────────────────────────────────────────────────────────────
def _load(self) -> None:
try:
from openvoice.api import ToneColorConverter # noqa: F401 — validate import
OV_CKPT_DIR.mkdir(parents=True, exist_ok=True)
# Download checkpoint from HF Hub once, then use local cache
converter_cfg = OV_CKPT_DIR / "converter" / "config.json"
if not converter_cfg.exists():
logger.info("VoiceCloner: downloading OpenVoice V2 from HF Hub …")
from huggingface_hub import snapshot_download
snapshot_download(
repo_id=OV_HF_REPO,
local_dir=str(OV_CKPT_DIR),
token=HF_TOKEN,
)
# Find config — repo layout may vary
cfg_path = self._find_converter_config()
if cfg_path is None:
raise FileNotFoundError(
f"converter/config.json not found under {OV_CKPT_DIR}"
)
from openvoice.api import ToneColorConverter
logger.info("VoiceCloner: loading ToneColorConverter from %s …", cfg_path)
converter = ToneColorConverter(str(cfg_path), device="cpu")
ckpt = cfg_path.parent / "checkpoint.pth"
converter.load_ckpt(str(ckpt))
with self._lock:
self._converter = converter
self._ready = True
logger.info("VoiceCloner: OpenVoice V2 ready")
except Exception as exc:
self._error = str(exc)
logger.warning(
"VoiceCloner: load failed — voice cloning disabled: %s", exc
)
def _find_converter_config(self) -> Optional[Path]:
"""Probe known checkpoint layouts to locate converter/config.json."""
candidates = [
OV_CKPT_DIR / "converter" / "config.json",
OV_CKPT_DIR / "checkpoints_v2" / "converter" / "config.json",
]
for p in candidates:
if p.exists():
return p
# Walk one level deep as fallback
for p in OV_CKPT_DIR.rglob("config.json"):
if p.parent.name == "converter":
return p
return None
# ── SE extraction ─────────────────────────────────────────────────────────
def extract_se(self, audio_np: np.ndarray, sr: int) -> Optional[np.ndarray]:
"""
Extract OpenVoice V2 tone-color SE from raw float32 audio.
Returns a numpy array (shape depends on OV model, typically (1, 256)),
or None if not ready.
"""
if not self._ready:
return None
try:
import soundfile as sf
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
tmp = f.name
sf.write(tmp, audio_np, sr)
se = self._extract_se_from_file(tmp)
Path(tmp).unlink(missing_ok=True)
return se
except Exception as exc:
logger.debug("VoiceCloner.extract_se: %s", exc)
return None
def _extract_se_from_file(self, audio_path: str) -> Optional[np.ndarray]:
try:
from openvoice import se_extractor
se, _ = se_extractor.get_se(
audio_path,
self._converter,
target_dir=str(OV_CKPT_DIR / "tmp"),
vad=False,
)
arr = se.cpu().numpy() if hasattr(se, "cpu") else np.array(se)
return arr
except Exception as exc:
logger.debug("VoiceCloner._extract_se_from_file: %s", exc)
return None
# ── Voice conversion ──────────────────────────────────────────────────────
def convert(
self,
audio_np: np.ndarray,
sr: int,
target_se: np.ndarray,
) -> Optional[tuple[np.ndarray, int]]:
"""
Reshape audio to match the target speaker's tone color.
Args:
audio_np: float32 audio from WaxalTTS (base voice).
sr: sample rate of audio_np.
target_se: OpenVoice SE from SpeakerProfileManager (Individual or
Collective).
Returns (cloned_audio_float32, sample_rate) or None if not ready.
"""
if not self._ready:
return None
try:
import soundfile as sf
import torch
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
src_path = f.name
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
out_path = f.name
sf.write(src_path, audio_np, sr)
with self._lock:
# Extract source SE on first call, then cache it for the session
if self._src_se is None:
se = self._extract_se_from_file(src_path)
if se is not None:
self._src_se = se
if self._src_se is None:
logger.warning("VoiceCloner: could not extract source SE")
return None
src_se_t = torch.tensor(self._src_se)
tgt_se_t = torch.tensor(target_se)
# Ensure batch dim matches what the converter expects
if src_se_t.dim() == 1:
src_se_t = src_se_t.unsqueeze(0)
if tgt_se_t.dim() == 1:
tgt_se_t = tgt_se_t.unsqueeze(0)
self._converter.convert(
audio_src_path=src_path,
src_se=src_se_t,
tgt_se=tgt_se_t,
output_path=out_path,
message="@MyShell",
)
audio_out, out_sr = sf.read(out_path, dtype="float32")
Path(src_path).unlink(missing_ok=True)
Path(out_path).unlink(missing_ok=True)
return audio_out.astype(np.float32), out_sr
except Exception as exc:
logger.error("VoiceCloner.convert: %s", exc)
return None