@voqalize/avatar 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +692 -0
- package/client/dist/Avatar.d.ts +24 -0
- package/client/dist/Avatar.d.ts.map +1 -0
- package/client/dist/Avatar.js +7 -0
- package/client/dist/Avatar.js.map +1 -0
- package/client/dist/AvatarClient.d.ts +173 -0
- package/client/dist/AvatarClient.d.ts.map +1 -0
- package/client/dist/AvatarClient.js +274 -0
- package/client/dist/AvatarClient.js.map +1 -0
- package/client/dist/pipecat.d.ts +21 -0
- package/client/dist/pipecat.d.ts.map +1 -0
- package/client/dist/pipecat.js +21 -0
- package/client/dist/pipecat.js.map +1 -0
- package/client/dist/react.d.ts +16 -0
- package/client/dist/react.d.ts.map +1 -0
- package/client/dist/react.js +17 -0
- package/client/dist/react.js.map +1 -0
- package/client/dist/types.d.ts +101 -0
- package/client/dist/types.d.ts.map +1 -0
- package/client/dist/types.js +31 -0
- package/client/dist/types.js.map +1 -0
- package/client/dist/useAvatar.d.ts +53 -0
- package/client/dist/useAvatar.d.ts.map +1 -0
- package/client/dist/useAvatar.js +68 -0
- package/client/dist/useAvatar.js.map +1 -0
- package/client/src/Avatar.tsx +38 -0
- package/client/src/AvatarClient.ts +343 -0
- package/client/src/pipecat.ts +38 -0
- package/client/src/react.ts +34 -0
- package/client/src/types.ts +127 -0
- package/client/src/useAvatar.ts +113 -0
- package/docs/contract-avatar.md +337 -0
- package/docs/contract-protocol.md +401 -0
- package/package.json +89 -0
- package/src/audio-fallback.js +100 -0
- package/src/avatar.d.ts +241 -0
- package/src/avatar.js +722 -0
- package/src/clips.js +144 -0
- package/src/emotions.js +55 -0
- package/src/face-core.js +154 -0
- package/src/face-myna.js +725 -0
- package/src/face-peep.js +767 -0
- package/src/face-wren.js +470 -0
- package/src/gaze.js +155 -0
- package/src/idle.js +535 -0
- package/src/interjections.js +578 -0
- package/src/line-art.js +111 -0
- package/src/params.js +176 -0
- package/src/perform.js +105 -0
- package/src/visemes.js +230 -0
package/src/clips.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gesture clip player.
|
|
3
|
+
*
|
|
4
|
+
* A clip is a short, multi-channel keyframed timeline. Clip channels are
|
|
5
|
+
* *additive deltas*, not absolute poses — a nod adds pitch to whatever the head
|
|
6
|
+
* is already doing, which is what makes "nod while speaking" work without any
|
|
7
|
+
* special-casing.
|
|
8
|
+
*
|
|
9
|
+
* Two behaviours matter more here than the curves themselves:
|
|
10
|
+
* · clips are interruptible (barge-in must kill an in-flight nod), and
|
|
11
|
+
* · the queue collapses duplicates — three stacked nods reads as a fault.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { clamp } from './params.js';
|
|
15
|
+
import { VisemeTrack } from './visemes.js';
|
|
16
|
+
|
|
17
|
+
const smoothstep = (t) => t * t * (3 - 2 * t);
|
|
18
|
+
|
|
19
|
+
function sampleTrack(keys, u) {
|
|
20
|
+
if (!keys.length) return 0;
|
|
21
|
+
if (u <= keys[0][0]) return keys[0][1];
|
|
22
|
+
const last = keys[keys.length - 1];
|
|
23
|
+
if (u >= last[0]) return last[1];
|
|
24
|
+
for (let i = 1; i < keys.length; i++) {
|
|
25
|
+
if (u <= keys[i][0]) {
|
|
26
|
+
const [t0, v0] = keys[i - 1];
|
|
27
|
+
const [t1, v1] = keys[i];
|
|
28
|
+
const k = t1 === t0 ? 1 : smoothstep((u - t0) / (t1 - t0));
|
|
29
|
+
return v0 + (v1 - v0) * k;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return last[1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RAMP_IN = 70;
|
|
36
|
+
const RAMP_OUT = 150;
|
|
37
|
+
|
|
38
|
+
export class ClipPlayer {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} hooks
|
|
41
|
+
* @param {(name:string|null)=>void} hooks.onGaze clip-scoped gaze override
|
|
42
|
+
* @param {()=>void} hooks.onBlink
|
|
43
|
+
*/
|
|
44
|
+
constructor(hooks = {}) {
|
|
45
|
+
this.hooks = hooks;
|
|
46
|
+
this.clip = null;
|
|
47
|
+
this.t = 0;
|
|
48
|
+
this.fading = false;
|
|
49
|
+
this.fadeT = 0;
|
|
50
|
+
this.mouth = new VisemeTrack();
|
|
51
|
+
this._blinksDone = 0;
|
|
52
|
+
this.onEnd = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get playing() { return !!this.clip; }
|
|
56
|
+
get id() { return this.clip ? this.clip.id : null; }
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} clip
|
|
60
|
+
* @param {HTMLAudioElement} [audio] if given, the clip's mouth track is
|
|
61
|
+
* scheduled against the audio clock instead of the local timer
|
|
62
|
+
*/
|
|
63
|
+
play(clip, audio) {
|
|
64
|
+
if (!clip) return;
|
|
65
|
+
// Collapse a repeat of the clip already running.
|
|
66
|
+
if (this.clip && this.clip.id === clip.id && !this.fading) return;
|
|
67
|
+
|
|
68
|
+
this.clip = clip;
|
|
69
|
+
this.t = 0;
|
|
70
|
+
this.fading = false;
|
|
71
|
+
this.fadeT = 0;
|
|
72
|
+
this._blinksDone = 0;
|
|
73
|
+
this.audio = audio || null;
|
|
74
|
+
|
|
75
|
+
if (clip.mouthCues && clip.mouthCues.length) {
|
|
76
|
+
const clock = audio
|
|
77
|
+
? () => audio.currentTime * 1000
|
|
78
|
+
: () => this.t;
|
|
79
|
+
this.mouth.tailMs = 60;
|
|
80
|
+
this.mouth.start(clip.mouthCues, clock);
|
|
81
|
+
} else {
|
|
82
|
+
this.mouth.stop();
|
|
83
|
+
}
|
|
84
|
+
if (clip.gaze && this.hooks.onGaze) this.hooks.onGaze(clip.gaze);
|
|
85
|
+
if (audio) { audio.currentTime = 0; audio.play().catch(() => {}); }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Barge-in. Fades rather than cutting, so the head doesn't snap. */
|
|
89
|
+
stop(immediate = false) {
|
|
90
|
+
if (!this.clip) return;
|
|
91
|
+
if (immediate) return this._end();
|
|
92
|
+
this.fading = true;
|
|
93
|
+
this.fadeT = 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_end() {
|
|
97
|
+
const had = this.clip;
|
|
98
|
+
this.clip = null;
|
|
99
|
+
this.fading = false;
|
|
100
|
+
this.mouth.stop();
|
|
101
|
+
if (this.audio) { this.audio.pause(); this.audio = null; }
|
|
102
|
+
if (had && had.gaze && this.hooks.onGaze) this.hooks.onGaze(null);
|
|
103
|
+
if (this.onEnd) this.onEnd(had);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @returns {{delta: object, weight: number, mouth: object|null, ownsMouth: boolean}}
|
|
108
|
+
*/
|
|
109
|
+
update(dtMs) {
|
|
110
|
+
if (!this.clip) return { delta: null, weight: 0, mouth: null, ownsMouth: false };
|
|
111
|
+
this.t += dtMs;
|
|
112
|
+
const c = this.clip;
|
|
113
|
+
const u = clamp(this.t / c.duration);
|
|
114
|
+
|
|
115
|
+
let w = Math.min(1, this.t / RAMP_IN) * Math.min(1, (c.duration - this.t) / RAMP_OUT);
|
|
116
|
+
if (this.fading) {
|
|
117
|
+
this.fadeT += dtMs;
|
|
118
|
+
w *= Math.max(0, 1 - this.fadeT / 110);
|
|
119
|
+
if (this.fadeT >= 110) { this._end(); return { delta: null, weight: 0, mouth: null, ownsMouth: false }; }
|
|
120
|
+
}
|
|
121
|
+
w = clamp(w);
|
|
122
|
+
|
|
123
|
+
if (c.blinkAt && this.hooks.onBlink) {
|
|
124
|
+
while (this._blinksDone < c.blinkAt.length && u >= c.blinkAt[this._blinksDone]) {
|
|
125
|
+
this._blinksDone++;
|
|
126
|
+
this.hooks.onBlink();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const delta = {};
|
|
131
|
+
for (const ch in c.keys) delta[ch] = sampleTrack(c.keys[ch], u) * w;
|
|
132
|
+
|
|
133
|
+
let mouth = null;
|
|
134
|
+
const ownsMouth = !!(c.mouthCues && c.mouthCues.length);
|
|
135
|
+
if (ownsMouth) mouth = this.mouth.sample();
|
|
136
|
+
|
|
137
|
+
if (this.t >= c.duration && !this.fading) {
|
|
138
|
+
// Let the mouth track finish its tail before tearing down.
|
|
139
|
+
if (!ownsMouth || !this.mouth.playing) this._end();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { delta, weight: w, mouth, ownsMouth };
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/emotions.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Affect is a separate axis from state.
|
|
3
|
+
*
|
|
4
|
+
* If emotion were folded into the state enum you'd need SPEAKING_WARM,
|
|
5
|
+
* SPEAKING_CONCERNED, LISTENING_WARM ... and the table would be unmaintainable
|
|
6
|
+
* within a week. Instead: `setState('LISTENING', { emotion: 'curious' })`. The
|
|
7
|
+
* emotion contributes a base pose that every other layer builds on top of.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const EMOTIONS = {
|
|
11
|
+
neutral: {
|
|
12
|
+
mouthCornerL: 0.10, mouthCornerR: 0.10,
|
|
13
|
+
},
|
|
14
|
+
warm: {
|
|
15
|
+
mouthCornerL: 0.48, mouthCornerR: 0.48,
|
|
16
|
+
// A real smile squints. Without this it reads as a mask.
|
|
17
|
+
squintL: 0.30, squintR: 0.30, lidL: 0.04, lidR: 0.04,
|
|
18
|
+
browRaiseL: 0.10, browRaiseR: 0.10,
|
|
19
|
+
},
|
|
20
|
+
curious: {
|
|
21
|
+
browRaiseL: 0.34, browRaiseR: 0.12,
|
|
22
|
+
browAngleL: 0.10,
|
|
23
|
+
headRoll: 0.07,
|
|
24
|
+
mouthCornerL: 0.18, mouthCornerR: 0.14,
|
|
25
|
+
lidL: -0.10, lidR: -0.10,
|
|
26
|
+
},
|
|
27
|
+
concerned: {
|
|
28
|
+
browInnerL: 0.55, browInnerR: 0.55,
|
|
29
|
+
browRaiseL: -0.08, browRaiseR: -0.08,
|
|
30
|
+
mouthCornerL: -0.22, mouthCornerR: -0.22,
|
|
31
|
+
lidL: 0.06, lidR: 0.06,
|
|
32
|
+
},
|
|
33
|
+
encouraging: {
|
|
34
|
+
mouthCornerL: 0.58, mouthCornerR: 0.58,
|
|
35
|
+
browRaiseL: 0.26, browRaiseR: 0.26,
|
|
36
|
+
squintL: 0.22, squintR: 0.22,
|
|
37
|
+
headPitch: 0.05,
|
|
38
|
+
},
|
|
39
|
+
thoughtful: {
|
|
40
|
+
browRaiseL: -0.14, browRaiseR: -0.06,
|
|
41
|
+
browInnerL: 0.18, browInnerR: 0.10,
|
|
42
|
+
mouthPress: 0.45, mouthCornerL: -0.05, mouthCornerR: 0.02,
|
|
43
|
+
lidL: 0.10, lidR: 0.10,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const EMOTION_NAMES = Object.keys(EMOTIONS);
|
|
48
|
+
|
|
49
|
+
/** Blend an emotion toward neutral by `intensity`. */
|
|
50
|
+
export function emotionPose(name, intensity = 1) {
|
|
51
|
+
const src = EMOTIONS[name] || EMOTIONS.neutral;
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const k in src) out[k] = src[k] * intensity;
|
|
54
|
+
return out;
|
|
55
|
+
}
|
package/src/face-core.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// face-core — the part of a face module that is not the face.
|
|
3
|
+
//
|
|
4
|
+
// Three avatars were built independently before this module existed, and their
|
|
5
|
+
// renderers converged on the same shape: an element table, a memoized attribute
|
|
6
|
+
// writer, and an apply() that runs torso lean → shoulders → parallax → eyes →
|
|
7
|
+
// brows → mouth → teeth → tongue. The geometry differs per character; the
|
|
8
|
+
// plumbing and the pose mechanics do not. This module absorbs the proven-
|
|
9
|
+
// identical parts, so a face module supplies art and feature geometry and gets
|
|
10
|
+
// the body mechanics for free.
|
|
11
|
+
//
|
|
12
|
+
// What lives here is exactly what those faces shared line-for-line, or differed
|
|
13
|
+
// in only by a named scalar. Anything a character has an opinion about — what
|
|
14
|
+
// an eye is, how a mouth is drawn — stays in the face module. Two of the three
|
|
15
|
+
// have since been retired; what they proved about the seam is why this file
|
|
16
|
+
// looks the way it does, and helpers only they used went with them.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
import { clamp } from './params.js';
|
|
20
|
+
|
|
21
|
+
/** Attribute-value number formatter: 2 decimal places, no trailing zeros. */
|
|
22
|
+
export const f = (n) => (Math.round(n * 100) / 100).toString();
|
|
23
|
+
|
|
24
|
+
// Constants every rig agreed on. Named here so a new avatar inherits the
|
|
25
|
+
// agreement instead of re-deriving it.
|
|
26
|
+
export const LEAN_SCALE = 0.055; // scale gain at torsoLean = 1
|
|
27
|
+
export const ROLL_TORSO = 1.5; // degrees of roll the torso follows…
|
|
28
|
+
export const ROLL_HEAD = 5.5; // …versus what the head itself takes.
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mount the static markup and return the per-instance tools: the svg root, an
|
|
32
|
+
* id-scoped selector for building the element table, and the memoized `set`.
|
|
33
|
+
* SVG attribute setting is the hot path; `set` skips redundant DOM writes.
|
|
34
|
+
*/
|
|
35
|
+
export function createFaceShell(mount, id, markupHtml) {
|
|
36
|
+
mount.innerHTML = markupHtml;
|
|
37
|
+
const svg = mount.querySelector(`#${id}`);
|
|
38
|
+
const $ = (n) => svg.querySelector(`#${id}-${n}`);
|
|
39
|
+
const prev = new Map();
|
|
40
|
+
const set = (node, attr, val) => {
|
|
41
|
+
const k = node.id + attr;
|
|
42
|
+
if (prev.get(k) === val) return;
|
|
43
|
+
prev.set(k, val);
|
|
44
|
+
node.setAttribute(attr, val);
|
|
45
|
+
};
|
|
46
|
+
return { svg, $, set };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The one return shape every face honours: { svg, apply, theme, destroy }. */
|
|
50
|
+
export function faceApi(mount, svg, apply, theme) {
|
|
51
|
+
return { svg, apply, theme, destroy: () => { mount.innerHTML = ''; } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Blocks A–C of every apply(): torso lean, shoulders, and the layer parallax
|
|
56
|
+
* loop. `spec` is a module-level constant in the face module:
|
|
57
|
+
*
|
|
58
|
+
* leanTravel px of downward travel at torsoLean = 1
|
|
59
|
+
* leanPivot {x, y} the scale pivot — behind the head, not the frame base:
|
|
60
|
+
* scaling about the bottom makes the head rise as it grows,
|
|
61
|
+
* which reads as standing up instead of leaning in
|
|
62
|
+
* shrugLift px of shoulder lift at shrug = 1
|
|
63
|
+
* shrugTiltDeg degrees of one-sided-shrug rotation at tilt = 1 — a per-rig
|
|
64
|
+
* judgement about how much the collar can cover, not taste
|
|
65
|
+
* shrugPivot {x, y} the sternum
|
|
66
|
+
* yawPx px of head travel at headYaw = 1
|
|
67
|
+
* pitchPx px at headPitch = 1
|
|
68
|
+
* pivot {x, y} roll pivot — the base of the neck, not the chin:
|
|
69
|
+
* rotating about the chin swings the whole cranium sideways
|
|
70
|
+
* and reads as a puppet on a stick
|
|
71
|
+
* breathSwell fractional scale of the torso layers at breath = 1, about
|
|
72
|
+
* `swellPivot`. Required. It replaced a rigid vertical bob of
|
|
73
|
+
* the whole shirt, which moved the hem — and the hem is the one
|
|
74
|
+
* part of a seated torso that does not move, so the result read
|
|
75
|
+
* as the figure being nudged up and down rather than as breath.
|
|
76
|
+
* A scale about the hem raises the shoulder line and widens the
|
|
77
|
+
* chest, which is what an inbreath does and is the only breath
|
|
78
|
+
* cue a head-and-shoulders crop can actually show.
|
|
79
|
+
* swellPivot {x, y} the hem — bottom of the frame, on the midline
|
|
80
|
+
* turnPx px of lateral trunk travel at torsoTurn = 1 (0 if the rig
|
|
81
|
+
* does not declare it)
|
|
82
|
+
* layers draw-order list of layer names in the element table
|
|
83
|
+
* parallax {layer: multiplier} — follows the art, not a standard
|
|
84
|
+
* torsoLayers the subset of `layers` that moves at torso speed
|
|
85
|
+
* units the rig's linear scale factor relative to the numbers above
|
|
86
|
+
* (1 where travels are already in the rig's own units). Kept as
|
|
87
|
+
* a separate factor, applied last, so a rig built by scaling
|
|
88
|
+
* another's numbers states that lineage — and so evaluation
|
|
89
|
+
* order matches code that wrote `travel * 9 * S` longhand.
|
|
90
|
+
* Degrees never take it: degrees are degrees at any scale.
|
|
91
|
+
*
|
|
92
|
+
* In a webcam frame a lean is read almost entirely as a change of scale, so
|
|
93
|
+
* that is how it is drawn.
|
|
94
|
+
*/
|
|
95
|
+
export function poseTransforms(p, set, el, spec) {
|
|
96
|
+
const u = spec.units;
|
|
97
|
+
const lean = p.torsoLean;
|
|
98
|
+
const leanT = lean
|
|
99
|
+
? `translate(0 ${f(lean * spec.leanTravel * u)}) `
|
|
100
|
+
+ `translate(${f(spec.leanPivot.x)} ${f(spec.leanPivot.y)}) `
|
|
101
|
+
+ `scale(${f(1 + lean * LEAN_SCALE)}) `
|
|
102
|
+
+ `translate(${f(-spec.leanPivot.x)} ${f(-spec.leanPivot.y)}) `
|
|
103
|
+
: '';
|
|
104
|
+
|
|
105
|
+
// One shoulder cannot rise without the other when the shirt is a single
|
|
106
|
+
// path. A small rotation about the sternum is what a one-sided shrug looks
|
|
107
|
+
// like anyway, and it needs no new geometry.
|
|
108
|
+
const shrug = (p.shoulderL + p.shoulderR) * 0.5;
|
|
109
|
+
const tilt = (p.shoulderR - p.shoulderL) * 0.5;
|
|
110
|
+
|
|
111
|
+
// Breath as chest expansion. The swell scales the torso layers about the
|
|
112
|
+
// hem, so the shoulder line rises and the chest widens while the bottom of
|
|
113
|
+
// the shirt stays put. The head then has to ride whatever the shoulders did
|
|
114
|
+
// or the neck telescopes — and that lift is not a tuned constant, it is
|
|
115
|
+
// arithmetic: the swell's vertical displacement at the neck pivot. One
|
|
116
|
+
// number to author, and the two layers cannot drift out of agreement.
|
|
117
|
+
const swell = spec.breathSwell ? p.breath * spec.breathSwell : 0;
|
|
118
|
+
const neckLift = swell ? swell * (spec.swellPivot.y - spec.pivot.y) : 0;
|
|
119
|
+
const swellT = swell
|
|
120
|
+
? `translate(${f(spec.swellPivot.x)} ${f(spec.swellPivot.y)}) `
|
|
121
|
+
+ `scale(${f(1 + swell)}) `
|
|
122
|
+
+ `translate(${f(-spec.swellPivot.x)} ${f(-spec.swellPivot.y)}) `
|
|
123
|
+
: '';
|
|
124
|
+
|
|
125
|
+
const torsoT = `translate(${f(p.torsoTurn * (spec.turnPx || 0) * u)} ${f(-shrug * spec.shrugLift * u)}) `
|
|
126
|
+
+ `rotate(${f(-tilt * spec.shrugTiltDeg)} ${f(spec.shrugPivot.x)} ${f(spec.shrugPivot.y)}) `
|
|
127
|
+
+ swellT;
|
|
128
|
+
|
|
129
|
+
const yx = p.headYaw * spec.yawPx;
|
|
130
|
+
const py = p.headPitch * spec.pitchPx;
|
|
131
|
+
for (const key of spec.layers) {
|
|
132
|
+
const k = spec.parallax[key];
|
|
133
|
+
const torso = spec.torsoLayers.includes(key);
|
|
134
|
+
const roll = torso ? p.headRoll * ROLL_TORSO : p.headRoll * ROLL_HEAD;
|
|
135
|
+
// Torso layers get their breath from the swell inside torsoT; head layers
|
|
136
|
+
// get the matching lift, which is derived from the swell rather than tuned
|
|
137
|
+
// separately — the neck rides on the chest it sits on.
|
|
138
|
+
const bob = torso ? 0 : -neckLift;
|
|
139
|
+
set(el[key], 'transform',
|
|
140
|
+
leanT + (torso ? torsoT : '')
|
|
141
|
+
+ `translate(${f(yx * k)} ${f(py * k + bob)}) rotate(${f(roll)} ${f(spec.pivot.x)} ${f(spec.pivot.y)})`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Upper-and-lower teeth pair (peep, wren). Lower teeth appear only once
|
|
147
|
+
* the mouth is genuinely open: below that the lower lip is over them and
|
|
148
|
+
* drawing them turns every mid-open viseme into a grin; above it, their
|
|
149
|
+
* absence is what made viseme D read as a cave.
|
|
150
|
+
*/
|
|
151
|
+
export function pairedTeeth(p, set, el, teethPath, m) {
|
|
152
|
+
set(el.teeth, 'd', teethPath(m, clamp(p.teethUpper), false));
|
|
153
|
+
set(el.teethLo, 'd', teethPath(m, clamp(p.teethUpper) * clamp((m.open - 0.45) / 0.4), true));
|
|
154
|
+
}
|