@voqalize/avatar 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +46 -28
  2. package/assets/README.md +4 -3
  3. package/assets/tanya.glb +0 -0
  4. package/assets/tara.glb +0 -0
  5. package/assets/tess.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/internal.ts +20 -0
  8. package/client/three/assets.ts +16 -16
  9. package/client/three/holds.ts +7 -2
  10. package/client/three/internal.ts +5 -5
  11. package/client/three/motion-limits.json +5 -1
  12. package/client/three/sequences.ts +5 -5
  13. package/client/three/tanya-asset.ts +6 -0
  14. package/client/three/tanya.ts +2 -2
  15. package/client/three/tara-asset.ts +25 -0
  16. package/client/three/tara-rig.ts +37 -14
  17. package/client/three/tara.ts +2 -1
  18. package/client/three/tess-asset.ts +6 -0
  19. package/client/three/tess.ts +72 -0
  20. package/client/three/tushar-asset.ts +6 -0
  21. package/client/three/tushar.ts +2 -2
  22. package/dist/internal.d.ts +2 -2
  23. package/dist/internal.d.ts.map +1 -1
  24. package/dist/internal.js +12 -1
  25. package/dist/internal.js.map +1 -1
  26. package/dist/three/assets.d.ts +1 -16
  27. package/dist/three/assets.d.ts.map +1 -1
  28. package/dist/three/assets.js +15 -16
  29. package/dist/three/assets.js.map +1 -1
  30. package/dist/three/holds.d.ts +7 -2
  31. package/dist/three/holds.d.ts.map +1 -1
  32. package/dist/three/holds.js +7 -2
  33. package/dist/three/holds.js.map +1 -1
  34. package/dist/three/internal.d.ts +5 -5
  35. package/dist/three/internal.js +5 -5
  36. package/dist/three/motion-limits.json +5 -1
  37. package/dist/three/sequences.d.ts +5 -5
  38. package/dist/three/sequences.js +5 -5
  39. package/dist/three/tanya-asset.d.ts +7 -0
  40. package/dist/three/tanya-asset.d.ts.map +1 -0
  41. package/dist/three/tanya-asset.js +7 -0
  42. package/dist/three/tanya-asset.js.map +1 -0
  43. package/dist/three/tanya.js +2 -2
  44. package/dist/three/tanya.js.map +1 -1
  45. package/dist/three/tara-asset.d.ts +26 -0
  46. package/dist/three/tara-asset.d.ts.map +1 -0
  47. package/dist/three/tara-asset.js +26 -0
  48. package/dist/three/tara-asset.js.map +1 -0
  49. package/dist/three/tara-rig.d.ts +29 -9
  50. package/dist/three/tara-rig.d.ts.map +1 -1
  51. package/dist/three/tara-rig.js +14 -9
  52. package/dist/three/tara-rig.js.map +1 -1
  53. package/dist/three/tara.d.ts.map +1 -1
  54. package/dist/three/tara.js +2 -1
  55. package/dist/three/tara.js.map +1 -1
  56. package/dist/three/tess-asset.d.ts +7 -0
  57. package/dist/three/tess-asset.d.ts.map +1 -0
  58. package/dist/three/tess-asset.js +7 -0
  59. package/dist/three/tess-asset.js.map +1 -0
  60. package/dist/three/tess.d.ts +38 -0
  61. package/dist/three/tess.d.ts.map +1 -0
  62. package/dist/three/tess.js +54 -0
  63. package/dist/three/tess.js.map +1 -0
  64. package/dist/three/tushar-asset.d.ts +7 -0
  65. package/dist/three/tushar-asset.d.ts.map +1 -0
  66. package/dist/three/tushar-asset.js +7 -0
  67. package/dist/three/tushar-asset.js.map +1 -0
  68. package/dist/three/tushar.js +2 -2
  69. package/dist/three/tushar.js.map +1 -1
  70. package/package.json +5 -1
  71. package/src/avatar.d.ts +65 -2
  72. package/src/avatar.js +68 -63
  73. package/src/faces.js +1 -1
  74. package/src/gaze.js +19 -44
  75. package/src/idle.js +60 -26
  76. package/src/prosody.js +104 -14
  77. package/src/rig.d.ts +7 -0
package/README.md CHANGED
@@ -5,10 +5,11 @@ avatars are lip-synced to the audio and they are state aware: they know when
5
5
  they have been interrupted, when the user is talking versus idle, when a tool
6
6
  call has started and stopped.
7
7
 
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.
8
+ No video track, no per-minute avatar vendor, no second media path. SVG faces
9
+ and 2.5-D characters ship with it, one per entry point, so you pay for the one
10
+ you import, and you can author your own. Canvas2D identities also ship and are
11
+ frozen: they still work, they get no further work,
12
+ and they come out in 0.5.0 (below).
12
13
 
13
14
  This is the browser half. The pipeline half is
14
15
  [`voqalize-avatar`](https://pypi.org/project/voqalize-avatar/) on PyPI; they are
@@ -16,10 +17,10 @@ two ends of one wire format and release independently, kept compatible by the
16
17
  wire contract rather than a shared version number
17
18
  ([RELEASING.md § Compatibility](https://github.com/voqalize/avatar/blob/main/RELEASING.md#compatibility)).
18
19
 
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
20
+ **Licence: MIT for the code and the SVG and Canvas2D avatars, CC-BY 4.0 for the
21
+ 2.5-D character binaries** (`assets/*.glb` — see `assets/README.md` for the
22
+ credit line). Which applies is decided by the kind of avatar, so adding a
23
+ character never moves the line. The manifest declares the pair as
23
24
  `MIT AND CC-BY-4.0`.
24
25
 
25
26
  ## Install
@@ -85,14 +86,14 @@ always the renderer's.
85
86
 
86
87
  What is left over is small, specific, and each item is a case the library
87
88
  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
+ enter your pipeline, a pose richer than the core states, a backend that is not
89
90
  ours. [The architecture
90
91
  page](https://github.com/voqalize/avatar/blob/main/docs/architecture.md) is the
91
92
  canonical reference for all of it.
92
93
 
93
94
  ## The wire protocol
94
95
 
95
- Three commands, one envelope
96
+ `state`, `action` and `cues`, one envelope
96
97
  ([contract-wire.md](https://github.com/voqalize/avatar/blob/main/docs/contract-wire.md)):
97
98
 
98
99
  ```json
@@ -113,16 +114,16 @@ Emission is overwrite, never merge: a `cues` message says "discard everything
113
114
  queued at or after `from_ms`, then append these". The server decides; the client
114
115
  has no say and no way to refuse.
115
116
 
116
- **Not using our backend?** Any server can produce cues, three ways, best first.
117
+ **Not using our backend?** Any server can produce cues; the ways, best first:
117
118
  If your TTS emits native viseme events, map the integer ids through
118
119
  `AZURE_VISEME_TO_LETTER` and ship `{t, v}` as they stream. Otherwise force-align
119
120
  the text against the audio and map ARPAbet through `ARPABET_TO_VISEME`. With no
120
121
  server work at all, `textToCues(text)` is a crude grapheme guesser, fit for
121
- previews only. All three are exported from `@voqalize/avatar/internal`.
122
+ previews only. Each is exported from `@voqalize/avatar/internal`.
122
123
 
123
124
  ## The faces
124
125
 
125
- Three ship today, all hand-authored line art: **`peep`** (the default),
126
+ All hand-authored line art: **`peep`** (the default),
126
127
  **`wren`**, **`myna`**. Each is its own entry point, and you pass the value
127
128
  rather than a name:
128
129
 
@@ -133,11 +134,18 @@ createAvatar({ mount, client, face: myna });
133
134
  ```
134
135
 
135
136
  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.
137
+ every drawing in every consumer's bundle to render one.
137
138
 
138
- ## Professional avatars
139
+ ## Professional avatars — frozen, removed in 0.5.0
139
140
 
140
- Six complete, code-authored avatars ship as their own `createAvatar` modules:
141
+ **Do not start here.** These are a Canvas2D renderer that ended up in the
142
+ wrong place: more expensive to author than an SVG drawing and less expressive
143
+ than the 2.5-D characters, which is the whole range it was meant to sit between.
144
+ They still work and they are still tested, but they get no further work, and
145
+ their entry points come out in 0.5.0. Pick an SVG face or a 2.5-D character
146
+ instead; both are below.
147
+
148
+ Complete, code-authored avatars, each its own `createAvatar` module:
141
149
 
142
150
  ```js
143
151
  import { createAvatar } from '@voqalize/avatar/avatars/arjun';
@@ -151,15 +159,15 @@ const avatar = createAvatar({ mount, client: pipecatClient });
151
159
  ```
152
160
 
153
161
  `arjun`/`meera` are the original interviewer pair; `vikram`/`ishita` and
154
- `kabir`/`naina` are two further wardrobe/hair directions on the same rig. The
162
+ `kabir`/`naina` are further wardrobe/hair directions on the same rig. The
155
163
  entry points `interviewer-male`, `interviewer-female`, `professional-male-a`,
156
164
  `professional-female-a`, `professional-male-b` and `professional-female-b`
157
165
  still work — they are `@deprecated` aliases for the names above, kept so
158
166
  existing imports do not break, and should not be used in new code.
159
167
 
160
- All six depict Indian professionals in their late twenties, without caricature
168
+ They depict Indian professionals in their late twenties, without caricature
161
169
  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
170
+ every expression target, continuous visemes, gaze, blink, head motion, and
163
171
  the frame-edge gesture hand. Each module is a complete identity, not a face
164
172
  value: import one instead of the default module and do not pass a `face`
165
173
  option.
@@ -171,23 +179,25 @@ only in low-motion upper-cheek areas; none are used as demographic cues.
171
179
  They use the same public contract and the same Pipecat lifecycle/viseme driver
172
180
  as the SVG avatars. Their private renderer is Canvas2D; its rig data and bitmap
173
181
  wardrobe assets are implementation details and no Canvas or pose API is added
174
- to the package surface.
182
+ to the package surface. Like the SVG faces they are code, MIT, and ask for no
183
+ attribution.
175
184
 
176
185
  ## The 2.5-D characters
177
186
 
178
- Three characters ship as compiled binaries, each its own `createAvatar` module:
187
+ The characters ship as compiled binaries, each its own `createAvatar` module:
179
188
 
180
189
  ```js
181
190
  import { createAvatar } from '@voqalize/avatar/avatars/tara';
182
191
  // or: @voqalize/avatar/avatars/tushar
183
192
  // @voqalize/avatar/avatars/tanya
193
+ // @voqalize/avatar/avatars/tess
184
194
 
185
195
  const avatar = createAvatar({ mount, client: pipecatClient });
186
196
  ```
187
197
 
188
198
  A photograph of a face projected onto shallow geometry, with the parts that have
189
199
  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
200
+ Three.js is an *optional* peer (`three`, `>=0.180 <0.187`) behind those
191
201
  entry points only, so an SVG or Canvas consumer never downloads it, and the `.glb`
192
202
  is fetched when the avatar mounts.
193
203
 
@@ -196,7 +206,7 @@ cue-synced mouth, and a server that has never heard of these characters drives o
196
206
  correctly. The head turns 15° of yaw and 24° of pitch, which is a measured limit
197
207
  rather than an option.
198
208
 
199
- **The three binaries are artwork under CC-BY 4.0**, separately from the MIT code
209
+ **The binaries are artwork under CC-BY 4.0**, separately from the MIT code
200
210
  around them; the credit line is in `assets/README.md`. Mounting, sizing, the asset
201
211
  budget and what the characters can be asked to do:
202
212
  [characters.md](https://github.com/voqalize/avatar/blob/main/docs/characters.md).
@@ -234,7 +244,7 @@ React binding. `src/` is the widget itself: the mixer, the SVG rig and drawings,
234
244
  plus the private Canvas2D interviewer rigs and their assets, as dependency-free
235
245
  ES modules with no build step, imported by `dist/` through ordinary relative
236
246
  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
247
+ the source maps resolve. `assets/` is the compiled characters and their
238
248
  licence note — the only non-JavaScript thing here, fetched at runtime by URL.
239
249
 
240
250
  The contract documents do not ship here. They live in the repository, which is
@@ -244,10 +254,18 @@ where they are kept current:
244
254
  ## License
245
255
 
246
256
  **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.
257
+ copyright on all of it. The licence follows the kind of avatar, not the roster:
258
+
259
+ - **SVG faces and Canvas2D identities are code** — MIT, no attribution. Each is
260
+ drawn by the function that ships it; a Canvas2D identity's rig data and
261
+ wardrobe bitmaps are implementation details of that function, not artwork you
262
+ are licensing.
263
+ - **2.5-D characters are artwork** — `assets/*.glb`, and nothing else in the
264
+ tarball, under CC-BY 4.0. `assets/README.md` carries the credit line.
265
+
266
+ MIT is usable anywhere, including in closed-source products. The manifest
267
+ declares the package as `MIT AND CC-BY-4.0`, which is what a licence scanner
268
+ reports whether or not you import a 2.5-D character.
251
269
 
252
270
  The drawing idiom `peep` is authored in is
253
271
  [Open Peeps](https://www.openpeeps.com/) (CC0) — no artwork is copied. The
package/assets/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # The character binaries
2
2
 
3
- Three 2.5-D characters, one compiled file each:
3
+ The 2.5-D characters, one compiled file each:
4
4
 
5
5
  | file | character | what it is |
6
6
  |---|---|---|
7
7
  | `tara.glb` | tara | the first; shallow geometry under a projected portrait |
8
8
  | `tushar.glb` | tushar | the second, her scripts with his landmarks |
9
9
  | `tanya.glb` | tanya | the third, built from supplied reference images |
10
+ | `tess.glb` | tess | the fourth, from a single supplied reference sheet |
10
11
 
11
12
  They are loaded for you. `@voqalize/avatar/avatars/tara` resolves the file beside
12
13
  itself and hands it to a loader; you never name a path, and the only reason to
@@ -20,7 +21,7 @@ in this directory to regenerate it from, and the next release overwrites it.
20
21
 
21
22
  ## Licence: CC-BY 4.0
22
23
 
23
- The rest of this package is MIT. These three files are artwork, not code, so they
24
+ The rest of this package is MIT. These files are artwork, not code, so they
24
25
  carry an artwork licence: **Creative Commons Attribution 4.0 International**, the
25
26
  full text in `LICENSE-CC-BY-4.0` at the package root. Use them in a product,
26
27
  commercially, modified or retextured, and credit us:
@@ -41,7 +42,7 @@ file is the only way terms travel with it.
41
42
  Every character began as an image from a generative model — OpenAI's and
42
43
  Google's — and **none of them depicts a real person**. Any resemblance to one is
43
44
  coincidence, not a likeness, and there are no personality rights attached to any
44
- of these three. What we did with that image is ours: the geometry, the measured
45
+ of them. What we did with that image is ours: the geometry, the measured
45
46
  landmarks, the projection, the relighting and the authored morph targets are all
46
47
  human work, and they are the substance of what is licensed here.
47
48
 
package/assets/tanya.glb CHANGED
Binary file
package/assets/tara.glb CHANGED
Binary file
Binary file
package/assets/tushar.glb CHANGED
Binary file
@@ -47,6 +47,24 @@ export {
47
47
  LEAD_MS,
48
48
  // The body's share of a held tilt, for a page driving a rig by hand.
49
49
  SHOULDER_TILT,
50
+ // Stepping a gesture by hand: the catalogue, the player, and the smoothing
51
+ // law the mixer puts between a clip's keyframes and the face. A filmstrip
52
+ // instrument needs all three, and the one thing it must not do is fork the
53
+ // smoothing — that is the part a keyframe is authored against
54
+ // (docs/internal-mixer.md § Smoothing).
55
+ INTERNAL_CLIPS,
56
+ ClipPlayer,
57
+ TAU,
58
+ clamp,
59
+ approach,
60
+ // Posing a face directly, with no mixer, no clock and no client above it —
61
+ // what a pose sheet or a rig-conformance page does. `makeParams` fills the
62
+ // rests around a handful of overrides, `emotionPose` names a familiar set of
63
+ // them, `avatarFrame` wraps the result and `createSvgRig(face)` draws it.
64
+ makeParams,
65
+ emotionPose,
66
+ avatarFrame,
67
+ createSvgRig,
50
68
  shapeFor,
51
69
  normalizeCues,
52
70
  textToCues,
@@ -55,6 +73,8 @@ export {
55
73
  } from "../src/avatar.js";
56
74
 
57
75
  export type {
76
+ Clip,
77
+ ClipSample,
58
78
  AvatarApi,
59
79
  AvatarStateName,
60
80
  AvatarActionId,
@@ -1,23 +1,23 @@
1
1
  /**
2
- * Where the three compiled characters are, resolved against this module's own
3
- * location.
2
+ * Every compiled character as one table — for a page that enumerates them.
4
3
  *
5
- * `new URL(…, import.meta.url)` and not a bundler's asset import: these files
6
- * are fetched at runtime by a consumer's app, and the spelling has to survive
7
- * Vite, webpack, Rollup, esbuild, a plain `tsc` output and a browser loading
8
- * the module directly. The query-suffix form this used to carry is Vite syntax —
9
- * every other toolchain, including the compiler that now builds this directory,
10
- * passes it through verbatim and produces an import of a file that is not there.
11
- *
12
- * The depth is the same from the source tree and from the compiled one
13
- * (`client/three/` and `dist/three/` are siblings), so one literal is correct in
14
- * both — and literal is load-bearing, because a bundler can only follow this
15
- * pattern when it can read the path without running anything.
4
+ * Each URL lives in its own module (`tara-asset.ts` and its siblings) and this
5
+ * file only gathers them, because a bundler emits assets per module: anything
6
+ * importing *this* ships every GLB, which is right for a rig instrument that
7
+ * switches between characters and wrong for a consumer who mounted one.
8
+ * So the character modules never import this — they import their own — and the
9
+ * only reader is `/internal/three`, a separate entry point that ships nothing.
16
10
  */
11
+ import { TANYA_GLB } from "./tanya-asset.js";
12
+ import { TESS_GLB } from "./tess-asset.js";
13
+ import { TARA_GLB } from "./tara-asset.js";
14
+ import { TUSHAR_GLB } from "./tushar-asset.js";
15
+
17
16
  export const ASSETS = Object.freeze({
18
- tara: new URL("../../assets/tara.glb", import.meta.url).href,
19
- tushar: new URL("../../assets/tushar.glb", import.meta.url).href,
20
- tanya: new URL("../../assets/tanya.glb", import.meta.url).href,
17
+ tara: TARA_GLB,
18
+ tushar: TUSHAR_GLB,
19
+ tanya: TANYA_GLB,
20
+ tess: TESS_GLB,
21
21
  });
22
22
 
23
23
  /** Every compiled character, in build order. */
@@ -3,12 +3,16 @@
3
3
  * pose units per axis, ready to hand to `createSvgAvatar` as `headHold`.
4
4
  *
5
5
  * The angles themselves are in `motion-limits.json` and are only
6
- * there. They were read off the three faces by the person who owns how they
6
+ * there. They were read off the faces by the person who owns how they
7
7
  * look, one axis at a time, at the surface a call actually shows — which is
8
8
  * the only way this kind of number can be got, and the reason nothing here
9
9
  * recomputes, adjusts or second-guesses one. This file is the unit change and
10
10
  * nothing else.
11
11
  *
12
+ * An axis the file never carried is the same case, and a character built but
13
+ * not yet driven by the owner has none of them: the file says so instead of
14
+ * carrying a number nobody read, and the fallback below is what holds her.
15
+ *
12
16
  * An axis the file marks for re-measurement is not enforced: it was read off an
13
17
  * asset that has since been fixed, and holding the driver to a defect that no
14
18
  * longer exists is worse than the defect. It falls back to the tightest angle
@@ -52,7 +56,8 @@ function safest(axis: string): number | undefined {
52
56
  return angles.length ? Math.min(...angles) : undefined;
53
57
  }
54
58
 
55
- /** `name` as the limits file spells it: tara, tushar, tanya. */
59
+ /** `name` as the limits file spells it — a character with no live axis there
60
+ * is held to the tightest budget any other is still vouched for. */
56
61
  export function headHold(name: keyof typeof LIMITS.characters): HeadHold {
57
62
  const out: HeadHold = {};
58
63
  for (const channel of Object.keys(AXIS) as (keyof typeof AXIS)[]) {
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * The 2.5-D rig, below the `createAvatar` modules — `/internal`, no semver promise.
3
3
  *
4
- * `tara.ts`, `tushar.ts` and `tanya.ts` are what a consumer gets: one
5
- * `createAvatar` each, the mixer behind it, nothing to configure. This is the
6
- * other door, for a page whose subject is the *rig* rather than the call — the
7
- * renderer with no mixer in front of it, the three compiled characters, and the
8
- * head envelope their channels are scaled by. Its readers are the rig instruments
4
+ * A character's own module — `tara.ts`, `tushar.ts`, `tanya.ts`, `tess.ts` — is
5
+ * what a consumer gets: one `createAvatar` each, the mixer behind it, nothing to
6
+ * configure. This is the other door, for a page whose subject is the *rig*
7
+ * rather than the call — the renderer with no mixer in front of it, every
8
+ * compiled character, and the head envelope their channels are scaled by. Its readers are the rig instruments
9
9
  * in the working tree, and nothing that ships.
10
10
  *
11
11
  * It exists because those instruments were reaching around the package instead,
@@ -110,7 +110,11 @@
110
110
  "fixed": "2026-09-19"
111
111
  }
112
112
  ]
113
+ },
114
+ "tess": {
115
+ "unmeasured": "Nothing here has been read off her. She was built on 2026-09-20 and the owner has not driven her yet, so this entry carries no angle at all rather than a plausible one: `holds.ts` falls back to the tightest angle still live on each axis, which is the budget the faces nobody doubts are already held to. Drive her with the hold-and-move instrument and this becomes the same shape as the entries above.",
116
+ "defects": []
113
117
  }
114
118
  },
115
- "remeasure": "An axis carrying `remeasure` was measured on an asset that has since changed. It stays as written \u2014 a measurement is not edited by someone who did not make it \u2014 and a gate skips it and says so."
119
+ "remeasure": "An axis carrying `remeasure` was measured on an asset that has since changed. It stays as written — a measurement is not edited by someone who did not make it — and a gate skips it and says so."
116
120
  }
@@ -262,13 +262,13 @@ void (BLENDER_ACTIONS satisfies Partial<Record<keyof typeof ACTIONS, Sequence>>)
262
262
  * What every character in this package answers to — each one's `supports`, the
263
263
  * optional declaration a driving UI reads (`AvatarSupport`).
264
264
  *
265
- * Derived rather than written down, because the three parts already exist: the
266
- * two ids every avatar owes a server, the mixer's own clip catalogue these
267
- * characters inherit whole, and this file's own names. A hand-kept fourth copy
268
- * would only be able to disagree with them. `BLENDER_ACTIONS` adds nothing —
265
+ * Derived rather than written down, because every part of it already exists: the
266
+ * ids every avatar owes a server, the mixer's own clip catalogue these
267
+ * characters inherit whole, and this file's own names. A hand-kept copy beside
268
+ * them would only be able to disagree with them. `BLENDER_ACTIONS` adds nothing —
269
269
  * it re-shapes an id the mixer already has.
270
270
  *
271
- * It is one list for tara, tushar and tanya for the same reason the tables are:
271
+ * It is one list for every 2.5-D character for the same reason the tables are:
272
272
  * they are one rig driven through one set of channels. A character that stops
273
273
  * being able to do one of these owes its own `supports`, not an edit here.
274
274
  */
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where tanya's compiled GLB is. One character, one module — see
3
+ * [tara-asset.ts](./tara-asset.ts) for why that separation is load-bearing and
4
+ * why the `new URL` literal is spelled exactly this way.
5
+ */
6
+ export const TANYA_GLB = new URL("../../assets/tanya.glb", import.meta.url).href;
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { PipecatClient } from "@pipecat-ai/client-js";
17
17
  import { AvatarClient, createSvgAvatar } from "../internal.js";
18
- import { ASSETS } from "./assets.js";
18
+ import { TANYA_GLB } from "./tanya-asset.js";
19
19
  import { BLENDER_ACTIONS, BLENDER_SEQUENCES, BLENDER_SUPPORTS } from "./sequences.js";
20
20
  import { headHold } from "./holds.js";
21
21
  import { createTaraRig, TARA_TUNING } from "./tara-rig.js";
@@ -45,7 +45,7 @@ export function createAvatar(options: AvatarOptions): AvatarInstance {
45
45
  if (!mount) throw new TypeError("createAvatar: `mount` is required");
46
46
  if (!client) throw new TypeError("createAvatar: `client` is required");
47
47
 
48
- const rigOptions: TaraRigOptions = { onReady, url: ASSETS.tanya };
48
+ const rigOptions: TaraRigOptions = { onReady, url: TANYA_GLB };
49
49
  // TARA-SPECIFIC, kept on purpose: `TARA_TUNING` (her mouth and motion gains)
50
50
  // is the first thing to question if she reads wrong in a call.
51
51
  const widget = createSvgAvatar({
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where tara's compiled GLB is — one character, one module, on purpose.
3
+ *
4
+ * `new URL(…, import.meta.url)` and not a bundler's asset import: these files
5
+ * are fetched at runtime by a consumer's app, and the spelling has to survive
6
+ * Vite, webpack, Rollup, esbuild, a plain `tsc` output and a browser loading the
7
+ * module directly. The query-suffix form this used to carry is Vite syntax —
8
+ * every other toolchain, including the compiler that now builds this directory,
9
+ * passes it through verbatim and produces an import of a file that is not there.
10
+ *
11
+ * The depth is the same from the source tree and from the compiled one
12
+ * (`client/three/` and `dist/three/` are siblings), so one literal is correct in
13
+ * both — and literal is load-bearing, because a bundler can only follow this
14
+ * pattern when it can read the path without running anything.
15
+ *
16
+ * **One module per character is the whole point, and it is a bundling fact, not
17
+ * tidiness.** A bundler that can follow `new URL` emits the file it names, and
18
+ * it decides what to emit per *module*: three literals in one object meant a
19
+ * consumer who imported one character shipped all three GLBs — ~1.3 MB of a
20
+ * character they never mount, in every build we checked. Splitting them is what
21
+ * makes `@voqalize/avatar/avatars/tara` cost tara. Nothing that a character
22
+ * module loads may reach for another character's URL, which is also why the rig
23
+ * takes its `url` from its caller instead of defaulting to tara's.
24
+ */
25
+ export const TARA_GLB = new URL("../../assets/tara.glb", import.meta.url).href;
@@ -31,7 +31,6 @@ import { REST } from "../internal.js";
31
31
  import type { AvatarFrame, AvatarRig, RigPose } from "../internal.js";
32
32
  import * as THREE from "three";
33
33
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
34
- import { ASSETS } from "./assets.js";
35
34
  import { HARD_BUDGET, pixelRatioFor } from "./budgets.js";
36
35
 
37
36
  /**
@@ -70,10 +69,11 @@ const FRAME_CENTRE = (FRAME.top + FRAME.bottom) / 2;
70
69
  * shell, and a large turn is where that reads as a cardboard cutout rather than
71
70
  * a head. **It opened from 9° to 15° on 2026-09-18.** The cutout was measured
72
71
  * rather than assumed — a ladder rendered at 9/12/15/18/21/25/30 and read at
73
- * crop on all three characters is clean to 21° on tara and to 18° on tanya and
74
- * tushar. 9° was therefore set at half of where the artefact actually begins,
75
- * and the stiffness the owner reported on tanya's turns was that margin, not
76
- * her asset. 15° keeps 3° of headroom on the tightest of the three.
72
+ * crop on the characters that existed then is clean to 21° on tara and to 18°
73
+ * on tanya and tushar. 9° was therefore set at half of where the artefact
74
+ * actually begins, and the stiffness the owner reported on tanya's turns was
75
+ * that margin, not her asset. 15° keeps 3° of headroom under the 18° the
76
+ * tightest of them read.
77
77
  *
78
78
  * This is not for speech: Busso wants ±1.15° of yaw in neutral conversation and
79
79
  * always did. It is for a head that turns to *look* at something, which is what
@@ -918,14 +918,33 @@ const MIN_FRAME_MS = 1000 / RENDER_FPS;
918
918
  export interface TaraRigOptions {
919
919
  /** Called once the GLB is in the scene, for a capture tool that must wait. */
920
920
  readonly onReady?: () => void;
921
- /** The character's GLB, when it is not tara. The second-character seam: every
922
- * build fact the rig reads (jaw-shadow tile, rim depth, morph names) travels
923
- * in the GLB's own extras, so a character built by the same scripts needs
924
- * nothing else. Tara's tuning is still applied, which is the experiment. */
925
- readonly url?: string;
921
+ /** The character's GLB. The second-character seam: every build fact the rig
922
+ * reads (jaw-shadow tile, rim depth, morph names) travels in the GLB's own
923
+ * extras, so a character built by the same scripts needs nothing else. Tara's
924
+ * tuning is still applied, which is the experiment.
925
+ *
926
+ * Required, and it used to default to tara's. A default meant this module
927
+ * imported one character's URL, and a bundler emits assets per module — so
928
+ * every consumer of *any* character shipped tara's GLB whether or not they
929
+ * mounted her. The caller knows which character it is building; this file
930
+ * must not ([tara-asset.ts](./tara-asset.ts)). */
931
+ readonly url: string;
926
932
  /** `false` leaves an asset's expression maps unread, for a capture tool
927
933
  * comparing the face with and without them. */
928
934
  readonly expression?: boolean;
935
+ /** Keeps the drawing buffer readable after the browser has composited it, so
936
+ * a caller can copy the canvas out at any moment rather than only from
937
+ * inside the frame that drew it.
938
+ *
939
+ * A pose sheet needs this and a consumer must not have it. WebGL contexts are
940
+ * a handful per page, so a sheet of thirty poses cannot be thirty canvases —
941
+ * it holds one pose still, copies the canvas into a tile, and moves on. Copy
942
+ * outside the drawing frame and the buffer has already been cleared, which is
943
+ * a blank tile and not an error. The cost is that the buffer cannot be
944
+ * discarded, which on some drivers means a second copy of every frame; the
945
+ * consumer renders thirty frames a second forever and pays nothing for a
946
+ * readback it never performs. */
947
+ readonly readback?: boolean;
929
948
  }
930
949
 
931
950
  const radians = (deg: number) => (deg * Math.PI) / 180;
@@ -935,9 +954,9 @@ const radians = (deg: number) => (deg * Math.PI) / 180;
935
954
  * Three.js throws out of the constructor rather than returning anything, and it
936
955
  * has already written its own line to the console by then; that line is kept
937
956
  * because it names the underlying reason, which this one does not. */
938
- function webglRenderer(): THREE.WebGLRenderer | null {
957
+ function webglRenderer(readback = false): THREE.WebGLRenderer | null {
939
958
  try {
940
- return new THREE.WebGLRenderer({ antialias: true, alpha: true });
959
+ return new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: readback });
941
960
  } catch {
942
961
  return null;
943
962
  }
@@ -1148,14 +1167,18 @@ const expressionWeights = (pose: RigPose, side: "L" | "R", expression: Expressio
1148
1167
  // `options` is `unknown` in the contract, and stays `unknown` here: the mixer
1149
1168
  // passes `rigOptions` through verbatim and has no way to know any rig's shape.
1150
1169
  export function createTaraRig(mount: HTMLElement, options?: unknown): AvatarRig {
1151
- const { onReady, url = ASSETS.tara, expression: readExpression = true } =
1170
+ const { onReady, url, expression: readExpression = true, readback = false } =
1152
1171
  (options ?? {}) as TaraRigOptions;
1172
+ // A missing `url` is a caller's defect, not a browser condition — the WebGL
1173
+ // path below degrades because a driver is nobody's fault, whereas this would
1174
+ // otherwise be a 404 on a path spelled `undefined`.
1175
+ if (!url) throw new TypeError("createTaraRig: `url` is required");
1153
1176
  const scene = new THREE.Scene();
1154
1177
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 100);
1155
1178
  camera.position.set(0, FRAME_CENTRE, 6);
1156
1179
  camera.lookAt(0, FRAME_CENTRE, 0);
1157
1180
 
1158
- const renderer = webglRenderer();
1181
+ const renderer = webglRenderer(readback);
1159
1182
  // No context, no face — and that has to be the whole of it. `createAvatar` is
1160
1183
  // synchronous and returns `{ destroy }`, so a consumer has nothing to catch:
1161
1184
  // anything thrown here lands in *their* window and takes the call page with
@@ -16,6 +16,7 @@
16
16
  import type { PipecatClient } from "@pipecat-ai/client-js";
17
17
  import { AvatarClient, createSvgAvatar } from "../internal.js";
18
18
  import { BLENDER_ACTIONS, BLENDER_SEQUENCES, BLENDER_SUPPORTS } from "./sequences.js";
19
+ import { TARA_GLB } from "./tara-asset.js";
19
20
  import { headHold } from "./holds.js";
20
21
  import { createTaraRig, TARA_TUNING } from "./tara-rig.js";
21
22
  import type { TaraRigOptions } from "./tara-rig.js";
@@ -44,7 +45,7 @@ export function createAvatar(options: AvatarOptions): AvatarInstance {
44
45
  if (!mount) throw new TypeError("createAvatar: `mount` is required");
45
46
  if (!client) throw new TypeError("createAvatar: `client` is required");
46
47
 
47
- const rigOptions: TaraRigOptions = { onReady };
48
+ const rigOptions: TaraRigOptions = { onReady, url: TARA_GLB };
48
49
  // `hand: false` disables the bundled SVG hand renderer only; the semantic
49
50
  // hand frame still reaches the rig, which ignores it — tara has no arms, and
50
51
  // that is the library's oldest standing constraint rather than an omission.
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where tess's compiled GLB is. One character, one module — see
3
+ * [tara-asset.ts](./tara-asset.ts) for why that separation is load-bearing and
4
+ * why the `new URL` literal is spelled exactly this way.
5
+ */
6
+ export const TESS_GLB = new URL("../../assets/tess.glb", import.meta.url).href;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * tess — American, and like `tushar.ts` and `tanya.ts` a copy of `tara.ts`.
3
+ *
4
+ * Her GLB is tara's scripts run with her landmarks in place of tara's
5
+ * (`characters/tess/`, whose README has the provenance and the commands), so
6
+ * the rig, the mixer, the sequences and her tuning are all hers, unmodified;
7
+ * the one seam is `TaraRigOptions.url`.
8
+ *
9
+ * She is back inside tara's atlas window — her hair is drawn into a low tail
10
+ * that clears both ears and reaches |u| 0.492 against a window that stops at
11
+ * 0.64 — so unlike tanya she needs no `face_texture.py` of her own, and her
12
+ * `Hair` shell gives up no roll: nothing of hers hangs past the jaw to lag.
13
+ *
14
+ * Her head angles have not been read off her yet. `motion-limits.json` says so
15
+ * rather than guessing, and `headHold` gives her the tightest budget any face
16
+ * is still vouched for until the owner drives her.
17
+ */
18
+
19
+ import type { PipecatClient } from "@pipecat-ai/client-js";
20
+ import { AvatarClient, createSvgAvatar } from "../internal.js";
21
+ import { TESS_GLB } from "./tess-asset.js";
22
+ import { BLENDER_ACTIONS, BLENDER_SEQUENCES, BLENDER_SUPPORTS } from "./sequences.js";
23
+ import { headHold } from "./holds.js";
24
+ import { createTaraRig, TARA_TUNING } from "./tara-rig.js";
25
+ import type { TaraRigOptions } from "./tara-rig.js";
26
+
27
+ export interface AvatarOptions {
28
+ readonly mount: HTMLElement;
29
+ readonly client: PipecatClient;
30
+ /** Viseme amplitude, 0..2. `1` is as authored. */
31
+ readonly mouthGain?: number;
32
+ /** Gesture-clip amplitude, 0..2. */
33
+ readonly gestureGain?: number;
34
+ /** Idle/liveness amplitude, 0..2. */
35
+ readonly motionGain?: number;
36
+ /** Fires when the GLB is in the scene — for a capture tool, not a consumer. */
37
+ readonly onReady?: () => void;
38
+ }
39
+
40
+ export interface AvatarInstance { destroy(): void; }
41
+
42
+ /** The optional driving-UI declaration; one list for every character built on
43
+ * this rig (`sequences.ts`), because they are one rig. */
44
+ export const supports = BLENDER_SUPPORTS;
45
+
46
+ export function createAvatar(options: AvatarOptions): AvatarInstance {
47
+ const { mount, client, onReady, ...gains } = options;
48
+ if (!mount) throw new TypeError("createAvatar: `mount` is required");
49
+ if (!client) throw new TypeError("createAvatar: `client` is required");
50
+
51
+ const rigOptions: TaraRigOptions = { onReady, url: TESS_GLB };
52
+ // TARA-SPECIFIC, kept on purpose: `TARA_TUNING` (her mouth and motion gains)
53
+ // is the first thing to question if she reads wrong in a call.
54
+ const widget = createSvgAvatar({
55
+ mount, rig: createTaraRig, rigOptions, hand: false,
56
+ sequences: BLENDER_SEQUENCES, actions: BLENDER_ACTIONS, ...TARA_TUNING,
57
+ headHold: headHold("tess"), ...gains,
58
+ });
59
+ const driver = new AvatarClient(widget);
60
+ const detach = driver.attach(client);
61
+
62
+ let destroyed = false;
63
+ return {
64
+ destroy() {
65
+ if (destroyed) return;
66
+ destroyed = true;
67
+ detach();
68
+ driver.destroy();
69
+ widget.destroy();
70
+ },
71
+ };
72
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where tushar's compiled GLB is. One character, one module — see
3
+ * [tara-asset.ts](./tara-asset.ts) for why that separation is load-bearing and
4
+ * why the `new URL` literal is spelled exactly this way.
5
+ */
6
+ export const TUSHAR_GLB = new URL("../../assets/tushar.glb", import.meta.url).href;