@voqalize/avatar 0.4.0 → 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 (77) 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 +16 -16
  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/tanya-asset.ts +6 -0
  14. package/client/three/tanya.ts +2 -2
  15. package/client/three/tara-asset.ts +25 -0
  16. package/client/three/tara-rig.ts +37 -14
  17. package/client/three/tara.ts +2 -1
  18. package/client/three/tess-asset.ts +6 -0
  19. package/client/three/tess.ts +72 -0
  20. package/client/three/tushar-asset.ts +6 -0
  21. package/client/three/tushar.ts +2 -2
  22. package/dist/internal.d.ts +2 -2
  23. package/dist/internal.d.ts.map +1 -1
  24. package/dist/internal.js +12 -1
  25. package/dist/internal.js.map +1 -1
  26. package/dist/three/assets.d.ts +1 -16
  27. package/dist/three/assets.d.ts.map +1 -1
  28. package/dist/three/assets.js +15 -16
  29. package/dist/three/assets.js.map +1 -1
  30. package/dist/three/holds.d.ts +7 -2
  31. package/dist/three/holds.d.ts.map +1 -1
  32. package/dist/three/holds.js +7 -2
  33. package/dist/three/holds.js.map +1 -1
  34. package/dist/three/internal.d.ts +5 -5
  35. package/dist/three/internal.js +5 -5
  36. package/dist/three/motion-limits.json +5 -1
  37. package/dist/three/sequences.d.ts +5 -5
  38. package/dist/three/sequences.js +5 -5
  39. package/dist/three/tanya-asset.d.ts +7 -0
  40. package/dist/three/tanya-asset.d.ts.map +1 -0
  41. package/dist/three/tanya-asset.js +7 -0
  42. package/dist/three/tanya-asset.js.map +1 -0
  43. package/dist/three/tanya.js +2 -2
  44. package/dist/three/tanya.js.map +1 -1
  45. package/dist/three/tara-asset.d.ts +26 -0
  46. package/dist/three/tara-asset.d.ts.map +1 -0
  47. package/dist/three/tara-asset.js +26 -0
  48. package/dist/three/tara-asset.js.map +1 -0
  49. package/dist/three/tara-rig.d.ts +29 -9
  50. package/dist/three/tara-rig.d.ts.map +1 -1
  51. package/dist/three/tara-rig.js +14 -9
  52. package/dist/three/tara-rig.js.map +1 -1
  53. package/dist/three/tara.d.ts.map +1 -1
  54. package/dist/three/tara.js +2 -1
  55. package/dist/three/tara.js.map +1 -1
  56. package/dist/three/tess-asset.d.ts +7 -0
  57. package/dist/three/tess-asset.d.ts.map +1 -0
  58. package/dist/three/tess-asset.js +7 -0
  59. package/dist/three/tess-asset.js.map +1 -0
  60. package/dist/three/tess.d.ts +38 -0
  61. package/dist/three/tess.d.ts.map +1 -0
  62. package/dist/three/tess.js +54 -0
  63. package/dist/three/tess.js.map +1 -0
  64. package/dist/three/tushar-asset.d.ts +7 -0
  65. package/dist/three/tushar-asset.d.ts.map +1 -0
  66. package/dist/three/tushar-asset.js +7 -0
  67. package/dist/three/tushar-asset.js.map +1 -0
  68. package/dist/three/tushar.js +2 -2
  69. package/dist/three/tushar.js.map +1 -1
  70. package/package.json +5 -1
  71. package/src/avatar.d.ts +65 -2
  72. package/src/avatar.js +68 -63
  73. package/src/faces.js +1 -1
  74. package/src/gaze.js +19 -44
  75. package/src/idle.js +60 -26
  76. package/src/prosody.js +104 -14
  77. package/src/rig.d.ts +7 -0
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;
package/src/idle.js CHANGED
@@ -54,7 +54,13 @@ import { HeadPose } from './head.js';
54
54
  */
55
55
  export const DEFAULT_PROFILE = {
56
56
  sway: 1.0,
57
- settle: [1.8, 4.6],
57
+ // A head re-positions oftener than it used to (2026-09-21). On a character
58
+ // whose held angle is budgeted this is the half of "more movement" that
59
+ // actually arrives: the angle is capped by `headHold` and the *rate* is not,
60
+ // so a body that re-settles every couple of seconds reads as more alive than
61
+ // one that reaches further every five. Still an event every second or two at
62
+ // its fastest, which is nowhere near the 1.5 Hz line.
63
+ settle: [1.4, 3.4],
58
64
  blinkGap: [1.9, 5.4],
59
65
  slowBlink: 0,
60
66
  breathRate: 1.0,
@@ -62,7 +68,7 @@ export const DEFAULT_PROFILE = {
62
68
  hold: null,
63
69
  rhythm: null,
64
70
  flick: null,
65
- shift: [9, 22],
71
+ shift: [7, 16],
66
72
  };
67
73
 
68
74
  const rand = ([a, b]) => a + Math.random() * (b - a);
@@ -120,7 +126,7 @@ function nextPosture(prev) {
120
126
  // a mechanism that is invisible half the time reads as a body that only
121
127
  // moves sometimes. Nobody shifts their weight by a millimetre. The 25% that
122
128
  // stays on the same side is what keeps it off a left-right metronome.
123
- const mag = 0.16 + Math.random() * 0.26;
129
+ const mag = 0.20 + Math.random() * 0.32;
124
130
  const away = prev.torsoTurn > 0 ? -1 : prev.torsoTurn < 0 ? 1 : (Math.random() < 0.5 ? -1 : 1);
125
131
  const turn = mag * (Math.random() < 0.75 ? away : -away);
126
132
  const leanAway = prev.torsoLean > 0 ? -1 : 1;
@@ -158,11 +164,24 @@ function nextPosture(prev) {
158
164
  * and not a small number, is what keeps pitch off the wire's vocabulary. It was
159
165
  * held to under a degree as well, which measured as a listening head with no
160
166
  * pitch in it at all (1.05° peak over thirty seconds, against yaw's 5.5°), and
161
- * a head that only ever turns is a head on a turntable. The range is wider now
162
- * and still biased upward: a chin that drifts down and stays there is the
163
- * downcast read, which on a photographed face arrives long before any other.
167
+ * a head that only ever turns is a head on a turntable. The range is biased
168
+ * upward: a chin that drifts down and stays there is the downcast read, which
169
+ * on a photographed face arrives long before any other.
170
+ *
171
+ * **Widened again on 2026-09-21, on the owner's read that the whole figure is
172
+ * too restricted.** The ceiling here is not this table — it is `headHold` in
173
+ * the mixer (step 6b), which softens what the layers together ask the head to
174
+ * *hold* against the angle the owner measured on each character by eye
175
+ * (`motion-limits.json`). So these numbers are what the drawing gets and what a
176
+ * budgeted character gets is its own measured angle, which is the arrangement
177
+ * this file should have been sized against all along: on tara the widest settle
178
+ * yaw is 4.9° under a 6° hold budget, the widest pitch 2.6° under 5°, and roll
179
+ * stays the most conservative of the three because her roll *transition* is a
180
+ * recorded defect at any angle. The `dur` is untouched — a wider move over the
181
+ * same half second is a faster move, not a busier one, and the frequency is
182
+ * what the movement budget is about (CLAUDE.md).
164
183
  */
165
- const SETTLE = { yaw: [0.16, 0.34], pitch: [-0.11, 0.08], roll: [0.10, 0.28], dur: [0.45, 0.8], switchP: 0.7 };
184
+ const SETTLE = { yaw: [0.22, 0.46], pitch: [-0.15, 0.11], roll: [0.14, 0.38], dur: [0.45, 0.8], switchP: 0.7 };
166
185
  const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
167
186
 
168
187
  /**
@@ -186,7 +205,7 @@ const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
186
205
  * same gate, so a quiet head does not sit on a torso still rocking under it.
187
206
  */
188
207
  const LIVE = {
189
- yaw: 0.05, pitch: 0.02, roll: 0.05, breathPitch: 0.006,
208
+ yaw: 0.07, pitch: 0.028, roll: 0.07, breathPitch: 0.006,
190
209
  retarget: [0.6, 2.2], tau: 0.38,
191
210
  drift: [2.0, 5.0], still: [1.2, 3.5], stillGain: 0.12,
192
211
  };
@@ -208,7 +227,6 @@ export class IdleLayer {
208
227
  this._nextBlink = 2 + Math.random() * 3;
209
228
  this._blinkT = -1;
210
229
  this._blinkDur = 0.13;
211
- this._double = false;
212
230
  this._lastBlinkAt = -Infinity;
213
231
  // Two incommensurate frequencies per axis so the sway never visibly loops.
214
232
  this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
@@ -294,19 +312,18 @@ export class IdleLayer {
294
312
  * rate is a decision to revisit in that state, not something for the eyelids
295
313
  * to drop silently.
296
314
  */
297
- blink(double = false, evoked = false, forced = false) {
315
+ blink(evoked = false, forced = false) {
298
316
  if (evoked) {
299
317
  if (this.t - this._lastBlinkAt < BLINK_REFRACTORY) return;
300
318
  const gap = this._nextBlink - this._lastBlinkAt;
301
319
  if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
302
320
  }
303
- this._startBlink(0.11 + Math.random() * 0.04, double);
321
+ this._startBlink(0.11 + Math.random() * 0.04);
304
322
  }
305
323
 
306
- _startBlink(dur, double) {
324
+ _startBlink(dur) {
307
325
  if (this._blinkT >= 0) return;
308
326
  this._blinkT = 0;
309
- this._double = double;
310
327
  this._blinkDur = dur;
311
328
  this._lastBlinkAt = this.t;
312
329
  this._nextBlink = this.t + rand(this.profile.blinkGap);
@@ -319,12 +336,12 @@ export class IdleLayer {
319
336
  * of speech with no pauses in it.
320
337
  */
321
338
  phraseBlink() {
322
- this.blink(false, true);
339
+ this.blink(true);
323
340
  }
324
341
 
325
342
  /** A slow, deliberate blink — reads as thinking or fatigue. */
326
343
  slowBlink() {
327
- this._startBlink(0.34, false);
344
+ this._startBlink(0.34);
328
345
  }
329
346
 
330
347
  _blinkValue(dt) {
@@ -333,7 +350,6 @@ export class IdleLayer {
333
350
  const d = this._blinkDur;
334
351
  const p = this._blinkT / d;
335
352
  if (p >= 1) {
336
- if (this._double) { this._double = false; this._blinkT = 0; return 0; }
337
353
  this._blinkT = -1;
338
354
  return 0;
339
355
  }
@@ -351,10 +367,15 @@ export class IdleLayer {
351
367
  // Re-armed here as well as in _startBlink, for the frame the timer comes
352
368
  // due in the middle of a blink already running.
353
369
  this._nextBlink = t + rand(pr.blinkGap);
354
- // A share of them slow if the state asks for it; roughly one in six of
355
- // the rest comes in a pair.
370
+ // A share of them slow if the state asks for it. **The rest are single,
371
+ // and a paired blink is not a thing this layer does** — a share of them
372
+ // used to re-close the instant the lid reached open, which at this
373
+ // duration is a 7-9 Hz flutter and was read by the owner on 2026-09-21 as
374
+ // a dropped frame rather than as a pair. A human doublet is two blinks a
375
+ // few hundred ms apart, which is what the gap already produces when it
376
+ // draws short; nothing in research-biomechanics.md asks for the rest.
356
377
  if (Math.random() < pr.slowBlink) this.slowBlink();
357
- else this.blink(Math.random() < 0.16);
378
+ else this.blink();
358
379
  }
359
380
  const blink = this._blinkValue(dt);
360
381
 
@@ -524,6 +545,12 @@ export class IdleLayer {
524
545
  // actually present on screen. `gain` is where a deployment that
525
546
  // really is paying for the pixels turns it back down.
526
547
  // The head is the exception: it settles rather than sways (SETTLE).
548
+ // The *idle* terms below went up again on 2026-09-21 — the owner read
549
+ // the figure as too restricted — and only the idle terms: the speech
550
+ // share is `prosody.js`'s business and is raised there, beside the
551
+ // words it is timed to. These are all far below 1.5 Hz, so the raise
552
+ // buys excursion and not rate; what it costs the encoder is a slightly
553
+ // larger slow motion, which is the cheap kind.
527
554
  headYaw: hs.headYaw + flickYaw + post.headYaw * ps,
528
555
  headPitch: hs.headPitch + workPitch,
529
556
  headRoll: hs.headRoll + post.headRoll * ps,
@@ -531,9 +558,16 @@ export class IdleLayer {
531
558
  // outbreath over the phrase (research-biomechanics.md §6.1), which the
532
559
  // prosody layer draws. Quiet breathing steps mostly back under it.
533
560
  breath: breath * this.gain * (1 - 0.6 * this.talk),
534
- // The brows are never quite still either.
535
- browRaiseL: s(0, 0.089) * 0.020 * a,
536
- browRaiseR: s(1, 0.083) * 0.020 * a,
561
+ // The brows are never quite still either — and they were nearly still
562
+ // here, at an amplitude a beat could swallow whole. Two video
563
+ // reviewers named a motionless upper face as the biggest reason these
564
+ // read as uncanny; `prosody.POSE.brow` answered that for a face that
565
+ // is *talking*, and the avatar listens far more than it speaks
566
+ // (CLAUDE.md). The two rates are further apart than they were so the
567
+ // pair drifts out of phase inside one call rather than over several:
568
+ // a matched pair is the drawing, not the face.
569
+ browRaiseL: s(0, 0.089) * 0.032 * a,
570
+ browRaiseR: s(1, 0.071) * 0.032 * a,
537
571
  // Shoulders, and the body's share of speech emphasis — a head that
538
572
  // moves on its own above a torso that never does is the head-on-a-stick
539
573
  // read, and it was the loudest note in the first round of stakeholder
@@ -559,15 +593,15 @@ export class IdleLayer {
559
593
  // said should be the louder. What is left here is the part prosody
560
594
  // cannot supply: motion in the gaps *between* phrases, where there is
561
595
  // no accent to hang anything on.
562
- shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
596
+ shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
563
597
  + workL + post.shoulderL * ps,
564
- shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
598
+ shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
565
599
  + workR + post.shoulderR * ps,
566
- torsoLean: s(0, 0.085) * (0.028 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
600
+ torsoLean: s(0, 0.085) * (0.036 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
567
601
  // The trunk's own drift, small next to the weight shift that dominates
568
602
  // this channel. It exists so the body is not perfectly still *between*
569
603
  // shifts, which would make each shift read as a discrete event.
570
- torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.06 * a
604
+ torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.075 * a
571
605
  + post.torsoTurn * ps,
572
606
  },
573
607
  };