@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/idle.js CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { approach } from './params.js';
20
+ import { HeadPose } from './head.js';
20
21
 
21
22
  /**
22
23
  * An idle profile. Every state carries one (sparse — missing keys mean these
@@ -24,7 +25,13 @@ import { approach } from './params.js';
24
25
  * the layer, because an oscillator whose amplitude steps is a visible pop.
25
26
  *
26
27
  * sway 0..~1.2 head-drift / brow-drift / shoulder / torso amplitude
27
- * blinkGap [min,max] seconds between spontaneous blinks
28
+ * blinkGap [min,max] seconds between blinks — ALL blinks. A blink the
29
+ * face is made to do (a gaze shift, a state change, a pause in
30
+ * speech) replaces the next timed one instead of adding to it,
31
+ * so this is the state's rate whatever else is going on.
32
+ * slowBlink 0..1 share of timed blinks drawn slow (0.34 s) — the heavy,
33
+ * deliberate blink of someone thinking. A share of the budget,
34
+ * not a second clock on top of it.
28
35
  * breathRate multiplier on the 0.23 Hz resting cycle (13.8/min)
29
36
  * breathAmp breath excursion scale; cognitive load = faster AND shallower
30
37
  * hold { every:[s,s], dur:[s,s] } — freeze the sway (breath continues).
@@ -41,10 +48,15 @@ import { approach } from './params.js';
41
48
  * perfectly still. Amplitude rides on `sway`, which is how the
42
49
  * cognitive states get the measured sway *suppression* under
43
50
  * load (§6.2) without a second knob.
51
+ * settle [s,s] gap between the head's own small re-positionings, or
52
+ * null for a head that only moves when something moves it.
53
+ * Amplitude rides on `sway` too.
44
54
  */
45
55
  export const DEFAULT_PROFILE = {
46
56
  sway: 1.0,
57
+ settle: [1.8, 4.6],
47
58
  blinkGap: [1.9, 5.4],
59
+ slowBlink: 0,
48
60
  breathRate: 1.0,
49
61
  breathAmp: 1.0,
50
62
  hold: null,
@@ -55,6 +67,26 @@ export const DEFAULT_PROFILE = {
55
67
 
56
68
  const rand = ([a, b]) => a + Math.random() * (b - a);
57
69
 
70
+ // No evoked blink within this long of the last blink. A glance out and back
71
+ // is two large gaze shifts a second apart and carries one blink, not two; and
72
+ // a state change that lands on a gaze shift is one event to the eyelids.
73
+ const BLINK_REFRACTORY = 1.5;
74
+
75
+ // How far into the current gap an odds-rolled evoked blink may land, as a
76
+ // fraction of that gap. A state that moves its gaze oftener than it blinks —
77
+ // SEARCHING_SCREEN hops 42 times a minute against a 10/min budget, and nearly
78
+ // every hop clears the ramp — would otherwise blink at its gaze's rate rather
79
+ // than its own. This lets such a blink only *move* the next one onto a shift,
80
+ // which is the half worth keeping (research-biomechanics.md §5.4: a blink at a
81
+ // boundary is worth several placed at random), and not add one.
82
+ //
83
+ // 0.80 is where the trade turns, measured on tara's targets at 30 min a state:
84
+ // SEARCHING_SCREEN falls from 2.12x its authored rate to 1.06x and
85
+ // REVIEWING_SCREEN from 1.36x to 1.01x, while 54% of the former's blinks still
86
+ // land on a hop. Tighter values buy hundredths of rate and halve that
87
+ // placement — 0.90 holds only 26%.
88
+ const EVOKED_EARLIEST = 0.80;
89
+
58
90
  /**
59
91
  * Postural weight shift: the one part of this layer that is not periodic, and
60
92
  * the part that does most of the work.
@@ -62,7 +94,7 @@ const rand = ([a, b]) => a + Math.random() * (b - a);
62
94
  * Everything else here is an oscillator, and an oscillator cannot make a body
63
95
  * look alive across a thirty-second hold. Fast enough to notice and it reads
64
96
  * as rocking; slow enough not to and it is indistinguishable from a still
65
- * image. A motion map of the listening state (tools/motion.mjs) showed the
97
+ * image. A headless motion map of the listening state showed the
66
98
  * outer edge of the torso travelling zero pixels over 24 seconds — the sway
67
99
  * was there in the numbers and rendered as nothing at all.
68
100
  *
@@ -107,6 +139,58 @@ function nextPosture(prev) {
107
139
  };
108
140
  }
109
141
 
142
+ /**
143
+ * The head's settle: where a person's head goes while nothing is moving it.
144
+ *
145
+ * It used to be a pair of slow sines per axis, and on tara that was two
146
+ * failures at once. At 0.05-0.09 Hz and under a degree it was too slow to see
147
+ * as motion, so a listening face read as frozen — "a dead stare, only blinks",
148
+ * in the video review that found it — and what could be seen of it was a head
149
+ * that never stops gliding, which is the other failure. A listener's head does
150
+ * neither: it is held, and every few seconds it re-positions — one to two
151
+ * degrees, over about half a second — and is held again (head.js). Pose units
152
+ * at sway 1; on tara a unit of yaw is 10.7°, of pitch 17.1°, of roll 5.7°. The
153
+ * first cut asked for about a degree and a recorded call measured it at 3 px
154
+ * of head travel, which is the frozen listener it was meant to fix.
155
+ *
156
+ * None of these is a nod. The renderer does not acknowledge on its own
157
+ * (CLAUDE.md), so a settle is one move, never a down-and-back — that property,
158
+ * and not a small number, is what keeps pitch off the wire's vocabulary. It was
159
+ * held to under a degree as well, which measured as a listening head with no
160
+ * pitch in it at all (1.05° peak over thirty seconds, against yaw's 5.5°), and
161
+ * a head that only ever turns is a head on a turntable. The range is wider now
162
+ * and still biased upward: a chin that drifts down and stays there is the
163
+ * downcast read, which on a photographed face arrives long before any other.
164
+ */
165
+ const SETTLE = { yaw: [0.16, 0.34], pitch: [-0.11, 0.08], roll: [0.10, 0.28], dur: [0.45, 0.8], switchP: 0.7 };
166
+ const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
167
+
168
+ /**
169
+ * The moving hold: what keeps a held head alive.
170
+ *
171
+ * Hold-and-move fixed the floating head, and the same reviewer then found the
172
+ * holds themselves uncanny — "absolute 0-velocity holds", a head locked while
173
+ * only the mouth moves. Animators met this long ago: a character that stops
174
+ * dead reads as a still frame, so a held pose keeps drifting by a fraction of
175
+ * its size: here a few tenths of a degree, small enough that a move still
176
+ * reads as the event. It runs in every state, speech included, and yields to
177
+ * a static hold, which is stillness on purpose.
178
+ *
179
+ * **It is not a sum of sines.** The first cut was, at 0.2-0.6 Hz, and the next
180
+ * review read it at once as floating underwater: a periodic sway is a loop,
181
+ * and people see loops. Human postural sway is a random walk, and it stops —
182
+ * a head drifts for a few seconds, then is quiet for a couple. So each axis
183
+ * glides to a new random offset every 0.6-2.2 s through two smoothing stages
184
+ * (an S-shaped approach, no overshoot, no rhythm), and a gate alternates
185
+ * drifting stretches with near-still ones. The body's idle drift takes the
186
+ * same gate, so a quiet head does not sit on a torso still rocking under it.
187
+ */
188
+ const LIVE = {
189
+ yaw: 0.05, pitch: 0.02, roll: 0.05, breathPitch: 0.006,
190
+ retarget: [0.6, 2.2], tau: 0.38,
191
+ drift: [2.0, 5.0], still: [1.2, 3.5], stillGain: 0.12,
192
+ };
193
+
110
194
  /** Ease so the shift has no corners at either end — a weight shift accelerates
111
195
  * and settles, and a linear ramp between two postures reads as a slide. */
112
196
  const smoothstep = (x) => x * x * (3 - 2 * x);
@@ -125,8 +209,18 @@ export class IdleLayer {
125
209
  this._blinkT = -1;
126
210
  this._blinkDur = 0.13;
127
211
  this._double = false;
212
+ this._lastBlinkAt = -Infinity;
128
213
  // Two incommensurate frequencies per axis so the sway never visibly loops.
129
214
  this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
215
+ // The moving hold (LIVE): per axis a random target, a first stage chasing
216
+ // it and the output chasing that; and the drift/still gate.
217
+ this._liveTo = [0, 0, 0];
218
+ this._liveA = [0, 0, 0];
219
+ this._liveB = [0, 0, 0];
220
+ this._liveAt = 0;
221
+ this._liveOn = true;
222
+ this._liveFlip = rand(LIVE.drift);
223
+ this._liveGate = 1;
130
224
  // Glided amplitudes (profiles set the target, these chase it).
131
225
  this._sway = 1;
132
226
  this._breathAmp = 1;
@@ -151,6 +245,11 @@ export class IdleLayer {
151
245
  this._shiftAt = 8;
152
246
  this._shiftT0 = -1;
153
247
  this._shiftDur = 2;
248
+ // Settle machine: the head's held pose, and when it next moves.
249
+ this._head = new HeadPose();
250
+ this._settleAt = 1 + Math.random() * 2;
251
+ this._settleSide = Math.random() < 0.5 ? -1 : 1;
252
+ this._settled = false;
154
253
  // Speech phrasing: a slow gain the talking body's excursion rides on, so
155
254
  // it comes in waves rather than as a steady hum.
156
255
  this._phrase = 0;
@@ -179,20 +278,53 @@ export class IdleLayer {
179
278
  }
180
279
  }
181
280
 
182
- /** Force a blink now — used on gaze shifts and state transitions. */
183
- blink(double = false) {
281
+ /**
282
+ * Blink now. `evoked` is a blink the face is made to do by something else —
283
+ * a large gaze shift, a state change, a pause in speech — and it is refused
284
+ * inside BLINK_REFRACTORY of the last one, and again before EVOKED_EARLIEST
285
+ * of the way through the current gap. Every blink, evoked or not, restarts
286
+ * the timer: people blink at a rate, and an event moves a blink earlier
287
+ * rather than adding one. Without that, a state that glances or wanders
288
+ * blinks at its timer's rate plus its gaze's, far past the rates in
289
+ * docs/research-biomechanics.md §5 that the gaps are set from.
290
+ *
291
+ * `forced` is a beat the state asked for by name — `glance.blinkTo` and
292
+ * `blinkBack`, the re-engagement the author wanted seen. It clears the budget
293
+ * gate but not the refractory: a state whose authored beats overrun its own
294
+ * rate is a decision to revisit in that state, not something for the eyelids
295
+ * to drop silently.
296
+ */
297
+ blink(double = false, evoked = false, forced = false) {
298
+ if (evoked) {
299
+ if (this.t - this._lastBlinkAt < BLINK_REFRACTORY) return;
300
+ const gap = this._nextBlink - this._lastBlinkAt;
301
+ if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
302
+ }
303
+ this._startBlink(0.11 + Math.random() * 0.04, double);
304
+ }
305
+
306
+ _startBlink(dur, double) {
184
307
  if (this._blinkT >= 0) return;
185
308
  this._blinkT = 0;
186
309
  this._double = double;
187
- this._blinkDur = 0.11 + Math.random() * 0.04;
310
+ this._blinkDur = dur;
311
+ this._lastBlinkAt = this.t;
312
+ this._nextBlink = this.t + rand(this.profile.blinkGap);
313
+ }
314
+
315
+ /**
316
+ * A blink placed by speech, at a pause. It also restarts the timer: a speaker
317
+ * who blinks at a clause boundary does not blink again a moment later
318
+ * because a clock said so, and the timer is only there to fill a long run
319
+ * of speech with no pauses in it.
320
+ */
321
+ phraseBlink() {
322
+ this.blink(false, true);
188
323
  }
189
324
 
190
325
  /** A slow, deliberate blink — reads as thinking or fatigue. */
191
326
  slowBlink() {
192
- if (this._blinkT >= 0) return;
193
- this._blinkT = 0;
194
- this._double = false;
195
- this._blinkDur = 0.34;
327
+ this._startBlink(0.34, false);
196
328
  }
197
329
 
198
330
  _blinkValue(dt) {
@@ -216,9 +348,13 @@ export class IdleLayer {
216
348
  const pr = this.profile;
217
349
 
218
350
  if (t >= this._nextBlink) {
351
+ // Re-armed here as well as in _startBlink, for the frame the timer comes
352
+ // due in the middle of a blink already running.
219
353
  this._nextBlink = t + rand(pr.blinkGap);
220
- // Roughly one blink in six comes in a pair.
221
- this.blink(Math.random() < 0.16);
354
+ // A share of them slow if the state asks for it; roughly one in six of
355
+ // the rest comes in a pair.
356
+ if (Math.random() < pr.slowBlink) this.slowBlink();
357
+ else this.blink(Math.random() < 0.16);
222
358
  }
223
359
  const blink = this._blinkValue(dt);
224
360
 
@@ -327,6 +463,49 @@ export class IdleLayer {
327
463
  this._flickT = -1;
328
464
  }
329
465
 
466
+ // The settle. Not while talking — speech holds and moves the head itself
467
+ // (prosody.js), and two layers each re-positioning it is a head with two
468
+ // minds — and never inside a static hold, which is stillness on purpose.
469
+ if (pr.settle && this.talk < 0.5) {
470
+ if (t >= this._settleAt && t >= this._holdUntil) {
471
+ this._settleAt = t + rand(pr.settle);
472
+ if (Math.random() < SETTLE.switchP) this._settleSide = -this._settleSide;
473
+ const m = this._sway * this.gain;
474
+ this._head.moveTo({
475
+ headYaw: this._settleSide * rand(SETTLE.yaw) * m,
476
+ headPitch: rand(SETTLE.pitch) * m,
477
+ headRoll: (Math.random() < 0.5 ? -1 : 1) * rand(SETTLE.roll) * m,
478
+ }, t * 1000, rand(SETTLE.dur) * 1000);
479
+ this._settled = true;
480
+ }
481
+ } else if (this._settled) {
482
+ this._settled = false;
483
+ this._head.moveTo(HOME, t * 1000, 600);
484
+ }
485
+ const hs = this._head.sample(t * 1000);
486
+ // The moving hold (LIVE). Only half of it rides on `sway`: a state that
487
+ // turns its drift down still has a living head.
488
+ if (t >= this._liveAt) {
489
+ this._liveAt = t + rand(LIVE.retarget);
490
+ for (let i = 0; i < 3; i++) this._liveTo[i] = Math.random() * 2 - 1;
491
+ }
492
+ if (t >= this._liveFlip) {
493
+ this._liveOn = !this._liveOn;
494
+ this._liveFlip = t + rand(this._liveOn ? LIVE.drift : LIVE.still);
495
+ }
496
+ this._liveGate = approach(this._liveGate, this._liveOn ? 1 : LIVE.stillGain, 0.5, dt);
497
+ for (let i = 0; i < 3; i++) {
498
+ this._liveA[i] = approach(this._liveA[i], this._liveTo[i], LIVE.tau, dt);
499
+ this._liveB[i] = approach(this._liveB[i], this._liveA[i], LIVE.tau, dt);
500
+ }
501
+ const live = this.gain * this._holdAmp * (0.5 + 0.5 * this._sway) * this._liveGate;
502
+ const [ly, lp, lr] = this._liveB;
503
+ hs.headYaw += ly * LIVE.yaw * live;
504
+ hs.headPitch += (lp * LIVE.pitch - Math.sin(this._breathPh) * LIVE.breathPitch * this._breathAmp) * live;
505
+ hs.headRoll += lr * LIVE.roll * live;
506
+ // The body's idle drift, quieted with the head (the speech term is not).
507
+ const ad = a * (0.35 + 0.65 * this._liveGate);
508
+
330
509
  // Speech moves the body more, and in waves. `talk` says whether sound is
331
510
  // being produced, `_phrase` says how hard this stretch of it is being
332
511
  // pushed.
@@ -344,11 +523,14 @@ export class IdleLayer {
344
523
  // below the 0.04-0.6 Hz seated trunk band, still calm, but now
345
524
  // actually present on screen. `gain` is where a deployment that
346
525
  // really is paying for the pixels turns it back down.
347
- headYaw: (s(0, 0.094) * 0.6 + s(0, 0.058) * 0.4) * 0.075 * a
348
- + flickYaw + post.headYaw * ps,
349
- headPitch: (s(1, 0.072) * 0.6 + s(1, 0.046) * 0.4) * 0.055 * a + workPitch,
350
- headRoll: s(2, 0.061) * 0.048 * a + post.headRoll * ps,
351
- breath: breath * this.gain,
526
+ // The head is the exception: it settles rather than sways (SETTLE).
527
+ headYaw: hs.headYaw + flickYaw + post.headYaw * ps,
528
+ headPitch: hs.headPitch + workPitch,
529
+ headRoll: hs.headRoll + post.headRoll * ps,
530
+ // Speech reorganises breathing: a quick inbreath at a pause and a long
531
+ // outbreath over the phrase (research-biomechanics.md §6.1), which the
532
+ // prosody layer draws. Quiet breathing steps mostly back under it.
533
+ breath: breath * this.gain * (1 - 0.6 * this.talk),
352
534
  // The brows are never quite still either.
353
535
  browRaiseL: s(0, 0.089) * 0.020 * a,
354
536
  browRaiseR: s(1, 0.083) * 0.020 * a,
@@ -368,11 +550,20 @@ export class IdleLayer {
368
550
  // torso band's mean luminance range falls 28 -> 24. Sway suppression is
369
551
  // a statement about drift. Speech reorganises the body; it does not
370
552
  // park it.
371
- shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * a + 0.10 * say * this.gain)
553
+ // The speech term is smaller than it was, and that is a handover rather
554
+ // than a reduction. `prosody.js` now puts the body's share of an accent
555
+ // and of a phrase's posture on the shoulders and the lean, timed off
556
+ // the words; this is a pair of sine waves timed off nothing. Two
557
+ // unrelated sources at full strength read as a body that is busy
558
+ // without being about anything, and the one that knows what is being
559
+ // said should be the louder. What is left here is the part prosody
560
+ // cannot supply: motion in the gaps *between* phrases, where there is
561
+ // no accent to hang anything on.
562
+ shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
372
563
  + workL + post.shoulderL * ps,
373
- shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * a + 0.10 * say * this.gain)
564
+ shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
374
565
  + workR + post.shoulderR * ps,
375
- torsoLean: s(0, 0.085) * (0.028 * a + 0.075 * say * this.gain) + post.torsoLean * ps,
566
+ torsoLean: s(0, 0.085) * (0.028 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
376
567
  // The trunk's own drift, small next to the weight shift that dominates
377
568
  // this channel. It exists so the body is not perfectly still *between*
378
569
  // shifts, which would make each shift read as a discrete event.
@@ -384,91 +575,37 @@ export class IdleLayer {
384
575
  }
385
576
 
386
577
  /**
387
- * The listening engine: backchannels and engagement posture for LISTENING.
388
- *
389
- * An agent who sits motionless while you talk feels like a recording — but the
390
- * fix is not more nodding. Gratch's rapport experiments showed that nod
391
- * *frequency* without *contingency* creates no rapport at all, and an agent
392
- * that acknowledges on a metronome reads as distracting
393
- * (docs/research-biomechanics.md §3.5). So this engine is contingent first:
394
- *
395
- * · The server tells it about the user's voice — a coarse speaking flag
396
- * (setUserSpeaking), off its own endpointer. With no signal at all the
397
- * scheduler falls back to a timer, which is the weakest mode and is meant
398
- * to be: contingency is the whole point.
399
- * · Acknowledgements fire at PAUSE ONSETS: when the user stops talking, a
400
- * nod lands 250–600 ms later, about half the time, never more often than
401
- * every 2.5 s. That timing is where a human listener's nod sits.
402
- * · During a long unbroken stretch of user speech a rare mid-speech nod
403
- * keeps the face alive (nods fill ~26% of human listening time — we err
404
- * far quieter, per the screen-share bitrate constraint).
405
- * · Engagement posture: `engage` rises while the user speaks and relaxes
406
- * after long silence. The mixer spends it on torsoLean — forward lean is
407
- * the highest-value listening channel the rig has (§6.3).
578
+ * The listening engine is now deliberately posture-only.
408
579
  *
409
- * If the host never supplies any user signal, the old loose random timer runs
410
- * instead — a worse listener, but never a dead one.
580
+ * `setUserSpeaking()` receives Pipecat's VAD verdict through the client
581
+ * adapter. It may alter the sustained engagement lean, but it never chooses or
582
+ * fires a clip: a nod, brow acknowledgement or any other facial claim of
583
+ * understanding is an explicit backend/application `action()` decision.
411
584
  */
412
585
  export class ListeningEngine {
413
- constructor(fire) {
414
- this.fire = fire;
586
+ constructor() {
415
587
  this.enabled = false;
416
588
  this.t = 0;
417
589
  this.engage = 0;
418
- this.lastFireAt = -1e9;
419
- // --- no-signal fallback timer (the pre-contingency behaviour, verbatim)
420
- this.minGap = 3.4;
421
- this.maxGap = 8.0;
422
- this._next = 0;
423
- // --- user-signal state
424
590
  this._hasSignal = false;
425
- this._explicit = null; // server-declared flag; null = not driven
426
- this._speaking = false; // merged VAD, after hysteresis
427
- this._spokeAt = -1e9; // start of the current speech stretch
428
- this._silentAt = 0; // end of the last one
429
- this._pending = -1; // scheduled contingent fire time, <0 = none
430
- this._midNext = 0;
591
+ this._explicit = null;
592
+ this._speaking = false;
593
+ this._silentAt = 0;
431
594
  }
432
595
 
433
- /** Push the next autonomous fire out — called on state changes and after any
434
- * manual interjection, so scheduled nods never pile onto server-driven ones. */
435
- reset(delay = 2.5) {
436
- this._next = this.t + delay;
437
- this.lastFireAt = this.t;
438
- this._pending = -1;
439
- }
596
+ /** Compatibility no-ops retained for existing hosts that reached these
597
+ * internals indirectly. With no autonomous clips there is nothing to reset
598
+ * or cede. */
599
+ cede() {}
600
+ get ceded() { return false; }
601
+ reset() {}
440
602
 
441
- /** Server-declared user speech, off the pipeline's own endpointer. Pass null
442
- * to hand back to the no-signal timer. */
603
+ /** Pipecat VAD's user-speaking truth. `null` removes the engagement signal. */
443
604
  setUserSpeaking(b) {
444
605
  if (b !== null) this._hasSignal = true;
445
606
  this._explicit = b === null ? null : !!b;
446
607
  }
447
608
 
448
- /** The one seam for choosing an acknowledgement. Context-aware: what the
449
- * user just did decides the weight class of the reply
450
- * (docs/research-biomechanics.md §3.3 — continuers co-occur with ongoing
451
- * speech, assessments with completed content; corpus mix 49/40/12,
452
- * shifted quieter here per the screen-share constraint). */
453
- pickAck(context) {
454
- const r = Math.random();
455
- // Mid-speech nods stay minimal: the user still has the floor.
456
- if (context === 'midspeech') return r < 0.7 ? 'NOD_SMALL' : 'BROW_ACK';
457
- // A pause after a LONG stretch earns an assessment-class nod — the user
458
- // completed a thought, and answering a paragraph with a continuer reads
459
- // as not having listened to it. NOD_UP is rationed: a realization every
460
- // few seconds stops meaning realization.
461
- const utter = this._hasSignal ? this._silentAt - this._spokeAt : 0;
462
- if (utter >= 4) {
463
- if (r < 0.45) return 'NOD_SLOW';
464
- if (r < 0.65) return 'NOD_UP';
465
- if (r < 0.85) return 'NOD_SMALL';
466
- return 'BROW_ACK';
467
- }
468
- // Short utterance (and the no-signal fallback timer): continuer country.
469
- return r < 0.55 ? 'NOD_SMALL' : r < 0.8 ? 'BROW_ACK' : 'NOD_SLOW';
470
- }
471
-
472
609
  get speaking() { return this._explicit === true; }
473
610
 
474
611
  update(dt) {
@@ -480,39 +617,10 @@ export class ListeningEngine {
480
617
  // relaxing the moment they pause would read as relief that they stopped.
481
618
  if (speaking !== this._speaking) {
482
619
  this._speaking = speaking;
483
- if (speaking) { this._spokeAt = t; this._pending = -1; }
484
- else {
485
- this._silentAt = t;
486
- // Pause onset: the contingent backchannel moment. Half of pauses get
487
- // an acknowledgement; the other half, keeping still IS the answer.
488
- if (this.enabled && this._hasSignal
489
- && t - this.lastFireAt >= 2.5 && Math.random() < 0.5) {
490
- this._pending = t + 0.15 + Math.random() * 0.3;
491
- }
492
- }
620
+ if (!speaking) this._silentAt = t;
493
621
  }
494
622
  const engaged = speaking || t - this._silentAt < 8;
495
623
  this.engage = approach(this.engage, engaged && this._hasSignal ? 1 : 0,
496
624
  speaking ? 1.5 : 6.0, dt);
497
-
498
- if (!this.enabled) { this._pending = -1; return; }
499
-
500
- if (this._hasSignal) {
501
- if (this._pending > 0 && t >= this._pending) {
502
- this._pending = -1;
503
- this.lastFireAt = t;
504
- this.fire(this.pickAck('pause'));
505
- }
506
- // A long unbroken stretch of user speech earns a rare mid-speech nod.
507
- if (speaking && t - this._spokeAt > 5.5 && t - this.lastFireAt > 3.5 && t >= this._midNext) {
508
- this._midNext = t + 2.6 + Math.random() * 1.8;
509
- if (Math.random() < 0.35) { this.lastFireAt = t; this.fire(this.pickAck('midspeech')); }
510
- }
511
- } else if (t >= this._next) {
512
- // No user signal was ever supplied: the loose timer, exactly as before.
513
- this._next = t + this.minGap + Math.random() * (this.maxGap - this.minGap);
514
- this.lastFireAt = t;
515
- this.fire(this.pickAck('pause'));
516
- }
517
625
  }
518
626
  }