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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -7,8 +7,8 @@
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-DzmNpOtE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css">
<script type="module" crossorigin src="/assets/index-DesgNIBq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
</head>
<body>
<div id="root"></div>
+48 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react"
import { Upload, Link2, ExternalLink, Check, Sparkles } from "lucide-react"
import { Upload, Link2, ExternalLink, Check, Sparkles, Volume2, Loader2 } from "lucide-react"
import { saveUploadedVrm, loadUploadedVrm } from "@/lib/voice/vrmStore"
// Avatar selbst aussuchen: kuratierte Galerie (öffentliche, CORS-freie VRMs) + eigenes .vrm
@@ -26,7 +26,36 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
const [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "piper")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const previewAudio = useRef<HTMLAudioElement | null>(null)
// „Probe hören": synthetisiert einen festen Satz mit der aktuellen Engine+Stimme und spielt ihn ab.
const playPreview = async () => {
if (previewing) return
setPreviewing(true)
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: "Hallo! Ich bin deine Assistentin. So klingt diese Stimme auf Deutsch.",
engine, voice,
}),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
const url = URL.createObjectURL(await r.blob())
previewAudio.current?.pause()
const a = new Audio(url)
previewAudio.current = a
a.onended = () => URL.revokeObjectURL(url)
await a.play()
} catch (e) {
console.error("Probe fehlgeschlagen:", e)
} finally {
setPreviewing(false)
}
}
// Hochgeladenes VRM nach Reload wiederherstellen.
useEffect(() => {
@@ -70,8 +99,9 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
localStorage.setItem("mc_voice_voice", v)
}
// Feste Reihenfolge der bekannten Engines (ElevenLabs auch ohne Key sichtbar → „Key fehlt"-Hinweis).
const enginesAvail = ["piper", "chatterbox", "elevenlabs"]
// Feste Reihenfolge der bekannten Engines (Edge zuerst = natürlich+gratis; ElevenLabs auch ohne Key
// sichtbar → „Key fehlt"-Hinweis).
const enginesAvail = ["edge", "piper", "chatterbox", "elevenlabs"]
const voicesForEngine = voices.filter((v) => v.engine === engine)
return (
@@ -158,7 +188,7 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`}
>
{eng === "piper" ? "Piper (schnell)" : eng === "chatterbox" ? "Chatterbox (lokal)" : eng === "elevenlabs" ? "ElevenLabs (premium)" : eng}
{eng === "edge" ? "Edge (natürlich · gratis)" : eng === "piper" ? "Piper (lokal)" : eng === "chatterbox" ? "Chatterbox (lokal)" : eng === "elevenlabs" ? "ElevenLabs (premium)" : eng}
</button>
))}
</div>
@@ -172,6 +202,20 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
<option key={v.id} value={v.id}>{v.label}{v.clonable ? " · klonbar" : ""}</option>
))}
</select>
<button
onClick={playPreview}
disabled={previewing}
className="flex w-full items-center justify-center gap-2 rounded-md border border-primary/40 bg-primary/10 px-2.5 py-2 text-xs text-primary hover:bg-primary/20 transition-colors disabled:opacity-60"
>
{previewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
{previewing ? "Spielt …" : "Probe hören"}
</button>
{engine === "edge" && (
<p className="text-[11px] text-muted-foreground">
Edge-TTS: native deutsche Azure-Stimmen sehr natürlich, kein Akzent, gratis & ohne Key.
Jüngste Stimme: <code>Gisela</code>. Cloud (Text Microsoft).
</p>
)}
{engine === "chatterbox" && (
<p className="text-[11px] text-muted-foreground">
Chatterbox läuft auf CPU erste Antwort kann ein paar Sekunden dauern. Lokal + Voice-Cloning (dt. mit Akzent).
+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":
+4 -3
View File
@@ -1,9 +1,10 @@
# Kern (immer nötig — STT + Piper-TTS + Server). Chatterbox + torch zieht install.sh
# separat best-effort nach (CPU-Wheel), damit der Voice-Loop auch ohne Premium-Engine läuft.
# Kern (immer nötig — STT + Server). Piper-TTS läuft über das offizielle Binary (install.sh lädt
# es), nicht über das PyPI-Paket (piper-phonemize hat auf neueren Linux keine Wheels). Chatterbox
# + torch zieht install.sh best-effort nach (CPU-Wheel), damit der Loop auch ohne Premium läuft.
fastapi
uvicorn
python-multipart
soundfile
numpy
faster-whisper
piper-tts==1.2.0
edge-tts