@voqalize/avatar 0.0.1

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