@voqalize/avatar 0.4.4 → 0.5.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.
- package/README.md +65 -154
- package/dist/index.d.ts +37 -26
- package/dist/index.js +48 -25
- package/dist/pin.d.ts +2 -0
- package/dist/pin.js +3 -0
- package/dist/react.d.ts +21 -6
- package/dist/react.js +24 -6
- package/package.json +8 -67
- package/LICENSE-APACHE-2.0 +0 -201
- package/LICENSE-CC-BY-4.0 +0 -396
- package/assets/README.md +0 -60
- package/assets/tanvi.glb +0 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tess.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/Avatar.tsx +0 -57
- package/client/AvatarClient.ts +0 -818
- package/client/createAvatar.ts +0 -177
- package/client/index.ts +0 -44
- package/client/internal.ts +0 -132
- package/client/playout.ts +0 -95
- package/client/react.ts +0 -13
- package/client/supports.ts +0 -27
- package/client/three/assets.ts +0 -26
- package/client/three/budgets.ts +0 -27
- package/client/three/character-rig.ts +0 -1746
- package/client/three/createCharacter.ts +0 -109
- package/client/three/holds.ts +0 -69
- package/client/three/internal.ts +0 -55
- package/client/three/motion-limits.json +0 -124
- package/client/three/sequences.ts +0 -281
- package/client/three/tanvi-asset.ts +0 -6
- package/client/three/tanvi.ts +0 -30
- package/client/three/tanya-asset.ts +0 -6
- package/client/three/tanya.ts +0 -29
- package/client/three/tara-asset.ts +0 -25
- package/client/three/tara.ts +0 -36
- package/client/three/tess-asset.ts +0 -6
- package/client/three/tess.ts +0 -30
- package/client/three/tushar-asset.ts +0 -6
- package/client/three/tushar.ts +0 -28
- package/client/types.ts +0 -235
- package/client/useAvatar.ts +0 -74
- package/dist/Avatar.d.ts +0 -44
- package/dist/Avatar.d.ts.map +0 -1
- package/dist/Avatar.js +0 -7
- package/dist/Avatar.js.map +0 -1
- package/dist/AvatarClient.d.ts +0 -274
- package/dist/AvatarClient.d.ts.map +0 -1
- package/dist/AvatarClient.js +0 -712
- package/dist/AvatarClient.js.map +0 -1
- package/dist/createAvatar.d.ts +0 -135
- package/dist/createAvatar.d.ts.map +0 -1
- package/dist/createAvatar.js +0 -75
- package/dist/createAvatar.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts +0 -34
- package/dist/internal.d.ts.map +0 -1
- package/dist/internal.js +0 -56
- package/dist/internal.js.map +0 -1
- package/dist/playout.d.ts +0 -42
- package/dist/playout.d.ts.map +0 -1
- package/dist/playout.js +0 -85
- package/dist/playout.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/supports.d.ts +0 -19
- package/dist/supports.d.ts.map +0 -1
- package/dist/supports.js +0 -25
- package/dist/supports.js.map +0 -1
- package/dist/three/assets.d.ts +0 -10
- package/dist/three/assets.d.ts.map +0 -1
- package/dist/three/assets.js +0 -23
- package/dist/three/assets.js.map +0 -1
- package/dist/three/budgets.d.ts +0 -17
- package/dist/three/budgets.d.ts.map +0 -1
- package/dist/three/budgets.js +0 -21
- package/dist/three/budgets.js.map +0 -1
- package/dist/three/character-rig.d.ts +0 -366
- package/dist/three/character-rig.d.ts.map +0 -1
- package/dist/three/character-rig.js +0 -1644
- package/dist/three/character-rig.js.map +0 -1
- package/dist/three/createCharacter.d.ts +0 -60
- package/dist/three/createCharacter.d.ts.map +0 -1
- package/dist/three/createCharacter.js +0 -84
- package/dist/three/createCharacter.js.map +0 -1
- package/dist/three/holds.d.ts +0 -38
- package/dist/three/holds.d.ts.map +0 -1
- package/dist/three/holds.js +0 -61
- package/dist/three/holds.js.map +0 -1
- package/dist/three/internal.d.ts +0 -52
- package/dist/three/internal.d.ts.map +0 -1
- package/dist/three/internal.js +0 -49
- package/dist/three/internal.js.map +0 -1
- package/dist/three/motion-limits.json +0 -124
- package/dist/three/sequences.d.ts +0 -71
- package/dist/three/sequences.d.ts.map +0 -1
- package/dist/three/sequences.js +0 -262
- package/dist/three/sequences.js.map +0 -1
- package/dist/three/tanvi-asset.d.ts +0 -7
- package/dist/three/tanvi-asset.d.ts.map +0 -1
- package/dist/three/tanvi-asset.js +0 -7
- package/dist/three/tanvi-asset.js.map +0 -1
- package/dist/three/tanvi.d.ts +0 -24
- package/dist/three/tanvi.d.ts.map +0 -1
- package/dist/three/tanvi.js +0 -24
- package/dist/three/tanvi.js.map +0 -1
- package/dist/three/tanya-asset.d.ts +0 -7
- package/dist/three/tanya-asset.d.ts.map +0 -1
- package/dist/three/tanya-asset.js +0 -7
- package/dist/three/tanya-asset.js.map +0 -1
- package/dist/three/tanya.d.ts +0 -23
- package/dist/three/tanya.d.ts.map +0 -1
- package/dist/three/tanya.js +0 -23
- package/dist/three/tanya.js.map +0 -1
- package/dist/three/tara-asset.d.ts +0 -26
- package/dist/three/tara-asset.d.ts.map +0 -1
- package/dist/three/tara-asset.js +0 -26
- package/dist/three/tara-asset.js.map +0 -1
- package/dist/three/tara.d.ts +0 -30
- package/dist/three/tara.d.ts.map +0 -1
- package/dist/three/tara.js +0 -30
- package/dist/three/tara.js.map +0 -1
- package/dist/three/tess-asset.d.ts +0 -7
- package/dist/three/tess-asset.d.ts.map +0 -1
- package/dist/three/tess-asset.js +0 -7
- package/dist/three/tess-asset.js.map +0 -1
- package/dist/three/tess.d.ts +0 -24
- package/dist/three/tess.d.ts.map +0 -1
- package/dist/three/tess.js +0 -24
- package/dist/three/tess.js.map +0 -1
- package/dist/three/tushar-asset.d.ts +0 -7
- package/dist/three/tushar-asset.d.ts.map +0 -1
- package/dist/three/tushar-asset.js +0 -7
- package/dist/three/tushar-asset.js.map +0 -1
- package/dist/three/tushar.d.ts +0 -22
- package/dist/three/tushar.d.ts.map +0 -1
- package/dist/three/tushar.js +0 -22
- package/dist/three/tushar.js.map +0 -1
- package/dist/types.d.ts +0 -149
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -140
- package/dist/types.js.map +0 -1
- package/dist/useAvatar.d.ts +0 -39
- package/dist/useAvatar.d.ts.map +0 -1
- package/dist/useAvatar.js +0 -35
- package/dist/useAvatar.js.map +0 -1
- package/src/avatar.d.ts +0 -436
- package/src/avatar.js +0 -1378
- package/src/behavior.d.ts +0 -42
- package/src/behavior.js +0 -114
- package/src/camera.js +0 -29
- package/src/clips.js +0 -155
- package/src/conformance.js +0 -119
- package/src/emotions.js +0 -58
- package/src/face-core.js +0 -180
- package/src/face-myna.d.ts +0 -7
- package/src/face-myna.js +0 -786
- package/src/face-peep-control-plane.js +0 -167
- package/src/face-peep.d.ts +0 -7
- package/src/face-peep.js +0 -925
- package/src/face-wren.d.ts +0 -7
- package/src/face-wren.js +0 -468
- package/src/faces.d.ts +0 -12
- package/src/faces.js +0 -53
- package/src/gaze.js +0 -504
- package/src/hand.js +0 -709
- package/src/head.js +0 -134
- package/src/idle.js +0 -668
- package/src/interjections.js +0 -747
- package/src/line-art.js +0 -137
- package/src/params.js +0 -180
- package/src/perform.js +0 -107
- package/src/prosody.js +0 -741
- package/src/rig.d.ts +0 -31
- package/src/rig.js +0 -32
- package/src/speech-timing.js +0 -51
- package/src/visemes.js +0 -410
package/src/idle.js
DELETED
|
@@ -1,668 +0,0 @@
|
|
|
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
|
-
import { HeadPose } from './head.js';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* An idle profile. Every state carries one (sparse — missing keys mean these
|
|
24
|
-
* defaults). Rates may jump between states; *amplitudes* glide over ~1s inside
|
|
25
|
-
* the layer, because an oscillator whose amplitude steps is a visible pop.
|
|
26
|
-
*
|
|
27
|
-
* sway 0..~1.2 head-drift / brow-drift / shoulder / torso amplitude
|
|
28
|
-
* blinkGap [min,max] seconds between blinks — ALL blinks. A blink the
|
|
29
|
-
* face is made to do (a gaze shift, a state change, a pause in
|
|
30
|
-
* speech) replaces the next timed one instead of adding to it,
|
|
31
|
-
* so this is the state's rate whatever else is going on.
|
|
32
|
-
* slowBlink 0..1 share of timed blinks drawn slow (0.34 s) — the heavy,
|
|
33
|
-
* deliberate blink of someone thinking. A share of the budget,
|
|
34
|
-
* not a second clock on top of it.
|
|
35
|
-
* breathRate multiplier on the 0.23 Hz resting cycle (13.8/min)
|
|
36
|
-
* breathAmp breath excursion scale; cognitive load = faster AND shallower
|
|
37
|
-
* hold { every:[s,s], dur:[s,s] } — freeze the sway (breath continues).
|
|
38
|
-
* Limited animation's static hold: deliberate stillness read as
|
|
39
|
-
* attention, and a bitrate saving besides.
|
|
40
|
-
* rhythm { amp, freq } — quasi-rhythmic alternating shoulder work in
|
|
41
|
-
* burst/pause phrasing; the "busy at the keyboard" tell.
|
|
42
|
-
* flick { amp, every:[s,s] } — a rare, tiny, fast-dying yaw wiggle:
|
|
43
|
-
* the "no, not this one" of someone hunting through options.
|
|
44
|
-
* Deliberately quick (a flick, not a shake — the 1.5 Hz
|
|
45
|
-
* impatience line is about sustained nodding, and this dies in
|
|
46
|
-
* half a second).
|
|
47
|
-
* shift [s,s] gap between postural weight shifts, or null to sit
|
|
48
|
-
* perfectly still. Amplitude rides on `sway`, which is how the
|
|
49
|
-
* cognitive states get the measured sway *suppression* under
|
|
50
|
-
* load (§6.2) without a second knob.
|
|
51
|
-
* settle [s,s] gap between the head's own small re-positionings, or
|
|
52
|
-
* null for a head that only moves when something moves it.
|
|
53
|
-
* Amplitude rides on `sway` too.
|
|
54
|
-
*/
|
|
55
|
-
export const DEFAULT_PROFILE = {
|
|
56
|
-
sway: 1.0,
|
|
57
|
-
// A head re-positions oftener than it used to (2026-09-21). On a character
|
|
58
|
-
// whose held angle is budgeted this is the half of "more movement" that
|
|
59
|
-
// actually arrives: the angle is capped by `headHold` and the *rate* is not,
|
|
60
|
-
// so a body that re-settles every couple of seconds reads as more alive than
|
|
61
|
-
// one that reaches further every five. Still an event every second or two at
|
|
62
|
-
// its fastest, which is nowhere near the 1.5 Hz line.
|
|
63
|
-
settle: [1.4, 3.4],
|
|
64
|
-
blinkGap: [1.9, 5.4],
|
|
65
|
-
slowBlink: 0,
|
|
66
|
-
breathRate: 1.0,
|
|
67
|
-
breathAmp: 1.0,
|
|
68
|
-
hold: null,
|
|
69
|
-
rhythm: null,
|
|
70
|
-
flick: null,
|
|
71
|
-
shift: [7, 16],
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
const rand = ([a, b]) => a + Math.random() * (b - a);
|
|
75
|
-
|
|
76
|
-
// No evoked blink within this long of the last blink. A glance out and back
|
|
77
|
-
// is two large gaze shifts a second apart and carries one blink, not two; and
|
|
78
|
-
// a state change that lands on a gaze shift is one event to the eyelids.
|
|
79
|
-
const BLINK_REFRACTORY = 1.5;
|
|
80
|
-
// How long an ordinary blink takes, open to open, in seconds. Centred on the
|
|
81
|
-
// 208 ms "short" blink Hömke et al. gave their avatar listener
|
|
82
|
-
// (research-biomechanics.md §3.6) — the one their listeners read as nothing
|
|
83
|
-
// but a blink. It was 0.11–0.15 s, and in a recorded call on 2026-09-24 a
|
|
84
|
-
// photographic lid covered that in two or three frames: open, shut, open, a
|
|
85
|
-
// pop rather than a lid travelling, which is the flicker a reviewer calls
|
|
86
|
-
// ghosting. A line face swallowed it; a photograph shows every frame.
|
|
87
|
-
const BLINK_DUR = [0.19, 0.23];
|
|
88
|
-
|
|
89
|
-
// How far into the current gap an odds-rolled evoked blink may land, as a
|
|
90
|
-
// fraction of that gap. A state that moves its gaze oftener than it blinks —
|
|
91
|
-
// SEARCHING_SCREEN hops 42 times a minute against a 10/min budget, and nearly
|
|
92
|
-
// every hop clears the ramp — would otherwise blink at its gaze's rate rather
|
|
93
|
-
// than its own. This lets such a blink only *move* the next one onto a shift,
|
|
94
|
-
// which is the half worth keeping (research-biomechanics.md §5.4: a blink at a
|
|
95
|
-
// boundary is worth several placed at random), and not add one.
|
|
96
|
-
//
|
|
97
|
-
// 0.80 is where the trade turns, measured on tara's targets at 30 min a state:
|
|
98
|
-
// SEARCHING_SCREEN falls from 2.12x its authored rate to 1.06x and
|
|
99
|
-
// REVIEWING_SCREEN from 1.36x to 1.01x, while 54% of the former's blinks still
|
|
100
|
-
// land on a hop. Tighter values buy hundredths of rate and halve that
|
|
101
|
-
// placement — 0.90 holds only 26%.
|
|
102
|
-
const EVOKED_EARLIEST = 0.80;
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Postural weight shift: the one part of this layer that is not periodic, and
|
|
106
|
-
* the part that does most of the work.
|
|
107
|
-
*
|
|
108
|
-
* Everything else here is an oscillator, and an oscillator cannot make a body
|
|
109
|
-
* look alive across a thirty-second hold. Fast enough to notice and it reads
|
|
110
|
-
* as rocking; slow enough not to and it is indistinguishable from a still
|
|
111
|
-
* image. A headless motion map of the listening state showed the
|
|
112
|
-
* outer edge of the torso travelling zero pixels over 24 seconds — the sway
|
|
113
|
-
* was there in the numbers and rendered as nothing at all.
|
|
114
|
-
*
|
|
115
|
-
* What a seated person actually does is re-settle: every 15-40 seconds the
|
|
116
|
-
* trunk arrives at a slightly different resting posture over a second or two
|
|
117
|
-
* and then *stays* there. Discrete, aperiodic, and mostly holding still, which
|
|
118
|
-
* is also why it costs almost nothing to encode — the motion is rare rather
|
|
119
|
-
* than small.
|
|
120
|
-
*
|
|
121
|
-
* The counter-turn is the detail that makes it read as a body rather than a
|
|
122
|
-
* drift: the trunk goes one way and the head yaws slightly the other, because
|
|
123
|
-
* a person shifting their weight keeps looking at the person they are
|
|
124
|
-
* listening to. Without it the whole figure slides sideways as one piece.
|
|
125
|
-
*/
|
|
126
|
-
const ZERO_POSTURE = { torsoTurn: 0, torsoLean: 0, headRoll: 0, headYaw: 0, shoulderL: 0, shoulderR: 0 };
|
|
127
|
-
const POSTURE_KEYS = Object.keys(ZERO_POSTURE);
|
|
128
|
-
|
|
129
|
-
function nextPosture(prev) {
|
|
130
|
-
// A magnitude with a floor, and a side chosen against wherever the body
|
|
131
|
-
// already is. The obvious version — draw each channel uniformly about zero —
|
|
132
|
-
// was written first and measured worse than no shift at all: half the draws
|
|
133
|
-
// land near the posture already held, so half the re-settles go nowhere, and
|
|
134
|
-
// a mechanism that is invisible half the time reads as a body that only
|
|
135
|
-
// moves sometimes. Nobody shifts their weight by a millimetre. The 25% that
|
|
136
|
-
// stays on the same side is what keeps it off a left-right metronome.
|
|
137
|
-
const mag = 0.20 + Math.random() * 0.32;
|
|
138
|
-
const away = prev.torsoTurn > 0 ? -1 : prev.torsoTurn < 0 ? 1 : (Math.random() < 0.5 ? -1 : 1);
|
|
139
|
-
const turn = mag * (Math.random() < 0.75 ? away : -away);
|
|
140
|
-
const leanAway = prev.torsoLean > 0 ? -1 : 1;
|
|
141
|
-
const drop = Math.random() * 0.09 - 0.03;
|
|
142
|
-
return {
|
|
143
|
-
torsoTurn: turn,
|
|
144
|
-
torsoLean: (0.03 + Math.random() * 0.05) * leanAway,
|
|
145
|
-
// The head tips slightly against the trunk, and yaws slightly against it
|
|
146
|
-
// too, because a person shifting their weight goes on looking at the
|
|
147
|
-
// person they are listening to. Without the counter-turn the whole figure
|
|
148
|
-
// slides sideways in one piece, which is a camera move, not a body.
|
|
149
|
-
headRoll: turn * -0.28 + (Math.random() * 0.10 - 0.05),
|
|
150
|
-
headYaw: turn * -0.13,
|
|
151
|
-
shoulderL: drop + turn * 0.07, // weight onto one side lifts that shoulder
|
|
152
|
-
shoulderR: drop - turn * 0.07,
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* The head's settle: where a person's head goes while nothing is moving it.
|
|
158
|
-
*
|
|
159
|
-
* It used to be a pair of slow sines per axis, and on tara that was two
|
|
160
|
-
* failures at once. At 0.05-0.09 Hz and under a degree it was too slow to see
|
|
161
|
-
* as motion, so a listening face read as frozen — "a dead stare, only blinks",
|
|
162
|
-
* in the video review that found it — and what could be seen of it was a head
|
|
163
|
-
* that never stops gliding, which is the other failure. A listener's head does
|
|
164
|
-
* neither: it is held, and every few seconds it re-positions — one to two
|
|
165
|
-
* degrees, over about half a second — and is held again (head.js). Pose units
|
|
166
|
-
* at sway 1; on tara a unit of yaw is 10.7°, of pitch 17.1°, of roll 5.7°. The
|
|
167
|
-
* first cut asked for about a degree and a recorded call measured it at 3 px
|
|
168
|
-
* of head travel, which is the frozen listener it was meant to fix.
|
|
169
|
-
*
|
|
170
|
-
* None of these is a nod. The renderer does not acknowledge on its own
|
|
171
|
-
* (CLAUDE.md), so a settle is one move, never a down-and-back — that property,
|
|
172
|
-
* and not a small number, is what keeps pitch off the wire's vocabulary. It was
|
|
173
|
-
* held to under a degree as well, which measured as a listening head with no
|
|
174
|
-
* pitch in it at all (1.05° peak over thirty seconds, against yaw's 5.5°), and
|
|
175
|
-
* a head that only ever turns is a head on a turntable. The range is biased
|
|
176
|
-
* upward: a chin that drifts down and stays there is the downcast read, which
|
|
177
|
-
* on a photographed face arrives long before any other.
|
|
178
|
-
*
|
|
179
|
-
* **Widened again on 2026-09-21, on the owner's read that the whole figure is
|
|
180
|
-
* too restricted.** The ceiling here is not this table — it is `headHold` in
|
|
181
|
-
* the mixer (step 6b), which softens what the layers together ask the head to
|
|
182
|
-
* *hold* against the angle the owner measured on each character by eye
|
|
183
|
-
* (`motion-limits.json`). So these numbers are what the drawing gets and what a
|
|
184
|
-
* budgeted character gets is its own measured angle, which is the arrangement
|
|
185
|
-
* this file should have been sized against all along: on tara the widest settle
|
|
186
|
-
* yaw is 4.9° under a 6° hold budget, the widest pitch 2.6° under 5°, and roll
|
|
187
|
-
* stays the most conservative of the three because her roll *transition* is a
|
|
188
|
-
* recorded defect at any angle. The `dur` is untouched — a wider move over the
|
|
189
|
-
* same half second is a faster move, not a busier one, and the frequency is
|
|
190
|
-
* what the movement budget is about (CLAUDE.md).
|
|
191
|
-
*/
|
|
192
|
-
const SETTLE = { yaw: [0.22, 0.46], pitch: [-0.15, 0.11], roll: [0.14, 0.38], dur: [0.45, 0.8], switchP: 0.7 };
|
|
193
|
-
const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* The moving hold: what keeps a held head alive.
|
|
197
|
-
*
|
|
198
|
-
* Hold-and-move fixed the floating head, and the same reviewer then found the
|
|
199
|
-
* holds themselves uncanny — "absolute 0-velocity holds", a head locked while
|
|
200
|
-
* only the mouth moves. Animators met this long ago: a character that stops
|
|
201
|
-
* dead reads as a still frame, so a held pose keeps drifting by a fraction of
|
|
202
|
-
* its size: here a few tenths of a degree, small enough that a move still
|
|
203
|
-
* reads as the event. It runs in every state, speech included, and yields to
|
|
204
|
-
* a static hold, which is stillness on purpose.
|
|
205
|
-
*
|
|
206
|
-
* **It is not a sum of sines.** The first cut was, at 0.2-0.6 Hz, and the next
|
|
207
|
-
* review read it at once as floating underwater: a periodic sway is a loop,
|
|
208
|
-
* and people see loops. Human postural sway is a random walk, and it stops —
|
|
209
|
-
* a head drifts for a few seconds, then is quiet for a couple. So each axis
|
|
210
|
-
* glides to a new random offset every 0.6-2.2 s through two smoothing stages
|
|
211
|
-
* (an S-shaped approach, no overshoot, no rhythm), and a gate alternates
|
|
212
|
-
* drifting stretches with near-still ones. The body's idle drift takes the
|
|
213
|
-
* same gate, so a quiet head does not sit on a torso still rocking under it.
|
|
214
|
-
*/
|
|
215
|
-
const LIVE = {
|
|
216
|
-
yaw: 0.07, pitch: 0.028, roll: 0.07, breathPitch: 0.006,
|
|
217
|
-
retarget: [0.6, 2.2], tau: 0.38,
|
|
218
|
-
drift: [2.0, 5.0], still: [1.2, 3.5], stillGain: 0.12,
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
/** Ease so the shift has no corners at either end — a weight shift accelerates
|
|
222
|
-
* and settles, and a linear ramp between two postures reads as a slide. */
|
|
223
|
-
const smoothstep = (x) => x * x * (3 - 2 * x);
|
|
224
|
-
|
|
225
|
-
export class IdleLayer {
|
|
226
|
-
constructor() {
|
|
227
|
-
this.t = 0;
|
|
228
|
-
this.enabled = true;
|
|
229
|
-
this.profile = DEFAULT_PROFILE;
|
|
230
|
-
this._profileRef = undefined;
|
|
231
|
-
// How much the avatar is talking, 0..1, set by the mixer. Speech does not make
|
|
232
|
-
// the head *busier* — sway still drops — it moves the liveness down into
|
|
233
|
-
// the shoulders and torso, which is where a speaking body actually moves.
|
|
234
|
-
this.talk = 0;
|
|
235
|
-
this._nextBlink = 2 + Math.random() * 3;
|
|
236
|
-
this._blinkT = -1;
|
|
237
|
-
this._blinkDur = BLINK_DUR[0];
|
|
238
|
-
this._lastBlinkAt = -Infinity;
|
|
239
|
-
// Two incommensurate frequencies per axis so the sway never visibly loops.
|
|
240
|
-
this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
|
|
241
|
-
// The moving hold (LIVE): per axis a random target, a first stage chasing
|
|
242
|
-
// it and the output chasing that; and the drift/still gate.
|
|
243
|
-
this._liveTo = [0, 0, 0];
|
|
244
|
-
this._liveA = [0, 0, 0];
|
|
245
|
-
this._liveB = [0, 0, 0];
|
|
246
|
-
this._liveAt = 0;
|
|
247
|
-
this._liveOn = true;
|
|
248
|
-
this._liveFlip = rand(LIVE.drift);
|
|
249
|
-
this._liveGate = 1;
|
|
250
|
-
// Glided amplitudes (profiles set the target, these chase it).
|
|
251
|
-
this._sway = 1;
|
|
252
|
-
this._breathAmp = 1;
|
|
253
|
-
// Breath is a phase integrator, not sin(t*f): rate changes must bend the
|
|
254
|
-
// cycle, not teleport it.
|
|
255
|
-
this._breathPh = Math.random() * Math.PI * 2;
|
|
256
|
-
// Static-hold machine. _holdAmp glides 1 -> 0 -> 1 around each hold.
|
|
257
|
-
this._holdUntil = 0;
|
|
258
|
-
this._nextHold = 0;
|
|
259
|
-
this._holdAmp = 1;
|
|
260
|
-
// Work-rhythm burst/pause machine.
|
|
261
|
-
this._rhythmOn = false;
|
|
262
|
-
this._rhythmFlip = 0;
|
|
263
|
-
this._rhythmAmp = 0;
|
|
264
|
-
// Flick machine: _flickT is time-into-flick, <0 = idle.
|
|
265
|
-
this._flickAt = 0;
|
|
266
|
-
this._flickT = -1;
|
|
267
|
-
// Weight-shift machine: a posture the body is easing from, one it is
|
|
268
|
-
// easing to, and a long wait in between.
|
|
269
|
-
this._postFrom = ZERO_POSTURE;
|
|
270
|
-
this._postTo = ZERO_POSTURE;
|
|
271
|
-
this._shiftAt = 8;
|
|
272
|
-
this._shiftT0 = -1;
|
|
273
|
-
this._shiftDur = 2;
|
|
274
|
-
// Settle machine: the head's held pose, and when it next moves.
|
|
275
|
-
this._head = new HeadPose();
|
|
276
|
-
this._settleAt = 1 + Math.random() * 2;
|
|
277
|
-
this._settleSide = Math.random() < 0.5 ? -1 : 1;
|
|
278
|
-
this._settled = false;
|
|
279
|
-
// Speech phrasing: a slow gain the talking body's excursion rides on, so
|
|
280
|
-
// it comes in waves rather than as a steady hum.
|
|
281
|
-
this._phrase = 0;
|
|
282
|
-
this._phraseFlip = 0;
|
|
283
|
-
this._phraseTo = 1;
|
|
284
|
-
// Global amplitude on everything this layer emits. The honest answer to
|
|
285
|
-
// "keep idle motion cheap": a deployment that composites the avatar into
|
|
286
|
-
// an encoded stream turns it down, rather than the default being frozen.
|
|
287
|
-
this.gain = 1;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/** Adopt a (sparse) profile. Cheap to call every frame; same ref is a no-op. */
|
|
291
|
-
setProfile(p) {
|
|
292
|
-
if (p === this._profileRef) return;
|
|
293
|
-
this._profileRef = p;
|
|
294
|
-
this.profile = Object.assign({}, DEFAULT_PROFILE, p || {});
|
|
295
|
-
// Re-arm the hold scheduler so a state that uses holds doesn't inherit a
|
|
296
|
-
// stale far-future timestamp from one that doesn't.
|
|
297
|
-
this._nextHold = this.t + (this.profile.hold ? rand(this.profile.hold.every) : 0);
|
|
298
|
-
this._flickAt = this.t + (this.profile.flick ? rand(this.profile.flick.every) : 0);
|
|
299
|
-
// A shift already under way is left alone: interrupting a weight shift
|
|
300
|
-
// half-finished is a lurch, and states change far more often than the
|
|
301
|
-
// body re-settles.
|
|
302
|
-
if (this._shiftT0 < 0) {
|
|
303
|
-
this._shiftAt = this.t + (this.profile.shift ? rand(this.profile.shift) * 0.6 : 0);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Blink now. `evoked` is a blink the face is made to do by something else —
|
|
309
|
-
* a large gaze shift, a state change, a pause in speech — and it is refused
|
|
310
|
-
* inside BLINK_REFRACTORY of the last one, and again before EVOKED_EARLIEST
|
|
311
|
-
* of the way through the current gap. Every blink, evoked or not, restarts
|
|
312
|
-
* the timer: people blink at a rate, and an event moves a blink earlier
|
|
313
|
-
* rather than adding one. Without that, a state that glances or wanders
|
|
314
|
-
* blinks at its timer's rate plus its gaze's, far past the rates in
|
|
315
|
-
* docs/research-biomechanics.md §5 that the gaps are set from.
|
|
316
|
-
*
|
|
317
|
-
* `forced` is a beat the state asked for by name — `glance.blinkTo` and
|
|
318
|
-
* `blinkBack`, the re-engagement the author wanted seen. It clears the budget
|
|
319
|
-
* gate but not the refractory: a state whose authored beats overrun its own
|
|
320
|
-
* rate is a decision to revisit in that state, not something for the eyelids
|
|
321
|
-
* to drop silently.
|
|
322
|
-
*/
|
|
323
|
-
blink(evoked = false, forced = false) {
|
|
324
|
-
if (evoked) {
|
|
325
|
-
if (this.t - this._lastBlinkAt < BLINK_REFRACTORY) return;
|
|
326
|
-
const gap = this._nextBlink - this._lastBlinkAt;
|
|
327
|
-
if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
|
|
328
|
-
}
|
|
329
|
-
this._startBlink(rand(BLINK_DUR));
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
_startBlink(dur) {
|
|
333
|
-
if (this._blinkT >= 0) return;
|
|
334
|
-
this._blinkT = 0;
|
|
335
|
-
this._blinkDur = dur;
|
|
336
|
-
this._lastBlinkAt = this.t;
|
|
337
|
-
this._nextBlink = this.t + rand(this.profile.blinkGap);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* A blink placed by speech, at a pause. It also restarts the timer: a speaker
|
|
342
|
-
* who blinks at a clause boundary does not blink again a moment later
|
|
343
|
-
* because a clock said so, and the timer is only there to fill a long run
|
|
344
|
-
* of speech with no pauses in it.
|
|
345
|
-
*/
|
|
346
|
-
phraseBlink() {
|
|
347
|
-
this.blink(true);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** A slow, deliberate blink — reads as thinking or fatigue. */
|
|
351
|
-
slowBlink() {
|
|
352
|
-
this._startBlink(0.34);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
_blinkValue(dt) {
|
|
356
|
-
if (this._blinkT < 0) return 0;
|
|
357
|
-
this._blinkT += dt;
|
|
358
|
-
const d = this._blinkDur;
|
|
359
|
-
const p = this._blinkT / d;
|
|
360
|
-
if (p >= 1) {
|
|
361
|
-
this._blinkT = -1;
|
|
362
|
-
return 0;
|
|
363
|
-
}
|
|
364
|
-
// Fast close (35% of the window), slower reopen. Symmetric blinks look robotic.
|
|
365
|
-
return p < 0.35 ? p / 0.35 : 1 - (p - 0.35) / 0.65;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
update(dt) {
|
|
369
|
-
if (!this.enabled) return { add: {}, blink: 0 };
|
|
370
|
-
this.t += dt;
|
|
371
|
-
const t = this.t;
|
|
372
|
-
const pr = this.profile;
|
|
373
|
-
|
|
374
|
-
if (t >= this._nextBlink) {
|
|
375
|
-
// Re-armed here as well as in _startBlink, for the frame the timer comes
|
|
376
|
-
// due in the middle of a blink already running.
|
|
377
|
-
this._nextBlink = t + rand(pr.blinkGap);
|
|
378
|
-
// A share of them slow if the state asks for it. **The rest are single,
|
|
379
|
-
// and a paired blink is not a thing this layer does** — a share of them
|
|
380
|
-
// used to re-close the instant the lid reached open, which at this
|
|
381
|
-
// duration is a 7-9 Hz flutter and was read by the owner on 2026-09-21 as
|
|
382
|
-
// a dropped frame rather than as a pair. A human doublet is two blinks a
|
|
383
|
-
// few hundred ms apart, which is what the gap already produces when it
|
|
384
|
-
// draws short; nothing in research-biomechanics.md asks for the rest.
|
|
385
|
-
if (Math.random() < pr.slowBlink) this.slowBlink();
|
|
386
|
-
else this.blink();
|
|
387
|
-
}
|
|
388
|
-
const blink = this._blinkValue(dt);
|
|
389
|
-
|
|
390
|
-
// Static hold: sway freezes, breath does not — held breath reads as alarm.
|
|
391
|
-
if (pr.hold) {
|
|
392
|
-
if (t >= this._nextHold && t >= this._holdUntil) {
|
|
393
|
-
this._holdUntil = t + rand(pr.hold.dur);
|
|
394
|
-
this._nextHold = this._holdUntil + rand(pr.hold.every);
|
|
395
|
-
}
|
|
396
|
-
} else {
|
|
397
|
-
this._holdUntil = 0;
|
|
398
|
-
}
|
|
399
|
-
this._holdAmp = approach(this._holdAmp, t < this._holdUntil ? 0 : 1, 0.18, dt);
|
|
400
|
-
|
|
401
|
-
// Amplitudes glide; rates jump. (~1s ramp keeps profile changes silent.)
|
|
402
|
-
this._sway = approach(this._sway, pr.sway, 1.0, dt);
|
|
403
|
-
this._breathAmp = approach(this._breathAmp, pr.breathAmp, 1.0, dt);
|
|
404
|
-
const a = this._sway * this._holdAmp * this.gain;
|
|
405
|
-
// The weight shift takes the profile's amplitude but NOT the hold: a hold
|
|
406
|
-
// freezes the body where it is, and multiplying a posture by a decaying
|
|
407
|
-
// hold factor would spring it back to centre instead.
|
|
408
|
-
const ps = this._sway * this.gain;
|
|
409
|
-
|
|
410
|
-
this._breathPh += dt * 0.23 * pr.breathRate * Math.PI * 2;
|
|
411
|
-
const breath = (Math.sin(this._breathPh) + 1) * 0.5 * this._breathAmp;
|
|
412
|
-
|
|
413
|
-
// Weight shift. Never starts inside a static hold — the hold is the state
|
|
414
|
-
// saying "this body is concentrating", and a re-settle mid-hold undoes it.
|
|
415
|
-
let post = ZERO_POSTURE;
|
|
416
|
-
if (pr.shift) {
|
|
417
|
-
if (this._shiftT0 < 0 && t >= this._shiftAt && t >= this._holdUntil) {
|
|
418
|
-
this._shiftT0 = t;
|
|
419
|
-
this._shiftDur = 1.5 + Math.random() * 1.6;
|
|
420
|
-
this._postFrom = this._postTo;
|
|
421
|
-
this._postTo = nextPosture(this._postTo);
|
|
422
|
-
}
|
|
423
|
-
if (this._shiftT0 >= 0) {
|
|
424
|
-
const k = (t - this._shiftT0) / this._shiftDur;
|
|
425
|
-
if (k >= 1) {
|
|
426
|
-
this._shiftT0 = -1;
|
|
427
|
-
this._shiftAt = t + rand(pr.shift);
|
|
428
|
-
post = this._postTo;
|
|
429
|
-
} else {
|
|
430
|
-
const e = smoothstep(k);
|
|
431
|
-
post = {};
|
|
432
|
-
for (const c of POSTURE_KEYS) {
|
|
433
|
-
post[c] = this._postFrom[c] + (this._postTo[c] - this._postFrom[c]) * e;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
} else post = this._postTo;
|
|
437
|
-
} else {
|
|
438
|
-
// A state that shifts nothing still has to come home from wherever the
|
|
439
|
-
// last one left the body, or the posture sticks across the transition.
|
|
440
|
-
this._postTo = this._postFrom = ZERO_POSTURE;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Speech phrasing: the talking body's excursion swells and subsides over
|
|
444
|
-
// 0.6-1.6 s rather than humming at a constant level. Speech is phrased and
|
|
445
|
-
// a body pushing it is phrased with it; a steady oscillation while talking
|
|
446
|
-
// is the single most robotic thing this layer could do.
|
|
447
|
-
if (t >= this._phraseFlip) {
|
|
448
|
-
this._phraseFlip = t + 0.6 + Math.random() * 1.0;
|
|
449
|
-
this._phraseTo = 0.45 + Math.random() * 0.85;
|
|
450
|
-
}
|
|
451
|
-
this._phrase = approach(this._phrase, this._phraseTo, 0.35, dt);
|
|
452
|
-
|
|
453
|
-
const s = (i, f) => Math.sin(t * f * Math.PI * 2 + this._ph[i]);
|
|
454
|
-
|
|
455
|
-
// Work rhythm: bursts of alternating-shoulder movement with pauses between,
|
|
456
|
-
// because continuous oscillation reads as rocking and phrased oscillation
|
|
457
|
-
// reads as *doing something*. Authored ~3x the intended excursion: at
|
|
458
|
-
// ~2.2 Hz the shoulders' 0.19s tau attenuates the target to ~0.36 of it.
|
|
459
|
-
let workL = 0, workR = 0, workPitch = 0;
|
|
460
|
-
if (pr.rhythm) {
|
|
461
|
-
if (t >= this._rhythmFlip) {
|
|
462
|
-
this._rhythmOn = !this._rhythmOn;
|
|
463
|
-
this._rhythmFlip = t + (this._rhythmOn ? 0.5 + Math.random() * 0.7 : 0.35 + Math.random() * 0.55);
|
|
464
|
-
}
|
|
465
|
-
this._rhythmAmp = approach(this._rhythmAmp, this._rhythmOn ? pr.rhythm.amp : 0, 0.15, dt);
|
|
466
|
-
const w = Math.sin(t * pr.rhythm.freq * Math.PI * 2) * this._rhythmAmp;
|
|
467
|
-
workL = w;
|
|
468
|
-
workR = -w * 0.85;
|
|
469
|
-
// A trace of the same activity in the head — eyes tracking the work.
|
|
470
|
-
workPitch = Math.sin(t * pr.rhythm.freq * 1.6 * Math.PI * 2) * this._rhythmAmp * 0.16;
|
|
471
|
-
} else {
|
|
472
|
-
this._rhythmAmp = 0;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// The "not this one" flick: two fast wiggles dying exponentially. Authored
|
|
476
|
-
// ~2.5x the intended excursion — at ~2.3 Hz the head's 0.16s tau renders
|
|
477
|
-
// roughly 0.4 of the target.
|
|
478
|
-
let flickYaw = 0;
|
|
479
|
-
if (pr.flick) {
|
|
480
|
-
if (this._flickT < 0 && t >= this._flickAt) this._flickT = 0;
|
|
481
|
-
if (this._flickT >= 0) {
|
|
482
|
-
this._flickT += dt;
|
|
483
|
-
const ft = this._flickT;
|
|
484
|
-
if (ft > 0.6) {
|
|
485
|
-
this._flickT = -1;
|
|
486
|
-
this._flickAt = t + rand(pr.flick.every);
|
|
487
|
-
} else {
|
|
488
|
-
flickYaw = pr.flick.amp * Math.sin(ft * 2.3 * Math.PI * 2) * Math.exp(-ft / 0.18);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
} else {
|
|
492
|
-
this._flickT = -1;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// The settle. Not while talking — speech holds and moves the head itself
|
|
496
|
-
// (prosody.js), and two layers each re-positioning it is a head with two
|
|
497
|
-
// minds — and never inside a static hold, which is stillness on purpose.
|
|
498
|
-
if (pr.settle && this.talk < 0.5) {
|
|
499
|
-
if (t >= this._settleAt && t >= this._holdUntil) {
|
|
500
|
-
this._settleAt = t + rand(pr.settle);
|
|
501
|
-
if (Math.random() < SETTLE.switchP) this._settleSide = -this._settleSide;
|
|
502
|
-
const m = this._sway * this.gain;
|
|
503
|
-
this._head.moveTo({
|
|
504
|
-
headYaw: this._settleSide * rand(SETTLE.yaw) * m,
|
|
505
|
-
headPitch: rand(SETTLE.pitch) * m,
|
|
506
|
-
headRoll: (Math.random() < 0.5 ? -1 : 1) * rand(SETTLE.roll) * m,
|
|
507
|
-
}, t * 1000, rand(SETTLE.dur) * 1000);
|
|
508
|
-
this._settled = true;
|
|
509
|
-
}
|
|
510
|
-
} else if (this._settled) {
|
|
511
|
-
this._settled = false;
|
|
512
|
-
this._head.moveTo(HOME, t * 1000, 600);
|
|
513
|
-
}
|
|
514
|
-
const hs = this._head.sample(t * 1000);
|
|
515
|
-
// The moving hold (LIVE). Only half of it rides on `sway`: a state that
|
|
516
|
-
// turns its drift down still has a living head.
|
|
517
|
-
if (t >= this._liveAt) {
|
|
518
|
-
this._liveAt = t + rand(LIVE.retarget);
|
|
519
|
-
for (let i = 0; i < 3; i++) this._liveTo[i] = Math.random() * 2 - 1;
|
|
520
|
-
}
|
|
521
|
-
if (t >= this._liveFlip) {
|
|
522
|
-
this._liveOn = !this._liveOn;
|
|
523
|
-
this._liveFlip = t + rand(this._liveOn ? LIVE.drift : LIVE.still);
|
|
524
|
-
}
|
|
525
|
-
this._liveGate = approach(this._liveGate, this._liveOn ? 1 : LIVE.stillGain, 0.5, dt);
|
|
526
|
-
for (let i = 0; i < 3; i++) {
|
|
527
|
-
this._liveA[i] = approach(this._liveA[i], this._liveTo[i], LIVE.tau, dt);
|
|
528
|
-
this._liveB[i] = approach(this._liveB[i], this._liveA[i], LIVE.tau, dt);
|
|
529
|
-
}
|
|
530
|
-
const live = this.gain * this._holdAmp * (0.5 + 0.5 * this._sway) * this._liveGate;
|
|
531
|
-
const [ly, lp, lr] = this._liveB;
|
|
532
|
-
hs.headYaw += ly * LIVE.yaw * live;
|
|
533
|
-
hs.headPitch += (lp * LIVE.pitch - Math.sin(this._breathPh) * LIVE.breathPitch * this._breathAmp) * live;
|
|
534
|
-
hs.headRoll += lr * LIVE.roll * live;
|
|
535
|
-
// The body's idle drift, quieted with the head (the speech term is not).
|
|
536
|
-
const ad = a * (0.35 + 0.65 * this._liveGate);
|
|
537
|
-
|
|
538
|
-
// Speech moves the body more, and in waves. `talk` says whether sound is
|
|
539
|
-
// being produced, `_phrase` says how hard this stretch of it is being
|
|
540
|
-
// pushed.
|
|
541
|
-
const say = this.talk * this._phrase;
|
|
542
|
-
|
|
543
|
-
return {
|
|
544
|
-
blink,
|
|
545
|
-
add: {
|
|
546
|
-
// Sway frequencies run ~1.6x the original set and amplitudes ~2x.
|
|
547
|
-
// docs/research-biomechanics.md §6.2 recorded the old numbers as
|
|
548
|
-
// deliberately about a quarter of measured human sway, traded for
|
|
549
|
-
// encoder cost; at that setting the head's whole idle excursion
|
|
550
|
-
// rendered as half a pixel at call-tile size, which is not a quiet
|
|
551
|
-
// motion but no motion. This lands nearer half speed — still well
|
|
552
|
-
// below the 0.04-0.6 Hz seated trunk band, still calm, but now
|
|
553
|
-
// actually present on screen. `gain` is where a deployment that
|
|
554
|
-
// really is paying for the pixels turns it back down.
|
|
555
|
-
// The head is the exception: it settles rather than sways (SETTLE).
|
|
556
|
-
// The *idle* terms below went up again on 2026-09-21 — the owner read
|
|
557
|
-
// the figure as too restricted — and only the idle terms: the speech
|
|
558
|
-
// share is `prosody.js`'s business and is raised there, beside the
|
|
559
|
-
// words it is timed to. These are all far below 1.5 Hz, so the raise
|
|
560
|
-
// buys excursion and not rate; what it costs the encoder is a slightly
|
|
561
|
-
// larger slow motion, which is the cheap kind.
|
|
562
|
-
headYaw: hs.headYaw + flickYaw + post.headYaw * ps,
|
|
563
|
-
headPitch: hs.headPitch + workPitch,
|
|
564
|
-
headRoll: hs.headRoll + post.headRoll * ps,
|
|
565
|
-
// Speech reorganises breathing: a quick inbreath at a pause and a long
|
|
566
|
-
// outbreath over the phrase (research-biomechanics.md §6.1), which the
|
|
567
|
-
// prosody layer draws. Quiet breathing steps mostly back under it.
|
|
568
|
-
breath: breath * this.gain * (1 - 0.6 * this.talk),
|
|
569
|
-
// The brows are never quite still either — and they were nearly still
|
|
570
|
-
// here, at an amplitude a beat could swallow whole. Two video
|
|
571
|
-
// reviewers named a motionless upper face as the biggest reason these
|
|
572
|
-
// read as uncanny; `prosody.POSE.brow` answered that for a face that
|
|
573
|
-
// is *talking*, and the avatar listens far more than it speaks
|
|
574
|
-
// (CLAUDE.md). The two rates are further apart than they were so the
|
|
575
|
-
// pair drifts out of phase inside one call rather than over several:
|
|
576
|
-
// a matched pair is the drawing, not the face.
|
|
577
|
-
browRaiseL: s(0, 0.089) * 0.032 * a,
|
|
578
|
-
browRaiseR: s(1, 0.071) * 0.032 * a,
|
|
579
|
-
// Shoulders, and the body's share of speech emphasis — a head that
|
|
580
|
-
// moves on its own above a torso that never does is the head-on-a-stick
|
|
581
|
-
// read, and it was the loudest note in the first round of stakeholder
|
|
582
|
-
// feedback. The slow pair of frequencies is deliberate now that the
|
|
583
|
-
// amplitude is big enough to see: the old 0.31 Hz term was inaudible at
|
|
584
|
-
// 0.012 and would have read as a twitch at 0.04.
|
|
585
|
-
// Note which factor each term takes. The idle drift is scaled by `a`,
|
|
586
|
-
// so a state that asks for stillness gets it. The speech term is NOT:
|
|
587
|
-
// states that speak lower their `sway` on purpose (SPEAKING sits at
|
|
588
|
-
// 0.55, because the head should not wander while the mouth is the thing
|
|
589
|
-
// being watched), and running the body's share of speech through that
|
|
590
|
-
// same number costs a third of it — measured, same seed, 30 s of
|
|
591
|
-
// SPEAKING: shoulder line travels 6 px gated against 9 px here, and the
|
|
592
|
-
// torso band's mean luminance range falls 28 -> 24. Sway suppression is
|
|
593
|
-
// a statement about drift. Speech reorganises the body; it does not
|
|
594
|
-
// park it.
|
|
595
|
-
// The speech term is smaller than it was, and that is a handover rather
|
|
596
|
-
// than a reduction. `prosody.js` now puts the body's share of an accent
|
|
597
|
-
// and of a phrase's posture on the shoulders and the lean, timed off
|
|
598
|
-
// the words; this is a pair of sine waves timed off nothing. Two
|
|
599
|
-
// unrelated sources at full strength read as a body that is busy
|
|
600
|
-
// without being about anything, and the one that knows what is being
|
|
601
|
-
// said should be the louder. What is left here is the part prosody
|
|
602
|
-
// cannot supply: motion in the gaps *between* phrases, where there is
|
|
603
|
-
// no accent to hang anything on.
|
|
604
|
-
shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
|
|
605
|
-
+ workL + post.shoulderL * ps,
|
|
606
|
-
shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.052 * ad + 0.045 * say * this.gain)
|
|
607
|
-
+ workR + post.shoulderR * ps,
|
|
608
|
-
torsoLean: s(0, 0.085) * (0.036 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
|
|
609
|
-
// The trunk's own drift, small next to the weight shift that dominates
|
|
610
|
-
// this channel. It exists so the body is not perfectly still *between*
|
|
611
|
-
// shifts, which would make each shift read as a discrete event.
|
|
612
|
-
torsoTurn: (s(2, 0.047) * 0.6 + s(1, 0.031) * 0.4) * 0.075 * a
|
|
613
|
-
+ post.torsoTurn * ps,
|
|
614
|
-
},
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* The listening engine is now deliberately posture-only.
|
|
621
|
-
*
|
|
622
|
-
* `setUserSpeaking()` receives Pipecat's VAD verdict through the client
|
|
623
|
-
* adapter. It may alter the sustained engagement lean, but it never chooses or
|
|
624
|
-
* fires a clip: a nod, brow acknowledgement or any other facial claim of
|
|
625
|
-
* understanding is an explicit backend/application `action()` decision.
|
|
626
|
-
*/
|
|
627
|
-
export class ListeningEngine {
|
|
628
|
-
constructor() {
|
|
629
|
-
this.enabled = false;
|
|
630
|
-
this.t = 0;
|
|
631
|
-
this.engage = 0;
|
|
632
|
-
this._hasSignal = false;
|
|
633
|
-
this._explicit = null;
|
|
634
|
-
this._speaking = false;
|
|
635
|
-
this._silentAt = 0;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/** Compatibility no-ops retained for existing hosts that reached these
|
|
639
|
-
* internals indirectly. With no autonomous clips there is nothing to reset
|
|
640
|
-
* or cede. */
|
|
641
|
-
cede() {}
|
|
642
|
-
get ceded() { return false; }
|
|
643
|
-
reset() {}
|
|
644
|
-
|
|
645
|
-
/** Pipecat VAD's user-speaking truth. `null` removes the engagement signal. */
|
|
646
|
-
setUserSpeaking(b) {
|
|
647
|
-
if (b !== null) this._hasSignal = true;
|
|
648
|
-
this._explicit = b === null ? null : !!b;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
get speaking() { return this._explicit === true; }
|
|
652
|
-
|
|
653
|
-
update(dt) {
|
|
654
|
-
this.t += dt;
|
|
655
|
-
const t = this.t;
|
|
656
|
-
const speaking = this.speaking;
|
|
657
|
-
|
|
658
|
-
// Engagement: quick to lean in when the user starts, slow to give it up —
|
|
659
|
-
// relaxing the moment they pause would read as relief that they stopped.
|
|
660
|
-
if (speaking !== this._speaking) {
|
|
661
|
-
this._speaking = speaking;
|
|
662
|
-
if (!speaking) this._silentAt = t;
|
|
663
|
-
}
|
|
664
|
-
const engaged = speaking || t - this._silentAt < 8;
|
|
665
|
-
this.engage = approach(this.engage, engaged && this._hasSignal ? 1 : 0,
|
|
666
|
-
speaking ? 1.5 : 6.0, dt);
|
|
667
|
-
}
|
|
668
|
-
}
|