Cleanup: nur noch ElevenLabs + Edge (Chatterbox/Piper/Referenz-Upload raus); EL-Library-Voices gefiltert

Ursache des Dauerfehlers: "Standardstimme" = Rachel = Library-Voice → Free-Tier verbietet die per API
(402). Fix: /voices liefert bei ElevenLabs NUR eigene Stimmen (category!=premade); Picker wählt
automatisch die erste echte Stimme (nie leer/Standardstimme). UI auf 2 Engines reduziert (EL premium +
Edge gratis), Default-Engine = elevenlabs. Chatterbox/Piper aus /voices+/health entfernt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-28 02:15:27 +02:00
parent dd4c4c8c0f
commit 15d5598eec
6 changed files with 221 additions and 254 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="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-Crcfqytj.js"></script>
<script type="module" crossorigin src="/assets/index-CS3v-0XV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
</head>
<body>
+56 -92
View File
@@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from "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
// hochladen + per URL laden + VRoid-Hub-Link. Dazu die Stimm-Auswahl (Engine + Stimme).
// Auswahl bleibt in localStorage / IndexedDB erhalten.
// Avatar selbst aussuchen (Galerie + .vrm-Upload + URL) + Stimm-Auswahl.
// Stimm-Engines: ElevenLabs (Premium, eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage.
interface GalleryItem { id: string; label: string; url: string; note?: string }
@@ -17,46 +16,51 @@ const GALLERY: GalleryItem[] = [
export const DEFAULT_AVATAR = GALLERY[0].url
interface Voice { engine: string; id: string; label: string; clonable?: boolean }
interface Voice { engine: string; id: string; label: string }
// Nur die zwei Engines, die wir wirklich nutzen.
const ENGINES = ["elevenlabs", "edge"] as const
const ENGINE_LABEL: Record<string, string> = {
elevenlabs: "ElevenLabs (premium)",
edge: "Edge (natürlich · gratis)",
}
export function AvatarPicker({ avatarUrl, onAvatarChange }: {
avatarUrl: string; onAvatarChange: (url: string) => void
}) {
const [urlInput, setUrlInput] = useState("")
const [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "piper")
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "elevenlabs")
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
const [previewing, setPreviewing] = useState(false)
const [refActive, setRefActive] = useState(false)
const [refUploading, setRefUploading] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const refFileRef = useRef<HTMLInputElement>(null)
const previewAudio = useRef<HTMLAudioElement | null>(null)
// Aktive Chatterbox-Klon-Referenz anzeigen.
const saveVoice = (eng: string, v: string) => {
setEngine(eng); setVoice(v)
localStorage.setItem("mc_voice_engine", eng)
localStorage.setItem("mc_voice_voice", v)
}
// Stimmen vom Sidecar holen. WICHTIG: nie auf leerer Stimme bleiben — ElevenLabs' „Standardstimme"
// (Rachel) ist eine Library-Voice, die das Free-Tier per API NICHT nutzen darf (402). Darum bei
// leerer Auswahl automatisch die erste echte Stimme der aktiven Engine wählen.
useEffect(() => {
fetch("/api/voice/reference").then((r) => r.json()).then((d) => setRefActive(!!d.active)).catch(() => {})
fetch("/api/voice/voices")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => {
const list: Voice[] = d.voices || []
setVoices(list)
if (!voice) {
const first = list.find((v) => v.engine === engine)
if (first) saveVoice(engine, first.id)
}
})
.catch(() => setVoices([]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const uploadReference = async (file: File) => {
setRefUploading(true)
try {
const fd = new FormData()
fd.append("audio", file)
const r = await fetch("/api/voice/reference", { method: "POST", body: fd })
if (!r.ok) throw new Error(`${r.status}`)
setRefActive(true)
const v = await fetch("/api/voice/voices").then((r) => r.json())
setVoices(v.voices || [])
saveVoice("chatterbox", "clone") // direkt auf die geklonte Stimme schalten
} catch (e) {
console.error("Referenz-Upload fehlgeschlagen:", e)
} finally {
setRefUploading(false)
}
}
// „Probe hören": synthetisiert einen festen Satz mit der aktuellen Engine+Stimme und spielt ihn ab.
// „Probe hören": festen Satz mit aktueller Engine+Stimme abspielen.
const playPreview = async () => {
if (previewing) return
setPreviewing(true)
@@ -64,10 +68,7 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
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,
}),
body: JSON.stringify({ text: "Hallo! So klingt diese Stimme auf Deutsch.", engine, voice }),
})
if (!r.ok) throw new Error(`TTS ${r.status}`)
const url = URL.createObjectURL(await r.blob())
@@ -92,14 +93,6 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
}
}, [onAvatarChange])
// Stimmen vom Sidecar holen.
useEffect(() => {
fetch("/api/voice/voices")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => setVoices(d.voices || []))
.catch(() => setVoices([]))
}, [])
const pickGallery = (url: string) => {
localStorage.setItem("mc_voice_avatar_url", url)
localStorage.removeItem("mc_voice_avatar_uploaded")
@@ -119,16 +112,14 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
if (u) pickGallery(u)
}
const saveVoice = (eng: string, v: string) => {
setEngine(eng); setVoice(v)
localStorage.setItem("mc_voice_engine", eng)
localStorage.setItem("mc_voice_voice", v)
// Beim Engine-Wechsel direkt die erste Stimme der neuen Engine wählen (nie leer/„Standardstimme").
const pickEngine = (eng: string) => {
const first = voices.find((v) => v.engine === eng)
saveVoice(eng, first?.id || "")
}
// 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)
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
return (
<div className="space-y-5 text-sm">
@@ -206,15 +197,15 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
) : (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{enginesAvail.map((eng) => (
{ENGINES.map((eng) => (
<button
key={eng}
onClick={() => saveVoice(eng, "")}
onClick={() => pickEngine(eng)}
className={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`}
>
{eng === "edge" ? "Edge (natürlich · gratis)" : eng === "piper" ? "Piper (lokal)" : eng === "chatterbox" ? "Chatterbox (lokal)" : eng === "elevenlabs" ? "ElevenLabs (premium)" : eng}
{ENGINE_LABEL[eng]}
</button>
))}
</div>
@@ -223,59 +214,32 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
onChange={(e) => saveVoice(engine, e.target.value)}
className="w-full rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs outline-none focus:border-primary/50"
>
<option value="">Standardstimme</option>
{voicesForEngine.map((v) => (
<option key={v.id} value={v.id}>{v.label}{v.clonable ? " · klonbar" : ""}</option>
<option key={v.id} value={v.id}>{v.label}</option>
))}
</select>
<button
onClick={playPreview}
disabled={previewing}
disabled={previewing || elKeyMissing}
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 === "elevenlabs" && !elKeyMissing && (
<p className="text-[11px] text-muted-foreground">
ElevenLabs: deine eigene Stimme, sauberes Deutsch, schnell. Nutze NUR eigene Stimmen
Library-Stimmen sperrt das Free-Tier per API.
</p>
)}
{elKeyMissing && (
<p className="text-[11px] text-amber-300">
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder noch keine eigene Stimme angelegt.
</p>
)}
{engine === "edge" && (
<p className="text-[11px] text-muted-foreground">
Edge-TTS: native deutsche Azure-Stimmen sehr natürlich, kein Akzent, gratis & ohne Key.
Probier <code>Seraphina</code> oder <code>Katja</code>. Echte Kinder-/Jungstimmen (Gisela)
gäbe es nur über Azure direkt (Key). Cloud (Text Microsoft).
</p>
)}
{engine === "chatterbox" && (
<div className="space-y-1.5">
<input
ref={refFileRef}
type="file"
accept="audio/*,.mp3,.wav,.m4a,.ogg"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadReference(f) }}
/>
<button
onClick={() => refFileRef.current?.click()}
disabled={refUploading}
className="flex w-full items-center justify-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs hover:bg-accent transition-colors disabled:opacity-60"
>
{refUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{refActive ? "Referenz-Stimme ersetzen" : "Referenz-Stimme hochladen (mp3/wav)"}
</button>
<p className="text-[11px] text-muted-foreground">
{refActive
? "✓ Referenz aktiv → wähle oben die Stimme »Chatterbox (deine Referenz)«. "
: "Lad deine (z.B. via ElevenLabs erzeugte) Stimme hoch — Chatterbox klont sie. "}
CPU: paar Sek./Satz. Der Akzent der Referenz überträgt sich.
</p>
</div>
)}
{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.
Edge: native deutsche Stimmen, gratis & ohne Key. Cloud (Text Microsoft).
</p>
)}
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ function getSessionId(): string {
function readSettings() {
return {
engine: localStorage.getItem("mc_voice_engine") || "piper",
engine: localStorage.getItem("mc_voice_engine") || "elevenlabs",
voice: localStorage.getItem("mc_voice_voice") || "",
}
}
+9 -6
View File
@@ -265,9 +265,12 @@ def elevenlabs_list() -> list[dict]:
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())
# NUR eigene Stimmen (generated/cloned/professional) — Library-/premade-Stimmen sperrt das
# Free-Tier per API (402). So landet im Picker nie eine unbrauchbare Stimme.
return [
{"engine": "elevenlabs", "id": v["voice_id"], "label": v.get("name", v["voice_id"]), "clonable": False}
{"engine": "elevenlabs", "id": v["voice_id"], "label": v.get("name", v["voice_id"])}
for v in data.get("voices", [])
if v.get("category") != "premade"
]
except Exception: # noqa: BLE001
log.warning("ElevenLabs-Stimmenliste fehlgeschlagen", exc_info=True)
@@ -356,15 +359,15 @@ class TTSIn(BaseModel):
@app.get("/health")
def health() -> dict:
return {"ok": True, "stt_model": STT_MODEL,
"engines": ["edge", "piper", "chatterbox", "elevenlabs"],
"elevenlabs_key": bool(_eleven_key()),
"piper_voices": [v["id"] for v in piper_list()]}
"engines": ["elevenlabs", "edge"],
"elevenlabs_key": bool(_eleven_key())}
@app.get("/voices")
def voices() -> dict:
return {"voices": edge_list() + piper_list() + chatterbox_list() + elevenlabs_list(),
"default": {"engine": "piper", "voice": PIPER_DEFAULT}}
# Nur die genutzten Engines: ElevenLabs (eigene Stimme) + Edge (gratis, nativ-deutsch).
return {"voices": elevenlabs_list() + edge_list(),
"default": {"engine": "edge", "voice": EDGE_DEFAULT}}
@app.post("/stt")