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:
@@ -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
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+321
-331
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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,129 +74,58 @@ 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 className="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
<div>
|
<Sparkles className="h-3.5 w-3.5" /> Stimme
|
||||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Avatar</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
{voices.length === 0 ? (
|
||||||
{GALLERY.map((g) => {
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||||
const active = avatarUrl === g.url
|
Voice-Dienst nicht erreichbar — Stimmen werden geladen, sobald der Sidecar läuft.
|
||||||
return (
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{ENGINES.map((eng) => (
|
||||||
<button
|
<button
|
||||||
key={g.id}
|
key={eng}
|
||||||
onClick={() => pickGallery(g.url)}
|
onClick={() => pickEngine(eng)}
|
||||||
className={`flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors ${
|
className={`rounded-md border px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
active ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
|
engine === eng ? "border-primary/50 bg-primary/10 text-primary" : "border-border/40 hover:bg-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>
|
{ENGINE_LABEL[eng]}
|
||||||
<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>
|
</button>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
</div>
|
<select
|
||||||
</div>
|
value={voice}
|
||||||
|
onChange={(e) => saveVoice(engine, e.target.value)}
|
||||||
{/* Eigenes Modell */}
|
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"
|
||||||
<div className="space-y-2">
|
>
|
||||||
<input
|
{voicesForEngine.map((v) => (
|
||||||
ref={fileRef}
|
<option key={v.id} value={v.id}>{v.label}</option>
|
||||||
type="file"
|
))}
|
||||||
accept=".vrm,model/gltf-binary"
|
</select>
|
||||||
className="hidden"
|
<button
|
||||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) void onUpload(f) }}
|
onClick={playPreview}
|
||||||
/>
|
disabled={previewing || elKeyMissing}
|
||||||
<button
|
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"
|
||||||
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"
|
{previewing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
>
|
{previewing ? "Spielt …" : "Probe hören"}
|
||||||
<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>
|
</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
// Winziger IndexedDB-Wrapper, um ein hochgeladenes .vrm (ArrayBuffer) über Reloads hinweg zu
|
|
||||||
// behalten. localStorage scheidet aus (VRMs sind oft 5–40 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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user