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:
Hitonabi
2026-06-28 02:34:35 +02:00
parent b4a8f92cfa
commit 49c9e27502
7 changed files with 388 additions and 548 deletions
+5
View File
@@ -7,6 +7,11 @@ __pycache__/
frontend/node_modules/ frontend/node_modules/
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/ # frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
frontend/public/avatar.vrm
frontend/dist/avatar.vrm
# Env / local # Env / local
*.env *.env
.DS_Store .DS_Store
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-JmrFoH9g.js"></script> <script type="module" crossorigin src="/assets/index-BiSwZCwF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css"> <link rel="stylesheet" crossorigin href="/assets/index-jNLzkOWh.css">
</head> </head>
<body> <body>
+8 -127
View File
@@ -1,39 +1,22 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { Upload, Link2, ExternalLink, Check, Sparkles, Volume2, Loader2 } from "lucide-react" import { Sparkles, Volume2, Loader2 } from "lucide-react"
import { saveUploadedVrm, loadUploadedVrm } from "@/lib/voice/vrmStore"
// Avatar selbst aussuchen (Galerie + .vrm-Upload + URL) + Stimm-Auswahl. // Stimm-Steuerung (Engine + Stimme + Probe). Der Avatar ist fest (kein Picker mehr).
// Stimm-Engines: ElevenLabs (Premium, eigene Stimme) + Edge (gratis, nativ-deutsch). Auswahl in localStorage. // Engines: ElevenLabs (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 } interface Voice { engine: string; id: string; label: string }
// Nur die zwei Engines, die wir wirklich nutzen.
const ENGINES = ["elevenlabs", "edge"] as const const ENGINES = ["elevenlabs", "edge"] as const
const ENGINE_LABEL: Record<string, string> = { const ENGINE_LABEL: Record<string, string> = {
elevenlabs: "ElevenLabs (premium)", elevenlabs: "ElevenLabs (premium)",
edge: "Edge (natürlich · gratis)", edge: "Edge (natürlich · gratis)",
} }
export function AvatarPicker({ avatarUrl, onAvatarChange }: { export function VoiceControls() {
avatarUrl: string; onAvatarChange: (url: string) => void
}) {
const [urlInput, setUrlInput] = useState("")
const [voices, setVoices] = useState<Voice[]>([]) const [voices, setVoices] = useState<Voice[]>([])
const [engine, setEngine] = useState(localStorage.getItem("mc_voice_engine") || "elevenlabs") 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 fileRef = useRef<HTMLInputElement>(null)
const previewAudio = useRef<HTMLAudioElement | null>(null) const previewAudio = useRef<HTMLAudioElement | null>(null)
const saveVoice = (eng: string, v: string) => { const saveVoice = (eng: string, v: string) => {
@@ -42,9 +25,8 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
localStorage.setItem("mc_voice_voice", v) localStorage.setItem("mc_voice_voice", v)
} }
// Stimmen vom Sidecar holen. WICHTIG: nie auf leerer Stimme bleiben ElevenLabs' „Standardstimme" // Stimmen holen. NIE auf leerer Stimme bleiben (ElevenLabs „Standardstimme" = Library-Voice, die das
// (Rachel) ist eine Library-Voice, die das Free-Tier per API NICHT nutzen darf (402). Darum bei // Free-Tier per API sperrt) → erste echte Stimme der aktiven Engine automatisch wählen.
// leerer Auswahl automatisch die erste echte Stimme der aktiven Engine wählen.
useEffect(() => { useEffect(() => {
fetch("/api/voice/voices") fetch("/api/voice/voices")
.then((r) => (r.ok ? r.json() : Promise.reject())) .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 // 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)
@@ -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 pickEngine = (eng: string) => {
const first = voices.find((v) => v.engine === eng) const first = voices.find((v) => v.engine === eng)
saveVoice(eng, first?.id || "") saveVoice(eng, first?.id || "")
@@ -122,71 +74,7 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0 const elKeyMissing = engine === "elevenlabs" && voicesForEngine.length === 0
return ( return (
<div className="space-y-5 text-sm"> <div className="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"> <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 <Sparkles className="h-3.5 w-3.5" /> Stimme
</div> </div>
@@ -226,15 +114,9 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
{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 && ( {elKeyMissing && (
<p className="text-[11px] text-amber-300"> <p className="text-[11px] text-amber-300">
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder noch keine eigene Stimme angelegt. Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder keine eigene Stimme angelegt.
</p> </p>
)} )}
{engine === "edge" && ( {engine === "edge" && (
@@ -245,6 +127,5 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
</div> </div>
)} )}
</div> </div>
</div>
) )
} }
-35
View File
@@ -1,35 +0,0 @@
// Winziger IndexedDB-Wrapper, um ein hochgeladenes .vrm (ArrayBuffer) über Reloads hinweg zu
// behalten. localStorage scheidet aus (VRMs sind oft 540 MB). Ein einziger Record genügt.
const DB = "mc2-voice"
const STORE = "avatar"
const KEY = "uploaded-vrm"
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB, 1)
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
export async function saveUploadedVrm(buf: ArrayBuffer): Promise<void> {
const db = await open()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, "readwrite")
tx.objectStore(STORE).put(buf, KEY)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
export async function loadUploadedVrm(): Promise<ArrayBuffer | null> {
const db = await open()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE, "readonly")
const req = tx.objectStore(STORE).get(KEY)
req.onsuccess = () => resolve((req.result as ArrayBuffer) ?? null)
req.onerror = () => reject(req.error)
})
}
+8 -9
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react" import { useEffect, useRef } from "react"
import { Mic, RotateCcw, Loader2, Volume2 } from "lucide-react" import { Mic, RotateCcw, Loader2, Volume2 } from "lucide-react"
import { Avatar3D } from "@/components/voice/Avatar3D" import { Avatar3D } from "@/components/voice/Avatar3D"
import { AvatarPicker, DEFAULT_AVATAR } from "@/components/voice/AvatarPicker" import { VoiceControls } from "@/components/voice/AvatarPicker"
import { useVoiceAgent } from "@/lib/voice/useVoiceAgent" import { useVoiceAgent } from "@/lib/voice/useVoiceAgent"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Fester Avatar (das eine, gewählte VRM). Liegt unter frontend/public/avatar.vrm → /avatar.vrm.
const FIXED_AVATAR = "/avatar.vrm"
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
idle: "Bereit — halte zum Sprechen", idle: "Bereit — halte zum Sprechen",
listening: "Höre zu …", listening: "Höre zu …",
@@ -15,14 +18,10 @@ const STATUS_LABEL: Record<string, string> = {
} }
export function VoiceView() { export function VoiceView() {
const [avatarUrl, setAvatarUrl] = useState(
() => localStorage.getItem("mc_voice_avatar_url") || DEFAULT_AVATAR,
)
const { status, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset } = const { status, messages, error, recording, audioLevel, emotion, pressStart, pressEnd, reset } =
useVoiceAgent() useVoiceAgent()
const holding = useRef(false) const holding = useRef(false)
const onAvatarChange = useCallback((url: string) => setAvatarUrl(url), [])
// Push-to-talk per Leertaste (solange der Sprechen-Tab fokussiert ist und kein Eingabefeld aktiv). // Push-to-talk per Leertaste (solange der Sprechen-Tab fokussiert ist und kein Eingabefeld aktiv).
useEffect(() => { useEffect(() => {
@@ -49,7 +48,7 @@ export function VoiceView() {
{/* Avatar-Bühne */} {/* Avatar-Bühne */}
<div className="relative flex min-w-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/30 overflow-hidden"> <div className="relative flex min-w-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/30 overflow-hidden">
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<Avatar3D url={avatarUrl} audioLevel={audioLevel} emotion={emotion} /> <Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} />
</div> </div>
{/* Status + Push-to-talk */} {/* Status + Push-to-talk */}
@@ -85,8 +84,8 @@ export function VoiceView() {
{/* Seitenspalte: Einstellungen + Transcript */} {/* Seitenspalte: Einstellungen + Transcript */}
<div className="flex w-80 shrink-0 flex-col gap-4"> <div className="flex w-80 shrink-0 flex-col gap-4">
<div className="rounded-xl border border-border/40 bg-card/40 p-4 overflow-y-auto scrollbar-thin max-h-[55%]"> <div className="rounded-xl border border-border/40 bg-card/40 p-4">
<AvatarPicker avatarUrl={avatarUrl} onAvatarChange={onAvatarChange} /> <VoiceControls />
</div> </div>
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40"> <div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40">