@voqalize/avatar 0.3.0 → 0.4.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.
- package/LICENSE-CC-BY-4.0 +396 -0
- package/README.md +65 -17
- package/assets/README.md +48 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/AvatarClient.ts +337 -42
- package/client/arjun.ts +2 -0
- package/client/createAvatar.ts +38 -0
- package/client/index.ts +6 -1
- package/client/internal.ts +31 -3
- package/client/ishita.ts +2 -0
- package/client/kabir.ts +2 -0
- package/client/meera.ts +2 -0
- package/client/naina.ts +2 -0
- package/client/playout.ts +95 -0
- package/client/supports.ts +28 -0
- package/client/three/assets.ts +22 -0
- package/client/three/budgets.ts +27 -0
- package/client/three/holds.ts +64 -0
- package/client/three/internal.ts +54 -0
- package/client/three/motion-limits.json +116 -0
- package/client/three/sequences.ts +281 -0
- package/client/three/tanya-asset.ts +6 -0
- package/client/three/tanya.ts +69 -0
- package/client/three/tara-asset.ts +25 -0
- package/client/three/tara-rig.ts +1571 -0
- package/client/three/tara.ts +77 -0
- package/client/three/tushar-asset.ts +6 -0
- package/client/three/tushar.ts +65 -0
- package/client/types.ts +109 -20
- package/client/vikram.ts +2 -0
- package/dist/AvatarClient.d.ts +89 -11
- package/dist/AvatarClient.d.ts.map +1 -1
- package/dist/AvatarClient.js +297 -35
- package/dist/AvatarClient.js.map +1 -1
- package/dist/arjun.d.ts +1 -0
- package/dist/arjun.d.ts.map +1 -1
- package/dist/arjun.js +2 -0
- package/dist/arjun.js.map +1 -1
- package/dist/createAvatar.d.ts +36 -0
- package/dist/createAvatar.d.ts.map +1 -1
- package/dist/createAvatar.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +9 -5
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +13 -5
- package/dist/internal.js.map +1 -1
- package/dist/ishita.d.ts +1 -0
- package/dist/ishita.d.ts.map +1 -1
- package/dist/ishita.js +2 -0
- package/dist/ishita.js.map +1 -1
- package/dist/kabir.d.ts +1 -0
- package/dist/kabir.d.ts.map +1 -1
- package/dist/kabir.js +2 -0
- package/dist/kabir.js.map +1 -1
- package/dist/meera.d.ts +1 -0
- package/dist/meera.d.ts.map +1 -1
- package/dist/meera.js +2 -0
- package/dist/meera.js.map +1 -1
- package/dist/naina.d.ts +1 -0
- package/dist/naina.d.ts.map +1 -1
- package/dist/naina.js +2 -0
- package/dist/naina.js.map +1 -1
- package/dist/playout.d.ts +42 -0
- package/dist/playout.d.ts.map +1 -0
- package/dist/playout.js +85 -0
- package/dist/playout.js.map +1 -0
- package/dist/supports.d.ts +20 -0
- package/dist/supports.d.ts.map +1 -0
- package/dist/supports.js +26 -0
- package/dist/supports.js.map +1 -0
- package/dist/three/assets.d.ts +8 -0
- package/dist/three/assets.d.ts.map +1 -0
- package/dist/three/assets.js +19 -0
- package/dist/three/assets.js.map +1 -0
- package/dist/three/budgets.d.ts +17 -0
- package/dist/three/budgets.d.ts.map +1 -0
- package/dist/three/budgets.js +21 -0
- package/dist/three/budgets.js.map +1 -0
- package/dist/three/holds.d.ts +33 -0
- package/dist/three/holds.d.ts.map +1 -0
- package/dist/three/holds.js +56 -0
- package/dist/three/holds.js.map +1 -0
- package/dist/three/internal.d.ts +52 -0
- package/dist/three/internal.d.ts.map +1 -0
- package/dist/three/internal.js +49 -0
- package/dist/three/internal.js.map +1 -0
- package/dist/three/motion-limits.json +116 -0
- package/dist/three/sequences.d.ts +71 -0
- package/dist/three/sequences.d.ts.map +1 -0
- package/dist/three/sequences.js +262 -0
- package/dist/three/sequences.js.map +1 -0
- package/dist/three/tanya-asset.d.ts +7 -0
- package/dist/three/tanya-asset.d.ts.map +1 -0
- package/dist/three/tanya-asset.js +7 -0
- package/dist/three/tanya-asset.js.map +1 -0
- package/dist/three/tanya.d.ts +35 -0
- package/dist/three/tanya.d.ts.map +1 -0
- package/dist/three/tanya.js +51 -0
- package/dist/three/tanya.js.map +1 -0
- package/dist/three/tara-asset.d.ts +26 -0
- package/dist/three/tara-asset.d.ts.map +1 -0
- package/dist/three/tara-asset.js +26 -0
- package/dist/three/tara-asset.js.map +1 -0
- package/dist/three/tara-rig.d.ts +409 -0
- package/dist/three/tara-rig.d.ts.map +1 -0
- package/dist/three/tara-rig.js +1504 -0
- package/dist/three/tara-rig.js.map +1 -0
- package/dist/three/tara.d.ts +35 -0
- package/dist/three/tara.d.ts.map +1 -0
- package/dist/three/tara.js +59 -0
- package/dist/three/tara.js.map +1 -0
- package/dist/three/tushar-asset.d.ts +7 -0
- package/dist/three/tushar-asset.d.ts.map +1 -0
- package/dist/three/tushar-asset.js +7 -0
- package/dist/three/tushar-asset.js.map +1 -0
- package/dist/three/tushar.d.ts +31 -0
- package/dist/three/tushar.d.ts.map +1 -0
- package/dist/three/tushar.js +47 -0
- package/dist/three/tushar.js.map +1 -0
- package/dist/types.d.ts +65 -10
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +58 -11
- package/dist/types.js.map +1 -1
- package/dist/vikram.d.ts +1 -0
- package/dist/vikram.d.ts.map +1 -1
- package/dist/vikram.js +2 -0
- package/dist/vikram.js.map +1 -1
- package/package.json +34 -6
- package/src/avatar.d.ts +59 -2
- package/src/avatar.js +743 -94
- package/src/behavior.d.ts +28 -12
- package/src/behavior.js +54 -42
- package/src/canvas/create-rig.js +1 -1
- package/src/canvas/src/live.js +2 -2
- package/src/conformance.js +1 -1
- package/src/emotions.js +4 -2
- package/src/gaze.js +334 -73
- package/src/head.js +134 -0
- package/src/idle.js +210 -19
- package/src/interjections.js +70 -26
- package/src/prosody.js +647 -0
- 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
|
+
}
|