@voqalize/avatar 0.4.1 → 0.4.2
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/README.md +46 -28
- package/assets/README.md +4 -3
- 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 +20 -0
- package/client/three/assets.ts +5 -3
- package/client/three/holds.ts +7 -2
- package/client/three/internal.ts +5 -5
- package/client/three/motion-limits.json +5 -1
- package/client/three/sequences.ts +5 -5
- package/client/three/tara-rig.ts +22 -8
- package/client/three/tess-asset.ts +6 -0
- package/client/three/tess.ts +72 -0
- package/dist/internal.d.ts +2 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +12 -1
- package/dist/internal.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 +5 -3
- package/dist/three/assets.js.map +1 -1
- package/dist/three/holds.d.ts +7 -2
- package/dist/three/holds.d.ts.map +1 -1
- package/dist/three/holds.js +7 -2
- package/dist/three/holds.js.map +1 -1
- package/dist/three/internal.d.ts +5 -5
- package/dist/three/internal.js +5 -5
- package/dist/three/motion-limits.json +5 -1
- package/dist/three/sequences.d.ts +5 -5
- package/dist/three/sequences.js +5 -5
- package/dist/three/tara-rig.d.ts +18 -4
- package/dist/three/tara-rig.d.ts.map +1 -1
- package/dist/three/tara-rig.js +9 -8
- package/dist/three/tara-rig.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 +38 -0
- package/dist/three/tess.d.ts.map +1 -0
- package/dist/three/tess.js +54 -0
- package/dist/three/tess.js.map +1 -0
- package/package.json +5 -1
- package/src/avatar.d.ts +65 -2
- package/src/avatar.js +68 -63
- package/src/faces.js +1 -1
- package/src/gaze.js +19 -44
- package/src/idle.js +60 -26
- package/src/prosody.js +104 -14
- package/src/rig.d.ts +7 -0
package/src/avatar.d.ts
CHANGED
|
@@ -195,7 +195,7 @@ export interface AvatarApi {
|
|
|
195
195
|
readonly gestureGain: Gain;
|
|
196
196
|
setMotionGain(g: Gain): AvatarApi;
|
|
197
197
|
readonly motionGain: Gain;
|
|
198
|
-
blink(
|
|
198
|
+
blink(): AvatarApi;
|
|
199
199
|
/** Advance one frame by hand — only meaningful under `{ manual: true }`. */
|
|
200
200
|
step(dt: number): AvatarApi;
|
|
201
201
|
/** Pin channels to fixed values, above the whole mix. `null` releases. */
|
|
@@ -316,7 +316,12 @@ export function createAvatar(opts: CreateAvatarOptions): AvatarApi;
|
|
|
316
316
|
export const STATES: Readonly<Record<AvatarStateName, Readonly<Record<string, unknown>>>>;
|
|
317
317
|
export const STATE_NAMES: readonly AvatarStateName[];
|
|
318
318
|
export const GAZE_NAMES: readonly AvatarGazeName[];
|
|
319
|
-
|
|
319
|
+
/** Where each named direction puts the eyes and the head: pupil offset
|
|
320
|
+
* (`px`/`py`), the head's share of it (`hx`/`hy`) and any roll. Pose units, and
|
|
321
|
+
* the head deliberately carries less than the whole (`src/gaze.js`). */
|
|
322
|
+
export const GAZE_TARGETS: Readonly<Record<AvatarGazeName, {
|
|
323
|
+
px: number; py: number; hx: number; hy: number; roll?: number;
|
|
324
|
+
}>>;
|
|
320
325
|
export const EMOTION_NAMES: readonly AvatarEmotionName[];
|
|
321
326
|
export const ACTIONS: Readonly<Record<AvatarActionId, unknown>>;
|
|
322
327
|
export const ACTION_IDS: readonly AvatarActionId[];
|
|
@@ -334,12 +339,24 @@ export const REST: Readonly<Record<PoseChannel, number>>;
|
|
|
334
339
|
export const CHANNELS: readonly PoseChannel[];
|
|
335
340
|
/** Post-mix clamp per channel, `[min, max]`. */
|
|
336
341
|
export const RANGE: Readonly<Record<PoseChannel, readonly [number, number]>>;
|
|
342
|
+
/** Each channel's smoothing time constant, in seconds. */
|
|
343
|
+
export const TAU: Readonly<Record<PoseChannel, number>>;
|
|
344
|
+
export function clamp(v: number, lo?: number, hi?: number): number;
|
|
345
|
+
/** One frame of exponential smoothing: where `cur` lands `dt` seconds into a
|
|
346
|
+
* chase of `target` with time constant `tau`. */
|
|
347
|
+
export function approach(cur: number, target: number, tau: number, dt: number): number;
|
|
337
348
|
export const VISEME_LETTERS: readonly VisemeLetter[];
|
|
338
349
|
export const VISEME_SHAPES: Readonly<Record<VisemeLetter, PoseOverrides>>;
|
|
339
350
|
/** Cues lead the audio by this many ms — perceptual tolerance is asymmetric. */
|
|
340
351
|
export const LEAD_MS: number;
|
|
341
352
|
/** The shoulder line's share of a held `headRoll`, per pose unit. */
|
|
342
353
|
export const SHOULDER_TILT: number;
|
|
354
|
+
/** A full pose from a handful of overrides: every unnamed channel takes its
|
|
355
|
+
* `REST` value. The only correct way to build a frame by hand. */
|
|
356
|
+
export function makeParams(overrides?: PoseOverrides): Record<PoseChannel, number>;
|
|
357
|
+
/** One named emotion's channel deltas at `intensity` (0..1). */
|
|
358
|
+
export function emotionPose(name: AvatarEmotionName, intensity?: number): PoseOverrides;
|
|
359
|
+
export { avatarFrame, createSvgRig } from "./rig.js";
|
|
343
360
|
export const ARPABET_TO_VISEME: Readonly<Record<string, VisemeLetter>>;
|
|
344
361
|
export const AZURE_VISEME_TO_LETTER: Readonly<Record<number, VisemeLetter>>;
|
|
345
362
|
|
|
@@ -365,6 +382,52 @@ export class VisemeTrack {
|
|
|
365
382
|
readonly playing: boolean;
|
|
366
383
|
}
|
|
367
384
|
|
|
385
|
+
/**
|
|
386
|
+
* One authored gesture: a short multi-channel timeline of additive deltas,
|
|
387
|
+
* optionally with its own mouth track, gaze override and blink beats.
|
|
388
|
+
*/
|
|
389
|
+
export interface Clip {
|
|
390
|
+
readonly id: string;
|
|
391
|
+
readonly label: string;
|
|
392
|
+
readonly text: string;
|
|
393
|
+
/** Milliseconds, end to end. */
|
|
394
|
+
readonly duration: number;
|
|
395
|
+
/** Per channel, `[u, delta]` keys against normalized clip time. */
|
|
396
|
+
readonly keys: Readonly<Record<string, ReadonlyArray<readonly [number, number]>>>;
|
|
397
|
+
readonly mouthCues?: readonly Cue[];
|
|
398
|
+
readonly gaze?: AvatarGazeName;
|
|
399
|
+
readonly blinkAt?: readonly number[];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Every clip this renderer has, keyed by id — the authoring library, not the
|
|
404
|
+
* wire's vocabulary. Most are reachable only from inside the mixer: `ACTIONS`
|
|
405
|
+
* is what a server may name.
|
|
406
|
+
*/
|
|
407
|
+
export const INTERNAL_CLIPS: Readonly<Record<string, Clip>>;
|
|
408
|
+
|
|
409
|
+
/** What one tick of a clip contributes: additive channel deltas, the ramp
|
|
410
|
+
* weight they already carry, and the mouth when the clip owns it. */
|
|
411
|
+
export interface ClipSample {
|
|
412
|
+
delta: Record<string, number> | null;
|
|
413
|
+
weight: number;
|
|
414
|
+
mouth: { letter: VisemeLetter; intensity: number } | null;
|
|
415
|
+
ownsMouth: boolean;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Plays one clip forward in ticks you supply. Fixed-`dt` stepping is what
|
|
419
|
+
* makes a rendered gesture reproducible. */
|
|
420
|
+
export class ClipPlayer {
|
|
421
|
+
constructor(hooks?: { onGaze?: (name: string | null) => void; onBlink?: () => void });
|
|
422
|
+
play(clip: Clip, audio?: HTMLMediaElement | null, opts?: { queue?: boolean }): void;
|
|
423
|
+
/** Advance by `dtMs` and report this frame's contribution. */
|
|
424
|
+
update(dtMs: number): ClipSample;
|
|
425
|
+
stop(immediate?: boolean): void;
|
|
426
|
+
onEnd: ((clip: Clip | null) => void) | null;
|
|
427
|
+
readonly playing: boolean;
|
|
428
|
+
readonly id: string | null;
|
|
429
|
+
}
|
|
430
|
+
|
|
368
431
|
export function attachAudio(id: string, url: string): void;
|
|
369
432
|
export function normalizeActions(actions: AvatarAction[]): AvatarAction[];
|
|
370
433
|
export function normalizeCues(cues: Cue[]): Cue[];
|
package/src/avatar.js
CHANGED
|
@@ -91,8 +91,8 @@ export const STATES = {
|
|
|
91
91
|
// is kept to a fifth of looks because on a face this real it reads as
|
|
92
92
|
// downcast. The pose takes back `thoughtful`'s lid drop: a thinking face is
|
|
93
93
|
// alert, and the two together measured past the 0.15 that reads drowsy.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
96
|
THINKING: { gaze: 'AWAY_SIDE', emotion: 'thoughtful', engagement: false,
|
|
97
97
|
// Fixational jumps rare and small: a thinker's eyes rest
|
|
98
98
|
// where they land. At the default gap the look jittered
|
|
@@ -138,11 +138,11 @@ export const STATES = {
|
|
|
138
138
|
// eyes drawn inside a head tipped back aimed every look
|
|
139
139
|
// meant to be level at the ceiling.
|
|
140
140
|
pose: { lidL: -0.10, lidR: -0.10 } },
|
|
141
|
-
// Eyes on the user
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
SPEAKING: { gaze: 'USER', emotion: 'neutral',
|
|
141
|
+
// Eyes on the user for the whole turn, and no `aversion`: gaze.js has why a
|
|
142
|
+
// speaker's measured looks away are not this rig's to render. What moves
|
|
143
|
+
// while it talks is the head it holds per phrase and the trunk under it
|
|
144
|
+
// (prosody.js), never the eyes leaving the user.
|
|
145
|
+
SPEAKING: { gaze: 'USER', emotion: 'neutral',
|
|
146
146
|
idle: { sway: 0.55 }, engagement: false },
|
|
147
147
|
REVIEWING_SCREEN: { gaze: 'SCREEN_CENTER', emotion: 'thoughtful', engagement: false,
|
|
148
148
|
idle: { sway: 0.8, blinkGap: [4.0, 6.5] },
|
|
@@ -450,7 +450,7 @@ export function createAvatar(opts = {}) {
|
|
|
450
450
|
// coordinates or the hand layer's private geometry.
|
|
451
451
|
const rig = opts.rig ? opts.rig(mount, opts.rigOptions) : createSvgRig(face, hand);
|
|
452
452
|
|
|
453
|
-
gaze.onLargeShift = (forced) => idle.blink(
|
|
453
|
+
gaze.onLargeShift = (forced) => idle.blink(true, forced);
|
|
454
454
|
|
|
455
455
|
const listeners = {
|
|
456
456
|
state: [], speakEnd: [], clipEnd: [], performEnd: [], gestureEnd: [],
|
|
@@ -617,7 +617,6 @@ export function createAvatar(opts = {}) {
|
|
|
617
617
|
let wanderAt = 0;
|
|
618
618
|
let driftAt = 0;
|
|
619
619
|
let trunkYaw = 0;
|
|
620
|
-
let turnStartPending = false;
|
|
621
620
|
let glanceAt = 0;
|
|
622
621
|
let glanceUntil = 0;
|
|
623
622
|
let lastBack = null;
|
|
@@ -627,8 +626,6 @@ export function createAvatar(opts = {}) {
|
|
|
627
626
|
const dart = { x: 0, y: 0 };
|
|
628
627
|
// THINKING -> SPEAKING mid-look: when the eyes go back to the user, and
|
|
629
628
|
// when that handoff happened (it stands in for the turn-start look).
|
|
630
|
-
let returnAt = 0;
|
|
631
|
-
let carriedAt = -Infinity;
|
|
632
629
|
// The state whose gaze is showing, and when the current state takes it
|
|
633
630
|
// over if that is still pending (GAP_SETTLE). Usually the same state.
|
|
634
631
|
let gazeState = 'IDLE';
|
|
@@ -754,11 +751,7 @@ export function createAvatar(opts = {}) {
|
|
|
754
751
|
// spot every time. Either leg's gaze-evoked blink is the shift's odds
|
|
755
752
|
// unless the state says (`blinkTo`, `blinkBack`), because which leg blinks
|
|
756
753
|
// is part of what the glance means.
|
|
757
|
-
if (
|
|
758
|
-
returnAt = 0;
|
|
759
|
-
setGaze(gst.gaze);
|
|
760
|
-
}
|
|
761
|
-
if (gst.glance && !returnAt) {
|
|
754
|
+
if (gst.glance) {
|
|
762
755
|
const gl = gst.glance;
|
|
763
756
|
if (glanceUntil && elapsed > glanceUntil) {
|
|
764
757
|
glanceUntil = 0;
|
|
@@ -807,25 +800,7 @@ export function createAvatar(opts = {}) {
|
|
|
807
800
|
// mixer's one-frame veto — anything that means "the user is checking
|
|
808
801
|
// whether I am with them" sets it (see api.attend).
|
|
809
802
|
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
|
-
}
|
|
803
|
+
gaze.hold = attendUntil > elapsed || clip.playing;
|
|
829
804
|
|
|
830
805
|
engagement.enabled = !!st.engagement && !clip.playing;
|
|
831
806
|
engagement.update(dt);
|
|
@@ -1041,11 +1016,6 @@ export function createAvatar(opts = {}) {
|
|
|
1041
1016
|
// sliding.
|
|
1042
1017
|
const TRUNK_FOLLOW = opts.trunkFollow ?? 0.45;
|
|
1043
1018
|
|
|
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
1019
|
// The channels speech owns outright — exactly the params.js mouth group
|
|
1050
1020
|
// (mouth corners stay free: a clip may smile over a sentence).
|
|
1051
1021
|
const MOUTH_LOCK = new Set(GROUPS.mouth);
|
|
@@ -1059,13 +1029,6 @@ export function createAvatar(opts = {}) {
|
|
|
1059
1029
|
const ENGAGE_SHOULDER = 0.10;
|
|
1060
1030
|
const SPEAK_SMILE_RETAIN = 0.35;
|
|
1061
1031
|
|
|
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];
|
|
1068
|
-
|
|
1069
1032
|
// Between the user's turn and the reply the server's claim can change
|
|
1070
1033
|
// several times a second — THINKING, a tool's WORKING, THINKING again, a
|
|
1071
1034
|
// grace timer's CANT_HEAR — and every change used to retarget the eyes and
|
|
@@ -1152,25 +1115,40 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1152
1115
|
const deferrable = !o.gaze && !o.keepGaze && GAP_STATES.has(gazeState) && GAP_STATES.has(name);
|
|
1153
1116
|
if (deferrable && changed) settleAt = name === gazeState ? 0 : elapsed + GAP_SETTLE;
|
|
1154
1117
|
else if (!(deferrable && settleAt)) { settleAt = 0; enterGaze(name, o, changed); }
|
|
1118
|
+
// The floor has come back to the user: the face receives it (prosody.js
|
|
1119
|
+
// `listen`). It is a state change rather than a VAD event on purpose —
|
|
1120
|
+
// what is being welcomed is the turn, and the server is the one that knows
|
|
1121
|
+
// a turn has changed hands.
|
|
1122
|
+
if (changed && name === 'LISTENING') prosody.listen();
|
|
1155
1123
|
if (changed) emit('state', name);
|
|
1156
1124
|
return api;
|
|
1157
1125
|
}
|
|
1158
1126
|
|
|
1127
|
+
// Whether the eyes were last aimed by somebody who meant it — the public
|
|
1128
|
+
// `setGaze`, or a performance's `gaze` verb — rather than by the state's own
|
|
1129
|
+
// schedule. It is the one thing that outranks a state's `gaze`, and it is
|
|
1130
|
+
// why `speak()` can take the eyes back without overriding a caller.
|
|
1131
|
+
let gazeExplicit = false;
|
|
1132
|
+
|
|
1159
1133
|
/** The state `name` takes the gaze: its target and its schedules. */
|
|
1160
1134
|
function enterGaze(name, o, blink) {
|
|
1161
|
-
const prev = gazeState;
|
|
1162
1135
|
gazeState = name;
|
|
1163
1136
|
const st = states[name];
|
|
1164
1137
|
const gl = st.glance;
|
|
1165
|
-
// A reply
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
+
//
|
|
1171
1149
|
// A state with an `opening` enters as though its check-in on the user is
|
|
1172
1150
|
// already under way, and leaves it when that runs out.
|
|
1173
|
-
if (!o.keepGaze
|
|
1151
|
+
if (!o.keepGaze) { setGaze(o.gaze || (gl && gl.opening ? gl.to : st.gaze)); gazeExplicit = false; }
|
|
1174
1152
|
// Arm every scheduler fresh, so entering a state never fires a timestamp
|
|
1175
1153
|
// left over from the last one — the wander in particular, which used to
|
|
1176
1154
|
// pick a new target on the first frame and override the state's own gaze.
|
|
@@ -1181,7 +1159,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1181
1159
|
readCol = readCols = readRow = readRows = 0;
|
|
1182
1160
|
dartAt = dartBrowUntil = 0;
|
|
1183
1161
|
dart.x = dart.y = 0;
|
|
1184
|
-
if (blink) idle.blink(
|
|
1162
|
+
if (blink) idle.blink(true);
|
|
1185
1163
|
}
|
|
1186
1164
|
|
|
1187
1165
|
function setEmotion(name, intensity = 1) { emotion = name; emotionAmt = intensity; return api; }
|
|
@@ -1217,8 +1195,18 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1217
1195
|
: () => performance.now() - speakStart;
|
|
1218
1196
|
speech.start(o.cues || [], speakClock);
|
|
1219
1197
|
prosody.reset(newTurn);
|
|
1220
|
-
|
|
1221
|
-
|
|
1198
|
+
// **The eyes come back to the user when the audio starts, and that is the
|
|
1199
|
+
// point of inferring the state at all (2026-09-21).** This used to keep
|
|
1200
|
+
// whatever gaze was already set, which meant a reply arriving while
|
|
1201
|
+
// THINKING was looking away spent its *whole turn* aimed off the user —
|
|
1202
|
+
// 11.9 deg off it in `presence.test.ts`, never returning, because nothing
|
|
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.
|
|
1209
|
+
if (stateName !== 'SPEAKING') setState('SPEAKING', { keepGaze: gazeExplicit });
|
|
1222
1210
|
if (o.audio && o.audio.paused) o.audio.play().catch(() => {});
|
|
1223
1211
|
return api;
|
|
1224
1212
|
}
|
|
@@ -1376,7 +1364,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1376
1364
|
// composed turn in the demo already behaves.
|
|
1377
1365
|
if (a.do === 'state') setState(a.name, { keepGaze: a.keepGaze !== false });
|
|
1378
1366
|
else if (a.do === 'emotion') setEmotion(a.name, a.i ?? 1);
|
|
1379
|
-
else if (a.do === 'gaze') setGaze(a.name);
|
|
1367
|
+
else if (a.do === 'gaze') { setGaze(a.name); gazeExplicit = true; }
|
|
1380
1368
|
else if (a.do === 'action') action(a.id);
|
|
1381
1369
|
} catch (e) {
|
|
1382
1370
|
console.warn(`perform: ${a.do} at ${a.t}ms skipped — ${e.message}`);
|
|
@@ -1417,7 +1405,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1417
1405
|
}
|
|
1418
1406
|
|
|
1419
1407
|
const api = {
|
|
1420
|
-
setState, setEmotion, setGaze: (name, custom) => setGaze(name, custom), speak, pushCues, stopSpeaking, attend,
|
|
1408
|
+
setState, setEmotion, setGaze: (name, custom) => { gazeExplicit = true; return setGaze(name, custom); }, speak, pushCues, stopSpeaking, attend,
|
|
1421
1409
|
action, perform,
|
|
1422
1410
|
/** Which hand the character gestures with: +1 the viewer's right (its own
|
|
1423
1411
|
* left), -1 the other. Both are anatomically real — the thumb splays away
|
|
@@ -1433,7 +1421,7 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1433
1421
|
/** Idle body-motion gain: 1 is the liveness layer as authored, 0 freezes it. */
|
|
1434
1422
|
setMotionGain: (g) => { idle.gain = g; return api; },
|
|
1435
1423
|
get motionGain() { return idle.gain; },
|
|
1436
|
-
blink: (
|
|
1424
|
+
blink: () => { idle.blink(); return api; },
|
|
1437
1425
|
/** Advance one frame by hand. Only meaningful under `{manual: true}`;
|
|
1438
1426
|
* fixed-dt stepping is what makes a motion render reproducible. */
|
|
1439
1427
|
step: (dt) => { elapsed += dt; step(dt, dt * 1000); return api; },
|
|
@@ -1474,12 +1462,29 @@ const SPEAK_SMILE_RETAIN = 0.35;
|
|
|
1474
1462
|
// this channel's neutral value" before it can map the channel onto whatever it
|
|
1475
1463
|
// controls — a morph target's influence is `(pose - rest) / (1 - rest)`, and a
|
|
1476
1464
|
// rig that hard-codes those rests has quietly forked `params.js`.
|
|
1477
|
-
export { REST, CHANNELS, RANGE } from './params.js';
|
|
1465
|
+
export { REST, CHANNELS, RANGE, TAU, clamp, approach, makeParams } from './params.js';
|
|
1466
|
+
// The smoothing law itself, for a tool that steps the rig by hand. A
|
|
1467
|
+
// filmstrip that reimplemented `approach` would be measuring its own copy of
|
|
1468
|
+
// the thing under test — and the smoothing between keyframes is what the face
|
|
1469
|
+
// actually does (docs/internal-mixer.md § Smoothing).
|
|
1470
|
+
// A pose with no mixer above it: `makeParams(overrides)` fills the rests,
|
|
1471
|
+
// `avatarFrame` wraps it, `createSvgRig(face).apply` draws it. That is the whole
|
|
1472
|
+
// path an instrument needs to hold a face at one named extreme — no clock, no
|
|
1473
|
+
// client, no animation — and the reason it is exported is that a pose sheet
|
|
1474
|
+
// that cannot reach it forks the channel rests instead.
|
|
1475
|
+
export { avatarFrame, createSvgRig } from './rig.js';
|
|
1478
1476
|
export { ACTION_IDS, ACTIONS, attachAudio } from './interjections.js';
|
|
1477
|
+
// The full authoring catalogue and the player that steps it. Not a server
|
|
1478
|
+
// vocabulary — `ACTIONS` is that, and most of these clips are reachable only
|
|
1479
|
+
// from inside the mixer. They are exported for the filmstrip instrument,
|
|
1480
|
+
// which lays one clip out as frames and therefore has to drive a real
|
|
1481
|
+
// `ClipPlayer` rather than re-sample its keys.
|
|
1482
|
+
export { INTERNAL_CLIPS } from './interjections.js';
|
|
1483
|
+
export { ClipPlayer } from './clips.js';
|
|
1479
1484
|
export { GAZE_NAMES, GAZE_TARGETS } from './gaze.js';
|
|
1480
1485
|
export { normalizeActions } from './perform.js';
|
|
1481
1486
|
export { checkHandFraming } from './hand.js';
|
|
1482
|
-
export { EMOTION_NAMES } from './emotions.js';
|
|
1487
|
+
export { EMOTION_NAMES, emotionPose } from './emotions.js';
|
|
1483
1488
|
// The mouth clock travels with the rest of it. Someone has to turn a cue array
|
|
1484
1489
|
// plus a clock into "which letter is on screen right now", every renderer needs
|
|
1485
1490
|
// exactly that, and none of them should write it twice — so it is a plain class
|
package/src/faces.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The faces we ship, as a table — for tooling that wants every one of them.
|
|
3
3
|
*
|
|
4
4
|
* **Importing this costs all three drawings** (~2k lines of path data). That is
|
|
5
|
-
* the right trade for
|
|
5
|
+
* the right trade for the pose sheet, the sweep and Studio,
|
|
6
6
|
* which exist to compare faces against each other. It is the wrong trade for an
|
|
7
7
|
* application, which renders one: those import a single face module and hand
|
|
8
8
|
* the record to `createAvatar` directly.
|
package/src/gaze.js
CHANGED
|
@@ -84,9 +84,6 @@ const LID_FOLLOW = { down: 0.34, up: 0 };
|
|
|
84
84
|
// peaks mid-turn, is gone when the head lands, and sizes itself on a rig whose
|
|
85
85
|
// head moves four times faster. A 0.75-unit look on tara sinks ~0.8°.
|
|
86
86
|
const HEAD_DIP = 0.06;
|
|
87
|
-
// A gated aversion waits this long past its due time for a phrase boundary,
|
|
88
|
-
// then goes anyway: a run of speech with no pause in it still gets its look.
|
|
89
|
-
const GATE_WAIT = 1.5;
|
|
90
87
|
// How much of a drift the head joins. Little: a drift is a reader's scan or a
|
|
91
88
|
// thinker's gaze moving where it rests, and both are eye movements. At 0.35
|
|
92
89
|
// the head nodded along with every step of a reading scan, which on a
|
|
@@ -219,30 +216,24 @@ export const AVERSION = {
|
|
|
219
216
|
mag: [0.30, 0.44],
|
|
220
217
|
dirs: [[-1, 0.06], [-1, 0.06], [1, 0.02], [1, 0.02], [-0.7, -0.5], [0.5, 0.35]],
|
|
221
218
|
},
|
|
222
|
-
// While speaking: 1.96 s every 4.75 s mid-turn, a turn-start look on 73% of
|
|
223
|
-
// turns — the planning look, away to find the words and back to deliver
|
|
224
|
-
// them — and none in the last 2.4 s, because the floor is handed back under
|
|
225
|
-
// mutual gaze (§4.2; the mixer owns that hold, since only it can see the
|
|
226
|
-
// cue track). Human speakers hold the listener's eyes for about 41% of a
|
|
227
|
-
// turn. This is set for 70-80% instead — the
|
|
228
|
-
// listener here watches a face on a screen, where 41% reads as evasive — so
|
|
229
|
-
// the looks keep about Andrist's length and come a little less often.
|
|
230
|
-
// `every` sits under Andrist's 4.75 s because the phrase gate and the
|
|
231
|
-
// end-of-turn hold both stretch it: at 3.8-5.8 a turn measured 6-8 looks a
|
|
232
|
-
// minute and 76-82% contact, the top edge of the band rather than its middle.
|
|
233
|
-
// `gated`: a mid-turn look waits for a phrase boundary, because speakers
|
|
234
|
-
// break gaze between clauses, not in the middle of a word.
|
|
235
|
-
SPEAK: {
|
|
236
|
-
every: [3.4, 5.2],
|
|
237
|
-
dur: [1.5, 2.3],
|
|
238
|
-
mag: [0.34, 0.48],
|
|
239
|
-
dirs: [[-1, 0.12], [1, 0.12], [-0.75, 0.55], [0.7, 0.5], [-0.6, -0.45]],
|
|
240
|
-
gated: true,
|
|
241
|
-
start: { p: 0.73, dur: [1.1, 1.8], window: 0.8 },
|
|
242
|
-
},
|
|
243
219
|
};
|
|
244
|
-
// There is deliberately no
|
|
245
|
-
// (
|
|
220
|
+
// **There is deliberately no SPEAK profile, and that is the owner's call
|
|
221
|
+
// (2026-09-21): while the avatar talks, its eyes are on the user.** There was
|
|
222
|
+
// one — Andrist's measured speaker, a planning look away at the start of most
|
|
223
|
+
// turns and a short one every few seconds at a phrase boundary — and in a live
|
|
224
|
+
// call it read as the eyes darting off and coming back. The research it came
|
|
225
|
+
// from is not disputed and the reason it does not transfer is structural: a
|
|
226
|
+
// human speaker's look away is a *head and body* movement that the eyes only
|
|
227
|
+
// lead, and this rig has no such movement to give it. Eye travel on its own is
|
|
228
|
+
// not a smaller version of that, it is a different thing, and on tara the
|
|
229
|
+
// amplitude it needed to be visible at all (`aversionGain` 1.8) is exactly what
|
|
230
|
+
// made it read as a dart. What the speaking face moves instead is the head it
|
|
231
|
+
// holds per phrase and the trunk under it (prosody.js), which is communicative
|
|
232
|
+
// rather than evasive. If the face and body half is ever built, this is where
|
|
233
|
+
// the eye half comes back.
|
|
234
|
+
//
|
|
235
|
+
// There is deliberately no THINK profile either. The *cognitive* aversion —
|
|
236
|
+
// 3.54 s (SD 1.26), splitting 39.3% down / 29.4% up / 31.3% side (§4.2) — is longer
|
|
246
237
|
// and deeper than the listening kind, and THINKING renders it with its own
|
|
247
238
|
// dwell cycle (a `glance` in STATES), which moves the whole gaze target rather
|
|
248
239
|
// than nudging off it. Two mechanisms producing the same look would fight.
|
|
@@ -296,9 +287,6 @@ export class GazeLayer {
|
|
|
296
287
|
this._avHead = { x: 0, y: 0 };
|
|
297
288
|
this._avVel = { x: 0, y: 0 };
|
|
298
289
|
this._avProfileRef = undefined;
|
|
299
|
-
/** Whether now is a phrase boundary, for a `gated` profile. Set by the mixer. */
|
|
300
|
-
this.gate = true;
|
|
301
|
-
this._avStartBy = 0; // a turn-start look may still fire until this time
|
|
302
290
|
// --- drift: a small held offset about the target, set by the mixer
|
|
303
291
|
this.drift = { x: 0, y: 0 };
|
|
304
292
|
this._drift = { x: 0, y: 0 };
|
|
@@ -313,15 +301,6 @@ export class GazeLayer {
|
|
|
313
301
|
// it is entered off a stale timestamp from one that didn't.
|
|
314
302
|
this._avNext = this._t + (p ? p.every[0] + Math.random() * (p.every[1] - p.every[0]) : 0);
|
|
315
303
|
this._avUntil = 0;
|
|
316
|
-
this._avStartBy = 0;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** A turn begins. If the profile has a turn-start look and the dice take it,
|
|
320
|
-
* it fires as soon as `hold` allows inside its window, or not at all — a
|
|
321
|
-
* planning look that arrives a second into the words is just a look. */
|
|
322
|
-
startTurn() {
|
|
323
|
-
const s = this.aversion && this.aversion.start;
|
|
324
|
-
if (s && Math.random() < s.p) this._avStartBy = this._t + s.window;
|
|
325
304
|
}
|
|
326
305
|
|
|
327
306
|
_avert(t, dt) {
|
|
@@ -339,17 +318,13 @@ export class GazeLayer {
|
|
|
339
318
|
// moment the hold lifted — out, back and out again across one turn
|
|
340
319
|
// edge, which is three saccades where the floor wanted none.
|
|
341
320
|
this._avUntil = 0;
|
|
342
|
-
this._avStartBy = 0;
|
|
343
321
|
this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
|
|
344
322
|
} else if (this._avUntil && t >= this._avUntil) {
|
|
345
323
|
this._avUntil = 0;
|
|
346
324
|
this._nextMicro = 0;
|
|
347
325
|
this._avNext = t + p.every[0] + Math.random() * (p.every[1] - p.every[0]);
|
|
348
|
-
} else if (!this._avUntil && !this.hold &&
|
|
349
|
-
|
|
350
|
-
const dur = t < this._avStartBy ? p.start.dur : p.dur;
|
|
351
|
-
this._avStartBy = 0;
|
|
352
|
-
this._avUntil = t + dur[0] + Math.random() * (dur[1] - dur[0]);
|
|
326
|
+
} else if (!this._avUntil && !this.hold && t >= this._avNext) {
|
|
327
|
+
this._avUntil = t + p.dur[0] + Math.random() * (p.dur[1] - p.dur[0]);
|
|
353
328
|
this._nextMicro = 0;
|
|
354
329
|
const d = p.dirs[(Math.random() * p.dirs.length) | 0];
|
|
355
330
|
const m = (p.mag[0] + Math.random() * (p.mag[1] - p.mag[0])) * this.avertGain;
|