@voqalize/avatar 0.0.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 +661 -0
- package/README.md +692 -0
- package/client/dist/Avatar.d.ts +24 -0
- package/client/dist/Avatar.d.ts.map +1 -0
- package/client/dist/Avatar.js +7 -0
- package/client/dist/Avatar.js.map +1 -0
- package/client/dist/AvatarClient.d.ts +173 -0
- package/client/dist/AvatarClient.d.ts.map +1 -0
- package/client/dist/AvatarClient.js +274 -0
- package/client/dist/AvatarClient.js.map +1 -0
- package/client/dist/pipecat.d.ts +21 -0
- package/client/dist/pipecat.d.ts.map +1 -0
- package/client/dist/pipecat.js +21 -0
- package/client/dist/pipecat.js.map +1 -0
- package/client/dist/react.d.ts +16 -0
- package/client/dist/react.d.ts.map +1 -0
- package/client/dist/react.js +17 -0
- package/client/dist/react.js.map +1 -0
- package/client/dist/types.d.ts +101 -0
- package/client/dist/types.d.ts.map +1 -0
- package/client/dist/types.js +31 -0
- package/client/dist/types.js.map +1 -0
- package/client/dist/useAvatar.d.ts +53 -0
- package/client/dist/useAvatar.d.ts.map +1 -0
- package/client/dist/useAvatar.js +68 -0
- package/client/dist/useAvatar.js.map +1 -0
- package/client/src/Avatar.tsx +38 -0
- package/client/src/AvatarClient.ts +343 -0
- package/client/src/pipecat.ts +38 -0
- package/client/src/react.ts +34 -0
- package/client/src/types.ts +127 -0
- package/client/src/useAvatar.ts +113 -0
- package/docs/contract-avatar.md +337 -0
- package/docs/contract-protocol.md +401 -0
- package/package.json +89 -0
- package/src/audio-fallback.js +100 -0
- package/src/avatar.d.ts +241 -0
- package/src/avatar.js +722 -0
- package/src/clips.js +144 -0
- package/src/emotions.js +55 -0
- package/src/face-core.js +154 -0
- package/src/face-myna.js +725 -0
- package/src/face-peep.js +767 -0
- package/src/face-wren.js +470 -0
- package/src/gaze.js +155 -0
- package/src/idle.js +535 -0
- package/src/interjections.js +578 -0
- package/src/line-art.js +111 -0
- package/src/params.js +176 -0
- package/src/perform.js +105 -0
- package/src/visemes.js +230 -0
package/src/avatar.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The avatar — a programmable talking head.
|
|
3
|
+
*
|
|
4
|
+
* The public surface. Everything the server drives goes through here:
|
|
5
|
+
*
|
|
6
|
+
* avatar.setState('LISTENING', { emotion: 'warm' })
|
|
7
|
+
* avatar.setGaze('SCREEN_LEFT')
|
|
8
|
+
* avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
|
|
9
|
+
* avatar.pushCues(moreCues) // streaming top-up
|
|
10
|
+
* avatar.interject('OKAY')
|
|
11
|
+
* avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
|
|
12
|
+
* avatar.setUserAudio(micStream) // or setUserSpeaking(bool) — the
|
|
13
|
+
* // user's voice, so listening is
|
|
14
|
+
* // contingent instead of timed
|
|
15
|
+
*
|
|
16
|
+
* Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
|
|
17
|
+
* by later ones on the channels they touch; the gesture and idle layers are
|
|
18
|
+
* additive so they compose rather than fight.
|
|
19
|
+
*
|
|
20
|
+
* base pose (state + emotion) -> gaze -> visemes -> clip -> idle
|
|
21
|
+
*
|
|
22
|
+
* The one hard priority rule: while the server viseme track is playing, it owns
|
|
23
|
+
* the mouth outright. An interjection firing mid-sentence contributes its head
|
|
24
|
+
* and brows and its mouth track is dropped — otherwise the avatar would appear
|
|
25
|
+
* to say two things at once.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
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
|
+
import { emotionPose } from './emotions.js';
|
|
33
|
+
import { GazeLayer, GAZE_TARGETS } from './gaze.js';
|
|
34
|
+
import { IdleLayer, ListeningEngine } from './idle.js';
|
|
35
|
+
import { ClipPlayer } from './clips.js';
|
|
36
|
+
import { INTERJECTIONS } from './interjections.js';
|
|
37
|
+
import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
|
|
38
|
+
import { PerformTrack } from './perform.js';
|
|
39
|
+
import { AudioFallback } from './audio-fallback.js';
|
|
40
|
+
|
|
41
|
+
// Each state's `idle` is a profile for the liveness layer (see DEFAULT_PROFILE
|
|
42
|
+
// in idle.js). Blink gaps come from docs/research-biomechanics.md §5: the rate
|
|
43
|
+
// alone separates listening (~16/min) from thinking (~25/min) from visually
|
|
44
|
+
// busy (~9/min), and it is the cheapest state signal the rig has.
|
|
45
|
+
export const STATES = {
|
|
46
|
+
IDLE: { gaze: 'USER', emotion: 'neutral', idle: { sway: 1.0 }, backchannel: false },
|
|
47
|
+
LISTENING: { gaze: 'USER', emotion: 'neutral', backchannel: true,
|
|
48
|
+
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
49
|
+
pose: { browRaiseL: 0.06, browRaiseR: 0.06, lidL: -0.04, lidR: -0.04 } },
|
|
50
|
+
// Faster, shallower breath is the measured cognitive-load signature, and the
|
|
51
|
+
// occasional dead-still hold is the strongest "working on it" cue a rig this
|
|
52
|
+
// simple can make — deliberate stillness, not more motion. The aversion
|
|
53
|
+
// leads DOWN (39% of measured cognitive aversions, §4.2) and wanders on the
|
|
54
|
+
// ~3.5s cognitive-aversion cadence, coming back to the user roughly one
|
|
55
|
+
// dwell in four — still with you, working.
|
|
56
|
+
THINKING: { gaze: 'AWAY_DOWN', emotion: 'thoughtful', backchannel: false,
|
|
57
|
+
idle: { sway: 0.7, blinkGap: [2.1, 2.7], breathRate: 1.18, breathAmp: 0.7,
|
|
58
|
+
hold: { every: [4.5, 9.0], dur: [0.8, 1.5] } },
|
|
59
|
+
wander: { targets: ['AWAY_DOWN', 'AWAY_DOWN', 'AWAY_THINKING', 'USER'],
|
|
60
|
+
every: [2.6, 4.4] } },
|
|
61
|
+
SPEAKING: { gaze: 'USER', emotion: 'neutral', idle: { sway: 0.55 }, backchannel: false },
|
|
62
|
+
REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', backchannel: false,
|
|
63
|
+
idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
|
|
64
|
+
wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_RIGHT', 'SCREEN_TOP', 'SCREEN_WORK'],
|
|
65
|
+
every: [1.8, 5.0] } },
|
|
66
|
+
// The head cant is the state's signature cue, and it has to clear the roll
|
|
67
|
+
// multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
|
|
68
|
+
// is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
|
|
69
|
+
// size, still gentle. Every other channel in this pose read fine on screen.
|
|
70
|
+
WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', backchannel: true,
|
|
71
|
+
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
72
|
+
pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
|
|
73
|
+
// Straining to hear. The one state where the amplitude constraint yields,
|
|
74
|
+
// because the lean IS the message: torsoLean well past LISTENING's
|
|
75
|
+
// engagement ceiling (+0.16), head cheated aside on USER_EAR so an ear
|
|
76
|
+
// favors the speaker while the eyes hold contact, and a concentration
|
|
77
|
+
// squint with knit brows. Stillness does the rest — straining people
|
|
78
|
+
// freeze — so holds are frequent and there are NO backchannels: you don't
|
|
79
|
+
// nod along to what you can't hear. Server sends it on soft/low-SNR user
|
|
80
|
+
// audio, typically followed by SORRY or a "could you repeat" utterance.
|
|
81
|
+
CANT_HEAR: {
|
|
82
|
+
gaze: 'USER_EAR', emotion: 'neutral', backchannel: false,
|
|
83
|
+
idle: { sway: 0.5, blinkGap: [4.5, 6.5],
|
|
84
|
+
hold: { every: [2.5, 5.5], dur: [1.0, 1.8] } },
|
|
85
|
+
// A minimal line face swallows small deltas — the ink moves whole units
|
|
86
|
+
// or it doesn't move. These values are set from the contact sheet's
|
|
87
|
+
// extremes row, not from what a fleshed rig would need: brows DOWN
|
|
88
|
+
// (corrugator effort, not the browInner worry-lift), a real squint, and
|
|
89
|
+
// the resting smile pressed flat — nobody smiles while straining to hear.
|
|
90
|
+
pose: {
|
|
91
|
+
torsoLean: 0.70, headPitch: 0.10,
|
|
92
|
+
lidL: 0.12, lidR: 0.12, squintL: 0.75, squintR: 0.75,
|
|
93
|
+
browRaiseL: -0.45, browRaiseR: -0.45, browInnerL: 0.15, browInnerR: 0.12,
|
|
94
|
+
mouthPress: 0.45, mouthCornerL: -0.22, mouthCornerR: -0.22,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
// --- application state ---------------------------------------------------
|
|
98
|
+
// "Momentarily busy on the thing you asked for." No hands in frame, so the
|
|
99
|
+
// whole read comes from four cheap cues (docs/research-biomechanics.md §6.4):
|
|
100
|
+
// gaze parked DOWN on a stable target, blinks suppressed to task-focus rate
|
|
101
|
+
// (~9/min), shoulders slightly raised and *working* — the burst/pause rhythm
|
|
102
|
+
// is what says activity rather than rocking — and, the important one, a
|
|
103
|
+
// brief glance back up to the user every few seconds. The glance is the tell
|
|
104
|
+
// that the user has not been forgotten; without it, busy is just absent.
|
|
105
|
+
TYPING: {
|
|
106
|
+
// SCREEN_WORK, not NOTES: on a steep down target the gaze layer's lid
|
|
107
|
+
// follow seals the eyes, and at tile size shut eyes read as asleep, not
|
|
108
|
+
// busy. A mild down-left with the head pitched into it keeps the iris in
|
|
109
|
+
// the opening — eyes down but awake.
|
|
110
|
+
gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
|
|
111
|
+
idle: { sway: 0.6, blinkGap: [6.0, 7.5], breathRate: 1.05,
|
|
112
|
+
rhythm: { amp: 0.05, freq: 2.2 } },
|
|
113
|
+
glance: { to: 'USER', every: [4, 7], hold: [0.7, 1.1] },
|
|
114
|
+
pose: { headPitch: 0.10, lidL: -0.04, lidR: -0.04,
|
|
115
|
+
shoulderL: 0.06, shoulderR: 0.06 },
|
|
116
|
+
},
|
|
117
|
+
// The audio channel is broken and the agent is typing in the chat window to
|
|
118
|
+
// communicate — TYPING's mechanics turned *communicative*. The glance is
|
|
119
|
+
// the difference: TYPING checks in briefly (~0.8 s) and goes back to work;
|
|
120
|
+
// this looks up and HOLDS 1.2–2 s, expectant, because the chat (and the
|
|
121
|
+
// user's face) is now the only channel there is. A touch of browInner
|
|
122
|
+
// carries the apology. Relation to DEGRADED is by semantics, not merger:
|
|
123
|
+
// DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
|
|
124
|
+
// it" — a server will typically sequence DEGRADED → TYPING_CHAT.
|
|
125
|
+
TYPING_CHAT: {
|
|
126
|
+
gaze: 'SCREEN_WORK', emotion: 'neutral', backchannel: false,
|
|
127
|
+
idle: { sway: 0.6, blinkGap: [5.5, 7.0], breathRate: 1.05,
|
|
128
|
+
rhythm: { amp: 0.055, freq: 2.5 } },
|
|
129
|
+
glance: { to: 'USER', every: [3.2, 5.5], hold: [1.2, 2.0] },
|
|
130
|
+
// Line-face scaled (see CANT_HEAR); the apology has to survive the rig's
|
|
131
|
+
// baked resting smile, so the corners go clearly negative.
|
|
132
|
+
pose: { headPitch: 0.10, lidL: -0.04, lidR: -0.04,
|
|
133
|
+
shoulderL: 0.06, shoulderR: 0.06,
|
|
134
|
+
browInnerL: 0.45, browInnerR: 0.38,
|
|
135
|
+
mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
|
|
136
|
+
},
|
|
137
|
+
// Attention genuinely elsewhere. What separates this from TYPING is target
|
|
138
|
+
// *stability* (§6.4): busy is one steady off-user target, distracted is
|
|
139
|
+
// wandering ones, held long (aversion >3s), with no backchannels — the
|
|
140
|
+
// missing nod is as diagnostic as the look-away. Sway is looser than
|
|
141
|
+
// LISTENING because attention is what was holding the body still. The
|
|
142
|
+
// widget only looks away; deciding when to snap back is the server's call.
|
|
143
|
+
DISTRACTED: {
|
|
144
|
+
gaze: 'AWAY_RIGHT', emotion: 'neutral', backchannel: false,
|
|
145
|
+
idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
|
|
146
|
+
// Sideways and up, never steep-down: lateral is where real intimacy/
|
|
147
|
+
// distraction aversions live, and a steep down target seals this rig's
|
|
148
|
+
// eyes (see TYPING).
|
|
149
|
+
wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
|
|
150
|
+
every: [2.8, 6.8] },
|
|
151
|
+
},
|
|
152
|
+
// The buying-time move: hunting for a control on screen. Distinct from
|
|
153
|
+
// REVIEWING_SCREEN by *hunt* quality — reading dwells (1.8–5 s) become
|
|
154
|
+
// search saccades (0.8–2 s) with revisits (targets repeat in the wander
|
|
155
|
+
// set), plus the idle layer's flick: the tiny "no, not this one" yaw
|
|
156
|
+
// wiggle nobody makes while merely reading. Server semantics: a filler
|
|
157
|
+
// while an async activity completes; the server exits it when done.
|
|
158
|
+
SEARCHING_SCREEN: {
|
|
159
|
+
gaze: 'SCREEN_CENTER', emotion: 'neutral', backchannel: false,
|
|
160
|
+
idle: { sway: 0.65, blinkGap: [5.0, 6.8], breathRate: 1.05,
|
|
161
|
+
flick: { amp: 0.30, every: [3.5, 7.0] } },
|
|
162
|
+
wander: { targets: ['SCREEN_CENTER', 'SCREEN_LEFT', 'SCREEN_TOP', 'SCREEN_WORK',
|
|
163
|
+
'SCREEN_RIGHT', 'SCREEN_CENTER', 'SCREEN_LEFT'],
|
|
164
|
+
every: [0.8, 2.0] },
|
|
165
|
+
// Line-face scaled (see CANT_HEAR). Note the corners: peep's REST mouth
|
|
166
|
+
// is drawn smiling, so "not smiling" is a clearly negative net corner,
|
|
167
|
+
// not zero.
|
|
168
|
+
pose: { squintL: 0.40, squintR: 0.40, mouthPress: 0.65,
|
|
169
|
+
mouthCornerL: -0.25, mouthCornerR: -0.25, browRaiseL: -0.26, browRaiseR: -0.20 },
|
|
170
|
+
},
|
|
171
|
+
// --- floor management ----------------------------------------------------
|
|
172
|
+
// Turn-taking is the part of a voice call that goes wrong most often: the
|
|
173
|
+
// user either talks over the agent or sits in silence waiting for a signal
|
|
174
|
+
// that never comes. These are states rather than clips because the floor is a
|
|
175
|
+
// condition and not an event — WANTS_IN in particular has to hold for as long
|
|
176
|
+
// as it takes the other person to notice it.
|
|
177
|
+
//
|
|
178
|
+
// All three lift the shoulders and part the lips, because that is what an
|
|
179
|
+
// inbreath looks like from outside, and an inbreath is the cue humans actually
|
|
180
|
+
// use to predict that someone is about to speak. The head comes *up* rather
|
|
181
|
+
// than down: a lowered head is deferential and reads as yielding.
|
|
182
|
+
TAKING_FLOOR: {
|
|
183
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.6 }, backchannel: false,
|
|
184
|
+
pose: {
|
|
185
|
+
browRaiseL: 0.26, browRaiseR: 0.22, lidL: -0.10, lidR: -0.10,
|
|
186
|
+
headPitch: -0.10, torsoLean: 0.22, shoulderL: 0.30, shoulderR: 0.30,
|
|
187
|
+
mouthOpen: 0.10, mouthPress: -0.10,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
// The one signal the rig had no way to give at all. An agent needs to be
|
|
191
|
+
// able to say "I'd like to come in" without talking over the user, and
|
|
192
|
+
// every part of this pose is doing that job: held still (idle is low on
|
|
193
|
+
// purpose — stillness is what makes it read as intent rather than as fidget),
|
|
194
|
+
// leaning in, lips apart and staying apart.
|
|
195
|
+
WANTS_IN: {
|
|
196
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.45 }, backchannel: false,
|
|
197
|
+
pose: {
|
|
198
|
+
browRaiseL: 0.42, browRaiseR: 0.38, lidL: -0.14, lidR: -0.14,
|
|
199
|
+
headPitch: -0.14, torsoLean: 0.42, shoulderL: 0.45, shoulderR: 0.45,
|
|
200
|
+
mouthOpen: 0.16, mouthWidth: 0.30, mouthPress: -0.14,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
// Interrupted mid-word. The mouth shutting is the whole message, and it has to
|
|
204
|
+
// happen faster than anything else on the face — see YIELD_FLOOR, which is
|
|
205
|
+
// what actually delivers the snap.
|
|
206
|
+
YIELDED: {
|
|
207
|
+
gaze: 'USER', emotion: 'neutral', idle: { sway: 0.9 }, backchannel: false,
|
|
208
|
+
pose: {
|
|
209
|
+
browRaiseL: 0.10, browRaiseR: 0.08,
|
|
210
|
+
torsoLean: -0.18, shoulderL: -0.12, shoulderR: -0.12,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
DEGRADED: { gaze: 'USER', emotion: 'neutral', backchannel: false,
|
|
215
|
+
idle: { sway: 0.4, blinkGap: [4.0, 8.0] },
|
|
216
|
+
pose: { lidL: 0.3, lidR: 0.3 }, filter: 'grayscale(.55) brightness(.82)' },
|
|
217
|
+
OFFLINE: { gaze: 'USER', emotion: 'neutral', backchannel: false,
|
|
218
|
+
idle: { sway: 0.15, blinkGap: [9, 15] },
|
|
219
|
+
pose: { lidL: 0.95, lidR: 0.95, mouthCornerL: 0, mouthCornerR: 0 },
|
|
220
|
+
filter: 'grayscale(1) brightness(.6)' },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const STATE_NAMES = Object.keys(STATES);
|
|
224
|
+
|
|
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
|
+
export function createAvatar(opts = {}) {
|
|
267
|
+
const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
|
|
268
|
+
if (!mount) throw new Error('createAvatar: mount element required');
|
|
269
|
+
|
|
270
|
+
// `opts.avatar` names one from AVATARS; `opts.face` passes a factory directly,
|
|
271
|
+
// so a host can supply an avatar the rig has never heard of. A bare factory
|
|
272
|
+
// has no descriptor, so meta falls back to what the svg itself declares.
|
|
273
|
+
const entry = opts.face ? { create: opts.face } : AVATARS[opts.avatar || DEFAULT_AVATAR];
|
|
274
|
+
if (!entry) {
|
|
275
|
+
throw new Error(`createAvatar: unknown avatar "${opts.avatar}" (have: ${AVATAR_NAMES.join(', ')})`);
|
|
276
|
+
}
|
|
277
|
+
const face = entry.create(mount, opts.theme);
|
|
278
|
+
const meta = entry.meta || (() => {
|
|
279
|
+
const vb = (face.svg.getAttribute('viewBox') || '0 0 1 1').split(/[\s,]+/).map(Number);
|
|
280
|
+
return { viewBox: { x: vb[0], y: vb[1], w: vb[2], h: vb[3] } };
|
|
281
|
+
})();
|
|
282
|
+
const gaze = new GazeLayer();
|
|
283
|
+
const idle = new IdleLayer();
|
|
284
|
+
const speech = new VisemeTrack();
|
|
285
|
+
const fallback = new AudioFallback();
|
|
286
|
+
// A second analyser for the USER's voice — same machinery, opposite
|
|
287
|
+
// direction: this one never touches the mouth, it feeds the listening engine.
|
|
288
|
+
const userAudio = new AudioFallback();
|
|
289
|
+
let userAudioOn = false;
|
|
290
|
+
|
|
291
|
+
let gazeOverrideByClip = null;
|
|
292
|
+
const clip = new ClipPlayer({
|
|
293
|
+
onGaze: (g) => { gazeOverrideByClip = g; applyGaze(); },
|
|
294
|
+
onBlink: () => idle.blink(),
|
|
295
|
+
});
|
|
296
|
+
const backchannel = new ListeningEngine((id) => { interject(id); emit('backchannel', id); });
|
|
297
|
+
const performTrack = new PerformTrack();
|
|
298
|
+
|
|
299
|
+
gaze.onLargeShift = () => idle.blink();
|
|
300
|
+
|
|
301
|
+
const listeners = { state: [], speakEnd: [], clipEnd: [], backchannel: [], performEnd: [] };
|
|
302
|
+
const emit = (ev, ...a) => listeners[ev] && listeners[ev].forEach((f) => f(...a));
|
|
303
|
+
clip.onEnd = (c) => { if (c) emit('clipEnd', c.id); };
|
|
304
|
+
speech.onEnd = () => { emit('speakEnd'); };
|
|
305
|
+
performTrack.onEnd = () => { emit('performEnd'); };
|
|
306
|
+
|
|
307
|
+
// --- live state -----------------------------------------------------------
|
|
308
|
+
let stateName = 'IDLE';
|
|
309
|
+
let emotion = 'neutral';
|
|
310
|
+
let emotionAmt = 1;
|
|
311
|
+
let gazeName = 'USER';
|
|
312
|
+
let gazeCustom = null;
|
|
313
|
+
let overrides = null; // demo/debug direct param injection
|
|
314
|
+
// Articulation gain. The per-cue `i` only ever attenuates (shapeFor maps it to
|
|
315
|
+
// 0.45..1.0 of the table), so there was no way to ask for a *bigger* mouth than
|
|
316
|
+
// VISEME_SHAPES describes. That table is tuned for a face at conversational
|
|
317
|
+
// size; at avatar size, sharing the screen with live video, the same shapes
|
|
318
|
+
// read as under-articulated. This scales every viseme away from rest, so the
|
|
319
|
+
// shape identities and their relative sizes are preserved and only the
|
|
320
|
+
// excursion changes. Values above ~1.5 saturate the open vowels against the
|
|
321
|
+
// channel clamp, which is the intended ceiling rather than a bug.
|
|
322
|
+
let mouthGain = opts.mouthGain ?? 1;
|
|
323
|
+
// Gesture gain, same idea for the clip layer. A nod is ballistic — NOD_SMALL
|
|
324
|
+
// peaks at 149ms — but the head smooths at a 160ms time constant, so barely
|
|
325
|
+
// 60% of an authored peak is ever rendered. The keyframes were written against
|
|
326
|
+
// the numbers, not against what comes out the other side, which is why small
|
|
327
|
+
// gestures read as nothing at all.
|
|
328
|
+
let gestureGain = opts.gestureGain ?? 1;
|
|
329
|
+
// Body-liveness gain. Constraint 8 (this widget shares the screen with a
|
|
330
|
+
// live video call) argues for the smallest idle motion that still reads, and
|
|
331
|
+
// the amplitudes in idle.js are set where they read. A host that is actually
|
|
332
|
+
// re-encoding the avatar — compositing it into an outgoing stream rather
|
|
333
|
+
// than rendering it locally as SVG, where the motion costs nothing — turns
|
|
334
|
+
// this down instead of the default being a body that does not move.
|
|
335
|
+
idle.gain = opts.motionGain ?? 1;
|
|
336
|
+
let wanderAt = 0;
|
|
337
|
+
let slowBlinkAt = 0;
|
|
338
|
+
let glanceAt = 0;
|
|
339
|
+
let glanceUntil = 0;
|
|
340
|
+
let speakClock = null;
|
|
341
|
+
let speakStart = 0;
|
|
342
|
+
let useFallback = false;
|
|
343
|
+
|
|
344
|
+
const cur = Object.assign({}, REST);
|
|
345
|
+
const target = Object.assign({}, REST);
|
|
346
|
+
|
|
347
|
+
function applyGaze() {
|
|
348
|
+
const g = gazeOverrideByClip || gazeName;
|
|
349
|
+
gaze.set(g, gazeOverrideByClip ? null : gazeCustom);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// --- the frame ------------------------------------------------------------
|
|
353
|
+
let raf = 0;
|
|
354
|
+
let last = 0;
|
|
355
|
+
let elapsed = 0;
|
|
356
|
+
// `manual` withholds the rAF loop so a tool can drive frames itself. The
|
|
357
|
+
// baseline pages could already step a ClipPlayer by hand, but nothing could
|
|
358
|
+
// step the *mixer* — which is where idle, gaze and engagement actually
|
|
359
|
+
// compose — so motion had no reproducible render. See tools/motion.mjs.
|
|
360
|
+
const manual = !!opts.manual;
|
|
361
|
+
|
|
362
|
+
function frame(now) {
|
|
363
|
+
raf = requestAnimationFrame(frame);
|
|
364
|
+
if (!last) last = now;
|
|
365
|
+
// Cap dt so a backgrounded tab doesn't fast-forward the whole rig on return.
|
|
366
|
+
const dt = Math.min(0.05, (now - last) / 1000);
|
|
367
|
+
last = now;
|
|
368
|
+
elapsed += dt;
|
|
369
|
+
step(dt, dt * 1000);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function step(dt, dtMs) {
|
|
373
|
+
// 0. the performance timeline. Sampled before the pose is built so a verb
|
|
374
|
+
// firing this frame shapes this frame.
|
|
375
|
+
performTrack.update();
|
|
376
|
+
|
|
377
|
+
const st = STATES[stateName] || STATES.IDLE;
|
|
378
|
+
|
|
379
|
+
// 1. base pose: rest + emotion + state-specific overlay
|
|
380
|
+
for (const c of CHANNELS) target[c] = REST[c];
|
|
381
|
+
const ep = emotionPose(emotion, emotionAmt);
|
|
382
|
+
for (const k in ep) target[k] = REST[k] + ep[k];
|
|
383
|
+
if (st.pose) for (const k in st.pose) target[k] = (target[k] || 0) + st.pose[k];
|
|
384
|
+
|
|
385
|
+
// 2. gaze (absolute: pupils + partial head follow, plus the lid that rides
|
|
386
|
+
// with the eye — looking down without it bares sclera and reads as alarm)
|
|
387
|
+
const g = gaze.update(elapsed, dt);
|
|
388
|
+
for (const k in g) {
|
|
389
|
+
if (k === 'lidBias') continue;
|
|
390
|
+
target[k] = (k.startsWith('head') ? target[k] : 0) + g[k];
|
|
391
|
+
}
|
|
392
|
+
target.lidL += g.lidBias;
|
|
393
|
+
target.lidR += g.lidBias;
|
|
394
|
+
|
|
395
|
+
// 2b. the trunk follows the head. Sampled HERE, after gaze and before the
|
|
396
|
+
// clip layer, on purpose: a sustained turn toward the screen recruits
|
|
397
|
+
// the trunk, and a nod or a head shake does not — a body that swings
|
|
398
|
+
// with every gesture reads as a mannequin on a turntable. The lag is
|
|
399
|
+
// not authored anywhere; torsoTurn simply chases the same target at
|
|
400
|
+
// nearly 3x the head's time constant (TAU in params.js), so the trunk
|
|
401
|
+
// leaves late and settles late for free.
|
|
402
|
+
target.torsoTurn += target.headYaw * TRUNK_FOLLOW;
|
|
403
|
+
|
|
404
|
+
// 3. state-driven autonomous behaviour
|
|
405
|
+
if (st.wander && elapsed > wanderAt) {
|
|
406
|
+
const w = st.wander;
|
|
407
|
+
wanderAt = elapsed + w.every[0] + Math.random() * (w.every[1] - w.every[0]);
|
|
408
|
+
setGaze(w.targets[(Math.random() * w.targets.length) | 0]);
|
|
409
|
+
}
|
|
410
|
+
if (stateName === 'THINKING' && elapsed > slowBlinkAt) {
|
|
411
|
+
slowBlinkAt = elapsed + 2.4 + Math.random() * 2.5;
|
|
412
|
+
idle.slowBlink();
|
|
413
|
+
}
|
|
414
|
+
// Periodic glance (TYPING's look-up-at-you beat). The return leg goes back
|
|
415
|
+
// to the state's own gaze; the gaze layer's large-shift blink fires on
|
|
416
|
+
// both legs for free, which is exactly the blink a real glance carries.
|
|
417
|
+
if (st.glance) {
|
|
418
|
+
const gl = st.glance;
|
|
419
|
+
if (glanceUntil && elapsed > glanceUntil) {
|
|
420
|
+
glanceUntil = 0;
|
|
421
|
+
glanceAt = elapsed + gl.every[0] + Math.random() * (gl.every[1] - gl.every[0]);
|
|
422
|
+
setGaze(st.gaze);
|
|
423
|
+
} else if (!glanceUntil && elapsed > glanceAt) {
|
|
424
|
+
glanceUntil = elapsed + gl.hold[0] + Math.random() * (gl.hold[1] - gl.hold[0]);
|
|
425
|
+
setGaze(gl.to);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
backchannel.enabled = !!st.backchannel && !clip.playing;
|
|
429
|
+
if (userAudioOn) { userAudio.sample(dt); backchannel.observeLevel(userAudio.level); }
|
|
430
|
+
backchannel.update(dt);
|
|
431
|
+
// Engagement posture: forward lean while the user holds the floor, spent
|
|
432
|
+
// only in the states that are *about* the user holding the floor. The
|
|
433
|
+
// research (docs/research-biomechanics.md §6.3) puts sustained attentive
|
|
434
|
+
// lean at +0.15–0.25; engage glides, and torsoLean's 0.24s tau smooths
|
|
435
|
+
// the state gate, so the lean arrives and leaves like weight shifting.
|
|
436
|
+
if (st.backchannel) target.torsoLean += 0.16 * backchannel.engage;
|
|
437
|
+
// Straining leans harder while there is actually a faint voice to strain
|
|
438
|
+
// after. engage already tracks "the user is (barely) talking", so this
|
|
439
|
+
// costs nothing; with no user signal the static pose carries the state.
|
|
440
|
+
else if (stateName === 'CANT_HEAR') target.torsoLean += 0.10 * backchannel.engage;
|
|
441
|
+
|
|
442
|
+
// 4. mouth. Server track wins; then clip track; then a fallback analyser.
|
|
443
|
+
const clipOut = clip.update(dtMs);
|
|
444
|
+
let mouth = speech.sample();
|
|
445
|
+
let mouthOwner = mouth ? 'speech' : null;
|
|
446
|
+
if (!mouth && clipOut.ownsMouth && clipOut.mouth) { mouth = clipOut.mouth; mouthOwner = 'clip'; }
|
|
447
|
+
if (!mouth && useFallback) { mouth = fallback.sample(dt); mouthOwner = 'fallback'; }
|
|
448
|
+
if (mouth) {
|
|
449
|
+
const shape = mouth.letter !== SILENT
|
|
450
|
+
? shapeFor(mouth.letter, mouth.intensity)
|
|
451
|
+
: shapeFor(SILENT, 1);
|
|
452
|
+
// Gain pivots on the rest shape, not on zero: scaling absolute values would
|
|
453
|
+
// drag the closed mouth open, which is the one thing lipsync must never do.
|
|
454
|
+
for (const k in shape) {
|
|
455
|
+
target[k] = mouthGain === 1
|
|
456
|
+
? shape[k]
|
|
457
|
+
: REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
|
|
458
|
+
}
|
|
459
|
+
// A smile held static through a sentence is discounted as insincere, and
|
|
460
|
+
// corners riding every open viseme read as laughing through the words
|
|
461
|
+
// (research-perception.md §3: warmth must be episodic). While the mouth
|
|
462
|
+
// is genuinely speech-driven the BASE smile decays to a fraction of
|
|
463
|
+
// itself; the smile channels' 130ms tau turns the gate into an ease.
|
|
464
|
+
// Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
|
|
465
|
+
// and only the base is scaled, so a gesture clip can still smile over a
|
|
466
|
+
// sentence by authoring corner keys (they add, unscaled, in step 5).
|
|
467
|
+
if (mouthOwner !== 'clip') {
|
|
468
|
+
target.mouthCornerL *= SPEAK_SMILE_RETAIN;
|
|
469
|
+
target.mouthCornerR *= SPEAK_SMILE_RETAIN;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 5. gesture deltas (additive, so a nod survives whatever else is happening)
|
|
474
|
+
if (clipOut.delta) {
|
|
475
|
+
for (const k in clipOut.delta) {
|
|
476
|
+
// Never let a clip's mouth keyframes fight the live viseme stream.
|
|
477
|
+
if (mouthOwner === 'speech' && MOUTH_LOCK.has(k)) continue;
|
|
478
|
+
target[k] = (target[k] || 0) + clipOut.delta[k] * gestureGain;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// 6. idle: sway, breath, blink
|
|
483
|
+
// The torso's share of the liveness follows whether sound is actually being
|
|
484
|
+
// produced, not what state the avatar is nominally in — a SPEAKING state with the
|
|
485
|
+
// track finished should already be settling.
|
|
486
|
+
idle.talk = approach(idle.talk, mouthOwner ? 1 : 0, 0.25, dt);
|
|
487
|
+
idle.setProfile(st.idle);
|
|
488
|
+
const il = idle.update(dt);
|
|
489
|
+
for (const k in il.add) target[k] = (target[k] || 0) + il.add[k];
|
|
490
|
+
|
|
491
|
+
// 7. clamp, then blink wins outright over whatever the lids were doing
|
|
492
|
+
for (const c of CHANNELS) {
|
|
493
|
+
const r = RANGE[c];
|
|
494
|
+
target[c] = clamp(target[c], r[0], r[1]);
|
|
495
|
+
}
|
|
496
|
+
if (il.blink > 0) {
|
|
497
|
+
target.lidL = Math.max(target.lidL, il.blink);
|
|
498
|
+
target.lidR = Math.max(target.lidR, il.blink);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (overrides) for (const k in overrides) target[k] = overrides[k];
|
|
502
|
+
|
|
503
|
+
// 8. smooth toward the target — this is where co-articulation happens
|
|
504
|
+
for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
|
|
505
|
+
|
|
506
|
+
face.apply(cur);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const REST_SHAPE = shapeFor(SILENT, 1);
|
|
510
|
+
|
|
511
|
+
// How much of a sustained head turn the trunk takes up. Well under 1: people
|
|
512
|
+
// under-rotate the head and then under-rotate the trunk again behind it, and
|
|
513
|
+
// at a head-and-shoulders crop the trunk's share is the part you register
|
|
514
|
+
// without noticing.
|
|
515
|
+
const TRUNK_FOLLOW = 0.45;
|
|
516
|
+
|
|
517
|
+
// The channels speech owns outright — exactly the params.js mouth group
|
|
518
|
+
// (mouth corners stay free: a clip may smile over a sentence).
|
|
519
|
+
const MOUTH_LOCK = new Set(GROUPS.mouth);
|
|
520
|
+
|
|
521
|
+
// What survives of the resting/emotion smile while speech owns the mouth.
|
|
522
|
+
// ~a third keeps the face warm without the corners fighting the visemes;
|
|
523
|
+
// full warmth returns the moment the track ends, which is exactly the
|
|
524
|
+
// episodic onset/offset a credible smile needs (research-perception.md §3).
|
|
525
|
+
const SPEAK_SMILE_RETAIN = 0.35;
|
|
526
|
+
|
|
527
|
+
// --- API ------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
function setState(name, o = {}) {
|
|
530
|
+
if (!STATES[name]) throw new Error(`unknown state: ${name}`);
|
|
531
|
+
const changed = name !== stateName;
|
|
532
|
+
stateName = name;
|
|
533
|
+
const st = STATES[name];
|
|
534
|
+
if (o.emotion !== undefined) emotion = o.emotion;
|
|
535
|
+
else if (changed) emotion = st.emotion;
|
|
536
|
+
if (o.intensity !== undefined) emotionAmt = o.intensity;
|
|
537
|
+
|
|
538
|
+
if (!o.keepGaze) setGaze(o.gaze || st.gaze);
|
|
539
|
+
idle.setProfile(st.idle);
|
|
540
|
+
// Arm the glance scheduler fresh so entering a glancing state doesn't
|
|
541
|
+
// fire a stale timestamp immediately.
|
|
542
|
+
glanceUntil = 0;
|
|
543
|
+
glanceAt = elapsed + (st.glance ? st.glance.every[0] + Math.random() * (st.glance.every[1] - st.glance.every[0]) : 0);
|
|
544
|
+
backchannel.reset(name === 'LISTENING' ? 2.2 : 4);
|
|
545
|
+
face.svg.style.filter = st.filter || '';
|
|
546
|
+
face.svg.style.transition = 'filter .5s ease';
|
|
547
|
+
if (changed) { idle.blink(); emit('state', name); }
|
|
548
|
+
return api;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
|
|
552
|
+
|
|
553
|
+
/** @param {string} name @param {{x:number,y:number}} [custom] normalized -1..1 */
|
|
554
|
+
function setGaze(name, custom) {
|
|
555
|
+
gazeName = GAZE_TARGETS[name] ? name : 'USER';
|
|
556
|
+
gazeCustom = custom || null;
|
|
557
|
+
applyGaze();
|
|
558
|
+
return api;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* @param {object} o
|
|
563
|
+
* @param {Array<{t:number,v:string,i?:number}>} o.cues
|
|
564
|
+
* @param {HTMLMediaElement} [o.audio] preferred clock source
|
|
565
|
+
* @param {() => number} [o.clock] custom ms clock, if you drive audio yourself
|
|
566
|
+
*/
|
|
567
|
+
function speak(o = {}) {
|
|
568
|
+
// A spoken interjection must die before real speech starts.
|
|
569
|
+
if (clip.playing && clip.clip.mouthCues) clip.stop();
|
|
570
|
+
speakStart = performance.now();
|
|
571
|
+
speakClock = o.clock
|
|
572
|
+
? o.clock
|
|
573
|
+
: o.audio
|
|
574
|
+
? () => o.audio.currentTime * 1000
|
|
575
|
+
: () => performance.now() - speakStart;
|
|
576
|
+
speech.start(o.cues || [], speakClock);
|
|
577
|
+
if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: true });
|
|
578
|
+
if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
|
|
579
|
+
return api;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function pushCues(cues) { speech.push(cues); return api; }
|
|
583
|
+
|
|
584
|
+
function stopSpeaking() { speech.stop(); return api; }
|
|
585
|
+
|
|
586
|
+
function interject(id) {
|
|
587
|
+
const c = INTERJECTIONS[id];
|
|
588
|
+
if (!c) throw new Error(`unknown interjection: ${id}`);
|
|
589
|
+
clip.play(c, c.audioEl);
|
|
590
|
+
backchannel.reset(3.5);
|
|
591
|
+
return api;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function setAudioFallback(source) {
|
|
595
|
+
if (!source) { fallback.detach(); useFallback = false; return api; }
|
|
596
|
+
fallback.attach(source);
|
|
597
|
+
useFallback = true;
|
|
598
|
+
return api;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Give the listening engine the USER's voice, so backchannels become
|
|
603
|
+
* contingent on their pauses instead of running on a timer. Either works,
|
|
604
|
+
* and the flag wins when both are driven:
|
|
605
|
+
* setUserAudio(streamOrElement) — the widget runs its own coarse VAD
|
|
606
|
+
* setUserSpeaking(bool) — the host (or server endpointer) decides;
|
|
607
|
+
* pass null to hand back to the level VAD
|
|
608
|
+
*/
|
|
609
|
+
function setUserAudio(source) {
|
|
610
|
+
if (!source) { userAudio.detach(); userAudioOn = false; return api; }
|
|
611
|
+
userAudio.attach(source);
|
|
612
|
+
userAudioOn = true;
|
|
613
|
+
return api;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function setUserSpeaking(b) { backchannel.setUserSpeaking(b); return api; }
|
|
617
|
+
|
|
618
|
+
// What one action does when its moment comes. Enum validity is checked here,
|
|
619
|
+
// where the enums live: a bad value warns and is skipped, because one stale
|
|
620
|
+
// verb must never take down the performance around it.
|
|
621
|
+
function dispatchAction(a) {
|
|
622
|
+
try {
|
|
623
|
+
// `state` defaults to keepGaze — a timeline that wants the gaze moved
|
|
624
|
+
// says so with a `gaze` verb at the moment it means, which is how every
|
|
625
|
+
// composed turn in the demo already behaves.
|
|
626
|
+
if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
|
|
627
|
+
else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
|
|
628
|
+
else if (a.do === 'gaze') setGaze(a.name);
|
|
629
|
+
else if (a.do === 'interject') interject(a.id);
|
|
630
|
+
} catch (e) {
|
|
631
|
+
console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
let performGen = 0;
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Play a timed action track — the composition surface a server assembles
|
|
639
|
+
* turns from. Verbs: state / emotion / gaze / interject (see perform.js for
|
|
640
|
+
* hygiene, docs/contract-protocol.md for the schema).
|
|
641
|
+
*
|
|
642
|
+
* Clock resolution mirrors speak(): explicit `clock` fn, else the audio
|
|
643
|
+
* element's own time, else ms elapsed since this call. perform() never
|
|
644
|
+
* starts or stops audio — speak() owns the sound; this owns the choreography
|
|
645
|
+
* that rides it.
|
|
646
|
+
*
|
|
647
|
+
* @param {Array<{t: number, do: string}>} actions
|
|
648
|
+
* @param {{audio?: HTMLMediaElement, clock?: () => number,
|
|
649
|
+
* onAction?: (a: object) => void}} [o]
|
|
650
|
+
* @returns {{stop: () => void}} stop() cancels the *future* of this
|
|
651
|
+
* performance only: an in-flight interjection finishes, a live cue track
|
|
652
|
+
* is untouched, and 'performEnd' does not fire. A handle whose
|
|
653
|
+
* performance was already replaced by a newer perform() is a no-op.
|
|
654
|
+
*/
|
|
655
|
+
function perform(actions, o = {}) {
|
|
656
|
+
const start = performance.now();
|
|
657
|
+
const clock = o.clock
|
|
658
|
+
? o.clock
|
|
659
|
+
: o.audio
|
|
660
|
+
? () => o.audio.currentTime * 1000
|
|
661
|
+
: () => performance.now() - start;
|
|
662
|
+
performTrack.onAction = (a) => { dispatchAction(a); if (o.onAction) o.onAction(a); };
|
|
663
|
+
const gen = ++performGen;
|
|
664
|
+
performTrack.start(actions, clock);
|
|
665
|
+
return { stop: () => { if (gen === performGen) performTrack.stop(); } };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const api = {
|
|
669
|
+
setState, setEmotion, setGaze, speak, pushCues, stopSpeaking, interject,
|
|
670
|
+
perform,
|
|
671
|
+
setAudioFallback, setUserAudio, setUserSpeaking,
|
|
672
|
+
/** Articulation gain: 1 is the VISEME_SHAPES table as authored. */
|
|
673
|
+
setMouthGain: (g) => { mouthGain = g; return api; },
|
|
674
|
+
get mouthGain() { return mouthGain; },
|
|
675
|
+
/** Gesture gain: scales every clip delta. 1 is the timelines as authored. */
|
|
676
|
+
setGestureGain: (g) => { gestureGain = g; return api; },
|
|
677
|
+
get gestureGain() { return gestureGain; },
|
|
678
|
+
/** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
|
|
679
|
+
setMotionGain: (g) => { idle.gain = g; return api; },
|
|
680
|
+
get motionGain() { return idle.gain; },
|
|
681
|
+
blink: (dbl) => { idle.blink(dbl); return api; },
|
|
682
|
+
/** Advance one frame by hand. Only meaningful under `{manual: true}`;
|
|
683
|
+
* fixed-dt stepping is what makes a motion render reproducible. */
|
|
684
|
+
step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
|
|
685
|
+
/** Direct parameter injection — for tuning UIs, not production. */
|
|
686
|
+
setOverrides: (o) => { overrides = o; return api; },
|
|
687
|
+
on: (ev, fn) => { (listeners[ev] || (listeners[ev] = [])).push(fn); return api; },
|
|
688
|
+
get state() { return stateName; },
|
|
689
|
+
get emotion() { return emotion; },
|
|
690
|
+
get gaze() { return gazeName; },
|
|
691
|
+
get speaking() { return speech.playing; },
|
|
692
|
+
get performing() { return performTrack.playing; },
|
|
693
|
+
get clip() { return clip.id; },
|
|
694
|
+
get params() { return cur; },
|
|
695
|
+
get audioLevel() { return fallback.level; },
|
|
696
|
+
get userSpeaking() { return backchannel.speaking; },
|
|
697
|
+
svg: face.svg,
|
|
698
|
+
meta,
|
|
699
|
+
/** The mounted rig's palette, merged with any `opts.theme` overrides. A
|
|
700
|
+
* host that has to paint anything *around* the widget — a tile margin, a
|
|
701
|
+
* page behind a transparent mount — needs the same colours the drawing
|
|
702
|
+
* used, and guessing them per avatar is how the two drift apart. */
|
|
703
|
+
theme: face.theme,
|
|
704
|
+
destroy() { cancelAnimationFrame(raf); fallback.detach(); userAudio.detach(); face.destroy(); },
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
setState('IDLE');
|
|
708
|
+
if (!manual) raf = requestAnimationFrame(frame);
|
|
709
|
+
return api;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export { INTERJECTIONS, INTERJECTION_IDS, SPOKEN_IDS, attachAudio } from './interjections.js';
|
|
713
|
+
export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
|
|
714
|
+
export { normalizeActions } from './perform.js';
|
|
715
|
+
export { EMOTION_NAMES } from './emotions.js';
|
|
716
|
+
export {
|
|
717
|
+
VISEME_LETTERS, VISEME_SHAPES, normalizeCues, textToCues,
|
|
718
|
+
ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
|
|
719
|
+
} from './visemes.js';
|
|
720
|
+
// No THEME re-export: each face module owns its palette, and `api.theme` is
|
|
721
|
+
// the mounted avatar's. A single barrel THEME was one rig's palette wearing a
|
|
722
|
+
// public name — misleading the moment that rig stopped being the default.
|