@convai/embody 1.0.1-beta.0 → 1.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +12 -8
- package/dist/character/createCharacter.d.ts +66 -5
- package/dist/character/createCharacter.js +93 -22
- package/dist/character/pipeline.d.ts +9 -5
- package/dist/clips/animations.d.ts +13 -1
- package/dist/clips/animations.js +28 -6
- package/dist/clips/retarget.d.ts +168 -0
- package/dist/clips/retarget.js +856 -0
- package/dist/clips/sources.d.ts +70 -0
- package/dist/clips/sources.js +119 -0
- package/dist/connect/session.d.ts +11 -0
- package/dist/connect/session.js +22 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +12 -0
- package/dist/inspect/inspectModel.d.ts +14 -0
- package/dist/inspect/inspectModel.js +26 -0
- package/dist/inspect/morphMap.d.ts +54 -0
- package/dist/inspect/morphMap.js +140 -0
- package/dist/inspect/report.d.ts +88 -0
- package/dist/inspect/report.js +193 -0
- package/dist/motion/eyeAim.d.ts +94 -0
- package/dist/motion/eyeAim.js +276 -0
- package/dist/motion/eyeGaze.d.ts +17 -0
- package/dist/motion/eyeGaze.js +42 -0
- package/dist/motion/footLock.d.ts +67 -0
- package/dist/motion/footLock.js +176 -0
- package/dist/motion/gaze.d.ts +135 -180
- package/dist/motion/gaze.js +257 -491
- package/dist/motion/hair.d.ts +27 -0
- package/dist/motion/hair.js +127 -0
- package/dist/motion/hairClearance.d.ts +39 -0
- package/dist/motion/hairClearance.js +125 -0
- package/dist/motion/morphNormals.d.ts +38 -0
- package/dist/motion/morphNormals.js +237 -0
- package/dist/motion/types.d.ts +15 -0
- package/dist/react/CharacterModel.d.ts +3 -1
- package/dist/react/CharacterModel.js +9 -1
- package/dist/react/ConvaiCharacter.js +4 -1
- package/dist/react/PortraitLights.d.ts +3 -0
- package/dist/react/PortraitLights.js +44 -0
- package/dist/render/faceMaterials.d.ts +60 -3
- package/dist/render/faceMaterials.js +320 -96
- package/dist/render/portraitRig.d.ts +28 -0
- package/dist/render/portraitRig.js +55 -0
- package/dist/render/surfaces.d.ts +181 -0
- package/dist/render/surfaces.js +1020 -0
- package/dist/rig/resolve.d.ts +8 -2
- package/dist/rig/resolve.js +7 -3
- package/dist/rig/units.d.ts +35 -16
- package/dist/rig/units.js +143 -42
- package/dist/speech/expression.d.ts +20 -0
- package/dist/speech/expression.js +118 -0
- package/dist/speech/lipsync.d.ts +68 -2
- package/dist/speech/lipsync.js +205 -5
- package/dist/speech/mouthFinish.d.ts +46 -0
- package/dist/speech/mouthFinish.js +235 -0
- package/dist/speech/voiceClock.d.ts +94 -0
- package/dist/speech/voiceClock.js +176 -0
- package/package.json +1 -1
package/dist/rig/resolve.d.ts
CHANGED
|
@@ -27,7 +27,13 @@ export interface ResolvedRig {
|
|
|
27
27
|
* them — validating those is the upload pipeline's job, not embody's.
|
|
28
28
|
*/
|
|
29
29
|
export declare const REQUIRED_BONES: readonly BoneRole[];
|
|
30
|
-
/**
|
|
31
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Apply a profile to a loaded root. Call once, at load — never per frame.
|
|
32
|
+
* `quiet` leaves missing bones to the caller to report (createCharacter's
|
|
33
|
+
* report says it, with the fix).
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveRig(root: THREE.Object3D, profile: RigProfile, opts?: {
|
|
36
|
+
quiet?: boolean;
|
|
37
|
+
}): ResolvedRig;
|
|
32
38
|
/** Write a value to every mesh carrying a role. Absent roles are a no-op. */
|
|
33
39
|
export declare function writeMorph(rig: ResolvedRig, role: MorphRole, value: number): void;
|
package/dist/rig/resolve.js
CHANGED
|
@@ -14,8 +14,12 @@ function findFirst(root, aliases) {
|
|
|
14
14
|
}
|
|
15
15
|
return null;
|
|
16
16
|
}
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Apply a profile to a loaded root. Call once, at load — never per frame.
|
|
19
|
+
* `quiet` leaves missing bones to the caller to report (createCharacter's
|
|
20
|
+
* report says it, with the fix).
|
|
21
|
+
*/
|
|
22
|
+
export function resolveRig(root, profile, opts = {}) {
|
|
19
23
|
const bones = {};
|
|
20
24
|
const restPose = {};
|
|
21
25
|
const missing = [];
|
|
@@ -33,7 +37,7 @@ export function resolveRig(root, profile) {
|
|
|
33
37
|
if (!bones[role])
|
|
34
38
|
missing.push(role);
|
|
35
39
|
}
|
|
36
|
-
if (missing.length) {
|
|
40
|
+
if (missing.length && !opts.quiet) {
|
|
37
41
|
const detail = missing.map((r) => {
|
|
38
42
|
const aliases = profile.bones[r] ?? [];
|
|
39
43
|
const desc = aliases.length > 0 ? aliases.join('|') : 'no aliases declared in profile';
|
package/dist/rig/units.d.ts
CHANGED
|
@@ -1,27 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit normalisation for character models.
|
|
2
|
+
* Unit normalisation and grounding for character models.
|
|
3
3
|
*
|
|
4
|
-
* Assets disagree about export scale (sofia.glb: raw centimetres;
|
|
4
|
+
* SCALE. Assets disagree about export scale (sofia.glb: raw centimetres;
|
|
5
5
|
* japanese-f.glb: centimetre geometry under a baked 0.01 root), and every
|
|
6
6
|
* static measure of a SkinnedMesh lies about rendered size — Box3 and
|
|
7
7
|
* computeBoundingBox read bind-pose geometry, bone spans miss bind scaling.
|
|
8
|
+
* The assumption-free answer is to measure the head→ankle bone span in WORLD
|
|
9
|
+
* space and multiply the scale by the error until it converges.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* That measurement is only meaningful in an upright pose, so it is taken
|
|
12
|
+
* ONCE, at construction, in the bind pose (nothing has animated the skeleton
|
|
13
|
+
* yet), and never again. Re-measuring every frame — what this module used to
|
|
14
|
+
* do — reads any pose that is not standing straight as "too small" and blows
|
|
15
|
+
* the character up: kneeling 1.5x, crouched 2.1x, bending to pick something up
|
|
16
|
+
* 2.7x (measured with Mixamo clips on a convai-mha-1 body).
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* GROUND. The soles go on the ground, not the ankle bone: pinning `foot_l`
|
|
19
|
+
* put the soles ~10 cm under the floor. At construction the lowest skinned
|
|
20
|
+
* vertices under each foot and ball bone are captured in that bone's local
|
|
21
|
+
* frame; every frame they are carried by the bone (so a rolled foot, a lifted
|
|
22
|
+
* heel or tiptoes are followed) and the lowest of them, across both feet, is
|
|
23
|
+
* placed on `groundY`. A body without skinned feet falls back to the ankle
|
|
24
|
+
* bones.
|
|
25
|
+
*
|
|
26
|
+
* `grounding: 'clip'` places the body once and then leaves vertical motion to
|
|
27
|
+
* the clip — for jumps and anything else that should leave the floor.
|
|
18
28
|
*/
|
|
19
29
|
import * as THREE from 'three';
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
update(): boolean;
|
|
23
|
-
}
|
|
24
|
-
export declare function createUnitNormalizer(root: THREE.Object3D, opts?: {
|
|
30
|
+
export type Grounding = 'feet' | 'clip';
|
|
31
|
+
export interface UnitNormalizerOptions {
|
|
25
32
|
targetHeight?: number;
|
|
26
33
|
groundY?: number;
|
|
27
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `feet` (default): every frame, the lowest sole point rests on `groundY`.
|
|
36
|
+
* `clip`: grounded once at load; the clip's own root motion decides height.
|
|
37
|
+
*/
|
|
38
|
+
grounding?: Grounding;
|
|
39
|
+
}
|
|
40
|
+
export interface UnitNormalizer {
|
|
41
|
+
/** Root scale after the one-time measurement. */
|
|
42
|
+
readonly scale: number;
|
|
43
|
+
/** Put the current pose on the ground. Call after the pose is final for the frame. */
|
|
44
|
+
ground(): void;
|
|
45
|
+
}
|
|
46
|
+
export declare function createUnitNormalizer(root: THREE.Object3D, opts?: UnitNormalizerOptions): UnitNormalizer;
|
package/dist/rig/units.js
CHANGED
|
@@ -1,61 +1,162 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit normalisation for character models.
|
|
2
|
+
* Unit normalisation and grounding for character models.
|
|
3
3
|
*
|
|
4
|
-
* Assets disagree about export scale (sofia.glb: raw centimetres;
|
|
4
|
+
* SCALE. Assets disagree about export scale (sofia.glb: raw centimetres;
|
|
5
5
|
* japanese-f.glb: centimetre geometry under a baked 0.01 root), and every
|
|
6
6
|
* static measure of a SkinnedMesh lies about rendered size — Box3 and
|
|
7
7
|
* computeBoundingBox read bind-pose geometry, bone spans miss bind scaling.
|
|
8
|
+
* The assumption-free answer is to measure the head→ankle bone span in WORLD
|
|
9
|
+
* space and multiply the scale by the error until it converges.
|
|
8
10
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* That measurement is only meaningful in an upright pose, so it is taken
|
|
12
|
+
* ONCE, at construction, in the bind pose (nothing has animated the skeleton
|
|
13
|
+
* yet), and never again. Re-measuring every frame — what this module used to
|
|
14
|
+
* do — reads any pose that is not standing straight as "too small" and blows
|
|
15
|
+
* the character up: kneeling 1.5x, crouched 2.1x, bending to pick something up
|
|
16
|
+
* 2.7x (measured with Mixamo clips on a convai-mha-1 body).
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* GROUND. The soles go on the ground, not the ankle bone: pinning `foot_l`
|
|
19
|
+
* put the soles ~10 cm under the floor. At construction the lowest skinned
|
|
20
|
+
* vertices under each foot and ball bone are captured in that bone's local
|
|
21
|
+
* frame; every frame they are carried by the bone (so a rolled foot, a lifted
|
|
22
|
+
* heel or tiptoes are followed) and the lowest of them, across both feet, is
|
|
23
|
+
* placed on `groundY`. A body without skinned feet falls back to the ankle
|
|
24
|
+
* bones.
|
|
25
|
+
*
|
|
26
|
+
* `grounding: 'clip'` places the body once and then leaves vertical motion to
|
|
27
|
+
* the clip — for jumps and anything else that should leave the floor.
|
|
18
28
|
*/
|
|
19
29
|
import * as THREE from 'three';
|
|
20
30
|
/** Head→ankle joint span as a fraction of full stature (calibrated on Sofia). */
|
|
21
31
|
const BONE_SPAN_RATIO = 0.837;
|
|
32
|
+
/** Bones whose skinned vertices can touch the floor when standing. */
|
|
33
|
+
const CONTACT_BONES = ['foot_l', 'foot_r', 'ball_l', 'ball_r'];
|
|
34
|
+
/** A vertex within this of a bone region's lowest point is part of its sole, m. */
|
|
35
|
+
const SOLE_BAND = 0.01;
|
|
36
|
+
/** At most this many sole points are kept per bone (spread over the sole). */
|
|
37
|
+
const POINTS_PER_BONE = 12;
|
|
38
|
+
function findBone(root, name) {
|
|
39
|
+
let hit = null;
|
|
40
|
+
root.traverse((o) => {
|
|
41
|
+
if (!hit && o.name.trim().toLowerCase() === name)
|
|
42
|
+
hit = o;
|
|
43
|
+
});
|
|
44
|
+
return hit;
|
|
45
|
+
}
|
|
46
|
+
function worldY(o, tmp) {
|
|
47
|
+
o.updateWorldMatrix(true, false);
|
|
48
|
+
return o.getWorldPosition(tmp).y;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The lowest skinned vertices of each contact bone, in that bone's local frame.
|
|
52
|
+
* A vertex belongs to the bone carrying most of its weight. Read in the bind
|
|
53
|
+
* pose, once.
|
|
54
|
+
*/
|
|
55
|
+
function captureSoles(root, bones) {
|
|
56
|
+
const regions = new Map();
|
|
57
|
+
const v = new THREE.Vector3();
|
|
58
|
+
// updateMatrixWorld, not updateWorldMatrix: only the former runs
|
|
59
|
+
// SkinnedMesh's override that refreshes bindMatrixInverse, which
|
|
60
|
+
// getVertexPosition reads.
|
|
61
|
+
root.updateMatrixWorld(true);
|
|
62
|
+
root.traverse((o) => {
|
|
63
|
+
const mesh = o;
|
|
64
|
+
if (!mesh.isSkinnedMesh)
|
|
65
|
+
return;
|
|
66
|
+
const si = mesh.geometry.attributes.skinIndex;
|
|
67
|
+
const sw = mesh.geometry.attributes.skinWeight;
|
|
68
|
+
if (!si || !sw)
|
|
69
|
+
return;
|
|
70
|
+
for (let i = 0; i < si.count; i++) {
|
|
71
|
+
let best = -1;
|
|
72
|
+
let bone = -1;
|
|
73
|
+
for (let c = 0; c < 4; c++) {
|
|
74
|
+
const w = sw.getComponent(i, c);
|
|
75
|
+
if (w > best) {
|
|
76
|
+
best = w;
|
|
77
|
+
bone = si.getComponent(i, c);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const b = mesh.skeleton.bones[bone];
|
|
81
|
+
if (!b || !bones.has(b.name.trim().toLowerCase()))
|
|
82
|
+
continue;
|
|
83
|
+
mesh.getVertexPosition(i, v).applyMatrix4(mesh.matrixWorld);
|
|
84
|
+
let list = regions.get(b);
|
|
85
|
+
if (!list)
|
|
86
|
+
regions.set(b, (list = []));
|
|
87
|
+
list.push(v.clone());
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const points = [];
|
|
91
|
+
for (const [bone, verts] of regions) {
|
|
92
|
+
const low = Math.min(...verts.map((p) => p.y));
|
|
93
|
+
const sole = verts.filter((p) => p.y <= low + SOLE_BAND);
|
|
94
|
+
const step = Math.max(1, Math.floor(sole.length / POINTS_PER_BONE));
|
|
95
|
+
bone.updateWorldMatrix(true, false);
|
|
96
|
+
for (let i = 0; i < sole.length; i += step) {
|
|
97
|
+
points.push({ bone, local: bone.worldToLocal(sole[i].clone()) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return points;
|
|
101
|
+
}
|
|
22
102
|
export function createUnitNormalizer(root, opts = {}) {
|
|
23
103
|
const target = (opts.targetHeight ?? 1.7) * BONE_SPAN_RATIO;
|
|
24
104
|
const groundY = opts.groundY ?? 0;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
});
|
|
34
|
-
const a = new THREE.Vector3();
|
|
35
|
-
const b = new THREE.Vector3();
|
|
36
|
-
let settled = 0;
|
|
37
|
-
return {
|
|
38
|
-
update() {
|
|
39
|
-
if (!head || !foot)
|
|
40
|
-
return true;
|
|
41
|
-
head.updateWorldMatrix(true, false);
|
|
42
|
-
foot.updateWorldMatrix(true, false);
|
|
43
|
-
const span = head.getWorldPosition(a).y - foot.getWorldPosition(b).y;
|
|
105
|
+
const head = findBone(root, 'head');
|
|
106
|
+
const ankle = findBone(root, 'foot_l');
|
|
107
|
+
const tmp = new THREE.Vector3();
|
|
108
|
+
// Scale, once. Uniform scale makes the span linear in the root scale, so
|
|
109
|
+
// this converges in one or two passes; the bound is only insurance.
|
|
110
|
+
if (head && ankle) {
|
|
111
|
+
for (let i = 0; i < 8; i++) {
|
|
112
|
+
const span = worldY(head, tmp) - worldY(ankle, tmp);
|
|
44
113
|
if (!(span > 1e-9))
|
|
45
|
-
|
|
114
|
+
break;
|
|
46
115
|
const err = target / span;
|
|
47
|
-
if (Math.abs(err - 1)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
116
|
+
if (Math.abs(err - 1) < 1e-6)
|
|
117
|
+
break;
|
|
118
|
+
root.scale.multiplyScalar(err);
|
|
119
|
+
root.updateWorldMatrix(true, true);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const contactBones = new Map();
|
|
123
|
+
for (const name of CONTACT_BONES) {
|
|
124
|
+
const b = findBone(root, name);
|
|
125
|
+
if (b)
|
|
126
|
+
contactBones.set(name, b);
|
|
127
|
+
}
|
|
128
|
+
const soles = captureSoles(root, contactBones);
|
|
129
|
+
// Without skinned feet, the ankle bones themselves are the contact points.
|
|
130
|
+
const contacts = soles.length
|
|
131
|
+
? soles
|
|
132
|
+
: [...contactBones.values()].map((bone) => ({ bone, local: new THREE.Vector3() }));
|
|
133
|
+
const lowest = () => {
|
|
134
|
+
let low = Infinity;
|
|
135
|
+
for (const c of contacts) {
|
|
136
|
+
c.bone.updateWorldMatrix(true, false);
|
|
137
|
+
const y = tmp.copy(c.local).applyMatrix4(c.bone.matrixWorld).y;
|
|
138
|
+
if (y < low)
|
|
139
|
+
low = y;
|
|
140
|
+
}
|
|
141
|
+
return low;
|
|
142
|
+
};
|
|
143
|
+
const place = () => {
|
|
144
|
+
if (!contacts.length)
|
|
145
|
+
return;
|
|
146
|
+
const low = lowest();
|
|
147
|
+
if (!Number.isFinite(low))
|
|
148
|
+
return;
|
|
149
|
+
root.position.y += groundY - low;
|
|
150
|
+
root.updateWorldMatrix(false, false);
|
|
151
|
+
};
|
|
152
|
+
// Grounded at load in either mode, so 'clip' starts on the floor too.
|
|
153
|
+
place();
|
|
154
|
+
return {
|
|
155
|
+
scale: root.scale.x,
|
|
156
|
+
ground() {
|
|
157
|
+
if (opts.grounding === 'clip')
|
|
158
|
+
return;
|
|
159
|
+
place();
|
|
59
160
|
},
|
|
60
161
|
};
|
|
61
162
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Mood = 'warm' | 'neutral' | 'curious' | 'focused' | 'steady';
|
|
2
|
+
export declare class Expression {
|
|
3
|
+
/** How much of the warm expression the face wears. Cross-fades over ~1 s. */
|
|
4
|
+
mood: Mood;
|
|
5
|
+
private readonly mouth;
|
|
6
|
+
private readonly eyes;
|
|
7
|
+
private readonly rounding;
|
|
8
|
+
private readonly closure;
|
|
9
|
+
private readonly depress;
|
|
10
|
+
private readonly brow;
|
|
11
|
+
private readonly browSlow;
|
|
12
|
+
private warmMouth;
|
|
13
|
+
private warmEyes;
|
|
14
|
+
private quiet;
|
|
15
|
+
private t;
|
|
16
|
+
private readonly m;
|
|
17
|
+
constructor(mood?: Mood);
|
|
18
|
+
/** Apply to `out` in place. `speaking`: the character's voice is playing. */
|
|
19
|
+
apply(out: Float32Array, dt: number, speaking: boolean): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expression over the lipsync stream: a face that keeps its warmth between
|
|
3
|
+
* sentences, applied to the shaped frame (MHA channel order) before it is
|
|
4
|
+
* written to the face.
|
|
5
|
+
*
|
|
6
|
+
* Speech is fast and mouth-only; emotion is slow and face-wide. So each
|
|
7
|
+
* channel has an owner:
|
|
8
|
+
* · Speech owns articulation (jaw, lip press, purse, funnel, raise, depress,
|
|
9
|
+
* stretch): never written here.
|
|
10
|
+
* · Shared channels (mouth corners, dimples) are split into a slow part and
|
|
11
|
+
* the stream's fast motion; the smile is screened onto the slow part only,
|
|
12
|
+
* gated off by lip rounding and closure (a smile flattening an "oo" or a
|
|
13
|
+
* "p" reads worse than a missing smile).
|
|
14
|
+
* · Emotion (cheek raise, upper lid): a weak Duchenne smile, eased to ~45%
|
|
15
|
+
* of the mouth's and ~35% of the eyes' rest level while speaking. The
|
|
16
|
+
* stream's own cheek raise and inner squint are capped: it sends up to
|
|
17
|
+
* ~0.45 on stressed words, which closed the eyes to slits mid-sentence.
|
|
18
|
+
* · Sad shapes the stream sends are remapped, not deleted: an inner-only brow
|
|
19
|
+
* raise becomes a full raise (interest), brow-down halves under a raised
|
|
20
|
+
* inner brow, and corner depress is cancelled by the smile.
|
|
21
|
+
*
|
|
22
|
+
* Timing: back to the rest expression only after 0.7 s of silence (pauses
|
|
23
|
+
* between sentences don't pop it), held 2.5 s, then relaxed to a lighter idle
|
|
24
|
+
* level; a slow few-percent drift so it is never frozen. The mood sets how
|
|
25
|
+
* much of it there is. Values are the coffee-shop studio's.
|
|
26
|
+
*/
|
|
27
|
+
import { MHA_PROFILE } from '@convai/web-sdk/lipsync-helpers';
|
|
28
|
+
const MOODS = {
|
|
29
|
+
warm: { mouth: 1, eyes: 1, browDown: 0, browOuter: 0 },
|
|
30
|
+
neutral: { mouth: 0.2, eyes: 0.15, browDown: 0, browOuter: 0 },
|
|
31
|
+
curious: { mouth: 0.6, eyes: 0.5, browDown: 0, browOuter: 0.12 },
|
|
32
|
+
focused: { mouth: 0.25, eyes: 0.1, browDown: 0.14, browOuter: 0 },
|
|
33
|
+
// Present without the smile, brows a touch down: the default on a male
|
|
34
|
+
// record, where the light smile of `neutral` read soft.
|
|
35
|
+
steady: { mouth: 0.08, eyes: 0.05, browDown: 0.12, browOuter: 0 },
|
|
36
|
+
};
|
|
37
|
+
/** The smile at full warmth, per channel (base names; both sides). */
|
|
38
|
+
const MOUTH = { mouthCornerPull: 0.3, mouthCornerUp: 0.2, mouthDimple: 0.06 };
|
|
39
|
+
const EYES = { eyeCheekRaise: 0.15, eyeUpperLidUp: 0.04, eyeSquintInner: 0 };
|
|
40
|
+
const STREAM_EYE_CAP = { eyeCheekRaise: 0.08, eyeSquintInner: 0.04, eyeUpperLidUp: 1 };
|
|
41
|
+
const ROUNDING = /^(mouthFunnel|mouthLipsPurse|mouthLipsTowards)/;
|
|
42
|
+
const CLOSURE = /^(mouthLipsPress|mouthPress|mouthLipsTogether)/;
|
|
43
|
+
const SPEAK_MOUTH = 0.45;
|
|
44
|
+
const SPEAK_EYES = 0.35;
|
|
45
|
+
const REST_AFTER = 0.7;
|
|
46
|
+
const HOLD = 2.5;
|
|
47
|
+
const IDLE_SHARE = 0.35;
|
|
48
|
+
const RELAX = 2.5;
|
|
49
|
+
const NAMES = MHA_PROFILE.channelNames.map((n) => n.replace(/^CTRL_expressions_/, ''));
|
|
50
|
+
const where = (test) => NAMES.map((n, i) => (test(n) ? i : -1)).filter((i) => i >= 0);
|
|
51
|
+
const screen = (a, b) => a + b - a * b;
|
|
52
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
53
|
+
export class Expression {
|
|
54
|
+
constructor(mood = 'neutral') {
|
|
55
|
+
this.mouth = where((n) => MOUTH[n.replace(/[LR]$/, '')] !== undefined)
|
|
56
|
+
.map((i) => ({ i, amount: MOUTH[NAMES[i].replace(/[LR]$/, '')], slow: 0 }));
|
|
57
|
+
this.eyes = where((n) => EYES[n.replace(/[LR]$/, '')] !== undefined)
|
|
58
|
+
.map((i) => ({ i, amount: EYES[NAMES[i].replace(/[LR]$/, '')], cap: STREAM_EYE_CAP[NAMES[i].replace(/[LR]$/, '')] ?? 1 }));
|
|
59
|
+
this.rounding = where((n) => ROUNDING.test(n));
|
|
60
|
+
this.closure = where((n) => CLOSURE.test(n));
|
|
61
|
+
this.depress = where((n) => /^mouthCornerDepress[LR]$/.test(n));
|
|
62
|
+
this.brow = (name) => NAMES.indexOf(name);
|
|
63
|
+
this.browSlow = new Float32Array(NAMES.length);
|
|
64
|
+
this.warmMouth = 1;
|
|
65
|
+
this.warmEyes = 1;
|
|
66
|
+
this.quiet = Infinity;
|
|
67
|
+
this.t = 0;
|
|
68
|
+
this.mood = mood;
|
|
69
|
+
this.m = { ...MOODS[mood] };
|
|
70
|
+
}
|
|
71
|
+
/** Apply to `out` in place. `speaking`: the character's voice is playing. */
|
|
72
|
+
apply(out, dt, speaking) {
|
|
73
|
+
if (dt <= 0)
|
|
74
|
+
return;
|
|
75
|
+
this.t += dt;
|
|
76
|
+
this.quiet = speaking ? 0 : this.quiet + dt;
|
|
77
|
+
const atRest = this.quiet >= REST_AFTER;
|
|
78
|
+
const idle = this.quiet >= REST_AFTER + HOLD;
|
|
79
|
+
const restLevel = idle ? IDLE_SHARE : 1;
|
|
80
|
+
const ease = (v, to) => v + (to - v) * (1 - Math.exp(-dt / (idle && to < v ? RELAX : to < v ? 0.45 : 1.0)));
|
|
81
|
+
this.warmMouth = ease(this.warmMouth, atRest ? restLevel : SPEAK_MOUTH);
|
|
82
|
+
this.warmEyes = ease(this.warmEyes, atRest ? restLevel : SPEAK_EYES);
|
|
83
|
+
const km = 1 - Math.exp(-dt / 1.0);
|
|
84
|
+
const target = MOODS[this.mood] ?? MOODS.neutral;
|
|
85
|
+
for (const k of Object.keys(this.m))
|
|
86
|
+
this.m[k] += (target[k] - this.m[k]) * km;
|
|
87
|
+
// Never frozen: a slow drift of a few percent.
|
|
88
|
+
const drift = 1 + 0.05 * Math.sin(this.t * 0.83) + 0.03 * Math.sin(this.t * 0.37 + 1.3);
|
|
89
|
+
const peak = (list) => list.reduce((m, i) => Math.max(m, out[i]), 0);
|
|
90
|
+
const gate = Math.max(0, 1 - 0.9 * Math.min(1, peak(this.rounding) * 2) - 0.5 * Math.min(1, peak(this.closure) * 2));
|
|
91
|
+
const kSlow = 1 - Math.exp(-dt / 0.25);
|
|
92
|
+
for (const c of this.mouth) {
|
|
93
|
+
const v = out[c.i];
|
|
94
|
+
c.slow += (v - c.slow) * kSlow;
|
|
95
|
+
out[c.i] = clamp01(v - c.slow + screen(c.slow, c.amount * this.warmMouth * this.m.mouth * drift * gate));
|
|
96
|
+
}
|
|
97
|
+
for (const c of this.eyes)
|
|
98
|
+
out[c.i] = clamp01(screen(Math.min(out[c.i], c.cap), c.amount * this.warmEyes * this.m.eyes * drift));
|
|
99
|
+
// Brows: smoothed (~100 ms) and remapped, per side.
|
|
100
|
+
const kBrow = 1 - Math.exp(-dt / 0.1);
|
|
101
|
+
for (const side of ['L', 'R']) {
|
|
102
|
+
const inner = this.brow(`browRaiseIn${side}`), outer = this.brow(`browRaiseOuter${side}`), down = this.brow(`browDown${side}`);
|
|
103
|
+
for (const i of [inner, outer, down])
|
|
104
|
+
if (i >= 0)
|
|
105
|
+
this.browSlow[i] += (out[i] - this.browSlow[i]) * kBrow;
|
|
106
|
+
const innerSlow = inner >= 0 ? this.browSlow[inner] : 0;
|
|
107
|
+
if (inner >= 0)
|
|
108
|
+
out[inner] = innerSlow;
|
|
109
|
+
if (outer >= 0)
|
|
110
|
+
out[outer] = clamp01(screen(Math.max(this.browSlow[outer], 0.8 * innerSlow), this.m.browOuter));
|
|
111
|
+
if (down >= 0)
|
|
112
|
+
out[down] = clamp01(screen(this.browSlow[down] * (innerSlow > 0.1 ? 0.5 : 1), this.m.browDown));
|
|
113
|
+
}
|
|
114
|
+
const pull = MOUTH.mouthCornerPull * this.warmMouth * this.m.mouth;
|
|
115
|
+
for (const i of this.depress)
|
|
116
|
+
out[i] = clamp01(out[i] - pull);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/dist/speech/lipsync.d.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import type * as THREE from 'three';
|
|
17
17
|
import type { BlendshapeQueue } from '@convai/web-sdk/core';
|
|
18
18
|
import { type CorrectiveMap } from './correctiveMap.js';
|
|
19
|
+
import type { VoiceClock } from './voiceClock.js';
|
|
20
|
+
import { Expression, type Mood } from './expression.js';
|
|
19
21
|
/** Tier-1 customization: the mouth knobs, per character. Defaults = pagoda-act's tuned values. */
|
|
20
22
|
export interface MouthTuning {
|
|
21
23
|
/** Hard cap on jawOpen; peak excursions read exaggerated on camera. */
|
|
@@ -50,8 +52,28 @@ export interface MouthTuning {
|
|
|
50
52
|
teethLowerTuck?: number;
|
|
51
53
|
/** Gain multipliers by MHA channel name, forwarded to the processor. */
|
|
52
54
|
gainOverrides?: Record<string, number>;
|
|
53
|
-
/** Seconds of visual lead granted at speech start. */
|
|
55
|
+
/** Seconds of visual lead granted at speech start (wall-clock path only). */
|
|
54
56
|
visualLeadSeconds?: number;
|
|
57
|
+
/**
|
|
58
|
+
* How far the mouth leads the voice, s, when it follows the voice's own
|
|
59
|
+
* clock. The anchor pairs the first audible sound with the first frame that
|
|
60
|
+
* opens the jaw, which comes ~150 ms into the syllable; the rest is the
|
|
61
|
+
* small natural lead of a mouth over its sound, plus the finishing's ~33 ms
|
|
62
|
+
* of smoothing. Default 0.2.
|
|
63
|
+
*/
|
|
64
|
+
voiceLeadSeconds?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Mouth finishing (bilabial closures, rest in the voice's breaks, stall
|
|
67
|
+
* cover, a soft knee on over-wide openings, light smoothing, range). See
|
|
68
|
+
* MouthFinish. Default `true`.
|
|
69
|
+
*/
|
|
70
|
+
finish?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The face's resting expression between and under speech (see Expression):
|
|
73
|
+
* a mood, or `false` to play the stream's face as sent. Change it live
|
|
74
|
+
* through `lipsync.expression.mood`. Default `'neutral'` (a light smile).
|
|
75
|
+
*/
|
|
76
|
+
expression?: Mood | false;
|
|
55
77
|
/**
|
|
56
78
|
* Seconds without a fresh frame, while the bot still reads as speaking,
|
|
57
79
|
* after which the mouth relaxes instead of holding its last shape: a stream
|
|
@@ -59,8 +81,9 @@ export interface MouthTuning {
|
|
|
59
81
|
*/
|
|
60
82
|
staleReleaseSeconds?: number;
|
|
61
83
|
}
|
|
62
|
-
type Tuning = Required<Omit<MouthTuning, 'gainOverrides' | 'teethLowerTuck'>> & {
|
|
84
|
+
type Tuning = Required<Omit<MouthTuning, 'gainOverrides' | 'teethLowerTuck' | 'expression'>> & {
|
|
63
85
|
gainOverrides: Record<string, number>;
|
|
86
|
+
expression: Mood | false;
|
|
64
87
|
};
|
|
65
88
|
export declare class LipsyncSystem {
|
|
66
89
|
readonly t: Tuning;
|
|
@@ -85,11 +108,54 @@ export declare class LipsyncSystem {
|
|
|
85
108
|
correctiveMap: CorrectiveMap | null;
|
|
86
109
|
private mapped;
|
|
87
110
|
private unpinned;
|
|
111
|
+
/**
|
|
112
|
+
* The voice's own clock, when the received audio can be read. Set by
|
|
113
|
+
* createCharacter from the session; null keeps the wall-clock path.
|
|
114
|
+
*/
|
|
115
|
+
voice: VoiceClock | null;
|
|
116
|
+
/** Frames consumed this turn, and the turn frame where speech starts. */
|
|
117
|
+
private turnFrame;
|
|
118
|
+
private anchor;
|
|
119
|
+
/** Frames waiting when the voice's packets stopped, or null while they flow. */
|
|
120
|
+
private holdWaiting;
|
|
121
|
+
/** The queue ran dry after the anchor: the next frames are a new start. */
|
|
122
|
+
private dry;
|
|
123
|
+
/** Since it ran dry: whether the voice went quiet, and how long frames have waited. */
|
|
124
|
+
private gapSeen;
|
|
125
|
+
private lateFor;
|
|
126
|
+
/**
|
|
127
|
+
* Where the stream's motion starts: the first frame that moves the mouth,
|
|
128
|
+
* any mouth channel away from its rest in the first frame, within the
|
|
129
|
+
* lead-in. Keyed on the jaw alone, a reply opening on rounded or closed
|
|
130
|
+
* lips ("Who knew the moon", "Peter") matched a second into the stream
|
|
131
|
+
* (63 and 80 frames measured), the whole reply ran that far early, and its
|
|
132
|
+
* last words had no frames left.
|
|
133
|
+
*/
|
|
134
|
+
private leadIn;
|
|
135
|
+
private blend;
|
|
136
|
+
private readonly finisher;
|
|
137
|
+
/** The resting expression, or null when switched off. Set `.mood` to change it. */
|
|
138
|
+
readonly expression: Expression | null;
|
|
88
139
|
private readonly ch;
|
|
89
140
|
constructor(tuning?: MouthTuning, correctiveMap?: CorrectiveMap | null);
|
|
90
141
|
/** Bind every morph-bearing mesh under root. Returns matched channel count. */
|
|
91
142
|
bind(root: THREE.Object3D): number;
|
|
92
143
|
private correct;
|
|
144
|
+
private wasSpeakingClock;
|
|
145
|
+
/**
|
|
146
|
+
* Consume the queue up to where the voice is, and return the stream at that
|
|
147
|
+
* exact time: the last whole frame blended toward the next by the fraction
|
|
148
|
+
* left over. Embody draws once per render tick, so at ~46 fps against a
|
|
149
|
+
* 60 fps stream whole frames stepped 1, 2 or 3 at a time, an uneven speed
|
|
150
|
+
* that read as jitter. Null until the voice is audible, or when nothing new
|
|
151
|
+
* is due.
|
|
152
|
+
*/
|
|
153
|
+
private followVoice;
|
|
154
|
+
/** The last whole frame consumed on the voice clock, uncorrected. */
|
|
155
|
+
private rawHeld;
|
|
156
|
+
private zeros;
|
|
157
|
+
/** A rest frame in the stream's layout, for the finisher to add to. */
|
|
158
|
+
private zero;
|
|
93
159
|
/** Tick each frame. Pass the queue only if this character owns the turn. */
|
|
94
160
|
update(delta: number, q: BlendshapeQueue | undefined): void;
|
|
95
161
|
}
|