@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
@@ -1,5 +1,7 @@
1
- import { createLipsyncProcessor, mhaChannelIndex, } from '@convai/web-sdk/lipsync-helpers';
1
+ import { createLipsyncProcessor, mhaChannelIndex, MHA_PROFILE, } from '@convai/web-sdk/lipsync-helpers';
2
2
  import { applyCorrectiveMap } from './correctiveMap.js';
3
+ import { MouthFinish } from './mouthFinish.js';
4
+ import { Expression } from './expression.js';
3
5
  /**
4
6
  * Teeth values are the ones approved on a convai-mha-1 head (2026-10-13) and
5
7
  * re-checked on the two default bodies: the teeth stay behind the lips at
@@ -15,13 +17,47 @@ const DEFAULTS = {
15
17
  teethLowerRest: 0.18,
16
18
  teethLowerDrop: 0.35,
17
19
  visualLeadSeconds: 0.1,
20
+ voiceLeadSeconds: 0.2,
21
+ finish: true,
22
+ expression: 'neutral',
18
23
  staleReleaseSeconds: 0.35,
19
24
  // 0.45 is the SDK's recommended gain; 0.28 damped articulation.
20
25
  gainOverrides: { mouthLowerLipDepressL: 0.45, mouthLowerLipDepressR: 0.45 },
21
26
  };
27
+ /**
28
+ * How far into a reply's stream its first mouth movement is looked for, s.
29
+ * The MHA stream opens ~150 ms into the first syllable; a frame further in
30
+ * than this is not the onset, and the stream is taken from its first frame.
31
+ */
32
+ const MAX_LEAD_IN = 0.35;
33
+ /** A mouth channel this far from its value in the stream's first frame has moved. */
34
+ const MOVED = 0.04;
35
+ /** Voice level (0–1) below which the voice is between sounds. */
36
+ const QUIET = 0.02;
37
+ /** Frames arriving under a voice that never pauses are late: they start after this, s. */
38
+ const LATE_GRACE = 0.35;
22
39
  /** Wall-clock seconds, for catching up when a caller caps or skips its delta. */
23
40
  const nowSeconds = () => (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
24
41
  export class LipsyncSystem {
42
+ /**
43
+ * Where the stream's motion starts: the first frame that moves the mouth,
44
+ * any mouth channel away from its rest in the first frame, within the
45
+ * lead-in. Keyed on the jaw alone, a reply opening on rounded or closed
46
+ * lips ("Who knew the moon", "Peter") matched a second into the stream
47
+ * (63 and 80 frames measured), the whole reply ran that far early, and its
48
+ * last words had no frames left.
49
+ */
50
+ leadIn(q, fps) {
51
+ const limit = Math.min(q.length, Math.round(MAX_LEAD_IN * fps));
52
+ const rest = q.getFrame(0);
53
+ let first = 0;
54
+ for (; rest && first < limit; first++) {
55
+ const f = q.getFrame(first);
56
+ if (f && this.ch.mouth.some((i) => Math.abs(f[i] - rest[i]) > MOVED))
57
+ break;
58
+ }
59
+ return first < limit ? first : 0;
60
+ }
25
61
  constructor(tuning = {}, correctiveMap = null) {
26
62
  this.bindings = [];
27
63
  this.accum = 0;
@@ -37,6 +73,27 @@ export class LipsyncSystem {
37
73
  this.speaking = false;
38
74
  this.mapped = null;
39
75
  this.unpinned = null;
76
+ /**
77
+ * The voice's own clock, when the received audio can be read. Set by
78
+ * createCharacter from the session; null keeps the wall-clock path.
79
+ */
80
+ this.voice = null;
81
+ /** Frames consumed this turn, and the turn frame where speech starts. */
82
+ this.turnFrame = 0;
83
+ this.anchor = null;
84
+ /** Frames waiting when the voice's packets stopped, or null while they flow. */
85
+ this.holdWaiting = null;
86
+ /** The queue ran dry after the anchor: the next frames are a new start. */
87
+ this.dry = false;
88
+ /** Since it ran dry: whether the voice went quiet, and how long frames have waited. */
89
+ this.gapSeen = false;
90
+ this.lateFor = 0;
91
+ this.blend = null;
92
+ this.finisher = new MouthFinish();
93
+ this.wasSpeakingClock = false;
94
+ /** The last whole frame consumed on the voice clock, uncorrected. */
95
+ this.rawHeld = null;
96
+ this.zeros = null;
40
97
  this.correctiveMap = correctiveMap;
41
98
  const { teethLowerTuck, ...rest } = tuning;
42
99
  this.t = {
@@ -45,6 +102,7 @@ export class LipsyncSystem {
45
102
  ...rest,
46
103
  gainOverrides: { ...DEFAULTS.gainOverrides, ...tuning.gainOverrides },
47
104
  };
105
+ this.expression = this.t.expression === false ? null : new Expression(this.t.expression);
48
106
  this.processor = createLipsyncProcessor('mha', {
49
107
  skipEyeChannels: true,
50
108
  gainOverrides: this.t.gainOverrides,
@@ -61,6 +119,8 @@ export class LipsyncSystem {
61
119
  teethUpUpper: idx('teethUpU'),
62
120
  teethDownLower: idx('teethDownD'),
63
121
  noseUpper: idxs('noseWrinkleUpperL', 'noseWrinkleUpperR'),
122
+ // Every mouth and jaw channel; the profile's names carry the CTRL_expressions_ prefix.
123
+ mouth: MHA_PROFILE.channelNames.map((n, i) => (/^(mouth|jaw)/.test(n.replace(/^CTRL_expressions_/, '')) ? i : -1)).filter((i) => i >= 0),
64
124
  };
65
125
  }
66
126
  /** Bind every morph-bearing mesh under root. Returns matched channel count. */
@@ -91,6 +151,108 @@ export class LipsyncSystem {
91
151
  this.mapped = new Float32Array(frame.length);
92
152
  return applyCorrectiveMap(frame, map, this.mapped);
93
153
  }
154
+ /**
155
+ * Consume the queue up to where the voice is, and return the stream at that
156
+ * exact time: the last whole frame blended toward the next by the fraction
157
+ * left over. Embody draws once per render tick, so at ~46 fps against a
158
+ * 60 fps stream whole frames stepped 1, 2 or 3 at a time, an uneven speed
159
+ * that read as jitter. Null until the voice is audible, or when nothing new
160
+ * is due.
161
+ */
162
+ followVoice(q, elapsed, fps) {
163
+ const voice = this.voice;
164
+ const played = voice.update(elapsed);
165
+ if (played === null)
166
+ return null;
167
+ // A break in the packets is a stall between the voice's chunks or a
168
+ // silence the sender suppressed. Frames exist only for audio that was
169
+ // made: a suppressed silence has its closed-mouth frames waiting when the
170
+ // packets stop; a stall has none, and its frames (the next chunk's) come
171
+ // with the next packets. So after a break the clock may move the mouth
172
+ // only as far as the frames that were waiting when it began; the rest of
173
+ // the jump is taken off the anchor. Measured on an OpenAI voice: 0.3 s
174
+ // stalls between chunks, 23 frames of the next phrase skipped on each.
175
+ if (voice.holding) {
176
+ if (this.holdWaiting === null)
177
+ this.holdWaiting = q.length;
178
+ }
179
+ else if (this.holdWaiting !== null) {
180
+ const excess = Math.max(0, voice.resumedBy - this.holdWaiting / fps);
181
+ if (this.anchor !== null && excess > 0)
182
+ this.anchor -= excess * fps;
183
+ this.holdWaiting = null;
184
+ }
185
+ const loud = voice.level === null || voice.level > QUIET;
186
+ if (this.anchor === null) {
187
+ this.anchor = this.turnFrame + this.leadIn(q, fps);
188
+ }
189
+ else if (this.dry) {
190
+ // Frames after a dry spell are the next chunk's, or late. The voice's
191
+ // chunks and their frames are made together; the lead empties the queue
192
+ // ~0.2 s before a chunk's sound ends, and the next chunk's frames can
193
+ // land then, or a second before their sound. Catching up in a burst
194
+ // showed a phrase's first shapes in one tick before its sound (17
195
+ // frames in 90 ms measured); starting on arrival while the last chunk
196
+ // still sounded ran 0.25 s early. So they start at the voice's next
197
+ // onset, as a reply does: once it has gone quiet and come back. A voice
198
+ // that never drops means the frames are late under continuous speech,
199
+ // and they start after a short grace; an unreadable level starts them
200
+ // at once.
201
+ if (!loud)
202
+ this.gapSeen = true;
203
+ if (q.hasFrames()) {
204
+ this.lateFor += elapsed;
205
+ const start = voice.level === null || (this.gapSeen ? loud : this.lateFor > LATE_GRACE);
206
+ if (!start)
207
+ return null;
208
+ this.anchor = this.turnFrame + this.leadIn(q, fps) - played * fps;
209
+ this.dry = false;
210
+ }
211
+ }
212
+ const target = this.anchor + (played + this.t.voiceLeadSeconds) * fps;
213
+ let last = null;
214
+ while (this.turnFrame <= Math.floor(target) && q.hasFrames()) {
215
+ last = q.getFrameWithAlpha(0);
216
+ q.consumeFrames(1);
217
+ this.turnFrame++;
218
+ }
219
+ if (!q.hasFrames() && !this.dry) {
220
+ this.dry = true;
221
+ this.gapSeen = false;
222
+ this.lateFor = 0;
223
+ }
224
+ if (last) {
225
+ // Copied: the queue's frames are its own and may be reused.
226
+ if (this.rawHeld?.length !== last.length)
227
+ this.rawHeld = new Float32Array(last.length);
228
+ this.rawHeld.set(last);
229
+ }
230
+ const held = this.rawHeld;
231
+ if (!held)
232
+ return null;
233
+ const next = q.getFrame(0);
234
+ const a = target - Math.floor(target);
235
+ // Nothing new consumed and nothing to move toward: not fresh, so a
236
+ // starved queue still releases the mouth after staleReleaseSeconds.
237
+ if (!next)
238
+ return last ? held : null;
239
+ if (a <= 0)
240
+ return held;
241
+ if (this.blend?.length !== held.length)
242
+ this.blend = new Float32Array(held.length);
243
+ for (let i = 0; i < held.length; i++)
244
+ this.blend[i] = held[i] + ((next[i] ?? held[i]) - held[i]) * a;
245
+ return this.blend;
246
+ }
247
+ /** A rest frame in the stream's layout, for the finisher to add to. */
248
+ zero() {
249
+ const n = MHA_PROFILE.channelNames.length;
250
+ if (this.zeros?.length !== n)
251
+ this.zeros = new Float32Array(n);
252
+ else
253
+ this.zeros.fill(0);
254
+ return this.zeros;
255
+ }
94
256
  /** Tick each frame. Pass the queue only if this character owns the turn. */
95
257
  update(delta, q) {
96
258
  if (!this.bindings.length)
@@ -108,7 +270,14 @@ export class LipsyncSystem {
108
270
  // or the body layer holds "talking" forever.
109
271
  this.speaking = false;
110
272
  this.energy += (0 - this.energy) * (1 - Math.exp(-delta / 0.22));
111
- this.processor.apply(this.processor.process(null, delta), this.bindings);
273
+ let idle = this.processor.process(null, delta);
274
+ if (!idle && this.t.finish && this.finisher.active)
275
+ idle = this.zero();
276
+ if (idle && this.t.finish)
277
+ this.finisher.apply(idle, delta, { raw: null, speaking: false, level: null, envelope: 0 });
278
+ if (idle)
279
+ this.expression?.apply(idle, delta, false);
280
+ this.processor.apply(idle, this.bindings);
112
281
  return;
113
282
  }
114
283
  const fps = q.getPlaybackFps?.() || 60;
@@ -119,8 +288,28 @@ export class LipsyncSystem {
119
288
  this.sinceFresh = 0;
120
289
  }
121
290
  this.wasSpeaking = speaking;
291
+ const clocked = speaking && !!this.voice?.available;
292
+ if (speaking && !this.wasSpeakingClock) {
293
+ this.voice?.reset();
294
+ this.turnFrame = 0;
295
+ this.anchor = null;
296
+ this.holdWaiting = null;
297
+ this.dry = false;
298
+ this.rawHeld = null;
299
+ }
300
+ this.wasSpeakingClock = speaking;
122
301
  let fresh = null;
123
- if (speaking) {
302
+ if (clocked) {
303
+ fresh = this.followVoice(q, elapsed, fps);
304
+ if (fresh) {
305
+ this.lastFrame = this.correct(fresh);
306
+ this.sinceFresh = 0;
307
+ }
308
+ else if (this.anchor !== null) {
309
+ this.sinceFresh += elapsed;
310
+ }
311
+ }
312
+ else if (speaking) {
124
313
  this.accum += elapsed;
125
314
  while (this.accum >= frameTime && q.hasFrames()) {
126
315
  const frame = q.getFrameWithAlpha(0);
@@ -145,12 +334,23 @@ export class LipsyncSystem {
145
334
  // Holding the last shape through a stall reads as a frozen mouth; let the
146
335
  // envelope settle it instead, and pick up again on the next frame.
147
336
  const stale = this.sinceFresh > this.t.staleReleaseSeconds;
148
- const raw = speaking && !stale ? this.lastFrame : null;
149
- const out = this.processor.process(raw, delta);
337
+ // On the voice clock the mouth holds at rest until the voice is audible.
338
+ const waiting = clocked && this.anchor === null;
339
+ const raw = speaking && !stale && !waiting ? this.lastFrame : null;
340
+ let out = this.processor.process(raw, delta);
341
+ // The processor returns nothing once settled; the finisher's own motion
342
+ // (a voice-driven cover) must still be written out until it is gone.
343
+ if (!out && this.t.finish && this.finisher.active)
344
+ out = this.zero();
150
345
  // mirror the processor's envelope so the seal fades with speech
151
346
  const target = raw ? 1 : 0;
152
347
  const tau = target > this.env ? 0.09 : 0.18;
153
348
  this.env += (target - this.env) * (1 - Math.exp(-delta / tau));
349
+ if (out && this.t.finish) {
350
+ this.finisher.apply(out, delta, { raw, speaking, level: this.voice?.level ?? null, envelope: this.env });
351
+ }
352
+ if (out)
353
+ this.expression?.apply(out, delta, speaking);
154
354
  if (out) {
155
355
  const { jawCap, sealCeiling, sealOnset, teethRetract, teethUpperRest, teethUpperLift, teethLowerRest, teethLowerDrop } = this.t;
156
356
  const ch = this.ch;
@@ -0,0 +1,46 @@
1
+ export interface MouthFinishState {
2
+ /** The frame the stream is showing (before shaping), or null. */
3
+ raw: Float32Array | null;
4
+ speaking: boolean;
5
+ /** The voice's level now (0–1), or null when it cannot be read. */
6
+ level: number | null;
7
+ /** The processor's speech envelope, 0–1. */
8
+ envelope: number;
9
+ }
10
+ export declare class MouthFinish {
11
+ private readonly jaw;
12
+ private readonly peel;
13
+ private readonly funnel;
14
+ private readonly contact;
15
+ private readonly lipsTogether;
16
+ /** Every mouth and jaw channel. The profile's names carry the CTRL_expressions_ prefix. */
17
+ private readonly mouth;
18
+ /** Current bilabial closure, 0–1. */
19
+ seal: number;
20
+ private rest;
21
+ private pause;
22
+ private quiet;
23
+ private voiceEnv;
24
+ private print;
25
+ private still;
26
+ /** Covering a held stream, and how far the cover is in (0–1). */
27
+ private covering;
28
+ private cover;
29
+ private drive;
30
+ private starved;
31
+ private heard;
32
+ private closedFor;
33
+ private capped;
34
+ private readonly sx;
35
+ private readonly sv;
36
+ private primed;
37
+ /**
38
+ * Whether the finisher is still adding to the face: the stall cover, which
39
+ * the voice drives. The processor returns no frame once its own envelope
40
+ * has settled, and whatever was last written would then stay on the face;
41
+ * while this is true the caller must keep feeding frames.
42
+ */
43
+ get active(): boolean;
44
+ /** Finish `out` in place. */
45
+ apply(out: Float32Array, dt: number, s: MouthFinishState): void;
46
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Mouth finishing: what the MHA stream needs before it reads as a person
3
+ * talking, applied to the shaped frame (MHA channel order) before it is
4
+ * written to the face. The motion is the stream's own; smoothing harder,
5
+ * rate caps and voice-driven closures were all tried in the coffee-shop
6
+ * studio and each blurred articulation or stuttered. What remains:
7
+ *
8
+ * · Bilabials. The stream closes the lips with mouthLipsTogether*, a
9
+ * MetaHuman corrective the published bodies do not carry, so m/b/p never
10
+ * closed. On a closure the lip peel, funnel and most of the jaw are taken
11
+ * away as the stream asks, and the lips touch with the sticky shapes,
12
+ * bridging only the gap that is left. A closure longer than a real stop
13
+ * (the stream runs neighbouring m/b/p together: "Peter Piper picked a peck
14
+ * of pickled peppers" sealed half the line) lets go while the voice goes on.
15
+ * · Breaks. The stream keeps the mouth moving through the voice's pauses;
16
+ * from 150 ms of silence it drifts most of the way to rest, and parts again
17
+ * as the voice returns, at a human pace in both directions.
18
+ * · Stalls. When the voice plays and the stream gives nothing — frozen on
19
+ * one shape, or run dry — the voice's level drives the jaw until frames
20
+ * return: no voice ever plays on a still mouth. The cover eases in and
21
+ * out and holds until the stream moves again.
22
+ * · Onset. The processor's envelope fades speech in over ~90 ms, which showed
23
+ * the first syllable of a reply at a quarter of its size, after the voice;
24
+ * it is divided back out while it rises.
25
+ * · A soft knee on the odd over-wide opening (peaks of 0.5–0.57 against a
26
+ * typical high of 0.18 read as aggressive), lips scaled with the jaw.
27
+ * · Light critically damped smoothing (~33 ms), then every channel clamped
28
+ * to [0, 1] (past 1 a morph extrapolates and the lips cross).
29
+ *
30
+ * Voice level comes from the voice clock; without it, breaks and stalls are
31
+ * left alone (the gates stay off). Values are the studio's.
32
+ */
33
+ import { MHA_PROFILE, mhaChannelIndex } from '@convai/web-sdk/lipsync-helpers';
34
+ const LT_ON = 0.09;
35
+ const LT_FULL = 0.3;
36
+ /**
37
+ * A hold this long is a stall, s. Longer than the mouth's lead on the voice
38
+ * (LipsyncSystem's voiceLeadSeconds, 0.2) plus slack: the stream runs out
39
+ * that far ahead of the voice at the end of EVERY reply, and its last shape
40
+ * held under the voice's tail is not a stall (measured: 190 ms). At 60 ms
41
+ * the cover fired on every last word and the voice popped the jaw open.
42
+ * 0.34 s was seen on a live reply's end, so the line sits well past it.
43
+ */
44
+ const STALL = 0.5;
45
+ /** Voice with no stream at all for this long is a dry queue, s. */
46
+ const STARVED = 0.15;
47
+ const MAX_CLOSURE = 0.12;
48
+ const SMOOTH = 60;
49
+ const JAW_KNEE = 0.3;
50
+ const JAW_RATIO = 2.5;
51
+ const PAUSE = 0.15;
52
+ const PAUSE_FULL = 0.4;
53
+ const PAUSE_DEPTH = 0.85;
54
+ const smoothstep = (a, b, x) => {
55
+ const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
56
+ return t * t * (3 - 2 * t);
57
+ };
58
+ /** Sticky that just closes the gap left at this jaw; ×0.7 (at full, the lips clung like magnets). */
59
+ const stickyFor = (jaw) => 0.7 * Math.min(0.7, 0.35 + 3.5 * jaw);
60
+ const ix = (...names) => names.map((n) => mhaChannelIndex(n)).filter((i) => i !== undefined);
61
+ export class MouthFinish {
62
+ constructor() {
63
+ this.jaw = mhaChannelIndex('jawOpen') ?? -1;
64
+ this.peel = ix('mouthUpperLipRaiseL', 'mouthUpperLipRaiseR', 'mouthLowerLipDepressL', 'mouthLowerLipDepressR');
65
+ this.funnel = ix('mouthFunnelUL', 'mouthFunnelUR', 'mouthFunnelDL', 'mouthFunnelDR');
66
+ this.contact = ix('mouthStickyUC', 'mouthStickyDC', 'mouthStickyUINL', 'mouthStickyUINR', 'mouthStickyDINL', 'mouthStickyDINR');
67
+ this.lipsTogether = ix('mouthLipsTogetherUL', 'mouthLipsTogetherUR');
68
+ /** Every mouth and jaw channel. The profile's names carry the CTRL_expressions_ prefix. */
69
+ this.mouth = MHA_PROFILE.channelNames
70
+ .map((n, i) => (/^(mouth|jaw)/.test(n.replace(/^CTRL_expressions_/, '')) ? i : -1))
71
+ .filter((i) => i >= 0);
72
+ /** Current bilabial closure, 0–1. */
73
+ this.seal = 0;
74
+ this.rest = 1;
75
+ this.pause = 0;
76
+ this.quiet = 0;
77
+ this.voiceEnv = 0;
78
+ this.print = NaN;
79
+ this.still = 0;
80
+ /** Covering a held stream, and how far the cover is in (0–1). */
81
+ this.covering = false;
82
+ this.cover = 0;
83
+ this.drive = 0;
84
+ this.starved = 0;
85
+ this.heard = false;
86
+ this.closedFor = 0;
87
+ this.capped = false;
88
+ this.sx = new Float32Array(this.mouth.length);
89
+ this.sv = new Float32Array(this.mouth.length);
90
+ this.primed = false;
91
+ }
92
+ /**
93
+ * Whether the finisher is still adding to the face: the stall cover, which
94
+ * the voice drives. The processor returns no frame once its own envelope
95
+ * has settled, and whatever was last written would then stay on the face;
96
+ * while this is true the caller must keep feeding frames.
97
+ */
98
+ get active() {
99
+ return this.cover > 0.002 || this.drive > 0.002;
100
+ }
101
+ /** Finish `out` in place. */
102
+ apply(out, dt, s) {
103
+ if (this.jaw < 0 || dt <= 0)
104
+ return;
105
+ // Speaking once the voice is heard: the client flags a reply ~0.45 s
106
+ // before its audio arrives, and until then the mouth rests, ready.
107
+ if (!s.speaking)
108
+ this.heard = false;
109
+ else if (s.level === null || s.level > 0.02)
110
+ this.heard = true;
111
+ const speaking = s.speaking && this.heard;
112
+ let raw = s.raw;
113
+ // Stall: a stream holding exactly still while the voice plays.
114
+ this.voiceEnv += ((s.level === null ? 0 : smoothstep(0.008, 0.09, s.level)) - this.voiceEnv) * (1 - Math.exp(-dt / 0.05));
115
+ // The cover blends in and out (never switches), and once on it stays on
116
+ // until the stream moves again, speaking or not. The commonest hold is
117
+ // the end of a reply: the stream stops on its last shape while the
118
+ // voice's tail plays. Switched, the cover shut the mouth there, then let
119
+ // go as the reply ended and the held shape opened it again before the
120
+ // rest closed it: a flutter on every last word.
121
+ if (raw) {
122
+ const print = raw[this.jaw] + 3 * raw[0] + 7 * raw[raw.length >> 1] + 13 * raw[raw.length - 1];
123
+ if (print !== this.print) {
124
+ this.still = 0;
125
+ this.covering = false;
126
+ }
127
+ else
128
+ this.still += dt;
129
+ this.print = print;
130
+ this.starved = 0;
131
+ if (speaking && s.level !== null && this.still > STALL)
132
+ this.covering = true;
133
+ }
134
+ else {
135
+ // No stream. Under an audible voice that is a dry queue; the end of a
136
+ // reply, where the stream (which leads) runs out ~0.2 s before the
137
+ // voice, reads as a fading level and drives nothing.
138
+ this.starved = speaking && s.level !== null && s.level > 0.02 ? this.starved + dt : 0;
139
+ if (this.starved > STARVED)
140
+ this.covering = true;
141
+ if (!s.speaking && !this.covering)
142
+ this.still = 0;
143
+ }
144
+ if (!s.speaking)
145
+ this.covering = false;
146
+ this.cover += ((this.covering ? 1 : 0) - this.cover) * (1 - Math.exp(-dt / (this.covering ? 0.08 : 0.12)));
147
+ this.drive += ((this.covering ? this.voiceEnv : 0) - this.drive) * (1 - Math.exp(-dt / 0.1));
148
+ if (this.cover > 0.002) {
149
+ const k = this.cover;
150
+ for (const i of this.mouth)
151
+ out[i] *= 1 - k;
152
+ out[this.jaw] += 0.18 * this.drive * k;
153
+ for (const i of this.peel)
154
+ out[i] += 0.08 * this.drive * k;
155
+ if (this.covering)
156
+ raw = null;
157
+ }
158
+ // Onset: the first syllable at full size.
159
+ if (speaking && raw && s.envelope < 0.98) {
160
+ this.rest = 0;
161
+ const g = 1 / Math.max(s.envelope, 0.2);
162
+ for (const i of this.mouth)
163
+ out[i] *= g;
164
+ }
165
+ // Soft knee on the odd over-wide opening.
166
+ const j0 = out[this.jaw];
167
+ if (j0 > JAW_KNEE) {
168
+ const r = (JAW_KNEE + (j0 - JAW_KNEE) / JAW_RATIO) / j0;
169
+ out[this.jaw] = j0 * r;
170
+ for (const i of this.peel)
171
+ out[i] *= r;
172
+ }
173
+ // Rest after a reply, and in its breaks.
174
+ this.rest += ((speaking ? 0 : 1) - this.rest) * (1 - Math.exp(-dt / (speaking ? 0.05 : 0.3)));
175
+ this.quiet = speaking && s.level !== null && s.level < 0.006 ? this.quiet + dt : 0;
176
+ const gate = PAUSE_DEPTH * smoothstep(PAUSE, PAUSE_FULL, this.quiet);
177
+ this.pause += (gate - this.pause) * (1 - Math.exp(-dt / (gate > this.pause ? 0.09 : 0.08)));
178
+ const settle = Math.max(this.rest, this.pause);
179
+ if (settle > 0.002)
180
+ for (const i of this.mouth)
181
+ out[i] *= 1 - settle;
182
+ // Bilabials, on the stream's own timing.
183
+ let lt = 0;
184
+ if (speaking && raw)
185
+ for (const i of this.lipsTogether)
186
+ lt = Math.max(lt, raw[i]);
187
+ let target = smoothstep(LT_ON, LT_FULL, lt);
188
+ if (speaking && target > 0.5) {
189
+ this.closedFor += dt;
190
+ if (this.closedFor > MAX_CLOSURE && s.level !== null && s.level > 0.01)
191
+ this.capped = true;
192
+ }
193
+ else if (target < 0.3) {
194
+ this.closedFor = 0;
195
+ this.capped = false;
196
+ }
197
+ if (this.capped)
198
+ target = 0;
199
+ this.seal += (target - this.seal) * (1 - Math.exp(-dt / (target > this.seal ? 0.05 : 0.08)));
200
+ const c = this.seal;
201
+ if (c > 0.002) {
202
+ for (const i of this.peel)
203
+ out[i] *= 1 - c;
204
+ for (const i of this.funnel)
205
+ out[i] *= 1 - c;
206
+ out[this.jaw] *= 1 - 0.9 * c;
207
+ }
208
+ // Smoothing, exact for any frame time.
209
+ const ex = Math.exp(-SMOOTH * dt);
210
+ for (let k = 0; k < this.mouth.length; k++) {
211
+ const i = this.mouth[k];
212
+ const goal = out[i];
213
+ if (!this.primed) {
214
+ this.sx[k] = goal;
215
+ this.sv[k] = 0;
216
+ continue;
217
+ }
218
+ const y = this.sx[k] - goal;
219
+ const tmp = (this.sv[k] + SMOOTH * y) * dt;
220
+ this.sx[k] = goal + (y + tmp) * ex;
221
+ this.sv[k] = (this.sv[k] - SMOOTH * tmp) * ex;
222
+ out[i] = this.sx[k];
223
+ }
224
+ this.primed = true;
225
+ // Range, then the lips touch: on a bilabial, and lightly at rest.
226
+ for (const i of this.mouth)
227
+ out[i] = Math.min(1, Math.max(0, out[i]));
228
+ const touch = Math.max(c, settle);
229
+ if (touch > 0.002) {
230
+ const v = stickyFor(out[this.jaw]) * touch;
231
+ for (const i of this.contact)
232
+ out[i] = Math.max(out[i], v);
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The voice's own clock, for lipsync.
3
+ *
4
+ * Mouth frames arrive paced for the wall clock (60 per second from the moment
5
+ * the character starts speaking). WebRTC does not play the voice on the wall
6
+ * clock: its jitter buffer holds it (~180 ms measured) and stretches and
7
+ * squeezes it to absorb network jitter (a 25.25 s reply played in 24.9 s).
8
+ * Against the pauses in the voice, a wall-clocked mouth started ~230 ms late
9
+ * and ended ~250 ms early in a 20 s reply: it moved through silences.
10
+ *
11
+ * So the mouth follows the voice. This reads the received audio track's RTP
12
+ * synchronization source — the timestamp of the sample playing now (48 kHz)
13
+ * and its level — and reports how much voice has played since the first
14
+ * audible sample:
15
+ *
16
+ * · until the voice is audible it reports null (the mouth holds);
17
+ * · after that it advances by the voice's own RTP steps, so stretching and
18
+ * squeezing move the mouth with the voice;
19
+ * · while no packets arrive (a network break, a server stall, silence
20
+ * suppression) it holds, and when they return advances by the voice time
21
+ * that actually passed — a wall-clocked mouth stayed ahead of the voice by
22
+ * every break's length for the rest of the reply.
23
+ *
24
+ * The level comes from the source when the sender negotiated the RTP
25
+ * audio-level extension. Convai's voice does not (measured on staging:
26
+ * sources carry no `audioLevel`), so otherwise it comes from the receiver's
27
+ * inbound-rtp stats: the level of the decoded audio, refreshed every
28
+ * ~100–200 ms in Chrome. Coarse, but breaks are 150 ms and longer. One stats
29
+ * request is in flight at a time; a stats level older than 0.5 s is not used.
30
+ *
31
+ * A level is believed only once it has read above zero. A voice the page
32
+ * does not play out (autoplay blocked before a user gesture, a muted or
33
+ * detached element) still arrives and still advances the clock, but Chrome
34
+ * measures its level as exactly 0 throughout (measured: 306 speaking frames,
35
+ * peak 0, totalAudioEnergy 0). Taken at its word, that silence held every
36
+ * break gate shut and pulled the mouth to rest against the stream every
37
+ * 150 ms of a reply. Until the voice is shown to be playing, the level is
38
+ * unknown (null), and the gates that depend on it stay off.
39
+ *
40
+ * No AudioContext: neither level needs a user gesture.
41
+ */
42
+ /** What the clock needs of an RTCRtpReceiver. */
43
+ export interface VoiceReceiver {
44
+ getSynchronizationSources(): {
45
+ rtpTimestamp: number;
46
+ timestamp: number;
47
+ audioLevel?: number;
48
+ }[];
49
+ getStats?(): Promise<{
50
+ forEach(cb: (s: {
51
+ type: string;
52
+ kind?: string;
53
+ audioLevel?: number;
54
+ }) => void): void;
55
+ }>;
56
+ }
57
+ export declare class VoiceClock {
58
+ private readonly receiver;
59
+ private readonly now;
60
+ private started;
61
+ private played;
62
+ private lastRtp;
63
+ private lastGoodRtp;
64
+ private held;
65
+ private waited;
66
+ /** Seconds of packets seen while waiting for the voice. */
67
+ private packets;
68
+ private stats;
69
+ private polling;
70
+ /** Whether any level has read above zero: the voice is being played out. */
71
+ private playing;
72
+ /** Audio level of the voice this tick (0–1), or null without a receiver. */
73
+ level: number | null;
74
+ /** True while no packets arrive: the clock is holding. */
75
+ holding: boolean;
76
+ /** Seconds the clock jumped on the tick packets came back; 0 on every other tick. */
77
+ resumedBy: number;
78
+ constructor(receiver: () => VoiceReceiver | null, now?: () => number);
79
+ /** Whether a receiver is available to clock against. */
80
+ get available(): boolean;
81
+ /** Start of a turn: forget the last one. */
82
+ reset(): void;
83
+ /**
84
+ * The latest inbound-rtp level, and ask for the next one. `undefined` while
85
+ * the first answer is still coming (a level is readable, just not yet);
86
+ * null when the receiver has no stats at all.
87
+ */
88
+ private statsLevel;
89
+ /**
90
+ * Advance by `wallStep` seconds of render time. Returns seconds of voice
91
+ * played since it became audible, or null while it is not audible yet.
92
+ */
93
+ update(wallStep: number): number | null;
94
+ }