@voqalize/avatar 0.0.1

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 (51) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +692 -0
  3. package/client/dist/Avatar.d.ts +24 -0
  4. package/client/dist/Avatar.d.ts.map +1 -0
  5. package/client/dist/Avatar.js +7 -0
  6. package/client/dist/Avatar.js.map +1 -0
  7. package/client/dist/AvatarClient.d.ts +173 -0
  8. package/client/dist/AvatarClient.d.ts.map +1 -0
  9. package/client/dist/AvatarClient.js +274 -0
  10. package/client/dist/AvatarClient.js.map +1 -0
  11. package/client/dist/pipecat.d.ts +21 -0
  12. package/client/dist/pipecat.d.ts.map +1 -0
  13. package/client/dist/pipecat.js +21 -0
  14. package/client/dist/pipecat.js.map +1 -0
  15. package/client/dist/react.d.ts +16 -0
  16. package/client/dist/react.d.ts.map +1 -0
  17. package/client/dist/react.js +17 -0
  18. package/client/dist/react.js.map +1 -0
  19. package/client/dist/types.d.ts +101 -0
  20. package/client/dist/types.d.ts.map +1 -0
  21. package/client/dist/types.js +31 -0
  22. package/client/dist/types.js.map +1 -0
  23. package/client/dist/useAvatar.d.ts +53 -0
  24. package/client/dist/useAvatar.d.ts.map +1 -0
  25. package/client/dist/useAvatar.js +68 -0
  26. package/client/dist/useAvatar.js.map +1 -0
  27. package/client/src/Avatar.tsx +38 -0
  28. package/client/src/AvatarClient.ts +343 -0
  29. package/client/src/pipecat.ts +38 -0
  30. package/client/src/react.ts +34 -0
  31. package/client/src/types.ts +127 -0
  32. package/client/src/useAvatar.ts +113 -0
  33. package/docs/contract-avatar.md +337 -0
  34. package/docs/contract-protocol.md +401 -0
  35. package/package.json +89 -0
  36. package/src/audio-fallback.js +100 -0
  37. package/src/avatar.d.ts +241 -0
  38. package/src/avatar.js +722 -0
  39. package/src/clips.js +144 -0
  40. package/src/emotions.js +55 -0
  41. package/src/face-core.js +154 -0
  42. package/src/face-myna.js +725 -0
  43. package/src/face-peep.js +767 -0
  44. package/src/face-wren.js +470 -0
  45. package/src/gaze.js +155 -0
  46. package/src/idle.js +535 -0
  47. package/src/interjections.js +578 -0
  48. package/src/line-art.js +111 -0
  49. package/src/params.js +176 -0
  50. package/src/perform.js +105 -0
  51. package/src/visemes.js +230 -0
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@voqalize/avatar/pipecat` β€” drive the widget from a pipecat session.
3
+ *
4
+ * Framework-free: everything here is plain TypeScript over the `AvatarApi` the
5
+ * root export returns. `AvatarClient` is the whole surface β€” construct it
6
+ * around a mounted widget and either `attach()` it to a live `PipecatClient`
7
+ * or feed it messages yourself with `dispatch()`.
8
+ *
9
+ * import { createAvatar } from "@voqalize/avatar";
10
+ * import { AvatarClient } from "@voqalize/avatar/pipecat";
11
+ *
12
+ * const avatar = createAvatar({ mount: "#tile" });
13
+ * const detach = new AvatarClient(avatar).attach(pipecatClient);
14
+ *
15
+ * `@pipecat-ai/client-js` is a peer dependency of this subpath only β€” the root
16
+ * export has no dependencies at all, and a host that carries avatar commands
17
+ * over its own transport can import this module and never call `attach()`.
18
+ */
19
+
20
+ export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
21
+
22
+ export {
23
+ isAvatarMessage,
24
+ AVATAR_MESSAGE_TYPE,
25
+ AVATAR_PROTOCOL_VERSION,
26
+ type AvatarCommand,
27
+ type AvatarCue,
28
+ type AvatarCuesCmd,
29
+ type AvatarHintCmd,
30
+ type AvatarInterjectCmd,
31
+ type AvatarPerformAction,
32
+ type AvatarPerformCmd,
33
+ type AvatarServerMessage,
34
+ type AvatarSpeechCmd,
35
+ type AvatarStateCmd,
36
+ type AvatarUnknownCmd,
37
+ type AvatarUserCmd,
38
+ } from "./types.js";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@voqalize/avatar/react` β€” the React binding.
3
+ *
4
+ * import { Avatar } from "@voqalize/avatar/react";
5
+ *
6
+ * <Avatar client={pipecatClient} className="tile" />
7
+ *
8
+ * Peers: `react >= 18` and `@pipecat-ai/client-js`. Everything a non-React
9
+ * host needs is in `@voqalize/avatar/pipecat`; this module adds a mount
10
+ * lifecycle and nothing else.
11
+ */
12
+
13
+ export { useAvatar, type UseAvatarHandle, type UseAvatarOptions } from "./useAvatar.js";
14
+ export { Avatar, type AvatarProps } from "./Avatar.js";
15
+
16
+ // Re-exported so a React consumer needs one import for the common case.
17
+ export { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
18
+ export {
19
+ isAvatarMessage,
20
+ AVATAR_MESSAGE_TYPE,
21
+ AVATAR_PROTOCOL_VERSION,
22
+ type AvatarCommand,
23
+ type AvatarCue,
24
+ type AvatarCuesCmd,
25
+ type AvatarHintCmd,
26
+ type AvatarInterjectCmd,
27
+ type AvatarPerformAction,
28
+ type AvatarPerformCmd,
29
+ type AvatarServerMessage,
30
+ type AvatarSpeechCmd,
31
+ type AvatarStateCmd,
32
+ type AvatarUnknownCmd,
33
+ type AvatarUserCmd,
34
+ } from "./types.js";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * types.ts β€” the avatar wire vocabulary, client side.
3
+ *
4
+ * The binding definition is `docs/contract-protocol.md`; this file is its
5
+ * TypeScript restatement and must not drift from it. The Python half of the
6
+ * same vocabulary is `py/src/voqalize_avatar/messages.py` β€” the three are
7
+ * maintained together, and a command added to one without the others is
8
+ * incomplete.
9
+ *
10
+ * A server pushes these as RTVI `server-message`s under the envelope
11
+ * `{ type: "avatar", v: 1, ...cmd-specific fields }`. {@link AvatarCommand}
12
+ * describes the *payload*, not the envelope, because the payload is what
13
+ * arrives however the host chose to carry it β€” `AvatarClient.dispatch()`
14
+ * accepts anything with a string `cmd`, so an application that tunnels these
15
+ * through its own message type can hand them straight over.
16
+ */
17
+
18
+ /** A viseme cue: `t` is a ms offset into the utterance's clock, `v` is a Rhubarb A–H (or X) letter. */
19
+ export interface AvatarCue {
20
+ t: number;
21
+ v: string;
22
+ i?: number;
23
+ }
24
+
25
+ /** `perform()` timeline action β€” see docs/contract-protocol.md Β§ Composing behavior. */
26
+ export interface AvatarPerformAction {
27
+ t: number;
28
+ do: "state" | "emotion" | "gaze" | "interject";
29
+ name?: string;
30
+ id?: string;
31
+ i?: number;
32
+ keepGaze?: boolean;
33
+ }
34
+
35
+ export interface AvatarStateCmd {
36
+ cmd: "state";
37
+ name: string;
38
+ emotion?: string;
39
+ gaze?: string;
40
+ }
41
+
42
+ export interface AvatarInterjectCmd {
43
+ cmd: "interject";
44
+ id: string;
45
+ }
46
+
47
+ export interface AvatarPerformCmd {
48
+ cmd: "perform";
49
+ actions: AvatarPerformAction[];
50
+ ctx?: string;
51
+ }
52
+
53
+ export interface AvatarCuesCmd {
54
+ cmd: "cues";
55
+ ctx: string;
56
+ /** Discard queued cues at or after this offset (ms), then append `cues`. */
57
+ from_ms: number;
58
+ cues: AvatarCue[];
59
+ /**
60
+ * True on the one chunk that completes this turn's track: the TTS context is
61
+ * closed, so no further chunk will splice into `ctx`. What a client may
62
+ * assume, exactly β€” nothing about playout. The audio it describes is still
63
+ * ahead, and `speech stop` remains the end of the turn. It is safe to release
64
+ * per-turn cue state (the splice buffer for `ctx`) once the last cue has
65
+ * played, and safe to stop expecting more.
66
+ *
67
+ * Absent on an interrupted turn, deliberately: a turn that was cut never
68
+ * claims to have completed. Absent chunks are the normal case β€” the widget's
69
+ * own track already completes on the trailing `X`, so ignoring `final`
70
+ * entirely is a correct implementation.
71
+ */
72
+ final?: boolean;
73
+ }
74
+
75
+ export interface AvatarSpeechCmd {
76
+ cmd: "speech";
77
+ event: "start" | "stop";
78
+ ctx: string;
79
+ }
80
+
81
+ export interface AvatarUserCmd {
82
+ cmd: "user";
83
+ speaking: boolean;
84
+ }
85
+
86
+ export interface AvatarHintCmd {
87
+ cmd: "hint";
88
+ kind: "eager_eot" | (string & {});
89
+ }
90
+
91
+ /** A cmd this build doesn't recognize β€” dispatched to nothing, ignored for forward compat. */
92
+ export interface AvatarUnknownCmd {
93
+ cmd: string;
94
+ [key: string]: unknown;
95
+ }
96
+
97
+ export type AvatarCommand =
98
+ | AvatarStateCmd
99
+ | AvatarInterjectCmd
100
+ | AvatarPerformCmd
101
+ | AvatarCuesCmd
102
+ | AvatarSpeechCmd
103
+ | AvatarUserCmd
104
+ | AvatarHintCmd
105
+ | AvatarUnknownCmd;
106
+
107
+ /** The full server-message payload: the avatar envelope plus its `cmd`. */
108
+ export type AvatarServerMessage = AvatarCommand & {
109
+ type?: "avatar";
110
+ v?: number;
111
+ };
112
+
113
+ /** Narrows an unknown server-message payload to an avatar command. */
114
+ export function isAvatarMessage(msg: unknown): msg is AvatarServerMessage {
115
+ if (typeof msg !== "object" || msg === null) return false;
116
+ const m = msg as Record<string, unknown>;
117
+ return typeof m.cmd === "string";
118
+ }
119
+
120
+ /** The envelope `type` the protocol reserves for avatar traffic. */
121
+ export const AVATAR_MESSAGE_TYPE = "avatar";
122
+
123
+ /** The protocol version this client speaks β€” matches `AVATAR_PROTOCOL_VERSION`
124
+ * in the Python package. Sent as `v` and, today, never checked: an unknown
125
+ * `cmd` is ignored rather than version-gated, which is the forward-compat rule
126
+ * the contract states. */
127
+ export const AVATAR_PROTOCOL_VERSION = 1;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * useAvatar β€” mount the widget, wire it to a live session, dispatch its
3
+ * server-messages, and clean up.
4
+ *
5
+ * Options are read through a ref so the effect doesn't re-subscribe on every
6
+ * render. Split in two effects: mounting the widget happens once (an avatar or
7
+ * theme swap remounts by design β€” see the note in the effect); attaching to
8
+ * the pipecat client re-runs whenever the client identity changes (a session
9
+ * reconnect mints a new one) or once the widget instance becomes available.
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import type { PipecatClient } from "@pipecat-ai/client-js";
14
+ import { createAvatar, type AvatarApi, type CreateAvatarOptions } from "../../src/avatar.js";
15
+ import { AvatarClient, type AvatarClientOptions } from "./AvatarClient.js";
16
+
17
+ export interface UseAvatarOptions extends AvatarClientOptions {
18
+ /** Name from `AVATAR_NAMES`. Omit for the widget's own `DEFAULT_AVATAR`. */
19
+ avatar?: string;
20
+ theme?: CreateAvatarOptions["theme"];
21
+ /** Articulation gains β€” see docs/contract-protocol.md Β§ Events, gains, introspection. */
22
+ mouthGain?: number;
23
+ gestureGain?: number;
24
+ /** The live `PipecatClient` to dispatch server-messages from, or `null`
25
+ * before connect. `useAvatar` (dis)connects the subscription as this
26
+ * changes; it does not create or own the client. */
27
+ client?: PipecatClient | null;
28
+ }
29
+
30
+ /**
31
+ * The mount ref's type, written out rather than named as React's `RefObject`.
32
+ *
33
+ * React 18 and 19 declare that alias with different type arguments β€” 18's
34
+ * `useRef<T>(null)` yields `RefObject<T>`, 19's yields `RefObject<T | null>` β€”
35
+ * and because both are the *same alias*, TypeScript compares them by variance
36
+ * and rejects whichever one we didn't pick. An anonymous shape forces a
37
+ * structural comparison instead, which both versions satisfy, and which the
38
+ * `ref` prop accepts on both. This is the only place the 18-vs-19 split shows
39
+ * up in the binding; keep it that way.
40
+ */
41
+ export type AvatarMountRef = { current: HTMLDivElement | null };
42
+
43
+ export interface UseAvatarHandle {
44
+ /** Attach to the mount element: `<div ref={containerRef} />`. */
45
+ containerRef: AvatarMountRef;
46
+ /** The live widget instance once mounted, else `null`. */
47
+ avatar: AvatarApi | null;
48
+ /** The dispatcher wrapping `avatar` β€” `null` until mounted. Exposed for
49
+ * tests and telemetry (`turnCtx`, `turnCues`) and for manual dispatch. */
50
+ client: AvatarClient | null;
51
+ /** Dispatch one avatar command by hand β€” e.g. from a dev-tools console, or
52
+ * from a transport that isn't a `PipecatClient`. No-ops before mount. */
53
+ dispatch: (msg: unknown) => void;
54
+ }
55
+
56
+ export function useAvatar(options: UseAvatarOptions = {}): UseAvatarHandle {
57
+ const containerRef = useRef<HTMLDivElement>(null);
58
+ const [avatar, setAvatar] = useState<AvatarApi | null>(null);
59
+ const avatarClientRef = useRef<AvatarClient | null>(null);
60
+
61
+ // Latest-options ref, so the mount effect (which runs once) still reads live
62
+ // callback props without re-subscribing.
63
+ const optionsRef = useRef(options);
64
+ optionsRef.current = options;
65
+
66
+ useEffect(() => {
67
+ const mount = containerRef.current;
68
+ if (!mount) return;
69
+ const instance = createAvatar({
70
+ mount,
71
+ avatar: optionsRef.current.avatar,
72
+ theme: optionsRef.current.theme,
73
+ mouthGain: optionsRef.current.mouthGain,
74
+ gestureGain: optionsRef.current.gestureGain,
75
+ });
76
+ const wrapper = new AvatarClient(instance, {
77
+ onHint: (kind, msg) => optionsRef.current.onHint?.(kind, msg),
78
+ onUnknownCmd: (msg) => optionsRef.current.onUnknownCmd?.(msg),
79
+ onError: (err, msg) => optionsRef.current.onError?.(err, msg),
80
+ onSpeakingDrift: (info) => optionsRef.current.onSpeakingDrift?.(info),
81
+ accept: optionsRef.current.accept,
82
+ now: optionsRef.current.now,
83
+ });
84
+ avatarClientRef.current = wrapper;
85
+ setAvatar(instance);
86
+
87
+ return () => {
88
+ instance.destroy();
89
+ avatarClientRef.current = null;
90
+ setAvatar(null);
91
+ };
92
+ // Mount once. `avatar`/`theme`/the gains are read at mount time only β€” the
93
+ // widget has no hot-swap-avatar API (`createFace` runs once per mount), so
94
+ // changing them re-renders nothing here by design; a caller that needs a
95
+ // different avatar remounts with a `key` prop (see the component's doc).
96
+ // eslint-disable-next-line react-hooks/exhaustive-deps
97
+ }, []);
98
+
99
+ useEffect(() => {
100
+ const wrapper = avatarClientRef.current;
101
+ const pipecatClient = options.client;
102
+ if (!wrapper || !pipecatClient) return;
103
+ return wrapper.attach(pipecatClient);
104
+ // Re-subscribe when the widget mounts or the session's client changes.
105
+ // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ }, [avatar, options.client]);
107
+
108
+ const dispatch = useCallback((msg: unknown) => {
109
+ avatarClientRef.current?.dispatch(msg);
110
+ }, []);
111
+
112
+ return { containerRef, avatar, client: avatarClientRef.current, dispatch };
113
+ }
@@ -0,0 +1,337 @@
1
+ # Contract B β€” mixer ↔ face (the avatar contract)
2
+
3
+ *Living document. Describes the code as of `src/face*.js` on `main`; the
4
+ [Direction](#direction) section flags what is about to change. The counterpart
5
+ contract β€” what the server drives β€” is
6
+ [contract-protocol.md](contract-protocol.md).*
7
+
8
+ An avatar is a module exporting exactly this, and nothing more:
9
+
10
+ ```js
11
+ createFace(mount, theme) -> { svg, apply(params), theme, destroy() }
12
+ META = { viewBox: {x, y, w, h}, mouthCrop: {x, y, w, h} }
13
+ ```
14
+
15
+ - `mount` β€” element to render into. `destroy()` empties it.
16
+ - `theme` β€” optional per-key colour overrides merged over the rig's palette;
17
+ the merged object is returned as `theme`. Theme *keys* are per-avatar
18
+ (peep and wren carry 8 each; the retired rigs shared a ~25-key palette).
19
+ Hosts that paint *around* the widget read them off `api.theme` β€” see
20
+ Palettes below.
21
+ - `svg` β€” the live `<svg>` element.
22
+ - `apply(params)` β€” write one full parameter vector into the DOM. Called every
23
+ animation frame.
24
+ - `META` β€” the **avatar descriptor**: what a host or tool may know about the
25
+ face without opening it. `viewBox` is the framing (hosts derive aspect from
26
+ it; `createAvatar` exposes it as `api.meta`); `mouthCrop` frames the mouth
27
+ for close inspection (the contact sheet's viseme-detail row). Deliberately
28
+ minimal β€” a landmark joins META when a second consumer needs it, not before.
29
+
30
+ **A face must be callable standalone.** Three rig-tooling pages call
31
+ `AVATARS[name].create(mount)` directly with no mixer and drive `apply()` from
32
+ a raw vector; a face that only works under `createAvatar` is broken.
33
+
34
+ Nothing above the face knows what a face looks like; nothing in the face knows
35
+ what a call is. If a visual change needs `params.js` touched, reconsider; if
36
+ it needs anything outside `src/face*.js`, it's a bug.
37
+
38
+ ## Obligations of `apply(params)`
39
+
40
+ - **Consume, don't smooth.** Values arrive already clamped to `RANGE` and
41
+ already smoothed through per-channel time constants. Add no easing of your
42
+ own.
43
+ - **Idempotent and cheap.** Same vector in, same DOM out; memoize attribute
44
+ writes (every rig shares a `prev`-map `set(node, attr, val)` helper) so
45
+ an unchanged channel costs nothing. ~60 calls/s is the budget.
46
+ - **Never write `viewBox`.** Every pose channel is a transform or a path,
47
+ never the camera. Tooling relies on this to crop safely after `apply()`.
48
+ - **Honour the channel's *semantic*, not its plumbing.** The channel value is
49
+ what an author of `visemes.js`/`emotions.js` β€” who never sees your rig β€”
50
+ thinks they are asking for. The standing example: `mouthOpen` denotes the
51
+ *visible aperture*. peep initially mapped it to the gap between lip
52
+ centrelines; the drawn lip band was ~11 units thick, so the mouth stayed
53
+ visibly shut until 0.25 and two of the nine visemes live below that. The fix
54
+ was to solve back from aperture to control points, not to re-tune the
55
+ visemes.
56
+
57
+ ## The parameter vector
58
+
59
+ 30 float channels (`src/params.js`). Rest is the neutral face; range is the
60
+ post-mix clamp; Ο„ is the smoothing time constant the mixer applies (the face
61
+ never does). Sign conventions are from the *viewer's* perspective.
62
+
63
+ | channel | rest | range | Ο„ (s) | means |
64
+ |---|---|---|---|---|
65
+ | `mouthOpen` | 0.02 | 0..1 | 0.042 | visible vertical aperture |
66
+ | `mouthWidth` | 0.42 | 0..1 | 0.042 | narrow..wide (0.42 neutral) |
67
+ | `mouthRound` | 0.10 | 0..1 | 0.042 | pucker / protrusion |
68
+ | `mouthPress` | 0.15 | 0..1 | 0.042 | lips thinned & pressed |
69
+ | `mouthTuck` | 0 | 0..1 | 0.042 | lower lip under upper teeth (F/V) |
70
+ | `mouthCornerL/R` | 0.10 | βˆ’1.4..1.4 | 0.13 | βˆ’frown..+smile |
71
+ | `teethUpper` | 0 | 0..1 | 0.042 | upper-teeth reveal |
72
+ | `tongue` | 0 | 0..1 | 0.042 | tongue raised into aperture (L) |
73
+ | `jaw` | 0 | 0..1 | 0.07 | extra chin drop, lags the lips |
74
+ | `lidL/R` | 0.12 | 0..1 | 0.018 | 0 wide open..1 closed; rest grazes the iris |
75
+ | `squintL/R` | 0 | 0..1 | 0.12 | lower lid raised (smile/suspicion) |
76
+ | `pupilX/Y` | 0 / 0.05 | βˆ’1..1 | 0.032 | gaze offset, +right / +down |
77
+ | `browRaiseL/R` | 0 | βˆ’1..1 | 0.08 | whole-brow lift |
78
+ | `browAngleL/R` | 0 | βˆ’1.4..1.4 | 0.08 | outer-end up |
79
+ | `browInnerL/R` | 0 | βˆ’1..1 | 0.08 | inner-end lift (AU1, "concern") |
80
+ | `headYaw` | 0 | βˆ’1.4..1.4 | 0.16 | + toward viewer's right |
81
+ | `headPitch` | 0 | βˆ’1.4..1.4 | 0.16 | + chin down |
82
+ | `headRoll` | 0 | βˆ’1.4..1.4 | 0.16 | + tilt toward viewer's right |
83
+ | `breath` | 0 | 0..1 | 0.25 | idle-driven breathing cycle |
84
+ | `shoulderL/R` | 0 | βˆ’1..1 | 0.19 | βˆ’dropped..+raised |
85
+ | `torsoLean` | 0 | βˆ’1..1 | 0.24 | βˆ’back..+forward; reads as scale change |
86
+ | `torsoTurn` | 0 | βˆ’1..1 | 0.44 | trunk lateral travel, + toward viewer's right |
87
+
88
+ Groups (`GROUPS`): `mouth`, `smile`, `eyes`, `gaze`, `brows`, `head`, `body`
89
+ (breath + torsoLean + torsoTurn), `shoulders` β€” clips declare group ownership by
90
+ them.
91
+
92
+ `torsoTurn`'s time constant is nearly 3Γ— the head's, and that ratio is load-
93
+ bearing rather than taste: the mixer feeds it the *same* target as `headYaw`
94
+ (scaled by `TRUNK_FOLLOW = 0.45` in `avatar.js`), so a sustained head turn is
95
+ chased by a trunk that leaves late and settles late. Follow-through falls out of
96
+ the smoothing the rig already had; there is no second animation system. Shorten
97
+ it toward 0.16 and head and trunk move as one rigid piece, which is the puppet
98
+ read.
99
+
100
+ A face should consume all 30. One sanctioned exception exists: peep ignores
101
+ `jaw` (its construction has no drawn jaw line to drop β€” a documented character
102
+ decision, not an oversight).
103
+
104
+ There are deliberately **no arm or hand channels**; see the note in
105
+ `params.js` before considering any.
106
+
107
+ ## Invariant vs per-avatar
108
+
109
+ Three rigs were built independently and their `apply()` implementations
110
+ converged on the same eight blocks in the same order β€” torso lean β†’ shoulders
111
+ β†’ parallax layer loop β†’ eyes β†’ brows β†’ mouth β†’ teeth β†’ tongue β€” with the same
112
+ memoizer and the same return shape. That convergence now lives in
113
+ **`src/face-core.js`**, which owns:
114
+
115
+ - the shell: mount, id-scoped selector, the memoized `set(node, attr, val)`;
116
+ - `poseTransforms(p, set, el, POSE)` β€” lean, shoulders, parallax, driven by a
117
+ per-rig `POSE` spec of named scalars (below);
118
+ - the shared feature fragments a rig opts into where its model matches:
119
+ `pairedTeeth` (peep, wren). Two more β€” `irisLidEyes` and `browPair` β€” were
120
+ removed with the rigs that used them; a future rig with sclera and
121
+ endpoint-pair brows should recover them from git history rather than
122
+ re-derive them;
123
+ - the shared constants: lean scale `0.055`, head-roll multipliers Γ—5.5
124
+ features / Γ—1.5 torso, shrug/tilt derivation `shrug=(L+R)/2`, `tilt=(Rβˆ’L)/2`,
125
+ lower-teeth reveal ramp `(open βˆ’ 0.45) / 0.4`, tongue gate `> 0.02`;
126
+ - `faceApi` β€” the return shape.
127
+
128
+ What legitimately varies per avatar, and stays in the face module:
129
+
130
+ - **The `POSE`/`EYES` spec values**: `yawPx`/`pitchPx` (parallax travel),
131
+ `pivot`, lean travel and pivot, shrug lift and tilt degrees, `turnPx` (lateral
132
+ trunk travel at `torsoTurn = 1`), the breath model, pupil travel, `lidFollow`
133
+ strength (0.22 on both current rigs), plus a `units` factor (see Art units).
134
+ - **The breath model's numbers**. A rig declares `breathSwell` + `swellPivot`
135
+ and breathes as a *scale about the hem*:
136
+ the shoulder line rises and the chest widens while the bottom of the shirt
137
+ stays put, and the head's matching lift is derived arithmetic
138
+ (`swell Γ— (swellPivot.y βˆ’ pivot.y)`) rather than a second tuned constant, so
139
+ the two layers cannot drift and the neck cannot telescope. `breathSwell` is
140
+ required. It replaced a rigid vertical slide of the whole shirt, which moved
141
+ *more* pixels and read as *less* alive β€” a figure translating up and down has
142
+ been nudged, not filled with air.
143
+ - **Structural choices**: layer set and parallax table (the retired rigs ran 7
144
+ layers and 4; peep and wren fuse to 4 β€” the art decides, not a standard); eye
145
+ model
146
+ (iris + 4 lid paths vs peep's single translated group + regenerated bean);
147
+ brow input (endpoint pair vs drawn point list); mouth output (path strings
148
+ vs peep's contour point list); which optional elements exist (`jaw` shade,
149
+ `subLip`, `seam`, split lip edges, lower teeth). A rig whose model diverges
150
+ keeps its own block instead of the shared fragment β€” peep's eyes and brows
151
+ are the standing example.
152
+
153
+ Do not chase parity between faces: they are separate drawings, not renderings
154
+ of one drawing. A visual improvement lands in one face and stops there.
155
+
156
+ ## Art units
157
+
158
+ Units are per-rig (peep 760Γ—950 cropped to `92 76 576 800`; wren and myna the
159
+ same window at `92 50`; the retired rigs were 320Γ—400 and a native 1024Β² cropped to
160
+ `179 42 666 832` β€” note how little the aspect agreed). **Copying a magnitude
161
+ between rigs is silent breakage**: one retired rig's travels were the other's
162
+ numbers with `units: S` (S = 2.67) in its `POSE` spec; peep's torso channels
163
+ were once ported without conversion and the shoulders stopped reading, while
164
+ nothing threw and `sweep()` passed. The trap inside the
165
+ trap: **translations convert, degrees don't** β€” a rotation is already
166
+ unit-independent, which is why `shrugTiltDeg` never takes the `units` factor.
167
+
168
+ `viewBox` is not a rig constant. Hosts derive aspect from `META.viewBox` (or
169
+ `api.meta.viewBox`); the demos do.
170
+
171
+ ## Registration
172
+
173
+ `src/avatar.js` holds the registry β€” `{ create, meta }` records:
174
+
175
+ ```js
176
+ export const AVATARS = {
177
+ peep: { create, meta },
178
+ wren: { create, meta },
179
+ myna: { create, meta },
180
+ };
181
+ export const DEFAULT_AVATAR = 'peep';
182
+ createAvatar({ avatar: 'peep' }) // by name
183
+ createAvatar({ face: myCreateFace }) // any factory, never registered
184
+ ```
185
+
186
+ A bare `face:` factory has no descriptor; `createAvatar` then derives
187
+ `meta.viewBox` from the produced svg and `meta.mouthCrop` is absent β€” registry
188
+ avatars always carry the full META.
189
+
190
+ Palettes: there is no barrel `THEME` export β€” each face module owns its
191
+ palette, and `api.theme` returns the mounted avatar's. A host needs it
192
+ whenever it paints anything *around* the widget: every rig is drawn portrait,
193
+ so a 16:9 call tile leaves a margin either side of the drawing, and the margin
194
+ has to be filled with the rig's own backdrop or the tile reads as a portrait
195
+ picture hung in a landscape frame. `demo/call.html` does exactly that β€” tile
196
+ background from `theme.bg0/bg1`, plus a mask feathering the drawing's two
197
+ vertical edges, because peep's white shirt is drawn to run off its own frame
198
+ and otherwise stops in mid-air. Reshaping the art to fit a host's box is the
199
+ wrong fix; the widget does not control the box. peep has
200
+ no dark palette **by decision** (inverting two-value line art recolours the
201
+ hair and ages the character; that is geometry wearing a palette's clothes) β€”
202
+ its theme keys stay overridable, but do not add a `dark` selector.
203
+
204
+ ## Checklist for a new avatar
205
+
206
+ 1. Serve with `python3 serve.py 8777` (never `python3 -m http.server` β€” its
207
+ caching has burned this project three times).
208
+ 2. `demo/rig/contact-sheet.html?face=NAME` β€” every viseme, emotion, gaze and
209
+ channel extreme. Check the **mouth-detail crop row**, not just full heads:
210
+ two visemes can be numerically distinct and visually identical (`G` vs `B`
211
+ both read as a white strip until `G` was rebuilt as nearly-all-teeth). At
212
+ avatar size a viseme is ~40 px tall; letter collisions are invisible on the
213
+ full-head row. The crop row frames itself from your `META.mouthCrop`.
214
+ 3. `demo/rig/torso-check.html?face=NAME` β€” shoulders Γ— lean Γ— head pose.
215
+ These channels only fail *in combination*; this is where a rig leaks
216
+ background from behind the shirt if it is going to.
217
+ 4. `demo/rig/clip-strip.html?clip=NOD_SMALL&face=NAME` β€” phase relationships
218
+ through the mixer's own smoothing, as a filmstrip.
219
+ 5. `demo/rig/rig-check.html` β†’ `await sweep()` β€” conformance: params finite,
220
+ `|v| ≀ 2`, svg connected, across every state/emotion/gaze/interjection and
221
+ a viseme track. Sweep also cannot see *looks*; it reaches shoulders/torso
222
+ only through clips, so drive those with a `setOverrides` loop over
223
+ `[-1, 0, 1]` per channel.
224
+ 6. Auto-traced art has known failure modes to budget for: zero-margin abutting
225
+ contours open seams under parallax; the trace stops at the source crop;
226
+ hard horizontal edges invisible in the source appear under motion.
227
+ 7. **The 130 px acceptance pass** β€” downsample the rest pose, the emotions
228
+ row and the X/A mouth crops to ~130 px and judge *there*. Author at
229
+ close-up, accept at tile size: the mouth must still read as smiling (not
230
+ merely present), the six emotions must be tellable apart, and X vs A must
231
+ differ in *shape*. Run the fixation audit on the rest tile: name the first
232
+ three things you see, in order β€” the eye/mouth band places no worse than
233
+ second. (Why: [research-perception.md](research-perception.md) Β§2, Β§5, Β§8.)
234
+ 8. **Levelness by mirror** β€” render rest, flip it horizontally, and compare
235
+ the pair; tilt and lopsidedness pop instantly. Judge on the glasses line
236
+ and eye line. Rest must be channel-neutral and dead level: the mixer adds
237
+ roll, sway and glances at runtime, and a baked-in tilt compounds with all
238
+ of them. Drawing asymmetry (fringe, chin off midline) is welcome; *pose*
239
+ asymmetry is a defect.
240
+ 9. **Worst-case composites, not rest poses, for clearances** β€” build the
241
+ extreme combination for every pair of marks that move relative to each
242
+ other (brows-down + squint + pitch against a glasses frame; wide-open
243
+ mouth against any under-lip mark) and verify a hard 3–4 unit gap.
244
+ Near-tangency shimmers under animation. If an accessory and a channel
245
+ collide, the accessory yields. Also render one **mid-blink** frame (lids
246
+ held ~0.5 via rig-check's `pose()`): anything that must ride the lid β€” a
247
+ lash line β€” is caught here, not at open or closed.
248
+ 10. **Independent design review** β€” before a face is called done, a fresh-eyes
249
+ reviewer (not the author) critiques it against the *product brief* at
250
+ tile size, organized around the questions in
251
+ [research-perception.md](research-perception.md): fixation hierarchy,
252
+ resting trust/warmth, neoteny calibration, caricature economy, animation
253
+ head-room, silhouette, long-session comfort. The output is prescriptions
254
+ ranked by perceptual payoff Γ· stroke cost, plus a **protect-list** of
255
+ marks confirmed right β€” which then stops future churn on them.
256
+
257
+ ## Adding a new avatar
258
+
259
+ Both halves of the old Direction section landed (`src/face-core.js` and
260
+ `META`), and the recipe has been run end-to-end twice: `wren` as the plumbing
261
+ proof, and `myna` (2026-08-07) as the proof of the *staged* process below β€”
262
+ which is where the time and the judgement actually went.
263
+
264
+ ### The staged process
265
+
266
+ Identity and production quality are different problems; solving them in
267
+ stages, with different acceptance bars, is what kept the myna run converging.
268
+
269
+ - **Stage 0 β€” identity source.** A stakeholder-supplied reference image is
270
+ the identity spec. Hard lesson (koel, rejected on sight the same day it
271
+ passed every rig check): character identity is judged against a concrete
272
+ image, not against adjectives β€” a brief-first avatar optimizes the wrong
273
+ target however well it verifies. If there is no reference, get one agreed
274
+ before authoring.
275
+ - **Stage 1 β€” distill, then match.** Extract from the reference: the
276
+ silhouette, the 3–5 identity marks, and the palette structure (where the
277
+ one accent sits). That distillate β€” not the pixels β€” is what gets matched
278
+ (peak shift: exaggerate the distinctive, simplify the generic β€”
279
+ research-perception.md Β§5). *Measure* proportions off the bitmap instead of
280
+ eyeballing (lens w:h, feature heights as fractions of face height β€” myna's
281
+ glasses only landed when measured). Hand-author in the idiom; never
282
+ auto-trace. The bar for this stage is the squint test at full size and
283
+ 130 px: same person?
284
+ - **Stage 2 β€” production calibration.** The reference *stops being the bar*;
285
+ the brief takes over. This is where the science does the work, all of it
286
+ judged on the 130 px surface: mouth ink floor so warmth survives the tile;
287
+ channel→geometry gains raised until shared emotion poses land (categorical
288
+ perception: near-extreme or invisible); resting brow/mouth set for trust,
289
+ not personality (the smirk composite); resting lid graze against stare;
290
+ interiors calmed against nod-flicker; clearance envelopes at worst-case
291
+ composites; shape-not-amplitude viseme separation. Guardrail: **one neoteny
292
+ move per round** (eye size *or* forehead *or* jaw *or* nose), then re-judge
293
+ β€” two at once is how a professional assistant becomes a mascot. Note where
294
+ production calibration diverges from the reference rather than silently
295
+ splitting the difference.
296
+ - **Stage 3 β€” independent review** (checklist item 10), then the stakeholder.
297
+ The author does not review their own likeness; anchoring is real. Expect
298
+ the reviewer to find the class of error the author cannot: authored at
299
+ close-up, judged at close-up.
300
+
301
+ ### What a face module supplies
302
+
303
+ A new face module supplies:
304
+
305
+ 1. **Static art** β€” the markup function: layer groups, the element table's
306
+ nodes, theme-keyed fills. Hand-authored or cleaned trace; budget for the
307
+ auto-trace failure modes in the checklist if tracing. For a line-art
308
+ character, build every mark with `src/line-art.js` (`taper`, `taperRing`,
309
+ `region` β€” filled variable-width marks, width profiles over normalized s);
310
+ the width *profiles* are per-character and stay in the face module.
311
+ 2. **A `POSE` spec** for `poseTransforms` β€” the named scalars (travels,
312
+ pivots, bob, tilt degrees), the layer list/parallax table/torso subset, and
313
+ `units`. Start from the rig whose construction is closest and re-derive
314
+ every *travel* in your own units; keep degrees as judgements about your own
315
+ collar/neck geometry, not conversions.
316
+ 3. **Feature blocks** β€” use the face-core fragments where your model matches
317
+ (`irisLidEyes`, `browPair`, `pairedTeeth`); write your own where the
318
+ character disagrees. The mouth is always yours: honour the channel
319
+ semantics in *Obligations* above. peep's bean-eye, point-list-brow and
320
+ contour-mouth generators carried into wren as copies with re-derived
321
+ constants β€” if a third line-art face repeats that, extract them into
322
+ parameterized factories the way the stroke engine was extracted.
323
+ 4. **`META`** β€” viewBox and mouthCrop.
324
+ 5. **A registry entry** β€” `{ create, meta }` in `src/avatar.js`.
325
+
326
+ What you get for free: the mixer, visemes, emotions, gaze, idle, clips,
327
+ interjections, the pose mechanics, the memoizer, and every host page and rig
328
+ tool β€” the demos' avatar pickers, contact sheet, torso check, clip strip and
329
+ `sweep()` all enumerate the registry. The wren run measured the split: the
330
+ plumbing steps (2, 4, 5) are mechanical; the art (step 1) and the read of
331
+ every state at tile size (the checklist) are where the judgement β€” and the
332
+ time β€” actually goes. Static accessories interact with channels: wren's lens
333
+ rings cap pupil travel, the exact channel DISTRACTED needs most β€” check your
334
+ accessory against the gaze extremes early, not last.
335
+
336
+ Then run the checklist above, and judge by eye β€” passing `sweep()` is not
337
+ evidence a face looks right.