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:
+66
-3
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user