@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/idle.js ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * The always-on liveness layer: breathing, blinking, and a slow head drift.
3
+ *
4
+ * This is the cheapest layer to build and the one people notice most. A face
5
+ * that holds perfectly still for two seconds looks like a crashed process, so
6
+ * this layer never stops running — not even in DEGRADED.
7
+ *
8
+ * Output is *additive* (except blink, which takes a max against the base lid
9
+ * value so a blink always closes fully regardless of the current squint).
10
+ *
11
+ * What the layer does is state-shaped but the layer itself is state-blind: it
12
+ * renders a PROFILE (below), and the states table in avatar.js decides which
13
+ * profile is in force. Blink rate alone separates listening from thinking from
14
+ * busy at a glance — docs/research-biomechanics.md §5: conversation ~16/min,
15
+ * cognitive work ~25/min, visual task ~9/min. It is the cheapest state
16
+ * differentiation the rig has.
17
+ */
18
+
19
+ import { approach } from './params.js';
20
+
21
+ /**
22
+ * An idle profile. Every state carries one (sparse — missing keys mean these
23
+ * defaults). Rates may jump between states; *amplitudes* glide over ~1s inside
24
+ * the layer, because an oscillator whose amplitude steps is a visible pop.
25
+ *
26
+ * sway 0..~1.2 head-drift / brow-drift / shoulder / torso amplitude
27
+ * blinkGap [min,max] seconds between spontaneous blinks
28
+ * breathRate multiplier on the 0.23 Hz resting cycle (13.8/min)
29
+ * breathAmp breath excursion scale; cognitive load = faster AND shallower
30
+ * hold { every:[s,s], dur:[s,s] } — freeze the sway (breath continues).
31
+ * Limited animation's static hold: deliberate stillness read as
32
+ * attention, and a bitrate saving besides.
33
+ * rhythm { amp, freq } — quasi-rhythmic alternating shoulder work in
34
+ * burst/pause phrasing; the "busy at the keyboard" tell.
35
+ * flick { amp, every:[s,s] } — a rare, tiny, fast-dying yaw wiggle:
36
+ * the "no, not this one" of someone hunting through options.
37
+ * Deliberately quick (a flick, not a shake — the 1.5 Hz
38
+ * impatience line is about sustained nodding, and this dies in
39
+ * half a second).
40
+ * shift [s,s] gap between postural weight shifts, or null to sit
41
+ * perfectly still. Amplitude rides on `sway`, which is how the
42
+ * cognitive states get the measured sway *suppression* under
43
+ * load (§6.2) without a second knob.
44
+ */
45
+ export const DEFAULT_PROFILE = {
46
+ sway: 1.0,
47
+ blinkGap: [1.9, 5.4],
48
+ breathRate: 1.0,
49
+ breathAmp: 1.0,
50
+ hold: null,
51
+ rhythm: null,
52
+ flick: null,
53
+ shift: [9, 22],
54
+ };
55
+
56
+ const rand = ([a, b]) => a + Math.random() * (b - a);
57
+
58
+ /**
59
+ * Postural weight shift: the one part of this layer that is not periodic, and
60
+ * the part that does most of the work.
61
+ *
62
+ * Everything else here is an oscillator, and an oscillator cannot make a body
63
+ * look alive across a thirty-second hold. Fast enough to notice and it reads
64
+ * as rocking; slow enough not to and it is indistinguishable from a still
65
+ * image. A motion map of the listening state (tools/motion.mjs) showed the
66
+ * outer edge of the torso travelling zero pixels over 24 seconds — the sway
67
+ * was there in the numbers and rendered as nothing at all.
68
+ *
69
+ * What a seated person actually does is re-settle: every 15-40 seconds the
70
+ * trunk arrives at a slightly different resting posture over a second or two
71
+ * and then *stays* there. Discrete, aperiodic, and mostly holding still, which
72
+ * is also why it costs almost nothing to encode — the motion is rare rather
73
+ * than small.
74
+ *
75
+ * The counter-turn is the detail that makes it read as a body rather than a
76
+ * drift: the trunk goes one way and the head yaws slightly the other, because
77
+ * a person shifting their weight keeps looking at the person they are
78
+ * listening to. Without it the whole figure slides sideways as one piece.
79
+ */
80
+ const ZERO_POSTURE = { torsoTurn: 0, torsoLean: 0, headRoll: 0, headYaw: 0, shoulderL: 0, shoulderR: 0 };
81
+ const POSTURE_KEYS = Object.keys(ZERO_POSTURE);
82
+
83
+ function nextPosture(prev) {
84
+ // A magnitude with a floor, and a side chosen against wherever the body
85
+ // already is. The obvious version — draw each channel uniformly about zero —
86
+ // was written first and measured worse than no shift at all: half the draws
87
+ // land near the posture already held, so half the re-settles go nowhere, and
88
+ // a mechanism that is invisible half the time reads as a body that only
89
+ // moves sometimes. Nobody shifts their weight by a millimetre. The 25% that
90
+ // stays on the same side is what keeps it off a left-right metronome.
91
+ const mag = 0.16 + Math.random() * 0.26;
92
+ const away = prev.torsoTurn > 0 ? -1 : prev.torsoTurn < 0 ? 1 : (Math.random() < 0.5 ? -1 : 1);
93
+ const turn = mag * (Math.random() < 0.75 ? away : -away);
94
+ const leanAway = prev.torsoLean > 0 ? -1 : 1;
95
+ const drop = Math.random() * 0.09 - 0.03;
96
+ return {
97
+ torsoTurn: turn,
98
+ torsoLean: (0.03 + Math.random() * 0.05) * leanAway,
99
+ // The head tips slightly against the trunk, and yaws slightly against it
100
+ // too, because a person shifting their weight goes on looking at the
101
+ // person they are listening to. Without the counter-turn the whole figure
102
+ // slides sideways in one piece, which is a camera move, not a body.
103
+ headRoll: turn * -0.28 + (Math.random() * 0.10 - 0.05),
104
+ headYaw: turn * -0.13,
105
+ shoulderL: drop + turn * 0.07, // weight onto one side lifts that shoulder
106
+ shoulderR: drop - turn * 0.07,
107
+ };
108
+ }
109
+
110
+ /** Ease so the shift has no corners at either end — a weight shift accelerates
111
+ * and settles, and a linear ramp between two postures reads as a slide. */
112
+ const smoothstep = (x) => x * x * (3 - 2 * x);
113
+
114
+ export class IdleLayer {
115
+ constructor() {
116
+ this.t = 0;
117
+ this.enabled = true;
118
+ this.profile = DEFAULT_PROFILE;
119
+ this._profileRef = undefined;
120
+ // How much the avatar is talking, 0..1, set by the mixer. Speech does not make
121
+ // the head *busier* — sway still drops — it moves the liveness down into
122
+ // the shoulders and torso, which is where a speaking body actually moves.
123
+ this.talk = 0;
124
+ this._nextBlink = 2 + Math.random() * 3;
125
+ this._blinkT = -1;
126
+ this._blinkDur = 0.13;
127
+ this._double = false;
128
+ // Two incommensurate frequencies per axis so the sway never visibly loops.
129
+ this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
130
+ // Glided amplitudes (profiles set the target, these chase it).
131
+ this._sway = 1;
132
+ this._breathAmp = 1;
133
+ // Breath is a phase integrator, not sin(t*f): rate changes must bend the
134
+ // cycle, not teleport it.
135
+ this._breathPh = Math.random() * Math.PI * 2;
136
+ // Static-hold machine. _holdAmp glides 1 -> 0 -> 1 around each hold.
137
+ this._holdUntil = 0;
138
+ this._nextHold = 0;
139
+ this._holdAmp = 1;
140
+ // Work-rhythm burst/pause machine.
141
+ this._rhythmOn = false;
142
+ this._rhythmFlip = 0;
143
+ this._rhythmAmp = 0;
144
+ // Flick machine: _flickT is time-into-flick, <0 = idle.
145
+ this._flickAt = 0;
146
+ this._flickT = -1;
147
+ // Weight-shift machine: a posture the body is easing from, one it is
148
+ // easing to, and a long wait in between.
149
+ this._postFrom = ZERO_POSTURE;
150
+ this._postTo = ZERO_POSTURE;
151
+ this._shiftAt = 8;
152
+ this._shiftT0 = -1;
153
+ this._shiftDur = 2;
154
+ // Speech phrasing: a slow gain the talking body's excursion rides on, so
155
+ // it comes in waves rather than as a steady hum.
156
+ this._phrase = 0;
157
+ this._phraseFlip = 0;
158
+ this._phraseTo = 1;
159
+ // Global amplitude on everything this layer emits. The honest answer to
160
+ // "keep idle motion cheap": a deployment that composites the avatar into
161
+ // an encoded stream turns it down, rather than the default being frozen.
162
+ this.gain = 1;
163
+ }
164
+
165
+ /** Adopt a (sparse) profile. Cheap to call every frame; same ref is a no-op. */
166
+ setProfile(p) {
167
+ if (p === this._profileRef) return;
168
+ this._profileRef = p;
169
+ this.profile = Object.assign({}, DEFAULT_PROFILE, p || {});
170
+ // Re-arm the hold scheduler so a state that uses holds doesn't inherit a
171
+ // stale far-future timestamp from one that doesn't.
172
+ this._nextHold = this.t + (this.profile.hold ? rand(this.profile.hold.every) : 0);
173
+ this._flickAt = this.t + (this.profile.flick ? rand(this.profile.flick.every) : 0);
174
+ // A shift already under way is left alone: interrupting a weight shift
175
+ // half-finished is a lurch, and states change far more often than the
176
+ // body re-settles.
177
+ if (this._shiftT0 < 0) {
178
+ this._shiftAt = this.t + (this.profile.shift ? rand(this.profile.shift) * 0.6 : 0);
179
+ }
180
+ }
181
+
182
+ /** Force a blink now — used on gaze shifts and state transitions. */
183
+ blink(double = false) {
184
+ if (this._blinkT >= 0) return;
185
+ this._blinkT = 0;
186
+ this._double = double;
187
+ this._blinkDur = 0.11 + Math.random() * 0.04;
188
+ }
189
+
190
+ /** A slow, deliberate blink — reads as thinking or fatigue. */
191
+ slowBlink() {
192
+ if (this._blinkT >= 0) return;
193
+ this._blinkT = 0;
194
+ this._double = false;
195
+ this._blinkDur = 0.34;
196
+ }
197
+
198
+ _blinkValue(dt) {
199
+ if (this._blinkT < 0) return 0;
200
+ this._blinkT += dt;
201
+ const d = this._blinkDur;
202
+ const p = this._blinkT / d;
203
+ if (p >= 1) {
204
+ if (this._double) { this._double = false; this._blinkT = 0; return 0; }
205
+ this._blinkT = -1;
206
+ return 0;
207
+ }
208
+ // Fast close (35% of the window), slower reopen. Symmetric blinks look robotic.
209
+ return p < 0.35 ? p / 0.35 : 1 - (p - 0.35) / 0.65;
210
+ }
211
+
212
+ update(dt) {
213
+ if (!this.enabled) return { add: {}, blink: 0 };
214
+ this.t += dt;
215
+ const t = this.t;
216
+ const pr = this.profile;
217
+
218
+ if (t >= this._nextBlink) {
219
+ this._nextBlink = t + rand(pr.blinkGap);
220
+ // Roughly one blink in six comes in a pair.
221
+ this.blink(Math.random() < 0.16);
222
+ }
223
+ const blink = this._blinkValue(dt);
224
+
225
+ // Static hold: sway freezes, breath does not — held breath reads as alarm.
226
+ if (pr.hold) {
227
+ if (t >= this._nextHold && t >= this._holdUntil) {
228
+ this._holdUntil = t + rand(pr.hold.dur);
229
+ this._nextHold = this._holdUntil + rand(pr.hold.every);
230
+ }
231
+ } else {
232
+ this._holdUntil = 0;
233
+ }
234
+ this._holdAmp = approach(this._holdAmp, t < this._holdUntil ? 0 : 1, 0.18, dt);
235
+
236
+ // Amplitudes glide; rates jump. (~1s ramp keeps profile changes silent.)
237
+ this._sway = approach(this._sway, pr.sway, 1.0, dt);
238
+ this._breathAmp = approach(this._breathAmp, pr.breathAmp, 1.0, dt);
239
+ const a = this._sway * this._holdAmp * this.gain;
240
+ // The weight shift takes the profile's amplitude but NOT the hold: a hold
241
+ // freezes the body where it is, and multiplying a posture by a decaying
242
+ // hold factor would spring it back to centre instead.
243
+ const ps = this._sway * this.gain;
244
+
245
+ this._breathPh += dt * 0.23 * pr.breathRate * Math.PI * 2;
246
+ const breath = (Math.sin(this._breathPh) + 1) * 0.5 * this._breathAmp;
247
+
248
+ // Weight shift. Never starts inside a static hold — the hold is the state
249
+ // saying "this body is concentrating", and a re-settle mid-hold undoes it.
250
+ let post = ZERO_POSTURE;
251
+ if (pr.shift) {
252
+ if (this._shiftT0 < 0 && t >= this._shiftAt && t >= this._holdUntil) {
253
+ this._shiftT0 = t;
254
+ this._shiftDur = 1.5 + Math.random() * 1.6;
255
+ this._postFrom = this._postTo;
256
+ this._postTo = nextPosture(this._postTo);
257
+ }
258
+ if (this._shiftT0 >= 0) {
259
+ const k = (t - this._shiftT0) / this._shiftDur;
260
+ if (k >= 1) {
261
+ this._shiftT0 = -1;
262
+ this._shiftAt = t + rand(pr.shift);
263
+ post = this._postTo;
264
+ } else {
265
+ const e = smoothstep(k);
266
+ post = {};
267
+ for (const c of POSTURE_KEYS) {
268
+ post[c] = this._postFrom[c] + (this._postTo[c] - this._postFrom[c]) * e;
269
+ }
270
+ }
271
+ } else post = this._postTo;
272
+ } else {
273
+ // A state that shifts nothing still has to come home from wherever the
274
+ // last one left the body, or the posture sticks across the transition.
275
+ this._postTo = this._postFrom = ZERO_POSTURE;
276
+ }
277
+
278
+ // Speech phrasing: the talking body's excursion swells and subsides over
279
+ // 0.6-1.6 s rather than humming at a constant level. Speech is phrased and
280
+ // a body pushing it is phrased with it; a steady oscillation while talking
281
+ // is the single most robotic thing this layer could do.
282
+ if (t >= this._phraseFlip) {
283
+ this._phraseFlip = t + 0.6 + Math.random() * 1.0;
284
+ this._phraseTo = 0.45 + Math.random() * 0.85;
285
+ }
286
+ this._phrase = approach(this._phrase, this._phraseTo, 0.35, dt);
287
+
288
+ const s = (i, f) => Math.sin(t * f * Math.PI * 2 + this._ph[i]);
289
+
290
+ // Work rhythm: bursts of alternating-shoulder movement with pauses between,
291
+ // because continuous oscillation reads as rocking and phrased oscillation
292
+ // reads as *doing something*. Authored ~3x the intended excursion: at
293
+ // ~2.2 Hz the shoulders' 0.19s tau attenuates the target to ~0.36 of it.
294
+ let workL = 0, workR = 0, workPitch = 0;
295
+ if (pr.rhythm) {
296
+ if (t >= this._rhythmFlip) {
297
+ this._rhythmOn = !this._rhythmOn;
298
+ this._rhythmFlip = t + (this._rhythmOn ? 0.5 + Math.random() * 0.7 : 0.35 + Math.random() * 0.55);
299
+ }
300
+ this._rhythmAmp = approach(this._rhythmAmp, this._rhythmOn ? pr.rhythm.amp : 0, 0.15, dt);
301
+ const w = Math.sin(t * pr.rhythm.freq * Math.PI * 2) * this._rhythmAmp;
302
+ workL = w;
303
+ workR = -w * 0.85;
304
+ // A trace of the same activity in the head — eyes tracking the work.
305
+ workPitch = Math.sin(t * pr.rhythm.freq * 1.6 * Math.PI * 2) * this._rhythmAmp * 0.16;
306
+ } else {
307
+ this._rhythmAmp = 0;
308
+ }
309
+
310
+ // The "not this one" flick: two fast wiggles dying exponentially. Authored
311
+ // ~2.5x the intended excursion — at ~2.3 Hz the head's 0.16s tau renders
312
+ // roughly 0.4 of the target.
313
+ let flickYaw = 0;
314
+ if (pr.flick) {
315
+ if (this._flickT < 0 && t >= this._flickAt) this._flickT = 0;
316
+ if (this._flickT >= 0) {
317
+ this._flickT += dt;
318
+ const ft = this._flickT;
319
+ if (ft > 0.6) {
320
+ this._flickT = -1;
321
+ this._flickAt = t + rand(pr.flick.every);
322
+ } else {
323
+ flickYaw = pr.flick.amp * Math.sin(ft * 2.3 * Math.PI * 2) * Math.exp(-ft / 0.18);
324
+ }
325
+ }
326
+ } else {
327
+ this._flickT = -1;
328
+ }
329
+
330
+ // Speech moves the body more, and in waves. `talk` says whether sound is
331
+ // being produced, `_phrase` says how hard this stretch of it is being
332
+ // pushed.
333
+ const say = this.talk * this._phrase;
334
+
335
+ return {
336
+ blink,
337
+ add: {
338
+ // Sway frequencies run ~1.6x the original set and amplitudes ~2x.
339
+ // docs/research-biomechanics.md §6.2 recorded the old numbers as
340
+ // deliberately about a quarter of measured human sway, traded for
341
+ // encoder cost; at that setting the head's whole idle excursion
342
+ // rendered as half a pixel at call-tile size, which is not a quiet
343
+ // motion but no motion. This lands nearer half speed — still well
344
+ // below the 0.04-0.6 Hz seated trunk band, still calm, but now
345
+ // actually present on screen. `gain` is where a deployment that
346
+ // really is paying for the pixels turns it back down.
347
+ headYaw: (s(0, 0.094) * 0.6 + s(0, 0.058) * 0.4) * 0.075 * a
348
+ + flickYaw + post.headYaw * ps,
349
+ headPitch: (s(1, 0.072) * 0.6 + s(1, 0.046) * 0.4) * 0.055 * a + workPitch,
350
+ headRoll: s(2, 0.061) * 0.048 * a + post.headRoll * ps,
351
+ breath: breath * this.gain,
352
+ // The brows are never quite still either.
353
+ browRaiseL: s(0, 0.089) * 0.020 * a,
354
+ browRaiseR: s(1, 0.083) * 0.020 * a,
355
+ // Shoulders, and the body's share of speech emphasis — a head that
356
+ // moves on its own above a torso that never does is the head-on-a-stick
357
+ // read, and it was the loudest note in the first round of stakeholder
358
+ // feedback. The slow pair of frequencies is deliberate now that the
359
+ // amplitude is big enough to see: the old 0.31 Hz term was inaudible at
360
+ // 0.012 and would have read as a twitch at 0.04.
361
+ // Note which factor each term takes. The idle drift is scaled by `a`,
362
+ // so a state that asks for stillness gets it. The speech term is NOT:
363
+ // states that speak lower their `sway` on purpose (SPEAKING sits at
364
+ // 0.55, because the head should not wander while the mouth is the thing
365
+ // being watched), and running the body's share of speech through that
366
+ // same number costs a third of it — measured, same seed, 30 s of
367
+ // SPEAKING: shoulder line travels 6 px gated against 9 px here, and the
368
+ // torso band's mean luminance range falls 28 -> 24. Sway suppression is
369
+ // a statement about drift. Speech reorganises the body; it does not
370
+ // park it.
371
+ shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * a + 0.10 * say * this.gain)
372
+ + workL + post.shoulderL * ps,
373
+ shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * a + 0.10 * say * this.gain)
374
+ + workR + post.shoulderR * ps,
375
+ torsoLean: s(0, 0.085) * (0.028 * a + 0.075 * say * this.gain) + post.torsoLean * ps,
376
+ // The trunk's own drift, small next to the weight shift that dominates
377
+ // this channel. It exists so the body is not perfectly still *between*
378
+ // shifts, which would make each shift read as a discrete event.
379
+ torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.06 * a
380
+ + post.torsoTurn * ps,
381
+ },
382
+ };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * The listening engine: backchannels and engagement posture for LISTENING.
388
+ *
389
+ * An agent who sits motionless while you talk feels like a recording — but the
390
+ * fix is not more nodding. Gratch's rapport experiments showed that nod
391
+ * *frequency* without *contingency* creates no rapport at all, and an agent
392
+ * that acknowledges on a metronome reads as distracting
393
+ * (docs/research-biomechanics.md §3.5). So this engine is contingent first:
394
+ *
395
+ * · The host tells it about the user's voice — a coarse speaking flag
396
+ * (setUserSpeaking) or a measured level (observeLevel), flag wins.
397
+ * · Acknowledgements fire at PAUSE ONSETS: when the user stops talking, a
398
+ * nod lands 250–600 ms later, about half the time, never more often than
399
+ * every 2.5 s. That timing is where a human listener's nod sits.
400
+ * · During a long unbroken stretch of user speech a rare mid-speech nod
401
+ * keeps the face alive (nods fill ~26% of human listening time — we err
402
+ * far quieter, per the screen-share bitrate constraint).
403
+ * · Engagement posture: `engage` rises while the user speaks and relaxes
404
+ * after long silence. The mixer spends it on torsoLean — forward lean is
405
+ * the highest-value listening channel the rig has (§6.3).
406
+ *
407
+ * If the host never supplies any user signal, the old loose random timer runs
408
+ * instead — a worse listener, but never a dead one.
409
+ */
410
+ export class ListeningEngine {
411
+ constructor(fire) {
412
+ this.fire = fire;
413
+ this.enabled = false;
414
+ this.t = 0;
415
+ this.engage = 0;
416
+ this.lastFireAt = -1e9;
417
+ // --- no-signal fallback timer (the pre-contingency behaviour, verbatim)
418
+ this.minGap = 3.4;
419
+ this.maxGap = 8.0;
420
+ this._next = 0;
421
+ // --- user-signal state
422
+ this._hasSignal = false;
423
+ this._explicit = null; // host-declared flag; null = not driven
424
+ this._derived = false; // level-derived VAD
425
+ this._levelOn = false; // raw hysteresis state behind _derived
426
+ this._flipT = 0; // how long the level has disagreed with _levelOn
427
+ this._speaking = false; // merged VAD, after hysteresis
428
+ this._spokeAt = -1e9; // start of the current speech stretch
429
+ this._silentAt = 0; // end of the last one
430
+ this._pending = -1; // scheduled contingent fire time, <0 = none
431
+ this._midNext = 0;
432
+ }
433
+
434
+ /** Push the next autonomous fire out — called on state changes and after any
435
+ * manual interjection, so scheduled nods never pile onto server-driven ones. */
436
+ reset(delay = 2.5) {
437
+ this._next = this.t + delay;
438
+ this.lastFireAt = this.t;
439
+ this._pending = -1;
440
+ }
441
+
442
+ /** Host-declared user speech. Pass null to hand control back to the level VAD. */
443
+ setUserSpeaking(b) {
444
+ if (b !== null) this._hasSignal = true;
445
+ this._explicit = b === null ? null : !!b;
446
+ }
447
+
448
+ /** Feed the smoothed user audio level (an AudioFallback.level). Same scale,
449
+ * thresholds and asymmetry as the demo's RMS VAD: quick in (80 ms), slow
450
+ * out (250 ms) — declaring the turn over early is the expensive mistake,
451
+ * and the 250 ms quiet-hold IS the pause detector the contingent
452
+ * scheduler keys off. */
453
+ observeLevel(level) {
454
+ this._hasSignal = true;
455
+ const on = !!this._levelOn;
456
+ const wants = level > (on ? 0.018 : 0.030);
457
+ if (wants === on) this._flipT = this.t;
458
+ else if (this.t - this._flipT >= (on ? 0.25 : 0.08)) {
459
+ this._levelOn = wants;
460
+ this._flipT = this.t;
461
+ }
462
+ this._derived = !!this._levelOn;
463
+ }
464
+
465
+ /** The one seam for choosing an acknowledgement. Context-aware: what the
466
+ * user just did decides the weight class of the reply
467
+ * (docs/research-biomechanics.md §3.3 — continuers co-occur with ongoing
468
+ * speech, assessments with completed content; corpus mix 49/40/12,
469
+ * shifted quieter here per the screen-share constraint). */
470
+ pickAck(context) {
471
+ const r = Math.random();
472
+ // Mid-speech nods stay minimal: the user still has the floor.
473
+ if (context === 'midspeech') return r < 0.7 ? 'NOD_SMALL' : 'BROW_ACK';
474
+ // A pause after a LONG stretch earns an assessment-class nod — the user
475
+ // completed a thought, and answering a paragraph with a continuer reads
476
+ // as not having listened to it. NOD_UP is rationed: a realization every
477
+ // few seconds stops meaning realization.
478
+ const utter = this._hasSignal ? this._silentAt - this._spokeAt : 0;
479
+ if (utter >= 4) {
480
+ if (r < 0.45) return 'NOD_SLOW';
481
+ if (r < 0.65) return 'NOD_UP';
482
+ if (r < 0.85) return 'NOD_SMALL';
483
+ return 'BROW_ACK';
484
+ }
485
+ // Short utterance (and the no-signal fallback timer): continuer country.
486
+ return r < 0.55 ? 'NOD_SMALL' : r < 0.8 ? 'BROW_ACK' : 'NOD_SLOW';
487
+ }
488
+
489
+ get speaking() { return this._explicit !== null ? this._explicit : this._derived; }
490
+
491
+ update(dt) {
492
+ this.t += dt;
493
+ const t = this.t;
494
+ const speaking = this.speaking;
495
+
496
+ // Engagement: quick to lean in when the user starts, slow to give it up —
497
+ // relaxing the moment they pause would read as relief that they stopped.
498
+ if (speaking !== this._speaking) {
499
+ this._speaking = speaking;
500
+ if (speaking) { this._spokeAt = t; this._pending = -1; }
501
+ else {
502
+ this._silentAt = t;
503
+ // Pause onset: the contingent backchannel moment. Half of pauses get
504
+ // an acknowledgement; the other half, keeping still IS the answer.
505
+ if (this.enabled && this._hasSignal
506
+ && t - this.lastFireAt >= 2.5 && Math.random() < 0.5) {
507
+ this._pending = t + 0.15 + Math.random() * 0.3;
508
+ }
509
+ }
510
+ }
511
+ const engaged = speaking || t - this._silentAt < 8;
512
+ this.engage = approach(this.engage, engaged && this._hasSignal ? 1 : 0,
513
+ speaking ? 1.5 : 6.0, dt);
514
+
515
+ if (!this.enabled) { this._pending = -1; return; }
516
+
517
+ if (this._hasSignal) {
518
+ if (this._pending > 0 && t >= this._pending) {
519
+ this._pending = -1;
520
+ this.lastFireAt = t;
521
+ this.fire(this.pickAck('pause'));
522
+ }
523
+ // A long unbroken stretch of user speech earns a rare mid-speech nod.
524
+ if (speaking && t - this._spokeAt > 5.5 && t - this.lastFireAt > 3.5 && t >= this._midNext) {
525
+ this._midNext = t + 2.6 + Math.random() * 1.8;
526
+ if (Math.random() < 0.35) { this.lastFireAt = t; this.fire(this.pickAck('midspeech')); }
527
+ }
528
+ } else if (t >= this._next) {
529
+ // No user signal was ever supplied: the loose timer, exactly as before.
530
+ this._next = t + this.minGap + Math.random() * (this.maxGap - this.minGap);
531
+ this.lastFireAt = t;
532
+ this.fire(this.pickAck('pause'));
533
+ }
534
+ }
535
+ }