@voqalize/avatar 0.4.2 → 0.4.4
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 +11 -98
- package/assets/README.md +28 -17
- 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 +4 -0
- package/client/supports.ts +6 -7
- package/client/three/assets.ts +2 -0
- package/client/three/budgets.ts +1 -1
- package/client/three/{tara-rig.ts → character-rig.ts} +708 -547
- package/client/three/createCharacter.ts +109 -0
- package/client/three/holds.ts +1 -1
- package/client/three/internal.ts +4 -3
- package/client/three/motion-limits.json +5 -1
- 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.ts +17 -59
- package/client/three/tushar.ts +18 -55
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +5 -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 +1 -0
- package/dist/three/assets.d.ts.map +1 -1
- package/dist/three/assets.js +2 -0
- 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} +664 -525
- 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.js +1 -1
- package/dist/three/holds.js.map +1 -1
- package/dist/three/internal.d.ts +3 -3
- package/dist/three/internal.d.ts.map +1 -1
- package/dist/three/internal.js +2 -2
- package/dist/three/internal.js.map +1 -1
- package/dist/three/motion-limits.json +5 -1
- 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.d.ts +15 -29
- package/dist/three/tess.d.ts.map +1 -1
- package/dist/three/tess.js +14 -44
- package/dist/three/tess.js.map +1 -1
- 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 +7 -50
- package/src/avatar.d.ts +2 -0
- package/src/avatar.js +99 -220
- package/src/gaze.js +1 -3
- package/src/idle.js +10 -2
- package/src/prosody.js +7 -3
- package/src/speech-timing.js +28 -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 -423
- 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';
|
|
@@ -36,6 +20,7 @@ import { IdleLayer, ListeningEngine } from './idle.js';
|
|
|
36
20
|
import { ClipPlayer } from './clips.js';
|
|
37
21
|
import { ACTIONS, INTERNAL_CLIPS } from './interjections.js';
|
|
38
22
|
import { VisemeTrack, shapeFor, SILENT } from './visemes.js';
|
|
23
|
+
import { LIP_CONTACT_AIM } from './speech-timing.js';
|
|
39
24
|
import { SpeechProsody, UNCALIBRATED_HEAD_GAIN } from './prosody.js';
|
|
40
25
|
import { HEAD_AXES, soften } from './head.js';
|
|
41
26
|
import { PerformTrack } from './perform.js';
|
|
@@ -67,8 +52,7 @@ export const STATES = {
|
|
|
67
52
|
// the attentive pose it looks like — it is a demand for more talk (Rossano)
|
|
68
53
|
// and it measures as *tense*, not attentive (Wang & Gratch). See AVERSION in
|
|
69
54
|
// 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.
|
|
55
|
+
// At 3.2-4.4 s the blink timer measured 20-23/min.
|
|
72
56
|
LISTENING: { gaze: 'USER', emotion: 'neutral', engagement: true,
|
|
73
57
|
aversion: 'LISTEN',
|
|
74
58
|
idle: { sway: 1.0, blinkGap: [3.6, 4.8] },
|
|
@@ -91,8 +75,6 @@ export const STATES = {
|
|
|
91
75
|
// is kept to a fifth of looks because on a face this real it reads as
|
|
92
76
|
// downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
|
|
93
77
|
// alert, and the two together measured past the 0.15 that reads drowsy.
|
|
94
|
-
// There is no handoff to SPEAKING any more: a reply that starts mid-look
|
|
95
|
-
// brings the eyes back with its first word (see enterGaze, 2026-09-21).
|
|
96
78
|
THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
|
|
97
79
|
// Fixational jumps rare and small: a thinker's eyes rest
|
|
98
80
|
// where they land. At the default gap the look jittered
|
|
@@ -117,26 +99,15 @@ export const STATES = {
|
|
|
117
99
|
'AWAY_SIDE', 'AWAY_SIDE', 'AWAY_SIDE',
|
|
118
100
|
'AWAY_RIGHT', 'AWAY_RIGHT', 'AWAY_DOWN', 'AWAY_DOWN'],
|
|
119
101
|
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.
|
|
102
|
+
// **The chin comes up**: thinking is the one stretch of
|
|
103
|
+
// a call where the avatar owes the user feedback and
|
|
104
|
+
// has no mouth to give it with, the eyes being off the
|
|
105
|
+
// user by design. A head tipping back as the gaze
|
|
106
|
+
// leaves is the swing-up of research-biomechanics.md
|
|
107
|
+
// § 3.3. It rides the looks that go up, as the head's
|
|
108
|
+
// *share* of them: held as a pose it lifted the chin on
|
|
109
|
+
// the level and downward looks too, so the user was
|
|
110
|
+
// looked at down the nose.
|
|
140
111
|
pose: { lidL: -0.10, lidR: -0.10 } },
|
|
141
112
|
// Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
|
|
142
113
|
// speaker's measured looks away are not this rig's to render. What moves
|
|
@@ -151,13 +122,13 @@ export const STATES = {
|
|
|
151
122
|
// The head cant is the state's signature cue, and it has to clear the roll
|
|
152
123
|
// multiplier to exist at all: 0.05 here renders as 0.3° of rotation, which
|
|
153
124
|
// is no tilt whatever the number says. 0.30 renders ~1.7° — visible at tile
|
|
154
|
-
// size, still gentle.
|
|
125
|
+
// size, still gentle.
|
|
155
126
|
WAITING_FOR_USER: { gaze: 'USER', emotion: 'encouraging', engagement: true,
|
|
156
127
|
idle: { sway: 1.0, blinkGap: [3.1, 4.2] },
|
|
157
128
|
pose: { headRoll: 0.30, browRaiseL: 0.16, browRaiseR: 0.12 } },
|
|
158
129
|
// Straining to hear. The one state where the amplitude constraint yields,
|
|
159
|
-
// because the lean IS the message: torsoLean well past
|
|
160
|
-
//
|
|
130
|
+
// because the lean IS the message: torsoLean well past what the engagement
|
|
131
|
+
// layer ever spends, head cheated aside on USER_EAR so an ear
|
|
161
132
|
// favors the speaker while the eyes hold contact, and a concentration
|
|
162
133
|
// squint with knit brows. Stillness does the rest — straining people
|
|
163
134
|
// freeze — so holds are frequent and there is no engagement lean: you don't
|
|
@@ -211,7 +182,7 @@ export const STATES = {
|
|
|
211
182
|
},
|
|
212
183
|
// --- application state ---------------------------------------------------
|
|
213
184
|
// "Momentarily busy on the thing you asked for." No hands in frame, so the
|
|
214
|
-
// whole read comes from
|
|
185
|
+
// whole read comes from cheap cues (docs/research-biomechanics.md §6.4,
|
|
215
186
|
// recommendation 19): gaze down on one stable target with a reading scan,
|
|
216
187
|
// blinks suppressed to task-focus rate (~9/min), shoulders slightly raised
|
|
217
188
|
// and *held* with brief micro-freezes, and a glance back up to the user.
|
|
@@ -230,9 +201,8 @@ export const STATES = {
|
|
|
230
201
|
// take back most of the down look's follow so the eyes stay awake.
|
|
231
202
|
gaze: 'OWN_SCREEN', emotion: 'neutral', engagement: false,
|
|
232
203
|
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.
|
|
204
|
+
// §6.4's ~9/min is a count of blinks: at the table's own 6-7.5 s gap it
|
|
205
|
+
// measured 12-13/min, which is not focus.
|
|
236
206
|
idle: { sway: 0.5, blinkGap: [6.8, 8.6], breathRate: 1.05, breathAmp: 0.8,
|
|
237
207
|
hold: { every: [5.0, 9.0], dur: [0.6, 1.1] } },
|
|
238
208
|
// The look up to check is brows-first and blinkless, the lids leading it
|
|
@@ -254,11 +224,9 @@ export const STATES = {
|
|
|
254
224
|
browRaiseL: -0.08, browRaiseR: -0.08, browInnerL: -0.10, browInnerR: -0.10 },
|
|
255
225
|
},
|
|
256
226
|
// 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:
|
|
227
|
+
// communicate. The glance looks up and HOLDS 1.2–2 s, expectant, because the
|
|
228
|
+
// chat (and the user's face) is now the only channel there is. A touch of
|
|
229
|
+
// browInner carries the apology. Relation to DEGRADED is by semantics, not merger:
|
|
262
230
|
// DEGRADED says "my feed is broken", TYPING_CHAT says "I'm working around
|
|
263
231
|
// it" — a server will typically sequence DEGRADED → TYPING_CHAT.
|
|
264
232
|
TYPING_CHAT: {
|
|
@@ -273,7 +241,7 @@ export const STATES = {
|
|
|
273
241
|
browInnerL: 0.45, browInnerR: 0.38,
|
|
274
242
|
mouthPress: 0.50, mouthCornerL: -0.28, mouthCornerR: -0.28 },
|
|
275
243
|
},
|
|
276
|
-
// Attention genuinely elsewhere. What separates this from
|
|
244
|
+
// Attention genuinely elsewhere. What separates this from WORKING is target
|
|
277
245
|
// *stability* (§6.4): busy is one steady off-user target, distracted is
|
|
278
246
|
// wandering ones, held long (aversion >3s), with no engagement lean — the
|
|
279
247
|
// missing nod is as diagnostic as the look-away. Sway is looser than
|
|
@@ -284,7 +252,7 @@ export const STATES = {
|
|
|
284
252
|
idle: { sway: 1.15, blinkGap: [1.8, 4.2] },
|
|
285
253
|
// Sideways and up, never steep-down: lateral is where real intimacy/
|
|
286
254
|
// distraction aversions live, and a steep down target seals this rig's
|
|
287
|
-
// eyes (see
|
|
255
|
+
// eyes (see WORKING).
|
|
288
256
|
wander: { targets: ['AWAY_RIGHT', 'AWAY_THINKING', 'SCREEN_LEFT', 'SCREEN_TOP'],
|
|
289
257
|
every: [2.8, 6.8] },
|
|
290
258
|
},
|
|
@@ -321,7 +289,7 @@ export const STATES = {
|
|
|
321
289
|
// condition and not an event — WANTS_IN in particular has to hold for as long
|
|
322
290
|
// as it takes the other person to notice it.
|
|
323
291
|
//
|
|
324
|
-
//
|
|
292
|
+
// Each of these lifts the shoulders and parts the lips, because that is what an
|
|
325
293
|
// inbreath looks like from outside, and an inbreath is the cue humans actually
|
|
326
294
|
// use to predict that someone is about to speak. The head comes *up* rather
|
|
327
295
|
// than down: a lowered head is deferential and reads as yielding.
|
|
@@ -403,9 +371,7 @@ export const STATE_NAMES = Object.keys(STATES);
|
|
|
403
371
|
* figure Live2D gives its body angles, put 6 px of shoulder on an 8 degree tilt
|
|
404
372
|
* and read as a shrug arriving with the head.
|
|
405
373
|
*
|
|
406
|
-
* Exported for
|
|
407
|
-
* mixer would have put under the tilt; the rig instruments in the working tree
|
|
408
|
-
* are its only callers.
|
|
374
|
+
* Exported for the rig instruments.
|
|
409
375
|
*/
|
|
410
376
|
export const SHOULDER_TILT = 0.08;
|
|
411
377
|
|
|
@@ -413,11 +379,9 @@ export function createAvatar(opts = {}) {
|
|
|
413
379
|
const mount = typeof opts.mount === 'string' ? document.querySelector(opts.mount) : opts.mount;
|
|
414
380
|
if (!mount) throw new Error('createAvatar: mount element required');
|
|
415
381
|
|
|
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.
|
|
382
|
+
// `opts.face` is a Face record — `{ create, meta }`. Passed in rather than
|
|
383
|
+
// named: a name needs a table, and a table imports every drawing to answer
|
|
384
|
+
// one lookup (`src/faces.js` has that table, for tooling that wants them).
|
|
421
385
|
const entry = opts.rig ? null : opts.face;
|
|
422
386
|
if (!opts.rig && !entry) {
|
|
423
387
|
throw new Error('createAvatar: a `face` (see src/faces.js) or a `rig` is required');
|
|
@@ -445,9 +409,6 @@ export function createAvatar(opts = {}) {
|
|
|
445
409
|
// control. `hand: false` only disables its SVG rendering; gesture actions
|
|
446
410
|
// still emit the semantic hand frame for a supplied custom rig.
|
|
447
411
|
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
412
|
const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
|
|
452
413
|
|
|
453
414
|
gaze.onLargeShift = (forced) => idle.blink(true, forced);
|
|
@@ -467,22 +428,19 @@ export function createAvatar(opts = {}) {
|
|
|
467
428
|
let gazeName = 'USER';
|
|
468
429
|
let gazeCustom = null;
|
|
469
430
|
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.
|
|
431
|
+
// Articulation gain. VISEME_SHAPES is tuned for a face at conversational
|
|
432
|
+
// size; sharing the screen with live video the same shapes read as
|
|
433
|
+
// under-articulated. Scales every viseme away from rest, so shape identities
|
|
434
|
+
// and their relative sizes survive and only the excursion changes. Above
|
|
435
|
+
// ~1.5 the open vowels saturate against the clamp — the intended ceiling.
|
|
478
436
|
let mouthGain = opts.mouthGain ?? 1;
|
|
479
437
|
let handSide = opts.handSide === -1 ? 'left' : 'right';
|
|
480
438
|
/**
|
|
481
|
-
* This avatar's own addressable motions, on top of the
|
|
439
|
+
* This avatar's own addressable motions, on top of the core intents.
|
|
482
440
|
*
|
|
483
441
|
* An avatar is a drawing with a body, and some bodies can do things the wire
|
|
484
|
-
* has no portable word for. The
|
|
485
|
-
*
|
|
442
|
+
* has no portable word for. The nod types the listening research separates —
|
|
443
|
+
* a continuer, an assessment, a realisation — are one
|
|
486
444
|
* `ACKNOWLEDGE` to a server, because that is all a server can ask of every
|
|
487
445
|
* face; the *shapes* are sized in a rig's own units and belong to the rig
|
|
488
446
|
* (`packages/avatar/client/three/sequences.ts` is the first table of them).
|
|
@@ -514,13 +472,9 @@ export function createAvatar(opts = {}) {
|
|
|
514
472
|
}
|
|
515
473
|
/**
|
|
516
474
|
* 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.
|
|
475
|
+
* the held face instead of the gesture. A rig replaces a state's fields
|
|
476
|
+
* whole (`pose`, say), and only for a state the table already has: it can
|
|
477
|
+
* re-render the vocabulary, never extend it.
|
|
524
478
|
*/
|
|
525
479
|
const states = { ...STATES };
|
|
526
480
|
for (const [id, own] of Object.entries(opts.states || {})) {
|
|
@@ -529,11 +483,8 @@ export function createAvatar(opts = {}) {
|
|
|
529
483
|
}
|
|
530
484
|
let handAction = null;
|
|
531
485
|
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.
|
|
486
|
+
// Gesture gain, same idea for the clip layer: small gestures under-render
|
|
487
|
+
// through the head's τ — see internal-mixer.md § Smoothing.
|
|
537
488
|
let gestureGain = opts.gestureGain ?? 1;
|
|
538
489
|
// Body-liveness gain. Constraint 8 (this widget shares the screen with a
|
|
539
490
|
// live video call) argues for the smallest idle motion that still reads, and
|
|
@@ -554,28 +505,14 @@ export function createAvatar(opts = {}) {
|
|
|
554
505
|
// Aversions get their own gain: a look-away must read as one from across
|
|
555
506
|
// the call, where a fixation step must not, so one number cannot size both.
|
|
556
507
|
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.
|
|
508
|
+
// How far this face may hold its head off centre, per axis (internal-mixer.md
|
|
509
|
+
// § The held-head budget): the rig supplies it; an axis left out is unbudgeted.
|
|
564
510
|
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.
|
|
511
|
+
// Per-axis gain on the head's *continuous* drive — speech phrasing and idle,
|
|
512
|
+
// both sized in pose units for a line drawing. Measuring a 2.5-D face found
|
|
513
|
+
// the drive spending a third of the pitch a speaking human uses and nearly
|
|
514
|
+
// twice the yaw. In front of 6b deliberately: a gain that skipped the budget
|
|
515
|
+
// would be measuring the face's failure rather than the layer's range.
|
|
579
516
|
const headGain = typeof opts.headGain === 'number'
|
|
580
517
|
? { headYaw: opts.headGain, headPitch: opts.headGain, headRoll: opts.headGain }
|
|
581
518
|
: { headYaw: 1, headPitch: 1, headRoll: 1, ...(opts.headGain || {}) };
|
|
@@ -584,8 +521,7 @@ export function createAvatar(opts = {}) {
|
|
|
584
521
|
// A rig that says what its pose units are in degrees gets the eye-head
|
|
585
522
|
// system sized for it (gaze.js): its own look targets, how an aversion
|
|
586
523
|
// splits between eyes and head, lids that follow the eye both ways, and the
|
|
587
|
-
// reflex in step 8b.
|
|
588
|
-
// exactly.
|
|
524
|
+
// reflex in step 8b.
|
|
589
525
|
const ocu = opts.oculomotor || {};
|
|
590
526
|
if (ocu.targets) gaze.targets = { ...GAZE_TARGETS, ...ocu.targets };
|
|
591
527
|
if (ocu.avert) gaze.avertSplit = ocu.avert;
|
|
@@ -601,13 +537,8 @@ export function createAvatar(opts = {}) {
|
|
|
601
537
|
? { x: vorGain.x * ocu.angles.head.x / ocu.angles.eye.x,
|
|
602
538
|
y: vorGain.y * ocu.angles.head.y / ocu.angles.eye.y }
|
|
603
539
|
: 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.
|
|
540
|
+
// How far the reflex may carry the eye in its socket, in pupil units:
|
|
541
|
+
// Guitton & Volle's effective oculomotor range; the rig supplies the reach.
|
|
611
542
|
const reach = ocu.range || { x: 1, up: 1, down: 1 };
|
|
612
543
|
const reflexX = (px) => clamp(px + vor.x * (aim.x - cur.headYaw), -reach.x, reach.x);
|
|
613
544
|
const reflexY = (py) => clamp(py + vor.y * (aim.y - cur.headPitch), -reach.up, reach.down);
|
|
@@ -624,8 +555,6 @@ export function createAvatar(opts = {}) {
|
|
|
624
555
|
// how long the brows stay up for it.
|
|
625
556
|
let dartAt = 0, dartBrowUntil = 0;
|
|
626
557
|
const dart = { x: 0, y: 0 };
|
|
627
|
-
// THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
|
|
628
|
-
// when that handoff happened (it stands in for the turn-start look).
|
|
629
558
|
// The state whose gaze is showing, and when the current state takes it
|
|
630
559
|
// over if that is still pending (GAP_SETTLE). Usually the same state.
|
|
631
560
|
let gazeState = 'IDLE';
|
|
@@ -638,11 +567,11 @@ export function createAvatar(opts = {}) {
|
|
|
638
567
|
|
|
639
568
|
const cur = Object.assign({}, REST);
|
|
640
569
|
const target = Object.assign({}, REST);
|
|
641
|
-
// The
|
|
570
|
+
// The head axes again, carrying only what is held (step 6b).
|
|
642
571
|
const hold = { headYaw: 0, headPitch: 0, headRoll: 0 };
|
|
643
|
-
// What the rig is handed: `cur` with the reflex applied to the eyes
|
|
644
|
-
//
|
|
645
|
-
const shown =
|
|
572
|
+
// What the rig is handed: `cur` with the reflex applied to the eyes and the
|
|
573
|
+
// lips stopped at shut.
|
|
574
|
+
const shown = Object.assign({}, REST);
|
|
646
575
|
|
|
647
576
|
function applyGaze(blink) {
|
|
648
577
|
const g = gazeOverrideByClip || gazeName;
|
|
@@ -653,11 +582,7 @@ export function createAvatar(opts = {}) {
|
|
|
653
582
|
let raf = 0;
|
|
654
583
|
let last = 0;
|
|
655
584
|
let elapsed = 0;
|
|
656
|
-
// `manual` withholds the rAF loop so a tool can drive frames itself.
|
|
657
|
-
// baseline pages could already step a ClipPlayer by hand, but nothing could
|
|
658
|
-
// step the *mixer* — which is where idle, gaze and engagement actually
|
|
659
|
-
// compose — so motion had no reproducible render. The headless motion map in
|
|
660
|
-
// the working tree is what steps it.
|
|
585
|
+
// `manual` withholds the rAF loop so a tool can drive frames itself.
|
|
661
586
|
const manual = !!opts.manual;
|
|
662
587
|
|
|
663
588
|
function frame(now) {
|
|
@@ -716,11 +641,11 @@ export function createAvatar(opts = {}) {
|
|
|
716
641
|
// clip layer, on purpose: a sustained turn toward the screen recruits
|
|
717
642
|
// the trunk, and a nod or a head shake does not — a body that swings
|
|
718
643
|
// with every gesture reads as a mannequin on a turntable. The lag is
|
|
719
|
-
// not authored anywhere; torsoTurn
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
// going and not the looks riding on it, and it holds through a
|
|
723
|
-
// checking on the user is a look, not a turn toward them.
|
|
644
|
+
// not authored anywhere; torsoTurn chases the same target at a slower
|
|
645
|
+
// TAU than the head (params.js), so the trunk leaves late and settles
|
|
646
|
+
// late for free, at the share TRUNK_FOLLOW names. It follows where the
|
|
647
|
+
// head is going and not the looks riding on it, and it holds through a
|
|
648
|
+
// glance: checking on the user is a look, not a turn toward them.
|
|
724
649
|
if (!glanceUntil) trunkYaw = target.headYaw - g.headYaw + g.trunkYaw;
|
|
725
650
|
target.torsoTurn += trunkYaw * TRUNK_FOLLOW;
|
|
726
651
|
|
|
@@ -839,6 +764,8 @@ export function createAvatar(opts = {}) {
|
|
|
839
764
|
const shape = mouth.letter !== SILENT
|
|
840
765
|
? shapeFor(mouth.letter, mouth.intensity)
|
|
841
766
|
: shapeFor(SILENT, 1);
|
|
767
|
+
// Rounding coming up is held against the shape's own (VisemeTrack.sample).
|
|
768
|
+
if (mouth.round > shape.mouthRound) shape.mouthRound = mouth.round;
|
|
842
769
|
// Gain pivots on the rest shape, not on zero: scaling absolute values would
|
|
843
770
|
// drag the closed mouth open, which is the one thing lipsync must never do.
|
|
844
771
|
for (const k in shape) {
|
|
@@ -846,11 +773,6 @@ export function createAvatar(opts = {}) {
|
|
|
846
773
|
? shape[k]
|
|
847
774
|
: REST_SHAPE[k] + (shape[k] - REST_SHAPE[k]) * mouthGain;
|
|
848
775
|
}
|
|
849
|
-
// A smile held static through a sentence is discounted as insincere, and
|
|
850
|
-
// corners riding every open viseme read as laughing through the words
|
|
851
|
-
// (research-perception.md §3: warmth must be episodic). While the mouth
|
|
852
|
-
// is genuinely speech-driven the BASE smile decays to a fraction of
|
|
853
|
-
// itself; the smile channels' 130ms tau turns the gate into an ease.
|
|
854
776
|
// Clip-owned mouths are exempt — a spoken OKAY *is* the warmth episode —
|
|
855
777
|
// and only the base is scaled, so a gesture clip can still smile over a
|
|
856
778
|
// sentence by authoring corner keys (they add, unscaled, in step 5).
|
|
@@ -930,11 +852,9 @@ export function createAvatar(opts = {}) {
|
|
|
930
852
|
for (const k in il.add) target[k] = (target[k] || 0) + il.add[k] * (headGain[k] ?? 1);
|
|
931
853
|
for (const c of HEAD_AXES) hold[c] += (il.add[c] || 0) * headGain[c];
|
|
932
854
|
|
|
933
|
-
// 6b. the held-head budget.
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
// go. Only the excess over `soften` comes off, and only off the hold:
|
|
937
|
-
// a nod, a beat and a clip keep every degree they were authored with,
|
|
855
|
+
// 6b. the held-head budget (internal-mixer.md § The held-head budget).
|
|
856
|
+
// Only the excess over `soften` comes off, and only off the hold: a
|
|
857
|
+
// nod, a beat and a clip keep every degree they were authored with,
|
|
938
858
|
// which is why this is subtracted here rather than applied to the pose.
|
|
939
859
|
for (const c of HEAD_AXES) {
|
|
940
860
|
if (headHold[c] === undefined) continue;
|
|
@@ -944,10 +864,10 @@ export function createAvatar(opts = {}) {
|
|
|
944
864
|
// 6c. the body answers a held tilt (SHOULDER_TILT). The trunk takes the
|
|
945
865
|
// same share of it that it takes of a turn in 2b — Live2D gives its
|
|
946
866
|
// body the same fraction of AngleZ as of AngleX (research-head-rotation
|
|
947
|
-
// .md §3) — and the shoulder line tips with the head. Both channels
|
|
948
|
-
// slower than the head (TAU
|
|
949
|
-
//
|
|
950
|
-
//
|
|
867
|
+
// .md §3) — and the shoulder line tips with the head. Both channels
|
|
868
|
+
// smooth slower than the head (TAU), so the body leaves late and
|
|
869
|
+
// settles late, and that follow-through is most of what separates a
|
|
870
|
+
// neck bending from a hinge.
|
|
951
871
|
// Only the *held* roll, and after the budget: a stroke or a clip's roll
|
|
952
872
|
// is a gesture riding on the pose, and a body that answers those is 2b's
|
|
953
873
|
// mannequin on a turntable. The idle layer's own posture is already
|
|
@@ -967,6 +887,10 @@ export function createAvatar(opts = {}) {
|
|
|
967
887
|
const r = RANGE[c];
|
|
968
888
|
target[c] = clamp(target[c], r[0], r[1]);
|
|
969
889
|
}
|
|
890
|
+
// 7a. a bilabial aims past shut (`LIP_CONTACT_AIM`), after the clamp
|
|
891
|
+
// because it is the one target no pose is allowed to hold: only the
|
|
892
|
+
// chase sees it, and step 8b stops what is drawn at shut.
|
|
893
|
+
if (mouth && mouth.letter === 'A') target.mouthOpen = -LIP_CONTACT_AIM;
|
|
970
894
|
if (il.blink > 0) {
|
|
971
895
|
target.lidL = Math.max(target.lidL, il.blink);
|
|
972
896
|
target.lidR = Math.max(target.lidR, il.blink);
|
|
@@ -977,19 +901,14 @@ export function createAvatar(opts = {}) {
|
|
|
977
901
|
// 8. smooth toward the target — this is where co-articulation happens
|
|
978
902
|
for (const c of CHANNELS) cur[c] = approach(cur[c], target[c], TAU[c], dt);
|
|
979
903
|
|
|
980
|
-
// 8b.
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
// land after gaze. It also gives a large shift its real shape for free:
|
|
988
|
-
// the eyes jump to the target, past where they will sit, and roll back
|
|
989
|
-
// in the head as it arrives under them. A pupil a tuning UI overrides is
|
|
990
|
-
// left where it was put.
|
|
904
|
+
// 8b. What is drawn: the lips stop at shut however far past it they aimed.
|
|
905
|
+
Object.assign(shown, cur);
|
|
906
|
+
if (shown.mouthOpen < 0) shown.mouthOpen = 0;
|
|
907
|
+
|
|
908
|
+
// 8c. The vestibulo-ocular reflex. Here and not in the gaze layer because
|
|
909
|
+
// only here is the head that is actually drawn known: prosody, clips
|
|
910
|
+
// and idle all land after gaze.
|
|
991
911
|
if (vor) {
|
|
992
|
-
Object.assign(shown, cur);
|
|
993
912
|
if (!overrides || overrides.pupilX === undefined) {
|
|
994
913
|
shown.pupilX = clamp(reflexX(cur.pupilX), RANGE.pupilX[0], RANGE.pupilX[1]);
|
|
995
914
|
}
|
|
@@ -1020,14 +939,16 @@ export function createAvatar(opts = {}) {
|
|
|
1020
939
|
// (mouth corners stay free: a clip may smile over a sentence).
|
|
1021
940
|
const MOUTH_LOCK = new Set(GROUPS.mouth);
|
|
1022
941
|
|
|
942
|
+
/** The shoulders' share of the attentive posture: two thirds of the lean the
|
|
943
|
+
* engagement layer spends — the shoulders come up with it, they do not lead it. */
|
|
944
|
+
const ENGAGE_SHOULDER = 0.10;
|
|
945
|
+
|
|
1023
946
|
// What survives of the resting/emotion smile while speech owns the mouth.
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
const ENGAGE_SHOULDER = 0.10;
|
|
1030
|
-
const SPEAK_SMILE_RETAIN = 0.35;
|
|
947
|
+
// A smile held static through a sentence is discounted as insincere, and
|
|
948
|
+
// corners riding every open viseme read as laughing through the words: warmth
|
|
949
|
+
// must be episodic (research-perception.md §3). Full warmth returns the moment
|
|
950
|
+
// the track ends, which is that onset/offset.
|
|
951
|
+
const SPEAK_SMILE_RETAIN = 0.35;
|
|
1031
952
|
|
|
1032
953
|
// Between the user's turn and the reply the server's claim can change
|
|
1033
954
|
// several times a second — THINKING, a tool's WORKING, THINKING again, a
|
|
@@ -1135,17 +1056,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1135
1056
|
gazeState = name;
|
|
1136
1057
|
const st = states[name];
|
|
1137
1058
|
const gl = st.glance;
|
|
1138
|
-
// **A reply brings the eyes back with its first word, and that is the
|
|
1139
|
-
// whole of it (2026-09-21).** A THINKING look that was still running used
|
|
1140
|
-
// to be carried a quarter to six tenths of a second into the turn, on the
|
|
1141
|
-
// reasoning that a speaker looks away to find the words and back to
|
|
1142
|
-
// deliver them — but what the owner sees at the top of a turn is the
|
|
1143
|
-
// avatar talking while looking somewhere else, and then a dart back. The
|
|
1144
|
-
// carry's own justification was the turn-start aversion it would otherwise
|
|
1145
|
-
// have doubled with, and that aversion is deleted (gaze.js), so nothing is
|
|
1146
|
-
// left for it to avoid. `setGaze` glides; the return is a saccade, not a
|
|
1147
|
-
// cut, and it now lands on the first word instead of after it.
|
|
1148
|
-
//
|
|
1149
1059
|
// A state with an `opening` enters as though its check-in on the user is
|
|
1150
1060
|
// already under way, and leaves it when that runs out.
|
|
1151
1061
|
if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
|
|
@@ -1195,17 +1105,11 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1195
1105
|
: () => performance.now() - speakStart;
|
|
1196
1106
|
speech.start(o.cues || [], speakClock);
|
|
1197
1107
|
prosody.reset(newTurn);
|
|
1198
|
-
//
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
//
|
|
1203
|
-
// in SPEAKING retargets. The owner's report was of the eyes being
|
|
1204
|
-
// elsewhere as the bot starts talking, and this is the half of it that
|
|
1205
|
-
// survives in a real call: the audit drives the state directly and so
|
|
1206
|
-
// never took this path. `keepGaze` stays for the one caller that means
|
|
1207
|
-
// it — a performance that aimed the eyes with its own `gaze` verb keeps
|
|
1208
|
-
// them, since that is an instruction and not a leftover schedule.
|
|
1108
|
+
// The eyes come back to the user when the audio starts: a reply arriving
|
|
1109
|
+
// while THINKING was looking away would otherwise spend its whole turn
|
|
1110
|
+
// aimed off the user, because nothing in SPEAKING retargets. `keepGaze` is
|
|
1111
|
+
// for the one caller that means it — a performance that aimed the eyes with
|
|
1112
|
+
// its own `gaze` verb keeps them, an instruction and not a leftover.
|
|
1209
1113
|
if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
|
|
1210
1114
|
if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
|
|
1211
1115
|
return api;
|
|
@@ -1217,25 +1121,14 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1217
1121
|
* This is the widget's half of the **gaze window**. In face-to-face talk a
|
|
1218
1122
|
* speaker periodically looks at the listener, mutual gaze is established, the
|
|
1219
1123
|
* listener responds inside that window, and the speaker looks away again
|
|
1220
|
-
* (Bavelas, Coates & Johnson 2002)
|
|
1221
|
-
* window rather than being scattered across the turn.
|
|
1124
|
+
* (Bavelas, Coates & Johnson 2002).
|
|
1222
1125
|
*
|
|
1223
1126
|
* We cannot see the user, so we cannot observe the window opening. What a
|
|
1224
1127
|
* caller *can* do is name the moments that co-occur with it — a mid-turn
|
|
1225
1128
|
* pause, a tag question ("...right?", "you know?"), a completed clause with
|
|
1226
1129
|
* the turn analyzer's completion probability high, the user answering a
|
|
1227
|
-
* question the bot asked. `
|
|
1228
|
-
*
|
|
1229
|
-
* any response to be *seen*. Emitting the response itself stays a separate
|
|
1230
|
-
* call — a window that opens and draws nothing is a real and common outcome
|
|
1231
|
-
* (with every measured invitation cue present, humans respond to only ~30% of
|
|
1232
|
-
* opportunities), and conflating the two would make the avatar answer
|
|
1233
|
-
* everything. The explicit response remains a backend/application decision.
|
|
1234
|
-
*
|
|
1235
|
-
* Deliberately **not on the wire yet**: there is no `attend` command in
|
|
1236
|
-
* `packages/avatar/client/types.ts`, so today this is reachable only from JS (the demo and
|
|
1237
|
-
* the rig pages). Adding the command is a protocol change and waits for a
|
|
1238
|
-
* server that has something real to key it off — see docs/internal-mixer.md.
|
|
1130
|
+
* question the bot asked. For `ms` the face stops averting and holds the
|
|
1131
|
+
* user, which is the prerequisite for any response to be *seen*.
|
|
1239
1132
|
*
|
|
1240
1133
|
* @param {number} [ms=1200] how long to hold. Binetti (N=498) puts preferred
|
|
1241
1134
|
* mutual gaze at 3295 ± 706 ms, so this is a fraction of the ceiling.
|
|
@@ -1312,7 +1205,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1312
1205
|
const faceClip = actionShapes[id] || ACTIONS[id] || sequences[id];
|
|
1313
1206
|
if (!faceClip) return api;
|
|
1314
1207
|
clip.play(faceClip, faceClip.audioEl, { queue: true });
|
|
1315
|
-
// The
|
|
1208
|
+
// The acknowledgements smile. Only these, and only ever because the
|
|
1316
1209
|
// server sent one: a smile the renderer timed for itself would be an
|
|
1317
1210
|
// acknowledgement nobody sent.
|
|
1318
1211
|
if (id === 'ACK_NOD' || id === 'ACK_RECEIVE') prosody.acknowledge();
|
|
@@ -1347,11 +1240,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1347
1240
|
return { gesture: handAction.gesture, progress: Math.max(0, progress), side: handSide };
|
|
1348
1241
|
}
|
|
1349
1242
|
|
|
1350
|
-
/**
|
|
1351
|
-
* Tell the listening engine whether Pipecat VAD says the user holds the
|
|
1352
|
-
* floor. This changes only sustained engagement posture; it can never create
|
|
1353
|
-
* a nod or acknowledgement clip.
|
|
1354
|
-
*/
|
|
1355
1243
|
function setUserSpeaking(b) { engagement.setUserSpeaking(b); return api; }
|
|
1356
1244
|
|
|
1357
1245
|
// What one action does when its moment comes. Enum validity is checked here,
|
|
@@ -1374,15 +1262,6 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1374
1262
|
let performGen = 0;
|
|
1375
1263
|
|
|
1376
1264
|
/**
|
|
1377
|
-
* Play a timed action track — the composition surface a server assembles
|
|
1378
|
-
* turns from. Verbs: state / emotion / gaze / action (see
|
|
1379
|
-
* perform.js for hygiene, docs/internal-mixer.md for the schema).
|
|
1380
|
-
*
|
|
1381
|
-
* Clock resolution mirrors speak(): explicit `clock` fn, else the audio
|
|
1382
|
-
* element's own time, else ms elapsed since this call. perform() never
|
|
1383
|
-
* starts or stops audio — speak() owns the sound; this owns the choreography
|
|
1384
|
-
* that rides it.
|
|
1385
|
-
*
|
|
1386
1265
|
* @param {Array<{t: number, do: string}>} actions
|
|
1387
1266
|
* @param {{audio?: HTMLMediaElement, clock?: () => number,
|
|
1388
1267
|
* onAction?: (a: object) => void}} [o]
|
|
@@ -1490,7 +1369,7 @@ export { EMOTION_NAMES, emotionPose } from './emotions.js';
|
|
|
1490
1369
|
// exactly that, and none of them should write it twice — so it is a plain class
|
|
1491
1370
|
// to construct, not a contract to implement.
|
|
1492
1371
|
export {
|
|
1493
|
-
VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, SILENT,
|
|
1372
|
+
VISEME_LETTERS, VISEME_SHAPES, VisemeTrack, shapeFor, JAW_OF_OPEN, SILENT,
|
|
1494
1373
|
normalizeCues, textToCues,
|
|
1495
1374
|
ARPABET_TO_VISEME, AZURE_VISEME_TO_LETTER, LEAD_MS,
|
|
1496
1375
|
} from './visemes.js';
|
package/src/gaze.js
CHANGED
|
@@ -187,9 +187,7 @@ function chase(pos, vel, gx, gy, dt, accel, speed) {
|
|
|
187
187
|
* costs η²ₚ = .52–.56 with post-hoc d of 1.09–2.69, with partial inattention
|
|
188
188
|
* costing most of what total inattention costs. So this is not "look away
|
|
189
189
|
* sometimes" — it is a narrow band, and the numbers below are Andrist's measured
|
|
190
|
-
* ones rather than a taste call (docs/research-biomechanics.md §4.2).
|
|
191
|
-
* that arrived at this band, and what it measured on peep, is
|
|
192
|
-
* docs/research-active-listening.md.
|
|
190
|
+
* ones rather than a taste call (docs/research-biomechanics.md §4.2).
|
|
193
191
|
*
|
|
194
192
|
* `every`/`dur` are seconds. `dirs` are unit-ish directions weighted by how
|
|
195
193
|
* often each is taken; magnitude is scaled per-fire.
|