@voqalize/avatar 0.2.1 → 0.3.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 (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
package/src/hand.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * The avatar has no arms (CLAUDE.md, constraint 9): two earlier rigs carried a
5
5
  * full forearm/hand chain, nine parameter channels and several hundred lines of
6
6
  * geometry each, and it was removed on sight. This is the other design. It
7
- * survived a stakeholder trial in `experiments/arm-gesture/` and was promoted
8
- * on 2026-08-07; what makes it a different proposition from the thing that was
9
- * cut is that **it is not part of the rig**. No parameter channel, no per-face
7
+ * survived a stakeholder trial and was promoted on 2026-08-07; what makes it a
8
+ * different proposition from the thing that was cut is that **it is not part of
9
+ * the rig**. No parameter channel, no per-face
10
10
  * geometry, no forearm — one drawing, placed by four numbers derived from the
11
11
  * mounted avatar's own `META.viewBox`, painted in its own theme, appended over
12
12
  * its SVG. A face that never plays a gesture is byte-for-byte what it was.
@@ -39,37 +39,37 @@
39
39
  * the waves sit LOWER and further back instead (`sc`), and the face stays
40
40
  * entirely clear.
41
41
  *
42
- * 4. THE ONLY EDGE THAT CUTS THE HAND IS THE BOTTOM ONE. Every shipped avatar's
43
- * viewBox is a portrait window pillarboxed inside a 16:9 tile, so anything
44
- * past the window's right edge is sliced by a hard vertical line that reads
45
- * as a rendering fault. Outboard travel is budgeted against the hand's own
42
+ * 4. THE ONLY EDGE THAT CUTS THE HAND IS THE BOTTOM ONE. Anything past the
43
+ * camera's side edge is sliced by a hard vertical line that reads as a
44
+ * rendering fault. Outboard travel is budgeted against the hand's own
46
45
  * width AND its rotation — a hand 440 units tall swings ~110 units sideways
47
46
  * at 14 degrees, which is what silently blew the budget the first time.
48
47
  * `checkHandFraming()` asserts both rules against the real timelines.
49
48
  *
50
- * See docs/contract-protocol.md § Hand gestures for the wire surface and
51
- * docs/contract-avatar.md § The hand for what a face module owes this module
49
+ * See docs/internal-mixer.md § Hand gestures for the wire surface and
50
+ * docs/authoring-a-face.md § The hand for what a face module owes this module
52
51
  * (the answer is: a viewBox and two theme keys).
53
52
  */
54
53
 
55
54
  import { taper, taperRing, region, smooth } from './line-art.js';
56
55
  import { f } from './face-core.js';
56
+ import { CALL_CAMERA } from './camera.js';
57
57
 
58
58
  // --- the frame --------------------------------------------------------------
59
59
  // Everything the hand needs to place itself comes out of `META.viewBox`, and
60
- // that is the whole reason this module needed no new META field. The three
61
- // shipped avatars draw a 576x800 portrait window; the numbers below are ratios
62
- // and offsets against that window, not against any one character's anatomy.
60
+ // that is the whole reason this module needed no new META field. The shared
61
+ // camera makes 70% of its height a head, so size follows the person rather
62
+ // than growing merely because the new 4:3 camera is wider.
63
63
  //
64
64
  // The wrist sits below the frame edge by a clear margin, never at it: a wrist
65
65
  // exactly on the line renders as a rounded end kissing the border, which reads
66
66
  // as a hand stuck to the frame rather than one coming from off-camera.
67
67
  const WRIST_DROP = 24; // minimum units the wrist stays below the frame bottom
68
68
  const HIDE = 574; // fully off-camera; must clear the tallest shape
69
- // How far the outermost ink may sit from the frame centre before the portrait
70
- // window slices it (rule 4): half the window, less a hair of margin.
69
+ // How far the outermost ink may sit from the frame centre before the camera
70
+ // slices it (rule 4): half the window, less a hair of margin.
71
71
  const SIDE_MARGIN = 8;
72
- // Author scale at a 576-unit-wide window. The number this design changed most,
72
+ // Author scale at a 480-unit-tall head. The number this design changed most,
73
73
  // and both directions were rendered and judged. Earlier cuts drew the hand at
74
74
  // FACE depth: a 19 cm hand against a 23 cm head is 0.83 of it, peep's head is
75
75
  // 477 art units, so ~394 — which came out looking like a pale tube rising past
@@ -85,7 +85,8 @@ const SIDE_MARGIN = 8;
85
85
  // becomes ground, and the tile reads as broken before it reads as a gesture.
86
86
  // Optically correct, perceptually wrong. 2.95 puts the palm at ~0.82
87
87
  // head-widths — still clearly nearer the lens, still clearly not the subject.
88
- const REACH_AT_576 = 2.95;
88
+ // Compact avatar tiles need a hand that reads as a gesture, not as the subject.
89
+ const REACH_AT_480_HEAD = 2.40;
89
90
 
90
91
  /** The four placement numbers, derived. A host never sees these. */
91
92
  function frameOf(viewBox) {
@@ -93,7 +94,7 @@ function frameOf(viewBox) {
93
94
  return {
94
95
  cx: vb.x + vb.w / 2, // frame centre; also where a resting hand starts
95
96
  bottom: vb.y + vb.h, // the visible bottom edge — the line the hand rises past
96
- reach: (REACH_AT_576 * vb.w) / 576,
97
+ reach: (REACH_AT_480_HEAD * vb.h * CALL_CAMERA.head) / 480,
97
98
  outboardLimit: vb.w / 2 - SIDE_MARGIN,
98
99
  };
99
100
  }
@@ -129,17 +130,10 @@ const PALM = [
129
130
  // bottom edge, which reads as a shape RESTING ON the edge (a bag, a sheet of
130
131
  // paper) rather than one continuing past it. Widest at ~a third down from the
131
132
  // fingertips, ~65% of that at the cut, and the eye infers the arm for free.
132
- [26, 300], [28, 60], [31, 16],
133
- [36, -20], [41, -54], // the widest band
134
- [40, -86], [38, -112], // ring+little, splayed
135
- [35, -138], [27, -150], [18, -147], // DOMED tip, never pointed
136
- [13, -134], // notch floor: ~15% of finger length, and a U not a V
137
- [8, -146], [1, -164], [-8, -166], // middle, the longest
138
- [-16, -156],
139
- [-20, -136], // notch floor
140
- [-25, -148], [-33, -157], [-40, -150], // index, domed
141
- [-44, -130], [-46, -110],
142
- [-47, -92], // the thumb web — shallow, so this is a hand not a fork
133
+ [24, 300], [26, 58], [30, 18], [38, -18], [42, -48],
134
+ [39, -72], [31, -86], [21, -84],
135
+ [17, -104], [8, -116], [-4, -114], [-13, -102], [-16, -84],
136
+ [-22, -102], [-34, -111], [-46, -104], [-53, -91], [-53, -76],
143
137
  // The thumb is a CONVEX WEDGE, and that is a deletion rather than an
144
138
  // addition. An earlier thumb had a re-entrant curl in its outer contour; at
145
139
  // 130 px the hook read as a detached ear sitting beside the jaw, which was the
@@ -147,11 +141,8 @@ const PALM = [
147
141
  // this contour: it leaves the palm's outer edge, swells to a rounded pad at
148
142
  // ~40 degrees off the palm axis, and comes back. A thumb is a wedge, and a
149
143
  // wedge has two edges.
150
- [-56, -99], [-65, -96], [-71, -87],
151
- [-72, -75], [-65, -67], // the pad: broad, round
152
- [-56, -58], [-49, -46],
153
- [-46, -26], [-42, 4], // thenar, then the taper
154
- [-36, 60], [-32, 300],
144
+ [-68, -73], [-80, -64], [-84, -52], [-80, -40],
145
+ [-68, -31], [-56, -22], [-47, -5], [-41, 28], [-34, 300],
155
146
  ];
156
147
  // Where the finger information moved to. An earlier cut put the interdigital
157
148
  // valleys 50-60% of the way down the fingers, which makes a SAW: at 130 px a
@@ -167,8 +158,8 @@ const PALM = [
167
158
  // size this ships at) and at full size it reads as a scratch. Economy is not a
168
159
  // style here, it is the difference between a mark and a blemish.
169
160
  const PALM_SEPS = [
170
- [[13, -130], [14, -122], [15, -113]], // little/ring from middle
171
- [[-20, -132], [-21, -124], [-21, -115]], // middle from index
161
+ [[10, -94], [10, -89], [11, -84]],
162
+ [[-20, -94], [-21, -89], [-21, -84]],
172
163
  ];
173
164
  // The one interior mark that says PALM rather than back-of-hand — and it is the
174
165
  // THENAR crease, the arc around the ball of the thumb, not a line across the
@@ -177,7 +168,7 @@ const PALM_SEPS = [
177
168
  // rounded white form, at tile size, beside a face, reads as a MOUTH. The arc
178
169
  // runs off the bottom edge instead of ending, so nothing about it says "this
179
170
  // mark stopped here".
180
- const PALM_CREASE = [[-46, -80], [-41, -54], [-30, -26], [-14, -2], [-8, 14]];
171
+ const PALM_CREASE = [[-53, -65], [-46, -47], [-34, -27], [-18, -8]];
181
172
 
182
173
  // --- dorsal: the fist, for thumbs-up ----------------------------------------
183
174
  // The back of a closed hand: an undulating top edge of metacarpal heads, and a
@@ -199,13 +190,10 @@ const PALM_CREASE = [[-46, -80], [-41, -54], [-30, -26], [-14, -2], [-8, 14]];
199
190
  // that is true, but by 16 units across 60 rather than by 30; the mass is 82 wide
200
191
  // against 78 visible. The two undulations survive only as inflections in it.
201
192
  const FIST = [
202
- [24, 300], [26, 60], [30, 12], // the wrist really is ~60% of a fist
203
- [36, -22], [40, -46], // the heel, and the widest band
204
- [39, -66], [30, -74], [22, -70], // TWO undulations, not four knuckles
205
- [12, -80], [-2, -86], [-14, -82], // the larger one, index side
206
- [-26, -76], [-36, -64],
207
- [-42, -42], [-41, -16],
208
- [-37, 60], [-34, 300],
193
+ [24, 300], [26, 62], [30, 20], [39, -7], [45, -31],
194
+ [42, -51], [31, -64], [14, -70], [-4, -69],
195
+ [-21, -63], [-35, -51], [-42, -31], [-40, -6],
196
+ [-35, 62], [-31, 300],
209
197
  ];
210
198
  // Four small knuckle bumps were drawn at the same scale and rhythm as peep's
211
199
  // hair spikes, so at 130 px the row read as a lapel zigzag continuing the
@@ -223,7 +211,7 @@ const FIST = [
223
211
  // frame cut and left the knuckle half of the fist empty. An empty top and a
224
212
  // marked bottom is upside down: the eye goes to the crest first, and finds
225
213
  // nothing there.
226
- const FIST_CURL = [[24, -48], [8, -58], [-8, -60], [-24, -52]];
214
+ const FIST_CURL = [[22, -43], [7, -50], [-10, -49], [-25, -41]];
227
215
  // The thumb is a SEPARATE CLOSED SHAPE crossing the fist, which is what keeps
228
216
  // the middle-finger read dead — the first thumbs-up drawn here was rejected on
229
217
  // sight for exactly that. A glyph has tolerances and these are them:
@@ -248,12 +236,9 @@ const FIST_CURL = [[24, -48], [8, -58], [-8, -60], [-24, -52]];
248
236
  // its tip and the entire radial column reads as ONE very long digit — the
249
237
  // middle-finger silhouette, rebuilt out of correct parts.
250
238
  const FIST_THUMB = [
251
- [10, -48], // base, deep inside the fist's mass
252
- [2, -78], [-1, -100], // inner edge — SHORT, and that is the point
253
- [-6, -118], [-18, -128], [-31, -122], // domed tip, and a wide dome
254
- [-38, -106], [-38, -88],
255
- [-36, -70], [-32, -56], [-25, -44], // outer edge, tucking back under the crest
256
- [10, -48],
239
+ [17, -42], [8, -61], [2, -78], [-8, -90],
240
+ [-21, -90], [-31, -80], [-33, -66], [-27, -52],
241
+ [-16, -41], [17, -42],
257
242
  ];
258
243
  // There is no thumbnail, and that is the clearest single deletion in this
259
244
  // drawing. A short curved crease near the top of the thumb, at 130 px, on a
@@ -270,11 +255,9 @@ const FIST_THUMB = [
270
255
  // meaning lives in one unambiguous silhouette rather than in notches, and where
271
256
  // it crosses the hair, value does the separating.
272
257
  const POINT = [
273
- [24, 300], [26, 60], [30, 12],
274
- [36, -22], [40, -46], // the heel, and the widest band
275
- [39, -66], [30, -74], [22, -70], // two undulations, as on FIST
276
- [12, -80], [-2, -84],
277
- [-8, -90], // the web — a rounded corner, barely a notch
258
+ [24, 300], [26, 62], [30, 20], [39, -7], [44, -29],
259
+ [37, -49], [17, -53], [8, -66], [6, -107],
260
+ [0, -128], [-11, -136], [-23, -130], [-29, -116], [-29, -72],
278
261
  // The index. It once cleared the knuckles by 75 units against a visible fist
279
262
  // of 90 — anatomically defensible, and at 0.79 of a head-width it was the
280
263
  // longest single mark in the tile. Nothing that is not the face gets to be
@@ -286,15 +269,9 @@ const POINT = [
286
269
  // It tapers toward the tip and carries no joint pinch. The pinch was drawn in
287
270
  // the OUTLINE, and a wobble that small in a contour does not read as a knuckle
288
271
  // at any size — it reads as an unsteady line.
289
- [-12, -106], [-15, -122], // inner edge
290
- [-18, -136], [-25, -146], [-32, -143], // domed tip, never pointed
291
- [-35, -129], [-34, -112], // outer edge
292
- [-36, -94],
293
272
  // The thumb, clamped across the curled fingers and showing as a lobe on the
294
273
  // flank. An index-up fist with no thumb anywhere in it is quietly impossible.
295
- [-44, -76], [-47, -62], [-44, -50],
296
- [-42, -38], [-41, -16],
297
- [-37, 60], [-34, 300],
274
+ [-39, -55], [-42, -31], [-40, -6], [-35, 62], [-31, 300],
298
275
  ];
299
276
 
300
277
  // Ink weights are in ART units, sampled off peep's own marks: the torso runs
@@ -318,22 +295,22 @@ const POINT = [
318
295
  // (compose so it doesn't cross; weight hierarchy; knockout) and it is the one
319
296
  // already native to these rigs, whose head contour outweighs the brow which
320
297
  // outweighs the ear whorl.
321
- const W_OUTLINE = [16, 15, 9, 6, 9, 15, 17];
298
+ const W_OUTLINE = [12, 11, 8, 6, 8, 11, 12];
322
299
  // Device 3, the knockout: a white halo that breaks the shirt's seams where the
323
300
  // hand crosses them. It is deliberately NARROW across the middle of the mark:
324
301
  // the hand's upper half sits against the background, and a white rim there would
325
302
  // turn it into a cut-out sticker. Only the tails — the part over the shirt — get
326
303
  // the full gap. Where the halo width equals the ink width it is entirely covered
327
304
  // and costs nothing, so the profile is scaled with the ink above it.
328
- const W_HALO = [34, 29, 13, 6, 13, 29, 35];
305
+ const W_HALO = [26, 22, 13, 6, 13, 22, 26];
329
306
  const W_CREASE = [2, 8, 2];
330
307
  // The palm's finger separators: thick where they leave the notch floor, gone by
331
308
  // the end. This is the ear-whorl mark language, applied to a hand.
332
- const W_SEP = [7, 3, 0.5];
309
+ const W_SEP = [5, 2, 0.5];
333
310
  // The fist's one interior mark. Thick-to-thin across an S, so it reads as a form
334
311
  // turning rather than as a drawn line.
335
312
  const W_CURL = [3, 9, 7, 2];
336
- const W_THUMB = [12, 14, 11, 8, 11, 14, 12];
313
+ const W_THUMB = [9, 11, 9, 7, 9, 11, 9];
337
314
 
338
315
  // The shapes, and the interior marks each one carries. Interior marks carry
339
316
  // their own width profile rather than sharing one: three of them exist, they do
@@ -354,7 +331,7 @@ const SHAPES = {
354
331
  // being an empty slab.
355
332
  POINT: {
356
333
  outline: POINT,
357
- marks: [{ pts: [[22, -50], [6, -58], [-10, -58]], w: W_CURL }],
334
+ marks: [{ pts: [[20, -42], [5, -48], [-11, -47]], w: W_CURL }],
358
335
  rings: [],
359
336
  },
360
337
  };
@@ -435,11 +412,11 @@ export const HAND_GESTURES = {
435
412
  //
436
413
  // The swing is deliberately ASYMMETRIC, -2 out and +16 in. A wave rotating
437
414
  // about a wrist below the frame throws the fingertips ~110 units sideways,
438
- // and spending that outboard is what put the thumb through the portrait
439
- // window the first time. Swinging further toward the person you are waving at
415
+ // and spending that outboard is what put the thumb through the camera edge
416
+ // the first time. Swinging further toward the person you are waving at
440
417
  // is also, conveniently, what people do.
441
- HI: {
442
- id: 'HI', label: 'wave hello', shape: 'PALM', face: 'WAVE', dur: 1250, sc: 0.70,
418
+ GESTURE_GREET: {
419
+ id: 'GESTURE_GREET', label: 'greet', shape: 'PALM', face: 'GESTURE_GREET', dur: 1250, sc: 0.70,
443
420
  out: [[0, 0], [150, 74], [300, 114], [1000, 114], [1250, 30]],
444
421
  dy: [[0, HIDE], [150, 240], [300, 40], [390, 60], [700, 50], [1000, 58], [1120, 136], [1250, HIDE]],
445
422
  rot: [[0, -3], [150, 2], [310, 16], [475, -2], [640, 16], [805, -1], [970, 12], [1000, 8], [1250, -3]],
@@ -447,8 +424,8 @@ export const HAND_GESTURES = {
447
424
  // Goodbye: same hand, slower, one more swing, and it lingers at the top
448
425
  // before dropping. A wave that leaves as briskly as it arrived reads as a
449
426
  // dismissal rather than a farewell.
450
- BYE: {
451
- id: 'BYE', label: 'wave goodbye', shape: 'PALM', face: 'WAVE', dur: 1550, sc: 0.70,
427
+ GESTURE_GOODBYE: {
428
+ id: 'GESTURE_GOODBYE', label: 'goodbye', shape: 'PALM', face: 'GESTURE_GREET', dur: 1550, sc: 0.70,
452
429
  out: [[0, 0], [170, 76], [320, 116], [1300, 116], [1550, 32]],
453
430
  dy: [[0, HIDE], [160, 230], [320, 32], [410, 54], [700, 42], [1000, 52], [1300, 46], [1420, 128], [1550, HIDE]],
454
431
  rot: [[0, -3], [170, 2], [330, 16], [510, -2], [690, 16], [870, -2], [1050, 16], [1230, -1], [1300, 8], [1550, -3]],
@@ -461,8 +438,8 @@ export const HAND_GESTURES = {
461
438
  // exactly why it read the way it did. The wrist spends the entire budget (it
462
439
  // sits on the floor), so the fist rides the bottom edge and only the thumb is
463
440
  // up near the face.
464
- THUMBS_UP: {
465
- id: 'THUMBS_UP', label: 'thumbs up', shape: 'FIST', face: 'THUMBS_UP', dur: 1300,
441
+ GESTURE_APPROVE: {
442
+ id: 'GESTURE_APPROVE', label: 'approve', shape: 'FIST', face: 'GESTURE_APPROVE', dur: 1300, sc: 0.82,
466
443
  out: [[0, 0], [160, 54], [300, 84], [1050, 84], [1300, 32]],
467
444
  dy: [[0, HIDE], [160, 174], [300, 24], [390, 42], [700, 34], [1050, 40], [1160, 120], [1300, HIDE]],
468
445
  rot: [[0, -6], [160, -2], [300, 3], [400, 0], [700, 1.5], [1000, 0], [1050, 0], [1300, -8]],
@@ -472,8 +449,8 @@ export const HAND_GESTURES = {
472
449
  // couple of units and a degree, which reads as a held hand rather than as a
473
450
  // stopped clock. Held longest of the four, because it is the one gesture
474
451
  // whose job is to buy time (research-perception.md §1, latency masking).
475
- ONE_MOMENT: {
476
- id: 'ONE_MOMENT', label: 'one moment', shape: 'POINT', face: 'ONE_MOMENT', dur: 1700,
452
+ GESTURE_WAIT: {
453
+ id: 'GESTURE_WAIT', label: 'wait', shape: 'POINT', face: 'GESTURE_WAIT', dur: 1700, sc: 0.78,
477
454
  out: [[0, 0], [170, 60], [320, 96], [1400, 96], [1700, 34]],
478
455
  dy: [[0, HIDE], [170, 244], [320, 32], [410, 54], [700, 46], [1100, 53], [1400, 48], [1520, 136], [1700, HIDE]],
479
456
  rot: [[0, -7], [170, -2], [320, 2], [420, 0], [800, 1], [1200, -0.5], [1400, 0], [1700, -9]],
@@ -482,6 +459,22 @@ export const HAND_GESTURES = {
482
459
 
483
460
  export const HAND_GESTURE_IDS = Object.keys(HAND_GESTURES);
484
461
 
462
+ /** Public renderer-frame gesture names → current SVG timelines. */
463
+ const FRAME_GESTURES = Object.freeze({
464
+ greet: 'GESTURE_GREET',
465
+ farewell: 'GESTURE_GOODBYE',
466
+ approve: 'GESTURE_APPROVE',
467
+ wait: 'GESTURE_WAIT',
468
+ });
469
+ const GESTURE_TO_FRAME = Object.freeze(Object.fromEntries(
470
+ Object.entries(FRAME_GESTURES).map(([name, id]) => [id, name]),
471
+ ));
472
+
473
+ /** Stable SVG-action names → renderer-neutral hand controls. */
474
+ export const HAND_ACTION_TO_FRAME_GESTURE = Object.freeze({
475
+ ...GESTURE_TO_FRAME,
476
+ });
477
+
485
478
  // GO_ON is deliberately absent, and this is the reasoning rather than an
486
479
  // oversight. It was drawn as a low splayed open palm rocking at the wrist; the
487
480
  // stakeholder's verdict was "go on doesn't work for me" and the review was
@@ -520,7 +513,7 @@ function sample(keys, t) {
520
513
 
521
514
  /**
522
515
  * Mount the hand over a face and return the player. Built by `createAvatar`;
523
- * a host drives it through `avatar.gesture(id)`, never directly.
516
+ * the public avatar composes it through a semantic `GESTURE_*` action.
524
517
  *
525
518
  * @param {SVGElement} svg the mounted face's own root — same coordinate space
526
519
  * @param {{ink:string, paper:string}} theme the mounted face's palette
@@ -575,6 +568,7 @@ export function createHand(svg, theme, meta, opts = {}) {
575
568
  for (const [k, s] of Object.entries(SHAPES)) shapes[k] = build(s);
576
569
 
577
570
  let current = null; // { def, start }
571
+ const queue = [];
578
572
  let lastT = 0;
579
573
 
580
574
  function place(x, y, rot, sc) {
@@ -588,19 +582,50 @@ export function createHand(svg, theme, meta, opts = {}) {
588
582
  for (const s of Object.values(shapes)) s.style.display = 'none';
589
583
  place(fr.cx, fr.bottom + HIDE, 0, 1);
590
584
  }
585
+ function show(def) {
586
+ for (const [k, s] of Object.entries(shapes)) s.style.display = k === def.shape ? '' : 'none';
587
+ }
588
+ function applyFrame(frame) {
589
+ if (!frame) { park(); return; }
590
+ const id = FRAME_GESTURES[frame.gesture];
591
+ const def = HAND_GESTURES[id];
592
+ if (!def) { park(); return; }
593
+ dir = frame.side === 'left' ? -1 : 1;
594
+ show(def);
595
+ const local = Math.max(0, Math.min(1, frame.progress)) * def.dur;
596
+ place(
597
+ fr.cx + dir * sample(def.out, local),
598
+ fr.bottom + sample(def.dy, local),
599
+ sample(def.rot, local),
600
+ def.sc || 1,
601
+ );
602
+ }
591
603
  park();
592
604
 
593
605
  return {
594
606
  get playing() { return !!current; },
595
607
  get id() { return current ? current.def.id : null; },
608
+ /** The semantic hand control for the current animation frame, or null. */
609
+ get frame() {
610
+ if (!current) return null;
611
+ return {
612
+ gesture: GESTURE_TO_FRAME[current.def.id],
613
+ progress: Math.max(0, Math.min(1, (lastT - current.start) / current.def.dur)),
614
+ side: dir === -1 ? 'left' : 'right',
615
+ };
616
+ },
617
+ /** Render a first-class AvatarFrame hand control. */
618
+ applyFrame,
596
619
  setDir(d) { dir = d === -1 ? -1 : 1; if (!current) park(); },
597
620
  /** @param {string} id @param {number} [atMs] start time on the layer's clock */
598
- play(id, atMs) {
621
+ play(id, atMs, { queue: shouldQueue = false } = {}) {
599
622
  const def = HAND_GESTURES[id];
600
623
  if (!def) throw new Error(`unknown hand gesture: ${id}`);
601
- for (const [k, s] of Object.entries(shapes)) {
602
- s.style.display = k === def.shape ? '' : 'none';
624
+ if (current && shouldQueue) {
625
+ if (current.def.id !== id && !queue.some((item) => item.id === id)) queue.push({ id, def });
626
+ return def;
603
627
  }
628
+ show(def);
604
629
  current = { def, start: atMs !== undefined ? atMs : lastT };
605
630
  return def;
606
631
  },
@@ -610,15 +635,19 @@ export function createHand(svg, theme, meta, opts = {}) {
610
635
  const local = tMs - current.start;
611
636
  if (local < 0) return;
612
637
  const def = current.def;
613
- if (local >= def.dur) { current = null; park(); return def; }
614
- place(
615
- fr.cx + dir * sample(def.out, local),
616
- fr.bottom + sample(def.dy, local),
617
- sample(def.rot, local),
618
- def.sc || 1
619
- );
638
+ if (local >= def.dur) {
639
+ const done = def;
640
+ const next = queue.shift();
641
+ if (next) {
642
+ show(next.def);
643
+ current = { def: next.def, start: tMs };
644
+ } else {
645
+ current = null;
646
+ }
647
+ return done;
648
+ }
620
649
  },
621
- stop() { current = null; park(); },
650
+ stop() { current = null; queue.length = 0; park(); },
622
651
  destroy() { if (g.parentNode) g.parentNode.removeChild(g); },
623
652
  };
624
653
  }
@@ -626,12 +655,12 @@ export function createHand(svg, theme, meta, opts = {}) {
626
655
  /**
627
656
  * Rules 1 and 4, asserted rather than eyeballed — both were violated by an
628
657
  * early cut and neither is visible in a still of the resting pose. Run by
629
- * `tools/sweep.mjs` against every registered avatar, because the frame numbers
658
+ * `pnpm test` against every registered avatar, because the frame numbers
630
659
  * are derived per avatar and a rig with a different window could break the
631
660
  * budget without anything else noticing.
632
661
  *
633
662
  * rule 1 the wrist never rises into the frame;
634
- * rule 4 no gesture pushes ink through the portrait window's side edge, at
663
+ * rule 4 no gesture pushes ink through the camera window's side edge, at
635
664
  * any point in its rotation. A hand 440 units tall throws its outer
636
665
  * corner ~110 units sideways at 14 degrees, so the budget has to be
637
666
  * spent against `out` AND `rot` together.
package/src/idle.js CHANGED
@@ -62,7 +62,7 @@ const rand = ([a, b]) => a + Math.random() * (b - a);
62
62
  * Everything else here is an oscillator, and an oscillator cannot make a body
63
63
  * look alive across a thirty-second hold. Fast enough to notice and it reads
64
64
  * 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
65
+ * image. A motion map of the listening state (apps/authoring/tools/motion.mjs) showed the
66
66
  * outer edge of the torso travelling zero pixels over 24 seconds — the sway
67
67
  * was there in the numbers and rendered as nothing at all.
68
68
  *
@@ -384,91 +384,37 @@ export class IdleLayer {
384
384
  }
385
385
 
386
386
  /**
387
- * The listening engine: backchannels and engagement posture for LISTENING.
387
+ * The listening engine is now deliberately posture-only.
388
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).
408
- *
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.
389
+ * `setUserSpeaking()` receives Pipecat's VAD verdict through the client
390
+ * adapter. It may alter the sustained engagement lean, but it never chooses or
391
+ * fires a clip: a nod, brow acknowledgement or any other facial claim of
392
+ * understanding is an explicit backend/application `action()` decision.
411
393
  */
412
394
  export class ListeningEngine {
413
- constructor(fire) {
414
- this.fire = fire;
395
+ constructor() {
415
396
  this.enabled = false;
416
397
  this.t = 0;
417
398
  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
399
  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;
400
+ this._explicit = null;
401
+ this._speaking = false;
402
+ this._silentAt = 0;
431
403
  }
432
404
 
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
- }
405
+ /** Compatibility no-ops retained for existing hosts that reached these
406
+ * internals indirectly. With no autonomous clips there is nothing to reset
407
+ * or cede. */
408
+ cede() {}
409
+ get ceded() { return false; }
410
+ reset() {}
440
411
 
441
- /** Server-declared user speech, off the pipeline's own endpointer. Pass null
442
- * to hand back to the no-signal timer. */
412
+ /** Pipecat VAD's user-speaking truth. `null` removes the engagement signal. */
443
413
  setUserSpeaking(b) {
444
414
  if (b !== null) this._hasSignal = true;
445
415
  this._explicit = b === null ? null : !!b;
446
416
  }
447
417
 
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
418
  get speaking() { return this._explicit === true; }
473
419
 
474
420
  update(dt) {
@@ -480,39 +426,10 @@ export class ListeningEngine {
480
426
  // relaxing the moment they pause would read as relief that they stopped.
481
427
  if (speaking !== this._speaking) {
482
428
  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
- }
429
+ if (!speaking) this._silentAt = t;
493
430
  }
494
431
  const engaged = speaking || t - this._silentAt < 8;
495
432
  this.engage = approach(this.engage, engaged && this._hasSignal ? 1 : 0,
496
433
  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
434
  }
518
435
  }