@voqalize/avatar 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
@@ -1,461 +0,0 @@
1
- # Contract A — server ↔ widget (the driving protocol)
2
-
3
- *Living document. Describes the code as of `src/avatar.js` on `main`; the
4
- [Direction](#direction) section flags what is about to change. The counterpart
5
- contract — what a face module owes the mixer — is
6
- [contract-avatar.md](contract-avatar.md).*
7
-
8
- The server is the source of truth. It decides what the agent is doing, feeling,
9
- saying and looking at, and tells the widget; the widget's only job is to look
10
- right while rendering that. Nothing in this contract lets the client decide
11
- call content, and nothing in it requires the server to know what a face looks
12
- like. The whole protocol is: **a state enum, an emotion enum, a gaze enum, an
13
- interjection id, a hand-gesture id, and a stream of timed viseme letters.**
14
-
15
- Everything below is reachable from one import:
16
-
17
- ```js
18
- import { createAvatar } from './src/avatar.js';
19
- const avatar = createAvatar({ mount: '#avatar' }); // also: avatar, face, theme, mouthGain, gestureGain
20
- ```
21
-
22
- All setters are chainable. Unknown state, interjection and gesture ids
23
- **throw**;
24
- unknown emotion falls back to `neutral` silently; unknown gaze falls back to
25
- `USER` silently.
26
-
27
- ## States — `setState(name, { emotion?, intensity?, gaze?, keepGaze? })`
28
-
29
- A state is a *condition*, not an event: it holds until replaced. Each state
30
- bundles a default gaze, emotion, idle-energy level and whether autonomous
31
- backchannel nods run. Passing `emotion`/`gaze` overrides the bundle;
32
- `keepGaze: true` preserves whatever gaze was already set.
33
-
34
- | state | send when | behaviour highlights |
35
- |---|---|---|
36
- | `IDLE` | nothing is happening | full idle motion, gaze on user |
37
- | `LISTENING` | the user is speaking | slight brow lift + eye widen; blink ~16/min; contingent backchannels + engagement lean when the user's voice is supplied (see below) |
38
- | `THINKING` | the agent is working out what to say | gaze breaks away **downward** (`AWAY_DOWN` — where measured cognitive aversion actually goes), wandering on the ~3.5 s aversion cadence with a return to the user about one dwell in four; thoughtful affect, slow blinks, faster/shallower breath, occasional dead-still holds |
39
- | `SPEAKING` | agent audio is playing | reduced idle sway so the head is stable while talking. `speak()` enters it automatically |
40
- | `REVIEWING_SCREEN` | the agent is reading the shared screen | gaze wanders across screen targets every 1.8–5 s |
41
- | `WAITING_FOR_USER` | the agent asked something and the floor is the user's | encouraging affect, raised brows, head tilt; backchannel on |
42
- | `TYPING` | the agent is busy doing something the user asked for | head pitched into work, gaze parked down-left on `SCREEN_WORK`, task-rate blinks (~9/min), shoulders raised and working in bursts; glances back up to the user every 4–7 s — the cue that they aren't forgotten |
43
- | `TYPING_CHAT` | the audio channel is broken and the agent is typing in chat to communicate | `TYPING`'s mechanics turned communicative: after each typing burst it looks up and **holds** on the user 1.2–2 s, expectant (chat is now the channel), vs `TYPING`'s brief ~0.8 s check-in; mouth pressed flat with a touch of browInner apology. Sequence it after `DEGRADED` — DEGRADED says "my feed is broken", this says "I'm working around it". They stay separate states |
44
- | `DISTRACTED` | the agent's attention is genuinely elsewhere | gaze wanders sideways/up targets, held long (2.8–6.8 s each), looser sway, **no backchannels** — the missing nod is as diagnostic as the look-away. The widget only looks away; deciding when to snap back is the server's call |
45
- | `SEARCHING_SCREEN` | filler while an async activity completes — "finding the right control". Server exits it when the activity is done | the hunt: search saccades every 0.8–2 s across screen targets with revisits (vs `REVIEWING_SCREEN`'s 1.8–5 s reading dwells), an occasional tiny "not this one" yaw flick, lowered brows, mouth pressed flat. Buys time while *visibly working on it* |
46
- | `CANT_HEAR` | the user's audio is soft / low-SNR and the agent is trying | the strongest lean the widget makes (the lean *is* the message), head cheated aside on `USER_EAR` so an ear favors the speaker while the eyes hold contact, concentration squint + lowered brows, frequent dead-still holds, **no backchannels** — you don't nod along to what you can't hear. Typically followed by `SORRY` or a "could you repeat" utterance. If the user's voice is supplied, the lean intensifies slightly while a faint voice is actually present |
47
- | `TAKING_FLOOR` | ~350 ms before agent audio starts | inbreath pose: shoulders rise, lips part, lean in |
48
- | `WANTS_IN` | the agent wants the floor but won't barge in | stronger, *stiller* version of the same bid — holds until the user notices |
49
- | `YIELDED` | the user barged in and the agent stopped | recoil: lean back, shoulders drop; pair with the `YIELD_FLOOR` interjection which delivers the fast mouth-shut |
50
- | `DEGRADED` | the connection/pipeline is impaired | drowsy lids + desaturating CSS filter |
51
- | `OFFLINE` | the agent is gone | lids nearly shut, grayscale |
52
-
53
- The `state` enum is exported as `STATE_NAMES`, and every state's full recipe as
54
- `STATES`.
55
-
56
- ## Emotion — `setEmotion(name, intensity = 1)`
57
-
58
- Affect is a separate axis from state, so the enums don't multiply.
59
- Six values (`EMOTION_NAMES`): `neutral`, `warm`, `curious`, `concerned`,
60
- `encouraging`, `thoughtful`. `intensity` scales the pose linearly toward
61
- neutral; it is not clamped, but past ~1.3 poses saturate against channel
62
- clamps. Entering a state *adopts that state's default emotion* unless you pass
63
- one explicitly.
64
-
65
- ## Gaze — `setGaze(name, custom?)`
66
-
67
- Semantic directions; the client does the oculomotor work (ballistic eyes,
68
- lagging under-rotated head, gaze-evoked blink). Twelve names (`GAZE_NAMES`):
69
-
70
- | target | meaning |
71
- |---|---|
72
- | `USER` | down the webcam barrel — the conversational default |
73
- | `USER_EAR` | still on the user, head cheated aside so an ear favors the speaker — the "trying to hear you" attitude. Head-follow and pupils point opposite ways, which is what keeps it reading as contact |
74
- | `SCREEN_CENTER` / `SCREEN_LEFT` / `SCREEN_RIGHT` / `SCREEN_TOP` / `SCREEN_BOTTOM` | regions of the shared screen |
75
- | `SCREEN_WORK` | lower-left work area of the shared screen |
76
- | `NOTES` | down-right glance at the agent's own notes |
77
- | `AWAY_THINKING` | up-left "recalling" break of eye contact — the stylized "let me think" beat |
78
- | `AWAY_RIGHT` | up-right variant |
79
- | `AWAY_DOWN` | down-left considering — measured cognitive aversion is mostly downward, so this is the one long THINKING dwells use |
80
-
81
- Escape hatch: `setGaze('CUSTOM', { x, y })` with normalized −1..1 screen
82
- coordinates, for when the server knows exactly where something is. (Any name
83
- plus a `custom` object works; the coordinates win.)
84
-
85
- **Floor-passing rule (server-side):** do not command a gaze aversion in the
86
- final ~2.4 s of the agent's own utterance. Human speakers return to mutual
87
- gaze before they stop talking; an agent that ends its turn looking away fails
88
- to pass the floor, and the user sits waiting for a signal that never comes.
89
-
90
- Gaze is also set implicitly by states (table above) and temporarily by a few
91
- interjections (`HMM`, `ONE_MOMENT`); a clip's gaze wins for the clip's
92
- duration, then releases.
93
-
94
- ## Interjections — `interject(id)`
95
-
96
- One-shot gesture clips with baked plausible timings, so they are convincing
97
- with **no audio attached**. `attachAudio(id, url)` upgrades any of them with a
98
- real TTS clip later; the clip then clocks itself off that audio. Durations
99
- below are the baked defaults, in ms. Full list: `INTERJECTION_IDS`; the 12 with
100
- spoken text: `SPOKEN_IDS`.
101
-
102
- **Wordless backchannel** — fire while the user talks; these are also fired
103
- autonomously in `LISTENING`/`WAITING_FOR_USER` by the listening engine (see
104
- [The user's voice](#the-users-voice--setuseraudiosource-setuserspeakingbool)):
105
- contingently on the user's pauses when the host supplies a voice signal, on a
106
- loose 3.4–8 s timer when it never does. The picker answers what the user just
107
- did: mid-speech stays minimal (70% `NOD_SMALL` / 30% `BROW_ACK`); a pause
108
- after a *short* utterance draws from continuer country (55% `NOD_SMALL`, 25%
109
- `BROW_ACK`, 20% `NOD_SLOW`); a pause after a **long** utterance (≥4 s) earns
110
- the assessment class (45% `NOD_SLOW`, 20% `NOD_UP`, 20% `NOD_SMALL`, 15%
111
- `BROW_ACK`). The three nods are the mocap taxonomy — continuer / assessment /
112
- realization — with authored cycle decay, and all sit under the 1.5 Hz line
113
- where a nod flips from attention to impatience.
114
-
115
- | id | dur | intent |
116
- |---|---|---|
117
- | `NOD_SMALL` | 800 | single-cycle continuer — "go on" |
118
- | `NOD_SLOW` | 1420 | two-beat assessment — first beat deepest, second decayed; "I agree" |
119
- | `NOD_UP` | 1750 | realization — rises *before* it commits, brows leading; "ah, I see" |
120
- | `BROW_ACK` | 720 | eyebrow acknowledgement, no head commitment |
121
- | `HEAD_SHAKE` | 1350 | firm "no" — two decaying yaw cycles (~1.5 Hz), lowered brows, mouth firmed flat. **Server-sent only**: disagreement is never autonomous |
122
- | `HEAD_SHAKE_SOFT` | 1700 | polite "hmm, not quite" — slower cycle-and-a-half at smaller amplitude, sympathetic head tilt, knit brows: sorry to be disagreeing. **Server-sent only**, same rule |
123
- | `BLINK_LONG` | 850 | deliberate ~600 ms blink + barely-there nod: "that's noted — move on". **Server-sent only, never autonomous** — it measurably shortens what the user says next, so send it as a policy decision, not a reflex |
124
-
125
- **Floor management** — pair with the floor states.
126
-
127
- | id | dur | intent |
128
- |---|---|---|
129
- | `CLAIM_FLOOR` | 480 | visible inhale; fire ~350 ms before audio starts. Ends *held*, not resolved |
130
- | `YIELD_FLOOR` | 420 | interrupted: lips shut within ~50 ms, recoil |
131
- | `RAISE_HAND` | 1600 | "may I come in" — long held plateau is the message |
132
-
133
- **Re-authored gestures** — these ids predate the arm removal and keep their
134
- wire meaning, now performed by face, shoulders and torso.
135
-
136
- | id | dur | intent |
137
- |---|---|---|
138
- | `WAVE` | 1300 | greeting (eyebrow flash) |
139
- | `THUMBS_UP` | 1500 | approval (deep slow nod + broad smile) |
140
- | `SHRUG` | 1250 | don't-know (shoulders to maximum, held) |
141
- | `GO_ON_ARM` | 1400 | emphatic "go on" |
142
-
143
- **Spoken** — carry text and a hand-tuned viseme track; silent but plausible
144
- until `attachAudio` gives them a voice.
145
-
146
- | id | dur | | id | dur |
147
- |---|---|---|---|---|
148
- | `MM_HMM` | 820 | | `GO_ON` | 820 |
149
- | `OKAY` | 860 | | `ONE_MOMENT` | 1350 |
150
- | `YES` | 740 | | `SORRY` | 1050 |
151
- | `SURE` | 860 | | `HMM` | 1250 |
152
- | `I_SEE` | 1050 | | `GOT_IT` | 820 |
153
- | `RIGHT` | 740 | | `TAKE_YOUR_TIME` | 1500 |
154
-
155
- A repeated `interject(id)` while that clip is already playing is collapsed to a
156
- no-op; a *different* id replaces the running clip immediately.
157
-
158
- ## Hand gestures — `gesture(id)`
159
-
160
- A hand rising into the bottom of the frame, plus the face half that makes it
161
- belong to somebody. **This is a separate verb from `interject` on purpose.**
162
- The four ids below are disjoint from `INTERJECTION_IDS`, and `interject('WAVE')`
163
- still means exactly what it always meant — the face alone. A server that
164
- upgrades the widget gets no hand until it asks for one.
165
-
166
- | id | dur | what it does |
167
- |---|---|---|
168
- | `HI` | 1250 | open palm rises and waves — greeting. Face half: `WAVE` |
169
- | `BYE` | 1550 | the same wave, one swing longer and a touch slower — parting. Face half: `WAVE` |
170
- | `THUMBS_UP` | 1300 | fist, back of hand to camera, thumb up — approval. Face half: `THUMBS_UP` |
171
- | `ONE_MOMENT` | 1700 | a single raised index finger, held — "one moment". Face half: `ONE_MOMENT`, which speaks |
172
-
173
- Calling `gesture(id)` fires the face half as an `interject()` on the caller's
174
- behalf — do **not** send both; the second one replaces the first mid-clip. It
175
- also suppresses autonomous backchannels for the gesture's duration plus 500 ms,
176
- because a nod landing on top of a deliberate hand is the listening engine
177
- talking over the server.
178
-
179
- What the widget guarantees, and why it is stated here rather than left to the
180
- drawing: **nothing but a single digit ever passes the mouth.** Mouth sync is
181
- the headline feature, so a gesture is free to fire mid-speech. The hand also
182
- never leaves the frame sideways and never shows a wrist — see
183
- `docs/contract-avatar.md` § The hand for the rules and the per-avatar check.
184
-
185
- Degradation is total and silent. An avatar mounted with `hand: false` — a face
186
- drawn in some other idiom, or a tile too small to spend the pixels — plays the
187
- face half and nothing else, which is the same fallback every id already had
188
- before the hand existed. `api.gesturing` is the id in flight, or `null` — which
189
- is what it always reads under `hand: false`, and `gestureEnd` correspondingly
190
- never fires there: both describe the *hand*, and there is no hand. An unknown
191
- id throws, as `interject` does.
192
-
193
- `setHandSide(+1 | -1)` picks which side of the frame the hand enters from;
194
- `+1` (the viewer's right) is the default.
195
-
196
- ## Speech — `speak({ cues, audio?, clock? })`, `pushCues(cues)`, `stopSpeaking()`
197
-
198
- The headline feature. A **cue** is:
199
-
200
- ```js
201
- { t: 1234, // ms offset into the utterance
202
- v: 'D', // Rhubarb letter A–H, or X for silence
203
- i: 0.8 } // optional 0..1 loudness; omit for 1
204
- ```
205
-
206
- Letters are the Rhubarb Lip Sync alphabet (a condensation of the Preston Blair
207
- set): `A` closed (P/B/M), `B` teeth together (most consonants), `C` open,
208
- `D` wide open, `E` rounded, `F` puckered, `G` lip-to-teeth (F/V), `H` tongue up
209
- (L), `X` silence. Exported: `VISEME_LETTERS`, `VISEME_SHAPES`.
210
-
211
- Rules the widget enforces (`normalizeCues`, applied to every track):
212
-
213
- - cues are sorted by `t`; consecutive duplicates merge;
214
- - cues shorter than **30 ms** are dropped — except that a closure (`A`/`G`)
215
- replaces the cue it collapses into, because closures carry the most
216
- lip-reading information;
217
- - unknown letters become `X`.
218
-
219
- So the server may emit noisy tracks; it should still try to end every
220
- utterance with an explicit `X` cue (the track only completes on a trailing
221
- `X`).
222
-
223
- **Clock.** Cues are scheduled against the *audio clock*, never wall time.
224
- Pass `audio` (an `HTMLMediaElement`; the widget uses `currentTime` and will
225
- call `play()` if paused) or `clock` (a `() => ms` function, for WebAudio or
226
- server-driven time). Wall time is the fallback only when neither is given.
227
- The mouth runs **40 ms ahead** of the clock (`LEAD_MS`): perceptual tolerance
228
- is asymmetric (about −45 ms audio-first to +125 ms video-first), so leading is
229
- the safe side.
230
-
231
- **Streaming.** `pushCues(cues)` appends mid-utterance — send cues in chunks as
232
- TTS produces them; the merged track is re-normalized each push.
233
-
234
- `speak()` auto-enters `SPEAKING` (keeping the current gaze) and kills any
235
- spoken interjection in flight. `speakEnd` fires when the track completes.
236
-
237
- ## The user's voice — `setUserSpeaking(bool)`
238
-
239
- Backchannels only create rapport when they are *contingent* on the speaker —
240
- identical nods on a timer measurably read as distracting
241
- (docs/research-biomechanics.md §3.5). Tell the widget when the user holds the
242
- floor and the listening engine does the rest.
243
-
244
- `setUserSpeaking(bool)` is the whole input side: the turn signal from the
245
- server's endpointer, which is the one that already decides where a turn ends.
246
- Pass `null` to hand back. The widget used to be able to derive this itself from
247
- the user's `MediaStream` (`setUserAudio`), a second VAD racing the server's —
248
- `docs/removed.md` § Client-side VAD.
249
-
250
- While `LISTENING`/`WAITING_FOR_USER` with a signal supplied: an
251
- acknowledgement fires 250–600 ms after a user pause onset, on about half of
252
- pauses, never more often than every 2.5 s; long unbroken user speech earns a
253
- rare minimal mid-speech nod; and the avatar leans in (`torsoLean` up to
254
- +0.16) while the user holds the floor, relaxing after ~8 s of silence. Every
255
- autonomous fire is announced via `on('backchannel', id)`, so a server can log
256
- exactly what the widget did on its own.
257
-
258
- If neither method is ever called, the engine falls back to the loose 3.4–8 s
259
- timer — a worse listener, never a dead one. Server-driven `interject()` calls
260
- push the autonomous scheduler out (min 2.5 s), so the two sources never pile
261
- up.
262
-
263
- ### The mouth priority rule (invariant)
264
-
265
- **Server viseme track > clip mouth track.** While a server track plays, it
266
- owns the mouth outright: an interjection fired mid-utterance contributes head
267
- and brows only, and its mouth track is dropped. There used to be a third tier —
268
- an amplitude/spectral guesser off the bot's own audio element, for a server
269
- that sent no cues at all (`docs/removed.md` § Amplitude lipsync). Anything that
270
- degrades this ordering is a regression.
271
-
272
- ## Events, gains, introspection
273
-
274
- - `on('state', fn)` — state changed (fires with the new name)
275
- - `on('speakEnd', fn)` — cue track completed
276
- - `on('clipEnd', fn)` — interjection finished (fires with its id)
277
- - `on('backchannel', fn)` — the listening engine fired an acknowledgement on
278
- its own (fires with the interjection id)
279
- - `on('performEnd', fn)` — a performance's last action has fired (see
280
- *Composing behavior*)
281
- - `on('gestureEnd', fn)` — the hand has left the frame (fires with the gesture
282
- id). Only where a hand is mounted — see *Hand gestures*
283
- - `setMouthGain(g)` — scales viseme excursion away from rest (1 = as authored;
284
- useful when the avatar renders small). Never drags a closed mouth open.
285
- - `setGestureGain(g)` — scales clip deltas; small gestures under-render
286
- through the head's smoothing, and this is the knob that compensates.
287
- - `setMotionGain(g)` — scales the idle liveness layer as a whole: breath,
288
- sway, postural weight shifts, the body's share of speech emphasis. A host
289
- rendering the avatar into a small tile, or one that re-encodes it into a
290
- video stream where motion costs bitrate, can turn it down; 0 freezes the
291
- body without freezing blinks, gaze or visemes. Where "alive" stops and
292
- "fidgety" starts moves with tile size and with the audience, so this is
293
- deliberately a host decision rather than a constant.
294
- - Getters: `state`, `emotion`, `gaze`, `speaking`, `performing`, `clip`,
295
- `gesturing`, `params` (the live smoothed vector), `svg`.
296
- - `setOverrides({channel: value})` — direct parameter injection, post-clamp.
297
- For tuning UIs and tests, not production.
298
- - `blink(double?)`, `destroy()`.
299
-
300
- ## Producing cues server-side
301
-
302
- Three tiers, best first — full recipes with code are in
303
- [README.md § Getting mouth shapes out of speech](../README.md):
304
-
305
- 1. **Native TTS viseme events** (Azure et al.): map integer viseme ids through
306
- `AZURE_VISEME_TO_LETTER`, ship `{t, v}` as they stream. Nearly free.
307
- 2. **Forced alignment** (any TTS): phonemize + align (MFA, gentle, or
308
- `rhubarb-lip-sync` directly — our letters *are* Rhubarb's), then map ARPAbet
309
- through `ARPABET_TO_VISEME`.
310
- 3. **No server work**: the client amplitude fallback, or `textToCues(text)` —
311
- a crude grapheme guesser fit for previews only.
312
-
313
- `experiments/rhubarb-textsync/` derived letters from *text* before audio
314
- exists, to keep model-init cost off the time-to-first-audio path. It graduated:
315
- the production form is `native/avatarsync/`, one resident binary serving both a
316
- ~0.2 ms text leg and a ~15–35 ms warm audio-recognition leg, driven by
317
- `voqalize-avatar` (see *The reference backend*).
318
-
319
- ## Composing behavior: `perform(actions, { audio?, clock?, onAction? })`
320
-
321
- The composition surface. A **performance** is a list of timed verbs fired
322
- against a clock; each verb resolves to one of the enums above. This is how a
323
- backend assembles a turn: it sequences from a constrained vocabulary and
324
- cannot invent motion — every wire-visible move is something that was authored
325
- and tuned on the rig. A backend wanting a new move asks for a new enum entry
326
- (a state, an interjection), never for a channel-level escape hatch.
327
-
328
- ```js
329
- { "t": 4200, "do": "emotion", "name": "warm", "i": 0.8 }
330
- { "t": 5100, "do": "gaze", "name": "SCREEN_WORK" }
331
- { "t": 6300, "do": "interject", "id": "NOD_SMALL" }
332
- { "t": 8000, "do": "state", "name": "WAITING_FOR_USER" }
333
- ```
334
-
335
- | verb | args | dispatches to |
336
- |---|---|---|
337
- | `state` | `name`, `keepGaze?` (default **true**) | `setState(name, {keepGaze})` |
338
- | `emotion` | `name`, `i?` 0..1 (default 1) | `setEmotion(name, i)` |
339
- | `gaze` | `name` | `setGaze(name)` |
340
- | `interject` | `id` | `interject(id)` |
341
- | `gesture` | `id` | `gesture(id)` — the hand *and* its face half |
342
-
343
- The natural unit a server assembles is audio + cue track + action track on
344
- **one clock** (`demo/perf-clips.json` scripts every demo turn this way, and
345
- `demo/floor.js` plays them through this API):
346
-
347
- ```js
348
- avatar.speak({ cues, audio }); // the utterance
349
- avatar.perform(turn.beats, { audio }); // its choreography, same clock
350
- ```
351
-
352
- Rules:
353
-
354
- - **Clock** resolves like `speak()`: explicit `clock` fn > `audio.currentTime`
355
- > ms elapsed since the call. Ride the audio element you speak with.
356
- `perform` never starts or stops audio — `speak` owns the sound.
357
- - **Times fire verbatim** — no `LEAD_MS`. Visemes lead the audio because
358
- phoneme sync is frame-critical; gestures arrive through their channels'
359
- smoothing lag, and a deliberate lead (`CLAIM_FLOOR` ~350 ms before the first
360
- sample) is authored into `t` by the composer.
361
- - **There is no `speak` verb.** Speech defines the clock a performance rides
362
- on; a timeline that could start new audio would be a clock inside a clock,
363
- and stopping it would have to answer for the cue track too. The utterance
364
- and its choreography stay sibling calls against the same element.
365
- - **`state` defaults to `keepGaze: true`** inside a performance: a timeline
366
- that wants the gaze moved says so with a `gaze` verb at the moment it means.
367
- - **Hygiene** (`normalizeActions`, exported): actions are sorted by `t`;
368
- entries with no finite `t`, an unknown verb, or a missing `name`/`id` are
369
- dropped with a console warning. Enum values are checked when the verb
370
- *fires*: a bad one warns and is skipped. A malformed action never breaks the
371
- performance around it.
372
- - A new `perform()` replaces the running one. The returned handle's `stop()`
373
- cancels **future actions only** — an in-flight interjection finishes, a live
374
- cue track is untouched — and `performEnd` does not fire. A handle whose
375
- performance was already replaced is a no-op.
376
- - `on('performEnd')` fires when the last action has *fired*, not when its
377
- effects (a still-playing interjection, say) finish rendering.
378
- - `onAction(a)` is called after each verb dispatches — the telemetry/log hook;
379
- the demo's token stream uses it.
380
- - Seeking the audio backward does not re-fire earlier actions.
381
-
382
- ## The reference backend — `voqalize-avatar`
383
-
384
- This contract has a living server implementation in this repo: the `py/`
385
- package (`pip install voqalize-avatar`), a pipecat `FrameProcessor` that infers
386
- the base states from stock frames and emits the envelope below as RTVI
387
- server-messages. Design and rationale:
388
- [design-library-split.md](design-library-split.md). A host driving the widget
389
- through that stack never calls the API above directly; it renders
390
- `<Avatar client={pipecatClient} />` from `@voqalize/avatar`, and the dispatcher
391
- inside it turns these messages into the calls above:
392
-
393
- ```json
394
- { "type": "avatar", "cmd": "state", "name": "THINKING" }
395
- ```
396
-
397
- `{"type": "avatar"}` is the whole membership test — an RTVI server-message in
398
- that envelope is the avatar's, and one outside it is not, whoever sent it.
399
- There is no protocol version field; forward compatibility is the ignore-unknown
400
- rule below, which a version number would not have improved
401
- (`docs/removed.md` § The `v` field).
402
-
403
- | `cmd` | payload → widget call |
404
- |---|---|
405
- | `state` | `name`, `emotion?`, `gaze?` → `setState` |
406
- | `interject` | `id` → `interject` |
407
- | `gesture` | `id` → `gesture` (a hand gesture id, not an interjection id) |
408
- | `cues` | `ctx`, `from_ms`, `cues`, `final?` → splice, then `speak`/`pushCues` |
409
- | `speech` | `event: start\|stop`, `ctx` → anchor / release the turn clock |
410
- | `user` | `speaking` → `setUserSpeaking` |
411
-
412
- That is the whole wire vocabulary. `perform` (a timeline as one message) and
413
- `hint` (an advisory with no rendering) were both on it and are not any more —
414
- `docs/removed.md` § The `perform` command and § The `hint` command. `perform()`
415
- itself is untouched; what went away is a *server* being able to send one.
416
-
417
- Semantics the envelope adds on top of this contract:
418
-
419
- - **The splice.** `speak()` replaces and `pushCues()` only appends, so tail
420
- replacement is the *driver's* job: the client wrapper keeps the turn's
421
- canonical track, discards queued cues with `t >= from_ms`, appends, and
422
- re-issues `speak()` on the turn's original clock when anything was
423
- discarded. This is how the server's fast text-predicted cues are overwritten
424
- by audio-recognized ones mid-turn without the widget ever seeing a seam.
425
- - **The anchor.** `speech start` (sent on the server's playout-true
426
- bot-started-speaking signal) sets the turn's t=0; the cue clock is
427
- `performance.now() - t0`. The data channel beats jitter-buffered audio, so
428
- residual error lands video-first — the +125 ms side of the asymmetric
429
- tolerance window, not the −45 ms one.
430
- - **Explicit instructions override heuristics.** An application that knows
431
- something the pipeline cannot infer pushes the same envelope from its own
432
- code; it dispatches into the same handler. The backend's state heuristics are
433
- the default layer underneath, not a competing one.
434
- - Unknown `cmd`s are ignored — the server may grow vocabulary ahead of
435
- deployed clients.
436
-
437
- ## Direction
438
-
439
- Agreed direction, not yet landed; backend work can anticipate it:
440
-
441
- - More compound application states in the `TYPING`/`DISTRACTED` mould as
442
- applications need them — same `setState` surface, one STATES entry each.
443
- - Backend heuristics for the states still unmapped server-side: `CANT_HEAR`
444
- from the STT's own confidence signal plus user volume; `DISTRACTED`,
445
- `SEARCHING_SCREEN` and `TYPING` from tool-call names. A tool call shows
446
- `THINKING` today and only that — an application that wants to distinguish its
447
- own tools subclasses `AvatarStateMachine` and says so.
448
-
449
- For servers written against a pre-2026-08 version of this contract, the
450
- renames were: `createKiran`→`createAvatar`, gaze `CANDIDATE`→`USER` and
451
- `CODE_AREA`→`SCREEN_WORK`, state `WAITING_FOR_ANSWER`→`WAITING_FOR_USER`.
452
- Everything else in this document — the descriptor (`api.meta`), `perform()`,
453
- the listening engine, the compound states, the disagree family and the hand
454
- gestures — landed 2026-08 and is current. `gesture` is the newest verb
455
- (2026-08-07); a widget older than it ignores the `cmd` and drops the
456
- `perform()` verb with a warning, which is the forward-compat rule working as
457
- intended, so a backend may send it unconditionally.
458
-
459
- The 0.2 release cut the wire down to the six commands in the table and the
460
- package down to one React component. Everything it removed, and how to get any
461
- of it back, is [removed.md](removed.md).