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="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-DzmNpOtE.js"></script> <script type="module" crossorigin src="/assets/index-DesgNIBq.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DueAT_Qc.css"> <link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+48 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react" 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" import { saveUploadedVrm, loadUploadedVrm } from "@/lib/voice/vrmStore"
// Avatar selbst aussuchen: kuratierte Galerie (öffentliche, CORS-freie VRMs) + eigenes .vrm // 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 [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "piper") const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "piper")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "") const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) 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. // Hochgeladenes VRM nach Reload wiederherstellen.
useEffect(() => { useEffect(() => {
@@ -70,8 +99,9 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
localStorage.setItem("mc_voice_voice", v) localStorage.setItem("mc_voice_voice", v)
} }
// Feste Reihenfolge der bekannten Engines (ElevenLabs auch ohne Key sichtbar → „Key fehlt"-Hinweis). // Feste Reihenfolge der bekannten Engines (Edge zuerst = natürlich+gratis; ElevenLabs auch ohne Key
const enginesAvail = ["piper", "chatterbox", "elevenlabs"] // sichtbar → „Key fehlt"-Hinweis).
const enginesAvail = ["edge", "piper", "chatterbox", "elevenlabs"]
const voicesForEngine = voices.filter((v) => v.engine === engine) const voicesForEngine = voices.filter((v) => v.engine === engine)
return ( 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" 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> </button>
))} ))}
</div> </div>
@@ -172,6 +202,20 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
<option key={v.id} value={v.id}>{v.label}{v.clonable ? " · klonbar" : ""}</option> <option key={v.id} value={v.id}>{v.label}{v.clonable ? " · klonbar" : ""}</option>
))} ))}
</select> </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" && ( {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. Lokal + Voice-Cloning (dt. mit Akzent). 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_MODEL = os.environ.get("VOICE_ELEVENLABS_MODEL", "eleven_flash_v2_5")
ELEVEN_DEFAULT_VOICE = os.environ.get("VOICE_ELEVENLABS_VOICE", "21m00Tcm4TlvDq8ikWAM") ELEVEN_DEFAULT_VOICE = os.environ.get("VOICE_ELEVENLABS_VOICE", "21m00Tcm4TlvDq8ikWAM")
HERMES_ENV = Path.home() / ".hermes" / ".env" 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: def _eleven_key() -> str:
@@ -230,6 +232,60 @@ def elevenlabs_list() -> list[dict]:
return [] 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 # FastAPI
# ================================================================================= # =================================================================================
@@ -258,14 +314,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", "elevenlabs"], "engines": ["edge", "piper", "chatterbox", "elevenlabs"],
"elevenlabs_key": bool(_eleven_key()), "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() + elevenlabs_list(), return {"voices": edge_list() + piper_list() + chatterbox_list() + elevenlabs_list(),
"default": {"engine": "piper", "voice": PIPER_DEFAULT}} "default": {"engine": "piper", "voice": PIPER_DEFAULT}}
@@ -284,6 +340,8 @@ 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 == "edge":
return Response(content=edge_tts_synth(text, body.voice), media_type="audio/mpeg")
if body.engine == "elevenlabs": if body.engine == "elevenlabs":
return Response(content=elevenlabs_tts(text, body.voice), media_type="audio/mpeg") return Response(content=elevenlabs_tts(text, body.voice), media_type="audio/mpeg")
if body.engine == "chatterbox": 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 # Kern (immer nötig — STT + Server). Piper-TTS läuft über das offizielle Binary (install.sh lädt
# separat best-effort nach (CPU-Wheel), damit der Voice-Loop auch ohne Premium-Engine läuft. # 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 fastapi
uvicorn uvicorn
python-multipart python-multipart
soundfile soundfile
numpy numpy
faster-whisper faster-whisper
piper-tts==1.2.0 edge-tts