@convai/embody 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +191 -0
  3. package/README.md +165 -0
  4. package/dist/character/createCharacter.d.ts +101 -0
  5. package/dist/character/createCharacter.js +163 -0
  6. package/dist/character/lifecycle.d.ts +55 -0
  7. package/dist/character/lifecycle.js +28 -0
  8. package/dist/character/pipeline.d.ts +46 -0
  9. package/dist/character/pipeline.js +29 -0
  10. package/dist/clips/animations.d.ts +39 -0
  11. package/dist/clips/animations.js +77 -0
  12. package/dist/decorate/types.d.ts +29 -0
  13. package/dist/decorate/types.js +13 -0
  14. package/dist/index.d.ts +30 -0
  15. package/dist/index.js +14 -0
  16. package/dist/motion/blink.d.ts +56 -0
  17. package/dist/motion/blink.js +127 -0
  18. package/dist/motion/breathe.d.ts +72 -0
  19. package/dist/motion/breathe.js +93 -0
  20. package/dist/motion/eyeGaze.d.ts +65 -0
  21. package/dist/motion/eyeGaze.js +66 -0
  22. package/dist/motion/gaze.d.ts +212 -0
  23. package/dist/motion/gaze.js +467 -0
  24. package/dist/motion/reducedMotion.d.ts +13 -0
  25. package/dist/motion/reducedMotion.js +13 -0
  26. package/dist/motion/types.d.ts +16 -0
  27. package/dist/motion/types.js +1 -0
  28. package/dist/react/ConvaiCharacter.d.ts +7 -0
  29. package/dist/react/ConvaiCharacter.js +25 -0
  30. package/dist/react/index.d.ts +4 -0
  31. package/dist/react/index.js +2 -0
  32. package/dist/react/useCharacter.d.ts +11 -0
  33. package/dist/react/useCharacter.js +45 -0
  34. package/dist/resolve/resolver.d.ts +56 -0
  35. package/dist/resolve/resolver.js +45 -0
  36. package/dist/rig/profile.d.ts +64 -0
  37. package/dist/rig/profile.js +110 -0
  38. package/dist/rig/resolve.d.ts +33 -0
  39. package/dist/rig/resolve.js +76 -0
  40. package/dist/rig/units.d.ts +27 -0
  41. package/dist/rig/units.js +61 -0
  42. package/dist/speech/lipsync.d.ts +56 -0
  43. package/dist/speech/lipsync.js +139 -0
  44. package/package.json +109 -0
@@ -0,0 +1,93 @@
1
+ import * as THREE from 'three';
2
+ function freshAnchor() {
3
+ return { base: new THREE.Quaternion(), lastWritten: new THREE.Quaternion(), has: false };
4
+ }
5
+ /**
6
+ * Subtle additive chest/spine motion so the body never reads frozen.
7
+ *
8
+ * RULING (2026-09-07 fix wave, C3, superseding an earlier fix in this same
9
+ * wave — see the coordinator correction in final-fix-report.md): the
10
+ * ORIGINAL version of this class post-multiplied the breath delta directly
11
+ * onto `chest.quaternion`/`waist.quaternion`, on the claim that "the mixer
12
+ * rewrites every frame, so multiplying in never accumulates." That's only
13
+ * true while an active AnimationMixer action is bound to those bones, and
14
+ * `createCharacter` registers clips without ever calling `.play()` — on
15
+ * that (default) path nothing resets the bone between frames, so the
16
+ * original code compounded without bound (measured ±30°+ at 30fps).
17
+ *
18
+ * A FIRST fix composed `rest * breath` absolutely every frame, using the
19
+ * bone's bind pose captured once at `resolveRig` time. That bounded the
20
+ * no-clip case but silently broke the WITH-clip case: every clip in
21
+ * `demo/fixtures/japanese-f-animations.glb` (Idle included, which the demo
22
+ * actually plays) animates `spine_02`/`spine_04` — the exact two bones this
23
+ * system drives — so composing onto a STATIC captured rest pose discarded
24
+ * the clip's own spine motion outright, every frame, on the normal
25
+ * (with-clip) path. That is worse than the bug it fixed: a spinning torso
26
+ * with no clip is unmissable on day one; silently-dropped clip spine
27
+ * animation on the normal path is not.
28
+ *
29
+ * Fixed properly here by having each bone remember what THIS system itself
30
+ * last wrote to it (`BoneAnchor.lastWritten`). Each frame:
31
+ * - if the bone's LIVE quaternion differs from `lastWritten`, something
32
+ * else (the mixer) rewrote it since — adopt the bone's current value as
33
+ * this frame's `base` and remember it.
34
+ * - if it is identical, this system's own previous write is still
35
+ * standing (nothing else touched the bone) — reuse the stored `base`
36
+ * rather than compounding on top of what was already composed.
37
+ * Then write `base * breath`. With an active clip continuously rewriting
38
+ * the bone (the normal path), `base` re-adopts the clip's live pose every
39
+ * frame and breath composes on top of it, exactly as the pipeline's stage-3
40
+ * "additive, after the mixer" contract intends. With no clip at all (the
41
+ * path the original bug was about), nothing ever differs from
42
+ * `lastWritten`, so `base` stays anchored to whatever the bone held on the
43
+ * first frame this system ever touched it (its bind pose, in practice) —
44
+ * bounded, exactly as the earlier fix intended, without needing a
45
+ * separately-captured rest pose at all.
46
+ *
47
+ * The comparison is EXACT (`Quaternion.equals`, not an epsilon): a
48
+ * `.copy()` followed by a later read of that SAME untouched object is
49
+ * bit-identical, so exact equality has no false-negative risk from float
50
+ * drift on the "nothing touched it" path. An epsilon would also risk a
51
+ * false negative on the "something touched it" path — a real clip whose
52
+ * frame-to-frame delta happens to be smaller than some chosen tolerance
53
+ * (e.g. a slow sway lingering near an extremum) would be missed and
54
+ * wrongly treated as "unchanged."
55
+ */
56
+ export class BreathingSystem {
57
+ constructor(config = {}) {
58
+ this.stage = 3;
59
+ this.clock = Math.random() * 10; // random phase per instance
60
+ this.q = new THREE.Quaternion(); // pre-allocated
61
+ this.chestAnchor = freshAnchor();
62
+ this.waistAnchor = freshAnchor();
63
+ this.rate = config.rate ?? 0.22;
64
+ this.amount = config.amount ?? 0.012;
65
+ this.enabled = config.enabled ?? true;
66
+ }
67
+ update(dt, ctx) {
68
+ if (!this.enabled || ctx.reducedMotion)
69
+ return;
70
+ const chest = ctx.rig.bones.spineChest;
71
+ const waist = ctx.rig.bones.spineWaist;
72
+ if (!chest && !waist)
73
+ return;
74
+ this.clock += dt;
75
+ const phase = this.clock * this.rate * Math.PI * 2;
76
+ // Asymmetric breath: sharper rise (inhale), longer fall (exhale).
77
+ const breath = Math.sin(phase) + 0.3 * Math.sin(phase * 2 + 1.2);
78
+ const basis = ctx.rig.profile.basis;
79
+ if (chest)
80
+ this.applyBreath(chest, this.chestAnchor, -breath * this.amount, basis.right);
81
+ if (waist)
82
+ this.applyBreath(waist, this.waistAnchor, -breath * this.amount * 0.5, basis.right);
83
+ }
84
+ applyBreath(bone, anchor, angle, axis) {
85
+ if (!anchor.has || !bone.quaternion.equals(anchor.lastWritten)) {
86
+ anchor.base.copy(bone.quaternion);
87
+ anchor.has = true;
88
+ }
89
+ this.q.setFromAxisAngle(axis, angle);
90
+ bone.quaternion.copy(anchor.base).multiply(this.q);
91
+ anchor.lastWritten.copy(bone.quaternion);
92
+ }
93
+ }
@@ -0,0 +1,65 @@
1
+ import type { FrameContext, System } from '../character/pipeline.js';
2
+ import type { GazeSource } from './types.js';
3
+ export interface EyeGazeConfig {
4
+ /**
5
+ * Per-frame ease rate toward the gaze target. Verbatim from
6
+ * `useEyeTracking.ts`'s `DEFAULT_CONFIG.lerpSpeed` (useEyeTracking.ts:108)
7
+ * — NOT this plan's originally-invented `ease: 0.25` (RULING R47).
8
+ */
9
+ lerpSpeed?: number;
10
+ /**
11
+ * Constant downward bias folded into the vertical target before
12
+ * clamping — "pupils look slightly lower by default"
13
+ * (`DEFAULT_CONFIG.verticalOffset`, useEyeTracking.ts:109, doc at :31).
14
+ * The original brief omitted this field entirely (RULING R47).
15
+ */
16
+ verticalOffset?: number;
17
+ /**
18
+ * Scale on the eye-look MORPH values at full deflection. RULING
19
+ * (2026-09-07 fix wave): default is the rig-tuned value from the actual
20
+ * convai-mha-1 call site (SofiaV4.tsx:323, `morphGain: 0.65`), not the
21
+ * generic `useEyeTracking.ts` DEFAULT_CONFIG value of 1 — that call site's
22
+ * rig shares this exact CTRL_expressions_eyeLook* morph set (same names
23
+ * `CONVAI_MHA_1` resolves against) and its own comment gives the reason:
24
+ * "the v4 eye-look shapes encode ~40°+ of eyeball rotation at 1.0; scale
25
+ * down so a 30° gaze angle maps to a ~30° eyeball turn." That is a
26
+ * rig-mesh property, independent of C4's `DOWN_LID_SCALE` (which damps
27
+ * only the down-gaze lid-travel channel) — the two compound only on the
28
+ * down direction, by design (down gaze is the one direction stacked with
29
+ * BlinkSystem's resting droop too).
30
+ */
31
+ morphGain?: number;
32
+ }
33
+ /**
34
+ * Eye gaze on convai-mha-1 is MORPH-ONLY — the mandated rig has no eye bones
35
+ * (character spec 2.2). This system ports only the morph-output half of
36
+ * `useEyeTracking`'s dual bone+morph mechanism
37
+ * (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
38
+ * of scope here.
39
+ *
40
+ * The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
41
+ * toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
42
+ * the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
43
+ * that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
44
+ * `eyeLookOutR`); this system only ever speaks in in/out terms and never
45
+ * references a literal left/right channel name.
46
+ *
47
+ * DEFERRED (RULING R48 — logged for final review, deliberately NOT built
48
+ * here):
49
+ * - saccades: dart-to-a-new-point-and-hold idle micro-movement
50
+ * (useEyeTracking.ts:254-262, 458-485).
51
+ * - the speech lock that suppresses saccades for full eye contact while
52
+ * speaking, and the `isListening` lerp-to-1.0 override
53
+ * (useEyeTracking.ts:264-265, 401, 485).
54
+ *
55
+ * Registered at stage 6 (morph), between lipsync and blink.
56
+ */
57
+ export declare class EyeGazeSystem implements System {
58
+ private readonly gaze;
59
+ private readonly cfg;
60
+ readonly stage: 6;
61
+ private h;
62
+ private v;
63
+ constructor(gaze: GazeSource, cfg?: EyeGazeConfig);
64
+ update(_dt: number, ctx: FrameContext): void;
65
+ }
@@ -0,0 +1,66 @@
1
+ import * as THREE from 'three';
2
+ import { writeMorph } from '../rig/resolve.js';
3
+ /**
4
+ * Eye gaze on convai-mha-1 is MORPH-ONLY — the mandated rig has no eye bones
5
+ * (character spec 2.2). This system ports only the morph-output half of
6
+ * `useEyeTracking`'s dual bone+morph mechanism
7
+ * (useEyeTracking.ts:538-562) — the bone-rotation path is CC4-only and out
8
+ * of scope here.
9
+ *
10
+ * The channel names are cross-named: driving `eyeLookLeft*` rotates the eyes
11
+ * toward the character's RIGHT (useEyeTracking.ts:156-159, verified live on
12
+ * the UE rig by driving each morph in isolation). `CONVAI_MHA_1` encodes
13
+ * that as in/out morph roles (`eyeLookInL`/`eyeLookOutL`/`eyeLookInR`/
14
+ * `eyeLookOutR`); this system only ever speaks in in/out terms and never
15
+ * references a literal left/right channel name.
16
+ *
17
+ * DEFERRED (RULING R48 — logged for final review, deliberately NOT built
18
+ * here):
19
+ * - saccades: dart-to-a-new-point-and-hold idle micro-movement
20
+ * (useEyeTracking.ts:254-262, 458-485).
21
+ * - the speech lock that suppresses saccades for full eye contact while
22
+ * speaking, and the `isListening` lerp-to-1.0 override
23
+ * (useEyeTracking.ts:264-265, 401, 485).
24
+ *
25
+ * Registered at stage 6 (morph), between lipsync and blink.
26
+ */
27
+ export class EyeGazeSystem {
28
+ constructor(gaze, cfg = {}) {
29
+ this.gaze = gaze;
30
+ this.cfg = cfg;
31
+ this.stage = 6;
32
+ this.h = 0;
33
+ this.v = 0;
34
+ }
35
+ update(_dt, ctx) {
36
+ const lerpSpeed = this.cfg.lerpSpeed ?? 0.2;
37
+ const verticalOffset = this.cfg.verticalOffset ?? -0.15;
38
+ const gain = this.cfg.morphGain ?? 0.65;
39
+ const d = this.gaze.direction;
40
+ const targetH = ctx.reducedMotion ? 0 : THREE.MathUtils.clamp(d.horizontal, -1, 1);
41
+ const targetV = ctx.reducedMotion
42
+ ? 0
43
+ : THREE.MathUtils.clamp(d.vertical + verticalOffset, -1, 1);
44
+ this.h += (targetH - this.h) * lerpSpeed;
45
+ this.v += (targetV - this.v) * lerpSpeed;
46
+ const h = this.h * gain;
47
+ const v = this.v * gain;
48
+ // Positive h = looking to the character's RIGHT. That is "in" (nose-ward)
49
+ // for the LEFT eye and "out" for the RIGHT eye.
50
+ writeMorph(ctx.rig, 'eyeLookInL', Math.max(0, h));
51
+ writeMorph(ctx.rig, 'eyeLookOutL', Math.max(0, -h));
52
+ writeMorph(ctx.rig, 'eyeLookInR', Math.max(0, -h));
53
+ writeMorph(ctx.rig, 'eyeLookOutR', Math.max(0, h));
54
+ // Positive v = looking up. Up/down are named straight (not cross-named)
55
+ // on both eyes. Down-gaze lid travel is damped (RULING C4, 2026-09-07 fix
56
+ // wave): ported from useEyeTracking.ts:545-551's `DOWN_LID_SCALE = 0.65`
57
+ // — at 1:1 the Look_Down morphs plus BlinkSystem's resting droop
58
+ // (`relax`) shut the eyes whenever the gaze drops. Only the DOWN
59
+ // direction is scaled; up/left/right are untouched.
60
+ const DOWN_LID_SCALE = 0.65;
61
+ writeMorph(ctx.rig, 'eyeLookUpL', Math.max(0, v));
62
+ writeMorph(ctx.rig, 'eyeLookDownL', DOWN_LID_SCALE * Math.max(0, -v));
63
+ writeMorph(ctx.rig, 'eyeLookUpR', Math.max(0, v));
64
+ writeMorph(ctx.rig, 'eyeLookDownR', DOWN_LID_SCALE * Math.max(0, -v));
65
+ }
66
+ }
@@ -0,0 +1,212 @@
1
+ import type { FrameContext, System } from '../character/pipeline.js';
2
+ import type { GazeDirection, GazeSource } from './types.js';
3
+ /**
4
+ * Config surface for `GazeSystem`. Task 6 landed the geometric core —
5
+ * target -> yaw/pitch -> head/neck split -> time-based, velocity-capped
6
+ * smoothing -> `setLookQuat`. Task 7 (this task) adds the aliveness layer:
7
+ * idle drift, look-away gaze shifts and turn-bank, all read by `update()`
8
+ * below. Task 8 (clip-blending: camera lock, deviation pull-back) extends
9
+ * the SAME class and will read the fields still marked "reserved" — they
10
+ * are declared now so the class's public config shape does not change
11
+ * under that task, but `update()` does not read them yet.
12
+ */
13
+ export interface GazeConfig {
14
+ /** Half-angle (rad) the HEAD alone covers before the neck starts sharing
15
+ * yaw. Verbatim from `HeadTrackingConfig.DEFAULT_CONFIG.headOnlyAngle`
16
+ * (useHeadTracking.ts:107) — NOT the interface JSDoc's PI/3 (line 17),
17
+ * which the implementation itself does not use. Per RULING R20: the
18
+ * DEFAULTS object is ground truth, and its own comment explains 0.44 was
19
+ * a deliberate retune down from a wider zone that put the head near its
20
+ * joint limit for a side viewer. */
21
+ headOnlyAngle?: number;
22
+ /** Beyond this yaw (rad) tracking disengages entirely for the frame
23
+ * (target clamped to zero, not clamped-in-range). */
24
+ maxTrackAngle?: number;
25
+ /** Vertical (pitch) clamp, radians. */
26
+ maxPitchAngle?: number;
27
+ /** Ease rate (per-second, before the `* 60` time-based conversion in
28
+ * `update()`) toward the target yaw/pitch. Velocity-capped — see
29
+ * `update()`. */
30
+ lerpSpeed?: number;
31
+ /** Share of pitch applied to the head. */
32
+ headPitchFactor?: number;
33
+ /** Share of pitch applied to each neck bone. */
34
+ neckPitchFactor?: number;
35
+ /** Share of the excess (beyond `headOnlyAngle`) yaw applied to the neck.
36
+ * Verbatim from `DEFAULT_CONFIG.neckYawFactor` (useHeadTracking.ts:113),
37
+ * which is 0.75, not the interface JSDoc's 0.7 (line 30). Per R20. */
38
+ neckYawFactor?: number;
39
+ /** Idle micro-drift amplitude on yaw, radians. */
40
+ idleYawAmp?: number;
41
+ /** Idle micro-drift amplitude on pitch, radians. */
42
+ idlePitchAmp?: number;
43
+ /** Idle micro-drift amplitude on roll, radians. */
44
+ idleRollAmp?: number;
45
+ /** Time multiplier for the idle-drift clock. */
46
+ idleSpeed?: number;
47
+ /** Roll added per radian of (capped) head yaw — banks the head into a
48
+ * turn. */
49
+ turnBank?: number;
50
+ /** Max yaw (radians) of an occasional look-away gaze shift. */
51
+ gazeYawRange?: number;
52
+ /** Max pitch (radians) of a gaze shift. */
53
+ gazePitchRange?: number;
54
+ /** Max roll (radians) of a gaze shift. */
55
+ gazeRollRange?: number;
56
+ /** Ease rate (per-second, before the `* 60` time-based conversion) the
57
+ * gaze-shift offset eases toward its target at. */
58
+ gazeEase?: number;
59
+ /** Minimum seconds a gaze-shift target is held before re-picking. */
60
+ gazeDwellMin?: number;
61
+ /** Maximum seconds a gaze-shift target is held before re-picking. */
62
+ gazeDwellMax?: number;
63
+ /** Camera-lock strength while idle (0-1): how much of the clip's local
64
+ * head rotation is replaced by the look pose. High values mean
65
+ * procedural drift/gaze carry the aliveness and the clip barely
66
+ * reaches the output. */
67
+ idleLock?: number;
68
+ /** Camera-lock strength while `ctx.speaking` — focused on the viewer. */
69
+ talkLock?: number;
70
+ /** Clip-deviation (radians, angle between the clip's head pose and the
71
+ * look pose) below which the clip flows through untouched. */
72
+ deviationLo?: number;
73
+ /** Clip-deviation (radians) by which the pull-back saturates — the head
74
+ * is fully locked back to the look pose regardless of `idleLock`/
75
+ * `talkLock`. */
76
+ deviationHi?: number;
77
+ /** Static construction-time gate. Live control is `setEnabled()`. */
78
+ enabled?: boolean;
79
+ }
80
+ /**
81
+ * Head/neck gaze pose solve, ported from `useHeadTracking`
82
+ * (convai-web-sdk-internal `examples/react-three-fiber/src/hooks/useHeadTracking.ts`).
83
+ *
84
+ * Stage 2 — the ONLY system permitted to OVERRIDE the mixer's head/neck
85
+ * rotation outright (`.copy(...)`, never `.multiply(...)`): a head that
86
+ * blends additively with its own animation clip fights itself every frame.
87
+ *
88
+ * Task 6 ported the geometric core: world target -> yaw/pitch about the
89
+ * rig's own basis -> head/neck split -> time-based, velocity-capped ease ->
90
+ * `setLookQuat`. Task 7 layered the aliveness on top of that same solve:
91
+ * always-on idle micro-drift, occasional look-away gaze shifts with dwell
92
+ * timing, and turn-bank roll — all suppressed under `ctx.reducedMotion`.
93
+ * Task 8 (this task) adds the clip-blending layer: a smoothed camera-lock
94
+ * strength (`idleLock`/`talkLock`, eased rather than switched — see
95
+ * `update()`) blended against the mixer's own clip pose, with a deviation
96
+ * pull-back (`deviationLo`/`deviationHi`) that guards large clip excursions
97
+ * regardless of lock strength; and `calm`, which de-emphasises the idle
98
+ * drift/gaze-shift amplitudes while the (smoothed) lock is high, i.e. while
99
+ * speaking — a focused speaker stops glancing around.
100
+ *
101
+ * The HEAD is the only bone that blends with the clip; the neck bones are
102
+ * still overridden outright (Task 6/7 behaviour, unchanged) — see
103
+ * `update()`.
104
+ *
105
+ * See `.superpowers/sdd/2026-09-07-convai-embody-motion-and-staging/task-6-report.md`,
106
+ * `task-7-report.md` and `task-8-report.md` for the line-by-line comparison
107
+ * against the prior art and the places this deliberately differs from it.
108
+ */
109
+ export declare class GazeSystem implements System, GazeSource {
110
+ private readonly cfg;
111
+ readonly stage: 2;
112
+ readonly direction: GazeDirection;
113
+ private gate;
114
+ private yaw;
115
+ private pitch;
116
+ /** Idle-roll + turn-bank, written by the aliveness layer below. */
117
+ private roll;
118
+ /** Idle-drift clock, advanced by `dt * idleSpeed`. Randomly seeded so
119
+ * multiple GazeSystem instances on stage together don't drift in
120
+ * lockstep. */
121
+ private driftClock;
122
+ /** Current eased gaze-shift offsets (the "look-away" the head is
123
+ * easing toward / holding). */
124
+ private shiftYaw;
125
+ private shiftPitch;
126
+ private shiftRoll;
127
+ /** The look-away target `shift*` is easing toward, re-picked every
128
+ * dwell period. */
129
+ private shiftTargetYaw;
130
+ private shiftTargetPitch;
131
+ private shiftTargetRoll;
132
+ /** Seconds remaining before the next gaze-shift target is re-picked. */
133
+ private dwell;
134
+ /** Smoothed camera-lock strength, eased toward `idleLock`/`talkLock`
135
+ * each frame (RULING R39) rather than switched instantly — ported
136
+ * from `useHeadTracking.ts:581`'s `lockRef`, but time-based (R40)
137
+ * instead of the prior art's bare per-frame `* 0.04`. Initialised to
138
+ * `idleLock` so the very first frame doesn't jump. */
139
+ private lock;
140
+ private readonly vTarget;
141
+ private readonly vHead;
142
+ private readonly vLocal;
143
+ private readonly mInv;
144
+ /** What the mixer wrote to the head this frame, before we touch it. */
145
+ private readonly qClip;
146
+ /** The camera-locked look pose computed this frame. */
147
+ private readonly qLook;
148
+ /** Low-passed (tau ~0.2s) copy of `qClip`. RULING R43 part 2: the
149
+ * deviation boost is measured against THIS, not the raw clip pose, so
150
+ * one noisy/fast clip frame can't make the boost — and therefore the
151
+ * blend — jump within a frame. Ported from useHeadTracking.ts:594-606. */
152
+ private readonly animHead;
153
+ private animHeadHas;
154
+ /** The blended pose, before the output velocity cap below is applied. */
155
+ private readonly qOut;
156
+ /** The actual pose WRITTEN to the head last frame — the anchor the
157
+ * output velocity cap (RULING R43 part 3) rotates from. Ported from
158
+ * useHeadTracking.ts:621-633. */
159
+ private readonly lastOutHead;
160
+ private lastOutHas;
161
+ /** RULING R61: the neck bones blend with their OWN clip pose too — same
162
+ * low-pass, same shared head-derived `blend`, same output cap — ported
163
+ * from useHeadTracking.ts:598-606 (low-pass) and :635-671 (blend +
164
+ * cap). One low-pass/cap pair per neck bone, each independent (the
165
+ * clip poses and, after the cap, the written poses differ per bone). */
166
+ private readonly animNeck1;
167
+ private animNeck1Has;
168
+ private readonly animNeck2;
169
+ private animNeck2Has;
170
+ private readonly qOutNeck;
171
+ private readonly lastOutNeck1;
172
+ private lastOutNeck1Has;
173
+ private readonly lastOutNeck2;
174
+ private lastOutNeck2Has;
175
+ constructor(cfg?: GazeConfig);
176
+ /**
177
+ * Live on/off gate, read every frame. While false the system writes
178
+ * NOTHING to the rig — the mixer's clips own the head/neck — and eases
179
+ * the internal yaw/pitch/roll state toward neutral so re-enabling does
180
+ * not snap the head back onto the camera.
181
+ */
182
+ setEnabled(on: boolean): void;
183
+ /**
184
+ * Time-based exponential ease toward `tgt`, velocity-capped so a frame
185
+ * hitch can't yank the head. Ported verbatim from
186
+ * useHeadTracking.ts:480-484 — NOT the bare `x += (target - x) * lerp`
187
+ * form, which is frame-rate dependent by construction (a 144Hz monitor
188
+ * would track visibly faster than a 60Hz one). Per RULING R22.
189
+ *
190
+ * Hoisted to a method (RULING R25) rather than a closure allocated inside
191
+ * `update()` — a per-frame closure is exactly the steady-state allocation
192
+ * this class's zero-allocation rule forbids. `useHeadTracking.ts` can
193
+ * afford the closure because it's a React hook body re-created rarely and
194
+ * garbage-collected on unmount; embody's `update()` runs every frame for
195
+ * the lifetime of the character.
196
+ */
197
+ private approach;
198
+ /**
199
+ * Smooth, non-repeating drift in ~[-1, 1] — three incommensurate sines
200
+ * (an fbm-lite). Ported VERBATIM from useHeadTracking.ts:144-150
201
+ * (function `drift`) — NOT the brief's single-sine placeholder
202
+ * (`Math.sin(t * 0.31)`), which is a different, un-sourced waveform.
203
+ * The three fixed frequencies/weights are shared by every axis; only
204
+ * the per-axis `seed` (and its own amplitude) differs, exactly as in
205
+ * the prior art's `drift(t, 1.0|7.3|13.1)` calls at lines 556-559.
206
+ *
207
+ * Hoisted to a method rather than a closure (RULING R25, same as
208
+ * `approach` above) — no steady-state allocation.
209
+ */
210
+ private drift;
211
+ update(dt: number, ctx: FrameContext): void;
212
+ }