Lucy v2 (Etappe 1.2): echte Bewegungs-Clips (.vrma) + lebendiges Gesicht

- @pixiv/three-vrm-animation: VRMA-Clips laden/abspielen (AnimationMixer)
- Ruhiger prozeduraler Leerlauf als Basis; echte Thinking-Pose blendet beim Nachdenken ein
- (.vrma sind Gesten, kein Ruhe-Loop -> nicht als Dauerschleife; Einmal-Einschuebe folgen)
- Blick-Sakkaden gegen eingefrorenes Gesicht
- VRMA-Clips: tk256ailab/vrm-viewer (MIT), siehe public/vrma/ATTRIBUTION.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-30 19:01:55 +02:00
parent ef1e7eb8e6
commit ac475ba509
12 changed files with 3936 additions and 53 deletions
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "lucy-desktop",
"version": "0.1.0",
"description": "Lucy — lokaler Sprach-Companion (Hermes-Hirn auf der Box, lokale Stimme auf der 9070 XT)",
"main": "out/main/index.js",
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview"
},
"dependencies": {
"@pixiv/three-vrm": "^3.4.0",
"@pixiv/three-vrm-animation": "^3.5.4",
"@react-three/drei": "^9.114.0",
"@react-three/fiber": "^8.17.10",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"three": "^0.169.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.169.0",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^33.2.1",
"electron-vite": "^3.0.0",
"typescript": "^5.6.3",
"vite": "^6.0.3"
},
"allowScripts": {
"electron": true,
"esbuild": true
}
}
@@ -0,0 +1,11 @@
# VRMA-Animationen — Quelle & Lizenz
Die `.vrma`-Bewegungs-Clips in diesem Ordner stammen aus:
**tk256ailab/vrm-viewer** — https://github.com/tk256ailab/vrm-viewer
Lizenz: **MIT** · Copyright (c) 2025 TK256
Die MIT-Lizenz erlaubt Nutzung, Modifikation und Weitergabe; der Copyright-
Hinweis ist beizubehalten (= diese Datei).
Genutzte Clips: Relax, Thinking, LookAround, Surprised, Sad, Clapping, Goodbye, Blush.
@@ -2,16 +2,24 @@ import { Canvas, useFrame } from "@react-three/fiber"
import { OrbitControls } from "@react-three/drei"
import { useEffect, useRef, useState, type MutableRefObject } from "react"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { Object3D, Vector3 } from "three"
import { Object3D, Vector3, AnimationMixer, LoopPingPong, type AnimationAction } from "three"
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
import { VRMAnimationLoaderPlugin, createVRMAnimationClip, type VRMAnimation } from "@pixiv/three-vrm-animation"
import type { Emotion } from "../lib/voice/sentiment"
// 3D-Avatar (VRM): Lippensync (Mund folgt TTS-Pegel), Blinzeln, Mimik, lebendige Idle-/Sprech-Bewegung.
// Liest Live-Werte aus Mutable-Refs (kein Re-Render pro Frame).
// 3D-Avatar (VRM). BEWEGUNG: echte Mocap-Clips (.vrma) je nach Zustand, weich ueberblendet
// (Relax/LookAround im Leerlauf, Thinking beim Nachdenken). Darueber gelegt: Atmen + Sprech-Nicken
// (additiv), Lippensync (echte Vokal-Mundformen), Blick (ohne Schielen), Mimik, Blinzeln.
// Faellt sicher auf die alte prozedurale Bewegung zurueck, falls Clips nicht laden.
type LevelRef = MutableRefObject<{ current: number; aa?: number; ih?: number; ou?: number }>
type EmotionRef = MutableRefObject<Emotion>
// Welche Clips geladen werden (Dateien unter public/vrma/, MIT-Lizenz, siehe ATTRIBUTION.md).
// Die .vrma sind GESTEN (kein echter Ruhe-Loop) -> nur fuer klare Zustaende/Einschuebe nutzen,
// nicht als Dauerschleife. Ruhiger Leerlauf laeuft prozedural. Weitere Clips liegen auf Platte bereit.
const CLIPS = ["Thinking"] as const
const REST: Record<string, [number, number, number]> = {
leftUpperArm: [0, 0, 1.2], rightUpperArm: [0, 0, -1.2], leftLowerArm: [0, -0.2, 0], rightLowerArm: [0, 0.2, 0],
}
@@ -19,28 +27,42 @@ function setBone(vrm: VRM, name: string, x: number, y: number, z: number) {
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
if (b) b.rotation.set(x, y, z)
}
function addBone(vrm: VRM, name: string, x: number, y: number, z: number) {
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
if (b) { b.rotation.x += x; b.rotation.y += y; b.rotation.z += z }
}
function applyRestPose(vrm: VRM) {
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
vrm.humanoid?.update()
}
function applyIdle(vrm: VRM, t: number, level: number, lookYaw: number, lookPitch: number, lean: number, speak: number) {
// ADDITIVE Lebendigkeit OBEN AUF dem Clip: dezentes Atmen, Sprech-Nicken, Vorlehnen beim Zuhoeren.
// Kleine Amplituden -> ergaenzt den Clip, kaempft nicht mit ihm.
function addLife(vrm: VRM, t: number, speak: number, lean: number) {
const breathe = Math.sin(t * 1.6)
addBone(vrm, "spine", breathe * 0.012 + lean * 0.05, 0, 0)
addBone(vrm, "chest", breathe * 0.01, 0, 0)
const beat = Math.sin(t * 2.4) + Math.sin(t * 3.7) * 0.5
const nod = speak * (0.018 * beat + 0.012 * Math.sin(t * 1.3))
addBone(vrm, "neck", nod * 0.4 + lean * 0.03, 0, 0)
addBone(vrm, "head", nod, 0, 0)
}
// Fallback (keine Clips): die fruehere rein prozedurale Bewegung, leicht abgespeckt.
function applyIdleProcedural(vrm: VRM, t: number, level: number, lean: number, speak: number) {
const breathe = Math.sin(t * 1.6), sway = Math.sin(t * 0.45), weight = Math.sin(t * 0.32), weight2 = Math.sin(t * 0.21 + 1)
const emph = Math.min(1, level * 1.6)
// Sprech-Rhythmus: organischer Doppel-Takt -> Kopf nickt/betont auf der Stimme
const beat = Math.sin(t * 2.4) + Math.sin(t * 3.7) * 0.5
const nod = (emph * 0.05 + speak * 0.02) * beat + emph * Math.sin(t * 1.3) * 0.03
const tilt = Math.sin(t * 0.7 + 0.5) * 0.03 + speak * Math.sin(t * 0.9) * 0.045 // gelegentliches Kopf-Neigen
const tilt = Math.sin(t * 0.7 + 0.5) * 0.03 + speak * Math.sin(t * 0.9) * 0.045
setBone(vrm, "hips", 0, weight * 0.06, weight * 0.04 + weight2 * 0.02)
setBone(vrm, "spine", breathe * 0.03 + lean * 0.08 + speak * emph * 0.03, sway * 0.03, -weight * 0.04)
setBone(vrm, "chest", breathe * 0.025 + lean * 0.025, sway * 0.022, weight2 * 0.012)
setBone(vrm, "upperChest", breathe * 0.018, sway * 0.01, 0)
setBone(vrm, "neck", lookPitch * 0.4 + nod * 0.5, lookYaw * 0.4, tilt * 0.5)
setBone(vrm, "head", lookPitch * 0.6 + nod + Math.sin(t * 0.6) * 0.02,
lookYaw * 0.6 + Math.sin(t * 0.27) * 0.035, tilt + Math.sin(t * 0.5) * 0.025)
// Arme: lebhafterer Schwung um die Ruhepose + dezente Sprech-Geste — SICHER (kleine Amplituden)
setBone(vrm, "neck", nod * 0.5, 0, tilt * 0.5)
setBone(vrm, "head", nod + Math.sin(t * 0.6) * 0.02, Math.sin(t * 0.27) * 0.035, tilt + Math.sin(t * 0.5) * 0.025)
const armSwing = Math.sin(t * 0.8) * 0.05 + speak * Math.sin(t * 1.6) * 0.045
const lift = speak * emph * 0.07 // hebt die Arme beim Reden minimal an (mit den Händen reden)
const lift = speak * emph * 0.07
setBone(vrm, "leftUpperArm", -lift, 0, 1.16 + armSwing + weight * 0.05)
setBone(vrm, "rightUpperArm", -lift, 0, -1.16 - armSwing + weight * 0.05)
setBone(vrm, "leftLowerArm", 0, -0.18 - Math.sin(t * 0.8) * 0.04 - speak * 0.07, 0)
@@ -58,12 +80,16 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
const [vrm, setVrm] = useState<VRM | null>(null)
const smooth = useRef<Record<string, number>>({})
const blink = useRef({ t: 0, next: 3, active: 0 })
const motion = useRef({ yaw: 0, pitch: 0, tYaw: 0, tPitch: 0, t: 0, next: 2.5, lean: 0 })
const think = useRef(0) // 0..1 Nachdenk-Pose-Blend
const speak = useRef(0) // 0..1 Sprech-Lebendigkeit
const lean = useRef(0) // 0..1 Vorlehnen (Zuhoeren/lauter Ton)
const gaze = useRef(new Object3D()) // weit entfernter Blickpunkt (gegen Schielen bei naher Kamera)
const headPos = useRef(new Vector3())
const camPos = useRef(new Vector3())
// Bewegungs-Clips
const mixer = useRef<AnimationMixer | null>(null)
const actions = useRef<Record<string, AnimationAction>>({})
const thinkW = useRef(0) // 0..1 Einblendung der Thinking-Pose
const sac = useRef({ x: 0, y: 0, tx: 0, ty: 0, t: 0, next: 1.5 }) // Blick-Mikrobewegung (Sakkaden)
useEffect(() => {
let disposed = false, loaded: VRM | null = null
@@ -78,66 +104,87 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
v.scene.rotation.y = Math.PI
applyRestPose(v)
loaded = v; setVrm(v)
// Bewegungs-Clips laden + Mixer aufsetzen (additiv-frei: voller Body-Override durch den Clip)
const mx = new AnimationMixer(v.scene)
mixer.current = mx
const aLoader = new GLTFLoader()
aLoader.register((parser) => new VRMAnimationLoaderPlugin(parser))
for (const name of CLIPS) {
aLoader.load(`/vrma/${name}.vrma`, (g) => {
if (disposed) return
const anims = g.userData.vrmAnimations as VRMAnimation[] | undefined
if (!anims?.[0]) return
const clip = createVRMAnimationClip(anims[0], v)
const action = mx.clipAction(clip)
action.setLoop(LoopPingPong, Infinity) // vor-zurueck -> kein harter Loop-Sprung
action.timeScale = 0.85 // etwas ruhiger
action.enabled = true
action.setEffectiveWeight(0) // startet aus; blendet nur bei Bedarf ein
action.play()
actions.current[name] = action
}, undefined, (e) => console.warn(`VRMA ${name} nicht geladen:`, e))
}
}, undefined, (err) => { console.error("VRM-Load:", err); onError("Avatar konnte nicht geladen werden.") })
return () => { disposed = true; if (loaded) VRMUtils.deepDispose(loaded.scene); setVrm(null) }
return () => {
disposed = true
mixer.current?.stopAllAction(); mixer.current = null; actions.current = {}
if (loaded) VRMUtils.deepDispose(loaded.scene)
setVrm(null)
}
}, [url, onError])
useFrame((state, delta) => {
if (!vrm) return
const st = status.current
const tc = state.clock.elapsedTime
const target = audioLevel.current?.current ?? 0
// Blick zur Kamera, aber auf einen WEIT entfernten Punkt in Kamerarichtung -> Augen laufen
// nicht zusammen (kein Schielen bei naher Kamera), wirken aber weiter "auf dich gerichtet".
const spk = (speak.current += (((st === "speaking") ? 1 : 0) - speak.current) * Math.min(1, delta * 4))
const attentive = st === "listening" ? 1 : 0
lean.current += ((Math.min(1, target * 1.6) + attentive * 0.5) - lean.current) * Math.min(1, delta * 3)
// Bewegung: ruhiger PROZEDURALER Leerlauf als Basis (Atmen/leichtes Wiegen); fuers Nachdenken
// blendet die echte Thinking-Pose ein. Die .vrma sind Gesten -> taugen nicht als Dauerschleife;
// echte Bewegungs-Einschuebe (Strecken/Umschauen) kommen spaeter als gelegentliche Einmal-Gesten.
const mx = mixer.current
const thinkAction = mx ? actions.current["Thinking"] : undefined
const wantThink = (st === "thinking" || st === "transcribing") ? 1 : 0
thinkW.current += (wantThink - thinkW.current) * Math.min(1, delta * 4)
if (mx && thinkAction) { thinkAction.setEffectiveWeight(thinkW.current); mx.update(delta) }
if (thinkAction && thinkW.current > 0.05) {
addLife(vrm, tc, spk, lean.current) // Thinking-Clip + Atmen/Nicken obendrauf
} else {
applyIdleProcedural(vrm, tc, target, lean.current, spk) // ruhiger Leerlauf
}
// Blick: weit entfernter Punkt in Kamerarichtung -> Augen parallel (kein Schielen), wirkt "auf dich".
if (vrm.lookAt) {
const headNode = vrm.humanoid?.getNormalizedBoneNode("head")
const hp = headPos.current
if (headNode) headNode.getWorldPosition(hp); else hp.set(0, 1.3, 0)
const cp = camPos.current.copy(state.camera.position)
gaze.current.position.copy(cp).sub(hp).multiplyScalar(4).add(hp) // 4x = Blickpunkt weit dahinter
gaze.current.position.copy(cp).sub(hp).multiplyScalar(4).add(hp)
// dezente Blick-Mikrobewegung (Sakkaden) -> das Gesicht wirkt nicht eingefroren
const s = sac.current
s.t += delta
if (s.t > s.next) {
s.tx = (Math.random() - 0.5) * 0.22; s.ty = (Math.random() - 0.5) * 0.12
s.t = 0; s.next = 0.8 + Math.random() * 2.4
}
s.x += (s.tx - s.x) * Math.min(1, delta * 10)
s.y += (s.ty - s.y) * Math.min(1, delta * 10)
gaze.current.position.x += s.x; gaze.current.position.y += s.y
vrm.lookAt.target = gaze.current
}
const st = status.current
const spk = (speak.current += (((st === "speaking") ? 1 : 0) - speak.current) * Math.min(1, delta * 4))
const m = motion.current
m.t += delta
if (m.t > m.next) {
// beim Sprechen häufiger + eher zur Kamera (engagiert), sonst lebhaftes Umschauen
const range = spk > 0.5 ? 0.28 : 0.6
m.tYaw = (Math.random() - 0.5) * range; m.tPitch = (Math.random() - 0.5) * 0.3
m.t = 0; m.next = (spk > 0.5 ? 1.4 : 1.9) + Math.random() * 2.6
}
m.yaw += (m.tYaw - m.yaw) * Math.min(1, delta * 1.8)
m.pitch += (m.tPitch - m.pitch) * Math.min(1, delta * 1.8)
// Nachdenk-Blend hoch wenn 'thinking'/'transcribing', sonst runter
const wantThink = (st === "thinking" || st === "transcribing") ? 1 : 0
think.current += (wantThink - think.current) * Math.min(1, delta * 2.5)
const th = think.current
const attentive = st === "listening" ? 1 : 0
m.lean += ((Math.min(1, target * 1.6) + attentive * 0.5) - m.lean) * Math.min(1, delta * 3)
// Beim Nachdenken: Blick zur Seite/oben (pensiv, weg von der Kamera) statt zufälligem Umschauen
const tc = state.clock.elapsedTime
// Nachdenken: Blick zur Seite/leicht nach oben (pensiv, weg von der Kamera) + Kopf-Neigung.
// KEINE Arm-Bewegung (risikolos; die Hand-zum-Kinn-Pose liess sich blind nicht sauber treffen).
const yaw = m.yaw + th * (-0.38 + Math.sin(tc * 0.5) * 0.05)
const pitch = m.pitch + th * (-0.12)
applyIdle(vrm, tc, target * (1 - th * 0.5), yaw, pitch, m.lean, spk)
if (th > 0.02) {
// sanftes nachdenkliches Kopf-Neigen (nur Roll dazu, Idle-Kopfbewegung bleibt erhalten)
setBone(vrm, "head", pitch * 0.6 + Math.sin(tc * 0.6) * 0.015, yaw * 0.6 + Math.sin(tc * 0.27) * 0.025,
Math.sin(tc * 0.5) * 0.02 + th * 0.14)
vrm.humanoid?.update()
}
const em = vrm.expressionManager
if (em) {
// Lippensync v2: echte Vokal-Mundformen (aa/ih/ou) aus der Audio-Analyse statt nur "Mund auf".
// Fallback auf den Gesamtpegel (target) fuer aa, falls keine Mundform-Werte vorliegen.
const lv = audioLevel.current
const visTargets: Record<string, number> = {
aa: lv?.aa ?? target, ih: lv?.ih ?? 0, ou: lv?.ou ?? 0,
}
const visTargets: Record<string, number> = { aa: lv?.aa ?? target, ih: lv?.ih ?? 0, ou: lv?.ou ?? 0 }
for (const v of ["aa", "ih", "ou"]) {
const cv = smooth.current[v] ?? 0
const nv = cv + (visTargets[v] - cv) * Math.min(1, delta * 14) // schnell genug fuers Sprechen
const nv = cv + (visTargets[v] - cv) * Math.min(1, delta * 14)
smooth.current[v] = nv; em.setValue(v, nv)
}
const want = EMO_TO_EXPR[emotion.current]