@convai/embody 1.0.1 → 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.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
@@ -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
- /** Apply a profile to a loaded root. Call once, at load — never per frame. */
31
- export declare function resolveRig(root: THREE.Object3D, profile: RigProfile): ResolvedRig;
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;
@@ -14,8 +14,12 @@ function findFirst(root, aliases) {
14
14
  }
15
15
  return null;
16
16
  }
17
- /** Apply a profile to a loaded root. Call once, at load — never per frame. */
18
- export function resolveRig(root, profile) {
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';
@@ -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
- * The assumption-free answer (proven in the playhouse's CharacterPreview):
10
- * measure the head→ankle bone span actually in world space every frame and
11
- * multiply the scale by the error until it converges. Costs two
12
- * getWorldPosition calls; settles in a frame or two; cannot be wrong about
13
- * units because it never assumes any.
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
- * This is consumer-side insurance. The character spec gains a units mandate
16
- * and the processing pipeline will normalise export scale; this stays as the
17
- * belt to that suspenders.
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 interface UnitNormalizer {
21
- /** Run every frame until it reports true (converged). Cheap either way. */
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
- }): UnitNormalizer;
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
- * The assumption-free answer (proven in the playhouse's CharacterPreview):
10
- * measure the head→ankle bone span actually in world space every frame and
11
- * multiply the scale by the error until it converges. Costs two
12
- * getWorldPosition calls; settles in a frame or two; cannot be wrong about
13
- * units because it never assumes any.
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
- * This is consumer-side insurance. The character spec gains a units mandate
16
- * and the processing pipeline will normalise export scale; this stays as the
17
- * belt to that suspenders.
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
- let head = null;
26
- let foot = null;
27
- root.traverse((o) => {
28
- const n = o.name.trim().toLowerCase();
29
- if (!head && n === 'head')
30
- head = o;
31
- if (!foot && n === 'foot_l')
32
- foot = o;
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
- return false;
114
+ break;
46
115
  const err = target / span;
47
- if (Math.abs(err - 1) > 0.005) {
48
- root.scale.multiplyScalar(err);
49
- root.updateWorldMatrix(true, true);
50
- settled = 0;
51
- }
52
- else
53
- settled++;
54
- // feet on the floor, every pass, so scale correction never leaves the
55
- // character hovering or sunk
56
- foot.updateWorldMatrix(true, false);
57
- root.position.y += groundY - foot.getWorldPosition(b).y;
58
- return settled > 2;
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
+ }
@@ -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
  }