@voqalize/avatar 0.4.1 → 0.4.2

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 (52) hide show
  1. package/README.md +46 -28
  2. package/assets/README.md +4 -3
  3. package/assets/tanya.glb +0 -0
  4. package/assets/tara.glb +0 -0
  5. package/assets/tess.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/internal.ts +20 -0
  8. package/client/three/assets.ts +5 -3
  9. package/client/three/holds.ts +7 -2
  10. package/client/three/internal.ts +5 -5
  11. package/client/three/motion-limits.json +5 -1
  12. package/client/three/sequences.ts +5 -5
  13. package/client/three/tara-rig.ts +22 -8
  14. package/client/three/tess-asset.ts +6 -0
  15. package/client/three/tess.ts +72 -0
  16. package/dist/internal.d.ts +2 -2
  17. package/dist/internal.d.ts.map +1 -1
  18. package/dist/internal.js +12 -1
  19. package/dist/internal.js.map +1 -1
  20. package/dist/three/assets.d.ts +1 -0
  21. package/dist/three/assets.d.ts.map +1 -1
  22. package/dist/three/assets.js +5 -3
  23. package/dist/three/assets.js.map +1 -1
  24. package/dist/three/holds.d.ts +7 -2
  25. package/dist/three/holds.d.ts.map +1 -1
  26. package/dist/three/holds.js +7 -2
  27. package/dist/three/holds.js.map +1 -1
  28. package/dist/three/internal.d.ts +5 -5
  29. package/dist/three/internal.js +5 -5
  30. package/dist/three/motion-limits.json +5 -1
  31. package/dist/three/sequences.d.ts +5 -5
  32. package/dist/three/sequences.js +5 -5
  33. package/dist/three/tara-rig.d.ts +18 -4
  34. package/dist/three/tara-rig.d.ts.map +1 -1
  35. package/dist/three/tara-rig.js +9 -8
  36. package/dist/three/tara-rig.js.map +1 -1
  37. package/dist/three/tess-asset.d.ts +7 -0
  38. package/dist/three/tess-asset.d.ts.map +1 -0
  39. package/dist/three/tess-asset.js +7 -0
  40. package/dist/three/tess-asset.js.map +1 -0
  41. package/dist/three/tess.d.ts +38 -0
  42. package/dist/three/tess.d.ts.map +1 -0
  43. package/dist/three/tess.js +54 -0
  44. package/dist/three/tess.js.map +1 -0
  45. package/package.json +5 -1
  46. package/src/avatar.d.ts +65 -2
  47. package/src/avatar.js +68 -63
  48. package/src/faces.js +1 -1
  49. package/src/gaze.js +19 -44
  50. package/src/idle.js +60 -26
  51. package/src/prosody.js +104 -14
  52. package/src/rig.d.ts +7 -0
package/src/avatar.d.ts CHANGED
@@ -195,7 +195,7 @@ export interface AvatarApi {
195
195
  readonly gestureGain: Gain;
196
196
  setMotionGain(g: Gain): AvatarApi;
197
197
  readonly motionGain: Gain;
198
- blink(double?: boolean): AvatarApi;
198
+ blink(): AvatarApi;
199
199
  /** Advance one frame by hand — only meaningful under `{ manual: true }`. */
200
200
  step(dt: number): AvatarApi;
201
201
  /** Pin channels to fixed values, above the whole mix. `null` releases. */
@@ -316,7 +316,12 @@ export function createAvatar(opts: CreateAvatarOptions): AvatarApi;
316
316
  export const STATES: Readonly<Record<AvatarStateName, Readonly<Record<string, unknown>>>>;
317
317
  export const STATE_NAMES: readonly AvatarStateName[];
318
318
  export const GAZE_NAMES: readonly AvatarGazeName[];
319
- export const GAZE_TARGETS: Readonly<Record<AvatarGazeName, { x: number; y: number }>>;
319
+ /** Where each named direction puts the eyes and the head: pupil offset
320
+ * (`px`/`py`), the head's share of it (`hx`/`hy`) and any roll. Pose units, and
321
+ * the head deliberately carries less than the whole (`src/gaze.js`). */
322
+ export const GAZE_TARGETS: Readonly<Record<AvatarGazeName, {
323
+ px: number; py: number; hx: number; hy: number; roll?: number;
324
+ }>>;
320
325
  export const EMOTION_NAMES: readonly AvatarEmotionName[];
321
326
  export const ACTIONS: Readonly<Record<AvatarActionId, unknown>>;
322
327
  export const ACTION_IDS: readonly AvatarActionId[];
@@ -334,12 +339,24 @@ export const REST: Readonly<Record<PoseChannel, number>>;
334
339
  export const CHANNELS: readonly PoseChannel[];
335
340
  /** Post-mix clamp per channel, `[min, max]`. */
336
341
  export const RANGE: Readonly<Record<PoseChannel, readonly [number, number]>>;
342
+ /** Each channel's smoothing time constant, in seconds. */
343
+ export const TAU: Readonly<Record<PoseChannel, number>>;
344
+ export function clamp(v: number, lo?: number, hi?: number): number;
345
+ /** One frame of exponential smoothing: where `cur` lands `dt` seconds into a
346
+ * chase of `target` with time constant `tau`. */
347
+ export function approach(cur: number, target: number, tau: number, dt: number): number;
337
348
  export const VISEME_LETTERS: readonly VisemeLetter[];
338
349
  export const VISEME_SHAPES: Readonly<Record<VisemeLetter, PoseOverrides>>;
339
350
  /** Cues lead the audio by this many ms — perceptual tolerance is asymmetric. */
340
351
  export const LEAD_MS: number;
341
352
  /** The shoulder line's share of a held `headRoll`, per pose unit. */
342
353
  export const SHOULDER_TILT: number;
354
+ /** A full pose from a handful of overrides: every unnamed channel takes its
355
+ * `REST` value. The only correct way to build a frame by hand. */
356
+ export function makeParams(overrides?: PoseOverrides): Record<PoseChannel, number>;
357
+ /** One named emotion's channel deltas at `intensity` (0..1). */
358
+ export function emotionPose(name: AvatarEmotionName, intensity?: number): PoseOverrides;
359
+ export { avatarFrame, createSvgRig } from "./rig.js";
343
360
  export const ARPABET_TO_VISEME: Readonly<Record<string, VisemeLetter>>;
344
361
  export const AZURE_VISEME_TO_LETTER: Readonly<Record<number, VisemeLetter>>;
345
362
 
@@ -365,6 +382,52 @@ export class VisemeTrack {
365
382
  readonly playing: boolean;
366
383
  }
367
384
 
385
+ /**
386
+ * One authored gesture: a short multi-channel timeline of additive deltas,
387
+ * optionally with its own mouth track, gaze override and blink beats.
388
+ */
389
+ export interface Clip {
390
+ readonly id: string;
391
+ readonly label: string;
392
+ readonly text: string;
393
+ /** Milliseconds, end to end. */
394
+ readonly duration: number;
395
+ /** Per channel, `[u, delta]` keys against normalized clip time. */
396
+ readonly keys: Readonly<Record<string, ReadonlyArray<readonly [number, number]>>>;
397
+ readonly mouthCues?: readonly Cue[];
398
+ readonly gaze?: AvatarGazeName;
399
+ readonly blinkAt?: readonly number[];
400
+ }
401
+
402
+ /**
403
+ * Every clip this renderer has, keyed by id — the authoring library, not the
404
+ * wire's vocabulary. Most are reachable only from inside the mixer: `ACTIONS`
405
+ * is what a server may name.
406
+ */
407
+ export const INTERNAL_CLIPS: Readonly<Record<string, Clip>>;
408
+
409
+ /** What one tick of a clip contributes: additive channel deltas, the ramp
410
+ * weight they already carry, and the mouth when the clip owns it. */
411
+ export interface ClipSample {
412
+ delta: Record<string, number> | null;
413
+ weight: number;
414
+ mouth: { letter: VisemeLetter; intensity: number } | null;
415
+ ownsMouth: boolean;
416
+ }
417
+
418
+ /** Plays one clip forward in ticks you supply. Fixed-`dt` stepping is what
419
+ * makes a rendered gesture reproducible. */
420
+ export class ClipPlayer {
421
+ constructor(hooks?: { onGaze?: (name: string | null) => void; onBlink?: () => void });
422
+ play(clip: Clip, audio?: HTMLMediaElement | null, opts?: { queue?: boolean }): void;
423
+ /** Advance by `dtMs` and report this frame's contribution. */
424
+ update(dtMs: number): ClipSample;
425
+ stop(immediate?: boolean): void;
426
+ onEnd: ((clip: Clip | null) => void) | null;
427
+ readonly playing: boolean;
428
+ readonly id: string | null;
429
+ }
430
+
368
431
  export function attachAudio(id: string, url: string): void;
369
432
  export function normalizeActions(actions: AvatarAction[]): AvatarAction[];
370
433
  export function normalizeCues(cues: Cue[]): Cue[];
package/src/avatar.js CHANGED
@@ -91,8 +91,8 @@ export const STATES = {
91
91
  // is kept to a fifth of looks because on a face this real it reads as
92
92
  // downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
93
93
  // alert, and the two together measured past the 0.15 that reads drowsy.
94
- // The handoff to SPEAKING is in setState: a reply that starts mid-look
95
- // finishes the look instead of snapping back.
94
+ // There is no handoff to SPEAKING any more: a reply that starts mid-look
95
+ // brings the eyes back with its first word (see enterGaze, 2026-09-21).
96
96
  THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
97
97
  // Fixational jumps rare and small: a thinker's eyes rest
98
98
  // where they land. At the default gap the look jittered
@@ -138,11 +138,11 @@ export const STATES = {
138
138
  // eyes drawn inside a head tipped back aimed every look
139
139
  // meant to be level at the ceiling.
140
140
  pose: { lidL: -0.10, lidR: -0.10 } },
141
- // Eyes on the user, with the speaker's own looks away (AVERSION.SPEAK):
142
- // a planning look at the start of most turns, a short one every ~5 s at a
143
- // phrase boundary, and none as the turn ends. A speaker who never looks
144
- // away is staring the listener down.
145
- SPEAKING: { gaze: 'USER', emotion: 'neutral', aversion: 'SPEAK',
141
+ // Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
142
+ // speaker's measured looks away are not this rig's to render. What moves
143
+ // while it talks is the head it holds per phrase and the trunk under it
144
+ // (prosody.js), never the eyes leaving the user.
145
+ SPEAKING: { gaze: 'USER', emotion: 'neutral',
146
146
  idle: { sway: 0.55 }, engagement: false },
147
147
  REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
148
148
  idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
@@ -450,7 +450,7 @@ export function createAvatar(opts = {}) {
450
450
  // coordinates or the hand layer's private geometry.
451
451
  const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
452
452
 
453
- gaze.onLargeShift = (forced) => idle.blink(false, true, forced);
453
+ gaze.onLargeShift = (forced) => idle.blink(true, forced);
454
454
 
455
455
  const listeners = {
456
456
  state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
@@ -617,7 +617,6 @@ export function createAvatar(opts = {}) {
617
617
  let wanderAt = 0;
618
618
  let driftAt = 0;
619
619
  let trunkYaw = 0;
620
- let turnStartPending = false;
621
620
  let glanceAt = 0;
622
621
  let glanceUntil = 0;
623
622
  let lastBack = null;
@@ -627,8 +626,6 @@ export function createAvatar(opts = {}) {
627
626
  const dart = { x: 0, y: 0 };
628
627
  // THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
629
628
  // when that handoff happened (it stands in for the turn-start look).
630
- let returnAt = 0;
631
- let carriedAt = -Infinity;
632
629
  // The state whose gaze is showing, and when the current state takes it
633
630
  // over if that is still pending (GAP_SETTLE). Usually the same state.
634
631
  let gazeState = 'IDLE';
@@ -754,11 +751,7 @@ export function createAvatar(opts = {}) {
754
751
  // spot every time. Either leg's gaze-evoked blink is the shift's odds
755
752
  // unless the state says (`blinkTo`, `blinkBack`), because which leg blinks
756
753
  // is part of what the glance means.
757
- if (returnAt && elapsed > returnAt) {
758
- returnAt = 0;
759
- setGaze(gst.gaze);
760
- }
761
- if (gst.glance && !returnAt) {
754
+ if (gst.glance) {
762
755
  const gl = gst.glance;
763
756
  if (glanceUntil && elapsed > glanceUntil) {
764
757
  glanceUntil = 0;
@@ -807,25 +800,7 @@ export function createAvatar(opts = {}) {
807
800
  // mixer's one-frame veto — anything that means "the user is checking
808
801
  // whether I am with them" sets it (see api.attend).
809
802
  gaze.setAversion(gst.aversion ? AVERSION[gst.aversion] : null);
810
- // A speaking turn ends under mutual gaze: no look in the last 2.4 s of the
811
- // cue track as far as it has arrived, nor once it has run out (§4.2). The
812
- // mid-turn looks wait for a silent cue, which is a phrase boundary. Both
813
- // read last frame's sample, a frame late and harmlessly so.
814
- let floorReturn = false;
815
- gaze.gate = true;
816
- if (gst.aversion === 'SPEAK') {
817
- const cues = speech.cues;
818
- floorReturn = !speech.playing || !cues.length
819
- || cues[cues.length - 1].t - speech.now < FLOOR_RETURN_MS;
820
- const cue = cues[speech.index];
821
- gaze.gate = !!cue && cue.v === SILENT;
822
- }
823
- gaze.hold = attendUntil > elapsed || clip.playing || floorReturn;
824
- if (turnStartPending) {
825
- turnStartPending = false;
826
- // A reply that began mid-think has had its planning look already.
827
- if (elapsed - carriedAt > 1.5) gaze.startTurn();
828
- }
803
+ gaze.hold = attendUntil > elapsed || clip.playing;
829
804
 
830
805
  engagement.enabled = !!st.engagement && !clip.playing;
831
806
  engagement.update(dt);
@@ -1041,11 +1016,6 @@ export function createAvatar(opts = {}) {
1041
1016
  // sliding.
1042
1017
  const TRUNK_FOLLOW = opts.trunkFollow ?? 0.45;
1043
1018
 
1044
- // Speakers are back on the listener's eyes this long before they stop
1045
- // (Andrist, docs/research-biomechanics.md §4.2): the floor is handed over
1046
- // under mutual gaze, and a look away there declines it.
1047
- const FLOOR_RETURN_MS = 2400;
1048
-
1049
1019
  // The channels speech owns outright — exactly the params.js mouth group
1050
1020
  // (mouth corners stay free: a clip may smile over a sentence).
1051
1021
  const MOUTH_LOCK = new Set(GROUPS.mouth);
@@ -1059,13 +1029,6 @@ export function createAvatar(opts = {}) {
1059
1029
  const ENGAGE_SHOULDER = 0.10;
1060
1030
  const SPEAK_SMILE_RETAIN = 0.35;
1061
1031
 
1062
- // How long a reply that starts mid-think keeps the look before it comes
1063
- // back. Speakers look away to find the words and back to deliver them
1064
- // (§4.2); a reply that snapped to the user on its first syllable and then,
1065
- // three times in four, left again for the turn-start look did both halves
1066
- // twice.
1067
- const THINK_CARRY = [0.25, 0.6];
1068
-
1069
1032
  // Between the user's turn and the reply the server's claim can change
1070
1033
  // several times a second — THINKING, a tool's WORKING, THINKING again, a
1071
1034
  // grace timer's CANT_HEAR — and every change used to retarget the eyes and
@@ -1152,25 +1115,40 @@ const SPEAK_SMILE_RETAIN = 0.35;
1152
1115
  const deferrable = !o.gaze && !o.keepGaze && GAP_STATES.has(gazeState) && GAP_STATES.has(name);
1153
1116
  if (deferrable && changed) settleAt = name === gazeState ? 0 : elapsed + GAP_SETTLE;
1154
1117
  else if (!(deferrable && settleAt)) { settleAt = 0; enterGaze(name, o, changed); }
1118
+ // The floor has come back to the user: the face receives it (prosody.js
1119
+ // `listen`). It is a state change rather than a VAD event on purpose —
1120
+ // what is being welcomed is the turn, and the server is the one that knows
1121
+ // a turn has changed hands.
1122
+ if (changed && name === 'LISTENING') prosody.listen();
1155
1123
  if (changed) emit('state', name);
1156
1124
  return api;
1157
1125
  }
1158
1126
 
1127
+ // Whether the eyes were last aimed by somebody who meant it — the public
1128
+ // `setGaze`, or a performance's `gaze` verb — rather than by the state's own
1129
+ // schedule. It is the one thing that outranks a state's `gaze`, and it is
1130
+ // why `speak()` can take the eyes back without overriding a caller.
1131
+ let gazeExplicit = false;
1132
+
1159
1133
  /** The state `name` takes the gaze: its target and its schedules. */
1160
1134
  function enterGaze(name, o, blink) {
1161
- const prev = gazeState;
1162
1135
  gazeState = name;
1163
1136
  const st = states[name];
1164
1137
  const gl = st.glance;
1165
- // A reply that starts while THINKING is looking away finishes that look
1166
- // first, and the look stands in for the turn-start one.
1167
- const carry = prev === 'THINKING' && name === 'SPEAKING'
1168
- && !o.gaze && !o.keepGaze && gazeName !== st.gaze && !glanceUntil;
1169
- returnAt = carry ? elapsed + rand(THINK_CARRY) : 0;
1170
- if (carry) carriedAt = elapsed;
1138
+ // **A reply brings the eyes back with its first word, and that is the
1139
+ // whole of it (2026-09-21).** A THINKING look that was still running used
1140
+ // to be carried a quarter to six tenths of a second into the turn, on the
1141
+ // reasoning that a speaker looks away to find the words and back to
1142
+ // deliver them — but what the owner sees at the top of a turn is the
1143
+ // avatar talking while looking somewhere else, and then a dart back. The
1144
+ // carry's own justification was the turn-start aversion it would otherwise
1145
+ // have doubled with, and that aversion is deleted (gaze.js), so nothing is
1146
+ // left for it to avoid. `setGaze` glides; the return is a saccade, not a
1147
+ // cut, and it now lands on the first word instead of after it.
1148
+ //
1171
1149
  // A state with an `opening` enters as though its check-in on the user is
1172
1150
  // already under way, and leaves it when that runs out.
1173
- if (!o.keepGaze && !carry) setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze));
1151
+ if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
1174
1152
  // Arm every scheduler fresh, so entering a state never fires a timestamp
1175
1153
  // left over from the last one — the wander in particular, which used to
1176
1154
  // pick a new target on the first frame and override the state's own gaze.
@@ -1181,7 +1159,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1181
1159
  readCol = readCols = readRow = readRows = 0;
1182
1160
  dartAt = dartBrowUntil = 0;
1183
1161
  dart.x = dart.y = 0;
1184
- if (blink) idle.blink(false, true);
1162
+ if (blink) idle.blink(true);
1185
1163
  }
1186
1164
 
1187
1165
  function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
@@ -1217,8 +1195,18 @@ const SPEAK_SMILE_RETAIN = 0.35;
1217
1195
  : () => performance.now() - speakStart;
1218
1196
  speech.start(o.cues || [], speakClock);
1219
1197
  prosody.reset(newTurn);
1220
- if (newTurn) turnStartPending = true;
1221
- if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: true });
1198
+ // **The eyes come back to the user when the audio starts, and that is the
1199
+ // point of inferring the state at all (2026-09-21).** This used to keep
1200
+ // whatever gaze was already set, which meant a reply arriving while
1201
+ // THINKING was looking away spent its *whole turn* aimed off the user —
1202
+ // 11.9 deg off it in `presence.test.ts`, never returning, because nothing
1203
+ // in SPEAKING retargets. The owner's report was of the eyes being
1204
+ // elsewhere as the bot starts talking, and this is the half of it that
1205
+ // survives in a real call: the audit drives the state directly and so
1206
+ // never took this path. `keepGaze` stays for the one caller that means
1207
+ // it — a performance that aimed the eyes with its own `gaze` verb keeps
1208
+ // them, since that is an instruction and not a leftover schedule.
1209
+ if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
1222
1210
  if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
1223
1211
  return api;
1224
1212
  }
@@ -1376,7 +1364,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1376
1364
  // composed turn in the demo already behaves.
1377
1365
  if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
1378
1366
  else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
1379
- else if (a.do === 'gaze') setGaze(a.name);
1367
+ else if (a.do === 'gaze') { setGaze(a.name); gazeExplicit = true; }
1380
1368
  else if (a.do === 'action') action(a.id);
1381
1369
  } catch (e) {
1382
1370
  console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
@@ -1417,7 +1405,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1417
1405
  }
1418
1406
 
1419
1407
  const api = {
1420
- setState, setEmotion, setGaze: (name, custom) => setGaze(name, custom), speak, pushCues, stopSpeaking, attend,
1408
+ setState, setEmotion, setGaze: (name, custom) => { gazeExplicit = true; return setGaze(name, custom); }, speak, pushCues, stopSpeaking, attend,
1421
1409
  action, perform,
1422
1410
  /** Which hand the character gestures with: +1 the viewer's right (its own
1423
1411
  * left), -1 the other. Both are anatomically real — the thumb splays away
@@ -1433,7 +1421,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1433
1421
  /** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
1434
1422
  setMotionGain: (g) => { idle.gain = g; return api; },
1435
1423
  get motionGain() { return idle.gain; },
1436
- blink: (dbl) => { idle.blink(dbl); return api; },
1424
+ blink: () => { idle.blink(); return api; },
1437
1425
  /** Advance one frame by hand. Only meaningful under `{manual: true}`;
1438
1426
  * fixed-dt stepping is what makes a motion render reproducible. */
1439
1427
  step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
@@ -1474,12 +1462,29 @@ const SPEAK_SMILE_RETAIN = 0.35;
1474
1462
  // this channel's neutral value" before it can map the channel onto whatever it
1475
1463
  // controls — a morph target's influence is `(pose - rest) / (1 - rest)`, and a
1476
1464
  // rig that hard-codes those rests has quietly forked `params.js`.
1477
- export { REST, CHANNELS, RANGE } from './params.js';
1465
+ export { REST, CHANNELS, RANGE, TAU, clamp, approach, makeParams } from './params.js';
1466
+ // The smoothing law itself, for a tool that steps the rig by hand. A
1467
+ // filmstrip that reimplemented `approach` would be measuring its own copy of
1468
+ // the thing under test — and the smoothing between keyframes is what the face
1469
+ // actually does (docs/internal-mixer.md § Smoothing).
1470
+ // A pose with no mixer above it: `makeParams(overrides)` fills the rests,
1471
+ // `avatarFrame` wraps it, `createSvgRig(face).apply` draws it. That is the whole
1472
+ // path an instrument needs to hold a face at one named extreme — no clock, no
1473
+ // client, no animation — and the reason it is exported is that a pose sheet
1474
+ // that cannot reach it forks the channel rests instead.
1475
+ export { avatarFrame, createSvgRig } from './rig.js';
1478
1476
  export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
1477
+ // The full authoring catalogue and the player that steps it. Not a server
1478
+ // vocabulary — `ACTIONS` is that, and most of these clips are reachable only
1479
+ // from inside the mixer. They are exported for the filmstrip instrument,
1480
+ // which lays one clip out as frames and therefore has to drive a real
1481
+ // `ClipPlayer` rather than re-sample its keys.
1482
+ export { INTERNAL_CLIPS } from './interjections.js';
1483
+ export { ClipPlayer } from './clips.js';
1479
1484
  export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
1480
1485
  export { normalizeActions } from './perform.js';
1481
1486
  export { checkHandFraming } from './hand.js';
1482
- export { EMOTION_NAMES } from './emotions.js';
1487
+ export { EMOTION_NAMES, emotionPose } from './emotions.js';
1483
1488
  // The mouth clock travels with the rest of it. Someone has to turn a cue array
1484
1489
  // plus a clock into "which letter is on screen right now", every renderer needs
1485
1490
  // exactly that, and none of them should write it twice — so it is a plain class
package/src/faces.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * The faces we ship, as a table — for tooling that wants every one of them.
3
3
  *
4
4
  * **Importing this costs all three drawings** (~2k lines of path data). That is
5
- * the right trade for `rig-check`, the contact sheets, the sweep and Studio,
5
+ * the right trade for the pose sheet, the sweep and Studio,
6
6
  * which exist to compare faces against each other. It is the wrong trade for an
7
7
  * application, which renders one: those import a single face module and hand
8
8
  * the record to `createAvatar` directly.
package/src/gaze.js CHANGED
@@ -84,9 +84,6 @@ const LID_FOLLOW = { down: 0.34, up: 0 };
84
84
  // peaks mid-turn, is gone when the head lands, and sizes itself on a rig whose
85
85
  // head moves four times faster. A 0.75-unit look on tara sinks ~0.8°.
86
86
  const HEAD_DIP = 0.06;
87
- // A gated aversion waits this long past its due time for a phrase boundary,
88
- // then goes anyway: a run of speech with no pause in it still gets its look.
89
- const GATE_WAIT = 1.5;
90
87
  // How much of a drift the head joins. Little: a drift is a reader's scan or a
91
88
  // thinker's gaze moving where it rests, and both are eye movements. At 0.35
92
89
  // the head nodded along with every step of a reading scan, which on a
@@ -219,30 +216,24 @@ export const AVERSION = {
219
216
  mag: [0.30, 0.44],
220
217
  dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
221
218
  },
222
- // While speaking: 1.96 s every 4.75 s mid-turn, a turn-start look on 73% of
223
- // turns — the planning look, away to find the words and back to deliver
224
- // them — and none in the last 2.4 s, because the floor is handed back under
225
- // mutual gaze (§4.2; the mixer owns that hold, since only it can see the
226
- // cue track). Human speakers hold the listener's eyes for about 41% of a
227
- // turn. This is set for 70-80% instead — the
228
- // listener here watches a face on a screen, where 41% reads as evasive — so
229
- // the looks keep about Andrist's length and come a little less often.
230
- // `every` sits under Andrist's 4.75 s because the phrase gate and the
231
- // end-of-turn hold both stretch it: at 3.8-5.8 a turn measured 6-8 looks a
232
- // minute and 76-82% contact, the top edge of the band rather than its middle.
233
- // `gated`: a mid-turn look waits for a phrase boundary, because speakers
234
- // break gaze between clauses, not in the middle of a word.
235
- SPEAK: {
236
- every: [3.4, 5.2],
237
- dur: [1.5, 2.3],
238
- mag: [0.34, 0.48],
239
- dirs: [[-1, 0.12], [1, 0.12], [-0.75, 0.55], [0.7, 0.5], [-0.6, -0.45]],
240
- gated: true,
241
- start: { p: 0.73, dur: [1.1, 1.8], window: 0.8 },
242
- },
243
219
  };
244
- // There is deliberately no THINK profile. The *cognitive* aversion — 3.54 s
245
- // (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
220
+ // **There is deliberately no SPEAK profile, and that is the owner's call
221
+ // (2026-09-21): while the avatar talks, its eyes are on the user.** There was
222
+ // one — Andrist's measured speaker, a planning look away at the start of most
223
+ // turns and a short one every few seconds at a phrase boundary — and in a live
224
+ // call it read as the eyes darting off and coming back. The research it came
225
+ // from is not disputed and the reason it does not transfer is structural: a
226
+ // human speaker's look away is a *head and body* movement that the eyes only
227
+ // lead, and this rig has no such movement to give it. Eye travel on its own is
228
+ // not a smaller version of that, it is a different thing, and on tara the
229
+ // amplitude it needed to be visible at all (`aversionGain` 1.8) is exactly what
230
+ // made it read as a dart. What the speaking face moves instead is the head it
231
+ // holds per phrase and the trunk under it (prosody.js), which is communicative
232
+ // rather than evasive. If the face and body half is ever built, this is where
233
+ // the eye half comes back.
234
+ //
235
+ // There is deliberately no THINK profile either. The *cognitive* aversion —
236
+ // 3.54 s (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
246
237
  // and deeper than the listening kind, and THINKING renders it with its own
247
238
  // dwell cycle (a `glance` in STATES), which moves the whole gaze target rather
248
239
  // than nudging off it. Two mechanisms producing the same look would fight.
@@ -296,9 +287,6 @@ export class GazeLayer {
296
287
  this._avHead = { x: 0, y: 0 };
297
288
  this._avVel = { x: 0, y: 0 };
298
289
  this._avProfileRef = undefined;
299
- /** Whether now is a phrase boundary, for a `gated` profile. Set by the mixer. */
300
- this.gate = true;
301
- this._avStartBy = 0; // a turn-start look may still fire until this time
302
290
  // --- drift: a small held offset about the target, set by the mixer
303
291
  this.drift = { x: 0, y: 0 };
304
292
  this._drift = { x: 0, y: 0 };
@@ -313,15 +301,6 @@ export class GazeLayer {
313
301
  // it is entered off a stale timestamp from one that didn't.
314
302
  this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
315
303
  this._avUntil = 0;
316
- this._avStartBy = 0;
317
- }
318
-
319
- /** A turn begins. If the profile has a turn-start look and the dice take it,
320
- * it fires as soon as `hold` allows inside its window, or not at all — a
321
- * planning look that arrives a second into the words is just a look. */
322
- startTurn() {
323
- const s = this.aversion && this.aversion.start;
324
- if (s && Math.random() < s.p) this._avStartBy = this._t + s.window;
325
304
  }
326
305
 
327
306
  _avert(t, dt) {
@@ -339,17 +318,13 @@ export class GazeLayer {
339
318
  // moment the hold lifted — out, back and out again across one turn
340
319
  // edge, which is three saccades where the floor wanted none.
341
320
  this._avUntil = 0;
342
- this._avStartBy = 0;
343
321
  this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
344
322
  } else if (this._avUntil && t >= this._avUntil) {
345
323
  this._avUntil = 0;
346
324
  this._nextMicro = 0;
347
325
  this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
348
- } else if (!this._avUntil && !this.hold && (t < this._avStartBy || (t >= this._avNext
349
- && (this.gate || !p.gated || t >= this._avNext + GATE_WAIT)))) {
350
- const dur = t < this._avStartBy ? p.start.dur : p.dur;
351
- this._avStartBy = 0;
352
- this._avUntil = t + dur[0] + Math.random() * (dur[1] - dur[0]);
326
+ } else if (!this._avUntil && !this.hold && t >= this._avNext) {
327
+ this._avUntil = t + p.dur[0] + Math.random() * (p.dur[1] - p.dur[0]);
353
328
  this._nextMicro = 0;
354
329
  const d = p.dirs[(Math.random() * p.dirs.length) | 0];
355
330
  const m = (p.mag[0] + Math.random() * (p.mag[1] - p.mag[0])) * this.avertGain;