133 lines
5.0 KiB
Python
133 lines
5.0 KiB
Python
import math
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
SUPPORTED_LANGS = (
|
|
"en", "es", "fr", "de", "it", "pt", "pl", "tr", "ru",
|
|
"nl", "cs", "ar", "zh-cn", "ja", "hu", "ko", "hi"
|
|
)
|
|
SUPPORTED_AUDIO = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".wma"}
|
|
|
|
|
|
class VoiceCloner:
|
|
"""TTS and Voice Conversion cloning engine using Coqui XTTS and FreeVC."""
|
|
XTTS_MODEL = "tts_models/multilingual/multi-dataset/xtts_v2"
|
|
VC_MODEL = "voice_conversion_models/multilingual/vctk/freevc24"
|
|
SUPPORTED_LANGS = SUPPORTED_LANGS
|
|
SUPPORTED_AUDIO = SUPPORTED_AUDIO
|
|
|
|
def __init__(self, log_fn=print):
|
|
self.log = log_fn
|
|
self.device = "cpu"
|
|
self.tts = None
|
|
self.vc = None
|
|
|
|
def _load_runtime(self):
|
|
try:
|
|
import torch
|
|
from TTS.api import TTS
|
|
except Exception as e:
|
|
raise RuntimeError(
|
|
"Voice-Cloning ist nicht installiert.\n\n"
|
|
"Bitte installiere zuerst:\n"
|
|
" py -3.12 -m pip install coqui-tts torch torchaudio\n\n"
|
|
f"Details: {e}"
|
|
) from e
|
|
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
return TTS
|
|
|
|
def _ensure_xtts(self):
|
|
if self.tts is not None:
|
|
return
|
|
TTS = self._load_runtime()
|
|
self.log(f"VOICE: Lade XTTS v2 ({self.device}) ...")
|
|
self.tts = TTS(self.XTTS_MODEL).to(self.device)
|
|
self.log("VOICE: XTTS bereit.")
|
|
|
|
def _ensure_vc(self):
|
|
if self.vc is not None:
|
|
return
|
|
TTS = self._load_runtime()
|
|
self.log(f"VOICE: Lade FreeVC ({self.device}) ...")
|
|
self.vc = TTS(self.VC_MODEL).to(self.device)
|
|
self.log("VOICE: FreeVC bereit.")
|
|
|
|
@staticmethod
|
|
def _check_audio(path, label):
|
|
p = Path(path)
|
|
if not p.is_file():
|
|
raise RuntimeError(f"{label} nicht gefunden:\n{path}")
|
|
if p.suffix.lower() not in SUPPORTED_AUDIO:
|
|
raise RuntimeError(
|
|
f"{label} hat ein nicht unterstuetztes Format: {p.suffix}\n"
|
|
f"Erlaubt: {', '.join(sorted(SUPPORTED_AUDIO))}"
|
|
)
|
|
return str(p)
|
|
|
|
def clone_from_text(self, speaker_wav, text, language, out_file):
|
|
"""Clone voice from reference speaker WAV using text-to-speech."""
|
|
if not text.strip():
|
|
raise RuntimeError("Bitte Text eingeben.")
|
|
language = (language or "de").strip().lower()
|
|
if language not in self.SUPPORTED_LANGS:
|
|
raise RuntimeError(f"Sprache '{language}' nicht unterstuetzt. Erlaubt: {', '.join(self.SUPPORTED_LANGS)}")
|
|
|
|
speaker_wav = self._check_audio(speaker_wav, "Referenz-Stimme")
|
|
self._ensure_xtts()
|
|
|
|
out_path = Path(out_file)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.tts.tts_to_file(text=text, speaker_wav=speaker_wav, language=language, file_path=str(out_path))
|
|
return str(out_path)
|
|
|
|
def clone_from_audio(self, speaker_wav, source_wav, out_file):
|
|
"""Convert voice characteristics from source_wav to speaker_wav using FreeVC."""
|
|
speaker_wav = self._check_audio(speaker_wav, "Referenz-Stimme")
|
|
source_wav = self._check_audio(source_wav, "Eingabe-Audio")
|
|
self._ensure_vc()
|
|
|
|
try:
|
|
import soundfile as sf
|
|
import numpy as np
|
|
except ImportError:
|
|
raise RuntimeError("soundfile ist nicht installiert: py -3.12 -m pip install soundfile")
|
|
|
|
out_path = Path(out_file)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
CHUNK_SEC = 30
|
|
data, sr = sf.read(source_wav, always_2d=False)
|
|
chunk_samples = int(CHUNK_SEC * sr)
|
|
total_samples = len(data)
|
|
num_chunks = math.ceil(total_samples / chunk_samples)
|
|
|
|
if num_chunks <= 1:
|
|
self.vc.voice_conversion_to_file(
|
|
source_wav=source_wav, target_wav=speaker_wav, file_path=str(out_path)
|
|
)
|
|
return str(out_path)
|
|
|
|
self.log(f"VOICE: Datei lang ({total_samples / sr:.1f}s) — verarbeite {num_chunks} Segmente ...")
|
|
results = []
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
for i in range(num_chunks):
|
|
start = i * chunk_samples
|
|
end = min(start + chunk_samples, total_samples)
|
|
chunk = data[start:end]
|
|
chunk_in = Path(tmpdir) / f"chunk_{i:04d}_in.wav"
|
|
chunk_out = Path(tmpdir) / f"chunk_{i:04d}_out.wav"
|
|
sf.write(str(chunk_in), chunk, sr)
|
|
self.log(f"VOICE: Segment {i + 1}/{num_chunks} ...")
|
|
self.vc.voice_conversion_to_file(
|
|
source_wav=str(chunk_in), target_wav=speaker_wav, file_path=str(chunk_out)
|
|
)
|
|
out_data, out_sr = sf.read(str(chunk_out))
|
|
results.append((out_data, out_sr))
|
|
|
|
self.log("VOICE: Fuege Segmente zusammen ...")
|
|
target_sr = results[0][1]
|
|
merged = np.concatenate([r for r, _ in results], axis=0)
|
|
sf.write(str(out_path), merged, target_sr)
|
|
|
|
return str(out_path)
|