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/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
.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="icon" type="image/svg+xml" href="/favicon.svg" />
<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">
</head>
<body>
+8 -127
View File
@@ -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,71 +74,7 @@ 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 (
<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="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>
@@ -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 ? "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.
Keine ElevenLabs-Stimmen Key in <code>~/.hermes/.env</code> fehlt, oder keine eigene Stimme angelegt.
</p>
)}
{engine === "edge" && (
@@ -245,6 +127,5 @@ export function AvatarPicker({ avatarUrl, onAvatarChange }: {
</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 { 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 { 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> = {
idle: "Bereit — halte zum Sprechen",
listening: "Höre zu …",
@@ -15,14 +18,10 @@ const STATUS_LABEL: Record<string, string> = {
}
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 } =
useVoiceAgent()
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).
useEffect(() => {
@@ -49,7 +48,7 @@ export function VoiceView() {
{/* 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="flex-1 min-h-0">
<Avatar3D url={avatarUrl} audioLevel={audioLevel} emotion={emotion} />
<Avatar3D url={FIXED_AVATAR} audioLevel={audioLevel} emotion={emotion} />
</div>
{/* Status + Push-to-talk */}
@@ -85,8 +84,8 @@ export function VoiceView() {
{/* Seitenspalte: Einstellungen + Transcript */}
<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%]">
<AvatarPicker avatarUrl={avatarUrl} onAvatarChange={onAvatarChange} />
<div className="rounded-xl border border-border/40 bg-card/40 p-4">
<VoiceControls />
</div>
<div className="flex min-h-0 flex-1 flex-col rounded-xl border border-border/40 bg-card/40">