@voqalize/avatar 0.2.2 → 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/avatar.js CHANGED
@@ -1,14 +1,18 @@
1
1
  /**
2
- * The avatar — a programmable talking head.
2
+ * The mixer — a programmable talking head.
3
3
  *
4
- * The public surface. Everything the server drives goes through here:
4
+ * Not the public surface: that is `createAvatar({ mount, client })` in
5
+ * `packages/avatar/client/createAvatar.ts`, and this is what it drives. Everything below is
6
+ * addressed by our own runtime, our tooling, and an avatar author who chose to
7
+ * build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
5
8
  *
9
+ * const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
6
10
  * avatar.setState('LISTENING', { emotion: 'warm' })
7
11
  * avatar.setGaze('SCREEN_LEFT')
8
12
  * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
9
13
  * avatar.pushCues(moreCues) // streaming top-up
10
- * avatar.interject('OKAY')
11
- * avatar.gesture('HI') // a hand at the frame edge + its face
14
+ * avatar.action('ACK_RECEIVE')
15
+ * avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
12
16
  * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
13
17
  * avatar.setUserSpeaking(bool) // the user has the floor, so listening
14
18
  * // is contingent instead of timed
@@ -26,25 +30,33 @@
26
30
  */
27
31
 
28
32
  import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
29
- import { createFace as createPeepFace, META as peepMeta } from './face-peep.js';
30
- import { createFace as createWrenFace, META as wrenMeta } from './face-wren.js';
31
- import { createFace as createMynaFace, META as mynaMeta } from './face-myna.js';
32
33
  import { emotionPose } from './emotions.js';
33
- import { GazeLayer, GAZE_TARGETS } from './gaze.js';
34
+ import { GazeLayer, GAZE_TARGETS, AVERSION } from './gaze.js';
34
35
  import { IdleLayer, ListeningEngine } from './idle.js';
35
36
  import { ClipPlayer } from './clips.js';
36
- import { INTERJECTIONS } from './interjections.js';
37
+ import { ACTIONS, INTERNAL_CLIPS } from './interjections.js';
37
38
  import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
38
39
  import { PerformTrack } from './perform.js';
39
- import { createHand, HAND_GESTURES } from './hand.js';
40
+ import { createHand, HAND_GESTURES, HAND_ACTION_TO_FRAME_GESTURE } from './hand.js';
41
+ import { avatarFrame, createSvgRig } from './rig.js';
40
42
 
41
43
  // Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
42
44
  // in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
43
45
  // alone separates listening (~16/min) from thinking (~25/min) from visually
44
46
  // busy (~9/min), and it is the cheapest state signal the rig has.
45
47
  export const STATES = {
46
- IDLE: { gaze: 'USER', emotion: 'neutral', idle: { sway: 1.0 }, backchannel: false },
47
- LISTENING: { gaze: 'USER', emotion: 'neutral', backchannel: true,
48
+ // Idle means present but occupied with one's own quiet business. It must not
49
+ // compete with LISTENING's sustained user attention: the default target is
50
+ // away from the user and the wander only visits other non-task targets.
51
+ IDLE: { gaze: 'AWAY_THINKING', emotion: 'neutral', engagement: false,
52
+ idle: { sway: 0.72, blinkGap: [4.6, 6.6] },
53
+ wander: { targets: ['AWAY_THINKING', 'AWAY_RIGHT', 'NOTES'], every: [3.6, 6.8] } },
54
+ // `aversion` is why this state does not stare. Continuous eye contact is not
55
+ // the attentive pose it looks like — it is a demand for more talk (Rossano)
56
+ // and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
57
+ // gaze.js for the numbers; the mixer holds it off near a turn boundary.
58
+ LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
59
+ aversion: 'LISTEN',
48
60
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
49
61
  pose: { browRaiseL: 0.06, browRaiseR: 0.06, lidL: -0.04, lidR: -0.04 } },
50
62
  // Faster, shallower breath is the measured cognitive-load signature, and the
@@ -53,13 +65,13 @@ export const STATES = {
53
65
  // leads DOWN (39% of measured cognitive aversions, §4.2) and wanders on the
54
66
  // ~3.5s cognitive-aversion cadence, coming back to the user roughly one
55
67
  // dwell in four — still with you, working.
56
- THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', backchannel: false,
68
+ THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', engagement: false,
57
69
  idle: { sway: 0.7, blinkGap: [2.1, 2.7], breathRate: 1.18, breathAmp: 0.7,
58
70
  hold: { every: [4.5, 9.0], dur: [0.8, 1.5] } },
59
71
  wander: { targets: ['AWAY_DOWN', 'AWAY_DOWN', 'AWAY_THINKING', 'USER'],
60
72
  every: [2.6, 4.4] } },
61
- SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, backchannel: false },
62
- REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', backchannel: false,
73
+ SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, engagement: false },
74
+ REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
63
75
  idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
64
76
  wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_RIGHT', 'SCREEN_TOP', 'SCREEN_WORK'],
65
77
  every: [1.8, 5.0] } },
@@ -67,7 +79,7 @@ export const STATES = {
67
79
  // multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
68
80
  // is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
69
81
  // size, still gentle. Every other channel in this pose read fine on screen.
70
- WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', backchannel: true,
82
+ WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
71
83
  idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
72
84
  pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
73
85
  // Straining to hear. The one state where the amplitude constraint yields,
@@ -75,11 +87,11 @@ export const STATES = {
75
87
  // engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
76
88
  // favors the speaker while the eyes hold contact, and a concentration
77
89
  // squint with knit brows. Stillness does the rest — straining people
78
- // freeze — so holds are frequent and there are NO backchannels: you don't
90
+ // freeze — so holds are frequent and there is no engagement lean: you don't
79
91
  // nod along to what you can't hear. Server sends it on soft/low-SNR user
80
92
  // audio, typically followed by SORRY or a "could you repeat" utterance.
81
93
  CANT_HEAR: {
82
- gaze: 'USER_EAR', emotion: 'neutral', backchannel: false,
94
+ gaze: 'USER_EAR', emotion: 'neutral', engagement: false,
83
95
  idle: { sway: 0.5, blinkGap: [4.5, 6.5],
84
96
  hold: { every: [2.5, 5.5], dur: [1.0, 1.8] } },
85
97
  // A minimal line face swallows small deltas — the ink moves whole units
@@ -94,6 +106,33 @@ export const STATES = {
94
106
  mouthPress: 0.45, mouthCornerL: -0.22, mouthCornerR: -0.22,
95
107
  },
96
108
  },
109
+ // The user's microphone is closed, and the agent is the one that closed it.
110
+ // Authored against CANT_HEAR as its exact inverse, because that contrast is
111
+ // the whole read: straining leans *in* to get more of the user, this settles
112
+ // *back* — nothing more is coming through and the avatar knows why. The eyes
113
+ // stay on the user, because a deliberate hold is not inattention; what
114
+ // carries it is the mouth, pressed shut and staying shut, and the slow,
115
+ // unhurried blink of waiting rather than working. No engagement lean: you
116
+ // cannot nod along to a channel you have muted yourself. No filter, ever —
117
+ // DEGRADED and OFFLINE own "something is broken", and this is a decision.
118
+ MUTED: {
119
+ gaze: 'USER', emotion: 'neutral', engagement: false,
120
+ idle: { sway: 0.45, blinkGap: [4.5, 7.0],
121
+ hold: { every: [3.0, 6.0], dur: [0.9, 1.6] } },
122
+ // Line-face scaled (see CANT_HEAR): peep's resting mouth is drawn smiling,
123
+ // so a closed mouth has to be authored clearly past flat to read as closed
124
+ // at all. Corners were -0.20 first, which rendered as a straight line and
125
+ // at 130 px against LISTENING said nothing — the delta a viewer gets is
126
+ // curvature, and flat is the halfway point of it, not the end. -0.34 is as
127
+ // far as it goes before the hold starts reading as sulking. No squint and
128
+ // no lid drop: one is straining, the other was asleep. browInner carries
129
+ // the "one moment" without the worry lift.
130
+ pose: {
131
+ torsoLean: -0.28, headPitch: 0.04,
132
+ browRaiseL: 0.08, browRaiseR: 0.06, browInnerL: 0.28, browInnerR: 0.22,
133
+ mouthPress: 0.78, mouthCornerL: -0.34, mouthCornerR: -0.34,
134
+ },
135
+ },
97
136
  // --- application state ---------------------------------------------------
98
137
  // "Momentarily busy on the thing you asked for." No hands in frame, so the
99
138
  // whole read comes from four cheap cues (docs/research-biomechanics.md §6.4):
@@ -102,12 +141,12 @@ export const STATES = {
102
141
  // is what says activity rather than rocking — and, the important one, a
103
142
  // brief glance back up to the user every few seconds. The glance is the tell
104
143
  // that the user has not been forgotten; without it, busy is just absent.
105
- TYPING: {
144
+ WORKING: {
106
145
  // SCREEN_WORK, not NOTES: on a steep down target the gaze layer's lid
107
146
  // follow seals the eyes, and at tile size shut eyes read as asleep, not
108
147
  // busy. A mild down-left with the head pitched into it keeps the iris in
109
148
  // the opening — eyes down but awake.
110
- gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
149
+ gaze: 'SCREEN_WORK', emotion: 'neutral', engagement: false,
111
150
  idle: { sway: 0.6, blinkGap: [6.0, 7.5], breathRate: 1.05,
112
151
  rhythm: { amp: 0.05, freq: 2.2 } },
113
152
  glance: { to: 'USER', every: [4, 7], hold: [0.7, 1.1] },
@@ -123,7 +162,7 @@ export const STATES = {
123
162
  // DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
124
163
  // it" — a server will typically sequence DEGRADED → TYPING_CHAT.
125
164
  TYPING_CHAT: {
126
- gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
165
+ gaze: 'SCREEN_WORK', emotion: 'neutral', engagement: false,
127
166
  idle: { sway: 0.6, blinkGap: [5.5, 7.0], breathRate: 1.05,
128
167
  rhythm: { amp: 0.055, freq: 2.5 } },
129
168
  glance: { to: 'USER', every: [3.2, 5.5], hold: [1.2, 2.0] },
@@ -136,12 +175,12 @@ export const STATES = {
136
175
  },
137
176
  // Attention genuinely elsewhere. What separates this from TYPING is target
138
177
  // *stability* (§6.4): busy is one steady off-user target, distracted is
139
- // wandering ones, held long (aversion >3s), with no backchannels — the
178
+ // wandering ones, held long (aversion >3s), with no engagement lean — the
140
179
  // missing nod is as diagnostic as the look-away. Sway is looser than
141
180
  // LISTENING because attention is what was holding the body still. The
142
181
  // widget only looks away; deciding when to snap back is the server's call.
143
182
  DISTRACTED: {
144
- gaze: 'AWAY_RIGHT', emotion: 'neutral', backchannel: false,
183
+ gaze: 'AWAY_RIGHT', emotion: 'neutral', engagement: false,
145
184
  idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
146
185
  // Sideways and up, never steep-down: lateral is where real intimacy/
147
186
  // distraction aversions live, and a steep down target seals this rig's
@@ -156,7 +195,7 @@ export const STATES = {
156
195
  // wiggle nobody makes while merely reading. Server semantics: a filler
157
196
  // while an async activity completes; the server exits it when done.
158
197
  SEARCHING_SCREEN: {
159
- gaze: 'SCREEN_CENTER', emotion: 'neutral', backchannel: false,
198
+ gaze: 'SCREEN_CENTER', emotion: 'neutral', engagement: false,
160
199
  idle: { sway: 0.65, blinkGap: [5.0, 6.8], breathRate: 1.05,
161
200
  flick: { amp: 0.30, every: [3.5, 7.0] } },
162
201
  wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_TOP', 'SCREEN_WORK',
@@ -180,7 +219,7 @@ export const STATES = {
180
219
  // use to predict that someone is about to speak. The head comes *up* rather
181
220
  // than down: a lowered head is deferential and reads as yielding.
182
221
  TAKING_FLOOR: {
183
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, backchannel: false,
222
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, engagement: false,
184
223
  pose: {
185
224
  browRaiseL: 0.26, browRaiseR: 0.22, lidL: -0.10, lidR: -0.10,
186
225
  headPitch: -0.10, torsoLean: 0.22, shoulderL: 0.30, shoulderR: 0.30,
@@ -193,7 +232,7 @@ export const STATES = {
193
232
  // purpose — stillness is what makes it read as intent rather than as fidget),
194
233
  // leaning in, lips apart and staying apart.
195
234
  WANTS_IN: {
196
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, backchannel: false,
235
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, engagement: false,
197
236
  pose: {
198
237
  browRaiseL: 0.42, browRaiseR: 0.38, lidL: -0.14, lidR: -0.14,
199
238
  headPitch: -0.14, torsoLean: 0.42, shoulderL: 0.45, shoulderR: 0.45,
@@ -204,17 +243,17 @@ export const STATES = {
204
243
  // happen faster than anything else on the face — see YIELD_FLOOR, which is
205
244
  // what actually delivers the snap.
206
245
  YIELDED: {
207
- gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, backchannel: false,
246
+ gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, engagement: false,
208
247
  pose: {
209
248
  browRaiseL: 0.10, browRaiseR: 0.08,
210
249
  torsoLean: -0.18, shoulderL: -0.12, shoulderR: -0.12,
211
250
  },
212
251
  },
213
252
 
214
- DEGRADED: { gaze: 'USER', emotion: 'neutral', backchannel: false,
253
+ DEGRADED: { gaze: 'USER', emotion: 'neutral', engagement: false,
215
254
  idle: { sway: 0.4, blinkGap: [4.0, 8.0] },
216
255
  pose: { lidL: 0.3, lidR: 0.3 }, filter: 'grayscale(.55) brightness(.82)' },
217
- OFFLINE: { gaze: 'USER', emotion: 'neutral', backchannel: false,
256
+ OFFLINE: { gaze: 'USER', emotion: 'neutral', engagement: false,
218
257
  idle: { sway: 0.15, blinkGap: [9, 15] },
219
258
  pose: { lidL: 0.95, lidR: 0.95, mouthCornerL: 0, mouthCornerR: 0 },
220
259
  filter: 'grayscale(1) brightness(.6)' },
@@ -222,63 +261,26 @@ export const STATES = {
222
261
 
223
262
  export const STATE_NAMES = Object.keys(STATES);
224
263
 
225
- /**
226
- * The avatars this rig can wear: `{ create, meta }` records.
227
- *
228
- * `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
229
- * callable standalone — the rig tooling drives faces with no mixer attached.
230
- * That behavioural contract is still the whole of what the *rig* needs:
231
- * everything else — visemes, emotions, gaze, idle, clips, the mixer — works
232
- * in parameter space and never learns which face it is driving.
233
- *
234
- * `meta` is the avatar descriptor (viewBox, mouthCrop — see META in any face
235
- * module): the things a HOST or a TOOL needs to frame a face without opening
236
- * it. This registry was once factories-only, on the argument that a schema
237
- * guessed from two faces would be wrong; the third face settled it. Every rig
238
- * needed exactly a framing rect and a mouth rect to stop the tooling from
239
- * hard-coding per-avatar tables, and nothing else — so that is all meta
240
- * carries.
241
- *
242
- * The key is the avatar's name, not its rank. It used to be possible to read
243
- * rank into it — the original rig was keyed `default`, which became a lie the
244
- * moment it stopped being the one we ship. DEFAULT_AVATAR below is the only
245
- * place the choice is made.
246
- *
247
- * Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
248
- * stakeholders accepted the line-art pair and rejected both of the others, so
249
- * carrying them was maintenance against art nobody wanted. What they taught
250
- * the abstraction survives them — `face-core.js` exists because all three of
251
- * the first rigs wrote the same apply(), and META exists because all three
252
- * needed the same two rects. Their code is in git history if a lesson ever
253
- * needs re-reading.
254
- */
255
- export const AVATARS = {
256
- peep: { create: createPeepFace, meta: peepMeta },
257
- wren: { create: createWrenFace, meta: wrenMeta },
258
- myna: { create: createMynaFace, meta: mynaMeta },
259
- };
260
-
261
- export const AVATAR_NAMES = Object.keys(AVATARS);
262
-
263
- /** The avatar a host gets when it does not ask for one. */
264
- export const DEFAULT_AVATAR = 'peep';
265
-
266
264
  export function createAvatar(opts = {}) {
267
265
  const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
268
266
  if (!mount) throw new Error('createAvatar: mount element required');
269
267
 
270
- // `opts.avatar` names one from AVATARS; `opts.face` passes a factory directly,
271
- // so a host can supply an avatar the rig has never heard of. A bare factory
272
- // has no descriptor, so meta falls back to what the svg itself declares.
273
- const entry = opts.face ? { create: opts.face } : AVATARS[opts.avatar || DEFAULT_AVATAR];
274
- if (!entry) {
275
- throw new Error(`createAvatar: unknown avatar "${opts.avatar}" (have: ${AVATAR_NAMES.join(', ')})`);
268
+ // `opts.face` is a Face record — `{ create, meta }`, one per face module. It
269
+ // is passed in rather than named, because a name would need a table, and a
270
+ // table would need every face imported to answer any lookup: three drawings
271
+ // in every consumer's bundle to render one. `src/faces.js` still has that
272
+ // table, for tooling that genuinely wants all of them.
273
+ const entry = opts.rig ? null : opts.face;
274
+ if (!opts.rig && !entry) {
275
+ throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
276
276
  }
277
- const face = entry.create(mount, opts.theme);
278
- const meta = entry.meta || (() => {
279
- const vb = (face.svg.getAttribute('viewBox') || '0 0 1 1').split(/[\s,]+/).map(Number);
280
- return { viewBox: { x: vb[0], y: vb[1], w: vb[2], h: vb[3] } };
281
- })();
277
+ const face = entry ? entry.create(mount, opts.theme) : null;
278
+ // A renderer-neutral rig needs no SVG descriptor: `meta` is what SVG hosts
279
+ // and tools frame the drawing with, never a requirement of the rig contract.
280
+ // A face carries its own — a `{ create }` with no META used to be tolerated
281
+ // here and the viewBox re-read off the produced svg, which meant a face could
282
+ // ship half a descriptor and nothing would say so.
283
+ const meta = face ? entry.meta : null;
282
284
  const gaze = new GazeLayer();
283
285
  const idle = new IdleLayer();
284
286
  const speech = new VisemeTrack();
@@ -288,19 +290,21 @@ export function createAvatar(opts = {}) {
288
290
  onGaze: (g) => { gazeOverrideByClip = g; applyGaze(); },
289
291
  onBlink: () => idle.blink(),
290
292
  });
291
- const backchannel = new ListeningEngine((id) => { interject(id); emit('backchannel', id); });
293
+ const engagement = new ListeningEngine();
292
294
  const performTrack = new PerformTrack();
293
- // The hand is a sibling of the mixer, not a layer inside it: it writes SVG
294
- // directly rather than parameter channels, because a hand at the frame edge is
295
- // not part of the rig's body (see hand.js, and CLAUDE.md constraint 9 for the
296
- // arm chain this replaces). `hand: false` opts out — for a face drawn in some
297
- // other idiom, or a tile too small to spend the pixels.
298
- const hand = opts.hand === false ? null : createHand(face.svg, face.theme, meta, { dir: opts.handSide });
295
+ // The current SVG hand is a renderer adapter for the first-class `frame.hand`
296
+ // control. `hand: false` only disables its SVG rendering; gesture actions
297
+ // still emit the semantic hand frame for a supplied custom rig.
298
+ const hand = face && opts.hand !== false ? createHand(face.svg, face.theme, meta, { dir: opts.handSide }) : null;
299
+ // The existing SVG face and hand are one migration adapter implementing the
300
+ // renderer-agnostic AvatarRig contract. New renderers never need face SVG
301
+ // coordinates or the hand layer's private geometry.
302
+ const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
299
303
 
300
304
  gaze.onLargeShift = () => idle.blink();
301
305
 
302
306
  const listeners = {
303
- state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [], gestureEnd: [],
307
+ state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
304
308
  };
305
309
  const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
306
310
  clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
@@ -323,6 +327,9 @@ export function createAvatar(opts = {}) {
323
327
  // excursion changes. Values above ~1.5 saturate the open vowels against the
324
328
  // channel clamp, which is the intended ceiling rather than a bug.
325
329
  let mouthGain = opts.mouthGain ?? 1;
330
+ let handSide = opts.handSide === -1 ? 'left' : 'right';
331
+ let handAction = null;
332
+ const handQueue = [];
326
333
  // Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
327
334
  // peaks at 149ms — but the head smooths at a 160ms time constant, so barely
328
335
  // 60% of an authored peak is ever rendered. The keyframes were written against
@@ -340,6 +347,7 @@ export function createAvatar(opts = {}) {
340
347
  let slowBlinkAt = 0;
341
348
  let glanceAt = 0;
342
349
  let glanceUntil = 0;
350
+ let attendUntil = 0;
343
351
  let speakClock = null;
344
352
  let speakStart = 0;
345
353
 
@@ -358,7 +366,7 @@ export function createAvatar(opts = {}) {
358
366
  // `manual` withholds the rAF loop so a tool can drive frames itself. The
359
367
  // baseline pages could already step a ClipPlayer by hand, but nothing could
360
368
  // step the *mixer* — which is where idle, gaze and engagement actually
361
- // compose — so motion had no reproducible render. See tools/motion.mjs.
369
+ // compose — so motion had no reproducible render. See apps/authoring/tools/motion.mjs.
362
370
  const manual = !!opts.manual;
363
371
 
364
372
  function frame(now) {
@@ -427,23 +435,31 @@ export function createAvatar(opts = {}) {
427
435
  setGaze(gl.to);
428
436
  }
429
437
  }
430
- backchannel.enabled = !!st.backchannel && !clip.playing;
431
- backchannel.update(dt);
438
+ // Aversion is a property of the state, but it is held off around a turn
439
+ // boundary: the floor is handed over under mutual gaze, and an avatar that
440
+ // looks away exactly as the user finishes has declined it. `attend` is the
441
+ // mixer's one-frame veto — anything that means "the user is checking
442
+ // whether I am with them" sets it (see api.attend).
443
+ gaze.setAversion(st.aversion ? AVERSION[st.aversion] : null);
444
+ gaze.hold = attendUntil > elapsed || clip.playing;
445
+
446
+ engagement.enabled = !!st.engagement && !clip.playing;
447
+ engagement.update(dt);
432
448
  // Engagement posture: forward lean while the user holds the floor, spent
433
449
  // only in the states that are *about* the user holding the floor. The
434
450
  // research (docs/research-biomechanics.md §6.3) puts sustained attentive
435
451
  // lean at +0.15–0.25; engage glides, and torsoLean's 0.24s tau smooths
436
452
  // the state gate, so the lean arrives and leaves like weight shifting.
437
- if (st.backchannel) target.torsoLean += 0.16 * backchannel.engage;
453
+ if (st.engagement) target.torsoLean += 0.16 * engagement.engage;
438
454
  // Straining leans harder while there is actually a faint voice to strain
439
455
  // after. engage already tracks "the user is (barely) talking", so this
440
456
  // costs nothing; with no user signal the static pose carries the state.
441
- else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * backchannel.engage;
457
+ else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * engagement.engage;
442
458
 
443
459
  // 4. mouth. The server's viseme track wins; a clip's mouth track fills the
444
460
  // gaps. There is deliberately no third leg: with no cues the mouth stays
445
461
  // shut, and a still mouth under speech is the *visible* symptom of a
446
- // backend that could not align — see docs/removed.md § Amplitude lipsync.
462
+ // backend that could not align.
447
463
  const clipOut = clip.update(dtMs);
448
464
  let mouth = speech.sample();
449
465
  let mouthOwner = mouth ? 'speech' : null;
@@ -506,17 +522,11 @@ export function createAvatar(opts = {}) {
506
522
  // 8. smooth toward the target — this is where co-articulation happens
507
523
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
508
524
 
509
- face.apply(cur);
510
-
511
- // 9. the hand, outside all of the above. It writes a transform on its own
512
- // group rather than parameter channels, so it neither smooths nor
513
- // composes — its timelines are authored as delivered motion, not as
514
- // targets to chase. `elapsed` is the mixer's own clock, so a manual
515
- // stepper gets a reproducible gesture for free.
516
- if (hand) {
517
- const done = hand.update(elapsed * 1000);
518
- if (done) emit('gestureEnd', done.id);
519
- }
525
+ // 9. First-class hand control. The semantic frame is generated here, above
526
+ // every renderer, so SVG, WebGL, and video rigs receive exactly the same
527
+ // gesture/progress information. A handless rig simply ignores `frame.hand`.
528
+ const handFrame = updateHandAction(elapsed * 1000);
529
+ rig.apply(avatarFrame(cur, handFrame || undefined));
520
530
  }
521
531
 
522
532
  const REST_SHAPE = shapeFor(SILENT, 1);
@@ -554,9 +564,12 @@ export function createAvatar(opts = {}) {
554
564
  // fire a stale timestamp immediately.
555
565
  glanceUntil = 0;
556
566
  glanceAt = elapsed + (st.glance ? st.glance.every[0] + Math.random() * (st.glance.every[1] - st.glance.every[0]) : 0);
557
- backchannel.reset(name === 'LISTENING' ? 2.2 : 4);
558
- face.svg.style.filter = st.filter || '';
559
- face.svg.style.transition = 'filter .5s ease';
567
+ // SVG's desaturation filter is a legacy renderer detail. A generic rig
568
+ // receives the same state pose and may express degradation its own way.
569
+ if (face) {
570
+ face.svg.style.filter = st.filter || '';
571
+ face.svg.style.transition = 'filter .5s ease';
572
+ }
560
573
  if (changed) { idle.blink(); emit('state', name); }
561
574
  return api;
562
575
  }
@@ -578,8 +591,9 @@ export function createAvatar(opts = {}) {
578
591
  * @param {() => number} [o.clock] custom ms clock, if you drive audio yourself
579
592
  */
580
593
  function speak(o = {}) {
581
- // A spoken interjection must die before real speech starts.
582
- if (clip.playing && clip.clip.mouthCues) clip.stop();
594
+ // Speech owns the mouth in the mixer, but a server action still gets to
595
+ // complete its physical landing on the other channels. Do not cancel it
596
+ // here: a hand or head cannot disappear simply because playout began.
583
597
  speakStart = performance.now();
584
598
  speakClock = o.clock
585
599
  ? o.clock
@@ -592,15 +606,48 @@ export function createAvatar(opts = {}) {
592
606
  return api;
593
607
  }
594
608
 
609
+ /**
610
+ * "The user may be checking whether I am with them — hold their eyes."
611
+ *
612
+ * This is the widget's half of the **gaze window**. In face-to-face talk a
613
+ * speaker periodically looks at the listener, mutual gaze is established, the
614
+ * listener responds inside that window, and the speaker looks away again
615
+ * (Bavelas, Coates & Johnson 2002) — listener responses cluster inside the
616
+ * window rather than being scattered across the turn.
617
+ *
618
+ * We cannot see the user, so we cannot observe the window opening. What a
619
+ * caller *can* do is name the moments that co-occur with it — a mid-turn
620
+ * pause, a tag question ("...right?", "you know?"), a completed clause with
621
+ * the turn analyzer's completion probability high, the user answering a
622
+ * question the bot asked. `attend(ms)` is how those arrive: for its duration
623
+ * the face stops averting and holds the user, which is the prerequisite for
624
+ * any response to be *seen*. Emitting the response itself stays a separate
625
+ * call — a window that opens and draws nothing is a real and common outcome
626
+ * (with every measured invitation cue present, humans respond to only ~30% of
627
+ * opportunities), and conflating the two would make the avatar answer
628
+ * everything. The explicit response remains a backend/application decision.
629
+ *
630
+ * Deliberately **not on the wire yet**: there is no `attend` command in
631
+ * `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
632
+ * the rig pages). Adding the command is a protocol change and waits for a
633
+ * server that has something real to key it off — see docs/internal-mixer.md.
634
+ *
635
+ * @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
636
+ * mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
637
+ */
638
+ function attend(ms = 1200) {
639
+ attendUntil = Math.max(attendUntil, elapsed + ms / 1000);
640
+ return api;
641
+ }
642
+
595
643
  function pushCues(cues) { speech.push(cues); return api; }
596
644
 
597
645
  function stopSpeaking() { speech.stop(); return api; }
598
646
 
599
647
  function interject(id) {
600
- const c = INTERJECTIONS[id];
648
+ const c = INTERNAL_CLIPS[id];
601
649
  if (!c) throw new Error(`unknown interjection: ${id}`);
602
650
  clip.play(c, c.audioEl);
603
- backchannel.reset(3.5);
604
651
  return api;
605
652
  }
606
653
 
@@ -610,34 +657,70 @@ export function createAvatar(opts = {}) {
610
657
  *
611
658
  * The face half is not a convenience — a hand rising to the jaw over a head
612
659
  * and shoulders sitting perfectly still is a cut-out, not a gesture. Each
613
- * entry in HAND_GESTURES names an interjection that already exists and was
614
- * already tuned (`WAVE`, `THUMBS_UP`, `ONE_MOMENT`); firing it here is the
660
+ * entry in HAND_GESTURES names the matching semantic face action; firing it here is the
615
661
  * library composing two authored things, not the client inventing motion.
616
662
  *
617
- * The two halves stay separable in both directions. `interject('WAVE')` is
618
- * still the face alone and is unchanged by this — a server that upgrades gets
619
- * no new behaviour until it asks for one — and on an avatar mounted with
620
- * `hand: false` this call degrades to exactly that interjection, which is the
621
- * same graceful failure the arm removal already forced every id through.
663
+ * On an avatar mounted with `hand: false` this internal helper degrades to
664
+ * the face action alone.
622
665
  */
623
666
  function gesture(id) {
624
667
  const def = HAND_GESTURES[id];
625
668
  if (!def) throw new Error(`unknown hand gesture: ${id}`);
626
669
  if (hand) hand.play(id, elapsed * 1000);
627
670
  if (def.face) interject(def.face);
628
- // A hand in frame is a deliberate move; a backchannel landing on top of it
629
- // is the listening engine talking over the server.
630
- backchannel.reset(def.dur / 1000 + 0.5);
631
671
  return api;
632
672
  }
633
673
 
674
+ /** One self-completing server action. State continues to resolve underneath;
675
+ * face and hand layers queue their next movement so an in-flight physical
676
+ * gesture always gets to land. */
677
+ function action(id) {
678
+ const handDef = HAND_GESTURES[id];
679
+ if (handDef) {
680
+ startHandAction(id, handDef);
681
+ if (handDef.face) {
682
+ const faceClip = ACTIONS[handDef.face];
683
+ if (faceClip) clip.play(faceClip, faceClip.audioEl, { queue: true });
684
+ }
685
+ return api;
686
+ }
687
+ const faceClip = ACTIONS[id];
688
+ if (!faceClip) throw new Error(`unknown action: ${id}`);
689
+ clip.play(faceClip, faceClip.audioEl, { queue: true });
690
+ return api;
691
+ }
692
+
693
+ function startHandAction(id, def) {
694
+ const gesture = HAND_ACTION_TO_FRAME_GESTURE[id];
695
+ if (!gesture) return;
696
+ if (handAction) {
697
+ if (handAction.id !== id && !handQueue.some((item) => item.id === id)) {
698
+ handQueue.push({ id, def, gesture });
699
+ }
700
+ return;
701
+ }
702
+ handAction = { id, def, gesture, start: elapsed * 1000 };
703
+ }
704
+
705
+ function updateHandAction(nowMs) {
706
+ if (!handAction) return null;
707
+ const progress = (nowMs - handAction.start) / handAction.def.dur;
708
+ if (progress >= 1) {
709
+ const done = handAction;
710
+ const next = handQueue.shift();
711
+ handAction = next ? { ...next, start: nowMs } : null;
712
+ emit('gestureEnd', done.id);
713
+ return handAction ? { gesture: handAction.gesture, progress: 0, side: handSide } : null;
714
+ }
715
+ return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
716
+ }
717
+
634
718
  /**
635
- * Tell the listening engine whether the USER holds the floor, so backchannels
636
- * become contingent on their pauses instead of running on a timer. The server
637
- * owns this — it has the endpointer — and sends it as the `user` command;
638
- * `null` hands back to the no-signal timer fallback.
719
+ * Tell the listening engine whether Pipecat VAD says the user holds the
720
+ * floor. This changes only sustained engagement posture; it can never create
721
+ * a nod or acknowledgement clip.
639
722
  */
640
- function setUserSpeaking(b) { backchannel.setUserSpeaking(b); return api; }
723
+ function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
641
724
 
642
725
  // What one action does when its moment comes. Enum validity is checked here,
643
726
  // where the enums live: a bad value warns and is skipped, because one stale
@@ -650,8 +733,7 @@ export function createAvatar(opts = {}) {
650
733
  if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
651
734
  else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
652
735
  else if (a.do === 'gaze') setGaze(a.name);
653
- else if (a.do === 'interject') interject(a.id);
654
- else if (a.do === 'gesture') gesture(a.id);
736
+ else if (a.do === 'action') action(a.id);
655
737
  } catch (e) {
656
738
  console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
657
739
  }
@@ -661,8 +743,8 @@ export function createAvatar(opts = {}) {
661
743
 
662
744
  /**
663
745
  * Play a timed action track — the composition surface a server assembles
664
- * turns from. Verbs: state / emotion / gaze / interject / gesture (see
665
- * perform.js for hygiene, docs/contract-protocol.md for the schema).
746
+ * turns from. Verbs: state / emotion / gaze / action (see
747
+ * perform.js for hygiene, docs/internal-mixer.md for the schema).
666
748
  *
667
749
  * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
668
750
  * element's own time, else ms elapsed since this call. perform() never
@@ -691,12 +773,12 @@ export function createAvatar(opts = {}) {
691
773
  }
692
774
 
693
775
  const api = {
694
- setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, interject,
695
- gesture, perform,
776
+ setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, attend,
777
+ action, perform,
696
778
  /** Which hand the character gestures with: +1 the viewer's right (its own
697
779
  * left), -1 the other. Both are anatomically real — the thumb splays away
698
780
  * from the body either way — so this is a character choice, not a fix. */
699
- setHandSide: (d) => { if (hand) hand.setDir(d); return api; },
781
+ setHandSide: (d) => { handSide = d === -1 ? 'left' : 'right'; return api; },
700
782
  setUserSpeaking,
701
783
  /** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
702
784
  setMouthGain: (g) => { mouthGain = g; return api; },
@@ -720,21 +802,22 @@ export function createAvatar(opts = {}) {
720
802
  get speaking() { return speech.playing; },
721
803
  get performing() { return performTrack.playing; },
722
804
  get clip() { return clip.id; },
723
- /** The hand gesture in flight, or null. `null` forever if `hand: false`. */
724
- get gesturing() { return hand ? hand.id : null; },
805
+ /** Semantic hand gesture in flight, independent of renderer capability. */
806
+ get gesturing() { return handAction ? handAction.id : null; },
725
807
  get params() { return cur; },
726
- get userSpeaking() { return backchannel.speaking; },
727
- svg: face.svg,
728
- meta,
808
+ get userSpeaking() { return engagement.speaking; },
809
+ // Legacy SVG inspection fields. New AvatarRig implementations should not
810
+ // rely on or provide them; the renderer-neutral contract is apply/destroy.
811
+ svg: face?.svg || null,
812
+ meta: meta || null,
729
813
  /** The mounted rig's palette, merged with any `opts.theme` overrides. A
730
814
  * host that has to paint anything *around* the widget — a tile margin, a
731
815
  * page behind a transparent mount — needs the same colours the drawing
732
816
  * used, and guessing them per avatar is how the two drift apart. */
733
- theme: face.theme,
817
+ theme: face?.theme,
734
818
  destroy() {
735
819
  cancelAnimationFrame(raf);
736
- if (hand) hand.destroy();
737
- face.destroy();
820
+ rig.destroy();
738
821
  },
739
822
  };
740
823
 
@@ -743,13 +826,18 @@ export function createAvatar(opts = {}) {
743
826
  return api;
744
827
  }
745
828
 
746
- export { INTERJECTIONS, INTERJECTION_IDS, SPOKEN_IDS, attachAudio } from './interjections.js';
829
+ export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
747
830
  export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
748
831
  export { normalizeActions } from './perform.js';
749
- export { HAND_GESTURES, HAND_GESTURE_IDS, checkHandFraming } from './hand.js';
832
+ export { checkHandFraming } from './hand.js';
750
833
  export { EMOTION_NAMES } from './emotions.js';
834
+ // The mouth clock travels with the rest of it. Someone has to turn a cue array
835
+ // plus a clock into "which letter is on screen right now", every renderer needs
836
+ // exactly that, and none of them should write it twice — so it is a plain class
837
+ // to construct, not a contract to implement.
751
838
  export {
752
- VISEME_LETTERS, VISEME_SHAPES, normalizeCues, textToCues,
839
+ VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
840
+ normalizeCues, textToCues,
753
841
  ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
754
842
  } from './visemes.js';
755
843
  // No THEME re-export: each face module owns its palette, and `api.theme` is