Skip to content

Commit 02cbbf4

Browse files
feat(stage-ui-three): Enhanced Lip Sync Animation (#740)
* feature: enhanced lip sync animation * [autofix.ci] apply automated fixes * feature: enhanced lip sync animation - code review --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 27acfcd commit 02cbbf4

File tree

3 files changed

+99
-18
lines changed

3 files changed

+99
-18
lines changed

packages/stage-ui-three/src/components/Model/VRMModel.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ async function loadModel() {
422422
blink.update(vrm.value, delta)
423423
idleEyeSaccades.update(vrm.value, lookAtTarget, delta)
424424
vrmEmote.value?.update(delta)
425-
vrmLipSync.update(vrm.value)
425+
vrmLipSync.update(vrm.value, delta)
426426
vrm.value?.springBoneManager?.update(delta)
427427
}).off
428428

packages/stage-ui-three/src/composables/vrm/lip-sync.ts

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,111 @@ export function useVRMLipSync(audioNode: Ref<AudioBufferSourceNode | undefined,
1515
const { state: lipSyncNode, isReady } = useAsyncState(createWLipSyncNode(audioContext, profile as Profile), undefined)
1616

1717
// https://github.com/mrxz/wLipSync/blob/c3bc4b321dc7e1ca333d75f7aa1e9e746cbbb23a/example/index.js#L50-L66
18-
const lipSyncMap = {
18+
const RAW_KEYS = ['A', 'E', 'I', 'O', 'U', 'S'] as const
19+
type LipKey = 'A' | 'E' | 'I' | 'O' | 'U'
20+
const LIP_KEYS: LipKey[] = ['A', 'E', 'I', 'O', 'U']
21+
const BLENDSHAPE_MAP: Record<LipKey, string> = {
1922
A: 'aa',
2023
E: 'ee',
2124
I: 'ih',
2225
O: 'oh',
2326
U: 'ou',
2427
}
28+
const RAW_TO_LIP: Record<typeof RAW_KEYS[number], LipKey> = {
29+
A: 'A',
30+
E: 'E',
31+
I: 'I',
32+
O: 'O',
33+
U: 'U',
34+
S: 'I',
35+
}
2536

26-
watch([isReady, audioNode], ([ready, newAudioNode], [, oldAudioNode]) => {
27-
if (oldAudioNode)
28-
oldAudioNode.disconnect()
37+
const smoothState: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
38+
const ATTACK = 50 // the speed moving to the next mouth shape animation
39+
const RELEASE = 30 // the speed ending the current mouth shape animation
40+
const CAP = 0.7
41+
const SILENCE_VOL = 0.04
42+
const SILENCE_GAIN = 0.05
43+
const IDLE_MS = 160
44+
let lastActiveAt = 0
2945

30-
if (ready && newAudioNode)
31-
newAudioNode.connect(lipSyncNode.value!)
46+
watch([isReady, audioNode], ([ready, newAudioNode], [, oldAudioNode]) => {
47+
if (oldAudioNode && oldAudioNode !== newAudioNode) {
48+
try {
49+
oldAudioNode.disconnect()
50+
}
51+
catch {}
52+
}
53+
if (!ready || !newAudioNode || !lipSyncNode.value)
54+
return
55+
try {
56+
newAudioNode.connect(lipSyncNode.value)
57+
}
58+
catch {}
3259
}, { immediate: true })
3360
onUnmounted(() => audioNode.value?.disconnect())
3461

35-
function update(vrm?: VRMCore) {
36-
if (!vrm?.expressionManager || !lipSyncNode.value)
62+
function update(vrm?: VRMCore, delta = 0.016) {
63+
const node = lipSyncNode.value
64+
if (!vrm?.expressionManager || !node)
3765
return
3866

39-
for (const key of Object.keys(lipSyncNode.value.weights)) {
40-
const weight = lipSyncNode.value.weights[key] * lipSyncNode.value.volume
41-
vrm.expressionManager?.setValue(lipSyncMap[key as keyof typeof lipSyncMap], weight)
67+
const vol = node.volume ?? 0
68+
const amp = Math.min(vol * 0.9, 1) ** 0.7
69+
70+
// Remapping wLipSync output AEIOUS to AEIOU
71+
const projected: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
72+
for (const raw of RAW_KEYS) {
73+
const lip = RAW_TO_LIP[raw]
74+
const rawVal = node.weights[raw] ?? 0
75+
projected[lip] = Math.max(projected[lip], rawVal * amp)
76+
}
77+
78+
// winner + runner
79+
// Original code: all AEIOU mouth shape blended together. Because the A mouth shape has the largest deformation, mixing A-E-I-O-U based on their raw weights causes the combined result to be biased heavily toward A in most cases.
80+
// Improved code: Only the 2 mouth shapes with the largest weights will be blended.
81+
let winner: LipKey = 'I'
82+
let runner: LipKey = 'E'
83+
let winnerVal = -Infinity
84+
let runnerVal = -Infinity
85+
for (const key of LIP_KEYS) {
86+
const val = projected[key]
87+
if (val > winnerVal) {
88+
runnerVal = winnerVal
89+
runner = winner
90+
winnerVal = val
91+
winner = key
92+
}
93+
else if (val > runnerVal) {
94+
runnerVal = val
95+
runner = key
96+
}
97+
}
98+
99+
// Detect pause or keep silence
100+
const now = performance.now()
101+
let silent = amp < SILENCE_VOL || winnerVal < SILENCE_GAIN
102+
if (!silent)
103+
lastActiveAt = now
104+
if (now - lastActiveAt > IDLE_MS)
105+
silent = true
106+
107+
// winner + runner weights
108+
const target: Record<LipKey, number> = { A: 0, E: 0, I: 0, O: 0, U: 0 }
109+
if (!silent) {
110+
target[winner] = Math.min(CAP, winnerVal)
111+
target[runner] = Math.min(CAP * 0.5, runnerVal * 0.6)
112+
}
113+
114+
// smoothness and expression generation
115+
for (const key of LIP_KEYS) {
116+
const from = smoothState[key]
117+
const to = target[key]
118+
// lerp
119+
const rate = 1 - Math.exp(-(to > from ? ATTACK : RELEASE) * delta)
120+
smoothState[key] = from + (to - from) * rate
121+
const weight = (smoothState[key] <= 0.01 ? 0 : smoothState[key]) * 0.7
122+
vrm.expressionManager.setValue(BLENDSHAPE_MAP[key], weight)
42123
}
43124
}
44125

packages/stage-ui/src/stores/providers/openai-compatible-builder.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,12 @@ export function buildOpenAICompatibleProvider(
289289
validators: finalValidators,
290290
...(resolvedCategory === 'transcription'
291291
? {
292-
transcriptionFeatures: transcriptionFeatures ?? {
293-
supportsGenerate: true,
294-
supportsStreamOutput: false,
295-
supportsStreamInput: false,
296-
},
297-
}
292+
transcriptionFeatures: transcriptionFeatures ?? {
293+
supportsGenerate: true,
294+
supportsStreamOutput: false,
295+
supportsStreamInput: false,
296+
},
297+
}
298298
: {}),
299299
...rest,
300300
} as ProviderMetadata

0 commit comments

Comments
 (0)