@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
@@ -26,9 +26,13 @@ const nod = (peak, at = 0.24, rebound = 0.56) => [
26
26
  [0, 0], [at, peak], [rebound, -peak * 0.24], [1, 0],
27
27
  ];
28
28
 
29
- export const INTERJECTIONS = {
29
+ // This is the authoring library. It intentionally contains exploratory clips
30
+ // as well as production ones; only the small `ACTIONS` export below is
31
+ // part of the public action contract.
32
+ const CLIPS = {
30
33
  // ---------------------------------------------------------------------
31
- // Pure backchannel — no words, fired automatically while LISTENING.
34
+ // Wordless acknowledgement clips. Every one is an explicit backend or
35
+ // application decision; the client never fires them from VAD or a timer.
32
36
  // ---------------------------------------------------------------------
33
37
  // The nod peaks below are pre-compensated for the head's 160ms time constant.
34
38
  // Authored at their perceptual value (~0.3) they rendered at ~0.6x and read as
@@ -44,26 +48,27 @@ export const INTERJECTIONS = {
44
48
  // drops further than the trend — and everything stays under the 1.5 Hz line
45
49
  // where sustained attention turns into impatience.
46
50
  NOD_SMALL: {
47
- id: 'NOD_SMALL', label: 'nod (continuer)', text: '', duration: 800,
51
+ id: 'NOD_SMALL', label: 'nod (continuer)', text: '', duration: 900,
48
52
  keys: {
49
- // One cycle: single-cycle nods are 42% of the corpus, and the continuer
50
- // is the quietest citizen of the family — "go on", not "I agree".
51
- headPitch: nod(0.55, 0.22, 0.52),
52
- browRaiseL: [[0, 0], [0.3, 0.14], [1, 0]],
53
- browRaiseR: [[0, 0], [0.3, 0.14], [1, 0]],
53
+ // One readable stroke, not an ambient bob. The peak is deliberately
54
+ // authored above its desired rendered travel because the common head
55
+ // smoothing absorbs a fast impulse before it reaches the SVG.
56
+ headPitch: [[0, 0], [0.25, 0.74], [0.38, 0.70], [0.68, -0.12], [1, 0]],
57
+ browRaiseL: [[0, 0], [0.34, 0.16], [1, 0]],
58
+ browRaiseR: [[0, 0], [0.34, 0.16], [1, 0]],
54
59
  },
55
60
  },
56
61
  NOD_SLOW: {
57
- id: 'NOD_SLOW', label: 'nod (assessment, two-beat)', text: '', duration: 1420,
62
+ id: 'NOD_SLOW', label: 'nod (assessment / receipt)', text: '', duration: 1200,
58
63
  keys: {
59
- // Two cycles at ~1.4 Hz. The first beat is the deepest thing any nod
60
- // does (anticipatory rising) and the second sits well below it —
61
- // declination plus final lowering, which is what makes the gesture
62
- // resolve instead of merely stopping. The lift between beats crosses
63
- // slightly above rest so the cycles read as separate strokes.
64
- headPitch: [[0, 0], [0.16, 0.85], [0.38, -0.10], [0.60, 0.55], [0.85, -0.06], [1, 0]],
65
- mouthCornerL: [[0, 0], [0.45, 0.12], [1, 0]],
66
- mouthCornerR: [[0, 0], [0.45, 0.12], [1, 0]],
64
+ // A receipt needs an arrival. The former two fast peaks registered as
65
+ // idle bobbing at call-tile size, so this is one deep downstroke with a
66
+ // short dwell and a slower recovery. The tiny torso commitment keeps the
67
+ // head from looking like it is sliding independently of the body.
68
+ headPitch: [[0, 0], [0.18, 0.18], [0.39, 1.06], [0.52, 1.02], [0.78, -0.16], [1, 0]],
69
+ torsoLean: [[0, 0], [0.40, 0.09], [0.58, 0.07], [1, 0]],
70
+ browRaiseL: [[0, 0], [0.23, 0.12], [0.62, 0.08], [1, 0]],
71
+ browRaiseR: [[0, 0], [0.23, 0.10], [0.62, 0.07], [1, 0]],
67
72
  },
68
73
  },
69
74
  // The "ah — I see" nod. What separates it from agreement is the upswing:
@@ -88,13 +93,93 @@ export const INTERJECTIONS = {
88
93
  keys: {
89
94
  browRaiseL: [[0, 0], [0.24, 0.30], [0.7, 0.06], [1, 0]],
90
95
  browRaiseR: [[0, 0], [0.24, 0.26], [0.7, 0.06], [1, 0]],
91
- headPitch: [[0, 0], [0.3, 0.08], [1, 0]],
92
96
  mouthCornerL: [[0, 0], [0.35, 0.14], [1, 0]],
93
97
  mouthCornerR: [[0, 0], [0.35, 0.14], [1, 0]],
94
98
  },
95
99
  blinkAt: [0.22],
96
100
  },
97
- // The disagree family follows the same three laws as the nods (§3.4): the
101
+ // ---------------------------------------------------------------------
102
+ // Understanding beats — eye/face/body composites, deliberately separate
103
+ // from nods. They are the shared low-authoring-cost alternative when a
104
+ // portrait's pitch geometry is too limited to carry acknowledgement alone.
105
+ // ---------------------------------------------------------------------
106
+ ACK_CONTINUE: {
107
+ id: 'ACK_CONTINUE', label: 'acknowledge: continue', text: '', duration: 620,
108
+ keys: {
109
+ // Eyes acknowledge first; nothing here claims agreement or closes the
110
+ // conversational floor while the user is still talking.
111
+ browRaiseL: [[0, 0], [0.18, 0.14], [0.48, 0.06], [1, 0]],
112
+ browRaiseR: [[0, 0], [0.18, 0.12], [0.48, 0.05], [1, 0]],
113
+ lidL: [[0, 0], [0.20, 0.035], [0.58, 0.01], [1, 0]],
114
+ lidR: [[0, 0], [0.20, 0.035], [0.58, 0.01], [1, 0]],
115
+ },
116
+ },
117
+ ACK_RECEIVE: {
118
+ id: 'ACK_RECEIVE', label: 'acknowledge: received', text: '', duration: 1120,
119
+ keys: {
120
+ // Recognition → take it in → settle. The face does not nod; it lands in
121
+ // a quiet, held receipt, leaving room for the user to see the response.
122
+ browRaiseL: [[0, 0], [0.12, 0.16], [0.34, 0.055], [1, 0]],
123
+ browRaiseR: [[0, 0], [0.12, 0.13], [0.34, 0.045], [1, 0]],
124
+ lidL: [[0, 0], [0.16, 0.15], [0.49, 0.14], [0.82, 0.035], [1, 0]],
125
+ lidR: [[0, 0], [0.16, 0.15], [0.49, 0.14], [0.82, 0.035], [1, 0]],
126
+ mouthPress: [[0, 0], [0.23, 0.20], [0.60, 0.17], [1, 0]],
127
+ mouthCornerL: [[0, 0], [0.34, 0.08], [0.80, 0.055], [1, 0]],
128
+ mouthCornerR: [[0, 0], [0.34, 0.08], [0.80, 0.055], [1, 0]],
129
+ torsoLean: [[0, 0], [0.34, 0.13], [0.70, 0.10], [1, 0]],
130
+ shoulderL: [[0, 0], [0.50, 0.070], [0.76, 0.045], [1, 0]],
131
+ shoulderR: [[0, 0], [0.50, 0.070], [0.76, 0.045], [1, 0]],
132
+ },
133
+ },
134
+ ACK_REALIZE: {
135
+ id: 'ACK_REALIZE', label: 'acknowledge: realization', text: '', duration: 980,
136
+ keys: {
137
+ // The brows and widened lid lead: this means "the point connected", not
138
+ // the generic social smile which looks like agreement in a small tile.
139
+ browRaiseL: [[0, 0], [0.15, 0.36], [0.42, 0.20], [1, 0]],
140
+ browRaiseR: [[0, 0], [0.15, 0.32], [0.42, 0.18], [1, 0]],
141
+ lidL: [[0, 0], [0.16, -0.07], [0.48, -0.025], [1, 0]],
142
+ lidR: [[0, 0], [0.16, -0.07], [0.48, -0.025], [1, 0]],
143
+ mouthCornerL: [[0, 0], [0.35, 0.12], [0.80, 0.09], [1, 0]],
144
+ mouthCornerR: [[0, 0], [0.35, 0.12], [0.80, 0.09], [1, 0]],
145
+ torsoLean: [[0, 0], [0.38, 0.07], [0.72, 0.05], [1, 0]],
146
+ },
147
+ },
148
+ ACK_EMPATHIZE: {
149
+ id: 'ACK_EMPATHIZE', label: 'acknowledge: empathy', text: '', duration: 1180,
150
+ keys: {
151
+ // Inner brows and softened lids acknowledge the affect without signalling
152
+ // agreement. Keep the mouth nearly neutral for professional contexts.
153
+ browInnerL: [[0, 0], [0.22, 0.24], [0.68, 0.16], [1, 0]],
154
+ browInnerR: [[0, 0], [0.22, 0.20], [0.68, 0.13], [1, 0]],
155
+ browRaiseL: [[0, 0], [0.22, 0.07], [0.68, 0.05], [1, 0]],
156
+ browRaiseR: [[0, 0], [0.22, 0.06], [0.68, 0.04], [1, 0]],
157
+ lidL: [[0, 0], [0.25, 0.06], [0.72, 0.04], [1, 0]],
158
+ lidR: [[0, 0], [0.25, 0.06], [0.72, 0.04], [1, 0]],
159
+ mouthPress: [[0, 0], [0.34, 0.06], [0.72, 0.045], [1, 0]],
160
+ torsoLean: [[0, 0], [0.40, 0.065], [0.76, 0.045], [1, 0]],
161
+ },
162
+ },
163
+ // A comparison nod, not the director's default. Two quick decaying strokes
164
+ // with a barely-there listening tilt reflect the common conversational form
165
+ // the current single deep receipt does not show. It stays comparison-only:
166
+ // the host can decide whether this faster cadence fits its conversation.
167
+ ACK_NOD: {
168
+ id: 'ACK_NOD', label: 'acknowledge: nod', text: '', duration: 1120,
169
+ keys: {
170
+ // Values are intentionally a little theatrical at close range: at a
171
+ // video-call tile the head's 160 ms smoothing otherwise eats the second
172
+ // beat and the slight listening tilt entirely.
173
+ headPitch: [[0, 0], [0.12, -0.15], [0.30, 0.98], [0.46, -0.13], [0.64, 0.68], [0.82, -0.09], [1, 0]],
174
+ headRoll: [[0, 0], [0.18, -0.090], [0.68, -0.070], [1, 0]],
175
+ torsoLean: [[0, 0], [0.31, 0.115], [0.68, 0.075], [1, 0]],
176
+ shoulderL: [[0, 0], [0.43, 0.055], [0.73, 0.032], [1, 0]],
177
+ shoulderR: [[0, 0], [0.43, 0.055], [0.73, 0.032], [1, 0]],
178
+ lidL: [[0, 0], [0.30, 0.065], [0.64, 0.038], [1, 0]],
179
+ lidR: [[0, 0], [0.30, 0.065], [0.64, 0.038], [1, 0]],
180
+ },
181
+ },
182
+ // The disagree family follows the same three laws as explicit nods (§3.4): the
98
183
  // first swing is the biggest, every cycle decays, and the whole gesture
99
184
  // stays at or under ~1.5 Hz. Both are SERVER-SENT ONLY — an agent must
100
185
  // never disagree autonomously, so neither is in the listening engine's
@@ -211,6 +296,24 @@ export const INTERJECTIONS = {
211
296
  blinkAt: [0.1],
212
297
  },
213
298
 
299
+ /** Server-confirmed cut-off after playout stopped. This is not a state: the
300
+ * clip explains the abrupt transition, then lands in whatever factual pose
301
+ * Pipecat has resolved underneath it. */
302
+ RESPONSE_INTERRUPTED: {
303
+ id: 'RESPONSE_INTERRUPTED', label: 'response: interrupted', text: '', duration: 1550,
304
+ keys: {
305
+ mouthOpen: [[0, 0], [0.06, 0.36], [0.80, 0.36], [1, 0]],
306
+ jaw: [[0, 0], [0.08, 0.20], [0.80, 0.20], [1, 0]],
307
+ browRaiseL: [[0, 0], [0.12, 0.46], [0.72, 0.34], [1, 0]],
308
+ browRaiseR: [[0, 0], [0.12, 0.40], [0.72, 0.30], [1, 0]],
309
+ lidL: [[0, 0], [0.12, -0.18], [0.72, -0.12], [1, 0]],
310
+ lidR: [[0, 0], [0.12, -0.16], [0.72, -0.10], [1, 0]],
311
+ torsoLean: [[0, 0], [0.18, -0.16], [0.72, -0.10], [1, 0]],
312
+ shoulderL: [[0, 0], [0.18, -0.12], [0.72, -0.08], [1, 0]],
313
+ shoulderR: [[0, 0], [0.18, -0.12], [0.72, -0.08], [1, 0]],
314
+ },
315
+ },
316
+
214
317
  /**
215
318
  * "May I come in." This used to be a raised palm, and it is now the same
216
319
  * signal without one: the body claims a little space and the face asks. The
@@ -273,8 +376,8 @@ export const INTERJECTIONS = {
273
376
  * oscillation left to fill the time; a held smile past about a second stops
274
377
  * being a greeting and becomes an expression.
275
378
  */
276
- WAVE: {
277
- id: 'WAVE', label: 'greet (brow flash)', text: '', duration: 1300,
379
+ GESTURE_GREET: {
380
+ id: 'GESTURE_GREET', label: 'gesture: greet', text: '', duration: 1300,
278
381
  keys: {
279
382
  browRaiseL: [[0, 0], [0.11, 0.85], [0.4, 0.22], [0.75, 0.12], [1, 0]],
280
383
  browRaiseR: [[0, 0], [0.11, 0.80], [0.4, 0.20], [0.75, 0.10], [1, 0]],
@@ -304,8 +407,8 @@ export const INTERJECTIONS = {
304
407
  * Deep enough to be unmistakable: peak 0.62 against NOD_SMALL's 0.52, over
305
408
  * nearly three times the duration.
306
409
  */
307
- THUMBS_UP: {
308
- id: 'THUMBS_UP', label: 'approve', text: '', duration: 1500,
410
+ GESTURE_APPROVE: {
411
+ id: 'GESTURE_APPROVE', label: 'gesture: approve', text: '', duration: 1500,
309
412
  keys: {
310
413
  headPitch: [[0, 0], [0.3, 0.62], [0.58, 0.20], [0.78, 0.34], [1, 0]],
311
414
  mouthCornerL: [[0, 0], [0.36, 0.58], [0.8, 0.48], [1, 0]],
@@ -473,8 +576,8 @@ export const INTERJECTIONS = {
473
576
  torsoLean: [[0, 0], [0.25, 0.10], [0.7, 0.08], [1, 0]],
474
577
  },
475
578
  },
476
- ONE_MOMENT: {
477
- id: 'ONE_MOMENT', label: 'one moment', text: 'one moment', duration: 1350,
579
+ GESTURE_WAIT: {
580
+ id: 'GESTURE_WAIT', label: 'gesture: wait', text: 'one moment', duration: 1350,
478
581
  // w · ʌ · n · m · oʊ · m · ə · n · t
479
582
  mouthCues: [
480
583
  { t: 0, v: 'F' }, { t: 110, v: 'C' }, { t: 210, v: 'A' }, { t: 320, v: 'F' },
@@ -557,19 +660,41 @@ export const INTERJECTIONS = {
557
660
  },
558
661
  };
559
662
 
560
- export const INTERJECTION_IDS = Object.keys(INTERJECTIONS);
663
+ /** Full local authoring library. Not a server action vocabulary. */
664
+ export const INTERNAL_CLIPS = CLIPS;
665
+
666
+ /**
667
+ * Public, server-addressable actions. Naming is `CATEGORY_INTENT`:
668
+ * acknowledgements are `ACK_*`, the special transition is `RESPONSE_*`, and
669
+ * visible hand/body movements are `GESTURE_*`. This is deliberately an intent
670
+ * vocabulary — `ACK_NOD` is one implementation, not a promise about anatomy.
671
+ */
672
+ export const ACTION_IDS = Object.freeze([
673
+ 'ACK_RECEIVE', 'ACK_NOD',
674
+ 'RESPONSE_INTERRUPTED',
675
+ 'GESTURE_GREET', 'GESTURE_GOODBYE', 'GESTURE_APPROVE', 'GESTURE_WAIT',
676
+ ]);
561
677
 
562
- /** Clips that carry words, vs. wordless backchannel. */
563
- export const SPOKEN_IDS = INTERJECTION_IDS.filter((k) => INTERJECTIONS[k].text);
678
+ /** Face-capable subset of the action contract. */
679
+ export const ACTIONS = Object.freeze({
680
+ ...Object.fromEntries(ACTION_IDS.filter((id) => CLIPS[id]).map((id) => [id, CLIPS[id]])),
681
+ // The goodbye face intentionally reuses the greeting brow-flash while its
682
+ // hand owns the distinct, longer farewell motion. It still needs metadata so
683
+ // a client can enumerate every public action without knowing that detail.
684
+ GESTURE_GOODBYE: {
685
+ ...CLIPS.GESTURE_GREET,
686
+ id: 'GESTURE_GOODBYE', label: 'gesture: goodbye', duration: 1550,
687
+ },
688
+ });
564
689
 
565
690
  /**
566
- * Attach audio to a clip. Call once at boot with your own TTS renders:
567
- * attachAudio('OKAY', '/audio/agent-okay.mp3')
691
+ * Attach audio to an internal authoring clip. Production actions are silent
692
+ * sequences; response speech always belongs to Pipecat's audio/viseme track.
568
693
  * The baked viseme track is then scheduled against that file's clock instead of
569
694
  * the local timer, so any timing drift resolves in the audio's favour.
570
695
  */
571
696
  export function attachAudio(id, url) {
572
- const clip = INTERJECTIONS[id];
697
+ const clip = CLIPS[id];
573
698
  if (!clip) throw new Error(`unknown interjection: ${id}`);
574
699
  const el = new Audio(url);
575
700
  el.preload = 'auto';
package/src/params.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * mouthCornerL/R + up (smile)
15
15
  */
16
16
 
17
+ import { JAW_RESPONSE_TAU_S, MOUTH_RESPONSE_TAU_S } from './speech-timing.js';
18
+
17
19
  export const REST = {
18
20
  // --- mouth -------------------------------------------------------------
19
21
  mouthOpen: 0.02, // vertical aperture, 0..1
@@ -21,8 +23,10 @@ export const REST = {
21
23
  mouthRound: 0.1, // pucker / lip protrusion
22
24
  mouthPress: 0.15, // lips thinned & pressed together
23
25
  mouthTuck: 0.0, // lower lip drawn under upper teeth (F/V)
24
- mouthCornerL: 0.1, // -1 frown .. +1 smile
25
- mouthCornerR: 0.1,
26
+ // Availability is not a permanent smile. Warmth and encouragement are
27
+ // explicit behavioral choices layered above this neutral resting shape.
28
+ mouthCornerL: 0.0, // -1 frown .. +1 smile
29
+ mouthCornerR: 0.0,
26
30
  teethUpper: 0.0, // how far the upper teeth show, 0..1
27
31
  tongue: 0.0, // tongue raised into the aperture, 0..1
28
32
  jaw: 0.0, // extra chin drop, follows mouthOpen but slower
@@ -118,7 +122,7 @@ export const GROUPS = {
118
122
  export const TAU = (() => {
119
123
  const t = {};
120
124
  for (const c of CHANNELS) t[c] = 0.09;
121
- for (const c of GROUPS.mouth) t[c] = 0.042;
125
+ for (const c of GROUPS.mouth) t[c] = MOUTH_RESPONSE_TAU_S;
122
126
  for (const c of GROUPS.smile) t[c] = 0.13;
123
127
  t.lidL = t.lidR = 0.018; // blinks must be crisp
124
128
  t.squintL = t.squintR = 0.12;
@@ -126,7 +130,7 @@ export const TAU = (() => {
126
130
  for (const c of GROUPS.brows) t[c] = 0.08;
127
131
  for (const c of GROUPS.head) t[c] = 0.16; // the head has real mass
128
132
  t.breath = 0.25;
129
- t.jaw = 0.07; // the jaw lags the lips slightly
133
+ t.jaw = JAW_RESPONSE_TAU_S; // the jaw lags the lips slightly
130
134
  // The torso has more mass than the head and reads wrong when it hasn't.
131
135
  t.shoulderL = t.shoulderR = 0.19;
132
136
  t.torsoLean = 0.24;
package/src/perform.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A performance is the server's choreography: timed verbs `{t, do, ...}` fired
5
5
  * against a clock, where every verb resolves to one of the widget's own enums —
6
- * states, emotions, gaze targets, interjections, hand gestures. The vocabulary is deliberately
6
+ * states, emotions, gaze targets, and semantic actions. The vocabulary is deliberately
7
7
  * closed: the backend sequences what the rig already does well, it cannot
8
8
  * invent motion. That constraint is what makes the wire format assemblable by
9
9
  * a dialogue manager and reviewable by a human.
@@ -14,25 +14,23 @@
14
14
  * tab is backgrounded. One deliberate difference: beat times fire *verbatim*,
15
15
  * with no LEAD_MS. Visemes lead the sound because phoneme sync is
16
16
  * frame-critical; a gesture arrives through its channels' own smoothing lag,
17
- * and any deliberate lead (CLAIM_FLOOR starts ~350ms before the first sample)
18
- * is authored into the times by the composer, not imposed here.
17
+ * and any deliberate lead is authored into the times by the composer, not
18
+ * imposed here.
19
19
  *
20
20
  * Seeking the audio backward does not re-fire earlier actions: verbs have side
21
21
  * effects, and replaying a nod is worse than missing one.
22
22
  */
23
23
 
24
- const VERBS = new Set(['state', 'emotion', 'gaze', 'interject', 'gesture']);
25
- // The verbs addressed by `id` rather than `name`. Both id verbs name a clip the
26
- // widget already owns; both name-verbs name an enum value.
27
- const ID_VERBS = new Set(['interject', 'gesture']);
24
+ const VERBS = new Set(['state', 'emotion', 'gaze', 'action']);
25
+ // `action` is addressed by id; the remaining verbs name an enum value.
26
+ const ID_VERBS = new Set(['action']);
28
27
 
29
28
  /**
30
29
  * Shape hygiene for action arrays, in the spirit of normalizeCues: sort by
31
30
  * time, drop what cannot possibly fire — no finite `t`, an unknown verb, a
32
31
  * missing `name`/`id` — each with a console warning, never a throw.
33
32
  *
34
- * This checks *shape* only. Enum values (is "THINKING" a state? is "NOD_UP"
35
- * an interjection?) are checked when the verb fires, by the dispatcher in
33
+ * This checks *shape* only. Enum values are checked when the verb fires, by the dispatcher in
36
34
  * avatar.js — deliberately, and not just because importing the enums here
37
35
  * would be a dependency cycle: a track half-composed against a newer widget
38
36
  * should lose the verbs the widget doesn't know, not the whole performance.
package/src/rig.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** The mixer→rig pose model, internal to the SVG renderer; see docs/internal-rig.md. */
2
+ export type RigChannel = string;
3
+ export type RigPose = Readonly<Record<RigChannel, number>>;
4
+
5
+ export interface HandFrame {
6
+ gesture: "greet" | "farewell" | "approve" | "wait";
7
+ progress: number;
8
+ side: "left" | "right";
9
+ }
10
+
11
+ export interface AvatarFrame {
12
+ pose: RigPose;
13
+ hand?: HandFrame;
14
+ }
15
+
16
+ export interface AvatarRig {
17
+ apply(frame: AvatarFrame): void;
18
+ destroy(): void;
19
+ }
20
+
21
+ export type AvatarRigFactory = (mount: HTMLElement, options?: unknown) => AvatarRig;
22
+
23
+ export const HAND_GESTURE_NAMES: ReadonlyArray<HandFrame["gesture"]>;
24
+ export function avatarFrame(pose: RigPose, hand?: HandFrame): AvatarFrame;
package/src/rig.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Renderer-agnostic frame types expressed as runtime helpers.
3
+ *
4
+ * `createSvgRig()` is a migration adapter for the existing SVG faces. It lets
5
+ * the mixer submit one AvatarFrame per tick today while new renderers can
6
+ * implement the same small `{ apply(frame), destroy() }` contract directly.
7
+ */
8
+
9
+ export const HAND_GESTURE_NAMES = Object.freeze(['greet', 'farewell', 'approve', 'wait']);
10
+
11
+ /** @param {Record<string, number>} pose @param {object|undefined} hand */
12
+ export function avatarFrame(pose, hand) {
13
+ return hand ? { pose, hand } : { pose };
14
+ }
15
+
16
+ /**
17
+ * Adapt an existing SVG face plus its first-class hand renderer to AvatarRig.
18
+ * `face` and `hand` retain their own SVG-private geometry; callers only submit
19
+ * semantic frames.
20
+ */
21
+ export function createSvgRig(face, hand = null) {
22
+ return {
23
+ apply(frame) {
24
+ face.apply(frame.pose);
25
+ if (hand && typeof hand.applyFrame === 'function') hand.applyFrame(frame.hand);
26
+ },
27
+ destroy() {
28
+ if (hand) hand.destroy();
29
+ face.destroy();
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Timing policy for the mouth track.
3
+ *
4
+ * Keep perceptual cleanup, cue presentation, and mouth response constants
5
+ * named by their job. Network delivery is deliberately absent: it cannot be
6
+ * corrected by shifting every cue on the utterance clock.
7
+ */
8
+
9
+ /** Cues shorter than this do not survive visual normalization. */
10
+ export const MIN_VISIBLE_CUE_MS = 30;
11
+
12
+ /** Cue timestamps render verbatim; the clock's epoch is supplied by the caller. */
13
+ export const CUE_TRACK_LEAD_MS = 0;
14
+
15
+ /** Extra time after a final silence cue before a general speech track ends. */
16
+ export const SPEECH_TRACK_TAIL_MS = 120;
17
+
18
+ /** Shorter completion tail for self-contained spoken interjection clips. */
19
+ export const INTERJECTION_TRACK_TAIL_MS = 60;
20
+
21
+ /** First-order response constants: lips settle before the jaw does. */
22
+ export const MOUTH_RESPONSE_TAU_S = 0.042;
23
+ export const JAW_RESPONSE_TAU_S = 0.07;
package/src/visemes.js CHANGED
@@ -23,6 +23,12 @@
23
23
  * the same viseme shouted and murmured should not look identical.
24
24
  */
25
25
 
26
+ import {
27
+ CUE_TRACK_LEAD_MS,
28
+ MIN_VISIBLE_CUE_MS,
29
+ SPEECH_TRACK_TAIL_MS,
30
+ } from './speech-timing.js';
31
+
26
32
  export const VISEME_SHAPES = {
27
33
  X: { mouthOpen: 0.02, mouthWidth: 0.42, mouthRound: 0.10, mouthPress: 0.15, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
28
34
  A: { mouthOpen: 0.00, mouthWidth: 0.40, mouthRound: 0.18, mouthPress: 0.55, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
@@ -63,7 +69,6 @@ export function shapeFor(letter, intensity = 1) {
63
69
  // ---------------------------------------------------------------------------
64
70
  // Cue track hygiene
65
71
  // ---------------------------------------------------------------------------
66
- const MIN_CUE_MS = 30; // shorter than this and the mouth just flutters
67
72
 
68
73
  /**
69
74
  * Sort, merge consecutive duplicates, and drop sub-perceptual cues. Servers
@@ -76,10 +81,20 @@ export function normalizeCues(cues) {
76
81
  const v = VISEME_SHAPES[c.v] ? c.v : SILENT;
77
82
  const prev = out[out.length - 1];
78
83
  if (prev && prev.v === v) continue; // merge repeats
79
- if (prev && c.t - prev.t < MIN_CUE_MS) {
84
+ if (prev && c.t - prev.t < MIN_VISIBLE_CUE_MS) {
80
85
  // Too short to read. Keep whichever is more visually salient: a closure
81
86
  // (A/G) carries more lip-reading information than a mid-open vowel.
82
- if (v === 'A' || v === 'G') out[out.length - 1] = { ...c, v };
87
+ if (v === 'A' || v === 'G') {
88
+ // The short cue can sit between two identical closures (G → F → G).
89
+ // Its replacement would otherwise create a duplicate visible shape;
90
+ // preserving the first G is both the stable wire form and the face the
91
+ // viewer actually saw.
92
+ if (out.length > 1 && out[out.length - 2].v === v) out.pop();
93
+ // A winning closure replaces the preceding shape for the entire
94
+ // sub-perceptual interval. Preserve that cue's timestamp while taking
95
+ // the closure's intensity, matching the server-side wire normalizer.
96
+ else out[out.length - 1] = { ...c, t: prev.t, v };
97
+ }
83
98
  continue;
84
99
  }
85
100
  out.push({ t: c.t, v, i: c.i == null ? 1 : c.i });
@@ -88,17 +103,18 @@ export function normalizeCues(cues) {
88
103
  }
89
104
 
90
105
  /**
91
- * Schedules a cue track against an audio clock.
106
+ * Schedules a cue track against an utterance clock.
92
107
  *
93
- * The clock must come from the audio itself (`audioEl.currentTime * 1000` or
94
- * `AudioContext.currentTime`), never from wall time — wall time drifts against
95
- * playback and you will spend the rest of your life chasing it.
108
+ * An audio-owned clock (`audioEl.currentTime * 1000` or
109
+ * `AudioContext.currentTime`) is the strongest source when the caller owns
110
+ * playback. The Pipecat adapter cannot see browser device playout; it supplies
111
+ * elapsed time from `botStartedSpeaking`, Pipecat's output-lifecycle epoch.
96
112
  *
97
- * LEAD_MS biases the mouth slightly ahead of the sound. Perceptually the
98
- * tolerance is asymmetric: roughly -45ms (audio first) to +125ms (video first),
99
- * so leading is the safe side to err on.
113
+ * There is deliberately no renderer-wide lead. Network/media skew cannot be
114
+ * corrected by moving every cue, and the predicted backend leg owns its own
115
+ * explicit prediction cushion.
100
116
  */
101
- export const LEAD_MS = 40;
117
+ export const LEAD_MS = CUE_TRACK_LEAD_MS;
102
118
 
103
119
  export class VisemeTrack {
104
120
  constructor() {
@@ -107,7 +123,7 @@ export class VisemeTrack {
107
123
  this.playing = false;
108
124
  this._idx = 0;
109
125
  this.onEnd = null;
110
- this.tailMs = 120; // how long past the last cue before we call it done
126
+ this.tailMs = SPEECH_TRACK_TAIL_MS;
111
127
  }
112
128
 
113
129
  /** @param {() => number} clock returns elapsed ms of the audio being played */
@@ -1,27 +0,0 @@
1
- /**
2
- * Avatar — a call-tile-ready wrapper around the widget, and the package's
3
- * whole public surface.
4
- *
5
- * <Avatar client={session.client} className="avatar-tile" />
6
- *
7
- * Two props of its own; everything else is forwarded to the mount `<div>`, so
8
- * it sizes and styles like the tile it lives in. There is nothing to configure
9
- * because there is nothing the server does not already say: the
10
- * `AvatarProcessor` in the pipeline drives the state, the gaze and the mouth.
11
- *
12
- * The widget has no hot-swap-avatar API — `createFace` runs once per mount —
13
- * so `avatar` is read once, at mount. To switch faces at runtime, remount with
14
- * a `key` prop:
15
- *
16
- * <Avatar key={name} avatar={name} client={session.client} />
17
- */
18
- import type { HTMLAttributes } from "react";
19
- import type { PipecatClient } from "@pipecat-ai/client-js";
20
- export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
21
- /** The live `PipecatClient`, or `null` before connect. */
22
- client?: PipecatClient | null;
23
- /** Which face. Omit for the default. Read at mount only — see above. */
24
- avatar?: string;
25
- }
26
- export declare function Avatar({ client, avatar, ...rest }: AvatarProps): import("react").JSX.Element;
27
- //# sourceMappingURL=Avatar.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"Avatar.d.ts","sourceRoot":"","sources":["../src/Avatar.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,MAAM,WAAW,WAAY,SAAQ,cAAc,CAAC,cAAc,CAAC;IACjE,0DAA0D;IAC1D,MAAM,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC9B,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EAAE,WAAW,+BAG9D"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"Avatar.js","sourceRoot":"","sources":["../src/Avatar.tsx"],"names":[],"mappings":";AAoBA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAS3C,MAAM,UAAU,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,EAAe;IAC7D,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvD,OAAO,cAAK,IAAI,EAAC,KAAK,gBAAY,QAAQ,KAAK,IAAI,EAAE,GAAG,EAAE,YAAY,GAAI,CAAC;AAC7E,CAAC"}