@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
|
@@ -0,0 +1,712 @@
|
|
|
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
|
+
* Base Pipecat TTS gives each serialized TTS context an opaque `context_id`.
|
|
8
|
+
* The server uses it only to group and splice cue chunks. `botStartedSpeaking`
|
|
9
|
+
* has no context payload, so the browser FIFO-claims the next buffered context
|
|
10
|
+
* at that Pipecat output-lifecycle event. `botStoppedSpeaking` closes it. No
|
|
11
|
+
* avatar-specific speech marker exists.
|
|
12
|
+
*
|
|
13
|
+
* The event says *which* audio is starting, not *when* it is heard, and the
|
|
14
|
+
* turn clock is anchored to the sound itself (`playout.ts` has the
|
|
15
|
+
* measurements that forced it). `attach()` listens to the bot's audio track;
|
|
16
|
+
* at the event, the turn's zero is backdated to a sound that has already begun,
|
|
17
|
+
* or the mouth is held shut until one does. When the track cannot be heard —
|
|
18
|
+
* no `attach()`, no track, a suspended audio graph, or a sound that never
|
|
19
|
+
* arrives within `ONSET_WAIT_MS` — the event is the anchor, as it always was.
|
|
20
|
+
* Which one won is logged once per turn, at `info`, because a desync report is
|
|
21
|
+
* unanswerable without it.
|
|
22
|
+
*
|
|
23
|
+
* The clock then runs `VISUAL_LEAD_MS` ahead of the sound, since the mixer's
|
|
24
|
+
* smoothing makes every mouth shape late by about that much. It is pulled back
|
|
25
|
+
* by any output-device latency beyond what the display already matches (a
|
|
26
|
+
* Bluetooth headset). Within a turn, each resumption after a pause in the
|
|
27
|
+
* track is a second chance to hear where the sound really is — but a mouth
|
|
28
|
+
* opens before its sound, so a resumption heard within the track's usual
|
|
29
|
+
* anticipation of it agrees with the clock. Only a disagreement beyond that is
|
|
30
|
+
* slewed out, at no more than 10 % of clock rate, never jumped, because a
|
|
31
|
+
* mouth that skips is seen and one that runs briefly fast is not.
|
|
32
|
+
*
|
|
33
|
+
* `attach()` subscribes to the avatar server-message channel *and* Pipecat's
|
|
34
|
+
* standard lifecycle events. Server messages carry only what Pipecat cannot:
|
|
35
|
+
* correlated speech/cue timing and deliberate application instructions. The
|
|
36
|
+
* lifecycle events project the factual presence states locally. The server
|
|
37
|
+
* supplies only the three lower-priority states and deliberate,
|
|
38
|
+
* self-completing actions. This keeps the face tied to Pipecat's output truth
|
|
39
|
+
* even if a server state is delayed or stale.
|
|
40
|
+
*
|
|
41
|
+
* ## Cue splice
|
|
42
|
+
*
|
|
43
|
+
* The widget has two cue-track primitives: `speak({cues, clock})` (a full
|
|
44
|
+
* replace) and `pushCues(cues)` (a pure union that can only grow the track,
|
|
45
|
+
* never shrink it). Neither is "discard queued cues at or after `from_ms`,
|
|
46
|
+
* then append" on its own — `pushCues` has no way to drop a stale tail. So
|
|
47
|
+
* this client keeps the turn's canonical cue array itself (kept portion +
|
|
48
|
+
* every appended chunk, spliced on each `cues` message) and picks the cheapest
|
|
49
|
+
* widget call that stays correct:
|
|
50
|
+
*
|
|
51
|
+
* - if the splice's `from_ms` doesn't reach back into anything already
|
|
52
|
+
* queued — the common case past a turn's first sentence, since only the
|
|
53
|
+
* first sentence genuinely plays fast-leg cues — nothing needs discarding:
|
|
54
|
+
* `pushCues(newCues)` is the cheap, correct append.
|
|
55
|
+
* - if it does reach back (a real fast→accurate splice), `pushCues` cannot
|
|
56
|
+
* express the discard; we call `speak()` again with the full spliced
|
|
57
|
+
* canonical array on the turn's original clock. `speak()` is otherwise
|
|
58
|
+
* documented as also killing an in-flight spoken interjection and
|
|
59
|
+
* re-entering `SPEAKING` — both harmless mid-splice (an interjection
|
|
60
|
+
* should not be running while a server track owns the mouth; re-entering
|
|
61
|
+
* an unchanged state is a no-op past the profile/gaze reset the widget
|
|
62
|
+
* already does for a same-name `setState`).
|
|
63
|
+
*
|
|
64
|
+
* Cues commonly arrive **before** `botStartedSpeaking` — the fast leg starts
|
|
65
|
+
* the moment a sentence is handed to TTS, well before bot output begins.
|
|
66
|
+
* Chunks that arrive before the clock is anchored are spliced into the
|
|
67
|
+
* canonical array but not yet handed to the widget; `botStartedSpeaking` hands over
|
|
68
|
+
* whatever has accumulated as the turn's first `speak()` call. So "the first
|
|
69
|
+
* chunk of a turn starts speak()" means the first *widget* call, not
|
|
70
|
+
* necessarily the first *message*.
|
|
71
|
+
*/
|
|
72
|
+
import { BehaviorController } from "../src/behavior.js";
|
|
73
|
+
import { createPlayoutProbe } from "./playout.js";
|
|
74
|
+
import { isAvatarMessage, parseAvatarCommand, } from "./types.js";
|
|
75
|
+
/**
|
|
76
|
+
* Longest the mouth is held for audio `botStartedSpeaking` has announced but
|
|
77
|
+
* the track has not yet carried. The event has been measured leading the first
|
|
78
|
+
* sample by ~160 ms on a local stack; twice that is a stall, and a mouth that
|
|
79
|
+
* starts without its sound is better than one that waits indefinitely.
|
|
80
|
+
*/
|
|
81
|
+
const ONSET_WAIT_MS = 350;
|
|
82
|
+
const ONSET_POLL_MS = 10;
|
|
83
|
+
/**
|
|
84
|
+
* How far the cue clock runs ahead of the sound. The mixer eases every mouth
|
|
85
|
+
* channel toward its target with a 42 ms time constant
|
|
86
|
+
* (`MOUTH_RESPONSE_TAU_S`), which delays a shape by about that much, and a
|
|
87
|
+
* frame is drawn on average half a frame after its time. Picture ahead of sound
|
|
88
|
+
* is the side people forgive (ITU-R BT.1359: sound leading is noticed at about
|
|
89
|
+
* 45 ms, picture leading at about 125 ms), so a small surplus is the safe error.
|
|
90
|
+
*/
|
|
91
|
+
export const VISUAL_LEAD_MS = 50;
|
|
92
|
+
/** Output latency the display already matches: a compositor and a screen are
|
|
93
|
+
* late by about as much as a wired speaker, so only the excess counts. */
|
|
94
|
+
const DISPLAY_LATENCY_MS = 40;
|
|
95
|
+
/** A reported latency beyond this is a broken report, not a device. */
|
|
96
|
+
const MAX_OUTPUT_LATENCY_MS = 250;
|
|
97
|
+
/** A pause in the track at least this long is one the audio can be heard to
|
|
98
|
+
* resume after — shorter ones are usually not digital silence on the wire. */
|
|
99
|
+
const REANCHOR_GAP_MS = 250;
|
|
100
|
+
/** When to look, after the resumption is due, and for how long. The probe
|
|
101
|
+
* sees 170 ms back, so one look catches an early sound as well as a late one. */
|
|
102
|
+
const REANCHOR_LOOK_MS = 100;
|
|
103
|
+
const REANCHOR_WINDOW_MS = 150;
|
|
104
|
+
/**
|
|
105
|
+
* Where a resumption is heard, after the cue that marks it. The track opens
|
|
106
|
+
* the mouth before the sound leaves digital silence: by 30–41 ms at most of the
|
|
107
|
+
* demo corpus's silent resumptions (median 32, both voices) and by up to 105 at
|
|
108
|
+
* a few, and the browser hears the sound a few ms after it leaves the wire. A
|
|
109
|
+
* sound heard anywhere in that band is the clock being right. Taking
|
|
110
|
+
* it as the resumption itself set every later phrase of a turn that much late —
|
|
111
|
+
* the direction people notice — and a mouth left early by an underrun smaller
|
|
112
|
+
* than the band is the direction they forgive (see `VISUAL_LEAD_MS`).
|
|
113
|
+
*/
|
|
114
|
+
const RESUME_HEARD_MS = 30;
|
|
115
|
+
const RESUME_HEARD_RANGE_MS = [0, 130];
|
|
116
|
+
/** The most a re-anchor may speed or slow the clock, as a fraction of real time. */
|
|
117
|
+
const MAX_SLEW = 0.1;
|
|
118
|
+
/**
|
|
119
|
+
* The one `RTVIEvent` member `attach()` subscribes to, spelled as its value.
|
|
120
|
+
*
|
|
121
|
+
* Written out rather than imported because that enum was this module's *only*
|
|
122
|
+
* runtime reference to `@pipecat-ai/client-js`, and one runtime reference makes
|
|
123
|
+
* the whole `/pipecat` subpath fail to load without the peer installed — even
|
|
124
|
+
* for a host that drives `dispatch()` from its own transport and never calls
|
|
125
|
+
* `attach()`. The peer is declared optional; this is what makes that true
|
|
126
|
+
* rather than aspirational.
|
|
127
|
+
*
|
|
128
|
+
* String enums are nominal in TypeScript, so the compiler cannot check these
|
|
129
|
+
* against the real ones from a type-only import. `packages/avatar/test/AvatarClient.test.ts`
|
|
130
|
+
* does it instead, against the actual enum — the devDependency is present
|
|
131
|
+
* exactly where the check belongs and absent from what we ship.
|
|
132
|
+
*/
|
|
133
|
+
export const RTVI_EVENTS = {
|
|
134
|
+
serverMessage: "serverMessage",
|
|
135
|
+
connected: "connected",
|
|
136
|
+
disconnected: "disconnected",
|
|
137
|
+
botReady: "botReady",
|
|
138
|
+
error: "error",
|
|
139
|
+
userStartedSpeaking: "userStartedSpeaking",
|
|
140
|
+
userStoppedSpeaking: "userStoppedSpeaking",
|
|
141
|
+
botStartedSpeaking: "botStartedSpeaking",
|
|
142
|
+
botStoppedSpeaking: "botStoppedSpeaking",
|
|
143
|
+
// Only to find the bot's audio track once the transport has it; which track
|
|
144
|
+
// is re-read from `tracks()`, the one authority for whose it is.
|
|
145
|
+
trackStarted: "trackStarted",
|
|
146
|
+
// Mute is a Pipecat fact, and there is no server state for it: the server's
|
|
147
|
+
// mute strategy emits `UserMuteStarted/StoppedFrame`, the RTVI observer
|
|
148
|
+
// forwards them, and the browser client raises these. So "has muted you" costs no wire verb —
|
|
149
|
+
// reading the events the peer already sends is exactly the authority model.
|
|
150
|
+
userMuteStarted: "userMuteStarted",
|
|
151
|
+
userMuteStopped: "userMuteStopped",
|
|
152
|
+
};
|
|
153
|
+
/** Defensive unwrap for the `RTVIEvent.ServerMessage` `{ data }` quirk: some
|
|
154
|
+
* transports deliver the payload directly and some wrap it once more. */
|
|
155
|
+
function unwrapServerMessage(raw) {
|
|
156
|
+
const obj = (raw ?? {});
|
|
157
|
+
const inner = obj["data"];
|
|
158
|
+
return inner && "type" in inner ? inner : obj;
|
|
159
|
+
}
|
|
160
|
+
export class AvatarClient {
|
|
161
|
+
avatar;
|
|
162
|
+
/** Maps factual/wire intent into the broader client behavior catalog. */
|
|
163
|
+
behavior;
|
|
164
|
+
opts;
|
|
165
|
+
now;
|
|
166
|
+
turn = null;
|
|
167
|
+
turns = new Map();
|
|
168
|
+
pendingCtxs = [];
|
|
169
|
+
closedCtxs = new Set();
|
|
170
|
+
projected = null;
|
|
171
|
+
serverState = null;
|
|
172
|
+
userSpeaking = false;
|
|
173
|
+
botSpeaking = false;
|
|
174
|
+
muted = false;
|
|
175
|
+
listening = false;
|
|
176
|
+
// Idle is earned after a connected, quiet listening interval. A newly
|
|
177
|
+
// mounted avatar is available, not already "stepped aside".
|
|
178
|
+
idle = false;
|
|
179
|
+
failure = null;
|
|
180
|
+
idleTimer = null;
|
|
181
|
+
pendingInterruptedAction = false;
|
|
182
|
+
discardQueuedContextsOnBotStop = false;
|
|
183
|
+
idleDelayMs;
|
|
184
|
+
setTimer;
|
|
185
|
+
clearTimer;
|
|
186
|
+
probe;
|
|
187
|
+
probeTrack = null;
|
|
188
|
+
/** Whether the probe has ever heard an onset. One that has not, and times
|
|
189
|
+
* out, is deaf to this track rather than early to it. */
|
|
190
|
+
probeHeard = false;
|
|
191
|
+
onsetTimer = null;
|
|
192
|
+
reanchorTimer = null;
|
|
193
|
+
constructor(avatar, opts = {}) {
|
|
194
|
+
this.avatar = avatar;
|
|
195
|
+
this.behavior = new BehaviorController(avatar);
|
|
196
|
+
this.opts = opts;
|
|
197
|
+
this.now = opts.now ?? (() => performance.now());
|
|
198
|
+
this.idleDelayMs = opts.idleDelayMs ?? 12_000;
|
|
199
|
+
this.setTimer = opts.setTimeout ?? globalThis.setTimeout.bind(globalThis);
|
|
200
|
+
this.clearTimer = opts.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
|
|
201
|
+
this.probe = opts.playoutProbe ?? null;
|
|
202
|
+
}
|
|
203
|
+
/** The active turn's ctx, or `null` between turns. For tests and telemetry. */
|
|
204
|
+
get turnCtx() {
|
|
205
|
+
return this.turn?.ctx ?? null;
|
|
206
|
+
}
|
|
207
|
+
/** The active turn's canonical (already-spliced) cue track. For tests and telemetry. */
|
|
208
|
+
get turnCues() {
|
|
209
|
+
return this.turn ? [...this.turn.cues] : [];
|
|
210
|
+
}
|
|
211
|
+
/** Current resolved projection. Internal — for tests and Studio's inspector.
|
|
212
|
+
* Before the first lifecycle fact the renderer is simply at its ready rest
|
|
213
|
+
* pose, so `LISTENING` is the safe value; it is not a session status. */
|
|
214
|
+
get presenceState() {
|
|
215
|
+
return this.projected ?? "LISTENING";
|
|
216
|
+
}
|
|
217
|
+
/** Re-apply the currently resolved factual projection after an embedding
|
|
218
|
+
* tool has temporarily used raw renderer controls. This does not invent a
|
|
219
|
+
* lifecycle event or cancel a finite action. */
|
|
220
|
+
restoreProjection() {
|
|
221
|
+
this.applyProjection(true);
|
|
222
|
+
}
|
|
223
|
+
/** Dispatch one server message. Anything that isn't in the avatar envelope
|
|
224
|
+
* is not ours and is ignored; so is an envelope carrying a `cmd` this build
|
|
225
|
+
* has never heard of, per the wire protocol's forward-compat rule. */
|
|
226
|
+
dispatch(raw) {
|
|
227
|
+
if (!isAvatarMessage(raw))
|
|
228
|
+
return;
|
|
229
|
+
const msg = parseAvatarCommand(raw);
|
|
230
|
+
if (!msg)
|
|
231
|
+
return;
|
|
232
|
+
try {
|
|
233
|
+
switch (msg.cmd) {
|
|
234
|
+
case "state":
|
|
235
|
+
this.handleState(msg.state);
|
|
236
|
+
break;
|
|
237
|
+
case "action":
|
|
238
|
+
this.handleAction(msg.id);
|
|
239
|
+
break;
|
|
240
|
+
case "cues":
|
|
241
|
+
this.handleCues(msg);
|
|
242
|
+
break;
|
|
243
|
+
// No default: an unknown `cmd` is a newer server talking to an older
|
|
244
|
+
// widget, and the protocol's forward-compat rule says ignore it. There
|
|
245
|
+
// is no callback for it — a hook nobody could act on is observability,
|
|
246
|
+
// not an interface.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (this.opts.onError)
|
|
251
|
+
this.opts.onError(err, msg);
|
|
252
|
+
else
|
|
253
|
+
console.warn("[avatar] dispatch failed", msg, err);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
handleState(state) {
|
|
257
|
+
this.serverState = state;
|
|
258
|
+
if (state)
|
|
259
|
+
this.idle = false;
|
|
260
|
+
this.applyProjection();
|
|
261
|
+
this.armIdleIfEligible();
|
|
262
|
+
}
|
|
263
|
+
handleAction(id) {
|
|
264
|
+
// An interruption is a server-confirmed explanation of a transition, not
|
|
265
|
+
// authority to steal the mouth while bot audio is still playing. Hold it
|
|
266
|
+
// until Pipecat output has released the speaking state.
|
|
267
|
+
if (id === "RESPONSE_INTERRUPTED" && this.botSpeaking) {
|
|
268
|
+
this.pendingInterruptedAction = true;
|
|
269
|
+
// Any prefetched TTS contexts behind interrupted output belong to
|
|
270
|
+
// audio Pipecat will now discard. Never let one animate a later reply.
|
|
271
|
+
this.discardQueuedContextsOnBotStop = true;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.playAction(id);
|
|
275
|
+
}
|
|
276
|
+
playAction(id) {
|
|
277
|
+
this.behavior.wireAction(id);
|
|
278
|
+
}
|
|
279
|
+
ensureTurn(ctx) {
|
|
280
|
+
const existing = this.turns.get(ctx);
|
|
281
|
+
if (existing)
|
|
282
|
+
return existing;
|
|
283
|
+
const turn = {
|
|
284
|
+
ctx, cues: [], started: false, clock: null, t0: null,
|
|
285
|
+
target: null, read: 0, lead: 0, checked: -Infinity,
|
|
286
|
+
};
|
|
287
|
+
this.turns.set(ctx, turn);
|
|
288
|
+
this.pendingCtxs.push(ctx);
|
|
289
|
+
return turn;
|
|
290
|
+
}
|
|
291
|
+
handleCues(msg) {
|
|
292
|
+
if (this.closedCtxs.has(msg.ctx))
|
|
293
|
+
return;
|
|
294
|
+
const turn = this.ensureTurn(msg.ctx);
|
|
295
|
+
const kept = turn.cues.filter((c) => c.t < msg.from_ms);
|
|
296
|
+
const discarded = turn.cues.length - kept.length;
|
|
297
|
+
turn.cues = [...kept, ...msg.cues].sort((a, b) => a.t - b.t);
|
|
298
|
+
if (!turn.started) {
|
|
299
|
+
// No clock yet — buffer. Pipecat output will claim this FIFO context.
|
|
300
|
+
if (this.botSpeaking && this.turn === null)
|
|
301
|
+
this.activateNextTurn();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (discarded === 0) {
|
|
305
|
+
this.avatar.pushCues(msg.cues);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
this.avatar.speak({ cues: turn.cues, clock: turn.clock });
|
|
309
|
+
}
|
|
310
|
+
// A resumption this chunk added may be the next one worth listening for.
|
|
311
|
+
this.scheduleReanchor(turn);
|
|
312
|
+
}
|
|
313
|
+
activateNextTurn() {
|
|
314
|
+
if (this.turn)
|
|
315
|
+
return;
|
|
316
|
+
let turn;
|
|
317
|
+
while (this.pendingCtxs.length && !turn) {
|
|
318
|
+
turn = this.turns.get(this.pendingCtxs.shift());
|
|
319
|
+
}
|
|
320
|
+
if (!turn)
|
|
321
|
+
return;
|
|
322
|
+
const active = turn;
|
|
323
|
+
active.lead = this.leadMs();
|
|
324
|
+
active.clock = () => {
|
|
325
|
+
if (active.t0 === null)
|
|
326
|
+
return -1;
|
|
327
|
+
const now = this.now();
|
|
328
|
+
if (active.target !== null) {
|
|
329
|
+
const room = MAX_SLEW * Math.max(0, now - active.read);
|
|
330
|
+
const off = active.target - active.t0;
|
|
331
|
+
if (Math.abs(off) <= room) {
|
|
332
|
+
active.t0 = active.target;
|
|
333
|
+
active.target = null;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
active.t0 += Math.sign(off) * room;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
active.read = now;
|
|
340
|
+
return now - active.t0 + active.lead;
|
|
341
|
+
};
|
|
342
|
+
active.started = true;
|
|
343
|
+
this.turn = active;
|
|
344
|
+
this.anchor(active);
|
|
345
|
+
this.avatar.speak({ cues: active.cues, clock: active.clock });
|
|
346
|
+
}
|
|
347
|
+
/** Put the turn's zero where its sound began — see the header. */
|
|
348
|
+
anchor(turn) {
|
|
349
|
+
const event = this.now();
|
|
350
|
+
// A context made before any gesture can sit suspended; every turn asks again.
|
|
351
|
+
this.probe?.resume?.();
|
|
352
|
+
const heard = this.probe?.onset();
|
|
353
|
+
if (typeof heard === "number") {
|
|
354
|
+
this.probeHeard = true;
|
|
355
|
+
this.anchored(turn, heard, "the sound", event);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (heard !== null) {
|
|
359
|
+
this.anchored(turn, event, this.probe ? "the event (probe cannot say)" : "the event (no probe)", event);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const poll = () => {
|
|
363
|
+
this.onsetTimer = null;
|
|
364
|
+
if (this.turn !== turn)
|
|
365
|
+
return;
|
|
366
|
+
const onset = this.probe?.onset();
|
|
367
|
+
if (typeof onset === "number") {
|
|
368
|
+
this.probeHeard = true;
|
|
369
|
+
this.anchored(turn, onset, "the sound", event);
|
|
370
|
+
}
|
|
371
|
+
else if (onset === null && this.now() - event < ONSET_WAIT_MS) {
|
|
372
|
+
this.onsetTimer = this.setTimer(poll, ONSET_POLL_MS);
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
if (!this.probeHeard)
|
|
376
|
+
this.dropProbe();
|
|
377
|
+
this.anchored(turn, event, onset === null ? "the event (no sound in time)" : "the event (probe cannot say)", event);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
this.onsetTimer = this.setTimer(poll, ONSET_POLL_MS);
|
|
381
|
+
}
|
|
382
|
+
anchored(turn, t0, how, event) {
|
|
383
|
+
turn.t0 = t0;
|
|
384
|
+
turn.read = this.now();
|
|
385
|
+
console.info(`[avatar] ${turn.ctx} anchored on ${how}: sound ${Math.round(event - t0)} ms before the event, `
|
|
386
|
+
+ `mouth ${Math.round(turn.lead)} ms ahead`);
|
|
387
|
+
this.scheduleReanchor(turn);
|
|
388
|
+
}
|
|
389
|
+
/** The lead for a turn starting now — see `VISUAL_LEAD_MS`. */
|
|
390
|
+
leadMs() {
|
|
391
|
+
const out = this.probe?.outputLatencyMs?.() ?? 0;
|
|
392
|
+
const excess = Math.min(MAX_OUTPUT_LATENCY_MS, Math.max(0, out - DISPLAY_LATENCY_MS));
|
|
393
|
+
return VISUAL_LEAD_MS - excess;
|
|
394
|
+
}
|
|
395
|
+
/** The first resumption after `after` (track ms) that follows a pause long
|
|
396
|
+
* enough to be heard as one. */
|
|
397
|
+
nextResumption(turn, after) {
|
|
398
|
+
let silentFrom = null;
|
|
399
|
+
for (const c of turn.cues) {
|
|
400
|
+
if (c.v === "X") {
|
|
401
|
+
silentFrom ??= c.t;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (silentFrom !== null && c.t - silentFrom >= REANCHOR_GAP_MS && c.t > after)
|
|
405
|
+
return c.t;
|
|
406
|
+
silentFrom = null;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
/** Listen for the sound at the turn's next resumption, once. */
|
|
411
|
+
scheduleReanchor(turn) {
|
|
412
|
+
if (this.reanchorTimer !== null || !this.probe || turn.t0 === null || this.turn !== turn)
|
|
413
|
+
return;
|
|
414
|
+
const zero = turn.target ?? turn.t0;
|
|
415
|
+
const at = this.nextResumption(turn, Math.max(turn.checked, this.now() - zero - REANCHOR_WINDOW_MS));
|
|
416
|
+
if (at === null)
|
|
417
|
+
return;
|
|
418
|
+
const poll = () => {
|
|
419
|
+
this.reanchorTimer = null;
|
|
420
|
+
if (this.turn !== turn || !this.probe || turn.t0 === null)
|
|
421
|
+
return;
|
|
422
|
+
const due = (turn.target ?? turn.t0) + at;
|
|
423
|
+
const onset = this.probe.onset();
|
|
424
|
+
if (onset === null && this.now() < due + RESUME_HEARD_MS + REANCHOR_WINDOW_MS) {
|
|
425
|
+
this.reanchorTimer = this.setTimer(poll, ONSET_POLL_MS);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
turn.checked = at;
|
|
429
|
+
// `undefined` is sound all the way back: the pause was not silence on
|
|
430
|
+
// the wire, or it resumed long before the track says. Either way there
|
|
431
|
+
// is nothing to measure.
|
|
432
|
+
if (typeof onset === "number") {
|
|
433
|
+
const heard = onset - due;
|
|
434
|
+
const [earliest, latest] = RESUME_HEARD_RANGE_MS;
|
|
435
|
+
const agrees = heard >= earliest && heard <= latest;
|
|
436
|
+
if (!agrees && Math.abs(heard - RESUME_HEARD_MS) <= REANCHOR_WINDOW_MS) {
|
|
437
|
+
turn.target = onset - at - RESUME_HEARD_MS;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
this.scheduleReanchor(turn);
|
|
441
|
+
};
|
|
442
|
+
const zeroNow = turn.target ?? turn.t0;
|
|
443
|
+
this.reanchorTimer = this.setTimer(poll, Math.max(0, zeroNow + at + REANCHOR_LOOK_MS - this.now()));
|
|
444
|
+
}
|
|
445
|
+
clearReanchorTimer() {
|
|
446
|
+
if (this.reanchorTimer !== null) {
|
|
447
|
+
this.clearTimer(this.reanchorTimer);
|
|
448
|
+
this.reanchorTimer = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
clearOnsetTimer() {
|
|
452
|
+
if (this.onsetTimer !== null) {
|
|
453
|
+
this.clearTimer(this.onsetTimer);
|
|
454
|
+
this.onsetTimer = null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
dropProbe() {
|
|
458
|
+
this.probe?.dispose();
|
|
459
|
+
this.probe = null;
|
|
460
|
+
this.probeTrack = null;
|
|
461
|
+
this.probeHeard = false;
|
|
462
|
+
}
|
|
463
|
+
/** Listen to the bot's audio track, if the transport has one yet. */
|
|
464
|
+
listenTo(client, heard) {
|
|
465
|
+
if (this.opts.playoutProbe)
|
|
466
|
+
return;
|
|
467
|
+
let track = heard;
|
|
468
|
+
if (!track) {
|
|
469
|
+
try {
|
|
470
|
+
track = client.tracks().bot?.audio;
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (!track || track === this.probeTrack)
|
|
477
|
+
return;
|
|
478
|
+
this.dropProbe();
|
|
479
|
+
this.probe = createPlayoutProbe(track, this.now);
|
|
480
|
+
this.probeTrack = this.probe ? track : null;
|
|
481
|
+
}
|
|
482
|
+
discardQueuedTurns() {
|
|
483
|
+
for (const ctx of this.pendingCtxs.splice(0)) {
|
|
484
|
+
this.closedCtxs.add(ctx);
|
|
485
|
+
this.turns.delete(ctx);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
lifecycleState() {
|
|
489
|
+
// The one copy of the ladder is docs/pipecat-lifecycle-protocol.md; this is
|
|
490
|
+
// its implementation, in the same order.
|
|
491
|
+
//
|
|
492
|
+
// Audio truth is the P0 invariant: no lower state or microphone event may
|
|
493
|
+
// put the face in a non-speaking pose while bot speech is audible.
|
|
494
|
+
if (this.botSpeaking)
|
|
495
|
+
return "SPEAKING";
|
|
496
|
+
if (this.userSpeaking)
|
|
497
|
+
return "LISTENING";
|
|
498
|
+
// A broken session outranks mute, because a mute nobody can hear about is
|
|
499
|
+
// not the thing worth saying about a dead call.
|
|
500
|
+
if (this.failure)
|
|
501
|
+
return this.failure;
|
|
502
|
+
if (this.muted)
|
|
503
|
+
return "MUTED";
|
|
504
|
+
// A server state *is* the presence state of the same name, so there is
|
|
505
|
+
// nothing to rank here — only one can be in flight, and the command sets
|
|
506
|
+
// rather than adds. CANT_HEAR > THINKING > WORKING is decided where more
|
|
507
|
+
// than one condition can hold at once, which is the server:
|
|
508
|
+
// `AvatarStateMachine._resolve()`. Ranking them again on this side would be
|
|
509
|
+
// a second, silent copy of that ladder, and the two would drift.
|
|
510
|
+
if (this.serverState)
|
|
511
|
+
return this.serverState;
|
|
512
|
+
if (this.idle)
|
|
513
|
+
return "IDLE";
|
|
514
|
+
return "LISTENING";
|
|
515
|
+
}
|
|
516
|
+
applyProjection(force = false) {
|
|
517
|
+
const state = this.lifecycleState();
|
|
518
|
+
if (force || this.projected !== state) {
|
|
519
|
+
this.behavior.setState(state);
|
|
520
|
+
this.projected = state;
|
|
521
|
+
this.opts.onPresenceChange?.(state);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
clearIdleTimer() {
|
|
525
|
+
if (this.idleTimer !== null) {
|
|
526
|
+
this.clearTimer(this.idleTimer);
|
|
527
|
+
this.idleTimer = null;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
armIdleIfEligible() {
|
|
531
|
+
this.clearIdleTimer();
|
|
532
|
+
// Quiet under a mute is not the quiet that earns IDLE — the silence was
|
|
533
|
+
// imposed, and letting the timer run behind it would reveal a stepped-aside
|
|
534
|
+
// face the moment the microphone came back.
|
|
535
|
+
if (!this.eligibleForIdle())
|
|
536
|
+
return;
|
|
537
|
+
this.idleTimer = this.setTimer(() => {
|
|
538
|
+
this.idleTimer = null;
|
|
539
|
+
if (this.eligibleForIdle()) {
|
|
540
|
+
this.idle = true;
|
|
541
|
+
this.applyProjection();
|
|
542
|
+
}
|
|
543
|
+
}, this.idleDelayMs);
|
|
544
|
+
}
|
|
545
|
+
eligibleForIdle() {
|
|
546
|
+
return this.listening && !this.userSpeaking && !this.botSpeaking
|
|
547
|
+
&& !this.muted && !this.serverState && !this.failure;
|
|
548
|
+
}
|
|
549
|
+
enterListening() {
|
|
550
|
+
this.listening = true;
|
|
551
|
+
this.idle = false;
|
|
552
|
+
this.applyProjection();
|
|
553
|
+
this.armIdleIfEligible();
|
|
554
|
+
}
|
|
555
|
+
clearServerStateForTurnBoundary() {
|
|
556
|
+
// A server state is a lower-priority hint. A fresh user turn or bot output
|
|
557
|
+
// means any prior thinking/working one may not reappear later.
|
|
558
|
+
this.serverState = null;
|
|
559
|
+
}
|
|
560
|
+
maybePlayInterrupted() {
|
|
561
|
+
if (this.pendingInterruptedAction && !this.botSpeaking) {
|
|
562
|
+
this.pendingInterruptedAction = false;
|
|
563
|
+
this.playAction("RESPONSE_INTERRUPTED");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
clearRecoverableFailure() {
|
|
567
|
+
if (this.failure === "DEGRADED") {
|
|
568
|
+
this.failure = null;
|
|
569
|
+
this.applyProjection();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
onUserStartedSpeaking = () => {
|
|
573
|
+
this.clearRecoverableFailure();
|
|
574
|
+
this.userSpeaking = true;
|
|
575
|
+
this.clearServerStateForTurnBoundary();
|
|
576
|
+
this.listening = true;
|
|
577
|
+
this.idle = false;
|
|
578
|
+
this.clearIdleTimer();
|
|
579
|
+
this.avatar.setUserSpeaking(true);
|
|
580
|
+
this.applyProjection();
|
|
581
|
+
};
|
|
582
|
+
onUserStoppedSpeaking = () => {
|
|
583
|
+
this.userSpeaking = false;
|
|
584
|
+
this.avatar.setUserSpeaking(false);
|
|
585
|
+
this.enterListening();
|
|
586
|
+
};
|
|
587
|
+
onBotStartedSpeaking = () => {
|
|
588
|
+
this.clearRecoverableFailure();
|
|
589
|
+
this.botSpeaking = true;
|
|
590
|
+
this.clearServerStateForTurnBoundary();
|
|
591
|
+
this.idle = false;
|
|
592
|
+
this.clearIdleTimer();
|
|
593
|
+
this.applyProjection();
|
|
594
|
+
this.activateNextTurn();
|
|
595
|
+
};
|
|
596
|
+
onBotStoppedSpeaking = () => {
|
|
597
|
+
this.botSpeaking = false;
|
|
598
|
+
this.clearOnsetTimer();
|
|
599
|
+
this.clearReanchorTimer();
|
|
600
|
+
// Playout truth releases the only active mouth track. A late cue chunk for
|
|
601
|
+
// this context is ignored rather than reviving a silent mouth.
|
|
602
|
+
if (this.turn) {
|
|
603
|
+
this.avatar.stopSpeaking();
|
|
604
|
+
this.closedCtxs.add(this.turn.ctx);
|
|
605
|
+
this.turns.delete(this.turn.ctx);
|
|
606
|
+
this.turn = null;
|
|
607
|
+
}
|
|
608
|
+
if (this.discardQueuedContextsOnBotStop) {
|
|
609
|
+
this.discardQueuedContextsOnBotStop = false;
|
|
610
|
+
this.discardQueuedTurns();
|
|
611
|
+
}
|
|
612
|
+
this.enterListening();
|
|
613
|
+
this.maybePlayInterrupted();
|
|
614
|
+
};
|
|
615
|
+
onUserMuteStarted = () => {
|
|
616
|
+
this.muted = true;
|
|
617
|
+
this.idle = false;
|
|
618
|
+
this.clearIdleTimer();
|
|
619
|
+
this.applyProjection();
|
|
620
|
+
};
|
|
621
|
+
onUserMuteStopped = () => {
|
|
622
|
+
this.muted = false;
|
|
623
|
+
// Whatever the avatar was waiting on before the mute, it is waiting on the
|
|
624
|
+
// user again now — the same place a turn ends.
|
|
625
|
+
this.enterListening();
|
|
626
|
+
};
|
|
627
|
+
onError = (raw) => {
|
|
628
|
+
const data = raw?.data;
|
|
629
|
+
this.failure = data?.fatal === true ? "OFFLINE" : "DEGRADED";
|
|
630
|
+
this.applyProjection();
|
|
631
|
+
};
|
|
632
|
+
onDisconnected = () => {
|
|
633
|
+
this.failure = "OFFLINE";
|
|
634
|
+
this.clearIdleTimer();
|
|
635
|
+
this.applyProjection();
|
|
636
|
+
};
|
|
637
|
+
onConnectedOrReady = () => {
|
|
638
|
+
if (this.failure === "OFFLINE")
|
|
639
|
+
this.failure = null;
|
|
640
|
+
// Pipecat has established a session. Until a factual speech event says
|
|
641
|
+
// otherwise, the avatar is available to listen; it earns IDLE only after
|
|
642
|
+
// the regular quiet timer expires.
|
|
643
|
+
this.listening = true;
|
|
644
|
+
this.idle = false;
|
|
645
|
+
this.applyProjection();
|
|
646
|
+
this.armIdleIfEligible();
|
|
647
|
+
};
|
|
648
|
+
/**
|
|
649
|
+
* Subscribe to a live `PipecatClient`. Standard client events own the normal
|
|
650
|
+
* lifecycle projection; avatar server-messages carry correlated visemes and
|
|
651
|
+
* explicit application intent. Never throws on malformed or irrelevant
|
|
652
|
+
* server messages.
|
|
653
|
+
*
|
|
654
|
+
* @returns an unsubscribe function; call it on unmount or disconnect.
|
|
655
|
+
*/
|
|
656
|
+
attach(client) {
|
|
657
|
+
const onServerMessage = (raw) => this.dispatch(unwrapServerMessage(raw));
|
|
658
|
+
// The bot's track has to come from the event. SmallWebRTC never lists a
|
|
659
|
+
// remote track in `tracks()`; it only hands it to this callback, with no
|
|
660
|
+
// participant. Asking `tracks()` alone meant no probe on that transport,
|
|
661
|
+
// so every turn fell back to the event.
|
|
662
|
+
const onTrackStarted = (track, participant) => {
|
|
663
|
+
if (participant?.local || (track && track.kind !== "audio"))
|
|
664
|
+
return;
|
|
665
|
+
this.listenTo(client, track);
|
|
666
|
+
};
|
|
667
|
+
const subscriptions = [
|
|
668
|
+
[RTVI_EVENTS.serverMessage, onServerMessage],
|
|
669
|
+
[RTVI_EVENTS.connected, this.onConnectedOrReady],
|
|
670
|
+
[RTVI_EVENTS.botReady, this.onConnectedOrReady],
|
|
671
|
+
[RTVI_EVENTS.disconnected, this.onDisconnected],
|
|
672
|
+
[RTVI_EVENTS.error, this.onError],
|
|
673
|
+
[RTVI_EVENTS.userStartedSpeaking, this.onUserStartedSpeaking],
|
|
674
|
+
[RTVI_EVENTS.userStoppedSpeaking, this.onUserStoppedSpeaking],
|
|
675
|
+
[RTVI_EVENTS.botStartedSpeaking, this.onBotStartedSpeaking],
|
|
676
|
+
[RTVI_EVENTS.botStoppedSpeaking, this.onBotStoppedSpeaking],
|
|
677
|
+
[RTVI_EVENTS.userMuteStarted, this.onUserMuteStarted],
|
|
678
|
+
[RTVI_EVENTS.userMuteStopped, this.onUserMuteStopped],
|
|
679
|
+
[RTVI_EVENTS.trackStarted, onTrackStarted],
|
|
680
|
+
];
|
|
681
|
+
for (const [event, listener] of subscriptions)
|
|
682
|
+
client.on(event, listener);
|
|
683
|
+
this.listenTo(client);
|
|
684
|
+
// Some browsers keep an audio context suspended until it is resumed inside
|
|
685
|
+
// a gesture, and the connect click usually lands before the bot's track
|
|
686
|
+
// exists. Any later click or key is the next chance.
|
|
687
|
+
const wake = () => this.probe?.resume?.();
|
|
688
|
+
const doc = typeof document === "undefined" ? null : document;
|
|
689
|
+
doc?.addEventListener("pointerdown", wake, true);
|
|
690
|
+
doc?.addEventListener("keydown", wake, true);
|
|
691
|
+
return () => {
|
|
692
|
+
doc?.removeEventListener("pointerdown", wake, true);
|
|
693
|
+
doc?.removeEventListener("keydown", wake, true);
|
|
694
|
+
this.clearIdleTimer();
|
|
695
|
+
this.clearOnsetTimer();
|
|
696
|
+
this.clearReanchorTimer();
|
|
697
|
+
if (!this.opts.playoutProbe)
|
|
698
|
+
this.dropProbe();
|
|
699
|
+
for (const [event, listener] of subscriptions)
|
|
700
|
+
client.off(event, listener);
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
/** Dispose controller-owned timers when its mounted avatar is destroyed. */
|
|
704
|
+
destroy() {
|
|
705
|
+
this.clearIdleTimer();
|
|
706
|
+
this.clearOnsetTimer();
|
|
707
|
+
this.clearReanchorTimer();
|
|
708
|
+
this.dropProbe();
|
|
709
|
+
this.behavior.destroy();
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
//# sourceMappingURL=AvatarClient.js.map
|