@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/speech/lipsync.js
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
-
|
|
149
|
-
const
|
|
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
|
+
}
|