@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,470 @@
1
+ /**
2
+ * Avatar: "wren".
3
+ *
4
+ * The second line-art character, and the proof run for the recipe in
5
+ * docs/contract-avatar.md: static art + a POSE spec + feature blocks + META,
6
+ * with the stroke engine imported from line-art.js rather than carried along.
7
+ * Character: female, thirties, a big natural-curl mass, round glasses, a
8
+ * crew-neck tee. Same idiom as peep — no strokes anywhere, ink and paper and
9
+ * one accent — different person.
10
+ *
11
+ * What is deliberately different from peep, and why it is cheap:
12
+ * - THE HAIR IS THE SILHOUETTE. A scalloped cloud four times peep's hair mass,
13
+ * drawn as one closed loop (outer lobes out, hairline lobes back), covering
14
+ * the ears entirely — which is why this file has no ear geometry at all.
15
+ * - THE GLASSES ARE THE ACCENT. peep spends its one colour on the collar; wren
16
+ * wears it on the frames, which makes the two instantly tellable apart at
17
+ * any size where a face reads at all.
18
+ * - CREW NECK, NOT POLO. One band arc instead of blades + placket.
19
+ *
20
+ * What the glasses cost the rig: the eye beans live inside the lens rings, so
21
+ * pupil travel is capped by the frame (9/7 against peep's 11/8) — past that
22
+ * the bean collides with the ring and reads as the eye hitting glass. In
23
+ * exchange the ring is a fixed reference the bean moves against, which makes
24
+ * small gaze shifts MORE legible than on peep, whose beans move against
25
+ * nothing.
26
+ */
27
+
28
+ import { clamp, lerp } from './params.js';
29
+ import {
30
+ f, createFaceShell, faceApi, poseTransforms, pairedTeeth,
31
+ } from './face-core.js';
32
+ import { taper, taperRing, region } from './line-art.js';
33
+
34
+ export const THEME = {
35
+ bg0: '#f6f3ee',
36
+ bg1: '#e8e2d8',
37
+ ink: '#1b1b1b',
38
+ paper: '#ffffff',
39
+ accent: '#f97415',
40
+ mouthIn: '#1b1b1b',
41
+ teeth: '#ffffff',
42
+ tongue: '#8d7f79',
43
+ };
44
+
45
+ // Frame: same native 760x950 art space as peep — not a requirement, just no
46
+ // reason to differ — with the window shifted up because the hair cloud crowns
47
+ // ~40 units higher than peep's fade.
48
+ const VB = { x: 92, y: 50, w: 576, h: 800 };
49
+
50
+ export const META = {
51
+ viewBox: { x: VB.x, y: VB.y, w: VB.w, h: VB.h },
52
+ mouthCrop: { x: 298, y: 432, w: 164, h: 96 },
53
+ };
54
+
55
+ // --- landmarks --------------------------------------------------------------
56
+ const CX = 380;
57
+ const HEAD_TOP = 148;
58
+ const CHIN_Y = 572;
59
+
60
+ const EYE = { y: 386, dx: 55, rx: 15, ry: 17.5 };
61
+ const NOSE_TOP = 408;
62
+ const MOUTH = { cx: CX, cy: 486 };
63
+ const MOUTH_APERTURE = 36;
64
+
65
+ // Brows: shorter and rounder than peep's, sitting clear above the lens rings
66
+ // (lens top is y=355; a full browRaise lifts these 15 units and they still
67
+ // never touch the frames). Drawn control points, per the peep correction —
68
+ // the arch is not recoverable from endpoints.
69
+ const BROW_L = [[CX - 22, 342], [CX - 40, 334], [CX - 58, 330], [CX - 72, 331],
70
+ [CX - 82, 334], [CX - 89, 338], [CX - 93, 343]];
71
+ const BROW_R = [[CX + 24, 340], [CX + 42, 332], [CX + 60, 329], [CX + 73, 330],
72
+ [CX + 82, 333], [CX + 88, 337], [CX + 92, 342]];
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Static art: head.
76
+ //
77
+ // Same three-segment jaw rule as every head in this project (an unbroken
78
+ // ear-to-ear curve is an egg), but the runs are softer: the gonion corner is
79
+ // rounded off and the chin pad is narrower — that softness, not any single
80
+ // feature, is most of what makes this face read as a different person before
81
+ // the hair even loads. Chin a touch left of midline; symmetric heads read
82
+ // as machine output.
83
+ // ---------------------------------------------------------------------------
84
+ const HEAD = [
85
+ [CX, HEAD_TOP],
86
+ [452, 147], [520, 204], [524, 288],
87
+ [527, 340], [523, 390], [520, 428],
88
+ [518, 462], [513, 486], [502, 510],
89
+ [488, 532], [456, 558], [426, 568],
90
+ [408, 575], [352, 574], [334, 566],
91
+ [308, 554], [282, 534], [264, 508],
92
+ [250, 482], [244, 458], [248, 426],
93
+ [242, 390], [237, 340], [240, 288],
94
+ [244, 202], [308, 145], [CX, HEAD_TOP],
95
+ ];
96
+ const HEAD_W = [3.5, 7, 11, 14, 15, 13.5, 10, 7, 4.5, 3.5];
97
+
98
+ // Neck: peep's truncated-cone construction, narrower. Fused into the head
99
+ // layer and run long past the neckline for the same layer-parallax reasons.
100
+ const NECK_FILL =
101
+ 'M336 514C326 575 318 630 316 700L316 790L444 790L444 700C442 630 434 575 424 514Z';
102
+ const NECK_L = [[336, 532], [329, 588], [323, 656], [320, 750]];
103
+ const NECK_R = [[424, 528], [431, 584], [437, 654], [440, 750]];
104
+ const JAW_UNDER = [[336, 560], [354, 586], [408, 588], [428, 558]];
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Static art: hair — the curl cloud.
108
+ //
109
+ // One closed loop: outer silhouette left-to-right over the crown as a chain of
110
+ // LOBES (each segment's controls pushed outward, so consecutive lobes meet at
111
+ // soft cusps — that cusp rhythm is what says "curls" with zero interior
112
+ // marks), then the hairline right-to-left back across the forehead as smaller
113
+ // lobes pointing down. What the loop encloses is the hair band itself: the
114
+ // sides run 40 units wide down to y≈470, which is why wren needs no ears.
115
+ // ---------------------------------------------------------------------------
116
+ const HAIR = [
117
+ [252, 468],
118
+ // up the left side, three outer lobes
119
+ [214, 442], [206, 408], [218, 382],
120
+ [188, 352], [190, 306], [218, 284],
121
+ [196, 240], [222, 196], [262, 186],
122
+ // over the crown
123
+ [286, 138], [330, 116], [366, 124],
124
+ [398, 102], [446, 106], [462, 130],
125
+ [508, 138], [534, 170], [526, 196],
126
+ // down the right side
127
+ [560, 226], [562, 268], [546, 290],
128
+ [568, 322], [566, 362], [538, 388],
129
+ [552, 424], [540, 456], [508, 466],
130
+ // transition onto the hairline
131
+ [500, 462], [498, 458], [496, 452],
132
+ // back across the forehead, right to left: smaller lobes, pointing down
133
+ [500, 396], [498, 352], [486, 330],
134
+ [482, 296], [468, 278], [452, 272],
135
+ [430, 252], [396, 246], [380, 256],
136
+ [362, 242], [326, 252], [306, 270],
137
+ [288, 282], [278, 306], [272, 330],
138
+ [262, 352], [258, 400], [258, 452],
139
+ // close the loop at the bottom-left
140
+ [256, 460], [254, 464], [252, 468],
141
+ ];
142
+ const HAIR_D = region(HAIR);
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Static art: glasses.
146
+ //
147
+ // Round frames in the ACCENT colour — wren's version of peep's collar trim.
148
+ // Rings are taperRings with a gently uneven profile (first and last width
149
+ // equal, or the ring seams at the 3 o'clock point). The temples run outward
150
+ // and vanish under the hair band, which is what lets them be four points long.
151
+ // ---------------------------------------------------------------------------
152
+ const LENS_R = 29;
153
+ const K = 0.5523; // circle-as-four-cubics constant
154
+
155
+ function circlePts(cx, cy, r) {
156
+ const k = r * K;
157
+ return [
158
+ [cx + r, cy], [cx + r, cy + k], [cx + k, cy + r], [cx, cy + r],
159
+ [cx - k, cy + r], [cx - r, cy + k], [cx - r, cy], [cx - r, cy - k],
160
+ [cx - k, cy - r], [cx, cy - r], [cx + k, cy - r], [cx + r, cy - k],
161
+ [cx + r, cy],
162
+ ];
163
+ }
164
+ const LENS_L = circlePts(CX - EYE.dx, EYE.y, LENS_R);
165
+ const LENS_R_ = circlePts(CX + EYE.dx, EYE.y, LENS_R);
166
+ const RING_W = [5, 6, 5, 6.5, 5];
167
+ const BRIDGE = [[CX - 26, 378], [CX - 10, 366], [CX + 10, 366], [CX + 26, 378]];
168
+ const TEMPLE_L = [[CX - 86, 384], [CX - 100, 380], [CX - 112, 378], [CX - 121, 378]];
169
+ const TEMPLE_R = [[CX + 86, 384], [CX + 100, 380], [CX + 112, 378], [CX + 121, 378]];
170
+
171
+ /** The nose: one mark, a shorter hook than peep's — the bridge work is done
172
+ * by the glasses, so the nose only has to land the tip. */
173
+ const NOSE_D = taper(
174
+ [[CX - 3, NOSE_TOP], [CX - 10, 424], [CX - 15, 438], [CX - 9, 448],
175
+ [CX - 3, 455], [CX + 9, 453], [CX + 15, 444]],
176
+ [2, 8, 3]
177
+ );
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Static art: torso — a crew-neck tee.
181
+ //
182
+ // Same two-run shoulder construction as peep (outer arm edge, hard turn at
183
+ // the acromion, near-horizontal trapezius shelf), set slightly narrower. The
184
+ // neckline is one band arc with an ink underline — no blades, no placket.
185
+ // ---------------------------------------------------------------------------
186
+ const TORSO = [
187
+ [60, 950],
188
+ [62, 864], [104, 750], [184, 720],
189
+ [240, 710], [286, 676], [322, 632],
190
+ [334, 662], [426, 664], [440, 628],
191
+ [474, 672], [518, 706], [570, 716],
192
+ [648, 746], [694, 860], [696, 950],
193
+ ];
194
+ const TORSO_W = [9, 10, 8, 7.5, 10, 9];
195
+
196
+ const SEAM_L = [[186, 722], [168, 782], [172, 862], [186, 950]];
197
+ const SEAM_R = [[568, 718], [587, 778], [583, 858], [572, 950]];
198
+
199
+ const NECKBAND = [[322, 630], [340, 645], [360, 655], [380, 658],
200
+ [400, 654], [422, 644], [438, 627]];
201
+
202
+ // Two creases, on the right this time — cloth folds where the body pulls it,
203
+ // and mirroring peep's left-side set would make the two shirts read as the
204
+ // same drawing recoloured.
205
+ const CREASES = [
206
+ { p: [[452, 738], [447, 764], [446, 788], [450, 806]], w: [2, 6, 2] },
207
+ { p: [[478, 742], [474, 766], [473, 790], [476, 808]], w: [2, 5, 2] },
208
+ ];
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Layers: the same four as peep, same order, same parallax.
212
+ // ---------------------------------------------------------------------------
213
+ const PARALLAX = { head: 1.0, body: 0.1, features: 1.22, hair: 1.12 };
214
+ const LAYERS = ['head', 'body', 'features', 'hair'];
215
+ const PIVOT = { x: CX, y: 700 };
216
+
217
+ // yawPx a touch under peep's 28: the hair cloud is a much larger mass and at
218
+ // 28 its parallax slide against the head read as a wig shifting.
219
+ const POSE = {
220
+ leanTravel: 23, leanPivot: { x: PIVOT.x, y: 560 },
221
+ shrugLift: 30, shrugTiltDeg: 1.8, shrugPivot: { x: PIVOT.x, y: 800 },
222
+ yawPx: 26, pitchPx: 16,
223
+ pivot: PIVOT,
224
+ // peep's breath and trunk numbers; same construction, same art units, and
225
+ // the two characters should breathe alike. See face-peep.js for the
226
+ // derivation.
227
+ breathSwell: 0.012, swellPivot: { x: CX, y: 950 },
228
+ turnPx: 16,
229
+ layers: LAYERS, parallax: PARALLAX,
230
+ torsoLayers: ['body'],
231
+ units: 1,
232
+ };
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Generators: mouth. peep's contour model with wren's landmarks — the model
236
+ // carried over intact; only the sizing constants are this face's own. See
237
+ // face-peep.js for the full derivation commentary (aperture-not-centreline,
238
+ // the 0.18 compensation ramp, 3:1 downward opening, the cubic 0.75 solve).
239
+ // ---------------------------------------------------------------------------
240
+ function mouthGeometry(p) {
241
+ const cx = MOUTH.cx;
242
+ const cy = MOUTH.cy;
243
+ const open = clamp(p.mouthOpen);
244
+ const round = clamp(p.mouthRound);
245
+ const tuck = clamp(p.mouthTuck);
246
+
247
+ const w = (24 + clamp(p.mouthWidth) * 30) * (1 - 0.36 * round);
248
+
249
+ const t = 1 - 0.4 * clamp(p.mouthPress);
250
+ const profile = [2.5 * t, 9.5 * t, 3 * t, 10.5 * t * (1 + 0.35 * tuck), 2.5 * t];
251
+ const halfUp = profile[1] / 2;
252
+ const halfLo = profile[3] / 2;
253
+
254
+ const h = open * MOUTH_APERTURE;
255
+ const k = clamp(open / 0.18);
256
+
257
+ const yL = cy - 6 - p.mouthCornerL * 22;
258
+ const yR = cy - 6 - p.mouthCornerR * 22;
259
+
260
+ const apTop = cy - h * 0.25;
261
+ let apBot = cy + h * 0.75;
262
+ if (tuck > 0) apBot = Math.max(apTop + 6, apBot - tuck * (h * 0.6 + 4));
263
+
264
+ const cornerMid = (yL + yR) / 8;
265
+ const topY = (apTop - k * halfUp - cornerMid) / 0.75;
266
+ const botY = (apBot + k * halfLo - cornerMid) / 0.75;
267
+
268
+ const contour = [
269
+ [cx - w, yL],
270
+ [cx - w * 0.55, topY], [cx + w * 0.55, topY], [cx + w, yR],
271
+ [cx + w * 0.55, botY], [cx - w * 0.55, botY], [cx - w, yL],
272
+ ];
273
+
274
+ const innerTop = cornerMid + 0.75 * topY + halfUp;
275
+ const innerBot = cornerMid + 0.75 * botY - halfLo;
276
+
277
+ return { contour, profile, cx, cy, w, h, topY, botY, innerTop, innerBot, open, tuck };
278
+ }
279
+
280
+ /** peep's dental-arch teeth, verbatim but for the width factors. */
281
+ function teethPath(m, amt, lower) {
282
+ if (amt < 0.01) return '';
283
+ const gap = m.innerBot - m.innerTop;
284
+ if (gap < 2) return '';
285
+ const tw = m.w * (lower ? 0.6 : 0.76);
286
+ const cap = lower ? 0.5 : 0.5 + 0.35 * m.tuck;
287
+ const th = Math.min(amt * (lower ? 13 : 20), gap * cap);
288
+
289
+ if (lower) {
290
+ const base = m.innerBot + 8;
291
+ const edge = m.innerBot - th;
292
+ const end = m.innerBot - th * 0.35;
293
+ return (
294
+ `M${f(m.cx - tw)} ${f(base)}L${f(m.cx + tw)} ${f(base)}` +
295
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
296
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
297
+ );
298
+ }
299
+ const top = m.innerTop - 8;
300
+ const edge = m.innerTop + th;
301
+ const end = m.innerTop + th * 0.35;
302
+ return (
303
+ `M${f(m.cx - tw)} ${f(top)}L${f(m.cx + tw)} ${f(top)}` +
304
+ `L${f(m.cx + tw * 0.92)} ${f(end)}` +
305
+ `Q${f(m.cx)} ${f(2 * edge - end)} ${f(m.cx - tw * 0.92)} ${f(end)}Z`
306
+ );
307
+ }
308
+
309
+ // Eyes: peep's bean model, verbatim but for the tilt values (wren's beans sit
310
+ // nearly level — the glasses rings already give the face its geometry, and a
311
+ // strong bean tilt inside a perfect circle reads as misaligned lenses).
312
+ function eyePath(cx, cy, lid, squint, tiltDeg) {
313
+ const L = clamp(lid);
314
+ const topY = lerp(cy - EYE.ry * 1.05, cy - EYE.ry * 0.42, L);
315
+ const botY = lerp(cy + EYE.ry * 1.05, cy - EYE.ry * 0.05, L) - clamp(squint) * EYE.ry * 0.7;
316
+ const rx = EYE.rx;
317
+ const a = (tiltDeg * Math.PI) / 180;
318
+ const ca = Math.cos(a), sa = Math.sin(a);
319
+ const R = (x, y) => {
320
+ const dx = x - cx, dy = y - cy;
321
+ return `${f(cx + dx * ca - dy * sa)} ${f(cy + dx * sa + dy * ca)}`;
322
+ };
323
+ return (
324
+ `M${R(cx - rx, cy)}` +
325
+ `C${R(cx - rx * 0.5, topY)} ${R(cx + rx * 0.5, topY)} ${R(cx + rx, cy)}` +
326
+ `C${R(cx + rx * 0.5, botY)} ${R(cx - rx * 0.5, botY)} ${R(cx - rx, cy)}Z`
327
+ );
328
+ }
329
+
330
+ // Brows: peep's point-list deformation, wren's points and a lighter profile.
331
+ function browPath(pts, raise, angle, inner) {
332
+ const n = pts.length - 1;
333
+ const out = pts.map(([x, y], i) => {
334
+ const u = i / n;
335
+ return [x, y - raise * 15 - inner * 11 * (1 - u) - angle * 12 * u];
336
+ });
337
+ return taper(out, [3, 12, 6], 6);
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // Static markup
342
+ // ---------------------------------------------------------------------------
343
+ const HEAD_FILL = region(HEAD);
344
+ const HEAD_RING = taperRing(HEAD, HEAD_W, 10);
345
+
346
+ function markup(id, t) {
347
+ const ink = (d) => `<path d="${d}" fill="${t.ink}"/>`;
348
+ return `
349
+ <svg id="${id}" viewBox="${VB.x} ${VB.y} ${VB.w} ${VB.h}" xmlns="http://www.w3.org/2000/svg"
350
+ preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">
351
+ <defs>
352
+ <radialGradient id="${id}-gBg" cx="50%" cy="36%" r="76%">
353
+ <stop offset="0%" stop-color="${t.bg0}"/><stop offset="100%" stop-color="${t.bg1}"/>
354
+ </radialGradient>
355
+ <clipPath id="${id}-clipMouth"><path id="${id}-clipMouthP" d=""/></clipPath>
356
+ </defs>
357
+
358
+ <rect x="${VB.x}" y="${VB.y}" width="${VB.w}" height="${VB.h}" fill="url(#${id}-gBg)"/>
359
+
360
+ <!-- head and neck. No ears: the hair band covers them (see the hair note).
361
+ Hair underlay at head parallax, same insurance as peep's — the cloud
362
+ abuts the skull with no margin, and the hair layer slides against the
363
+ head under yaw. -->
364
+ <g id="${id}-head">
365
+ <path d="${NECK_FILL}" fill="${t.paper}"/>
366
+ ${ink(taper(NECK_L, [3, 8, 6]))}
367
+ ${ink(taper(NECK_R, [3, 8, 6]))}
368
+ <path d="${HEAD_FILL}" fill="${t.paper}"/>
369
+ ${ink(HEAD_RING)}
370
+ ${ink(taper(JAW_UNDER, [2, 5.5, 2]))}
371
+ <path d="${HAIR_D}" fill="${t.ink}"/>
372
+ </g>
373
+
374
+ <!-- tee: silhouette, sleeve seams, neckband, creases -->
375
+ <g id="${id}-body">
376
+ <path d="${region(TORSO)}" fill="${t.paper}"/>
377
+ ${ink(taper(TORSO, TORSO_W))}
378
+ ${ink(taper(SEAM_L, [6, 7, 5]))}
379
+ ${ink(taper(SEAM_R, [6, 7, 5]))}
380
+ ${ink(taper(NECKBAND, [5, 8, 5]))}
381
+ ${CREASES.map((c) => ink(taper(c.p, c.w))).join('\n ')}
382
+ </g>
383
+
384
+ <!-- features: brows, eyes, nose, glasses, mouth -->
385
+ <g id="${id}-features">
386
+ <path id="${id}-browL" fill="${t.ink}"/>
387
+ <path id="${id}-browR" fill="${t.ink}"/>
388
+ <g id="${id}-eyes">
389
+ <path id="${id}-eyeL" fill="${t.ink}"/>
390
+ <path id="${id}-eyeR" fill="${t.ink}"/>
391
+ </g>
392
+ <path d="${NOSE_D}" fill="${t.ink}"/>
393
+ <path d="${taperRing(LENS_L, RING_W)}" fill="${t.accent}"/>
394
+ <path d="${taperRing(LENS_R_, RING_W)}" fill="${t.accent}"/>
395
+ <path d="${taper(BRIDGE, [4, 5.5, 4])}" fill="${t.accent}"/>
396
+ <path d="${taper(TEMPLE_L, [4, 4.5, 5])}" fill="${t.accent}"/>
397
+ <path d="${taper(TEMPLE_R, [4, 4.5, 5])}" fill="${t.accent}"/>
398
+ <g id="${id}-mouth">
399
+ <path id="${id}-mouthIn" fill="${t.mouthIn}"/>
400
+ <g clip-path="url(#${id}-clipMouth)">
401
+ <ellipse id="${id}-tongue" fill="${t.tongue}"/>
402
+ <path id="${id}-teeth" fill="${t.teeth}"/>
403
+ <path id="${id}-teethLo" fill="${t.teeth}" opacity=".85"/>
404
+ </g>
405
+ <path id="${id}-lips" fill="${t.ink}"/>
406
+ </g>
407
+ </g>
408
+
409
+ <!-- hair -->
410
+ <g id="${id}-hair">
411
+ <path d="${HAIR_D}" fill="${t.ink}"/>
412
+ </g>
413
+ </svg>`;
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Renderer — peep's apply() shape exactly; only the travels differ.
418
+ // ---------------------------------------------------------------------------
419
+ let uid = 0;
420
+
421
+ export function createFace(mount, theme = {}) {
422
+ const t = Object.assign({}, THEME, theme);
423
+ const id = `wren${++uid}`;
424
+ const { svg, $, set } = createFaceShell(mount, id, markup(id, t));
425
+
426
+ const el = {
427
+ head: $('head'), body: $('body'), features: $('features'), hair: $('hair'),
428
+ browL: $('browL'), browR: $('browR'),
429
+ eyes: $('eyes'), eyeL: $('eyeL'), eyeR: $('eyeR'),
430
+ mouthIn: $('mouthIn'), lips: $('lips'), clipMouth: $('clipMouthP'),
431
+ teeth: $('teeth'), teethLo: $('teethLo'), tongue: $('tongue'),
432
+ };
433
+
434
+ function apply(p) {
435
+ poseTransforms(p, set, el, POSE);
436
+
437
+ // Eyes: bean travel capped by the lens rings — see the header. 10 is the
438
+ // most x the frame allows (bean edge 25 against an inner radius of 26);
439
+ // DISTRACTED is the state that needs every unit of it, same lesson as
440
+ // peep's 7→11 raise, hit from the other side.
441
+ set(el.eyes, 'transform', `translate(${f(p.pupilX * 10)} ${f(p.pupilY * 7)})`);
442
+
443
+ const lidFollow = Math.max(0, p.pupilY) * 0.22;
444
+ set(el.eyeL, 'd', eyePath(CX - EYE.dx, EYE.y + 1, p.lidL + lidFollow, p.squintL, -3));
445
+ set(el.eyeR, 'd', eyePath(CX + EYE.dx, EYE.y - 1, p.lidR + lidFollow, p.squintR, 3));
446
+
447
+ set(el.browL, 'd', browPath(BROW_L, p.browRaiseL, p.browAngleL, p.browInnerL));
448
+ set(el.browR, 'd', browPath(BROW_R, p.browRaiseR, p.browAngleR, p.browInnerR));
449
+
450
+ const m = mouthGeometry(p);
451
+ const contour = region(m.contour);
452
+ set(el.mouthIn, 'd', contour);
453
+ set(el.clipMouth, 'd', contour);
454
+ set(el.lips, 'd', taperRing(m.contour, m.profile, 12));
455
+ // Keyed off the drawn gap, not mouthOpen — a half-opaque interior is GREY,
456
+ // and grey on a two-value face reads as a rendering fault (peep's F bug).
457
+ set(el.mouthIn, 'opacity', f(clamp((m.innerBot - m.innerTop) / 3)));
458
+
459
+ pairedTeeth(p, set, el, teethPath, m);
460
+
461
+ const tg = clamp(p.tongue);
462
+ set(el.tongue, 'cx', f(m.cx));
463
+ set(el.tongue, 'cy', f(m.innerBot + 6 - tg * ((m.innerBot - m.innerTop) * 0.8 + 6)));
464
+ set(el.tongue, 'rx', f(m.w * 0.58));
465
+ set(el.tongue, 'ry', '8');
466
+ set(el.tongue, 'opacity', tg > 0.02 ? '1' : '0');
467
+ }
468
+
469
+ return faceApi(mount, svg, apply, t);
470
+ }
package/src/gaze.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Gaze.
3
+ *
4
+ * The server sends a semantic direction; the client does the oculomotor work.
5
+ * Three details do almost all the perceptual heavy lifting, and skipping any
6
+ * one of them makes the face read as a puppet:
7
+ *
8
+ * 1. The eyes get there first. Saccades are near-instant; the head ambles
9
+ * after — accelerating, cruising, braking to a stop (see HEAD_ACCEL).
10
+ * 2. The head only goes part of the way. Real people under-rotate the head
11
+ * and let the eyes carry the remainder.
12
+ * 3. A blink fires on any large shift. Gaze-evoked blinks are involuntary and
13
+ * their absence is uncanny even though nobody can name what's wrong.
14
+ */
15
+
16
+ export const GAZE_TARGETS = {
17
+ // Straight down the barrel of the webcam — the default during conversation.
18
+ USER: { px: 0.00, py: 0.06, hx: 0.00, hy: 0.00 },
19
+ // Still on the user, but the head is cheated aside so an ear favors the
20
+ // speaker — the "I'm trying to hear you" attitude. The trick is that the
21
+ // head-follow and the pupils point OPPOSITE ways: the head turns off-axis
22
+ // and the eyes counter back to the camera, which is what keeps it reading
23
+ // as contact rather than as looking away. CANT_HEAR sits on this target.
24
+ USER_EAR: { px: -0.42, py: 0.05, hx: 0.55, hy: 0.02, roll: 0.55 },
25
+ SCREEN_CENTER: { px: 0.00, py: -0.16, hx: 0.00, hy: -0.10 },
26
+ SCREEN_LEFT: { px: -0.78, py: -0.10, hx: -0.36, hy: -0.05 },
27
+ SCREEN_RIGHT: { px: 0.78, py: -0.10, hx: 0.36, hy: -0.05 },
28
+ SCREEN_TOP: { px: 0.00, py: -0.72, hx: 0.00, hy: -0.30 },
29
+ SCREEN_BOTTOM: { px: 0.00, py: 0.62, hx: 0.00, hy: 0.24 },
30
+ SCREEN_WORK: { px: -0.62, py: 0.18, hx: -0.26, hy: 0.12 },
31
+ NOTES: { px: 0.18, py: 0.72, hx: 0.04, hy: 0.28 },
32
+ // The classic "recalling something" break of eye contact. Keep it for the
33
+ // stylized "let me think" beat — audiences read up-and-away regardless of
34
+ // whether real thinkers do it.
35
+ AWAY_THINKING: { px: -0.58, py: -0.58, hx: -0.20, hy: -0.18, roll: 0.08 },
36
+ AWAY_RIGHT: { px: 0.58, py: -0.52, hx: 0.20, hy: -0.16, roll: -0.06 },
37
+ // Where measured cognitive aversion actually goes: DOWN (39%, more than up
38
+ // or side — docs/research-biomechanics.md §4.2). Down-left, mild enough
39
+ // that the lid follow shades the eyes without sealing them; the head
40
+ // carries a share so the pupils stay inside the aperture at tile size.
41
+ AWAY_DOWN: { px: -0.45, py: 0.42, hx: -0.18, hy: 0.20, roll: 0.04 },
42
+ };
43
+
44
+ export const GAZE_NAMES = Object.keys(GAZE_TARGETS);
45
+
46
+ const HEAD_FOLLOW_TAU = 0.34; // roll only — the head lags the eyes badly, on purpose
47
+ const BLINK_THRESHOLD = 0.45; // shift magnitude that triggers a gaze-evoked blink
48
+
49
+ // Head follow is ballistic, not exponential. An exponential chase has its peak
50
+ // velocity at t=0 and then creeps forever — motion that starts instantly and
51
+ // never quite arrives is what "drifting" looks like. A real orienting head
52
+ // accelerates, cruises, and *brakes to a stop*; the stop is the cue that
53
+ // attention has landed (docs/research-biomechanics.md §1.2 — the head's travel
54
+ // is a discrete arriving move, eyes first, head after). The constants keep the
55
+ // old τ=0.34 amble: a typical 0.3-unit shift completes in ~0.55s, USER_EAR's
56
+ // 0.55-unit swing in ~0.85s. The mixer still low-passes headYaw/headPitch at
57
+ // τ=0.16 downstream, which rounds the hard stop into a short settle — that
58
+ // cascade is deliberate, so do not "help" by softening the brake here too.
59
+ const HEAD_ACCEL = 4.0; // units/s² — sets both launch and braking firmness
60
+ const HEAD_SPEED = 0.9; // units/s — cruise cap; only long swings ever reach it
61
+
62
+ export class GazeLayer {
63
+ constructor() {
64
+ this.target = GAZE_TARGETS.USER;
65
+ this.name = 'USER';
66
+ this.head = { x: 0, y: 0, roll: 0 };
67
+ this.vel = { x: 0, y: 0 };
68
+ this.onLargeShift = null;
69
+ this.jitter = { x: 0, y: 0 };
70
+ this._nextMicro = 0;
71
+ }
72
+
73
+ /**
74
+ * @param {string} name one of GAZE_NAMES
75
+ * @param {{x:number,y:number}} [override] normalized -1..1 escape hatch for
76
+ * when the server knows exact screen coordinates
77
+ */
78
+ set(name, override) {
79
+ const next = override
80
+ ? { px: override.x, py: override.y, hx: override.x * 0.42, hy: override.y * 0.36 }
81
+ : GAZE_TARGETS[name] || GAZE_TARGETS.USER;
82
+ const d = Math.hypot(next.px - this.target.px, next.py - this.target.py);
83
+ this.target = next;
84
+ this.name = override ? 'CUSTOM' : name;
85
+ if (d > BLINK_THRESHOLD && this.onLargeShift) this.onLargeShift();
86
+ }
87
+
88
+ /**
89
+ * Micro-saccades. Eyes are never still; a perfectly fixed pupil reads as
90
+ * dead. Small, frequent, and irregular.
91
+ */
92
+ _micro(t, dt) {
93
+ if (t >= this._nextMicro) {
94
+ this._nextMicro = t + 0.7 + Math.random() * 1.6;
95
+ this.jitter.x = (Math.random() - 0.5) * 0.16;
96
+ this.jitter.y = (Math.random() - 0.5) * 0.11;
97
+ }
98
+ const decay = 1 - Math.exp(-dt / 0.5);
99
+ this.jitter.x -= this.jitter.x * decay;
100
+ this.jitter.y -= this.jitter.y * decay;
101
+ }
102
+
103
+ update(t, dt) {
104
+ this._micro(t, dt);
105
+
106
+ // Ballistic head follow: steer velocity toward "full speed at the target,
107
+ // but never faster than can still brake to a stop within the distance
108
+ // left". The braking bound is v² = 2·a·d solved for v, with a half-step
109
+ // correction for discrete time (the −maxA term) so a frame never lands
110
+ // past the target. As d shrinks the bound falls to zero, which *is* the
111
+ // deceleration — no separate easing curve.
112
+ const dx = this.target.hx - this.head.x;
113
+ const dy = this.target.hy - this.head.y;
114
+ const d = Math.hypot(dx, dy);
115
+ const maxA = HEAD_ACCEL * dt;
116
+ const brake = 0.5 * (Math.sqrt(maxA * maxA + 8 * HEAD_ACCEL * d) - maxA);
117
+ const goal = Math.min(HEAD_SPEED, brake);
118
+ let ax = (d ? (dx / d) * goal : 0) - this.vel.x;
119
+ let ay = (d ? (dy / d) * goal : 0) - this.vel.y;
120
+ const a = Math.hypot(ax, ay);
121
+ if (a > maxA) { ax *= maxA / a; ay *= maxA / a; }
122
+ this.vel.x += ax;
123
+ this.vel.y += ay;
124
+ // A dropped-frame dt could step past the target; land on it instead.
125
+ // Judged on the toward-target component so a mid-retarget frame with
126
+ // sideways velocity keeps flying rather than teleporting.
127
+ const along = d ? ((this.vel.x * dx + this.vel.y * dy) / d) * dt : 0;
128
+ if (along >= d && d >= 0) {
129
+ this.head.x = this.target.hx;
130
+ this.head.y = this.target.hy;
131
+ this.vel.x = 0;
132
+ this.vel.y = 0;
133
+ } else {
134
+ this.head.x += this.vel.x * dt;
135
+ this.head.y += this.vel.y * dt;
136
+ }
137
+
138
+ // Roll stays on the exponential: its travels are tiny (≤0.55 and usually
139
+ // ~0.08), far below where the drift tail is visible.
140
+ const k = 1 - Math.exp(-dt / HEAD_FOLLOW_TAU);
141
+ this.head.roll += ((this.target.roll || 0) - this.head.roll) * k;
142
+
143
+ const pupilY = this.target.py + this.jitter.y;
144
+ return {
145
+ pupilX: this.target.px + this.jitter.x,
146
+ pupilY,
147
+ headYaw: this.head.x,
148
+ headPitch: this.head.y,
149
+ headRoll: this.head.roll,
150
+ // The upper lid tracks the eye vertically. Without this, looking down
151
+ // exposes a band of sclera above the iris and the avatar looks startled.
152
+ lidBias: pupilY * 0.34,
153
+ };
154
+ }
155
+ }