@voqalize/avatar 0.4.2 → 0.4.4

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} +708 -547
  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} +664 -525
  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 +99 -220
  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 +28 -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';
@@ -36,6 +20,7 @@ import { IdleLayer, ListeningEngine } from './idle.js';
36
20
  import { ClipPlayer } from './clips.js';
37
21
  import { ACTIONS, INTERNAL_CLIPS } from './interjections.js';
38
22
  import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
23
+ import { LIP_CONTACT_AIM } from './speech-timing.js';
39
24
  import { SpeechProsody, UNCALIBRATED_HEAD_GAIN } from './prosody.js';
40
25
  import { HEAD_AXES, soften } from './head.js';
41
26
  import { PerformTrack } from './perform.js';
@@ -67,8 +52,7 @@ export const STATES = {
67
52
  // the attentive pose it looks like — it is a demand for more talk (Rossano)
68
53
  // and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
69
54
  // 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.
55
+ // At 3.2-4.4 s the blink timer measured 20-23/min.
72
56
  LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
73
57
  aversion: 'LISTEN',
74
58
  idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
@@ -91,8 +75,6 @@ export const STATES = {
91
75
  // is kept to a fifth of looks because on a face this real it reads as
92
76
  // downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
93
77
  // 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
78
  THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
97
79
  // Fixational jumps rare and small: a thinker's eyes rest
98
80
  // where they land. At the default gap the look jittered
@@ -117,26 +99,15 @@ export const STATES = {
117
99
  'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
118
100
  'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
119
101
  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.
102
+ // **The chin comes up**: thinking is the one stretch of
103
+ // a call where the avatar owes the user feedback and
104
+ // has no mouth to give it with, the eyes being off the
105
+ // user by design. A head tipping back as the gaze
106
+ // leaves is the swing-up of research-biomechanics.md
107
+ // § 3.3. It rides the looks that go up, as the head's
108
+ // *share* of them: held as a pose it lifted the chin on
109
+ // the level and downward looks too, so the user was
110
+ // looked at down the nose.
140
111
  pose: { lidL: -0.10, lidR: -0.10 } },
141
112
  // Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
142
113
  // speaker's measured looks away are not this rig's to render. What moves
@@ -151,13 +122,13 @@ export const STATES = {
151
122
  // The head cant is the state's signature cue, and it has to clear the roll
152
123
  // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
153
124
  // 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.
125
+ // size, still gentle.
155
126
  WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
156
127
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
157
128
  pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
158
129
  // 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
130
+ // because the lean IS the message: torsoLean well past what the engagement
131
+ // layer ever spends, head cheated aside on USER_EAR so an ear
161
132
  // favors the speaker while the eyes hold contact, and a concentration
162
133
  // squint with knit brows. Stillness does the rest — straining people
163
134
  // freeze — so holds are frequent and there is no engagement lean: you don't
@@ -211,7 +182,7 @@ export const STATES = {
211
182
  },
212
183
  // --- application state ---------------------------------------------------
213
184
  // "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,
185
+ // whole read comes from cheap cues (docs/research-biomechanics.md §6.4,
215
186
  // recommendation 19): gaze down on one stable target with a reading scan,
216
187
  // blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
217
188
  // and *held* with brief micro-freezes, and a glance back up to the user.
@@ -230,9 +201,8 @@ export const STATES = {
230
201
  // take back most of the down look's follow so the eyes stay awake.
231
202
  gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
232
203
  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.
204
+ // §6.4's ~9/min is a count of blinks: at the table's own 6-7.5 s gap it
205
+ // measured 12-13/min, which is not focus.
236
206
  idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
237
207
  hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
238
208
  // The look up to check is brows-first and blinkless, the lids leading it
@@ -254,11 +224,9 @@ export const STATES = {
254
224
  browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
255
225
  },
256
226
  // 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:
227
+ // communicate. The glance looks up and HOLDS 1.2–2 s, expectant, because the
228
+ // chat (and the user's face) is now the only channel there is. A touch of
229
+ // browInner carries the apology. Relation to DEGRADED is by semantics, not merger:
262
230
  // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
263
231
  // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
264
232
  TYPING_CHAT: {
@@ -273,7 +241,7 @@ export const STATES = {
273
241
  browInnerL: 0.45, browInnerR: 0.38,
274
242
  mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
275
243
  },
276
- // Attention genuinely elsewhere. What separates this from TYPING is target
244
+ // Attention genuinely elsewhere. What separates this from WORKING is target
277
245
  // *stability* (§6.4): busy is one steady off-user target, distracted is
278
246
  // wandering ones, held long (aversion >3s), with no engagement lean — the
279
247
  // missing nod is as diagnostic as the look-away. Sway is looser than
@@ -284,7 +252,7 @@ export const STATES = {
284
252
  idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
285
253
  // Sideways and up, never steep-down: lateral is where real intimacy/
286
254
  // distraction aversions live, and a steep down target seals this rig's
287
- // eyes (see TYPING).
255
+ // eyes (see WORKING).
288
256
  wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
289
257
  every: [2.8, 6.8] },
290
258
  },
@@ -321,7 +289,7 @@ export const STATES = {
321
289
  // condition and not an event — WANTS_IN in particular has to hold for as long
322
290
  // as it takes the other person to notice it.
323
291
  //
324
- // All three lift the shoulders and part the lips, because that is what an
292
+ // Each of these lifts the shoulders and parts the lips, because that is what an
325
293
  // inbreath looks like from outside, and an inbreath is the cue humans actually
326
294
  // use to predict that someone is about to speak. The head comes *up* rather
327
295
  // than down: a lowered head is deferential and reads as yielding.
@@ -403,9 +371,7 @@ export const STATE_NAMES = Object.keys(STATES);
403
371
  * figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
404
372
  * and read as a shrug arriving with the head.
405
373
  *
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.
374
+ * Exported for the rig instruments.
409
375
  */
410
376
  export const SHOULDER_TILT = 0.08;
411
377
 
@@ -413,11 +379,9 @@ export function createAvatar(opts = {}) {
413
379
  const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
414
380
  if (!mount) throw new Error('createAvatar: mount element required');
415
381
 
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.
382
+ // `opts.face` is a Face record — `{ create, meta }`. Passed in rather than
383
+ // named: a name needs a table, and a table imports every drawing to answer
384
+ // one lookup (`src/faces.js` has that table, for tooling that wants them).
421
385
  const entry = opts.rig ? null : opts.face;
422
386
  if (!opts.rig && !entry) {
423
387
  throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
@@ -445,9 +409,6 @@ export function createAvatar(opts = {}) {
445
409
  // control. `hand: false` only disables its SVG rendering; gesture actions
446
410
  // still emit the semantic hand frame for a supplied custom rig.
447
411
  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
412
  const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
452
413
 
453
414
  gaze.onLargeShift = (forced) => idle.blink(true, forced);
@@ -467,22 +428,19 @@ export function createAvatar(opts = {}) {
467
428
  let gazeName = 'USER';
468
429
  let gazeCustom = null;
469
430
  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.
431
+ // Articulation gain. VISEME_SHAPES is tuned for a face at conversational
432
+ // size; sharing the screen with live video the same shapes read as
433
+ // under-articulated. Scales every viseme away from rest, so shape identities
434
+ // and their relative sizes survive and only the excursion changes. Above
435
+ // ~1.5 the open vowels saturate against the clamp — the intended ceiling.
478
436
  let mouthGain = opts.mouthGain ?? 1;
479
437
  let handSide = opts.handSide === -1 ? 'left' : 'right';
480
438
  /**
481
- * This avatar's own addressable motions, on top of the two core intents.
439
+ * This avatar's own addressable motions, on top of the core intents.
482
440
  *
483
441
  * 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
442
+ * has no portable word for. The nod types the listening research separates —
443
+ * a continuer, an assessment, a realisation — are one
486
444
  * `ACKNOWLEDGE` to a server, because that is all a server can ask of every
487
445
  * face; the *shapes* are sized in a rig's own units and belong to the rig
488
446
  * (`packages/avatar/client/three/sequences.ts` is the first table of them).
@@ -514,13 +472,9 @@ export function createAvatar(opts = {}) {
514
472
  }
515
473
  /**
516
474
  * 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.
475
+ * the held face instead of the gesture. A rig replaces a state's fields
476
+ * whole (`pose`, say), and only for a state the table already has: it can
477
+ * re-render the vocabulary, never extend it.
524
478
  */
525
479
  const states = { ...STATES };
526
480
  for (const [id, own] of Object.entries(opts.states || {})) {
@@ -529,11 +483,8 @@ export function createAvatar(opts = {}) {
529
483
  }
530
484
  let handAction = null;
531
485
  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.
486
+ // Gesture gain, same idea for the clip layer: small gestures under-render
487
+ // through the head's τ — see internal-mixer.md § Smoothing.
537
488
  let gestureGain = opts.gestureGain ?? 1;
538
489
  // Body-liveness gain. Constraint 8 (this widget shares the screen with a
539
490
  // live video call) argues for the smallest idle motion that still reads, and
@@ -554,28 +505,14 @@ export function createAvatar(opts = {}) {
554
505
  // Aversions get their own gain: a look-away must read as one from across
555
506
  // the call, where a fixation step must not, so one number cannot size both.
556
507
  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.
508
+ // How far this face may hold its head off centre, per axis (internal-mixer.md
509
+ // § The held-head budget): the rig supplies it; an axis left out is unbudgeted.
564
510
  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.
511
+ // Per-axis gain on the head's *continuous* drive — speech phrasing and idle,
512
+ // both sized in pose units for a line drawing. Measuring a 2.5-D face found
513
+ // the drive spending a third of the pitch a speaking human uses and nearly
514
+ // twice the yaw. In front of 6b deliberately: a gain that skipped the budget
515
+ // would be measuring the face's failure rather than the layer's range.
579
516
  const headGain = typeof opts.headGain === 'number'
580
517
  ? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
581
518
  : { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
@@ -584,8 +521,7 @@ export function createAvatar(opts = {}) {
584
521
  // A rig that says what its pose units are in degrees gets the eye-head
585
522
  // system sized for it (gaze.js): its own look targets, how an aversion
586
523
  // 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.
524
+ // reflex in step 8b.
589
525
  const ocu = opts.oculomotor || {};
590
526
  if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
591
527
  if (ocu.avert) gaze.avertSplit = ocu.avert;
@@ -601,13 +537,8 @@ export function createAvatar(opts = {}) {
601
537
  ? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
602
538
  y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
603
539
  : 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.
540
+ // How far the reflex may carry the eye in its socket, in pupil units:
541
+ // Guitton & Volle's effective oculomotor range; the rig supplies the reach.
611
542
  const reach = ocu.range || { x: 1, up: 1, down: 1 };
612
543
  const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
613
544
  const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
@@ -624,8 +555,6 @@ export function createAvatar(opts = {}) {
624
555
  // how long the brows stay up for it.
625
556
  let dartAt = 0, dartBrowUntil = 0;
626
557
  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
558
  // The state whose gaze is showing, and when the current state takes it
630
559
  // over if that is still pending (GAP_SETTLE). Usually the same state.
631
560
  let gazeState = 'IDLE';
@@ -638,11 +567,11 @@ export function createAvatar(opts = {}) {
638
567
 
639
568
  const cur = Object.assign({}, REST);
640
569
  const target = Object.assign({}, REST);
641
- // The three head axes again, carrying only what is held (step 6b).
570
+ // The head axes again, carrying only what is held (step 6b).
642
571
  const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
643
- // What the rig is handed: `cur` with the reflex applied to the eyes. The
644
- // same object when there is no reflex.
645
- const shown = vor ? Object.assign({}, REST) : cur;
572
+ // What the rig is handed: `cur` with the reflex applied to the eyes and the
573
+ // lips stopped at shut.
574
+ const shown = Object.assign({}, REST);
646
575
 
647
576
  function applyGaze(blink) {
648
577
  const g = gazeOverrideByClip || gazeName;
@@ -653,11 +582,7 @@ export function createAvatar(opts = {}) {
653
582
  let raf = 0;
654
583
  let last = 0;
655
584
  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.
585
+ // `manual` withholds the rAF loop so a tool can drive frames itself.
661
586
  const manual = !!opts.manual;
662
587
 
663
588
  function frame(now) {
@@ -716,11 +641,11 @@ export function createAvatar(opts = {}) {
716
641
  // clip layer, on purpose: a sustained turn toward the screen recruits
717
642
  // the trunk, and a nod or a head shake does not — a body that swings
718
643
  // 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.
644
+ // not authored anywhere; torsoTurn chases the same target at a slower
645
+ // TAU than the head (params.js), so the trunk leaves late and settles
646
+ // late for free, at the share TRUNK_FOLLOW names. It follows where the
647
+ // head is going and not the looks riding on it, and it holds through a
648
+ // glance: checking on the user is a look, not a turn toward them.
724
649
  if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
725
650
  target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
726
651
 
@@ -839,6 +764,8 @@ export function createAvatar(opts = {}) {
839
764
  const shape = mouth.letter !== SILENT
840
765
  ? shapeFor(mouth.letter, mouth.intensity)
841
766
  : shapeFor(SILENT, 1);
767
+ // Rounding coming up is held against the shape's own (VisemeTrack.sample).
768
+ if (mouth.round > shape.mouthRound) shape.mouthRound = mouth.round;
842
769
  // Gain pivots on the rest shape, not on zero: scaling absolute values would
843
770
  // drag the closed mouth open, which is the one thing lipsync must never do.
844
771
  for (const k in shape) {
@@ -846,11 +773,6 @@ export function createAvatar(opts = {}) {
846
773
  ? shape[k]
847
774
  : REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
848
775
  }
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
776
  // Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
855
777
  // and only the base is scaled, so a gesture clip can still smile over a
856
778
  // sentence by authoring corner keys (they add, unscaled, in step 5).
@@ -930,11 +852,9 @@ export function createAvatar(opts = {}) {
930
852
  for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
931
853
  for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
932
854
 
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,
855
+ // 6b. the held-head budget (internal-mixer.md § The held-head budget).
856
+ // Only the excess over `soften` comes off, and only off the hold: a
857
+ // nod, a beat and a clip keep every degree they were authored with,
938
858
  // which is why this is subtracted here rather than applied to the pose.
939
859
  for (const c of HEAD_AXES) {
940
860
  if (headHold[c] === undefined) continue;
@@ -944,10 +864,10 @@ export function createAvatar(opts = {}) {
944
864
  // 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
945
865
  // same share of it that it takes of a turn in 2b — Live2D gives its
946
866
  // 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.
867
+ // .md §3) — and the shoulder line tips with the head. Both channels
868
+ // smooth slower than the head (TAU), so the body leaves late and
869
+ // settles late, and that follow-through is most of what separates a
870
+ // neck bending from a hinge.
951
871
  // Only the *held* roll, and after the budget: a stroke or a clip's roll
952
872
  // is a gesture riding on the pose, and a body that answers those is 2b's
953
873
  // mannequin on a turntable. The idle layer's own posture is already
@@ -967,6 +887,10 @@ export function createAvatar(opts = {}) {
967
887
  const r = RANGE[c];
968
888
  target[c] = clamp(target[c], r[0], r[1]);
969
889
  }
890
+ // 7a. a bilabial aims past shut (`LIP_CONTACT_AIM`), after the clamp
891
+ // because it is the one target no pose is allowed to hold: only the
892
+ // chase sees it, and step 8b stops what is drawn at shut.
893
+ if (mouth && mouth.letter === 'A') target.mouthOpen = -LIP_CONTACT_AIM;
970
894
  if (il.blink > 0) {
971
895
  target.lidL = Math.max(target.lidL, il.blink);
972
896
  target.lidR = Math.max(target.lidR, il.blink);
@@ -977,19 +901,14 @@ export function createAvatar(opts = {}) {
977
901
  // 8. smooth toward the target — this is where co-articulation happens
978
902
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
979
903
 
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.
904
+ // 8b. What is drawn: the lips stop at shut however far past it they aimed.
905
+ Object.assign(shown, cur);
906
+ if (shown.mouthOpen < 0) shown.mouthOpen = 0;
907
+
908
+ // 8c. The vestibulo-ocular reflex. Here and not in the gaze layer because
909
+ // only here is the head that is actually drawn known: prosody, clips
910
+ // and idle all land after gaze.
991
911
  if (vor) {
992
- Object.assign(shown, cur);
993
912
  if (!overrides || overrides.pupilX === undefined) {
994
913
  shown.pupilX = clamp(reflexX(cur.pupilX), RANGE.pupilX[0], RANGE.pupilX[1]);
995
914
  }
@@ -1020,14 +939,16 @@ export function createAvatar(opts = {}) {
1020
939
  // (mouth corners stay free: a clip may smile over a sentence).
1021
940
  const MOUTH_LOCK = new Set(GROUPS.mouth);
1022
941
 
942
+ /** The shoulders' share of the attentive posture: two thirds of the lean the
943
+ * engagement layer spends — the shoulders come up with it, they do not lead it. */
944
+ const ENGAGE_SHOULDER = 0.10;
945
+
1023
946
  // 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;
947
+ // A smile held static through a sentence is discounted as insincere, and
948
+ // corners riding every open viseme read as laughing through the words: warmth
949
+ // must be episodic (research-perception.md §3). Full warmth returns the moment
950
+ // the track ends, which is that onset/offset.
951
+ const SPEAK_SMILE_RETAIN = 0.35;
1031
952
 
1032
953
  // Between the user's turn and the reply the server's claim can change
1033
954
  // several times a second — THINKING, a tool's WORKING, THINKING again, a
@@ -1135,17 +1056,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1135
1056
  gazeState = name;
1136
1057
  const st = states[name];
1137
1058
  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
1059
  // A state with an `opening` enters as though its check-in on the user is
1150
1060
  // already under way, and leaves it when that runs out.
1151
1061
  if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
@@ -1195,17 +1105,11 @@ const SPEAK_SMILE_RETAIN = 0.35;
1195
1105
  : () => performance.now() - speakStart;
1196
1106
  speech.start(o.cues || [], speakClock);
1197
1107
  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.
1108
+ // The eyes come back to the user when the audio starts: a reply arriving
1109
+ // while THINKING was looking away would otherwise spend its whole turn
1110
+ // aimed off the user, because nothing in SPEAKING retargets. `keepGaze` is
1111
+ // for the one caller that means it — a performance that aimed the eyes with
1112
+ // its own `gaze` verb keeps them, an instruction and not a leftover.
1209
1113
  if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
1210
1114
  if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
1211
1115
  return api;
@@ -1217,25 +1121,14 @@ const SPEAK_SMILE_RETAIN = 0.35;
1217
1121
  * This is the widget's half of the **gaze window**. In face-to-face talk a
1218
1122
  * speaker periodically looks at the listener, mutual gaze is established, the
1219
1123
  * 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.
1124
+ * (Bavelas, Coates & Johnson 2002).
1222
1125
  *
1223
1126
  * We cannot see the user, so we cannot observe the window opening. What a
1224
1127
  * caller *can* do is name the moments that co-occur with it — a mid-turn
1225
1128
  * pause, a tag question ("...right?", "you know?"), a completed clause with
1226
1129
  * 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.
1130
+ * question the bot asked. For `ms` the face stops averting and holds the
1131
+ * user, which is the prerequisite for any response to be *seen*.
1239
1132
  *
1240
1133
  * @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
1241
1134
  * mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
@@ -1312,7 +1205,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1312
1205
  const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
1313
1206
  if (!faceClip) return api;
1314
1207
  clip.play(faceClip, faceClip.audioEl, { queue: true });
1315
- // The two acknowledgements smile. Only these, and only ever because the
1208
+ // The acknowledgements smile. Only these, and only ever because the
1316
1209
  // server sent one: a smile the renderer timed for itself would be an
1317
1210
  // acknowledgement nobody sent.
1318
1211
  if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
@@ -1347,11 +1240,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1347
1240
  return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
1348
1241
  }
1349
1242
 
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
1243
  function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
1356
1244
 
1357
1245
  // What one action does when its moment comes. Enum validity is checked here,
@@ -1374,15 +1262,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1374
1262
  let performGen = 0;
1375
1263
 
1376
1264
  /**
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
1265
  * @param {Array<{t: number, do: string}>} actions
1387
1266
  * @param {{audio?: HTMLMediaElement, clock?: () => number,
1388
1267
  * onAction?: (a: object) => void}} [o]
@@ -1490,7 +1369,7 @@ export { EMOTION_NAMES, emotionPose } from './emotions.js';
1490
1369
  // exactly that, and none of them should write it twice — so it is a plain class
1491
1370
  // to construct, not a contract to implement.
1492
1371
  export {
1493
- VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
1372
+ VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, JAW_OF_OPEN, SILENT,
1494
1373
  normalizeCues, textToCues,
1495
1374
  ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
1496
1375
  } 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.