@voqalize/avatar 0.0.1
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/LICENSE +661 -0
- package/README.md +692 -0
- package/client/dist/Avatar.d.ts +24 -0
- package/client/dist/Avatar.d.ts.map +1 -0
- package/client/dist/Avatar.js +7 -0
- package/client/dist/Avatar.js.map +1 -0
- package/client/dist/AvatarClient.d.ts +173 -0
- package/client/dist/AvatarClient.d.ts.map +1 -0
- package/client/dist/AvatarClient.js +274 -0
- package/client/dist/AvatarClient.js.map +1 -0
- package/client/dist/pipecat.d.ts +21 -0
- package/client/dist/pipecat.d.ts.map +1 -0
- package/client/dist/pipecat.js +21 -0
- package/client/dist/pipecat.js.map +1 -0
- package/client/dist/react.d.ts +16 -0
- package/client/dist/react.d.ts.map +1 -0
- package/client/dist/react.js +17 -0
- package/client/dist/react.js.map +1 -0
- package/client/dist/types.d.ts +101 -0
- package/client/dist/types.d.ts.map +1 -0
- package/client/dist/types.js +31 -0
- package/client/dist/types.js.map +1 -0
- package/client/dist/useAvatar.d.ts +53 -0
- package/client/dist/useAvatar.d.ts.map +1 -0
- package/client/dist/useAvatar.js +68 -0
- package/client/dist/useAvatar.js.map +1 -0
- package/client/src/Avatar.tsx +38 -0
- package/client/src/AvatarClient.ts +343 -0
- package/client/src/pipecat.ts +38 -0
- package/client/src/react.ts +34 -0
- package/client/src/types.ts +127 -0
- package/client/src/useAvatar.ts +113 -0
- package/docs/contract-avatar.md +337 -0
- package/docs/contract-protocol.md +401 -0
- package/package.json +89 -0
- package/src/audio-fallback.js +100 -0
- package/src/avatar.d.ts +241 -0
- package/src/avatar.js +722 -0
- package/src/clips.js +144 -0
- package/src/emotions.js +55 -0
- package/src/face-core.js +154 -0
- package/src/face-myna.js +725 -0
- package/src/face-peep.js +767 -0
- package/src/face-wren.js +470 -0
- package/src/gaze.js +155 -0
- package/src/idle.js +535 -0
- package/src/interjections.js +578 -0
- package/src/line-art.js +111 -0
- package/src/params.js +176 -0
- package/src/perform.js +105 -0
- package/src/visemes.js +230 -0
package/src/line-art.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* line-art — the variable-width stroke engine peep was built with.
|
|
3
|
+
*
|
|
4
|
+
* The construction idiom of the Open Peeps style is that NOTHING IS A STROKE:
|
|
5
|
+
* every line is a filled outline whose width varies along its length, which is
|
|
6
|
+
* what lets a mark swell in the middle and come to a point at the ends. A
|
|
7
|
+
* uniform `stroke-width` with round caps is a rope with a blob at each end,
|
|
8
|
+
* and it is the whole difference between "vector illustration" and "someone
|
|
9
|
+
* drew this". Extracted from face-peep.js verbatim so the next line-art
|
|
10
|
+
* character starts from the kit, not from a copy of peep.
|
|
11
|
+
*
|
|
12
|
+
* A curve is a flat list of points in polybezier form — [p0, c1, c2, p1, c3,
|
|
13
|
+
* c4, p2, ...]. `widths` is a PROFILE across the whole mark, sampled at even
|
|
14
|
+
* intervals: [3, 11, 3] means thin-fat-thin whether the curve has one Bézier
|
|
15
|
+
* segment or six. Indexing per node instead is a trap — a one-segment curve
|
|
16
|
+
* has only two nodes, so a third entry is never read and the mark comes out
|
|
17
|
+
* blunt at one end.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { f } from './face-core.js';
|
|
21
|
+
|
|
22
|
+
export function toSegs(flat) {
|
|
23
|
+
// Loud, because the failure is otherwise silent: a point count that is not
|
|
24
|
+
// 3n+1 simply drops the trailing points and the mark comes out short with no
|
|
25
|
+
// error anywhere.
|
|
26
|
+
if (flat.length < 4 || (flat.length - 1) % 3 !== 0) {
|
|
27
|
+
throw new Error(`curve needs 3n+1 points, got ${flat.length}`);
|
|
28
|
+
}
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let i = 0; i + 3 < flat.length; i += 3) out.push(flat.slice(i, i + 4));
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const bezPt = (P, t) => {
|
|
35
|
+
const u = 1 - t;
|
|
36
|
+
return [0, 1].map(
|
|
37
|
+
(i) => u * u * u * P[0][i] + 3 * u * u * t * P[1][i] + 3 * u * t * t * P[2][i] + t * t * t * P[3][i]
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
export const bezTan = (P, t) => {
|
|
41
|
+
const u = 1 - t;
|
|
42
|
+
return [0, 1].map(
|
|
43
|
+
(i) => 3 * (u * u * (P[1][i] - P[0][i]) + 2 * u * t * (P[2][i] - P[1][i]) + t * t * (P[3][i] - P[2][i]))
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function widthAt(ws, s) {
|
|
48
|
+
const u = s * (ws.length - 1);
|
|
49
|
+
const i = Math.min(Math.floor(u), ws.length - 2);
|
|
50
|
+
return ws[i] + (ws[i + 1] - ws[i]) * (u - i);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Sample a polybezier: point, unit normal, and normalized position along it. */
|
|
54
|
+
export function walk(segs, per) {
|
|
55
|
+
const out = [];
|
|
56
|
+
segs.forEach((P, i) => {
|
|
57
|
+
for (let k = i === 0 ? 0 : 1; k <= per; k++) {
|
|
58
|
+
const t = k / per;
|
|
59
|
+
const d = bezTan(P, t);
|
|
60
|
+
const L = Math.hypot(d[0], d[1]) || 1;
|
|
61
|
+
out.push({ p: bezPt(P, t), n: [-d[1] / L, d[0] / L], s: (i + t) / segs.length });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const polyD = (pts, cmd) =>
|
|
68
|
+
pts.map(([x, y], i) => `${i ? 'L' : cmd}${f(x)} ${f(y)}`).join('');
|
|
69
|
+
|
|
70
|
+
/** An open tapered mark: offset ±w/2 along the normal and close the polygon. */
|
|
71
|
+
export function taper(flat, widths, per = 8) {
|
|
72
|
+
const s = walk(toSegs(flat), per);
|
|
73
|
+
const a = [], b = [];
|
|
74
|
+
for (const { p, n, s: u } of s) {
|
|
75
|
+
const h = widthAt(widths, u) / 2;
|
|
76
|
+
a.push([p[0] + n[0] * h, p[1] + n[1] * h]);
|
|
77
|
+
b.push([p[0] - n[0] * h, p[1] - n[1] * h]);
|
|
78
|
+
}
|
|
79
|
+
return polyD(a, 'M') + polyD(b.reverse(), 'L') + 'Z';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A closed tapered outline — an annulus of varying width around a contour.
|
|
84
|
+
* Two subpaths of opposite winding, so the inside is a hole under nonzero fill.
|
|
85
|
+
*/
|
|
86
|
+
export function taperRing(flat, widths, per = 8) {
|
|
87
|
+
const s = walk(toSegs(flat), per);
|
|
88
|
+
const a = [], b = [];
|
|
89
|
+
for (const { p, n, s: u } of s) {
|
|
90
|
+
const h = widthAt(widths, u) / 2;
|
|
91
|
+
a.push([p[0] + n[0] * h, p[1] + n[1] * h]);
|
|
92
|
+
b.push([p[0] - n[0] * h, p[1] - n[1] * h]);
|
|
93
|
+
}
|
|
94
|
+
return polyD(a, 'M') + 'Z' + polyD(b.reverse(), 'M') + 'Z';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The plain region a curve encloses — the white "background" path of a part. */
|
|
98
|
+
export function region(flat) {
|
|
99
|
+
const segs = toSegs(flat);
|
|
100
|
+
let d = `M${f(segs[0][0][0])} ${f(segs[0][0][1])}`;
|
|
101
|
+
for (const P of segs) {
|
|
102
|
+
d += `C${f(P[1][0])} ${f(P[1][1])} ${f(P[2][0])} ${f(P[2][1])} ${f(P[3][0])} ${f(P[3][1])}`;
|
|
103
|
+
}
|
|
104
|
+
return d + 'Z';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Deterministic jitter, so a drawing is the same every load. */
|
|
108
|
+
export function rng(seed) {
|
|
109
|
+
let s = seed >>> 0;
|
|
110
|
+
return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
|
|
111
|
+
}
|
package/src/params.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rig parameter space.
|
|
3
|
+
*
|
|
4
|
+
* Everything the face can do is a point in this ~30-dimensional space. Visemes,
|
|
5
|
+
* emotions, gaze poses and gesture keyframes are all just named vectors here, so
|
|
6
|
+
* blending them is plain arithmetic rather than SVG path surgery.
|
|
7
|
+
*
|
|
8
|
+
* Sign conventions (viewer's perspective):
|
|
9
|
+
* headYaw + turns toward viewer's right
|
|
10
|
+
* headPitch + chin down
|
|
11
|
+
* headRoll + tilts toward viewer's right
|
|
12
|
+
* pupilX + right, pupilY + down
|
|
13
|
+
* browRaise + up, browAngle + outer end up, browInner + inner end up
|
|
14
|
+
* mouthCornerL/R + up (smile)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const REST = {
|
|
18
|
+
// --- mouth -------------------------------------------------------------
|
|
19
|
+
mouthOpen: 0.02, // vertical aperture, 0..1
|
|
20
|
+
mouthWidth: 0.42, // 0 narrow .. 1 wide (0.42 is neutral)
|
|
21
|
+
mouthRound: 0.1, // pucker / lip protrusion
|
|
22
|
+
mouthPress: 0.15, // lips thinned & pressed together
|
|
23
|
+
mouthTuck: 0.0, // lower lip drawn under upper teeth (F/V)
|
|
24
|
+
mouthCornerL: 0.1, // -1 frown .. +1 smile
|
|
25
|
+
mouthCornerR: 0.1,
|
|
26
|
+
teethUpper: 0.0, // how far the upper teeth show, 0..1
|
|
27
|
+
tongue: 0.0, // tongue raised into the aperture, 0..1
|
|
28
|
+
jaw: 0.0, // extra chin drop, follows mouthOpen but slower
|
|
29
|
+
|
|
30
|
+
// --- eyes --------------------------------------------------------------
|
|
31
|
+
// A fully-open lid shows an unnatural ring of sclera; real neutral eyes sit
|
|
32
|
+
// with the upper lid already grazing the iris.
|
|
33
|
+
lidL: 0.12, // 0 wide open .. 1 fully closed
|
|
34
|
+
lidR: 0.12,
|
|
35
|
+
squintL: 0.0, // lower lid raised (smile / suspicion)
|
|
36
|
+
squintR: 0.0,
|
|
37
|
+
pupilX: 0.0,
|
|
38
|
+
pupilY: 0.05,
|
|
39
|
+
|
|
40
|
+
// --- brows -------------------------------------------------------------
|
|
41
|
+
browRaiseL: 0.0,
|
|
42
|
+
browRaiseR: 0.0,
|
|
43
|
+
browAngleL: 0.0,
|
|
44
|
+
browAngleR: 0.0,
|
|
45
|
+
browInnerL: 0.0, // inner-end lift, the "concern" muscle (AU1)
|
|
46
|
+
browInnerR: 0.0,
|
|
47
|
+
|
|
48
|
+
// --- head & body -------------------------------------------------------
|
|
49
|
+
headYaw: 0.0,
|
|
50
|
+
headPitch: 0.0,
|
|
51
|
+
headRoll: 0.0,
|
|
52
|
+
breath: 0.0, // driven by the idle layer, 0..1 through the cycle
|
|
53
|
+
|
|
54
|
+
// --- shoulders & torso -------------------------------------------------
|
|
55
|
+
// Shoulders are a floor-management channel as much as an affective one. The
|
|
56
|
+
// single most legible "I would like to come in" signal a person gives is the
|
|
57
|
+
// shoulders rising with an inbreath, and the face alone cannot say it.
|
|
58
|
+
shoulderL: 0.0, // -1 dropped .. +1 raised
|
|
59
|
+
shoulderR: 0.0,
|
|
60
|
+
// Leaning in is engagement and leaning back is withdrawal, and in a webcam
|
|
61
|
+
// frame both are read almost entirely as a change of scale.
|
|
62
|
+
torsoLean: 0.0, // -1 back .. +1 forward
|
|
63
|
+
// The trunk's own lateral axis, and the last body degree of freedom the rig
|
|
64
|
+
// was missing. Before it, everything below the collar could do was rise,
|
|
65
|
+
// fall and scale: a motion map over a 24-second listening run showed the
|
|
66
|
+
// outer edge of the body travelling exactly zero pixels. A seated person's
|
|
67
|
+
// trunk shifts sideways constantly — settling, re-settling, and lagging
|
|
68
|
+
// after a head turn — and none of that was expressible.
|
|
69
|
+
//
|
|
70
|
+
// It carries the head's turn on a much slower time constant (below), which
|
|
71
|
+
// is what buys follow-through for free: the mixer retargets it to headYaw
|
|
72
|
+
// every frame and the difference in TAU does the rest.
|
|
73
|
+
torsoTurn: 0.0, // -1 trunk toward viewer's left .. +1 right
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// There are deliberately no arm or hand channels. The rig carried a full
|
|
77
|
+
// forearm/hand chain — raise, spread, wrist rotation, splay, thumb, index — and
|
|
78
|
+
// it was removed rather than fixed. Two reasons, both worth knowing before
|
|
79
|
+
// anyone re-adds it. The framing is a head-and-shoulders portrait, so a hand
|
|
80
|
+
// only exists at the bottom edge of the crop and every pose is a compromise
|
|
81
|
+
// between "large enough to read" and "not covering the face"; and gesture is a
|
|
82
|
+
// fraction of a percent of what this widget is for, against which the arm chain
|
|
83
|
+
// was the single largest and most defect-prone body of geometry in the rig.
|
|
84
|
+
// What the arms used to say — assent, deference, wanting the floor, apology —
|
|
85
|
+
// is said by the head, brows, shoulders and torso lean instead, which is where
|
|
86
|
+
// a viewer looking at a face on a video call is already looking.
|
|
87
|
+
|
|
88
|
+
export const CHANNELS = Object.keys(REST);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Channel groups. Gesture clips declare which groups they own so that, say, a
|
|
92
|
+
* nod during speech moves the head without stealing the mouth from the viseme
|
|
93
|
+
* stream.
|
|
94
|
+
*/
|
|
95
|
+
export const GROUPS = {
|
|
96
|
+
mouth: [
|
|
97
|
+
'mouthOpen', 'mouthWidth', 'mouthRound', 'mouthPress', 'mouthTuck',
|
|
98
|
+
'teethUpper', 'tongue', 'jaw',
|
|
99
|
+
],
|
|
100
|
+
smile: ['mouthCornerL', 'mouthCornerR'],
|
|
101
|
+
eyes: ['lidL', 'lidR', 'squintL', 'squintR'],
|
|
102
|
+
gaze: ['pupilX', 'pupilY'],
|
|
103
|
+
brows: [
|
|
104
|
+
'browRaiseL', 'browRaiseR', 'browAngleL', 'browAngleR',
|
|
105
|
+
'browInnerL', 'browInnerR',
|
|
106
|
+
],
|
|
107
|
+
head: ['headYaw', 'headPitch', 'headRoll'],
|
|
108
|
+
body: ['breath', 'torsoLean', 'torsoTurn'],
|
|
109
|
+
shoulders: ['shoulderL', 'shoulderR'],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Per-channel smoothing time constants, in seconds. This is where the face gets
|
|
114
|
+
* its sense of mass: the mouth snaps, the head drifts. It also does all the
|
|
115
|
+
* viseme co-articulation for free — we never blend shapes explicitly, we just
|
|
116
|
+
* retarget and let the mouth channels chase at ~40ms.
|
|
117
|
+
*/
|
|
118
|
+
export const TAU = (() => {
|
|
119
|
+
const t = {};
|
|
120
|
+
for (const c of CHANNELS) t[c] = 0.09;
|
|
121
|
+
for (const c of GROUPS.mouth) t[c] = 0.042;
|
|
122
|
+
for (const c of GROUPS.smile) t[c] = 0.13;
|
|
123
|
+
t.lidL = t.lidR = 0.018; // blinks must be crisp
|
|
124
|
+
t.squintL = t.squintR = 0.12;
|
|
125
|
+
t.pupilX = t.pupilY = 0.032; // saccades are ballistic and fast
|
|
126
|
+
for (const c of GROUPS.brows) t[c] = 0.08;
|
|
127
|
+
for (const c of GROUPS.head) t[c] = 0.16; // the head has real mass
|
|
128
|
+
t.breath = 0.25;
|
|
129
|
+
t.jaw = 0.07; // the jaw lags the lips slightly
|
|
130
|
+
// The torso has more mass than the head and reads wrong when it hasn't.
|
|
131
|
+
t.shoulderL = t.shoulderR = 0.19;
|
|
132
|
+
t.torsoLean = 0.24;
|
|
133
|
+
// Nearly 3x the head's, and that ratio is the whole point rather than a
|
|
134
|
+
// taste call. The mixer feeds torsoTurn the *same* target as headYaw, so
|
|
135
|
+
// every head turn is chased by a trunk that arrives late and settles late —
|
|
136
|
+
// follow-through out of the smoothing mechanism the rig already had, with no
|
|
137
|
+
// second animation system to keep in sync. Shorten this toward the head's
|
|
138
|
+
// 0.16 and the two move as one rigid piece, which is the puppet read.
|
|
139
|
+
t.torsoTurn = 0.44;
|
|
140
|
+
return t;
|
|
141
|
+
})();
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Per-channel clamp bounds, applied after the layers mix and before smoothing.
|
|
145
|
+
*
|
|
146
|
+
* The head, mouth corners and brow angles are deliberately allowed past 1: they
|
|
147
|
+
* are the channels a gesture clip adds to on top of an already-posed face, and
|
|
148
|
+
* clipping them at 1 flattens the peak of every nod fired during an emotion.
|
|
149
|
+
*/
|
|
150
|
+
export const RANGE = (() => {
|
|
151
|
+
const r = {};
|
|
152
|
+
for (const c of CHANNELS) r[c] = [0, 1];
|
|
153
|
+
for (const c of GROUPS.head) r[c] = [-1.4, 1.4];
|
|
154
|
+
r.mouthCornerL = r.mouthCornerR = [-1.4, 1.4];
|
|
155
|
+
r.browAngleL = r.browAngleR = [-1.4, 1.4];
|
|
156
|
+
r.browRaiseL = r.browRaiseR = [-1, 1];
|
|
157
|
+
r.browInnerL = r.browInnerR = [-1, 1];
|
|
158
|
+
r.pupilX = r.pupilY = [-1, 1];
|
|
159
|
+
r.shoulderL = r.shoulderR = [-1, 1];
|
|
160
|
+
r.torsoLean = [-1, 1];
|
|
161
|
+
r.torsoTurn = [-1, 1];
|
|
162
|
+
return r;
|
|
163
|
+
})();
|
|
164
|
+
|
|
165
|
+
export const clamp = (v, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v);
|
|
166
|
+
export const lerp = (a, b, t) => a + (b - a) * t;
|
|
167
|
+
|
|
168
|
+
export function makeParams(overrides) {
|
|
169
|
+
return Object.assign({}, REST, overrides);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Frame-rate independent exponential approach toward a target. */
|
|
173
|
+
export function approach(cur, target, tau, dt) {
|
|
174
|
+
if (tau <= 0) return target;
|
|
175
|
+
return cur + (target - cur) * (1 - Math.exp(-dt / tau));
|
|
176
|
+
}
|
package/src/perform.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Perform — the composable action timeline.
|
|
3
|
+
*
|
|
4
|
+
* A performance is the server's choreography: timed verbs `{t, do, ...}` fired
|
|
5
|
+
* against a clock, where every verb resolves to one of the widget's own enums —
|
|
6
|
+
* states, emotions, gaze targets, interjections. The vocabulary is deliberately
|
|
7
|
+
* closed: the backend sequences what the rig already does well, it cannot
|
|
8
|
+
* invent motion. That constraint is what makes the wire format assemblable by
|
|
9
|
+
* a dialogue manager and reviewable by a human.
|
|
10
|
+
*
|
|
11
|
+
* The clock discipline is VisemeTrack's (visemes.js): ride the audio clock
|
|
12
|
+
* whenever there is audio, because a gesture that drifts out of its own
|
|
13
|
+
* sentence is worse than no gesture at all, and a timer drifts the moment the
|
|
14
|
+
* tab is backgrounded. One deliberate difference: beat times fire *verbatim*,
|
|
15
|
+
* with no LEAD_MS. Visemes lead the sound because phoneme sync is
|
|
16
|
+
* frame-critical; a gesture arrives through its channels' own smoothing lag,
|
|
17
|
+
* and any deliberate lead (CLAIM_FLOOR starts ~350ms before the first sample)
|
|
18
|
+
* is authored into the times by the composer, not imposed here.
|
|
19
|
+
*
|
|
20
|
+
* Seeking the audio backward does not re-fire earlier actions: verbs have side
|
|
21
|
+
* effects, and replaying a nod is worse than missing one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const VERBS = new Set(['state', 'emotion', 'gaze', 'interject']);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Shape hygiene for action arrays, in the spirit of normalizeCues: sort by
|
|
28
|
+
* time, drop what cannot possibly fire — no finite `t`, an unknown verb, a
|
|
29
|
+
* missing `name`/`id` — each with a console warning, never a throw.
|
|
30
|
+
*
|
|
31
|
+
* This checks *shape* only. Enum values (is "THINKING" a state? is "NOD_UP"
|
|
32
|
+
* an interjection?) are checked when the verb fires, by the dispatcher in
|
|
33
|
+
* avatar.js — deliberately, and not just because importing the enums here
|
|
34
|
+
* would be a dependency cycle: a track half-composed against a newer widget
|
|
35
|
+
* should lose the verbs the widget doesn't know, not the whole performance.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeActions(actions) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const a of actions || []) {
|
|
40
|
+
if (!a || typeof a.t !== 'number' || !Number.isFinite(a.t)) {
|
|
41
|
+
console.warn('perform: dropped action without a time', a);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!VERBS.has(a.do)) {
|
|
45
|
+
console.warn(`perform: dropped unknown verb "${a && a.do}"`, a);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if ((a.do === 'interject' ? a.id : a.name) == null) {
|
|
49
|
+
console.warn(`perform: dropped ${a.do} with no ${a.do === 'interject' ? 'id' : 'name'}`, a);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
out.push(a);
|
|
53
|
+
}
|
|
54
|
+
return out.sort((x, y) => x.t - y.t);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Schedules an action track against a clock. Sampled by the mixer once per
|
|
59
|
+
* frame, so firing granularity is one frame — gestures cannot tell.
|
|
60
|
+
*/
|
|
61
|
+
export class PerformTrack {
|
|
62
|
+
constructor() {
|
|
63
|
+
this.actions = [];
|
|
64
|
+
this.clock = null;
|
|
65
|
+
this.playing = false;
|
|
66
|
+
this._idx = 0;
|
|
67
|
+
/** @type {(a: object) => void} fired per action, in time order */
|
|
68
|
+
this.onAction = null;
|
|
69
|
+
/** Fired when the last action has fired — not when its effects finish. */
|
|
70
|
+
this.onEnd = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {() => number} clock elapsed ms of whatever the track rides on */
|
|
74
|
+
start(actions, clock) {
|
|
75
|
+
this.actions = normalizeActions(actions);
|
|
76
|
+
this.clock = clock;
|
|
77
|
+
this._idx = 0;
|
|
78
|
+
this.playing = true;
|
|
79
|
+
if (!this.actions.length) this._finish();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Cancels future actions. Does not fire onEnd: a stopped performance did
|
|
83
|
+
* not complete, and nothing downstream should be told it did. */
|
|
84
|
+
stop() {
|
|
85
|
+
this.playing = false;
|
|
86
|
+
this.actions = [];
|
|
87
|
+
this.clock = null;
|
|
88
|
+
this._idx = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
update() {
|
|
92
|
+
if (!this.playing || !this.clock) return;
|
|
93
|
+
const now = this.clock();
|
|
94
|
+
while (this._idx < this.actions.length && this.actions[this._idx].t <= now) {
|
|
95
|
+
const a = this.actions[this._idx++];
|
|
96
|
+
if (this.onAction) this.onAction(a);
|
|
97
|
+
}
|
|
98
|
+
if (this._idx >= this.actions.length) this._finish();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_finish() {
|
|
102
|
+
this.playing = false;
|
|
103
|
+
if (this.onEnd) this.onEnd();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/visemes.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visemes — the wire protocol between the server and the mouth.
|
|
3
|
+
*
|
|
4
|
+
* We use the Rhubarb Lip Sync alphabet (A–H, plus X for silence), which is a
|
|
5
|
+
* condensation of the Preston Blair mouth set. Nine shapes is plenty for a
|
|
6
|
+
* stylized 2D face, and it gives the server side an obvious open-source
|
|
7
|
+
* reference implementation to target.
|
|
8
|
+
*
|
|
9
|
+
* A closed lips P B M (also the resting closure)
|
|
10
|
+
* B slightly open, K S T D, and consonantal EE
|
|
11
|
+
* teeth together
|
|
12
|
+
* C open EH AE
|
|
13
|
+
* D wide open AA
|
|
14
|
+
* E slightly rounded AO ER
|
|
15
|
+
* F puckered UW OW W
|
|
16
|
+
* G lip to upper teeth F V
|
|
17
|
+
* H tongue up L
|
|
18
|
+
* X idle / silence
|
|
19
|
+
*
|
|
20
|
+
* A cue is `{ t, v, i? }` — millisecond offset into the utterance, the letter,
|
|
21
|
+
* and an optional 0..1 intensity (loudness). Intensity is cheap for the server
|
|
22
|
+
* to derive from TTS energy and is the single biggest realism win available:
|
|
23
|
+
* the same viseme shouted and murmured should not look identical.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const VISEME_SHAPES = {
|
|
27
|
+
X: { mouthOpen: 0.02, mouthWidth: 0.42, mouthRound: 0.10, mouthPress: 0.15, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
28
|
+
A: { mouthOpen: 0.00, mouthWidth: 0.40, mouthRound: 0.18, mouthPress: 0.55, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
29
|
+
B: { mouthOpen: 0.16, mouthWidth: 0.54, mouthRound: 0.05, mouthPress: 0.10, mouthTuck: 0, teethUpper: 0.75, tongue: 0.0 },
|
|
30
|
+
C: { mouthOpen: 0.45, mouthWidth: 0.58, mouthRound: 0.05, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.45, tongue: 0.0 },
|
|
31
|
+
D: { mouthOpen: 0.85, mouthWidth: 0.52, mouthRound: 0.02, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.25, tongue: 0.15 },
|
|
32
|
+
E: { mouthOpen: 0.34, mouthWidth: 0.28, mouthRound: 0.55, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.15, tongue: 0.0 },
|
|
33
|
+
F: { mouthOpen: 0.22, mouthWidth: 0.10, mouthRound: 0.95, mouthPress: 0.10, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
34
|
+
G: { mouthOpen: 0.20, mouthWidth: 0.46, mouthRound: 0.10, mouthPress: 0.40, mouthTuck: 1.00, teethUpper: 1.00, tongue: 0.0 },
|
|
35
|
+
H: { mouthOpen: 0.40, mouthWidth: 0.48, mouthRound: 0.05, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.35, tongue: 0.90 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const VISEME_LETTERS = Object.keys(VISEME_SHAPES);
|
|
39
|
+
|
|
40
|
+
/** Resting mouth used when nothing is speaking. */
|
|
41
|
+
export const SILENT = 'X';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Scale a shape by loudness. Only the "effortful" channels scale — a quiet 'D'
|
|
45
|
+
* is a small D, not a different shape.
|
|
46
|
+
*/
|
|
47
|
+
export function shapeFor(letter, intensity = 1) {
|
|
48
|
+
const base = VISEME_SHAPES[letter] || VISEME_SHAPES[SILENT];
|
|
49
|
+
const rest = VISEME_SHAPES.X;
|
|
50
|
+
const k = 0.45 + 0.55 * Math.max(0, Math.min(1, intensity));
|
|
51
|
+
return {
|
|
52
|
+
mouthOpen: rest.mouthOpen + (base.mouthOpen - rest.mouthOpen) * k,
|
|
53
|
+
mouthWidth: rest.mouthWidth + (base.mouthWidth - rest.mouthWidth) * k,
|
|
54
|
+
mouthRound: base.mouthRound,
|
|
55
|
+
mouthPress: base.mouthPress,
|
|
56
|
+
mouthTuck: base.mouthTuck,
|
|
57
|
+
teethUpper: base.teethUpper * k,
|
|
58
|
+
tongue: base.tongue,
|
|
59
|
+
jaw: (rest.mouthOpen + (base.mouthOpen - rest.mouthOpen) * k) * 0.7,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Cue track hygiene
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
const MIN_CUE_MS = 30; // shorter than this and the mouth just flutters
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Sort, merge consecutive duplicates, and drop sub-perceptual cues. Servers
|
|
70
|
+
* emit noisy tracks; this makes them watchable.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeCues(cues) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const sorted = [...cues].sort((a, b) => a.t - b.t);
|
|
75
|
+
for (const c of sorted) {
|
|
76
|
+
const v = VISEME_SHAPES[c.v] ? c.v : SILENT;
|
|
77
|
+
const prev = out[out.length - 1];
|
|
78
|
+
if (prev && prev.v === v) continue; // merge repeats
|
|
79
|
+
if (prev && c.t - prev.t < MIN_CUE_MS) {
|
|
80
|
+
// Too short to read. Keep whichever is more visually salient: a closure
|
|
81
|
+
// (A/G) carries more lip-reading information than a mid-open vowel.
|
|
82
|
+
if (v === 'A' || v === 'G') out[out.length - 1] = { ...c, v };
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
out.push({ t: c.t, v, i: c.i == null ? 1 : c.i });
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Schedules a cue track against an audio clock.
|
|
92
|
+
*
|
|
93
|
+
* The clock must come from the audio itself (`audioEl.currentTime * 1000` or
|
|
94
|
+
* `AudioContext.currentTime`), never from wall time — wall time drifts against
|
|
95
|
+
* playback and you will spend the rest of your life chasing it.
|
|
96
|
+
*
|
|
97
|
+
* LEAD_MS biases the mouth slightly ahead of the sound. Perceptually the
|
|
98
|
+
* tolerance is asymmetric: roughly -45ms (audio first) to +125ms (video first),
|
|
99
|
+
* so leading is the safe side to err on.
|
|
100
|
+
*/
|
|
101
|
+
export const LEAD_MS = 40;
|
|
102
|
+
|
|
103
|
+
export class VisemeTrack {
|
|
104
|
+
constructor() {
|
|
105
|
+
this.cues = [];
|
|
106
|
+
this.clock = null;
|
|
107
|
+
this.playing = false;
|
|
108
|
+
this._idx = 0;
|
|
109
|
+
this.onEnd = null;
|
|
110
|
+
this.tailMs = 120; // how long past the last cue before we call it done
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {() => number} clock returns elapsed ms of the audio being played */
|
|
114
|
+
start(cues, clock) {
|
|
115
|
+
this.cues = normalizeCues(cues);
|
|
116
|
+
this.clock = clock;
|
|
117
|
+
this._idx = 0;
|
|
118
|
+
this.playing = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Streaming top-up: append cues that arrive mid-utterance. */
|
|
122
|
+
push(cues) {
|
|
123
|
+
const merged = normalizeCues([...this.cues, ...cues]);
|
|
124
|
+
this.cues = merged;
|
|
125
|
+
// Re-seek rather than trusting the old index against a re-normalized array.
|
|
126
|
+
this._idx = 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop() {
|
|
130
|
+
this.playing = false;
|
|
131
|
+
this.cues = [];
|
|
132
|
+
this.clock = null;
|
|
133
|
+
this._idx = 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @returns {{letter: string, intensity: number} | null} */
|
|
137
|
+
sample() {
|
|
138
|
+
if (!this.playing || !this.cues.length || !this.clock) return null;
|
|
139
|
+
const now = this.clock() + LEAD_MS;
|
|
140
|
+
|
|
141
|
+
// Cues are time-ordered and `now` is mostly monotonic, so this walk is O(1)
|
|
142
|
+
// amortized. Reset on seek-backward.
|
|
143
|
+
if (this._idx > 0 && this.cues[this._idx] && this.cues[this._idx].t > now) this._idx = 0;
|
|
144
|
+
while (this._idx + 1 < this.cues.length && this.cues[this._idx + 1].t <= now) this._idx++;
|
|
145
|
+
|
|
146
|
+
const last = this.cues[this.cues.length - 1];
|
|
147
|
+
if (now > last.t + this.tailMs && last.v === SILENT) {
|
|
148
|
+
this.playing = false;
|
|
149
|
+
if (this.onEnd) this.onEnd();
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cue = this.cues[this._idx];
|
|
154
|
+
if (cue.t > now) return { letter: SILENT, intensity: 1 };
|
|
155
|
+
return { letter: cue.v, intensity: cue.i == null ? 1 : cue.i };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Reference mappings for the server side
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* ARPAbet phoneme -> Rhubarb letter. This is the table to port server-side if
|
|
165
|
+
* you go the forced-alignment / G2P route (CMUdict, phonemizer, MFA).
|
|
166
|
+
*/
|
|
167
|
+
export const ARPABET_TO_VISEME = {
|
|
168
|
+
// closures
|
|
169
|
+
P: 'A', B: 'A', M: 'A',
|
|
170
|
+
// labiodental
|
|
171
|
+
F: 'G', V: 'G',
|
|
172
|
+
// rounded
|
|
173
|
+
W: 'F', UW: 'F', UH: 'F', OW: 'F', OY: 'F',
|
|
174
|
+
AO: 'E', ER: 'E', AXR: 'E', R: 'E',
|
|
175
|
+
// tongue-up
|
|
176
|
+
L: 'H',
|
|
177
|
+
// wide-open vowels
|
|
178
|
+
AA: 'D', AY: 'D', AW: 'D',
|
|
179
|
+
AE: 'C', AH: 'C', EH: 'C', EY: 'C', HH: 'C',
|
|
180
|
+
IH: 'B', IY: 'B', Y: 'B',
|
|
181
|
+
// alveolars / sibilants / the rest
|
|
182
|
+
T: 'B', D: 'B', S: 'B', Z: 'B', N: 'B', K: 'B', G: 'B', NG: 'B',
|
|
183
|
+
SH: 'B', ZH: 'B', CH: 'B', JH: 'B', TH: 'B', DH: 'B',
|
|
184
|
+
SIL: 'X', SP: 'X',
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Azure Speech emits integer viseme IDs (0-21) on its `visemeReceived` event.
|
|
189
|
+
* This maps them straight onto our letters — the cheapest possible path to
|
|
190
|
+
* production-quality lipsync if you're already on Azure TTS.
|
|
191
|
+
*/
|
|
192
|
+
export const AZURE_VISEME_TO_LETTER = [
|
|
193
|
+
'X', 'C', 'D', 'E', 'C', 'E', 'B', 'F', 'F', 'D',
|
|
194
|
+
'E', 'D', 'C', 'E', 'H', 'B', 'B', 'B', 'G', 'B',
|
|
195
|
+
'B', 'A',
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A crude grapheme-level guesser. NOT for production — it exists so the demo
|
|
200
|
+
* can preview arbitrary text without a TTS round-trip, and to make the shape of
|
|
201
|
+
* the mapping concrete. Real timing must come from the server.
|
|
202
|
+
*/
|
|
203
|
+
export function textToCues(text, { wpm = 165 } = {}) {
|
|
204
|
+
const msPerChar = 60000 / (wpm * 5.1);
|
|
205
|
+
const cues = [];
|
|
206
|
+
let t = 0;
|
|
207
|
+
const s = text.toLowerCase();
|
|
208
|
+
const push = (v, dur) => { cues.push({ t: Math.round(t), v }); t += dur; };
|
|
209
|
+
|
|
210
|
+
for (let i = 0; i < s.length; i++) {
|
|
211
|
+
const two = s.slice(i, i + 2);
|
|
212
|
+
const c = s[i];
|
|
213
|
+
if (two === 'th' || two === 'sh' || two === 'ch') { push('B', msPerChar * 1.6); i++; continue; }
|
|
214
|
+
if (two === 'oo' || two === 'ou' || two === 'ow') { push('F', msPerChar * 1.8); i++; continue; }
|
|
215
|
+
if (two === 'ee' || two === 'ea') { push('B', msPerChar * 1.7); i++; continue; }
|
|
216
|
+
if (/[pbm]/.test(c)) push('A', msPerChar * 1.1);
|
|
217
|
+
else if (/[fv]/.test(c)) push('G', msPerChar * 1.2);
|
|
218
|
+
else if (/[wu]/.test(c)) push('F', msPerChar * 1.4);
|
|
219
|
+
else if (/[or]/.test(c)) push('E', msPerChar * 1.4);
|
|
220
|
+
else if (c === 'l') push('H', msPerChar * 1.2);
|
|
221
|
+
else if (/[ai]/.test(c)) push('D', msPerChar * 1.5);
|
|
222
|
+
else if (/[e]/.test(c)) push('C', msPerChar * 1.4);
|
|
223
|
+
else if (/[a-z]/.test(c)) push('B', msPerChar);
|
|
224
|
+
else if (/[\s]/.test(c)) push('X', msPerChar * 0.9);
|
|
225
|
+
else if (/[,;:]/.test(c)) push('X', 180);
|
|
226
|
+
else if (/[.!?]/.test(c)) push('X', 320);
|
|
227
|
+
}
|
|
228
|
+
push('X', 0);
|
|
229
|
+
return cues;
|
|
230
|
+
}
|