@voqalize/avatar 0.0.1 → 0.2.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 (46) hide show
  1. package/README.md +86 -51
  2. package/client/dist/Avatar.d.ts +17 -14
  3. package/client/dist/Avatar.d.ts.map +1 -1
  4. package/client/dist/Avatar.js +3 -3
  5. package/client/dist/Avatar.js.map +1 -1
  6. package/client/dist/AvatarClient.d.ts +23 -57
  7. package/client/dist/AvatarClient.d.ts.map +1 -1
  8. package/client/dist/AvatarClient.js +20 -71
  9. package/client/dist/AvatarClient.js.map +1 -1
  10. package/client/dist/index.d.ts +22 -0
  11. package/client/dist/index.d.ts.map +1 -0
  12. package/client/dist/index.js +22 -0
  13. package/client/dist/index.js.map +1 -0
  14. package/client/dist/types.d.ts +22 -37
  15. package/client/dist/types.d.ts.map +1 -1
  16. package/client/dist/types.js +12 -12
  17. package/client/dist/types.js.map +1 -1
  18. package/client/dist/useAvatar.d.ts +8 -19
  19. package/client/dist/useAvatar.d.ts.map +1 -1
  20. package/client/dist/useAvatar.js +15 -32
  21. package/client/dist/useAvatar.js.map +1 -1
  22. package/client/src/Avatar.tsx +19 -24
  23. package/client/src/AvatarClient.ts +38 -111
  24. package/client/src/index.ts +22 -0
  25. package/client/src/types.ts +24 -47
  26. package/client/src/useAvatar.ts +19 -47
  27. package/docs/contract-avatar.md +37 -3
  28. package/docs/contract-protocol.md +90 -30
  29. package/package.json +3 -11
  30. package/src/avatar.d.ts +38 -6
  31. package/src/avatar.js +72 -37
  32. package/src/hand.js +680 -0
  33. package/src/idle.js +8 -25
  34. package/src/line-art.js +26 -0
  35. package/src/perform.js +8 -4
  36. package/client/dist/pipecat.d.ts +0 -21
  37. package/client/dist/pipecat.d.ts.map +0 -1
  38. package/client/dist/pipecat.js +0 -21
  39. package/client/dist/pipecat.js.map +0 -1
  40. package/client/dist/react.d.ts +0 -16
  41. package/client/dist/react.d.ts.map +0 -1
  42. package/client/dist/react.js +0 -17
  43. package/client/dist/react.js.map +0 -1
  44. package/client/src/pipecat.ts +0 -38
  45. package/client/src/react.ts +0 -34
  46. package/src/audio-fallback.js +0 -100
@@ -10,7 +10,7 @@ saying and looking at, and tells the widget; the widget's only job is to look
10
10
  right while rendering that. Nothing in this contract lets the client decide
11
11
  call content, and nothing in it requires the server to know what a face looks
12
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.**
13
+ interjection id, a hand-gesture id, and a stream of timed viseme letters.**
14
14
 
15
15
  Everything below is reachable from one import:
16
16
 
@@ -19,7 +19,8 @@ import { createAvatar } from './src/avatar.js';
19
19
  const avatar = createAvatar({ mount: '#avatar' }); // also: avatar, face, theme, mouthGain, gestureGain
20
20
  ```
21
21
 
22
- All setters are chainable. Unknown state and interjection ids **throw**;
22
+ All setters are chainable. Unknown state, interjection and gesture ids
23
+ **throw**;
23
24
  unknown emotion falls back to `neutral` silently; unknown gaze falls back to
24
25
  `USER` silently.
25
26
 
@@ -154,6 +155,44 @@ until `attachAudio` gives them a voice.
154
155
  A repeated `interject(id)` while that clip is already playing is collapsed to a
155
156
  no-op; a *different* id replaces the running clip immediately.
156
157
 
158
+ ## Hand gestures — `gesture(id)`
159
+
160
+ A hand rising into the bottom of the frame, plus the face half that makes it
161
+ belong to somebody. **This is a separate verb from `interject` on purpose.**
162
+ The four ids below are disjoint from `INTERJECTION_IDS`, and `interject('WAVE')`
163
+ still means exactly what it always meant — the face alone. A server that
164
+ upgrades the widget gets no hand until it asks for one.
165
+
166
+ | id | dur | what it does |
167
+ |---|---|---|
168
+ | `HI` | 1250 | open palm rises and waves — greeting. Face half: `WAVE` |
169
+ | `BYE` | 1550 | the same wave, one swing longer and a touch slower — parting. Face half: `WAVE` |
170
+ | `THUMBS_UP` | 1300 | fist, back of hand to camera, thumb up — approval. Face half: `THUMBS_UP` |
171
+ | `ONE_MOMENT` | 1700 | a single raised index finger, held — "one moment". Face half: `ONE_MOMENT`, which speaks |
172
+
173
+ Calling `gesture(id)` fires the face half as an `interject()` on the caller's
174
+ behalf — do **not** send both; the second one replaces the first mid-clip. It
175
+ also suppresses autonomous backchannels for the gesture's duration plus 500 ms,
176
+ because a nod landing on top of a deliberate hand is the listening engine
177
+ talking over the server.
178
+
179
+ What the widget guarantees, and why it is stated here rather than left to the
180
+ drawing: **nothing but a single digit ever passes the mouth.** Mouth sync is
181
+ the headline feature, so a gesture is free to fire mid-speech. The hand also
182
+ never leaves the frame sideways and never shows a wrist — see
183
+ `docs/contract-avatar.md` § The hand for the rules and the per-avatar check.
184
+
185
+ Degradation is total and silent. An avatar mounted with `hand: false` — a face
186
+ drawn in some other idiom, or a tile too small to spend the pixels — plays the
187
+ face half and nothing else, which is the same fallback every id already had
188
+ before the hand existed. `api.gesturing` is the id in flight, or `null` — which
189
+ is what it always reads under `hand: false`, and `gestureEnd` correspondingly
190
+ never fires there: both describe the *hand*, and there is no hand. An unknown
191
+ id throws, as `interject` does.
192
+
193
+ `setHandSide(+1 | -1)` picks which side of the frame the hand enters from;
194
+ `+1` (the viewer's right) is the default.
195
+
157
196
  ## Speech — `speak({ cues, audio?, clock? })`, `pushCues(cues)`, `stopSpeaking()`
158
197
 
159
198
  The headline feature. A **cue** is:
@@ -195,19 +234,18 @@ TTS produces them; the merged track is re-normalized each push.
195
234
  `speak()` auto-enters `SPEAKING` (keeping the current gaze) and kills any
196
235
  spoken interjection in flight. `speakEnd` fires when the track completes.
197
236
 
198
- ## The user's voice — `setUserAudio(source?)`, `setUserSpeaking(bool)`
237
+ ## The user's voice — `setUserSpeaking(bool)`
199
238
 
200
239
  Backchannels only create rapport when they are *contingent* on the speaker —
201
240
  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:
241
+ (docs/research-biomechanics.md §3.5). Tell the widget when the user holds the
242
+ floor and the listening engine does the rest.
204
243
 
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.
244
+ `setUserSpeaking(bool)` is the whole input side: the turn signal from the
245
+ server's endpointer, which is the one that already decides where a turn ends.
246
+ Pass `null` to hand back. The widget used to be able to derive this itself from
247
+ the user's `MediaStream` (`setUserAudio`), a second VAD racing the server's —
248
+ `docs/removed.md` § Client-side VAD.
211
249
 
212
250
  While `LISTENING`/`WAITING_FOR_USER` with a signal supplied: an
213
251
  acknowledgement fires 250–600 ms after a user pause onset, on about half of
@@ -224,12 +262,12 @@ up.
224
262
 
225
263
  ### The mouth priority rule (invariant)
226
264
 
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.
265
+ **Server viseme track > clip mouth track.** While a server track plays, it
266
+ owns the mouth outright: an interjection fired mid-utterance contributes head
267
+ and brows only, and its mouth track is dropped. There used to be a third tier —
268
+ an amplitude/spectral guesser off the bot's own audio element, for a server
269
+ that sent no cues at all (`docs/removed.md` § Amplitude lipsync). Anything that
270
+ degrades this ordering is a regression.
233
271
 
234
272
  ## Events, gains, introspection
235
273
 
@@ -240,6 +278,8 @@ track is live. Anything that degrades this ordering is a regression.
240
278
  its own (fires with the interjection id)
241
279
  - `on('performEnd', fn)` — a performance's last action has fired (see
242
280
  *Composing behavior*)
281
+ - `on('gestureEnd', fn)` — the hand has left the frame (fires with the gesture
282
+ id). Only where a hand is mounted — see *Hand gestures*
243
283
  - `setMouthGain(g)` — scales viseme excursion away from rest (1 = as authored;
244
284
  useful when the avatar renders small). Never drags a closed mouth open.
245
285
  - `setGestureGain(g)` — scales clip deltas; small gestures under-render
@@ -252,7 +292,7 @@ track is live. Anything that degrades this ordering is a regression.
252
292
  "fidgety" starts moves with tile size and with the audience, so this is
253
293
  deliberately a host decision rather than a constant.
254
294
  - Getters: `state`, `emotion`, `gaze`, `speaking`, `performing`, `clip`,
255
- `params` (the live smoothed vector), `audioLevel`, `svg`.
295
+ `gesturing`, `params` (the live smoothed vector), `svg`.
256
296
  - `setOverrides({channel: value})` — direct parameter injection, post-clamp.
257
297
  For tuning UIs and tests, not production.
258
298
  - `blink(double?)`, `destroy()`.
@@ -298,6 +338,7 @@ and tuned on the rig. A backend wanting a new move asks for a new enum entry
298
338
  | `emotion` | `name`, `i?` 0..1 (default 1) | `setEmotion(name, i)` |
299
339
  | `gaze` | `name` | `setGaze(name)` |
300
340
  | `interject` | `id` | `interject(id)` |
341
+ | `gesture` | `id` | `gesture(id)` — the hand *and* its face half |
301
342
 
302
343
  The natural unit a server assembles is audio + cue track + action track on
303
344
  **one clock** (`demo/perf-clips.json` scripts every demo turn this way, and
@@ -345,22 +386,33 @@ package (`pip install voqalize-avatar`), a pipecat `FrameProcessor` that infers
345
386
  the base states from stock frames and emits the envelope below as RTVI
346
387
  server-messages. Design and rationale:
347
388
  [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:
389
+ through that stack never calls the API above directly; it renders
390
+ `<Avatar client={pipecatClient} />` from `@voqalize/avatar`, and the dispatcher
391
+ inside it turns these messages into the calls above:
350
392
 
351
393
  ```json
352
- { "type": "avatar", "v": 1, "cmd": "state", "name": "THINKING" }
394
+ { "type": "avatar", "cmd": "state", "name": "THINKING" }
353
395
  ```
354
396
 
397
+ `{"type": "avatar"}` is the whole membership test — an RTVI server-message in
398
+ that envelope is the avatar's, and one outside it is not, whoever sent it.
399
+ There is no protocol version field; forward compatibility is the ignore-unknown
400
+ rule below, which a version number would not have improved
401
+ (`docs/removed.md` § The `v` field).
402
+
355
403
  | `cmd` | payload → widget call |
356
404
  |---|---|
357
405
  | `state` | `name`, `emotion?`, `gaze?` → `setState` |
358
406
  | `interject` | `id` → `interject` |
359
- | `perform` | `actions`, `ctx` → `perform` |
407
+ | `gesture` | `id` → `gesture` (a hand gesture id, not an interjection id) |
360
408
  | `cues` | `ctx`, `from_ms`, `cues`, `final?` → splice, then `speak`/`pushCues` |
361
409
  | `speech` | `event: start\|stop`, `ctx` → anchor / release the turn clock |
362
410
  | `user` | `speaking` → `setUserSpeaking` |
363
- | `hint` | `kind: eager_eot` → app hook (listening engine may ack now) |
411
+
412
+ That is the whole wire vocabulary. `perform` (a timeline as one message) and
413
+ `hint` (an advisory with no rendering) were both on it and are not any more —
414
+ `docs/removed.md` § The `perform` command and § The `hint` command. `perform()`
415
+ itself is untouched; what went away is a *server* being able to send one.
364
416
 
365
417
  Semantics the envelope adds on top of this contract:
366
418
 
@@ -376,9 +428,9 @@ Semantics the envelope adds on top of this contract:
376
428
  residual error lands video-first — the +125 ms side of the asymmetric
377
429
  tolerance window, not the −45 ms one.
378
430
  - **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.
431
+ something the pipeline cannot infer pushes the same envelope from its own
432
+ code; it dispatches into the same handler. The backend's state heuristics are
433
+ the default layer underneath, not a competing one.
382
434
  - Unknown `cmd`s are ignored — the server may grow vocabulary ahead of
383
435
  deployed clients.
384
436
 
@@ -390,12 +442,20 @@ Agreed direction, not yet landed; backend work can anticipate it:
390
442
  applications need them — same `setState` surface, one STATES entry each.
391
443
  - Backend heuristics for the states still unmapped server-side: `CANT_HEAR`
392
444
  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.
445
+ `SEARCHING_SCREEN` and `TYPING` from tool-call names. A tool call shows
446
+ `THINKING` today and only that — an application that wants to distinguish its
447
+ own tools subclasses `AvatarStateMachine` and says so.
395
448
 
396
449
  For servers written against a pre-2026-08 version of this contract, the
397
450
  renames were: `createKiran`→`createAvatar`, gaze `CANDIDATE`→`USER` and
398
451
  `CODE_AREA`→`SCREEN_WORK`, state `WAITING_FOR_ANSWER`→`WAITING_FOR_USER`.
399
452
  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.
453
+ the listening engine, the compound states, the disagree family and the hand
454
+ gestures — landed 2026-08 and is current. `gesture` is the newest verb
455
+ (2026-08-07); a widget older than it ignores the `cmd` and drops the
456
+ `perform()` verb with a warning, which is the forward-compat rule working as
457
+ intended, so a backend may send it unconditionally.
458
+
459
+ The 0.2 release cut the wire down to the six commands in the table and the
460
+ package down to one React component. Everything it removed, and how to get any
461
+ of it back, is [removed.md](removed.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voqalize/avatar",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "description": "A JavaScript-programmable 2-D talking head for AI voice calls: server-synced lipsync, state through motion, zero dependencies.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Voqalize",
@@ -33,16 +33,8 @@
33
33
  "sideEffects": false,
34
34
  "exports": {
35
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"
36
+ "types": "./client/dist/index.d.ts",
37
+ "default": "./client/dist/index.js"
46
38
  },
47
39
  "./package.json": "./package.json"
48
40
  },
package/src/avatar.d.ts CHANGED
@@ -89,6 +89,12 @@ export type AvatarInterjectionId =
89
89
  | "GOT_IT"
90
90
  | "TAKE_YOUR_TIME";
91
91
 
92
+ /** `HAND_GESTURE_IDS` — see docs/contract-protocol.md § Hand gestures. A hand at
93
+ * the frame edge plus the face half that makes it belong to somebody. Disjoint
94
+ * from the interjection ids on purpose: `interject("WAVE")` is still the face
95
+ * alone. */
96
+ export type AvatarHandGestureId = "HI" | "BYE" | "THUMBS_UP" | "ONE_MOMENT";
97
+
92
98
  /** Rhubarb Lip Sync letter — see docs/contract-protocol.md § Speech. */
93
99
  export type VisemeLetter = "A" | "B" | "C" | "D" | "E" | "F" | "G" | "H" | "X";
94
100
 
@@ -108,7 +114,7 @@ export interface GazeCustom {
108
114
  /** A `perform()` timeline action — see docs/contract-protocol.md § Composing behavior. */
109
115
  export interface AvatarAction {
110
116
  t: number;
111
- do: "state" | "emotion" | "gaze" | "interject";
117
+ do: "state" | "emotion" | "gaze" | "interject" | "gesture";
112
118
  name?: string;
113
119
  id?: string;
114
120
  i?: number;
@@ -139,7 +145,8 @@ export interface PerformHandle {
139
145
  stop: () => void;
140
146
  }
141
147
 
142
- export type AvatarEventName = "state" | "speakEnd" | "clipEnd" | "backchannel" | "performEnd";
148
+ export type AvatarEventName =
149
+ | "state" | "speakEnd" | "clipEnd" | "backchannel" | "performEnd" | "gestureEnd";
143
150
 
144
151
  /** What a host needs to frame an avatar it has never seen: the drawing's own
145
152
  * window, and where the mouth is inside it. See CLAUDE.md § The two
@@ -158,19 +165,24 @@ export interface AvatarApi {
158
165
  pushCues(cues: Cue[]): AvatarApi;
159
166
  stopSpeaking(): AvatarApi;
160
167
  interject(id: AvatarInterjectionId | (string & {})): AvatarApi;
168
+ gesture(id: AvatarHandGestureId | (string & {})): AvatarApi;
169
+ /** +1 the viewer's right (the character's own left hand), -1 the other. */
170
+ setHandSide(dir: number): AvatarApi;
161
171
  perform(actions: AvatarAction[], o?: PerformOptions): PerformHandle;
162
- setAudioFallback(source?: HTMLMediaElement | MediaStream | null): AvatarApi;
163
- setUserAudio(source?: HTMLMediaElement | MediaStream | null): AvatarApi;
164
172
  setUserSpeaking(speaking: boolean | null): AvatarApi;
165
173
  setMouthGain(g: number): AvatarApi;
166
174
  readonly mouthGain: number;
167
175
  setGestureGain(g: number): AvatarApi;
168
176
  readonly gestureGain: number;
177
+ setMotionGain(g: number): AvatarApi;
178
+ readonly motionGain: number;
169
179
  blink(double?: boolean): AvatarApi;
180
+ /** Advance one frame by hand — only meaningful under `{ manual: true }`. */
181
+ step(dt: number): AvatarApi;
170
182
  setOverrides(o: Record<string, number> | null): AvatarApi;
171
183
  on(event: "state", fn: (name: AvatarStateName) => void): AvatarApi;
172
184
  on(event: "speakEnd" | "performEnd", fn: () => void): AvatarApi;
173
- on(event: "clipEnd" | "backchannel", fn: (id: string) => void): AvatarApi;
185
+ on(event: "clipEnd" | "backchannel" | "gestureEnd", fn: (id: string) => void): AvatarApi;
174
186
  on(event: AvatarEventName, fn: (...args: unknown[]) => void): AvatarApi;
175
187
  readonly state: AvatarStateName;
176
188
  readonly emotion: AvatarEmotionName;
@@ -178,8 +190,9 @@ export interface AvatarApi {
178
190
  readonly speaking: boolean;
179
191
  readonly performing: boolean;
180
192
  readonly clip: string | null;
193
+ /** The hand gesture in flight, or null — always null under `hand: false`. */
194
+ readonly gesturing: AvatarHandGestureId | null;
181
195
  readonly params: Record<string, number>;
182
- readonly audioLevel: number;
183
196
  readonly userSpeaking: boolean;
184
197
  readonly svg: SVGSVGElement;
185
198
  readonly meta: AvatarMeta;
@@ -209,6 +222,15 @@ export interface CreateAvatarOptions {
209
222
  theme?: unknown;
210
223
  mouthGain?: number;
211
224
  gestureGain?: number;
225
+ motionGain?: number;
226
+ /** Withhold the frame-edge hand entirely — for a face drawn in some other
227
+ * idiom, or a tile too small to spend the pixels. `gesture()` then degrades
228
+ * to the gesture's face half. Default true. */
229
+ hand?: boolean;
230
+ /** Which hand the character gestures with: +1 the viewer's right. */
231
+ handSide?: number;
232
+ /** Withhold the rAF loop so a tool can drive frames itself via `step(dt)`. */
233
+ manual?: boolean;
212
234
  }
213
235
 
214
236
  export function createAvatar(opts: CreateAvatarOptions): AvatarApi;
@@ -226,6 +248,16 @@ export const GAZE_TARGETS: Record<string, { x: number; y: number }>;
226
248
  export const EMOTION_NAMES: AvatarEmotionName[];
227
249
  export const INTERJECTIONS: Record<string, unknown>;
228
250
  export const INTERJECTION_IDS: AvatarInterjectionId[];
251
+ export const HAND_GESTURES: Record<string, Record<string, unknown>>;
252
+ export const HAND_GESTURE_IDS: AvatarHandGestureId[];
253
+ /** Asserts the two framing rules against a face's own window. Throws on
254
+ * violation — `tools/sweep.mjs` runs it for every registered avatar. */
255
+ export function checkHandFraming(meta: AvatarMeta): {
256
+ ok: true;
257
+ wristDrop: number;
258
+ outboardLimit: number;
259
+ worst: Record<string, number>;
260
+ };
229
261
  /** The subset that has (or expects) audio — the rest are silent gestures. */
230
262
  export const SPOKEN_IDS: AvatarInterjectionId[];
231
263
  export const VISEME_LETTERS: VisemeLetter[];
package/src/avatar.js CHANGED
@@ -8,10 +8,10 @@
8
8
  * avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
9
9
  * avatar.pushCues(moreCues) // streaming top-up
10
10
  * avatar.interject('OKAY')
11
+ * avatar.gesture('HI') // a hand at the frame edge + its face
11
12
  * avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
12
- * avatar.setUserAudio(micStream) // or setUserSpeaking(bool) — the
13
- * // user's voice, so listening is
14
- * // contingent instead of timed
13
+ * avatar.setUserSpeaking(bool) // the user has the floor, so listening
14
+ * // is contingent instead of timed
15
15
  *
16
16
  * Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
17
17
  * by later ones on the channels they touch; the gesture and idle layers are
@@ -36,7 +36,7 @@ import { ClipPlayer } from './clips.js';
36
36
  import { INTERJECTIONS } from './interjections.js';
37
37
  import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
38
38
  import { PerformTrack } from './perform.js';
39
- import { AudioFallback } from './audio-fallback.js';
39
+ import { createHand, HAND_GESTURES } from './hand.js';
40
40
 
41
41
  // Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
42
42
  // in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
@@ -282,11 +282,6 @@ export function createAvatar(opts = {}) {
282
282
  const gaze = new GazeLayer();
283
283
  const idle = new IdleLayer();
284
284
  const speech = new VisemeTrack();
285
- const fallback = new AudioFallback();
286
- // A second analyser for the USER's voice — same machinery, opposite
287
- // direction: this one never touches the mouth, it feeds the listening engine.
288
- const userAudio = new AudioFallback();
289
- let userAudioOn = false;
290
285
 
291
286
  let gazeOverrideByClip = null;
292
287
  const clip = new ClipPlayer({
@@ -295,10 +290,18 @@ export function createAvatar(opts = {}) {
295
290
  });
296
291
  const backchannel = new ListeningEngine((id) => { interject(id); emit('backchannel', id); });
297
292
  const performTrack = new PerformTrack();
293
+ // The hand is a sibling of the mixer, not a layer inside it: it writes SVG
294
+ // directly rather than parameter channels, because a hand at the frame edge is
295
+ // not part of the rig's body (see hand.js, and CLAUDE.md constraint 9 for the
296
+ // arm chain this replaces). `hand: false` opts out — for a face drawn in some
297
+ // other idiom, or a tile too small to spend the pixels.
298
+ const hand = opts.hand === false ? null : createHand(face.svg, face.theme, meta, { dir: opts.handSide });
298
299
 
299
300
  gaze.onLargeShift = () => idle.blink();
300
301
 
301
- const listeners = { state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [] };
302
+ const listeners = {
303
+ state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [], gestureEnd: [],
304
+ };
302
305
  const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
303
306
  clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
304
307
  speech.onEnd = () => { emit('speakEnd'); };
@@ -339,7 +342,6 @@ export function createAvatar(opts = {}) {
339
342
  let glanceUntil = 0;
340
343
  let speakClock = null;
341
344
  let speakStart = 0;
342
- let useFallback = false;
343
345
 
344
346
  const cur = Object.assign({}, REST);
345
347
  const target = Object.assign({}, REST);
@@ -426,7 +428,6 @@ export function createAvatar(opts = {}) {
426
428
  }
427
429
  }
428
430
  backchannel.enabled = !!st.backchannel && !clip.playing;
429
- if (userAudioOn) { userAudio.sample(dt); backchannel.observeLevel(userAudio.level); }
430
431
  backchannel.update(dt);
431
432
  // Engagement posture: forward lean while the user holds the floor, spent
432
433
  // only in the states that are *about* the user holding the floor. The
@@ -439,12 +440,14 @@ export function createAvatar(opts = {}) {
439
440
  // costs nothing; with no user signal the static pose carries the state.
440
441
  else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * backchannel.engage;
441
442
 
442
- // 4. mouth. Server track wins; then clip track; then a fallback analyser.
443
+ // 4. mouth. The server's viseme track wins; a clip's mouth track fills the
444
+ // gaps. There is deliberately no third leg: with no cues the mouth stays
445
+ // shut, and a still mouth under speech is the *visible* symptom of a
446
+ // backend that could not align — see docs/removed.md § Amplitude lipsync.
443
447
  const clipOut = clip.update(dtMs);
444
448
  let mouth = speech.sample();
445
449
  let mouthOwner = mouth ? 'speech' : null;
446
450
  if (!mouth && clipOut.ownsMouth && clipOut.mouth) { mouth = clipOut.mouth; mouthOwner = 'clip'; }
447
- if (!mouth && useFallback) { mouth = fallback.sample(dt); mouthOwner = 'fallback'; }
448
451
  if (mouth) {
449
452
  const shape = mouth.letter !== SILENT
450
453
  ? shapeFor(mouth.letter, mouth.intensity)
@@ -504,6 +507,16 @@ export function createAvatar(opts = {}) {
504
507
  for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
505
508
 
506
509
  face.apply(cur);
510
+
511
+ // 9. the hand, outside all of the above. It writes a transform on its own
512
+ // group rather than parameter channels, so it neither smooths nor
513
+ // composes — its timelines are authored as delivered motion, not as
514
+ // targets to chase. `elapsed` is the mixer's own clock, so a manual
515
+ // stepper gets a reproducible gesture for free.
516
+ if (hand) {
517
+ const done = hand.update(elapsed * 1000);
518
+ if (done) emit('gestureEnd', done.id);
519
+ }
507
520
  }
508
521
 
509
522
  const REST_SHAPE = shapeFor(SILENT, 1);
@@ -591,28 +604,39 @@ export function createAvatar(opts = {}) {
591
604
  return api;
592
605
  }
593
606
 
594
- function setAudioFallback(source) {
595
- if (!source) { fallback.detach(); useFallback = false; return api; }
596
- fallback.attach(source);
597
- useFallback = true;
598
- return api;
599
- }
600
-
601
607
  /**
602
- * Give the listening engine the USER's voice, so backchannels become
603
- * contingent on their pauses instead of running on a timer. Either works,
604
- * and the flag wins when both are driven:
605
- * setUserAudio(streamOrElement) — the widget runs its own coarse VAD
606
- * setUserSpeaking(bool) — the host (or server endpointer) decides;
607
- * pass null to hand back to the level VAD
608
+ * A hand gesture: the hand at the frame edge, plus the face half that makes it
609
+ * belong to somebody.
610
+ *
611
+ * The face half is not a convenience — a hand rising to the jaw over a head
612
+ * and shoulders sitting perfectly still is a cut-out, not a gesture. Each
613
+ * entry in HAND_GESTURES names an interjection that already exists and was
614
+ * already tuned (`WAVE`, `THUMBS_UP`, `ONE_MOMENT`); firing it here is the
615
+ * library composing two authored things, not the client inventing motion.
616
+ *
617
+ * The two halves stay separable in both directions. `interject('WAVE')` is
618
+ * still the face alone and is unchanged by this — a server that upgrades gets
619
+ * no new behaviour until it asks for one — and on an avatar mounted with
620
+ * `hand: false` this call degrades to exactly that interjection, which is the
621
+ * same graceful failure the arm removal already forced every id through.
608
622
  */
609
- function setUserAudio(source) {
610
- if (!source) { userAudio.detach(); userAudioOn = false; return api; }
611
- userAudio.attach(source);
612
- userAudioOn = true;
623
+ function gesture(id) {
624
+ const def = HAND_GESTURES[id];
625
+ if (!def) throw new Error(`unknown hand gesture: ${id}`);
626
+ if (hand) hand.play(id, elapsed * 1000);
627
+ if (def.face) interject(def.face);
628
+ // A hand in frame is a deliberate move; a backchannel landing on top of it
629
+ // is the listening engine talking over the server.
630
+ backchannel.reset(def.dur / 1000 + 0.5);
613
631
  return api;
614
632
  }
615
633
 
634
+ /**
635
+ * Tell the listening engine whether the USER holds the floor, so backchannels
636
+ * become contingent on their pauses instead of running on a timer. The server
637
+ * owns this — it has the endpointer — and sends it as the `user` command;
638
+ * `null` hands back to the no-signal timer fallback.
639
+ */
616
640
  function setUserSpeaking(b) { backchannel.setUserSpeaking(b); return api; }
617
641
 
618
642
  // What one action does when its moment comes. Enum validity is checked here,
@@ -627,6 +651,7 @@ export function createAvatar(opts = {}) {
627
651
  else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
628
652
  else if (a.do === 'gaze') setGaze(a.name);
629
653
  else if (a.do === 'interject') interject(a.id);
654
+ else if (a.do === 'gesture') gesture(a.id);
630
655
  } catch (e) {
631
656
  console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
632
657
  }
@@ -636,8 +661,8 @@ export function createAvatar(opts = {}) {
636
661
 
637
662
  /**
638
663
  * Play a timed action track — the composition surface a server assembles
639
- * turns from. Verbs: state / emotion / gaze / interject (see perform.js for
640
- * hygiene, docs/contract-protocol.md for the schema).
664
+ * turns from. Verbs: state / emotion / gaze / interject / gesture (see
665
+ * perform.js for hygiene, docs/contract-protocol.md for the schema).
641
666
  *
642
667
  * Clock resolution mirrors speak(): explicit `clock` fn, else the audio
643
668
  * element's own time, else ms elapsed since this call. perform() never
@@ -667,8 +692,12 @@ export function createAvatar(opts = {}) {
667
692
 
668
693
  const api = {
669
694
  setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, interject,
670
- perform,
671
- setAudioFallback, setUserAudio, setUserSpeaking,
695
+ gesture, perform,
696
+ /** Which hand the character gestures with: +1 the viewer's right (its own
697
+ * left), -1 the other. Both are anatomically real — the thumb splays away
698
+ * from the body either way — so this is a character choice, not a fix. */
699
+ setHandSide: (d) => { if (hand) hand.setDir(d); return api; },
700
+ setUserSpeaking,
672
701
  /** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
673
702
  setMouthGain: (g) => { mouthGain = g; return api; },
674
703
  get mouthGain() { return mouthGain; },
@@ -691,8 +720,9 @@ export function createAvatar(opts = {}) {
691
720
  get speaking() { return speech.playing; },
692
721
  get performing() { return performTrack.playing; },
693
722
  get clip() { return clip.id; },
723
+ /** The hand gesture in flight, or null. `null` forever if `hand: false`. */
724
+ get gesturing() { return hand ? hand.id : null; },
694
725
  get params() { return cur; },
695
- get audioLevel() { return fallback.level; },
696
726
  get userSpeaking() { return backchannel.speaking; },
697
727
  svg: face.svg,
698
728
  meta,
@@ -701,7 +731,11 @@ export function createAvatar(opts = {}) {
701
731
  * page behind a transparent mount — needs the same colours the drawing
702
732
  * used, and guessing them per avatar is how the two drift apart. */
703
733
  theme: face.theme,
704
- destroy() { cancelAnimationFrame(raf); fallback.detach(); userAudio.detach(); face.destroy(); },
734
+ destroy() {
735
+ cancelAnimationFrame(raf);
736
+ if (hand) hand.destroy();
737
+ face.destroy();
738
+ },
705
739
  };
706
740
 
707
741
  setState('IDLE');
@@ -712,6 +746,7 @@ export function createAvatar(opts = {}) {
712
746
  export { INTERJECTIONS, INTERJECTION_IDS, SPOKEN_IDS, attachAudio } from './interjections.js';
713
747
  export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
714
748
  export { normalizeActions } from './perform.js';
749
+ export { HAND_GESTURES, HAND_GESTURE_IDS, checkHandFraming } from './hand.js';
715
750
  export { EMOTION_NAMES } from './emotions.js';
716
751
  export {
717
752
  VISEME_LETTERS, VISEME_SHAPES, normalizeCues, textToCues,