Files
mission-control-v2/frontend/src/components/voice/AvatarPicker.tsx
T
Hitonabi 15d5598eec 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>
2026-06-28 02:15:27 +02:00

251 lines
10 KiB
TypeScript

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 (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 }
// Verifiziert: 200 + Access-Control-Allow-Origin:* (im Browser ladbar).
const GALLERY: GalleryItem[] = [
{ id: "sample-a", label: "VRoid Sample A", url: "https://raw.githubusercontent.com/madjin/vrm-samples/master/vroid/stable/AvatarSample_A.vrm", note: "Anime, weiblich" },
{ id: "sample-b", label: "VRoid Sample B", url: "https://raw.githubusercontent.com/madjin/vrm-samples/master/vroid/stable/AvatarSample_B.vrm", note: "Anime, männlich" },
{ id: "pixiv", label: "Pixiv Demo", url: "https://raw.githubusercontent.com/pixiv/three-vrm/dev/packages/three-vrm/examples/models/VRM1_Constraint_Twist_Sample.vrm", note: "VRM1-Testmodell" },
]
export const DEFAULT_AVATAR = GALLERY[0].url
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") || "elevenlabs")
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)
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/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)
try {
const r = await fetch("/api/voice/tts", {
method: "POST",
headers: { "Content-Type": "application/json" },
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())
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(() => {
if (localStorage.getItem("mc_voice_avatar_uploaded") === "1") {
loadUploadedVrm().then((buf) => {
if (buf) onAvatarChange(URL.createObjectURL(new Blob([buf], { type: "model/gltf-binary" })))
})
}
}, [onAvatarChange])
const pickGallery = (url: string) => {
localStorage.setItem("mc_voice_avatar_url", url)
localStorage.removeItem("mc_voice_avatar_uploaded")
onAvatarChange(url)
}
const onUpload = async (file: File) => {
const buf = await file.arrayBuffer()
await saveUploadedVrm(buf)
localStorage.setItem("mc_voice_avatar_uploaded", "1")
localStorage.removeItem("mc_voice_avatar_url")
onAvatarChange(URL.createObjectURL(new Blob([buf], { type: "model/gltf-binary" })))
}
const loadUrl = () => {
const u = urlInput.trim()
if (u) pickGallery(u)
}
// 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 || "")
}
const voicesForEngine = voices.filter((v) => v.engine === engine)
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
return (
<div className="space-y-5 text-sm">
{/* Galerie */}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Avatar</div>
<div className="space-y-1.5">
{GALLERY.map((g) => {
const active = avatarUrl === g.url
return (
<button
key={g.id}
onClick={() => pickGallery(g.url)}
className={`flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors ${
active ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
}`}
>
<span>
<span className="font-medium">{g.label}</span>
{g.note && <span className="ml-2 text-[11px] text-muted-foreground">{g.note}</span>}
</span>
{active && <Check className="h-4 w-4" />}
</button>
)
})}
</div>
</div>
{/* Eigenes Modell */}
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept=".vrm,model/gltf-binary"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onUpload(f) }}
/>
<button
onClick={() => fileRef.current?.click()}
className="flex w-full items-center gap-2 rounded-md border border-border/40 bg-background/40 px-3 py-2 hover:bg-accent transition-colors"
>
<Upload className="h-4 w-4" /> Eigenes .vrm hochladen
</button>
<div className="flex gap-1.5">
<input
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && loadUrl()}
placeholder="…oder VRM-URL einfügen"
className="min-w-0 flex-1 rounded-md border border-border/40 bg-background/40 px-2.5 py-2 text-xs outline-none focus:border-primary/50"
/>
<button onClick={loadUrl} className="rounded-md border border-border/40 bg-background/40 px-2.5 hover:bg-accent" title="Laden">
<Link2 className="h-4 w-4" />
</button>
</div>
<a
href="https://hub.vroid.com/en/characters"
target="_blank"
rel="noopener"
className="flex items-center gap-1.5 text-xs text-primary/80 hover:text-primary"
>
<ExternalLink className="h-3.5 w-3.5" /> Mehr Avatare auf VRoid Hub (kostenlos) herunterladen & hochladen
</a>
</div>
{/* Stimme */}
<div>
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" /> Stimme
</div>
{voices.length === 0 ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
Voice-Dienst nicht erreichbar Stimmen werden geladen, sobald der Sidecar läuft.
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{ENGINES.map((eng) => (
<button
key={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"
}`}
>
{ENGINE_LABEL[eng]}
</button>
))}
</div>
<select
value={voice}
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"
>
{voicesForEngine.map((v) => (
<option key={v.id} value={v.id}>{v.label}</option>
))}
</select>
<button
onClick={playPreview}
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: native deutsche Stimmen, gratis & ohne Key. Cloud (Text Microsoft).
</p>
)}
</div>
)}
</div>
</div>
)
}