Feat: Edge-TTS-Engine (native dt. Azure-Stimmen, kein Akzent) + Probe-hören-Knopf

Edge-TTS als 4. Engine (gratis, kein Key, KEIN Cloning → natives Deutsch ohne Akzent — die einzige
Lösung gegen das Akzent-Problem aller Cloning-Engines). /voices listet dt. Edge-Stimmen (weiblich
zuerst, inkl. Gisela). Picker: Engine 'Edge (natürlich · gratis)' + Probe-hören-Knopf (festen Satz je
Engine/Stimme abspielen, ohne reinsprechen). Default bleibt Piper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 00:44:01 +02:00
parent aa62c98247
commit 11f0066b47
7 changed files with 257 additions and 154 deletions
+60 -2
View File
@@ -60,6 +60,8 @@ CHATTERBOX_REF = os.environ.get("VOICE_CHATTERBOX_REF", "")
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"
# Edge-TTS: native deutsche Azure-Neural-Stimmen, gratis & ohne Key (kein Cloning → kein Akzent).
EDGE_DEFAULT = os.environ.get("VOICE_EDGE_VOICE", "de-DE-KatjaNeural")
def _eleven_key() -> str:
@@ -230,6 +232,60 @@ def elevenlabs_list() -> list[dict]:
return []
# =================================================================================
# TTS — Edge-TTS (native dt. Azure-Neural-Stimmen, gratis, kein Key, KEIN Cloning → kein Akzent)
# =================================================================================
def edge_tts_synth(text: str, voice: str) -> bytes:
import asyncio
import edge_tts
v = voice or EDGE_DEFAULT
async def _run() -> bytes:
buf = bytearray()
communicate = edge_tts.Communicate(text, v)
async for chunk in communicate.stream():
if chunk["type"] == "audio":
buf.extend(chunk["data"])
return bytes(buf)
try:
return asyncio.run(_run())
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"Edge-TTS-Fehler: {exc}")
_edge_cache: list | None = None
def edge_list() -> list[dict]:
"""Deutsche Edge/Azure-Stimmen (einmal vom Dienst geholt + gecacht). Weibliche zuerst."""
global _edge_cache
if _edge_cache is not None:
return _edge_cache
try:
import asyncio
import edge_tts
voices = asyncio.run(edge_tts.list_voices())
out = []
for v in voices:
if not v.get("Locale", "").startswith("de-"):
continue
short = v["ShortName"] # z.B. de-DE-KatjaNeural
gender = "weiblich" if v.get("Gender") == "Female" else "männlich"
name = short.split("-")[-1].replace("Neural", "").replace("Multilingual", " (multiling.)")
out.append({"engine": "edge", "id": short,
"label": f"{name} · {v['Locale']} · {gender}",
"clonable": False, "_female": v.get("Gender") == "Female"})
out.sort(key=lambda i: (not i.pop("_female"), i["id"]))
_edge_cache = out
except Exception: # noqa: BLE001
log.warning("Edge-Stimmenliste fehlgeschlagen", exc_info=True)
_edge_cache = []
return _edge_cache
# =================================================================================
# FastAPI
# =================================================================================
@@ -258,14 +314,14 @@ class TTSIn(BaseModel):
@app.get("/health")
def health() -> dict:
return {"ok": True, "stt_model": STT_MODEL,
"engines": ["piper", "chatterbox", "elevenlabs"],
"engines": ["edge", "piper", "chatterbox", "elevenlabs"],
"elevenlabs_key": bool(_eleven_key()),
"piper_voices": [v["id"] for v in piper_list()]}
@app.get("/voices")
def voices() -> dict:
return {"voices": piper_list() + chatterbox_list() + elevenlabs_list(),
return {"voices": edge_list() + piper_list() + chatterbox_list() + elevenlabs_list(),
"default": {"engine": "piper", "voice": PIPER_DEFAULT}}
@@ -284,6 +340,8 @@ def tts(body: TTSIn) -> Response:
text = (body.text or "").strip()
if not text:
raise HTTPException(400, "Leerer Text.")
if body.engine == "edge":
return Response(content=edge_tts_synth(text, body.voice), media_type="audio/mpeg")
if body.engine == "elevenlabs":
return Response(content=elevenlabs_tts(text, body.voice), media_type="audio/mpeg")
if body.engine == "chatterbox":