@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.
Files changed (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +692 -0
  3. package/client/dist/Avatar.d.ts +24 -0
  4. package/client/dist/Avatar.d.ts.map +1 -0
  5. package/client/dist/Avatar.js +7 -0
  6. package/client/dist/Avatar.js.map +1 -0
  7. package/client/dist/AvatarClient.d.ts +173 -0
  8. package/client/dist/AvatarClient.d.ts.map +1 -0
  9. package/client/dist/AvatarClient.js +274 -0
  10. package/client/dist/AvatarClient.js.map +1 -0
  11. package/client/dist/pipecat.d.ts +21 -0
  12. package/client/dist/pipecat.d.ts.map +1 -0
  13. package/client/dist/pipecat.js +21 -0
  14. package/client/dist/pipecat.js.map +1 -0
  15. package/client/dist/react.d.ts +16 -0
  16. package/client/dist/react.d.ts.map +1 -0
  17. package/client/dist/react.js +17 -0
  18. package/client/dist/react.js.map +1 -0
  19. package/client/dist/types.d.ts +101 -0
  20. package/client/dist/types.d.ts.map +1 -0
  21. package/client/dist/types.js +31 -0
  22. package/client/dist/types.js.map +1 -0
  23. package/client/dist/useAvatar.d.ts +53 -0
  24. package/client/dist/useAvatar.d.ts.map +1 -0
  25. package/client/dist/useAvatar.js +68 -0
  26. package/client/dist/useAvatar.js.map +1 -0
  27. package/client/src/Avatar.tsx +38 -0
  28. package/client/src/AvatarClient.ts +343 -0
  29. package/client/src/pipecat.ts +38 -0
  30. package/client/src/react.ts +34 -0
  31. package/client/src/types.ts +127 -0
  32. package/client/src/useAvatar.ts +113 -0
  33. package/docs/contract-avatar.md +337 -0
  34. package/docs/contract-protocol.md +401 -0
  35. package/package.json +89 -0
  36. package/src/audio-fallback.js +100 -0
  37. package/src/avatar.d.ts +241 -0
  38. package/src/avatar.js +722 -0
  39. package/src/clips.js +144 -0
  40. package/src/emotions.js +55 -0
  41. package/src/face-core.js +154 -0
  42. package/src/face-myna.js +725 -0
  43. package/src/face-peep.js +767 -0
  44. package/src/face-wren.js +470 -0
  45. package/src/gaze.js +155 -0
  46. package/src/idle.js +535 -0
  47. package/src/interjections.js +578 -0
  48. package/src/line-art.js +111 -0
  49. package/src/params.js +176 -0
  50. package/src/perform.js +105 -0
  51. package/src/visemes.js +230 -0
@@ -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
+ }