@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/head.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hold-and-move: how a head spends a conversation.
|
|
3
|
+
*
|
|
4
|
+
* Watch a person on a video call with the sound off and the head is almost
|
|
5
|
+
* never *moving*. It is held, and every second or two it goes somewhere else
|
|
6
|
+
* — a quick, decided move of a few degrees, a short settle, and then still
|
|
7
|
+
* again. Speech adds strokes on top: a nod on a stressed word, down and back,
|
|
8
|
+
* over in a third of a second. What it never does is glide, and gliding is
|
|
9
|
+
* the one thing a stack of smoothed envelopes can do. That stack is what this
|
|
10
|
+
* replaced: beats, a phrase drift and a lead-in, each a linear keyframe curve,
|
|
11
|
+
* summed and then low-passed at the head's 160 ms, drew a head in continuous
|
|
12
|
+
* motion from the first word to the last. A video reviewer called it a
|
|
13
|
+
* screensaver (a recorded call, reviewed 2026-09-12), and that is
|
|
14
|
+
* the right word: every part was a movement the literature measured, and the
|
|
15
|
+
* sum was a movement nobody makes.
|
|
16
|
+
*
|
|
17
|
+
* So this keeps the *events* the research found — a head that moves before the
|
|
18
|
+
* voice, beats on stressed vowels, the settle as a phrase ends — and changes
|
|
19
|
+
* what an event is:
|
|
20
|
+
*
|
|
21
|
+
* - A **move** takes the held pose somewhere new along a minimum-jerk path,
|
|
22
|
+
* the profile a practised human reach follows (Flash & Hogan 1985): it
|
|
23
|
+
* starts and stops at zero velocity and acceleration, and nearly all of its
|
|
24
|
+
* travel is in the middle. It arrives, and then the pose is exactly still.
|
|
25
|
+
* - A **stroke** is a pulse on top of the held pose: out fast, back a little
|
|
26
|
+
* slower, and nothing left over when it is done.
|
|
27
|
+
*
|
|
28
|
+
* Between those the output does not change at all, and that stillness is the
|
|
29
|
+
* point: it is what makes the moves read as decisions.
|
|
30
|
+
*
|
|
31
|
+
* Times are ms on whatever clock the owner keeps; this only needs it to be
|
|
32
|
+
* monotonic.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Minimum-jerk position for a normalised time in [0, 1]. */
|
|
36
|
+
export const minJerk = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * x * (10 + x * (-15 + 6 * x)));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fold `x` into ±`limit` without a corner: identity up to `KNEE` of the way
|
|
40
|
+
* there, then an exponential approach that reaches the limit only in the
|
|
41
|
+
* limit. It is memoryless — the same input gives the same output whenever it
|
|
42
|
+
* arrives — so nothing it touches is re-timed, which is what lets it sit in a
|
|
43
|
+
* chain whose curves are all authored against a known smoothing.
|
|
44
|
+
*
|
|
45
|
+
* A hard clamp was the obvious thing and is wrong here: what wants limiting is
|
|
46
|
+
* a sum of layers that each stay small and occasionally point the same way, so
|
|
47
|
+
* a clamp would be invisible for a minute and then hold the head perfectly
|
|
48
|
+
* still at the stop for half a second — the one thing a held pose must not do,
|
|
49
|
+
* because stillness is how this file says "decided". The knee sits at 0.7
|
|
50
|
+
* because below it the pose passes through untouched, and a recorded minute of
|
|
51
|
+
* either conversational state spends its median there.
|
|
52
|
+
*/
|
|
53
|
+
const KNEE = 0.7;
|
|
54
|
+
export const soften = (x, limit) => {
|
|
55
|
+
const a = KNEE * limit;
|
|
56
|
+
const m = Math.abs(x);
|
|
57
|
+
if (!(limit > 0) || m <= a) return x;
|
|
58
|
+
return Math.sign(x) * (a + (limit - a) * (1 - Math.exp(-(m - a) / (limit - a))));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const HEAD_AXES = ['headYaw', 'headPitch', 'headRoll'];
|
|
62
|
+
const ZERO = Object.freeze({ headYaw: 0, headPitch: 0, headRoll: 0 });
|
|
63
|
+
|
|
64
|
+
export class HeadPose {
|
|
65
|
+
constructor() {
|
|
66
|
+
// The held pose as a list of moves, each starting where the one before it
|
|
67
|
+
// had got to. A move may be booked ahead of time (a head moves before the
|
|
68
|
+
// voice it anticipates), so the next one can be waiting while this one
|
|
69
|
+
// plays.
|
|
70
|
+
this._moves = [{ at: -Infinity, dur: 0, from: ZERO, to: ZERO }];
|
|
71
|
+
this._strokes = [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The pose the head will hold once every booked move has landed. */
|
|
75
|
+
get aim() {
|
|
76
|
+
return this._moves[this._moves.length - 1].to;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Go to `pose` (missing axes keep where they are), starting at `at` and
|
|
81
|
+
* arriving `dur` ms later. A move booked to start before one already
|
|
82
|
+
* booked replaces it: the later plan wins, from wherever the head will be.
|
|
83
|
+
*/
|
|
84
|
+
moveTo(pose, at, dur) {
|
|
85
|
+
while (this._moves.length > 1 && this._moves[this._moves.length - 1].at > at) this._moves.pop();
|
|
86
|
+
const from = this._held(at);
|
|
87
|
+
this._moves.push({ at, dur: Math.max(1, dur), from, to: { ...from, ...pose } });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A pulse of `add` (per axis) that peaks `attack` ms after `at` and is gone `release` ms later. */
|
|
91
|
+
stroke(add, at, attack, release) {
|
|
92
|
+
this._strokes.push({ add, at, attack, release });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Drop every stroke that has not begun by `now`; the ones under way finish. */
|
|
96
|
+
cancelStrokes(now) {
|
|
97
|
+
this._strokes = this._strokes.filter((s) => s.at <= now);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True when nothing is booked or playing after `now`: the head is holding. */
|
|
101
|
+
still(now) {
|
|
102
|
+
const m = this._moves[this._moves.length - 1];
|
|
103
|
+
return now >= m.at + m.dur && this._strokes.every((s) => now >= s.at + s.attack + s.release);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The head's offset at `now`, per axis. */
|
|
107
|
+
sample(now) {
|
|
108
|
+
// A move whose successor has started can no longer be seen.
|
|
109
|
+
while (this._moves.length > 1 && this._moves[1].at <= now) this._moves.shift();
|
|
110
|
+
this._strokes = this._strokes.filter((s) => now < s.at + s.attack + s.release);
|
|
111
|
+
const out = this._held(now);
|
|
112
|
+
for (const s of this._strokes) {
|
|
113
|
+
const t = now - s.at;
|
|
114
|
+
if (t <= 0) continue;
|
|
115
|
+
const e = t < s.attack ? minJerk(t / s.attack) : 1 - minJerk((t - s.attack) / s.release);
|
|
116
|
+
for (const k in s.add) out[k] += e * s.add[k];
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The held pose at `t`, strokes left out: where the head *is*, not what it is doing. */
|
|
122
|
+
held(t) {
|
|
123
|
+
return this._held(t);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_held(t) {
|
|
127
|
+
let m = this._moves[0];
|
|
128
|
+
for (const n of this._moves) if (n.at <= t) m = n;
|
|
129
|
+
const k = minJerk((t - m.at) / m.dur);
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const c of HEAD_AXES) out[c] = m.from[c] + (m.to[c] - m.from[c]) * k;
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/idle.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { approach } from './params.js';
|
|
20
|
+
import { HeadPose } from './head.js';
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* An idle profile. Every state carries one (sparse — missing keys mean these
|
|
@@ -24,7 +25,13 @@ import { approach } from './params.js';
|
|
|
24
25
|
* the layer, because an oscillator whose amplitude steps is a visible pop.
|
|
25
26
|
*
|
|
26
27
|
* sway 0..~1.2 head-drift / brow-drift / shoulder / torso amplitude
|
|
27
|
-
* blinkGap [min,max] seconds between
|
|
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.
|
|
28
35
|
* breathRate multiplier on the 0.23 Hz resting cycle (13.8/min)
|
|
29
36
|
* breathAmp breath excursion scale; cognitive load = faster AND shallower
|
|
30
37
|
* hold { every:[s,s], dur:[s,s] } — freeze the sway (breath continues).
|
|
@@ -41,10 +48,15 @@ import { approach } from './params.js';
|
|
|
41
48
|
* perfectly still. Amplitude rides on `sway`, which is how the
|
|
42
49
|
* cognitive states get the measured sway *suppression* under
|
|
43
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.
|
|
44
54
|
*/
|
|
45
55
|
export const DEFAULT_PROFILE = {
|
|
46
56
|
sway: 1.0,
|
|
57
|
+
settle: [1.8, 4.6],
|
|
47
58
|
blinkGap: [1.9, 5.4],
|
|
59
|
+
slowBlink: 0,
|
|
48
60
|
breathRate: 1.0,
|
|
49
61
|
breathAmp: 1.0,
|
|
50
62
|
hold: null,
|
|
@@ -55,6 +67,26 @@ export const DEFAULT_PROFILE = {
|
|
|
55
67
|
|
|
56
68
|
const rand = ([a, b]) => a + Math.random() * (b - a);
|
|
57
69
|
|
|
70
|
+
// No evoked blink within this long of the last blink. A glance out and back
|
|
71
|
+
// is two large gaze shifts a second apart and carries one blink, not two; and
|
|
72
|
+
// a state change that lands on a gaze shift is one event to the eyelids.
|
|
73
|
+
const BLINK_REFRACTORY = 1.5;
|
|
74
|
+
|
|
75
|
+
// How far into the current gap an odds-rolled evoked blink may land, as a
|
|
76
|
+
// fraction of that gap. A state that moves its gaze oftener than it blinks —
|
|
77
|
+
// SEARCHING_SCREEN hops 42 times a minute against a 10/min budget, and nearly
|
|
78
|
+
// every hop clears the ramp — would otherwise blink at its gaze's rate rather
|
|
79
|
+
// than its own. This lets such a blink only *move* the next one onto a shift,
|
|
80
|
+
// which is the half worth keeping (research-biomechanics.md §5.4: a blink at a
|
|
81
|
+
// boundary is worth several placed at random), and not add one.
|
|
82
|
+
//
|
|
83
|
+
// 0.80 is where the trade turns, measured on tara's targets at 30 min a state:
|
|
84
|
+
// SEARCHING_SCREEN falls from 2.12x its authored rate to 1.06x and
|
|
85
|
+
// REVIEWING_SCREEN from 1.36x to 1.01x, while 54% of the former's blinks still
|
|
86
|
+
// land on a hop. Tighter values buy hundredths of rate and halve that
|
|
87
|
+
// placement — 0.90 holds only 26%.
|
|
88
|
+
const EVOKED_EARLIEST = 0.80;
|
|
89
|
+
|
|
58
90
|
/**
|
|
59
91
|
* Postural weight shift: the one part of this layer that is not periodic, and
|
|
60
92
|
* the part that does most of the work.
|
|
@@ -62,7 +94,7 @@ const rand = ([a, b]) => a + Math.random() * (b - a);
|
|
|
62
94
|
* Everything else here is an oscillator, and an oscillator cannot make a body
|
|
63
95
|
* look alive across a thirty-second hold. Fast enough to notice and it reads
|
|
64
96
|
* as rocking; slow enough not to and it is indistinguishable from a still
|
|
65
|
-
* image. A motion map of the listening state
|
|
97
|
+
* image. A headless motion map of the listening state showed the
|
|
66
98
|
* outer edge of the torso travelling zero pixels over 24 seconds — the sway
|
|
67
99
|
* was there in the numbers and rendered as nothing at all.
|
|
68
100
|
*
|
|
@@ -107,6 +139,58 @@ function nextPosture(prev) {
|
|
|
107
139
|
};
|
|
108
140
|
}
|
|
109
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The head's settle: where a person's head goes while nothing is moving it.
|
|
144
|
+
*
|
|
145
|
+
* It used to be a pair of slow sines per axis, and on tara that was two
|
|
146
|
+
* failures at once. At 0.05-0.09 Hz and under a degree it was too slow to see
|
|
147
|
+
* as motion, so a listening face read as frozen — "a dead stare, only blinks",
|
|
148
|
+
* in the video review that found it — and what could be seen of it was a head
|
|
149
|
+
* that never stops gliding, which is the other failure. A listener's head does
|
|
150
|
+
* neither: it is held, and every few seconds it re-positions — one to two
|
|
151
|
+
* degrees, over about half a second — and is held again (head.js). Pose units
|
|
152
|
+
* at sway 1; on tara a unit of yaw is 10.7°, of pitch 17.1°, of roll 5.7°. The
|
|
153
|
+
* first cut asked for about a degree and a recorded call measured it at 3 px
|
|
154
|
+
* of head travel, which is the frozen listener it was meant to fix.
|
|
155
|
+
*
|
|
156
|
+
* None of these is a nod. The renderer does not acknowledge on its own
|
|
157
|
+
* (CLAUDE.md), so a settle is one move, never a down-and-back — that property,
|
|
158
|
+
* and not a small number, is what keeps pitch off the wire's vocabulary. It was
|
|
159
|
+
* held to under a degree as well, which measured as a listening head with no
|
|
160
|
+
* pitch in it at all (1.05° peak over thirty seconds, against yaw's 5.5°), and
|
|
161
|
+
* a head that only ever turns is a head on a turntable. The range is wider now
|
|
162
|
+
* and still biased upward: a chin that drifts down and stays there is the
|
|
163
|
+
* downcast read, which on a photographed face arrives long before any other.
|
|
164
|
+
*/
|
|
165
|
+
const SETTLE = { yaw: [0.16, 0.34], pitch: [-0.11, 0.08], roll: [0.10, 0.28], dur: [0.45, 0.8], switchP: 0.7 };
|
|
166
|
+
const HOME = { headYaw: 0, headPitch: 0, headRoll: 0 };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The moving hold: what keeps a held head alive.
|
|
170
|
+
*
|
|
171
|
+
* Hold-and-move fixed the floating head, and the same reviewer then found the
|
|
172
|
+
* holds themselves uncanny — "absolute 0-velocity holds", a head locked while
|
|
173
|
+
* only the mouth moves. Animators met this long ago: a character that stops
|
|
174
|
+
* dead reads as a still frame, so a held pose keeps drifting by a fraction of
|
|
175
|
+
* its size: here a few tenths of a degree, small enough that a move still
|
|
176
|
+
* reads as the event. It runs in every state, speech included, and yields to
|
|
177
|
+
* a static hold, which is stillness on purpose.
|
|
178
|
+
*
|
|
179
|
+
* **It is not a sum of sines.** The first cut was, at 0.2-0.6 Hz, and the next
|
|
180
|
+
* review read it at once as floating underwater: a periodic sway is a loop,
|
|
181
|
+
* and people see loops. Human postural sway is a random walk, and it stops —
|
|
182
|
+
* a head drifts for a few seconds, then is quiet for a couple. So each axis
|
|
183
|
+
* glides to a new random offset every 0.6-2.2 s through two smoothing stages
|
|
184
|
+
* (an S-shaped approach, no overshoot, no rhythm), and a gate alternates
|
|
185
|
+
* drifting stretches with near-still ones. The body's idle drift takes the
|
|
186
|
+
* same gate, so a quiet head does not sit on a torso still rocking under it.
|
|
187
|
+
*/
|
|
188
|
+
const LIVE = {
|
|
189
|
+
yaw: 0.05, pitch: 0.02, roll: 0.05, breathPitch: 0.006,
|
|
190
|
+
retarget: [0.6, 2.2], tau: 0.38,
|
|
191
|
+
drift: [2.0, 5.0], still: [1.2, 3.5], stillGain: 0.12,
|
|
192
|
+
};
|
|
193
|
+
|
|
110
194
|
/** Ease so the shift has no corners at either end — a weight shift accelerates
|
|
111
195
|
* and settles, and a linear ramp between two postures reads as a slide. */
|
|
112
196
|
const smoothstep = (x) => x * x * (3 - 2 * x);
|
|
@@ -125,8 +209,18 @@ export class IdleLayer {
|
|
|
125
209
|
this._blinkT = -1;
|
|
126
210
|
this._blinkDur = 0.13;
|
|
127
211
|
this._double = false;
|
|
212
|
+
this._lastBlinkAt = -Infinity;
|
|
128
213
|
// Two incommensurate frequencies per axis so the sway never visibly loops.
|
|
129
214
|
this._ph = [Math.random() * 9, Math.random() * 9, Math.random() * 9];
|
|
215
|
+
// The moving hold (LIVE): per axis a random target, a first stage chasing
|
|
216
|
+
// it and the output chasing that; and the drift/still gate.
|
|
217
|
+
this._liveTo = [0, 0, 0];
|
|
218
|
+
this._liveA = [0, 0, 0];
|
|
219
|
+
this._liveB = [0, 0, 0];
|
|
220
|
+
this._liveAt = 0;
|
|
221
|
+
this._liveOn = true;
|
|
222
|
+
this._liveFlip = rand(LIVE.drift);
|
|
223
|
+
this._liveGate = 1;
|
|
130
224
|
// Glided amplitudes (profiles set the target, these chase it).
|
|
131
225
|
this._sway = 1;
|
|
132
226
|
this._breathAmp = 1;
|
|
@@ -151,6 +245,11 @@ export class IdleLayer {
|
|
|
151
245
|
this._shiftAt = 8;
|
|
152
246
|
this._shiftT0 = -1;
|
|
153
247
|
this._shiftDur = 2;
|
|
248
|
+
// Settle machine: the head's held pose, and when it next moves.
|
|
249
|
+
this._head = new HeadPose();
|
|
250
|
+
this._settleAt = 1 + Math.random() * 2;
|
|
251
|
+
this._settleSide = Math.random() < 0.5 ? -1 : 1;
|
|
252
|
+
this._settled = false;
|
|
154
253
|
// Speech phrasing: a slow gain the talking body's excursion rides on, so
|
|
155
254
|
// it comes in waves rather than as a steady hum.
|
|
156
255
|
this._phrase = 0;
|
|
@@ -179,20 +278,53 @@ export class IdleLayer {
|
|
|
179
278
|
}
|
|
180
279
|
}
|
|
181
280
|
|
|
182
|
-
/**
|
|
183
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Blink now. `evoked` is a blink the face is made to do by something else —
|
|
283
|
+
* a large gaze shift, a state change, a pause in speech — and it is refused
|
|
284
|
+
* inside BLINK_REFRACTORY of the last one, and again before EVOKED_EARLIEST
|
|
285
|
+
* of the way through the current gap. Every blink, evoked or not, restarts
|
|
286
|
+
* the timer: people blink at a rate, and an event moves a blink earlier
|
|
287
|
+
* rather than adding one. Without that, a state that glances or wanders
|
|
288
|
+
* blinks at its timer's rate plus its gaze's, far past the rates in
|
|
289
|
+
* docs/research-biomechanics.md §5 that the gaps are set from.
|
|
290
|
+
*
|
|
291
|
+
* `forced` is a beat the state asked for by name — `glance.blinkTo` and
|
|
292
|
+
* `blinkBack`, the re-engagement the author wanted seen. It clears the budget
|
|
293
|
+
* gate but not the refractory: a state whose authored beats overrun its own
|
|
294
|
+
* rate is a decision to revisit in that state, not something for the eyelids
|
|
295
|
+
* to drop silently.
|
|
296
|
+
*/
|
|
297
|
+
blink(double = false, evoked = false, forced = false) {
|
|
298
|
+
if (evoked) {
|
|
299
|
+
if (this.t - this._lastBlinkAt < BLINK_REFRACTORY) return;
|
|
300
|
+
const gap = this._nextBlink - this._lastBlinkAt;
|
|
301
|
+
if (!forced && this.t - this._lastBlinkAt < EVOKED_EARLIEST * gap) return;
|
|
302
|
+
}
|
|
303
|
+
this._startBlink(0.11 + Math.random() * 0.04, double);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
_startBlink(dur, double) {
|
|
184
307
|
if (this._blinkT >= 0) return;
|
|
185
308
|
this._blinkT = 0;
|
|
186
309
|
this._double = double;
|
|
187
|
-
this._blinkDur =
|
|
310
|
+
this._blinkDur = dur;
|
|
311
|
+
this._lastBlinkAt = this.t;
|
|
312
|
+
this._nextBlink = this.t + rand(this.profile.blinkGap);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A blink placed by speech, at a pause. It also restarts the timer: a speaker
|
|
317
|
+
* who blinks at a clause boundary does not blink again a moment later
|
|
318
|
+
* because a clock said so, and the timer is only there to fill a long run
|
|
319
|
+
* of speech with no pauses in it.
|
|
320
|
+
*/
|
|
321
|
+
phraseBlink() {
|
|
322
|
+
this.blink(false, true);
|
|
188
323
|
}
|
|
189
324
|
|
|
190
325
|
/** A slow, deliberate blink — reads as thinking or fatigue. */
|
|
191
326
|
slowBlink() {
|
|
192
|
-
|
|
193
|
-
this._blinkT = 0;
|
|
194
|
-
this._double = false;
|
|
195
|
-
this._blinkDur = 0.34;
|
|
327
|
+
this._startBlink(0.34, false);
|
|
196
328
|
}
|
|
197
329
|
|
|
198
330
|
_blinkValue(dt) {
|
|
@@ -216,9 +348,13 @@ export class IdleLayer {
|
|
|
216
348
|
const pr = this.profile;
|
|
217
349
|
|
|
218
350
|
if (t >= this._nextBlink) {
|
|
351
|
+
// Re-armed here as well as in _startBlink, for the frame the timer comes
|
|
352
|
+
// due in the middle of a blink already running.
|
|
219
353
|
this._nextBlink = t + rand(pr.blinkGap);
|
|
220
|
-
//
|
|
221
|
-
|
|
354
|
+
// A share of them slow if the state asks for it; roughly one in six of
|
|
355
|
+
// the rest comes in a pair.
|
|
356
|
+
if (Math.random() < pr.slowBlink) this.slowBlink();
|
|
357
|
+
else this.blink(Math.random() < 0.16);
|
|
222
358
|
}
|
|
223
359
|
const blink = this._blinkValue(dt);
|
|
224
360
|
|
|
@@ -327,6 +463,49 @@ export class IdleLayer {
|
|
|
327
463
|
this._flickT = -1;
|
|
328
464
|
}
|
|
329
465
|
|
|
466
|
+
// The settle. Not while talking — speech holds and moves the head itself
|
|
467
|
+
// (prosody.js), and two layers each re-positioning it is a head with two
|
|
468
|
+
// minds — and never inside a static hold, which is stillness on purpose.
|
|
469
|
+
if (pr.settle && this.talk < 0.5) {
|
|
470
|
+
if (t >= this._settleAt && t >= this._holdUntil) {
|
|
471
|
+
this._settleAt = t + rand(pr.settle);
|
|
472
|
+
if (Math.random() < SETTLE.switchP) this._settleSide = -this._settleSide;
|
|
473
|
+
const m = this._sway * this.gain;
|
|
474
|
+
this._head.moveTo({
|
|
475
|
+
headYaw: this._settleSide * rand(SETTLE.yaw) * m,
|
|
476
|
+
headPitch: rand(SETTLE.pitch) * m,
|
|
477
|
+
headRoll: (Math.random() < 0.5 ? -1 : 1) * rand(SETTLE.roll) * m,
|
|
478
|
+
}, t * 1000, rand(SETTLE.dur) * 1000);
|
|
479
|
+
this._settled = true;
|
|
480
|
+
}
|
|
481
|
+
} else if (this._settled) {
|
|
482
|
+
this._settled = false;
|
|
483
|
+
this._head.moveTo(HOME, t * 1000, 600);
|
|
484
|
+
}
|
|
485
|
+
const hs = this._head.sample(t * 1000);
|
|
486
|
+
// The moving hold (LIVE). Only half of it rides on `sway`: a state that
|
|
487
|
+
// turns its drift down still has a living head.
|
|
488
|
+
if (t >= this._liveAt) {
|
|
489
|
+
this._liveAt = t + rand(LIVE.retarget);
|
|
490
|
+
for (let i = 0; i < 3; i++) this._liveTo[i] = Math.random() * 2 - 1;
|
|
491
|
+
}
|
|
492
|
+
if (t >= this._liveFlip) {
|
|
493
|
+
this._liveOn = !this._liveOn;
|
|
494
|
+
this._liveFlip = t + rand(this._liveOn ? LIVE.drift : LIVE.still);
|
|
495
|
+
}
|
|
496
|
+
this._liveGate = approach(this._liveGate, this._liveOn ? 1 : LIVE.stillGain, 0.5, dt);
|
|
497
|
+
for (let i = 0; i < 3; i++) {
|
|
498
|
+
this._liveA[i] = approach(this._liveA[i], this._liveTo[i], LIVE.tau, dt);
|
|
499
|
+
this._liveB[i] = approach(this._liveB[i], this._liveA[i], LIVE.tau, dt);
|
|
500
|
+
}
|
|
501
|
+
const live = this.gain * this._holdAmp * (0.5 + 0.5 * this._sway) * this._liveGate;
|
|
502
|
+
const [ly, lp, lr] = this._liveB;
|
|
503
|
+
hs.headYaw += ly * LIVE.yaw * live;
|
|
504
|
+
hs.headPitch += (lp * LIVE.pitch - Math.sin(this._breathPh) * LIVE.breathPitch * this._breathAmp) * live;
|
|
505
|
+
hs.headRoll += lr * LIVE.roll * live;
|
|
506
|
+
// The body's idle drift, quieted with the head (the speech term is not).
|
|
507
|
+
const ad = a * (0.35 + 0.65 * this._liveGate);
|
|
508
|
+
|
|
330
509
|
// Speech moves the body more, and in waves. `talk` says whether sound is
|
|
331
510
|
// being produced, `_phrase` says how hard this stretch of it is being
|
|
332
511
|
// pushed.
|
|
@@ -344,11 +523,14 @@ export class IdleLayer {
|
|
|
344
523
|
// below the 0.04-0.6 Hz seated trunk band, still calm, but now
|
|
345
524
|
// actually present on screen. `gain` is where a deployment that
|
|
346
525
|
// really is paying for the pixels turns it back down.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
headPitch:
|
|
350
|
-
headRoll:
|
|
351
|
-
|
|
526
|
+
// The head is the exception: it settles rather than sways (SETTLE).
|
|
527
|
+
headYaw: hs.headYaw + flickYaw + post.headYaw * ps,
|
|
528
|
+
headPitch: hs.headPitch + workPitch,
|
|
529
|
+
headRoll: hs.headRoll + post.headRoll * ps,
|
|
530
|
+
// Speech reorganises breathing: a quick inbreath at a pause and a long
|
|
531
|
+
// outbreath over the phrase (research-biomechanics.md §6.1), which the
|
|
532
|
+
// prosody layer draws. Quiet breathing steps mostly back under it.
|
|
533
|
+
breath: breath * this.gain * (1 - 0.6 * this.talk),
|
|
352
534
|
// The brows are never quite still either.
|
|
353
535
|
browRaiseL: s(0, 0.089) * 0.020 * a,
|
|
354
536
|
browRaiseR: s(1, 0.083) * 0.020 * a,
|
|
@@ -368,11 +550,20 @@ export class IdleLayer {
|
|
|
368
550
|
// torso band's mean luminance range falls 28 -> 24. Sway suppression is
|
|
369
551
|
// a statement about drift. Speech reorganises the body; it does not
|
|
370
552
|
// park it.
|
|
371
|
-
|
|
553
|
+
// The speech term is smaller than it was, and that is a handover rather
|
|
554
|
+
// than a reduction. `prosody.js` now puts the body's share of an accent
|
|
555
|
+
// and of a phrase's posture on the shoulders and the lean, timed off
|
|
556
|
+
// the words; this is a pair of sine waves timed off nothing. Two
|
|
557
|
+
// unrelated sources at full strength read as a body that is busy
|
|
558
|
+
// without being about anything, and the one that knows what is being
|
|
559
|
+
// said should be the louder. What is left here is the part prosody
|
|
560
|
+
// cannot supply: motion in the gaps *between* phrases, where there is
|
|
561
|
+
// no accent to hang anything on.
|
|
562
|
+
shoulderL: (s(1, 0.11) * 0.5 + s(2, 0.22) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
|
|
372
563
|
+ workL + post.shoulderL * ps,
|
|
373
|
-
shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 *
|
|
564
|
+
shoulderR: (s(2, 0.10) * 0.5 + s(0, 0.19) * 0.5) * (0.040 * ad + 0.045 * say * this.gain)
|
|
374
565
|
+ workR + post.shoulderR * ps,
|
|
375
|
-
torsoLean: s(0, 0.085) * (0.028 *
|
|
566
|
+
torsoLean: s(0, 0.085) * (0.028 * ad + 0.035 * say * this.gain) + post.torsoLean * ps,
|
|
376
567
|
// The trunk's own drift, small next to the weight shift that dominates
|
|
377
568
|
// this channel. It exists so the body is not perfectly still *between*
|
|
378
569
|
// shifts, which would make each shift read as a discrete event.
|
package/src/interjections.js
CHANGED
|
@@ -119,16 +119,21 @@ const CLIPS = {
|
|
|
119
119
|
keys: {
|
|
120
120
|
// Recognition → take it in → settle. The face does not nod; it lands in
|
|
121
121
|
// a quiet, held receipt, leaving room for the user to see the response.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
// Recognition is quick: everything arrives in the first ~150 ms and then
|
|
123
|
+
// holds. It used to build over the first third of the clip with the lids
|
|
124
|
+
// half-lowered and the trunk sinking, and a video reviewer read that as
|
|
125
|
+
// the head drifting down — "looks like losing consciousness". The lids
|
|
126
|
+
// are a flicker now, not a droop, and the lean is a small forward set.
|
|
127
|
+
browRaiseL: [[0, 0], [0.09, 0.17], [0.30, 0.06], [1, 0]],
|
|
128
|
+
browRaiseR: [[0, 0], [0.09, 0.14], [0.30, 0.05], [1, 0]],
|
|
129
|
+
lidL: [[0, 0], [0.10, 0.09], [0.30, 0.04], [0.70, 0.02], [1, 0]],
|
|
130
|
+
lidR: [[0, 0], [0.10, 0.09], [0.30, 0.04], [0.70, 0.02], [1, 0]],
|
|
131
|
+
mouthPress: [[0, 0], [0.13, 0.20], [0.60, 0.15], [1, 0]],
|
|
132
|
+
mouthCornerL: [[0, 0], [0.18, 0.09], [0.80, 0.055], [1, 0]],
|
|
133
|
+
mouthCornerR: [[0, 0], [0.18, 0.09], [0.80, 0.055], [1, 0]],
|
|
134
|
+
torsoLean: [[0, 0], [0.14, 0.07], [0.70, 0.05], [1, 0]],
|
|
135
|
+
shoulderL: [[0, 0], [0.16, 0.060], [0.70, 0.035], [1, 0]],
|
|
136
|
+
shoulderR: [[0, 0], [0.16, 0.060], [0.70, 0.035], [1, 0]],
|
|
132
137
|
},
|
|
133
138
|
},
|
|
134
139
|
ACK_REALIZE: {
|
|
@@ -164,19 +169,47 @@ const CLIPS = {
|
|
|
164
169
|
// with a barely-there listening tilt reflect the common conversational form
|
|
165
170
|
// the current single deep receipt does not show. It stays comparison-only:
|
|
166
171
|
// the host can decide whether this faster cadence fits its conversation.
|
|
172
|
+
// **Slower than it was, and that is what made it land.** This nod ran two
|
|
173
|
+
// down-beats 381 ms apart — 2.6 Hz, which is past the 1.5 Hz line CLAUDE.md
|
|
174
|
+
// and research-biomechanics.md § 3.4 both draw between "I'm with you" and
|
|
175
|
+
// "hurry up", and which the head's own 160 ms smoothing attenuates to about a
|
|
176
|
+
// third. Authoring it louder was the obvious fix and the wrong one: the
|
|
177
|
+
// gesture was not too small, it was too *fast* to survive the mass of the
|
|
178
|
+
// thing it was moving.
|
|
179
|
+
//
|
|
180
|
+
// At 780 ms between down-beats it is 1.28 Hz, attenuated to about 0.64
|
|
181
|
+
// instead of 0.36, and 1.5 s long — which is where the research puts this
|
|
182
|
+
// kind of nod anyway. § 3.3 separates a `short` continuer (0.83 s, small)
|
|
183
|
+
// from a `long` assessment nod (1.42 s, large range), and an acknowledgement
|
|
184
|
+
// of what someone just said is the second. § 3.4's two structural laws hold:
|
|
185
|
+
// the first cycle is the biggest, and each one decays.
|
|
186
|
+
//
|
|
187
|
+
// **No opening up-beat, and the corpus is why.** An earlier pass gave this a
|
|
188
|
+
// small preparatory lift on the theory that a nod winds up before it starts.
|
|
189
|
+
// § 3.3 separates `long` (large range, *no* swing-up) from `long_p` (large
|
|
190
|
+
// range *with* one, reading as a cognitive shift), so a lift here was quietly
|
|
191
|
+
// making agreement look like realisation. It also broke the rate: the beat
|
|
192
|
+
// sat 330 ms before the first peak, a 1.52 Hz opening half-cycle in a clip
|
|
193
|
+
// whose two down-beats are a comfortable 1.28 Hz apart.
|
|
167
194
|
ACK_NOD: {
|
|
168
|
-
id: 'ACK_NOD', label: 'acknowledge: nod', text: '', duration:
|
|
169
|
-
keys: {
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
id: 'ACK_NOD', label: 'acknowledge: nod', text: '', duration: 1500,
|
|
196
|
+
keys: {
|
|
197
|
+
// Down, up through neutral, down again, settle. The trailing −0.08 flourish
|
|
198
|
+
// that used to sit at 0.94 is gone: it put two *return* beats 570 ms apart,
|
|
199
|
+
// a 1.75 Hz oscillation inside a clip whose down-beats are a comfortable
|
|
200
|
+
// 1.28 Hz. A gesture's last move should be releasing, not one more beat.
|
|
201
|
+
// Each down is the stroke and each up the recovery, so the downs are
|
|
202
|
+
// fast and the ups take their time: a symmetric ramp on both sides read
|
|
203
|
+
// as "a very slow, linear dip" on video. The downs still sit 720 ms
|
|
204
|
+
// apart (1.39 Hz), and a short hold at the bottom is what gives the
|
|
205
|
+
// stroke its weight.
|
|
206
|
+
headPitch: [[0, 0], [0.16, 1.10], [0.24, 1.00], [0.52, -0.20], [0.64, 0.66], [0.72, 0.58], [1, 0]],
|
|
207
|
+
headRoll: [[0, 0], [0.18, -0.090], [0.70, -0.070], [1, 0]],
|
|
208
|
+
torsoLean: [[0, 0], [0.32, 0.130], [0.70, 0.085], [1, 0]],
|
|
209
|
+
shoulderL: [[0, 0], [0.44, 0.062], [0.76, 0.036], [1, 0]],
|
|
210
|
+
shoulderR: [[0, 0], [0.44, 0.062], [0.76, 0.036], [1, 0]],
|
|
211
|
+
lidL: [[0, 0], [0.30, 0.070], [0.68, 0.040], [1, 0]],
|
|
212
|
+
lidR: [[0, 0], [0.30, 0.070], [0.68, 0.040], [1, 0]],
|
|
180
213
|
},
|
|
181
214
|
},
|
|
182
215
|
// The disagree family follows the same three laws as explicit nods (§3.4): the
|
|
@@ -664,10 +697,21 @@ const CLIPS = {
|
|
|
664
697
|
export const INTERNAL_CLIPS = CLIPS;
|
|
665
698
|
|
|
666
699
|
/**
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
700
|
+
* This renderer's own addressable actions — the bundled SVG faces' catalogue,
|
|
701
|
+
* not the wire's vocabulary, which is open and has only two required ids
|
|
702
|
+
* (`docs/contract-wire.md` § Action).
|
|
703
|
+
*
|
|
704
|
+
* Every one of these is a name a server may send *once it knows this renderer
|
|
705
|
+
* is mounted*, and nothing here is a name it may send blind. Two of them are
|
|
706
|
+
* what `ACKNOWLEDGE` resolves to on the floor — `ACK_NOD` while the user still
|
|
707
|
+
* has it, `ACK_RECEIVE` once they have stopped — and `RESPONSE_INTERRUPTED` is
|
|
708
|
+
* required of every avatar, so it appears here as this renderer's shape for it
|
|
709
|
+
* rather than as its own idea. The four `GESTURE_*` are things this body does
|
|
710
|
+
* and no protocol asks for.
|
|
711
|
+
*
|
|
712
|
+
* Naming is `CATEGORY_INTENT`, and the list doubles as the conformance sweep's
|
|
713
|
+
* roster: `ACKNOWLEDGE` is absent on purpose, because sweeping both shapes it
|
|
714
|
+
* resolves to covers strictly more.
|
|
671
715
|
*/
|
|
672
716
|
export const ACTION_IDS = Object.freeze([
|
|
673
717
|
'ACK_RECEIVE', 'ACK_NOD',
|