@voqalize/avatar 0.4.2 → 0.4.3

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 (191) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +11 -98
  3. package/assets/README.md +28 -17
  4. package/assets/tanvi.glb +0 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tess.glb +0 -0
  8. package/assets/tushar.glb +0 -0
  9. package/client/internal.ts +4 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +2 -0
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +673 -545
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +1 -1
  16. package/client/three/internal.ts +4 -3
  17. package/client/three/motion-limits.json +5 -1
  18. package/client/three/tanvi-asset.ts +6 -0
  19. package/client/three/tanvi.ts +30 -0
  20. package/client/three/tanya.ts +18 -58
  21. package/client/three/tara.ts +19 -60
  22. package/client/three/tess.ts +17 -59
  23. package/client/three/tushar.ts +18 -55
  24. package/dist/internal.d.ts +1 -1
  25. package/dist/internal.d.ts.map +1 -1
  26. package/dist/internal.js +5 -1
  27. package/dist/internal.js.map +1 -1
  28. package/dist/supports.d.ts +6 -7
  29. package/dist/supports.d.ts.map +1 -1
  30. package/dist/supports.js +6 -7
  31. package/dist/supports.js.map +1 -1
  32. package/dist/three/assets.d.ts +1 -0
  33. package/dist/three/assets.d.ts.map +1 -1
  34. package/dist/three/assets.js +2 -0
  35. package/dist/three/assets.js.map +1 -1
  36. package/dist/three/budgets.d.ts +1 -1
  37. package/dist/three/budgets.js +1 -1
  38. package/dist/three/character-rig.d.ts +366 -0
  39. package/dist/three/character-rig.d.ts.map +1 -0
  40. package/dist/three/{tara-rig.js → character-rig.js} +629 -523
  41. package/dist/three/character-rig.js.map +1 -0
  42. package/dist/three/createCharacter.d.ts +60 -0
  43. package/dist/three/createCharacter.d.ts.map +1 -0
  44. package/dist/three/createCharacter.js +84 -0
  45. package/dist/three/createCharacter.js.map +1 -0
  46. package/dist/three/holds.js +1 -1
  47. package/dist/three/holds.js.map +1 -1
  48. package/dist/three/internal.d.ts +3 -3
  49. package/dist/three/internal.d.ts.map +1 -1
  50. package/dist/three/internal.js +2 -2
  51. package/dist/three/internal.js.map +1 -1
  52. package/dist/three/motion-limits.json +5 -1
  53. package/dist/three/tanvi-asset.d.ts +7 -0
  54. package/dist/three/tanvi-asset.d.ts.map +1 -0
  55. package/dist/three/tanvi-asset.js +7 -0
  56. package/dist/three/tanvi-asset.js.map +1 -0
  57. package/dist/three/tanvi.d.ts +24 -0
  58. package/dist/three/tanvi.d.ts.map +1 -0
  59. package/dist/three/tanvi.js +24 -0
  60. package/dist/three/tanvi.js.map +1 -0
  61. package/dist/three/tanya.d.ts +16 -28
  62. package/dist/three/tanya.d.ts.map +1 -1
  63. package/dist/three/tanya.js +15 -43
  64. package/dist/three/tanya.js.map +1 -1
  65. package/dist/three/tara.d.ts +17 -22
  66. package/dist/three/tara.d.ts.map +1 -1
  67. package/dist/three/tara.js +16 -45
  68. package/dist/three/tara.js.map +1 -1
  69. package/dist/three/tess.d.ts +15 -29
  70. package/dist/three/tess.d.ts.map +1 -1
  71. package/dist/three/tess.js +14 -44
  72. package/dist/three/tess.js.map +1 -1
  73. package/dist/three/tushar.d.ts +16 -25
  74. package/dist/three/tushar.d.ts.map +1 -1
  75. package/dist/three/tushar.js +15 -40
  76. package/dist/three/tushar.js.map +1 -1
  77. package/package.json +7 -50
  78. package/src/avatar.d.ts +2 -0
  79. package/src/avatar.js +87 -216
  80. package/src/gaze.js +1 -3
  81. package/src/idle.js +10 -2
  82. package/src/prosody.js +7 -3
  83. package/src/speech-timing.js +9 -0
  84. package/src/visemes.js +107 -13
  85. package/client/arjun.ts +0 -26
  86. package/client/createCanvasAvatar.ts +0 -72
  87. package/client/interviewer-female.ts +0 -4
  88. package/client/interviewer-male.ts +0 -4
  89. package/client/ishita.ts +0 -26
  90. package/client/kabir.ts +0 -26
  91. package/client/meera.ts +0 -26
  92. package/client/naina.ts +0 -26
  93. package/client/professional-female-a.ts +0 -4
  94. package/client/professional-female-b.ts +0 -4
  95. package/client/professional-male-a.ts +0 -4
  96. package/client/professional-male-b.ts +0 -4
  97. package/client/vikram.ts +0 -26
  98. package/dist/arjun.d.ts +0 -7
  99. package/dist/arjun.d.ts.map +0 -1
  100. package/dist/arjun.js +0 -20
  101. package/dist/arjun.js.map +0 -1
  102. package/dist/createCanvasAvatar.d.ts +0 -22
  103. package/dist/createCanvasAvatar.d.ts.map +0 -1
  104. package/dist/createCanvasAvatar.js +0 -47
  105. package/dist/createCanvasAvatar.js.map +0 -1
  106. package/dist/interviewer-female.d.ts +0 -4
  107. package/dist/interviewer-female.d.ts.map +0 -1
  108. package/dist/interviewer-female.js +0 -3
  109. package/dist/interviewer-female.js.map +0 -1
  110. package/dist/interviewer-male.d.ts +0 -4
  111. package/dist/interviewer-male.d.ts.map +0 -1
  112. package/dist/interviewer-male.js +0 -3
  113. package/dist/interviewer-male.js.map +0 -1
  114. package/dist/ishita.d.ts +0 -7
  115. package/dist/ishita.d.ts.map +0 -1
  116. package/dist/ishita.js +0 -20
  117. package/dist/ishita.js.map +0 -1
  118. package/dist/kabir.d.ts +0 -7
  119. package/dist/kabir.d.ts.map +0 -1
  120. package/dist/kabir.js +0 -20
  121. package/dist/kabir.js.map +0 -1
  122. package/dist/meera.d.ts +0 -7
  123. package/dist/meera.d.ts.map +0 -1
  124. package/dist/meera.js +0 -20
  125. package/dist/meera.js.map +0 -1
  126. package/dist/naina.d.ts +0 -7
  127. package/dist/naina.d.ts.map +0 -1
  128. package/dist/naina.js +0 -20
  129. package/dist/naina.js.map +0 -1
  130. package/dist/professional-female-a.d.ts +0 -4
  131. package/dist/professional-female-a.d.ts.map +0 -1
  132. package/dist/professional-female-a.js +0 -3
  133. package/dist/professional-female-a.js.map +0 -1
  134. package/dist/professional-female-b.d.ts +0 -4
  135. package/dist/professional-female-b.d.ts.map +0 -1
  136. package/dist/professional-female-b.js +0 -3
  137. package/dist/professional-female-b.js.map +0 -1
  138. package/dist/professional-male-a.d.ts +0 -4
  139. package/dist/professional-male-a.d.ts.map +0 -1
  140. package/dist/professional-male-a.js +0 -3
  141. package/dist/professional-male-a.js.map +0 -1
  142. package/dist/professional-male-b.d.ts +0 -4
  143. package/dist/professional-male-b.d.ts.map +0 -1
  144. package/dist/professional-male-b.js +0 -3
  145. package/dist/professional-male-b.js.map +0 -1
  146. package/dist/three/tara-rig.d.ts +0 -423
  147. package/dist/three/tara-rig.d.ts.map +0 -1
  148. package/dist/three/tara-rig.js.map +0 -1
  149. package/dist/vikram.d.ts +0 -7
  150. package/dist/vikram.d.ts.map +0 -1
  151. package/dist/vikram.js +0 -20
  152. package/dist/vikram.js.map +0 -1
  153. package/src/canvas/author/parts/eye.mjs +0 -722
  154. package/src/canvas/author/parts/hand.mjs +0 -1156
  155. package/src/canvas/author/parts/mouth.mjs +0 -741
  156. package/src/canvas/author/parts/nose.mjs +0 -100
  157. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  158. package/src/canvas/author/path.mjs +0 -283
  159. package/src/canvas/author/rig.mjs +0 -405
  160. package/src/canvas/avatars/round/face.d.mts +0 -3
  161. package/src/canvas/avatars/round/face.mjs +0 -1307
  162. package/src/canvas/create-rig.d.ts +0 -15
  163. package/src/canvas/create-rig.js +0 -100
  164. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  165. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  166. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  167. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  168. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  169. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  170. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  171. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  172. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  173. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  174. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  175. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  176. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  177. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  178. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  179. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  180. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  181. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  182. package/src/canvas/data/interviewer-female.rig.json +0 -1
  183. package/src/canvas/data/interviewer-male.rig.json +0 -1
  184. package/src/canvas/data/professional-female-a.rig.json +0 -1
  185. package/src/canvas/data/professional-female-b.rig.json +0 -1
  186. package/src/canvas/data/professional-male-a.rig.json +0 -1
  187. package/src/canvas/data/professional-male-b.rig.json +0 -1
  188. package/src/canvas/src/live.js +0 -508
  189. package/src/canvas/src/render2d.js +0 -218
  190. package/src/canvas/src/rig.js +0 -297
  191. package/src/canvas/src/vocab.js +0 -96
package/src/avatar.js CHANGED
@@ -6,27 +6,11 @@
6
6
  * addressed by our own runtime, our tooling, and an avatar author who chose to
7
7
  * build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
8
8
  *
9
- * const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
10
- * avatar.setState('LISTENING', { emotion: 'warm' })
11
- * avatar.setGaze('SCREEN_LEFT')
12
- * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
13
- * avatar.pushCues(moreCues) // streaming top-up
14
- * avatar.action('ACK_RECEIVE')
15
- * avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
16
- * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
17
- * avatar.setUserSpeaking(bool) // the user has the floor, so listening
18
- * // is contingent instead of timed
19
- *
20
9
  * Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
21
10
  * by later ones on the channels they touch; the gesture and idle layers are
22
11
  * additive so they compose rather than fight.
23
12
  *
24
13
  * base pose (state + emotion) -> gaze -> visemes -> clip -> idle
25
- *
26
- * The one hard priority rule: while the server viseme track is playing, it owns
27
- * the mouth outright. An interjection firing mid-sentence contributes its head
28
- * and brows and its mouth track is dropped — otherwise the avatar would appear
29
- * to say two things at once.
30
14
  */
31
15
 
32
16
  import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
@@ -67,8 +51,7 @@ export const STATES = {
67
51
  // the attentive pose it looks like — it is a demand for more talk (Rossano)
68
52
  // and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
69
53
  // gaze.js for the numbers; the mixer holds it off near a turn boundary.
70
- // The blink timer runs slower than the ~17/min it lands on because a sixth
71
- // of timed blinks come as a pair; at 3.2-4.4 s it measured 20-23/min.
54
+ // At 3.2-4.4 s the blink timer measured 20-23/min.
72
55
  LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
73
56
  aversion: 'LISTEN',
74
57
  idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
@@ -91,8 +74,6 @@ export const STATES = {
91
74
  // is kept to a fifth of looks because on a face this real it reads as
92
75
  // downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
93
76
  // alert, and the two together measured past the 0.15 that reads drowsy.
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
77
  THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
97
78
  // Fixational jumps rare and small: a thinker's eyes rest
98
79
  // where they land. At the default gap the look jittered
@@ -117,26 +98,15 @@ export const STATES = {
117
98
  'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
118
99
  'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
119
100
  stick: 0.55, dart: { p: 0.4, mag: 0.08, brow: 0.12 }, blinkTo: true },
120
- // **The chin comes up, and that is the state's loudest
121
- // signal below the eyes.** Thinking is the one stretch
122
- // of a call where the avatar owes the user visible
123
- // feedback and has no mouth to give it with: the eyes
124
- // are off the user by design, so without the head there
125
- // is nothing left moving that says *working on it*
126
- // rather than *gone*. A head that tips back as the gaze
127
- // leaves is also what the research calls a swing-up —
128
- // "nodding with swinging up is regarded to reflect a
129
- // cognitive shift in the listener"
130
- // (research-biomechanics.md § 3.3) — so the same
131
- // gesture that means "ah, I see" at the end of a nod
132
- // means "let me think" at the start of a pause.
133
- //
134
- // It rides the looks that go up, as the head's share of
135
- // them, and not the state. Held as a pose it lifted the
136
- // chin on the level and downward looks too and on every
137
- // check-in, so the user was looked at down the nose, and
138
- // eyes drawn inside a head tipped back aimed every look
139
- // meant to be level at the ceiling.
101
+ // **The chin comes up**: thinking is the one stretch of
102
+ // a call where the avatar owes the user feedback and
103
+ // has no mouth to give it with, the eyes being off the
104
+ // user by design. A head tipping back as the gaze
105
+ // leaves is the swing-up of research-biomechanics.md
106
+ // § 3.3. It rides the looks that go up, as the head's
107
+ // *share* of them: held as a pose it lifted the chin on
108
+ // the level and downward looks too, so the user was
109
+ // looked at down the nose.
140
110
  pose: { lidL: -0.10, lidR: -0.10 } },
141
111
  // Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
142
112
  // speaker's measured looks away are not this rig's to render. What moves
@@ -151,13 +121,13 @@ export const STATES = {
151
121
  // The head cant is the state's signature cue, and it has to clear the roll
152
122
  // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
153
123
  // is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
154
- // size, still gentle. Every other channel in this pose read fine on screen.
124
+ // size, still gentle.
155
125
  WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
156
126
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
157
127
  pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
158
128
  // Straining to hear. The one state where the amplitude constraint yields,
159
- // because the lean IS the message: torsoLean well past LISTENING's
160
- // engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
129
+ // because the lean IS the message: torsoLean well past what the engagement
130
+ // layer ever spends, head cheated aside on USER_EAR so an ear
161
131
  // favors the speaker while the eyes hold contact, and a concentration
162
132
  // squint with knit brows. Stillness does the rest — straining people
163
133
  // freeze — so holds are frequent and there is no engagement lean: you don't
@@ -211,7 +181,7 @@ export const STATES = {
211
181
  },
212
182
  // --- application state ---------------------------------------------------
213
183
  // "Momentarily busy on the thing you asked for." No hands in frame, so the
214
- // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4,
184
+ // whole read comes from cheap cues (docs/research-biomechanics.md §6.4,
215
185
  // recommendation 19): gaze down on one stable target with a reading scan,
216
186
  // blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
217
187
  // and *held* with brief micro-freezes, and a glance back up to the user.
@@ -230,9 +200,8 @@ export const STATES = {
230
200
  // take back most of the down look's follow so the eyes stay awake.
231
201
  gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
232
202
  scan: [0.9, 2.0, 0.12],
233
- // §6.4's ~9/min is a count of blinks, and a sixth of timed blinks here
234
- // come as a pair, so the timer runs slower than the table's 6-7.5 s gap
235
- // to land on it; at 6-7.5 s it measured 12-13/min, which is not focus.
203
+ // §6.4's ~9/min is a count of blinks: at the table's own 6-7.5 s gap it
204
+ // measured 12-13/min, which is not focus.
236
205
  idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
237
206
  hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
238
207
  // The look up to check is brows-first and blinkless, the lids leading it
@@ -254,11 +223,9 @@ export const STATES = {
254
223
  browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
255
224
  },
256
225
  // The audio channel is broken and the agent is typing in the chat window to
257
- // communicate — TYPING's mechanics turned *communicative*. The glance is
258
- // the difference: TYPING checks in briefly (~0.8 s) and goes back to work;
259
- // this looks up and HOLDS 1.2–2 s, expectant, because the chat (and the
260
- // user's face) is now the only channel there is. A touch of browInner
261
- // carries the apology. Relation to DEGRADED is by semantics, not merger:
226
+ // communicate. The glance looks up and HOLDS 1.2–2 s, expectant, because the
227
+ // chat (and the user's face) is now the only channel there is. A touch of
228
+ // browInner carries the apology. Relation to DEGRADED is by semantics, not merger:
262
229
  // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
263
230
  // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
264
231
  TYPING_CHAT: {
@@ -273,7 +240,7 @@ export const STATES = {
273
240
  browInnerL: 0.45, browInnerR: 0.38,
274
241
  mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
275
242
  },
276
- // Attention genuinely elsewhere. What separates this from TYPING is target
243
+ // Attention genuinely elsewhere. What separates this from WORKING is target
277
244
  // *stability* (§6.4): busy is one steady off-user target, distracted is
278
245
  // wandering ones, held long (aversion >3s), with no engagement lean — the
279
246
  // missing nod is as diagnostic as the look-away. Sway is looser than
@@ -284,7 +251,7 @@ export const STATES = {
284
251
  idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
285
252
  // Sideways and up, never steep-down: lateral is where real intimacy/
286
253
  // distraction aversions live, and a steep down target seals this rig's
287
- // eyes (see TYPING).
254
+ // eyes (see WORKING).
288
255
  wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
289
256
  every: [2.8, 6.8] },
290
257
  },
@@ -321,7 +288,7 @@ export const STATES = {
321
288
  // condition and not an event — WANTS_IN in particular has to hold for as long
322
289
  // as it takes the other person to notice it.
323
290
  //
324
- // All three lift the shoulders and part the lips, because that is what an
291
+ // Each of these lifts the shoulders and parts the lips, because that is what an
325
292
  // inbreath looks like from outside, and an inbreath is the cue humans actually
326
293
  // use to predict that someone is about to speak. The head comes *up* rather
327
294
  // than down: a lowered head is deferential and reads as yielding.
@@ -403,9 +370,7 @@ export const STATE_NAMES = Object.keys(STATES);
403
370
  * figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
404
371
  * and read as a shrug arriving with the head.
405
372
  *
406
- * Exported for a page that drives a rig by hand and wants the body the shipping
407
- * mixer would have put under the tilt; the rig instruments in the working tree
408
- * are its only callers.
373
+ * Exported for the rig instruments.
409
374
  */
410
375
  export const SHOULDER_TILT = 0.08;
411
376
 
@@ -413,11 +378,9 @@ export function createAvatar(opts = {}) {
413
378
  const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
414
379
  if (!mount) throw new Error('createAvatar: mount element required');
415
380
 
416
- // `opts.face` is a Face record — `{ create, meta }`, one per face module. It
417
- // is passed in rather than named, because a name would need a table, and a
418
- // table would need every face imported to answer any lookup: three drawings
419
- // in every consumer's bundle to render one. `src/faces.js` still has that
420
- // table, for tooling that genuinely wants all of them.
381
+ // `opts.face` is a Face record — `{ create, meta }`. Passed in rather than
382
+ // named: a name needs a table, and a table imports every drawing to answer
383
+ // one lookup (`src/faces.js` has that table, for tooling that wants them).
421
384
  const entry = opts.rig ? null : opts.face;
422
385
  if (!opts.rig && !entry) {
423
386
  throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
@@ -445,9 +408,6 @@ export function createAvatar(opts = {}) {
445
408
  // control. `hand: false` only disables its SVG rendering; gesture actions
446
409
  // still emit the semantic hand frame for a supplied custom rig.
447
410
  const hand = face && opts.hand !== false ? createHand(face.svg, face.theme, meta, { dir: opts.handSide }) : null;
448
- // The existing SVG face and hand are one migration adapter implementing the
449
- // renderer-agnostic AvatarRig contract. New renderers never need face SVG
450
- // coordinates or the hand layer's private geometry.
451
411
  const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
452
412
 
453
413
  gaze.onLargeShift = (forced) => idle.blink(true, forced);
@@ -467,22 +427,19 @@ export function createAvatar(opts = {}) {
467
427
  let gazeName = 'USER';
468
428
  let gazeCustom = null;
469
429
  let overrides = null; // demo/debug direct param injection
470
- // Articulation gain. The per-cue `i` only ever attenuates (shapeFor maps it to
471
- // 0.45..1.0 of the table), so there was no way to ask for a *bigger* mouth than
472
- // VISEME_SHAPES describes. That table is tuned for a face at conversational
473
- // size; at avatar size, sharing the screen with live video, the same shapes
474
- // read as under-articulated. This scales every viseme away from rest, so the
475
- // shape identities and their relative sizes are preserved and only the
476
- // excursion changes. Values above ~1.5 saturate the open vowels against the
477
- // channel clamp, which is the intended ceiling rather than a bug.
430
+ // Articulation gain. VISEME_SHAPES is tuned for a face at conversational
431
+ // size; sharing the screen with live video the same shapes read as
432
+ // under-articulated. Scales every viseme away from rest, so shape identities
433
+ // and their relative sizes survive and only the excursion changes. Above
434
+ // ~1.5 the open vowels saturate against the clamp — the intended ceiling.
478
435
  let mouthGain = opts.mouthGain ?? 1;
479
436
  let handSide = opts.handSide === -1 ? 'left' : 'right';
480
437
  /**
481
- * This avatar's own addressable motions, on top of the two core intents.
438
+ * This avatar's own addressable motions, on top of the core intents.
482
439
  *
483
440
  * An avatar is a drawing with a body, and some bodies can do things the wire
484
- * has no portable word for. The three nod types the listening research
485
- * separates — a continuer, an assessment, a realisation — are one
441
+ * has no portable word for. The nod types the listening research separates —
442
+ * a continuer, an assessment, a realisation — are one
486
443
  * `ACKNOWLEDGE` to a server, because that is all a server can ask of every
487
444
  * face; the *shapes* are sized in a rig's own units and belong to the rig
488
445
  * (`packages/avatar/client/three/sequences.ts` is the first table of them).
@@ -514,13 +471,9 @@ export function createAvatar(opts = {}) {
514
471
  }
515
472
  /**
516
473
  * This avatar's own rendering of a state — the same door as `actions`, for
517
- * the held face instead of the gesture. A state's pose is authored where a
518
- * line face reads, and the cue that carries it there can say something else
519
- * on a photograph: WORKING's knit brows are what keep peep's reading face
520
- * from going blank, and on a real eye — already hooded by a lid following it
521
- * down to the screen — they finish a squint that reads as strain. A rig
522
- * replaces a state's fields whole (`pose`, say), and only for a state the
523
- * table already has: it can re-render the vocabulary, never extend it.
474
+ * the held face instead of the gesture. A rig replaces a state's fields
475
+ * whole (`pose`, say), and only for a state the table already has: it can
476
+ * re-render the vocabulary, never extend it.
524
477
  */
525
478
  const states = { ...STATES };
526
479
  for (const [id, own] of Object.entries(opts.states || {})) {
@@ -529,11 +482,8 @@ export function createAvatar(opts = {}) {
529
482
  }
530
483
  let handAction = null;
531
484
  const handQueue = [];
532
- // Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
533
- // peaks at 149ms — but the head smooths at a 160ms time constant, so barely
534
- // 60% of an authored peak is ever rendered. The keyframes were written against
535
- // the numbers, not against what comes out the other side, which is why small
536
- // gestures read as nothing at all.
485
+ // Gesture gain, same idea for the clip layer: small gestures under-render
486
+ // through the head's τ — see internal-mixer.md § Smoothing.
537
487
  let gestureGain = opts.gestureGain ?? 1;
538
488
  // Body-liveness gain. Constraint 8 (this widget shares the screen with a
539
489
  // live video call) argues for the smallest idle motion that still reads, and
@@ -554,28 +504,14 @@ export function createAvatar(opts = {}) {
554
504
  // Aversions get their own gain: a look-away must read as one from across
555
505
  // the call, where a fixation step must not, so one number cannot size both.
556
506
  const saccadeGain = opts.saccadeGain ?? 1;
557
- // How far this face may hold its head off centre, per axis, in pose units.
558
- // A pose unit is an angle on a mesh head and a pixel count on a drawing, and
559
- // what a 2.5-D face can hold before the photograph gives it away is a
560
- // measurement of that face and of nothing else — so the number is the rig's
561
- // to supply and is nowhere in this library. An axis left out is unbudgeted,
562
- // which is every SVG and Canvas face and is the behaviour they have always
563
- // had.
507
+ // How far this face may hold its head off centre, per axis (internal-mixer.md
508
+ // § The held-head budget): the rig supplies it; an axis left out is unbudgeted.
564
509
  const headHold = opts.headHold || {};
565
- // Per-axis gain on the head's *continuous* drive, in front of the budget.
566
- //
567
- // The two layers that hold a head somewhere — speech phrasing and idle —
568
- // are sized in pose units for a line drawing, and a 2.5-D face measured its
569
- // own limits afterwards. Measuring found the drive spending well under them:
570
- // a third of the pitch a speaking human uses and nearly twice the yaw, on a
571
- // face whose pitch budget has room for all of it. That is one number per
572
- // axis, not a rewrite of either layer, and it belongs here rather than in
573
- // the rig because it scales a *drive* — a rig scale would multiply the nods
574
- // and beats too, and those are authored at amplitudes that already read.
575
- //
576
- // Before 6b deliberately: the budget is what stops a scaled-up drive from
577
- // leaving the envelope, so a gain that skipped it would be measuring the
578
- // face's failure rather than the layer's range. A number, or one per axis.
510
+ // Per-axis gain on the head's *continuous* drive — speech phrasing and idle,
511
+ // both sized in pose units for a line drawing. Measuring a 2.5-D face found
512
+ // the drive spending a third of the pitch a speaking human uses and nearly
513
+ // twice the yaw. In front of 6b deliberately: a gain that skipped the budget
514
+ // would be measuring the face's failure rather than the layer's range.
579
515
  const headGain = typeof opts.headGain === 'number'
580
516
  ? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
581
517
  : { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
@@ -584,8 +520,7 @@ export function createAvatar(opts = {}) {
584
520
  // A rig that says what its pose units are in degrees gets the eye-head
585
521
  // system sized for it (gaze.js): its own look targets, how an aversion
586
522
  // splits between eyes and head, lids that follow the eye both ways, and the
587
- // reflex in step 8b. A face that does not say keeps the line-face behaviour
588
- // exactly.
523
+ // reflex in step 8b.
589
524
  const ocu = opts.oculomotor || {};
590
525
  if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
591
526
  if (ocu.avert) gaze.avertSplit = ocu.avert;
@@ -601,13 +536,8 @@ export function createAvatar(opts = {}) {
601
536
  ? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
602
537
  y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
603
538
  : null;
604
- // How far the reflex may carry the eye in its socket, in pupil units. A
605
- // person looking 8° up does not roll the eyes to the lid and wait for the
606
- // head: the eye stops well short of its mechanical limit and the head makes
607
- // up the rest (Guitton & Volle's effective oculomotor range). On a
608
- // photographic eye an iris pinned under the upper lid with white showing
609
- // beneath it reads as an eye-roll, not a thought. Down is looser — the lid
610
- // follows the eye down and hides the sclera there.
539
+ // How far the reflex may carry the eye in its socket, in pupil units:
540
+ // Guitton & Volle's effective oculomotor range; the rig supplies the reach.
611
541
  const reach = ocu.range || { x: 1, up: 1, down: 1 };
612
542
  const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
613
543
  const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
@@ -624,8 +554,6 @@ export function createAvatar(opts = {}) {
624
554
  // how long the brows stay up for it.
625
555
  let dartAt = 0, dartBrowUntil = 0;
626
556
  const dart = { x: 0, y: 0 };
627
- // THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
628
- // when that handoff happened (it stands in for the turn-start look).
629
557
  // The state whose gaze is showing, and when the current state takes it
630
558
  // over if that is still pending (GAP_SETTLE). Usually the same state.
631
559
  let gazeState = 'IDLE';
@@ -638,7 +566,7 @@ export function createAvatar(opts = {}) {
638
566
 
639
567
  const cur = Object.assign({}, REST);
640
568
  const target = Object.assign({}, REST);
641
- // The three head axes again, carrying only what is held (step 6b).
569
+ // The head axes again, carrying only what is held (step 6b).
642
570
  const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
643
571
  // What the rig is handed: `cur` with the reflex applied to the eyes. The
644
572
  // same object when there is no reflex.
@@ -653,11 +581,7 @@ export function createAvatar(opts = {}) {
653
581
  let raf = 0;
654
582
  let last = 0;
655
583
  let elapsed = 0;
656
- // `manual` withholds the rAF loop so a tool can drive frames itself. The
657
- // baseline pages could already step a ClipPlayer by hand, but nothing could
658
- // step the *mixer* — which is where idle, gaze and engagement actually
659
- // compose — so motion had no reproducible render. The headless motion map in
660
- // the working tree is what steps it.
584
+ // `manual` withholds the rAF loop so a tool can drive frames itself.
661
585
  const manual = !!opts.manual;
662
586
 
663
587
  function frame(now) {
@@ -716,11 +640,11 @@ export function createAvatar(opts = {}) {
716
640
  // clip layer, on purpose: a sustained turn toward the screen recruits
717
641
  // the trunk, and a nod or a head shake does not — a body that swings
718
642
  // with every gesture reads as a mannequin on a turntable. The lag is
719
- // not authored anywhere; torsoTurn simply chases the same target at
720
- // nearly 3x the head's time constant (TAU in params.js), so the trunk
721
- // leaves late and settles late for free. It follows where the head is
722
- // going and not the looks riding on it, and it holds through a glance:
723
- // checking on the user is a look, not a turn toward them.
643
+ // not authored anywhere; torsoTurn chases the same target at a slower
644
+ // TAU than the head (params.js), so the trunk leaves late and settles
645
+ // late for free, at the share TRUNK_FOLLOW names. It follows where the
646
+ // head is going and not the looks riding on it, and it holds through a
647
+ // glance: checking on the user is a look, not a turn toward them.
724
648
  if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
725
649
  target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
726
650
 
@@ -839,6 +763,8 @@ export function createAvatar(opts = {}) {
839
763
  const shape = mouth.letter !== SILENT
840
764
  ? shapeFor(mouth.letter, mouth.intensity)
841
765
  : shapeFor(SILENT, 1);
766
+ // Rounding coming up is held against the shape's own (VisemeTrack.sample).
767
+ if (mouth.round > shape.mouthRound) shape.mouthRound = mouth.round;
842
768
  // Gain pivots on the rest shape, not on zero: scaling absolute values would
843
769
  // drag the closed mouth open, which is the one thing lipsync must never do.
844
770
  for (const k in shape) {
@@ -846,11 +772,6 @@ export function createAvatar(opts = {}) {
846
772
  ? shape[k]
847
773
  : REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
848
774
  }
849
- // A smile held static through a sentence is discounted as insincere, and
850
- // corners riding every open viseme read as laughing through the words
851
- // (research-perception.md §3: warmth must be episodic). While the mouth
852
- // is genuinely speech-driven the BASE smile decays to a fraction of
853
- // itself; the smile channels' 130ms tau turns the gate into an ease.
854
775
  // Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
855
776
  // and only the base is scaled, so a gesture clip can still smile over a
856
777
  // sentence by authoring corner keys (they add, unscaled, in step 5).
@@ -930,11 +851,9 @@ export function createAvatar(opts = {}) {
930
851
  for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
931
852
  for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
932
853
 
933
- // 6b. the held-head budget. Every layer above holds its own small pose and
934
- // they are independent, so now and then they all point the same way and
935
- // the head arrives somewhere no one layer asked for and the face cannot
936
- // go. Only the excess over `soften` comes off, and only off the hold:
937
- // a nod, a beat and a clip keep every degree they were authored with,
854
+ // 6b. the held-head budget (internal-mixer.md § The held-head budget).
855
+ // Only the excess over `soften` comes off, and only off the hold: a
856
+ // nod, a beat and a clip keep every degree they were authored with,
938
857
  // which is why this is subtracted here rather than applied to the pose.
939
858
  for (const c of HEAD_AXES) {
940
859
  if (headHold[c] === undefined) continue;
@@ -944,10 +863,10 @@ export function createAvatar(opts = {}) {
944
863
  // 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
945
864
  // same share of it that it takes of a turn in 2b — Live2D gives its
946
865
  // body the same fraction of AngleZ as of AngleX (research-head-rotation
947
- // .md §3) — and the shoulder line tips with the head. Both channels are
948
- // slower than the head (TAU: 0.44 and 0.19 against 0.16), so the body
949
- // leaves late and settles late, and that follow-through is most of what
950
- // separates a neck bending from a hinge.
866
+ // .md §3) — and the shoulder line tips with the head. Both channels
867
+ // smooth slower than the head (TAU), so the body leaves late and
868
+ // settles late, and that follow-through is most of what separates a
869
+ // neck bending from a hinge.
951
870
  // Only the *held* roll, and after the budget: a stroke or a clip's roll
952
871
  // is a gesture riding on the pose, and a body that answers those is 2b's
953
872
  // mannequin on a turntable. The idle layer's own posture is already
@@ -977,17 +896,9 @@ export function createAvatar(opts = {}) {
977
896
  // 8. smooth toward the target — this is where co-articulation happens
978
897
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
979
898
 
980
- // 8b. The vestibulo-ocular reflex. Eyes held still in a moving head look
981
- // wherever the head points, so without this every nod, speech pose and
982
- // sway was also a small look somewhere else — and on a face whose head
983
- // turns further than its eyes, a look at a different spot each time.
984
- // Real eyes counter-rotate against the head within ~10 ms and stay on
985
- // what they look at. Here and not in the gaze layer because only here
986
- // is the head that is actually drawn known: prosody, clips and idle all
987
- // land after gaze. It also gives a large shift its real shape for free:
988
- // the eyes jump to the target, past where they will sit, and roll back
989
- // in the head as it arrives under them. A pupil a tuning UI overrides is
990
- // left where it was put.
899
+ // 8b. The vestibulo-ocular reflex. Here and not in the gaze layer because
900
+ // only here is the head that is actually drawn known: prosody, clips
901
+ // and idle all land after gaze.
991
902
  if (vor) {
992
903
  Object.assign(shown, cur);
993
904
  if (!overrides || overrides.pupilX === undefined) {
@@ -1020,14 +931,16 @@ export function createAvatar(opts = {}) {
1020
931
  // (mouth corners stay free: a clip may smile over a sentence).
1021
932
  const MOUTH_LOCK = new Set(GROUPS.mouth);
1022
933
 
934
+ /** The shoulders' share of the attentive posture: two thirds of the lean the
935
+ * engagement layer spends — the shoulders come up with it, they do not lead it. */
936
+ const ENGAGE_SHOULDER = 0.10;
937
+
1023
938
  // What survives of the resting/emotion smile while speech owns the mouth.
1024
- // ~a third keeps the face warm without the corners fighting the visemes;
1025
- // full warmth returns the moment the track ends, which is exactly the
1026
- // episodic onset/offset a credible smile needs (research-perception.md §3).
1027
- /** The shoulders' share of the attentive posture, against `torsoLean`'s 0.16.
1028
- * Two thirds of the lean: the shoulders come up with it, they do not lead it. */
1029
- const ENGAGE_SHOULDER = 0.10;
1030
- const SPEAK_SMILE_RETAIN = 0.35;
939
+ // A smile held static through a sentence is discounted as insincere, and
940
+ // corners riding every open viseme read as laughing through the words: warmth
941
+ // must be episodic (research-perception.md §3). Full warmth returns the moment
942
+ // the track ends, which is that onset/offset.
943
+ const SPEAK_SMILE_RETAIN = 0.35;
1031
944
 
1032
945
  // Between the user's turn and the reply the server's claim can change
1033
946
  // several times a second — THINKING, a tool's WORKING, THINKING again, a
@@ -1135,17 +1048,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1135
1048
  gazeState = name;
1136
1049
  const st = states[name];
1137
1050
  const gl = st.glance;
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
- //
1149
1051
  // A state with an `opening` enters as though its check-in on the user is
1150
1052
  // already under way, and leaves it when that runs out.
1151
1053
  if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
@@ -1195,17 +1097,11 @@ const SPEAK_SMILE_RETAIN = 0.35;
1195
1097
  : () => performance.now() - speakStart;
1196
1098
  speech.start(o.cues || [], speakClock);
1197
1099
  prosody.reset(newTurn);
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.
1100
+ // The eyes come back to the user when the audio starts: a reply arriving
1101
+ // while THINKING was looking away would otherwise spend its whole turn
1102
+ // aimed off the user, because nothing in SPEAKING retargets. `keepGaze` is
1103
+ // for the one caller that means it — a performance that aimed the eyes with
1104
+ // its own `gaze` verb keeps them, an instruction and not a leftover.
1209
1105
  if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
1210
1106
  if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
1211
1107
  return api;
@@ -1217,25 +1113,14 @@ const SPEAK_SMILE_RETAIN = 0.35;
1217
1113
  * This is the widget's half of the **gaze window**. In face-to-face talk a
1218
1114
  * speaker periodically looks at the listener, mutual gaze is established, the
1219
1115
  * listener responds inside that window, and the speaker looks away again
1220
- * (Bavelas, Coates & Johnson 2002) — listener responses cluster inside the
1221
- * window rather than being scattered across the turn.
1116
+ * (Bavelas, Coates & Johnson 2002).
1222
1117
  *
1223
1118
  * We cannot see the user, so we cannot observe the window opening. What a
1224
1119
  * caller *can* do is name the moments that co-occur with it — a mid-turn
1225
1120
  * pause, a tag question ("...right?", "you know?"), a completed clause with
1226
1121
  * the turn analyzer's completion probability high, the user answering a
1227
- * question the bot asked. `attend(ms)` is how those arrive: for its duration
1228
- * the face stops averting and holds the user, which is the prerequisite for
1229
- * any response to be *seen*. Emitting the response itself stays a separate
1230
- * call — a window that opens and draws nothing is a real and common outcome
1231
- * (with every measured invitation cue present, humans respond to only ~30% of
1232
- * opportunities), and conflating the two would make the avatar answer
1233
- * everything. The explicit response remains a backend/application decision.
1234
- *
1235
- * Deliberately **not on the wire yet**: there is no `attend` command in
1236
- * `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
1237
- * the rig pages). Adding the command is a protocol change and waits for a
1238
- * server that has something real to key it off — see docs/internal-mixer.md.
1122
+ * question the bot asked. For `ms` the face stops averting and holds the
1123
+ * user, which is the prerequisite for any response to be *seen*.
1239
1124
  *
1240
1125
  * @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
1241
1126
  * mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
@@ -1312,7 +1197,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1312
1197
  const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
1313
1198
  if (!faceClip) return api;
1314
1199
  clip.play(faceClip, faceClip.audioEl, { queue: true });
1315
- // The two acknowledgements smile. Only these, and only ever because the
1200
+ // The acknowledgements smile. Only these, and only ever because the
1316
1201
  // server sent one: a smile the renderer timed for itself would be an
1317
1202
  // acknowledgement nobody sent.
1318
1203
  if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
@@ -1347,11 +1232,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1347
1232
  return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
1348
1233
  }
1349
1234
 
1350
- /**
1351
- * Tell the listening engine whether Pipecat VAD says the user holds the
1352
- * floor. This changes only sustained engagement posture; it can never create
1353
- * a nod or acknowledgement clip.
1354
- */
1355
1235
  function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
1356
1236
 
1357
1237
  // What one action does when its moment comes. Enum validity is checked here,
@@ -1374,15 +1254,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1374
1254
  let performGen = 0;
1375
1255
 
1376
1256
  /**
1377
- * Play a timed action track — the composition surface a server assembles
1378
- * turns from. Verbs: state / emotion / gaze / action (see
1379
- * perform.js for hygiene, docs/internal-mixer.md for the schema).
1380
- *
1381
- * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
1382
- * element's own time, else ms elapsed since this call. perform() never
1383
- * starts or stops audio — speak() owns the sound; this owns the choreography
1384
- * that rides it.
1385
- *
1386
1257
  * @param {Array<{t: number, do: string}>} actions
1387
1258
  * @param {{audio?: HTMLMediaElement, clock?: () => number,
1388
1259
  * onAction?: (a: object) => void}} [o]
@@ -1490,7 +1361,7 @@ export { EMOTION_NAMES, emotionPose } from './emotions.js';
1490
1361
  // exactly that, and none of them should write it twice — so it is a plain class
1491
1362
  // to construct, not a contract to implement.
1492
1363
  export {
1493
- VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
1364
+ VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, JAW_OF_OPEN, SILENT,
1494
1365
  normalizeCues, textToCues,
1495
1366
  ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
1496
1367
  } from './visemes.js';
package/src/gaze.js CHANGED
@@ -187,9 +187,7 @@ function chase(pos, vel, gx, gy, dt, accel, speed) {
187
187
  * costs η²ₚ = .52–.56 with post-hoc d of 1.09–2.69, with partial inattention
188
188
  * costing most of what total inattention costs. So this is not "look away
189
189
  * sometimes" — it is a narrow band, and the numbers below are Andrist's measured
190
- * ones rather than a taste call (docs/research-biomechanics.md §4.2). The spike
191
- * that arrived at this band, and what it measured on peep, is
192
- * docs/research-active-listening.md.
190
+ * ones rather than a taste call (docs/research-biomechanics.md §4.2).
193
191
  *
194
192
  * `every`/`dur` are seconds. `dirs` are unit-ish directions weighted by how
195
193
  * often each is taken; magnitude is scaled per-fire.
package/src/idle.js CHANGED
@@ -77,6 +77,14 @@ const rand = ([a, b]) => a + Math.random() * (b - a);
77
77
  // is two large gaze shifts a second apart and carries one blink, not two; and
78
78
  // a state change that lands on a gaze shift is one event to the eyelids.
79
79
  const BLINK_REFRACTORY = 1.5;
80
+ // How long an ordinary blink takes, open to open, in seconds. Centred on the
81
+ // 208 ms "short" blink Hömke et al. gave their avatar listener
82
+ // (research-biomechanics.md §3.6) — the one their listeners read as nothing
83
+ // but a blink. It was 0.11–0.15 s, and in a recorded call on 2026-09-24 a
84
+ // photographic lid covered that in two or three frames: open, shut, open, a
85
+ // pop rather than a lid travelling, which is the flicker a reviewer calls
86
+ // ghosting. A line face swallowed it; a photograph shows every frame.
87
+ const BLINK_DUR = [0.19, 0.23];
80
88
 
81
89
  // How far into the current gap an odds-rolled evoked blink may land, as a
82
90
  // fraction of that gap. A state that moves its gaze oftener than it blinks —
@@ -226,7 +234,7 @@ export class IdleLayer {
226
234
  this.talk = 0;
227
235
  this._nextBlink = 2 + Math.random() * 3;
228
236
  this._blinkT = -1;
229
- this._blinkDur = 0.13;
237
+ this._blinkDur = BLINK_DUR[0];
230
238
  this._lastBlinkAt = -Infinity;
231
239
  // Two incommensurate frequencies per axis so the sway never visibly loops.
232
240
  this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
@@ -318,7 +326,7 @@ export class IdleLayer {
318
326
  const gap = this._nextBlink - this._lastBlinkAt;
319
327
  if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
320
328
  }
321
- this._startBlink(0.11 + Math.random() * 0.04);
329
+ this._startBlink(rand(BLINK_DUR));
322
330
  }
323
331
 
324
332
  _startBlink(dur) {