@voqalize/avatar 0.4.4 → 0.5.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.
Files changed (180) hide show
  1. package/README.md +65 -154
  2. package/dist/index.d.ts +37 -26
  3. package/dist/index.js +48 -25
  4. package/dist/pin.d.ts +2 -0
  5. package/dist/pin.js +3 -0
  6. package/dist/react.d.ts +21 -6
  7. package/dist/react.js +24 -6
  8. package/package.json +8 -67
  9. package/LICENSE-APACHE-2.0 +0 -201
  10. package/LICENSE-CC-BY-4.0 +0 -396
  11. package/assets/README.md +0 -60
  12. package/assets/tanvi.glb +0 -0
  13. package/assets/tanya.glb +0 -0
  14. package/assets/tara.glb +0 -0
  15. package/assets/tess.glb +0 -0
  16. package/assets/tushar.glb +0 -0
  17. package/client/Avatar.tsx +0 -57
  18. package/client/AvatarClient.ts +0 -818
  19. package/client/createAvatar.ts +0 -177
  20. package/client/index.ts +0 -44
  21. package/client/internal.ts +0 -132
  22. package/client/playout.ts +0 -95
  23. package/client/react.ts +0 -13
  24. package/client/supports.ts +0 -27
  25. package/client/three/assets.ts +0 -26
  26. package/client/three/budgets.ts +0 -27
  27. package/client/three/character-rig.ts +0 -1746
  28. package/client/three/createCharacter.ts +0 -109
  29. package/client/three/holds.ts +0 -69
  30. package/client/three/internal.ts +0 -55
  31. package/client/three/motion-limits.json +0 -124
  32. package/client/three/sequences.ts +0 -281
  33. package/client/three/tanvi-asset.ts +0 -6
  34. package/client/three/tanvi.ts +0 -30
  35. package/client/three/tanya-asset.ts +0 -6
  36. package/client/three/tanya.ts +0 -29
  37. package/client/three/tara-asset.ts +0 -25
  38. package/client/three/tara.ts +0 -36
  39. package/client/three/tess-asset.ts +0 -6
  40. package/client/three/tess.ts +0 -30
  41. package/client/three/tushar-asset.ts +0 -6
  42. package/client/three/tushar.ts +0 -28
  43. package/client/types.ts +0 -235
  44. package/client/useAvatar.ts +0 -74
  45. package/dist/Avatar.d.ts +0 -44
  46. package/dist/Avatar.d.ts.map +0 -1
  47. package/dist/Avatar.js +0 -7
  48. package/dist/Avatar.js.map +0 -1
  49. package/dist/AvatarClient.d.ts +0 -274
  50. package/dist/AvatarClient.d.ts.map +0 -1
  51. package/dist/AvatarClient.js +0 -712
  52. package/dist/AvatarClient.js.map +0 -1
  53. package/dist/createAvatar.d.ts +0 -135
  54. package/dist/createAvatar.d.ts.map +0 -1
  55. package/dist/createAvatar.js +0 -75
  56. package/dist/createAvatar.js.map +0 -1
  57. package/dist/index.d.ts.map +0 -1
  58. package/dist/index.js.map +0 -1
  59. package/dist/internal.d.ts +0 -34
  60. package/dist/internal.d.ts.map +0 -1
  61. package/dist/internal.js +0 -56
  62. package/dist/internal.js.map +0 -1
  63. package/dist/playout.d.ts +0 -42
  64. package/dist/playout.d.ts.map +0 -1
  65. package/dist/playout.js +0 -85
  66. package/dist/playout.js.map +0 -1
  67. package/dist/react.d.ts.map +0 -1
  68. package/dist/react.js.map +0 -1
  69. package/dist/supports.d.ts +0 -19
  70. package/dist/supports.d.ts.map +0 -1
  71. package/dist/supports.js +0 -25
  72. package/dist/supports.js.map +0 -1
  73. package/dist/three/assets.d.ts +0 -10
  74. package/dist/three/assets.d.ts.map +0 -1
  75. package/dist/three/assets.js +0 -23
  76. package/dist/three/assets.js.map +0 -1
  77. package/dist/three/budgets.d.ts +0 -17
  78. package/dist/three/budgets.d.ts.map +0 -1
  79. package/dist/three/budgets.js +0 -21
  80. package/dist/three/budgets.js.map +0 -1
  81. package/dist/three/character-rig.d.ts +0 -366
  82. package/dist/three/character-rig.d.ts.map +0 -1
  83. package/dist/three/character-rig.js +0 -1644
  84. package/dist/three/character-rig.js.map +0 -1
  85. package/dist/three/createCharacter.d.ts +0 -60
  86. package/dist/three/createCharacter.d.ts.map +0 -1
  87. package/dist/three/createCharacter.js +0 -84
  88. package/dist/three/createCharacter.js.map +0 -1
  89. package/dist/three/holds.d.ts +0 -38
  90. package/dist/three/holds.d.ts.map +0 -1
  91. package/dist/three/holds.js +0 -61
  92. package/dist/three/holds.js.map +0 -1
  93. package/dist/three/internal.d.ts +0 -52
  94. package/dist/three/internal.d.ts.map +0 -1
  95. package/dist/three/internal.js +0 -49
  96. package/dist/three/internal.js.map +0 -1
  97. package/dist/three/motion-limits.json +0 -124
  98. package/dist/three/sequences.d.ts +0 -71
  99. package/dist/three/sequences.d.ts.map +0 -1
  100. package/dist/three/sequences.js +0 -262
  101. package/dist/three/sequences.js.map +0 -1
  102. package/dist/three/tanvi-asset.d.ts +0 -7
  103. package/dist/three/tanvi-asset.d.ts.map +0 -1
  104. package/dist/three/tanvi-asset.js +0 -7
  105. package/dist/three/tanvi-asset.js.map +0 -1
  106. package/dist/three/tanvi.d.ts +0 -24
  107. package/dist/three/tanvi.d.ts.map +0 -1
  108. package/dist/three/tanvi.js +0 -24
  109. package/dist/three/tanvi.js.map +0 -1
  110. package/dist/three/tanya-asset.d.ts +0 -7
  111. package/dist/three/tanya-asset.d.ts.map +0 -1
  112. package/dist/three/tanya-asset.js +0 -7
  113. package/dist/three/tanya-asset.js.map +0 -1
  114. package/dist/three/tanya.d.ts +0 -23
  115. package/dist/three/tanya.d.ts.map +0 -1
  116. package/dist/three/tanya.js +0 -23
  117. package/dist/three/tanya.js.map +0 -1
  118. package/dist/three/tara-asset.d.ts +0 -26
  119. package/dist/three/tara-asset.d.ts.map +0 -1
  120. package/dist/three/tara-asset.js +0 -26
  121. package/dist/three/tara-asset.js.map +0 -1
  122. package/dist/three/tara.d.ts +0 -30
  123. package/dist/three/tara.d.ts.map +0 -1
  124. package/dist/three/tara.js +0 -30
  125. package/dist/three/tara.js.map +0 -1
  126. package/dist/three/tess-asset.d.ts +0 -7
  127. package/dist/three/tess-asset.d.ts.map +0 -1
  128. package/dist/three/tess-asset.js +0 -7
  129. package/dist/three/tess-asset.js.map +0 -1
  130. package/dist/three/tess.d.ts +0 -24
  131. package/dist/three/tess.d.ts.map +0 -1
  132. package/dist/three/tess.js +0 -24
  133. package/dist/three/tess.js.map +0 -1
  134. package/dist/three/tushar-asset.d.ts +0 -7
  135. package/dist/three/tushar-asset.d.ts.map +0 -1
  136. package/dist/three/tushar-asset.js +0 -7
  137. package/dist/three/tushar-asset.js.map +0 -1
  138. package/dist/three/tushar.d.ts +0 -22
  139. package/dist/three/tushar.d.ts.map +0 -1
  140. package/dist/three/tushar.js +0 -22
  141. package/dist/three/tushar.js.map +0 -1
  142. package/dist/types.d.ts +0 -149
  143. package/dist/types.d.ts.map +0 -1
  144. package/dist/types.js +0 -140
  145. package/dist/types.js.map +0 -1
  146. package/dist/useAvatar.d.ts +0 -39
  147. package/dist/useAvatar.d.ts.map +0 -1
  148. package/dist/useAvatar.js +0 -35
  149. package/dist/useAvatar.js.map +0 -1
  150. package/src/avatar.d.ts +0 -436
  151. package/src/avatar.js +0 -1378
  152. package/src/behavior.d.ts +0 -42
  153. package/src/behavior.js +0 -114
  154. package/src/camera.js +0 -29
  155. package/src/clips.js +0 -155
  156. package/src/conformance.js +0 -119
  157. package/src/emotions.js +0 -58
  158. package/src/face-core.js +0 -180
  159. package/src/face-myna.d.ts +0 -7
  160. package/src/face-myna.js +0 -786
  161. package/src/face-peep-control-plane.js +0 -167
  162. package/src/face-peep.d.ts +0 -7
  163. package/src/face-peep.js +0 -925
  164. package/src/face-wren.d.ts +0 -7
  165. package/src/face-wren.js +0 -468
  166. package/src/faces.d.ts +0 -12
  167. package/src/faces.js +0 -53
  168. package/src/gaze.js +0 -504
  169. package/src/hand.js +0 -709
  170. package/src/head.js +0 -134
  171. package/src/idle.js +0 -668
  172. package/src/interjections.js +0 -747
  173. package/src/line-art.js +0 -137
  174. package/src/params.js +0 -180
  175. package/src/perform.js +0 -107
  176. package/src/prosody.js +0 -741
  177. package/src/rig.d.ts +0 -31
  178. package/src/rig.js +0 -32
  179. package/src/speech-timing.js +0 -51
  180. 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
- }
@@ -1,51 +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 past touching a bilabial aims the lips, in `mouthOpen`.
27
- *
28
- * Lips do not ease into a closure; they arrive near their peak speed and
29
- * compress, because the target they are sent to is a *negative* aperture —
30
- * the lips would pass through each other if they could (Löfqvist & Gracco
31
- * 1997, JSLHR 40:877). A first-order chase aimed at zero never touches: it
32
- * slows to nothing on the way in, and a short [b] in running speech ends with
33
- * the lips still apart and the teeth showing through the gap. Aimed past
34
- * zero, the chase crosses it at speed and the drawn aperture stops at shut.
35
- *
36
- * The literature gives the direction, not a size in these units. This is the
37
- * knee measured on the Studio lipsync takes (both engines, the mouth modelled
38
- * at `MOUTH_RESPONSE_TAU_S`): at 0.1 some bilabials still stopped short, at
39
- * 0.2 all but a stray one met, and 0.3 met no more. From an ordinary open
40
- * vowel it touches in τ·ln((0.44 + 0.2) / 0.2) ≈ 49 ms.
41
- */
42
- export const LIP_CONTACT_AIM = 0.2;
43
-
44
- /**
45
- * How far ahead of a rounded vowel the lips begin to round. Anticipatory
46
- * rounding is among the longest-range coarticulation there is — 100–300 ms
47
- * before an audible [y]/[u] (Schwartz & Savariaux 2014) — and this is the
48
- * bottom of that range, because running speech compresses it and a pucker
49
- * spread across a whole preceding word reads as a pout.
50
- */
51
- 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
- }