@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/gaze.js CHANGED
@@ -9,8 +9,17 @@
9
9
  * after — accelerating, cruising, braking to a stop (see HEAD_ACCEL).
10
10
  * 2. The head only goes part of the way. Real people under-rotate the head
11
11
  * and let the eyes carry the remainder.
12
- * 3. A blink fires on any large shift. Gaze-evoked blinks are involuntary and
13
- * their absence is uncanny even though nobody can name what's wrong.
12
+ * 3. A blink rides a large shift. Gaze-evoked blinks are involuntary, more
13
+ * likely the bigger the shift (Evinger et al. 1994), and their absence
14
+ * is uncanny even though nobody can name what's wrong.
15
+ *
16
+ * A rig that states what a pose unit is in degrees (`angles`) gets the rest of
17
+ * the eye-head system too: its own target table, where the head carries most
18
+ * of a look and the eyes land about a third of the way off centre (Freedman &
19
+ * Sparks; Pejsa & Andrist's gaze model), and a vestibulo-ocular reflex in the
20
+ * mixer that holds the eyes on their target while the head moves under them.
21
+ * Without the reflex every nod and every speech pose change is also a look
22
+ * somewhere else.
14
23
  */
15
24
 
16
25
  export const GAZE_TARGETS = {
@@ -29,6 +38,12 @@ export const GAZE_TARGETS = {
29
38
  SCREEN_BOTTOM: { px: 0.00, py: 0.62, hx: 0.00, hy: 0.24 },
30
39
  SCREEN_WORK: { px: -0.62, py: 0.18, hx: -0.26, hy: 0.12 },
31
40
  NOTES: { px: 0.18, py: 0.72, hx: 0.04, hy: 0.28 },
41
+ // The agent's own display, just under the camera: where anyone on a video
42
+ // call looks while they do something for you. Eyes a little down and the
43
+ // head barely joins, because reading your own screen is an eye movement.
44
+ // SCREEN_WORK turned the head a quarter of the way to the left and held it
45
+ // there for the whole task, which read as turning away from the user.
46
+ OWN_SCREEN: { px: 0.04, py: 0.30, hx: 0.02, hy: 0.07 },
32
47
  // The classic "recalling something" break of eye contact. Keep it for the
33
48
  // stylized "let me think" beat — audiences read up-and-away regardless of
34
49
  // whether real thinkers do it.
@@ -39,12 +54,64 @@ export const GAZE_TARGETS = {
39
54
  // that the lid follow shades the eyes without sealing them; the head
40
55
  // carries a share so the pupils stay inside the aperture at tile size.
41
56
  AWAY_DOWN: { px: -0.45, py: 0.42, hx: -0.18, hy: 0.20, roll: 0.04 },
57
+ // Level and to the side: the other third of measured cognitive aversions
58
+ // (31.3%, §4.2), neither up nor down. Right, so a thinker who alternates it
59
+ // with AWAY_DOWN's left is not looking at the same patch of floor twice.
60
+ AWAY_SIDE: { px: 0.55, py: 0.08, hx: 0.20, hy: 0.03, roll: -0.03 },
42
61
  };
43
62
 
44
63
  export const GAZE_NAMES = Object.keys(GAZE_TARGETS);
45
64
 
46
65
  const HEAD_FOLLOW_TAU = 0.34; // roll only — the head lags the eyes badly, on purpose
47
- const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked blink
66
+ // Gaze-evoked blink odds, ramping with the size of the shift: none below the
67
+ // first number, certain above the second. Degrees on a rig that states them —
68
+ // stylised, well under Evinger's 10-40°, because every look here is scaled
69
+ // down to what a head-and-shoulders crop shows: a thinking look (~11°) always
70
+ // blinks, a speaker's glance aside (~5°) about a third of the time, a reading
71
+ // step never. Pose units on one that does not, where 0.45 was once a hard
72
+ // threshold and now blinks a quarter of the time.
73
+ const BLINK_RAMP_DEG = [3, 9];
74
+ const BLINK_RAMP_UNITS = [0.3, 0.9];
75
+ // The upper lid rides the eye, as a share of the pupil's travel from USER. A
76
+ // line face's lid follows down only: its lid travels further than its iris,
77
+ // and one that opened past rest on an upward look bared a band of sclera and
78
+ // read as alarm. A rig whose lid and iris travel alike follows both ways.
79
+ const LID_FOLLOW = { down: 0.34, up: 0 };
80
+ // A turning head dips. Heads travel in arcs, not on a flat plane — a turn that
81
+ // holds its pitch reads as a turntable (Williams, *The Animator's Survival
82
+ // Kit*, on head turns). A share of the turn's length, read off the yaw speed
83
+ // as v²/a — which at the middle of a braked turn *is* its length — so the dip
84
+ // peaks mid-turn, is gone when the head lands, and sizes itself on a rig whose
85
+ // head moves four times faster. A 0.75-unit look on tara sinks ~0.8°.
86
+ const HEAD_DIP = 0.06;
87
+ // A gated aversion waits this long past its due time for a phrase boundary,
88
+ // then goes anyway: a run of speech with no pause in it still gets its look.
89
+ const GATE_WAIT = 1.5;
90
+ // How much of a drift the head joins. Little: a drift is a reader's scan or a
91
+ // thinker's gaze moving where it rests, and both are eye movements. At 0.35
92
+ // the head nodded along with every step of a reading scan, which on a
93
+ // photographic face reads as a bobbing head rather than a moving eye.
94
+ const DRIFT_HEAD = 0.12;
95
+
96
+ // Where the eyes land inside a fixation on the user: the user's two eyes and,
97
+ // less often, the mouth — the triangle a listener's gaze scans a face with.
98
+ // A fixation that sits on one point is the stare; one that visits these
99
+ // reads as someone looking at a person. Pose units at scanGain 1.
100
+ const FACE_POINTS = [[-0.05, 0], [0.05, 0], [-0.05, 0], [0.05, 0], [0, 0.07], [0, 0]];
101
+ // Default gap between those fixational saccades, seconds. Eyes Alive
102
+ // (docs/research-biomechanics.md §4.5) is that this interval differs by mode,
103
+ // so states set their own through the mixer.
104
+ const SCAN_EVERY = [0.5, 1.6];
105
+ // Fixational jumps inside a look away, as a share of the usual size. The look
106
+ // is one saccade out and one back; big jumps inside it read as searching, and
107
+ // on a rig with a large scanGain one could land the eyes back on the face
108
+ // mid-look, so the look happened and did not read.
109
+ const AVERT_SCAN = 0.35;
110
+ // The least a new fixation moves from the last, pose units at scanGain 1.
111
+ // Drawn independently, one in a few landed on the one before, and two jumps
112
+ // that go nowhere are one long stare: 6 s on a single point, measured, in the
113
+ // state that is waiting for the user to come back.
114
+ const MIN_STEP = 0.025;
48
115
 
49
116
  // Head follow is ballistic, not exponential. An exponential chase has its peak
50
117
  // velocity at t=0 and then creeps forever — motion that starts instantly and
@@ -56,8 +123,129 @@ const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked bli
56
123
  // 0.55-unit swing in ~0.85s. The mixer still low-passes headYaw/headPitch at
57
124
  // τ=0.16 downstream, which rounds the hard stop into a short settle — that
58
125
  // cascade is deliberate, so do not "help" by softening the brake here too.
126
+ // A rig whose head unit is a few degrees sets its own (`headAccel`,
127
+ // `headSpeed`): an amble sized in line-face units took a 7° look 1.2 s.
59
128
  const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
60
129
  const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
130
+ // The head takes only a fraction of what an aversion takes — a brief look-away
131
+ // is an eye movement that the head barely joins, and a head that follows it
132
+ // fully reads as turning away. Sized for a line face, whose pupils travel far
133
+ // inside a big eye; a rig sets its own split (`avertSplit`).
134
+ const HEAD_SHARE = 0.22;
135
+
136
+ /**
137
+ * Ballistic follow: steer velocity toward "full speed at the goal, but never
138
+ * faster than can still brake to a stop within the distance left". The
139
+ * braking bound is v² = 2·a·d solved for v, with a half-step correction for
140
+ * discrete time (the −maxA term) so a frame never lands past the goal. As d
141
+ * shrinks the bound falls to zero, which *is* the deceleration — no separate
142
+ * easing curve.
143
+ */
144
+ function chase(pos, vel, gx, gy, dt, accel, speed) {
145
+ const dx = gx - pos.x;
146
+ const dy = gy - pos.y;
147
+ const d = Math.hypot(dx, dy);
148
+ const maxA = accel * dt;
149
+ const brake = 0.5 * (Math.sqrt(maxA * maxA + 8 * accel * d) - maxA);
150
+ const goal = Math.min(speed, brake);
151
+ let ax = (d ? (dx / d) * goal : 0) - vel.x;
152
+ let ay = (d ? (dy / d) * goal : 0) - vel.y;
153
+ const a = Math.hypot(ax, ay);
154
+ if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
155
+ vel.x += ax;
156
+ vel.y += ay;
157
+ // A dropped-frame dt could step past the goal; land on it instead. Judged
158
+ // on the toward-goal component so a mid-retarget frame with sideways
159
+ // velocity keeps flying rather than teleporting.
160
+ const along = d ? ((vel.x * dx + vel.y * dy) / d) * dt : 0;
161
+ if (along >= d && d >= 0) {
162
+ pos.x = gx;
163
+ pos.y = gy;
164
+ vel.x = 0;
165
+ vel.y = 0;
166
+ } else {
167
+ pos.x += vel.x * dt;
168
+ pos.y += vel.y * dt;
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Aversion profiles: a brief, deliberate break of eye contact that returns to
174
+ * whatever the gaze target already was.
175
+ *
176
+ * This exists because *holding* the user's eyes is not the attentive pose it
177
+ * looks like. Wang & Gratch (CHI 2010, n=133) ran the condition directly: a
178
+ * virtual listener that simply stares rated no better than one that visibly
179
+ * ignored the user (rapport 3.49 vs 3.34, n.s.), was rated the most *tense* of
180
+ * the three conditions, and raised the speaker's own disfluency rate to
181
+ * 36.75/min against 22.44 for a responsive listener. All three were rated
182
+ * equally natural, so it is not an animation-quality artefact. Rossano supplies
183
+ * the mechanism — sustained mutual gaze is a *demand for more talk*, not a
184
+ * signal of attention (95% of sequences expanded when both parties kept looking;
185
+ * 84% closed when both withdrew) — and Binetti (N=498) puts preferred mutual
186
+ * gaze at 3295 ± 706 ms, which is well short of forever.
187
+ *
188
+ * The other wall is just as hard: sustained *aversion* is an ostracism cue, and
189
+ * measured on an animated character (Chotpitayasunondh & Douglas, N=128) it
190
+ * costs η²ₚ = .52–.56 with post-hoc d of 1.09–2.69, with partial inattention
191
+ * costing most of what total inattention costs. So this is not "look away
192
+ * sometimes" — it is a narrow band, and the numbers below are Andrist's measured
193
+ * ones rather than a taste call (docs/research-biomechanics.md §4.2). The spike
194
+ * that arrived at this band, and what it measured on peep, is
195
+ * docs/research-active-listening.md.
196
+ *
197
+ * `every`/`dur` are seconds. `dirs` are unit-ish directions weighted by how
198
+ * often each is taken; magnitude is scaled per-fire.
199
+ */
200
+ export const AVERSION = {
201
+ // Idle, and before a call: present and relaxed rather than attending. The
202
+ // eyes rest on the user a comfortable bout (preferred mutual gaze 3.3 s,
203
+ // §4.1) and leave for longer, softer looks than a listener's — level or a
204
+ // little down, never up, because up is the "recalling" look and there is
205
+ // nothing to recall. This is the first face anyone sees, and it used to
206
+ // open looking up and to the left.
207
+ IDLE: {
208
+ every: [3.2, 6.0],
209
+ dur: [1.4, 2.8],
210
+ mag: [0.30, 0.46],
211
+ dirs: [[-1, 0.10], [1, 0.10], [-0.8, 0.45], [0.8, 0.40], [-1, 0.25]],
212
+ },
213
+ // While listening: 1.14 s (SD 0.27) every 7.21 s (SD 1.88), 57.5% sideways.
214
+ // Sideways dominates because down reads as submission and up reads as
215
+ // exasperation on a face this schematic.
216
+ LISTEN: {
217
+ every: [5.3, 9.1],
218
+ dur: [0.85, 1.45],
219
+ mag: [0.30, 0.44],
220
+ dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
221
+ },
222
+ // While speaking: 1.96 s every 4.75 s mid-turn, a turn-start look on 73% of
223
+ // turns — the planning look, away to find the words and back to deliver
224
+ // them — and none in the last 2.4 s, because the floor is handed back under
225
+ // mutual gaze (§4.2; the mixer owns that hold, since only it can see the
226
+ // cue track). Human speakers hold the listener's eyes for about 41% of a
227
+ // turn. This is set for 70-80% instead — the
228
+ // listener here watches a face on a screen, where 41% reads as evasive — so
229
+ // the looks keep about Andrist's length and come a little less often.
230
+ // `every` sits under Andrist's 4.75 s because the phrase gate and the
231
+ // end-of-turn hold both stretch it: at 3.8-5.8 a turn measured 6-8 looks a
232
+ // minute and 76-82% contact, the top edge of the band rather than its middle.
233
+ // `gated`: a mid-turn look waits for a phrase boundary, because speakers
234
+ // break gaze between clauses, not in the middle of a word.
235
+ SPEAK: {
236
+ every: [3.4, 5.2],
237
+ dur: [1.5, 2.3],
238
+ mag: [0.34, 0.48],
239
+ dirs: [[-1, 0.12], [1, 0.12], [-0.75, 0.55], [0.7, 0.5], [-0.6, -0.45]],
240
+ gated: true,
241
+ start: { p: 0.73, dur: [1.1, 1.8], window: 0.8 },
242
+ },
243
+ };
244
+ // There is deliberately no THINK profile. The *cognitive* aversion — 3.54 s
245
+ // (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
246
+ // and deeper than the listening kind, and THINKING renders it with its own
247
+ // dwell cycle (a `glance` in STATES), which moves the whole gaze target rather
248
+ // than nudging off it. Two mechanisms producing the same look would fight.
61
249
 
62
250
  export class GazeLayer {
63
251
  constructor() {
@@ -68,88 +256,276 @@ export class GazeLayer {
68
256
  this.onLargeShift = null;
69
257
  this.jitter = { x: 0, y: 0 };
70
258
  this._nextMicro = 0;
259
+ /** How far a fixational saccade travels. A pupil unit is a different
260
+ * angle on every rig, and one sized for a line face is invisible on a
261
+ * photographic one — the mixer passes the rig's `saccadeGain`. */
262
+ this.scanGain = 1;
263
+ /** The same for conversational aversions (AVERSION), which are sized for
264
+ * a line face too. On tara a speaking look-away at 1 is 2-3 px of iris
265
+ * travel, and a recorded call read the whole turn as a locked stare. */
266
+ this.avertGain = 1;
267
+ /** Where each named look goes, on this rig. The shared table is sized for
268
+ * a line face; a rig whose eyes are a fraction of its head's reach
269
+ * brings its own, where the head carries the look. */
270
+ this.targets = GAZE_TARGETS;
271
+ /** How an aversion divides: `eye` of it stays in the eyes, and `head` of
272
+ * it (in head units per pupil unit) goes to the neck. */
273
+ this.avertSplit = { eye: 1, head: HEAD_SHARE };
274
+ /** Degrees per pose unit, `{ eye: {x, y}, head: {x, y} }`, or null on a
275
+ * rig that has not said — then shifts are measured in pupil units. */
276
+ this.angles = null;
277
+ this.lidFollow = LID_FOLLOW;
278
+ this.headAccel = HEAD_ACCEL;
279
+ this.headSpeed = HEAD_SPEED;
280
+ /** [min, max] seconds between fixational saccades, or null for the
281
+ * default. Set per state by the mixer. */
282
+ this.scanEvery = null;
283
+ this._t = 0;
284
+ // --- aversion scheduler
285
+ this.aversion = null; // one of AVERSION, or null for none
286
+ /** Set true when eye contact must be held: the floor is about to change
287
+ * hands. Andrist prohibits intimacy-regulating aversions near utterance
288
+ * end for exactly this reason — the floor is passed with mutual gaze, and
289
+ * an avatar that looks away as the user finishes has just declined it. */
290
+ this.hold = false;
291
+ this._avNext = 0;
292
+ this._avUntil = 0;
293
+ this._avVec = { x: 0, y: 0 };
294
+ this._avAmt = 0; // glided 0..1 so the return is a movement, not a cut
295
+ this._avWant = 0; // where _avAmt is going; the head chases this
296
+ this._avHead = { x: 0, y: 0 };
297
+ this._avVel = { x: 0, y: 0 };
298
+ this._avProfileRef = undefined;
299
+ /** Whether now is a phrase boundary, for a `gated` profile. Set by the mixer. */
300
+ this.gate = true;
301
+ this._avStartBy = 0; // a turn-start look may still fire until this time
302
+ // --- drift: a small held offset about the target, set by the mixer
303
+ this.drift = { x: 0, y: 0 };
304
+ this._drift = { x: 0, y: 0 };
71
305
  }
72
306
 
307
+ /** Adopt an aversion profile (or null). Cheap to call every frame. */
308
+ setAversion(p) {
309
+ if (p === this._avProfileRef) return;
310
+ this._avProfileRef = p;
311
+ this.aversion = p || null;
312
+ // Re-arm rather than inherit: a state that averts must not fire the instant
313
+ // it is entered off a stale timestamp from one that didn't.
314
+ this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
315
+ this._avUntil = 0;
316
+ this._avStartBy = 0;
317
+ }
318
+
319
+ /** A turn begins. If the profile has a turn-start look and the dice take it,
320
+ * it fires as soon as `hold` allows inside its window, or not at all — a
321
+ * planning look that arrives a second into the words is just a look. */
322
+ startTurn() {
323
+ const s = this.aversion && this.aversion.start;
324
+ if (s && Math.random() < s.p) this._avStartBy = this._t + s.window;
325
+ }
326
+
327
+ _avert(t, dt) {
328
+ const p = this.aversion;
329
+ if (!p) {
330
+ // Glide home even after the profile is gone, so a state change mid-look
331
+ // returns the eyes instead of snapping them.
332
+ this._avAmt = Math.max(0, this._avAmt - dt / 0.18);
333
+ this._avWant = 0;
334
+ return;
335
+ }
336
+ if (this._avUntil && this.hold) {
337
+ // `hold` ends a look already running, and the schedule starts over
338
+ // behind it. Suspending the look instead sent the eyes back out the
339
+ // moment the hold lifted — out, back and out again across one turn
340
+ // edge, which is three saccades where the floor wanted none.
341
+ this._avUntil = 0;
342
+ this._avStartBy = 0;
343
+ this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
344
+ } else if (this._avUntil && t >= this._avUntil) {
345
+ this._avUntil = 0;
346
+ this._nextMicro = 0;
347
+ this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
348
+ } else if (!this._avUntil && !this.hold && (t < this._avStartBy || (t >= this._avNext
349
+ && (this.gate || !p.gated || t >= this._avNext + GATE_WAIT)))) {
350
+ const dur = t < this._avStartBy ? p.start.dur : p.dur;
351
+ this._avStartBy = 0;
352
+ this._avUntil = t + dur[0] + Math.random() * (dur[1] - dur[0]);
353
+ this._nextMicro = 0;
354
+ const d = p.dirs[(Math.random() * p.dirs.length) | 0];
355
+ const m = (p.mag[0] + Math.random() * (p.mag[1] - p.mag[0])) * this.avertGain;
356
+ this._avVec.x = d[0] * m;
357
+ this._avVec.y = d[1] * m;
358
+ // A look away is a gaze shift like any other, and blinks by its size.
359
+ const s = this.avertSplit;
360
+ const out = { px: d[0] * m * s.eye, py: d[1] * m * s.eye, hx: d[0] * m * s.head, hy: d[1] * m * s.head };
361
+ this._shiftBlink({ px: 0, py: 0, hx: 0, hy: 0 }, out);
362
+ }
363
+ // `hold` cancels an aversion already running, it does not merely postpone
364
+ // the next: the turn can end mid-look, and the eyes have to be back.
365
+ const want = this._avUntil && !this.hold ? 1 : 0;
366
+ this._avWant = want;
367
+ // Out fast, back slightly slower. A saccade away is ballistic; the return
368
+ // to a face is a fraction more deliberate, and symmetric timing here is one
369
+ // of the things that makes a rig read as a metronome.
370
+ const rate = want ? dt / 0.055 : dt / 0.11;
371
+ this._avAmt = want
372
+ ? Math.min(1, this._avAmt + rate)
373
+ : Math.max(0, this._avAmt - rate);
374
+ }
375
+
376
+ /** How far off-target the eyes currently are, 0..1. The mixer reads this to
377
+ * keep the trunk out of it — an aversion is eyes and a little head, never a
378
+ * body turn. */
379
+ get averted() { return this._avAmt; }
380
+
73
381
  /**
74
382
  * @param {string} name one of GAZE_NAMES
75
383
  * @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
76
384
  * when the server knows exact screen coordinates
385
+ * @param {boolean} [blink] true to ask for the evoked blink whatever the
386
+ * size, false to withhold it, omitted to leave it to the odds
77
387
  */
78
- set(name, override) {
388
+ set(name, override, blink) {
79
389
  const next = override
80
390
  ? { px: override.x, py: override.y, hx: override.x * 0.42, hy: override.y * 0.36 }
81
- : GAZE_TARGETS[name] || GAZE_TARGETS.USER;
82
- const d = Math.hypot(next.px - this.target.px, next.py - this.target.py);
391
+ : this.targets[name] || this.targets.USER;
392
+ const prev = this.target;
83
393
  this.target = next;
84
394
  this.name = override ? 'CUSTOM' : name;
85
- if (d > BLINK_THRESHOLD && this.onLargeShift) this.onLargeShift();
395
+ if (blink !== false) this._shiftBlink(prev, next, blink);
396
+ }
397
+
398
+ /** Roll the gaze-evoked blink for a shift from one target to another. */
399
+ _shiftBlink(a, b, force) {
400
+ if (!this.onLargeShift) return;
401
+ const g = this.angles;
402
+ const [lo, hi] = g ? BLINK_RAMP_DEG : BLINK_RAMP_UNITS;
403
+ const size = g
404
+ ? Math.hypot((b.px - a.px) * g.eye.x + (b.hx - a.hx) * g.head.x,
405
+ (b.py - a.py) * g.eye.y + ((b.hy || 0) - (a.hy || 0)) * g.head.y)
406
+ : Math.hypot(b.px - a.px, b.py - a.py);
407
+ // The listener is told which of the two it is: a beat the state named
408
+ // outranks the blink budget, a roll of the odds does not.
409
+ //
410
+ // A shift past `hi` is not a roll. The ramp prices those at probability 1,
411
+ // so the odds have already decided, and the budget was overruling the
412
+ // ramp's own certainty: on the call reel the three biggest hops a screen
413
+ // hunt makes -- 10.9°, 9.5°, 9.4° -- reached the eyelids as a coin toss
414
+ // that the budget then refused. A saccade that size wants every channel a
415
+ // head turn has (research-biomechanics §1.2: yaw, a pitch dip, a blink).
416
+ //
417
+ // Affordable because it is only the tail. Forcing the *whole* evoked
418
+ // population is what cost 2.12x the authored rate and is why
419
+ // EVOKED_EARLIEST exists; this is three shifts in a minute-long run, and
420
+ // over four seeds 78 blinks fired where the budget alone fired 79.
421
+ //
422
+ // The refractory is untouched and refuses half of these, because a hunt
423
+ // retargets every 0.8-2.0 s and that is faster than 1.5 s. That is the
424
+ // refractory working as written -- a glance out and back carries one
425
+ // blink, not two. So this masks the isolated large look and not the
426
+ // rapid ones inside a hunt, which stay open-eyed.
427
+ if (force || size >= hi) this.onLargeShift(true);
428
+ else if (Math.random() < (size - lo) / (hi - lo)) this.onLargeShift(false);
429
+ }
430
+
431
+ /** The lid's share of an eye at `pupilY`, added to the lid channels. */
432
+ lidBias(pupilY) {
433
+ const rest = GAZE_TARGETS.USER.py;
434
+ const f = this.lidFollow;
435
+ return rest * f.down + (pupilY - rest) * (pupilY > rest ? f.down : f.up);
86
436
  }
87
437
 
88
438
  /**
89
- * Micro-saccades. Eyes are never still; a perfectly fixed pupil reads as
90
- * dead. Small, frequent, and irregular.
439
+ * Fixational saccades. Eyes are never still, and they do not wobble about a
440
+ * point either: they jump and *hold*, a new fixation every half second or
441
+ * so. On the user they visit the user's eyes and mouth; anywhere else they
442
+ * land a little way about the target. These used to decay back to the exact
443
+ * centre within a second, so between jumps every state stared at one point,
444
+ * and at a photographic face's scale the jumps were under a pixel —
445
+ * §4.6's "sub-threshold jitter signals nothing", measured.
91
446
  */
92
- _micro(t, dt) {
93
- if (t >= this._nextMicro) {
94
- this._nextMicro = t + 0.7 + Math.random() * 1.6;
95
- this.jitter.x = (Math.random() - 0.5) * 0.16;
96
- this.jitter.y = (Math.random() - 0.5) * 0.11;
447
+ _micro(t) {
448
+ // A state's `scan` is [min gap, max gap, amplitude]: a reader's own
449
+ // line steps carry the movement, so a small third value keeps the
450
+ // fixational jumps from blurring them.
451
+ const [a, b, k = 1] = this.scanEvery || SCAN_EVERY;
452
+ // And a state that scans faster gets it from the frame it starts, not once
453
+ // the state before it has finished waiting. The deadline is drawn when a
454
+ // fixation fires, under whatever cadence was in force *then*, so entering a
455
+ // hunt out of a slower state finds a gap longer than the hunt's own maximum
456
+ // already on the clock and spends its first second at the old rate. On a
457
+ // two-second state that is most of the state: eyes that hold dead still and
458
+ // then jump once, which is read as mechanical rather than as searching.
459
+ // Clamped, not redrawn: the wait already served still counts, and a state
460
+ // change must not cost a draw from the shared stream — every blink and
461
+ // wander in the reel comes off it, and spending one here would move them.
462
+ if (this._nextMicro > t + b) this._nextMicro = t + b;
463
+ if (t < this._nextMicro) return;
464
+ this._nextMicro = t + a + Math.random() * (b - a);
465
+ const g = this.scanGain * k * (this._avUntil ? AVERT_SCAN : 1);
466
+ // The face scan is for looking *at* the user. A look away is not one,
467
+ // and a face-point offset riding on it pulled short looks back onto the
468
+ // face — the look happened and did not read.
469
+ const face = (this.name === 'USER' || this.name === 'USER_EAR') && !this._avUntil;
470
+ let x = 0, y = 0;
471
+ for (let i = 0; i < 4; i++) {
472
+ if (face) {
473
+ const [fx, fy] = FACE_POINTS[(Math.random() * FACE_POINTS.length) | 0];
474
+ x = (fx + (Math.random() - 0.5) * 0.02) * g;
475
+ y = (fy + (Math.random() - 0.5) * 0.02) * g;
476
+ } else {
477
+ x = (Math.random() - 0.5) * 0.12 * g;
478
+ y = (Math.random() - 0.5) * 0.08 * g;
479
+ }
480
+ if (Math.hypot(x - this.jitter.x, y - this.jitter.y) >= MIN_STEP * g) break;
97
481
  }
98
- const decay = 1 - Math.exp(-dt / 0.5);
99
- this.jitter.x -= this.jitter.x * decay;
100
- this.jitter.y -= this.jitter.y * decay;
482
+ this.jitter.x = x;
483
+ this.jitter.y = y;
101
484
  }
102
485
 
103
486
  update(t, dt) {
104
- this._micro(t, dt);
105
-
106
- // Ballistic head follow: steer velocity toward "full speed at the target,
107
- // but never faster than can still brake to a stop within the distance
108
- // left". The braking bound is v² = 2·a·d solved for v, with a half-step
109
- // correction for discrete time (the −maxA term) so a frame never lands
110
- // past the target. As d shrinks the bound falls to zero, which *is* the
111
- // deceleration — no separate easing curve.
112
- const dx = this.target.hx - this.head.x;
113
- const dy = this.target.hy - this.head.y;
114
- const d = Math.hypot(dx, dy);
115
- const maxA = HEAD_ACCEL * dt;
116
- const brake = 0.5 * (Math.sqrt(maxA * maxA + 8 * HEAD_ACCEL * d) - maxA);
117
- const goal = Math.min(HEAD_SPEED, brake);
118
- let ax = (d ? (dx / d) * goal : 0) - this.vel.x;
119
- let ay = (d ? (dy / d) * goal : 0) - this.vel.y;
120
- const a = Math.hypot(ax, ay);
121
- if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
122
- this.vel.x += ax;
123
- this.vel.y += ay;
124
- // A dropped-frame dt could step past the target; land on it instead.
125
- // Judged on the toward-target component so a mid-retarget frame with
126
- // sideways velocity keeps flying rather than teleporting.
127
- const along = d ? ((this.vel.x * dx + this.vel.y * dy) / d) * dt : 0;
128
- if (along >= d && d >= 0) {
129
- this.head.x = this.target.hx;
130
- this.head.y = this.target.hy;
131
- this.vel.x = 0;
132
- this.vel.y = 0;
133
- } else {
134
- this.head.x += this.vel.x * dt;
135
- this.head.y += this.vel.y * dt;
136
- }
487
+ this._t = t;
488
+ this._avert(t, dt);
489
+ this._micro(t);
490
+ // Drift lands like a saccade — quick, then held — not as a slide.
491
+ const kd = 1 - Math.exp(-dt / 0.04);
492
+ this._drift.x += (this.drift.x - this._drift.x) * kd;
493
+ this._drift.y += (this.drift.y - this._drift.y) * kd;
494
+ const s = this.avertSplit;
495
+ const avx = this._avVec.x * this._avAmt * s.eye + this._drift.x;
496
+ const avy = this._avVec.y * this._avAmt * s.eye + this._drift.y;
497
+ // The head's part of an aversion is a head movement like any other, so it
498
+ // gets the same ballistic launch and brake as the gaze follow — on its own
499
+ // follower, so the trunk (which tracks `this.head`) sits a look out.
500
+ const ahx = this._avVec.x * this._avWant * s.head;
501
+ const ahy = this._avVec.y * this._avWant * s.head;
502
+ chase(this._avHead, this._avVel, ahx, ahy, dt, this.headAccel, this.headSpeed);
503
+ chase(this.head, this.vel, this.target.hx, this.target.hy, dt, this.headAccel, this.headSpeed);
137
504
 
138
505
  // Roll stays on the exponential: its travels are tiny (≤0.55 and usually
139
506
  // ~0.08), far below where the drift tail is visible.
140
507
  const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
141
508
  this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
142
509
 
143
- const pupilY = this.target.py + this.jitter.y;
510
+ const vx = this.vel.x + this._avVel.x;
511
+ const dip = HEAD_DIP * vx * vx / this.headAccel;
144
512
  return {
145
- pupilX: this.target.px + this.jitter.x,
146
- pupilY,
147
- headYaw: this.head.x,
148
- headPitch: this.head.y,
513
+ pupilX: this.target.px + this.jitter.x + avx,
514
+ pupilY: this.target.py + this.jitter.y + avy,
515
+ headYaw: this.head.x + this._avHead.x + this._drift.x * DRIFT_HEAD,
516
+ headPitch: this.head.y + this._avHead.y + this._drift.y * DRIFT_HEAD + dip,
149
517
  headRoll: this.head.roll,
150
- // The upper lid tracks the eye vertically. Without this, looking down
151
- // exposes a band of sclera above the iris and the avatar looks startled.
152
- lidBias: pupilY * 0.34,
518
+ // Where the head is *going*, without the looks that ride on it. The
519
+ // mixer turns the trunk toward this and not toward headYaw: an aversion
520
+ // is eyes and a little head, never a body turn.
521
+ trunkYaw: this.head.x,
522
+ // Where the head will be once it lands — the pose the eyes' target was
523
+ // authored against. The mixer's reflex holds the eyes on that target
524
+ // against every difference between this and the head actually drawn:
525
+ // the follow still under way, the dip, and everything above the gaze
526
+ // layer that moves the head.
527
+ aimYaw: this.target.hx + ahx + this._drift.x * DRIFT_HEAD,
528
+ aimPitch: this.target.hy + ahy + this._drift.y * DRIFT_HEAD,
153
529
  };
154
530
  }
155
531
  }