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