Feat: festes Avatar-VRM (das eine Modell) + Avatar-Picker entfernt
Avatar fest auf /avatar.vrm (frontend/public/avatar.vrm → dist). Galerie/Upload/URL raus; AvatarPicker → reine VoiceControls (nur Stimme). vrmStore.ts gelöscht. avatar.vrm ist gitignored (23 MB, lizenz-/redistributionssensibel) — liegt auf der Box, nicht in git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,22 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Upload, Link2, ExternalLink, Check, Sparkles, Volume2, Loader2 } from "lucide-react"
|
||||
import { saveUploadedVrm, loadUploadedVrm } from "@/lib/voice/vrmStore"
|
||||
import { Sparkles, Volume2, Loader2 } from "lucide-react"
|
||||
|
||||
// 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
|
||||
// Stimm-Steuerung (Engine + Stimme + Probe). Der Avatar ist fest (kein Picker mehr).
|
||||
// Engines: ElevenLabs (eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage.
|
||||
|
||||
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("")
|
||||
export function VoiceControls() {
|
||||
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) => {
|
||||
@@ -42,9 +25,8 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
||||
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.
|
||||
// Stimmen holen. NIE auf leerer Stimme bleiben (ElevenLabs „Standardstimme" = Library-Voice, die das
|
||||
// Free-Tier per API sperrt) → erste echte Stimme der aktiven Engine automatisch wählen.
|
||||
useEffect(() => {
|
||||
fetch("/api/voice/voices")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
@@ -60,7 +42,6 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
||||
// 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)
|
||||
@@ -84,35 +65,6 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 || "")
|
||||
@@ -122,129 +74,58 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
|
||||
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 (
|
||||
<div className="text-sm">
|
||||
<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={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"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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" />}
|
||||
{ENGINE_LABEL[eng]}
|
||||
</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" />
|
||||
))}
|
||||
</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>
|
||||
{elKeyMissing && (
|
||||
<p className="text-[11px] text-amber-300">
|
||||
Keine ElevenLabs-Stimmen — Key in <code>~/.hermes/.env</code> fehlt, oder 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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user