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="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-Crcfqytj.js"></script>
|
<script type="module" crossorigin src="/assets/index-CS3v-0XV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,9 +2,8 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import { Upload, Link2, ExternalLink, Check, Sparkles, Volume2, Loader2 } 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 (Galerie + .vrm-Upload + URL) + Stimm-Auswahl.
|
||||||
// hochladen + per URL laden + VRoid-Hub-Link. Dazu die Stimm-Auswahl (Engine + Stimme).
|
// Stimm-Engines: ElevenLabs (Premium, eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage.
|
||||||
// Auswahl bleibt in localStorage / IndexedDB erhalten.
|
|
||||||
|
|
||||||
interface GalleryItem { id: string; label: string; url: string; note?: string }
|
interface GalleryItem { id: string; label: string; url: string; note?: string }
|
||||||
|
|
||||||
@@ -17,46 +16,51 @@ const GALLERY: GalleryItem[] = [
|
|||||||
|
|
||||||
export const DEFAULT_AVATAR = GALLERY[0].url
|
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 }: {
|
export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
||||||
avatarUrl: string; onAvatarChange: (url: string) => void
|
avatarUrl: string; onAvatarChange: (url: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [urlInput, setUrlInput] = useState("")
|
const [urlInput, setUrlInput] = useState("")
|
||||||
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") || "elevenlabs")
|
||||||
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
|
const [voice, setVoice] = useState(localStorage.getItem("mc_voice_voice") || "")
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [previewing, setPreviewing] = useState(false)
|
||||||
const [refActive, setRefActive] = useState(false)
|
|
||||||
const [refUploading, setRefUploading] = useState(false)
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null)
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
const refFileRef = useRef<HTMLInputElement>(null)
|
|
||||||
const previewAudio = useRef<HTMLAudioElement | null>(null)
|
const previewAudio = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
// Aktive Chatterbox-Klon-Referenz anzeigen.
|
const saveVoice = (eng: string, v: string) => {
|
||||||
useEffect(() => {
|
setEngine(eng); setVoice(v)
|
||||||
fetch("/api/voice/reference").then((r) => r.json()).then((d) => setRefActive(!!d.active)).catch(() => {})
|
localStorage.setItem("mc_voice_engine", eng)
|
||||||
}, [])
|
localStorage.setItem("mc_voice_voice", v)
|
||||||
|
|
||||||
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.
|
// 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 () => {
|
const playPreview = async () => {
|
||||||
if (previewing) return
|
if (previewing) return
|
||||||
setPreviewing(true)
|
setPreviewing(true)
|
||||||
@@ -64,10 +68,7 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
|||||||
const r = await fetch("/api/voice/tts", {
|
const r = await fetch("/api/voice/tts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ text: "Hallo! So klingt diese Stimme auf Deutsch.", engine, voice }),
|
||||||
text: "Hallo! Ich bin deine Assistentin. So klingt diese Stimme auf Deutsch.",
|
|
||||||
engine, voice,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!r.ok) throw new Error(`TTS ${r.status}`)
|
if (!r.ok) throw new Error(`TTS ${r.status}`)
|
||||||
const url = URL.createObjectURL(await r.blob())
|
const url = URL.createObjectURL(await r.blob())
|
||||||
@@ -92,14 +93,6 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
|||||||
}
|
}
|
||||||
}, [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) => {
|
const pickGallery = (url: string) => {
|
||||||
localStorage.setItem("mc_voice_avatar_url", url)
|
localStorage.setItem("mc_voice_avatar_url", url)
|
||||||
localStorage.removeItem("mc_voice_avatar_uploaded")
|
localStorage.removeItem("mc_voice_avatar_uploaded")
|
||||||
@@ -119,16 +112,14 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
|||||||
if (u) pickGallery(u)
|
if (u) pickGallery(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveVoice = (eng: string, v: string) => {
|
// Beim Engine-Wechsel direkt die erste Stimme der neuen Engine wählen (nie leer/„Standardstimme").
|
||||||
setEngine(eng); setVoice(v)
|
const pickEngine = (eng: string) => {
|
||||||
localStorage.setItem("mc_voice_engine", eng)
|
const first = voices.find((v) => v.engine === eng)
|
||||||
localStorage.setItem("mc_voice_voice", v)
|
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 voicesForEngine = voices.filter((v) => v.engine === engine)
|
||||||
|
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 text-sm">
|
<div className="space-y-5 text-sm">
|
||||||
@@ -206,15 +197,15 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{enginesAvail.map((eng) => (
|
{ENGINES.map((eng) => (
|
||||||
<button
|
<button
|
||||||
key={eng}
|
key={eng}
|
||||||
onClick={() => saveVoice(eng, "")}
|
onClick={() => pickEngine(eng)}
|
||||||
className={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
|
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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,59 +214,32 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
|||||||
onChange={(e) => saveVoice(engine, e.target.value)}
|
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"
|
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) => (
|
{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>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={playPreview}
|
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"
|
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 ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
{previewing ? "Spielt …" : "Probe hören"}
|
{previewing ? "Spielt …" : "Probe hören"}
|
||||||
</button>
|
</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" && (
|
{engine === "edge" && (
|
||||||
<p className="text-[11px] text-muted-foreground">
|
<p className="text-[11px] text-muted-foreground">
|
||||||
Edge-TTS: native deutsche Azure-Stimmen — sehr natürlich, kein Akzent, gratis & ohne Key.
|
Edge: native deutsche Stimmen, gratis & ohne Key. Cloud (Text → Microsoft).
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function getSessionId(): string {
|
|||||||
|
|
||||||
function readSettings() {
|
function readSettings() {
|
||||||
return {
|
return {
|
||||||
engine: localStorage.getItem("mc_voice_engine") || "piper",
|
engine: localStorage.getItem("mc_voice_engine") || "elevenlabs",
|
||||||
voice: localStorage.getItem("mc_voice_voice") || "",
|
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})
|
req = urllib.request.Request("https://api.elevenlabs.io/v1/voices", headers={"xi-api-key": key})
|
||||||
with urllib.request.urlopen(req, timeout=10) as r:
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
data = json.loads(r.read())
|
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 [
|
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", [])
|
for v in data.get("voices", [])
|
||||||
|
if v.get("category") != "premade"
|
||||||
]
|
]
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
log.warning("ElevenLabs-Stimmenliste fehlgeschlagen", exc_info=True)
|
log.warning("ElevenLabs-Stimmenliste fehlgeschlagen", exc_info=True)
|
||||||
@@ -356,15 +359,15 @@ 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": ["edge", "piper", "chatterbox", "elevenlabs"],
|
"engines": ["elevenlabs", "edge"],
|
||||||
"elevenlabs_key": bool(_eleven_key()),
|
"elevenlabs_key": bool(_eleven_key())}
|
||||||
"piper_voices": [v["id"] for v in piper_list()]}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/voices")
|
@app.get("/voices")
|
||||||
def voices() -> dict:
|
def voices() -> dict:
|
||||||
return {"voices": edge_list() + piper_list() + chatterbox_list() + elevenlabs_list(),
|
# Nur die genutzten Engines: ElevenLabs (eigene Stimme) + Edge (gratis, nativ-deutsch).
|
||||||
"default": {"engine": "piper", "voice": PIPER_DEFAULT}}
|
return {"voices": elevenlabs_list() + edge_list(),
|
||||||
|
"default": {"engine": "edge", "voice": EDGE_DEFAULT}}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/stt")
|
@app.post("/stt")
|
||||||
|
|||||||
Reference in New Issue
Block a user