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 = { 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([]) 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(null) const previewAudio = useRef(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 (
{/* Galerie */}
Avatar
{GALLERY.map((g) => { const active = avatarUrl === g.url return ( ) })}
{/* Eigenes Modell */}
{ const f = e.target.files?.[0]; if (f) void onUpload(f) }} />
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" />
Mehr Avatare auf VRoid Hub (kostenlos) → herunterladen & hochladen
{/* Stimme */}
Stimme
{voices.length === 0 ? (
Voice-Dienst nicht erreichbar — Stimmen werden geladen, sobald der Sidecar läuft.
) : (
{ENGINES.map((eng) => ( ))}
{engine === "elevenlabs" && !elKeyMissing && (

ElevenLabs: deine eigene Stimme, sauberes Deutsch, schnell. Nutze NUR eigene Stimmen — Library-Stimmen sperrt das Free-Tier per API.

)} {elKeyMissing && (

Keine ElevenLabs-Stimmen — Key in ~/.hermes/.env fehlt, oder noch keine eigene Stimme angelegt.

)} {engine === "edge" && (

Edge: native deutsche Stimmen, gratis & ohne Key. Cloud (Text → Microsoft).

)}
)}
) }