@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
package/src/clips.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Gesture clip player.
3
+ *
4
+ * A clip is a short, multi-channel keyframed timeline. Clip channels are
5
+ * *additive deltas*, not absolute poses — a nod adds pitch to whatever the head
6
+ * is already doing, which is what makes "nod while speaking" work without any
7
+ * special-casing.
8
+ *
9
+ * Two behaviours matter more here than the curves themselves:
10
+ * · clips are interruptible (barge-in must kill an in-flight nod), and
11
+ * · the queue collapses duplicates — three stacked nods reads as a fault.
12
+ */
13
+
14
+ import { clamp } from './params.js';
15
+ import { VisemeTrack } from './visemes.js';
16
+
17
+ const smoothstep = (t) => t * t * (3 - 2 * t);
18
+
19
+ function sampleTrack(keys, u) {
20
+ if (!keys.length) return 0;
21
+ if (u <= keys[0][0]) return keys[0][1];
22
+ const last = keys[keys.length - 1];
23
+ if (u >= last[0]) return last[1];
24
+ for (let i = 1; i < keys.length; i++) {
25
+ if (u <= keys[i][0]) {
26
+ const [t0, v0] = keys[i - 1];
27
+ const [t1, v1] = keys[i];
28
+ const k = t1 === t0 ? 1 : smoothstep((u - t0) / (t1 - t0));
29
+ return v0 + (v1 - v0) * k;
30
+ }
31
+ }
32
+ return last[1];
33
+ }
34
+
35
+ const RAMP_IN = 70;
36
+ const RAMP_OUT = 150;
37
+
38
+ export class ClipPlayer {
39
+ /**
40
+ * @param {object} hooks
41
+ * @param {(name:string|null)=>void} hooks.onGaze clip-scoped gaze override
42
+ * @param {()=>void} hooks.onBlink
43
+ */
44
+ constructor(hooks = {}) {
45
+ this.hooks = hooks;
46
+ this.clip = null;
47
+ this.t = 0;
48
+ this.fading = false;
49
+ this.fadeT = 0;
50
+ this.mouth = new VisemeTrack();
51
+ this._blinksDone = 0;
52
+ this.onEnd = null;
53
+ }
54
+
55
+ get playing() { return !!this.clip; }
56
+ get id() { return this.clip ? this.clip.id : null; }
57
+
58
+ /**
59
+ * @param {object} clip
60
+ * @param {HTMLAudioElement} [audio] if given, the clip's mouth track is
61
+ * scheduled against the audio clock instead of the local timer
62
+ */
63
+ play(clip, audio) {
64
+ if (!clip) return;
65
+ // Collapse a repeat of the clip already running.
66
+ if (this.clip && this.clip.id === clip.id && !this.fading) return;
67
+
68
+ this.clip = clip;
69
+ this.t = 0;
70
+ this.fading = false;
71
+ this.fadeT = 0;
72
+ this._blinksDone = 0;
73
+ this.audio = audio || null;
74
+
75
+ if (clip.mouthCues && clip.mouthCues.length) {
76
+ const clock = audio
77
+ ? () => audio.currentTime * 1000
78
+ : () => this.t;
79
+ this.mouth.tailMs = 60;
80
+ this.mouth.start(clip.mouthCues, clock);
81
+ } else {
82
+ this.mouth.stop();
83
+ }
84
+ if (clip.gaze && this.hooks.onGaze) this.hooks.onGaze(clip.gaze);
85
+ if (audio) { audio.currentTime = 0; audio.play().catch(() => {}); }
86
+ }
87
+
88
+ /** Barge-in. Fades rather than cutting, so the head doesn't snap. */
89
+ stop(immediate = false) {
90
+ if (!this.clip) return;
91
+ if (immediate) return this._end();
92
+ this.fading = true;
93
+ this.fadeT = 0;
94
+ }
95
+
96
+ _end() {
97
+ const had = this.clip;
98
+ this.clip = null;
99
+ this.fading = false;
100
+ this.mouth.stop();
101
+ if (this.audio) { this.audio.pause(); this.audio = null; }
102
+ if (had && had.gaze && this.hooks.onGaze) this.hooks.onGaze(null);
103
+ if (this.onEnd) this.onEnd(had);
104
+ }
105
+
106
+ /**
107
+ * @returns {{delta: object, weight: number, mouth: object|null, ownsMouth: boolean}}
108
+ */
109
+ update(dtMs) {
110
+ if (!this.clip) return { delta: null, weight: 0, mouth: null, ownsMouth: false };
111
+ this.t += dtMs;
112
+ const c = this.clip;
113
+ const u = clamp(this.t / c.duration);
114
+
115
+ let w = Math.min(1, this.t / RAMP_IN) * Math.min(1, (c.duration - this.t) / RAMP_OUT);
116
+ if (this.fading) {
117
+ this.fadeT += dtMs;
118
+ w *= Math.max(0, 1 - this.fadeT / 110);
119
+ if (this.fadeT >= 110) { this._end(); return { delta: null, weight: 0, mouth: null, ownsMouth: false }; }
120
+ }
121
+ w = clamp(w);
122
+
123
+ if (c.blinkAt && this.hooks.onBlink) {
124
+ while (this._blinksDone < c.blinkAt.length && u >= c.blinkAt[this._blinksDone]) {
125
+ this._blinksDone++;
126
+ this.hooks.onBlink();
127
+ }
128
+ }
129
+
130
+ const delta = {};
131
+ for (const ch in c.keys) delta[ch] = sampleTrack(c.keys[ch], u) * w;
132
+
133
+ let mouth = null;
134
+ const ownsMouth = !!(c.mouthCues && c.mouthCues.length);
135
+ if (ownsMouth) mouth = this.mouth.sample();
136
+
137
+ if (this.t >= c.duration && !this.fading) {
138
+ // Let the mouth track finish its tail before tearing down.
139
+ if (!ownsMouth || !this.mouth.playing) this._end();
140
+ }
141
+
142
+ return { delta, weight: w, mouth, ownsMouth };
143
+ }
144
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Affect is a separate axis from state.
3
+ *
4
+ * If emotion were folded into the state enum you'd need SPEAKING_WARM,
5
+ * SPEAKING_CONCERNED, LISTENING_WARM ... and the table would be unmaintainable
6
+ * within a week. Instead: `setState('LISTENING', { emotion: 'curious' })`. The
7
+ * emotion contributes a base pose that every other layer builds on top of.
8
+ */
9
+
10
+ export const EMOTIONS = {
11
+ neutral: {
12
+ mouthCornerL: 0.10, mouthCornerR: 0.10,
13
+ },
14
+ warm: {
15
+ mouthCornerL: 0.48, mouthCornerR: 0.48,
16
+ // A real smile squints. Without this it reads as a mask.
17
+ squintL: 0.30, squintR: 0.30, lidL: 0.04, lidR: 0.04,
18
+ browRaiseL: 0.10, browRaiseR: 0.10,
19
+ },
20
+ curious: {
21
+ browRaiseL: 0.34, browRaiseR: 0.12,
22
+ browAngleL: 0.10,
23
+ headRoll: 0.07,
24
+ mouthCornerL: 0.18, mouthCornerR: 0.14,
25
+ lidL: -0.10, lidR: -0.10,
26
+ },
27
+ concerned: {
28
+ browInnerL: 0.55, browInnerR: 0.55,
29
+ browRaiseL: -0.08, browRaiseR: -0.08,
30
+ mouthCornerL: -0.22, mouthCornerR: -0.22,
31
+ lidL: 0.06, lidR: 0.06,
32
+ },
33
+ encouraging: {
34
+ mouthCornerL: 0.58, mouthCornerR: 0.58,
35
+ browRaiseL: 0.26, browRaiseR: 0.26,
36
+ squintL: 0.22, squintR: 0.22,
37
+ headPitch: 0.05,
38
+ },
39
+ thoughtful: {
40
+ browRaiseL: -0.14, browRaiseR: -0.06,
41
+ browInnerL: 0.18, browInnerR: 0.10,
42
+ mouthPress: 0.45, mouthCornerL: -0.05, mouthCornerR: 0.02,
43
+ lidL: 0.10, lidR: 0.10,
44
+ },
45
+ };
46
+
47
+ export const EMOTION_NAMES = Object.keys(EMOTIONS);
48
+
49
+ /** Blend an emotion toward neutral by `intensity`. */
50
+ export function emotionPose(name, intensity = 1) {
51
+ const src = EMOTIONS[name] || EMOTIONS.neutral;
52
+ const out = {};
53
+ for (const k in src) out[k] = src[k] * intensity;
54
+ return out;
55
+ }
@@ -0,0 +1,154 @@
1
+ // ---------------------------------------------------------------------------
2
+ // face-core — the part of a face module that is not the face.
3
+ //
4
+ // Three avatars were built independently before this module existed, and their
5
+ // renderers converged on the same shape: an element table, a memoized attribute
6
+ // writer, and an apply() that runs torso lean → shoulders → parallax → eyes →
7
+ // brows → mouth → teeth → tongue. The geometry differs per character; the
8
+ // plumbing and the pose mechanics do not. This module absorbs the proven-
9
+ // identical parts, so a face module supplies art and feature geometry and gets
10
+ // the body mechanics for free.
11
+ //
12
+ // What lives here is exactly what those faces shared line-for-line, or differed
13
+ // in only by a named scalar. Anything a character has an opinion about — what
14
+ // an eye is, how a mouth is drawn — stays in the face module. Two of the three
15
+ // have since been retired; what they proved about the seam is why this file
16
+ // looks the way it does, and helpers only they used went with them.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import { clamp } from './params.js';
20
+
21
+ /** Attribute-value number formatter: 2 decimal places, no trailing zeros. */
22
+ export const f = (n) => (Math.round(n * 100) / 100).toString();
23
+
24
+ // Constants every rig agreed on. Named here so a new avatar inherits the
25
+ // agreement instead of re-deriving it.
26
+ export const LEAN_SCALE = 0.055; // scale gain at torsoLean = 1
27
+ export const ROLL_TORSO = 1.5; // degrees of roll the torso follows…
28
+ export const ROLL_HEAD = 5.5; // …versus what the head itself takes.
29
+
30
+ /**
31
+ * Mount the static markup and return the per-instance tools: the svg root, an
32
+ * id-scoped selector for building the element table, and the memoized `set`.
33
+ * SVG attribute setting is the hot path; `set` skips redundant DOM writes.
34
+ */
35
+ export function createFaceShell(mount, id, markupHtml) {
36
+ mount.innerHTML = markupHtml;
37
+ const svg = mount.querySelector(`#${id}`);
38
+ const $ = (n) => svg.querySelector(`#${id}-${n}`);
39
+ const prev = new Map();
40
+ const set = (node, attr, val) => {
41
+ const k = node.id + attr;
42
+ if (prev.get(k) === val) return;
43
+ prev.set(k, val);
44
+ node.setAttribute(attr, val);
45
+ };
46
+ return { svg, $, set };
47
+ }
48
+
49
+ /** The one return shape every face honours: { svg, apply, theme, destroy }. */
50
+ export function faceApi(mount, svg, apply, theme) {
51
+ return { svg, apply, theme, destroy: () => { mount.innerHTML = ''; } };
52
+ }
53
+
54
+ /**
55
+ * Blocks A–C of every apply(): torso lean, shoulders, and the layer parallax
56
+ * loop. `spec` is a module-level constant in the face module:
57
+ *
58
+ * leanTravel px of downward travel at torsoLean = 1
59
+ * leanPivot {x, y} the scale pivot — behind the head, not the frame base:
60
+ * scaling about the bottom makes the head rise as it grows,
61
+ * which reads as standing up instead of leaning in
62
+ * shrugLift px of shoulder lift at shrug = 1
63
+ * shrugTiltDeg degrees of one-sided-shrug rotation at tilt = 1 — a per-rig
64
+ * judgement about how much the collar can cover, not taste
65
+ * shrugPivot {x, y} the sternum
66
+ * yawPx px of head travel at headYaw = 1
67
+ * pitchPx px at headPitch = 1
68
+ * pivot {x, y} roll pivot — the base of the neck, not the chin:
69
+ * rotating about the chin swings the whole cranium sideways
70
+ * and reads as a puppet on a stick
71
+ * breathSwell fractional scale of the torso layers at breath = 1, about
72
+ * `swellPivot`. Required. It replaced a rigid vertical bob of
73
+ * the whole shirt, which moved the hem — and the hem is the one
74
+ * part of a seated torso that does not move, so the result read
75
+ * as the figure being nudged up and down rather than as breath.
76
+ * A scale about the hem raises the shoulder line and widens the
77
+ * chest, which is what an inbreath does and is the only breath
78
+ * cue a head-and-shoulders crop can actually show.
79
+ * swellPivot {x, y} the hem — bottom of the frame, on the midline
80
+ * turnPx px of lateral trunk travel at torsoTurn = 1 (0 if the rig
81
+ * does not declare it)
82
+ * layers draw-order list of layer names in the element table
83
+ * parallax {layer: multiplier} — follows the art, not a standard
84
+ * torsoLayers the subset of `layers` that moves at torso speed
85
+ * units the rig's linear scale factor relative to the numbers above
86
+ * (1 where travels are already in the rig's own units). Kept as
87
+ * a separate factor, applied last, so a rig built by scaling
88
+ * another's numbers states that lineage — and so evaluation
89
+ * order matches code that wrote `travel * 9 * S` longhand.
90
+ * Degrees never take it: degrees are degrees at any scale.
91
+ *
92
+ * In a webcam frame a lean is read almost entirely as a change of scale, so
93
+ * that is how it is drawn.
94
+ */
95
+ export function poseTransforms(p, set, el, spec) {
96
+ const u = spec.units;
97
+ const lean = p.torsoLean;
98
+ const leanT = lean
99
+ ? `translate(0 ${f(lean * spec.leanTravel * u)}) `
100
+ + `translate(${f(spec.leanPivot.x)} ${f(spec.leanPivot.y)}) `
101
+ + `scale(${f(1 + lean * LEAN_SCALE)}) `
102
+ + `translate(${f(-spec.leanPivot.x)} ${f(-spec.leanPivot.y)}) `
103
+ : '';
104
+
105
+ // One shoulder cannot rise without the other when the shirt is a single
106
+ // path. A small rotation about the sternum is what a one-sided shrug looks
107
+ // like anyway, and it needs no new geometry.
108
+ const shrug = (p.shoulderL + p.shoulderR) * 0.5;
109
+ const tilt = (p.shoulderR - p.shoulderL) * 0.5;
110
+
111
+ // Breath as chest expansion. The swell scales the torso layers about the
112
+ // hem, so the shoulder line rises and the chest widens while the bottom of
113
+ // the shirt stays put. The head then has to ride whatever the shoulders did
114
+ // or the neck telescopes — and that lift is not a tuned constant, it is
115
+ // arithmetic: the swell's vertical displacement at the neck pivot. One
116
+ // number to author, and the two layers cannot drift out of agreement.
117
+ const swell = spec.breathSwell ? p.breath * spec.breathSwell : 0;
118
+ const neckLift = swell ? swell * (spec.swellPivot.y - spec.pivot.y) : 0;
119
+ const swellT = swell
120
+ ? `translate(${f(spec.swellPivot.x)} ${f(spec.swellPivot.y)}) `
121
+ + `scale(${f(1 + swell)}) `
122
+ + `translate(${f(-spec.swellPivot.x)} ${f(-spec.swellPivot.y)}) `
123
+ : '';
124
+
125
+ const torsoT = `translate(${f(p.torsoTurn * (spec.turnPx || 0) * u)} ${f(-shrug * spec.shrugLift * u)}) `
126
+ + `rotate(${f(-tilt * spec.shrugTiltDeg)} ${f(spec.shrugPivot.x)} ${f(spec.shrugPivot.y)}) `
127
+ + swellT;
128
+
129
+ const yx = p.headYaw * spec.yawPx;
130
+ const py = p.headPitch * spec.pitchPx;
131
+ for (const key of spec.layers) {
132
+ const k = spec.parallax[key];
133
+ const torso = spec.torsoLayers.includes(key);
134
+ const roll = torso ? p.headRoll * ROLL_TORSO : p.headRoll * ROLL_HEAD;
135
+ // Torso layers get their breath from the swell inside torsoT; head layers
136
+ // get the matching lift, which is derived from the swell rather than tuned
137
+ // separately — the neck rides on the chest it sits on.
138
+ const bob = torso ? 0 : -neckLift;
139
+ set(el[key], 'transform',
140
+ leanT + (torso ? torsoT : '')
141
+ + `translate(${f(yx * k)} ${f(py * k + bob)}) rotate(${f(roll)} ${f(spec.pivot.x)} ${f(spec.pivot.y)})`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Upper-and-lower teeth pair (peep, wren). Lower teeth appear only once
147
+ * the mouth is genuinely open: below that the lower lip is over them and
148
+ * drawing them turns every mid-open viseme into a grin; above it, their
149
+ * absence is what made viseme D read as a cave.
150
+ */
151
+ export function pairedTeeth(p, set, el, teethPath, m) {
152
+ set(el.teeth, 'd', teethPath(m, clamp(p.teethUpper), false));
153
+ set(el.teethLo, 'd', teethPath(m, clamp(p.teethUpper) * clamp((m.open - 0.45) / 0.4), true));
154
+ }