Skip to content

Commit 055bba5

Browse files
xnne-botMrXnneHang
andauthored
❇️ feat(frontend): smooth pose mixer transitions across speaking and listening states (Open-LLM-VTuber#19)
Co-authored-by: MrXnneHang <XnneHang@gmail.com>
1 parent 591698c commit 055bba5

1 file changed

Lines changed: 193 additions & 37 deletions

File tree

src/renderer/src/hooks/canvas/live2d-pose-mixer-controller.ts

Lines changed: 193 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ export type PoseLayerId = 'idle_layer' | 'speech_layer' | 'backend_pose_layer' |
2626
interface LayerState {
2727
weight: number;
2828
values: PoseValues;
29+
weightTransition: WeightTransition | null;
30+
}
31+
32+
interface WeightTransition {
33+
fromWeight: number;
34+
toWeight: number;
35+
startTimeMs: number;
36+
durationMs: number;
37+
}
38+
39+
interface ScalarTransition {
40+
fromValue: number;
41+
toValue: number;
42+
startTimeMs: number;
43+
durationMs: number;
2944
}
3045

3146
const DEFAULT_LAYER_WEIGHTS: Record<PoseLayerId, number> = {
@@ -46,10 +61,26 @@ const ORIENTATION_CHANNELS: LogicalChannel[] = [
4661
'gaze_y',
4762
];
4863

64+
const LAYER_WEIGHT_TRANSITION_MS = 480;
65+
const IDLE_MOUTH_BLEND_TRANSITION_MS = 320;
66+
4967
function clamp(value: number, min: number, max: number): number {
5068
return Math.min(Math.max(value, min), max);
5169
}
5270

71+
function easeInOutCubic(alpha: number): number {
72+
if (alpha <= 0) {
73+
return 0;
74+
}
75+
if (alpha >= 1) {
76+
return 1;
77+
}
78+
if (alpha < 0.5) {
79+
return 4 * alpha * alpha * alpha;
80+
}
81+
return 1 - ((-2 * alpha + 2) ** 3) / 2;
82+
}
83+
5384
function sanitizeChannelValue(channel: LogicalChannel, value: number): number | null {
5485
if (!Number.isFinite(value)) {
5586
return null;
@@ -83,11 +114,15 @@ export class Live2DPoseMixerController {
83114

84115
private idleMouthEnabled = true;
85116

117+
private idleMouthBlend = 1;
118+
119+
private idleMouthBlendTransition: ScalarTransition | null = null;
120+
86121
private layers: Record<PoseLayerId, LayerState> = {
87-
idle_layer: { weight: DEFAULT_LAYER_WEIGHTS.idle_layer, values: {} },
88-
speech_layer: { weight: DEFAULT_LAYER_WEIGHTS.speech_layer, values: {} },
89-
backend_pose_layer: { weight: DEFAULT_LAYER_WEIGHTS.backend_pose_layer, values: {} },
90-
mouse_attention_layer: { weight: DEFAULT_LAYER_WEIGHTS.mouse_attention_layer, values: {} },
122+
idle_layer: { weight: DEFAULT_LAYER_WEIGHTS.idle_layer, values: {}, weightTransition: null },
123+
speech_layer: { weight: DEFAULT_LAYER_WEIGHTS.speech_layer, values: {}, weightTransition: null },
124+
backend_pose_layer: { weight: DEFAULT_LAYER_WEIGHTS.backend_pose_layer, values: {}, weightTransition: null },
125+
mouse_attention_layer: { weight: DEFAULT_LAYER_WEIGHTS.mouse_attention_layer, values: {}, weightTransition: null },
91126
};
92127

93128
private readonly recordedIdleDriver = new RecordedIdleDriver((pose) => {
@@ -97,8 +132,9 @@ export class Live2DPoseMixerController {
97132
private lastFinalPose: PoseValues = {};
98133

99134
private getMouseAttentionDragInput(): { x: number; y: number } {
135+
const nowMs = performance.now();
100136
const layer = this.layers.mouse_attention_layer;
101-
const layerWeight = layer?.weight;
137+
const layerWeight = this.getResolvedLayerWeight('mouse_attention_layer', nowMs);
102138
if (!layer || typeof layerWeight !== 'number' || !Number.isFinite(layerWeight) || layerWeight <= 0) {
103139
return { x: 0, y: 0 };
104140
}
@@ -123,9 +159,14 @@ export class Live2DPoseMixerController {
123159
*/
124160
public setLayerPose(layerId: PoseLayerId, values: PoseValues, weight?: number): void {
125161
this.layers[layerId] = {
126-
weight: typeof weight === 'number' && Number.isFinite(weight) ? weight : this.layers[layerId].weight,
162+
weight: this.layers[layerId].weight,
127163
values: { ...values },
164+
weightTransition: this.layers[layerId].weightTransition,
128165
};
166+
if (typeof weight === 'number' && Number.isFinite(weight)) {
167+
this.transitionLayerWeight(layerId, weight);
168+
return;
169+
}
129170
this.refreshFinalPoseSnapshot();
130171
}
131172

@@ -134,11 +175,15 @@ export class Live2DPoseMixerController {
134175
* Useful for backends that stream only changed channels.
135176
*/
136177
public patchLayerPose(layerId: PoseLayerId, values: PoseValues, weight?: number): void {
137-
const nextWeight = typeof weight === 'number' && Number.isFinite(weight) ? weight : this.layers[layerId].weight;
138178
this.layers[layerId] = {
139-
weight: nextWeight,
179+
weight: this.layers[layerId].weight,
140180
values: { ...this.layers[layerId].values, ...values },
181+
weightTransition: this.layers[layerId].weightTransition,
141182
};
183+
if (typeof weight === 'number' && Number.isFinite(weight)) {
184+
this.transitionLayerWeight(layerId, weight);
185+
return;
186+
}
142187
this.refreshFinalPoseSnapshot();
143188
}
144189

@@ -174,11 +219,7 @@ export class Live2DPoseMixerController {
174219
if (!Number.isFinite(weight) || weight < 0) {
175220
return;
176221
}
177-
this.layers[layerId] = {
178-
...this.layers[layerId],
179-
weight,
180-
};
181-
this.refreshFinalPoseSnapshot();
222+
this.transitionLayerWeight(layerId, weight);
182223
}
183224

184225
public patchLayerWeights(weights: Partial<Record<PoseLayerId, number>>): void {
@@ -188,10 +229,7 @@ export class Live2DPoseMixerController {
188229
if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) {
189230
return;
190231
}
191-
this.layers[layerId] = {
192-
...this.layers[layerId],
193-
weight,
194-
};
232+
this.transitionLayerWeight(layerId, weight, false);
195233
changed = true;
196234
});
197235

@@ -202,10 +240,7 @@ export class Live2DPoseMixerController {
202240

203241
public resetLayerWeights(): void {
204242
(Object.keys(DEFAULT_LAYER_WEIGHTS) as PoseLayerId[]).forEach((layerId) => {
205-
this.layers[layerId] = {
206-
...this.layers[layerId],
207-
weight: DEFAULT_LAYER_WEIGHTS[layerId],
208-
};
243+
this.transitionLayerWeight(layerId, DEFAULT_LAYER_WEIGHTS[layerId], false);
209244
});
210245
this.refreshFinalPoseSnapshot();
211246
}
@@ -245,8 +280,11 @@ export class Live2DPoseMixerController {
245280
const shouldEnableIdleMouth = this.idleState !== 'speaking';
246281
if (this.idleMouthEnabled !== shouldEnableIdleMouth) {
247282
this.idleMouthEnabled = shouldEnableIdleMouth;
248-
this.refreshFinalPoseSnapshot();
283+
this.transitionIdleMouthBlend(shouldEnableIdleMouth ? 1 : 0);
284+
return;
249285
}
286+
287+
this.refreshFinalPoseSnapshot();
250288
}
251289

252290
public setRecordedIdleBank(bank: IdleBankConfig | null): void {
@@ -270,22 +308,35 @@ export class Live2DPoseMixerController {
270308
}
271309

272310
public getLayerStates(): Record<PoseLayerId, LayerState> {
311+
const nowMs = performance.now();
273312
return {
274313
idle_layer: {
275-
weight: this.layers.idle_layer.weight,
314+
weight: this.getResolvedLayerWeight('idle_layer', nowMs),
276315
values: { ...this.layers.idle_layer.values },
316+
weightTransition: this.layers.idle_layer.weightTransition
317+
? { ...this.layers.idle_layer.weightTransition }
318+
: null,
277319
},
278320
speech_layer: {
279-
weight: this.layers.speech_layer.weight,
321+
weight: this.getResolvedLayerWeight('speech_layer', nowMs),
280322
values: { ...this.layers.speech_layer.values },
323+
weightTransition: this.layers.speech_layer.weightTransition
324+
? { ...this.layers.speech_layer.weightTransition }
325+
: null,
281326
},
282327
backend_pose_layer: {
283-
weight: this.layers.backend_pose_layer.weight,
328+
weight: this.getResolvedLayerWeight('backend_pose_layer', nowMs),
284329
values: { ...this.layers.backend_pose_layer.values },
330+
weightTransition: this.layers.backend_pose_layer.weightTransition
331+
? { ...this.layers.backend_pose_layer.weightTransition }
332+
: null,
285333
},
286334
mouse_attention_layer: {
287-
weight: this.layers.mouse_attention_layer.weight,
335+
weight: this.getResolvedLayerWeight('mouse_attention_layer', nowMs),
288336
values: { ...this.layers.mouse_attention_layer.values },
337+
weightTransition: this.layers.mouse_attention_layer.weightTransition
338+
? { ...this.layers.mouse_attention_layer.weightTransition }
339+
: null,
289340
},
290341
};
291342
}
@@ -299,6 +350,7 @@ export class Live2DPoseMixerController {
299350
modelUrl: this.modelUrl ?? null,
300351
idleState: this.idleState,
301352
idleMouthEnabled: this.idleMouthEnabled,
353+
idleMouthBlend: this.getIdleMouthBlend(performance.now()),
302354
layers: this.getLayerStates(),
303355
finalPose: this.getFinalMixedPose(),
304356
profile: this.getProfile(),
@@ -393,30 +445,133 @@ export class Live2DPoseMixerController {
393445
w.Live2DPoseMixerDebug = debugApi;
394446
}
395447

396-
private getActiveLayers(): PoseLayer[] {
448+
private transitionLayerWeight(layerId: PoseLayerId, targetWeight: number, refreshSnapshot: boolean = true): void {
449+
const nowMs = performance.now();
450+
const currentWeight = this.getResolvedLayerWeight(layerId, nowMs);
451+
const normalizedTarget = Math.max(0, targetWeight);
452+
this.layers[layerId] = {
453+
...this.layers[layerId],
454+
weight: normalizedTarget,
455+
weightTransition: Math.abs(currentWeight - normalizedTarget) <= 1e-4
456+
? null
457+
: {
458+
fromWeight: currentWeight,
459+
toWeight: normalizedTarget,
460+
startTimeMs: nowMs,
461+
durationMs: LAYER_WEIGHT_TRANSITION_MS,
462+
},
463+
};
464+
465+
if (refreshSnapshot) {
466+
this.refreshFinalPoseSnapshot();
467+
}
468+
}
469+
470+
private transitionIdleMouthBlend(targetValue: number): void {
471+
const nowMs = performance.now();
472+
const currentValue = this.getIdleMouthBlend(nowMs);
473+
const normalizedTarget = clamp(targetValue, 0, 1);
474+
this.idleMouthBlend = normalizedTarget;
475+
this.idleMouthBlendTransition = Math.abs(currentValue - normalizedTarget) <= 1e-4
476+
? null
477+
: {
478+
fromValue: currentValue,
479+
toValue: normalizedTarget,
480+
startTimeMs: nowMs,
481+
durationMs: IDLE_MOUTH_BLEND_TRANSITION_MS,
482+
};
483+
this.refreshFinalPoseSnapshot();
484+
}
485+
486+
private getResolvedTransitionValue(
487+
fromValue: number,
488+
toValue: number,
489+
startTimeMs: number,
490+
durationMs: number,
491+
nowMs: number,
492+
): number {
493+
if (durationMs <= 0) {
494+
return toValue;
495+
}
496+
497+
const progress = clamp((nowMs - startTimeMs) / durationMs, 0, 1);
498+
const alpha = easeInOutCubic(progress);
499+
return fromValue + (toValue - fromValue) * alpha;
500+
}
501+
502+
private getResolvedLayerWeight(layerId: PoseLayerId, nowMs: number = performance.now()): number {
503+
const layer = this.layers[layerId];
504+
const transition = layer.weightTransition;
505+
if (!transition) {
506+
return layer.weight;
507+
}
508+
509+
const resolvedWeight = this.getResolvedTransitionValue(
510+
transition.fromWeight,
511+
transition.toWeight,
512+
transition.startTimeMs,
513+
transition.durationMs,
514+
nowMs,
515+
);
516+
517+
if (nowMs - transition.startTimeMs >= transition.durationMs) {
518+
this.layers[layerId] = {
519+
...layer,
520+
weightTransition: null,
521+
};
522+
return transition.toWeight;
523+
}
524+
525+
return resolvedWeight;
526+
}
527+
528+
private getIdleMouthBlend(nowMs: number = performance.now()): number {
529+
const transition = this.idleMouthBlendTransition;
530+
if (!transition) {
531+
return this.idleMouthBlend;
532+
}
533+
534+
const resolvedValue = this.getResolvedTransitionValue(
535+
transition.fromValue,
536+
transition.toValue,
537+
transition.startTimeMs,
538+
transition.durationMs,
539+
nowMs,
540+
);
541+
542+
if (nowMs - transition.startTimeMs >= transition.durationMs) {
543+
this.idleMouthBlendTransition = null;
544+
return transition.toValue;
545+
}
546+
547+
return resolvedValue;
548+
}
549+
550+
private getActiveLayers(nowMs: number = performance.now()): PoseLayer[] {
551+
const idleMouthBlend = this.getIdleMouthBlend(nowMs);
397552
const layers: PoseLayer[] = [
398553
{
399554
id: 'idle_layer',
400-
weight: this.layers.idle_layer.weight,
555+
weight: this.getResolvedLayerWeight('idle_layer', nowMs),
401556
frame: isEmptyPose(this.layers.idle_layer.values) ? null : { values: this.layers.idle_layer.values },
402557
// 运行时策略:
403558
// - listening: 允许 recorded idle 的 mouth_open 参与
404559
// - speaking: 屏蔽 idle 的 mouth_open,嘴部交给语音链路(lip sync / speech)
405-
mask: this.idleMouthEnabled ? undefined : { mouth_open: 0 },
560+
mask: idleMouthBlend >= 0.999 ? undefined : { mouth_open: idleMouthBlend },
406561
},
407562
{
408563
id: 'speech_layer',
409-
weight: this.layers.speech_layer.weight,
564+
weight: this.getResolvedLayerWeight('speech_layer', nowMs),
410565
frame: isEmptyPose(this.layers.speech_layer.values) ? null : { values: this.layers.speech_layer.values },
411566
},
412567
{
413568
id: 'backend_pose_layer',
414-
weight: this.layers.backend_pose_layer.weight,
569+
weight: this.getResolvedLayerWeight('backend_pose_layer', nowMs),
415570
frame: isEmptyPose(this.layers.backend_pose_layer.values) ? null : { values: this.layers.backend_pose_layer.values },
416571
},
417572
{
418573
id: 'mouse_attention_layer',
419-
weight: this.layers.mouse_attention_layer.weight,
574+
weight: this.getResolvedLayerWeight('mouse_attention_layer', nowMs),
420575
frame: isEmptyPose(this.layers.mouse_attention_layer.values) ? null : { values: this.layers.mouse_attention_layer.values },
421576
},
422577
];
@@ -425,7 +580,7 @@ export class Live2DPoseMixerController {
425580
}
426581

427582
private refreshFinalPoseSnapshot(): void {
428-
this.lastFinalPose = this.mixer.apply(this.getActiveLayers());
583+
this.lastFinalPose = this.mixer.apply(this.getActiveLayers(performance.now()));
429584
}
430585

431586
private applyParameterByMode(
@@ -453,10 +608,11 @@ export class Live2DPoseMixerController {
453608
this.refreshFinalPoseSnapshot();
454609
const finalPose: PoseValues = { ...this.lastFinalPose };
455610

456-
const isMouseOnlyMode = this.layers.mouse_attention_layer.weight > 0
457-
&& this.layers.idle_layer.weight <= 0
458-
&& this.layers.speech_layer.weight <= 0
459-
&& this.layers.backend_pose_layer.weight <= 0;
611+
const nowMs = performance.now();
612+
const isMouseOnlyMode = this.getResolvedLayerWeight('mouse_attention_layer', nowMs) > 0
613+
&& this.getResolvedLayerWeight('idle_layer', nowMs) <= 0
614+
&& this.getResolvedLayerWeight('speech_layer', nowMs) <= 0
615+
&& this.getResolvedLayerWeight('backend_pose_layer', nowMs) <= 0;
460616

461617
if (isMouseOnlyMode) {
462618
ORIENTATION_CHANNELS.forEach((channel) => {

0 commit comments

Comments
 (0)