@voqalize/avatar 0.4.1 → 0.4.3

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 (203) hide show
  1. package/LICENSE-APACHE-2.0 +201 -0
  2. package/README.md +26 -95
  3. package/assets/README.md +30 -18
  4. package/assets/tanvi.glb +0 -0
  5. package/assets/tanya.glb +0 -0
  6. package/assets/tara.glb +0 -0
  7. package/assets/tess.glb +0 -0
  8. package/assets/tushar.glb +0 -0
  9. package/client/internal.ts +24 -0
  10. package/client/supports.ts +6 -7
  11. package/client/three/assets.ts +7 -3
  12. package/client/three/budgets.ts +1 -1
  13. package/client/three/{tara-rig.ts → character-rig.ts} +682 -540
  14. package/client/three/createCharacter.ts +109 -0
  15. package/client/three/holds.ts +8 -3
  16. package/client/three/internal.ts +8 -7
  17. package/client/three/motion-limits.json +10 -2
  18. package/client/three/sequences.ts +5 -5
  19. package/client/three/tanvi-asset.ts +6 -0
  20. package/client/three/tanvi.ts +30 -0
  21. package/client/three/tanya.ts +18 -58
  22. package/client/three/tara.ts +19 -60
  23. package/client/three/tess-asset.ts +6 -0
  24. package/client/three/tess.ts +30 -0
  25. package/client/three/tushar.ts +18 -55
  26. package/dist/internal.d.ts +2 -2
  27. package/dist/internal.d.ts.map +1 -1
  28. package/dist/internal.js +16 -1
  29. package/dist/internal.js.map +1 -1
  30. package/dist/supports.d.ts +6 -7
  31. package/dist/supports.d.ts.map +1 -1
  32. package/dist/supports.js +6 -7
  33. package/dist/supports.js.map +1 -1
  34. package/dist/three/assets.d.ts +2 -0
  35. package/dist/three/assets.d.ts.map +1 -1
  36. package/dist/three/assets.js +7 -3
  37. package/dist/three/assets.js.map +1 -1
  38. package/dist/three/budgets.d.ts +1 -1
  39. package/dist/three/budgets.js +1 -1
  40. package/dist/three/character-rig.d.ts +366 -0
  41. package/dist/three/character-rig.d.ts.map +1 -0
  42. package/dist/three/{tara-rig.js → character-rig.js} +633 -526
  43. package/dist/three/character-rig.js.map +1 -0
  44. package/dist/three/createCharacter.d.ts +60 -0
  45. package/dist/three/createCharacter.d.ts.map +1 -0
  46. package/dist/three/createCharacter.js +84 -0
  47. package/dist/three/createCharacter.js.map +1 -0
  48. package/dist/three/holds.d.ts +7 -2
  49. package/dist/three/holds.d.ts.map +1 -1
  50. package/dist/three/holds.js +8 -3
  51. package/dist/three/holds.js.map +1 -1
  52. package/dist/three/internal.d.ts +7 -7
  53. package/dist/three/internal.d.ts.map +1 -1
  54. package/dist/three/internal.js +6 -6
  55. package/dist/three/internal.js.map +1 -1
  56. package/dist/three/motion-limits.json +10 -2
  57. package/dist/three/sequences.d.ts +5 -5
  58. package/dist/three/sequences.js +5 -5
  59. package/dist/three/tanvi-asset.d.ts +7 -0
  60. package/dist/three/tanvi-asset.d.ts.map +1 -0
  61. package/dist/three/tanvi-asset.js +7 -0
  62. package/dist/three/tanvi-asset.js.map +1 -0
  63. package/dist/three/tanvi.d.ts +24 -0
  64. package/dist/three/tanvi.d.ts.map +1 -0
  65. package/dist/three/tanvi.js +24 -0
  66. package/dist/three/tanvi.js.map +1 -0
  67. package/dist/three/tanya.d.ts +16 -28
  68. package/dist/three/tanya.d.ts.map +1 -1
  69. package/dist/three/tanya.js +15 -43
  70. package/dist/three/tanya.js.map +1 -1
  71. package/dist/three/tara.d.ts +17 -22
  72. package/dist/three/tara.d.ts.map +1 -1
  73. package/dist/three/tara.js +16 -45
  74. package/dist/three/tara.js.map +1 -1
  75. package/dist/three/tess-asset.d.ts +7 -0
  76. package/dist/three/tess-asset.d.ts.map +1 -0
  77. package/dist/three/tess-asset.js +7 -0
  78. package/dist/three/tess-asset.js.map +1 -0
  79. package/dist/three/tess.d.ts +24 -0
  80. package/dist/three/tess.d.ts.map +1 -0
  81. package/dist/three/tess.js +24 -0
  82. package/dist/three/tess.js.map +1 -0
  83. package/dist/three/tushar.d.ts +16 -25
  84. package/dist/three/tushar.d.ts.map +1 -1
  85. package/dist/three/tushar.js +15 -40
  86. package/dist/three/tushar.js.map +1 -1
  87. package/package.json +10 -49
  88. package/src/avatar.d.ts +67 -2
  89. package/src/avatar.js +131 -255
  90. package/src/faces.js +1 -1
  91. package/src/gaze.js +20 -47
  92. package/src/idle.js +69 -27
  93. package/src/prosody.js +109 -15
  94. package/src/rig.d.ts +7 -0
  95. package/src/speech-timing.js +9 -0
  96. package/src/visemes.js +107 -13
  97. package/client/arjun.ts +0 -26
  98. package/client/createCanvasAvatar.ts +0 -72
  99. package/client/interviewer-female.ts +0 -4
  100. package/client/interviewer-male.ts +0 -4
  101. package/client/ishita.ts +0 -26
  102. package/client/kabir.ts +0 -26
  103. package/client/meera.ts +0 -26
  104. package/client/naina.ts +0 -26
  105. package/client/professional-female-a.ts +0 -4
  106. package/client/professional-female-b.ts +0 -4
  107. package/client/professional-male-a.ts +0 -4
  108. package/client/professional-male-b.ts +0 -4
  109. package/client/vikram.ts +0 -26
  110. package/dist/arjun.d.ts +0 -7
  111. package/dist/arjun.d.ts.map +0 -1
  112. package/dist/arjun.js +0 -20
  113. package/dist/arjun.js.map +0 -1
  114. package/dist/createCanvasAvatar.d.ts +0 -22
  115. package/dist/createCanvasAvatar.d.ts.map +0 -1
  116. package/dist/createCanvasAvatar.js +0 -47
  117. package/dist/createCanvasAvatar.js.map +0 -1
  118. package/dist/interviewer-female.d.ts +0 -4
  119. package/dist/interviewer-female.d.ts.map +0 -1
  120. package/dist/interviewer-female.js +0 -3
  121. package/dist/interviewer-female.js.map +0 -1
  122. package/dist/interviewer-male.d.ts +0 -4
  123. package/dist/interviewer-male.d.ts.map +0 -1
  124. package/dist/interviewer-male.js +0 -3
  125. package/dist/interviewer-male.js.map +0 -1
  126. package/dist/ishita.d.ts +0 -7
  127. package/dist/ishita.d.ts.map +0 -1
  128. package/dist/ishita.js +0 -20
  129. package/dist/ishita.js.map +0 -1
  130. package/dist/kabir.d.ts +0 -7
  131. package/dist/kabir.d.ts.map +0 -1
  132. package/dist/kabir.js +0 -20
  133. package/dist/kabir.js.map +0 -1
  134. package/dist/meera.d.ts +0 -7
  135. package/dist/meera.d.ts.map +0 -1
  136. package/dist/meera.js +0 -20
  137. package/dist/meera.js.map +0 -1
  138. package/dist/naina.d.ts +0 -7
  139. package/dist/naina.d.ts.map +0 -1
  140. package/dist/naina.js +0 -20
  141. package/dist/naina.js.map +0 -1
  142. package/dist/professional-female-a.d.ts +0 -4
  143. package/dist/professional-female-a.d.ts.map +0 -1
  144. package/dist/professional-female-a.js +0 -3
  145. package/dist/professional-female-a.js.map +0 -1
  146. package/dist/professional-female-b.d.ts +0 -4
  147. package/dist/professional-female-b.d.ts.map +0 -1
  148. package/dist/professional-female-b.js +0 -3
  149. package/dist/professional-female-b.js.map +0 -1
  150. package/dist/professional-male-a.d.ts +0 -4
  151. package/dist/professional-male-a.d.ts.map +0 -1
  152. package/dist/professional-male-a.js +0 -3
  153. package/dist/professional-male-a.js.map +0 -1
  154. package/dist/professional-male-b.d.ts +0 -4
  155. package/dist/professional-male-b.d.ts.map +0 -1
  156. package/dist/professional-male-b.js +0 -3
  157. package/dist/professional-male-b.js.map +0 -1
  158. package/dist/three/tara-rig.d.ts +0 -409
  159. package/dist/three/tara-rig.d.ts.map +0 -1
  160. package/dist/three/tara-rig.js.map +0 -1
  161. package/dist/vikram.d.ts +0 -7
  162. package/dist/vikram.d.ts.map +0 -1
  163. package/dist/vikram.js +0 -20
  164. package/dist/vikram.js.map +0 -1
  165. package/src/canvas/author/parts/eye.mjs +0 -722
  166. package/src/canvas/author/parts/hand.mjs +0 -1156
  167. package/src/canvas/author/parts/mouth.mjs +0 -741
  168. package/src/canvas/author/parts/nose.mjs +0 -100
  169. package/src/canvas/author/parts/skin-detail.mjs +0 -67
  170. package/src/canvas/author/path.mjs +0 -283
  171. package/src/canvas/author/rig.mjs +0 -405
  172. package/src/canvas/avatars/round/face.d.mts +0 -3
  173. package/src/canvas/avatars/round/face.mjs +0 -1307
  174. package/src/canvas/create-rig.d.ts +0 -15
  175. package/src/canvas/create-rig.js +0 -100
  176. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  177. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  178. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  179. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  180. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  181. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  182. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  183. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  184. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  185. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  186. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  187. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  188. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  189. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  190. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  191. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  192. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  193. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  194. package/src/canvas/data/interviewer-female.rig.json +0 -1
  195. package/src/canvas/data/interviewer-male.rig.json +0 -1
  196. package/src/canvas/data/professional-female-a.rig.json +0 -1
  197. package/src/canvas/data/professional-female-b.rig.json +0 -1
  198. package/src/canvas/data/professional-male-a.rig.json +0 -1
  199. package/src/canvas/data/professional-male-b.rig.json +0 -1
  200. package/src/canvas/src/live.js +0 -508
  201. package/src/canvas/src/render2d.js +0 -218
  202. package/src/canvas/src/rig.js +0 -297
  203. package/src/canvas/src/vocab.js +0 -96
package/src/avatar.js CHANGED
@@ -6,27 +6,11 @@
6
6
  * addressed by our own runtime, our tooling, and an avatar author who chose to
7
7
  * build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
8
8
  *
9
- * const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
10
- * avatar.setState('LISTENING', { emotion: 'warm' })
11
- * avatar.setGaze('SCREEN_LEFT')
12
- * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
13
- * avatar.pushCues(moreCues) // streaming top-up
14
- * avatar.action('ACK_RECEIVE')
15
- * avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
16
- * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
17
- * avatar.setUserSpeaking(bool) // the user has the floor, so listening
18
- * // is contingent instead of timed
19
- *
20
9
  * Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
21
10
  * by later ones on the channels they touch; the gesture and idle layers are
22
11
  * additive so they compose rather than fight.
23
12
  *
24
13
  * base pose (state + emotion) -> gaze -> visemes -> clip -> idle
25
- *
26
- * The one hard priority rule: while the server viseme track is playing, it owns
27
- * the mouth outright. An interjection firing mid-sentence contributes its head
28
- * and brows and its mouth track is dropped — otherwise the avatar would appear
29
- * to say two things at once.
30
14
  */
31
15
 
32
16
  import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
@@ -67,8 +51,7 @@ export const STATES = {
67
51
  // the attentive pose it looks like — it is a demand for more talk (Rossano)
68
52
  // and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
69
53
  // gaze.js for the numbers; the mixer holds it off near a turn boundary.
70
- // The blink timer runs slower than the ~17/min it lands on because a sixth
71
- // of timed blinks come as a pair; at 3.2-4.4 s it measured 20-23/min.
54
+ // At 3.2-4.4 s the blink timer measured 20-23/min.
72
55
  LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
73
56
  aversion: 'LISTEN',
74
57
  idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
@@ -91,8 +74,6 @@ export const STATES = {
91
74
  // is kept to a fifth of looks because on a face this real it reads as
92
75
  // downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
93
76
  // alert, and the two together measured past the 0.15 that reads drowsy.
94
- // The handoff to SPEAKING is in setState: a reply that starts mid-look
95
- // finishes the look instead of snapping back.
96
77
  THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
97
78
  // Fixational jumps rare and small: a thinker's eyes rest
98
79
  // where they land. At the default gap the look jittered
@@ -117,32 +98,21 @@ export const STATES = {
117
98
  'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
118
99
  'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
119
100
  stick: 0.55, dart: { p: 0.4, mag: 0.08, brow: 0.12 }, blinkTo: true },
120
- // **The chin comes up, and that is the state's loudest
121
- // signal below the eyes.** Thinking is the one stretch
122
- // of a call where the avatar owes the user visible
123
- // feedback and has no mouth to give it with: the eyes
124
- // are off the user by design, so without the head there
125
- // is nothing left moving that says *working on it*
126
- // rather than *gone*. A head that tips back as the gaze
127
- // leaves is also what the research calls a swing-up —
128
- // "nodding with swinging up is regarded to reflect a
129
- // cognitive shift in the listener"
130
- // (research-biomechanics.md § 3.3) — so the same
131
- // gesture that means "ah, I see" at the end of a nod
132
- // means "let me think" at the start of a pause.
133
- //
134
- // It rides the looks that go up, as the head's share of
135
- // them, and not the state. Held as a pose it lifted the
136
- // chin on the level and downward looks too and on every
137
- // check-in, so the user was looked at down the nose, and
138
- // eyes drawn inside a head tipped back aimed every look
139
- // meant to be level at the ceiling.
101
+ // **The chin comes up**: thinking is the one stretch of
102
+ // a call where the avatar owes the user feedback and
103
+ // has no mouth to give it with, the eyes being off the
104
+ // user by design. A head tipping back as the gaze
105
+ // leaves is the swing-up of research-biomechanics.md
106
+ // § 3.3. It rides the looks that go up, as the head's
107
+ // *share* of them: held as a pose it lifted the chin on
108
+ // the level and downward looks too, so the user was
109
+ // looked at down the nose.
140
110
  pose: { lidL: -0.10, lidR: -0.10 } },
141
- // Eyes on the user, with the speaker's own looks away (AVERSION.SPEAK):
142
- // a planning look at the start of most turns, a short one every ~5 s at a
143
- // phrase boundary, and none as the turn ends. A speaker who never looks
144
- // away is staring the listener down.
145
- SPEAKING: { gaze: 'USER', emotion: 'neutral', aversion: 'SPEAK',
111
+ // Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
112
+ // speaker's measured looks away are not this rig's to render. What moves
113
+ // while it talks is the head it holds per phrase and the trunk under it
114
+ // (prosody.js), never the eyes leaving the user.
115
+ SPEAKING: { gaze: 'USER', emotion: 'neutral',
146
116
  idle: { sway: 0.55 }, engagement: false },
147
117
  REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
148
118
  idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
@@ -151,13 +121,13 @@ export const STATES = {
151
121
  // The head cant is the state's signature cue, and it has to clear the roll
152
122
  // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
153
123
  // is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
154
- // size, still gentle. Every other channel in this pose read fine on screen.
124
+ // size, still gentle.
155
125
  WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
156
126
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
157
127
  pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
158
128
  // Straining to hear. The one state where the amplitude constraint yields,
159
- // because the lean IS the message: torsoLean well past LISTENING's
160
- // engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
129
+ // because the lean IS the message: torsoLean well past what the engagement
130
+ // layer ever spends, head cheated aside on USER_EAR so an ear
161
131
  // favors the speaker while the eyes hold contact, and a concentration
162
132
  // squint with knit brows. Stillness does the rest — straining people
163
133
  // freeze — so holds are frequent and there is no engagement lean: you don't
@@ -211,7 +181,7 @@ export const STATES = {
211
181
  },
212
182
  // --- application state ---------------------------------------------------
213
183
  // "Momentarily busy on the thing you asked for." No hands in frame, so the
214
- // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4,
184
+ // whole read comes from cheap cues (docs/research-biomechanics.md §6.4,
215
185
  // recommendation 19): gaze down on one stable target with a reading scan,
216
186
  // blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
217
187
  // and *held* with brief micro-freezes, and a glance back up to the user.
@@ -230,9 +200,8 @@ export const STATES = {
230
200
  // take back most of the down look's follow so the eyes stay awake.
231
201
  gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
232
202
  scan: [0.9, 2.0, 0.12],
233
- // §6.4's ~9/min is a count of blinks, and a sixth of timed blinks here
234
- // come as a pair, so the timer runs slower than the table's 6-7.5 s gap
235
- // to land on it; at 6-7.5 s it measured 12-13/min, which is not focus.
203
+ // §6.4's ~9/min is a count of blinks: at the table's own 6-7.5 s gap it
204
+ // measured 12-13/min, which is not focus.
236
205
  idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
237
206
  hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
238
207
  // The look up to check is brows-first and blinkless, the lids leading it
@@ -254,11 +223,9 @@ export const STATES = {
254
223
  browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
255
224
  },
256
225
  // The audio channel is broken and the agent is typing in the chat window to
257
- // communicate — TYPING's mechanics turned *communicative*. The glance is
258
- // the difference: TYPING checks in briefly (~0.8 s) and goes back to work;
259
- // this looks up and HOLDS 1.2–2 s, expectant, because the chat (and the
260
- // user's face) is now the only channel there is. A touch of browInner
261
- // carries the apology. Relation to DEGRADED is by semantics, not merger:
226
+ // communicate. The glance looks up and HOLDS 1.2–2 s, expectant, because the
227
+ // chat (and the user's face) is now the only channel there is. A touch of
228
+ // browInner carries the apology. Relation to DEGRADED is by semantics, not merger:
262
229
  // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
263
230
  // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
264
231
  TYPING_CHAT: {
@@ -273,7 +240,7 @@ export const STATES = {
273
240
  browInnerL: 0.45, browInnerR: 0.38,
274
241
  mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
275
242
  },
276
- // Attention genuinely elsewhere. What separates this from TYPING is target
243
+ // Attention genuinely elsewhere. What separates this from WORKING is target
277
244
  // *stability* (§6.4): busy is one steady off-user target, distracted is
278
245
  // wandering ones, held long (aversion >3s), with no engagement lean — the
279
246
  // missing nod is as diagnostic as the look-away. Sway is looser than
@@ -284,7 +251,7 @@ export const STATES = {
284
251
  idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
285
252
  // Sideways and up, never steep-down: lateral is where real intimacy/
286
253
  // distraction aversions live, and a steep down target seals this rig's
287
- // eyes (see TYPING).
254
+ // eyes (see WORKING).
288
255
  wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
289
256
  every: [2.8, 6.8] },
290
257
  },
@@ -321,7 +288,7 @@ export const STATES = {
321
288
  // condition and not an event — WANTS_IN in particular has to hold for as long
322
289
  // as it takes the other person to notice it.
323
290
  //
324
- // All three lift the shoulders and part the lips, because that is what an
291
+ // Each of these lifts the shoulders and parts the lips, because that is what an
325
292
  // inbreath looks like from outside, and an inbreath is the cue humans actually
326
293
  // use to predict that someone is about to speak. The head comes *up* rather
327
294
  // than down: a lowered head is deferential and reads as yielding.
@@ -403,9 +370,7 @@ export const STATE_NAMES = Object.keys(STATES);
403
370
  * figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
404
371
  * and read as a shrug arriving with the head.
405
372
  *
406
- * Exported for a page that drives a rig by hand and wants the body the shipping
407
- * mixer would have put under the tilt; the rig instruments in the working tree
408
- * are its only callers.
373
+ * Exported for the rig instruments.
409
374
  */
410
375
  export const SHOULDER_TILT = 0.08;
411
376
 
@@ -413,11 +378,9 @@ export function createAvatar(opts = {}) {
413
378
  const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
414
379
  if (!mount) throw new Error('createAvatar: mount element required');
415
380
 
416
- // `opts.face` is a Face record — `{ create, meta }`, one per face module. It
417
- // is passed in rather than named, because a name would need a table, and a
418
- // table would need every face imported to answer any lookup: three drawings
419
- // in every consumer's bundle to render one. `src/faces.js` still has that
420
- // table, for tooling that genuinely wants all of them.
381
+ // `opts.face` is a Face record — `{ create, meta }`. Passed in rather than
382
+ // named: a name needs a table, and a table imports every drawing to answer
383
+ // one lookup (`src/faces.js` has that table, for tooling that wants them).
421
384
  const entry = opts.rig ? null : opts.face;
422
385
  if (!opts.rig && !entry) {
423
386
  throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
@@ -445,12 +408,9 @@ export function createAvatar(opts = {}) {
445
408
  // control. `hand: false` only disables its SVG rendering; gesture actions
446
409
  // still emit the semantic hand frame for a supplied custom rig.
447
410
  const hand = face && opts.hand !== false ? createHand(face.svg, face.theme, meta, { dir: opts.handSide }) : null;
448
- // The existing SVG face and hand are one migration adapter implementing the
449
- // renderer-agnostic AvatarRig contract. New renderers never need face SVG
450
- // coordinates or the hand layer's private geometry.
451
411
  const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
452
412
 
453
- gaze.onLargeShift = (forced) => idle.blink(false, true, forced);
413
+ gaze.onLargeShift = (forced) => idle.blink(true, forced);
454
414
 
455
415
  const listeners = {
456
416
  state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
@@ -467,22 +427,19 @@ export function createAvatar(opts = {}) {
467
427
  let gazeName = 'USER';
468
428
  let gazeCustom = null;
469
429
  let overrides = null; // demo/debug direct param injection
470
- // Articulation gain. The per-cue `i` only ever attenuates (shapeFor maps it to
471
- // 0.45..1.0 of the table), so there was no way to ask for a *bigger* mouth than
472
- // VISEME_SHAPES describes. That table is tuned for a face at conversational
473
- // size; at avatar size, sharing the screen with live video, the same shapes
474
- // read as under-articulated. This scales every viseme away from rest, so the
475
- // shape identities and their relative sizes are preserved and only the
476
- // excursion changes. Values above ~1.5 saturate the open vowels against the
477
- // channel clamp, which is the intended ceiling rather than a bug.
430
+ // Articulation gain. VISEME_SHAPES is tuned for a face at conversational
431
+ // size; sharing the screen with live video the same shapes read as
432
+ // under-articulated. Scales every viseme away from rest, so shape identities
433
+ // and their relative sizes survive and only the excursion changes. Above
434
+ // ~1.5 the open vowels saturate against the clamp — the intended ceiling.
478
435
  let mouthGain = opts.mouthGain ?? 1;
479
436
  let handSide = opts.handSide === -1 ? 'left' : 'right';
480
437
  /**
481
- * This avatar's own addressable motions, on top of the two core intents.
438
+ * This avatar's own addressable motions, on top of the core intents.
482
439
  *
483
440
  * An avatar is a drawing with a body, and some bodies can do things the wire
484
- * has no portable word for. The three nod types the listening research
485
- * separates — a continuer, an assessment, a realisation — are one
441
+ * has no portable word for. The nod types the listening research separates —
442
+ * a continuer, an assessment, a realisation — are one
486
443
  * `ACKNOWLEDGE` to a server, because that is all a server can ask of every
487
444
  * face; the *shapes* are sized in a rig's own units and belong to the rig
488
445
  * (`packages/avatar/client/three/sequences.ts` is the first table of them).
@@ -514,13 +471,9 @@ export function createAvatar(opts = {}) {
514
471
  }
515
472
  /**
516
473
  * This avatar's own rendering of a state — the same door as `actions`, for
517
- * the held face instead of the gesture. A state's pose is authored where a
518
- * line face reads, and the cue that carries it there can say something else
519
- * on a photograph: WORKING's knit brows are what keep peep's reading face
520
- * from going blank, and on a real eye — already hooded by a lid following it
521
- * down to the screen — they finish a squint that reads as strain. A rig
522
- * replaces a state's fields whole (`pose`, say), and only for a state the
523
- * table already has: it can re-render the vocabulary, never extend it.
474
+ * the held face instead of the gesture. A rig replaces a state's fields
475
+ * whole (`pose`, say), and only for a state the table already has: it can
476
+ * re-render the vocabulary, never extend it.
524
477
  */
525
478
  const states = { ...STATES };
526
479
  for (const [id, own] of Object.entries(opts.states || {})) {
@@ -529,11 +482,8 @@ export function createAvatar(opts = {}) {
529
482
  }
530
483
  let handAction = null;
531
484
  const handQueue = [];
532
- // Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
533
- // peaks at 149ms — but the head smooths at a 160ms time constant, so barely
534
- // 60% of an authored peak is ever rendered. The keyframes were written against
535
- // the numbers, not against what comes out the other side, which is why small
536
- // gestures read as nothing at all.
485
+ // Gesture gain, same idea for the clip layer: small gestures under-render
486
+ // through the head's τ — see internal-mixer.md § Smoothing.
537
487
  let gestureGain = opts.gestureGain ?? 1;
538
488
  // Body-liveness gain. Constraint 8 (this widget shares the screen with a
539
489
  // live video call) argues for the smallest idle motion that still reads, and
@@ -554,28 +504,14 @@ export function createAvatar(opts = {}) {
554
504
  // Aversions get their own gain: a look-away must read as one from across
555
505
  // the call, where a fixation step must not, so one number cannot size both.
556
506
  const saccadeGain = opts.saccadeGain ?? 1;
557
- // How far this face may hold its head off centre, per axis, in pose units.
558
- // A pose unit is an angle on a mesh head and a pixel count on a drawing, and
559
- // what a 2.5-D face can hold before the photograph gives it away is a
560
- // measurement of that face and of nothing else — so the number is the rig's
561
- // to supply and is nowhere in this library. An axis left out is unbudgeted,
562
- // which is every SVG and Canvas face and is the behaviour they have always
563
- // had.
507
+ // How far this face may hold its head off centre, per axis (internal-mixer.md
508
+ // § The held-head budget): the rig supplies it; an axis left out is unbudgeted.
564
509
  const headHold = opts.headHold || {};
565
- // Per-axis gain on the head's *continuous* drive, in front of the budget.
566
- //
567
- // The two layers that hold a head somewhere — speech phrasing and idle —
568
- // are sized in pose units for a line drawing, and a 2.5-D face measured its
569
- // own limits afterwards. Measuring found the drive spending well under them:
570
- // a third of the pitch a speaking human uses and nearly twice the yaw, on a
571
- // face whose pitch budget has room for all of it. That is one number per
572
- // axis, not a rewrite of either layer, and it belongs here rather than in
573
- // the rig because it scales a *drive* — a rig scale would multiply the nods
574
- // and beats too, and those are authored at amplitudes that already read.
575
- //
576
- // Before 6b deliberately: the budget is what stops a scaled-up drive from
577
- // leaving the envelope, so a gain that skipped it would be measuring the
578
- // face's failure rather than the layer's range. A number, or one per axis.
510
+ // Per-axis gain on the head's *continuous* drive — speech phrasing and idle,
511
+ // both sized in pose units for a line drawing. Measuring a 2.5-D face found
512
+ // the drive spending a third of the pitch a speaking human uses and nearly
513
+ // twice the yaw. In front of 6b deliberately: a gain that skipped the budget
514
+ // would be measuring the face's failure rather than the layer's range.
579
515
  const headGain = typeof opts.headGain === 'number'
580
516
  ? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
581
517
  : { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
@@ -584,8 +520,7 @@ export function createAvatar(opts = {}) {
584
520
  // A rig that says what its pose units are in degrees gets the eye-head
585
521
  // system sized for it (gaze.js): its own look targets, how an aversion
586
522
  // splits between eyes and head, lids that follow the eye both ways, and the
587
- // reflex in step 8b. A face that does not say keeps the line-face behaviour
588
- // exactly.
523
+ // reflex in step 8b.
589
524
  const ocu = opts.oculomotor || {};
590
525
  if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
591
526
  if (ocu.avert) gaze.avertSplit = ocu.avert;
@@ -601,13 +536,8 @@ export function createAvatar(opts = {}) {
601
536
  ? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
602
537
  y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
603
538
  : null;
604
- // How far the reflex may carry the eye in its socket, in pupil units. A
605
- // person looking 8° up does not roll the eyes to the lid and wait for the
606
- // head: the eye stops well short of its mechanical limit and the head makes
607
- // up the rest (Guitton & Volle's effective oculomotor range). On a
608
- // photographic eye an iris pinned under the upper lid with white showing
609
- // beneath it reads as an eye-roll, not a thought. Down is looser — the lid
610
- // follows the eye down and hides the sclera there.
539
+ // How far the reflex may carry the eye in its socket, in pupil units:
540
+ // Guitton & Volle's effective oculomotor range; the rig supplies the reach.
611
541
  const reach = ocu.range || { x: 1, up: 1, down: 1 };
612
542
  const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
613
543
  const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
@@ -617,7 +547,6 @@ export function createAvatar(opts = {}) {
617
547
  let wanderAt = 0;
618
548
  let driftAt = 0;
619
549
  let trunkYaw = 0;
620
- let turnStartPending = false;
621
550
  let glanceAt = 0;
622
551
  let glanceUntil = 0;
623
552
  let lastBack = null;
@@ -625,10 +554,6 @@ export function createAvatar(opts = {}) {
625
554
  // how long the brows stay up for it.
626
555
  let dartAt = 0, dartBrowUntil = 0;
627
556
  const dart = { x: 0, y: 0 };
628
- // THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
629
- // when that handoff happened (it stands in for the turn-start look).
630
- let returnAt = 0;
631
- let carriedAt = -Infinity;
632
557
  // The state whose gaze is showing, and when the current state takes it
633
558
  // over if that is still pending (GAP_SETTLE). Usually the same state.
634
559
  let gazeState = 'IDLE';
@@ -641,7 +566,7 @@ export function createAvatar(opts = {}) {
641
566
 
642
567
  const cur = Object.assign({}, REST);
643
568
  const target = Object.assign({}, REST);
644
- // The three head axes again, carrying only what is held (step 6b).
569
+ // The head axes again, carrying only what is held (step 6b).
645
570
  const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
646
571
  // What the rig is handed: `cur` with the reflex applied to the eyes. The
647
572
  // same object when there is no reflex.
@@ -656,11 +581,7 @@ export function createAvatar(opts = {}) {
656
581
  let raf = 0;
657
582
  let last = 0;
658
583
  let elapsed = 0;
659
- // `manual` withholds the rAF loop so a tool can drive frames itself. The
660
- // baseline pages could already step a ClipPlayer by hand, but nothing could
661
- // step the *mixer* — which is where idle, gaze and engagement actually
662
- // compose — so motion had no reproducible render. The headless motion map in
663
- // the working tree is what steps it.
584
+ // `manual` withholds the rAF loop so a tool can drive frames itself.
664
585
  const manual = !!opts.manual;
665
586
 
666
587
  function frame(now) {
@@ -719,11 +640,11 @@ export function createAvatar(opts = {}) {
719
640
  // clip layer, on purpose: a sustained turn toward the screen recruits
720
641
  // the trunk, and a nod or a head shake does not — a body that swings
721
642
  // with every gesture reads as a mannequin on a turntable. The lag is
722
- // not authored anywhere; torsoTurn simply chases the same target at
723
- // nearly 3x the head's time constant (TAU in params.js), so the trunk
724
- // leaves late and settles late for free. It follows where the head is
725
- // going and not the looks riding on it, and it holds through a glance:
726
- // checking on the user is a look, not a turn toward them.
643
+ // not authored anywhere; torsoTurn chases the same target at a slower
644
+ // TAU than the head (params.js), so the trunk leaves late and settles
645
+ // late for free, at the share TRUNK_FOLLOW names. It follows where the
646
+ // head is going and not the looks riding on it, and it holds through a
647
+ // glance: checking on the user is a look, not a turn toward them.
727
648
  if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
728
649
  target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
729
650
 
@@ -754,11 +675,7 @@ export function createAvatar(opts = {}) {
754
675
  // spot every time. Either leg's gaze-evoked blink is the shift's odds
755
676
  // unless the state says (`blinkTo`, `blinkBack`), because which leg blinks
756
677
  // is part of what the glance means.
757
- if (returnAt && elapsed > returnAt) {
758
- returnAt = 0;
759
- setGaze(gst.gaze);
760
- }
761
- if (gst.glance && !returnAt) {
678
+ if (gst.glance) {
762
679
  const gl = gst.glance;
763
680
  if (glanceUntil && elapsed > glanceUntil) {
764
681
  glanceUntil = 0;
@@ -807,25 +724,7 @@ export function createAvatar(opts = {}) {
807
724
  // mixer's one-frame veto — anything that means "the user is checking
808
725
  // whether I am with them" sets it (see api.attend).
809
726
  gaze.setAversion(gst.aversion ? AVERSION[gst.aversion] : null);
810
- // A speaking turn ends under mutual gaze: no look in the last 2.4 s of the
811
- // cue track as far as it has arrived, nor once it has run out (§4.2). The
812
- // mid-turn looks wait for a silent cue, which is a phrase boundary. Both
813
- // read last frame's sample, a frame late and harmlessly so.
814
- let floorReturn = false;
815
- gaze.gate = true;
816
- if (gst.aversion === 'SPEAK') {
817
- const cues = speech.cues;
818
- floorReturn = !speech.playing || !cues.length
819
- || cues[cues.length - 1].t - speech.now < FLOOR_RETURN_MS;
820
- const cue = cues[speech.index];
821
- gaze.gate = !!cue && cue.v === SILENT;
822
- }
823
- gaze.hold = attendUntil > elapsed || clip.playing || floorReturn;
824
- if (turnStartPending) {
825
- turnStartPending = false;
826
- // A reply that began mid-think has had its planning look already.
827
- if (elapsed - carriedAt > 1.5) gaze.startTurn();
828
- }
727
+ gaze.hold = attendUntil > elapsed || clip.playing;
829
728
 
830
729
  engagement.enabled = !!st.engagement && !clip.playing;
831
730
  engagement.update(dt);
@@ -864,6 +763,8 @@ export function createAvatar(opts = {}) {
864
763
  const shape = mouth.letter !== SILENT
865
764
  ? shapeFor(mouth.letter, mouth.intensity)
866
765
  : shapeFor(SILENT, 1);
766
+ // Rounding coming up is held against the shape's own (VisemeTrack.sample).
767
+ if (mouth.round > shape.mouthRound) shape.mouthRound = mouth.round;
867
768
  // Gain pivots on the rest shape, not on zero: scaling absolute values would
868
769
  // drag the closed mouth open, which is the one thing lipsync must never do.
869
770
  for (const k in shape) {
@@ -871,11 +772,6 @@ export function createAvatar(opts = {}) {
871
772
  ? shape[k]
872
773
  : REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
873
774
  }
874
- // A smile held static through a sentence is discounted as insincere, and
875
- // corners riding every open viseme read as laughing through the words
876
- // (research-perception.md §3: warmth must be episodic). While the mouth
877
- // is genuinely speech-driven the BASE smile decays to a fraction of
878
- // itself; the smile channels' 130ms tau turns the gate into an ease.
879
775
  // Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
880
776
  // and only the base is scaled, so a gesture clip can still smile over a
881
777
  // sentence by authoring corner keys (they add, unscaled, in step 5).
@@ -955,11 +851,9 @@ export function createAvatar(opts = {}) {
955
851
  for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
956
852
  for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
957
853
 
958
- // 6b. the held-head budget. Every layer above holds its own small pose and
959
- // they are independent, so now and then they all point the same way and
960
- // the head arrives somewhere no one layer asked for and the face cannot
961
- // go. Only the excess over `soften` comes off, and only off the hold:
962
- // a nod, a beat and a clip keep every degree they were authored with,
854
+ // 6b. the held-head budget (internal-mixer.md § The held-head budget).
855
+ // Only the excess over `soften` comes off, and only off the hold: a
856
+ // nod, a beat and a clip keep every degree they were authored with,
963
857
  // which is why this is subtracted here rather than applied to the pose.
964
858
  for (const c of HEAD_AXES) {
965
859
  if (headHold[c] === undefined) continue;
@@ -969,10 +863,10 @@ export function createAvatar(opts = {}) {
969
863
  // 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
970
864
  // same share of it that it takes of a turn in 2b — Live2D gives its
971
865
  // body the same fraction of AngleZ as of AngleX (research-head-rotation
972
- // .md §3) — and the shoulder line tips with the head. Both channels are
973
- // slower than the head (TAU: 0.44 and 0.19 against 0.16), so the body
974
- // leaves late and settles late, and that follow-through is most of what
975
- // separates a neck bending from a hinge.
866
+ // .md §3) — and the shoulder line tips with the head. Both channels
867
+ // smooth slower than the head (TAU), so the body leaves late and
868
+ // settles late, and that follow-through is most of what separates a
869
+ // neck bending from a hinge.
976
870
  // Only the *held* roll, and after the budget: a stroke or a clip's roll
977
871
  // is a gesture riding on the pose, and a body that answers those is 2b's
978
872
  // mannequin on a turntable. The idle layer's own posture is already
@@ -1002,17 +896,9 @@ export function createAvatar(opts = {}) {
1002
896
  // 8. smooth toward the target — this is where co-articulation happens
1003
897
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
1004
898
 
1005
- // 8b. The vestibulo-ocular reflex. Eyes held still in a moving head look
1006
- // wherever the head points, so without this every nod, speech pose and
1007
- // sway was also a small look somewhere else — and on a face whose head
1008
- // turns further than its eyes, a look at a different spot each time.
1009
- // Real eyes counter-rotate against the head within ~10 ms and stay on
1010
- // what they look at. Here and not in the gaze layer because only here
1011
- // is the head that is actually drawn known: prosody, clips and idle all
1012
- // land after gaze. It also gives a large shift its real shape for free:
1013
- // the eyes jump to the target, past where they will sit, and roll back
1014
- // in the head as it arrives under them. A pupil a tuning UI overrides is
1015
- // left where it was put.
899
+ // 8b. The vestibulo-ocular reflex. Here and not in the gaze layer because
900
+ // only here is the head that is actually drawn known: prosody, clips
901
+ // and idle all land after gaze.
1016
902
  if (vor) {
1017
903
  Object.assign(shown, cur);
1018
904
  if (!overrides || overrides.pupilX === undefined) {
@@ -1041,30 +927,20 @@ export function createAvatar(opts = {}) {
1041
927
  // sliding.
1042
928
  const TRUNK_FOLLOW = opts.trunkFollow ?? 0.45;
1043
929
 
1044
- // Speakers are back on the listener's eyes this long before they stop
1045
- // (Andrist, docs/research-biomechanics.md §4.2): the floor is handed over
1046
- // under mutual gaze, and a look away there declines it.
1047
- const FLOOR_RETURN_MS = 2400;
1048
-
1049
930
  // The channels speech owns outright — exactly the params.js mouth group
1050
931
  // (mouth corners stay free: a clip may smile over a sentence).
1051
932
  const MOUTH_LOCK = new Set(GROUPS.mouth);
1052
933
 
934
+ /** The shoulders' share of the attentive posture: two thirds of the lean the
935
+ * engagement layer spends — the shoulders come up with it, they do not lead it. */
936
+ const ENGAGE_SHOULDER = 0.10;
937
+
1053
938
  // What survives of the resting/emotion smile while speech owns the mouth.
1054
- // ~a third keeps the face warm without the corners fighting the visemes;
1055
- // full warmth returns the moment the track ends, which is exactly the
1056
- // episodic onset/offset a credible smile needs (research-perception.md §3).
1057
- /** The shoulders' share of the attentive posture, against `torsoLean`'s 0.16.
1058
- * Two thirds of the lean: the shoulders come up with it, they do not lead it. */
1059
- const ENGAGE_SHOULDER = 0.10;
1060
- const SPEAK_SMILE_RETAIN = 0.35;
1061
-
1062
- // How long a reply that starts mid-think keeps the look before it comes
1063
- // back. Speakers look away to find the words and back to deliver them
1064
- // (§4.2); a reply that snapped to the user on its first syllable and then,
1065
- // three times in four, left again for the turn-start look did both halves
1066
- // twice.
1067
- const THINK_CARRY = [0.25, 0.6];
939
+ // A smile held static through a sentence is discounted as insincere, and
940
+ // corners riding every open viseme read as laughing through the words: warmth
941
+ // must be episodic (research-perception.md §3). Full warmth returns the moment
942
+ // the track ends, which is that onset/offset.
943
+ const SPEAK_SMILE_RETAIN = 0.35;
1068
944
 
1069
945
  // Between the user's turn and the reply the server's claim can change
1070
946
  // several times a second — THINKING, a tool's WORKING, THINKING again, a
@@ -1152,25 +1028,29 @@ const SPEAK_SMILE_RETAIN = 0.35;
1152
1028
  const deferrable = !o.gaze && !o.keepGaze && GAP_STATES.has(gazeState) && GAP_STATES.has(name);
1153
1029
  if (deferrable && changed) settleAt = name === gazeState ? 0 : elapsed + GAP_SETTLE;
1154
1030
  else if (!(deferrable && settleAt)) { settleAt = 0; enterGaze(name, o, changed); }
1031
+ // The floor has come back to the user: the face receives it (prosody.js
1032
+ // `listen`). It is a state change rather than a VAD event on purpose —
1033
+ // what is being welcomed is the turn, and the server is the one that knows
1034
+ // a turn has changed hands.
1035
+ if (changed && name === 'LISTENING') prosody.listen();
1155
1036
  if (changed) emit('state', name);
1156
1037
  return api;
1157
1038
  }
1158
1039
 
1040
+ // Whether the eyes were last aimed by somebody who meant it — the public
1041
+ // `setGaze`, or a performance's `gaze` verb — rather than by the state's own
1042
+ // schedule. It is the one thing that outranks a state's `gaze`, and it is
1043
+ // why `speak()` can take the eyes back without overriding a caller.
1044
+ let gazeExplicit = false;
1045
+
1159
1046
  /** The state `name` takes the gaze: its target and its schedules. */
1160
1047
  function enterGaze(name, o, blink) {
1161
- const prev = gazeState;
1162
1048
  gazeState = name;
1163
1049
  const st = states[name];
1164
1050
  const gl = st.glance;
1165
- // A reply that starts while THINKING is looking away finishes that look
1166
- // first, and the look stands in for the turn-start one.
1167
- const carry = prev === 'THINKING' && name === 'SPEAKING'
1168
- && !o.gaze && !o.keepGaze && gazeName !== st.gaze && !glanceUntil;
1169
- returnAt = carry ? elapsed + rand(THINK_CARRY) : 0;
1170
- if (carry) carriedAt = elapsed;
1171
1051
  // A state with an `opening` enters as though its check-in on the user is
1172
1052
  // already under way, and leaves it when that runs out.
1173
- if (!o.keepGaze && !carry) setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze));
1053
+ if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
1174
1054
  // Arm every scheduler fresh, so entering a state never fires a timestamp
1175
1055
  // left over from the last one — the wander in particular, which used to
1176
1056
  // pick a new target on the first frame and override the state's own gaze.
@@ -1181,7 +1061,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1181
1061
  readCol = readCols = readRow = readRows = 0;
1182
1062
  dartAt = dartBrowUntil = 0;
1183
1063
  dart.x = dart.y = 0;
1184
- if (blink) idle.blink(false, true);
1064
+ if (blink) idle.blink(true);
1185
1065
  }
1186
1066
 
1187
1067
  function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
@@ -1217,8 +1097,12 @@ const SPEAK_SMILE_RETAIN = 0.35;
1217
1097
  : () => performance.now() - speakStart;
1218
1098
  speech.start(o.cues || [], speakClock);
1219
1099
  prosody.reset(newTurn);
1220
- if (newTurn) turnStartPending = true;
1221
- if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: true });
1100
+ // The eyes come back to the user when the audio starts: a reply arriving
1101
+ // while THINKING was looking away would otherwise spend its whole turn
1102
+ // aimed off the user, because nothing in SPEAKING retargets. `keepGaze` is
1103
+ // for the one caller that means it — a performance that aimed the eyes with
1104
+ // its own `gaze` verb keeps them, an instruction and not a leftover.
1105
+ if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
1222
1106
  if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
1223
1107
  return api;
1224
1108
  }
@@ -1229,25 +1113,14 @@ const SPEAK_SMILE_RETAIN = 0.35;
1229
1113
  * This is the widget's half of the **gaze window**. In face-to-face talk a
1230
1114
  * speaker periodically looks at the listener, mutual gaze is established, the
1231
1115
  * listener responds inside that window, and the speaker looks away again
1232
- * (Bavelas, Coates & Johnson 2002) — listener responses cluster inside the
1233
- * window rather than being scattered across the turn.
1116
+ * (Bavelas, Coates & Johnson 2002).
1234
1117
  *
1235
1118
  * We cannot see the user, so we cannot observe the window opening. What a
1236
1119
  * caller *can* do is name the moments that co-occur with it — a mid-turn
1237
1120
  * pause, a tag question ("...right?", "you know?"), a completed clause with
1238
1121
  * the turn analyzer's completion probability high, the user answering a
1239
- * question the bot asked. `attend(ms)` is how those arrive: for its duration
1240
- * the face stops averting and holds the user, which is the prerequisite for
1241
- * any response to be *seen*. Emitting the response itself stays a separate
1242
- * call — a window that opens and draws nothing is a real and common outcome
1243
- * (with every measured invitation cue present, humans respond to only ~30% of
1244
- * opportunities), and conflating the two would make the avatar answer
1245
- * everything. The explicit response remains a backend/application decision.
1246
- *
1247
- * Deliberately **not on the wire yet**: there is no `attend` command in
1248
- * `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
1249
- * the rig pages). Adding the command is a protocol change and waits for a
1250
- * server that has something real to key it off — see docs/internal-mixer.md.
1122
+ * question the bot asked. For `ms` the face stops averting and holds the
1123
+ * user, which is the prerequisite for any response to be *seen*.
1251
1124
  *
1252
1125
  * @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
1253
1126
  * mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
@@ -1324,7 +1197,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1324
1197
  const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
1325
1198
  if (!faceClip) return api;
1326
1199
  clip.play(faceClip, faceClip.audioEl, { queue: true });
1327
- // The two acknowledgements smile. Only these, and only ever because the
1200
+ // The acknowledgements smile. Only these, and only ever because the
1328
1201
  // server sent one: a smile the renderer timed for itself would be an
1329
1202
  // acknowledgement nobody sent.
1330
1203
  if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
@@ -1359,11 +1232,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1359
1232
  return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
1360
1233
  }
1361
1234
 
1362
- /**
1363
- * Tell the listening engine whether Pipecat VAD says the user holds the
1364
- * floor. This changes only sustained engagement posture; it can never create
1365
- * a nod or acknowledgement clip.
1366
- */
1367
1235
  function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
1368
1236
 
1369
1237
  // What one action does when its moment comes. Enum validity is checked here,
@@ -1376,7 +1244,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1376
1244
  // composed turn in the demo already behaves.
1377
1245
  if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
1378
1246
  else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
1379
- else if (a.do === 'gaze') setGaze(a.name);
1247
+ else if (a.do === 'gaze') { setGaze(a.name); gazeExplicit = true; }
1380
1248
  else if (a.do === 'action') action(a.id);
1381
1249
  } catch (e) {
1382
1250
  console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
@@ -1386,15 +1254,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
1386
1254
  let performGen = 0;
1387
1255
 
1388
1256
  /**
1389
- * Play a timed action track — the composition surface a server assembles
1390
- * turns from. Verbs: state / emotion / gaze / action (see
1391
- * perform.js for hygiene, docs/internal-mixer.md for the schema).
1392
- *
1393
- * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
1394
- * element's own time, else ms elapsed since this call. perform() never
1395
- * starts or stops audio — speak() owns the sound; this owns the choreography
1396
- * that rides it.
1397
- *
1398
1257
  * @param {Array<{t: number, do: string}>} actions
1399
1258
  * @param {{audio?: HTMLMediaElement, clock?: () => number,
1400
1259
  * onAction?: (a: object) => void}} [o]
@@ -1417,7 +1276,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1417
1276
  }
1418
1277
 
1419
1278
  const api = {
1420
- setState, setEmotion, setGaze: (name, custom) => setGaze(name, custom), speak, pushCues, stopSpeaking, attend,
1279
+ setState, setEmotion, setGaze: (name, custom) => { gazeExplicit = true; return setGaze(name, custom); }, speak, pushCues, stopSpeaking, attend,
1421
1280
  action, perform,
1422
1281
  /** Which hand the character gestures with: +1 the viewer's right (its own
1423
1282
  * left), -1 the other. Both are anatomically real — the thumb splays away
@@ -1433,7 +1292,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
1433
1292
  /** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
1434
1293
  setMotionGain: (g) => { idle.gain = g; return api; },
1435
1294
  get motionGain() { return idle.gain; },
1436
- blink: (dbl) => { idle.blink(dbl); return api; },
1295
+ blink: () => { idle.blink(); return api; },
1437
1296
  /** Advance one frame by hand. Only meaningful under `{manual: true}`;
1438
1297
  * fixed-dt stepping is what makes a motion render reproducible. */
1439
1298
  step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
@@ -1474,18 +1333,35 @@ const SPEAK_SMILE_RETAIN = 0.35;
1474
1333
  // this channel's neutral value" before it can map the channel onto whatever it
1475
1334
  // controls — a morph target's influence is `(pose - rest) / (1 - rest)`, and a
1476
1335
  // rig that hard-codes those rests has quietly forked `params.js`.
1477
- export { REST, CHANNELS, RANGE } from './params.js';
1336
+ export { REST, CHANNELS, RANGE, TAU, clamp, approach, makeParams } from './params.js';
1337
+ // The smoothing law itself, for a tool that steps the rig by hand. A
1338
+ // filmstrip that reimplemented `approach` would be measuring its own copy of
1339
+ // the thing under test — and the smoothing between keyframes is what the face
1340
+ // actually does (docs/internal-mixer.md § Smoothing).
1341
+ // A pose with no mixer above it: `makeParams(overrides)` fills the rests,
1342
+ // `avatarFrame` wraps it, `createSvgRig(face).apply` draws it. That is the whole
1343
+ // path an instrument needs to hold a face at one named extreme — no clock, no
1344
+ // client, no animation — and the reason it is exported is that a pose sheet
1345
+ // that cannot reach it forks the channel rests instead.
1346
+ export { avatarFrame, createSvgRig } from './rig.js';
1478
1347
  export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
1348
+ // The full authoring catalogue and the player that steps it. Not a server
1349
+ // vocabulary — `ACTIONS` is that, and most of these clips are reachable only
1350
+ // from inside the mixer. They are exported for the filmstrip instrument,
1351
+ // which lays one clip out as frames and therefore has to drive a real
1352
+ // `ClipPlayer` rather than re-sample its keys.
1353
+ export { INTERNAL_CLIPS } from './interjections.js';
1354
+ export { ClipPlayer } from './clips.js';
1479
1355
  export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
1480
1356
  export { normalizeActions } from './perform.js';
1481
1357
  export { checkHandFraming } from './hand.js';
1482
- export { EMOTION_NAMES } from './emotions.js';
1358
+ export { EMOTION_NAMES, emotionPose } from './emotions.js';
1483
1359
  // The mouth clock travels with the rest of it. Someone has to turn a cue array
1484
1360
  // plus a clock into "which letter is on screen right now", every renderer needs
1485
1361
  // exactly that, and none of them should write it twice — so it is a plain class
1486
1362
  // to construct, not a contract to implement.
1487
1363
  export {
1488
- VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
1364
+ VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, JAW_OF_OPEN, SILENT,
1489
1365
  normalizeCues, textToCues,
1490
1366
  ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
1491
1367
  } from './visemes.js';