@voqalize/avatar 0.1.0 → 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.
- package/README.md +86 -51
- package/client/dist/Avatar.d.ts +17 -14
- package/client/dist/Avatar.d.ts.map +1 -1
- package/client/dist/Avatar.js +3 -3
- package/client/dist/Avatar.js.map +1 -1
- package/client/dist/AvatarClient.d.ts +23 -57
- package/client/dist/AvatarClient.d.ts.map +1 -1
- package/client/dist/AvatarClient.js +20 -71
- package/client/dist/AvatarClient.js.map +1 -1
- package/client/dist/index.d.ts +22 -0
- package/client/dist/index.d.ts.map +1 -0
- package/client/dist/index.js +22 -0
- package/client/dist/index.js.map +1 -0
- package/client/dist/types.d.ts +22 -37
- package/client/dist/types.d.ts.map +1 -1
- package/client/dist/types.js +12 -12
- package/client/dist/types.js.map +1 -1
- package/client/dist/useAvatar.d.ts +8 -19
- package/client/dist/useAvatar.d.ts.map +1 -1
- package/client/dist/useAvatar.js +15 -32
- package/client/dist/useAvatar.js.map +1 -1
- package/client/src/Avatar.tsx +19 -24
- package/client/src/AvatarClient.ts +38 -111
- package/client/src/index.ts +22 -0
- package/client/src/types.ts +24 -47
- package/client/src/useAvatar.ts +19 -47
- package/docs/contract-avatar.md +37 -3
- package/docs/contract-protocol.md +90 -30
- package/package.json +3 -11
- package/src/avatar.d.ts +38 -6
- package/src/avatar.js +72 -37
- package/src/hand.js +680 -0
- package/src/idle.js +8 -25
- package/src/line-art.js +26 -0
- package/src/perform.js +8 -4
- package/client/dist/pipecat.d.ts +0 -21
- package/client/dist/pipecat.d.ts.map +0 -1
- package/client/dist/pipecat.js +0 -21
- package/client/dist/pipecat.js.map +0 -1
- package/client/dist/react.d.ts +0 -16
- package/client/dist/react.d.ts.map +0 -1
- package/client/dist/react.js +0 -17
- package/client/dist/react.js.map +0 -1
- package/client/src/pipecat.ts +0 -38
- package/client/src/react.ts +0 -34
- 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
|
|
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 — `
|
|
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).
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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), `
|
|
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
|
|
349
|
-
`@voqalize/avatar
|
|
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", "
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
394
|
-
|
|
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
|
|
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.
|
|
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": "./
|
|
37
|
-
"default": "./
|
|
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 =
|
|
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.
|
|
13
|
-
* //
|
|
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 {
|
|
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 = {
|
|
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.
|
|
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
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
607
|
-
*
|
|
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
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
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
|
-
|
|
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() {
|
|
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,
|