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:
Generated
+3790
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,16 +2,24 @@ import { Canvas, useFrame } from "@react-three/fiber"
|
|||||||
import { OrbitControls } from "@react-three/drei"
|
import { OrbitControls } from "@react-three/drei"
|
||||||
import { useEffect, useRef, useState, type MutableRefObject } from "react"
|
import { useEffect, useRef, useState, type MutableRefObject } from "react"
|
||||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
|
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 { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm"
|
||||||
|
import { VRMAnimationLoaderPlugin, createVRMAnimationClip, type VRMAnimation } from "@pixiv/three-vrm-animation"
|
||||||
import type { Emotion } from "../lib/voice/sentiment"
|
import type { Emotion } from "../lib/voice/sentiment"
|
||||||
|
|
||||||
// 3D-Avatar (VRM): Lippensync (Mund folgt TTS-Pegel), Blinzeln, Mimik, lebendige Idle-/Sprech-Bewegung.
|
// 3D-Avatar (VRM). BEWEGUNG: echte Mocap-Clips (.vrma) je nach Zustand, weich ueberblendet
|
||||||
// Liest Live-Werte aus Mutable-Refs (kein Re-Render pro Frame).
|
// (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 LevelRef = MutableRefObject<{ current: number; aa?: number; ih?: number; ou?: number }>
|
||||||
type EmotionRef = MutableRefObject<Emotion>
|
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]> = {
|
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],
|
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)
|
const b = vrm.humanoid?.getNormalizedBoneNode(name as any)
|
||||||
if (b) b.rotation.set(x, y, z)
|
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) {
|
function applyRestPose(vrm: VRM) {
|
||||||
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
|
for (const [name, r] of Object.entries(REST)) setBone(vrm, name, r[0], r[1], r[2])
|
||||||
vrm.humanoid?.update()
|
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 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)
|
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 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 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, "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, "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, "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, "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, "neck", nod * 0.5, 0, tilt * 0.5)
|
||||||
setBone(vrm, "head", lookPitch * 0.6 + nod + Math.sin(t * 0.6) * 0.02,
|
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)
|
||||||
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)
|
|
||||||
const armSwing = Math.sin(t * 0.8) * 0.05 + speak * Math.sin(t * 1.6) * 0.045
|
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, "leftUpperArm", -lift, 0, 1.16 + armSwing + weight * 0.05)
|
||||||
setBone(vrm, "rightUpperArm", -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)
|
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 [vrm, setVrm] = useState<VRM | null>(null)
|
||||||
const smooth = useRef<Record<string, number>>({})
|
const smooth = useRef<Record<string, number>>({})
|
||||||
const blink = useRef({ t: 0, next: 3, active: 0 })
|
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 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 gaze = useRef(new Object3D()) // weit entfernter Blickpunkt (gegen Schielen bei naher Kamera)
|
||||||
const headPos = useRef(new Vector3())
|
const headPos = useRef(new Vector3())
|
||||||
const camPos = 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(() => {
|
useEffect(() => {
|
||||||
let disposed = false, loaded: VRM | null = null
|
let disposed = false, loaded: VRM | null = null
|
||||||
@@ -78,66 +104,87 @@ function VrmModel({ url, audioLevel, emotion, status, onError }: {
|
|||||||
v.scene.rotation.y = Math.PI
|
v.scene.rotation.y = Math.PI
|
||||||
applyRestPose(v)
|
applyRestPose(v)
|
||||||
loaded = v; setVrm(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.") })
|
}, 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])
|
}, [url, onError])
|
||||||
|
|
||||||
useFrame((state, delta) => {
|
useFrame((state, delta) => {
|
||||||
if (!vrm) return
|
if (!vrm) return
|
||||||
|
const st = status.current
|
||||||
|
const tc = state.clock.elapsedTime
|
||||||
const target = audioLevel.current?.current ?? 0
|
const target = audioLevel.current?.current ?? 0
|
||||||
// Blick zur Kamera, aber auf einen WEIT entfernten Punkt in Kamerarichtung -> Augen laufen
|
const spk = (speak.current += (((st === "speaking") ? 1 : 0) - speak.current) * Math.min(1, delta * 4))
|
||||||
// nicht zusammen (kein Schielen bei naher Kamera), wirken aber weiter "auf dich gerichtet".
|
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) {
|
if (vrm.lookAt) {
|
||||||
const headNode = vrm.humanoid?.getNormalizedBoneNode("head")
|
const headNode = vrm.humanoid?.getNormalizedBoneNode("head")
|
||||||
const hp = headPos.current
|
const hp = headPos.current
|
||||||
if (headNode) headNode.getWorldPosition(hp); else hp.set(0, 1.3, 0)
|
if (headNode) headNode.getWorldPosition(hp); else hp.set(0, 1.3, 0)
|
||||||
const cp = camPos.current.copy(state.camera.position)
|
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
|
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
|
const em = vrm.expressionManager
|
||||||
if (em) {
|
if (em) {
|
||||||
// Lippensync v2: echte Vokal-Mundformen (aa/ih/ou) aus der Audio-Analyse statt nur "Mund auf".
|
// 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 lv = audioLevel.current
|
||||||
const visTargets: Record<string, number> = {
|
const visTargets: Record<string, number> = { aa: lv?.aa ?? target, ih: lv?.ih ?? 0, ou: lv?.ou ?? 0 }
|
||||||
aa: lv?.aa ?? target, ih: lv?.ih ?? 0, ou: lv?.ou ?? 0,
|
|
||||||
}
|
|
||||||
for (const v of ["aa", "ih", "ou"]) {
|
for (const v of ["aa", "ih", "ou"]) {
|
||||||
const cv = smooth.current[v] ?? 0
|
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)
|
smooth.current[v] = nv; em.setValue(v, nv)
|
||||||
}
|
}
|
||||||
const want = EMO_TO_EXPR[emotion.current]
|
const want = EMO_TO_EXPR[emotion.current]
|
||||||
|
|||||||
Reference in New Issue
Block a user