@voqalize/avatar 0.2.2 → 0.4.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/LICENSE +21 -661
- package/LICENSE-CC-BY-4.0 +396 -0
- package/README.md +191 -662
- package/assets/README.md +48 -0
- package/assets/tanya.glb +0 -0
- package/assets/tara.glb +0 -0
- package/assets/tushar.glb +0 -0
- package/client/Avatar.tsx +57 -0
- package/client/AvatarClient.ts +818 -0
- package/client/arjun.ts +26 -0
- package/client/createAvatar.ts +177 -0
- package/client/createCanvasAvatar.ts +72 -0
- package/client/index.ts +44 -0
- package/client/internal.ts +108 -0
- package/client/interviewer-female.ts +4 -0
- package/client/interviewer-male.ts +4 -0
- package/client/ishita.ts +26 -0
- package/client/kabir.ts +26 -0
- package/client/meera.ts +26 -0
- package/client/naina.ts +26 -0
- package/client/playout.ts +95 -0
- package/client/professional-female-a.ts +4 -0
- package/client/professional-female-b.ts +4 -0
- package/client/professional-male-a.ts +4 -0
- package/client/professional-male-b.ts +4 -0
- package/client/react.ts +13 -0
- package/client/supports.ts +28 -0
- package/client/three/assets.ts +24 -0
- package/client/three/budgets.ts +27 -0
- package/client/three/holds.ts +64 -0
- package/client/three/internal.ts +54 -0
- package/client/three/motion-limits.json +116 -0
- package/client/three/sequences.ts +281 -0
- package/client/three/tanya.ts +69 -0
- package/client/three/tara-rig.ts +1562 -0
- package/client/three/tara.ts +76 -0
- package/client/three/tushar.ts +65 -0
- package/client/types.ts +235 -0
- package/client/useAvatar.ts +74 -0
- package/client/vikram.ts +26 -0
- package/dist/Avatar.d.ts +44 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/{client/dist → dist}/Avatar.js +2 -2
- package/dist/Avatar.js.map +1 -0
- package/dist/AvatarClient.d.ts +274 -0
- package/dist/AvatarClient.d.ts.map +1 -0
- package/dist/AvatarClient.js +712 -0
- package/dist/AvatarClient.js.map +1 -0
- package/dist/arjun.d.ts +7 -0
- package/dist/arjun.d.ts.map +1 -0
- package/dist/arjun.js +20 -0
- package/dist/arjun.js.map +1 -0
- package/dist/createAvatar.d.ts +135 -0
- package/dist/createAvatar.d.ts.map +1 -0
- package/dist/createAvatar.js +75 -0
- package/dist/createAvatar.js.map +1 -0
- package/dist/createCanvasAvatar.d.ts +22 -0
- package/dist/createCanvasAvatar.d.ts.map +1 -0
- package/dist/createCanvasAvatar.js +47 -0
- package/dist/createCanvasAvatar.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal.d.ts +34 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +41 -0
- package/dist/internal.js.map +1 -0
- package/dist/interviewer-female.d.ts +4 -0
- package/dist/interviewer-female.d.ts.map +1 -0
- package/dist/interviewer-female.js +3 -0
- package/dist/interviewer-female.js.map +1 -0
- package/dist/interviewer-male.d.ts +4 -0
- package/dist/interviewer-male.d.ts.map +1 -0
- package/dist/interviewer-male.js +3 -0
- package/dist/interviewer-male.js.map +1 -0
- package/dist/ishita.d.ts +7 -0
- package/dist/ishita.d.ts.map +1 -0
- package/dist/ishita.js +20 -0
- package/dist/ishita.js.map +1 -0
- package/dist/kabir.d.ts +7 -0
- package/dist/kabir.d.ts.map +1 -0
- package/dist/kabir.js +20 -0
- package/dist/kabir.js.map +1 -0
- package/dist/meera.d.ts +7 -0
- package/dist/meera.d.ts.map +1 -0
- package/dist/meera.js +20 -0
- package/dist/meera.js.map +1 -0
- package/dist/naina.d.ts +7 -0
- package/dist/naina.d.ts.map +1 -0
- package/dist/naina.js +20 -0
- package/dist/naina.js.map +1 -0
- package/dist/playout.d.ts +42 -0
- package/dist/playout.d.ts.map +1 -0
- package/dist/playout.js +85 -0
- package/dist/playout.js.map +1 -0
- package/dist/professional-female-a.d.ts +4 -0
- package/dist/professional-female-a.d.ts.map +1 -0
- package/dist/professional-female-a.js +3 -0
- package/dist/professional-female-a.js.map +1 -0
- package/dist/professional-female-b.d.ts +4 -0
- package/dist/professional-female-b.d.ts.map +1 -0
- package/dist/professional-female-b.js +3 -0
- package/dist/professional-female-b.js.map +1 -0
- package/dist/professional-male-a.d.ts +4 -0
- package/dist/professional-male-a.d.ts.map +1 -0
- package/dist/professional-male-a.js +3 -0
- package/dist/professional-male-a.js.map +1 -0
- package/dist/professional-male-b.d.ts +4 -0
- package/dist/professional-male-b.d.ts.map +1 -0
- package/dist/professional-male-b.js +3 -0
- package/dist/professional-male-b.js.map +1 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +13 -0
- package/dist/react.js.map +1 -0
- package/dist/supports.d.ts +20 -0
- package/dist/supports.d.ts.map +1 -0
- package/dist/supports.js +26 -0
- package/dist/supports.js.map +1 -0
- package/dist/three/assets.d.ts +24 -0
- package/dist/three/assets.d.ts.map +1 -0
- package/dist/three/assets.js +22 -0
- package/dist/three/assets.js.map +1 -0
- package/dist/three/budgets.d.ts +17 -0
- package/dist/three/budgets.d.ts.map +1 -0
- package/dist/three/budgets.js +21 -0
- package/dist/three/budgets.js.map +1 -0
- package/dist/three/holds.d.ts +33 -0
- package/dist/three/holds.d.ts.map +1 -0
- package/dist/three/holds.js +56 -0
- package/dist/three/holds.js.map +1 -0
- package/dist/three/internal.d.ts +52 -0
- package/dist/three/internal.d.ts.map +1 -0
- package/dist/three/internal.js +49 -0
- package/dist/three/internal.js.map +1 -0
- package/dist/three/motion-limits.json +116 -0
- package/dist/three/sequences.d.ts +71 -0
- package/dist/three/sequences.d.ts.map +1 -0
- package/dist/three/sequences.js +262 -0
- package/dist/three/sequences.js.map +1 -0
- package/dist/three/tanya.d.ts +35 -0
- package/dist/three/tanya.d.ts.map +1 -0
- package/dist/three/tanya.js +51 -0
- package/dist/three/tanya.js.map +1 -0
- package/dist/three/tara-rig.d.ts +403 -0
- package/dist/three/tara-rig.d.ts.map +1 -0
- package/dist/three/tara-rig.js +1500 -0
- package/dist/three/tara-rig.js.map +1 -0
- package/dist/three/tara.d.ts +35 -0
- package/dist/three/tara.d.ts.map +1 -0
- package/dist/three/tara.js +58 -0
- package/dist/three/tara.js.map +1 -0
- package/dist/three/tushar.d.ts +31 -0
- package/dist/three/tushar.d.ts.map +1 -0
- package/dist/three/tushar.js +47 -0
- package/dist/three/tushar.js.map +1 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +140 -0
- package/dist/types.js.map +1 -0
- package/dist/useAvatar.d.ts +39 -0
- package/dist/useAvatar.d.ts.map +1 -0
- package/dist/useAvatar.js +35 -0
- package/dist/useAvatar.js.map +1 -0
- package/dist/vikram.d.ts +7 -0
- package/dist/vikram.d.ts.map +1 -0
- package/dist/vikram.js +20 -0
- package/dist/vikram.js.map +1 -0
- package/package.json +110 -25
- package/src/avatar.d.ts +218 -120
- package/src/avatar.js +950 -213
- package/src/behavior.d.ts +42 -0
- package/src/behavior.js +114 -0
- package/src/camera.js +29 -0
- package/src/canvas/author/parts/eye.mjs +722 -0
- package/src/canvas/author/parts/hand.mjs +1156 -0
- package/src/canvas/author/parts/mouth.mjs +741 -0
- package/src/canvas/author/parts/nose.mjs +100 -0
- package/src/canvas/author/parts/skin-detail.mjs +67 -0
- package/src/canvas/author/path.mjs +283 -0
- package/src/canvas/author/rig.mjs +405 -0
- package/src/canvas/avatars/round/face.d.mts +3 -0
- package/src/canvas/avatars/round/face.mjs +1307 -0
- package/src/canvas/create-rig.d.ts +15 -0
- package/src/canvas/create-rig.js +100 -0
- package/src/canvas/data/img/professional-female-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-female-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-female-b-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-a-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-a-top-body.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-back.webp +0 -0
- package/src/canvas/data/img/professional-male-b-hair-front.webp +0 -0
- package/src/canvas/data/img/professional-male-b-top-body.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-back.webp +0 -0
- package/src/canvas/data/img/round-m3-hair-front.webp +0 -0
- package/src/canvas/data/img/round-m3-top-body.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-back.webp +0 -0
- package/src/canvas/data/img/round-w1-hair-front.webp +0 -0
- package/src/canvas/data/img/round-w1-top-body.webp +0 -0
- package/src/canvas/data/interviewer-female.rig.json +1 -0
- package/src/canvas/data/interviewer-male.rig.json +1 -0
- package/src/canvas/data/professional-female-a.rig.json +1 -0
- package/src/canvas/data/professional-female-b.rig.json +1 -0
- package/src/canvas/data/professional-male-a.rig.json +1 -0
- package/src/canvas/data/professional-male-b.rig.json +1 -0
- package/src/canvas/src/live.js +508 -0
- package/src/canvas/src/render2d.js +218 -0
- package/src/canvas/src/rig.js +297 -0
- package/src/canvas/src/vocab.js +96 -0
- package/src/clips.js +18 -7
- package/src/conformance.js +119 -0
- package/src/emotions.js +8 -5
- package/src/face-core.js +27 -1
- package/src/face-myna.d.ts +7 -0
- package/src/face-myna.js +192 -131
- package/src/face-peep-control-plane.js +167 -0
- package/src/face-peep.d.ts +7 -0
- package/src/face-peep.js +274 -116
- package/src/face-wren.d.ts +7 -0
- package/src/face-wren.js +17 -19
- package/src/faces.d.ts +12 -0
- package/src/faces.js +53 -0
- package/src/gaze.js +434 -58
- package/src/hand.js +120 -91
- package/src/head.js +134 -0
- package/src/idle.js +227 -119
- package/src/interjections.js +200 -31
- package/src/params.js +8 -4
- package/src/perform.js +7 -9
- package/src/prosody.js +647 -0
- package/src/rig.d.ts +24 -0
- package/src/rig.js +32 -0
- package/src/speech-timing.js +23 -0
- package/src/visemes.js +110 -24
- package/client/dist/Avatar.d.ts +0 -27
- package/client/dist/Avatar.d.ts.map +0 -1
- package/client/dist/Avatar.js.map +0 -1
- package/client/dist/AvatarClient.d.ts +0 -139
- package/client/dist/AvatarClient.d.ts.map +0 -1
- package/client/dist/AvatarClient.js +0 -223
- package/client/dist/AvatarClient.js.map +0 -1
- package/client/dist/index.d.ts +0 -22
- package/client/dist/index.d.ts.map +0 -1
- package/client/dist/index.js +0 -22
- package/client/dist/index.js.map +0 -1
- package/client/dist/types.d.ts +0 -86
- package/client/dist/types.d.ts.map +0 -1
- package/client/dist/types.js +0 -31
- package/client/dist/types.js.map +0 -1
- package/client/dist/useAvatar.d.ts +0 -42
- package/client/dist/useAvatar.d.ts.map +0 -1
- package/client/dist/useAvatar.js +0 -51
- package/client/dist/useAvatar.js.map +0 -1
- package/client/src/Avatar.tsx +0 -33
- package/client/src/AvatarClient.ts +0 -270
- package/client/src/index.ts +0 -22
- package/client/src/types.ts +0 -104
- package/client/src/useAvatar.ts +0 -85
- package/docs/contract-avatar.md +0 -371
- package/docs/contract-protocol.md +0 -461
package/src/rig.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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;
|
package/src/visemes.js
CHANGED
|
@@ -17,12 +17,28 @@
|
|
|
17
17
|
* H tongue up L
|
|
18
18
|
* X idle / silence
|
|
19
19
|
*
|
|
20
|
-
* A cue is `{ t, v, i? }` — millisecond offset into the utterance, the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* the
|
|
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.
|
|
24
34
|
*/
|
|
25
35
|
|
|
36
|
+
import {
|
|
37
|
+
CUE_TRACK_LEAD_MS,
|
|
38
|
+
MIN_VISIBLE_CUE_MS,
|
|
39
|
+
SPEECH_TRACK_TAIL_MS,
|
|
40
|
+
} from './speech-timing.js';
|
|
41
|
+
|
|
26
42
|
export const VISEME_SHAPES = {
|
|
27
43
|
X: { mouthOpen: 0.02, mouthWidth: 0.42, mouthRound: 0.10, mouthPress: 0.15, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
28
44
|
A: { mouthOpen: 0.00, mouthWidth: 0.40, mouthRound: 0.18, mouthPress: 0.55, mouthTuck: 0, teethUpper: 0.00, tongue: 0.0 },
|
|
@@ -63,42 +79,106 @@ export function shapeFor(letter, intensity = 1) {
|
|
|
63
79
|
// ---------------------------------------------------------------------------
|
|
64
80
|
// Cue track hygiene
|
|
65
81
|
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Group a time-ordered cue list into runs that share a mouth shape.
|
|
85
|
+
*
|
|
86
|
+
* A backend emits one cue per intersection of its shape and phone timelines, so
|
|
87
|
+
* a held shape arrives as several cues differing only in `p`. Grouping them is
|
|
88
|
+
* what lets the visibility rules stay rules about shapes.
|
|
89
|
+
*/
|
|
90
|
+
function shapeRuns(cues) {
|
|
91
|
+
const runs = [];
|
|
92
|
+
for (const c of cues) {
|
|
93
|
+
const v = VISEME_SHAPES[c.v] ? c.v : SILENT;
|
|
94
|
+
const clean = { t: c.t, v, i: c.i == null ? 1 : c.i, p: c.p ?? null };
|
|
95
|
+
if (runs.length && runs[runs.length - 1][0].v === v) runs[runs.length - 1].push(clean);
|
|
96
|
+
else runs.push([clean]);
|
|
97
|
+
}
|
|
98
|
+
return runs;
|
|
99
|
+
}
|
|
67
100
|
|
|
68
101
|
/**
|
|
69
102
|
* Sort, merge consecutive duplicates, and drop sub-perceptual cues. Servers
|
|
70
103
|
* emit noisy tracks; this makes them watchable.
|
|
104
|
+
*
|
|
105
|
+
* **Every decision here is a decision about shapes**, taken over the run heads
|
|
106
|
+
* and nothing else. MIN_VISIBLE_CUE_MS and the closure swap are rules about what
|
|
107
|
+
* the face can be *seen* to do, and a phone transition inside a held shape is
|
|
108
|
+
* not something the face does at all — letting one participate would mean a
|
|
109
|
+
* phone changing 15 ms before a real shape change could swallow that change,
|
|
110
|
+
* which is a lipsync regression bought with a field this file does not read.
|
|
111
|
+
* Phone detail is re-attached afterwards, bounded by the mouth positions either
|
|
112
|
+
* side of it. Mirrors `normalize_cues` server-side.
|
|
71
113
|
*/
|
|
72
114
|
export function normalizeCues(cues) {
|
|
115
|
+
const runs = shapeRuns([...cues].sort((a, b) => a.t - b.t));
|
|
116
|
+
|
|
117
|
+
// Pass one: the shape track, by exactly the rule that predates phone detail.
|
|
118
|
+
// `kept` maps each surviving cue back to the run it speaks for, which the
|
|
119
|
+
// closure swap can change without moving the cue's timestamp.
|
|
73
120
|
const out = [];
|
|
74
|
-
const
|
|
75
|
-
for (
|
|
76
|
-
const
|
|
121
|
+
const kept = [];
|
|
122
|
+
for (let index = 0; index < runs.length; index++) {
|
|
123
|
+
const head = runs[index][0];
|
|
124
|
+
const v = head.v;
|
|
77
125
|
const prev = out[out.length - 1];
|
|
78
126
|
if (prev && prev.v === v) continue; // merge repeats
|
|
79
|
-
if (prev &&
|
|
127
|
+
if (prev && head.t - prev.t < MIN_VISIBLE_CUE_MS) {
|
|
80
128
|
// Too short to read. Keep whichever is more visually salient: a closure
|
|
81
129
|
// (A/G) carries more lip-reading information than a mid-open vowel.
|
|
82
|
-
if (v === 'A' || v === 'G')
|
|
130
|
+
if (v === 'A' || v === 'G') {
|
|
131
|
+
// The short cue can sit between two identical closures (G → F → G).
|
|
132
|
+
// Its replacement would otherwise create a duplicate visible shape;
|
|
133
|
+
// preserving the first G is both the stable wire form and the face the
|
|
134
|
+
// viewer actually saw.
|
|
135
|
+
if (out.length > 1 && out[out.length - 2].v === v) { out.pop(); kept.pop(); }
|
|
136
|
+
// A winning closure replaces the preceding shape for the entire
|
|
137
|
+
// sub-perceptual interval. Preserve that cue's timestamp while taking
|
|
138
|
+
// the closure's intensity, matching the server-side wire normalizer.
|
|
139
|
+
else {
|
|
140
|
+
out[out.length - 1] = { ...head, t: prev.t, v };
|
|
141
|
+
kept[kept.length - 1] = index;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
83
144
|
continue;
|
|
84
145
|
}
|
|
85
|
-
out.push({ t:
|
|
146
|
+
out.push({ t: head.t, v, i: head.i, p: head.p });
|
|
147
|
+
kept.push(index);
|
|
86
148
|
}
|
|
87
|
-
|
|
149
|
+
|
|
150
|
+
// Pass two: the phone detail inside each surviving mouth position. Silence is
|
|
151
|
+
// never split — nothing is being articulated under a rest.
|
|
152
|
+
const result = [];
|
|
153
|
+
for (let slot = 0; slot < out.length; slot++) {
|
|
154
|
+
const cue = out[slot];
|
|
155
|
+
result.push(cue);
|
|
156
|
+
if (cue.v === SILENT) continue;
|
|
157
|
+
const limit = slot + 1 < out.length ? out[slot + 1].t : Infinity;
|
|
158
|
+
for (const sub of runs[kept[slot]].slice(1)) {
|
|
159
|
+
// Strictly after whatever was last emitted, not merely after the head: a
|
|
160
|
+
// splice joins two legs' tracks and both can name the same millisecond.
|
|
161
|
+
if (sub.t <= result[result.length - 1].t || sub.t >= limit) continue;
|
|
162
|
+
if (sub.p === result[result.length - 1].p) continue;
|
|
163
|
+
result.push({ t: sub.t, v: cue.v, i: sub.i, p: sub.p });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
88
167
|
}
|
|
89
168
|
|
|
90
169
|
/**
|
|
91
|
-
* Schedules a cue track against an
|
|
170
|
+
* Schedules a cue track against an utterance clock.
|
|
92
171
|
*
|
|
93
|
-
*
|
|
94
|
-
* `AudioContext.currentTime`)
|
|
95
|
-
* playback
|
|
172
|
+
* An audio-owned clock (`audioEl.currentTime * 1000` or
|
|
173
|
+
* `AudioContext.currentTime`) is the strongest source when the caller owns
|
|
174
|
+
* playback. The Pipecat adapter cannot see browser device playout; it supplies
|
|
175
|
+
* elapsed time from `botStartedSpeaking`, Pipecat's output-lifecycle epoch.
|
|
96
176
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
177
|
+
* There is deliberately no renderer-wide lead. Network/media skew cannot be
|
|
178
|
+
* corrected by moving every cue, and the predicted backend leg owns its own
|
|
179
|
+
* explicit prediction cushion.
|
|
100
180
|
*/
|
|
101
|
-
export const LEAD_MS =
|
|
181
|
+
export const LEAD_MS = CUE_TRACK_LEAD_MS;
|
|
102
182
|
|
|
103
183
|
export class VisemeTrack {
|
|
104
184
|
constructor() {
|
|
@@ -107,9 +187,14 @@ export class VisemeTrack {
|
|
|
107
187
|
this.playing = false;
|
|
108
188
|
this._idx = 0;
|
|
109
189
|
this.onEnd = null;
|
|
110
|
-
this.tailMs =
|
|
190
|
+
this.tailMs = SPEECH_TRACK_TAIL_MS;
|
|
191
|
+
// Where the last sample() landed, for the layers that read the track's
|
|
192
|
+
// future (prosody.js looks ahead to the end of a pause).
|
|
193
|
+
this.now = 0;
|
|
111
194
|
}
|
|
112
195
|
|
|
196
|
+
get index() { return this._idx; }
|
|
197
|
+
|
|
113
198
|
/** @param {() => number} clock returns elapsed ms of the audio being played */
|
|
114
199
|
start(cues, clock) {
|
|
115
200
|
this.cues = normalizeCues(cues);
|
|
@@ -133,10 +218,11 @@ export class VisemeTrack {
|
|
|
133
218
|
this._idx = 0;
|
|
134
219
|
}
|
|
135
220
|
|
|
136
|
-
/** @returns {{letter: string, intensity: number} | null} */
|
|
221
|
+
/** @returns {{letter: string, intensity: number, phone: string | null} | null} */
|
|
137
222
|
sample() {
|
|
138
223
|
if (!this.playing || !this.cues.length || !this.clock) return null;
|
|
139
224
|
const now = this.clock() + LEAD_MS;
|
|
225
|
+
this.now = now;
|
|
140
226
|
|
|
141
227
|
// Cues are time-ordered and `now` is mostly monotonic, so this walk is O(1)
|
|
142
228
|
// amortized. Reset on seek-backward.
|
|
@@ -151,8 +237,8 @@ export class VisemeTrack {
|
|
|
151
237
|
}
|
|
152
238
|
|
|
153
239
|
const cue = this.cues[this._idx];
|
|
154
|
-
if (cue.t > now) return { letter: SILENT, intensity: 1 };
|
|
155
|
-
return { letter: cue.v, intensity: cue.i == null ? 1 : cue.i };
|
|
240
|
+
if (cue.t > now) return { letter: SILENT, intensity: 1, phone: null };
|
|
241
|
+
return { letter: cue.v, intensity: cue.i == null ? 1 : cue.i, phone: cue.p ?? null };
|
|
156
242
|
}
|
|
157
243
|
}
|
|
158
244
|
|
package/client/dist/Avatar.d.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Avatar — a call-tile-ready wrapper around the widget, and the package's
|
|
3
|
-
* whole public surface.
|
|
4
|
-
*
|
|
5
|
-
* <Avatar client={session.client} className="avatar-tile" />
|
|
6
|
-
*
|
|
7
|
-
* Two props of its own; everything else is forwarded to the mount `<div>`, so
|
|
8
|
-
* it sizes and styles like the tile it lives in. There is nothing to configure
|
|
9
|
-
* because there is nothing the server does not already say: the
|
|
10
|
-
* `AvatarProcessor` in the pipeline drives the state, the gaze and the mouth.
|
|
11
|
-
*
|
|
12
|
-
* The widget has no hot-swap-avatar API — `createFace` runs once per mount —
|
|
13
|
-
* so `avatar` is read once, at mount. To switch faces at runtime, remount with
|
|
14
|
-
* a `key` prop:
|
|
15
|
-
*
|
|
16
|
-
* <Avatar key={name} avatar={name} client={session.client} />
|
|
17
|
-
*/
|
|
18
|
-
import type { HTMLAttributes } from "react";
|
|
19
|
-
import type { PipecatClient } from "@pipecat-ai/client-js";
|
|
20
|
-
export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
|
|
21
|
-
/** The live `PipecatClient`, or `null` before connect. */
|
|
22
|
-
client?: PipecatClient | null;
|
|
23
|
-
/** Which face. Omit for the default. Read at mount only — see above. */
|
|
24
|
-
avatar?: string;
|
|
25
|
-
}
|
|
26
|
-
export declare function Avatar({ client, avatar, ...rest }: AvatarProps): import("react").JSX.Element;
|
|
27
|
-
//# sourceMappingURL=Avatar.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Avatar.d.ts","sourceRoot":"","sources":["../src/Avatar.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAG3D,MAAM,WAAW,WAAY,SAAQ,cAAc,CAAC,cAAc,CAAC;IACjE,0DAA0D;IAC1D,MAAM,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC9B,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,EAAE,WAAW,+BAG9D"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Avatar.js","sourceRoot":"","sources":["../src/Avatar.tsx"],"names":[],"mappings":";AAoBA,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAS3C,MAAM,UAAU,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,IAAI,EAAe;IAC7D,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACvD,OAAO,cAAK,IAAI,EAAC,KAAK,gBAAY,QAAQ,KAAK,IAAI,EAAE,GAAG,EAAE,YAAY,GAAI,CAAC;AAC7E,CAAC"}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AvatarClient — the avatar's server-message dispatcher, turn clock, and cue
|
|
3
|
-
* splice, framework-free (no React; the hook and component wrap this).
|
|
4
|
-
*
|
|
5
|
-
* ## Turn clock anchoring
|
|
6
|
-
*
|
|
7
|
-
* A turn's `t0` is anchored to `performance.now()` **at the moment this client
|
|
8
|
-
* receives the `{cmd:"speech", event:"start"}` message** — cues are
|
|
9
|
-
* client-anchored, not per-cue server-released. That message rides the RTVI
|
|
10
|
-
* data channel, ahead of the jitter-buffered audio path, so the residual error
|
|
11
|
-
* lands on the video-leads side — the side `docs/contract-protocol.md` says
|
|
12
|
-
* perceptual tolerance favours (+125 ms vs -45 ms).
|
|
13
|
-
*
|
|
14
|
-
* We investigated anchoring on pipecat client-js's own `RTVIEvent
|
|
15
|
-
* .BotStartedSpeaking`/`BotStoppedSpeaking` instead (or as a refinement) and
|
|
16
|
-
* chose not to, for two reasons:
|
|
17
|
-
*
|
|
18
|
-
* 1. **No turn correlation.** Those events carry no payload — no `ctx` — so
|
|
19
|
-
* there is no way to tell which turn a firing belongs to. Our own
|
|
20
|
-
* `speech` command carries `ctx`, which the splice logic below needs
|
|
21
|
-
* regardless, so anchoring off it costs nothing extra.
|
|
22
|
-
* 2. **Same source, same path, no accuracy gain.** The `AvatarProcessor`
|
|
23
|
-
* sits between the TTS service and the output transport and observes the
|
|
24
|
-
* transport's own `BotStarted/StoppedSpeakingFrame` broadcasts — the exact
|
|
25
|
-
* frame pipecat's built-in speaking detection is *also* driven from. Both
|
|
26
|
-
* notifications travel the same data-channel path to the browser. There is
|
|
27
|
-
* no local "truly audible now" signal cheaply available: the audio arrives
|
|
28
|
-
* on a `MediaStreamTrack` whose only lifecycle events (`unmute`/`mute`)
|
|
29
|
-
* fire once per call, not per utterance. Tapping the decoded remote audio
|
|
30
|
-
* with a WebAudio `AnalyserNode` RMS gate *would* give one, but it adds
|
|
31
|
-
* its own onset latency and a real audio pipeline to build and tune, and
|
|
32
|
-
* it would eat into the intentional video-first safety margin rather than
|
|
33
|
-
* improve it. Left as a documented option, not built.
|
|
34
|
-
*
|
|
35
|
-
* `attach()` therefore subscribes to exactly one pipecat event,
|
|
36
|
-
* `serverMessage`. It used to also subscribe to both speaking events to report
|
|
37
|
-
* a diagnostic drift between our anchor and pipecat's; that hook is gone with
|
|
38
|
-
* the rest of the observability surface (`docs/removed.md` § Client callbacks).
|
|
39
|
-
*
|
|
40
|
-
* ## Cue splice
|
|
41
|
-
*
|
|
42
|
-
* The widget has two cue-track primitives: `speak({cues, clock})` (a full
|
|
43
|
-
* replace) and `pushCues(cues)` (a pure union that can only grow the track,
|
|
44
|
-
* never shrink it). Neither is "discard queued cues at or after `from_ms`,
|
|
45
|
-
* then append" on its own — `pushCues` has no way to drop a stale tail. So
|
|
46
|
-
* this client keeps the turn's canonical cue array itself (kept portion +
|
|
47
|
-
* every appended chunk, spliced on each `cues` message) and picks the cheapest
|
|
48
|
-
* widget call that stays correct:
|
|
49
|
-
*
|
|
50
|
-
* - if the splice's `from_ms` doesn't reach back into anything already
|
|
51
|
-
* queued — the common case past a turn's first sentence, since only the
|
|
52
|
-
* first sentence genuinely plays fast-leg cues — nothing needs discarding:
|
|
53
|
-
* `pushCues(newCues)` is the cheap, correct append.
|
|
54
|
-
* - if it does reach back (a real fast→accurate splice), `pushCues` cannot
|
|
55
|
-
* express the discard; we call `speak()` again with the full spliced
|
|
56
|
-
* canonical array on the turn's original clock. `speak()` is otherwise
|
|
57
|
-
* documented as also killing an in-flight spoken interjection and
|
|
58
|
-
* re-entering `SPEAKING` — both harmless mid-splice (an interjection
|
|
59
|
-
* should not be running while a server track owns the mouth; re-entering
|
|
60
|
-
* an unchanged state is a no-op past the profile/gaze reset the widget
|
|
61
|
-
* already does for a same-name `setState`).
|
|
62
|
-
*
|
|
63
|
-
* Cues commonly arrive **before** `speech start` — the fast leg starts the
|
|
64
|
-
* moment a sentence is handed to TTS, well before `BotStartedSpeakingFrame`.
|
|
65
|
-
* Chunks that arrive before the clock is anchored are spliced into the
|
|
66
|
-
* canonical array but not yet handed to the widget; `speech start` hands over
|
|
67
|
-
* whatever has accumulated as the turn's first `speak()` call. So "the first
|
|
68
|
-
* chunk of a turn starts speak()" means the first *widget* call, not
|
|
69
|
-
* necessarily the first *message*.
|
|
70
|
-
*/
|
|
71
|
-
import type { PipecatClient } from "@pipecat-ai/client-js";
|
|
72
|
-
import type { AvatarApi } from "../../src/avatar.js";
|
|
73
|
-
import { type AvatarCommand, type AvatarCue } from "./types.js";
|
|
74
|
-
/**
|
|
75
|
-
* Internal. Not exported from the package — see `index.ts` for the public
|
|
76
|
-
* surface, which is `<Avatar>` and nothing else.
|
|
77
|
-
*
|
|
78
|
-
* There is deliberately no `accept` predicate here any more. Avatar commands
|
|
79
|
-
* travel in one envelope, `{type:"avatar"}`, in both directions and from every
|
|
80
|
-
* source: a `AvatarProcessor` in the pipeline and a brain driving the face
|
|
81
|
-
* out of band emit the same shape. A per-deployment predicate meant the
|
|
82
|
-
* library could not state what an avatar message *is*, which is the one thing
|
|
83
|
-
* a wire format has to be able to say. See docs/removed.md § The accept
|
|
84
|
-
* predicate.
|
|
85
|
-
*/
|
|
86
|
-
export interface AvatarClientOptions {
|
|
87
|
-
/** A dispatch threw (e.g. an unknown state or interjection id, which the
|
|
88
|
-
* widget throws on). Defaults to `console.warn`. */
|
|
89
|
-
onError?: (err: unknown, msg: AvatarCommand) => void;
|
|
90
|
-
/** Override for tests. Defaults to `performance.now`. */
|
|
91
|
-
now?: () => number;
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* The one `RTVIEvent` member `attach()` subscribes to, spelled as its value.
|
|
95
|
-
*
|
|
96
|
-
* Written out rather than imported because that enum was this module's *only*
|
|
97
|
-
* runtime reference to `@pipecat-ai/client-js`, and one runtime reference makes
|
|
98
|
-
* the whole `/pipecat` subpath fail to load without the peer installed — even
|
|
99
|
-
* for a host that drives `dispatch()` from its own transport and never calls
|
|
100
|
-
* `attach()`. The peer is declared optional; this is what makes that true
|
|
101
|
-
* rather than aspirational.
|
|
102
|
-
*
|
|
103
|
-
* String enums are nominal in TypeScript, so the compiler cannot check these
|
|
104
|
-
* against the real ones from a type-only import. `client/test/AvatarClient.test.ts`
|
|
105
|
-
* does it instead, against the actual enum — the devDependency is present
|
|
106
|
-
* exactly where the check belongs and absent from what we ship.
|
|
107
|
-
*/
|
|
108
|
-
export declare const RTVI_EVENTS: {
|
|
109
|
-
readonly serverMessage: "serverMessage";
|
|
110
|
-
};
|
|
111
|
-
export declare class AvatarClient {
|
|
112
|
-
private readonly avatar;
|
|
113
|
-
private readonly opts;
|
|
114
|
-
private readonly now;
|
|
115
|
-
private turn;
|
|
116
|
-
constructor(avatar: AvatarApi, opts?: AvatarClientOptions);
|
|
117
|
-
/** The active turn's ctx, or `null` between turns. For tests and telemetry. */
|
|
118
|
-
get turnCtx(): string | null;
|
|
119
|
-
/** The active turn's canonical (already-spliced) cue track. For tests and telemetry. */
|
|
120
|
-
get turnCues(): AvatarCue[];
|
|
121
|
-
/** Dispatch one server message. Anything that isn't in the avatar envelope
|
|
122
|
-
* is not ours and is ignored; so is an envelope carrying a `cmd` this build
|
|
123
|
-
* has never heard of, per the wire protocol's forward-compat rule. */
|
|
124
|
-
dispatch(raw: unknown): void;
|
|
125
|
-
private handleState;
|
|
126
|
-
private ensureTurn;
|
|
127
|
-
private handleCues;
|
|
128
|
-
private handleSpeech;
|
|
129
|
-
/**
|
|
130
|
-
* Subscribe to a live `PipecatClient`'s server messages and dispatch the
|
|
131
|
-
* avatar commands among them — the ones in the protocol's own
|
|
132
|
-
* `{type:"avatar"}` envelope, which `isAvatarMessage` is the definition of.
|
|
133
|
-
* Never throws on a malformed or irrelevant message.
|
|
134
|
-
*
|
|
135
|
-
* @returns an unsubscribe function; call it on unmount or disconnect.
|
|
136
|
-
*/
|
|
137
|
-
attach(client: PipecatClient): () => void;
|
|
138
|
-
}
|
|
139
|
-
//# sourceMappingURL=AvatarClient.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"AvatarClient.d.ts","sourceRoot":"","sources":["../src/AvatarClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqEG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,uBAAuB,CAAC;AACtE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,SAAS,EAIf,MAAM,YAAY,CAAC;AAYpB;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,mBAAmB;IAClC;wDACoD;IACpD,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC;IACrD,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,WAAW;;CAEmB,CAAC;AAU5C,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAY;IACnC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAsB;IAC3C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,IAAI,CAAqB;gBAErB,MAAM,EAAE,SAAS,EAAE,IAAI,GAAE,mBAAwB;IAM7D,+EAA+E;IAC/E,IAAI,OAAO,IAAI,MAAM,GAAG,IAAI,CAE3B;IAED,wFAAwF;IACxF,IAAI,QAAQ,IAAI,SAAS,EAAE,CAE1B;IAED;;0EAEsE;IACtE,QAAQ,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI;IAkC5B,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,UAAU;IAWlB,OAAO,CAAC,UAAU;IAkBlB,OAAO,CAAC,YAAY;IAkBpB;;;;;;;OAOG;IACH,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,IAAI;CAM1C"}
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AvatarClient — the avatar's server-message dispatcher, turn clock, and cue
|
|
3
|
-
* splice, framework-free (no React; the hook and component wrap this).
|
|
4
|
-
*
|
|
5
|
-
* ## Turn clock anchoring
|
|
6
|
-
*
|
|
7
|
-
* A turn's `t0` is anchored to `performance.now()` **at the moment this client
|
|
8
|
-
* receives the `{cmd:"speech", event:"start"}` message** — cues are
|
|
9
|
-
* client-anchored, not per-cue server-released. That message rides the RTVI
|
|
10
|
-
* data channel, ahead of the jitter-buffered audio path, so the residual error
|
|
11
|
-
* lands on the video-leads side — the side `docs/contract-protocol.md` says
|
|
12
|
-
* perceptual tolerance favours (+125 ms vs -45 ms).
|
|
13
|
-
*
|
|
14
|
-
* We investigated anchoring on pipecat client-js's own `RTVIEvent
|
|
15
|
-
* .BotStartedSpeaking`/`BotStoppedSpeaking` instead (or as a refinement) and
|
|
16
|
-
* chose not to, for two reasons:
|
|
17
|
-
*
|
|
18
|
-
* 1. **No turn correlation.** Those events carry no payload — no `ctx` — so
|
|
19
|
-
* there is no way to tell which turn a firing belongs to. Our own
|
|
20
|
-
* `speech` command carries `ctx`, which the splice logic below needs
|
|
21
|
-
* regardless, so anchoring off it costs nothing extra.
|
|
22
|
-
* 2. **Same source, same path, no accuracy gain.** The `AvatarProcessor`
|
|
23
|
-
* sits between the TTS service and the output transport and observes the
|
|
24
|
-
* transport's own `BotStarted/StoppedSpeakingFrame` broadcasts — the exact
|
|
25
|
-
* frame pipecat's built-in speaking detection is *also* driven from. Both
|
|
26
|
-
* notifications travel the same data-channel path to the browser. There is
|
|
27
|
-
* no local "truly audible now" signal cheaply available: the audio arrives
|
|
28
|
-
* on a `MediaStreamTrack` whose only lifecycle events (`unmute`/`mute`)
|
|
29
|
-
* fire once per call, not per utterance. Tapping the decoded remote audio
|
|
30
|
-
* with a WebAudio `AnalyserNode` RMS gate *would* give one, but it adds
|
|
31
|
-
* its own onset latency and a real audio pipeline to build and tune, and
|
|
32
|
-
* it would eat into the intentional video-first safety margin rather than
|
|
33
|
-
* improve it. Left as a documented option, not built.
|
|
34
|
-
*
|
|
35
|
-
* `attach()` therefore subscribes to exactly one pipecat event,
|
|
36
|
-
* `serverMessage`. It used to also subscribe to both speaking events to report
|
|
37
|
-
* a diagnostic drift between our anchor and pipecat's; that hook is gone with
|
|
38
|
-
* the rest of the observability surface (`docs/removed.md` § Client callbacks).
|
|
39
|
-
*
|
|
40
|
-
* ## Cue splice
|
|
41
|
-
*
|
|
42
|
-
* The widget has two cue-track primitives: `speak({cues, clock})` (a full
|
|
43
|
-
* replace) and `pushCues(cues)` (a pure union that can only grow the track,
|
|
44
|
-
* never shrink it). Neither is "discard queued cues at or after `from_ms`,
|
|
45
|
-
* then append" on its own — `pushCues` has no way to drop a stale tail. So
|
|
46
|
-
* this client keeps the turn's canonical cue array itself (kept portion +
|
|
47
|
-
* every appended chunk, spliced on each `cues` message) and picks the cheapest
|
|
48
|
-
* widget call that stays correct:
|
|
49
|
-
*
|
|
50
|
-
* - if the splice's `from_ms` doesn't reach back into anything already
|
|
51
|
-
* queued — the common case past a turn's first sentence, since only the
|
|
52
|
-
* first sentence genuinely plays fast-leg cues — nothing needs discarding:
|
|
53
|
-
* `pushCues(newCues)` is the cheap, correct append.
|
|
54
|
-
* - if it does reach back (a real fast→accurate splice), `pushCues` cannot
|
|
55
|
-
* express the discard; we call `speak()` again with the full spliced
|
|
56
|
-
* canonical array on the turn's original clock. `speak()` is otherwise
|
|
57
|
-
* documented as also killing an in-flight spoken interjection and
|
|
58
|
-
* re-entering `SPEAKING` — both harmless mid-splice (an interjection
|
|
59
|
-
* should not be running while a server track owns the mouth; re-entering
|
|
60
|
-
* an unchanged state is a no-op past the profile/gaze reset the widget
|
|
61
|
-
* already does for a same-name `setState`).
|
|
62
|
-
*
|
|
63
|
-
* Cues commonly arrive **before** `speech start` — the fast leg starts the
|
|
64
|
-
* moment a sentence is handed to TTS, well before `BotStartedSpeakingFrame`.
|
|
65
|
-
* Chunks that arrive before the clock is anchored are spliced into the
|
|
66
|
-
* canonical array but not yet handed to the widget; `speech start` hands over
|
|
67
|
-
* whatever has accumulated as the turn's first `speak()` call. So "the first
|
|
68
|
-
* chunk of a turn starts speak()" means the first *widget* call, not
|
|
69
|
-
* necessarily the first *message*.
|
|
70
|
-
*/
|
|
71
|
-
import { isAvatarMessage, } from "./types.js";
|
|
72
|
-
/**
|
|
73
|
-
* The one `RTVIEvent` member `attach()` subscribes to, spelled as its value.
|
|
74
|
-
*
|
|
75
|
-
* Written out rather than imported because that enum was this module's *only*
|
|
76
|
-
* runtime reference to `@pipecat-ai/client-js`, and one runtime reference makes
|
|
77
|
-
* the whole `/pipecat` subpath fail to load without the peer installed — even
|
|
78
|
-
* for a host that drives `dispatch()` from its own transport and never calls
|
|
79
|
-
* `attach()`. The peer is declared optional; this is what makes that true
|
|
80
|
-
* rather than aspirational.
|
|
81
|
-
*
|
|
82
|
-
* String enums are nominal in TypeScript, so the compiler cannot check these
|
|
83
|
-
* against the real ones from a type-only import. `client/test/AvatarClient.test.ts`
|
|
84
|
-
* does it instead, against the actual enum — the devDependency is present
|
|
85
|
-
* exactly where the check belongs and absent from what we ship.
|
|
86
|
-
*/
|
|
87
|
-
export const RTVI_EVENTS = {
|
|
88
|
-
serverMessage: "serverMessage",
|
|
89
|
-
};
|
|
90
|
-
/** Defensive unwrap for the `RTVIEvent.ServerMessage` `{ data }` quirk: some
|
|
91
|
-
* transports deliver the payload directly and some wrap it once more. */
|
|
92
|
-
function unwrapServerMessage(raw) {
|
|
93
|
-
const obj = (raw ?? {});
|
|
94
|
-
const inner = obj["data"];
|
|
95
|
-
return inner && "type" in inner ? inner : obj;
|
|
96
|
-
}
|
|
97
|
-
export class AvatarClient {
|
|
98
|
-
avatar;
|
|
99
|
-
opts;
|
|
100
|
-
now;
|
|
101
|
-
turn = null;
|
|
102
|
-
constructor(avatar, opts = {}) {
|
|
103
|
-
this.avatar = avatar;
|
|
104
|
-
this.opts = opts;
|
|
105
|
-
this.now = opts.now ?? (() => performance.now());
|
|
106
|
-
}
|
|
107
|
-
/** The active turn's ctx, or `null` between turns. For tests and telemetry. */
|
|
108
|
-
get turnCtx() {
|
|
109
|
-
return this.turn?.ctx ?? null;
|
|
110
|
-
}
|
|
111
|
-
/** The active turn's canonical (already-spliced) cue track. For tests and telemetry. */
|
|
112
|
-
get turnCues() {
|
|
113
|
-
return this.turn ? [...this.turn.cues] : [];
|
|
114
|
-
}
|
|
115
|
-
/** Dispatch one server message. Anything that isn't in the avatar envelope
|
|
116
|
-
* is not ours and is ignored; so is an envelope carrying a `cmd` this build
|
|
117
|
-
* has never heard of, per the wire protocol's forward-compat rule. */
|
|
118
|
-
dispatch(raw) {
|
|
119
|
-
if (!isAvatarMessage(raw))
|
|
120
|
-
return;
|
|
121
|
-
const msg = raw;
|
|
122
|
-
try {
|
|
123
|
-
switch (msg.cmd) {
|
|
124
|
-
case "state":
|
|
125
|
-
this.handleState(msg);
|
|
126
|
-
break;
|
|
127
|
-
case "interject":
|
|
128
|
-
this.avatar.interject(msg.id);
|
|
129
|
-
break;
|
|
130
|
-
case "gesture":
|
|
131
|
-
this.avatar.gesture(msg.id);
|
|
132
|
-
break;
|
|
133
|
-
case "cues":
|
|
134
|
-
this.handleCues(msg);
|
|
135
|
-
break;
|
|
136
|
-
case "speech":
|
|
137
|
-
this.handleSpeech(msg);
|
|
138
|
-
break;
|
|
139
|
-
case "user":
|
|
140
|
-
this.avatar.setUserSpeaking(msg.speaking);
|
|
141
|
-
break;
|
|
142
|
-
// No default: an unknown `cmd` is a newer server talking to an older
|
|
143
|
-
// widget, and the protocol's forward-compat rule says ignore it. There
|
|
144
|
-
// is no callback for it — a hook nobody could act on is observability,
|
|
145
|
-
// not an interface (`docs/removed.md` § Client callbacks).
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
catch (err) {
|
|
149
|
-
if (this.opts.onError)
|
|
150
|
-
this.opts.onError(err, msg);
|
|
151
|
-
else
|
|
152
|
-
console.warn("[avatar] dispatch failed", msg, err);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
handleState(msg) {
|
|
156
|
-
// Deliberately no client-side dedup: pass every `state` command straight
|
|
157
|
-
// through. The widget's own setState already no-ops the parts that matter
|
|
158
|
-
// for an unchanged name (`changed` gates the blink and the 'state' event in
|
|
159
|
-
// avatar.js), and a server resending the same state name as a
|
|
160
|
-
// keepalive/resync must still land so an `emotion`/`gaze` override on this
|
|
161
|
-
// particular message takes effect.
|
|
162
|
-
this.avatar.setState(msg.name, { emotion: msg.emotion, gaze: msg.gaze });
|
|
163
|
-
}
|
|
164
|
-
ensureTurn(ctx) {
|
|
165
|
-
if (!this.turn || this.turn.ctx !== ctx) {
|
|
166
|
-
// A different ctx supersedes whatever turn we had — a stale trailing
|
|
167
|
-
// message for the old ctx will find `this.turn.ctx !== ctx` in
|
|
168
|
-
// handleSpeech's stop-guard and be ignored, rather than cutting off the
|
|
169
|
-
// new turn.
|
|
170
|
-
this.turn = { ctx, cues: [], started: false, clock: null };
|
|
171
|
-
}
|
|
172
|
-
return this.turn;
|
|
173
|
-
}
|
|
174
|
-
handleCues(msg) {
|
|
175
|
-
const turn = this.ensureTurn(msg.ctx);
|
|
176
|
-
const kept = turn.cues.filter((c) => c.t < msg.from_ms);
|
|
177
|
-
const discarded = turn.cues.length - kept.length;
|
|
178
|
-
turn.cues = [...kept, ...msg.cues].sort((a, b) => a.t - b.t);
|
|
179
|
-
if (!turn.started) {
|
|
180
|
-
// No clock yet — buffer. `speech start` will hand this over as the turn's
|
|
181
|
-
// first speak() call.
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (discarded === 0) {
|
|
185
|
-
this.avatar.pushCues(msg.cues);
|
|
186
|
-
}
|
|
187
|
-
else {
|
|
188
|
-
this.avatar.speak({ cues: turn.cues, clock: turn.clock });
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
handleSpeech(msg) {
|
|
192
|
-
if (msg.event === "start") {
|
|
193
|
-
const turn = this.ensureTurn(msg.ctx);
|
|
194
|
-
const t0 = this.now();
|
|
195
|
-
const clock = () => this.now() - t0;
|
|
196
|
-
turn.clock = clock;
|
|
197
|
-
turn.started = true;
|
|
198
|
-
this.avatar.speak({ cues: turn.cues, clock });
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// "stop": only act if it names the turn we're actually riding. A stale stop
|
|
202
|
-
// for an already-superseded ctx must not cut off a newer turn.
|
|
203
|
-
if (this.turn && this.turn.ctx === msg.ctx) {
|
|
204
|
-
this.avatar.stopSpeaking();
|
|
205
|
-
this.turn = null;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Subscribe to a live `PipecatClient`'s server messages and dispatch the
|
|
210
|
-
* avatar commands among them — the ones in the protocol's own
|
|
211
|
-
* `{type:"avatar"}` envelope, which `isAvatarMessage` is the definition of.
|
|
212
|
-
* Never throws on a malformed or irrelevant message.
|
|
213
|
-
*
|
|
214
|
-
* @returns an unsubscribe function; call it on unmount or disconnect.
|
|
215
|
-
*/
|
|
216
|
-
attach(client) {
|
|
217
|
-
const onServerMessage = (raw) => this.dispatch(unwrapServerMessage(raw));
|
|
218
|
-
const serverMessage = RTVI_EVENTS.serverMessage;
|
|
219
|
-
client.on(serverMessage, onServerMessage);
|
|
220
|
-
return () => client.off(serverMessage, onServerMessage);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
//# sourceMappingURL=AvatarClient.js.map
|