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