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:
+1
-1
File diff suppressed because one or more lines are too long
+152
-152
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="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>
|
||||
|
||||
@@ -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.
|
||||
useEffect(() => {
|
||||
fetch("/api/voice/reference").then((r) => r.json()).then((d) => setRefActive(!!d.active)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
const saveVoice = (eng: string, v: string) => {
|
||||
setEngine(eng); setVoice(v)
|
||||
localStorage.setItem("mc_voice_engine", eng)
|
||||
localStorage.setItem("mc_voice_voice", v)
|
||||
}
|
||||
|
||||
// „Probe hören": synthetisiert einen festen Satz mit der aktuellen Engine+Stimme und spielt ihn ab.
|
||||
// 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/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
|
||||
}, [])
|
||||
|
||||
// „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>
|
||||
|
||||
@@ -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") || "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user