@voqalize/avatar 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
|
@@ -1,461 +0,0 @@
|
|
|
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, a hand-gesture 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, interjection and gesture ids
|
|
23
|
-
**throw**;
|
|
24
|
-
unknown emotion falls back to `neutral` silently; unknown gaze falls back to
|
|
25
|
-
`USER` silently.
|
|
26
|
-
|
|
27
|
-
## States — `setState(name, { emotion?, intensity?, gaze?, keepGaze? })`
|
|
28
|
-
|
|
29
|
-
A state is a *condition*, not an event: it holds until replaced. Each state
|
|
30
|
-
bundles a default gaze, emotion, idle-energy level and whether autonomous
|
|
31
|
-
backchannel nods run. Passing `emotion`/`gaze` overrides the bundle;
|
|
32
|
-
`keepGaze: true` preserves whatever gaze was already set.
|
|
33
|
-
|
|
34
|
-
| state | send when | behaviour highlights |
|
|
35
|
-
|---|---|---|
|
|
36
|
-
| `IDLE` | nothing is happening | full idle motion, gaze on user |
|
|
37
|
-
| `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) |
|
|
38
|
-
| `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 |
|
|
39
|
-
| `SPEAKING` | agent audio is playing | reduced idle sway so the head is stable while talking. `speak()` enters it automatically |
|
|
40
|
-
| `REVIEWING_SCREEN` | the agent is reading the shared screen | gaze wanders across screen targets every 1.8–5 s |
|
|
41
|
-
| `WAITING_FOR_USER` | the agent asked something and the floor is the user's | encouraging affect, raised brows, head tilt; backchannel on |
|
|
42
|
-
| `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 |
|
|
43
|
-
| `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 |
|
|
44
|
-
| `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 |
|
|
45
|
-
| `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* |
|
|
46
|
-
| `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 |
|
|
47
|
-
| `TAKING_FLOOR` | ~350 ms before agent audio starts | inbreath pose: shoulders rise, lips part, lean in |
|
|
48
|
-
| `WANTS_IN` | the agent wants the floor but won't barge in | stronger, *stiller* version of the same bid — holds until the user notices |
|
|
49
|
-
| `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 |
|
|
50
|
-
| `DEGRADED` | the connection/pipeline is impaired | drowsy lids + desaturating CSS filter |
|
|
51
|
-
| `OFFLINE` | the agent is gone | lids nearly shut, grayscale |
|
|
52
|
-
|
|
53
|
-
The `state` enum is exported as `STATE_NAMES`, and every state's full recipe as
|
|
54
|
-
`STATES`.
|
|
55
|
-
|
|
56
|
-
## Emotion — `setEmotion(name, intensity = 1)`
|
|
57
|
-
|
|
58
|
-
Affect is a separate axis from state, so the enums don't multiply.
|
|
59
|
-
Six values (`EMOTION_NAMES`): `neutral`, `warm`, `curious`, `concerned`,
|
|
60
|
-
`encouraging`, `thoughtful`. `intensity` scales the pose linearly toward
|
|
61
|
-
neutral; it is not clamped, but past ~1.3 poses saturate against channel
|
|
62
|
-
clamps. Entering a state *adopts that state's default emotion* unless you pass
|
|
63
|
-
one explicitly.
|
|
64
|
-
|
|
65
|
-
## Gaze — `setGaze(name, custom?)`
|
|
66
|
-
|
|
67
|
-
Semantic directions; the client does the oculomotor work (ballistic eyes,
|
|
68
|
-
lagging under-rotated head, gaze-evoked blink). Twelve names (`GAZE_NAMES`):
|
|
69
|
-
|
|
70
|
-
| target | meaning |
|
|
71
|
-
|---|---|
|
|
72
|
-
| `USER` | down the webcam barrel — the conversational default |
|
|
73
|
-
| `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 |
|
|
74
|
-
| `SCREEN_CENTER` / `SCREEN_LEFT` / `SCREEN_RIGHT` / `SCREEN_TOP` / `SCREEN_BOTTOM` | regions of the shared screen |
|
|
75
|
-
| `SCREEN_WORK` | lower-left work area of the shared screen |
|
|
76
|
-
| `NOTES` | down-right glance at the agent's own notes |
|
|
77
|
-
| `AWAY_THINKING` | up-left "recalling" break of eye contact — the stylized "let me think" beat |
|
|
78
|
-
| `AWAY_RIGHT` | up-right variant |
|
|
79
|
-
| `AWAY_DOWN` | down-left considering — measured cognitive aversion is mostly downward, so this is the one long THINKING dwells use |
|
|
80
|
-
|
|
81
|
-
Escape hatch: `setGaze('CUSTOM', { x, y })` with normalized −1..1 screen
|
|
82
|
-
coordinates, for when the server knows exactly where something is. (Any name
|
|
83
|
-
plus a `custom` object works; the coordinates win.)
|
|
84
|
-
|
|
85
|
-
**Floor-passing rule (server-side):** do not command a gaze aversion in the
|
|
86
|
-
final ~2.4 s of the agent's own utterance. Human speakers return to mutual
|
|
87
|
-
gaze before they stop talking; an agent that ends its turn looking away fails
|
|
88
|
-
to pass the floor, and the user sits waiting for a signal that never comes.
|
|
89
|
-
|
|
90
|
-
Gaze is also set implicitly by states (table above) and temporarily by a few
|
|
91
|
-
interjections (`HMM`, `ONE_MOMENT`); a clip's gaze wins for the clip's
|
|
92
|
-
duration, then releases.
|
|
93
|
-
|
|
94
|
-
## Interjections — `interject(id)`
|
|
95
|
-
|
|
96
|
-
One-shot gesture clips with baked plausible timings, so they are convincing
|
|
97
|
-
with **no audio attached**. `attachAudio(id, url)` upgrades any of them with a
|
|
98
|
-
real TTS clip later; the clip then clocks itself off that audio. Durations
|
|
99
|
-
below are the baked defaults, in ms. Full list: `INTERJECTION_IDS`; the 12 with
|
|
100
|
-
spoken text: `SPOKEN_IDS`.
|
|
101
|
-
|
|
102
|
-
**Wordless backchannel** — fire while the user talks; these are also fired
|
|
103
|
-
autonomously in `LISTENING`/`WAITING_FOR_USER` by the listening engine (see
|
|
104
|
-
[The user's voice](#the-users-voice--setuseraudiosource-setuserspeakingbool)):
|
|
105
|
-
contingently on the user's pauses when the host supplies a voice signal, on a
|
|
106
|
-
loose 3.4–8 s timer when it never does. The picker answers what the user just
|
|
107
|
-
did: mid-speech stays minimal (70% `NOD_SMALL` / 30% `BROW_ACK`); a pause
|
|
108
|
-
after a *short* utterance draws from continuer country (55% `NOD_SMALL`, 25%
|
|
109
|
-
`BROW_ACK`, 20% `NOD_SLOW`); a pause after a **long** utterance (≥4 s) earns
|
|
110
|
-
the assessment class (45% `NOD_SLOW`, 20% `NOD_UP`, 20% `NOD_SMALL`, 15%
|
|
111
|
-
`BROW_ACK`). The three nods are the mocap taxonomy — continuer / assessment /
|
|
112
|
-
realization — with authored cycle decay, and all sit under the 1.5 Hz line
|
|
113
|
-
where a nod flips from attention to impatience.
|
|
114
|
-
|
|
115
|
-
| id | dur | intent |
|
|
116
|
-
|---|---|---|
|
|
117
|
-
| `NOD_SMALL` | 800 | single-cycle continuer — "go on" |
|
|
118
|
-
| `NOD_SLOW` | 1420 | two-beat assessment — first beat deepest, second decayed; "I agree" |
|
|
119
|
-
| `NOD_UP` | 1750 | realization — rises *before* it commits, brows leading; "ah, I see" |
|
|
120
|
-
| `BROW_ACK` | 720 | eyebrow acknowledgement, no head commitment |
|
|
121
|
-
| `HEAD_SHAKE` | 1350 | firm "no" — two decaying yaw cycles (~1.5 Hz), lowered brows, mouth firmed flat. **Server-sent only**: disagreement is never autonomous |
|
|
122
|
-
| `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 |
|
|
123
|
-
| `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 |
|
|
124
|
-
|
|
125
|
-
**Floor management** — pair with the floor states.
|
|
126
|
-
|
|
127
|
-
| id | dur | intent |
|
|
128
|
-
|---|---|---|
|
|
129
|
-
| `CLAIM_FLOOR` | 480 | visible inhale; fire ~350 ms before audio starts. Ends *held*, not resolved |
|
|
130
|
-
| `YIELD_FLOOR` | 420 | interrupted: lips shut within ~50 ms, recoil |
|
|
131
|
-
| `RAISE_HAND` | 1600 | "may I come in" — long held plateau is the message |
|
|
132
|
-
|
|
133
|
-
**Re-authored gestures** — these ids predate the arm removal and keep their
|
|
134
|
-
wire meaning, now performed by face, shoulders and torso.
|
|
135
|
-
|
|
136
|
-
| id | dur | intent |
|
|
137
|
-
|---|---|---|
|
|
138
|
-
| `WAVE` | 1300 | greeting (eyebrow flash) |
|
|
139
|
-
| `THUMBS_UP` | 1500 | approval (deep slow nod + broad smile) |
|
|
140
|
-
| `SHRUG` | 1250 | don't-know (shoulders to maximum, held) |
|
|
141
|
-
| `GO_ON_ARM` | 1400 | emphatic "go on" |
|
|
142
|
-
|
|
143
|
-
**Spoken** — carry text and a hand-tuned viseme track; silent but plausible
|
|
144
|
-
until `attachAudio` gives them a voice.
|
|
145
|
-
|
|
146
|
-
| id | dur | | id | dur |
|
|
147
|
-
|---|---|---|---|---|
|
|
148
|
-
| `MM_HMM` | 820 | | `GO_ON` | 820 |
|
|
149
|
-
| `OKAY` | 860 | | `ONE_MOMENT` | 1350 |
|
|
150
|
-
| `YES` | 740 | | `SORRY` | 1050 |
|
|
151
|
-
| `SURE` | 860 | | `HMM` | 1250 |
|
|
152
|
-
| `I_SEE` | 1050 | | `GOT_IT` | 820 |
|
|
153
|
-
| `RIGHT` | 740 | | `TAKE_YOUR_TIME` | 1500 |
|
|
154
|
-
|
|
155
|
-
A repeated `interject(id)` while that clip is already playing is collapsed to a
|
|
156
|
-
no-op; a *different* id replaces the running clip immediately.
|
|
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
|
-
|
|
196
|
-
## Speech — `speak({ cues, audio?, clock? })`, `pushCues(cues)`, `stopSpeaking()`
|
|
197
|
-
|
|
198
|
-
The headline feature. A **cue** is:
|
|
199
|
-
|
|
200
|
-
```js
|
|
201
|
-
{ t: 1234, // ms offset into the utterance
|
|
202
|
-
v: 'D', // Rhubarb letter A–H, or X for silence
|
|
203
|
-
i: 0.8 } // optional 0..1 loudness; omit for 1
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
Letters are the Rhubarb Lip Sync alphabet (a condensation of the Preston Blair
|
|
207
|
-
set): `A` closed (P/B/M), `B` teeth together (most consonants), `C` open,
|
|
208
|
-
`D` wide open, `E` rounded, `F` puckered, `G` lip-to-teeth (F/V), `H` tongue up
|
|
209
|
-
(L), `X` silence. Exported: `VISEME_LETTERS`, `VISEME_SHAPES`.
|
|
210
|
-
|
|
211
|
-
Rules the widget enforces (`normalizeCues`, applied to every track):
|
|
212
|
-
|
|
213
|
-
- cues are sorted by `t`; consecutive duplicates merge;
|
|
214
|
-
- cues shorter than **30 ms** are dropped — except that a closure (`A`/`G`)
|
|
215
|
-
replaces the cue it collapses into, because closures carry the most
|
|
216
|
-
lip-reading information;
|
|
217
|
-
- unknown letters become `X`.
|
|
218
|
-
|
|
219
|
-
So the server may emit noisy tracks; it should still try to end every
|
|
220
|
-
utterance with an explicit `X` cue (the track only completes on a trailing
|
|
221
|
-
`X`).
|
|
222
|
-
|
|
223
|
-
**Clock.** Cues are scheduled against the *audio clock*, never wall time.
|
|
224
|
-
Pass `audio` (an `HTMLMediaElement`; the widget uses `currentTime` and will
|
|
225
|
-
call `play()` if paused) or `clock` (a `() => ms` function, for WebAudio or
|
|
226
|
-
server-driven time). Wall time is the fallback only when neither is given.
|
|
227
|
-
The mouth runs **40 ms ahead** of the clock (`LEAD_MS`): perceptual tolerance
|
|
228
|
-
is asymmetric (about −45 ms audio-first to +125 ms video-first), so leading is
|
|
229
|
-
the safe side.
|
|
230
|
-
|
|
231
|
-
**Streaming.** `pushCues(cues)` appends mid-utterance — send cues in chunks as
|
|
232
|
-
TTS produces them; the merged track is re-normalized each push.
|
|
233
|
-
|
|
234
|
-
`speak()` auto-enters `SPEAKING` (keeping the current gaze) and kills any
|
|
235
|
-
spoken interjection in flight. `speakEnd` fires when the track completes.
|
|
236
|
-
|
|
237
|
-
## The user's voice — `setUserSpeaking(bool)`
|
|
238
|
-
|
|
239
|
-
Backchannels only create rapport when they are *contingent* on the speaker —
|
|
240
|
-
identical nods on a timer measurably read as distracting
|
|
241
|
-
(docs/research-biomechanics.md §3.5). Tell the widget when the user holds the
|
|
242
|
-
floor and the listening engine does the rest.
|
|
243
|
-
|
|
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.
|
|
249
|
-
|
|
250
|
-
While `LISTENING`/`WAITING_FOR_USER` with a signal supplied: an
|
|
251
|
-
acknowledgement fires 250–600 ms after a user pause onset, on about half of
|
|
252
|
-
pauses, never more often than every 2.5 s; long unbroken user speech earns a
|
|
253
|
-
rare minimal mid-speech nod; and the avatar leans in (`torsoLean` up to
|
|
254
|
-
+0.16) while the user holds the floor, relaxing after ~8 s of silence. Every
|
|
255
|
-
autonomous fire is announced via `on('backchannel', id)`, so a server can log
|
|
256
|
-
exactly what the widget did on its own.
|
|
257
|
-
|
|
258
|
-
If neither method is ever called, the engine falls back to the loose 3.4–8 s
|
|
259
|
-
timer — a worse listener, never a dead one. Server-driven `interject()` calls
|
|
260
|
-
push the autonomous scheduler out (min 2.5 s), so the two sources never pile
|
|
261
|
-
up.
|
|
262
|
-
|
|
263
|
-
### The mouth priority rule (invariant)
|
|
264
|
-
|
|
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.
|
|
271
|
-
|
|
272
|
-
## Events, gains, introspection
|
|
273
|
-
|
|
274
|
-
- `on('state', fn)` — state changed (fires with the new name)
|
|
275
|
-
- `on('speakEnd', fn)` — cue track completed
|
|
276
|
-
- `on('clipEnd', fn)` — interjection finished (fires with its id)
|
|
277
|
-
- `on('backchannel', fn)` — the listening engine fired an acknowledgement on
|
|
278
|
-
its own (fires with the interjection id)
|
|
279
|
-
- `on('performEnd', fn)` — a performance's last action has fired (see
|
|
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*
|
|
283
|
-
- `setMouthGain(g)` — scales viseme excursion away from rest (1 = as authored;
|
|
284
|
-
useful when the avatar renders small). Never drags a closed mouth open.
|
|
285
|
-
- `setGestureGain(g)` — scales clip deltas; small gestures under-render
|
|
286
|
-
through the head's smoothing, and this is the knob that compensates.
|
|
287
|
-
- `setMotionGain(g)` — scales the idle liveness layer as a whole: breath,
|
|
288
|
-
sway, postural weight shifts, the body's share of speech emphasis. A host
|
|
289
|
-
rendering the avatar into a small tile, or one that re-encodes it into a
|
|
290
|
-
video stream where motion costs bitrate, can turn it down; 0 freezes the
|
|
291
|
-
body without freezing blinks, gaze or visemes. Where "alive" stops and
|
|
292
|
-
"fidgety" starts moves with tile size and with the audience, so this is
|
|
293
|
-
deliberately a host decision rather than a constant.
|
|
294
|
-
- Getters: `state`, `emotion`, `gaze`, `speaking`, `performing`, `clip`,
|
|
295
|
-
`gesturing`, `params` (the live smoothed vector), `svg`.
|
|
296
|
-
- `setOverrides({channel: value})` — direct parameter injection, post-clamp.
|
|
297
|
-
For tuning UIs and tests, not production.
|
|
298
|
-
- `blink(double?)`, `destroy()`.
|
|
299
|
-
|
|
300
|
-
## Producing cues server-side
|
|
301
|
-
|
|
302
|
-
Three tiers, best first — full recipes with code are in
|
|
303
|
-
[README.md § Getting mouth shapes out of speech](../README.md):
|
|
304
|
-
|
|
305
|
-
1. **Native TTS viseme events** (Azure et al.): map integer viseme ids through
|
|
306
|
-
`AZURE_VISEME_TO_LETTER`, ship `{t, v}` as they stream. Nearly free.
|
|
307
|
-
2. **Forced alignment** (any TTS): phonemize + align (MFA, gentle, or
|
|
308
|
-
`rhubarb-lip-sync` directly — our letters *are* Rhubarb's), then map ARPAbet
|
|
309
|
-
through `ARPABET_TO_VISEME`.
|
|
310
|
-
3. **No server work**: the client amplitude fallback, or `textToCues(text)` —
|
|
311
|
-
a crude grapheme guesser fit for previews only.
|
|
312
|
-
|
|
313
|
-
`experiments/rhubarb-textsync/` derived letters from *text* before audio
|
|
314
|
-
exists, to keep model-init cost off the time-to-first-audio path. It graduated:
|
|
315
|
-
the production form is `native/avatarsync/`, one resident binary serving both a
|
|
316
|
-
~0.2 ms text leg and a ~15–35 ms warm audio-recognition leg, driven by
|
|
317
|
-
`voqalize-avatar` (see *The reference backend*).
|
|
318
|
-
|
|
319
|
-
## Composing behavior: `perform(actions, { audio?, clock?, onAction? })`
|
|
320
|
-
|
|
321
|
-
The composition surface. A **performance** is a list of timed verbs fired
|
|
322
|
-
against a clock; each verb resolves to one of the enums above. This is how a
|
|
323
|
-
backend assembles a turn: it sequences from a constrained vocabulary and
|
|
324
|
-
cannot invent motion — every wire-visible move is something that was authored
|
|
325
|
-
and tuned on the rig. A backend wanting a new move asks for a new enum entry
|
|
326
|
-
(a state, an interjection), never for a channel-level escape hatch.
|
|
327
|
-
|
|
328
|
-
```js
|
|
329
|
-
{ "t": 4200, "do": "emotion", "name": "warm", "i": 0.8 }
|
|
330
|
-
{ "t": 5100, "do": "gaze", "name": "SCREEN_WORK" }
|
|
331
|
-
{ "t": 6300, "do": "interject", "id": "NOD_SMALL" }
|
|
332
|
-
{ "t": 8000, "do": "state", "name": "WAITING_FOR_USER" }
|
|
333
|
-
```
|
|
334
|
-
|
|
335
|
-
| verb | args | dispatches to |
|
|
336
|
-
|---|---|---|
|
|
337
|
-
| `state` | `name`, `keepGaze?` (default **true**) | `setState(name, {keepGaze})` |
|
|
338
|
-
| `emotion` | `name`, `i?` 0..1 (default 1) | `setEmotion(name, i)` |
|
|
339
|
-
| `gaze` | `name` | `setGaze(name)` |
|
|
340
|
-
| `interject` | `id` | `interject(id)` |
|
|
341
|
-
| `gesture` | `id` | `gesture(id)` — the hand *and* its face half |
|
|
342
|
-
|
|
343
|
-
The natural unit a server assembles is audio + cue track + action track on
|
|
344
|
-
**one clock** (`demo/perf-clips.json` scripts every demo turn this way, and
|
|
345
|
-
`demo/floor.js` plays them through this API):
|
|
346
|
-
|
|
347
|
-
```js
|
|
348
|
-
avatar.speak({ cues, audio }); // the utterance
|
|
349
|
-
avatar.perform(turn.beats, { audio }); // its choreography, same clock
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
Rules:
|
|
353
|
-
|
|
354
|
-
- **Clock** resolves like `speak()`: explicit `clock` fn > `audio.currentTime`
|
|
355
|
-
> ms elapsed since the call. Ride the audio element you speak with.
|
|
356
|
-
`perform` never starts or stops audio — `speak` owns the sound.
|
|
357
|
-
- **Times fire verbatim** — no `LEAD_MS`. Visemes lead the audio because
|
|
358
|
-
phoneme sync is frame-critical; gestures arrive through their channels'
|
|
359
|
-
smoothing lag, and a deliberate lead (`CLAIM_FLOOR` ~350 ms before the first
|
|
360
|
-
sample) is authored into `t` by the composer.
|
|
361
|
-
- **There is no `speak` verb.** Speech defines the clock a performance rides
|
|
362
|
-
on; a timeline that could start new audio would be a clock inside a clock,
|
|
363
|
-
and stopping it would have to answer for the cue track too. The utterance
|
|
364
|
-
and its choreography stay sibling calls against the same element.
|
|
365
|
-
- **`state` defaults to `keepGaze: true`** inside a performance: a timeline
|
|
366
|
-
that wants the gaze moved says so with a `gaze` verb at the moment it means.
|
|
367
|
-
- **Hygiene** (`normalizeActions`, exported): actions are sorted by `t`;
|
|
368
|
-
entries with no finite `t`, an unknown verb, or a missing `name`/`id` are
|
|
369
|
-
dropped with a console warning. Enum values are checked when the verb
|
|
370
|
-
*fires*: a bad one warns and is skipped. A malformed action never breaks the
|
|
371
|
-
performance around it.
|
|
372
|
-
- A new `perform()` replaces the running one. The returned handle's `stop()`
|
|
373
|
-
cancels **future actions only** — an in-flight interjection finishes, a live
|
|
374
|
-
cue track is untouched — and `performEnd` does not fire. A handle whose
|
|
375
|
-
performance was already replaced is a no-op.
|
|
376
|
-
- `on('performEnd')` fires when the last action has *fired*, not when its
|
|
377
|
-
effects (a still-playing interjection, say) finish rendering.
|
|
378
|
-
- `onAction(a)` is called after each verb dispatches — the telemetry/log hook;
|
|
379
|
-
the demo's token stream uses it.
|
|
380
|
-
- Seeking the audio backward does not re-fire earlier actions.
|
|
381
|
-
|
|
382
|
-
## The reference backend — `voqalize-avatar`
|
|
383
|
-
|
|
384
|
-
This contract has a living server implementation in this repo: the `py/`
|
|
385
|
-
package (`pip install voqalize-avatar`), a pipecat `FrameProcessor` that infers
|
|
386
|
-
the base states from stock frames and emits the envelope below as RTVI
|
|
387
|
-
server-messages. Design and rationale:
|
|
388
|
-
[design-library-split.md](design-library-split.md). A host driving the widget
|
|
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:
|
|
392
|
-
|
|
393
|
-
```json
|
|
394
|
-
{ "type": "avatar", "cmd": "state", "name": "THINKING" }
|
|
395
|
-
```
|
|
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
|
-
|
|
403
|
-
| `cmd` | payload → widget call |
|
|
404
|
-
|---|---|
|
|
405
|
-
| `state` | `name`, `emotion?`, `gaze?` → `setState` |
|
|
406
|
-
| `interject` | `id` → `interject` |
|
|
407
|
-
| `gesture` | `id` → `gesture` (a hand gesture id, not an interjection id) |
|
|
408
|
-
| `cues` | `ctx`, `from_ms`, `cues`, `final?` → splice, then `speak`/`pushCues` |
|
|
409
|
-
| `speech` | `event: start\|stop`, `ctx` → anchor / release the turn clock |
|
|
410
|
-
| `user` | `speaking` → `setUserSpeaking` |
|
|
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.
|
|
416
|
-
|
|
417
|
-
Semantics the envelope adds on top of this contract:
|
|
418
|
-
|
|
419
|
-
- **The splice.** `speak()` replaces and `pushCues()` only appends, so tail
|
|
420
|
-
replacement is the *driver's* job: the client wrapper keeps the turn's
|
|
421
|
-
canonical track, discards queued cues with `t >= from_ms`, appends, and
|
|
422
|
-
re-issues `speak()` on the turn's original clock when anything was
|
|
423
|
-
discarded. This is how the server's fast text-predicted cues are overwritten
|
|
424
|
-
by audio-recognized ones mid-turn without the widget ever seeing a seam.
|
|
425
|
-
- **The anchor.** `speech start` (sent on the server's playout-true
|
|
426
|
-
bot-started-speaking signal) sets the turn's t=0; the cue clock is
|
|
427
|
-
`performance.now() - t0`. The data channel beats jitter-buffered audio, so
|
|
428
|
-
residual error lands video-first — the +125 ms side of the asymmetric
|
|
429
|
-
tolerance window, not the −45 ms one.
|
|
430
|
-
- **Explicit instructions override heuristics.** An application that knows
|
|
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.
|
|
434
|
-
- Unknown `cmd`s are ignored — the server may grow vocabulary ahead of
|
|
435
|
-
deployed clients.
|
|
436
|
-
|
|
437
|
-
## Direction
|
|
438
|
-
|
|
439
|
-
Agreed direction, not yet landed; backend work can anticipate it:
|
|
440
|
-
|
|
441
|
-
- More compound application states in the `TYPING`/`DISTRACTED` mould as
|
|
442
|
-
applications need them — same `setState` surface, one STATES entry each.
|
|
443
|
-
- Backend heuristics for the states still unmapped server-side: `CANT_HEAR`
|
|
444
|
-
from the STT's own confidence signal plus user volume; `DISTRACTED`,
|
|
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.
|
|
448
|
-
|
|
449
|
-
For servers written against a pre-2026-08 version of this contract, the
|
|
450
|
-
renames were: `createKiran`→`createAvatar`, gaze `CANDIDATE`→`USER` and
|
|
451
|
-
`CODE_AREA`→`SCREEN_WORK`, state `WAITING_FOR_ANSWER`→`WAITING_FOR_USER`.
|
|
452
|
-
Everything else in this document — the descriptor (`api.meta`), `perform()`,
|
|
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).
|