@voqalize/avatar 0.3.0 → 0.4.0

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 (132) hide show
  1. package/LICENSE-CC-BY-4.0 +396 -0
  2. package/README.md +65 -17
  3. package/assets/README.md +48 -0
  4. package/assets/tanya.glb +0 -0
  5. package/assets/tara.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/AvatarClient.ts +337 -42
  8. package/client/arjun.ts +2 -0
  9. package/client/createAvatar.ts +38 -0
  10. package/client/index.ts +6 -1
  11. package/client/internal.ts +31 -3
  12. package/client/ishita.ts +2 -0
  13. package/client/kabir.ts +2 -0
  14. package/client/meera.ts +2 -0
  15. package/client/naina.ts +2 -0
  16. package/client/playout.ts +95 -0
  17. package/client/supports.ts +28 -0
  18. package/client/three/assets.ts +24 -0
  19. package/client/three/budgets.ts +27 -0
  20. package/client/three/holds.ts +64 -0
  21. package/client/three/internal.ts +54 -0
  22. package/client/three/motion-limits.json +116 -0
  23. package/client/three/sequences.ts +281 -0
  24. package/client/three/tanya.ts +69 -0
  25. package/client/three/tara-rig.ts +1562 -0
  26. package/client/three/tara.ts +76 -0
  27. package/client/three/tushar.ts +65 -0
  28. package/client/types.ts +109 -20
  29. package/client/vikram.ts +2 -0
  30. package/dist/AvatarClient.d.ts +89 -11
  31. package/dist/AvatarClient.d.ts.map +1 -1
  32. package/dist/AvatarClient.js +297 -35
  33. package/dist/AvatarClient.js.map +1 -1
  34. package/dist/arjun.d.ts +1 -0
  35. package/dist/arjun.d.ts.map +1 -1
  36. package/dist/arjun.js +2 -0
  37. package/dist/arjun.js.map +1 -1
  38. package/dist/createAvatar.d.ts +36 -0
  39. package/dist/createAvatar.d.ts.map +1 -1
  40. package/dist/createAvatar.js.map +1 -1
  41. package/dist/index.d.ts +6 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal.d.ts +9 -5
  46. package/dist/internal.d.ts.map +1 -1
  47. package/dist/internal.js +13 -5
  48. package/dist/internal.js.map +1 -1
  49. package/dist/ishita.d.ts +1 -0
  50. package/dist/ishita.d.ts.map +1 -1
  51. package/dist/ishita.js +2 -0
  52. package/dist/ishita.js.map +1 -1
  53. package/dist/kabir.d.ts +1 -0
  54. package/dist/kabir.d.ts.map +1 -1
  55. package/dist/kabir.js +2 -0
  56. package/dist/kabir.js.map +1 -1
  57. package/dist/meera.d.ts +1 -0
  58. package/dist/meera.d.ts.map +1 -1
  59. package/dist/meera.js +2 -0
  60. package/dist/meera.js.map +1 -1
  61. package/dist/naina.d.ts +1 -0
  62. package/dist/naina.d.ts.map +1 -1
  63. package/dist/naina.js +2 -0
  64. package/dist/naina.js.map +1 -1
  65. package/dist/playout.d.ts +42 -0
  66. package/dist/playout.d.ts.map +1 -0
  67. package/dist/playout.js +85 -0
  68. package/dist/playout.js.map +1 -0
  69. package/dist/supports.d.ts +20 -0
  70. package/dist/supports.d.ts.map +1 -0
  71. package/dist/supports.js +26 -0
  72. package/dist/supports.js.map +1 -0
  73. package/dist/three/assets.d.ts +24 -0
  74. package/dist/three/assets.d.ts.map +1 -0
  75. package/dist/three/assets.js +22 -0
  76. package/dist/three/assets.js.map +1 -0
  77. package/dist/three/budgets.d.ts +17 -0
  78. package/dist/three/budgets.d.ts.map +1 -0
  79. package/dist/three/budgets.js +21 -0
  80. package/dist/three/budgets.js.map +1 -0
  81. package/dist/three/holds.d.ts +33 -0
  82. package/dist/three/holds.d.ts.map +1 -0
  83. package/dist/three/holds.js +56 -0
  84. package/dist/three/holds.js.map +1 -0
  85. package/dist/three/internal.d.ts +52 -0
  86. package/dist/three/internal.d.ts.map +1 -0
  87. package/dist/three/internal.js +49 -0
  88. package/dist/three/internal.js.map +1 -0
  89. package/dist/three/motion-limits.json +116 -0
  90. package/dist/three/sequences.d.ts +71 -0
  91. package/dist/three/sequences.d.ts.map +1 -0
  92. package/dist/three/sequences.js +262 -0
  93. package/dist/three/sequences.js.map +1 -0
  94. package/dist/three/tanya.d.ts +35 -0
  95. package/dist/three/tanya.d.ts.map +1 -0
  96. package/dist/three/tanya.js +51 -0
  97. package/dist/three/tanya.js.map +1 -0
  98. package/dist/three/tara-rig.d.ts +403 -0
  99. package/dist/three/tara-rig.d.ts.map +1 -0
  100. package/dist/three/tara-rig.js +1500 -0
  101. package/dist/three/tara-rig.js.map +1 -0
  102. package/dist/three/tara.d.ts +35 -0
  103. package/dist/three/tara.d.ts.map +1 -0
  104. package/dist/three/tara.js +58 -0
  105. package/dist/three/tara.js.map +1 -0
  106. package/dist/three/tushar.d.ts +31 -0
  107. package/dist/three/tushar.d.ts.map +1 -0
  108. package/dist/three/tushar.js +47 -0
  109. package/dist/three/tushar.js.map +1 -0
  110. package/dist/types.d.ts +65 -10
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/types.js +58 -11
  113. package/dist/types.js.map +1 -1
  114. package/dist/vikram.d.ts +1 -0
  115. package/dist/vikram.d.ts.map +1 -1
  116. package/dist/vikram.js +2 -0
  117. package/dist/vikram.js.map +1 -1
  118. package/package.json +34 -6
  119. package/src/avatar.d.ts +59 -2
  120. package/src/avatar.js +743 -94
  121. package/src/behavior.d.ts +28 -12
  122. package/src/behavior.js +54 -42
  123. package/src/canvas/create-rig.js +1 -1
  124. package/src/canvas/src/live.js +2 -2
  125. package/src/conformance.js +1 -1
  126. package/src/emotions.js +4 -2
  127. package/src/gaze.js +334 -73
  128. package/src/head.js +134 -0
  129. package/src/idle.js +210 -19
  130. package/src/interjections.js +70 -26
  131. package/src/prosody.js +647 -0
  132. package/src/visemes.js +85 -15
package/src/prosody.js ADDED
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Speech prosody: what a speaking face does *around* the mouth, read off the
3
+ * same viseme track that drives the mouth, so it can never disagree with it.
4
+ *
5
+ * A talking head is mostly moved by its voice. Head motion alone accounts for
6
+ * over 63 % of the variance in a speaker's F0 (Munhall et al. 2004), its
7
+ * first canonical correlation with prosody runs about 0.7 (Busso et al.
8
+ * 2007), and a face animated with the speaker's own natural head motion is
9
+ * understood better than the same face held still — or moved at double
10
+ * amplitude, which scored no better than none. That last result is the
11
+ * sizing rule for everything here: natural, not more. Research, with the
12
+ * numbers: docs/research-biomechanics.md §3.8.
13
+ *
14
+ * Each behaviour is here because a study found people use it:
15
+ *
16
+ * - A blink at each pause. Speakers' blinks cluster at breakpoints of speech,
17
+ * and listeners entrain to exactly those blinks (Nakano & Kitazawa 2010;
18
+ * research-biomechanics.md §3.7, §5.4). A blink placed at a clause boundary
19
+ * is worth several on a timer, so the idle timer is re-armed each time.
20
+ * - A pose held through each phrase and changed between them. Most of a
21
+ * speaker's head motion is not the beats but a pose held and changed at
22
+ * phrase scale (Hadar et al. 1983's slow band). The change comes *before*
23
+ * the voice — the head moves first at over 70 % of phrase onsets (Graf et
24
+ * al. 2002) — so it is booked inside the pause, with the inbreath: speech
25
+ * breathing is a quick inbreath at the pause and a long outbreath over the
26
+ * phrase (§6.1).
27
+ * - Beats on the prominent vowels, about one a second where the speech has
28
+ * them. Brows up and a head stroke, the brows leading the accented syllable
29
+ * by ~60 ms (Flecha-García 2010). Graf's speaker nodded on most pitch
30
+ * accents and swung to a new position on a fifth of them, mostly in pitch,
31
+ * sometimes diagonally with yaw, and repeated her own motions — so the side
32
+ * a diagonal leans to is kept for the turn. The gate is the point: brow
33
+ * raises placed at random give no benefit (Al Moubayed & Beskow 2009), so a
34
+ * vowel that does not stand out gets no beat at all.
35
+ * - A settle on the last vowel of a phrase: the head comes down a little as
36
+ * a phrase closes (final lowering, §3.4) and *stays* down through the
37
+ * pause, until the next phrase lifts it.
38
+ * - Warmth at the edges of a turn. A smile held through a sentence is
39
+ * discounted as insincere (research-perception.md §3), so this is an
40
+ * episode: it rises as a turn starts and again as it ends, and is gone in
41
+ * between. The mixer withholds it under an emotion whose corners are down.
42
+ *
43
+ * **The head moves by holding, and that is the 2026-09-12 redesign.** Every
44
+ * head behaviour above used to be a continuous envelope — a drift re-aimed each
45
+ * phrase, strokes with counter-lobes, a lead-in dip — summed and low-passed,
46
+ * which drew a head in motion from the first word to the last and read as
47
+ * floating. It is now discrete: the held pose moves on a minimum-jerk path and
48
+ * then is exactly still, and a beat is a pulse on top of it (head.js has the
49
+ * argument). The amplitudes came down with it, to what a webcam shows of a
50
+ * person talking — a few degrees — because they had been sized to the lab's
51
+ * whole-sentence ranges, and on tara, a photograph on shallow geometry, a
52
+ * turn that size is the moment she reads as a cutout.
53
+ *
54
+ * Prominence is estimated from what the cue stream carries. English stress is
55
+ * mostly duration and loudness, and a cue has both: its length, and `i`, which
56
+ * is TTS energy. That is a proxy for the pitch accent the studies measured, so
57
+ * everything here is sized to be missed rather than to be wrong. The one
58
+ * channel family it never writes is the mouth's articulation; lipsync is the
59
+ * headline, and the corners it lifts are the ones visemes never touch.
60
+ *
61
+ * These are speech-rhythm movements, not acknowledgements. A beat is part of
62
+ * how the avatar talks, like the torso moving more while it talks; a nod that
63
+ * *means* yes is still only ever a server action.
64
+ */
65
+
66
+ import { SILENT } from './visemes.js';
67
+ import { HeadPose } from './head.js';
68
+
69
+ // A silence this long is a pause, not the gap between two words. Word gaps in
70
+ // fluent TTS run ~50-200 ms; clause pauses start around a quarter second.
71
+ const PAUSE_MS = 250;
72
+ // Speakers blink ~20-26 times a minute. Clause pauses in fast speech can come
73
+ // closer than that, and a blink at every one reads as nervous.
74
+ const BLINK_MIN_GAP_MS = 1400;
75
+ // Only fire on a pause we arrive at, not one the track was joined midway into.
76
+ const BLINK_FRESH_MS = 150;
77
+
78
+ // The held pose, in pose units at gain 1. What a unit is worth in degrees is
79
+ // the rig's own scaling and is deliberately not repeated here: the figure that
80
+ // used to be, was wrong for the whole of the day the yaw envelope moved.
81
+ // Each phrase picks one, usually on the other side from the last,
82
+ // so consecutive phrases are a visible change of position rather than a
83
+ // wobble about the same one. Yaw is the narrowest in degrees because it is the
84
+ // axis a projected photograph survives least; pitch leans slightly chin-up,
85
+ // which is where a person talking to someone holds their head.
86
+ const POSE = {
87
+ headYaw: [0.12, 0.34],
88
+ headPitch: [-0.20, 0.14],
89
+ headRoll: [0.10, 0.30],
90
+ // The mouth corners take a small share of each pose: a speaking mouth whose
91
+ // corners never move reads as dubbed. They ease rather than move, since a
92
+ // corner that jumps reads as a twitch.
93
+ corner: [0, 0.09],
94
+ cornerTauS: 0.9,
95
+ // **The brows take a level per phrase too, and it is signed.** Measured over
96
+ // the audit's 56 s turn, `browRaiseL` sat at exactly 0.000 for 84% of frames
97
+ // and never once went negative: between beats the upper face was pinned at
98
+ // the floor of its range. Two video reviewers named that stillness — "the
99
+ // eyebrows, forehead, and the muscles around the eyes do not move" — as the
100
+ // single biggest reason these faces read as uncanny, ahead of the mouth. The
101
+ // corners above already had a held level and measured 0.1%, so the brows
102
+ // were the channel with no *pose*, not the channel with no events.
103
+ //
104
+ // Signed is the half that carries it. `browRaise` is the one channel the
105
+ // Blender rigs split by sign into two photographed maps — a raise above zero
106
+ // and a knit below it — so a phrase sitting negative is the only route a
107
+ // real furrow has into a sentence. Pinned at zero, half the asset was
108
+ // unreachable by construction. The floor is `thoughtful`'s own -0.14, which
109
+ // tara-rig's knit weight already cites as a third of the crease; the ceiling
110
+ // stays well under a beat's 0.34 so a held level is never read as an accent.
111
+ brow: [-0.14, 0.18],
112
+ browTauS: 0.8,
113
+ // One brow leads, the way BEAT.browAmp's 0.34/0.30 already has it: a matched
114
+ // pair is the drawing, not the face.
115
+ browAsym: 0.82,
116
+ switchP: 0.7,
117
+ // A phrase with no pause long enough to book a move keeps its pose, unless
118
+ // the pose is this old: then the phrase takes a new one as it starts.
119
+ holdMs: 3200,
120
+ };
121
+
122
+ // The move into a new phrase's pose. It starts inside the pause and lands
123
+ // around the first syllable, and only a pause this long books one — a shorter
124
+ // one is a breath between clauses, not a new thought.
125
+ const ONSET = { minPauseMs: 400, leadMs: 280, durMs: 380 };
126
+ // The inbreath: fast in across the pause's end, then let out over the phrase.
127
+ // The idle layer's quiet breathing steps back while speech runs (idle.js), so
128
+ // this is the breath a talking body shows rather than one on top of it.
129
+ const INHALE = [[-420, 0], [-140, 1], [700, 0.55], [2200, 0]];
130
+ const INHALE_AMP = 0.7;
131
+
132
+ const BEAT = {
133
+ // The open vowels carry the energy, and the jaw drop that a stressed vowel
134
+ // gets; the closures and the narrow shapes are never the peak.
135
+ vowels: new Set(['C', 'D', 'E']),
136
+ // Measured over a 42 s track of real cue data, a stricter gate made one beat
137
+ // per 4.2 s of voiced speech, where accents come about once a second; the
138
+ // median phrase on that track is 1.26 s, so a long floor disqualified a
139
+ // third of the phrases outright.
140
+ minPhraseMs: 600,
141
+ minVowels: 2,
142
+ minDurMs: 100,
143
+ // How far above the phrase's mean a vowel must stand to count as prominent,
144
+ // and it must also be the strongest within `localMs` either side of it. A
145
+ // phrase of equal vowels still gets nothing.
146
+ salience: 1.05,
147
+ localMs: 380,
148
+ // Beats at least this far apart; closer and they read as a metronome, and
149
+ // their stroke frequency climbs past the 1.5 Hz line (CLAUDE.md).
150
+ gapMs: 620,
151
+ // Each vowel is weighed once, as it enters this window ahead of the clock:
152
+ // late enough that the cues around it have arrived, early enough for the
153
+ // head stroke's run-up.
154
+ commitMs: 340,
155
+ windowMs: 180,
156
+ // Relative to the vowel onset. The brows hold ~150 ms and come down over
157
+ // ~300 ms, the slower release of a real frontalis, and the upper lids lift
158
+ // a little with them — a brow flash that leaves the lids is a frown's half.
159
+ brow: [[-140, 0], [-40, 1], [110, 1], [410, 0]],
160
+ browAmp: [0.34, 0.30],
161
+ lidAmp: -0.05,
162
+ // **How big a beat is, the syllable decides.** Over a 20.7 s turn of real
163
+ // cue data every beat was authored at exactly 0.340 — 55 of them across five
164
+ // seeds, sd 0.000 — while the loudness of the vowels that won them ran 0.51
165
+ // to 1.00. A vowel can win on length alone, since `score` is duration times
166
+ // loudness, so a quiet long syllable drew the same flash as a shouted one. A
167
+ // face whose every accent is the same size is the "repetitive, like a
168
+ // puppet" read the mouth already had before `shapeFor` scaled it by this
169
+ // same `i`; nothing scaled these.
170
+ //
171
+ // It varies *upward*, because the complaint underneath this one is a still
172
+ // upper face: no beat may shrink. The quiet end lands at 0.349, above the
173
+ // 0.340 every beat used to take, and the range runs to 0.408. That is also
174
+ // what keeps the line faces safe — `browPath` lifts a brow 15 units of an
175
+ // 800-unit frame, so a beat is only ~0.8 px at the 130 px acceptance size,
176
+ // and the mouth's own `0.45 + 0.55i` would have halved it into nothing. The
177
+ // ceiling is the asset's: tara's raise map reads `browRaise / 0.6` and
178
+ // clamps, so under `POSE.brow`'s 0.18 held level a gain past ~1.24 buys
179
+ // light that is already saturated.
180
+ browGain: [0.85, 0.35],
181
+ // **A brow has more than one shape.** Measured over a speaking turn,
182
+ // `browAngleL` and `browInnerL` once moved exactly 0.000: every beat drew the
183
+ // same symmetric raise. The plain raise stays the common one; the inner lift
184
+ // is AU1, the appeal, and deliberately the rarest, because a face that keeps
185
+ // lifting its inner brows reads as worried; the outer-end tilt exists so two
186
+ // beats in a row are not the same drawing. Shapes of one event, on one
187
+ // envelope.
188
+ browForms: [
189
+ { p: 0.60, inner: 0.00, angle: 0.00 },
190
+ { p: 0.25, inner: 0.26, angle: 0.00 },
191
+ { p: 0.15, inner: 0.00, angle: 0.30 },
192
+ ],
193
+ // What the head does with a beat. A swing is Graf's one-way movement — the
194
+ // beat taken as a move to a new pose — and is only offered once the pose has
195
+ // been held a while, so a phrase does not change position twice in a breath.
196
+ // Of the rest most are a nod and some leave the head alone: a beat on every
197
+ // accent is a metronome, and the brows still carry it.
198
+ swingP: 0.3,
199
+ swingAfterMs: 1800,
200
+ swingMs: 320,
201
+ nodP: 0.7,
202
+ // The nod: down and back, over by 0.4 s, and nothing after it. It peaks just
203
+ // ahead of the vowel so that, through the head's 160 ms smoothing, it lands on
204
+ // it. About 2.5° asked of tara, of which the smoothing renders most.
205
+ 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,
208
+ yaw: 0.12,
209
+ };
210
+
211
+ // **The body's share of a beat.** A speaker's trunk is not decorative on an
212
+ // accent: emphasis travels down, the shoulders lift and set slightly on a
213
+ // stressed syllable, and a phrase boundary is where posture resets (Hadar et
214
+ // al. 1983 tied the pattern of movement against stillness to juncture). It is
215
+ // not a gesture — there is no arm — but the head's stroke arriving late and
216
+ // small at the shoulders, which `params.TAU` supplies for free by chasing the
217
+ // shoulders at 0.19 s and the lean at 0.24 s. Each stroke returns through
218
+ // neutral: a trunk that only ever lifts is a standing shrug, which is what a
219
+ // filmstrip once showed as the figure sitting larger and lower in frame.
220
+ const BEAT_BODY = {
221
+ keys: [[-380, 0], [-60, 1], [140, 0.25], [320, -0.30], [600, 0]],
222
+ shoulder: 0.28, lean: 0.11, spread: 0.35,
223
+ };
224
+ // The lift a phrase carries, over its own span: up on the first stressed
225
+ // syllable and settling below where it started as the breath goes out.
226
+ const PHRASE_BODY = {
227
+ keys: [[-260, 0], [180, 1], [900, 0.5], [1500, -0.30], [2400, 0]],
228
+ shoulder: 0.22,
229
+ lean: 0.16,
230
+ };
231
+
232
+ // The phrase-final settle: chin down this far from the phrase's own pose,
233
+ // held until the next phrase lifts it. Pitch only, and slower than a nod — it
234
+ // closes a phrase rather than marking a word.
235
+ const SETTLE = { pitch: 0.07, lead: 60, durMs: 360, gapMs: 520 };
236
+
237
+ // A turn that ends, or a clip that takes the head, sends it home this fast.
238
+ const HOME_MS = 700;
239
+
240
+ // Warmth: corners, and the squint that makes them a smile rather than a mask
241
+ // (emotions.js). A smile that never varies is discounted as insincere
242
+ // (research-perception.md §3), so warmth only ever comes as an episode, with
243
+ // an onset, a hold and an offset, and each is tied to something that
244
+ // happened: the reply's first words (`onset`, from the first voiced cue), the
245
+ // reply played out to its end (`close`), and an acknowledgement the server
246
+ // sent (`ack`). Felt smiles run from about two-thirds of a second to four
247
+ // (Ekman & Friesen 1982), and a listener's smile is itself a backchannel
248
+ // (Brunner 1979).
249
+ //
250
+ // `close` is the face the user sees as the floor passes to them, so it is the
251
+ // biggest and the longest: full for 2.6 s, gone by 3.8 s, seen while they
252
+ // start to talk and gone before it could read as fixed. It used to be 0.18
253
+ // held 1.2 s, which on the Blender faces barely lifted the corners before it
254
+ // went; judged at crop on tara and tushar, 0.36 is the first height that
255
+ // reads as a smile rather than a resting mouth, and still well under the
256
+ // happy states' 0.48–0.58. `onset` stays small, because the visemes are about
257
+ // to take the mouth.
258
+ // `ack` rises with the nod and outlasts it, since a smile that ends with the
259
+ // head's last beat reads as part of the gesture rather than as pleasure.
260
+ // Times in ms.
261
+ const WARMTH = {
262
+ onset: { keys: [[0, 0], [350, 1], [1500, 1], [2800, 0]], corner: 0.18, squint: 0.08 },
263
+ close: { keys: [[0, 0], [300, 1], [2600, 1], [3800, 0]], corner: 0.36, squint: 0.14 },
264
+ ack: { keys: [[0, 0], [220, 1], [1100, 1], [2000, 0]], corner: 0.30, squint: 0.12 },
265
+ };
266
+ // A continuer can come every second or two. Each one smiling would hold the
267
+ // smile up for as long as the user talks, which is the fixed smile again, so
268
+ // an acknowledgement inside this long of the last smiling one nods without it.
269
+ const ACK_SMILE_REST_MS = 4000;
270
+
271
+ /**
272
+ * What a rig renders of the head amplitudes above, unless it says otherwise.
273
+ *
274
+ * **A pose unit is not a shared quantity.** On tara it is an angle; on an SVG
275
+ * face it is a pixel count — `peep`'s head travels about 17 px per unit of
276
+ * pitch on a 130 px drawing. A rig passes its own `prosodyHeadGain` (tara: 1);
277
+ * one that has not been measured renders this share. It was held at 0.3 while
278
+ * the amplitudes here were sized to the lab's ranges, which scaled peep's
279
+ * speaking head to 20 px; sized to the medium they are small enough to render
280
+ * whole, and the SVG faces now take the same hold-and-move head as tara —
281
+ * unjudged by eye on them, which is the open item this number stands for.
282
+ */
283
+ export const UNCALIBRATED_HEAD_GAIN = 1;
284
+
285
+ function envelope(t, keys) {
286
+ if (t <= keys[0][0] || t >= keys[keys.length - 1][0]) return 0;
287
+ for (let k = 1; k < keys.length; k++) {
288
+ const [t1, v1] = keys[k];
289
+ if (t <= t1) {
290
+ const [t0, v0] = keys[k - 1];
291
+ return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
292
+ }
293
+ }
294
+ return 0;
295
+ }
296
+
297
+ const endOf = (keys) => keys[keys.length - 1][0];
298
+ const rand = ([a, b]) => a + Math.random() * (b - a);
299
+ const side = () => (Math.random() < 0.5 ? -1 : 1);
300
+
301
+ const isPause = (cues, j) =>
302
+ cues[j].v === SILENT && (j + 1 >= cues.length || cues[j + 1].t - cues[j].t >= PAUSE_MS);
303
+
304
+ /** Channels this layer writes, besides the `blink` flag. */
305
+ export const PROSODY_CHANNELS = [
306
+ 'headPitch', 'headYaw', 'headRoll',
307
+ 'browRaiseL', 'browRaiseR', 'browInnerL', 'browInnerR',
308
+ 'browAngleL', 'browAngleR', 'lidL', 'lidR',
309
+ 'mouthCornerL', 'mouthCornerR', 'squintL', 'squintR',
310
+ 'breath',
311
+ 'shoulderL', 'shoulderR', 'torsoLean',
312
+ ];
313
+
314
+ export class SpeechProsody {
315
+ constructor(opts = {}) {
316
+ // Moves are timed on this layer's own clock, not the track's: a turn's
317
+ // clock restarts, and the tails of a settle or a closing smile outlive the
318
+ // track they came from.
319
+ this._ms = 0;
320
+ this._moves = [];
321
+ this._warm = [];
322
+ this._lastAckSmile = -Infinity;
323
+ this._head = new HeadPose();
324
+ this._home = true;
325
+ this._corner = 0;
326
+ this._cornerTo = 0;
327
+ this._brow = 0;
328
+ this._browTo = 0;
329
+ // See `_drawBrow`. Defaulted to the range every face has always drawn from
330
+ // with no band skipped, so a seeded reel of an SVG or Canvas face draws the
331
+ // same level from the same `Math.random` call as before.
332
+ this._brows = { range: POSE.brow, floor: 0, forms: BEAT.browForms, ...opts.brows };
333
+ this._side = side();
334
+ this.reset();
335
+ }
336
+
337
+ /**
338
+ * Forget the utterance: its times are offsets into a clock that restarts.
339
+ * `newTurn` false is the same turn re-spoken on the same clock (the accurate
340
+ * leg splicing), and everything already weighed stays weighed.
341
+ */
342
+ reset(newTurn = true) {
343
+ if (!newTurn) return;
344
+ this._blinkKey = null;
345
+ this._lastBlink = -Infinity;
346
+ this._resumeKey = null;
347
+ this._bookedKey = null;
348
+ this._scanT = -Infinity;
349
+ this._lastBeat = -Infinity;
350
+ this._lastMove = -Infinity;
351
+ this._phraseKey = null;
352
+ this._poseAt = -Infinity;
353
+ this._phrasePitch = 0;
354
+ this._onsetPending = true;
355
+ // The side a diagonal beat leans to, and which way roll couples to yaw,
356
+ // are the speaker's habit for the turn (Graf: motions repeat).
357
+ this._beatSide = side();
358
+ this._rollSide = side();
359
+ }
360
+
361
+ /** The track played out to its end: the turn's closing warmth. */
362
+ closeTurn() {
363
+ this._warm.push({ at: this._ms, w: WARMTH.close });
364
+ }
365
+
366
+ /**
367
+ * The server acknowledged what the user is saying: the smile that goes with
368
+ * it. Here rather than in the clip because it has to outlast the nod, and
369
+ * because it is one smile with the turn-edge warmth when the two meet.
370
+ */
371
+ acknowledge() {
372
+ if (this._ms - this._lastAckSmile < ACK_SMILE_REST_MS) return;
373
+ this._lastAckSmile = this._ms;
374
+ this._warm.push({ at: this._ms, w: WARMTH.ack });
375
+ }
376
+
377
+ /** Cut off: a smile that survives being interrupted has not noticed. */
378
+ cool() {
379
+ this._warm = [];
380
+ }
381
+
382
+ /**
383
+ * @param {{cues: {t:number, v:string, i?:number}[], now: number, index: number}} track
384
+ * the viseme track, already sampled this frame
385
+ * @param {boolean} active speech owns the mouth and no clip is gesturing
386
+ * @param {number} [dt] seconds since the last frame
387
+ * @returns {Record<string, number> & {blink: boolean}} additive deltas on
388
+ * PROSODY_CHANNELS, at gain 1
389
+ */
390
+ update(track, active, dt = 1 / 60) {
391
+ this._ms += dt * 1000;
392
+ const out = { blink: false };
393
+ for (const c of PROSODY_CHANNELS) out[c] = 0;
394
+
395
+ if (active && track.cues.length) {
396
+ this._home = false;
397
+ this._listen(track, out);
398
+ } else {
399
+ // Beats belong to the words: an interrupted or gesture-covered turn
400
+ // drops the ones still to come, and the head goes home. Tails play out.
401
+ this._moves = this._moves.filter((m) => m.keep);
402
+ this._cornerTo = 0;
403
+ this._browTo = 0;
404
+ if (!this._home) {
405
+ this._home = true;
406
+ this._head.cancelStrokes(this._ms);
407
+ this._head.moveTo({ headYaw: 0, headPitch: 0, headRoll: 0 }, this._ms, HOME_MS);
408
+ }
409
+ }
410
+
411
+ const t = this._ms;
412
+ const h = this._head.sample(t);
413
+ out.headYaw += h.headYaw;
414
+ out.headPitch += h.headPitch;
415
+ out.headRoll += h.headRoll;
416
+ // Where the head is turned, without the strokes on it. The trunk follows
417
+ // it — a phrase's pose recruits the body, a nod does not — and the mixer
418
+ // budgets against it, for the same division: what the head *holds* is what
419
+ // has to stay inside a face's measured range, and a stroke is forgiven a
420
+ // peak because it is over before it is read as a pose.
421
+ out.hold = this._head.held(t);
422
+ out.trunkYaw = out.hold.headYaw;
423
+ this._corner += (this._cornerTo - this._corner) * (1 - Math.exp(-dt / POSE.cornerTauS));
424
+ out.mouthCornerL += this._corner;
425
+ out.mouthCornerR += this._corner;
426
+ // Eased rather than moved: a brow that steps to its phrase's level reads as
427
+ // a beat, and the beats are already on top of this.
428
+ this._brow += (this._browTo - this._brow) * (1 - Math.exp(-dt / POSE.browTauS));
429
+ out.browRaiseL += this._brow;
430
+ out.browRaiseR += this._brow * POSE.browAsym;
431
+
432
+ this._moves = this._moves.filter((m) => t - m.at < endOf(m.keys));
433
+ for (const m of this._moves) {
434
+ const e = envelope(t - m.at, m.keys);
435
+ if (e) for (const c in m.add) out[c] += e * m.add[c];
436
+ }
437
+ // Two warmth episodes that meet are one smile, not a bigger one.
438
+ this._warm = this._warm.filter((m) => t - m.at < endOf(m.w.keys));
439
+ let corner = 0;
440
+ let squint = 0;
441
+ for (const m of this._warm) {
442
+ const e = envelope(t - m.at, m.w.keys);
443
+ corner = Math.max(corner, e * m.w.corner);
444
+ squint = Math.max(squint, e * m.w.squint);
445
+ }
446
+ out.mouthCornerL += corner;
447
+ out.mouthCornerR += corner;
448
+ out.squintL += squint;
449
+ out.squintR += squint;
450
+ return out;
451
+ }
452
+
453
+ _listen(track, out) {
454
+ const { cues, now } = track;
455
+ const i = Math.min(track.index, cues.length - 1);
456
+ const cue = cues[i];
457
+ if (cue.t > now) return;
458
+ // Converts a time on the track's clock to one on this layer's.
459
+ const at = (ms) => this._ms + (ms - now);
460
+
461
+ // The pause blink.
462
+ if (isPause(cues, i) && cue.t !== this._blinkKey) {
463
+ this._blinkKey = cue.t;
464
+ if (now - cue.t < BLINK_FRESH_MS && now - this._lastBlink >= BLINK_MIN_GAP_MS) {
465
+ this._lastBlink = now;
466
+ out.blink = true;
467
+ }
468
+ }
469
+
470
+ // The next phrase's pose and the inbreath: booked inside a long pause,
471
+ // played through the resume.
472
+ if (cue.v === SILENT && i + 1 < cues.length && cue.t !== this._resumeKey
473
+ && cues[i + 1].t - cue.t >= ONSET.minPauseMs) {
474
+ this._resumeKey = cue.t;
475
+ this._bookedKey = cues[i + 1].t;
476
+ this._pose(at(cues[i + 1].t) - ONSET.leadMs, ONSET.durMs);
477
+ this._moves.push({ at: at(cues[i + 1].t), keys: INHALE, add: { breath: INHALE_AMP } });
478
+ }
479
+
480
+ if (cue.v !== SILENT) {
481
+ if (this._onsetPending) {
482
+ this._onsetPending = false;
483
+ this._warm.push({ at: this._ms, w: WARMTH.onset });
484
+ }
485
+ let a = i;
486
+ while (a > 0 && !isPause(cues, a - 1)) a--;
487
+ if (cues[a].t !== this._phraseKey) {
488
+ this._phraseKey = cues[a].t;
489
+ // A phrase nothing was booked for: the turn's first words, or a
490
+ // phrase after a pause too short to book a move in. An old pose
491
+ // changes as it starts; a recent one just lifts out of its settle.
492
+ if (this._bookedKey !== cues[a].t) {
493
+ if (this._ms - this._poseAt >= POSE.holdMs) this._pose(this._ms, ONSET.durMs);
494
+ else this._head.moveTo({ headPitch: this._phrasePitch }, this._ms, ONSET.durMs);
495
+ }
496
+ // The postural lift belongs to the sentence.
497
+ this._moves.push({
498
+ at: at(cues[a].t), keys: PHRASE_BODY.keys,
499
+ add: {
500
+ shoulderL: PHRASE_BODY.shoulder, shoulderR: PHRASE_BODY.shoulder,
501
+ torsoLean: PHRASE_BODY.lean,
502
+ },
503
+ });
504
+ }
505
+ }
506
+
507
+ // Every vowel is weighed once, as it comes into the commit window. One
508
+ // that arrives already inside it is let go: a late stroke is worse than
509
+ // none.
510
+ for (let j = i; j + 1 < cues.length && cues[j].t - now <= BEAT.commitMs + BEAT.windowMs; j++) {
511
+ const c = cues[j];
512
+ if (c.t <= this._scanT || !BEAT.vowels.has(c.v)) continue;
513
+ this._scanT = c.t;
514
+ if (c.t - now >= BEAT.commitMs) this._weigh(cues, j, at);
515
+ }
516
+ }
517
+
518
+ // A new pose for the head, starting at `at` on this layer's clock. The next
519
+ // phrase usually turns the other way.
520
+ _pose(at, dur) {
521
+ this._poseAt = at;
522
+ if (Math.random() < POSE.switchP) this._side = -this._side;
523
+ const s = this._side;
524
+ this._phrasePitch = rand(POSE.headPitch);
525
+ this._head.moveTo({
526
+ headYaw: s * rand(POSE.headYaw),
527
+ headPitch: this._phrasePitch,
528
+ headRoll: s * this._rollSide * rand(POSE.headRoll),
529
+ }, at, dur);
530
+ this._cornerTo = rand(POSE.corner);
531
+ this._browTo = this._drawBrow();
532
+ }
533
+
534
+ /**
535
+ * The level this phrase holds its brows at.
536
+ *
537
+ * `floor` is a band about zero the draw skips. `browRaise` is the one channel
538
+ * the Blender rigs split by sign into two photographed maps — a raise above
539
+ * zero, a knit below it — and *both* read as nothing near zero, so a range
540
+ * straddling it spends most of its phrases in a dead band where the upper
541
+ * face recruits no light at all. That is the deadpan: not too few events, but
542
+ * a pose that is usually nowhere. Skipping the band makes each phrase commit
543
+ * to a furrow or a lift.
544
+ *
545
+ * It is a floor and not a bigger range on purpose. What the prohibitions here
546
+ * turn on is *hold*, not size — a held inner lift reads as worried, a held
547
+ * outer kink as a smirk — and this changes only how far the brows travel
548
+ * between one phrase and the next, never how long any shape is kept.
549
+ *
550
+ * The two sides keep their widths' share of the draw rather than collapsing
551
+ * onto ±floor: a skipped band must not become a pair of repeated values.
552
+ */
553
+ _drawBrow() {
554
+ const [a, b] = this._brows.range;
555
+ const f = this._brows.floor;
556
+ const lo = Math.max(0, -f - a);
557
+ const hi = Math.max(0, b - f);
558
+ if (!(f > 0) || lo + hi <= 0) return rand(this._brows.range);
559
+ return Math.random() * (lo + hi) < lo ? rand([a, -f]) : rand([f, b]);
560
+ }
561
+
562
+ // Decides whether vowel `j` carries a beat or closes its phrase.
563
+ _weigh(cues, j, at) {
564
+ let a = j;
565
+ while (a > 0 && !isPause(cues, a - 1)) a--;
566
+ let b = j;
567
+ while (b + 1 < cues.length && !isPause(cues, b)) b++;
568
+ const complete = isPause(cues, b);
569
+ const span = cues[b].t - cues[a].t;
570
+ const vowels = [];
571
+ for (let k = a; k < b; k++) {
572
+ const c = cues[k];
573
+ if (!BEAT.vowels.has(c.v)) continue;
574
+ const dur = cues[k + 1].t - c.t;
575
+ // `score` ranks, `i` sizes. They are kept apart deliberately: a missing
576
+ // loudness still scores as a full one so selection is unchanged, but it
577
+ // leaves `i` null so the beat below stays exactly as it was authored.
578
+ vowels.push({ t: c.t, dur, i: c.i, score: dur * (c.i == null ? 1 : c.i) });
579
+ }
580
+ const v = vowels.find((x) => x.t === cues[j].t);
581
+
582
+ // The last vowel before a pause is long because the phrase is ending, not
583
+ // because it is stressed: final lengthening marks a boundary, and scoring
584
+ // it would put a beat on the last word of nearly every phrase. It gets the
585
+ // settle instead.
586
+ if (complete && v === vowels[vowels.length - 1]) {
587
+ if (span >= BEAT.minPhraseMs && v.t - this._lastMove >= SETTLE.gapMs) {
588
+ this._lastMove = v.t;
589
+ this._head.moveTo({ headPitch: this._phrasePitch + SETTLE.pitch }, at(v.t) - SETTLE.lead, SETTLE.durMs);
590
+ }
591
+ return;
592
+ }
593
+ if (complete) vowels.pop();
594
+
595
+ if (span < BEAT.minPhraseMs || vowels.length < BEAT.minVowels) return;
596
+ const mean = vowels.reduce((s, x) => s + x.score, 0) / vowels.length;
597
+ if (v.dur < BEAT.minDurMs || v.score < BEAT.salience * mean) return;
598
+ if (vowels.some((x) => x !== v && Math.abs(x.t - v.t) < BEAT.localMs && x.score > v.score)) return;
599
+ if (v.t - this._lastBeat < BEAT.gapMs) return;
600
+ this._lastBeat = this._lastMove = v.t;
601
+
602
+ let yaw = 0;
603
+ const vt = at(v.t);
604
+ if (vt - this._poseAt >= BEAT.swingAfterMs && Math.random() < BEAT.swingP) {
605
+ this._pose(vt - BEAT.nod.lead, BEAT.swingMs);
606
+ yaw = this._head.aim.headYaw;
607
+ } else if (Math.random() < BEAT.nodP) {
608
+ const add = { headPitch: BEAT.nod.pitch };
609
+ if (Math.random() < BEAT.yawP) add.headYaw = yaw = this._beatSide * BEAT.yaw;
610
+ this._head.stroke(add, vt - BEAT.nod.lead, BEAT.nod.attack, BEAT.nod.release);
611
+ }
612
+
613
+ const forms = this._brows.forms;
614
+ let br = Math.random() * forms.reduce((s, f) => s + f.p, 0);
615
+ const bf = forms.find((f) => (br -= f.p) < 0) || forms[0];
616
+ // The whole gesture takes the syllable's size, not the raise alone: the
617
+ // form's parts keep their proportions to each other, so a loud beat is the
618
+ // same drawing bigger rather than a different shape.
619
+ const g = v.i == null ? 1 : BEAT.browGain[0] + BEAT.browGain[1] * v.i;
620
+ this._moves.push({
621
+ at: vt, keys: BEAT.brow,
622
+ add: {
623
+ browRaiseL: BEAT.browAmp[0] * g, browRaiseR: BEAT.browAmp[1] * g,
624
+ browInnerL: bf.inner * g, browInnerR: bf.inner * g,
625
+ // The tilt is the one asymmetric thing here: both outer ends go up on
626
+ // the side the beat already leans to, which is a shade rather than the
627
+ // matched pair a symmetric lift would draw.
628
+ browAngleL: bf.angle * g * (this._beatSide < 0 ? 1 : 0.55),
629
+ browAngleR: bf.angle * g * (this._beatSide > 0 ? 1 : 0.55),
630
+ lidL: BEAT.lidAmp * g, lidR: BEAT.lidAmp * g,
631
+ },
632
+ });
633
+ // The trunk takes the same stroke. The two shoulders are deliberately
634
+ // unequal — `spread` puts more of it on the side the head is going, so a
635
+ // diagonal beat reads as one body turning rather than two shoulders
636
+ // shrugging in unison, which is the tell of a rig with no spine.
637
+ const lead = yaw < 0 ? -1 : 1;
638
+ this._moves.push({
639
+ at: vt, keys: BEAT_BODY.keys,
640
+ add: {
641
+ shoulderL: BEAT_BODY.shoulder * (1 + lead * BEAT_BODY.spread),
642
+ shoulderR: BEAT_BODY.shoulder * (1 - lead * BEAT_BODY.spread),
643
+ torsoLean: BEAT_BODY.lean,
644
+ },
645
+ });
646
+ }
647
+ }