@voqalize/avatar 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +46 -28
  2. package/assets/README.md +4 -3
  3. package/assets/tanya.glb +0 -0
  4. package/assets/tara.glb +0 -0
  5. package/assets/tess.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/internal.ts +20 -0
  8. package/client/three/assets.ts +5 -3
  9. package/client/three/holds.ts +7 -2
  10. package/client/three/internal.ts +5 -5
  11. package/client/three/motion-limits.json +5 -1
  12. package/client/three/sequences.ts +5 -5
  13. package/client/three/tara-rig.ts +22 -8
  14. package/client/three/tess-asset.ts +6 -0
  15. package/client/three/tess.ts +72 -0
  16. package/dist/internal.d.ts +2 -2
  17. package/dist/internal.d.ts.map +1 -1
  18. package/dist/internal.js +12 -1
  19. package/dist/internal.js.map +1 -1
  20. package/dist/three/assets.d.ts +1 -0
  21. package/dist/three/assets.d.ts.map +1 -1
  22. package/dist/three/assets.js +5 -3
  23. package/dist/three/assets.js.map +1 -1
  24. package/dist/three/holds.d.ts +7 -2
  25. package/dist/three/holds.d.ts.map +1 -1
  26. package/dist/three/holds.js +7 -2
  27. package/dist/three/holds.js.map +1 -1
  28. package/dist/three/internal.d.ts +5 -5
  29. package/dist/three/internal.js +5 -5
  30. package/dist/three/motion-limits.json +5 -1
  31. package/dist/three/sequences.d.ts +5 -5
  32. package/dist/three/sequences.js +5 -5
  33. package/dist/three/tara-rig.d.ts +18 -4
  34. package/dist/three/tara-rig.d.ts.map +1 -1
  35. package/dist/three/tara-rig.js +9 -8
  36. package/dist/three/tara-rig.js.map +1 -1
  37. package/dist/three/tess-asset.d.ts +7 -0
  38. package/dist/three/tess-asset.d.ts.map +1 -0
  39. package/dist/three/tess-asset.js +7 -0
  40. package/dist/three/tess-asset.js.map +1 -0
  41. package/dist/three/tess.d.ts +38 -0
  42. package/dist/three/tess.d.ts.map +1 -0
  43. package/dist/three/tess.js +54 -0
  44. package/dist/three/tess.js.map +1 -0
  45. package/package.json +5 -1
  46. package/src/avatar.d.ts +65 -2
  47. package/src/avatar.js +68 -63
  48. package/src/faces.js +1 -1
  49. package/src/gaze.js +19 -44
  50. package/src/idle.js +60 -26
  51. package/src/prosody.js +104 -14
  52. package/src/rig.d.ts +7 -0
package/src/idle.js CHANGED
@@ -54,7 +54,13 @@ import { HeadPose } from './head.js';
54
54
  */
55
55
  export const DEFAULT_PROFILE = {
56
56
  sway: 1.0,
57
- settle: [1.8, 4.6],
57
+ // A head re-positions oftener than it used to (2026-09-21). On a character
58
+ // whose held angle is budgeted this is the half of "more movement" that
59
+ // actually arrives: the angle is capped by `headHold` and the *rate* is not,
60
+ // so a body that re-settles every couple of seconds reads as more alive than
61
+ // one that reaches further every five. Still an event every second or two at
62
+ // its fastest, which is nowhere near the 1.5 Hz line.
63
+ settle: [1.4, 3.4],
58
64
  blinkGap: [1.9, 5.4],
59
65
  slowBlink: 0,
60
66
  breathRate: 1.0,
@@ -62,7 +68,7 @@ export const DEFAULT_PROFILE = {
62
68
  hold: null,
63
69
  rhythm: null,
64
70
  flick: null,
65
- shift: [9, 22],
71
+ shift: [7, 16],
66
72
  };
67
73
 
68
74
  const rand = ([a, b]) => a + Math.random() * (b - a);
@@ -120,7 +126,7 @@ function nextPosture(prev) {
120
126
  // a mechanism that is invisible half the time reads as a body that only
121
127
  // moves sometimes. Nobody shifts their weight by a millimetre. The 25% that
122
128
  // stays on the same side is what keeps it off a left-right metronome.
123
- const mag = 0.16 + Math.random() * 0.26;
129
+ const mag = 0.20 + Math.random() * 0.32;
124
130
  const away = prev.torsoTurn > 0 ? -1 : prev.torsoTurn < 0 ? 1 : (Math.random() < 0.5 ? -1 : 1);
125
131
  const turn = mag * (Math.random() < 0.75 ? away : -away);
126
132
  const leanAway = prev.torsoLean > 0 ? -1 : 1;
@@ -158,11 +164,24 @@ function nextPosture(prev) {
158
164
  * and not a small number, is what keeps pitch off the wire's vocabulary. It was
159
165
  * held to under a degree as well, which measured as a listening head with no
160
166
  * pitch in it at all (1.05° peak over thirty seconds, against yaw's 5.5°), and
161
- * a head that only ever turns is a head on a turntable. The range is wider now
162
- * and still biased upward: a chin that drifts down and stays there is the
163
- * downcast read, which on a photographed face arrives long before any other.
167
+ * a head that only ever turns is a head on a turntable. The range is biased
168
+ * upward: a chin that drifts down and stays there is the downcast read, which
169
+ * on a photographed face arrives long before any other.
170
+ *
171
+ * **Widened again on 2026-09-21, on the owner's read that the whole figure is
172
+ * too restricted.** The ceiling here is not this table — it is `headHold` in
173
+ * the mixer (step 6b), which softens what the layers together ask the head to
174
+ * *hold* against the angle the owner measured on each character by eye
175
+ * (`motion-limits.json`). So these numbers are what the drawing gets and what a
176
+ * budgeted character gets is its own measured angle, which is the arrangement
177
+ * this file should have been sized against all along: on tara the widest settle
178
+ * yaw is 4.9° under a 6° hold budget, the widest pitch 2.6° under 5°, and roll
179
+ * stays the most conservative of the three because her roll *transition* is a
180
+ * recorded defect at any angle. The `dur` is untouched — a wider move over the
181
+ * same half second is a faster move, not a busier one, and the frequency is
182
+ * what the movement budget is about (CLAUDE.md).
164
183
  */
165
- const SETTLE = { yaw: [0.16, 0.34], pitch: [-0.11, 0.08], roll: [0.10, 0.28], dur: [0.45, 0.8], switchP: 0.7 };
184
+ const SETTLE = { yaw: [0.22, 0.46], pitch: [-0.15, 0.11], roll: [0.14, 0.38], dur: [0.45, 0.8], switchP: 0.7 };
166
185
  const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
167
186
 
168
187
  /**
@@ -186,7 +205,7 @@ const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
186
205
  * same gate, so a quiet head does not sit on a torso still rocking under it.
187
206
  */
188
207
  const LIVE = {
189
- yaw: 0.05, pitch: 0.02, roll: 0.05, breathPitch: 0.006,
208
+ yaw: 0.07, pitch: 0.028, roll: 0.07, breathPitch: 0.006,
190
209
  retarget: [0.6, 2.2], tau: 0.38,
191
210
  drift: [2.0, 5.0], still: [1.2, 3.5], stillGain: 0.12,
192
211
  };
@@ -208,7 +227,6 @@ export class IdleLayer {
208
227
  this._nextBlink = 2 + Math.random() * 3;
209
228
  this._blinkT = -1;
210
229
  this._blinkDur = 0.13;
211
- this._double = false;
212
230
  this._lastBlinkAt = -Infinity;
213
231
  // Two incommensurate frequencies per axis so the sway never visibly loops.
214
232
  this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
@@ -294,19 +312,18 @@ export class IdleLayer {
294
312
  * rate is a decision to revisit in that state, not something for the eyelids
295
313
  * to drop silently.
296
314
  */
297
- blink(double = false, evoked = false, forced = false) {
315
+ blink(evoked = false, forced = false) {
298
316
  if (evoked) {
299
317
  if (this.t - this._lastBlinkAt < BLINK_REFRACTORY) return;
300
318
  const gap = this._nextBlink - this._lastBlinkAt;
301
319
  if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
302
320
  }
303
- this._startBlink(0.11 + Math.random() * 0.04, double);
321
+ this._startBlink(0.11 + Math.random() * 0.04);
304
322
  }
305
323
 
306
- _startBlink(dur, double) {
324
+ _startBlink(dur) {
307
325
  if (this._blinkT >= 0) return;
308
326
  this._blinkT = 0;
309
- this._double = double;
310
327
  this._blinkDur = dur;
311
328
  this._lastBlinkAt = this.t;
312
329
  this._nextBlink = this.t + rand(this.profile.blinkGap);
@@ -319,12 +336,12 @@ export class IdleLayer {
319
336
  * of speech with no pauses in it.
320
337
  */
321
338
  phraseBlink() {
322
- this.blink(false, true);
339
+ this.blink(true);
323
340
  }
324
341
 
325
342
  /** A slow, deliberate blink — reads as thinking or fatigue. */
326
343
  slowBlink() {
327
- this._startBlink(0.34, false);
344
+ this._startBlink(0.34);
328
345
  }
329
346
 
330
347
  _blinkValue(dt) {
@@ -333,7 +350,6 @@ export class IdleLayer {
333
350
  const d = this._blinkDur;
334
351
  const p = this._blinkT / d;
335
352
  if (p >= 1) {
336
- if (this._double) { this._double = false; this._blinkT = 0; return 0; }
337
353
  this._blinkT = -1;
338
354
  return 0;
339
355
  }
@@ -351,10 +367,15 @@ export class IdleLayer {
351
367
  // Re-armed here as well as in _startBlink, for the frame the timer comes
352
368
  // due in the middle of a blink already running.
353
369
  this._nextBlink = t + rand(pr.blinkGap);
354
- // A share of them slow if the state asks for it; roughly one in six of
355
- // the rest comes in a pair.
370
+ // A share of them slow if the state asks for it. **The rest are single,
371
+ // and a paired blink is not a thing this layer does** — a share of them
372
+ // used to re-close the instant the lid reached open, which at this
373
+ // duration is a 7-9 Hz flutter and was read by the owner on 2026-09-21 as
374
+ // a dropped frame rather than as a pair. A human doublet is two blinks a
375
+ // few hundred ms apart, which is what the gap already produces when it
376
+ // draws short; nothing in research-biomechanics.md asks for the rest.
356
377
  if (Math.random() < pr.slowBlink) this.slowBlink();
357
- else this.blink(Math.random() < 0.16);
378
+ else this.blink();
358
379
  }
359
380
  const blink = this._blinkValue(dt);
360
381
 
@@ -524,6 +545,12 @@ export class IdleLayer {
524
545
  // actually present on screen. `gain` is where a deployment that
525
546
  // really is paying for the pixels turns it back down.
526
547
  // The head is the exception: it settles rather than sways (SETTLE).
548
+ // The *idle* terms below went up again on 2026-09-21 — the owner read
549
+ // the figure as too restricted — and only the idle terms: the speech
550
+ // share is `prosody.js`'s business and is raised there, beside the
551
+ // words it is timed to. These are all far below 1.5 Hz, so the raise
552
+ // buys excursion and not rate; what it costs the encoder is a slightly
553
+ // larger slow motion, which is the cheap kind.
527
554
  headYaw: hs.headYaw + flickYaw + post.headYaw * ps,
528
555
  headPitch: hs.headPitch + workPitch,
529
556
  headRoll: hs.headRoll + post.headRoll * ps,
@@ -531,9 +558,16 @@ export class IdleLayer {
531
558
  // outbreath over the phrase (research-biomechanics.md §6.1), which the
532
559
  // prosody layer draws. Quiet breathing steps mostly back under it.
533
560
  breath: breath * this.gain * (1 - 0.6 * this.talk),
534
- // The brows are never quite still either.
535
- browRaiseL: s(0, 0.089) * 0.020 * a,
536
- browRaiseR: s(1, 0.083) * 0.020 * a,
561
+ // The brows are never quite still either — and they were nearly still
562
+ // here, at an amplitude a beat could swallow whole. Two video
563
+ // reviewers named a motionless upper face as the biggest reason these
564
+ // read as uncanny; `prosody.POSE.brow` answered that for a face that
565
+ // is *talking*, and the avatar listens far more than it speaks
566
+ // (CLAUDE.md). The two rates are further apart than they were so the
567
+ // pair drifts out of phase inside one call rather than over several:
568
+ // a matched pair is the drawing, not the face.
569
+ browRaiseL: s(0, 0.089) * 0.032 * a,
570
+ browRaiseR: s(1, 0.071) * 0.032 * a,
537
571
  // Shoulders, and the body's share of speech emphasis — a head that
538
572
  // moves on its own above a torso that never does is the head-on-a-stick
539
573
  // read, and it was the loudest note in the first round of stakeholder
@@ -559,15 +593,15 @@ export class IdleLayer {
559
593
  // said should be the louder. What is left here is the part prosody
560
594
  // cannot supply: motion in the gaps *between* phrases, where there is
561
595
  // no accent to hang anything on.
562
- shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
596
+ shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
563
597
  + workL + post.shoulderL * ps,
564
- shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
598
+ shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
565
599
  + workR + post.shoulderR * ps,
566
- torsoLean: s(0, 0.085) * (0.028 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
600
+ torsoLean: s(0, 0.085) * (0.036 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
567
601
  // The trunk's own drift, small next to the weight shift that dominates
568
602
  // this channel. It exists so the body is not perfectly still *between*
569
603
  // shifts, which would make each shift read as a discrete event.
570
- torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.06 * a
604
+ torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.075 * a
571
605
  + post.torsoTurn * ps,
572
606
  },
573
607
  };
package/src/prosody.js CHANGED
@@ -83,10 +83,36 @@ const BLINK_FRESH_MS = 150;
83
83
  // wobble about the same one. Yaw is the narrowest in degrees because it is the
84
84
  // axis a projected photograph survives least; pitch leans slightly chin-up,
85
85
  // which is where a person talking to someone holds their head.
86
+ // **Every axis went up on 2026-09-21, and it is paying for something
87
+ // specific.** The speaking face used to get much of its movement from a gaze
88
+ // aversion — a look away that took the neck with it — and that is gone
89
+ // (gaze.js): the eyes now hold the user for the whole turn. Taking it out and
90
+ // changing nothing else left the speaking head measurably *stiller* than
91
+ // before, which is the defect the aversion had been introduced to fix. So the
92
+ // movement comes back as the pose the head holds per phrase, which is what a
93
+ // speaker's head actually does and is communicative rather than evasive.
94
+ //
95
+ // Sized against `presence.test.ts`, which renders a turn through the real
96
+ // mixer and prints the degrees. On tara the widest yaw here is ~4.9° and the
97
+ // widest chin-up ~4.4°, inside the 6° and 5° she was measured at for a *held*
98
+ // pose; what the same table showed before the raise was a speaking head
99
+ // reaching 4.2° of yaw and 2.1° of pitch, the second of which is under half of
100
+ // what the aversion had been supplying. `headHold` softens the sum in any
101
+ // case, so a character measured tighter than tara gets her own angle here for
102
+ // free.
86
103
  const POSE = {
87
- headYaw: [0.12, 0.34],
88
- headPitch: [-0.20, 0.14],
89
- headRoll: [0.10, 0.30],
104
+ headYaw: [0.15, 0.44],
105
+ // **Pitch widens downward only, and the chin-up end is pinned where it was.**
106
+ // Opening it to -0.26 with the rest measured as a speaking face reading
107
+ // *sleepy*: the lids follow the globe, and on a rig with a reflex a chin-up
108
+ // head means eyes rolled down to hold the user, which narrows the aperture
109
+ // for real. The audit's open-lid median went 0.149 -> 0.163 against a 0.15
110
+ // ceiling (research-perception.md § 6) on that change alone. So the chin-up
111
+ // end stays at the -0.20 it was measured safe at, and the extra travel comes
112
+ // off the other end, where the reflex rolls the eyes *up* and the lid opens.
113
+ // A phrase pose returns, so this is not the chin-drift that reads downcast.
114
+ headPitch: [-0.20, 0.22],
115
+ headRoll: [0.12, 0.34],
90
116
  // The mouth corners take a small share of each pose: a speaking mouth whose
91
117
  // corners never move reads as dubbed. They ease rather than move, since a
92
118
  // corner that jumps reads as a twitch.
@@ -195,16 +221,32 @@ const BEAT = {
195
221
  // been held a while, so a phrase does not change position twice in a breath.
196
222
  // Of the rest most are a nod and some leave the head alone: a beat on every
197
223
  // accent is a metronome, and the brows still carry it.
198
- swingP: 0.3,
199
- swingAfterMs: 1800,
224
+ //
225
+ // **Raised on 2026-09-21, and this is where the speaking head's motion went.**
226
+ // Deleting the speaking aversion (gaze.js) took its head share with it, and
227
+ // that share had been most of what moved the head *inside* a sentence: the
228
+ // per-sentence deviation fell from 0.79 deg of yaw to 0.55 measured on the
229
+ // audit's real cue track, because a held phrase pose contributes nothing to a
230
+ // deviation taken about that phrase's own mean. The swing is the honest way
231
+ // to put it back — it is a move to a new pose rather than a look away from
232
+ // the user, which is the whole distinction the removal was about, and Graf
233
+ // has the speaker doing it. Offered more often and after a shorter hold; the
234
+ // hold is still long enough that a phrase does not reposition twice in a
235
+ // breath.
236
+ swingP: 0.42,
237
+ swingAfterMs: 1400,
200
238
  swingMs: 320,
201
239
  nodP: 0.7,
202
240
  // The nod: down and back, over by 0.4 s, and nothing after it. It peaks just
203
241
  // ahead of the vowel so that, through the head's 160 ms smoothing, it lands on
204
242
  // it. About 2.5° asked of tara, of which the smoothing renders most.
205
243
  nod: { pitch: 0.15, attack: 130, release: 240, lead: 170 },
206
- // A diagonal nod's yaw, on the turn's side.
207
- yawP: 0.35,
244
+ // A diagonal nod's yaw, on the turn's side. Raised with the swing above, and
245
+ // for the same reason — a diagonal beat moves the head across the sentence
246
+ // where a pure pitch nod only moves it down and back. The amplitude is
247
+ // untouched: the nod's read defect was its geometry, never its size
248
+ // (3d-avatar-motion-audit.md, 2026-09-14).
249
+ yawP: 0.45,
208
250
  yaw: 0.12,
209
251
  };
210
252
 
@@ -217,16 +259,21 @@ const BEAT = {
217
259
  // shoulders at 0.19 s and the lean at 0.24 s. Each stroke returns through
218
260
  // neutral: a trunk that only ever lifts is a standing shrug, which is what a
219
261
  // filmstrip once showed as the figure sitting larger and lower in frame.
262
+ // Raised on 2026-09-21 with the rest of the body: the trunk has no measured
263
+ // hold budget the way the head does — nothing about a shoulder tells a
264
+ // projected photograph it has been turned too far — so it is the channel where
265
+ // "a bit more movement" is free, and it is the one the head-on-a-stick read
266
+ // comes from.
220
267
  const BEAT_BODY = {
221
268
  keys: [[-380, 0], [-60, 1], [140, 0.25], [320, -0.30], [600, 0]],
222
- shoulder: 0.28, lean: 0.11, spread: 0.35,
269
+ shoulder: 0.36, lean: 0.15, spread: 0.35,
223
270
  };
224
271
  // The lift a phrase carries, over its own span: up on the first stressed
225
272
  // syllable and settling below where it started as the breath goes out.
226
273
  const PHRASE_BODY = {
227
274
  keys: [[-260, 0], [180, 1], [900, 0.5], [1500, -0.30], [2400, 0]],
228
- shoulder: 0.22,
229
- lean: 0.16,
275
+ shoulder: 0.28,
276
+ lean: 0.21,
230
277
  };
231
278
 
232
279
  // The phrase-final settle: chin down this far from the phrase's own pose,
@@ -257,16 +304,40 @@ const HOME_MS = 700;
257
304
  // to take the mouth.
258
305
  // `ack` rises with the nod and outlasts it, since a smile that ends with the
259
306
  // head's last beat reads as part of the gesture rather than as pleasure.
307
+ //
308
+ // `listen` is the fourth, and it is the one episode that is not about speech at
309
+ // all: the floor has arrived and the face receives it. Every other state change
310
+ // the avatar makes is a change of task; this one is a change of *who is
311
+ // talking*, and a face that takes the floor back with no expression at all is
312
+ // where the "not smiling enough" read comes from. Its shape is the owner's
313
+ // (2026-09-21): up, then a long fade to neutral rather than a held level, which
314
+ // is the same argument the other three are built on — a smile that stays is
315
+ // discounted. The full height lasts 1.8 s, inside Ekman's felt-smile window,
316
+ // and the remaining 3.8 s is the fade. **It is the warmest of the four, raised
317
+ // on the owner's read of 2026-09-21 that it was not arriving.** It started at
318
+ // `ack`'s height and reasoned that `close` — the face that hands the floor
319
+ // over — should outrank the face that takes it; on screen that had it landing
320
+ // as a politeness rather than as a welcome, and this is the one episode a user
321
+ // is looking straight at when it fires.
260
322
  // Times in ms.
261
323
  const WARMTH = {
262
324
  onset: { keys: [[0, 0], [350, 1], [1500, 1], [2800, 0]], corner: 0.18, squint: 0.08 },
263
325
  close: { keys: [[0, 0], [300, 1], [2600, 1], [3800, 0]], corner: 0.36, squint: 0.14 },
264
326
  ack: { keys: [[0, 0], [220, 1], [1100, 1], [2000, 0]], corner: 0.30, squint: 0.12 },
327
+ listen: { keys: [[0, 0], [400, 1], [2200, 1], [6000, 0]], corner: 0.42, squint: 0.17 },
265
328
  };
266
329
  // A continuer can come every second or two. Each one smiling would hold the
267
330
  // smile up for as long as the user talks, which is the fixed smile again, so
268
331
  // an acknowledgement inside this long of the last smiling one nods without it.
269
332
  const ACK_SMILE_REST_MS = 4000;
333
+ // The listening smile rests behind *any* warmth, not just its own, and this is
334
+ // the whole of what keeps it from becoming the fixed smile. A turn normally
335
+ // ends SPEAKING -> `close` -> LISTENING within a few hundred ms, and `close` is
336
+ // already that handover's smile; a state that flaps back through LISTENING —
337
+ // through THINKING and out again, or a barge-in and a resume — would otherwise
338
+ // re-arm one every time. Longer than the episodes themselves, so two can never
339
+ // run end to end.
340
+ const LISTEN_SMILE_REST_MS = 7000;
270
341
 
271
342
  /**
272
343
  * What a rig renders of the head amplitudes above, unless it says otherwise.
@@ -320,6 +391,7 @@ export class SpeechProsody {
320
391
  this._moves = [];
321
392
  this._warm = [];
322
393
  this._lastAckSmile = -Infinity;
394
+ this._lastWarm = -Infinity;
323
395
  this._head = new HeadPose();
324
396
  this._home = true;
325
397
  this._corner = 0;
@@ -360,7 +432,23 @@ export class SpeechProsody {
360
432
 
361
433
  /** The track played out to its end: the turn's closing warmth. */
362
434
  closeTurn() {
363
- this._warm.push({ at: this._ms, w: WARMTH.close });
435
+ this._smile(WARMTH.close);
436
+ }
437
+
438
+ /**
439
+ * The avatar has entered LISTENING: the floor is the user's. A warmth
440
+ * episode, unless something smiled recently — see `LISTEN_SMILE_REST_MS`,
441
+ * which is what stands between this and a face that is always smiling.
442
+ */
443
+ listen() {
444
+ if (this._ms - this._lastWarm < LISTEN_SMILE_REST_MS) return;
445
+ this._smile(WARMTH.listen);
446
+ }
447
+
448
+ /** Start a warmth episode, and remember that the face has just smiled. */
449
+ _smile(w) {
450
+ this._lastWarm = this._ms;
451
+ this._warm.push({ at: this._ms, w });
364
452
  }
365
453
 
366
454
  /**
@@ -371,10 +459,12 @@ export class SpeechProsody {
371
459
  acknowledge() {
372
460
  if (this._ms - this._lastAckSmile < ACK_SMILE_REST_MS) return;
373
461
  this._lastAckSmile = this._ms;
374
- this._warm.push({ at: this._ms, w: WARMTH.ack });
462
+ this._smile(WARMTH.ack);
375
463
  }
376
464
 
377
- /** Cut off: a smile that survives being interrupted has not noticed. */
465
+ /** Cut off: a smile that survives being interrupted has not noticed. The
466
+ * rest period is deliberately *not* cleared — being interrupted is not an
467
+ * occasion to smile a fresh one at the state change that follows. */
378
468
  cool() {
379
469
  this._warm = [];
380
470
  }
@@ -480,7 +570,7 @@ export class SpeechProsody {
480
570
  if (cue.v !== SILENT) {
481
571
  if (this._onsetPending) {
482
572
  this._onsetPending = false;
483
- this._warm.push({ at: this._ms, w: WARMTH.onset });
573
+ this._smile(WARMTH.onset);
484
574
  }
485
575
  let a = i;
486
576
  while (a > 0 && !isPause(cues, a - 1)) a--;
package/src/rig.d.ts CHANGED
@@ -22,3 +22,10 @@ export type AvatarRigFactory = (mount: HTMLElement, options?: unknown) => Avatar
22
22
 
23
23
  export const HAND_GESTURE_NAMES: ReadonlyArray<HandFrame["gesture"]>;
24
24
  export function avatarFrame(pose: RigPose, hand?: HandFrame): AvatarFrame;
25
+ /** The bundled SVG renderer as a bare rig: what `createAvatar` builds when no
26
+ * `rig` option is passed, and what an instrument mounts when it wants a face
27
+ * it can pose directly. */
28
+ export function createSvgRig(
29
+ face: ReturnType<import("./avatar.js").FaceFactory>,
30
+ hand?: unknown,
31
+ ): AvatarRig;