""" OpenVoice v2 — tone-colour voice transfer. Wraps MyShell's ToneColorConverter to stamp a parent's voice timbre onto MMS-synthesised audio. Usage pattern: 1. At profile upload time: se = extract_se("parent_voice.wav") torch.save(se, "profile.pt") 2. At audio generation time: output_wav = transfer_voice(mms_wav_bytes, target_se_tensor) """ from __future__ import annotations import os import sys import tempfile import types from typing import Optional import torch # Lazy-loaded singletons _converter = None def load_converter(ckpt_dir: str, device: str = "cpu") -> None: """ Load the OpenVoice v2 ToneColorConverter from *ckpt_dir*. Expected files: - /config.json - /checkpoint.pth """ global _converter from openvoice.api import ToneColorConverter config_path = os.path.join(ckpt_dir, "config.json") ckpt_path = os.path.join(ckpt_dir, "checkpoint.pth") print(f"[OpenVoice] Loading converter from {ckpt_dir} …") # OpenVoice imports wavmark inside the converter constructor even though # watermarking is not needed for voice transfer. A no-op loader avoids an # extra model and works around its broken enable_watermark keyword path. wavmark_stub = types.ModuleType("wavmark") class DisabledWatermark: def to(self, _device): return None wavmark_stub.load_model = DisabledWatermark sys.modules["wavmark"] = wavmark_stub converter = ToneColorConverter(config_path, device=device) converter.load_ckpt(ckpt_path) _converter = converter print("[OpenVoice] Converter ready.") def _ensure_loaded() -> None: if _converter is None: raise RuntimeError( "OpenVoice converter is not loaded. Call load_converter() at startup." ) def extract_se(audio_path: str) -> torch.Tensor: """ Extract the speaker embedding (tone colour) from *audio_path*. Returns a torch.Tensor that can be saved with torch.save(). """ _ensure_loaded() return _converter.extract_se(audio_path) def transfer_voice( source_wav_bytes: bytes, target_se: torch.Tensor, tau: float = 0.3, ) -> bytes: """ Stamp *target_se* (parent's voice) onto *source_wav_bytes* (MMS output). *tau* controls transfer strength (0.0 = source unchanged, 1.0 = maximum speaker similarity). Default 0.3 balances naturalness vs. speaker match. Returns WAV bytes with the parent's voice characteristics applied. """ _ensure_loaded() # Write MMS output to a temp file with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as src_f: src_f.write(source_wav_bytes) src_path = src_f.name with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as out_f: out_path = out_f.name try: # Extract speaker embedding from MMS source audio src_se = _converter.extract_se(src_path) _converter.convert( audio_src_path=src_path, src_se=src_se, tgt_se=target_se, output_path=out_path, tau=tau, ) with open(out_path, "rb") as f: return f.read() finally: for p in (src_path, out_path): try: os.unlink(p) except OSError: pass