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:
+1
-1
File diff suppressed because one or more lines are too long
+76
-76
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user