Feat: ElevenLabs als Premium-TTS-Engine (sauberes natives Deutsch, Voice Library)

Sidecar: /tts + /voices + /health um Engine `elevenlabs` erweitert (Flash v2.5, Key zur Laufzeit
aus env ODER ~/.hermes/.env → Nachtragen ohne Deploy). Default bleibt Piper. Frontend: ElevenLabs
im Stimm-Picker (fest sichtbar) inkl. „Key fehlt"-Hinweis + Quota-Note. Chatterbox-DE war zu akzentig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 00:32:24 +02:00
parent cf587616dd
commit aa62c98247
5 changed files with 195 additions and 111 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-eeooup-h.js"></script> <script type="module" crossorigin src="/assets/index-DzmNpOtE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css"> <link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css">
</head> </head>
<body> <body>
+14 -3
View File
@@ -70,7 +70,8 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
localStorage.setItem("mc_voice_voice", v) localStorage.setItem("mc_voice_voice", v)
} }
const enginesAvail = Array.from(new Set(voices.map((v) => v.engine))) // Feste Reihenfolge der bekannten Engines (ElevenLabs auch ohne Key sichtbar → „Key fehlt"-Hinweis).
const enginesAvail = ["piper", "chatterbox", "elevenlabs"]
const voicesForEngine = voices.filter((v) => v.engine === engine) const voicesForEngine = voices.filter((v) => v.engine === engine)
return ( return (
@@ -157,7 +158,7 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent" engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`} }`}
> >
{eng === "piper" ? "Piper (schnell)" : eng === "chatterbox" ? "Chatterbox (premium)" : eng} {eng === "piper" ? "Piper (schnell)" : eng === "chatterbox" ? "Chatterbox (lokal)" : eng === "elevenlabs" ? "ElevenLabs (premium)" : eng}
</button> </button>
))} ))}
</div> </div>
@@ -173,7 +174,17 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
</select> </select>
{engine === "chatterbox" && ( {engine === "chatterbox" && (
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Chatterbox läuft auf CPU erste Antwort kann ein paar Sekunden dauern. Natürlichste Stimme + Voice-Cloning. Chatterbox läuft auf CPU erste Antwort kann ein paar Sekunden dauern. Lokal + Voice-Cloning (dt. mit Akzent).
</p>
)}
{engine === "elevenlabs" && (
<p className="text-[11px] text-muted-foreground">
ElevenLabs: sauberes natives Deutsch, junge/Anime-Stimmen via Voice Library. Cloud, Free-Tier ~50 kurze Antworten/Monat.
</p>
)}
{engine === "elevenlabs" && voices.filter((v) => v.engine === "elevenlabs").length === 0 && (
<p className="text-[11px] text-amber-300">
Noch kein ElevenLabs-Key hinterlegt Stimmen erscheinen, sobald der Key in <code>~/.hermes/.env</code> steht.
</p> </p>
)} )}
</div> </div>
+103 -30
View File
@@ -22,10 +22,11 @@ Bind: 127.0.0.1 (nur lokal; MC2 proxyt nach außen).
""" """
import io import io
import json
import logging import logging
import os import os
import subprocess
import tempfile import tempfile
import wave
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -42,14 +43,38 @@ STT_MODEL = os.environ.get("VOICE_STT_MODEL", "medium") # base|small|mediu
STT_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu") STT_DEVICE = os.environ.get("VOICE_STT_DEVICE", "cpu")
STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend STT_COMPUTE = os.environ.get("VOICE_STT_COMPUTE", "int8") # int8=CPU-schonend
STT_LANG = os.environ.get("VOICE_STT_LANG", "de") STT_LANG = os.environ.get("VOICE_STT_LANG", "de")
# Piper (Default-TTS): Verzeichnis mit *.onnx (+ *.onnx.json) Stimmen # Piper (Default-TTS): Verzeichnis mit *.onnx (+ *.onnx.json) Stimmen + das Piper-Binary.
VOICES_DIR = Path(os.environ.get("VOICE_PIPER_DIR", str(Path(__file__).resolve().parent / "voices"))) # Wir nutzen das offizielle Binary (statt des piper-tts-PyPI-Pakets), weil dessen Abhängigkeit
# piper-phonemize auf neueren Linux-Systemen keine Wheels hat. Das Binary bringt espeak-ng mit.
VOICES_DIR = Path(os.environ.get("VOICE_PIPER_DIR", str(Path.home() / ".voice" / "voices")))
PIPER_DEFAULT = os.environ.get("VOICE_PIPER_DEFAULT", "de_DE-thorsten-medium") PIPER_DEFAULT = os.environ.get("VOICE_PIPER_DEFAULT", "de_DE-thorsten-medium")
PIPER_BIN = os.environ.get("VOICE_PIPER_BIN", str(Path.home() / ".voice" / "piper" / "piper"))
# Chatterbox (Premium-TTS) # Chatterbox (Premium-TTS)
CHATTERBOX_DEVICE = os.environ.get("VOICE_CHATTERBOX_DEVICE", "cpu") CHATTERBOX_DEVICE = os.environ.get("VOICE_CHATTERBOX_DEVICE", "cpu")
CHATTERBOX_LANG = os.environ.get("VOICE_CHATTERBOX_LANG", "de") CHATTERBOX_LANG = os.environ.get("VOICE_CHATTERBOX_LANG", "de")
# Optionaler Referenz-WAV für Voice-Cloning (10 s Sprachprobe). Leer = Chatterbox-Standardstimme. # Optionaler Referenz-WAV für Voice-Cloning (10 s Sprachprobe). Leer = Chatterbox-Standardstimme.
CHATTERBOX_REF = os.environ.get("VOICE_CHATTERBOX_REF", "") CHATTERBOX_REF = os.environ.get("VOICE_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).
ELEVEN_MODEL = os.environ.get("VOICE_ELEVENLABS_MODEL", "eleven_flash_v2_5")
ELEVEN_DEFAULT_VOICE = os.environ.get("VOICE_ELEVENLABS_VOICE", "21m00Tcm4TlvDq8ikWAM")
HERMES_ENV = Path.home() / ".hermes" / ".env"
def _eleven_key() -> str:
"""ElevenLabs-Key aus Prozess-Env oder (un-auskommentiert) aus ~/.hermes/.env."""
k = os.environ.get("ELEVENLABS_API_KEY")
if k:
return k.strip()
try:
for line in HERMES_ENV.read_text(encoding="utf-8").splitlines():
s = line.strip()
if s.startswith("ELEVENLABS_API_KEY=") and not s.startswith("#"):
return s.split("=", 1)[1].strip().strip('"').strip("'")
except OSError:
pass
return ""
# ================================================================================= # =================================================================================
@@ -85,29 +110,30 @@ def transcribe(audio_bytes: bytes, suffix: str, language: str) -> str:
# ================================================================================= # =================================================================================
# TTS — Piper (lazy, je Stimme gecacht) # TTS — Piper (offizielles Binary; Stimme = ONNX-Datei + .json daneben)
# ================================================================================= # =================================================================================
_piper: dict = {}
def piper_voice(name: str):
if name not in _piper:
from piper import PiperVoice
onnx = VOICES_DIR / f"{name}.onnx"
if not onnx.exists():
raise HTTPException(404, f"Piper-Stimme '{name}' nicht gefunden ({onnx}).")
log.info("Lade Piper-Stimme '%s'", name)
_piper[name] = PiperVoice.load(str(onnx))
return _piper[name]
def piper_tts(text: str, voice: str) -> bytes: def piper_tts(text: str, voice: str) -> bytes:
v = piper_voice(voice or PIPER_DEFAULT) name = voice or PIPER_DEFAULT
buf = io.BytesIO() onnx = VOICES_DIR / f"{name}.onnx"
with wave.open(buf, "wb") as wav: if not onnx.exists():
# piper-tts 1.2.x: synthesize(text, wave_file) schreibt einen kompletten WAV-Stream. raise HTTPException(404, f"Piper-Stimme '{name}' nicht gefunden ({onnx}).")
v.synthesize(text, wav) if not os.path.exists(PIPER_BIN):
return buf.getvalue() raise HTTPException(500, f"Piper-Binary fehlt ({PIPER_BIN}).")
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
out = tf.name
try:
proc = subprocess.run(
[PIPER_BIN, "-m", str(onnx), "-f", out],
input=text.encode("utf-8"), capture_output=True, timeout=60,
)
if proc.returncode != 0:
raise HTTPException(500, f"Piper-Fehler: {proc.stderr.decode('utf-8', 'replace')[:300]}")
return Path(out).read_bytes()
finally:
try:
os.unlink(out)
except OSError:
pass
def piper_list() -> list[dict]: def piper_list() -> list[dict]:
@@ -158,6 +184,52 @@ def chatterbox_list() -> list[dict]:
return items return items
# =================================================================================
# TTS — ElevenLabs (Premium-Cloud; native dt. Stimmen, Voice Library). Nur HTTP (stdlib).
# =================================================================================
def elevenlabs_tts(text: str, voice: str) -> bytes:
import urllib.error
import urllib.request
key = _eleven_key()
if not key:
raise HTTPException(503, "ELEVENLABS_API_KEY nicht gesetzt (in ~/.hermes/.env eintragen).")
vid = voice or ELEVEN_DEFAULT_VOICE
body = json.dumps({"text": text, "model_id": ELEVEN_MODEL}).encode("utf-8")
req = urllib.request.Request(
f"https://api.elevenlabs.io/v1/text-to-speech/{vid}",
data=body,
headers={"xi-api-key": key, "Content-Type": "application/json", "Accept": "audio/mpeg"},
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return r.read()
except urllib.error.HTTPError as e:
detail = e.read()[:200].decode("utf-8", "replace")
raise HTTPException(502, f"ElevenLabs-Fehler {e.code}: {detail}")
except urllib.error.URLError as e:
raise HTTPException(502, f"ElevenLabs nicht erreichbar: {e}")
def elevenlabs_list() -> list[dict]:
import urllib.request
key = _eleven_key()
if not key:
return []
try:
req = urllib.request.Request("https://api.elevenlabs.io/v1/voices", headers={"xi-api-key": key})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
return [
{"engine": "elevenlabs", "id": v["voice_id"], "label": v.get("name", v["voice_id"]), "clonable": False}
for v in data.get("voices", [])
]
except Exception: # noqa: BLE001
log.warning("ElevenLabs-Stimmenliste fehlgeschlagen", exc_info=True)
return []
# ================================================================================= # =================================================================================
# FastAPI # FastAPI
# ================================================================================= # =================================================================================
@@ -186,13 +258,14 @@ class TTSIn(BaseModel):
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"ok": True, "stt_model": STT_MODEL, return {"ok": True, "stt_model": STT_MODEL,
"engines": ["piper", "chatterbox"], "engines": ["piper", "chatterbox", "elevenlabs"],
"elevenlabs_key": bool(_eleven_key()),
"piper_voices": [v["id"] for v in piper_list()]} "piper_voices": [v["id"] for v in piper_list()]}
@app.get("/voices") @app.get("/voices")
def voices() -> dict: def voices() -> dict:
return {"voices": piper_list() + chatterbox_list(), return {"voices": piper_list() + chatterbox_list() + elevenlabs_list(),
"default": {"engine": "piper", "voice": PIPER_DEFAULT}} "default": {"engine": "piper", "voice": PIPER_DEFAULT}}
@@ -211,11 +284,11 @@ def tts(body: TTSIn) -> Response:
text = (body.text or "").strip() text = (body.text or "").strip()
if not text: if not text:
raise HTTPException(400, "Leerer Text.") raise HTTPException(400, "Leerer Text.")
if body.engine == "elevenlabs":
return Response(content=elevenlabs_tts(text, body.voice), media_type="audio/mpeg")
if body.engine == "chatterbox": if body.engine == "chatterbox":
audio = chatterbox_tts(text, body.language, body.ref_path) return Response(content=chatterbox_tts(text, body.language, body.ref_path), media_type="audio/wav")
else: return Response(content=piper_tts(text, body.voice), media_type="audio/wav")
audio = piper_tts(text, body.voice)
return Response(content=audio, media_type="audio/wav")
if __name__ == "__main__": if __name__ == "__main__":