@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
package/src/avatar.js ADDED
@@ -0,0 +1,722 @@
1
+ /**
2
+ * The avatar — a programmable talking head.
3
+ *
4
+ * The public surface. Everything the server drives goes through here:
5
+ *
6
+ * avatar.setState('LISTENING', { emotion: 'warm' })
7
+ * avatar.setGaze('SCREEN_LEFT')
8
+ * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
9
+ * avatar.pushCues(moreCues) // streaming top-up
10
+ * avatar.interject('OKAY')
11
+ * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
12
+ * avatar.setUserAudio(micStream) // or setUserSpeaking(bool) — the
13
+ * // user's voice, so listening is
14
+ * // contingent instead of timed
15
+ *
16
+ * Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
17
+ * by later ones on the channels they touch; the gesture and idle layers are
18
+ * additive so they compose rather than fight.
19
+ *
20
+ * base pose (state + emotion) -> gaze -> visemes -> clip -> idle
21
+ *
22
+ * The one hard priority rule: while the server viseme track is playing, it owns
23
+ * the mouth outright. An interjection firing mid-sentence contributes its head
24
+ * and brows and its mouth track is dropped — otherwise the avatar would appear
25
+ * to say two things at once.
26
+ */
27
+
28
+ import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
29
+ import { createFace as createPeepFace, META as peepMeta } from './face-peep.js';
30
+ import { createFace as createWrenFace, META as wrenMeta } from './face-wren.js';
31
+ import { createFace as createMynaFace, META as mynaMeta } from './face-myna.js';
32
+ import { emotionPose } from './emotions.js';
33
+ import { GazeLayer, GAZE_TARGETS } from './gaze.js';
34
+ import { IdleLayer, ListeningEngine } from './idle.js';
35
+ import { ClipPlayer } from './clips.js';
36
+ import { INTERJECTIONS } from './interjections.js';
37
+ import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
38
+ import { PerformTrack } from './perform.js';
39
+ import { AudioFallback } from './audio-fallback.js';
40
+
41
+ // Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
42
+ // in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
43
+ // alone separates listening (~16/min) from thinking (~25/min) from visually
44
+ // busy (~9/min), and it is the cheapest state signal the rig has.
45
+ export const STATES = {
46
+ IDLE: { gaze: 'USER', emotion: 'neutral', idle: { sway: 1.0 }, backchannel: false },
47
+ LISTENING: { gaze: 'USER', emotion: 'neutral', backchannel: true,
48
+ idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
49
+ pose: { browRaiseL: 0.06, browRaiseR: 0.06, lidL: -0.04, lidR: -0.04 } },
50
+ // Faster, shallower breath is the measured cognitive-load signature, and the
51
+ // occasional dead-still hold is the strongest "working on it" cue a rig this
52
+ // simple can make — deliberate stillness, not more motion. The aversion
53
+ // leads DOWN (39% of measured cognitive aversions, §4.2) and wanders on the
54
+ // ~3.5s cognitive-aversion cadence, coming back to the user roughly one
55
+ // dwell in four — still with you, working.
56
+ THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', backchannel: false,
57
+ idle: { sway: 0.7, blinkGap: [2.1, 2.7], breathRate: 1.18, breathAmp: 0.7,
58
+ hold: { every: [4.5, 9.0], dur: [0.8, 1.5] } },
59
+ wander: { targets: ['AWAY_DOWN', 'AWAY_DOWN', 'AWAY_THINKING', 'USER'],
60
+ every: [2.6, 4.4] } },
61
+ SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, backchannel: false },
62
+ REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', backchannel: false,
63
+ idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
64
+ wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_RIGHT', 'SCREEN_TOP', 'SCREEN_WORK'],
65
+ every: [1.8, 5.0] } },
66
+ // The head cant is the state's signature cue, and it has to clear the roll
67
+ // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
68
+ // is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
69
+ // size, still gentle. Every other channel in this pose read fine on screen.
70
+ WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', backchannel: true,
71
+ idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
72
+ pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
73
+ // Straining to hear. The one state where the amplitude constraint yields,
74
+ // because the lean IS the message: torsoLean well past LISTENING's
75
+ // engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
76
+ // favors the speaker while the eyes hold contact, and a concentration
77
+ // squint with knit brows. Stillness does the rest — straining people
78
+ // freeze — so holds are frequent and there are NO backchannels: you don't
79
+ // nod along to what you can't hear. Server sends it on soft/low-SNR user
80
+ // audio, typically followed by SORRY or a "could you repeat" utterance.
81
+ CANT_HEAR: {
82
+ gaze: 'USER_EAR', emotion: 'neutral', backchannel: false,
83
+ idle: { sway: 0.5, blinkGap: [4.5, 6.5],
84
+ hold: { every: [2.5, 5.5], dur: [1.0, 1.8] } },
85
+ // A minimal line face swallows small deltas — the ink moves whole units
86
+ // or it doesn't move. These values are set from the contact sheet's
87
+ // extremes row, not from what a fleshed rig would need: brows DOWN
88
+ // (corrugator effort, not the browInner worry-lift), a real squint, and
89
+ // the resting smile pressed flat — nobody smiles while straining to hear.
90
+ pose: {
91
+ torsoLean: 0.70, headPitch: 0.10,
92
+ lidL: 0.12, lidR: 0.12, squintL: 0.75, squintR: 0.75,
93
+ browRaiseL: -0.45, browRaiseR: -0.45, browInnerL: 0.15, browInnerR: 0.12,
94
+ mouthPress: 0.45, mouthCornerL: -0.22, mouthCornerR: -0.22,
95
+ },
96
+ },
97
+ // --- application state ---------------------------------------------------
98
+ // "Momentarily busy on the thing you asked for." No hands in frame, so the
99
+ // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4):
100
+ // gaze parked DOWN on a stable target, blinks suppressed to task-focus rate
101
+ // (~9/min), shoulders slightly raised and *working* — the burst/pause rhythm
102
+ // is what says activity rather than rocking — and, the important one, a
103
+ // brief glance back up to the user every few seconds. The glance is the tell
104
+ // that the user has not been forgotten; without it, busy is just absent.
105
+ TYPING: {
106
+ // SCREEN_WORK, not NOTES: on a steep down target the gaze layer's lid
107
+ // follow seals the eyes, and at tile size shut eyes read as asleep, not
108
+ // busy. A mild down-left with the head pitched into it keeps the iris in
109
+ // the opening — eyes down but awake.
110
+ gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
111
+ idle: { sway: 0.6, blinkGap: [6.0, 7.5], breathRate: 1.05,
112
+ rhythm: { amp: 0.05, freq: 2.2 } },
113
+ glance: { to: 'USER', every: [4, 7], hold: [0.7, 1.1] },
114
+ pose: { headPitch: 0.10, lidL: -0.04, lidR: -0.04,
115
+ shoulderL: 0.06, shoulderR: 0.06 },
116
+ },
117
+ // The audio channel is broken and the agent is typing in the chat window to
118
+ // communicate — TYPING's mechanics turned *communicative*. The glance is
119
+ // the difference: TYPING checks in briefly (~0.8 s) and goes back to work;
120
+ // this looks up and HOLDS 1.2–2 s, expectant, because the chat (and the
121
+ // user's face) is now the only channel there is. A touch of browInner
122
+ // carries the apology. Relation to DEGRADED is by semantics, not merger:
123
+ // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
124
+ // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
125
+ TYPING_CHAT: {
126
+ gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
127
+ idle: { sway: 0.6, blinkGap: [5.5, 7.0], breathRate: 1.05,
128
+ rhythm: { amp: 0.055, freq: 2.5 } },
129
+ glance: { to: 'USER', every: [3.2, 5.5], hold: [1.2, 2.0] },
130
+ // Line-face scaled (see CANT_HEAR); the apology has to survive the rig's
131
+ // baked resting smile, so the corners go clearly negative.
132
+ pose: { headPitch: 0.10, lidL: -0.04, lidR: -0.04,
133
+ shoulderL: 0.06, shoulderR: 0.06,
134
+ browInnerL: 0.45, browInnerR: 0.38,
135
+ mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
136
+ },
137
+ // Attention genuinely elsewhere. What separates this from TYPING is target
138
+ // *stability* (§6.4): busy is one steady off-user target, distracted is
139
+ // wandering ones, held long (aversion >3s), with no backchannels — the
140
+ // missing nod is as diagnostic as the look-away. Sway is looser than
141
+ // LISTENING because attention is what was holding the body still. The
142
+ // widget only looks away; deciding when to snap back is the server's call.
143
+ DISTRACTED: {
144
+ gaze: 'AWAY_RIGHT', emotion: 'neutral', backchannel: false,
145
+ idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
146
+ // Sideways and up, never steep-down: lateral is where real intimacy/
147
+ // distraction aversions live, and a steep down target seals this rig's
148
+ // eyes (see TYPING).
149
+ wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
150
+ every: [2.8, 6.8] },
151
+ },
152
+ // The buying-time move: hunting for a control on screen. Distinct from
153
+ // REVIEWING_SCREEN by *hunt* quality — reading dwells (1.8–5 s) become
154
+ // search saccades (0.8–2 s) with revisits (targets repeat in the wander
155
+ // set), plus the idle layer's flick: the tiny "no, not this one" yaw
156
+ // wiggle nobody makes while merely reading. Server semantics: a filler
157
+ // while an async activity completes; the server exits it when done.
158
+ SEARCHING_SCREEN: {
159
+ gaze: 'SCREEN_CENTER', emotion: 'neutral', backchannel: false,
160
+ idle: { sway: 0.65, blinkGap: [5.0, 6.8], breathRate: 1.05,
161
+ flick: { amp: 0.30, every: [3.5, 7.0] } },
162
+ wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_TOP', 'SCREEN_WORK',
163
+ 'SCREEN_RIGHT', 'SCREEN_CENTER', 'SCREEN_LEFT'],
164
+ every: [0.8, 2.0] },
165
+ // Line-face scaled (see CANT_HEAR). Note the corners: peep's REST mouth
166
+ // is drawn smiling, so "not smiling" is a clearly negative net corner,
167
+ // not zero.
168
+ pose: { squintL: 0.40, squintR: 0.40, mouthPress: 0.65,
169
+ mouthCornerL: -0.25, mouthCornerR: -0.25, browRaiseL: -0.26, browRaiseR: -0.20 },
170
+ },
171
+ // --- floor management ----------------------------------------------------
172
+ // Turn-taking is the part of a voice call that goes wrong most often: the
173
+ // user either talks over the agent or sits in silence waiting for a signal
174
+ // that never comes. These are states rather than clips because the floor is a
175
+ // condition and not an event — WANTS_IN in particular has to hold for as long
176
+ // as it takes the other person to notice it.
177
+ //
178
+ // All three lift the shoulders and part the lips, because that is what an
179
+ // inbreath looks like from outside, and an inbreath is the cue humans actually
180
+ // use to predict that someone is about to speak. The head comes *up* rather
181
+ // than down: a lowered head is deferential and reads as yielding.
182
+ TAKING_FLOOR: {
183
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, backchannel: false,
184
+ pose: {
185
+ browRaiseL: 0.26, browRaiseR: 0.22, lidL: -0.10, lidR: -0.10,
186
+ headPitch: -0.10, torsoLean: 0.22, shoulderL: 0.30, shoulderR: 0.30,
187
+ mouthOpen: 0.10, mouthPress: -0.10,
188
+ },
189
+ },
190
+ // The one signal the rig had no way to give at all. An agent needs to be
191
+ // able to say "I'd like to come in" without talking over the user, and
192
+ // every part of this pose is doing that job: held still (idle is low on
193
+ // purpose — stillness is what makes it read as intent rather than as fidget),
194
+ // leaning in, lips apart and staying apart.
195
+ WANTS_IN: {
196
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, backchannel: false,
197
+ pose: {
198
+ browRaiseL: 0.42, browRaiseR: 0.38, lidL: -0.14, lidR: -0.14,
199
+ headPitch: -0.14, torsoLean: 0.42, shoulderL: 0.45, shoulderR: 0.45,
200
+ mouthOpen: 0.16, mouthWidth: 0.30, mouthPress: -0.14,
201
+ },
202
+ },
203
+ // Interrupted mid-word. The mouth shutting is the whole message, and it has to
204
+ // happen faster than anything else on the face — see YIELD_FLOOR, which is
205
+ // what actually delivers the snap.
206
+ YIELDED: {
207
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, backchannel: false,
208
+ pose: {
209
+ browRaiseL: 0.10, browRaiseR: 0.08,
210
+ torsoLean: -0.18, shoulderL: -0.12, shoulderR: -0.12,
211
+ },
212
+ },
213
+
214
+ DEGRADED: { gaze: 'USER', emotion: 'neutral', backchannel: false,
215
+ idle: { sway: 0.4, blinkGap: [4.0, 8.0] },
216
+ pose: { lidL: 0.3, lidR: 0.3 }, filter: 'grayscale(.55) brightness(.82)' },
217
+ OFFLINE: { gaze: 'USER', emotion: 'neutral', backchannel: false,
218
+ idle: { sway: 0.15, blinkGap: [9, 15] },
219
+ pose: { lidL: 0.95, lidR: 0.95, mouthCornerL: 0, mouthCornerR: 0 },
220
+ filter: 'grayscale(1) brightness(.6)' },
221
+ };
222
+
223
+ export const STATE_NAMES = Object.keys(STATES);
224
+
225
+ /**
226
+ * The avatars this rig can wear: `{ create, meta }` records.
227
+ *
228
+ * `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
229
+ * callable standalone — the rig tooling drives faces with no mixer attached.
230
+ * That behavioural contract is still the whole of what the *rig* needs:
231
+ * everything else — visemes, emotions, gaze, idle, clips, the mixer — works
232
+ * in parameter space and never learns which face it is driving.
233
+ *
234
+ * `meta` is the avatar descriptor (viewBox, mouthCrop — see META in any face
235
+ * module): the things a HOST or a TOOL needs to frame a face without opening
236
+ * it. This registry was once factories-only, on the argument that a schema
237
+ * guessed from two faces would be wrong; the third face settled it. Every rig
238
+ * needed exactly a framing rect and a mouth rect to stop the tooling from
239
+ * hard-coding per-avatar tables, and nothing else — so that is all meta
240
+ * carries.
241
+ *
242
+ * The key is the avatar's name, not its rank. It used to be possible to read
243
+ * rank into it — the original rig was keyed `default`, which became a lie the
244
+ * moment it stopped being the one we ship. DEFAULT_AVATAR below is the only
245
+ * place the choice is made.
246
+ *
247
+ * Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
248
+ * stakeholders accepted the line-art pair and rejected both of the others, so
249
+ * carrying them was maintenance against art nobody wanted. What they taught
250
+ * the abstraction survives them — `face-core.js` exists because all three of
251
+ * the first rigs wrote the same apply(), and META exists because all three
252
+ * needed the same two rects. Their code is in git history if a lesson ever
253
+ * needs re-reading.
254
+ */
255
+ export const AVATARS = {
256
+ peep: { create: createPeepFace, meta: peepMeta },
257
+ wren: { create: createWrenFace, meta: wrenMeta },
258
+ myna: { create: createMynaFace, meta: mynaMeta },
259
+ };
260
+
261
+ export const AVATAR_NAMES = Object.keys(AVATARS);
262
+
263
+ /** The avatar a host gets when it does not ask for one. */
264
+ export const DEFAULT_AVATAR = 'peep';
265
+
266
+ export function createAvatar(opts = {}) {
267
+ const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
268
+ if (!mount) throw new Error('createAvatar: mount element required');
269
+
270
+ // `opts.avatar` names one from AVATARS; `opts.face` passes a factory directly,
271
+ // so a host can supply an avatar the rig has never heard of. A bare factory
272
+ // has no descriptor, so meta falls back to what the svg itself declares.
273
+ const entry = opts.face ? { create: opts.face } : AVATARS[opts.avatar || DEFAULT_AVATAR];
274
+ if (!entry) {
275
+ throw new Error(`createAvatar: unknown avatar "${opts.avatar}" (have: ${AVATAR_NAMES.join(', ')})`);
276
+ }
277
+ const face = entry.create(mount, opts.theme);
278
+ const meta = entry.meta || (() => {
279
+ const vb = (face.svg.getAttribute('viewBox') || '0 0 1 1').split(/[\s,]+/).map(Number);
280
+ return { viewBox: { x: vb[0], y: vb[1], w: vb[2], h: vb[3] } };
281
+ })();
282
+ const gaze = new GazeLayer();
283
+ const idle = new IdleLayer();
284
+ const speech = new VisemeTrack();
285
+ const fallback = new AudioFallback();
286
+ // A second analyser for the USER's voice — same machinery, opposite
287
+ // direction: this one never touches the mouth, it feeds the listening engine.
288
+ const userAudio = new AudioFallback();
289
+ let userAudioOn = false;
290
+
291
+ let gazeOverrideByClip = null;
292
+ const clip = new ClipPlayer({
293
+ onGaze: (g) => { gazeOverrideByClip = g; applyGaze(); },
294
+ onBlink: () => idle.blink(),
295
+ });
296
+ const backchannel = new ListeningEngine((id) => { interject(id); emit('backchannel', id); });
297
+ const performTrack = new PerformTrack();
298
+
299
+ gaze.onLargeShift = () => idle.blink();
300
+
301
+ const listeners = { state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [] };
302
+ const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
303
+ clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
304
+ speech.onEnd = () => { emit('speakEnd'); };
305
+ performTrack.onEnd = () => { emit('performEnd'); };
306
+
307
+ // --- live state -----------------------------------------------------------
308
+ let stateName = 'IDLE';
309
+ let emotion = 'neutral';
310
+ let emotionAmt = 1;
311
+ let gazeName = 'USER';
312
+ let gazeCustom = null;
313
+ let overrides = null; // demo/debug direct param injection
314
+ // Articulation gain. The per-cue `i` only ever attenuates (shapeFor maps it to
315
+ // 0.45..1.0 of the table), so there was no way to ask for a *bigger* mouth than
316
+ // VISEME_SHAPES describes. That table is tuned for a face at conversational
317
+ // size; at avatar size, sharing the screen with live video, the same shapes
318
+ // read as under-articulated. This scales every viseme away from rest, so the
319
+ // shape identities and their relative sizes are preserved and only the
320
+ // excursion changes. Values above ~1.5 saturate the open vowels against the
321
+ // channel clamp, which is the intended ceiling rather than a bug.
322
+ let mouthGain = opts.mouthGain ?? 1;
323
+ // Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
324
+ // peaks at 149ms — but the head smooths at a 160ms time constant, so barely
325
+ // 60% of an authored peak is ever rendered. The keyframes were written against
326
+ // the numbers, not against what comes out the other side, which is why small
327
+ // gestures read as nothing at all.
328
+ let gestureGain = opts.gestureGain ?? 1;
329
+ // Body-liveness gain. Constraint 8 (this widget shares the screen with a
330
+ // live video call) argues for the smallest idle motion that still reads, and
331
+ // the amplitudes in idle.js are set where they read. A host that is actually
332
+ // re-encoding the avatar — compositing it into an outgoing stream rather
333
+ // than rendering it locally as SVG, where the motion costs nothing — turns
334
+ // this down instead of the default being a body that does not move.
335
+ idle.gain = opts.motionGain ?? 1;
336
+ let wanderAt = 0;
337
+ let slowBlinkAt = 0;
338
+ let glanceAt = 0;
339
+ let glanceUntil = 0;
340
+ let speakClock = null;
341
+ let speakStart = 0;
342
+ let useFallback = false;
343
+
344
+ const cur = Object.assign({}, REST);
345
+ const target = Object.assign({}, REST);
346
+
347
+ function applyGaze() {
348
+ const g = gazeOverrideByClip || gazeName;
349
+ gaze.set(g, gazeOverrideByClip ? null : gazeCustom);
350
+ }
351
+
352
+ // --- the frame ------------------------------------------------------------
353
+ let raf = 0;
354
+ let last = 0;
355
+ let elapsed = 0;
356
+ // `manual` withholds the rAF loop so a tool can drive frames itself. The
357
+ // baseline pages could already step a ClipPlayer by hand, but nothing could
358
+ // step the *mixer* — which is where idle, gaze and engagement actually
359
+ // compose — so motion had no reproducible render. See tools/motion.mjs.
360
+ const manual = !!opts.manual;
361
+
362
+ function frame(now) {
363
+ raf = requestAnimationFrame(frame);
364
+ if (!last) last = now;
365
+ // Cap dt so a backgrounded tab doesn't fast-forward the whole rig on return.
366
+ const dt = Math.min(0.05, (now - last) / 1000);
367
+ last = now;
368
+ elapsed += dt;
369
+ step(dt, dt * 1000);
370
+ }
371
+
372
+ function step(dt, dtMs) {
373
+ // 0. the performance timeline. Sampled before the pose is built so a verb
374
+ // firing this frame shapes this frame.
375
+ performTrack.update();
376
+
377
+ const st = STATES[stateName] || STATES.IDLE;
378
+
379
+ // 1. base pose: rest + emotion + state-specific overlay
380
+ for (const c of CHANNELS) target[c] = REST[c];
381
+ const ep = emotionPose(emotion, emotionAmt);
382
+ for (const k in ep) target[k] = REST[k] + ep[k];
383
+ if (st.pose) for (const k in st.pose) target[k] = (target[k] || 0) + st.pose[k];
384
+
385
+ // 2. gaze (absolute: pupils + partial head follow, plus the lid that rides
386
+ // with the eye — looking down without it bares sclera and reads as alarm)
387
+ const g = gaze.update(elapsed, dt);
388
+ for (const k in g) {
389
+ if (k === 'lidBias') continue;
390
+ target[k] = (k.startsWith('head') ? target[k] : 0) + g[k];
391
+ }
392
+ target.lidL += g.lidBias;
393
+ target.lidR += g.lidBias;
394
+
395
+ // 2b. the trunk follows the head. Sampled HERE, after gaze and before the
396
+ // clip layer, on purpose: a sustained turn toward the screen recruits
397
+ // the trunk, and a nod or a head shake does not — a body that swings
398
+ // with every gesture reads as a mannequin on a turntable. The lag is
399
+ // not authored anywhere; torsoTurn simply chases the same target at
400
+ // nearly 3x the head's time constant (TAU in params.js), so the trunk
401
+ // leaves late and settles late for free.
402
+ target.torsoTurn += target.headYaw * TRUNK_FOLLOW;
403
+
404
+ // 3. state-driven autonomous behaviour
405
+ if (st.wander && elapsed > wanderAt) {
406
+ const w = st.wander;
407
+ wanderAt = elapsed + w.every[0] + Math.random() * (w.every[1] - w.every[0]);
408
+ setGaze(w.targets[(Math.random() * w.targets.length) | 0]);
409
+ }
410
+ if (stateName === 'THINKING' && elapsed > slowBlinkAt) {
411
+ slowBlinkAt = elapsed + 2.4 + Math.random() * 2.5;
412
+ idle.slowBlink();
413
+ }
414
+ // Periodic glance (TYPING's look-up-at-you beat). The return leg goes back
415
+ // to the state's own gaze; the gaze layer's large-shift blink fires on
416
+ // both legs for free, which is exactly the blink a real glance carries.
417
+ if (st.glance) {
418
+ const gl = st.glance;
419
+ if (glanceUntil && elapsed > glanceUntil) {
420
+ glanceUntil = 0;
421
+ glanceAt = elapsed + gl.every[0] + Math.random() * (gl.every[1] - gl.every[0]);
422
+ setGaze(st.gaze);
423
+ } else if (!glanceUntil && elapsed > glanceAt) {
424
+ glanceUntil = elapsed + gl.hold[0] + Math.random() * (gl.hold[1] - gl.hold[0]);
425
+ setGaze(gl.to);
426
+ }
427
+ }
428
+ backchannel.enabled = !!st.backchannel && !clip.playing;
429
+ if (userAudioOn) { userAudio.sample(dt); backchannel.observeLevel(userAudio.level); }
430
+ backchannel.update(dt);
431
+ // Engagement posture: forward lean while the user holds the floor, spent
432
+ // only in the states that are *about* the user holding the floor. The
433
+ // research (docs/research-biomechanics.md §6.3) puts sustained attentive
434
+ // lean at +0.15–0.25; engage glides, and torsoLean's 0.24s tau smooths
435
+ // the state gate, so the lean arrives and leaves like weight shifting.
436
+ if (st.backchannel) target.torsoLean += 0.16 * backchannel.engage;
437
+ // Straining leans harder while there is actually a faint voice to strain
438
+ // after. engage already tracks "the user is (barely) talking", so this
439
+ // costs nothing; with no user signal the static pose carries the state.
440
+ else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * backchannel.engage;
441
+
442
+ // 4. mouth. Server track wins; then clip track; then a fallback analyser.
443
+ const clipOut = clip.update(dtMs);
444
+ let mouth = speech.sample();
445
+ let mouthOwner = mouth ? 'speech' : null;
446
+ if (!mouth && clipOut.ownsMouth && clipOut.mouth) { mouth = clipOut.mouth; mouthOwner = 'clip'; }
447
+ if (!mouth && useFallback) { mouth = fallback.sample(dt); mouthOwner = 'fallback'; }
448
+ if (mouth) {
449
+ const shape = mouth.letter !== SILENT
450
+ ? shapeFor(mouth.letter, mouth.intensity)
451
+ : shapeFor(SILENT, 1);
452
+ // Gain pivots on the rest shape, not on zero: scaling absolute values would
453
+ // drag the closed mouth open, which is the one thing lipsync must never do.
454
+ for (const k in shape) {
455
+ target[k] = mouthGain === 1
456
+ ? shape[k]
457
+ : REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
458
+ }
459
+ // A smile held static through a sentence is discounted as insincere, and
460
+ // corners riding every open viseme read as laughing through the words
461
+ // (research-perception.md §3: warmth must be episodic). While the mouth
462
+ // is genuinely speech-driven the BASE smile decays to a fraction of
463
+ // itself; the smile channels' 130ms tau turns the gate into an ease.
464
+ // Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
465
+ // and only the base is scaled, so a gesture clip can still smile over a
466
+ // sentence by authoring corner keys (they add, unscaled, in step 5).
467
+ if (mouthOwner !== 'clip') {
468
+ target.mouthCornerL *= SPEAK_SMILE_RETAIN;
469
+ target.mouthCornerR *= SPEAK_SMILE_RETAIN;
470
+ }
471
+ }
472
+
473
+ // 5. gesture deltas (additive, so a nod survives whatever else is happening)
474
+ if (clipOut.delta) {
475
+ for (const k in clipOut.delta) {
476
+ // Never let a clip's mouth keyframes fight the live viseme stream.
477
+ if (mouthOwner === 'speech' && MOUTH_LOCK.has(k)) continue;
478
+ target[k] = (target[k] || 0) + clipOut.delta[k] * gestureGain;
479
+ }
480
+ }
481
+
482
+ // 6. idle: sway, breath, blink
483
+ // The torso's share of the liveness follows whether sound is actually being
484
+ // produced, not what state the avatar is nominally in — a SPEAKING state with the
485
+ // track finished should already be settling.
486
+ idle.talk = approach(idle.talk, mouthOwner ? 1 : 0, 0.25, dt);
487
+ idle.setProfile(st.idle);
488
+ const il = idle.update(dt);
489
+ for (const k in il.add) target[k] = (target[k] || 0) + il.add[k];
490
+
491
+ // 7. clamp, then blink wins outright over whatever the lids were doing
492
+ for (const c of CHANNELS) {
493
+ const r = RANGE[c];
494
+ target[c] = clamp(target[c], r[0], r[1]);
495
+ }
496
+ if (il.blink > 0) {
497
+ target.lidL = Math.max(target.lidL, il.blink);
498
+ target.lidR = Math.max(target.lidR, il.blink);
499
+ }
500
+
501
+ if (overrides) for (const k in overrides) target[k] = overrides[k];
502
+
503
+ // 8. smooth toward the target — this is where co-articulation happens
504
+ for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
505
+
506
+ face.apply(cur);
507
+ }
508
+
509
+ const REST_SHAPE = shapeFor(SILENT, 1);
510
+
511
+ // How much of a sustained head turn the trunk takes up. Well under 1: people
512
+ // under-rotate the head and then under-rotate the trunk again behind it, and
513
+ // at a head-and-shoulders crop the trunk's share is the part you register
514
+ // without noticing.
515
+ const TRUNK_FOLLOW = 0.45;
516
+
517
+ // The channels speech owns outright — exactly the params.js mouth group
518
+ // (mouth corners stay free: a clip may smile over a sentence).
519
+ const MOUTH_LOCK = new Set(GROUPS.mouth);
520
+
521
+ // What survives of the resting/emotion smile while speech owns the mouth.
522
+ // ~a third keeps the face warm without the corners fighting the visemes;
523
+ // full warmth returns the moment the track ends, which is exactly the
524
+ // episodic onset/offset a credible smile needs (research-perception.md §3).
525
+ const SPEAK_SMILE_RETAIN = 0.35;
526
+
527
+ // --- API ------------------------------------------------------------------
528
+
529
+ function setState(name, o = {}) {
530
+ if (!STATES[name]) throw new Error(`unknown state: ${name}`);
531
+ const changed = name !== stateName;
532
+ stateName = name;
533
+ const st = STATES[name];
534
+ if (o.emotion !== undefined) emotion = o.emotion;
535
+ else if (changed) emotion = st.emotion;
536
+ if (o.intensity !== undefined) emotionAmt = o.intensity;
537
+
538
+ if (!o.keepGaze) setGaze(o.gaze || st.gaze);
539
+ idle.setProfile(st.idle);
540
+ // Arm the glance scheduler fresh so entering a glancing state doesn't
541
+ // fire a stale timestamp immediately.
542
+ glanceUntil = 0;
543
+ glanceAt = elapsed + (st.glance ? st.glance.every[0] + Math.random() * (st.glance.every[1] - st.glance.every[0]) : 0);
544
+ backchannel.reset(name === 'LISTENING' ? 2.2 : 4);
545
+ face.svg.style.filter = st.filter || '';
546
+ face.svg.style.transition = 'filter .5s ease';
547
+ if (changed) { idle.blink(); emit('state', name); }
548
+ return api;
549
+ }
550
+
551
+ function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
552
+
553
+ /** @param {string} name @param {{x:number,y:number}} [custom] normalized -1..1 */
554
+ function setGaze(name, custom) {
555
+ gazeName = GAZE_TARGETS[name] ? name : 'USER';
556
+ gazeCustom = custom || null;
557
+ applyGaze();
558
+ return api;
559
+ }
560
+
561
+ /**
562
+ * @param {object} o
563
+ * @param {Array<{t:number,v:string,i?:number}>} o.cues
564
+ * @param {HTMLMediaElement} [o.audio] preferred clock source
565
+ * @param {() => number} [o.clock] custom ms clock, if you drive audio yourself
566
+ */
567
+ function speak(o = {}) {
568
+ // A spoken interjection must die before real speech starts.
569
+ if (clip.playing && clip.clip.mouthCues) clip.stop();
570
+ speakStart = performance.now();
571
+ speakClock = o.clock
572
+ ? o.clock
573
+ : o.audio
574
+ ? () => o.audio.currentTime * 1000
575
+ : () => performance.now() - speakStart;
576
+ speech.start(o.cues || [], speakClock);
577
+ if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: true });
578
+ if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
579
+ return api;
580
+ }
581
+
582
+ function pushCues(cues) { speech.push(cues); return api; }
583
+
584
+ function stopSpeaking() { speech.stop(); return api; }
585
+
586
+ function interject(id) {
587
+ const c = INTERJECTIONS[id];
588
+ if (!c) throw new Error(`unknown interjection: ${id}`);
589
+ clip.play(c, c.audioEl);
590
+ backchannel.reset(3.5);
591
+ return api;
592
+ }
593
+
594
+ function setAudioFallback(source) {
595
+ if (!source) { fallback.detach(); useFallback = false; return api; }
596
+ fallback.attach(source);
597
+ useFallback = true;
598
+ return api;
599
+ }
600
+
601
+ /**
602
+ * Give the listening engine the USER's voice, so backchannels become
603
+ * contingent on their pauses instead of running on a timer. Either works,
604
+ * and the flag wins when both are driven:
605
+ * setUserAudio(streamOrElement) — the widget runs its own coarse VAD
606
+ * setUserSpeaking(bool) — the host (or server endpointer) decides;
607
+ * pass null to hand back to the level VAD
608
+ */
609
+ function setUserAudio(source) {
610
+ if (!source) { userAudio.detach(); userAudioOn = false; return api; }
611
+ userAudio.attach(source);
612
+ userAudioOn = true;
613
+ return api;
614
+ }
615
+
616
+ function setUserSpeaking(b) { backchannel.setUserSpeaking(b); return api; }
617
+
618
+ // What one action does when its moment comes. Enum validity is checked here,
619
+ // where the enums live: a bad value warns and is skipped, because one stale
620
+ // verb must never take down the performance around it.
621
+ function dispatchAction(a) {
622
+ try {
623
+ // `state` defaults to keepGaze — a timeline that wants the gaze moved
624
+ // says so with a `gaze` verb at the moment it means, which is how every
625
+ // composed turn in the demo already behaves.
626
+ if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
627
+ else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
628
+ else if (a.do === 'gaze') setGaze(a.name);
629
+ else if (a.do === 'interject') interject(a.id);
630
+ } catch (e) {
631
+ console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
632
+ }
633
+ }
634
+
635
+ let performGen = 0;
636
+
637
+ /**
638
+ * Play a timed action track — the composition surface a server assembles
639
+ * turns from. Verbs: state / emotion / gaze / interject (see perform.js for
640
+ * hygiene, docs/contract-protocol.md for the schema).
641
+ *
642
+ * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
643
+ * element's own time, else ms elapsed since this call. perform() never
644
+ * starts or stops audio — speak() owns the sound; this owns the choreography
645
+ * that rides it.
646
+ *
647
+ * @param {Array<{t: number, do: string}>} actions
648
+ * @param {{audio?: HTMLMediaElement, clock?: () => number,
649
+ * onAction?: (a: object) => void}} [o]
650
+ * @returns {{stop: () => void}} stop() cancels the *future* of this
651
+ * performance only: an in-flight interjection finishes, a live cue track
652
+ * is untouched, and 'performEnd' does not fire. A handle whose
653
+ * performance was already replaced by a newer perform() is a no-op.
654
+ */
655
+ function perform(actions, o = {}) {
656
+ const start = performance.now();
657
+ const clock = o.clock
658
+ ? o.clock
659
+ : o.audio
660
+ ? () => o.audio.currentTime * 1000
661
+ : () => performance.now() - start;
662
+ performTrack.onAction = (a) => { dispatchAction(a); if (o.onAction) o.onAction(a); };
663
+ const gen = ++performGen;
664
+ performTrack.start(actions, clock);
665
+ return { stop: () => { if (gen === performGen) performTrack.stop(); } };
666
+ }
667
+
668
+ const api = {
669
+ setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, interject,
670
+ perform,
671
+ setAudioFallback, setUserAudio, setUserSpeaking,
672
+ /** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
673
+ setMouthGain: (g) => { mouthGain = g; return api; },
674
+ get mouthGain() { return mouthGain; },
675
+ /** Gesture gain: scales every clip delta. 1 is the timelines as authored. */
676
+ setGestureGain: (g) => { gestureGain = g; return api; },
677
+ get gestureGain() { return gestureGain; },
678
+ /** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
679
+ setMotionGain: (g) => { idle.gain = g; return api; },
680
+ get motionGain() { return idle.gain; },
681
+ blink: (dbl) => { idle.blink(dbl); return api; },
682
+ /** Advance one frame by hand. Only meaningful under `{manual: true}`;
683
+ * fixed-dt stepping is what makes a motion render reproducible. */
684
+ step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
685
+ /** Direct parameter injection — for tuning UIs, not production. */
686
+ setOverrides: (o) => { overrides = o; return api; },
687
+ on: (ev, fn) => { (listeners[ev] || (listeners[ev] = [])).push(fn); return api; },
688
+ get state() { return stateName; },
689
+ get emotion() { return emotion; },
690
+ get gaze() { return gazeName; },
691
+ get speaking() { return speech.playing; },
692
+ get performing() { return performTrack.playing; },
693
+ get clip() { return clip.id; },
694
+ get params() { return cur; },
695
+ get audioLevel() { return fallback.level; },
696
+ get userSpeaking() { return backchannel.speaking; },
697
+ svg: face.svg,
698
+ meta,
699
+ /** The mounted rig's palette, merged with any `opts.theme` overrides. A
700
+ * host that has to paint anything *around* the widget — a tile margin, a
701
+ * page behind a transparent mount — needs the same colours the drawing
702
+ * used, and guessing them per avatar is how the two drift apart. */
703
+ theme: face.theme,
704
+ destroy() { cancelAnimationFrame(raf); fallback.detach(); userAudio.detach(); face.destroy(); },
705
+ };
706
+
707
+ setState('IDLE');
708
+ if (!manual) raf = requestAnimationFrame(frame);
709
+ return api;
710
+ }
711
+
712
+ export { INTERJECTIONS, INTERJECTION_IDS, SPOKEN_IDS, attachAudio } from './interjections.js';
713
+ export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
714
+ export { normalizeActions } from './perform.js';
715
+ export { EMOTION_NAMES } from './emotions.js';
716
+ export {
717
+ VISEME_LETTERS, VISEME_SHAPES, normalizeCues, textToCues,
718
+ ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
719
+ } from './visemes.js';
720
+ // No THEME re-export: each face module owns its palette, and `api.theme` is
721
+ // the mounted avatar's. A single barrel THEME was one rig's palette wearing a
722
+ // public name — misleading the moment that rig stopped being the default.