Feat: Chatterbox-Klon-Referenz hochladen (eigene Stimme via ElevenLabs → lokal unbegrenzt klonen)

Sidecar: /reference (POST Upload → 24kHz-Mono-WAV via PyAV/faster-whisper, kein System-ffmpeg;
GET/DELETE). Chatterbox nutzt aktive Referenz (active.txt, überlebt Restart). Backend: /api/voice/
reference-Proxy. Picker (Chatterbox): „Referenz-Stimme hochladen (mp3/wav)" → setzt Stimme auf
»deine Referenz«. Ermöglicht den User-Plan: Stimme besorgen → EL erzeugen → Chatterbox klont.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 00:55:40 +02:00
parent 75727c6b36
commit eeb397f066
6 changed files with 304 additions and 159 deletions
+66 -3
View File
@@ -54,6 +54,20 @@ CHATTERBOX_DEVICE = os.environ.get("VOICE_CHATTERBOX_DEVICE", "cpu")
CHATTERBOX_LANG = os.environ.get("VOICE_CHATTERBOX_LANG", "de")
# Optionaler Referenz-WAV für Voice-Cloning (10 s Sprachprobe). Leer = Chatterbox-Standardstimme.
CHATTERBOX_REF = os.environ.get("VOICE_CHATTERBOX_REF", "")
# Vom Nutzer hochgeladene Klon-Referenz (persistiert über Restarts). active.txt zeigt auf die Datei.
REFS_DIR = Path(os.environ.get("VOICE_REFS_DIR", str(Path.home() / ".voice" / "refs")))
ACTIVE_REF = REFS_DIR / "active.txt"
def active_ref() -> str:
"""Pfad der aktuell aktiven Klon-Referenz (Upload bevorzugt, sonst Env-Default)."""
try:
p = ACTIVE_REF.read_text(encoding="utf-8").strip()
if p and os.path.exists(p):
return p
except OSError:
pass
return CHATTERBOX_REF
# ElevenLabs (Premium-Cloud-TTS): saubere, native deutsche Stimmen (Voice Library). Key wird zur
# Laufzeit gelesen (env ODER ~/.hermes/.env), damit Nachtragen ohne Code-Deploy reicht. Default-
# Modell = Flash v2.5 (multilingual, 0,5 Credit/Zeichen → schont das Free-Tier-Kontingent).
@@ -166,7 +180,7 @@ def chatterbox_tts(text: str, language: str, ref_path: str) -> bytes:
import soundfile as sf
model = chatterbox_model()
kwargs = {"language_id": language or CHATTERBOX_LANG}
ref = ref_path or CHATTERBOX_REF
ref = ref_path or active_ref()
if ref and os.path.exists(ref):
kwargs["audio_prompt_path"] = ref # Zero-Shot Voice-Cloning aus Referenz
wav = model.generate(text, **kwargs)
@@ -181,8 +195,8 @@ def chatterbox_tts(text: str, language: str, ref_path: str) -> bytes:
def chatterbox_list() -> list[dict]:
# Chatterbox hat keine festen „Stimm-Dateien": Standardstimme + optionale Klon-Referenz.
items = [{"engine": "chatterbox", "id": "default", "label": "Chatterbox (Standard, dt.)", "clonable": True}]
if CHATTERBOX_REF and os.path.exists(CHATTERBOX_REF):
items.append({"engine": "chatterbox", "id": "clone", "label": "Chatterbox (geklonte Stimme)", "clonable": True})
if active_ref():
items.append({"engine": "chatterbox", "id": "clone", "label": "Chatterbox (deine Referenz)", "clonable": True})
return items
@@ -349,6 +363,55 @@ def tts(body: TTSIn) -> Response:
return Response(content=piper_tts(text, body.voice), media_type="audio/wav")
def _save_reference_as_wav(data: bytes, filename: str) -> str:
"""Upload (mp3/wav/…) → 24 kHz Mono-WAV für Chatterbox. Dekodierung via PyAV (steckt in
faster-whisper → kein System-ffmpeg nötig); Fallback soundfile, sonst Rohdatei."""
REFS_DIR.mkdir(parents=True, exist_ok=True)
raw = REFS_DIR / ("upload" + (Path(filename or "ref").suffix or ".bin"))
raw.write_bytes(data)
dest = REFS_DIR / "ref.wav"
import soundfile as sf
try:
from faster_whisper.audio import decode_audio # PyAV-basiert, kann mp3/m4a/ogg/wav
arr = decode_audio(str(raw), sampling_rate=24000)
sf.write(str(dest), arr, 24000, subtype="PCM_16")
return str(dest)
except Exception:
log.warning("PyAV-Dekodierung fehlgeschlagen, versuche soundfile", exc_info=True)
try:
arr, srr = sf.read(str(raw))
sf.write(str(dest), arr, srr, subtype="PCM_16")
return str(dest)
except Exception:
return str(raw) # Fallback: Chatterbox versucht selbst zu laden
@app.post("/reference")
async def set_reference(audio: UploadFile = File(...)) -> dict:
"""Klon-Referenz hochladen (z.B. ElevenLabs-Erzeugnis) → Chatterbox nutzt sie als Stimme."""
data = await audio.read()
if not data:
raise HTTPException(400, "Leeres Audio.")
path = _save_reference_as_wav(data, audio.filename or "ref.wav")
ACTIVE_REF.write_text(path, encoding="utf-8")
return {"ok": True, "path": path, "bytes": len(data)}
@app.get("/reference")
def get_reference() -> dict:
p = active_ref()
return {"active": bool(p), "path": p}
@app.delete("/reference")
def clear_reference() -> dict:
try:
ACTIVE_REF.unlink(missing_ok=True)
except OSError:
pass
return {"ok": True}
if __name__ == "__main__":
import uvicorn