@voqalize/avatar 0.2.2 → 0.4.0

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 (265) hide show
  1. package/LICENSE +21 -661
  2. package/LICENSE-CC-BY-4.0 +396 -0
  3. package/README.md +191 -662
  4. package/assets/README.md +48 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tushar.glb +0 -0
  8. package/client/Avatar.tsx +57 -0
  9. package/client/AvatarClient.ts +818 -0
  10. package/client/arjun.ts +26 -0
  11. package/client/createAvatar.ts +177 -0
  12. package/client/createCanvasAvatar.ts +72 -0
  13. package/client/index.ts +44 -0
  14. package/client/internal.ts +108 -0
  15. package/client/interviewer-female.ts +4 -0
  16. package/client/interviewer-male.ts +4 -0
  17. package/client/ishita.ts +26 -0
  18. package/client/kabir.ts +26 -0
  19. package/client/meera.ts +26 -0
  20. package/client/naina.ts +26 -0
  21. package/client/playout.ts +95 -0
  22. package/client/professional-female-a.ts +4 -0
  23. package/client/professional-female-b.ts +4 -0
  24. package/client/professional-male-a.ts +4 -0
  25. package/client/professional-male-b.ts +4 -0
  26. package/client/react.ts +13 -0
  27. package/client/supports.ts +28 -0
  28. package/client/three/assets.ts +24 -0
  29. package/client/three/budgets.ts +27 -0
  30. package/client/three/holds.ts +64 -0
  31. package/client/three/internal.ts +54 -0
  32. package/client/three/motion-limits.json +116 -0
  33. package/client/three/sequences.ts +281 -0
  34. package/client/three/tanya.ts +69 -0
  35. package/client/three/tara-rig.ts +1562 -0
  36. package/client/three/tara.ts +76 -0
  37. package/client/three/tushar.ts +65 -0
  38. package/client/types.ts +235 -0
  39. package/client/useAvatar.ts +74 -0
  40. package/client/vikram.ts +26 -0
  41. package/dist/Avatar.d.ts +44 -0
  42. package/dist/Avatar.d.ts.map +1 -0
  43. package/{client/dist → dist}/Avatar.js +2 -2
  44. package/dist/Avatar.js.map +1 -0
  45. package/dist/AvatarClient.d.ts +274 -0
  46. package/dist/AvatarClient.d.ts.map +1 -0
  47. package/dist/AvatarClient.js +712 -0
  48. package/dist/AvatarClient.js.map +1 -0
  49. package/dist/arjun.d.ts +7 -0
  50. package/dist/arjun.d.ts.map +1 -0
  51. package/dist/arjun.js +20 -0
  52. package/dist/arjun.js.map +1 -0
  53. package/dist/createAvatar.d.ts +135 -0
  54. package/dist/createAvatar.d.ts.map +1 -0
  55. package/dist/createAvatar.js +75 -0
  56. package/dist/createAvatar.js.map +1 -0
  57. package/dist/createCanvasAvatar.d.ts +22 -0
  58. package/dist/createCanvasAvatar.d.ts.map +1 -0
  59. package/dist/createCanvasAvatar.js +47 -0
  60. package/dist/createCanvasAvatar.js.map +1 -0
  61. package/dist/index.d.ts +34 -0
  62. package/dist/index.d.ts.map +1 -0
  63. package/dist/index.js +33 -0
  64. package/dist/index.js.map +1 -0
  65. package/dist/internal.d.ts +34 -0
  66. package/dist/internal.d.ts.map +1 -0
  67. package/dist/internal.js +41 -0
  68. package/dist/internal.js.map +1 -0
  69. package/dist/interviewer-female.d.ts +4 -0
  70. package/dist/interviewer-female.d.ts.map +1 -0
  71. package/dist/interviewer-female.js +3 -0
  72. package/dist/interviewer-female.js.map +1 -0
  73. package/dist/interviewer-male.d.ts +4 -0
  74. package/dist/interviewer-male.d.ts.map +1 -0
  75. package/dist/interviewer-male.js +3 -0
  76. package/dist/interviewer-male.js.map +1 -0
  77. package/dist/ishita.d.ts +7 -0
  78. package/dist/ishita.d.ts.map +1 -0
  79. package/dist/ishita.js +20 -0
  80. package/dist/ishita.js.map +1 -0
  81. package/dist/kabir.d.ts +7 -0
  82. package/dist/kabir.d.ts.map +1 -0
  83. package/dist/kabir.js +20 -0
  84. package/dist/kabir.js.map +1 -0
  85. package/dist/meera.d.ts +7 -0
  86. package/dist/meera.d.ts.map +1 -0
  87. package/dist/meera.js +20 -0
  88. package/dist/meera.js.map +1 -0
  89. package/dist/naina.d.ts +7 -0
  90. package/dist/naina.d.ts.map +1 -0
  91. package/dist/naina.js +20 -0
  92. package/dist/naina.js.map +1 -0
  93. package/dist/playout.d.ts +42 -0
  94. package/dist/playout.d.ts.map +1 -0
  95. package/dist/playout.js +85 -0
  96. package/dist/playout.js.map +1 -0
  97. package/dist/professional-female-a.d.ts +4 -0
  98. package/dist/professional-female-a.d.ts.map +1 -0
  99. package/dist/professional-female-a.js +3 -0
  100. package/dist/professional-female-a.js.map +1 -0
  101. package/dist/professional-female-b.d.ts +4 -0
  102. package/dist/professional-female-b.d.ts.map +1 -0
  103. package/dist/professional-female-b.js +3 -0
  104. package/dist/professional-female-b.js.map +1 -0
  105. package/dist/professional-male-a.d.ts +4 -0
  106. package/dist/professional-male-a.d.ts.map +1 -0
  107. package/dist/professional-male-a.js +3 -0
  108. package/dist/professional-male-a.js.map +1 -0
  109. package/dist/professional-male-b.d.ts +4 -0
  110. package/dist/professional-male-b.d.ts.map +1 -0
  111. package/dist/professional-male-b.js +3 -0
  112. package/dist/professional-male-b.js.map +1 -0
  113. package/dist/react.d.ts +13 -0
  114. package/dist/react.d.ts.map +1 -0
  115. package/dist/react.js +13 -0
  116. package/dist/react.js.map +1 -0
  117. package/dist/supports.d.ts +20 -0
  118. package/dist/supports.d.ts.map +1 -0
  119. package/dist/supports.js +26 -0
  120. package/dist/supports.js.map +1 -0
  121. package/dist/three/assets.d.ts +24 -0
  122. package/dist/three/assets.d.ts.map +1 -0
  123. package/dist/three/assets.js +22 -0
  124. package/dist/three/assets.js.map +1 -0
  125. package/dist/three/budgets.d.ts +17 -0
  126. package/dist/three/budgets.d.ts.map +1 -0
  127. package/dist/three/budgets.js +21 -0
  128. package/dist/three/budgets.js.map +1 -0
  129. package/dist/three/holds.d.ts +33 -0
  130. package/dist/three/holds.d.ts.map +1 -0
  131. package/dist/three/holds.js +56 -0
  132. package/dist/three/holds.js.map +1 -0
  133. package/dist/three/internal.d.ts +52 -0
  134. package/dist/three/internal.d.ts.map +1 -0
  135. package/dist/three/internal.js +49 -0
  136. package/dist/three/internal.js.map +1 -0
  137. package/dist/three/motion-limits.json +116 -0
  138. package/dist/three/sequences.d.ts +71 -0
  139. package/dist/three/sequences.d.ts.map +1 -0
  140. package/dist/three/sequences.js +262 -0
  141. package/dist/three/sequences.js.map +1 -0
  142. package/dist/three/tanya.d.ts +35 -0
  143. package/dist/three/tanya.d.ts.map +1 -0
  144. package/dist/three/tanya.js +51 -0
  145. package/dist/three/tanya.js.map +1 -0
  146. package/dist/three/tara-rig.d.ts +403 -0
  147. package/dist/three/tara-rig.d.ts.map +1 -0
  148. package/dist/three/tara-rig.js +1500 -0
  149. package/dist/three/tara-rig.js.map +1 -0
  150. package/dist/three/tara.d.ts +35 -0
  151. package/dist/three/tara.d.ts.map +1 -0
  152. package/dist/three/tara.js +58 -0
  153. package/dist/three/tara.js.map +1 -0
  154. package/dist/three/tushar.d.ts +31 -0
  155. package/dist/three/tushar.d.ts.map +1 -0
  156. package/dist/three/tushar.js +47 -0
  157. package/dist/three/tushar.js.map +1 -0
  158. package/dist/types.d.ts +149 -0
  159. package/dist/types.d.ts.map +1 -0
  160. package/dist/types.js +140 -0
  161. package/dist/types.js.map +1 -0
  162. package/dist/useAvatar.d.ts +39 -0
  163. package/dist/useAvatar.d.ts.map +1 -0
  164. package/dist/useAvatar.js +35 -0
  165. package/dist/useAvatar.js.map +1 -0
  166. package/dist/vikram.d.ts +7 -0
  167. package/dist/vikram.d.ts.map +1 -0
  168. package/dist/vikram.js +20 -0
  169. package/dist/vikram.js.map +1 -0
  170. package/package.json +110 -25
  171. package/src/avatar.d.ts +218 -120
  172. package/src/avatar.js +950 -213
  173. package/src/behavior.d.ts +42 -0
  174. package/src/behavior.js +114 -0
  175. package/src/camera.js +29 -0
  176. package/src/canvas/author/parts/eye.mjs +722 -0
  177. package/src/canvas/author/parts/hand.mjs +1156 -0
  178. package/src/canvas/author/parts/mouth.mjs +741 -0
  179. package/src/canvas/author/parts/nose.mjs +100 -0
  180. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  181. package/src/canvas/author/path.mjs +283 -0
  182. package/src/canvas/author/rig.mjs +405 -0
  183. package/src/canvas/avatars/round/face.d.mts +3 -0
  184. package/src/canvas/avatars/round/face.mjs +1307 -0
  185. package/src/canvas/create-rig.d.ts +15 -0
  186. package/src/canvas/create-rig.js +100 -0
  187. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  188. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  189. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  190. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  191. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  192. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  193. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  194. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  195. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  196. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  197. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  198. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  199. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  200. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  201. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  202. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  203. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  204. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  205. package/src/canvas/data/interviewer-female.rig.json +1 -0
  206. package/src/canvas/data/interviewer-male.rig.json +1 -0
  207. package/src/canvas/data/professional-female-a.rig.json +1 -0
  208. package/src/canvas/data/professional-female-b.rig.json +1 -0
  209. package/src/canvas/data/professional-male-a.rig.json +1 -0
  210. package/src/canvas/data/professional-male-b.rig.json +1 -0
  211. package/src/canvas/src/live.js +508 -0
  212. package/src/canvas/src/render2d.js +218 -0
  213. package/src/canvas/src/rig.js +297 -0
  214. package/src/canvas/src/vocab.js +96 -0
  215. package/src/clips.js +18 -7
  216. package/src/conformance.js +119 -0
  217. package/src/emotions.js +8 -5
  218. package/src/face-core.js +27 -1
  219. package/src/face-myna.d.ts +7 -0
  220. package/src/face-myna.js +192 -131
  221. package/src/face-peep-control-plane.js +167 -0
  222. package/src/face-peep.d.ts +7 -0
  223. package/src/face-peep.js +274 -116
  224. package/src/face-wren.d.ts +7 -0
  225. package/src/face-wren.js +17 -19
  226. package/src/faces.d.ts +12 -0
  227. package/src/faces.js +53 -0
  228. package/src/gaze.js +434 -58
  229. package/src/hand.js +120 -91
  230. package/src/head.js +134 -0
  231. package/src/idle.js +227 -119
  232. package/src/interjections.js +200 -31
  233. package/src/params.js +8 -4
  234. package/src/perform.js +7 -9
  235. package/src/prosody.js +647 -0
  236. package/src/rig.d.ts +24 -0
  237. package/src/rig.js +32 -0
  238. package/src/speech-timing.js +23 -0
  239. package/src/visemes.js +110 -24
  240. package/client/dist/Avatar.d.ts +0 -27
  241. package/client/dist/Avatar.d.ts.map +0 -1
  242. package/client/dist/Avatar.js.map +0 -1
  243. package/client/dist/AvatarClient.d.ts +0 -139
  244. package/client/dist/AvatarClient.d.ts.map +0 -1
  245. package/client/dist/AvatarClient.js +0 -223
  246. package/client/dist/AvatarClient.js.map +0 -1
  247. package/client/dist/index.d.ts +0 -22
  248. package/client/dist/index.d.ts.map +0 -1
  249. package/client/dist/index.js +0 -22
  250. package/client/dist/index.js.map +0 -1
  251. package/client/dist/types.d.ts +0 -86
  252. package/client/dist/types.d.ts.map +0 -1
  253. package/client/dist/types.js +0 -31
  254. package/client/dist/types.js.map +0 -1
  255. package/client/dist/useAvatar.d.ts +0 -42
  256. package/client/dist/useAvatar.d.ts.map +0 -1
  257. package/client/dist/useAvatar.js +0 -51
  258. package/client/dist/useAvatar.js.map +0 -1
  259. package/client/src/Avatar.tsx +0 -33
  260. package/client/src/AvatarClient.ts +0 -270
  261. package/client/src/index.ts +0 -22
  262. package/client/src/types.ts +0 -104
  263. package/client/src/useAvatar.ts +0 -85
  264. package/docs/contract-avatar.md +0 -371
  265. package/docs/contract-protocol.md +0 -461
package/src/avatar.js CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * The avatar — a programmable talking head.
2
+ * The mixer — a programmable talking head.
3
3
  *
4
- * The public surface. Everything the server drives goes through here:
4
+ * Not the public surface: that is `createAvatar({ mount, client })` in
5
+ * `packages/avatar/client/createAvatar.ts`, and this is what it drives. Everything below is
6
+ * addressed by our own runtime, our tooling, and an avatar author who chose to
7
+ * build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
5
8
  *
9
+ * const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
6
10
  * avatar.setState('LISTENING', { emotion: 'warm' })
7
11
  * avatar.setGaze('SCREEN_LEFT')
8
12
  * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
9
13
  * avatar.pushCues(moreCues) // streaming top-up
10
- * avatar.interject('OKAY')
11
- * avatar.gesture('HI') // a hand at the frame edge + its face
14
+ * avatar.action('ACK_RECEIVE')
15
+ * avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
12
16
  * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
13
17
  * avatar.setUserSpeaking(bool) // the user has the floor, so listening
14
18
  * // is contingent instead of timed
@@ -26,40 +30,121 @@
26
30
  */
27
31
 
28
32
  import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
29
- import { createFace as createPeepFace, META as peepMeta } from './face-peep.js';
30
- import { createFace as createWrenFace, META as wrenMeta } from './face-wren.js';
31
- import { createFace as createMynaFace, META as mynaMeta } from './face-myna.js';
32
- import { emotionPose } from './emotions.js';
33
- import { GazeLayer, GAZE_TARGETS } from './gaze.js';
33
+ import { EMOTIONS, emotionPose } from './emotions.js';
34
+ import { GazeLayer, GAZE_TARGETS, AVERSION } from './gaze.js';
34
35
  import { IdleLayer, ListeningEngine } from './idle.js';
35
36
  import { ClipPlayer } from './clips.js';
36
- import { INTERJECTIONS } from './interjections.js';
37
+ import { ACTIONS, INTERNAL_CLIPS } from './interjections.js';
37
38
  import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
39
+ import { SpeechProsody, UNCALIBRATED_HEAD_GAIN } from './prosody.js';
40
+ import { HEAD_AXES, soften } from './head.js';
38
41
  import { PerformTrack } from './perform.js';
39
- import { createHand, HAND_GESTURES } from './hand.js';
42
+ import { createHand, HAND_GESTURES, HAND_ACTION_TO_FRAME_GESTURE } from './hand.js';
43
+ import { avatarFrame, createSvgRig } from './rig.js';
44
+
45
+ const rand = ([a, b]) => a + Math.random() * (b - a);
46
+ const pick = (xs) => xs[(Math.random() * xs.length) | 0];
40
47
 
41
48
  // Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
42
49
  // in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
43
50
  // alone separates listening (~16/min) from thinking (~25/min) from visually
44
- // busy (~9/min), and it is the cheapest state signal the rig has.
51
+ // busy (~9/min), and it is the cheapest state signal the rig has. The gap is
52
+ // the budget for every blink, paired ones included (about one in six), so it
53
+ // sits a little longer than 60/rate.
45
54
  export const STATES = {
46
- IDLE: { gaze: 'USER', emotion: 'neutral', idle: { sway: 1.0 }, backchannel: false },
47
- LISTENING: { gaze: 'USER', emotion: 'neutral', backchannel: true,
48
- idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
55
+ // Idle means present and relaxed, not attending. It is also the face before
56
+ // a call connects — the first one anyone sees — so it rests on the user and
57
+ // leaves for soft, unhurried looks (AVERSION.IDLE) rather than parking the
58
+ // eyes somewhere. It used to open on AWAY_THINKING and wander between it,
59
+ // AWAY_RIGHT and NOTES from the first frame: a face that loads looking up
60
+ // and to the left, at nothing, reads as broken rather than idle. What keeps
61
+ // it apart from LISTENING is the longer, lazier looks, the slower scan and
62
+ // the missing engagement lean, not refusing to look at the user.
63
+ IDLE: { gaze: 'USER', emotion: 'neutral', engagement: false,
64
+ aversion: 'IDLE', scan: [0.8, 2.0],
65
+ idle: { sway: 0.72, blinkGap: [3.6, 5.2] } },
66
+ // `aversion` is why this state does not stare. Continuous eye contact is not
67
+ // the attentive pose it looks like — it is a demand for more talk (Rossano)
68
+ // and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
69
+ // 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.
72
+ LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
73
+ aversion: 'LISTEN',
74
+ idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
49
75
  pose: { browRaiseL: 0.06, browRaiseR: 0.06, lidL: -0.04, lidR: -0.04 } },
50
76
  // Faster, shallower breath is the measured cognitive-load signature, and the
51
77
  // occasional dead-still hold is the strongest "working on it" cue a rig this
52
- // simple can make — deliberate stillness, not more motion. The aversion
53
- // leads DOWN (39% of measured cognitive aversions, §4.2) and wanders on the
54
- // ~3.5s cognitive-aversion cadence, coming back to the user roughly one
55
- // dwell in four — still with you, working.
56
- THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', backchannel: false,
57
- idle: { sway: 0.7, blinkGap: [2.1, 2.7], breathRate: 1.18, breathAmp: 0.7,
78
+ // simple can make — deliberate stillness, not more motion. The look away is
79
+ // Andrist's cognitive aversion (§4.2): one look held ~3.5 s (SD 1.26) that
80
+ // drifts a little where it sits, a check-in with the user, then the next.
81
+ //
82
+ // In a call this state mostly lasts under two seconds — the gap between the
83
+ // user finishing and the reply starting — so its opening is what gets seen,
84
+ // and the opening used to be wrong. The eyes left the instant the user
85
+ // stopped, down and to the left, every turn: a listener who drops their
86
+ // eyes the moment you finish reads as ashamed or done with you. Now the
87
+ // eyes hold the user for `opening` first (a person takes in the end of
88
+ // what was said before they go to think), and a fast reply never looks
89
+ // away at all. The look is mostly up-and-aside or level-aside: that is the
90
+ // one audiences read as thinking, whatever the measured split, and down
91
+ // is kept to a fifth of looks because on a face this real it reads as
92
+ // downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
93
+ // alert, and the two together measured past the 0.15 that reads drowsy.
94
+ // The handoff to SPEAKING is in setState: a reply that starts mid-look
95
+ // finishes the look instead of snapping back.
96
+ THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
97
+ // Fixational jumps rare and small: a thinker's eyes rest
98
+ // where they land. At the default gap the look jittered
99
+ // about its spot every second, and with a drift on top
100
+ // the eyes never held anything — searching the wall, not
101
+ // thinking.
102
+ scan: [1.2, 2.6, 0.35],
103
+ idle: { sway: 0.7, blinkGap: [2.6, 3.2], slowBlink: 0.3,
104
+ breathRate: 1.18, breathAmp: 0.7,
58
105
  hold: { every: [4.5, 9.0], dur: [0.8, 1.5] } },
59
- wander: { targets: ['AWAY_DOWN', 'AWAY_DOWN', 'AWAY_THINKING', 'USER'],
60
- every: [2.6, 4.4] } },
61
- SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, backchannel: false },
62
- REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', backchannel: false,
106
+ // `stick`: people have a side they look to when they
107
+ // think and keep to it (Day; Kinsbourne's lateral eye
108
+ // movements) — one who alternates sides on every look is
109
+ // scanning the room. `dart`: now and then, once in a
110
+ // look, the eyes move on to a second spot with a flick of
111
+ // the brows, which is the thought moving; the random
112
+ // drift this replaces never stopped and read as roving.
113
+ // `blinkTo`: the look back to the user always blinks —
114
+ // the re-engagement is the beat to see.
115
+ glance: { to: 'USER', opening: [0.35, 0.7], every: [2.2, 4.4], hold: [0.8, 1.3],
116
+ back: ['AWAY_THINKING', 'AWAY_THINKING', 'AWAY_THINKING',
117
+ 'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
118
+ 'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
119
+ 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.
140
+ pose: { lidL: -0.10, lidR: -0.10 } },
141
+ // Eyes on the user, with the speaker's own looks away (AVERSION.SPEAK):
142
+ // a planning look at the start of most turns, a short one every ~5 s at a
143
+ // phrase boundary, and none as the turn ends. A speaker who never looks
144
+ // away is staring the listener down.
145
+ SPEAKING: { gaze: 'USER', emotion: 'neutral', aversion: 'SPEAK',
146
+ idle: { sway: 0.55 }, engagement: false },
147
+ REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
63
148
  idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
64
149
  wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_RIGHT', 'SCREEN_TOP', 'SCREEN_WORK'],
65
150
  every: [1.8, 5.0] } },
@@ -67,7 +152,7 @@ export const STATES = {
67
152
  // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
68
153
  // is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
69
154
  // size, still gentle. Every other channel in this pose read fine on screen.
70
- WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', backchannel: true,
155
+ WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
71
156
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
72
157
  pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
73
158
  // Straining to hear. The one state where the amplitude constraint yields,
@@ -75,11 +160,11 @@ export const STATES = {
75
160
  // engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
76
161
  // favors the speaker while the eyes hold contact, and a concentration
77
162
  // squint with knit brows. Stillness does the rest — straining people
78
- // freeze — so holds are frequent and there are NO backchannels: you don't
163
+ // freeze — so holds are frequent and there is no engagement lean: you don't
79
164
  // nod along to what you can't hear. Server sends it on soft/low-SNR user
80
165
  // audio, typically followed by SORRY or a "could you repeat" utterance.
81
166
  CANT_HEAR: {
82
- gaze: 'USER_EAR', emotion: 'neutral', backchannel: false,
167
+ gaze: 'USER_EAR', emotion: 'neutral', engagement: false,
83
168
  idle: { sway: 0.5, blinkGap: [4.5, 6.5],
84
169
  hold: { every: [2.5, 5.5], dur: [1.0, 1.8] } },
85
170
  // A minimal line face swallows small deltas — the ink moves whole units
@@ -87,32 +172,86 @@ export const STATES = {
87
172
  // extremes row, not from what a fleshed rig would need: brows DOWN
88
173
  // (corrugator effort, not the browInner worry-lift), a real squint, and
89
174
  // the resting smile pressed flat — nobody smiles while straining to hear.
175
+ // The squint is the lower lid's (AU7), with no upper lid dropped on top:
176
+ // the two together narrowed a photographic eye from above and read as
177
+ // drowsy, and a line face's squint is its lower lid already.
90
178
  pose: {
91
179
  torsoLean: 0.70, headPitch: 0.10,
92
- lidL: 0.12, lidR: 0.12, squintL: 0.75, squintR: 0.75,
180
+ squintL: 0.75, squintR: 0.75,
93
181
  browRaiseL: -0.45, browRaiseR: -0.45, browInnerL: 0.15, browInnerR: 0.12,
94
182
  mouthPress: 0.45, mouthCornerL: -0.22, mouthCornerR: -0.22,
95
183
  },
96
184
  },
185
+ // The user's microphone is closed, and the agent is the one that closed it.
186
+ // Authored against CANT_HEAR as its exact inverse, because that contrast is
187
+ // the whole read: straining leans *in* to get more of the user, this settles
188
+ // *back* — nothing more is coming through and the avatar knows why. The eyes
189
+ // stay on the user, because a deliberate hold is not inattention; what
190
+ // carries it is the mouth, pressed shut and staying shut, and the slow,
191
+ // unhurried blink of waiting rather than working. No engagement lean: you
192
+ // cannot nod along to a channel you have muted yourself. No filter, ever —
193
+ // DEGRADED and OFFLINE own "something is broken", and this is a decision.
194
+ MUTED: {
195
+ gaze: 'USER', emotion: 'neutral', engagement: false,
196
+ idle: { sway: 0.45, blinkGap: [4.5, 7.0],
197
+ hold: { every: [3.0, 6.0], dur: [0.9, 1.6] } },
198
+ // Line-face scaled (see CANT_HEAR): peep's resting mouth is drawn smiling,
199
+ // so a closed mouth has to be authored clearly past flat to read as closed
200
+ // at all. Corners were -0.20 first, which rendered as a straight line and
201
+ // at 130 px against LISTENING said nothing — the delta a viewer gets is
202
+ // curvature, and flat is the halfway point of it, not the end. -0.34 is as
203
+ // far as it goes before the hold starts reading as sulking. No squint and
204
+ // no lid drop: one is straining, the other was asleep. browInner carries
205
+ // the "one moment" without the worry lift.
206
+ pose: {
207
+ torsoLean: -0.28, headPitch: 0.04,
208
+ browRaiseL: 0.08, browRaiseR: 0.06, browInnerL: 0.28, browInnerR: 0.22,
209
+ mouthPress: 0.78, mouthCornerL: -0.34, mouthCornerR: -0.34,
210
+ },
211
+ },
97
212
  // --- application state ---------------------------------------------------
98
213
  // "Momentarily busy on the thing you asked for." No hands in frame, so the
99
- // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4):
100
- // gaze parked DOWN on a stable target, blinks suppressed to task-focus rate
101
- // (~9/min), shoulders slightly raised and *working* — the burst/pause rhythm
102
- // is what says activity rather than rocking — and, the important one, a
103
- // brief glance back up to the user every few seconds. The glance is the tell
104
- // that the user has not been forgotten; without it, busy is just absent.
105
- TYPING: {
106
- // SCREEN_WORK, not NOTES: on a steep down target the gaze layer's lid
107
- // follow seals the eyes, and at tile size shut eyes read as asleep, not
108
- // busy. A mild down-left with the head pitched into it keeps the iris in
109
- // the opening — eyes down but awake.
110
- gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
111
- idle: { sway: 0.6, blinkGap: [6.0, 7.5], breathRate: 1.05,
112
- rhythm: { amp: 0.05, freq: 2.2 } },
113
- glance: { to: 'USER', every: [4, 7], hold: [0.7, 1.1] },
114
- pose: { headPitch: 0.10, lidL: -0.04, lidR: -0.04,
115
- shoulderL: 0.06, shoulderR: 0.06 },
214
+ // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4,
215
+ // recommendation 19): gaze down on one stable target with a reading scan,
216
+ // blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
217
+ // and *held* with brief micro-freezes, and a glance back up to the user.
218
+ // The glance is the tell that the user has not been forgotten; without it,
219
+ // busy is just absent, and much more often than that it is fidgeting. There
220
+ // is no shoulder rhythm: at 2.2 Hz it ran over the 1.5 Hz ceiling every
221
+ // other motion here keeps, and held is what §6.4 describes. The trunk sits
222
+ // out the glance — checking on someone is a look, not a turn toward them.
223
+ WORKING: {
224
+ // OWN_SCREEN: eyes down at the agent's own display, head nearly level.
225
+ // This was SCREEN_WORK, which turned the head down and to the left and
226
+ // held it there for the whole task, with a random drift too small to see
227
+ // on it — a head turned away from the user, staring at a point. `read` is
228
+ // what makes it busy instead: fixations stepping along a line, a sweep
229
+ // back, the next line, a pause on the result (see readStep). The lids
230
+ // take back most of the down look's follow so the eyes stay awake.
231
+ gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
232
+ 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.
236
+ idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
237
+ hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
238
+ // The look up to check is brows-first and blinkless, the lids leading it
239
+ // (their 18 ms against the eye's 32 ms); the blink comes on the way back
240
+ // down, where the eyes re-engage with the work. A blink going up hid the
241
+ // one moment the user is actually looked at.
242
+ glance: { to: 'USER', every: [6, 11], hold: [0.8, 1.2], brow: 0.10,
243
+ blinkTo: false, blinkBack: true },
244
+ // Fewer, longer fixations than a page reader's. At 3-5 steps a quarter
245
+ // second apart the scan measured a shift a second, and over a webcam that
246
+ // is a jiggle, not reading.
247
+ read: { width: 0.20, steps: [2, 3], fix: [0.35, 0.7], lines: [2, 4], line: 0.05,
248
+ pause: [0.9, 1.8] },
249
+ // The brows draw down and together — AU4, the one facial action that
250
+ // reliably marks effortful concentration. Without it the face over a
251
+ // reading scan is blank, and blank over a moving eye reads as bored.
252
+ pose: { headPitch: 0.04, lidL: -0.08, lidR: -0.08,
253
+ shoulderL: 0.06, shoulderR: 0.06,
254
+ browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
116
255
  },
117
256
  // The audio channel is broken and the agent is typing in the chat window to
118
257
  // communicate — TYPING's mechanics turned *communicative*. The glance is
@@ -123,7 +262,7 @@ export const STATES = {
123
262
  // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
124
263
  // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
125
264
  TYPING_CHAT: {
126
- gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
265
+ gaze: 'SCREEN_WORK', emotion: 'neutral', engagement: false,
127
266
  idle: { sway: 0.6, blinkGap: [5.5, 7.0], breathRate: 1.05,
128
267
  rhythm: { amp: 0.055, freq: 2.5 } },
129
268
  glance: { to: 'USER', every: [3.2, 5.5], hold: [1.2, 2.0] },
@@ -136,12 +275,12 @@ export const STATES = {
136
275
  },
137
276
  // Attention genuinely elsewhere. What separates this from TYPING is target
138
277
  // *stability* (§6.4): busy is one steady off-user target, distracted is
139
- // wandering ones, held long (aversion >3s), with no backchannels — the
278
+ // wandering ones, held long (aversion >3s), with no engagement lean — the
140
279
  // missing nod is as diagnostic as the look-away. Sway is looser than
141
280
  // LISTENING because attention is what was holding the body still. The
142
281
  // widget only looks away; deciding when to snap back is the server's call.
143
282
  DISTRACTED: {
144
- gaze: 'AWAY_RIGHT', emotion: 'neutral', backchannel: false,
283
+ gaze: 'AWAY_RIGHT', emotion: 'neutral', engagement: false,
145
284
  idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
146
285
  // Sideways and up, never steep-down: lateral is where real intimacy/
147
286
  // distraction aversions live, and a steep down target seals this rig's
@@ -156,9 +295,16 @@ export const STATES = {
156
295
  // wiggle nobody makes while merely reading. Server semantics: a filler
157
296
  // while an async activity completes; the server exits it when done.
158
297
  SEARCHING_SCREEN: {
159
- gaze: 'SCREEN_CENTER', emotion: 'neutral', backchannel: false,
298
+ gaze: 'SCREEN_CENTER', emotion: 'neutral', engagement: false,
160
299
  idle: { sway: 0.65, blinkGap: [5.0, 6.8], breathRate: 1.05,
161
300
  flick: { amp: 0.30, every: [3.5, 7.0] } },
301
+ // The only screen state that never named its own scan, so it ran the
302
+ // default [0.5, 1.6] at full amplitude: a second full-size jump landing on
303
+ // top of a wander hop, which is most of what reads as snapping. A hunt's
304
+ // refixations are faster and much smaller than a reader's — quick little
305
+ // checks around the thing being looked at — so this is the other end of
306
+ // the dial from WORKING's [0.9, 2.0, 0.12] rather than a copy of it.
307
+ scan: [0.4, 1.1, 0.55],
162
308
  wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_TOP', 'SCREEN_WORK',
163
309
  'SCREEN_RIGHT', 'SCREEN_CENTER', 'SCREEN_LEFT'],
164
310
  every: [0.8, 2.0] },
@@ -180,7 +326,7 @@ export const STATES = {
180
326
  // use to predict that someone is about to speak. The head comes *up* rather
181
327
  // than down: a lowered head is deferential and reads as yielding.
182
328
  TAKING_FLOOR: {
183
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, backchannel: false,
329
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, engagement: false,
184
330
  pose: {
185
331
  browRaiseL: 0.26, browRaiseR: 0.22, lidL: -0.10, lidR: -0.10,
186
332
  headPitch: -0.10, torsoLean: 0.22, shoulderL: 0.30, shoulderR: 0.30,
@@ -193,7 +339,7 @@ export const STATES = {
193
339
  // purpose — stillness is what makes it read as intent rather than as fidget),
194
340
  // leaning in, lips apart and staying apart.
195
341
  WANTS_IN: {
196
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, backchannel: false,
342
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, engagement: false,
197
343
  pose: {
198
344
  browRaiseL: 0.42, browRaiseR: 0.38, lidL: -0.14, lidR: -0.14,
199
345
  headPitch: -0.14, torsoLean: 0.42, shoulderL: 0.45, shoulderR: 0.45,
@@ -204,107 +350,114 @@ export const STATES = {
204
350
  // happen faster than anything else on the face — see YIELD_FLOOR, which is
205
351
  // what actually delivers the snap.
206
352
  YIELDED: {
207
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, backchannel: false,
353
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, engagement: false,
208
354
  pose: {
209
355
  browRaiseL: 0.10, browRaiseR: 0.08,
210
356
  torsoLean: -0.18, shoulderL: -0.12, shoulderR: -0.12,
211
357
  },
212
358
  },
213
359
 
214
- DEGRADED: { gaze: 'USER', emotion: 'neutral', backchannel: false,
215
- idle: { sway: 0.4, blinkGap: [4.0, 8.0] },
216
- pose: { lidL: 0.3, lidR: 0.3 }, filter: 'grayscale(.55) brightness(.82)' },
217
- OFFLINE: { gaze: 'USER', emotion: 'neutral', backchannel: false,
218
- idle: { sway: 0.15, blinkGap: [9, 15] },
219
- pose: { lidL: 0.95, lidR: 0.95, mouthCornerL: 0, mouthCornerR: 0 },
360
+ // --- connection ----------------------------------------------------------
361
+ // Neither of these may close the eyes. A lid at 0.3 reads sleepy and one at
362
+ // 0.95 reads asleep (research-perception.md §6), and on a photographic face
363
+ // a disconnected call rendered as someone falling asleep on camera. The SVG
364
+ // faces also desaturate (`filter`); a rig that does not is carried by pose
365
+ // and gaze alone, so those have to say it.
366
+ //
367
+ // Our side of the call is struggling: still with the user, a little worried
368
+ // about it — inner brows up, mouth pressed — and quieter than listening.
369
+ DEGRADED: { gaze: 'USER', emotion: 'neutral', engagement: false,
370
+ aversion: 'LISTEN',
371
+ idle: { sway: 0.4, blinkGap: [3.4, 5.0] },
372
+ pose: { browInnerL: 0.30, browInnerR: 0.26, browRaiseL: -0.04, browRaiseR: -0.04,
373
+ mouthPress: 0.20, mouthCornerL: -0.10, mouthCornerR: -0.10 },
374
+ filter: 'grayscale(.55) brightness(.82)' },
375
+ // The call is gone. What a person does when their call drops: eyes on their
376
+ // own screen, waiting, and now and then a look back up to see whether it
377
+ // has come back. Still, with long holds — not in conversation — but awake.
378
+ OFFLINE: { gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
379
+ scan: [1.2, 2.6],
380
+ idle: { sway: 0.3, blinkGap: [4.5, 7.0],
381
+ hold: { every: [4.0, 8.0], dur: [1.0, 2.0] } },
382
+ glance: { to: 'USER', every: [7, 12], hold: [0.8, 1.3] },
383
+ pose: { lidL: -0.08, lidR: -0.08, mouthPress: 0.25 },
220
384
  filter: 'grayscale(1) brightness(.6)' },
221
385
  };
222
386
 
223
387
  export const STATE_NAMES = Object.keys(STATES);
224
388
 
225
389
  /**
226
- * The avatars this rig can wear: `{ create, meta }` records.
390
+ * The shoulder line's share of a held tilt, per unit of `headRoll`.
227
391
  *
228
- * `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
229
- * callable standalone — the rig tooling drives faces with no mixer attached.
230
- * That behavioural contract is still the whole of what the *rig* needs:
231
- * everything else — visemes, emotions, gaze, idle, clips, the mixer — works
232
- * in parameter space and never learns which face it is driving.
392
+ * Roll is the one head axis with nothing under it: a turn recruits the trunk
393
+ * and a nod bends the neck, but a tilt on a 2.5-D head is a rotation about a
394
+ * point near the chin and every other pixel stays exactly where it was. That is
395
+ * the read reported as "a hinge". Anatomically a tilt is lower-cervical
396
+ * (docs/research-head-rotation.md §2.1) — spread down the neck, ending at the
397
+ * girdle — so the shoulder line tips a little with it, and that tip is what says
398
+ * the neck bent rather than the head swinging off a pin.
233
399
  *
234
- * `meta` is the avatar descriptor (viewBox, mouthCrop — see META in any face
235
- * module): the things a HOST or a TOOL needs to frame a face without opening
236
- * it. This registry was once factories-only, on the argument that a schema
237
- * guessed from two faces would be wrong; the third face settled it. Every rig
238
- * needed exactly a framing rect and a mouth rect to stop the tooling from
239
- * hard-coding per-avatar tables, and nothing else — so that is all meta
240
- * carries.
400
+ * Sized to stay an accompaniment: at the roll clamp the line tips about a
401
+ * quarter of the trunk's own share of a turn, which lifts a shoulder ~0.11 of
402
+ * its shrug — inside the band a weight shift already occupies. A third, the
403
+ * figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
404
+ * and read as a shrug arriving with the head.
241
405
  *
242
- * The key is the avatar's name, not its rank. It used to be possible to read
243
- * rank into it — the original rig was keyed `default`, which became a lie the
244
- * moment it stopped being the one we ship. DEFAULT_AVATAR below is the only
245
- * place the choice is made.
246
- *
247
- * Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
248
- * stakeholders accepted the line-art pair and rejected both of the others, so
249
- * carrying them was maintenance against art nobody wanted. What they taught
250
- * the abstraction survives them — `face-core.js` exists because all three of
251
- * the first rigs wrote the same apply(), and META exists because all three
252
- * needed the same two rects. Their code is in git history if a lesson ever
253
- * needs re-reading.
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.
254
409
  */
255
- export const AVATARS = {
256
- peep: { create: createPeepFace, meta: peepMeta },
257
- wren: { create: createWrenFace, meta: wrenMeta },
258
- myna: { create: createMynaFace, meta: mynaMeta },
259
- };
260
-
261
- export const AVATAR_NAMES = Object.keys(AVATARS);
262
-
263
- /** The avatar a host gets when it does not ask for one. */
264
- export const DEFAULT_AVATAR = 'peep';
410
+ export const SHOULDER_TILT = 0.08;
265
411
 
266
412
  export function createAvatar(opts = {}) {
267
413
  const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
268
414
  if (!mount) throw new Error('createAvatar: mount element required');
269
415
 
270
- // `opts.avatar` names one from AVATARS; `opts.face` passes a factory directly,
271
- // so a host can supply an avatar the rig has never heard of. A bare factory
272
- // has no descriptor, so meta falls back to what the svg itself declares.
273
- const entry = opts.face ? { create: opts.face } : AVATARS[opts.avatar || DEFAULT_AVATAR];
274
- if (!entry) {
275
- throw new Error(`createAvatar: unknown avatar "${opts.avatar}" (have: ${AVATAR_NAMES.join(', ')})`);
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.
421
+ const entry = opts.rig ? null : opts.face;
422
+ if (!opts.rig && !entry) {
423
+ throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
276
424
  }
277
- const face = entry.create(mount, opts.theme);
278
- const meta = entry.meta || (() => {
279
- const vb = (face.svg.getAttribute('viewBox') || '0 0 1 1').split(/[\s,]+/).map(Number);
280
- return { viewBox: { x: vb[0], y: vb[1], w: vb[2], h: vb[3] } };
281
- })();
425
+ const face = entry ? entry.create(mount, opts.theme) : null;
426
+ // A renderer-neutral rig needs no SVG descriptor: `meta` is what SVG hosts
427
+ // and tools frame the drawing with, never a requirement of the rig contract.
428
+ // A face carries its own — a `{ create }` with no META used to be tolerated
429
+ // here and the viewBox re-read off the produced svg, which meant a face could
430
+ // ship half a descriptor and nothing would say so.
431
+ const meta = face ? entry.meta : null;
282
432
  const gaze = new GazeLayer();
283
433
  const idle = new IdleLayer();
284
434
  const speech = new VisemeTrack();
435
+ const prosody = new SpeechProsody({ brows: opts.brows });
285
436
 
286
437
  let gazeOverrideByClip = null;
287
438
  const clip = new ClipPlayer({
288
439
  onGaze: (g) => { gazeOverrideByClip = g; applyGaze(); },
289
440
  onBlink: () => idle.blink(),
290
441
  });
291
- const backchannel = new ListeningEngine((id) => { interject(id); emit('backchannel', id); });
442
+ const engagement = new ListeningEngine();
292
443
  const performTrack = new PerformTrack();
293
- // The hand is a sibling of the mixer, not a layer inside it: it writes SVG
294
- // directly rather than parameter channels, because a hand at the frame edge is
295
- // not part of the rig's body (see hand.js, and CLAUDE.md constraint 9 for the
296
- // arm chain this replaces). `hand: false` opts out — for a face drawn in some
297
- // other idiom, or a tile too small to spend the pixels.
298
- const hand = opts.hand === false ? null : createHand(face.svg, face.theme, meta, { dir: opts.handSide });
444
+ // The current SVG hand is a renderer adapter for the first-class `frame.hand`
445
+ // control. `hand: false` only disables its SVG rendering; gesture actions
446
+ // still emit the semantic hand frame for a supplied custom rig.
447
+ 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
+ const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
299
452
 
300
- gaze.onLargeShift = () => idle.blink();
453
+ gaze.onLargeShift = (forced) => idle.blink(false, true, forced);
301
454
 
302
455
  const listeners = {
303
- state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [], gestureEnd: [],
456
+ state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
304
457
  };
305
458
  const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
306
459
  clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
307
- speech.onEnd = () => { emit('speakEnd'); };
460
+ speech.onEnd = () => { prosody.closeTurn(); emit('speakEnd'); };
308
461
  performTrack.onEnd = () => { emit('performEnd'); };
309
462
 
310
463
  // --- live state -----------------------------------------------------------
@@ -323,6 +476,59 @@ export function createAvatar(opts = {}) {
323
476
  // excursion changes. Values above ~1.5 saturate the open vowels against the
324
477
  // channel clamp, which is the intended ceiling rather than a bug.
325
478
  let mouthGain = opts.mouthGain ?? 1;
479
+ let handSide = opts.handSide === -1 ? 'left' : 'right';
480
+ /**
481
+ * This avatar's own addressable motions, on top of the two core intents.
482
+ *
483
+ * 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
486
+ * `ACKNOWLEDGE` to a server, because that is all a server can ask of every
487
+ * face; the *shapes* are sized in a rig's own units and belong to the rig
488
+ * (`packages/avatar/client/three/sequences.ts` is the first table of them).
489
+ *
490
+ * Deliberately not a registry and not a loader: it is a plain object the
491
+ * avatar module passes in, and it can only add. `ACTIONS` wins on a name
492
+ * collision, so no asset can quietly change what a core action means.
493
+ */
494
+ const sequences = opts.sequences || {};
495
+ /**
496
+ * This avatar's own *rendering* of an id this renderer already has — same id,
497
+ * same intent, a shape sized for its body.
498
+ *
499
+ * These are an intent vocabulary, and `ACK_NOD` is one implementation of
500
+ * "the avatar nodded", not a promise about anatomy (interjections.js). The
501
+ * shared clips are authored in pose units that mean pixels on a line face;
502
+ * on a rig whose unit is a degree the same keys can land outside what the
503
+ * gesture *means* — the shared nod renders 15° on tara, where a continuer is
504
+ * 3-5° — and scaling it would not fix it, because a continuer is one stroke
505
+ * where an agreement is two. So a renderer may re-author the shape.
506
+ *
507
+ * What it may not do is change the vocabulary: every id here must already be
508
+ * one this renderer publishes, so no avatar can add a word through this door
509
+ * or make one mean something else. `sequences` is the door for adding.
510
+ */
511
+ const actionShapes = opts.actions || {};
512
+ for (const id of Object.keys(actionShapes)) {
513
+ if (!ACTIONS[id]) throw new Error(`actions: ${id} is not one of this renderer's own`);
514
+ }
515
+ /**
516
+ * 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.
524
+ */
525
+ const states = { ...STATES };
526
+ for (const [id, own] of Object.entries(opts.states || {})) {
527
+ if (!STATES[id]) throw new Error(`states: ${id} is not a state`);
528
+ states[id] = { ...STATES[id], ...own };
529
+ }
530
+ let handAction = null;
531
+ const handQueue = [];
326
532
  // Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
327
533
  // peaks at 149ms — but the head smooths at a 160ms time constant, so barely
328
534
  // 60% of an authored peak is ever rendered. The keyframes were written against
@@ -336,19 +542,114 @@ export function createAvatar(opts = {}) {
336
542
  // than rendering it locally as SVG, where the motion costs nothing — turns
337
543
  // this down instead of the default being a body that does not move.
338
544
  idle.gain = opts.motionGain ?? 1;
545
+ // Speech-rhythm gains. A pose unit is a different angle on every rig — peep's
546
+ // head travels 17 px per unit of pitch, a mesh head turns a few degrees — so
547
+ // the rig's own module sets these once rather than the library guessing.
548
+ // Defaults to the uncalibrated scale; `prosody.js` has what that stands for.
549
+ const prosodyHeadGain = opts.prosodyHeadGain ?? UNCALIBRATED_HEAD_GAIN;
550
+ const prosodyFaceGain = opts.prosodyFaceGain ?? 1;
551
+ // Fixational-saccade gain, the same idea for the eyes: how far a scan
552
+ // step, a drift or a line of reading travels. Sized for a line face's
553
+ // pupils at 1; a photographic eye needs several times that to move at all.
554
+ // Aversions get their own gain: a look-away must read as one from across
555
+ // the call, where a fixation step must not, so one number cannot size both.
556
+ 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.
564
+ 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.
579
+ const headGain = typeof opts.headGain === 'number'
580
+ ? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
581
+ : { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
582
+ gaze.scanGain = saccadeGain;
583
+ gaze.avertGain = opts.aversionGain ?? 1;
584
+ // A rig that says what its pose units are in degrees gets the eye-head
585
+ // system sized for it (gaze.js): its own look targets, how an aversion
586
+ // 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.
589
+ const ocu = opts.oculomotor || {};
590
+ if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
591
+ if (ocu.avert) gaze.avertSplit = ocu.avert;
592
+ if (ocu.angles) gaze.angles = ocu.angles;
593
+ if (ocu.lidFollow) gaze.lidFollow = ocu.lidFollow;
594
+ if (ocu.head) { gaze.headAccel = ocu.head.accel; gaze.headSpeed = ocu.head.speed; }
595
+ // Pupil units of counter-rotation per head unit: the reflex's gain times
596
+ // the ratio of what one unit of each is in degrees. The gain may differ by
597
+ // axis ({x, y}): a face whose pitch reads weaker than its yaw wants less
598
+ // of the eyes' answer to it.
599
+ const vorGain = typeof ocu.vor === 'object' ? ocu.vor : { x: ocu.vor, y: ocu.vor };
600
+ const vor = ocu.vor && ocu.angles
601
+ ? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
602
+ y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
603
+ : 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.
611
+ const reach = ocu.range || { x: 1, up: 1, down: 1 };
612
+ const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
613
+ const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
614
+ // The head the eyes' target was authored against, smoothed at the eye's
615
+ // own tau so a shift's compensation moves with the saccade, not ahead of it.
616
+ const aim = { x: 0, y: 0 };
339
617
  let wanderAt = 0;
340
- let slowBlinkAt = 0;
618
+ let driftAt = 0;
619
+ let trunkYaw = 0;
620
+ let turnStartPending = false;
341
621
  let glanceAt = 0;
342
622
  let glanceUntil = 0;
623
+ let lastBack = null;
624
+ // THINKING's second look inside a look away: when it moves, where to, and
625
+ // how long the brows stay up for it.
626
+ let dartAt = 0, dartBrowUntil = 0;
627
+ const dart = { x: 0, y: 0 };
628
+ // THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
629
+ // when that handoff happened (it stands in for the turn-start look).
630
+ let returnAt = 0;
631
+ let carriedAt = -Infinity;
632
+ // The state whose gaze is showing, and when the current state takes it
633
+ // over if that is still pending (GAP_SETTLE). Usually the same state.
634
+ let gazeState = 'IDLE';
635
+ let settleAt = 0;
636
+ // Reading scan: position in the line and the block, see readStep.
637
+ let readCol = 0, readCols = 0, readRow = 0, readRows = 0;
638
+ let attendUntil = 0;
343
639
  let speakClock = null;
344
640
  let speakStart = 0;
345
641
 
346
642
  const cur = Object.assign({}, REST);
347
643
  const target = Object.assign({}, REST);
644
+ // The three head axes again, carrying only what is held (step 6b).
645
+ const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
646
+ // What the rig is handed: `cur` with the reflex applied to the eyes. The
647
+ // same object when there is no reflex.
648
+ const shown = vor ? Object.assign({}, REST) : cur;
348
649
 
349
- function applyGaze() {
650
+ function applyGaze(blink) {
350
651
  const g = gazeOverrideByClip || gazeName;
351
- gaze.set(g, gazeOverrideByClip ? null : gazeCustom);
652
+ gaze.set(g, gazeOverrideByClip ? null : gazeCustom, blink);
352
653
  }
353
654
 
354
655
  // --- the frame ------------------------------------------------------------
@@ -358,7 +659,8 @@ export function createAvatar(opts = {}) {
358
659
  // `manual` withholds the rAF loop so a tool can drive frames itself. The
359
660
  // baseline pages could already step a ClipPlayer by hand, but nothing could
360
661
  // step the *mixer* — which is where idle, gaze and engagement actually
361
- // compose — so motion had no reproducible render. See tools/motion.mjs.
662
+ // compose — so motion had no reproducible render. The headless motion map in
663
+ // the working tree is what steps it.
362
664
  const manual = !!opts.manual;
363
665
 
364
666
  function frame(now) {
@@ -376,23 +678,42 @@ export function createAvatar(opts = {}) {
376
678
  // firing this frame shapes this frame.
377
679
  performTrack.update();
378
680
 
379
- const st = STATES[stateName] || STATES.IDLE;
681
+ if (settleAt && elapsed >= settleAt) { settleAt = 0; enterGaze(stateName, {}, true); }
682
+ const st = states[stateName] || states.IDLE;
683
+ // What the eyes are doing belongs to the state that has the gaze.
684
+ const gst = states[gazeState] || st;
380
685
 
381
686
  // 1. base pose: rest + emotion + state-specific overlay
382
687
  for (const c of CHANNELS) target[c] = REST[c];
383
688
  const ep = emotionPose(emotion, emotionAmt);
384
689
  for (const k in ep) target[k] = REST[k] + ep[k];
385
690
  if (st.pose) for (const k in st.pose) target[k] = (target[k] || 0) + st.pose[k];
691
+ // The head the state and emotion hold is an attitude, and the eyes are
692
+ // authored inside it: the reflex (8b) keeps them on their target against
693
+ // everything that moves the head except this.
694
+ const poseYaw = target.headYaw, posePitch = target.headPitch;
695
+ // What the head *holds*, accumulated alongside the pose as each layer that
696
+ // holds one adds to it: the attitude above, the gaze, a phrase's pose and
697
+ // the idle posture. Strokes, beats and clip deltas stay out — see 6b.
698
+ for (const c of HEAD_AXES) hold[c] = target[c];
386
699
 
387
700
  // 2. gaze (absolute: pupils + partial head follow, plus the lid that rides
388
701
  // with the eye — looking down without it bares sclera and reads as alarm)
389
702
  const g = gaze.update(elapsed, dt);
390
703
  for (const k in g) {
391
- if (k === 'lidBias') continue;
704
+ if (k === 'trunkYaw' || k === 'aimYaw' || k === 'aimPitch') continue;
392
705
  target[k] = (k.startsWith('head') ? target[k] : 0) + g[k];
393
706
  }
394
- target.lidL += g.lidBias;
395
- target.lidR += g.lidBias;
707
+ for (const c of HEAD_AXES) hold[c] += g[c];
708
+ // The lid follows the eye as drawn, reflex and all — last frame's reading
709
+ // of it, since the head it corrects for is not smoothed yet.
710
+ const lid = gaze.lidBias(vor ? reflexY(g.pupilY) : g.pupilY);
711
+ target.lidL += lid;
712
+ target.lidR += lid;
713
+ if (vor) {
714
+ aim.x = approach(aim.x, poseYaw + g.aimYaw, TAU.pupilX, dt);
715
+ aim.y = approach(aim.y, posePitch + g.aimPitch, TAU.pupilY, dt);
716
+ }
396
717
 
397
718
  // 2b. the trunk follows the head. Sampled HERE, after gaze and before the
398
719
  // clip layer, on purpose: a sustained turn toward the screen recruits
@@ -400,50 +721,141 @@ export function createAvatar(opts = {}) {
400
721
  // with every gesture reads as a mannequin on a turntable. The lag is
401
722
  // not authored anywhere; torsoTurn simply chases the same target at
402
723
  // nearly 3x the head's time constant (TAU in params.js), so the trunk
403
- // leaves late and settles late for free.
404
- target.torsoTurn += target.headYaw * TRUNK_FOLLOW;
724
+ // leaves late and settles late for free. It follows where the head is
725
+ // going and not the looks riding on it, and it holds through a glance:
726
+ // checking on the user is a look, not a turn toward them.
727
+ if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
728
+ target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
405
729
 
406
730
  // 3. state-driven autonomous behaviour
407
- if (st.wander && elapsed > wanderAt) {
408
- const w = st.wander;
409
- wanderAt = elapsed + w.every[0] + Math.random() * (w.every[1] - w.every[0]);
410
- setGaze(w.targets[(Math.random() * w.targets.length) | 0]);
731
+ if (gst.wander && elapsed > wanderAt) {
732
+ const w = gst.wander;
733
+ // Dwell first, and sometimes for two periods. A dwell of exactly one
734
+ // period every time is the metronome the eye reads as mechanical. The
735
+ // old code broke that up by accident: a draw landing on the target
736
+ // already held left the eyes still for another period. The freeze was a
737
+ // bug — the schedule counted a hop that moved nothing — but the dwell
738
+ // variety it produced was not, so it is kept deliberately, at the rate
739
+ // it used to happen: 1/n, the odds a uniform draw repeats the target.
740
+ const linger = Math.random() < 1 / w.targets.length ? 2 : 1;
741
+ wanderAt = elapsed + linger * (w.every[0] + Math.random() * (w.every[1] - w.every[0]));
742
+ // Then look somewhere the eyes are not. A wander set names a target more
743
+ // than once deliberately — SEARCHING_SCREEN's repeats are its revisits,
744
+ // the hunt coming back to the middle — but a revisit is arriving
745
+ // somewhere again, not never having left it. Same bounded redraw as
746
+ // pickBack, so a one-target set still terminates.
747
+ let g = pick(w.targets);
748
+ for (let i = 0; i < 4 && g === gazeName; i++) g = pick(w.targets);
749
+ setGaze(g);
411
750
  }
412
- if (stateName === 'THINKING' && elapsed > slowBlinkAt) {
413
- slowBlinkAt = elapsed + 2.4 + Math.random() * 2.5;
414
- idle.slowBlink();
751
+ // Periodic glance (WORKING's look-up-at-you beat, THINKING's check-in
752
+ // between two looks away). The return leg goes to the state's own gaze, or
753
+ // to a fresh pick from `back` — a thinker does not look away at the same
754
+ // spot every time. Either leg's gaze-evoked blink is the shift's odds
755
+ // unless the state says (`blinkTo`, `blinkBack`), because which leg blinks
756
+ // is part of what the glance means.
757
+ if (returnAt && elapsed > returnAt) {
758
+ returnAt = 0;
759
+ setGaze(gst.gaze);
415
760
  }
416
- // Periodic glance (TYPING's look-up-at-you beat). The return leg goes back
417
- // to the state's own gaze; the gaze layer's large-shift blink fires on
418
- // both legs for free, which is exactly the blink a real glance carries.
419
- if (st.glance) {
420
- const gl = st.glance;
761
+ if (gst.glance && !returnAt) {
762
+ const gl = gst.glance;
421
763
  if (glanceUntil && elapsed > glanceUntil) {
422
764
  glanceUntil = 0;
423
- glanceAt = elapsed + gl.every[0] + Math.random() * (gl.every[1] - gl.every[0]);
424
- setGaze(st.gaze);
765
+ const away = rand(gl.every);
766
+ glanceAt = elapsed + away;
767
+ setGaze(gl.back ? pickBack(gl.back, gl.stick) : gst.gaze, null, gl.blinkBack);
768
+ // At most one second look per look away, somewhere in its middle: a
769
+ // move at the start is the same look landing, and one at the end runs
770
+ // into the glance back.
771
+ dartAt = gl.dart && Math.random() < gl.dart.p ? elapsed + away * (0.35 + Math.random() * 0.3) : 0;
425
772
  } else if (!glanceUntil && elapsed > glanceAt) {
426
- glanceUntil = elapsed + gl.hold[0] + Math.random() * (gl.hold[1] - gl.hold[0]);
427
- setGaze(gl.to);
773
+ glanceUntil = elapsed + rand(gl.hold);
774
+ setGaze(gl.to, null, gl.blinkTo);
775
+ dartAt = 0;
776
+ dart.x = dart.y = 0;
777
+ }
778
+ if (dartAt && elapsed > dartAt) {
779
+ // On from where the eyes are, not back toward the user or across to
780
+ // the other side: the thought moves, it does not turn round.
781
+ dartAt = 0;
782
+ const t = gaze.target;
783
+ const a = Math.atan2(t.py, t.px) + (Math.random() < 0.5 ? -1 : 1) * DART_TURN;
784
+ const m = gl.dart.mag * saccadeGain;
785
+ dart.x = Math.cos(a) * m;
786
+ dart.y = Math.sin(a) * m;
787
+ dartBrowUntil = elapsed + DART_BROW;
428
788
  }
789
+ const brow = (glanceUntil ? gl.brow || 0 : 0)
790
+ + (gl.dart && elapsed < dartBrowUntil ? gl.dart.brow : 0);
791
+ target.browRaiseL += brow;
792
+ target.browRaiseR += brow;
793
+ }
794
+ gaze.scanEvery = gst.scan || null;
795
+ // A state that reads scans a line; one with a dart holds its second look.
796
+ // Neither during a glance: a check-in on the user is steady or it is not
797
+ // one.
798
+ if (gst.read && !glanceUntil) {
799
+ if (elapsed > driftAt) readStep(gst.read);
800
+ } else {
801
+ gaze.drift.x = glanceUntil ? 0 : dart.x;
802
+ gaze.drift.y = glanceUntil ? 0 : dart.y;
803
+ }
804
+ // Aversion is a property of the state, but it is held off around a turn
805
+ // boundary: the floor is handed over under mutual gaze, and an avatar that
806
+ // looks away exactly as the user finishes has declined it. `attend` is the
807
+ // mixer's one-frame veto — anything that means "the user is checking
808
+ // whether I am with them" sets it (see api.attend).
809
+ gaze.setAversion(gst.aversion ? AVERSION[gst.aversion] : null);
810
+ // A speaking turn ends under mutual gaze: no look in the last 2.4 s of the
811
+ // cue track as far as it has arrived, nor once it has run out (§4.2). The
812
+ // mid-turn looks wait for a silent cue, which is a phrase boundary. Both
813
+ // read last frame's sample, a frame late and harmlessly so.
814
+ let floorReturn = false;
815
+ gaze.gate = true;
816
+ if (gst.aversion === 'SPEAK') {
817
+ const cues = speech.cues;
818
+ floorReturn = !speech.playing || !cues.length
819
+ || cues[cues.length - 1].t - speech.now < FLOOR_RETURN_MS;
820
+ const cue = cues[speech.index];
821
+ gaze.gate = !!cue && cue.v === SILENT;
429
822
  }
430
- backchannel.enabled = !!st.backchannel && !clip.playing;
431
- backchannel.update(dt);
823
+ gaze.hold = attendUntil > elapsed || clip.playing || floorReturn;
824
+ if (turnStartPending) {
825
+ turnStartPending = false;
826
+ // A reply that began mid-think has had its planning look already.
827
+ if (elapsed - carriedAt > 1.5) gaze.startTurn();
828
+ }
829
+
830
+ engagement.enabled = !!st.engagement && !clip.playing;
831
+ engagement.update(dt);
432
832
  // Engagement posture: forward lean while the user holds the floor, spent
433
833
  // only in the states that are *about* the user holding the floor. The
434
834
  // research (docs/research-biomechanics.md §6.3) puts sustained attentive
435
835
  // lean at +0.15–0.25; engage glides, and torsoLean's 0.24s tau smooths
436
836
  // the state gate, so the lean arrives and leaves like weight shifting.
437
- if (st.backchannel) target.torsoLean += 0.16 * backchannel.engage;
837
+ if (st.engagement) {
838
+ target.torsoLean += 0.16 * engagement.engage;
839
+ // The shoulders come with it. A lean is a whole upper body arriving, and
840
+ // in a head-and-shoulders crop the shoulder line is the part of it that
841
+ // is actually on screen — §6.1 calls a shoulder rise the most legible
842
+ // thing this framing can draw. Without this the listening shoulders were
843
+ // the posture shift's ±0.07 and nothing else, so the one channel that
844
+ // could show attention sat still through every turn the user took.
845
+ // It rides `engage`, so it is contingent on the user's voice rather than
846
+ // on the state: quick in, slow out, and gone a few seconds into silence.
847
+ target.shoulderL += ENGAGE_SHOULDER * engagement.engage;
848
+ target.shoulderR += ENGAGE_SHOULDER * engagement.engage;
849
+ }
438
850
  // Straining leans harder while there is actually a faint voice to strain
439
851
  // after. engage already tracks "the user is (barely) talking", so this
440
852
  // costs nothing; with no user signal the static pose carries the state.
441
- else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * backchannel.engage;
853
+ else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * engagement.engage;
442
854
 
443
855
  // 4. mouth. The server's viseme track wins; a clip's mouth track fills the
444
856
  // gaps. There is deliberately no third leg: with no cues the mouth stays
445
857
  // shut, and a still mouth under speech is the *visible* symptom of a
446
- // backend that could not align — see docs/removed.md § Amplitude lipsync.
858
+ // backend that could not align.
447
859
  const clipOut = clip.update(dtMs);
448
860
  let mouth = speech.sample();
449
861
  let mouthOwner = mouth ? 'speech' : null;
@@ -473,6 +885,57 @@ export function createAvatar(opts = {}) {
473
885
  }
474
886
  }
475
887
 
888
+ // 4b. speech prosody: pause blinks, inbreaths, a head held per phrase and
889
+ // moved between them, beats and turn-edge warmth, read off the cue
890
+ // track (prosody.js has the research, head.js the hold-and-move). Off while a clip is gesturing, since the clip is
891
+ // already the head's and the brows' story. The warmth is withheld
892
+ // under an emotion whose corners are down: a smile starting a turn of
893
+ // concern is the wrong face, whatever the rhythm says.
894
+ const pro = prosody.update(speech, mouthOwner === 'speech' && !clip.playing, dt);
895
+ // The warmth rides over a clip, because an acknowledgement's smile is
896
+ // *with* its nod: behind the clip gate it arrived as the nod finished, a
897
+ // smile at nothing. It is withheld under a clip whose own corners go down
898
+ // (a shake, a sorry) for the same reason as under a concerned emotion.
899
+ const clipFrowns = !!clipOut.delta
900
+ && Math.min(clipOut.delta.mouthCornerL || 0, clipOut.delta.mouthCornerR || 0) < 0;
901
+ const warm = clipFrowns || ((EMOTIONS[emotion] || EMOTIONS.neutral).mouthCornerL || 0) < 0
902
+ ? 0 : prosodyFaceGain;
903
+ target.mouthCornerL += pro.mouthCornerL * warm;
904
+ target.mouthCornerR += pro.mouthCornerR * warm;
905
+ target.squintL += pro.squintL * warm;
906
+ target.squintR += pro.squintR * warm;
907
+ if (!clip.playing) {
908
+ target.headPitch += pro.headPitch * prosodyHeadGain * headGain.headPitch;
909
+ target.headYaw += pro.headYaw * prosodyHeadGain * headGain.headYaw;
910
+ target.headRoll += pro.headRoll * prosodyHeadGain * headGain.headRoll;
911
+ for (const c of HEAD_AXES) hold[c] += pro.hold[c] * prosodyHeadGain * headGain[c];
912
+ // The trunk follows a speech pose the way it follows a gaze turn (2b),
913
+ // and for a second reason on a 2.5-D head: a turn the shoulders take
914
+ // part of is a turn the neck does not have to stretch for, and the
915
+ // stretch is the one thing a video reviewer saw on every pose change.
916
+ // Scaled with the yaw it follows: a trunk that kept its old share of a
917
+ // wider turn would be the neck stretching again, which is the defect
918
+ // this line exists to fix.
919
+ target.torsoTurn += pro.trunkYaw * prosodyHeadGain * headGain.headYaw * TRUNK_FOLLOW;
920
+ target.browRaiseL += pro.browRaiseL * prosodyFaceGain;
921
+ target.browRaiseR += pro.browRaiseR * prosodyFaceGain;
922
+ target.browInnerL += pro.browInnerL * prosodyFaceGain;
923
+ target.browInnerR += pro.browInnerR * prosodyFaceGain;
924
+ target.browAngleL += pro.browAngleL * prosodyFaceGain;
925
+ target.browAngleR += pro.browAngleR * prosodyFaceGain;
926
+ target.lidL += pro.lidL * prosodyFaceGain;
927
+ target.lidR += pro.lidR * prosodyFaceGain;
928
+ target.breath += pro.breath * idle.gain;
929
+ // The trunk's share of speech rhythm. Scaled by `idle.gain` and not by
930
+ // `prosodyHeadGain`, because this is body liveness and that is the knob a
931
+ // host turns down when it is re-encoding the avatar into an outgoing
932
+ // stream — the same factor `idle.js`'s shoulders and lean already take.
933
+ target.shoulderL += pro.shoulderL * idle.gain;
934
+ target.shoulderR += pro.shoulderR * idle.gain;
935
+ target.torsoLean += pro.torsoLean * idle.gain;
936
+ }
937
+ if (pro.blink) idle.phraseBlink();
938
+
476
939
  // 5. gesture deltas (additive, so a nod survives whatever else is happening)
477
940
  if (clipOut.delta) {
478
941
  for (const k in clipOut.delta) {
@@ -489,7 +952,40 @@ export function createAvatar(opts = {}) {
489
952
  idle.talk = approach(idle.talk, mouthOwner ? 1 : 0, 0.25, dt);
490
953
  idle.setProfile(st.idle);
491
954
  const il = idle.update(dt);
492
- for (const k in il.add) target[k] = (target[k] || 0) + il.add[k];
955
+ for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
956
+ for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
957
+
958
+ // 6b. the held-head budget. Every layer above holds its own small pose and
959
+ // they are independent, so now and then they all point the same way and
960
+ // the head arrives somewhere no one layer asked for and the face cannot
961
+ // go. Only the excess over `soften` comes off, and only off the hold:
962
+ // a nod, a beat and a clip keep every degree they were authored with,
963
+ // which is why this is subtracted here rather than applied to the pose.
964
+ for (const c of HEAD_AXES) {
965
+ if (headHold[c] === undefined) continue;
966
+ target[c] -= hold[c] - soften(hold[c], headHold[c]);
967
+ }
968
+
969
+ // 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
970
+ // same share of it that it takes of a turn in 2b — Live2D gives its
971
+ // body the same fraction of AngleZ as of AngleX (research-head-rotation
972
+ // .md §3) — and the shoulder line tips with the head. Both channels are
973
+ // slower than the head (TAU: 0.44 and 0.19 against 0.16), so the body
974
+ // leaves late and settles late, and that follow-through is most of what
975
+ // separates a neck bending from a hinge.
976
+ // Only the *held* roll, and after the budget: a stroke or a clip's roll
977
+ // is a gesture riding on the pose, and a body that answers those is 2b's
978
+ // mannequin on a turntable. The idle layer's own posture is already
979
+ // coupled the other way round, from the weight shift to the head that
980
+ // counter-tips on it (idle.js `nextPosture`); this is that arrangement
981
+ // read from the head's end, and where both are in play they agree in
982
+ // sign — weight onto a side, head over that side, that shoulder up.
983
+ const heldRoll = headHold.headRoll === undefined
984
+ ? hold.headRoll
985
+ : soften(hold.headRoll, headHold.headRoll);
986
+ target.torsoTurn += heldRoll * TRUNK_FOLLOW;
987
+ target.shoulderR += heldRoll * SHOULDER_TILT;
988
+ target.shoulderL -= heldRoll * SHOULDER_TILT;
493
989
 
494
990
  // 7. clamp, then blink wins outright over whatever the lids were doing
495
991
  for (const c of CHANNELS) {
@@ -506,17 +1002,32 @@ export function createAvatar(opts = {}) {
506
1002
  // 8. smooth toward the target — this is where co-articulation happens
507
1003
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
508
1004
 
509
- face.apply(cur);
510
-
511
- // 9. the hand, outside all of the above. It writes a transform on its own
512
- // group rather than parameter channels, so it neither smooths nor
513
- // composes — its timelines are authored as delivered motion, not as
514
- // targets to chase. `elapsed` is the mixer's own clock, so a manual
515
- // stepper gets a reproducible gesture for free.
516
- if (hand) {
517
- const done = hand.update(elapsed * 1000);
518
- if (done) emit('gestureEnd', done.id);
1005
+ // 8b. The vestibulo-ocular reflex. Eyes held still in a moving head look
1006
+ // wherever the head points, so without this every nod, speech pose and
1007
+ // sway was also a small look somewhere else — and on a face whose head
1008
+ // turns further than its eyes, a look at a different spot each time.
1009
+ // Real eyes counter-rotate against the head within ~10 ms and stay on
1010
+ // what they look at. Here and not in the gaze layer because only here
1011
+ // is the head that is actually drawn known: prosody, clips and idle all
1012
+ // land after gaze. It also gives a large shift its real shape for free:
1013
+ // the eyes jump to the target, past where they will sit, and roll back
1014
+ // in the head as it arrives under them. A pupil a tuning UI overrides is
1015
+ // left where it was put.
1016
+ if (vor) {
1017
+ Object.assign(shown, cur);
1018
+ if (!overrides || overrides.pupilX === undefined) {
1019
+ shown.pupilX = clamp(reflexX(cur.pupilX), RANGE.pupilX[0], RANGE.pupilX[1]);
1020
+ }
1021
+ if (!overrides || overrides.pupilY === undefined) {
1022
+ shown.pupilY = clamp(reflexY(cur.pupilY), RANGE.pupilY[0], RANGE.pupilY[1]);
1023
+ }
519
1024
  }
1025
+
1026
+ // 9. First-class hand control. The semantic frame is generated here, above
1027
+ // every renderer, so SVG, WebGL, and video rigs receive exactly the same
1028
+ // gesture/progress information. A handless rig simply ignores `frame.hand`.
1029
+ const handFrame = updateHandAction(elapsed * 1000);
1030
+ rig.apply(avatarFrame(shown, handFrame || undefined));
520
1031
  }
521
1032
 
522
1033
  const REST_SHAPE = shapeFor(SILENT, 1);
@@ -524,8 +1035,16 @@ export function createAvatar(opts = {}) {
524
1035
  // How much of a sustained head turn the trunk takes up. Well under 1: people
525
1036
  // under-rotate the head and then under-rotate the trunk again behind it, and
526
1037
  // at a head-and-shoulders crop the trunk's share is the part you register
527
- // without noticing.
528
- const TRUNK_FOLLOW = 0.45;
1038
+ // without noticing. A rig may pass its own `trunkFollow`: on a mesh head whose
1039
+ // neck twists, the trunk's sway is most of what moves the neck's outline, so
1040
+ // the share that reads as a body on a line face reads there as the neck
1041
+ // sliding.
1042
+ const TRUNK_FOLLOW = opts.trunkFollow ?? 0.45;
1043
+
1044
+ // Speakers are back on the listener's eyes this long before they stop
1045
+ // (Andrist, docs/research-biomechanics.md §4.2): the floor is handed over
1046
+ // under mutual gaze, and a look away there declines it.
1047
+ const FLOOR_RETURN_MS = 2400;
529
1048
 
530
1049
  // The channels speech owns outright — exactly the params.js mouth group
531
1050
  // (mouth corners stay free: a clip may smile over a sentence).
@@ -535,39 +1054,144 @@ export function createAvatar(opts = {}) {
535
1054
  // ~a third keeps the face warm without the corners fighting the visemes;
536
1055
  // full warmth returns the moment the track ends, which is exactly the
537
1056
  // episodic onset/offset a credible smile needs (research-perception.md §3).
538
- const SPEAK_SMILE_RETAIN = 0.35;
1057
+ /** The shoulders' share of the attentive posture, against `torsoLean`'s 0.16.
1058
+ * Two thirds of the lean: the shoulders come up with it, they do not lead it. */
1059
+ const ENGAGE_SHOULDER = 0.10;
1060
+ const SPEAK_SMILE_RETAIN = 0.35;
1061
+
1062
+ // How long a reply that starts mid-think keeps the look before it comes
1063
+ // back. Speakers look away to find the words and back to deliver them
1064
+ // (§4.2); a reply that snapped to the user on its first syllable and then,
1065
+ // three times in four, left again for the turn-start look did both halves
1066
+ // twice.
1067
+ const THINK_CARRY = [0.25, 0.6];
1068
+
1069
+ // Between the user's turn and the reply the server's claim can change
1070
+ // several times a second — THINKING, a tool's WORKING, THINKING again, a
1071
+ // grace timer's CANT_HEAR — and every change used to retarget the eyes and
1072
+ // restart the state's looks: one fast tool call was away, down at the
1073
+ // screen, back to the user and away again inside a second, a blink at each.
1074
+ // Among these states the eyes change over only once the new one has held
1075
+ // this long, and a claim that returns before then moves nothing. The pose
1076
+ // still changes at once — it is the eyes that make a flicker visible.
1077
+ // SPEAKING and LISTENING are never held back: those are the floor.
1078
+ const GAP_STATES = new Set(['THINKING', 'WORKING', 'CANT_HEAR']);
1079
+ const GAP_SETTLE = 0.5;
1080
+
1081
+ // A dart leaves at up to 40° off the line of the look it is inside, and
1082
+ // lifts the brows for half a second — a thought arriving, not a stare.
1083
+ const DART_TURN = 0.7;
1084
+ const DART_BROW = 0.5;
1085
+
1086
+ /** A look away for the return leg of a glance. `stick` of the time it keeps
1087
+ * the side the last one took, spot and all — the side a person thinks
1088
+ * toward is theirs, and it outlasts one thinking pause. Otherwise any look
1089
+ * but the one just taken: a thinker who goes back to the same patch of
1090
+ * wall every time *without* meaning to is a loop. */
1091
+ function pickBack(xs, stick = 0) {
1092
+ const side = (n) => Math.sign(gaze.targets[n].px);
1093
+ let g;
1094
+ if (lastBack && Math.random() < stick) {
1095
+ const same = xs.filter((n) => side(n) === side(lastBack));
1096
+ g = pick(same.length ? same : xs);
1097
+ } else {
1098
+ g = pick(xs);
1099
+ for (let i = 0; i < 4 && g === lastBack; i++) g = pick(xs);
1100
+ }
1101
+ lastBack = g;
1102
+ return g;
1103
+ }
1104
+
1105
+ /**
1106
+ * One fixation of a reading scan: a step along the line, or at its end a
1107
+ * sweep back to the start of the next, or at the end of the block a pause
1108
+ * on the result. That is what busy-at-a-screen looks like from the other
1109
+ * side of a webcam — an irregular left-to-right march with returns, which
1110
+ * a random drift about a point is not. Steps are held, like a reader's
1111
+ * fixations, and the head sits out all of it (DRIFT_HEAD in gaze.js).
1112
+ */
1113
+ function readStep(r) {
1114
+ const n = ([a, b]) => a + ((Math.random() * (b - a + 1)) | 0);
1115
+ if (readCol < readCols) {
1116
+ readCol++;
1117
+ driftAt = elapsed + rand(r.fix);
1118
+ } else {
1119
+ readCol = 0;
1120
+ readCols = n(r.steps);
1121
+ if (++readRow >= readRows) {
1122
+ readRow = 0;
1123
+ readRows = n(r.lines);
1124
+ driftAt = elapsed + rand(r.pause);
1125
+ } else {
1126
+ driftAt = elapsed + rand(r.fix) * 1.4;
1127
+ }
1128
+ }
1129
+ gaze.drift.x = (readCol / readCols - 0.5) * r.width * saccadeGain;
1130
+ gaze.drift.y = (readRow - (readRows - 1) / 2) * r.line * saccadeGain;
1131
+ }
539
1132
 
540
1133
  // --- API ------------------------------------------------------------------
541
1134
 
542
1135
  function setState(name, o = {}) {
543
- if (!STATES[name]) throw new Error(`unknown state: ${name}`);
1136
+ if (!states[name]) throw new Error(`unknown state: ${name}`);
544
1137
  const changed = name !== stateName;
545
1138
  stateName = name;
546
- const st = STATES[name];
1139
+ const st = states[name];
547
1140
  if (o.emotion !== undefined) emotion = o.emotion;
548
1141
  else if (changed) emotion = st.emotion;
549
1142
  if (o.intensity !== undefined) emotionAmt = o.intensity;
550
-
551
- if (!o.keepGaze) setGaze(o.gaze || st.gaze);
552
1143
  idle.setProfile(st.idle);
553
- // Arm the glance scheduler fresh so entering a glancing state doesn't
554
- // fire a stale timestamp immediately.
555
- glanceUntil = 0;
556
- glanceAt = elapsed + (st.glance ? st.glance.every[0] + Math.random() * (st.glance.every[1] - st.glance.every[0]) : 0);
557
- backchannel.reset(name === 'LISTENING' ? 2.2 : 4);
558
- face.svg.style.filter = st.filter || '';
559
- face.svg.style.transition = 'filter .5s ease';
560
- if (changed) { idle.blink(); emit('state', name); }
1144
+ // SVG's desaturation filter is a legacy renderer detail. A generic rig
1145
+ // receives the same state pose and may express degradation its own way.
1146
+ if (face) {
1147
+ face.svg.style.filter = st.filter || '';
1148
+ face.svg.style.transition = 'filter .5s ease';
1149
+ }
1150
+ // The eyes wait out a flicker between gap states (GAP_SETTLE), and a
1151
+ // repeat of the state still settling leaves it settling.
1152
+ const deferrable = !o.gaze && !o.keepGaze && GAP_STATES.has(gazeState) && GAP_STATES.has(name);
1153
+ if (deferrable && changed) settleAt = name === gazeState ? 0 : elapsed + GAP_SETTLE;
1154
+ else if (!(deferrable && settleAt)) { settleAt = 0; enterGaze(name, o, changed); }
1155
+ if (changed) emit('state', name);
561
1156
  return api;
562
1157
  }
563
1158
 
1159
+ /** The state `name` takes the gaze: its target and its schedules. */
1160
+ function enterGaze(name, o, blink) {
1161
+ const prev = gazeState;
1162
+ gazeState = name;
1163
+ const st = states[name];
1164
+ const gl = st.glance;
1165
+ // A reply that starts while THINKING is looking away finishes that look
1166
+ // first, and the look stands in for the turn-start one.
1167
+ const carry = prev === 'THINKING' && name === 'SPEAKING'
1168
+ && !o.gaze && !o.keepGaze && gazeName !== st.gaze && !glanceUntil;
1169
+ returnAt = carry ? elapsed + rand(THINK_CARRY) : 0;
1170
+ if (carry) carriedAt = elapsed;
1171
+ // A state with an `opening` enters as though its check-in on the user is
1172
+ // already under way, and leaves it when that runs out.
1173
+ if (!o.keepGaze && !carry) setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze));
1174
+ // Arm every scheduler fresh, so entering a state never fires a timestamp
1175
+ // left over from the last one — the wander in particular, which used to
1176
+ // pick a new target on the first frame and override the state's own gaze.
1177
+ glanceUntil = gl && gl.opening && !o.gaze && !o.keepGaze ? elapsed + rand(gl.opening) : 0;
1178
+ glanceAt = elapsed + (gl ? rand(gl.every) : 0);
1179
+ wanderAt = elapsed + (st.wander ? rand(st.wander.every) : 0);
1180
+ driftAt = elapsed + (st.read ? rand(st.read.fix) : 0);
1181
+ readCol = readCols = readRow = readRows = 0;
1182
+ dartAt = dartBrowUntil = 0;
1183
+ dart.x = dart.y = 0;
1184
+ if (blink) idle.blink(false, true);
1185
+ }
1186
+
564
1187
  function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
565
1188
 
566
- /** @param {string} name @param {{x:number,y:number}} [custom] normalized -1..1 */
567
- function setGaze(name, custom) {
1189
+ /** @param {string} name @param {{x:number,y:number}} [custom] normalized -1..1
1190
+ * @param {boolean} [blink] the mixer's own say over the evoked blink; see GazeLayer.set */
1191
+ function setGaze(name, custom, blink) {
568
1192
  gazeName = GAZE_TARGETS[name] ? name : 'USER';
569
1193
  gazeCustom = custom || null;
570
- applyGaze();
1194
+ applyGaze(blink);
571
1195
  return api;
572
1196
  }
573
1197
 
@@ -578,8 +1202,13 @@ export function createAvatar(opts = {}) {
578
1202
  * @param {() => number} [o.clock] custom ms clock, if you drive audio yourself
579
1203
  */
580
1204
  function speak(o = {}) {
581
- // A spoken interjection must die before real speech starts.
582
- if (clip.playing && clip.clip.mouthCues) clip.stop();
1205
+ // Speech owns the mouth in the mixer, but a server action still gets to
1206
+ // complete its physical landing on the other channels. Do not cancel it
1207
+ // here: a hand or head cannot disappear simply because playout began.
1208
+ // A new clock is a new turn. The same clock handed back is the accurate
1209
+ // leg rewriting this one (AvatarClient re-speaks a splice), and that must
1210
+ // not roll the turn-start look a second time.
1211
+ const newTurn = !o.clock || o.clock !== speakClock;
583
1212
  speakStart = performance.now();
584
1213
  speakClock = o.clock
585
1214
  ? o.clock
@@ -587,20 +1216,55 @@ export function createAvatar(opts = {}) {
587
1216
  ? () => o.audio.currentTime * 1000
588
1217
  : () => performance.now() - speakStart;
589
1218
  speech.start(o.cues || [], speakClock);
1219
+ prosody.reset(newTurn);
1220
+ if (newTurn) turnStartPending = true;
590
1221
  if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: true });
591
1222
  if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
592
1223
  return api;
593
1224
  }
594
1225
 
1226
+ /**
1227
+ * "The user may be checking whether I am with them — hold their eyes."
1228
+ *
1229
+ * This is the widget's half of the **gaze window**. In face-to-face talk a
1230
+ * speaker periodically looks at the listener, mutual gaze is established, the
1231
+ * listener responds inside that window, and the speaker looks away again
1232
+ * (Bavelas, Coates & Johnson 2002) — listener responses cluster inside the
1233
+ * window rather than being scattered across the turn.
1234
+ *
1235
+ * We cannot see the user, so we cannot observe the window opening. What a
1236
+ * caller *can* do is name the moments that co-occur with it — a mid-turn
1237
+ * pause, a tag question ("...right?", "you know?"), a completed clause with
1238
+ * the turn analyzer's completion probability high, the user answering a
1239
+ * question the bot asked. `attend(ms)` is how those arrive: for its duration
1240
+ * the face stops averting and holds the user, which is the prerequisite for
1241
+ * any response to be *seen*. Emitting the response itself stays a separate
1242
+ * call — a window that opens and draws nothing is a real and common outcome
1243
+ * (with every measured invitation cue present, humans respond to only ~30% of
1244
+ * opportunities), and conflating the two would make the avatar answer
1245
+ * everything. The explicit response remains a backend/application decision.
1246
+ *
1247
+ * Deliberately **not on the wire yet**: there is no `attend` command in
1248
+ * `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
1249
+ * the rig pages). Adding the command is a protocol change and waits for a
1250
+ * server that has something real to key it off — see docs/internal-mixer.md.
1251
+ *
1252
+ * @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
1253
+ * mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
1254
+ */
1255
+ function attend(ms = 1200) {
1256
+ attendUntil = Math.max(attendUntil, elapsed + ms / 1000);
1257
+ return api;
1258
+ }
1259
+
595
1260
  function pushCues(cues) { speech.push(cues); return api; }
596
1261
 
597
1262
  function stopSpeaking() { speech.stop(); return api; }
598
1263
 
599
1264
  function interject(id) {
600
- const c = INTERJECTIONS[id];
1265
+ const c = INTERNAL_CLIPS[id];
601
1266
  if (!c) throw new Error(`unknown interjection: ${id}`);
602
1267
  clip.play(c, c.audioEl);
603
- backchannel.reset(3.5);
604
1268
  return api;
605
1269
  }
606
1270
 
@@ -610,34 +1274,97 @@ export function createAvatar(opts = {}) {
610
1274
  *
611
1275
  * The face half is not a convenience — a hand rising to the jaw over a head
612
1276
  * and shoulders sitting perfectly still is a cut-out, not a gesture. Each
613
- * entry in HAND_GESTURES names an interjection that already exists and was
614
- * already tuned (`WAVE`, `THUMBS_UP`, `ONE_MOMENT`); firing it here is the
1277
+ * entry in HAND_GESTURES names the matching semantic face action; firing it here is the
615
1278
  * library composing two authored things, not the client inventing motion.
616
1279
  *
617
- * The two halves stay separable in both directions. `interject('WAVE')` is
618
- * still the face alone and is unchanged by this — a server that upgrades gets
619
- * no new behaviour until it asks for one — and on an avatar mounted with
620
- * `hand: false` this call degrades to exactly that interjection, which is the
621
- * same graceful failure the arm removal already forced every id through.
1280
+ * On an avatar mounted with `hand: false` this internal helper degrades to
1281
+ * the face action alone.
622
1282
  */
623
1283
  function gesture(id) {
624
1284
  const def = HAND_GESTURES[id];
625
1285
  if (!def) throw new Error(`unknown hand gesture: ${id}`);
626
1286
  if (hand) hand.play(id, elapsed * 1000);
627
1287
  if (def.face) interject(def.face);
628
- // A hand in frame is a deliberate move; a backchannel landing on top of it
629
- // is the listening engine talking over the server.
630
- backchannel.reset(def.dur / 1000 + 0.5);
631
1288
  return api;
632
1289
  }
633
1290
 
1291
+ /** One self-completing server action. State continues to resolve underneath;
1292
+ * face and hand layers queue their next movement so an in-flight physical
1293
+ * gesture always gets to land. */
1294
+ function action(wireId) {
1295
+ // `ACKNOWLEDGE` is the whole backchannel family in one word, and which of
1296
+ // them a face makes is a rendering decision the server is not holding the
1297
+ // drawing for. The floor decides: a nod while the user still has it is a
1298
+ // continuer, "go on"; once they have stopped it is a receipt, and a nod
1299
+ // there reads as agreement with whatever they just said. Both are only ever
1300
+ // this explicit action — nothing here starts one on its own.
1301
+ const id = wireId === 'ACKNOWLEDGE'
1302
+ ? (engagement.speaking ? 'ACK_NOD' : 'ACK_RECEIVE')
1303
+ : wireId;
1304
+ const handDef = HAND_GESTURES[id];
1305
+ if (handDef) {
1306
+ startHandAction(id, handDef);
1307
+ if (handDef.face) {
1308
+ const faceClip = actionShapes[handDef.face] || ACTIONS[handDef.face];
1309
+ if (faceClip) clip.play(faceClip, faceClip.audioEl, { queue: true });
1310
+ }
1311
+ return api;
1312
+ }
1313
+ // Own renderings first, always. An avatar may *add* to what a server can
1314
+ // ask for and may never redefine a core intent: `ACKNOWLEDGE` has to mean
1315
+ // the same thing on every face or it is not a protocol. It may draw that
1316
+ // meaning in its own shape (`actionShapes`), which is a rendering, not a
1317
+ // redefinition — the id and the intent are fixed before this line.
1318
+ //
1319
+ // Unknown is a no-op, not a throw. The wire's action vocabulary is open, so
1320
+ // a server asking this face for a motion it does not have is the expected
1321
+ // case and not somebody's broken build — the same forward-compat rule an
1322
+ // unknown `cmd` gets. This is also where a sequence used to arrive by its
1323
+ // own method; one open vocabulary means one door.
1324
+ const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
1325
+ if (!faceClip) return api;
1326
+ clip.play(faceClip, faceClip.audioEl, { queue: true });
1327
+ // The two acknowledgements smile. Only these, and only ever because the
1328
+ // server sent one: a smile the renderer timed for itself would be an
1329
+ // acknowledgement nobody sent.
1330
+ if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
1331
+ // Now that warmth rides over clips, the reply's opening smile would
1332
+ // otherwise carry on through the interrupted face.
1333
+ if (id === 'RESPONSE_INTERRUPTED') prosody.cool();
1334
+ return api;
1335
+ }
1336
+
1337
+ function startHandAction(id, def) {
1338
+ const gesture = HAND_ACTION_TO_FRAME_GESTURE[id];
1339
+ if (!gesture) return;
1340
+ if (handAction) {
1341
+ if (handAction.id !== id && !handQueue.some((item) => item.id === id)) {
1342
+ handQueue.push({ id, def, gesture });
1343
+ }
1344
+ return;
1345
+ }
1346
+ handAction = { id, def, gesture, start: elapsed * 1000 };
1347
+ }
1348
+
1349
+ function updateHandAction(nowMs) {
1350
+ if (!handAction) return null;
1351
+ const progress = (nowMs - handAction.start) / handAction.def.dur;
1352
+ if (progress >= 1) {
1353
+ const done = handAction;
1354
+ const next = handQueue.shift();
1355
+ handAction = next ? { ...next, start: nowMs } : null;
1356
+ emit('gestureEnd', done.id);
1357
+ return handAction ? { gesture: handAction.gesture, progress: 0, side: handSide } : null;
1358
+ }
1359
+ return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
1360
+ }
1361
+
634
1362
  /**
635
- * Tell the listening engine whether the USER holds the floor, so backchannels
636
- * become contingent on their pauses instead of running on a timer. The server
637
- * owns this — it has the endpointer — and sends it as the `user` command;
638
- * `null` hands back to the no-signal timer fallback.
1363
+ * Tell the listening engine whether Pipecat VAD says the user holds the
1364
+ * floor. This changes only sustained engagement posture; it can never create
1365
+ * a nod or acknowledgement clip.
639
1366
  */
640
- function setUserSpeaking(b) { backchannel.setUserSpeaking(b); return api; }
1367
+ function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
641
1368
 
642
1369
  // What one action does when its moment comes. Enum validity is checked here,
643
1370
  // where the enums live: a bad value warns and is skipped, because one stale
@@ -650,8 +1377,7 @@ export function createAvatar(opts = {}) {
650
1377
  if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
651
1378
  else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
652
1379
  else if (a.do === 'gaze') setGaze(a.name);
653
- else if (a.do === 'interject') interject(a.id);
654
- else if (a.do === 'gesture') gesture(a.id);
1380
+ else if (a.do === 'action') action(a.id);
655
1381
  } catch (e) {
656
1382
  console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
657
1383
  }
@@ -661,8 +1387,8 @@ export function createAvatar(opts = {}) {
661
1387
 
662
1388
  /**
663
1389
  * Play a timed action track — the composition surface a server assembles
664
- * turns from. Verbs: state / emotion / gaze / interject / gesture (see
665
- * perform.js for hygiene, docs/contract-protocol.md for the schema).
1390
+ * turns from. Verbs: state / emotion / gaze / action (see
1391
+ * perform.js for hygiene, docs/internal-mixer.md for the schema).
666
1392
  *
667
1393
  * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
668
1394
  * element's own time, else ms elapsed since this call. perform() never
@@ -691,12 +1417,12 @@ export function createAvatar(opts = {}) {
691
1417
  }
692
1418
 
693
1419
  const api = {
694
- setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, interject,
695
- gesture, perform,
1420
+ setState, setEmotion, setGaze: (name, custom) => setGaze(name, custom), speak, pushCues, stopSpeaking, attend,
1421
+ action, perform,
696
1422
  /** Which hand the character gestures with: +1 the viewer's right (its own
697
1423
  * left), -1 the other. Both are anatomically real — the thumb splays away
698
1424
  * from the body either way — so this is a character choice, not a fix. */
699
- setHandSide: (d) => { if (hand) hand.setDir(d); return api; },
1425
+ setHandSide: (d) => { handSide = d === -1 ? 'left' : 'right'; return api; },
700
1426
  setUserSpeaking,
701
1427
  /** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
702
1428
  setMouthGain: (g) => { mouthGain = g; return api; },
@@ -720,21 +1446,22 @@ export function createAvatar(opts = {}) {
720
1446
  get speaking() { return speech.playing; },
721
1447
  get performing() { return performTrack.playing; },
722
1448
  get clip() { return clip.id; },
723
- /** The hand gesture in flight, or null. `null` forever if `hand: false`. */
724
- get gesturing() { return hand ? hand.id : null; },
725
- get params() { return cur; },
726
- get userSpeaking() { return backchannel.speaking; },
727
- svg: face.svg,
728
- meta,
1449
+ /** Semantic hand gesture in flight, independent of renderer capability. */
1450
+ get gesturing() { return handAction ? handAction.id : null; },
1451
+ get params() { return shown; },
1452
+ get userSpeaking() { return engagement.speaking; },
1453
+ // Legacy SVG inspection fields. New AvatarRig implementations should not
1454
+ // rely on or provide them; the renderer-neutral contract is apply/destroy.
1455
+ svg: face?.svg || null,
1456
+ meta: meta || null,
729
1457
  /** The mounted rig's palette, merged with any `opts.theme` overrides. A
730
1458
  * host that has to paint anything *around* the widget — a tile margin, a
731
1459
  * page behind a transparent mount — needs the same colours the drawing
732
1460
  * used, and guessing them per avatar is how the two drift apart. */
733
- theme: face.theme,
1461
+ theme: face?.theme,
734
1462
  destroy() {
735
1463
  cancelAnimationFrame(raf);
736
- if (hand) hand.destroy();
737
- face.destroy();
1464
+ rig.destroy();
738
1465
  },
739
1466
  };
740
1467
 
@@ -743,13 +1470,23 @@ export function createAvatar(opts = {}) {
743
1470
  return api;
744
1471
  }
745
1472
 
746
- export { INTERJECTIONS, INTERJECTION_IDS, SPOKEN_IDS, attachAudio } from './interjections.js';
1473
+ // The parameter space itself. A renderer-neutral rig has to answer "what is
1474
+ // this channel's neutral value" before it can map the channel onto whatever it
1475
+ // controls — a morph target's influence is `(pose - rest) / (1 - rest)`, and a
1476
+ // rig that hard-codes those rests has quietly forked `params.js`.
1477
+ export { REST, CHANNELS, RANGE } from './params.js';
1478
+ export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
747
1479
  export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
748
1480
  export { normalizeActions } from './perform.js';
749
- export { HAND_GESTURES, HAND_GESTURE_IDS, checkHandFraming } from './hand.js';
1481
+ export { checkHandFraming } from './hand.js';
750
1482
  export { EMOTION_NAMES } from './emotions.js';
1483
+ // The mouth clock travels with the rest of it. Someone has to turn a cue array
1484
+ // plus a clock into "which letter is on screen right now", every renderer needs
1485
+ // exactly that, and none of them should write it twice — so it is a plain class
1486
+ // to construct, not a contract to implement.
751
1487
  export {
752
- VISEME_LETTERS, VISEME_SHAPES, normalizeCues, textToCues,
1488
+ VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
1489
+ normalizeCues, textToCues,
753
1490
  ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
754
1491
  } from './visemes.js';
755
1492
  // No THEME re-export: each face module owns its palette, and `api.theme` is