@voqalize/avatar 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/LICENSE +21 -661
  2. package/README.md +144 -663
  3. package/client/Avatar.tsx +57 -0
  4. package/client/AvatarClient.ts +523 -0
  5. package/client/arjun.ts +24 -0
  6. package/client/createAvatar.ts +139 -0
  7. package/client/createCanvasAvatar.ts +72 -0
  8. package/client/index.ts +39 -0
  9. package/client/internal.ts +80 -0
  10. package/client/interviewer-female.ts +4 -0
  11. package/client/interviewer-male.ts +4 -0
  12. package/client/ishita.ts +24 -0
  13. package/client/kabir.ts +24 -0
  14. package/client/meera.ts +24 -0
  15. package/client/naina.ts +24 -0
  16. package/client/professional-female-a.ts +4 -0
  17. package/client/professional-female-b.ts +4 -0
  18. package/client/professional-male-a.ts +4 -0
  19. package/client/professional-male-b.ts +4 -0
  20. package/client/react.ts +13 -0
  21. package/client/types.ts +146 -0
  22. package/client/useAvatar.ts +74 -0
  23. package/client/vikram.ts +24 -0
  24. package/dist/Avatar.d.ts +44 -0
  25. package/dist/Avatar.d.ts.map +1 -0
  26. package/{client/dist → dist}/Avatar.js +2 -2
  27. package/dist/Avatar.js.map +1 -0
  28. package/dist/AvatarClient.d.ts +196 -0
  29. package/dist/AvatarClient.d.ts.map +1 -0
  30. package/dist/AvatarClient.js +450 -0
  31. package/dist/AvatarClient.js.map +1 -0
  32. package/dist/arjun.d.ts +6 -0
  33. package/dist/arjun.d.ts.map +1 -0
  34. package/dist/arjun.js +18 -0
  35. package/dist/arjun.js.map +1 -0
  36. package/dist/createAvatar.d.ts +99 -0
  37. package/dist/createAvatar.d.ts.map +1 -0
  38. package/dist/createAvatar.js +75 -0
  39. package/dist/createAvatar.js.map +1 -0
  40. package/dist/createCanvasAvatar.d.ts +22 -0
  41. package/dist/createCanvasAvatar.d.ts.map +1 -0
  42. package/dist/createCanvasAvatar.js +47 -0
  43. package/dist/createCanvasAvatar.js.map +1 -0
  44. package/dist/index.d.ts +30 -0
  45. package/dist/index.d.ts.map +1 -0
  46. package/dist/index.js +29 -0
  47. package/dist/index.js.map +1 -0
  48. package/dist/internal.d.ts +30 -0
  49. package/dist/internal.d.ts.map +1 -0
  50. package/dist/internal.js +33 -0
  51. package/dist/internal.js.map +1 -0
  52. package/dist/interviewer-female.d.ts +4 -0
  53. package/dist/interviewer-female.d.ts.map +1 -0
  54. package/dist/interviewer-female.js +3 -0
  55. package/dist/interviewer-female.js.map +1 -0
  56. package/dist/interviewer-male.d.ts +4 -0
  57. package/dist/interviewer-male.d.ts.map +1 -0
  58. package/dist/interviewer-male.js +3 -0
  59. package/dist/interviewer-male.js.map +1 -0
  60. package/dist/ishita.d.ts +6 -0
  61. package/dist/ishita.d.ts.map +1 -0
  62. package/dist/ishita.js +18 -0
  63. package/dist/ishita.js.map +1 -0
  64. package/dist/kabir.d.ts +6 -0
  65. package/dist/kabir.d.ts.map +1 -0
  66. package/dist/kabir.js +18 -0
  67. package/dist/kabir.js.map +1 -0
  68. package/dist/meera.d.ts +6 -0
  69. package/dist/meera.d.ts.map +1 -0
  70. package/dist/meera.js +18 -0
  71. package/dist/meera.js.map +1 -0
  72. package/dist/naina.d.ts +6 -0
  73. package/dist/naina.d.ts.map +1 -0
  74. package/dist/naina.js +18 -0
  75. package/dist/naina.js.map +1 -0
  76. package/dist/professional-female-a.d.ts +4 -0
  77. package/dist/professional-female-a.d.ts.map +1 -0
  78. package/dist/professional-female-a.js +3 -0
  79. package/dist/professional-female-a.js.map +1 -0
  80. package/dist/professional-female-b.d.ts +4 -0
  81. package/dist/professional-female-b.d.ts.map +1 -0
  82. package/dist/professional-female-b.js +3 -0
  83. package/dist/professional-female-b.js.map +1 -0
  84. package/dist/professional-male-a.d.ts +4 -0
  85. package/dist/professional-male-a.d.ts.map +1 -0
  86. package/dist/professional-male-a.js +3 -0
  87. package/dist/professional-male-a.js.map +1 -0
  88. package/dist/professional-male-b.d.ts +4 -0
  89. package/dist/professional-male-b.d.ts.map +1 -0
  90. package/dist/professional-male-b.js +3 -0
  91. package/dist/professional-male-b.js.map +1 -0
  92. package/dist/react.d.ts +13 -0
  93. package/dist/react.d.ts.map +1 -0
  94. package/dist/react.js +13 -0
  95. package/dist/react.js.map +1 -0
  96. package/dist/types.d.ts +94 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +93 -0
  99. package/dist/types.js.map +1 -0
  100. package/dist/useAvatar.d.ts +39 -0
  101. package/dist/useAvatar.d.ts.map +1 -0
  102. package/dist/useAvatar.js +35 -0
  103. package/dist/useAvatar.js.map +1 -0
  104. package/dist/vikram.d.ts +6 -0
  105. package/dist/vikram.d.ts.map +1 -0
  106. package/dist/vikram.js +18 -0
  107. package/dist/vikram.js.map +1 -0
  108. package/package.json +81 -24
  109. package/src/avatar.d.ts +161 -120
  110. package/src/avatar.js +234 -146
  111. package/src/behavior.d.ts +26 -0
  112. package/src/behavior.js +102 -0
  113. package/src/camera.js +29 -0
  114. package/src/canvas/author/parts/eye.mjs +722 -0
  115. package/src/canvas/author/parts/hand.mjs +1156 -0
  116. package/src/canvas/author/parts/mouth.mjs +741 -0
  117. package/src/canvas/author/parts/nose.mjs +100 -0
  118. package/src/canvas/author/parts/skin-detail.mjs +67 -0
  119. package/src/canvas/author/path.mjs +283 -0
  120. package/src/canvas/author/rig.mjs +405 -0
  121. package/src/canvas/avatars/round/face.d.mts +3 -0
  122. package/src/canvas/avatars/round/face.mjs +1307 -0
  123. package/src/canvas/create-rig.d.ts +15 -0
  124. package/src/canvas/create-rig.js +100 -0
  125. package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
  126. package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
  127. package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
  128. package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
  129. package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
  130. package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
  131. package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
  132. package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
  133. package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
  134. package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
  135. package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
  136. package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
  137. package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
  138. package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
  139. package/src/canvas/data/img/round-m3-top-body.webp +0 -0
  140. package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
  141. package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
  142. package/src/canvas/data/img/round-w1-top-body.webp +0 -0
  143. package/src/canvas/data/interviewer-female.rig.json +1 -0
  144. package/src/canvas/data/interviewer-male.rig.json +1 -0
  145. package/src/canvas/data/professional-female-a.rig.json +1 -0
  146. package/src/canvas/data/professional-female-b.rig.json +1 -0
  147. package/src/canvas/data/professional-male-a.rig.json +1 -0
  148. package/src/canvas/data/professional-male-b.rig.json +1 -0
  149. package/src/canvas/src/live.js +508 -0
  150. package/src/canvas/src/render2d.js +218 -0
  151. package/src/canvas/src/rig.js +297 -0
  152. package/src/canvas/src/vocab.js +96 -0
  153. package/src/clips.js +18 -7
  154. package/src/conformance.js +119 -0
  155. package/src/emotions.js +4 -3
  156. package/src/face-core.js +27 -1
  157. package/src/face-myna.d.ts +7 -0
  158. package/src/face-myna.js +192 -131
  159. package/src/face-peep-control-plane.js +167 -0
  160. package/src/face-peep.d.ts +7 -0
  161. package/src/face-peep.js +274 -116
  162. package/src/face-wren.d.ts +7 -0
  163. package/src/face-wren.js +17 -19
  164. package/src/faces.d.ts +12 -0
  165. package/src/faces.js +53 -0
  166. package/src/gaze.js +119 -4
  167. package/src/hand.js +120 -91
  168. package/src/idle.js +18 -101
  169. package/src/interjections.js +156 -31
  170. package/src/params.js +8 -4
  171. package/src/perform.js +7 -9
  172. package/src/rig.d.ts +24 -0
  173. package/src/rig.js +32 -0
  174. package/src/speech-timing.js +23 -0
  175. package/src/visemes.js +28 -12
  176. package/client/dist/Avatar.d.ts +0 -27
  177. package/client/dist/Avatar.d.ts.map +0 -1
  178. package/client/dist/Avatar.js.map +0 -1
  179. package/client/dist/AvatarClient.d.ts +0 -139
  180. package/client/dist/AvatarClient.d.ts.map +0 -1
  181. package/client/dist/AvatarClient.js +0 -223
  182. package/client/dist/AvatarClient.js.map +0 -1
  183. package/client/dist/index.d.ts +0 -22
  184. package/client/dist/index.d.ts.map +0 -1
  185. package/client/dist/index.js +0 -22
  186. package/client/dist/index.js.map +0 -1
  187. package/client/dist/types.d.ts +0 -86
  188. package/client/dist/types.d.ts.map +0 -1
  189. package/client/dist/types.js +0 -31
  190. package/client/dist/types.js.map +0 -1
  191. package/client/dist/useAvatar.d.ts +0 -42
  192. package/client/dist/useAvatar.d.ts.map +0 -1
  193. package/client/dist/useAvatar.js +0 -51
  194. package/client/dist/useAvatar.js.map +0 -1
  195. package/client/src/Avatar.tsx +0 -33
  196. package/client/src/AvatarClient.ts +0 -270
  197. package/client/src/index.ts +0 -22
  198. package/client/src/types.ts +0 -104
  199. package/client/src/useAvatar.ts +0 -85
  200. package/docs/contract-avatar.md +0 -371
  201. package/docs/contract-protocol.md +0 -461
package/README.md CHANGED
@@ -1,75 +1,53 @@
1
- # Avatar — a programmable 2-D talking head
1
+ # @voqalize/avatar
2
2
 
3
- A dependency-free SVG avatar for AI agents on voice calls. The agent can wear
4
- any face and go by any name — the demos call theirs Kiran. The server owns
5
- meaning (what state the agent is in, what it's saying, where it's looking); the
6
- client owns motion (how a face actually moves when it means those things).
3
+ A 2-D talking head for AI voice calls, driven by your pipecat client. The
4
+ avatars are lip-synced to the audio and they are state aware: they know when
5
+ they have been interrupted, when the user is talking versus idle, when a tool
6
+ call has started and stopped.
7
7
 
8
- No build step, no runtime dependencies, ~270KB of ES modules — a third of that
9
- is the three face rigs, and a host that ships one face pays for one.
8
+ No video track, no per-minute avatar vendor, no second media path. Three SVG
9
+ faces and six professional Canvas2D avatars ship with it — one identity per
10
+ entry point, so you pay for the one you import — and you can author your own.
10
11
 
11
- The two load-bearing interfaces are specified in
12
- [docs/contract-protocol.md](docs/contract-protocol.md) (server ↔ widget) and
13
- [docs/contract-avatar.md](docs/contract-avatar.md) (mixer ↔ face).
12
+ This is the browser half. The pipeline half is
13
+ [`voqalize-avatar`](https://pypi.org/project/voqalize-avatar/) on PyPI; they are
14
+ two ends of one wire format and publish in lockstep from one tag, because a
15
+ version pair that can drift is a protocol mismatch waiting to be debugged in
16
+ production.
14
17
 
15
- ```sh
16
- npm install @voqalize/avatar # the browser half
17
- pip install voqalize-avatar # the pipecat half
18
- ```
18
+ **Licence: MIT.** Use it anywhere, including in closed-source products.
19
19
 
20
- ```jsx
21
- import { Avatar } from '@voqalize/avatar';
22
-
23
- <Avatar client={pipecatClient} className="call-tile" />
24
- ```
20
+ ## Install
25
21
 
26
- ```python
27
- from voqalize_avatar import AvatarProcessor
28
-
29
- pipeline = Pipeline([..., tts, AvatarProcessor(), transport.output()])
22
+ ```sh
23
+ npm install @voqalize/avatar # this package, the browser half
24
+ pip install voqalize-avatar # the pipecat half
30
25
  ```
31
26
 
32
- That is the integration, both halves of it. The processor infers the agent's
33
- state from the frames already flowing past it and streams viseme letters synced
34
- to the audio; the component renders a face that listens, thinks, claims the
35
- floor, speaks and yields it. Nothing to configure, no binaries to install.
36
-
37
- Under that one component is a ~30-channel parameter mixer with a full driving
38
- API (`setState`, `interject`, `gesture`, `speak`, `perform`, …). It is not an
39
- npm entrypoint — one component is the whole public surface, deliberately, and
40
- [docs/removed.md](docs/removed.md) says what that cost and how to undo it. The
41
- rest of this README documents that machinery, because it is what you read when
42
- you are authoring a face or debugging a mouth.
27
+ Node 20+. The package is ESM with **no runtime dependencies** and ships its own
28
+ types. `@pipecat-ai/client-js` (`>=1.4 <2`) and React (`>=18`) are declared as
29
+ *optional* peers: the pipecat import is types-only, so nothing fails to load
30
+ without it, and React is only for `@voqalize/avatar/react`.
43
31
 
44
- ## What's in the box
32
+ ## Getting started
45
33
 
46
- The repo is one system in three pieces, because that is how it gets consumed —
47
- a browser widget alone is not a working avatar, and neither is a lipsync
48
- backend. See [docs/design-library-split.md](docs/design-library-split.md) for
49
- why this is a library rather than a product.
34
+ In the browser, wherever your app already renders the bot's tile:
50
35
 
51
- | piece | where | what it is |
52
- |---|---|---|
53
- | the widget | `src/` | the face. Dependency-free ES modules, no build step, mounts anywhere. Internal since 0.2 |
54
- | the client | `client/` → `@voqalize/avatar` | the dispatcher (turn clock, cue splice) and the `<Avatar>` component over it |
55
- | the backend | `py/` → `voqalize-avatar` (PyPI) | a pipecat `FrameProcessor` that infers state from stock frames and streams visemes |
56
- | the aligner | `native/avatarsync/` | the Rhubarb Lip Sync fork the backend drives — A–H letters from text *and* from audio |
36
+ ```js
37
+ import { createAvatar } from '@voqalize/avatar';
57
38
 
58
- Browser side, one entry point:
39
+ const avatar = createAvatar({ mount: el, client: pipecatClient });
40
+ ```
59
41
 
60
42
  ```jsx
61
- import { Avatar } from '@voqalize/avatar'; // peers: react >= 18, @pipecat-ai/client-js
43
+ import { Avatar } from '@voqalize/avatar/react';
44
+ import { wren } from '@voqalize/avatar/faces/wren'; // `peep` is the default
62
45
 
63
- <Avatar client={pipecatClient} avatar="peep" className="call-tile" />
46
+ <Avatar client={pipecatClient} options={{ face: wren }} className="call-tile" />
64
47
  ```
65
48
 
66
- `client` is the live `PipecatClient` (or `null` before connect); `avatar` picks
67
- a face and is read once, at mount. Everything else is forwarded to the mount
68
- `<div>`, so it sizes and styles like the tile it lives in. There is nothing to
69
- configure because the server already says all of it.
70
-
71
- Server side, the whole integration is one processor between your TTS and your
72
- output transport — see `py/` and `docs/contract-protocol.md`:
49
+ In the pipeline, between the TTS service and the transport's output — the seat
50
+ where it can see the audio that is about to be spoken, at generation speed:
73
51
 
74
52
  ```python
75
53
  from voqalize_avatar import AvatarProcessor
@@ -77,651 +55,154 @@ from voqalize_avatar import AvatarProcessor
77
55
  pipeline = Pipeline([..., tts, AvatarProcessor(), transport.output()])
78
56
  ```
79
57
 
80
- It takes no arguments. `StartFrame` tells it the sample rate, the aligner rides
81
- inside the wheel, and an application that needs to say something the pipeline
82
- cannot infer subclasses `AvatarStateMachine` — see `py/README.md`.
83
-
84
- That much is inferred from stock pipecat frames, with no application code.
85
- States that depend on what your application is *doing* — a tool call that
86
- should read as *reviewing the screen* rather than *thinking* — are signalled
87
- explicitly with `AvatarControlFrame`.
88
-
89
- ## Running the demo
90
-
91
- ES modules will not load over `file://`. Serve the directory:
92
-
93
- ```
94
- python3 serve.py 8777
95
- open http://localhost:8777/demo/call.html
96
- ```
97
-
98
- Use `serve.py`, not `python3 -m http.server`. The stdlib server sends
99
- `Last-Modified` and no `Cache-Control`, so browsers apply heuristic freshness and
100
- quietly stop revalidating modules you have edited; `serve.py` is the same server
101
- with `Cache-Control: no-store`. Do not work around a stale module with a `?v=`
102
- query string either — that puts two copies of it in the graph and fails worse.
103
-
104
- `demo/call.html` is the page to start with: a two-tile call with mic VAD,
105
- turn-taking and a log of every token the server would have sent. Hold `Space` to
106
- be the human side of the call if you have no microphone.
107
-
108
- The other one is the control harness:
109
-
110
- ```
111
- index.html?avatar=NAME every control maps to a server token; exposes
112
- window.avatar, so the console is a live REPL
113
- ```
114
-
115
- Those two are the entry points. Everything else is rig tooling — for the
116
- occasions when you are *building or repairing an avatar*, which is a different
117
- job — and it lives behind one index:
118
-
119
- ```
120
- demo/rig/index.html rig-check, contact sheet, torso check, clip strip,
121
- expression lab, lipsync eval
122
- ```
123
-
124
- `?avatar=NAME` (or `?face=NAME` on the rig pages) selects the rig; the call
125
- demo also has a live picker that swaps it without dropping the call.
126
-
127
- ---
128
-
129
- ## Design
130
-
131
- ### 1. The face is a vector, not a set of drawings
132
-
133
- Everything the avatar can do is a point in a ~30-dimensional parameter space
134
- (`src/params.js`): `mouthOpen`, `mouthRound`, `lidL`, `browInnerR`, `headYaw`,
135
- and so on. Visemes, emotions, gaze poses and gesture keyframes are all just
136
- named vectors in that space.
137
-
138
- This is the decision the rest of the system rests on. Blending a smile into a
139
- mid-sentence "oh" is arithmetic, not SVG path surgery — and a continuous stream
140
- of parameter updates from the server is the *native* input format rather than
141
- something to be adapted to.
142
-
143
- ### 2. Layers mix in a fixed order
144
-
145
- ```
146
- base pose (state + emotion) → gaze → visemes → clip deltas → idle
147
- ```
148
-
149
- Earlier layers are overwritten by later ones on the channels they touch. Gesture
150
- clips and the idle layer are **additive**, so they compose instead of fighting:
151
- a nod during speech moves the head while the mouth stays on the server's viseme
152
- track, with no special-casing anywhere.
153
-
154
- One hard rule: **while the server viseme track is playing it owns the mouth
155
- outright.** An interjection that fires mid-sentence contributes its head and
156
- brows, and its mouth track is silently dropped. Otherwise the avatar appears to
157
- say two things at once.
158
-
159
- ### 3. Smoothing is the animation
160
-
161
- There is no tweening engine. Every channel chases its target with a
162
- frame-rate-independent exponential approach, at a per-channel time constant:
163
-
164
- | channel group | τ | why |
165
- |---|---|---|
166
- | mouth | 42ms | fast enough to hit consonants, slow enough to blur between them |
167
- | lids | 18ms | blinks must be crisp or they read as a glitch |
168
- | pupils | 32ms | saccades are ballistic |
169
- | brows | 80ms | |
170
- | head | 160ms | the head has real mass |
171
- | smile | 130ms | expressions bloom, they don't snap |
172
- | shoulders | 190ms | the torso has more mass than the head and reads wrong when it hasn't |
173
- | lean | 240ms | |
58
+ That is the integration, both halves of it. Neither takes an argument.
59
+ `createAvatar` returns `{ destroy() }` and nothing else — the avatar is an
60
+ embodiment of your `PipecatClient` and reacts to it, so there is no avatar to
61
+ drive and no state to read back.
174
62
 
175
- This gives the face weight, and it does **viseme co-articulation for free** — we
176
- never blend shapes explicitly, we just retarget and let the mouth chase.
63
+ **Works the same with or without `@pipecat-ai/voice-ui-kit`.** All `createAvatar`
64
+ needs is the `PipecatClient` instance — however you built your call UI, if you
65
+ have that instance you can mount an avatar next to it. voice-ui-kit's
66
+ components (buttons, visualizers, transport controls) are chrome around the
67
+ same client; they neither wrap nor gate it.
177
68
 
178
- ### 4. Screen-share hygiene
69
+ ## What you get for free
179
70
 
180
- The call runs with screen share on. All idle motion is deliberately
181
- low-amplitude and low-frequency; a jittery avatar in the corner of a shared
182
- screen costs the video encoder real bitrate for no communicative gain.
71
+ Most of the avatar works on any pipecat application without customization — not
72
+ because integrations were enumerated, but because the behaviour is derived from
73
+ frames and events a pipecat pipeline already emits. `SPEAKING`, `LISTENING`,
74
+ `MUTED`, `OFFLINE` and `DEGRADED` come from your `PipecatClient` with no backend
75
+ involvement at all; `THINKING`, `WORKING` and `STRAINING` come from
76
+ `AvatarProcessor` watching turn boundaries, LLM response boundaries and
77
+ function-call frames; lipsync comes from the same karaoke frames pipecat already
78
+ pushes for word-level captions; blink, breath, gaze aversion and idle motion are
79
+ always the renderer's.
183
80
 
184
- ---
81
+ What is left over is small, specific, and each item is a case the library
82
+ refuses to guess at — a deliberate nod or greeting, a tool whose calls never
83
+ enter your pipeline, a pose richer than the nine states, a backend that is not
84
+ ours. [The architecture
85
+ page](https://github.com/voqalize/avatar/blob/main/docs/architecture.md) is the
86
+ canonical reference for all of it.
185
87
 
186
- ## The viseme protocol
88
+ ## The wire protocol
187
89
 
188
- The wire format is the **Rhubarb Lip Sync alphabet** — A–H plus X — a
189
- condensation of the Preston Blair mouth set. Nine shapes is plenty for a
190
- stylized 2-D face, and it means the server has an obvious open-source reference
191
- implementation to target.
90
+ Three commands, one envelope
91
+ ([contract-wire.md](https://github.com/voqalize/avatar/blob/main/docs/contract-wire.md)):
192
92
 
193
- | letter | mouth | phonemes |
194
- |---|---|---|
195
- | `A` | closed lips | P B M — also the resting closure |
196
- | `B` | slightly open, teeth together | K S T D, consonantal EE |
197
- | `C` | open | EH AE |
198
- | `D` | wide open | AA |
199
- | `E` | slightly rounded | AO ER R |
200
- | `F` | puckered | UW OW W |
201
- | `G` | lower lip to upper teeth | F V |
202
- | `H` | tongue up, visible | L |
203
- | `X` | idle / silence | — |
204
-
205
- A cue is `{ t, v, i? }`:
206
-
207
- ```js
208
- { t: 240, v: 'D', i: 0.8 }
209
- // ^ms into the utterance
210
- // ^letter
211
- // ^optional 0..1 loudness
93
+ ```json
94
+ { "type": "avatar", "cmd": "claim", "state": "WORKING" }
95
+ { "type": "avatar", "cmd": "action", "id": "ACK_NOD" }
96
+ { "type": "avatar", "cmd": "cues", "ctx": "tts-context", "from_ms": 0, "cues": [] }
212
97
  ```
213
98
 
214
- **Send intensity if you can.** It's cheap to derive from TTS energy and it's the
215
- single biggest realism win available — the same viseme shouted and murmured
216
- should not look identical. It scales only the effortful channels, so a quiet `D`
217
- is a small `D`, not a different shape.
218
-
219
- ### Sync rules (these matter more than the shapes)
220
-
221
- - **Schedule against the audio clock, never wall time.** `audioEl.currentTime *
222
- 1000` or `AudioContext.currentTime`. Wall time drifts against playback and you
223
- will spend the rest of your life chasing it. The client does this for you if
224
- you pass `audio` to `speak()`.
225
- - **The mouth leads the sound by 40ms** (`LEAD_MS`). Perceptual tolerance is
226
- asymmetric — roughly −45ms (audio first) to +125ms (video first) — so leading
227
- is the safe side to err on.
228
- - **Cues below 30ms are dropped** by `normalizeCues()`, which also sorts and
229
- merges consecutive repeats. When a sub-30ms cue must be dropped, closures
230
- (`A`/`G`) win over mid-open vowels: they carry more lip-reading information.
231
- - **Streaming is fine.** Start with what you have and `pushCues()` the rest as it
232
- arrives; the track re-normalizes and re-seeks.
99
+ **States are durable and they are prioritised.** A state holds until the facts
100
+ change; it does not complete on a timer. **The state pipecat reports always
101
+ wins** — bot-output lifecycle and user speech are observed Pipecat facts, and a
102
+ server `claim` is a *candidate* underneath them. **Actions are point-in-time animation
103
+ sequences** that land on top of whatever state is effective at the time; they
104
+ are finite, they complete on their own, and they never establish state.
233
105
 
234
- ---
106
+ Emission is overwrite, never merge: a `cues` message says "discard everything
107
+ queued at or after `from_ms`, then append these". The server decides; the client
108
+ has no say and no way to refuse.
235
109
 
236
- ## Getting mouth shapes out of speech (server side)
110
+ **Not using our backend?** Any server can produce cues, three ways, best first.
111
+ If your TTS emits native viseme events, map the integer ids through
112
+ `AZURE_VISEME_TO_LETTER` and ship `{t, v}` as they stream. Otherwise force-align
113
+ the text against the audio and map ARPAbet through `ARPABET_TO_VISEME`. With no
114
+ server work at all, `textToCues(text)` is a crude grapheme guesser, fit for
115
+ previews only. All three are exported from `@voqalize/avatar/internal`.
237
116
 
238
- Three tiers. Pick the highest one your TTS supports.
117
+ ## The faces
239
118
 
240
- ### Tier 1 — native TTS viseme events (best, and nearly free)
241
-
242
- Several TTS engines emit viseme events alongside the audio, already aligned.
243
-
244
- **Azure Speech** fires `visemeReceived` with an integer ID 0–21 and an audio
245
- offset in 100ns ticks. `src/visemes.js` exports the mapping:
119
+ Three ship today, all hand-authored line art: **`peep`** (the default),
120
+ **`wren`**, **`myna`**. Each is its own entry point, and you pass the value
121
+ rather than a name:
246
122
 
247
123
  ```js
248
- import { AZURE_VISEME_TO_LETTER } from './src/visemes.js';
249
-
250
- synth.visemeReceived = (_s, e) => {
251
- cues.push({
252
- t: e.audioOffset / 10000, // ticks → ms
253
- v: AZURE_VISEME_TO_LETTER[e.visemeId],
254
- });
255
- };
256
- ```
124
+ import { myna } from '@voqalize/avatar/faces/myna';
257
125
 
258
- **AWS Polly** with `SpeechMarkTypes: ['viseme']` returns a JSON-lines stream of
259
- `{time, type: 'viseme', value}` where `value` is a Polly viseme name (`p`, `t`,
260
- `S`, `T`, `f`, `k`, `i`, `r`, `s`, `u`, `@`, `a`, `e`, `E`, `o`, `O`, `sil`).
261
- Map those onto the letters above — `p→A`, `f→G`, `u/o/O→F`, `a→D`, `E/e→C`,
262
- `i→B`, `r/@→E`, `t/s/S/T/k→B`, `sil→X`.
263
-
264
- **ElevenLabs / OpenAI TTS** don't emit visemes. Use tier 2.
265
-
266
- ### Tier 2 — forced alignment (works with any TTS)
267
-
268
- Take the audio and the text you already have, get phonemes with timestamps, map
269
- phonemes to letters. Open-source options, cheapest first:
270
-
271
- - **[Montreal Forced Aligner](https://montreal-forced-aligner.readthedocs.io/)** —
272
- the standard. Accurate, ~real-time on CPU, Python.
273
- - **[whisper-timestamped](https://github.com/linto-ai/whisper-timestamped)** or
274
- **WhisperX** — word-level timings; interpolate phonemes within each word via
275
- CMUdict. Lower fidelity but you may already be running Whisper.
276
- - **[Rhubarb Lip Sync](https://github.com/DanielSWolf/rhubarb-lip-sync)** — a
277
- single binary that goes straight from WAV (+ optional transcript) to exactly
278
- this A–H alphabet. `rhubarb -f json -d dialog.txt audio.wav`. This is the
279
- reference implementation; if you want a one-command answer, it's this.
280
-
281
- For phoneme→letter, port `ARPABET_TO_VISEME` from `src/visemes.js` — it's the
282
- complete table and it's already tuned against these nine shapes.
283
-
284
- Rough recipe if you're rolling your own:
285
-
286
- 1. G2P the utterance text (CMUdict for known words, `g2p-en` or `phonemizer`
287
- for the rest) → ARPAbet phoneme sequence.
288
- 2. Force-align against the synthesized audio → per-phoneme start times.
289
- 3. Map each phoneme through `ARPABET_TO_VISEME`.
290
- 4. Emit `{t, v}` at each phoneme onset. Add `{t: end, v: 'X'}` at utterance end.
291
- 5. Don't smooth or interpolate — the client's per-channel smoothing does that,
292
- and doing it twice makes the mouth mushy.
293
-
294
- Latency note: alignment needs the whole audio, so for streaming TTS run it per
295
- sentence chunk and `pushCues()` each chunk as it completes.
296
-
297
- There used to be a Tier 3: a client-side amplitude/spectral guesser
298
- (`setAudioFallback`) for a server that sends no cues at all. It was removed in
299
- 0.2 — there is no such server any more, and a second, lower-fidelity mouth
300
- standing behind the real one mostly made a broken mouth harder to diagnose. See
301
- [docs/removed.md](docs/removed.md) § Amplitude lipsync.
302
-
303
- ---
304
-
305
- ## API
306
-
307
- ### States
308
-
309
- `setState(name, { emotion, intensity, gaze, keepGaze })`
310
-
311
- | state | behaviour |
312
- |---|---|
313
- | `IDLE` | neutral, full idle motion |
314
- | `LISTENING` | slightly widened eyes, brows up a touch, ~16 blinks/min, **backchannel nods fire automatically** — timed off the user's voice when one is supplied |
315
- | `THINKING` | gaze breaks away — mostly *down*, sometimes up-left — faster shallow breath, ~25 blinks/min, occasional dead-still holds |
316
- | `SPEAKING` | eye contact, damped idle so it doesn't fight the mouth |
317
- | `REVIEWING_SCREEN` | gaze wanders across screen regions on its own, leisurely |
318
- | `SEARCHING_SCREEN` | the same regions *hunted* — quick saccades, revisits, tiny "not this one" head flicks. A filler that buys the agent time |
319
- | `WAITING_FOR_USER` | head tilt, brows up, encouraging — the "go ahead" pose |
320
- | `CANT_HEAR` | leans right in, ear cheated toward the speaker, eyes holding contact, concentration squint. Send it when the user's audio is soft |
321
- | `TYPING` | gaze down into the work, task-rate blinks (~9/min), burst-pause shoulder rhythm, a brief glance up every few seconds — busy, not absent |
322
- | `TYPING_CHAT` | `TYPING` turned communicative: longer expectant glance-holds, a touch of apology. For when the audio channel is broken and chat is the workaround |
323
- | `DISTRACTED` | attention visibly elsewhere — lateral away-gaze wander, loosened sway, no backchannels |
324
- | `TAKING_FLOOR` | shoulders up, lips parting, head coming up — about to speak |
325
- | `WANTS_IN` | the same inbreath, held and very still — "I'd like to come in" |
326
- | `YIELDED` | shoulders and lean dropped — interrupted, and giving way |
327
- | `DEGRADED` | desaturated, heavy lids — signals a network problem honestly |
328
- | `OFFLINE` | eyes closed, greyscale |
329
-
330
- Setting a state adopts its default emotion and gaze unless you override them.
331
- Every state carries an idle *profile* — blink rate, breath rate and depth, sway,
332
- weight-shift interval, stillness holds — so the liveness itself is a state
333
- signal (the blink-rate spread alone separates listening from thinking from
334
- visually busy; the numbers come from `docs/research-biomechanics.md`).
335
-
336
- The body is part of that, not just the face. The trunk breathes as a chest
337
- swell about the hem rather than sliding up and down; it re-settles its weight
338
- every 9–22 s, discretely and aperiodically, with the head counter-rolling so
339
- the gaze stays on you through the shift; and it follows a sustained head turn
340
- at nearly 3× the head's time constant, which is where follow-through comes
341
- from. Amplitude rides on the state's `sway`, so concentration suppresses the
342
- lot and `OFFLINE` is genuinely still. `avatar.setMotionGain(g)` scales all of
343
- it if a host wants a calmer or livelier body; `demo/call.html` exposes it as
344
- the *Body motion* slider, because where "alive" stops and "fidgety" starts is a
345
- judgement that should be argued against a running rig.
346
-
347
- The three floor-management states exist because turn-taking is what goes wrong
348
- most often in a voice call — the user either talks over the agent or waits
349
- in silence for a signal that never comes. They are states rather than clips
350
- because the floor is a condition, not an event: `WANTS_IN` has to hold for as
351
- long as it takes the other person to notice it. All three lift the shoulders and
352
- part the lips, because that is what an inbreath looks like from outside, and an
353
- inbreath is the cue humans actually use to predict that someone is about to
354
- speak. The head comes *up*, not down — a lowered head reads as yielding.
355
-
356
- ### The user's voice
357
-
358
- Backchannels only create rapport when they are *contingent* — a nod coupled to
359
- the speaker's pauses reads as understanding; the same nod on a random timer
360
- reads as distracting (the research is unambiguous on this). Tell the widget when
361
- the user holds the floor and the listening engine does the rest:
362
-
363
- ```js
364
- avatar.setUserSpeaking(true / false); // the server endpointer's own signal
365
- avatar.on('backchannel', (id) => log(id)); // every autonomous ack, announced
126
+ createAvatar({ mount, client, face: myna });
366
127
  ```
367
128
 
368
- While the user holds the floor the avatar leans in a touch; at pause onsets it
369
- acknowledges within ~250–600ms (probability, refractory gap and nod choice all
370
- tuned from listening-corpus numbers — long user utterances earn the bigger
371
- nods). With no signal attached, the old plausible random cadence remains as the
372
- fallback. (The widget could once derive this itself with its own VAD over the
373
- mic stream; that raced the server's endpointer and went in 0.2 —
374
- [docs/removed.md](docs/removed.md) § Client-side VAD.) The server can always `interject()` explicitly; autonomous acks
375
- suppress themselves around it.
129
+ A name would need a table, and a table is a dynamic index no bundler can shake —
130
+ three drawings in every consumer's bundle to render one.
376
131
 
377
- ### Action timelines
132
+ ## Professional avatars
378
133
 
379
- The composable vocabulary: a server assembles behaviour from the enums above,
380
- timed against the utterance's own audio clock —
134
+ Six complete, code-authored avatars ship as their own `createAvatar` modules:
381
135
 
382
136
  ```js
383
- avatar.perform([
384
- { t: 0, do: 'state', name: 'SPEAKING' },
385
- { t: 900, do: 'gaze', name: 'SCREEN_WORK' },
386
- { t: 2100, do: 'interject', id: 'NOD_SMALL' },
387
- { t: 3000, do: 'emotion', name: 'warm', i: 0.7 },
388
- ], { audio: audioEl }); // clock: explicit fn > audio > elapsed
389
- ```
390
-
391
- Verbs: `state`, `emotion`, `gaze`, `interject`. `normalizeActions()` applies
392
- the same hygiene philosophy as `normalizeCues()` — sort, warn-and-drop
393
- malformed entries, never throw mid-performance. A new `perform()` replaces the
394
- running one; `stop()` on the returned handle cancels; `performEnd` fires when
395
- the last action has dispatched. The demo's scripted turns
396
- (`demo/perf-clips.json`) run through exactly this call.
397
-
398
- ### Emotion
399
-
400
- `setEmotion(name, intensity)` — `neutral`, `warm`, `thoughtful`, `concerned`,
401
- `encouraging`, `curious`.
402
-
403
- ### Avatars
137
+ import { createAvatar } from '@voqalize/avatar/avatars/arjun';
138
+ // or: @voqalize/avatar/avatars/meera
139
+ // @voqalize/avatar/avatars/vikram
140
+ // @voqalize/avatar/avatars/ishita
141
+ // @voqalize/avatar/avatars/kabir
142
+ // @voqalize/avatar/avatars/naina
404
143
 
405
- The rig can wear more than one face. Pick one at construction:
406
-
407
- ```js
408
- createAvatar({ mount, avatar: 'wren' }) // by name, from AVATARS
409
- createAvatar({ mount, face: myCreateFace }) // or pass a factory directly
144
+ const avatar = createAvatar({ mount, client: pipecatClient });
410
145
  ```
411
146
 
412
- `AVATAR_NAMES` lists what is registered. Two ship today, both line art:
413
-
414
- | name | module | what it is |
415
- |---|---|---|
416
- | `peep` | `face-peep.js` | the default. Open Peeps–style black-and-white line art, `#f97415` on the collar edge and two buttons. Hand-authored in a 760x950 space, portrait window `92 76 576 800` |
417
- | `wren` | `face-wren.js` | the second line-art character — curls, round glasses, same construction kit, window `92 50 576 800` |
418
-
419
- `DEFAULT_AVATAR` is `peep`, and it is the face under active work.
420
-
421
- There were two others — `classic`, the original hand-authored rig, and
422
- `blue-shirt`, a cleaned auto-trace. Both were removed on 2026-08-06 after
423
- stakeholder review accepted the line-art pair and rejected them: keeping four
424
- rigs meant maintaining art nobody wanted, and every visual fix had to be
425
- weighed against faces that were never going to ship. What they taught the
426
- abstraction outlived them — `face-core.js` exists because all three of the
427
- first rigs wrote the same `apply()`, and `META` exists because all three needed
428
- the same two rects. Both modules are in git history.
147
+ `arjun`/`meera` are the original interviewer pair; `vikram`/`ishita` and
148
+ `kabir`/`naina` are two further wardrobe/hair directions on the same rig. The
149
+ entry points `interviewer-male`, `interviewer-female`, `professional-male-a`,
150
+ `professional-female-a`, `professional-male-b` and `professional-female-b`
151
+ still work — they are `@deprecated` aliases for the names above, kept so
152
+ existing imports do not break, and should not be used in new code.
429
153
 
430
- `peep` is worth a paragraph because it is built on a rule the retired rigs did
431
- not follow: **it has no strokes anywhere.** Every line is a filled path, which is
432
- what lets a line swell and taper along its length the way a drawn mark does — a
433
- uniform `stroke-width` is the thing that makes vector line art read as clip art.
434
- Three helpers do all of it: `taper` for an open mark, `taperRing` for a closed
435
- annulus, `region` for an enclosed area. Widths are given as a *profile across the
436
- whole mark* in normalized `s ∈ [0,1]`, not per node, so the same profile survives
437
- re-authoring the points.
154
+ All six depict Indian professionals in their late twenties, without caricature
155
+ or regional costume cues. They are calibrated at call-tile size and preserve
156
+ all six expression targets, continuous visemes, gaze, blink, head motion, and
157
+ the frame-edge gesture hand. Each module is a complete identity, not a face
158
+ value: import one instead of the default module and do not pass a `face`
159
+ option.
438
160
 
439
- An avatar is any module exporting
161
+ Their faces use low-contrast, landmark-driven nose planes rather than an
162
+ outlined nose glyph. A few identity-specific freckles or a small mole add depth
163
+ only in low-motion upper-cheek areas; none are used as demographic cues.
440
164
 
441
- ```js
442
- createFace(mount, theme) -> { svg, apply(params), theme, destroy() }
443
- META = { viewBox, mouthCrop }
444
- ```
445
-
446
- Nothing above the renderer knows which face it is driving: visemes, emotions,
447
- gaze, idle, clips and the mixer all work in parameter space, so a new avatar
448
- costs no changes anywhere else. `META` is the avatar descriptor — the little a
449
- host or tool may know about a face without opening it: `viewBox` for framing
450
- (exposed as `api.meta`; the demo pages derive tile aspect from it) and
451
- `mouthCrop` for the contact sheet's viseme close-ups.
452
-
453
- The registry in `src/avatar.js` maps names to `{ create, meta }` records. It
454
- was factories-only for the first three faces, deliberately — a schema guessed
455
- from two rigs would have been wrong, and building the third supplied the
456
- evidence for what is genuinely shared. That evidence now lives in code rather
457
- than prose: `src/face-core.js` owns the pose mechanics (lean, shoulders,
458
- parallax) driven by per-rig scalar specs, the shared eye/brow/teeth fragments,
459
- and the renderer shell, so a face module supplies art, feature geometry and a
460
- handful of named scalars. The full recipe — what a new avatar must supply and
461
- what it gets for free — is in
462
- [docs/contract-avatar.md](docs/contract-avatar.md). The hard-won rules stand:
463
-
464
- - **Art units are per-rig, and copying a magnitude between rigs is silent
465
- breakage.** `peep`'s `yawPx` is 28 against the original rig's 13 because they
466
- are art units of different sizes; travels convert through the spec's `units`
467
- factor, degrees never do.
468
- - **A trace supplies static geometry, not a rig.** Anything the source art does
469
- not contain has to be authored, and two rigs can honour the same channel and
470
- mean visibly different things by it. (This is most of why the traced rig was
471
- the one that read worst, and why new avatars are hand-authored.)
472
- - **Layer sets follow the art, not a standard** — the first rig ran 7 layers;
473
- the line-art pair fuse to 4.
474
-
475
- Verify a new avatar against `demo/rig/rig-check.html` (every registered avatar side
476
- by side through the live mixer, plus `sweep()` — a scripted pass over every
477
- state, emotion, gaze, interjection and a viseme track, asserting the params stay
478
- finite and in range and the SVG stays connected); against
479
- `demo/rig/contact-sheet.html?face=<name>` for static poses, including a mouth
480
- close-up row, since visemes are only judgeable at that magnification; and against
481
- `demo/rig/torso-check.html?face=<name>` for the shoulder, lean and head-pose
482
- *combinations*, which is where a rig leaks background from behind the shirt if it
483
- is going to.
484
-
485
- `sweep()` returning `{ok: true}` is not evidence a change looks good — it catches
486
- dead avatars, NaN leaks and detached SVGs, and nothing else. Every defect this
487
- project has found was found by looking at a rendered page.
488
-
489
- Emotion is a separate axis from state on purpose. Fold it into the state enum
490
- and you need `SPEAKING_WARM`, `SPEAKING_CONCERNED`, `LISTENING_WARM`… and the
491
- table is unmaintainable within a week.
492
-
493
- ### Gaze
494
-
495
- `setGaze(name)` or `setGaze('CUSTOM', { x, y })` with normalized −1..1.
496
-
497
- Named targets: `USER`, `USER_EAR`, `SCREEN_CENTER`, `SCREEN_LEFT`,
498
- `SCREEN_RIGHT`, `SCREEN_TOP`, `SCREEN_BOTTOM`, `SCREEN_WORK`, `NOTES`,
499
- `AWAY_THINKING`, `AWAY_DOWN`, `AWAY_RIGHT`. (`USER_EAR` cheats the head aside
500
- while the eyes hold contact — `CANT_HEAR`'s signature; `AWAY_DOWN` is the
501
- thinking direction, because real cognitive aversion leads down, not up.)
502
-
503
- Send the semantic direction and let the client do the oculomotor work. Three
504
- details do the perceptual heavy lifting, and skipping any one reads as a puppet:
505
- the eyes arrive first (ballistic, ~32ms) while the head ambles after at 340ms;
506
- the head deliberately under-rotates and lets the eyes carry the rest; a blink
507
- fires involuntarily on any large shift. The upper lid also rides with vertical
508
- gaze — without that, looking down bares sclera and reads as alarm.
509
-
510
- ### Speaking
165
+ They use the same public contract and the same Pipecat lifecycle/viseme driver
166
+ as the SVG avatars. Their private renderer is Canvas2D; its rig data and bitmap
167
+ wardrobe assets are implementation details and no Canvas or pose API is added
168
+ to the package surface.
511
169
 
512
- ```js
513
- avatar.speak({ audio: audioEl, cues }); // audio element drives the clock
514
- avatar.speak({ cues, clock: () => myPlayer.positionMs });
515
- avatar.pushCues(moreCues); // streaming top-up
516
- avatar.stopSpeaking();
517
- avatar.on('speakEnd', () => avatar.setState('LISTENING'));
518
- ```
170
+ ## Shipping your own avatar
519
171
 
520
- `speak()` switches to `SPEAKING` and kills any in-flight spoken interjection
521
- first — barge-in is the normal case, not an error.
522
-
523
- For previewing without a TTS round-trip there's `textToCues(text, { wpm })`, a
524
- crude grapheme guesser. It exists for the demo. Do not ship it.
525
-
526
- ### Interjections
527
-
528
- `interject(id)`. These are the real-time feedback channel — they're what makes
529
- the avatar feel like a listener rather than a player, so they're the part most
530
- worth getting right.
531
-
532
- Each clip is a gesture timeline (head, brows, lids, smile) plus, where spoken, a
533
- hand-tuned viseme track and a **baked plausible duration**, so every clip plays
534
- convincingly with no audio at all.
535
-
536
- **Spoken:** `MM_HMM`, `OKAY`, `YES`, `SURE`, `RIGHT`, `GOT_IT`, `I_SEE`,
537
- `GO_ON`, `ONE_MOMENT`, `TAKE_YOUR_TIME`, `SORRY`, `HMM`
538
- **Wordless:** `NOD_SMALL`, `NOD_SLOW`, `NOD_UP`, `BROW_ACK`, `HEAD_SHAKE`,
539
- `HEAD_SHAKE_SOFT`, `BLINK_LONG`, `WAVE`, `THUMBS_UP`, `SHRUG`, `GO_ON_ARM`
540
- **Floor management:** `CLAIM_FLOOR`, `YIELD_FLOOR`, `RAISE_HAND`
541
-
542
- The nod family follows the measured taxonomy of human listening: `NOD_SMALL`
543
- is the single-cycle continuer, `NOD_SLOW` the two-beat assessment (first beat
544
- biggest — long nods start big and decay), `NOD_UP` the realization nod with the
545
- upward swing, for "ah, *I see*" moments. `HEAD_SHAKE` is the firm no,
546
- `HEAD_SHAKE_SOFT` the sympathetic "not quite" with a head tilt — neither ever
547
- fires autonomously, and nor does `BLINK_LONG`, the deliberate ~600ms blink that
548
- tells a speaker "that's noted, move on" (it measurably shortens answers, which
549
- is exactly why only the server may send it).
550
-
551
- Notes on a few, because the detail is the point:
552
-
553
- - `MM_HMM` — lips stay shut the whole way; the meaning is entirely in the nod.
554
- - `ONE_MOMENT` — breaks eye contact to `AWAY_RIGHT`. That break, not the words,
555
- is what communicates "hold on".
556
- - `SORRY` — driven by `browInner` (AU1, the inner-brow lift). That single
557
- channel is the whole apology.
558
- - `WAVE`, `THUMBS_UP`, `SHRUG`, `GO_ON_ARM`, `RAISE_HAND` — these were arm and
559
- hand gestures. The rig has neither any more, and the IDs stayed because they
560
- are a wire contract the server targets; each was re-authored to say the same
561
- thing from the face, shoulders and torso. `WAVE` is now the eyebrow flash,
562
- which is the greeting display a face makes when an arm is unavailable;
563
- `THUMBS_UP` is a slow deep approving nod; `SHRUG` runs the shoulders to the
564
- top of their range with the mouth corners pulled *down*, because raised
565
- shoulders over a neutral mouth is a flinch rather than an "I don't know".
566
-
567
- To use your own TTS clips, attach audio and the baked track re-schedules against
568
- the real file's clock:
172
+ A whole different rendering technology is not a face; it is a different
173
+ `createAvatar`, published as its own module — which is why there is no registry.
174
+ The interface is small enough to state in one line:
569
175
 
570
- ```js
571
- import { attachAudio } from './src/avatar.js';
572
- attachAudio('OKAY', '/audio/agent-okay.mp3');
176
+ ```ts
177
+ createAvatar({ mount, client, ...yourOptions }) -> { destroy() }
573
178
  ```
574
179
 
575
- Clips ramp in over 70ms and out over 150ms, are interruptible, and a repeat of
576
- the clip already playing collapses rather than stacking.
577
-
578
- ### Hand gestures
579
-
580
- `gesture(id)` — `HI`, `BYE`, `THUMBS_UP`, `ONE_MOMENT`. A hand rises into the
581
- bottom of the frame, and the matching interjection above plays with it, because
582
- a hand that arrives while the head sits perfectly still is not attached to
583
- anybody.
584
-
585
- There is still no arm. The hand enters the way a webcam sees one — only fingers
586
- and palm ever clear the edge, the wrist never does — which is what makes it a
587
- different proposition from the articulated forearm chain this project removed.
588
- It is not part of the rig either: no parameter channel, no per-face geometry.
589
- Every avatar gets it from its own `viewBox` and theme, and a face that never
590
- plays a gesture renders exactly what it rendered before.
591
-
592
- Two guarantees worth stating, because they are the reasons it could ship at
593
- all: **nothing but a single digit ever passes the mouth** (lipsync is the
594
- headline feature — a gesture is free to fire mid-speech), and the hand never
595
- leaves the frame sideways. `checkHandFraming(meta)` asserts both against the
596
- real timelines for every registered avatar, in `sweep()`.
180
+ What such an implementation needs to understand is the *wire*, because states,
181
+ actions and cues are all an avatar is ever told. `VisemeTrack` in
182
+ `@voqalize/avatar/internal` turns a cue array plus a clock into the mouth shape
183
+ for the current frame; every renderer needs that and none should write it twice.
597
184
 
598
- ```js
599
- avatar.gesture('HI');
600
- avatar.setHandSide(-1); // which side it enters from; +1 is the default
601
- avatar.gesturing; // the id in flight, or null
602
- createAvatar({ mount, hand: false }); // no hand; gesture() plays the face half alone
603
- ```
604
-
605
- `gesture` is a separate verb from `interject` on the wire too — `interject('WAVE')`
606
- is still the face alone, so a server that upgrades gets no hand until it asks.
185
+ **There is deliberately no renderer interface.** The pose channels our SVG mixer
186
+ uses to talk to our faces are internal, and a second public contract stays
187
+ premature until a second renderer says what it needs.
607
188
 
608
- ### Misc
189
+ ## What is in this tarball
609
190
 
610
- ```js
611
- avatar.blink(true); // true = double blink
612
- avatar.setMouthGain(1.2); // articulation: scales the viseme shapes as authored
613
- avatar.setGestureGain(0.8); // scales every clip delta
614
- avatar.setMotionGain(0.8); // scales the whole idle/body liveness layer
615
- avatar.setOverrides({ ... }); // direct param injection, for tuning UIs
616
- avatar.setOverrides(null);
617
- avatar.params; // live smoothed parameter vector (read-only)
618
- avatar.state / .emotion / .gaze / .speaking / .clip / .gesturing / .performing
619
- avatar.mouthGain / .gestureGain / .motionGain / .svg / .meta
620
- avatar.destroy();
621
- ```
191
+ `dist/` is the compiled client — `AvatarClient`, the avatar entry points and the
192
+ React binding. `src/` is the widget itself: the mixer, the SVG rig and drawings,
193
+ plus the private Canvas2D interviewer rigs and their assets, as dependency-free
194
+ ES modules with no build step, imported by `dist/` through ordinary relative
195
+ paths. `client/` is the TypeScript those `dist/` files were compiled from, so
196
+ the source maps resolve.
622
197
 
623
- Every setter returns the instance, so calls chain. `params`, `svg` and `meta`
624
- are properties, not methods. (`meta` is the mounted avatar's descriptor — the
625
- call demo sizes its tile from `meta.viewBox`.)
626
-
627
- Events: `state` (new state name), `speakEnd`, `clipEnd` (clip id),
628
- `backchannel` (autonomous ack id), `gestureEnd` (hand gesture id), `performEnd`.
629
-
630
- ---
631
-
632
- ## Files
633
-
634
- | file | |
635
- |---|---|
636
- | `src/params.js` | the parameter space, smoothing constants, channel groups |
637
- | `src/face-core.js` | what every face shares: the renderer shell, pose mechanics, shared feature fragments |
638
- | `src/face-peep.js` | the `peep` avatar — the default. Open Peeps line art. No strokes anywhere: every line is a filled path, so it swells and tapers |
639
- | `src/face-wren.js` | the `wren` avatar — the second line-art character, same kit |
640
- | `src/face-myna.js` | the `myna` avatar — the first built by the staged process, from a reference asset |
641
- | `src/line-art.js` | the stroke engine every line-art rig shares: `taper`, `taperRing`, `region` |
642
- | `src/avatar.d.ts` | hand-maintained types for the public surface. The widget has no compiler; this is written against the contract |
643
- | `src/visemes.js` | A–H protocol, cue hygiene, audio-clock scheduling, server mapping tables |
644
- | `src/emotions.js` | six affect poses |
645
- | `src/gaze.js` | named targets, saccade + head-follow model, micro-saccades |
646
- | `src/idle.js` | per-state liveness profiles, the `ListeningEngine`, autonomous backchannel |
647
- | `src/clips.js` | keyframe player for gesture timelines |
648
- | `src/interjections.js` | the 26 clips |
649
- | `src/hand.js` | the frame-edge hand: four gestures, placed from `META.viewBox` |
650
- | `src/perform.js` | the action-timeline player behind `perform()` |
651
- | `src/avatar.js` | the driving API, the per-frame mixer, and the `AVATARS` registry |
652
- | `client/src/AvatarClient.ts` | the dispatcher: turn-clock anchoring and the cue splice, framework-free |
653
- | `client/src/useAvatar.ts` `client/src/Avatar.tsx` | the React binding — a mount lifecycle over `AvatarClient` |
654
- | `client/src/index.ts` | the one export: `<Avatar>` |
655
- | `client/src/types.ts` | the wire vocabulary in TypeScript. Kept in step with `contract-protocol.md` and `messages.py` |
656
- | `py/src/voqalize_avatar/` | the pipecat backend: state machine, processor, viseme engine, `avatarsync` pool |
657
- | `native/avatarsync/` | the Rhubarb Lip Sync fork — text leg and audio leg — plus its patch and build script |
658
- | `docs/contract-protocol.md` `docs/contract-avatar.md` | the two binding interface contracts |
659
- | `docs/design-library-split.md` | why this is a library, and what each published artifact owns |
660
- | `docs/removed.md` | what 0.2 deleted from the public surface, why, and how to get any of it back |
661
- | `docs/research-biomechanics.md` | the citations behind the motion constants |
662
- | `tools/` | headless render / sweep / pixel-diff CLI (dev-only dependencies) |
663
- | `serve.py` | the dev server — `Cache-Control: no-store`. Use this one |
664
- | `index.html` | the full harness, driving one avatar as a host would |
665
- | `demo/call.html` | the Meet-style call: VAD, turn-taking, token log. The page to show people |
666
- | `demo/floor.js` | turn-taking — barge-in, backchannel acks, floor claim. A stand-in for the server |
667
- | `demo/vad.js` | mic voice activity — silero via CDN, RMS fallback |
668
- | `demo/perf-clips.json` `demo/perf-audio/` | 16 scripted turns the call demo plays: audio, cue tracks, gesture beats |
669
- | `demo/eval-clips.json` `demo/eval-audio/` | 24 clips the lipsync eval scores against |
670
- | `demo/rig/index.html` | the index for the rig tooling below — the way in |
671
- | `demo/rig/rig-check.html` | every registered avatar side by side through the live mixer; `sweep()` |
672
- | `demo/rig/contact-sheet.html` | static poses for one avatar: every viseme, emotion, gaze, extreme |
673
- | `demo/rig/torso-check.html` | shoulder / lean / trunk-turn / head combinations, which only fail together |
674
- | `demo/rig/body-lab.html` | the rig stepped by hand, seeded — the driver `tools/motion.mjs` measures |
675
- | `demo/rig/clip-strip.html` | one gesture clip as a filmstrip, with the mixer's own smoothing |
676
- | `demo/rig/expression-lab.html` | clip and beat authoring against real audio |
677
- | `demo/rig/lipsync-eval.html` | cue tracks A/B, sphinx vs phonetic |
678
- | `experiments/rhubarb-textsync/` | server-side experiment: A–H letters from text, before the audio exists. Ships nowhere near the widget |
679
-
680
- `face-peep.js` and `face-wren.js` draw four layers each; the original rig drew
681
- seven. Every layer carries a parallax multiplier (0.1 for the body, up to 1.34
682
- on the rig with a separate front fringe), which fakes a head turn convincingly
683
- without any 3-D. The layer set follows the art rather than a standard — the
684
- line-art rigs fuse pairs that a more detailed drawing keeps separate. If you
685
- restyle the character the parameter contract is unchanged; only the face module
686
- needs to know what a face looks like.
687
-
688
- The clip data lives in `demo/` rather than beside the pages that use it, because
689
- `demo/call.html` and both lab pages play the same wavs and one copy of a
690
- hundred of them is the point. The lab pages sit a directory deeper and carry a
691
- `DATA = '../'` constant for the hop back up.
198
+ The contract documents do not ship here. They live in the repository, which is
199
+ where they are kept current:
200
+ [github.com/voqalize/avatar](https://github.com/voqalize/avatar).
692
201
 
693
202
  ## License
694
203
 
695
- **AGPL-3.0-only.** Open source, and deliberately the restrictive end of it: you
696
- may use, modify and self-host this freely, but a modified version offered to
697
- users over a network has to offer them its source too. That is a starting
698
- position taken while the project is young, not a final one — Voqalize holds the
699
- copyright on all of it, so relicensing to something permissive later is a
700
- decision we can simply make.
701
-
702
- Two consequences worth knowing before you build on it:
703
-
704
- - Embedding the widget in a closed-source product is not what this license
705
- permits. If that is what you need, open an issue — a commercial license is a
706
- conversation we are happy to have.
707
- - The `avatarsync` aligner in `native/avatarsync/` is a fork of
708
- [Rhubarb Lip Sync](https://github.com/DanielSWolf/rhubarb-lip-sync), which is
709
- MIT. The prebuilt binaries there statically link pocketsphinx, sphinxbase,
710
- flite, WebRTC, cppformat, GSL, Boost and the CMU acoustic model; upstream's
711
- own notice file for all of them is committed beside them as
712
- `native/avatarsync/UPSTREAM-LICENSE.md`. Those terms are unchanged and travel
713
- with that directory.
714
-
715
- ### Third-party material
716
-
717
- | what | where | terms |
718
- |---|---|---|
719
- | [Open Peeps](https://www.openpeeps.com/) | the drawing *idiom* `peep` is authored in — no artwork is copied | CC0 |
720
- | Rhubarb Lip Sync 1.14.0 | `native/avatarsync/` (fetched at build time, not vendored) | MIT; see `UPSTREAM-LICENSE.md` |
721
- | [piper](https://github.com/OHF-Voice/piper1-gpl) voices `en_US-ljspeech-high`, `en_US-libritts_r-medium` | spoke every wav in `demo/*-audio/` and the fixtures in `py/tests/fixtures/` | LJSpeech is public domain; LibriTTS-R is CC BY 4.0 |
722
- | [`@ricky0123/vad-web`](https://github.com/ricky0123/vad) + onnxruntime-web (silero-vad) | loaded from jsDelivr by `demo/vad.js`, demo only — nothing in `src/` fetches it | MIT |
723
-
724
- The three avatars are original drawings. All demo audio is synthesised from text
725
- written for this repo.
726
-
727
- Releasing is documented in [RELEASING.md](RELEASING.md).
204
+ **MIT**, and Voqalize holds the copyright on all of it. The drawing idiom `peep`
205
+ is authored in is [Open Peeps](https://www.openpeeps.com/) (CC0) — no artwork is
206
+ copied. The `avatarsync` aligner that produces the mouth shapes is a fork of
207
+ [Rhubarb Lip Sync](https://github.com/DanielSWolf/rhubarb-lip-sync) (MIT) and
208
+ ships in the Python package, not this one.