@voqalize/avatar 0.2.2 → 0.4.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/LICENSE-CC-BY-4.0 +396 -0
- package/README.md +191 -662
- package/assets/README.md +48 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +818 -0
- package/client/arjun.ts +26 -0
- package/client/createAvatar.ts +177 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +44 -0
- package/client/internal.ts +108 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +26 -0
- package/client/kabir.ts +26 -0
- package/client/meera.ts +26 -0
- package/client/naina.ts +26 -0
- package/client/playout.ts +95 -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/supports.ts +28 -0
- package/client/three/assets.ts +24 -0
- package/client/three/budgets.ts +27 -0
- package/client/three/holds.ts +64 -0
- package/client/three/internal.ts +54 -0
- package/client/three/motion-limits.json +116 -0
- package/client/three/sequences.ts +281 -0
- package/client/three/tanya.ts +69 -0
- package/client/three/tara-rig.ts +1562 -0
- package/client/three/tara.ts +76 -0
- package/client/three/tushar.ts +65 -0
- package/client/types.ts +235 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +26 -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 +274 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +712 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +7 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +20 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +135 -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 +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +34 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +41 -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 +7 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +20 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +7 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +20 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +7 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +20 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +7 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +20 -0
- package/dist/naina.js.map +1 -0
- package/dist/playout.d.ts +42 -0
- package/dist/playout.d.ts.map +1 -0
- package/dist/playout.js +85 -0
- package/dist/playout.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/supports.d.ts +20 -0
- package/dist/supports.d.ts.map +1 -0
- package/dist/supports.js +26 -0
- package/dist/supports.js.map +1 -0
- package/dist/three/assets.d.ts +24 -0
- package/dist/three/assets.d.ts.map +1 -0
- package/dist/three/assets.js +22 -0
- package/dist/three/assets.js.map +1 -0
- package/dist/three/budgets.d.ts +17 -0
- package/dist/three/budgets.d.ts.map +1 -0
- package/dist/three/budgets.js +21 -0
- package/dist/three/budgets.js.map +1 -0
- package/dist/three/holds.d.ts +33 -0
- package/dist/three/holds.d.ts.map +1 -0
- package/dist/three/holds.js +56 -0
- package/dist/three/holds.js.map +1 -0
- package/dist/three/internal.d.ts +52 -0
- package/dist/three/internal.d.ts.map +1 -0
- package/dist/three/internal.js +49 -0
- package/dist/three/internal.js.map +1 -0
- package/dist/three/motion-limits.json +116 -0
- package/dist/three/sequences.d.ts +71 -0
- package/dist/three/sequences.d.ts.map +1 -0
- package/dist/three/sequences.js +262 -0
- package/dist/three/sequences.js.map +1 -0
- package/dist/three/tanya.d.ts +35 -0
- package/dist/three/tanya.d.ts.map +1 -0
- package/dist/three/tanya.js +51 -0
- package/dist/three/tanya.js.map +1 -0
- package/dist/three/tara-rig.d.ts +403 -0
- package/dist/three/tara-rig.d.ts.map +1 -0
- package/dist/three/tara-rig.js +1500 -0
- package/dist/three/tara-rig.js.map +1 -0
- package/dist/three/tara.d.ts +35 -0
- package/dist/three/tara.d.ts.map +1 -0
- package/dist/three/tara.js +58 -0
- package/dist/three/tara.js.map +1 -0
- package/dist/three/tushar.d.ts +31 -0
- package/dist/three/tushar.d.ts.map +1 -0
- package/dist/three/tushar.js +47 -0
- package/dist/three/tushar.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +140 -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 +7 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +20 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +110 -25
- package/src/avatar.d.ts +218 -120
- package/src/avatar.js +950 -213
- package/src/behavior.d.ts +42 -0
- package/src/behavior.js +114 -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 +8 -5
- 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 +434 -58
- package/src/hand.js +120 -91
- package/src/head.js +134 -0
- package/src/idle.js +227 -119
- package/src/interjections.js +200 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/prosody.js +647 -0
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +110 -24
- 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,58 @@
|
|
|
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. Twelve
|
|
9
|
+
avatars ship with it — three SVG faces, six professional Canvas2D identities and
|
|
10
|
+
three 2.5-D characters — one per entry point, so you pay for the one you import,
|
|
11
|
+
and you can author your own.
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
[
|
|
13
|
-
|
|
13
|
+
This is the browser half. The pipeline half is
|
|
14
|
+
[`voqalize-avatar`](https://pypi.org/project/voqalize-avatar/) on PyPI; they are
|
|
15
|
+
two ends of one wire format and release independently, kept compatible by the
|
|
16
|
+
wire contract rather than a shared version number
|
|
17
|
+
([RELEASING.md § Compatibility](https://github.com/voqalize/avatar/blob/main/RELEASING.md#compatibility)).
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
**Licence: MIT for the code, CC-BY 4.0 for the three 2.5-D character binaries**
|
|
20
|
+
(`assets/*.glb`, the artwork — see `assets/README.md` for the credit line). The
|
|
21
|
+
code is usable anywhere, including in closed-source products; the characters ask
|
|
22
|
+
for attribution and nothing else. The manifest declares the pair as
|
|
23
|
+
`MIT AND CC-BY-4.0`.
|
|
19
24
|
|
|
20
|
-
|
|
21
|
-
import { Avatar } from '@voqalize/avatar';
|
|
22
|
-
|
|
23
|
-
<Avatar client={pipecatClient} className="call-tile" />
|
|
24
|
-
```
|
|
25
|
+
## Install
|
|
25
26
|
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
pipeline = Pipeline([..., tts, AvatarProcessor(), transport.output()])
|
|
27
|
+
```sh
|
|
28
|
+
npm install @voqalize/avatar # this package, the browser half
|
|
29
|
+
pip install voqalize-avatar # the pipecat half
|
|
30
30
|
```
|
|
31
31
|
|
|
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.
|
|
32
|
+
Node 20+. The package is ESM with **no runtime dependencies** and ships its own
|
|
33
|
+
types. `@pipecat-ai/client-js` (`>=1.4 <2`) and React (`>=18`) are declared as
|
|
34
|
+
*optional* peers: the pipecat import is types-only, so nothing fails to load
|
|
35
|
+
without it, and React is only for `@voqalize/avatar/react`.
|
|
43
36
|
|
|
44
|
-
##
|
|
37
|
+
## Getting started
|
|
45
38
|
|
|
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.
|
|
39
|
+
In the browser, wherever your app already renders the bot's tile:
|
|
50
40
|
|
|
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 |
|
|
41
|
+
```js
|
|
42
|
+
import { createAvatar } from '@voqalize/avatar';
|
|
57
43
|
|
|
58
|
-
|
|
44
|
+
const avatar = createAvatar({ mount: el, client: pipecatClient });
|
|
45
|
+
```
|
|
59
46
|
|
|
60
47
|
```jsx
|
|
61
|
-
import { Avatar } from '@voqalize/avatar';
|
|
48
|
+
import { Avatar } from '@voqalize/avatar/react';
|
|
49
|
+
import { wren } from '@voqalize/avatar/faces/wren'; // `peep` is the default
|
|
62
50
|
|
|
63
|
-
<Avatar client={pipecatClient}
|
|
51
|
+
<Avatar client={pipecatClient} options={{ face: wren }} className="call-tile" />
|
|
64
52
|
```
|
|
65
53
|
|
|
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`:
|
|
54
|
+
In the pipeline, between the TTS service and the transport's output — the seat
|
|
55
|
+
where it can see the audio that is about to be spoken, at generation speed:
|
|
73
56
|
|
|
74
57
|
```python
|
|
75
58
|
from voqalize_avatar import AvatarProcessor
|
|
@@ -77,651 +60,197 @@ from voqalize_avatar import AvatarProcessor
|
|
|
77
60
|
pipeline = Pipeline([..., tts, AvatarProcessor(), transport.output()])
|
|
78
61
|
```
|
|
79
62
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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 | |
|
|
174
|
-
|
|
175
|
-
This gives the face weight, and it does **viseme co-articulation for free** — we
|
|
176
|
-
never blend shapes explicitly, we just retarget and let the mouth chase.
|
|
177
|
-
|
|
178
|
-
### 4. Screen-share hygiene
|
|
179
|
-
|
|
180
|
-
The call runs with screen share on. All idle motion is deliberately
|
|
181
|
-
low-amplitude and low-frequency; a jittery avatar in the corner of a shared
|
|
182
|
-
screen costs the video encoder real bitrate for no communicative gain.
|
|
183
|
-
|
|
184
|
-
---
|
|
185
|
-
|
|
186
|
-
## The viseme protocol
|
|
187
|
-
|
|
188
|
-
The wire format is the **Rhubarb Lip Sync alphabet** — A–H plus X — a
|
|
189
|
-
condensation of the Preston Blair mouth set. Nine shapes is plenty for a
|
|
190
|
-
stylized 2-D face, and it means the server has an obvious open-source reference
|
|
191
|
-
implementation to target.
|
|
192
|
-
|
|
193
|
-
| letter | mouth | phonemes |
|
|
194
|
-
|---|---|---|
|
|
195
|
-
| `A` | closed lips | P B M — also the resting closure |
|
|
196
|
-
| `B` | slightly open, teeth together | K S T D, consonantal EE |
|
|
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
|
|
63
|
+
That is the integration, both halves of it. Neither takes an argument.
|
|
64
|
+
`createAvatar` returns `{ destroy() }` and nothing else — the avatar is an
|
|
65
|
+
embodiment of your `PipecatClient` and reacts to it, so there is no avatar to
|
|
66
|
+
drive and no state to read back.
|
|
67
|
+
|
|
68
|
+
**Works the same with or without `@pipecat-ai/voice-ui-kit`.** All `createAvatar`
|
|
69
|
+
needs is the `PipecatClient` instance — however you built your call UI, if you
|
|
70
|
+
have that instance you can mount an avatar next to it. voice-ui-kit's
|
|
71
|
+
components (buttons, visualizers, transport controls) are chrome around the
|
|
72
|
+
same client; they neither wrap nor gate it.
|
|
73
|
+
|
|
74
|
+
## What you get for free
|
|
75
|
+
|
|
76
|
+
Most of the avatar works on any pipecat application without customization — not
|
|
77
|
+
because integrations were enumerated, but because the behaviour is derived from
|
|
78
|
+
frames and events a pipecat pipeline already emits. `SPEAKING`, `LISTENING`,
|
|
79
|
+
`MUTED`, `OFFLINE` and `DEGRADED` come from your `PipecatClient` with no backend
|
|
80
|
+
involvement at all; `THINKING`, `WORKING` and `CANT_HEAR` come from
|
|
81
|
+
`AvatarProcessor` watching turn boundaries, LLM response boundaries and
|
|
82
|
+
function-call frames; lipsync comes from the same karaoke frames pipecat already
|
|
83
|
+
pushes for word-level captions; blink, breath, gaze aversion and idle motion are
|
|
84
|
+
always the renderer's.
|
|
85
|
+
|
|
86
|
+
What is left over is small, specific, and each item is a case the library
|
|
87
|
+
refuses to guess at — a deliberate nod or greeting, a tool whose calls never
|
|
88
|
+
enter your pipeline, a pose richer than the nine states, a backend that is not
|
|
89
|
+
ours. [The architecture
|
|
90
|
+
page](https://github.com/voqalize/avatar/blob/main/docs/architecture.md) is the
|
|
91
|
+
canonical reference for all of it.
|
|
92
|
+
|
|
93
|
+
## The wire protocol
|
|
94
|
+
|
|
95
|
+
Three commands, one envelope
|
|
96
|
+
([contract-wire.md](https://github.com/voqalize/avatar/blob/main/docs/contract-wire.md)):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{ "type": "avatar", "cmd": "state", "state": "WORKING" }
|
|
100
|
+
{ "type": "avatar", "cmd": "action", "id": "ACKNOWLEDGE" }
|
|
101
|
+
{ "type": "avatar", "cmd": "cues", "ctx": "tts-context", "from_ms": 0, "cues": [] }
|
|
212
102
|
```
|
|
213
103
|
|
|
214
|
-
**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
104
|
+
**States are durable and they are prioritised.** A state holds until the facts
|
|
105
|
+
change; it does not complete on a timer. **The state pipecat reports always
|
|
106
|
+
wins** — bot-output lifecycle and user speech are observed Pipecat facts, and a
|
|
107
|
+
state the server sends is a *candidate* underneath them. **Actions are
|
|
108
|
+
point-in-time animations** that land on top of whatever state is effective at
|
|
109
|
+
the time; they are finite, they complete on their own, and they never establish
|
|
110
|
+
state.
|
|
218
111
|
|
|
219
|
-
|
|
112
|
+
Emission is overwrite, never merge: a `cues` message says "discard everything
|
|
113
|
+
queued at or after `from_ms`, then append these". The server decides; the client
|
|
114
|
+
has no say and no way to refuse.
|
|
220
115
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
116
|
+
**Not using our backend?** Any server can produce cues, three ways, best first.
|
|
117
|
+
If your TTS emits native viseme events, map the integer ids through
|
|
118
|
+
`AZURE_VISEME_TO_LETTER` and ship `{t, v}` as they stream. Otherwise force-align
|
|
119
|
+
the text against the audio and map ARPAbet through `ARPABET_TO_VISEME`. With no
|
|
120
|
+
server work at all, `textToCues(text)` is a crude grapheme guesser, fit for
|
|
121
|
+
previews only. All three are exported from `@voqalize/avatar/internal`.
|
|
233
122
|
|
|
234
|
-
|
|
123
|
+
## The faces
|
|
235
124
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
### Tier 1 — native TTS viseme events (best, and nearly free)
|
|
241
|
-
|
|
242
|
-
Several TTS engines emit viseme events alongside the audio, already aligned.
|
|
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:
|
|
125
|
+
Three ship today, all hand-authored line art: **`peep`** (the default),
|
|
126
|
+
**`wren`**, **`myna`**. Each is its own entry point, and you pass the value
|
|
127
|
+
rather than a name:
|
|
246
128
|
|
|
247
129
|
```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
|
-
```
|
|
130
|
+
import { myna } from '@voqalize/avatar/faces/myna';
|
|
257
131
|
|
|
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
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
While the user holds the floor the avatar leans in a touch; at pause onsets it
|
|
369
|
-
acknowledges within ~250–600ms (probability, refractory gap and nod choice all
|
|
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.
|
|
376
|
-
|
|
377
|
-
### Action timelines
|
|
378
|
-
|
|
379
|
-
The composable vocabulary: a server assembles behaviour from the enums above,
|
|
380
|
-
timed against the utterance's own audio clock —
|
|
381
|
-
|
|
382
|
-
```js
|
|
383
|
-
avatar.perform([
|
|
384
|
-
{ t: 0, do: 'state', name: 'SPEAKING' },
|
|
385
|
-
{ t: 900, do: 'gaze', name: 'SCREEN_WORK' },
|
|
386
|
-
{ t: 2100, do: 'interject', id: 'NOD_SMALL' },
|
|
387
|
-
{ t: 3000, do: 'emotion', name: 'warm', i: 0.7 },
|
|
388
|
-
], { audio: audioEl }); // clock: explicit fn > audio > elapsed
|
|
132
|
+
createAvatar({ mount, client, face: myna });
|
|
389
133
|
```
|
|
390
134
|
|
|
391
|
-
|
|
392
|
-
|
|
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`.
|
|
135
|
+
A name would need a table, and a table is a dynamic index no bundler can shake —
|
|
136
|
+
three drawings in every consumer's bundle to render one.
|
|
402
137
|
|
|
403
|
-
|
|
138
|
+
## Professional avatars
|
|
404
139
|
|
|
405
|
-
|
|
140
|
+
Six complete, code-authored avatars ship as their own `createAvatar` modules:
|
|
406
141
|
|
|
407
142
|
```js
|
|
408
|
-
|
|
409
|
-
|
|
143
|
+
import { createAvatar } from '@voqalize/avatar/avatars/arjun';
|
|
144
|
+
// or: @voqalize/avatar/avatars/meera
|
|
145
|
+
// @voqalize/avatar/avatars/vikram
|
|
146
|
+
// @voqalize/avatar/avatars/ishita
|
|
147
|
+
// @voqalize/avatar/avatars/kabir
|
|
148
|
+
// @voqalize/avatar/avatars/naina
|
|
149
|
+
|
|
150
|
+
const avatar = createAvatar({ mount, client: pipecatClient });
|
|
410
151
|
```
|
|
411
152
|
|
|
412
|
-
`
|
|
153
|
+
`arjun`/`meera` are the original interviewer pair; `vikram`/`ishita` and
|
|
154
|
+
`kabir`/`naina` are two further wardrobe/hair directions on the same rig. The
|
|
155
|
+
entry points `interviewer-male`, `interviewer-female`, `professional-male-a`,
|
|
156
|
+
`professional-female-a`, `professional-male-b` and `professional-female-b`
|
|
157
|
+
still work — they are `@deprecated` aliases for the names above, kept so
|
|
158
|
+
existing imports do not break, and should not be used in new code.
|
|
413
159
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
160
|
+
All six depict Indian professionals in their late twenties, without caricature
|
|
161
|
+
or regional costume cues. They are calibrated at call-tile size and preserve
|
|
162
|
+
all six expression targets, continuous visemes, gaze, blink, head motion, and
|
|
163
|
+
the frame-edge gesture hand. Each module is a complete identity, not a face
|
|
164
|
+
value: import one instead of the default module and do not pass a `face`
|
|
165
|
+
option.
|
|
418
166
|
|
|
419
|
-
|
|
167
|
+
Their faces use low-contrast, landmark-driven nose planes rather than an
|
|
168
|
+
outlined nose glyph. A few identity-specific freckles or a small mole add depth
|
|
169
|
+
only in low-motion upper-cheek areas; none are used as demographic cues.
|
|
420
170
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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.
|
|
171
|
+
They use the same public contract and the same Pipecat lifecycle/viseme driver
|
|
172
|
+
as the SVG avatars. Their private renderer is Canvas2D; its rig data and bitmap
|
|
173
|
+
wardrobe assets are implementation details and no Canvas or pose API is added
|
|
174
|
+
to the package surface.
|
|
429
175
|
|
|
430
|
-
|
|
431
|
-
not follow: **it has no strokes anywhere.** Every line is a filled path, which is
|
|
432
|
-
what lets a line swell and taper along its length the way a drawn mark does — a
|
|
433
|
-
uniform `stroke-width` is the thing that makes vector line art read as clip art.
|
|
434
|
-
Three helpers do all of it: `taper` for an open mark, `taperRing` for a closed
|
|
435
|
-
annulus, `region` for an enclosed area. Widths are given as a *profile across the
|
|
436
|
-
whole mark* in normalized `s ∈ [0,1]`, not per node, so the same profile survives
|
|
437
|
-
re-authoring the points.
|
|
176
|
+
## The 2.5-D characters
|
|
438
177
|
|
|
439
|
-
|
|
178
|
+
Three characters ship as compiled binaries, each its own `createAvatar` module:
|
|
440
179
|
|
|
441
180
|
```js
|
|
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
|
|
181
|
+
import { createAvatar } from '@voqalize/avatar/avatars/tara';
|
|
182
|
+
// or: @voqalize/avatar/avatars/tushar
|
|
183
|
+
// @voqalize/avatar/avatars/tanya
|
|
511
184
|
|
|
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'));
|
|
185
|
+
const avatar = createAvatar({ mount, client: pipecatClient });
|
|
518
186
|
```
|
|
519
187
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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:
|
|
188
|
+
A photograph of a face projected onto shallow geometry, with the parts that have
|
|
189
|
+
to move — eyes, teeth, the lip line — built as geometry rather than painted.
|
|
190
|
+
Three.js is an *optional* peer (`three`, `>=0.180 <0.187`) behind these three
|
|
191
|
+
entry points only, so an SVG or Canvas consumer never downloads it, and the `.glb`
|
|
192
|
+
is fetched when the avatar mounts.
|
|
569
193
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
194
|
+
Nothing above the renderer changes: the same wire, the same states, the same
|
|
195
|
+
cue-synced mouth, and a server that has never heard of these characters drives one
|
|
196
|
+
correctly. The head turns 15° of yaw and 24° of pitch, which is a measured limit
|
|
197
|
+
rather than an option.
|
|
574
198
|
|
|
575
|
-
|
|
576
|
-
the
|
|
199
|
+
**The three binaries are artwork under CC-BY 4.0**, separately from the MIT code
|
|
200
|
+
around them; the credit line is in `assets/README.md`. Mounting, sizing, the asset
|
|
201
|
+
budget and what the characters can be asked to do:
|
|
202
|
+
[characters.md](https://github.com/voqalize/avatar/blob/main/docs/characters.md).
|
|
577
203
|
|
|
578
|
-
|
|
204
|
+
## Shipping your own avatar
|
|
579
205
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
anybody.
|
|
206
|
+
A whole different rendering technology is not a face; it is a different
|
|
207
|
+
`createAvatar`, published as its own module — which is why there is no registry.
|
|
208
|
+
The interface is small enough to state in one line:
|
|
584
209
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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.
|
|
210
|
+
```ts
|
|
211
|
+
createAvatar({ mount, client, ...yourOptions }) -> { destroy() }
|
|
212
|
+
```
|
|
591
213
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
real timelines for every registered avatar, in `sweep()`.
|
|
214
|
+
What such an implementation needs to understand is the *wire*, because states,
|
|
215
|
+
actions and cues are all an avatar is ever told. `VisemeTrack` in
|
|
216
|
+
`@voqalize/avatar/internal` turns a cue array plus a clock into the mouth shape
|
|
217
|
+
for the current frame; every renderer needs that and none should write it twice.
|
|
597
218
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
avatar
|
|
602
|
-
|
|
603
|
-
|
|
219
|
+
An implementation may also export `supports` — one object naming the action ids
|
|
220
|
+
it answers to. Nothing in the library reads it; it is there because the wire's
|
|
221
|
+
action id is open and an unknown one is ignored in silence, so a page that
|
|
222
|
+
drives an avatar otherwise cannot tell a face that has no such motion from one
|
|
223
|
+
that did nothing ([design-avatar-interface.md](https://github.com/voqalize/avatar/blob/main/docs/design-avatar-interface.md)).
|
|
224
|
+
Omitting it is conforming.
|
|
604
225
|
|
|
605
|
-
|
|
606
|
-
|
|
226
|
+
**There is deliberately no renderer interface.** The pose channels our SVG mixer
|
|
227
|
+
uses to talk to our faces are internal, and a second public contract stays
|
|
228
|
+
premature until a second renderer says what it needs.
|
|
607
229
|
|
|
608
|
-
|
|
230
|
+
## What is in this tarball
|
|
609
231
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
```
|
|
232
|
+
`dist/` is the compiled client — `AvatarClient`, the avatar entry points and the
|
|
233
|
+
React binding. `src/` is the widget itself: the mixer, the SVG rig and drawings,
|
|
234
|
+
plus the private Canvas2D interviewer rigs and their assets, as dependency-free
|
|
235
|
+
ES modules with no build step, imported by `dist/` through ordinary relative
|
|
236
|
+
paths. `client/` is the TypeScript those `dist/` files were compiled from, so
|
|
237
|
+
the source maps resolve. `assets/` is the three compiled characters and their
|
|
238
|
+
licence note — the only non-JavaScript thing here, fetched at runtime by URL.
|
|
622
239
|
|
|
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.
|
|
240
|
+
The contract documents do not ship here. They live in the repository, which is
|
|
241
|
+
where they are kept current:
|
|
242
|
+
[github.com/voqalize/avatar](https://github.com/voqalize/avatar).
|
|
692
243
|
|
|
693
244
|
## License
|
|
694
245
|
|
|
695
|
-
**
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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).
|
|
246
|
+
**MIT for the code, CC-BY 4.0 for the artwork**, and Voqalize holds the
|
|
247
|
+
copyright on all of it. The manifest declares the pair as `MIT AND CC-BY-4.0`;
|
|
248
|
+
the artwork is `assets/*.glb`, the three 2.5-D characters, and `assets/README.md`
|
|
249
|
+
carries the credit line. Everything else in the tarball is MIT, usable anywhere
|
|
250
|
+
including in closed-source products.
|
|
251
|
+
|
|
252
|
+
The drawing idiom `peep` is authored in is
|
|
253
|
+
[Open Peeps](https://www.openpeeps.com/) (CC0) — no artwork is copied. The
|
|
254
|
+
`avatarsync` aligner that produces the mouth shapes is a fork of
|
|
255
|
+
[Rhubarb Lip Sync](https://github.com/DanielSWolf/rhubarb-lip-sync) (MIT) and
|
|
256
|
+
ships in the Python package, not this one.
|