@voqalize/avatar 0.3.0 → 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.
Files changed (132) hide show
  1. package/LICENSE-CC-BY-4.0 +396 -0
  2. package/README.md +65 -17
  3. package/assets/README.md +48 -0
  4. package/assets/tanya.glb +0 -0
  5. package/assets/tara.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/AvatarClient.ts +337 -42
  8. package/client/arjun.ts +2 -0
  9. package/client/createAvatar.ts +38 -0
  10. package/client/index.ts +6 -1
  11. package/client/internal.ts +31 -3
  12. package/client/ishita.ts +2 -0
  13. package/client/kabir.ts +2 -0
  14. package/client/meera.ts +2 -0
  15. package/client/naina.ts +2 -0
  16. package/client/playout.ts +95 -0
  17. package/client/supports.ts +28 -0
  18. package/client/three/assets.ts +24 -0
  19. package/client/three/budgets.ts +27 -0
  20. package/client/three/holds.ts +64 -0
  21. package/client/three/internal.ts +54 -0
  22. package/client/three/motion-limits.json +116 -0
  23. package/client/three/sequences.ts +281 -0
  24. package/client/three/tanya.ts +69 -0
  25. package/client/three/tara-rig.ts +1562 -0
  26. package/client/three/tara.ts +76 -0
  27. package/client/three/tushar.ts +65 -0
  28. package/client/types.ts +109 -20
  29. package/client/vikram.ts +2 -0
  30. package/dist/AvatarClient.d.ts +89 -11
  31. package/dist/AvatarClient.d.ts.map +1 -1
  32. package/dist/AvatarClient.js +297 -35
  33. package/dist/AvatarClient.js.map +1 -1
  34. package/dist/arjun.d.ts +1 -0
  35. package/dist/arjun.d.ts.map +1 -1
  36. package/dist/arjun.js +2 -0
  37. package/dist/arjun.js.map +1 -1
  38. package/dist/createAvatar.d.ts +36 -0
  39. package/dist/createAvatar.d.ts.map +1 -1
  40. package/dist/createAvatar.js.map +1 -1
  41. package/dist/index.d.ts +6 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal.d.ts +9 -5
  46. package/dist/internal.d.ts.map +1 -1
  47. package/dist/internal.js +13 -5
  48. package/dist/internal.js.map +1 -1
  49. package/dist/ishita.d.ts +1 -0
  50. package/dist/ishita.d.ts.map +1 -1
  51. package/dist/ishita.js +2 -0
  52. package/dist/ishita.js.map +1 -1
  53. package/dist/kabir.d.ts +1 -0
  54. package/dist/kabir.d.ts.map +1 -1
  55. package/dist/kabir.js +2 -0
  56. package/dist/kabir.js.map +1 -1
  57. package/dist/meera.d.ts +1 -0
  58. package/dist/meera.d.ts.map +1 -1
  59. package/dist/meera.js +2 -0
  60. package/dist/meera.js.map +1 -1
  61. package/dist/naina.d.ts +1 -0
  62. package/dist/naina.d.ts.map +1 -1
  63. package/dist/naina.js +2 -0
  64. package/dist/naina.js.map +1 -1
  65. package/dist/playout.d.ts +42 -0
  66. package/dist/playout.d.ts.map +1 -0
  67. package/dist/playout.js +85 -0
  68. package/dist/playout.js.map +1 -0
  69. package/dist/supports.d.ts +20 -0
  70. package/dist/supports.d.ts.map +1 -0
  71. package/dist/supports.js +26 -0
  72. package/dist/supports.js.map +1 -0
  73. package/dist/three/assets.d.ts +24 -0
  74. package/dist/three/assets.d.ts.map +1 -0
  75. package/dist/three/assets.js +22 -0
  76. package/dist/three/assets.js.map +1 -0
  77. package/dist/three/budgets.d.ts +17 -0
  78. package/dist/three/budgets.d.ts.map +1 -0
  79. package/dist/three/budgets.js +21 -0
  80. package/dist/three/budgets.js.map +1 -0
  81. package/dist/three/holds.d.ts +33 -0
  82. package/dist/three/holds.d.ts.map +1 -0
  83. package/dist/three/holds.js +56 -0
  84. package/dist/three/holds.js.map +1 -0
  85. package/dist/three/internal.d.ts +52 -0
  86. package/dist/three/internal.d.ts.map +1 -0
  87. package/dist/three/internal.js +49 -0
  88. package/dist/three/internal.js.map +1 -0
  89. package/dist/three/motion-limits.json +116 -0
  90. package/dist/three/sequences.d.ts +71 -0
  91. package/dist/three/sequences.d.ts.map +1 -0
  92. package/dist/three/sequences.js +262 -0
  93. package/dist/three/sequences.js.map +1 -0
  94. package/dist/three/tanya.d.ts +35 -0
  95. package/dist/three/tanya.d.ts.map +1 -0
  96. package/dist/three/tanya.js +51 -0
  97. package/dist/three/tanya.js.map +1 -0
  98. package/dist/three/tara-rig.d.ts +403 -0
  99. package/dist/three/tara-rig.d.ts.map +1 -0
  100. package/dist/three/tara-rig.js +1500 -0
  101. package/dist/three/tara-rig.js.map +1 -0
  102. package/dist/three/tara.d.ts +35 -0
  103. package/dist/three/tara.d.ts.map +1 -0
  104. package/dist/three/tara.js +58 -0
  105. package/dist/three/tara.js.map +1 -0
  106. package/dist/three/tushar.d.ts +31 -0
  107. package/dist/three/tushar.d.ts.map +1 -0
  108. package/dist/three/tushar.js +47 -0
  109. package/dist/three/tushar.js.map +1 -0
  110. package/dist/types.d.ts +65 -10
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/types.js +58 -11
  113. package/dist/types.js.map +1 -1
  114. package/dist/vikram.d.ts +1 -0
  115. package/dist/vikram.d.ts.map +1 -1
  116. package/dist/vikram.js +2 -0
  117. package/dist/vikram.js.map +1 -1
  118. package/package.json +34 -6
  119. package/src/avatar.d.ts +59 -2
  120. package/src/avatar.js +743 -94
  121. package/src/behavior.d.ts +28 -12
  122. package/src/behavior.js +54 -42
  123. package/src/canvas/create-rig.js +1 -1
  124. package/src/canvas/src/live.js +2 -2
  125. package/src/conformance.js +1 -1
  126. package/src/emotions.js +4 -2
  127. package/src/gaze.js +334 -73
  128. package/src/head.js +134 -0
  129. package/src/idle.js +210 -19
  130. package/src/interjections.js +70 -26
  131. package/src/prosody.js +647 -0
  132. package/src/visemes.js +85 -15
@@ -15,7 +15,7 @@
15
15
  * const track = new VisemeTrack();
16
16
  * track.start(cues, () => performance.now() - t0);
17
17
  * // per frame:
18
- * const s = track.sample(); // { letter: "D", intensity: 1 } | null
18
+ * const s = track.sample(); // { letter: "D", intensity: 1, phone: "OW" } | null
19
19
  *
20
20
  * `docs/internal-rig.md` describes the pose-channel model the bundled SVG
21
21
  * renderer uses internally. It is *not* the seam to implement — see
@@ -27,6 +27,11 @@ export {
27
27
  // all three in every bundle that wanted the viseme clock.
28
28
  // `@voqalize/avatar/faces/<name>` is where a face comes from.
29
29
  createAvatar as createSvgAvatar,
30
+ // The pose space a custom `rig` is handed on every frame, and the rests it is
31
+ // measured against.
32
+ REST,
33
+ CHANNELS,
34
+ RANGE,
30
35
  STATES,
31
36
  STATE_NAMES,
32
37
  ACTIONS,
@@ -40,6 +45,8 @@ export {
40
45
  VISEME_SHAPES,
41
46
  SILENT,
42
47
  LEAD_MS,
48
+ // The body's share of a held tilt, for a page driving a rig by hand.
49
+ SHOULDER_TILT,
43
50
  shapeFor,
44
51
  normalizeCues,
45
52
  textToCues,
@@ -62,8 +69,18 @@ export type {
62
69
  CreateAvatarOptions as CreateSvgAvatarOptions,
63
70
  } from "../src/avatar.js";
64
71
 
72
+ // The rig contract, for a renderer that implements `apply(frame)` directly
73
+ // instead of wearing the SVG adapter. `docs/internal-rig.md` — and note that
74
+ // this is the mixer's private seam, not the avatar interface.
75
+ export type {
76
+ AvatarFrame,
77
+ AvatarRig,
78
+ AvatarRigFactory,
79
+ HandFrame,
80
+ RigPose,
81
+ } from "../src/rig.js";
82
+
65
83
  export {
66
- BEHAVIOR_STATES,
67
84
  BEHAVIOR_STATE_IDS,
68
85
  BEHAVIOR_ACTIONS,
69
86
  BEHAVIOR_ACTION_IDS,
@@ -72,9 +89,20 @@ export {
72
89
 
73
90
  export type { BehaviorStateId, BehaviorActionId } from "../src/behavior.js";
74
91
 
75
- export { isAvatarMessage } from "./types.js";
92
+ export { isAvatarMessage, CORE_ACTION_IDS } from "./types.js";
93
+ // The optional driving-UI declaration, as a type only. The bundled avatar's own
94
+ // `supports` value lives on the public entry point and importing it here would
95
+ // drag `peep`'s drawing in behind it — the same reason the faces are not
96
+ // exported from this module. Compose yours from `CORE_ACTION_IDS` and
97
+ // `ACTION_IDS` above, which cost nothing.
98
+ export type { AvatarSupport } from "./createAvatar.js";
99
+ // A renderer may reuse the one lifecycle/precedence ladder without gaining a
100
+ // second public avatar interface.
101
+ export { AvatarClient } from "./AvatarClient.js";
76
102
  export type {
77
103
  AvatarCommand,
78
104
  AvatarCue,
79
105
  AvatarCuesCmd,
106
+ CoreActionId,
80
107
  } from "./types.js";
108
+ export type { AvatarClientOptions, AvatarDriver, AvatarPresenceState } from "./AvatarClient.js";
package/client/ishita.ts CHANGED
@@ -13,6 +13,8 @@ const IMAGES = {
13
13
  const FACE = () => import('../src/canvas/avatars/round/face.mjs');
14
14
 
15
15
  export type { CanvasAvatarOptions };
16
+ // Same mixer, same clip library, so the same declaration (`supports.ts`).
17
+ export { supports } from './supports.js';
16
18
 
17
19
  export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
18
20
  return createCanvasAvatar(options, {
package/client/kabir.ts CHANGED
@@ -13,6 +13,8 @@ const IMAGES = {
13
13
  const FACE = () => import('../src/canvas/avatars/round/face.mjs');
14
14
 
15
15
  export type { CanvasAvatarOptions };
16
+ // Same mixer, same clip library, so the same declaration (`supports.ts`).
17
+ export { supports } from './supports.js';
16
18
 
17
19
  export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
18
20
  return createCanvasAvatar(options, {
package/client/meera.ts CHANGED
@@ -13,6 +13,8 @@ const IMAGES = {
13
13
  const FACE = () => import('../src/canvas/avatars/round/face.mjs');
14
14
 
15
15
  export type { CanvasAvatarOptions };
16
+ // Same mixer, same clip library, so the same declaration (`supports.ts`).
17
+ export { supports } from './supports.js';
16
18
 
17
19
  export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
18
20
  return createCanvasAvatar(options, {
package/client/naina.ts CHANGED
@@ -13,6 +13,8 @@ const IMAGES = {
13
13
  const FACE = () => import('../src/canvas/avatars/round/face.mjs');
14
14
 
15
15
  export type { CanvasAvatarOptions };
16
+ // Same mixer, same clip library, so the same declaration (`supports.ts`).
17
+ export { supports } from './supports.js';
16
18
 
17
19
  export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
18
20
  return createCanvasAvatar(options, {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * When the bot's audio actually begins, heard on the track the browser plays.
3
+ *
4
+ * `BotStartedSpeaking` and the audio reach the browser by different roads —
5
+ * the event on the data channel, the sound over RTP and through a jitter
6
+ * buffer — so the event is a statement about *which* audio is starting and
7
+ * not about when anyone hears it. Measured on two stacks, the event arrived
8
+ * 57 ms after the first sample on one and 160 ms before it on the other: the
9
+ * mouth lagged the voice on the first and ran ahead of it on the second, and
10
+ * no constant corrects both. So the turn clock is anchored to the sound.
11
+ *
12
+ * What makes "the sound began" observable is that between turns the decoded
13
+ * track is exact digital zero — Opus decodes silence to silence — so onset is
14
+ * the first sample off zero, not a speech detector's guess at a threshold. That
15
+ * matters: cue `t: 0` is the first sample the TTS produced, lead-in included,
16
+ * and a detector keyed to *speech* would anchor 20-50 ms into the lead-in.
17
+ *
18
+ * What this probe hears is the sound entering the page's audio graph, not
19
+ * leaving the speaker. For a wired speaker the difference is about what the
20
+ * compositor and display add to the picture, and the two cancel. A Bluetooth
21
+ * headset adds 150-250 ms that nothing on the picture side matches, so the
22
+ * probe also reports the device latency the browser admits to, and the
23
+ * client delays the mouth by the excess (`AvatarClient.ts`).
24
+ */
25
+
26
+ export interface PlayoutProbe {
27
+ /**
28
+ * Where the bot's current sound began, on the caller's `now()` clock.
29
+ * `null`: the track is silent right now. `undefined`: this probe cannot say —
30
+ * the track has been sounding for longer than it can look back, or the
31
+ * audio graph is not running.
32
+ */
33
+ onset(): number | null | undefined;
34
+ /** The output device's latency as this context reports it, in ms; 0 when it
35
+ * will not say. The probe shares the device the call's audio plays on. */
36
+ outputLatencyMs?(): number;
37
+ /** Ask a suspended graph to run. A browser that needs a gesture for it
38
+ * ignores the request outside one, so this is safe to call at any time. */
39
+ resume?(): void;
40
+ dispose(): void;
41
+ }
42
+
43
+ /** 8192 samples is 170 ms at 48 kHz: past the 57 ms the sound has been seen to
44
+ * lead the event by, with room for a slower network. */
45
+ const WINDOW = 8192;
46
+ /** -80 dBFS. The gaps between turns are exact zero; this only keeps a
47
+ * denormal from reading as speech. */
48
+ const FLOOR = 1e-4;
49
+
50
+ export function createPlayoutProbe(track: MediaStreamTrack, now: () => number): PlayoutProbe | null {
51
+ const Ctx = globalThis.AudioContext;
52
+ if (!Ctx) return null;
53
+ let ctx: AudioContext;
54
+ let source: MediaStreamAudioSourceNode;
55
+ let analyser: AnalyserNode;
56
+ try {
57
+ ctx = new Ctx();
58
+ source = ctx.createMediaStreamSource(new MediaStream([track]));
59
+ analyser = ctx.createAnalyser();
60
+ analyser.fftSize = WINDOW;
61
+ source.connect(analyser);
62
+ } catch {
63
+ return null;
64
+ }
65
+ // A context made after the user's connect click is allowed to run; one that
66
+ // is not simply answers `undefined`, and the event anchors the turn.
67
+ ctx.resume().catch(() => {});
68
+ const buf = new Float32Array(WINDOW);
69
+
70
+ return {
71
+ onset() {
72
+ if (ctx.state !== "running") return undefined;
73
+ analyser.getFloatTimeDomainData(buf);
74
+ const t = now();
75
+ let first = -1;
76
+ for (let i = 0; i < buf.length; i++) {
77
+ if (Math.abs(buf[i]!) > FLOOR) { first = i; break; }
78
+ }
79
+ if (first < 0) return null;
80
+ if (first === 0) return undefined;
81
+ return t - ((buf.length - first) / ctx.sampleRate) * 1000;
82
+ },
83
+ outputLatencyMs() {
84
+ const s = ctx.outputLatency;
85
+ return typeof s === "number" && Number.isFinite(s) ? s * 1000 : 0;
86
+ },
87
+ resume() {
88
+ if (ctx.state === "suspended") ctx.resume().catch(() => {});
89
+ },
90
+ dispose() {
91
+ source.disconnect();
92
+ ctx.close().catch(() => {});
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `supports` for every avatar this package ships — the SVG faces and the six
3
+ * canvas identities alike.
4
+ *
5
+ * It is one constant and one file because they are one renderer: the canvas
6
+ * avatars mount the same mixer with the same clip library, so a second list
7
+ * would be the same list with a second chance to be wrong. It is not in
8
+ * `createAvatar.ts`, where the type is documented, because a value is imported
9
+ * for real — reading it from there would pull `peep`'s drawing into a canvas
10
+ * avatar's bundle.
11
+ *
12
+ * The two required ids first, then this renderer's own catalogue. `ACTION_IDS`
13
+ * already carries `RESPONSE_INTERRUPTED` as this renderer's shape for it, so
14
+ * the join dedupes rather than concatenating, and `ACKNOWLEDGE` is the one name
15
+ * here with no clip behind it because `action()` resolves it on the floor
16
+ * (docs/internal-mixer.md § Actions).
17
+ */
18
+
19
+ import { ACTION_IDS } from "../src/avatar.js";
20
+ import type { AvatarSupport } from "./createAvatar.js";
21
+ import { CORE_ACTION_IDS } from "./types.js";
22
+
23
+ export const supports: AvatarSupport = Object.freeze({
24
+ actions: Object.freeze([
25
+ ...CORE_ACTION_IDS,
26
+ ...ACTION_IDS.filter((id) => !(CORE_ACTION_IDS as readonly string[]).includes(id)),
27
+ ]),
28
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Where the three compiled characters are, resolved against this module's own
3
+ * location.
4
+ *
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.
16
+ */
17
+ 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,
21
+ });
22
+
23
+ /** Every compiled character, in build order. */
24
+ export type CharacterName = keyof typeof ASSETS;
@@ -0,0 +1,27 @@
1
+ /** Measurable limits for one 3-D avatar at its shipping surface. */
2
+ export const SHIPPING_SURFACE = Object.freeze({
3
+ cssWidth: 400,
4
+ cssHeight: 300,
5
+ maxDevicePixelRatio: 2,
6
+ });
7
+
8
+ /** Keep the physical drawing buffer inside the shipping ceiling at any mount size. */
9
+ export function pixelRatioFor(width: number, height: number, devicePixelRatio: number): number {
10
+ const maxWidth = SHIPPING_SURFACE.cssWidth * SHIPPING_SURFACE.maxDevicePixelRatio;
11
+ const maxHeight = SHIPPING_SURFACE.cssHeight * SHIPPING_SURFACE.maxDevicePixelRatio;
12
+ return Math.max(0.1, Math.min(
13
+ SHIPPING_SURFACE.maxDevicePixelRatio,
14
+ devicePixelRatio,
15
+ maxWidth / Math.max(1, width),
16
+ maxHeight / Math.max(1, height),
17
+ ));
18
+ }
19
+
20
+ /** Crossing one of these is a failed asset/runtime review, not a suggestion. */
21
+ export const HARD_BUDGET = Object.freeze({
22
+ assetBytes: 6 * 1024 * 1024,
23
+ triangles: 60_000,
24
+ drawCalls: 30,
25
+ bones: 40,
26
+ morphTargets: 40,
27
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * How far each character may hold its head off centre, as the mixer wants it:
3
+ * pose units per axis, ready to hand to `createSvgAvatar` as `headHold`.
4
+ *
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
7
+ * look, one axis at a time, at the surface a call actually shows — which is
8
+ * the only way this kind of number can be got, and the reason nothing here
9
+ * recomputes, adjusts or second-guesses one. This file is the unit change and
10
+ * nothing else.
11
+ *
12
+ * An axis the file marks for re-measurement is not enforced: it was read off an
13
+ * asset that has since been fixed, and holding the driver to a defect that no
14
+ * longer exists is worse than the defect. It falls back to the tightest angle
15
+ * still live on that axis across the other characters instead of falling out of
16
+ * the budget altogether, which is what it used to do. That is not a way of
17
+ * believing the stale number by the back door — it is the same budget the
18
+ * faces nobody has any doubt about are already held to. Dropping the axis made
19
+ * the one face nobody can currently vouch for the only one driven with no
20
+ * budget at all, and speech asks for about nine degrees of yaw before a budget
21
+ * trims it.
22
+ */
23
+
24
+ import LIMITS from "./motion-limits.json" with { type: "json" };
25
+ import { HEAD_CLAMP, HEAD_DEG } from "./tara-rig.js";
26
+
27
+ /** Degrees of each axis per pose unit, which is `HEAD_DEG` at the clamp. */
28
+ const PER_UNIT = {
29
+ headYaw: HEAD_DEG.yaw / HEAD_CLAMP,
30
+ headPitch: HEAD_DEG.pitch / HEAD_CLAMP,
31
+ headRoll: HEAD_DEG.roll / HEAD_CLAMP,
32
+ } as const;
33
+
34
+ const AXIS = { headYaw: "yaw", headPitch: "pitch", headRoll: "roll" } as const;
35
+
36
+ export type HeadHold = Partial<Record<keyof typeof AXIS, number>>;
37
+
38
+ type Measured = { safe: number; remeasure?: string };
39
+
40
+ /** What the file says about one character's axis, if it says anything live. */
41
+ const live = (name: string, axis: string): Measured | undefined => {
42
+ const measured = (LIMITS.characters as Record<string, Record<string, unknown>>)[name];
43
+ const entry = measured?.[axis] as Measured | undefined;
44
+ return entry && !entry.remeasure ? entry : undefined;
45
+ };
46
+
47
+ /** The tightest angle any character is still vouched for on this axis. */
48
+ function safest(axis: string): number | undefined {
49
+ const angles = Object.keys(LIMITS.characters)
50
+ .map((name) => live(name, axis)?.safe)
51
+ .filter((deg): deg is number => deg !== undefined);
52
+ return angles.length ? Math.min(...angles) : undefined;
53
+ }
54
+
55
+ /** `name` as the limits file spells it: tara, tushar, tanya. */
56
+ export function headHold(name: keyof typeof LIMITS.characters): HeadHold {
57
+ const out: HeadHold = {};
58
+ for (const channel of Object.keys(AXIS) as (keyof typeof AXIS)[]) {
59
+ const axis = AXIS[channel];
60
+ const deg = live(name, axis)?.safe ?? safest(axis);
61
+ if (deg !== undefined) out[channel] = deg / PER_UNIT[channel];
62
+ }
63
+ return out;
64
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The 2.5-D rig, below the `createAvatar` modules — `/internal`, no semver promise.
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
9
+ * in the working tree, and nothing that ships.
10
+ *
11
+ * It exists because those instruments were reaching around the package instead,
12
+ * by relative path into its source, and two of them carried their own copy of
13
+ * the head envelope — with a comment saying the copy would quietly lie the day
14
+ * the envelope moved. It moved on 2026-09-18. One export is cheaper than that
15
+ * warning, and it is the same `/internal` spelling this package already uses for
16
+ * the mixer.
17
+ */
18
+ export { ASSETS as CHARACTERS } from "./assets.js";
19
+ export type { CharacterName } from "./assets.js";
20
+
21
+ export { createTaraRig, EYE_DEG, HEAD_CLAMP, HEAD_DEG, TARA_TUNING } from "./tara-rig.js";
22
+ export type { TaraRigOptions } from "./tara-rig.js";
23
+ export { HARD_BUDGET, pixelRatioFor, SHIPPING_SURFACE } from "./budgets.js";
24
+
25
+ /**
26
+ * The authored motion tables these characters are driven by.
27
+ *
28
+ * `tara.ts` and its siblings hand them to the mixer and nobody else sees them,
29
+ * which is right for a consumer: a face's clips are not a configuration. An
30
+ * instrument that drives the mixer directly — a scripted take, where the script
31
+ * *is* the server — needs the same two tables, and the alternative was a
32
+ * relative import into the package's own source of the kind this file exists to
33
+ * end.
34
+ */
35
+ export { BLENDER_ACTIONS, BLENDER_SEQUENCES, BLENDER_SUPPORTS } from "./sequences.js";
36
+
37
+ /**
38
+ * The head budget each character is driven under, and the measurements it comes
39
+ * from.
40
+ *
41
+ * An instrument that watches a character move has to mount the *shipping*
42
+ * character or it is watching something else, and `headHold` is the one option
43
+ * `tara.ts` passes that a lab page would otherwise have to guess at — the
44
+ * budget is load-bearing, not a margin: speech asks for about nine degrees of
45
+ * yaw before it trims one. The limits themselves come along because a page that
46
+ * plots degrees has to draw the band, and reading it out of the same file the
47
+ * budget is derived from is the only way the two cannot disagree.
48
+ *
49
+ * Read-only, both of them. Nothing here recomputes a measured angle
50
+ * (`holds.ts`).
51
+ */
52
+ export { headHold } from "./holds.js";
53
+ export type { HeadHold } from "./holds.js";
54
+ export { default as MOTION_LIMITS } from "./motion-limits.json" with { type: "json" };
@@ -0,0 +1,116 @@
1
+ {
2
+ "what": "How far each character may HOLD its head off centre, in degrees, measured by eye. A sustained pose is the strict case and this is the strict number: past it a static turn stops reading as a head that turned, because the eye behind a flat projection never foreshortens. `holds.ts` converts these into the mixer's pose units and `test/three/presence.test.ts` gates LISTENING and SPEAKING against them.",
3
+ "versus": "This is not `HEAD_DEG` and is much smaller than it. `HEAD_DEG` (tara-rig.ts) is the envelope any motion may REACH — set by where the projection itself tears, which a rendered ladder put past 21 degrees of yaw on tara. A transient that returns is forgiven what a held pose is not, so a nod may pass through an angle this file will not let the head sit at.",
4
+ "units": "degrees from rest, either side, per axis driven alone from rest",
5
+ "surface": {
6
+ "cssWidth": 400,
7
+ "cssHeight": 300,
8
+ "devicePixelRatio": 2,
9
+ "magnification": 1
10
+ },
11
+ "measured": {
12
+ "on": "2026-09-18",
13
+ "by": "owner",
14
+ "using": "the hold-and-move rig instrument in the working tree, which pins one axis at a fixed angle",
15
+ "how": "static held pose, one axis at a time from rest, judged by eye at the shipping surface",
16
+ "safe": "reads as a head that turned, with nothing to notice",
17
+ "limit": "the last angle before something is visibly wrong"
18
+ },
19
+ "characters": {
20
+ "tara": {
21
+ "yaw": {
22
+ "safe": 6,
23
+ "limit": 7
24
+ },
25
+ "pitch": {
26
+ "safe": 5,
27
+ "limit": 6
28
+ },
29
+ "roll": {
30
+ "safe": 6,
31
+ "limit": 7
32
+ },
33
+ "defects": [
34
+ {
35
+ "axis": "yaw",
36
+ "from": 7,
37
+ "what": "the eye does not foreshorten or narrow; it is what gives the turn away first"
38
+ },
39
+ {
40
+ "axis": "roll",
41
+ "from": 0,
42
+ "what": "the transition reads as a hinge rather than a neck bending, at any angle"
43
+ }
44
+ ]
45
+ },
46
+ "tushar": {
47
+ "yaw": {
48
+ "safe": 6,
49
+ "limit": 7
50
+ },
51
+ "pitch": {
52
+ "safe": 5,
53
+ "limit": 6
54
+ },
55
+ "roll": {
56
+ "safe": 6,
57
+ "limit": 7
58
+ },
59
+ "defects": [
60
+ {
61
+ "axis": "yaw",
62
+ "from": 7,
63
+ "what": "the eye does not foreshorten or narrow; it is what gives the turn away first"
64
+ },
65
+ {
66
+ "axis": "roll",
67
+ "from": 0,
68
+ "what": "the transition reads as a hinge rather than a neck bending, at any angle"
69
+ }
70
+ ]
71
+ },
72
+ "tanya": {
73
+ "yaw": {
74
+ "safe": 5,
75
+ "limit": 5.5,
76
+ "remeasure": "every defect this was measured from was fixed on 2026-09-19 (8ba16e8 the neck under the jaw, 6c5c141 the hair that hangs past it), so the number is a record of the old asset and not of this one. Nothing may gate on it until the owner drives her again."
77
+ },
78
+ "pitch": {
79
+ "safe": 5,
80
+ "limit": 6
81
+ },
82
+ "roll": {
83
+ "safe": 1,
84
+ "limit": 1,
85
+ "remeasure": "every defect this was measured from was fixed on 2026-09-19 (8ba16e8 the neck under the jaw, 6c5c141 the hair that hangs past it), so the number is a record of the old asset and not of this one. Nothing may gate on it until the owner drives her again."
86
+ },
87
+ "defects": [
88
+ {
89
+ "axis": "yaw",
90
+ "from": 5.5,
91
+ "what": "a black area opens along the jaw line",
92
+ "fixed": "2026-09-19"
93
+ },
94
+ {
95
+ "axis": "yaw",
96
+ "from": 5.5,
97
+ "what": "something around the neck moves incorrectly",
98
+ "fixed": "2026-09-19"
99
+ },
100
+ {
101
+ "axis": "roll",
102
+ "from": 1,
103
+ "what": "a gap opens between the neck and the face on the far side",
104
+ "fixed": "2026-09-19"
105
+ },
106
+ {
107
+ "axis": "roll",
108
+ "from": 2,
109
+ "what": "the hair breaks up",
110
+ "fixed": "2026-09-19"
111
+ }
112
+ ]
113
+ }
114
+ },
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."
116
+ }