@voqalize/avatar 0.2.2 → 0.3.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/LICENSE +21 -661
- package/README.md +144 -663
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +523 -0
- package/client/arjun.ts +24 -0
- package/client/createAvatar.ts +139 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +39 -0
- package/client/internal.ts +80 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +24 -0
- package/client/kabir.ts +24 -0
- package/client/meera.ts +24 -0
- package/client/naina.ts +24 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/types.ts +146 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +24 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +196 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +450 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +6 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +18 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +99 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +30 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +33 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +6 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +18 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +6 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +18 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +6 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +18 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +6 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +18 -0
- package/dist/naina.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/types.d.ts +94 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +93 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +6 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +18 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +81 -24
- package/src/avatar.d.ts +161 -120
- package/src/avatar.js +234 -146
- package/src/behavior.d.ts +26 -0
- package/src/behavior.js +102 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +4 -3
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +119 -4
- package/src/hand.js +120 -91
- package/src/idle.js +18 -101
- package/src/interjections.js +156 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +28 -12
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
package/src/avatar.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The mixer — a programmable talking head.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Not the public surface: that is `createAvatar({ mount, client })` in
|
|
5
|
+
* `packages/avatar/client/createAvatar.ts`, and this is what it drives. Everything below is
|
|
6
|
+
* addressed by our own runtime, our tooling, and an avatar author who chose to
|
|
7
|
+
* build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
|
|
5
8
|
*
|
|
9
|
+
* const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
|
|
6
10
|
* avatar.setState('LISTENING', { emotion: 'warm' })
|
|
7
11
|
* avatar.setGaze('SCREEN_LEFT')
|
|
8
12
|
* avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
|
|
9
13
|
* avatar.pushCues(moreCues) // streaming top-up
|
|
10
|
-
* avatar.
|
|
11
|
-
* avatar.
|
|
14
|
+
* avatar.action('ACK_RECEIVE')
|
|
15
|
+
* avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
|
|
12
16
|
* avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
|
|
13
17
|
* avatar.setUserSpeaking(bool) // the user has the floor, so listening
|
|
14
18
|
* // is contingent instead of timed
|
|
@@ -26,25 +30,33 @@
|
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
|
|
29
|
-
import { createFace as createPeepFace, META as peepMeta } from './face-peep.js';
|
|
30
|
-
import { createFace as createWrenFace, META as wrenMeta } from './face-wren.js';
|
|
31
|
-
import { createFace as createMynaFace, META as mynaMeta } from './face-myna.js';
|
|
32
33
|
import { emotionPose } from './emotions.js';
|
|
33
|
-
import { GazeLayer, GAZE_TARGETS } from './gaze.js';
|
|
34
|
+
import { GazeLayer, GAZE_TARGETS, AVERSION } from './gaze.js';
|
|
34
35
|
import { IdleLayer, ListeningEngine } from './idle.js';
|
|
35
36
|
import { ClipPlayer } from './clips.js';
|
|
36
|
-
import {
|
|
37
|
+
import { ACTIONS, INTERNAL_CLIPS } from './interjections.js';
|
|
37
38
|
import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
|
|
38
39
|
import { PerformTrack } from './perform.js';
|
|
39
|
-
import { createHand, HAND_GESTURES } from './hand.js';
|
|
40
|
+
import { createHand, HAND_GESTURES, HAND_ACTION_TO_FRAME_GESTURE } from './hand.js';
|
|
41
|
+
import { avatarFrame, createSvgRig } from './rig.js';
|
|
40
42
|
|
|
41
43
|
// Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
|
|
42
44
|
// in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
|
|
43
45
|
// alone separates listening (~16/min) from thinking (~25/min) from visually
|
|
44
46
|
// busy (~9/min), and it is the cheapest state signal the rig has.
|
|
45
47
|
export const STATES = {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
// Idle means present but occupied with one's own quiet business. It must not
|
|
49
|
+
// compete with LISTENING's sustained user attention: the default target is
|
|
50
|
+
// away from the user and the wander only visits other non-task targets.
|
|
51
|
+
IDLE: { gaze: 'AWAY_THINKING', emotion: 'neutral', engagement: false,
|
|
52
|
+
idle: { sway: 0.72, blinkGap: [4.6, 6.6] },
|
|
53
|
+
wander: { targets: ['AWAY_THINKING', 'AWAY_RIGHT', 'NOTES'], every: [3.6, 6.8] } },
|
|
54
|
+
// `aversion` is why this state does not stare. Continuous eye contact is not
|
|
55
|
+
// the attentive pose it looks like — it is a demand for more talk (Rossano)
|
|
56
|
+
// and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
|
|
57
|
+
// gaze.js for the numbers; the mixer holds it off near a turn boundary.
|
|
58
|
+
LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
|
|
59
|
+
aversion: 'LISTEN',
|
|
48
60
|
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
49
61
|
pose: { browRaiseL: 0.06, browRaiseR: 0.06, lidL: -0.04, lidR: -0.04 } },
|
|
50
62
|
// Faster, shallower breath is the measured cognitive-load signature, and the
|
|
@@ -53,13 +65,13 @@ export const STATES = {
|
|
|
53
65
|
// leads DOWN (39% of measured cognitive aversions, §4.2) and wanders on the
|
|
54
66
|
// ~3.5s cognitive-aversion cadence, coming back to the user roughly one
|
|
55
67
|
// dwell in four — still with you, working.
|
|
56
|
-
THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful',
|
|
68
|
+
THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', engagement: false,
|
|
57
69
|
idle: { sway: 0.7, blinkGap: [2.1, 2.7], breathRate: 1.18, breathAmp: 0.7,
|
|
58
70
|
hold: { every: [4.5, 9.0], dur: [0.8, 1.5] } },
|
|
59
71
|
wander: { targets: ['AWAY_DOWN', 'AWAY_DOWN', 'AWAY_THINKING', 'USER'],
|
|
60
72
|
every: [2.6, 4.4] } },
|
|
61
|
-
SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 },
|
|
62
|
-
REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful',
|
|
73
|
+
SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, engagement: false },
|
|
74
|
+
REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
|
|
63
75
|
idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
|
|
64
76
|
wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_RIGHT', 'SCREEN_TOP', 'SCREEN_WORK'],
|
|
65
77
|
every: [1.8, 5.0] } },
|
|
@@ -67,7 +79,7 @@ export const STATES = {
|
|
|
67
79
|
// multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
|
|
68
80
|
// is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
|
|
69
81
|
// size, still gentle. Every other channel in this pose read fine on screen.
|
|
70
|
-
WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging',
|
|
82
|
+
WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
|
|
71
83
|
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
72
84
|
pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
|
|
73
85
|
// Straining to hear. The one state where the amplitude constraint yields,
|
|
@@ -75,11 +87,11 @@ export const STATES = {
|
|
|
75
87
|
// engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
|
|
76
88
|
// favors the speaker while the eyes hold contact, and a concentration
|
|
77
89
|
// squint with knit brows. Stillness does the rest — straining people
|
|
78
|
-
// freeze — so holds are frequent and there
|
|
90
|
+
// freeze — so holds are frequent and there is no engagement lean: you don't
|
|
79
91
|
// nod along to what you can't hear. Server sends it on soft/low-SNR user
|
|
80
92
|
// audio, typically followed by SORRY or a "could you repeat" utterance.
|
|
81
93
|
CANT_HEAR: {
|
|
82
|
-
gaze: 'USER_EAR', emotion: 'neutral',
|
|
94
|
+
gaze: 'USER_EAR', emotion: 'neutral', engagement: false,
|
|
83
95
|
idle: { sway: 0.5, blinkGap: [4.5, 6.5],
|
|
84
96
|
hold: { every: [2.5, 5.5], dur: [1.0, 1.8] } },
|
|
85
97
|
// A minimal line face swallows small deltas — the ink moves whole units
|
|
@@ -94,6 +106,33 @@ export const STATES = {
|
|
|
94
106
|
mouthPress: 0.45, mouthCornerL: -0.22, mouthCornerR: -0.22,
|
|
95
107
|
},
|
|
96
108
|
},
|
|
109
|
+
// The user's microphone is closed, and the agent is the one that closed it.
|
|
110
|
+
// Authored against CANT_HEAR as its exact inverse, because that contrast is
|
|
111
|
+
// the whole read: straining leans *in* to get more of the user, this settles
|
|
112
|
+
// *back* — nothing more is coming through and the avatar knows why. The eyes
|
|
113
|
+
// stay on the user, because a deliberate hold is not inattention; what
|
|
114
|
+
// carries it is the mouth, pressed shut and staying shut, and the slow,
|
|
115
|
+
// unhurried blink of waiting rather than working. No engagement lean: you
|
|
116
|
+
// cannot nod along to a channel you have muted yourself. No filter, ever —
|
|
117
|
+
// DEGRADED and OFFLINE own "something is broken", and this is a decision.
|
|
118
|
+
MUTED: {
|
|
119
|
+
gaze: 'USER', emotion: 'neutral', engagement: false,
|
|
120
|
+
idle: { sway: 0.45, blinkGap: [4.5, 7.0],
|
|
121
|
+
hold: { every: [3.0, 6.0], dur: [0.9, 1.6] } },
|
|
122
|
+
// Line-face scaled (see CANT_HEAR): peep's resting mouth is drawn smiling,
|
|
123
|
+
// so a closed mouth has to be authored clearly past flat to read as closed
|
|
124
|
+
// at all. Corners were -0.20 first, which rendered as a straight line and
|
|
125
|
+
// at 130 px against LISTENING said nothing — the delta a viewer gets is
|
|
126
|
+
// curvature, and flat is the halfway point of it, not the end. -0.34 is as
|
|
127
|
+
// far as it goes before the hold starts reading as sulking. No squint and
|
|
128
|
+
// no lid drop: one is straining, the other was asleep. browInner carries
|
|
129
|
+
// the "one moment" without the worry lift.
|
|
130
|
+
pose: {
|
|
131
|
+
torsoLean: -0.28, headPitch: 0.04,
|
|
132
|
+
browRaiseL: 0.08, browRaiseR: 0.06, browInnerL: 0.28, browInnerR: 0.22,
|
|
133
|
+
mouthPress: 0.78, mouthCornerL: -0.34, mouthCornerR: -0.34,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
97
136
|
// --- application state ---------------------------------------------------
|
|
98
137
|
// "Momentarily busy on the thing you asked for." No hands in frame, so the
|
|
99
138
|
// whole read comes from four cheap cues (docs/research-biomechanics.md §6.4):
|
|
@@ -102,12 +141,12 @@ export const STATES = {
|
|
|
102
141
|
// is what says activity rather than rocking — and, the important one, a
|
|
103
142
|
// brief glance back up to the user every few seconds. The glance is the tell
|
|
104
143
|
// that the user has not been forgotten; without it, busy is just absent.
|
|
105
|
-
|
|
144
|
+
WORKING: {
|
|
106
145
|
// SCREEN_WORK, not NOTES: on a steep down target the gaze layer's lid
|
|
107
146
|
// follow seals the eyes, and at tile size shut eyes read as asleep, not
|
|
108
147
|
// busy. A mild down-left with the head pitched into it keeps the iris in
|
|
109
148
|
// the opening — eyes down but awake.
|
|
110
|
-
gaze: 'SCREEN_WORK', emotion: 'neutral',
|
|
149
|
+
gaze: 'SCREEN_WORK', emotion: 'neutral', engagement: false,
|
|
111
150
|
idle: { sway: 0.6, blinkGap: [6.0, 7.5], breathRate: 1.05,
|
|
112
151
|
rhythm: { amp: 0.05, freq: 2.2 } },
|
|
113
152
|
glance: { to: 'USER', every: [4, 7], hold: [0.7, 1.1] },
|
|
@@ -123,7 +162,7 @@ export const STATES = {
|
|
|
123
162
|
// DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
|
|
124
163
|
// it" — a server will typically sequence DEGRADED → TYPING_CHAT.
|
|
125
164
|
TYPING_CHAT: {
|
|
126
|
-
gaze: 'SCREEN_WORK', emotion: 'neutral',
|
|
165
|
+
gaze: 'SCREEN_WORK', emotion: 'neutral', engagement: false,
|
|
127
166
|
idle: { sway: 0.6, blinkGap: [5.5, 7.0], breathRate: 1.05,
|
|
128
167
|
rhythm: { amp: 0.055, freq: 2.5 } },
|
|
129
168
|
glance: { to: 'USER', every: [3.2, 5.5], hold: [1.2, 2.0] },
|
|
@@ -136,12 +175,12 @@ export const STATES = {
|
|
|
136
175
|
},
|
|
137
176
|
// Attention genuinely elsewhere. What separates this from TYPING is target
|
|
138
177
|
// *stability* (§6.4): busy is one steady off-user target, distracted is
|
|
139
|
-
// wandering ones, held long (aversion >3s), with no
|
|
178
|
+
// wandering ones, held long (aversion >3s), with no engagement lean — the
|
|
140
179
|
// missing nod is as diagnostic as the look-away. Sway is looser than
|
|
141
180
|
// LISTENING because attention is what was holding the body still. The
|
|
142
181
|
// widget only looks away; deciding when to snap back is the server's call.
|
|
143
182
|
DISTRACTED: {
|
|
144
|
-
gaze: 'AWAY_RIGHT', emotion: 'neutral',
|
|
183
|
+
gaze: 'AWAY_RIGHT', emotion: 'neutral', engagement: false,
|
|
145
184
|
idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
|
|
146
185
|
// Sideways and up, never steep-down: lateral is where real intimacy/
|
|
147
186
|
// distraction aversions live, and a steep down target seals this rig's
|
|
@@ -156,7 +195,7 @@ export const STATES = {
|
|
|
156
195
|
// wiggle nobody makes while merely reading. Server semantics: a filler
|
|
157
196
|
// while an async activity completes; the server exits it when done.
|
|
158
197
|
SEARCHING_SCREEN: {
|
|
159
|
-
gaze: 'SCREEN_CENTER', emotion: 'neutral',
|
|
198
|
+
gaze: 'SCREEN_CENTER', emotion: 'neutral', engagement: false,
|
|
160
199
|
idle: { sway: 0.65, blinkGap: [5.0, 6.8], breathRate: 1.05,
|
|
161
200
|
flick: { amp: 0.30, every: [3.5, 7.0] } },
|
|
162
201
|
wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_TOP', 'SCREEN_WORK',
|
|
@@ -180,7 +219,7 @@ export const STATES = {
|
|
|
180
219
|
// use to predict that someone is about to speak. The head comes *up* rather
|
|
181
220
|
// than down: a lowered head is deferential and reads as yielding.
|
|
182
221
|
TAKING_FLOOR: {
|
|
183
|
-
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 },
|
|
222
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, engagement: false,
|
|
184
223
|
pose: {
|
|
185
224
|
browRaiseL: 0.26, browRaiseR: 0.22, lidL: -0.10, lidR: -0.10,
|
|
186
225
|
headPitch: -0.10, torsoLean: 0.22, shoulderL: 0.30, shoulderR: 0.30,
|
|
@@ -193,7 +232,7 @@ export const STATES = {
|
|
|
193
232
|
// purpose — stillness is what makes it read as intent rather than as fidget),
|
|
194
233
|
// leaning in, lips apart and staying apart.
|
|
195
234
|
WANTS_IN: {
|
|
196
|
-
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 },
|
|
235
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, engagement: false,
|
|
197
236
|
pose: {
|
|
198
237
|
browRaiseL: 0.42, browRaiseR: 0.38, lidL: -0.14, lidR: -0.14,
|
|
199
238
|
headPitch: -0.14, torsoLean: 0.42, shoulderL: 0.45, shoulderR: 0.45,
|
|
@@ -204,17 +243,17 @@ export const STATES = {
|
|
|
204
243
|
// happen faster than anything else on the face — see YIELD_FLOOR, which is
|
|
205
244
|
// what actually delivers the snap.
|
|
206
245
|
YIELDED: {
|
|
207
|
-
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 },
|
|
246
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, engagement: false,
|
|
208
247
|
pose: {
|
|
209
248
|
browRaiseL: 0.10, browRaiseR: 0.08,
|
|
210
249
|
torsoLean: -0.18, shoulderL: -0.12, shoulderR: -0.12,
|
|
211
250
|
},
|
|
212
251
|
},
|
|
213
252
|
|
|
214
|
-
DEGRADED: { gaze: 'USER', emotion: 'neutral',
|
|
253
|
+
DEGRADED: { gaze: 'USER', emotion: 'neutral', engagement: false,
|
|
215
254
|
idle: { sway: 0.4, blinkGap: [4.0, 8.0] },
|
|
216
255
|
pose: { lidL: 0.3, lidR: 0.3 }, filter: 'grayscale(.55) brightness(.82)' },
|
|
217
|
-
OFFLINE: { gaze: 'USER', emotion: 'neutral',
|
|
256
|
+
OFFLINE: { gaze: 'USER', emotion: 'neutral', engagement: false,
|
|
218
257
|
idle: { sway: 0.15, blinkGap: [9, 15] },
|
|
219
258
|
pose: { lidL: 0.95, lidR: 0.95, mouthCornerL: 0, mouthCornerR: 0 },
|
|
220
259
|
filter: 'grayscale(1) brightness(.6)' },
|
|
@@ -222,63 +261,26 @@ export const STATES = {
|
|
|
222
261
|
|
|
223
262
|
export const STATE_NAMES = Object.keys(STATES);
|
|
224
263
|
|
|
225
|
-
/**
|
|
226
|
-
* The avatars this rig can wear: `{ create, meta }` records.
|
|
227
|
-
*
|
|
228
|
-
* `create` is `createFace(mount, theme) -> { svg, apply, theme, destroy }`,
|
|
229
|
-
* callable standalone — the rig tooling drives faces with no mixer attached.
|
|
230
|
-
* That behavioural contract is still the whole of what the *rig* needs:
|
|
231
|
-
* everything else — visemes, emotions, gaze, idle, clips, the mixer — works
|
|
232
|
-
* in parameter space and never learns which face it is driving.
|
|
233
|
-
*
|
|
234
|
-
* `meta` is the avatar descriptor (viewBox, mouthCrop — see META in any face
|
|
235
|
-
* module): the things a HOST or a TOOL needs to frame a face without opening
|
|
236
|
-
* it. This registry was once factories-only, on the argument that a schema
|
|
237
|
-
* guessed from two faces would be wrong; the third face settled it. Every rig
|
|
238
|
-
* needed exactly a framing rect and a mouth rect to stop the tooling from
|
|
239
|
-
* hard-coding per-avatar tables, and nothing else — so that is all meta
|
|
240
|
-
* carries.
|
|
241
|
-
*
|
|
242
|
-
* The key is the avatar's name, not its rank. It used to be possible to read
|
|
243
|
-
* rank into it — the original rig was keyed `default`, which became a lie the
|
|
244
|
-
* moment it stopped being the one we ship. DEFAULT_AVATAR below is the only
|
|
245
|
-
* place the choice is made.
|
|
246
|
-
*
|
|
247
|
-
* Two earlier rigs, `classic` and `blue-shirt`, were removed on 2026-08-06:
|
|
248
|
-
* stakeholders accepted the line-art pair and rejected both of the others, so
|
|
249
|
-
* carrying them was maintenance against art nobody wanted. What they taught
|
|
250
|
-
* the abstraction survives them — `face-core.js` exists because all three of
|
|
251
|
-
* the first rigs wrote the same apply(), and META exists because all three
|
|
252
|
-
* needed the same two rects. Their code is in git history if a lesson ever
|
|
253
|
-
* needs re-reading.
|
|
254
|
-
*/
|
|
255
|
-
export const AVATARS = {
|
|
256
|
-
peep: { create: createPeepFace, meta: peepMeta },
|
|
257
|
-
wren: { create: createWrenFace, meta: wrenMeta },
|
|
258
|
-
myna: { create: createMynaFace, meta: mynaMeta },
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
export const AVATAR_NAMES = Object.keys(AVATARS);
|
|
262
|
-
|
|
263
|
-
/** The avatar a host gets when it does not ask for one. */
|
|
264
|
-
export const DEFAULT_AVATAR = 'peep';
|
|
265
|
-
|
|
266
264
|
export function createAvatar(opts = {}) {
|
|
267
265
|
const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
|
|
268
266
|
if (!mount) throw new Error('createAvatar: mount element required');
|
|
269
267
|
|
|
270
|
-
// `opts.
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
268
|
+
// `opts.face` is a Face record — `{ create, meta }`, one per face module. It
|
|
269
|
+
// is passed in rather than named, because a name would need a table, and a
|
|
270
|
+
// table would need every face imported to answer any lookup: three drawings
|
|
271
|
+
// in every consumer's bundle to render one. `src/faces.js` still has that
|
|
272
|
+
// table, for tooling that genuinely wants all of them.
|
|
273
|
+
const entry = opts.rig ? null : opts.face;
|
|
274
|
+
if (!opts.rig && !entry) {
|
|
275
|
+
throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
|
|
276
276
|
}
|
|
277
|
-
const face = entry.create(mount, opts.theme);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
277
|
+
const face = entry ? entry.create(mount, opts.theme) : null;
|
|
278
|
+
// A renderer-neutral rig needs no SVG descriptor: `meta` is what SVG hosts
|
|
279
|
+
// and tools frame the drawing with, never a requirement of the rig contract.
|
|
280
|
+
// A face carries its own — a `{ create }` with no META used to be tolerated
|
|
281
|
+
// here and the viewBox re-read off the produced svg, which meant a face could
|
|
282
|
+
// ship half a descriptor and nothing would say so.
|
|
283
|
+
const meta = face ? entry.meta : null;
|
|
282
284
|
const gaze = new GazeLayer();
|
|
283
285
|
const idle = new IdleLayer();
|
|
284
286
|
const speech = new VisemeTrack();
|
|
@@ -288,19 +290,21 @@ export function createAvatar(opts = {}) {
|
|
|
288
290
|
onGaze: (g) => { gazeOverrideByClip = g; applyGaze(); },
|
|
289
291
|
onBlink: () => idle.blink(),
|
|
290
292
|
});
|
|
291
|
-
const
|
|
293
|
+
const engagement = new ListeningEngine();
|
|
292
294
|
const performTrack = new PerformTrack();
|
|
293
|
-
// The hand is a
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
295
|
+
// The current SVG hand is a renderer adapter for the first-class `frame.hand`
|
|
296
|
+
// control. `hand: false` only disables its SVG rendering; gesture actions
|
|
297
|
+
// still emit the semantic hand frame for a supplied custom rig.
|
|
298
|
+
const hand = face && opts.hand !== false ? createHand(face.svg, face.theme, meta, { dir: opts.handSide }) : null;
|
|
299
|
+
// The existing SVG face and hand are one migration adapter implementing the
|
|
300
|
+
// renderer-agnostic AvatarRig contract. New renderers never need face SVG
|
|
301
|
+
// coordinates or the hand layer's private geometry.
|
|
302
|
+
const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
|
|
299
303
|
|
|
300
304
|
gaze.onLargeShift = () => idle.blink();
|
|
301
305
|
|
|
302
306
|
const listeners = {
|
|
303
|
-
state: [], speakEnd: [], clipEnd: [],
|
|
307
|
+
state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
|
|
304
308
|
};
|
|
305
309
|
const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
|
|
306
310
|
clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
|
|
@@ -323,6 +327,9 @@ export function createAvatar(opts = {}) {
|
|
|
323
327
|
// excursion changes. Values above ~1.5 saturate the open vowels against the
|
|
324
328
|
// channel clamp, which is the intended ceiling rather than a bug.
|
|
325
329
|
let mouthGain = opts.mouthGain ?? 1;
|
|
330
|
+
let handSide = opts.handSide === -1 ? 'left' : 'right';
|
|
331
|
+
let handAction = null;
|
|
332
|
+
const handQueue = [];
|
|
326
333
|
// Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
|
|
327
334
|
// peaks at 149ms — but the head smooths at a 160ms time constant, so barely
|
|
328
335
|
// 60% of an authored peak is ever rendered. The keyframes were written against
|
|
@@ -340,6 +347,7 @@ export function createAvatar(opts = {}) {
|
|
|
340
347
|
let slowBlinkAt = 0;
|
|
341
348
|
let glanceAt = 0;
|
|
342
349
|
let glanceUntil = 0;
|
|
350
|
+
let attendUntil = 0;
|
|
343
351
|
let speakClock = null;
|
|
344
352
|
let speakStart = 0;
|
|
345
353
|
|
|
@@ -358,7 +366,7 @@ export function createAvatar(opts = {}) {
|
|
|
358
366
|
// `manual` withholds the rAF loop so a tool can drive frames itself. The
|
|
359
367
|
// baseline pages could already step a ClipPlayer by hand, but nothing could
|
|
360
368
|
// step the *mixer* — which is where idle, gaze and engagement actually
|
|
361
|
-
// compose — so motion had no reproducible render. See tools/motion.mjs.
|
|
369
|
+
// compose — so motion had no reproducible render. See apps/authoring/tools/motion.mjs.
|
|
362
370
|
const manual = !!opts.manual;
|
|
363
371
|
|
|
364
372
|
function frame(now) {
|
|
@@ -427,23 +435,31 @@ export function createAvatar(opts = {}) {
|
|
|
427
435
|
setGaze(gl.to);
|
|
428
436
|
}
|
|
429
437
|
}
|
|
430
|
-
|
|
431
|
-
|
|
438
|
+
// Aversion is a property of the state, but it is held off around a turn
|
|
439
|
+
// boundary: the floor is handed over under mutual gaze, and an avatar that
|
|
440
|
+
// looks away exactly as the user finishes has declined it. `attend` is the
|
|
441
|
+
// mixer's one-frame veto — anything that means "the user is checking
|
|
442
|
+
// whether I am with them" sets it (see api.attend).
|
|
443
|
+
gaze.setAversion(st.aversion ? AVERSION[st.aversion] : null);
|
|
444
|
+
gaze.hold = attendUntil > elapsed || clip.playing;
|
|
445
|
+
|
|
446
|
+
engagement.enabled = !!st.engagement && !clip.playing;
|
|
447
|
+
engagement.update(dt);
|
|
432
448
|
// Engagement posture: forward lean while the user holds the floor, spent
|
|
433
449
|
// only in the states that are *about* the user holding the floor. The
|
|
434
450
|
// research (docs/research-biomechanics.md §6.3) puts sustained attentive
|
|
435
451
|
// lean at +0.15–0.25; engage glides, and torsoLean's 0.24s tau smooths
|
|
436
452
|
// the state gate, so the lean arrives and leaves like weight shifting.
|
|
437
|
-
if (st.
|
|
453
|
+
if (st.engagement) target.torsoLean += 0.16 * engagement.engage;
|
|
438
454
|
// Straining leans harder while there is actually a faint voice to strain
|
|
439
455
|
// after. engage already tracks "the user is (barely) talking", so this
|
|
440
456
|
// costs nothing; with no user signal the static pose carries the state.
|
|
441
|
-
else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 *
|
|
457
|
+
else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * engagement.engage;
|
|
442
458
|
|
|
443
459
|
// 4. mouth. The server's viseme track wins; a clip's mouth track fills the
|
|
444
460
|
// gaps. There is deliberately no third leg: with no cues the mouth stays
|
|
445
461
|
// shut, and a still mouth under speech is the *visible* symptom of a
|
|
446
|
-
// backend that could not align
|
|
462
|
+
// backend that could not align.
|
|
447
463
|
const clipOut = clip.update(dtMs);
|
|
448
464
|
let mouth = speech.sample();
|
|
449
465
|
let mouthOwner = mouth ? 'speech' : null;
|
|
@@ -506,17 +522,11 @@ export function createAvatar(opts = {}) {
|
|
|
506
522
|
// 8. smooth toward the target — this is where co-articulation happens
|
|
507
523
|
for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
|
|
508
524
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// targets to chase. `elapsed` is the mixer's own clock, so a manual
|
|
515
|
-
// stepper gets a reproducible gesture for free.
|
|
516
|
-
if (hand) {
|
|
517
|
-
const done = hand.update(elapsed * 1000);
|
|
518
|
-
if (done) emit('gestureEnd', done.id);
|
|
519
|
-
}
|
|
525
|
+
// 9. First-class hand control. The semantic frame is generated here, above
|
|
526
|
+
// every renderer, so SVG, WebGL, and video rigs receive exactly the same
|
|
527
|
+
// gesture/progress information. A handless rig simply ignores `frame.hand`.
|
|
528
|
+
const handFrame = updateHandAction(elapsed * 1000);
|
|
529
|
+
rig.apply(avatarFrame(cur, handFrame || undefined));
|
|
520
530
|
}
|
|
521
531
|
|
|
522
532
|
const REST_SHAPE = shapeFor(SILENT, 1);
|
|
@@ -554,9 +564,12 @@ export function createAvatar(opts = {}) {
|
|
|
554
564
|
// fire a stale timestamp immediately.
|
|
555
565
|
glanceUntil = 0;
|
|
556
566
|
glanceAt = elapsed + (st.glance ? st.glance.every[0] + Math.random() * (st.glance.every[1] - st.glance.every[0]) : 0);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
face
|
|
567
|
+
// SVG's desaturation filter is a legacy renderer detail. A generic rig
|
|
568
|
+
// receives the same state pose and may express degradation its own way.
|
|
569
|
+
if (face) {
|
|
570
|
+
face.svg.style.filter = st.filter || '';
|
|
571
|
+
face.svg.style.transition = 'filter .5s ease';
|
|
572
|
+
}
|
|
560
573
|
if (changed) { idle.blink(); emit('state', name); }
|
|
561
574
|
return api;
|
|
562
575
|
}
|
|
@@ -578,8 +591,9 @@ export function createAvatar(opts = {}) {
|
|
|
578
591
|
* @param {() => number} [o.clock] custom ms clock, if you drive audio yourself
|
|
579
592
|
*/
|
|
580
593
|
function speak(o = {}) {
|
|
581
|
-
//
|
|
582
|
-
|
|
594
|
+
// Speech owns the mouth in the mixer, but a server action still gets to
|
|
595
|
+
// complete its physical landing on the other channels. Do not cancel it
|
|
596
|
+
// here: a hand or head cannot disappear simply because playout began.
|
|
583
597
|
speakStart = performance.now();
|
|
584
598
|
speakClock = o.clock
|
|
585
599
|
? o.clock
|
|
@@ -592,15 +606,48 @@ export function createAvatar(opts = {}) {
|
|
|
592
606
|
return api;
|
|
593
607
|
}
|
|
594
608
|
|
|
609
|
+
/**
|
|
610
|
+
* "The user may be checking whether I am with them — hold their eyes."
|
|
611
|
+
*
|
|
612
|
+
* This is the widget's half of the **gaze window**. In face-to-face talk a
|
|
613
|
+
* speaker periodically looks at the listener, mutual gaze is established, the
|
|
614
|
+
* listener responds inside that window, and the speaker looks away again
|
|
615
|
+
* (Bavelas, Coates & Johnson 2002) — listener responses cluster inside the
|
|
616
|
+
* window rather than being scattered across the turn.
|
|
617
|
+
*
|
|
618
|
+
* We cannot see the user, so we cannot observe the window opening. What a
|
|
619
|
+
* caller *can* do is name the moments that co-occur with it — a mid-turn
|
|
620
|
+
* pause, a tag question ("...right?", "you know?"), a completed clause with
|
|
621
|
+
* the turn analyzer's completion probability high, the user answering a
|
|
622
|
+
* question the bot asked. `attend(ms)` is how those arrive: for its duration
|
|
623
|
+
* the face stops averting and holds the user, which is the prerequisite for
|
|
624
|
+
* any response to be *seen*. Emitting the response itself stays a separate
|
|
625
|
+
* call — a window that opens and draws nothing is a real and common outcome
|
|
626
|
+
* (with every measured invitation cue present, humans respond to only ~30% of
|
|
627
|
+
* opportunities), and conflating the two would make the avatar answer
|
|
628
|
+
* everything. The explicit response remains a backend/application decision.
|
|
629
|
+
*
|
|
630
|
+
* Deliberately **not on the wire yet**: there is no `attend` command in
|
|
631
|
+
* `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
|
|
632
|
+
* the rig pages). Adding the command is a protocol change and waits for a
|
|
633
|
+
* server that has something real to key it off — see docs/internal-mixer.md.
|
|
634
|
+
*
|
|
635
|
+
* @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
|
|
636
|
+
* mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
|
|
637
|
+
*/
|
|
638
|
+
function attend(ms = 1200) {
|
|
639
|
+
attendUntil = Math.max(attendUntil, elapsed + ms / 1000);
|
|
640
|
+
return api;
|
|
641
|
+
}
|
|
642
|
+
|
|
595
643
|
function pushCues(cues) { speech.push(cues); return api; }
|
|
596
644
|
|
|
597
645
|
function stopSpeaking() { speech.stop(); return api; }
|
|
598
646
|
|
|
599
647
|
function interject(id) {
|
|
600
|
-
const c =
|
|
648
|
+
const c = INTERNAL_CLIPS[id];
|
|
601
649
|
if (!c) throw new Error(`unknown interjection: ${id}`);
|
|
602
650
|
clip.play(c, c.audioEl);
|
|
603
|
-
backchannel.reset(3.5);
|
|
604
651
|
return api;
|
|
605
652
|
}
|
|
606
653
|
|
|
@@ -610,34 +657,70 @@ export function createAvatar(opts = {}) {
|
|
|
610
657
|
*
|
|
611
658
|
* The face half is not a convenience — a hand rising to the jaw over a head
|
|
612
659
|
* and shoulders sitting perfectly still is a cut-out, not a gesture. Each
|
|
613
|
-
* entry in HAND_GESTURES names
|
|
614
|
-
* already tuned (`WAVE`, `THUMBS_UP`, `ONE_MOMENT`); firing it here is the
|
|
660
|
+
* entry in HAND_GESTURES names the matching semantic face action; firing it here is the
|
|
615
661
|
* library composing two authored things, not the client inventing motion.
|
|
616
662
|
*
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
* no new behaviour until it asks for one — and on an avatar mounted with
|
|
620
|
-
* `hand: false` this call degrades to exactly that interjection, which is the
|
|
621
|
-
* same graceful failure the arm removal already forced every id through.
|
|
663
|
+
* On an avatar mounted with `hand: false` this internal helper degrades to
|
|
664
|
+
* the face action alone.
|
|
622
665
|
*/
|
|
623
666
|
function gesture(id) {
|
|
624
667
|
const def = HAND_GESTURES[id];
|
|
625
668
|
if (!def) throw new Error(`unknown hand gesture: ${id}`);
|
|
626
669
|
if (hand) hand.play(id, elapsed * 1000);
|
|
627
670
|
if (def.face) interject(def.face);
|
|
628
|
-
// A hand in frame is a deliberate move; a backchannel landing on top of it
|
|
629
|
-
// is the listening engine talking over the server.
|
|
630
|
-
backchannel.reset(def.dur / 1000 + 0.5);
|
|
631
671
|
return api;
|
|
632
672
|
}
|
|
633
673
|
|
|
674
|
+
/** One self-completing server action. State continues to resolve underneath;
|
|
675
|
+
* face and hand layers queue their next movement so an in-flight physical
|
|
676
|
+
* gesture always gets to land. */
|
|
677
|
+
function action(id) {
|
|
678
|
+
const handDef = HAND_GESTURES[id];
|
|
679
|
+
if (handDef) {
|
|
680
|
+
startHandAction(id, handDef);
|
|
681
|
+
if (handDef.face) {
|
|
682
|
+
const faceClip = ACTIONS[handDef.face];
|
|
683
|
+
if (faceClip) clip.play(faceClip, faceClip.audioEl, { queue: true });
|
|
684
|
+
}
|
|
685
|
+
return api;
|
|
686
|
+
}
|
|
687
|
+
const faceClip = ACTIONS[id];
|
|
688
|
+
if (!faceClip) throw new Error(`unknown action: ${id}`);
|
|
689
|
+
clip.play(faceClip, faceClip.audioEl, { queue: true });
|
|
690
|
+
return api;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function startHandAction(id, def) {
|
|
694
|
+
const gesture = HAND_ACTION_TO_FRAME_GESTURE[id];
|
|
695
|
+
if (!gesture) return;
|
|
696
|
+
if (handAction) {
|
|
697
|
+
if (handAction.id !== id && !handQueue.some((item) => item.id === id)) {
|
|
698
|
+
handQueue.push({ id, def, gesture });
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
handAction = { id, def, gesture, start: elapsed * 1000 };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function updateHandAction(nowMs) {
|
|
706
|
+
if (!handAction) return null;
|
|
707
|
+
const progress = (nowMs - handAction.start) / handAction.def.dur;
|
|
708
|
+
if (progress >= 1) {
|
|
709
|
+
const done = handAction;
|
|
710
|
+
const next = handQueue.shift();
|
|
711
|
+
handAction = next ? { ...next, start: nowMs } : null;
|
|
712
|
+
emit('gestureEnd', done.id);
|
|
713
|
+
return handAction ? { gesture: handAction.gesture, progress: 0, side: handSide } : null;
|
|
714
|
+
}
|
|
715
|
+
return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
|
|
716
|
+
}
|
|
717
|
+
|
|
634
718
|
/**
|
|
635
|
-
* Tell the listening engine whether
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* `null` hands back to the no-signal timer fallback.
|
|
719
|
+
* Tell the listening engine whether Pipecat VAD says the user holds the
|
|
720
|
+
* floor. This changes only sustained engagement posture; it can never create
|
|
721
|
+
* a nod or acknowledgement clip.
|
|
639
722
|
*/
|
|
640
|
-
function setUserSpeaking(b) {
|
|
723
|
+
function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
|
|
641
724
|
|
|
642
725
|
// What one action does when its moment comes. Enum validity is checked here,
|
|
643
726
|
// where the enums live: a bad value warns and is skipped, because one stale
|
|
@@ -650,8 +733,7 @@ export function createAvatar(opts = {}) {
|
|
|
650
733
|
if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
|
|
651
734
|
else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
|
|
652
735
|
else if (a.do === 'gaze') setGaze(a.name);
|
|
653
|
-
else if (a.do === '
|
|
654
|
-
else if (a.do === 'gesture') gesture(a.id);
|
|
736
|
+
else if (a.do === 'action') action(a.id);
|
|
655
737
|
} catch (e) {
|
|
656
738
|
console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
|
|
657
739
|
}
|
|
@@ -661,8 +743,8 @@ export function createAvatar(opts = {}) {
|
|
|
661
743
|
|
|
662
744
|
/**
|
|
663
745
|
* Play a timed action track — the composition surface a server assembles
|
|
664
|
-
* turns from. Verbs: state / emotion / gaze /
|
|
665
|
-
* perform.js for hygiene, docs/
|
|
746
|
+
* turns from. Verbs: state / emotion / gaze / action (see
|
|
747
|
+
* perform.js for hygiene, docs/internal-mixer.md for the schema).
|
|
666
748
|
*
|
|
667
749
|
* Clock resolution mirrors speak(): explicit `clock` fn, else the audio
|
|
668
750
|
* element's own time, else ms elapsed since this call. perform() never
|
|
@@ -691,12 +773,12 @@ export function createAvatar(opts = {}) {
|
|
|
691
773
|
}
|
|
692
774
|
|
|
693
775
|
const api = {
|
|
694
|
-
setState, setEmotion, setGaze, speak, pushCues, stopSpeaking,
|
|
695
|
-
|
|
776
|
+
setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, attend,
|
|
777
|
+
action, perform,
|
|
696
778
|
/** Which hand the character gestures with: +1 the viewer's right (its own
|
|
697
779
|
* left), -1 the other. Both are anatomically real — the thumb splays away
|
|
698
780
|
* from the body either way — so this is a character choice, not a fix. */
|
|
699
|
-
setHandSide: (d) => {
|
|
781
|
+
setHandSide: (d) => { handSide = d === -1 ? 'left' : 'right'; return api; },
|
|
700
782
|
setUserSpeaking,
|
|
701
783
|
/** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
|
|
702
784
|
setMouthGain: (g) => { mouthGain = g; return api; },
|
|
@@ -720,21 +802,22 @@ export function createAvatar(opts = {}) {
|
|
|
720
802
|
get speaking() { return speech.playing; },
|
|
721
803
|
get performing() { return performTrack.playing; },
|
|
722
804
|
get clip() { return clip.id; },
|
|
723
|
-
/**
|
|
724
|
-
get gesturing() { return
|
|
805
|
+
/** Semantic hand gesture in flight, independent of renderer capability. */
|
|
806
|
+
get gesturing() { return handAction ? handAction.id : null; },
|
|
725
807
|
get params() { return cur; },
|
|
726
|
-
get userSpeaking() { return
|
|
727
|
-
|
|
728
|
-
|
|
808
|
+
get userSpeaking() { return engagement.speaking; },
|
|
809
|
+
// Legacy SVG inspection fields. New AvatarRig implementations should not
|
|
810
|
+
// rely on or provide them; the renderer-neutral contract is apply/destroy.
|
|
811
|
+
svg: face?.svg || null,
|
|
812
|
+
meta: meta || null,
|
|
729
813
|
/** The mounted rig's palette, merged with any `opts.theme` overrides. A
|
|
730
814
|
* host that has to paint anything *around* the widget — a tile margin, a
|
|
731
815
|
* page behind a transparent mount — needs the same colours the drawing
|
|
732
816
|
* used, and guessing them per avatar is how the two drift apart. */
|
|
733
|
-
theme: face
|
|
817
|
+
theme: face?.theme,
|
|
734
818
|
destroy() {
|
|
735
819
|
cancelAnimationFrame(raf);
|
|
736
|
-
|
|
737
|
-
face.destroy();
|
|
820
|
+
rig.destroy();
|
|
738
821
|
},
|
|
739
822
|
};
|
|
740
823
|
|
|
@@ -743,13 +826,18 @@ export function createAvatar(opts = {}) {
|
|
|
743
826
|
return api;
|
|
744
827
|
}
|
|
745
828
|
|
|
746
|
-
export {
|
|
829
|
+
export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
|
|
747
830
|
export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
|
|
748
831
|
export { normalizeActions } from './perform.js';
|
|
749
|
-
export {
|
|
832
|
+
export { checkHandFraming } from './hand.js';
|
|
750
833
|
export { EMOTION_NAMES } from './emotions.js';
|
|
834
|
+
// The mouth clock travels with the rest of it. Someone has to turn a cue array
|
|
835
|
+
// plus a clock into "which letter is on screen right now", every renderer needs
|
|
836
|
+
// exactly that, and none of them should write it twice — so it is a plain class
|
|
837
|
+
// to construct, not a contract to implement.
|
|
751
838
|
export {
|
|
752
|
-
VISEME_LETTERS, VISEME_SHAPES,
|
|
839
|
+
VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
|
|
840
|
+
normalizeCues, textToCues,
|
|
753
841
|
ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
|
|
754
842
|
} from './visemes.js';
|
|
755
843
|
// No THEME re-export: each face module owns its palette, and `api.theme` is
|