@voqalize/avatar 0.4.1 → 0.4.3
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-APACHE-2.0 +201 -0
- package/README.md +26 -95
- package/assets/README.md +30 -18
- package/assets/tanvi.glb +0 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tess.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/internal.ts +24 -0
- package/client/supports.ts +6 -7
- package/client/three/assets.ts +7 -3
- package/client/three/budgets.ts +1 -1
- package/client/three/{tara-rig.ts → character-rig.ts} +682 -540
- package/client/three/createCharacter.ts +109 -0
- package/client/three/holds.ts +8 -3
- package/client/three/internal.ts +8 -7
- package/client/three/motion-limits.json +10 -2
- package/client/three/sequences.ts +5 -5
- package/client/three/tanvi-asset.ts +6 -0
- package/client/three/tanvi.ts +30 -0
- package/client/three/tanya.ts +18 -58
- package/client/three/tara.ts +19 -60
- package/client/three/tess-asset.ts +6 -0
- package/client/three/tess.ts +30 -0
- package/client/three/tushar.ts +18 -55
- package/dist/internal.d.ts +2 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +16 -1
- package/dist/internal.js.map +1 -1
- package/dist/supports.d.ts +6 -7
- package/dist/supports.d.ts.map +1 -1
- package/dist/supports.js +6 -7
- package/dist/supports.js.map +1 -1
- package/dist/three/assets.d.ts +2 -0
- package/dist/three/assets.d.ts.map +1 -1
- package/dist/three/assets.js +7 -3
- package/dist/three/assets.js.map +1 -1
- package/dist/three/budgets.d.ts +1 -1
- package/dist/three/budgets.js +1 -1
- package/dist/three/character-rig.d.ts +366 -0
- package/dist/three/character-rig.d.ts.map +1 -0
- package/dist/three/{tara-rig.js → character-rig.js} +633 -526
- package/dist/three/character-rig.js.map +1 -0
- package/dist/three/createCharacter.d.ts +60 -0
- package/dist/three/createCharacter.d.ts.map +1 -0
- package/dist/three/createCharacter.js +84 -0
- package/dist/three/createCharacter.js.map +1 -0
- package/dist/three/holds.d.ts +7 -2
- package/dist/three/holds.d.ts.map +1 -1
- package/dist/three/holds.js +8 -3
- package/dist/three/holds.js.map +1 -1
- package/dist/three/internal.d.ts +7 -7
- package/dist/three/internal.d.ts.map +1 -1
- package/dist/three/internal.js +6 -6
- package/dist/three/internal.js.map +1 -1
- package/dist/three/motion-limits.json +10 -2
- package/dist/three/sequences.d.ts +5 -5
- package/dist/three/sequences.js +5 -5
- package/dist/three/tanvi-asset.d.ts +7 -0
- package/dist/three/tanvi-asset.d.ts.map +1 -0
- package/dist/three/tanvi-asset.js +7 -0
- package/dist/three/tanvi-asset.js.map +1 -0
- package/dist/three/tanvi.d.ts +24 -0
- package/dist/three/tanvi.d.ts.map +1 -0
- package/dist/three/tanvi.js +24 -0
- package/dist/three/tanvi.js.map +1 -0
- package/dist/three/tanya.d.ts +16 -28
- package/dist/three/tanya.d.ts.map +1 -1
- package/dist/three/tanya.js +15 -43
- package/dist/three/tanya.js.map +1 -1
- package/dist/three/tara.d.ts +17 -22
- package/dist/three/tara.d.ts.map +1 -1
- package/dist/three/tara.js +16 -45
- package/dist/three/tara.js.map +1 -1
- package/dist/three/tess-asset.d.ts +7 -0
- package/dist/three/tess-asset.d.ts.map +1 -0
- package/dist/three/tess-asset.js +7 -0
- package/dist/three/tess-asset.js.map +1 -0
- package/dist/three/tess.d.ts +24 -0
- package/dist/three/tess.d.ts.map +1 -0
- package/dist/three/tess.js +24 -0
- package/dist/three/tess.js.map +1 -0
- package/dist/three/tushar.d.ts +16 -25
- package/dist/three/tushar.d.ts.map +1 -1
- package/dist/three/tushar.js +15 -40
- package/dist/three/tushar.js.map +1 -1
- package/package.json +10 -49
- package/src/avatar.d.ts +67 -2
- package/src/avatar.js +131 -255
- package/src/faces.js +1 -1
- package/src/gaze.js +20 -47
- package/src/idle.js +69 -27
- package/src/prosody.js +109 -15
- package/src/rig.d.ts +7 -0
- package/src/speech-timing.js +9 -0
- package/src/visemes.js +107 -13
- package/client/arjun.ts +0 -26
- package/client/createCanvasAvatar.ts +0 -72
- package/client/interviewer-female.ts +0 -4
- package/client/interviewer-male.ts +0 -4
- package/client/ishita.ts +0 -26
- package/client/kabir.ts +0 -26
- package/client/meera.ts +0 -26
- package/client/naina.ts +0 -26
- package/client/professional-female-a.ts +0 -4
- package/client/professional-female-b.ts +0 -4
- package/client/professional-male-a.ts +0 -4
- package/client/professional-male-b.ts +0 -4
- package/client/vikram.ts +0 -26
- package/dist/arjun.d.ts +0 -7
- package/dist/arjun.d.ts.map +0 -1
- package/dist/arjun.js +0 -20
- package/dist/arjun.js.map +0 -1
- package/dist/createCanvasAvatar.d.ts +0 -22
- package/dist/createCanvasAvatar.d.ts.map +0 -1
- package/dist/createCanvasAvatar.js +0 -47
- package/dist/createCanvasAvatar.js.map +0 -1
- package/dist/interviewer-female.d.ts +0 -4
- package/dist/interviewer-female.d.ts.map +0 -1
- package/dist/interviewer-female.js +0 -3
- package/dist/interviewer-female.js.map +0 -1
- package/dist/interviewer-male.d.ts +0 -4
- package/dist/interviewer-male.d.ts.map +0 -1
- package/dist/interviewer-male.js +0 -3
- package/dist/interviewer-male.js.map +0 -1
- package/dist/ishita.d.ts +0 -7
- package/dist/ishita.d.ts.map +0 -1
- package/dist/ishita.js +0 -20
- package/dist/ishita.js.map +0 -1
- package/dist/kabir.d.ts +0 -7
- package/dist/kabir.d.ts.map +0 -1
- package/dist/kabir.js +0 -20
- package/dist/kabir.js.map +0 -1
- package/dist/meera.d.ts +0 -7
- package/dist/meera.d.ts.map +0 -1
- package/dist/meera.js +0 -20
- package/dist/meera.js.map +0 -1
- package/dist/naina.d.ts +0 -7
- package/dist/naina.d.ts.map +0 -1
- package/dist/naina.js +0 -20
- package/dist/naina.js.map +0 -1
- package/dist/professional-female-a.d.ts +0 -4
- package/dist/professional-female-a.d.ts.map +0 -1
- package/dist/professional-female-a.js +0 -3
- package/dist/professional-female-a.js.map +0 -1
- package/dist/professional-female-b.d.ts +0 -4
- package/dist/professional-female-b.d.ts.map +0 -1
- package/dist/professional-female-b.js +0 -3
- package/dist/professional-female-b.js.map +0 -1
- package/dist/professional-male-a.d.ts +0 -4
- package/dist/professional-male-a.d.ts.map +0 -1
- package/dist/professional-male-a.js +0 -3
- package/dist/professional-male-a.js.map +0 -1
- package/dist/professional-male-b.d.ts +0 -4
- package/dist/professional-male-b.d.ts.map +0 -1
- package/dist/professional-male-b.js +0 -3
- package/dist/professional-male-b.js.map +0 -1
- package/dist/three/tara-rig.d.ts +0 -409
- package/dist/three/tara-rig.d.ts.map +0 -1
- package/dist/three/tara-rig.js.map +0 -1
- package/dist/vikram.d.ts +0 -7
- package/dist/vikram.d.ts.map +0 -1
- package/dist/vikram.js +0 -20
- package/dist/vikram.js.map +0 -1
- package/src/canvas/author/parts/eye.mjs +0 -722
- package/src/canvas/author/parts/hand.mjs +0 -1156
- package/src/canvas/author/parts/mouth.mjs +0 -741
- package/src/canvas/author/parts/nose.mjs +0 -100
- package/src/canvas/author/parts/skin-detail.mjs +0 -67
- package/src/canvas/author/path.mjs +0 -283
- package/src/canvas/author/rig.mjs +0 -405
- package/src/canvas/avatars/round/face.d.mts +0 -3
- package/src/canvas/avatars/round/face.mjs +0 -1307
- package/src/canvas/create-rig.d.ts +0 -15
- package/src/canvas/create-rig.js +0 -100
- 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 +0 -1
- package/src/canvas/data/interviewer-male.rig.json +0 -1
- package/src/canvas/data/professional-female-a.rig.json +0 -1
- package/src/canvas/data/professional-female-b.rig.json +0 -1
- package/src/canvas/data/professional-male-a.rig.json +0 -1
- package/src/canvas/data/professional-male-b.rig.json +0 -1
- package/src/canvas/src/live.js +0 -508
- package/src/canvas/src/render2d.js +0 -218
- package/src/canvas/src/rig.js +0 -297
- package/src/canvas/src/vocab.js +0 -96
package/src/avatar.js
CHANGED
|
@@ -6,27 +6,11 @@
|
|
|
6
6
|
* addressed by our own runtime, our tooling, and an avatar author who chose to
|
|
7
7
|
* build on the SVG renderer (`@voqalize/avatar/internal`, no semver promise).
|
|
8
8
|
*
|
|
9
|
-
* const avatar = createAvatar({ mount, face: peep }) // faces.js, or a face module
|
|
10
|
-
* avatar.setState('LISTENING', { emotion: 'warm' })
|
|
11
|
-
* avatar.setGaze('SCREEN_LEFT')
|
|
12
|
-
* avatar.speak({ audio, cues }) // cues are {t, v, i?} in ms
|
|
13
|
-
* avatar.pushCues(moreCues) // streaming top-up
|
|
14
|
-
* avatar.action('ACK_RECEIVE')
|
|
15
|
-
* avatar.action('GESTURE_GREET') // a hand at the frame edge + its face
|
|
16
|
-
* avatar.perform(beats, { audio }) // timed {t, do, ...} verbs, same clock
|
|
17
|
-
* avatar.setUserSpeaking(bool) // the user has the floor, so listening
|
|
18
|
-
* // is contingent instead of timed
|
|
19
|
-
*
|
|
20
9
|
* Per frame the mixer runs a fixed layer order. Earlier layers are overwritten
|
|
21
10
|
* by later ones on the channels they touch; the gesture and idle layers are
|
|
22
11
|
* additive so they compose rather than fight.
|
|
23
12
|
*
|
|
24
13
|
* base pose (state + emotion) -> gaze -> visemes -> clip -> idle
|
|
25
|
-
*
|
|
26
|
-
* The one hard priority rule: while the server viseme track is playing, it owns
|
|
27
|
-
* the mouth outright. An interjection firing mid-sentence contributes its head
|
|
28
|
-
* and brows and its mouth track is dropped — otherwise the avatar would appear
|
|
29
|
-
* to say two things at once.
|
|
30
14
|
*/
|
|
31
15
|
|
|
32
16
|
import { REST, CHANNELS, TAU, RANGE, GROUPS, clamp, approach } from './params.js';
|
|
@@ -67,8 +51,7 @@ export const STATES = {
|
|
|
67
51
|
// the attentive pose it looks like — it is a demand for more talk (Rossano)
|
|
68
52
|
// and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
|
|
69
53
|
// gaze.js for the numbers; the mixer holds it off near a turn boundary.
|
|
70
|
-
//
|
|
71
|
-
// of timed blinks come as a pair; at 3.2-4.4 s it measured 20-23/min.
|
|
54
|
+
// At 3.2-4.4 s the blink timer measured 20-23/min.
|
|
72
55
|
LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
|
|
73
56
|
aversion: 'LISTEN',
|
|
74
57
|
idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
|
|
@@ -91,8 +74,6 @@ export const STATES = {
|
|
|
91
74
|
// is kept to a fifth of looks because on a face this real it reads as
|
|
92
75
|
// downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
|
|
93
76
|
// alert, and the two together measured past the 0.15 that reads drowsy.
|
|
94
|
-
// The handoff to SPEAKING is in setState: a reply that starts mid-look
|
|
95
|
-
// finishes the look instead of snapping back.
|
|
96
77
|
THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
|
|
97
78
|
// Fixational jumps rare and small: a thinker's eyes rest
|
|
98
79
|
// where they land. At the default gap the look jittered
|
|
@@ -117,32 +98,21 @@ export const STATES = {
|
|
|
117
98
|
'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
|
|
118
99
|
'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
|
|
119
100
|
stick: 0.55, dart: { p: 0.4, mag: 0.08, brow: 0.12 }, blinkTo: true },
|
|
120
|
-
// **The chin comes up
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// cognitive shift in the listener"
|
|
130
|
-
// (research-biomechanics.md § 3.3) — so the same
|
|
131
|
-
// gesture that means "ah, I see" at the end of a nod
|
|
132
|
-
// means "let me think" at the start of a pause.
|
|
133
|
-
//
|
|
134
|
-
// It rides the looks that go up, as the head's share of
|
|
135
|
-
// them, and not the state. Held as a pose it lifted the
|
|
136
|
-
// chin on the level and downward looks too and on every
|
|
137
|
-
// check-in, so the user was looked at down the nose, and
|
|
138
|
-
// eyes drawn inside a head tipped back aimed every look
|
|
139
|
-
// meant to be level at the ceiling.
|
|
101
|
+
// **The chin comes up**: thinking is the one stretch of
|
|
102
|
+
// a call where the avatar owes the user feedback and
|
|
103
|
+
// has no mouth to give it with, the eyes being off the
|
|
104
|
+
// user by design. A head tipping back as the gaze
|
|
105
|
+
// leaves is the swing-up of research-biomechanics.md
|
|
106
|
+
// § 3.3. It rides the looks that go up, as the head's
|
|
107
|
+
// *share* of them: held as a pose it lifted the chin on
|
|
108
|
+
// the level and downward looks too, so the user was
|
|
109
|
+
// looked at down the nose.
|
|
140
110
|
pose: { lidL: -0.10, lidR: -0.10 } },
|
|
141
|
-
// Eyes on the user
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
SPEAKING: { gaze: 'USER', emotion: 'neutral',
|
|
111
|
+
// Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
|
|
112
|
+
// speaker's measured looks away are not this rig's to render. What moves
|
|
113
|
+
// while it talks is the head it holds per phrase and the trunk under it
|
|
114
|
+
// (prosody.js), never the eyes leaving the user.
|
|
115
|
+
SPEAKING: { gaze: 'USER', emotion: 'neutral',
|
|
146
116
|
idle: { sway: 0.55 }, engagement: false },
|
|
147
117
|
REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
|
|
148
118
|
idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
|
|
@@ -151,13 +121,13 @@ export const STATES = {
|
|
|
151
121
|
// The head cant is the state's signature cue, and it has to clear the roll
|
|
152
122
|
// multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
|
|
153
123
|
// is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
|
|
154
|
-
// size, still gentle.
|
|
124
|
+
// size, still gentle.
|
|
155
125
|
WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
|
|
156
126
|
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
157
127
|
pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
|
|
158
128
|
// Straining to hear. The one state where the amplitude constraint yields,
|
|
159
|
-
// because the lean IS the message: torsoLean well past
|
|
160
|
-
//
|
|
129
|
+
// because the lean IS the message: torsoLean well past what the engagement
|
|
130
|
+
// layer ever spends, head cheated aside on USER_EAR so an ear
|
|
161
131
|
// favors the speaker while the eyes hold contact, and a concentration
|
|
162
132
|
// squint with knit brows. Stillness does the rest — straining people
|
|
163
133
|
// freeze — so holds are frequent and there is no engagement lean: you don't
|
|
@@ -211,7 +181,7 @@ export const STATES = {
|
|
|
211
181
|
},
|
|
212
182
|
// --- application state ---------------------------------------------------
|
|
213
183
|
// "Momentarily busy on the thing you asked for." No hands in frame, so the
|
|
214
|
-
// whole read comes from
|
|
184
|
+
// whole read comes from cheap cues (docs/research-biomechanics.md §6.4,
|
|
215
185
|
// recommendation 19): gaze down on one stable target with a reading scan,
|
|
216
186
|
// blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
|
|
217
187
|
// and *held* with brief micro-freezes, and a glance back up to the user.
|
|
@@ -230,9 +200,8 @@ export const STATES = {
|
|
|
230
200
|
// take back most of the down look's follow so the eyes stay awake.
|
|
231
201
|
gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
|
|
232
202
|
scan: [0.9, 2.0, 0.12],
|
|
233
|
-
// §6.4's ~9/min is a count of blinks
|
|
234
|
-
//
|
|
235
|
-
// to land on it; at 6-7.5 s it measured 12-13/min, which is not focus.
|
|
203
|
+
// §6.4's ~9/min is a count of blinks: at the table's own 6-7.5 s gap it
|
|
204
|
+
// measured 12-13/min, which is not focus.
|
|
236
205
|
idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
|
|
237
206
|
hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
|
|
238
207
|
// The look up to check is brows-first and blinkless, the lids leading it
|
|
@@ -254,11 +223,9 @@ export const STATES = {
|
|
|
254
223
|
browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
|
|
255
224
|
},
|
|
256
225
|
// The audio channel is broken and the agent is typing in the chat window to
|
|
257
|
-
// communicate
|
|
258
|
-
// the
|
|
259
|
-
//
|
|
260
|
-
// user's face) is now the only channel there is. A touch of browInner
|
|
261
|
-
// carries the apology. Relation to DEGRADED is by semantics, not merger:
|
|
226
|
+
// communicate. The glance looks up and HOLDS 1.2–2 s, expectant, because the
|
|
227
|
+
// chat (and the user's face) is now the only channel there is. A touch of
|
|
228
|
+
// browInner carries the apology. Relation to DEGRADED is by semantics, not merger:
|
|
262
229
|
// DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
|
|
263
230
|
// it" — a server will typically sequence DEGRADED → TYPING_CHAT.
|
|
264
231
|
TYPING_CHAT: {
|
|
@@ -273,7 +240,7 @@ export const STATES = {
|
|
|
273
240
|
browInnerL: 0.45, browInnerR: 0.38,
|
|
274
241
|
mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
|
|
275
242
|
},
|
|
276
|
-
// Attention genuinely elsewhere. What separates this from
|
|
243
|
+
// Attention genuinely elsewhere. What separates this from WORKING is target
|
|
277
244
|
// *stability* (§6.4): busy is one steady off-user target, distracted is
|
|
278
245
|
// wandering ones, held long (aversion >3s), with no engagement lean — the
|
|
279
246
|
// missing nod is as diagnostic as the look-away. Sway is looser than
|
|
@@ -284,7 +251,7 @@ export const STATES = {
|
|
|
284
251
|
idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
|
|
285
252
|
// Sideways and up, never steep-down: lateral is where real intimacy/
|
|
286
253
|
// distraction aversions live, and a steep down target seals this rig's
|
|
287
|
-
// eyes (see
|
|
254
|
+
// eyes (see WORKING).
|
|
288
255
|
wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
|
|
289
256
|
every: [2.8, 6.8] },
|
|
290
257
|
},
|
|
@@ -321,7 +288,7 @@ export const STATES = {
|
|
|
321
288
|
// condition and not an event — WANTS_IN in particular has to hold for as long
|
|
322
289
|
// as it takes the other person to notice it.
|
|
323
290
|
//
|
|
324
|
-
//
|
|
291
|
+
// Each of these lifts the shoulders and parts the lips, because that is what an
|
|
325
292
|
// inbreath looks like from outside, and an inbreath is the cue humans actually
|
|
326
293
|
// use to predict that someone is about to speak. The head comes *up* rather
|
|
327
294
|
// than down: a lowered head is deferential and reads as yielding.
|
|
@@ -403,9 +370,7 @@ export const STATE_NAMES = Object.keys(STATES);
|
|
|
403
370
|
* figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
|
|
404
371
|
* and read as a shrug arriving with the head.
|
|
405
372
|
*
|
|
406
|
-
* Exported for
|
|
407
|
-
* mixer would have put under the tilt; the rig instruments in the working tree
|
|
408
|
-
* are its only callers.
|
|
373
|
+
* Exported for the rig instruments.
|
|
409
374
|
*/
|
|
410
375
|
export const SHOULDER_TILT = 0.08;
|
|
411
376
|
|
|
@@ -413,11 +378,9 @@ export function createAvatar(opts = {}) {
|
|
|
413
378
|
const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
|
|
414
379
|
if (!mount) throw new Error('createAvatar: mount element required');
|
|
415
380
|
|
|
416
|
-
// `opts.face` is a Face record — `{ create, meta }
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
// in every consumer's bundle to render one. `src/faces.js` still has that
|
|
420
|
-
// table, for tooling that genuinely wants all of them.
|
|
381
|
+
// `opts.face` is a Face record — `{ create, meta }`. Passed in rather than
|
|
382
|
+
// named: a name needs a table, and a table imports every drawing to answer
|
|
383
|
+
// one lookup (`src/faces.js` has that table, for tooling that wants them).
|
|
421
384
|
const entry = opts.rig ? null : opts.face;
|
|
422
385
|
if (!opts.rig && !entry) {
|
|
423
386
|
throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
|
|
@@ -445,12 +408,9 @@ export function createAvatar(opts = {}) {
|
|
|
445
408
|
// control. `hand: false` only disables its SVG rendering; gesture actions
|
|
446
409
|
// still emit the semantic hand frame for a supplied custom rig.
|
|
447
410
|
const hand = face && opts.hand !== false ? createHand(face.svg, face.theme, meta, { dir: opts.handSide }) : null;
|
|
448
|
-
// The existing SVG face and hand are one migration adapter implementing the
|
|
449
|
-
// renderer-agnostic AvatarRig contract. New renderers never need face SVG
|
|
450
|
-
// coordinates or the hand layer's private geometry.
|
|
451
411
|
const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
|
|
452
412
|
|
|
453
|
-
gaze.onLargeShift = (forced) => idle.blink(
|
|
413
|
+
gaze.onLargeShift = (forced) => idle.blink(true, forced);
|
|
454
414
|
|
|
455
415
|
const listeners = {
|
|
456
416
|
state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
|
|
@@ -467,22 +427,19 @@ export function createAvatar(opts = {}) {
|
|
|
467
427
|
let gazeName = 'USER';
|
|
468
428
|
let gazeCustom = null;
|
|
469
429
|
let overrides = null; // demo/debug direct param injection
|
|
470
|
-
// Articulation gain.
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// shape identities and their relative sizes are preserved and only the
|
|
476
|
-
// excursion changes. Values above ~1.5 saturate the open vowels against the
|
|
477
|
-
// channel clamp, which is the intended ceiling rather than a bug.
|
|
430
|
+
// Articulation gain. VISEME_SHAPES is tuned for a face at conversational
|
|
431
|
+
// size; sharing the screen with live video the same shapes read as
|
|
432
|
+
// under-articulated. Scales every viseme away from rest, so shape identities
|
|
433
|
+
// and their relative sizes survive and only the excursion changes. Above
|
|
434
|
+
// ~1.5 the open vowels saturate against the clamp — the intended ceiling.
|
|
478
435
|
let mouthGain = opts.mouthGain ?? 1;
|
|
479
436
|
let handSide = opts.handSide === -1 ? 'left' : 'right';
|
|
480
437
|
/**
|
|
481
|
-
* This avatar's own addressable motions, on top of the
|
|
438
|
+
* This avatar's own addressable motions, on top of the core intents.
|
|
482
439
|
*
|
|
483
440
|
* An avatar is a drawing with a body, and some bodies can do things the wire
|
|
484
|
-
* has no portable word for. The
|
|
485
|
-
*
|
|
441
|
+
* has no portable word for. The nod types the listening research separates —
|
|
442
|
+
* a continuer, an assessment, a realisation — are one
|
|
486
443
|
* `ACKNOWLEDGE` to a server, because that is all a server can ask of every
|
|
487
444
|
* face; the *shapes* are sized in a rig's own units and belong to the rig
|
|
488
445
|
* (`packages/avatar/client/three/sequences.ts` is the first table of them).
|
|
@@ -514,13 +471,9 @@ export function createAvatar(opts = {}) {
|
|
|
514
471
|
}
|
|
515
472
|
/**
|
|
516
473
|
* This avatar's own rendering of a state — the same door as `actions`, for
|
|
517
|
-
* the held face instead of the gesture. A state's
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
* from going blank, and on a real eye — already hooded by a lid following it
|
|
521
|
-
* down to the screen — they finish a squint that reads as strain. A rig
|
|
522
|
-
* replaces a state's fields whole (`pose`, say), and only for a state the
|
|
523
|
-
* table already has: it can re-render the vocabulary, never extend it.
|
|
474
|
+
* the held face instead of the gesture. A rig replaces a state's fields
|
|
475
|
+
* whole (`pose`, say), and only for a state the table already has: it can
|
|
476
|
+
* re-render the vocabulary, never extend it.
|
|
524
477
|
*/
|
|
525
478
|
const states = { ...STATES };
|
|
526
479
|
for (const [id, own] of Object.entries(opts.states || {})) {
|
|
@@ -529,11 +482,8 @@ export function createAvatar(opts = {}) {
|
|
|
529
482
|
}
|
|
530
483
|
let handAction = null;
|
|
531
484
|
const handQueue = [];
|
|
532
|
-
// Gesture gain, same idea for the clip layer
|
|
533
|
-
//
|
|
534
|
-
// 60% of an authored peak is ever rendered. The keyframes were written against
|
|
535
|
-
// the numbers, not against what comes out the other side, which is why small
|
|
536
|
-
// gestures read as nothing at all.
|
|
485
|
+
// Gesture gain, same idea for the clip layer: small gestures under-render
|
|
486
|
+
// through the head's τ — see internal-mixer.md § Smoothing.
|
|
537
487
|
let gestureGain = opts.gestureGain ?? 1;
|
|
538
488
|
// Body-liveness gain. Constraint 8 (this widget shares the screen with a
|
|
539
489
|
// live video call) argues for the smallest idle motion that still reads, and
|
|
@@ -554,28 +504,14 @@ export function createAvatar(opts = {}) {
|
|
|
554
504
|
// Aversions get their own gain: a look-away must read as one from across
|
|
555
505
|
// the call, where a fixation step must not, so one number cannot size both.
|
|
556
506
|
const saccadeGain = opts.saccadeGain ?? 1;
|
|
557
|
-
// How far this face may hold its head off centre, per axis
|
|
558
|
-
//
|
|
559
|
-
// what a 2.5-D face can hold before the photograph gives it away is a
|
|
560
|
-
// measurement of that face and of nothing else — so the number is the rig's
|
|
561
|
-
// to supply and is nowhere in this library. An axis left out is unbudgeted,
|
|
562
|
-
// which is every SVG and Canvas face and is the behaviour they have always
|
|
563
|
-
// had.
|
|
507
|
+
// How far this face may hold its head off centre, per axis (internal-mixer.md
|
|
508
|
+
// § The held-head budget): the rig supplies it; an axis left out is unbudgeted.
|
|
564
509
|
const headHold = opts.headHold || {};
|
|
565
|
-
// Per-axis gain on the head's *continuous* drive
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
// a third of the pitch a speaking human uses and nearly twice the yaw, on a
|
|
571
|
-
// face whose pitch budget has room for all of it. That is one number per
|
|
572
|
-
// axis, not a rewrite of either layer, and it belongs here rather than in
|
|
573
|
-
// the rig because it scales a *drive* — a rig scale would multiply the nods
|
|
574
|
-
// and beats too, and those are authored at amplitudes that already read.
|
|
575
|
-
//
|
|
576
|
-
// Before 6b deliberately: the budget is what stops a scaled-up drive from
|
|
577
|
-
// leaving the envelope, so a gain that skipped it would be measuring the
|
|
578
|
-
// face's failure rather than the layer's range. A number, or one per axis.
|
|
510
|
+
// Per-axis gain on the head's *continuous* drive — speech phrasing and idle,
|
|
511
|
+
// both sized in pose units for a line drawing. Measuring a 2.5-D face found
|
|
512
|
+
// the drive spending a third of the pitch a speaking human uses and nearly
|
|
513
|
+
// twice the yaw. In front of 6b deliberately: a gain that skipped the budget
|
|
514
|
+
// would be measuring the face's failure rather than the layer's range.
|
|
579
515
|
const headGain = typeof opts.headGain === 'number'
|
|
580
516
|
? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
|
|
581
517
|
: { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
|
|
@@ -584,8 +520,7 @@ export function createAvatar(opts = {}) {
|
|
|
584
520
|
// A rig that says what its pose units are in degrees gets the eye-head
|
|
585
521
|
// system sized for it (gaze.js): its own look targets, how an aversion
|
|
586
522
|
// splits between eyes and head, lids that follow the eye both ways, and the
|
|
587
|
-
// reflex in step 8b.
|
|
588
|
-
// exactly.
|
|
523
|
+
// reflex in step 8b.
|
|
589
524
|
const ocu = opts.oculomotor || {};
|
|
590
525
|
if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
|
|
591
526
|
if (ocu.avert) gaze.avertSplit = ocu.avert;
|
|
@@ -601,13 +536,8 @@ export function createAvatar(opts = {}) {
|
|
|
601
536
|
? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
|
|
602
537
|
y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
|
|
603
538
|
: null;
|
|
604
|
-
// How far the reflex may carry the eye in its socket, in pupil units
|
|
605
|
-
//
|
|
606
|
-
// head: the eye stops well short of its mechanical limit and the head makes
|
|
607
|
-
// up the rest (Guitton & Volle's effective oculomotor range). On a
|
|
608
|
-
// photographic eye an iris pinned under the upper lid with white showing
|
|
609
|
-
// beneath it reads as an eye-roll, not a thought. Down is looser — the lid
|
|
610
|
-
// follows the eye down and hides the sclera there.
|
|
539
|
+
// How far the reflex may carry the eye in its socket, in pupil units:
|
|
540
|
+
// Guitton & Volle's effective oculomotor range; the rig supplies the reach.
|
|
611
541
|
const reach = ocu.range || { x: 1, up: 1, down: 1 };
|
|
612
542
|
const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
|
|
613
543
|
const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
|
|
@@ -617,7 +547,6 @@ export function createAvatar(opts = {}) {
|
|
|
617
547
|
let wanderAt = 0;
|
|
618
548
|
let driftAt = 0;
|
|
619
549
|
let trunkYaw = 0;
|
|
620
|
-
let turnStartPending = false;
|
|
621
550
|
let glanceAt = 0;
|
|
622
551
|
let glanceUntil = 0;
|
|
623
552
|
let lastBack = null;
|
|
@@ -625,10 +554,6 @@ export function createAvatar(opts = {}) {
|
|
|
625
554
|
// how long the brows stay up for it.
|
|
626
555
|
let dartAt = 0, dartBrowUntil = 0;
|
|
627
556
|
const dart = { x: 0, y: 0 };
|
|
628
|
-
// THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
|
|
629
|
-
// when that handoff happened (it stands in for the turn-start look).
|
|
630
|
-
let returnAt = 0;
|
|
631
|
-
let carriedAt = -Infinity;
|
|
632
557
|
// The state whose gaze is showing, and when the current state takes it
|
|
633
558
|
// over if that is still pending (GAP_SETTLE). Usually the same state.
|
|
634
559
|
let gazeState = 'IDLE';
|
|
@@ -641,7 +566,7 @@ export function createAvatar(opts = {}) {
|
|
|
641
566
|
|
|
642
567
|
const cur = Object.assign({}, REST);
|
|
643
568
|
const target = Object.assign({}, REST);
|
|
644
|
-
// The
|
|
569
|
+
// The head axes again, carrying only what is held (step 6b).
|
|
645
570
|
const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
|
|
646
571
|
// What the rig is handed: `cur` with the reflex applied to the eyes. The
|
|
647
572
|
// same object when there is no reflex.
|
|
@@ -656,11 +581,7 @@ export function createAvatar(opts = {}) {
|
|
|
656
581
|
let raf = 0;
|
|
657
582
|
let last = 0;
|
|
658
583
|
let elapsed = 0;
|
|
659
|
-
// `manual` withholds the rAF loop so a tool can drive frames itself.
|
|
660
|
-
// baseline pages could already step a ClipPlayer by hand, but nothing could
|
|
661
|
-
// step the *mixer* — which is where idle, gaze and engagement actually
|
|
662
|
-
// compose — so motion had no reproducible render. The headless motion map in
|
|
663
|
-
// the working tree is what steps it.
|
|
584
|
+
// `manual` withholds the rAF loop so a tool can drive frames itself.
|
|
664
585
|
const manual = !!opts.manual;
|
|
665
586
|
|
|
666
587
|
function frame(now) {
|
|
@@ -719,11 +640,11 @@ export function createAvatar(opts = {}) {
|
|
|
719
640
|
// clip layer, on purpose: a sustained turn toward the screen recruits
|
|
720
641
|
// the trunk, and a nod or a head shake does not — a body that swings
|
|
721
642
|
// with every gesture reads as a mannequin on a turntable. The lag is
|
|
722
|
-
// not authored anywhere; torsoTurn
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
// going and not the looks riding on it, and it holds through a
|
|
726
|
-
// checking on the user is a look, not a turn toward them.
|
|
643
|
+
// not authored anywhere; torsoTurn chases the same target at a slower
|
|
644
|
+
// TAU than the head (params.js), so the trunk leaves late and settles
|
|
645
|
+
// late for free, at the share TRUNK_FOLLOW names. It follows where the
|
|
646
|
+
// head is going and not the looks riding on it, and it holds through a
|
|
647
|
+
// glance: checking on the user is a look, not a turn toward them.
|
|
727
648
|
if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
|
|
728
649
|
target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
|
|
729
650
|
|
|
@@ -754,11 +675,7 @@ export function createAvatar(opts = {}) {
|
|
|
754
675
|
// spot every time. Either leg's gaze-evoked blink is the shift's odds
|
|
755
676
|
// unless the state says (`blinkTo`, `blinkBack`), because which leg blinks
|
|
756
677
|
// is part of what the glance means.
|
|
757
|
-
if (
|
|
758
|
-
returnAt = 0;
|
|
759
|
-
setGaze(gst.gaze);
|
|
760
|
-
}
|
|
761
|
-
if (gst.glance && !returnAt) {
|
|
678
|
+
if (gst.glance) {
|
|
762
679
|
const gl = gst.glance;
|
|
763
680
|
if (glanceUntil && elapsed > glanceUntil) {
|
|
764
681
|
glanceUntil = 0;
|
|
@@ -807,25 +724,7 @@ export function createAvatar(opts = {}) {
|
|
|
807
724
|
// mixer's one-frame veto — anything that means "the user is checking
|
|
808
725
|
// whether I am with them" sets it (see api.attend).
|
|
809
726
|
gaze.setAversion(gst.aversion ? AVERSION[gst.aversion] : null);
|
|
810
|
-
|
|
811
|
-
// cue track as far as it has arrived, nor once it has run out (§4.2). The
|
|
812
|
-
// mid-turn looks wait for a silent cue, which is a phrase boundary. Both
|
|
813
|
-
// read last frame's sample, a frame late and harmlessly so.
|
|
814
|
-
let floorReturn = false;
|
|
815
|
-
gaze.gate = true;
|
|
816
|
-
if (gst.aversion === 'SPEAK') {
|
|
817
|
-
const cues = speech.cues;
|
|
818
|
-
floorReturn = !speech.playing || !cues.length
|
|
819
|
-
|| cues[cues.length - 1].t - speech.now < FLOOR_RETURN_MS;
|
|
820
|
-
const cue = cues[speech.index];
|
|
821
|
-
gaze.gate = !!cue && cue.v === SILENT;
|
|
822
|
-
}
|
|
823
|
-
gaze.hold = attendUntil > elapsed || clip.playing || floorReturn;
|
|
824
|
-
if (turnStartPending) {
|
|
825
|
-
turnStartPending = false;
|
|
826
|
-
// A reply that began mid-think has had its planning look already.
|
|
827
|
-
if (elapsed - carriedAt > 1.5) gaze.startTurn();
|
|
828
|
-
}
|
|
727
|
+
gaze.hold = attendUntil > elapsed || clip.playing;
|
|
829
728
|
|
|
830
729
|
engagement.enabled = !!st.engagement && !clip.playing;
|
|
831
730
|
engagement.update(dt);
|
|
@@ -864,6 +763,8 @@ export function createAvatar(opts = {}) {
|
|
|
864
763
|
const shape = mouth.letter !== SILENT
|
|
865
764
|
? shapeFor(mouth.letter, mouth.intensity)
|
|
866
765
|
: shapeFor(SILENT, 1);
|
|
766
|
+
// Rounding coming up is held against the shape's own (VisemeTrack.sample).
|
|
767
|
+
if (mouth.round > shape.mouthRound) shape.mouthRound = mouth.round;
|
|
867
768
|
// Gain pivots on the rest shape, not on zero: scaling absolute values would
|
|
868
769
|
// drag the closed mouth open, which is the one thing lipsync must never do.
|
|
869
770
|
for (const k in shape) {
|
|
@@ -871,11 +772,6 @@ export function createAvatar(opts = {}) {
|
|
|
871
772
|
? shape[k]
|
|
872
773
|
: REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
|
|
873
774
|
}
|
|
874
|
-
// A smile held static through a sentence is discounted as insincere, and
|
|
875
|
-
// corners riding every open viseme read as laughing through the words
|
|
876
|
-
// (research-perception.md §3: warmth must be episodic). While the mouth
|
|
877
|
-
// is genuinely speech-driven the BASE smile decays to a fraction of
|
|
878
|
-
// itself; the smile channels' 130ms tau turns the gate into an ease.
|
|
879
775
|
// Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
|
|
880
776
|
// and only the base is scaled, so a gesture clip can still smile over a
|
|
881
777
|
// sentence by authoring corner keys (they add, unscaled, in step 5).
|
|
@@ -955,11 +851,9 @@ export function createAvatar(opts = {}) {
|
|
|
955
851
|
for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
|
|
956
852
|
for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
|
|
957
853
|
|
|
958
|
-
// 6b. the held-head budget.
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
// go. Only the excess over `soften` comes off, and only off the hold:
|
|
962
|
-
// a nod, a beat and a clip keep every degree they were authored with,
|
|
854
|
+
// 6b. the held-head budget (internal-mixer.md § The held-head budget).
|
|
855
|
+
// Only the excess over `soften` comes off, and only off the hold: a
|
|
856
|
+
// nod, a beat and a clip keep every degree they were authored with,
|
|
963
857
|
// which is why this is subtracted here rather than applied to the pose.
|
|
964
858
|
for (const c of HEAD_AXES) {
|
|
965
859
|
if (headHold[c] === undefined) continue;
|
|
@@ -969,10 +863,10 @@ export function createAvatar(opts = {}) {
|
|
|
969
863
|
// 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
|
|
970
864
|
// same share of it that it takes of a turn in 2b — Live2D gives its
|
|
971
865
|
// body the same fraction of AngleZ as of AngleX (research-head-rotation
|
|
972
|
-
// .md §3) — and the shoulder line tips with the head. Both channels
|
|
973
|
-
// slower than the head (TAU
|
|
974
|
-
//
|
|
975
|
-
//
|
|
866
|
+
// .md §3) — and the shoulder line tips with the head. Both channels
|
|
867
|
+
// smooth slower than the head (TAU), so the body leaves late and
|
|
868
|
+
// settles late, and that follow-through is most of what separates a
|
|
869
|
+
// neck bending from a hinge.
|
|
976
870
|
// Only the *held* roll, and after the budget: a stroke or a clip's roll
|
|
977
871
|
// is a gesture riding on the pose, and a body that answers those is 2b's
|
|
978
872
|
// mannequin on a turntable. The idle layer's own posture is already
|
|
@@ -1002,17 +896,9 @@ export function createAvatar(opts = {}) {
|
|
|
1002
896
|
// 8. smooth toward the target — this is where co-articulation happens
|
|
1003
897
|
for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
|
|
1004
898
|
|
|
1005
|
-
// 8b. The vestibulo-ocular reflex.
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
// turns further than its eyes, a look at a different spot each time.
|
|
1009
|
-
// Real eyes counter-rotate against the head within ~10 ms and stay on
|
|
1010
|
-
// what they look at. Here and not in the gaze layer because only here
|
|
1011
|
-
// is the head that is actually drawn known: prosody, clips and idle all
|
|
1012
|
-
// land after gaze. It also gives a large shift its real shape for free:
|
|
1013
|
-
// the eyes jump to the target, past where they will sit, and roll back
|
|
1014
|
-
// in the head as it arrives under them. A pupil a tuning UI overrides is
|
|
1015
|
-
// left where it was put.
|
|
899
|
+
// 8b. The vestibulo-ocular reflex. Here and not in the gaze layer because
|
|
900
|
+
// only here is the head that is actually drawn known: prosody, clips
|
|
901
|
+
// and idle all land after gaze.
|
|
1016
902
|
if (vor) {
|
|
1017
903
|
Object.assign(shown, cur);
|
|
1018
904
|
if (!overrides || overrides.pupilX === undefined) {
|
|
@@ -1041,30 +927,20 @@ export function createAvatar(opts = {}) {
|
|
|
1041
927
|
// sliding.
|
|
1042
928
|
const TRUNK_FOLLOW = opts.trunkFollow ?? 0.45;
|
|
1043
929
|
|
|
1044
|
-
// Speakers are back on the listener's eyes this long before they stop
|
|
1045
|
-
// (Andrist, docs/research-biomechanics.md §4.2): the floor is handed over
|
|
1046
|
-
// under mutual gaze, and a look away there declines it.
|
|
1047
|
-
const FLOOR_RETURN_MS = 2400;
|
|
1048
|
-
|
|
1049
930
|
// The channels speech owns outright — exactly the params.js mouth group
|
|
1050
931
|
// (mouth corners stay free: a clip may smile over a sentence).
|
|
1051
932
|
const MOUTH_LOCK = new Set(GROUPS.mouth);
|
|
1052
933
|
|
|
934
|
+
/** The shoulders' share of the attentive posture: two thirds of the lean the
|
|
935
|
+
* engagement layer spends — the shoulders come up with it, they do not lead it. */
|
|
936
|
+
const ENGAGE_SHOULDER = 0.10;
|
|
937
|
+
|
|
1053
938
|
// What survives of the resting/emotion smile while speech owns the mouth.
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const ENGAGE_SHOULDER = 0.10;
|
|
1060
|
-
const SPEAK_SMILE_RETAIN = 0.35;
|
|
1061
|
-
|
|
1062
|
-
// How long a reply that starts mid-think keeps the look before it comes
|
|
1063
|
-
// back. Speakers look away to find the words and back to deliver them
|
|
1064
|
-
// (§4.2); a reply that snapped to the user on its first syllable and then,
|
|
1065
|
-
// three times in four, left again for the turn-start look did both halves
|
|
1066
|
-
// twice.
|
|
1067
|
-
const THINK_CARRY = [0.25, 0.6];
|
|
939
|
+
// A smile held static through a sentence is discounted as insincere, and
|
|
940
|
+
// corners riding every open viseme read as laughing through the words: warmth
|
|
941
|
+
// must be episodic (research-perception.md §3). Full warmth returns the moment
|
|
942
|
+
// the track ends, which is that onset/offset.
|
|
943
|
+
const SPEAK_SMILE_RETAIN = 0.35;
|
|
1068
944
|
|
|
1069
945
|
// Between the user's turn and the reply the server's claim can change
|
|
1070
946
|
// several times a second — THINKING, a tool's WORKING, THINKING again, a
|
|
@@ -1152,25 +1028,29 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1152
1028
|
const deferrable = !o.gaze && !o.keepGaze && GAP_STATES.has(gazeState) && GAP_STATES.has(name);
|
|
1153
1029
|
if (deferrable && changed) settleAt = name === gazeState ? 0 : elapsed + GAP_SETTLE;
|
|
1154
1030
|
else if (!(deferrable && settleAt)) { settleAt = 0; enterGaze(name, o, changed); }
|
|
1031
|
+
// The floor has come back to the user: the face receives it (prosody.js
|
|
1032
|
+
// `listen`). It is a state change rather than a VAD event on purpose —
|
|
1033
|
+
// what is being welcomed is the turn, and the server is the one that knows
|
|
1034
|
+
// a turn has changed hands.
|
|
1035
|
+
if (changed && name === 'LISTENING') prosody.listen();
|
|
1155
1036
|
if (changed) emit('state', name);
|
|
1156
1037
|
return api;
|
|
1157
1038
|
}
|
|
1158
1039
|
|
|
1040
|
+
// Whether the eyes were last aimed by somebody who meant it — the public
|
|
1041
|
+
// `setGaze`, or a performance's `gaze` verb — rather than by the state's own
|
|
1042
|
+
// schedule. It is the one thing that outranks a state's `gaze`, and it is
|
|
1043
|
+
// why `speak()` can take the eyes back without overriding a caller.
|
|
1044
|
+
let gazeExplicit = false;
|
|
1045
|
+
|
|
1159
1046
|
/** The state `name` takes the gaze: its target and its schedules. */
|
|
1160
1047
|
function enterGaze(name, o, blink) {
|
|
1161
|
-
const prev = gazeState;
|
|
1162
1048
|
gazeState = name;
|
|
1163
1049
|
const st = states[name];
|
|
1164
1050
|
const gl = st.glance;
|
|
1165
|
-
// A reply that starts while THINKING is looking away finishes that look
|
|
1166
|
-
// first, and the look stands in for the turn-start one.
|
|
1167
|
-
const carry = prev === 'THINKING' && name === 'SPEAKING'
|
|
1168
|
-
&& !o.gaze && !o.keepGaze && gazeName !== st.gaze && !glanceUntil;
|
|
1169
|
-
returnAt = carry ? elapsed + rand(THINK_CARRY) : 0;
|
|
1170
|
-
if (carry) carriedAt = elapsed;
|
|
1171
1051
|
// A state with an `opening` enters as though its check-in on the user is
|
|
1172
1052
|
// already under way, and leaves it when that runs out.
|
|
1173
|
-
if (!o.keepGaze
|
|
1053
|
+
if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
|
|
1174
1054
|
// Arm every scheduler fresh, so entering a state never fires a timestamp
|
|
1175
1055
|
// left over from the last one — the wander in particular, which used to
|
|
1176
1056
|
// pick a new target on the first frame and override the state's own gaze.
|
|
@@ -1181,7 +1061,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1181
1061
|
readCol = readCols = readRow = readRows = 0;
|
|
1182
1062
|
dartAt = dartBrowUntil = 0;
|
|
1183
1063
|
dart.x = dart.y = 0;
|
|
1184
|
-
if (blink) idle.blink(
|
|
1064
|
+
if (blink) idle.blink(true);
|
|
1185
1065
|
}
|
|
1186
1066
|
|
|
1187
1067
|
function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
|
|
@@ -1217,8 +1097,12 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1217
1097
|
: () => performance.now() - speakStart;
|
|
1218
1098
|
speech.start(o.cues || [], speakClock);
|
|
1219
1099
|
prosody.reset(newTurn);
|
|
1220
|
-
|
|
1221
|
-
|
|
1100
|
+
// The eyes come back to the user when the audio starts: a reply arriving
|
|
1101
|
+
// while THINKING was looking away would otherwise spend its whole turn
|
|
1102
|
+
// aimed off the user, because nothing in SPEAKING retargets. `keepGaze` is
|
|
1103
|
+
// for the one caller that means it — a performance that aimed the eyes with
|
|
1104
|
+
// its own `gaze` verb keeps them, an instruction and not a leftover.
|
|
1105
|
+
if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
|
|
1222
1106
|
if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
|
|
1223
1107
|
return api;
|
|
1224
1108
|
}
|
|
@@ -1229,25 +1113,14 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1229
1113
|
* This is the widget's half of the **gaze window**. In face-to-face talk a
|
|
1230
1114
|
* speaker periodically looks at the listener, mutual gaze is established, the
|
|
1231
1115
|
* listener responds inside that window, and the speaker looks away again
|
|
1232
|
-
* (Bavelas, Coates & Johnson 2002)
|
|
1233
|
-
* window rather than being scattered across the turn.
|
|
1116
|
+
* (Bavelas, Coates & Johnson 2002).
|
|
1234
1117
|
*
|
|
1235
1118
|
* We cannot see the user, so we cannot observe the window opening. What a
|
|
1236
1119
|
* caller *can* do is name the moments that co-occur with it — a mid-turn
|
|
1237
1120
|
* pause, a tag question ("...right?", "you know?"), a completed clause with
|
|
1238
1121
|
* the turn analyzer's completion probability high, the user answering a
|
|
1239
|
-
* question the bot asked. `
|
|
1240
|
-
*
|
|
1241
|
-
* any response to be *seen*. Emitting the response itself stays a separate
|
|
1242
|
-
* call — a window that opens and draws nothing is a real and common outcome
|
|
1243
|
-
* (with every measured invitation cue present, humans respond to only ~30% of
|
|
1244
|
-
* opportunities), and conflating the two would make the avatar answer
|
|
1245
|
-
* everything. The explicit response remains a backend/application decision.
|
|
1246
|
-
*
|
|
1247
|
-
* Deliberately **not on the wire yet**: there is no `attend` command in
|
|
1248
|
-
* `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
|
|
1249
|
-
* the rig pages). Adding the command is a protocol change and waits for a
|
|
1250
|
-
* server that has something real to key it off — see docs/internal-mixer.md.
|
|
1122
|
+
* question the bot asked. For `ms` the face stops averting and holds the
|
|
1123
|
+
* user, which is the prerequisite for any response to be *seen*.
|
|
1251
1124
|
*
|
|
1252
1125
|
* @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
|
|
1253
1126
|
* mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
|
|
@@ -1324,7 +1197,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1324
1197
|
const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
|
|
1325
1198
|
if (!faceClip) return api;
|
|
1326
1199
|
clip.play(faceClip, faceClip.audioEl, { queue: true });
|
|
1327
|
-
// The
|
|
1200
|
+
// The acknowledgements smile. Only these, and only ever because the
|
|
1328
1201
|
// server sent one: a smile the renderer timed for itself would be an
|
|
1329
1202
|
// acknowledgement nobody sent.
|
|
1330
1203
|
if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
|
|
@@ -1359,11 +1232,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1359
1232
|
return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
|
|
1360
1233
|
}
|
|
1361
1234
|
|
|
1362
|
-
/**
|
|
1363
|
-
* Tell the listening engine whether Pipecat VAD says the user holds the
|
|
1364
|
-
* floor. This changes only sustained engagement posture; it can never create
|
|
1365
|
-
* a nod or acknowledgement clip.
|
|
1366
|
-
*/
|
|
1367
1235
|
function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
|
|
1368
1236
|
|
|
1369
1237
|
// What one action does when its moment comes. Enum validity is checked here,
|
|
@@ -1376,7 +1244,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1376
1244
|
// composed turn in the demo already behaves.
|
|
1377
1245
|
if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
|
|
1378
1246
|
else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
|
|
1379
|
-
else if (a.do === 'gaze') setGaze(a.name);
|
|
1247
|
+
else if (a.do === 'gaze') { setGaze(a.name); gazeExplicit = true; }
|
|
1380
1248
|
else if (a.do === 'action') action(a.id);
|
|
1381
1249
|
} catch (e) {
|
|
1382
1250
|
console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
|
|
@@ -1386,15 +1254,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1386
1254
|
let performGen = 0;
|
|
1387
1255
|
|
|
1388
1256
|
/**
|
|
1389
|
-
* Play a timed action track — the composition surface a server assembles
|
|
1390
|
-
* turns from. Verbs: state / emotion / gaze / action (see
|
|
1391
|
-
* perform.js for hygiene, docs/internal-mixer.md for the schema).
|
|
1392
|
-
*
|
|
1393
|
-
* Clock resolution mirrors speak(): explicit `clock` fn, else the audio
|
|
1394
|
-
* element's own time, else ms elapsed since this call. perform() never
|
|
1395
|
-
* starts or stops audio — speak() owns the sound; this owns the choreography
|
|
1396
|
-
* that rides it.
|
|
1397
|
-
*
|
|
1398
1257
|
* @param {Array<{t: number, do: string}>} actions
|
|
1399
1258
|
* @param {{audio?: HTMLMediaElement, clock?: () => number,
|
|
1400
1259
|
* onAction?: (a: object) => void}} [o]
|
|
@@ -1417,7 +1276,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1417
1276
|
}
|
|
1418
1277
|
|
|
1419
1278
|
const api = {
|
|
1420
|
-
setState, setEmotion, setGaze: (name, custom) => setGaze(name, custom), speak, pushCues, stopSpeaking, attend,
|
|
1279
|
+
setState, setEmotion, setGaze: (name, custom) => { gazeExplicit = true; return setGaze(name, custom); }, speak, pushCues, stopSpeaking, attend,
|
|
1421
1280
|
action, perform,
|
|
1422
1281
|
/** Which hand the character gestures with: +1 the viewer's right (its own
|
|
1423
1282
|
* left), -1 the other. Both are anatomically real — the thumb splays away
|
|
@@ -1433,7 +1292,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1433
1292
|
/** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
|
|
1434
1293
|
setMotionGain: (g) => { idle.gain = g; return api; },
|
|
1435
1294
|
get motionGain() { return idle.gain; },
|
|
1436
|
-
blink: (
|
|
1295
|
+
blink: () => { idle.blink(); return api; },
|
|
1437
1296
|
/** Advance one frame by hand. Only meaningful under `{manual: true}`;
|
|
1438
1297
|
* fixed-dt stepping is what makes a motion render reproducible. */
|
|
1439
1298
|
step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
|
|
@@ -1474,18 +1333,35 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1474
1333
|
// this channel's neutral value" before it can map the channel onto whatever it
|
|
1475
1334
|
// controls — a morph target's influence is `(pose - rest) / (1 - rest)`, and a
|
|
1476
1335
|
// rig that hard-codes those rests has quietly forked `params.js`.
|
|
1477
|
-
export { REST, CHANNELS, RANGE } from './params.js';
|
|
1336
|
+
export { REST, CHANNELS, RANGE, TAU, clamp, approach, makeParams } from './params.js';
|
|
1337
|
+
// The smoothing law itself, for a tool that steps the rig by hand. A
|
|
1338
|
+
// filmstrip that reimplemented `approach` would be measuring its own copy of
|
|
1339
|
+
// the thing under test — and the smoothing between keyframes is what the face
|
|
1340
|
+
// actually does (docs/internal-mixer.md § Smoothing).
|
|
1341
|
+
// A pose with no mixer above it: `makeParams(overrides)` fills the rests,
|
|
1342
|
+
// `avatarFrame` wraps it, `createSvgRig(face).apply` draws it. That is the whole
|
|
1343
|
+
// path an instrument needs to hold a face at one named extreme — no clock, no
|
|
1344
|
+
// client, no animation — and the reason it is exported is that a pose sheet
|
|
1345
|
+
// that cannot reach it forks the channel rests instead.
|
|
1346
|
+
export { avatarFrame, createSvgRig } from './rig.js';
|
|
1478
1347
|
export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
|
|
1348
|
+
// The full authoring catalogue and the player that steps it. Not a server
|
|
1349
|
+
// vocabulary — `ACTIONS` is that, and most of these clips are reachable only
|
|
1350
|
+
// from inside the mixer. They are exported for the filmstrip instrument,
|
|
1351
|
+
// which lays one clip out as frames and therefore has to drive a real
|
|
1352
|
+
// `ClipPlayer` rather than re-sample its keys.
|
|
1353
|
+
export { INTERNAL_CLIPS } from './interjections.js';
|
|
1354
|
+
export { ClipPlayer } from './clips.js';
|
|
1479
1355
|
export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
|
|
1480
1356
|
export { normalizeActions } from './perform.js';
|
|
1481
1357
|
export { checkHandFraming } from './hand.js';
|
|
1482
|
-
export { EMOTION_NAMES } from './emotions.js';
|
|
1358
|
+
export { EMOTION_NAMES, emotionPose } from './emotions.js';
|
|
1483
1359
|
// The mouth clock travels with the rest of it. Someone has to turn a cue array
|
|
1484
1360
|
// plus a clock into "which letter is on screen right now", every renderer needs
|
|
1485
1361
|
// exactly that, and none of them should write it twice — so it is a plain class
|
|
1486
1362
|
// to construct, not a contract to implement.
|
|
1487
1363
|
export {
|
|
1488
|
-
VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
|
|
1364
|
+
VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, JAW_OF_OPEN, SILENT,
|
|
1489
1365
|
normalizeCues, textToCues,
|
|
1490
1366
|
ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
|
|
1491
1367
|
} from './visemes.js';
|