@voqalize/avatar 0.4.3 → 0.5.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.
- package/README.md +65 -154
- package/dist/index.d.ts +37 -26
- package/dist/index.js +48 -25
- package/dist/pin.d.ts +2 -0
- package/dist/pin.js +3 -0
- package/dist/react.d.ts +21 -6
- package/dist/react.js +24 -6
- package/package.json +8 -67
- package/LICENSE-APACHE-2.0 +0 -201
- package/LICENSE-CC-BY-4.0 +0 -396
- package/assets/README.md +0 -60
- 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/Avatar.tsx +0 -57
- package/client/AvatarClient.ts +0 -818
- package/client/createAvatar.ts +0 -177
- package/client/index.ts +0 -44
- package/client/internal.ts +0 -132
- package/client/playout.ts +0 -95
- package/client/react.ts +0 -13
- package/client/supports.ts +0 -27
- package/client/three/assets.ts +0 -26
- package/client/three/budgets.ts +0 -27
- package/client/three/character-rig.ts +0 -1713
- package/client/three/createCharacter.ts +0 -109
- package/client/three/holds.ts +0 -69
- package/client/three/internal.ts +0 -55
- package/client/three/motion-limits.json +0 -124
- package/client/three/sequences.ts +0 -281
- package/client/three/tanvi-asset.ts +0 -6
- package/client/three/tanvi.ts +0 -30
- package/client/three/tanya-asset.ts +0 -6
- package/client/three/tanya.ts +0 -29
- package/client/three/tara-asset.ts +0 -25
- package/client/three/tara.ts +0 -36
- package/client/three/tess-asset.ts +0 -6
- package/client/three/tess.ts +0 -30
- package/client/three/tushar-asset.ts +0 -6
- package/client/three/tushar.ts +0 -28
- package/client/types.ts +0 -235
- package/client/useAvatar.ts +0 -74
- package/dist/Avatar.d.ts +0 -44
- package/dist/Avatar.d.ts.map +0 -1
- package/dist/Avatar.js +0 -7
- package/dist/Avatar.js.map +0 -1
- package/dist/AvatarClient.d.ts +0 -274
- package/dist/AvatarClient.d.ts.map +0 -1
- package/dist/AvatarClient.js +0 -712
- package/dist/AvatarClient.js.map +0 -1
- package/dist/createAvatar.d.ts +0 -135
- package/dist/createAvatar.d.ts.map +0 -1
- package/dist/createAvatar.js +0 -75
- package/dist/createAvatar.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/internal.d.ts +0 -34
- package/dist/internal.d.ts.map +0 -1
- package/dist/internal.js +0 -56
- package/dist/internal.js.map +0 -1
- package/dist/playout.d.ts +0 -42
- package/dist/playout.d.ts.map +0 -1
- package/dist/playout.js +0 -85
- package/dist/playout.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/react.js.map +0 -1
- package/dist/supports.d.ts +0 -19
- package/dist/supports.d.ts.map +0 -1
- package/dist/supports.js +0 -25
- package/dist/supports.js.map +0 -1
- package/dist/three/assets.d.ts +0 -10
- package/dist/three/assets.d.ts.map +0 -1
- package/dist/three/assets.js +0 -23
- package/dist/three/assets.js.map +0 -1
- package/dist/three/budgets.d.ts +0 -17
- package/dist/three/budgets.d.ts.map +0 -1
- package/dist/three/budgets.js +0 -21
- package/dist/three/budgets.js.map +0 -1
- package/dist/three/character-rig.d.ts +0 -366
- package/dist/three/character-rig.d.ts.map +0 -1
- package/dist/three/character-rig.js +0 -1611
- package/dist/three/character-rig.js.map +0 -1
- package/dist/three/createCharacter.d.ts +0 -60
- package/dist/three/createCharacter.d.ts.map +0 -1
- package/dist/three/createCharacter.js +0 -84
- package/dist/three/createCharacter.js.map +0 -1
- package/dist/three/holds.d.ts +0 -38
- package/dist/three/holds.d.ts.map +0 -1
- package/dist/three/holds.js +0 -61
- package/dist/three/holds.js.map +0 -1
- package/dist/three/internal.d.ts +0 -52
- package/dist/three/internal.d.ts.map +0 -1
- package/dist/three/internal.js +0 -49
- package/dist/three/internal.js.map +0 -1
- package/dist/three/motion-limits.json +0 -124
- package/dist/three/sequences.d.ts +0 -71
- package/dist/three/sequences.d.ts.map +0 -1
- package/dist/three/sequences.js +0 -262
- package/dist/three/sequences.js.map +0 -1
- package/dist/three/tanvi-asset.d.ts +0 -7
- package/dist/three/tanvi-asset.d.ts.map +0 -1
- package/dist/three/tanvi-asset.js +0 -7
- package/dist/three/tanvi-asset.js.map +0 -1
- package/dist/three/tanvi.d.ts +0 -24
- package/dist/three/tanvi.d.ts.map +0 -1
- package/dist/three/tanvi.js +0 -24
- package/dist/three/tanvi.js.map +0 -1
- package/dist/three/tanya-asset.d.ts +0 -7
- package/dist/three/tanya-asset.d.ts.map +0 -1
- package/dist/three/tanya-asset.js +0 -7
- package/dist/three/tanya-asset.js.map +0 -1
- package/dist/three/tanya.d.ts +0 -23
- package/dist/three/tanya.d.ts.map +0 -1
- package/dist/three/tanya.js +0 -23
- package/dist/three/tanya.js.map +0 -1
- package/dist/three/tara-asset.d.ts +0 -26
- package/dist/three/tara-asset.d.ts.map +0 -1
- package/dist/three/tara-asset.js +0 -26
- package/dist/three/tara-asset.js.map +0 -1
- package/dist/three/tara.d.ts +0 -30
- package/dist/three/tara.d.ts.map +0 -1
- package/dist/three/tara.js +0 -30
- package/dist/three/tara.js.map +0 -1
- package/dist/three/tess-asset.d.ts +0 -7
- package/dist/three/tess-asset.d.ts.map +0 -1
- package/dist/three/tess-asset.js +0 -7
- package/dist/three/tess-asset.js.map +0 -1
- package/dist/three/tess.d.ts +0 -24
- package/dist/three/tess.d.ts.map +0 -1
- package/dist/three/tess.js +0 -24
- package/dist/three/tess.js.map +0 -1
- package/dist/three/tushar-asset.d.ts +0 -7
- package/dist/three/tushar-asset.d.ts.map +0 -1
- package/dist/three/tushar-asset.js +0 -7
- package/dist/three/tushar-asset.js.map +0 -1
- package/dist/three/tushar.d.ts +0 -22
- package/dist/three/tushar.d.ts.map +0 -1
- package/dist/three/tushar.js +0 -22
- package/dist/three/tushar.js.map +0 -1
- package/dist/types.d.ts +0 -149
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -140
- package/dist/types.js.map +0 -1
- package/dist/useAvatar.d.ts +0 -39
- package/dist/useAvatar.d.ts.map +0 -1
- package/dist/useAvatar.js +0 -35
- package/dist/useAvatar.js.map +0 -1
- package/src/avatar.d.ts +0 -436
- package/src/avatar.js +0 -1370
- package/src/behavior.d.ts +0 -42
- package/src/behavior.js +0 -114
- package/src/camera.js +0 -29
- package/src/clips.js +0 -155
- package/src/conformance.js +0 -119
- package/src/emotions.js +0 -58
- package/src/face-core.js +0 -180
- package/src/face-myna.d.ts +0 -7
- package/src/face-myna.js +0 -786
- package/src/face-peep-control-plane.js +0 -167
- package/src/face-peep.d.ts +0 -7
- package/src/face-peep.js +0 -925
- package/src/face-wren.d.ts +0 -7
- package/src/face-wren.js +0 -468
- package/src/faces.d.ts +0 -12
- package/src/faces.js +0 -53
- package/src/gaze.js +0 -504
- package/src/hand.js +0 -709
- package/src/head.js +0 -134
- package/src/idle.js +0 -668
- package/src/interjections.js +0 -747
- package/src/line-art.js +0 -137
- package/src/params.js +0 -180
- package/src/perform.js +0 -107
- package/src/prosody.js +0 -741
- package/src/rig.d.ts +0 -31
- package/src/rig.js +0 -32
- package/src/speech-timing.js +0 -32
- package/src/visemes.js +0 -410
package/src/rig.d.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** The mixer→rig pose model, internal to the SVG renderer; see docs/internal-rig.md. */
|
|
2
|
-
export type RigChannel = string;
|
|
3
|
-
export type RigPose = Readonly<Record<RigChannel, number>>;
|
|
4
|
-
|
|
5
|
-
export interface HandFrame {
|
|
6
|
-
gesture: "greet" | "farewell" | "approve" | "wait";
|
|
7
|
-
progress: number;
|
|
8
|
-
side: "left" | "right";
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export interface AvatarFrame {
|
|
12
|
-
pose: RigPose;
|
|
13
|
-
hand?: HandFrame;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface AvatarRig {
|
|
17
|
-
apply(frame: AvatarFrame): void;
|
|
18
|
-
destroy(): void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export type AvatarRigFactory = (mount: HTMLElement, options?: unknown) => AvatarRig;
|
|
22
|
-
|
|
23
|
-
export const HAND_GESTURE_NAMES: ReadonlyArray<HandFrame["gesture"]>;
|
|
24
|
-
export function avatarFrame(pose: RigPose, hand?: HandFrame): AvatarFrame;
|
|
25
|
-
/** The bundled SVG renderer as a bare rig: what `createAvatar` builds when no
|
|
26
|
-
* `rig` option is passed, and what an instrument mounts when it wants a face
|
|
27
|
-
* it can pose directly. */
|
|
28
|
-
export function createSvgRig(
|
|
29
|
-
face: ReturnType<import("./avatar.js").FaceFactory>,
|
|
30
|
-
hand?: unknown,
|
|
31
|
-
): AvatarRig;
|
package/src/rig.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Renderer-agnostic frame types expressed as runtime helpers.
|
|
3
|
-
*
|
|
4
|
-
* `createSvgRig()` is a migration adapter for the existing SVG faces. It lets
|
|
5
|
-
* the mixer submit one AvatarFrame per tick today while new renderers can
|
|
6
|
-
* implement the same small `{ apply(frame), destroy() }` contract directly.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
export const HAND_GESTURE_NAMES = Object.freeze(['greet', 'farewell', 'approve', 'wait']);
|
|
10
|
-
|
|
11
|
-
/** @param {Record<string, number>} pose @param {object|undefined} hand */
|
|
12
|
-
export function avatarFrame(pose, hand) {
|
|
13
|
-
return hand ? { pose, hand } : { pose };
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Adapt an existing SVG face plus its first-class hand renderer to AvatarRig.
|
|
18
|
-
* `face` and `hand` retain their own SVG-private geometry; callers only submit
|
|
19
|
-
* semantic frames.
|
|
20
|
-
*/
|
|
21
|
-
export function createSvgRig(face, hand = null) {
|
|
22
|
-
return {
|
|
23
|
-
apply(frame) {
|
|
24
|
-
face.apply(frame.pose);
|
|
25
|
-
if (hand && typeof hand.applyFrame === 'function') hand.applyFrame(frame.hand);
|
|
26
|
-
},
|
|
27
|
-
destroy() {
|
|
28
|
-
if (hand) hand.destroy();
|
|
29
|
-
face.destroy();
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
}
|
package/src/speech-timing.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Timing policy for the mouth track.
|
|
3
|
-
*
|
|
4
|
-
* Keep perceptual cleanup, cue presentation, and mouth response constants
|
|
5
|
-
* named by their job. Network delivery is deliberately absent: it cannot be
|
|
6
|
-
* corrected by shifting every cue on the utterance clock.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** Cues shorter than this do not survive visual normalization. */
|
|
10
|
-
export const MIN_VISIBLE_CUE_MS = 30;
|
|
11
|
-
|
|
12
|
-
/** Cue timestamps render verbatim; the clock's epoch is supplied by the caller. */
|
|
13
|
-
export const CUE_TRACK_LEAD_MS = 0;
|
|
14
|
-
|
|
15
|
-
/** Extra time after a final silence cue before a general speech track ends. */
|
|
16
|
-
export const SPEECH_TRACK_TAIL_MS = 120;
|
|
17
|
-
|
|
18
|
-
/** Shorter completion tail for self-contained spoken interjection clips. */
|
|
19
|
-
export const INTERJECTION_TRACK_TAIL_MS = 60;
|
|
20
|
-
|
|
21
|
-
/** First-order response constants: lips settle before the jaw does. */
|
|
22
|
-
export const MOUTH_RESPONSE_TAU_S = 0.042;
|
|
23
|
-
export const JAW_RESPONSE_TAU_S = 0.07;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* How far ahead of a rounded vowel the lips begin to round. Anticipatory
|
|
27
|
-
* rounding is among the longest-range coarticulation there is — 100–300 ms
|
|
28
|
-
* before an audible [y]/[u] (Schwartz & Savariaux 2014) — and this is the
|
|
29
|
-
* bottom of that range, because running speech compresses it and a pucker
|
|
30
|
-
* spread across a whole preceding word reads as a pout.
|
|
31
|
-
*/
|
|
32
|
-
export const ROUND_LEAD_MS = 100;
|
package/src/visemes.js
DELETED
|
@@ -1,410 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Visemes — the wire protocol between the server and the mouth.
|
|
3
|
-
*
|
|
4
|
-
* We use the Rhubarb Lip Sync alphabet (A–H, plus X for silence), which is a
|
|
5
|
-
* condensation of the Preston Blair mouth set. Nine shapes is plenty for a
|
|
6
|
-
* stylized 2D face, and it gives the server side an obvious open-source
|
|
7
|
-
* reference implementation to target.
|
|
8
|
-
*
|
|
9
|
-
* A closed lips P B M (also the resting closure)
|
|
10
|
-
* B slightly open, K S T D, and consonantal EE
|
|
11
|
-
* teeth together
|
|
12
|
-
* C open EH AE
|
|
13
|
-
* D wide open AA
|
|
14
|
-
* E slightly rounded AO ER
|
|
15
|
-
* F puckered UW OW W
|
|
16
|
-
* G lip to upper teeth F V
|
|
17
|
-
* H tongue up L
|
|
18
|
-
* X idle / silence
|
|
19
|
-
*
|
|
20
|
-
* A cue is `{ t, v, i?, p? }` — millisecond offset into the utterance, the
|
|
21
|
-
* letter, an optional 0..1 intensity (loudness): the same viseme shouted and
|
|
22
|
-
* murmured should not look identical, and an optional phone label. The backend
|
|
23
|
-
* measures `i` from the RMS under each cue, but only on the leg that has audio
|
|
24
|
-
* to measure — a predicted cue describes speech nobody has generated yet, so it
|
|
25
|
-
* arrives without one and is read here as 1.
|
|
26
|
-
*
|
|
27
|
-
* `p` is the phone being articulated under the letter, and the nine letters are
|
|
28
|
-
* a lossy projection of it: `B` above lists four phone families and absorbs
|
|
29
|
-
* fourteen more. Nothing in this file reads it — `VISEME_SHAPES` is the mouth
|
|
30
|
-
* every face owes the server and it is keyed by letter. It rides through
|
|
31
|
-
* normalization and out of `sample()` so that a rig with a finer mouth than
|
|
32
|
-
* nine shapes can use it without the server sending a second track, and so that
|
|
33
|
-
* a rig without one is unaffected.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import {
|
|
37
|
-
CUE_TRACK_LEAD_MS,
|
|
38
|
-
MIN_VISIBLE_CUE_MS,
|
|
39
|
-
ROUND_LEAD_MS,
|
|
40
|
-
SPEECH_TRACK_TAIL_MS,
|
|
41
|
-
} from './speech-timing.js';
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* The mouth every face owes the server, at the loudest a cue can say (`i` 1).
|
|
45
|
-
*
|
|
46
|
-
* **The apertures are measured speech, not taste.** The anchor is the open
|
|
47
|
-
* vowel: Huber & Chandrasekaran 2006 (JSLHR 49:1368, Table 2; Optotrak, 30
|
|
48
|
-
* adults, a sentence read aloud) open "Bobby"'s [ɑ] by 7.84 mm of lower lip
|
|
49
|
-
* over the jaw, 7.32 of jaw and 2.49 of upper lip — 17.65 mm of inter-lip
|
|
50
|
-
* opening, pooled over one comfortable and three loud conditions. Their Table 1
|
|
51
|
-
* puts comfortable at 0.83 of that pool (4.86 mm against a loud 6.06–6.35), so
|
|
52
|
-
* ordinary speech opens an [ɑ] **14.6 mm**, and `D` is sized so that it does at
|
|
53
|
-
* `i` 0.5 — which is where PyGato puts an engine's ordinary speaking level
|
|
54
|
-
* (`pygato.lipsync.intensity`). The same paper is the check on the parts this
|
|
55
|
-
* does not set: the jaw's share comes out at 6.6 mm against their ~6.1, and the
|
|
56
|
-
* upper lip at 1.7 mm against ~2.1 — the upper lip is the stable one, a third
|
|
57
|
-
* of the lower's travel, and the rig already had it so.
|
|
58
|
-
*
|
|
59
|
-
* Millimetres are face units at **176.7 mm** a unit (chin to hairline): Lin &
|
|
60
|
-
* Chen 2017 (PLoS ONE 12:e0188638) give women's menton–sellion as 107.8 mm,
|
|
61
|
-
* and sellion sits at 0.61 of the unit on every character here. Their mouth
|
|
62
|
-
* width (44.9 mm) would say 157 — these faces are drawn with slightly wide
|
|
63
|
-
* mouths, and the vertical measure is the one an aperture is.
|
|
64
|
-
*
|
|
65
|
-
* No source reachable gave per-vowel apertures in millimetres, so the other
|
|
66
|
-
* letters keep the ratios to `D` they were authored with and are scaled by the
|
|
67
|
-
* same factor (0.617 of their excursion from rest). Upper-teeth show is part
|
|
68
|
-
* of the opening and scales with it — except on `G`, where teeth on the lip
|
|
69
|
-
* *is* the shape. A mouth this size is what "the mouth is moving a lot" was
|
|
70
|
-
* asking for: the table it replaced opened an ordinary [ɑ] 26 mm, as far as the
|
|
71
|
-
* loud speech in that study opens a shout.
|
|
72
|
-
*/
|
|
73
|
-
export const VISEME_SHAPES = {
|
|
74
|
-
X: { mouthOpen: 0.02, mouthWidth: 0.42, mouthRound: 0.10, mouthPress: 0.15, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
75
|
-
A: { mouthOpen: 0.00, mouthWidth: 0.40, mouthRound: 0.18, mouthPress: 0.55, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
76
|
-
B: { mouthOpen: 0.11, mouthWidth: 0.54, mouthRound: 0.05, mouthPress: 0.10, mouthTuck: 0, teethUpper: 0.46, tongue: 0.0 },
|
|
77
|
-
C: { mouthOpen: 0.29, mouthWidth: 0.58, mouthRound: 0.05, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.28, tongue: 0.0 },
|
|
78
|
-
D: { mouthOpen: 0.53, mouthWidth: 0.52, mouthRound: 0.02, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.15, tongue: 0.15 },
|
|
79
|
-
E: { mouthOpen: 0.22, mouthWidth: 0.28, mouthRound: 0.55, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.09, tongue: 0.0 },
|
|
80
|
-
F: { mouthOpen: 0.14, mouthWidth: 0.10, mouthRound: 0.95, mouthPress: 0.10, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
81
|
-
G: { mouthOpen: 0.13, mouthWidth: 0.46, mouthRound: 0.10, mouthPress: 0.40, mouthTuck: 1.00, teethUpper: 1.00, tongue: 0.0 },
|
|
82
|
-
H: { mouthOpen: 0.25, mouthWidth: 0.48, mouthRound: 0.05, mouthPress: 0.00, mouthTuck: 0, teethUpper: 0.22, tongue: 0.90 },
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export const VISEME_LETTERS = Object.keys(VISEME_SHAPES);
|
|
86
|
-
|
|
87
|
-
/** Resting mouth used when nothing is speaking. */
|
|
88
|
-
export const SILENT = 'X';
|
|
89
|
-
|
|
90
|
-
/** The shapes that are a contact, not an aperture: lips together, lip to teeth. */
|
|
91
|
-
const CLOSURES = new Set(['A', 'G']);
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* How far the mandible falls for a given aperture — `jaw` is "follows
|
|
95
|
-
* mouthOpen but slower" (`params.js`) and this is the slower.
|
|
96
|
-
*
|
|
97
|
-
* It is exported because it is not a preference: **every amplitude behind the
|
|
98
|
-
* lips was calibrated at this ratio**, so a driver that picks its own moves the
|
|
99
|
-
* mandible relative to the lip that hides it. The lower arch is the surface
|
|
100
|
-
* that shows it, because it rides `jaw` alone while the lip peels off it on
|
|
101
|
-
* `mouthOpen`: what is seen of the arch is the difference between two much
|
|
102
|
-
* larger travels (`avatar-3d/scripts/morphs.ARCH_DROP`), so it goes to nothing
|
|
103
|
-
* at a ratio only a little above this one. A face driven at 1:1 opens wide and
|
|
104
|
-
* shows no lower teeth at any aperture — the arch descends as fast as the lip
|
|
105
|
-
* uncovering it, which is what mocap did until it read this number instead of
|
|
106
|
-
* ARKit's `jawOpen` directly.
|
|
107
|
-
*/
|
|
108
|
-
export const JAW_OF_OPEN = 0.7;
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* How much of a shape's excursion a cue's loudness buys, as `k = LOUDNESS_FLOOR
|
|
112
|
-
* + (1 - LOUDNESS_FLOOR) * i`.
|
|
113
|
-
*
|
|
114
|
-
* Louder speech is the same movement made bigger, and not by much: +10 dB
|
|
115
|
-
* opens the lips and jaw 1.28x as far (Huber & Chandrasekaran 2006, Table 1:
|
|
116
|
-
* 4.86 mm comfortable, 6.06–6.35 loud; Schulman 1989 calls it "amplification
|
|
117
|
-
* of normal movement patterns"). `i` spans 16 dB with ordinary speech at 0.5,
|
|
118
|
-
* so 0.5 either side is 8 dB and 1.22x — k runs 0.776 : 1 : 1.224 across it,
|
|
119
|
-
* which normalised to the loud end is this floor. The quiet side is the loud
|
|
120
|
-
* side's slope extended; nothing measured it.
|
|
121
|
-
*/
|
|
122
|
-
const LOUDNESS_FLOOR = 0.634;
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Scale a shape by loudness. Only the "effortful" channels scale — a quiet 'D'
|
|
126
|
-
* is a small D, not a different shape.
|
|
127
|
-
*/
|
|
128
|
-
export function shapeFor(letter, intensity = 1) {
|
|
129
|
-
const base = VISEME_SHAPES[letter] || VISEME_SHAPES[SILENT];
|
|
130
|
-
const rest = VISEME_SHAPES.X;
|
|
131
|
-
const k = LOUDNESS_FLOOR + (1 - LOUDNESS_FLOOR) * Math.max(0, Math.min(1, intensity));
|
|
132
|
-
return {
|
|
133
|
-
mouthOpen: rest.mouthOpen + (base.mouthOpen - rest.mouthOpen) * k,
|
|
134
|
-
mouthWidth: rest.mouthWidth + (base.mouthWidth - rest.mouthWidth) * k,
|
|
135
|
-
mouthRound: base.mouthRound,
|
|
136
|
-
mouthPress: base.mouthPress,
|
|
137
|
-
mouthTuck: base.mouthTuck,
|
|
138
|
-
teethUpper: base.teethUpper * k,
|
|
139
|
-
tongue: base.tongue,
|
|
140
|
-
jaw: (rest.mouthOpen + (base.mouthOpen - rest.mouthOpen) * k) * JAW_OF_OPEN,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ---------------------------------------------------------------------------
|
|
145
|
-
// Cue track hygiene
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Group a time-ordered cue list into runs that share a mouth shape.
|
|
150
|
-
*
|
|
151
|
-
* A backend emits one cue per intersection of its shape and phone timelines, so
|
|
152
|
-
* a held shape arrives as several cues differing only in `p`. Grouping them is
|
|
153
|
-
* what lets the visibility rules stay rules about shapes.
|
|
154
|
-
*/
|
|
155
|
-
function shapeRuns(cues) {
|
|
156
|
-
const runs = [];
|
|
157
|
-
for (const c of cues) {
|
|
158
|
-
const v = VISEME_SHAPES[c.v] ? c.v : SILENT;
|
|
159
|
-
const clean = { t: c.t, v, i: c.i == null ? 1 : c.i, p: c.p ?? null };
|
|
160
|
-
if (runs.length && runs[runs.length - 1][0].v === v) runs[runs.length - 1].push(clean);
|
|
161
|
-
else runs.push([clean]);
|
|
162
|
-
}
|
|
163
|
-
return runs;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Sort, merge consecutive duplicates, and drop sub-perceptual cues. Servers
|
|
168
|
-
* emit noisy tracks; this makes them watchable.
|
|
169
|
-
*
|
|
170
|
-
* **Every decision here is a decision about shapes**, taken over the run heads
|
|
171
|
-
* and nothing else. MIN_VISIBLE_CUE_MS and the closure swap are rules about what
|
|
172
|
-
* the face can be *seen* to do, and a phone transition inside a held shape is
|
|
173
|
-
* not something the face does at all — letting one participate would mean a
|
|
174
|
-
* phone changing 15 ms before a real shape change could swallow that change,
|
|
175
|
-
* which is a lipsync regression bought with a field this file does not read.
|
|
176
|
-
* Phone detail is re-attached afterwards, bounded by the mouth positions either
|
|
177
|
-
* side of it. Mirrors `normalize_cues` server-side.
|
|
178
|
-
*/
|
|
179
|
-
export function normalizeCues(cues) {
|
|
180
|
-
const runs = shapeRuns([...cues].sort((a, b) => a.t - b.t));
|
|
181
|
-
|
|
182
|
-
// Pass one: the shape track, by exactly the rule that predates phone detail.
|
|
183
|
-
// `kept` maps each surviving cue back to the run it speaks for, which the
|
|
184
|
-
// closure swap can change without moving the cue's timestamp.
|
|
185
|
-
const out = [];
|
|
186
|
-
const kept = [];
|
|
187
|
-
for (let index = 0; index < runs.length; index++) {
|
|
188
|
-
const head = runs[index][0];
|
|
189
|
-
const v = head.v;
|
|
190
|
-
const prev = out[out.length - 1];
|
|
191
|
-
if (prev && prev.v === v) continue; // merge repeats
|
|
192
|
-
if (prev && head.t - prev.t < MIN_VISIBLE_CUE_MS) {
|
|
193
|
-
// Too short to read. Keep whichever is more visually salient: a closure
|
|
194
|
-
// (A/G) carries more lip-reading information than a mid-open vowel.
|
|
195
|
-
if (CLOSURES.has(v)) {
|
|
196
|
-
// The short cue can sit between two identical closures (G → F → G).
|
|
197
|
-
// Its replacement would otherwise create a duplicate visible shape;
|
|
198
|
-
// preserving the first G is both the stable wire form and the face the
|
|
199
|
-
// viewer actually saw.
|
|
200
|
-
if (out.length > 1 && out[out.length - 2].v === v) { out.pop(); kept.pop(); }
|
|
201
|
-
// A winning closure replaces the preceding shape for the entire
|
|
202
|
-
// sub-perceptual interval. Preserve that cue's timestamp while taking
|
|
203
|
-
// the closure's intensity, matching the server-side wire normalizer.
|
|
204
|
-
else {
|
|
205
|
-
out[out.length - 1] = { ...head, t: prev.t, v };
|
|
206
|
-
kept[kept.length - 1] = index;
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
out.push({ t: head.t, v, i: head.i, p: head.p });
|
|
212
|
-
kept.push(index);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Pass two: the phone detail inside each surviving mouth position. Silence is
|
|
216
|
-
// never split — nothing is being articulated under a rest.
|
|
217
|
-
const result = [];
|
|
218
|
-
for (let slot = 0; slot < out.length; slot++) {
|
|
219
|
-
const cue = out[slot];
|
|
220
|
-
result.push(cue);
|
|
221
|
-
if (cue.v === SILENT) continue;
|
|
222
|
-
const limit = slot + 1 < out.length ? out[slot + 1].t : Infinity;
|
|
223
|
-
for (const sub of runs[kept[slot]].slice(1)) {
|
|
224
|
-
// Strictly after whatever was last emitted, not merely after the head: a
|
|
225
|
-
// splice joins two legs' tracks and both can name the same millisecond.
|
|
226
|
-
if (sub.t <= result[result.length - 1].t || sub.t >= limit) continue;
|
|
227
|
-
if (sub.p === result[result.length - 1].p) continue;
|
|
228
|
-
result.push({ t: sub.t, v: cue.v, i: sub.i, p: sub.p });
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Schedules a cue track against an utterance clock.
|
|
236
|
-
*
|
|
237
|
-
* An audio-owned clock (`audioEl.currentTime * 1000` or
|
|
238
|
-
* `AudioContext.currentTime`) is the strongest source when the caller owns
|
|
239
|
-
* playback. The Pipecat adapter cannot see browser device playout; it supplies
|
|
240
|
-
* elapsed time from `botStartedSpeaking`, Pipecat's output-lifecycle epoch.
|
|
241
|
-
*
|
|
242
|
-
* There is deliberately no renderer-wide lead. Network/media skew cannot be
|
|
243
|
-
* corrected by moving every cue, and the predicted backend leg owns its own
|
|
244
|
-
* explicit prediction cushion.
|
|
245
|
-
*/
|
|
246
|
-
export const LEAD_MS = CUE_TRACK_LEAD_MS;
|
|
247
|
-
|
|
248
|
-
export class VisemeTrack {
|
|
249
|
-
constructor() {
|
|
250
|
-
this.cues = [];
|
|
251
|
-
this.clock = null;
|
|
252
|
-
this.playing = false;
|
|
253
|
-
this._idx = 0;
|
|
254
|
-
this.onEnd = null;
|
|
255
|
-
this.tailMs = SPEECH_TRACK_TAIL_MS;
|
|
256
|
-
// Where the last sample() landed, for the layers that read the track's
|
|
257
|
-
// future (prosody.js looks ahead to the end of a pause).
|
|
258
|
-
this.now = 0;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
get index() { return this._idx; }
|
|
262
|
-
|
|
263
|
-
/** @param {() => number} clock returns elapsed ms of the audio being played */
|
|
264
|
-
start(cues, clock) {
|
|
265
|
-
this.cues = normalizeCues(cues);
|
|
266
|
-
this.clock = clock;
|
|
267
|
-
this._idx = 0;
|
|
268
|
-
this.playing = true;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Streaming top-up: append cues that arrive mid-utterance. */
|
|
272
|
-
push(cues) {
|
|
273
|
-
const merged = normalizeCues([...this.cues, ...cues]);
|
|
274
|
-
this.cues = merged;
|
|
275
|
-
// Re-seek rather than trusting the old index against a re-normalized array.
|
|
276
|
-
this._idx = 0;
|
|
277
|
-
// A sentence can arrive after the one before it has played out — a TTS
|
|
278
|
-
// whose next sentence lags its own audio does this routinely — and the
|
|
279
|
-
// track had stopped at that sentence's tail. Resume on the same clock, or
|
|
280
|
-
// the face holds still through audio the user can hear.
|
|
281
|
-
if (!this.playing && this.clock && merged.length) {
|
|
282
|
-
const last = merged[merged.length - 1];
|
|
283
|
-
this.playing = last.v !== SILENT || this.clock() + LEAD_MS <= last.t + this.tailMs;
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
stop() {
|
|
288
|
-
this.playing = false;
|
|
289
|
-
this.cues = [];
|
|
290
|
-
this.clock = null;
|
|
291
|
-
this._idx = 0;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* The shape to aim at now, which is not always the cue under the clock.
|
|
296
|
-
*
|
|
297
|
-
* Rounding starts before its sound, because it is the kind of gesture a
|
|
298
|
-
* viewer reads a word by (speech-timing.js has the measurements): `round`
|
|
299
|
-
* carries the pucker of a rounded vowel coming up within `ROUND_LEAD_MS`, for
|
|
300
|
-
* the mixer to hold against the current shape's own. It does not reach
|
|
301
|
-
* across a rest. A closure's early onset is not drawn here — the server
|
|
302
|
-
* already starts every closure cue ahead of its phone, and a second lead on
|
|
303
|
-
* top of that closed the lips a whole vowel early.
|
|
304
|
-
*
|
|
305
|
-
* @returns {{letter: string, intensity: number, phone: string | null, round: number} | null}
|
|
306
|
-
*/
|
|
307
|
-
sample() {
|
|
308
|
-
if (!this.playing || !this.cues.length || !this.clock) return null;
|
|
309
|
-
const now = this.clock() + LEAD_MS;
|
|
310
|
-
this.now = now;
|
|
311
|
-
|
|
312
|
-
// Cues are time-ordered and `now` is mostly monotonic, so this walk is O(1)
|
|
313
|
-
// amortized. Reset on seek-backward.
|
|
314
|
-
if (this._idx > 0 && this.cues[this._idx] && this.cues[this._idx].t > now) this._idx = 0;
|
|
315
|
-
while (this._idx + 1 < this.cues.length && this.cues[this._idx + 1].t <= now) this._idx++;
|
|
316
|
-
|
|
317
|
-
const last = this.cues[this.cues.length - 1];
|
|
318
|
-
if (now > last.t + this.tailMs && last.v === SILENT) {
|
|
319
|
-
this.playing = false;
|
|
320
|
-
if (this.onEnd) this.onEnd();
|
|
321
|
-
return null;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
const cue = this.cues[this._idx];
|
|
325
|
-
if (cue.t > now) return { letter: SILENT, intensity: 1, phone: null, round: 0 };
|
|
326
|
-
let round = 0;
|
|
327
|
-
for (let j = this._idx + 1; j < this.cues.length; j++) {
|
|
328
|
-
const ahead = this.cues[j];
|
|
329
|
-
if (ahead.v === SILENT || ahead.t - now > ROUND_LEAD_MS) break;
|
|
330
|
-
// Across consonants too: protrusion before a rounded vowel grows with the
|
|
331
|
-
// consonants in front of it (Noiray et al. 2011, JASA 129:340), so the
|
|
332
|
-
// lips of "sm-" in "smooth" are already on their way.
|
|
333
|
-
round = Math.max(round, VISEME_SHAPES[ahead.v].mouthRound);
|
|
334
|
-
}
|
|
335
|
-
return { letter: cue.v, intensity: cue.i == null ? 1 : cue.i, phone: cue.p ?? null, round };
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// ---------------------------------------------------------------------------
|
|
340
|
-
// Reference mappings for the server side
|
|
341
|
-
// ---------------------------------------------------------------------------
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* ARPAbet phoneme -> Rhubarb letter. This is the table to port server-side if
|
|
345
|
-
* you go the forced-alignment / G2P route (CMUdict, phonemizer, MFA).
|
|
346
|
-
*/
|
|
347
|
-
export const ARPABET_TO_VISEME = {
|
|
348
|
-
// closures
|
|
349
|
-
P: 'A', B: 'A', M: 'A',
|
|
350
|
-
// labiodental
|
|
351
|
-
F: 'G', V: 'G',
|
|
352
|
-
// rounded
|
|
353
|
-
W: 'F', UW: 'F', UH: 'F', OW: 'F', OY: 'F',
|
|
354
|
-
AO: 'E', ER: 'E', AXR: 'E', R: 'E',
|
|
355
|
-
// tongue-up
|
|
356
|
-
L: 'H',
|
|
357
|
-
// wide-open vowels
|
|
358
|
-
AA: 'D', AY: 'D', AW: 'D',
|
|
359
|
-
AE: 'C', AH: 'C', EH: 'C', EY: 'C', HH: 'C',
|
|
360
|
-
IH: 'B', IY: 'B', Y: 'B',
|
|
361
|
-
// alveolars / sibilants / the rest
|
|
362
|
-
T: 'B', D: 'B', S: 'B', Z: 'B', N: 'B', K: 'B', G: 'B', NG: 'B',
|
|
363
|
-
SH: 'B', ZH: 'B', CH: 'B', JH: 'B', TH: 'B', DH: 'B',
|
|
364
|
-
SIL: 'X', SP: 'X',
|
|
365
|
-
};
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* Azure Speech emits integer viseme IDs (0-21) on its `visemeReceived` event.
|
|
369
|
-
* This maps them straight onto our letters — the cheapest possible path to
|
|
370
|
-
* production-quality lipsync if you're already on Azure TTS.
|
|
371
|
-
*/
|
|
372
|
-
export const AZURE_VISEME_TO_LETTER = [
|
|
373
|
-
'X', 'C', 'D', 'E', 'C', 'E', 'B', 'F', 'F', 'D',
|
|
374
|
-
'E', 'D', 'C', 'E', 'H', 'B', 'B', 'B', 'G', 'B',
|
|
375
|
-
'B', 'A',
|
|
376
|
-
];
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* A crude grapheme-level guesser. NOT for production — it exists so the demo
|
|
380
|
-
* can preview arbitrary text without a TTS round-trip, and to make the shape of
|
|
381
|
-
* the mapping concrete. Real timing must come from the server.
|
|
382
|
-
*/
|
|
383
|
-
export function textToCues(text, { wpm = 165 } = {}) {
|
|
384
|
-
const msPerChar = 60000 / (wpm * 5.1);
|
|
385
|
-
const cues = [];
|
|
386
|
-
let t = 0;
|
|
387
|
-
const s = text.toLowerCase();
|
|
388
|
-
const push = (v, dur) => { cues.push({ t: Math.round(t), v }); t += dur; };
|
|
389
|
-
|
|
390
|
-
for (let i = 0; i < s.length; i++) {
|
|
391
|
-
const two = s.slice(i, i + 2);
|
|
392
|
-
const c = s[i];
|
|
393
|
-
if (two === 'th' || two === 'sh' || two === 'ch') { push('B', msPerChar * 1.6); i++; continue; }
|
|
394
|
-
if (two === 'oo' || two === 'ou' || two === 'ow') { push('F', msPerChar * 1.8); i++; continue; }
|
|
395
|
-
if (two === 'ee' || two === 'ea') { push('B', msPerChar * 1.7); i++; continue; }
|
|
396
|
-
if (/[pbm]/.test(c)) push('A', msPerChar * 1.1);
|
|
397
|
-
else if (/[fv]/.test(c)) push('G', msPerChar * 1.2);
|
|
398
|
-
else if (/[wu]/.test(c)) push('F', msPerChar * 1.4);
|
|
399
|
-
else if (/[or]/.test(c)) push('E', msPerChar * 1.4);
|
|
400
|
-
else if (c === 'l') push('H', msPerChar * 1.2);
|
|
401
|
-
else if (/[ai]/.test(c)) push('D', msPerChar * 1.5);
|
|
402
|
-
else if (/[e]/.test(c)) push('C', msPerChar * 1.4);
|
|
403
|
-
else if (/[a-z]/.test(c)) push('B', msPerChar);
|
|
404
|
-
else if (/[\s]/.test(c)) push('X', msPerChar * 0.9);
|
|
405
|
-
else if (/[,;:]/.test(c)) push('X', 180);
|
|
406
|
-
else if (/[.!?]/.test(c)) push('X', 320);
|
|
407
|
-
}
|
|
408
|
-
push('X', 0);
|
|
409
|
-
return cues;
|
|
410
|
-
}
|