@voqalize/avatar 0.3.0 → 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.
Files changed (132) hide show
  1. package/LICENSE-CC-BY-4.0 +396 -0
  2. package/README.md +65 -17
  3. package/assets/README.md +48 -0
  4. package/assets/tanya.glb +0 -0
  5. package/assets/tara.glb +0 -0
  6. package/assets/tushar.glb +0 -0
  7. package/client/AvatarClient.ts +337 -42
  8. package/client/arjun.ts +2 -0
  9. package/client/createAvatar.ts +38 -0
  10. package/client/index.ts +6 -1
  11. package/client/internal.ts +31 -3
  12. package/client/ishita.ts +2 -0
  13. package/client/kabir.ts +2 -0
  14. package/client/meera.ts +2 -0
  15. package/client/naina.ts +2 -0
  16. package/client/playout.ts +95 -0
  17. package/client/supports.ts +28 -0
  18. package/client/three/assets.ts +24 -0
  19. package/client/three/budgets.ts +27 -0
  20. package/client/three/holds.ts +64 -0
  21. package/client/three/internal.ts +54 -0
  22. package/client/three/motion-limits.json +116 -0
  23. package/client/three/sequences.ts +281 -0
  24. package/client/three/tanya.ts +69 -0
  25. package/client/three/tara-rig.ts +1562 -0
  26. package/client/three/tara.ts +76 -0
  27. package/client/three/tushar.ts +65 -0
  28. package/client/types.ts +109 -20
  29. package/client/vikram.ts +2 -0
  30. package/dist/AvatarClient.d.ts +89 -11
  31. package/dist/AvatarClient.d.ts.map +1 -1
  32. package/dist/AvatarClient.js +297 -35
  33. package/dist/AvatarClient.js.map +1 -1
  34. package/dist/arjun.d.ts +1 -0
  35. package/dist/arjun.d.ts.map +1 -1
  36. package/dist/arjun.js +2 -0
  37. package/dist/arjun.js.map +1 -1
  38. package/dist/createAvatar.d.ts +36 -0
  39. package/dist/createAvatar.d.ts.map +1 -1
  40. package/dist/createAvatar.js.map +1 -1
  41. package/dist/index.d.ts +6 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/internal.d.ts +9 -5
  46. package/dist/internal.d.ts.map +1 -1
  47. package/dist/internal.js +13 -5
  48. package/dist/internal.js.map +1 -1
  49. package/dist/ishita.d.ts +1 -0
  50. package/dist/ishita.d.ts.map +1 -1
  51. package/dist/ishita.js +2 -0
  52. package/dist/ishita.js.map +1 -1
  53. package/dist/kabir.d.ts +1 -0
  54. package/dist/kabir.d.ts.map +1 -1
  55. package/dist/kabir.js +2 -0
  56. package/dist/kabir.js.map +1 -1
  57. package/dist/meera.d.ts +1 -0
  58. package/dist/meera.d.ts.map +1 -1
  59. package/dist/meera.js +2 -0
  60. package/dist/meera.js.map +1 -1
  61. package/dist/naina.d.ts +1 -0
  62. package/dist/naina.d.ts.map +1 -1
  63. package/dist/naina.js +2 -0
  64. package/dist/naina.js.map +1 -1
  65. package/dist/playout.d.ts +42 -0
  66. package/dist/playout.d.ts.map +1 -0
  67. package/dist/playout.js +85 -0
  68. package/dist/playout.js.map +1 -0
  69. package/dist/supports.d.ts +20 -0
  70. package/dist/supports.d.ts.map +1 -0
  71. package/dist/supports.js +26 -0
  72. package/dist/supports.js.map +1 -0
  73. package/dist/three/assets.d.ts +24 -0
  74. package/dist/three/assets.d.ts.map +1 -0
  75. package/dist/three/assets.js +22 -0
  76. package/dist/three/assets.js.map +1 -0
  77. package/dist/three/budgets.d.ts +17 -0
  78. package/dist/three/budgets.d.ts.map +1 -0
  79. package/dist/three/budgets.js +21 -0
  80. package/dist/three/budgets.js.map +1 -0
  81. package/dist/three/holds.d.ts +33 -0
  82. package/dist/three/holds.d.ts.map +1 -0
  83. package/dist/three/holds.js +56 -0
  84. package/dist/three/holds.js.map +1 -0
  85. package/dist/three/internal.d.ts +52 -0
  86. package/dist/three/internal.d.ts.map +1 -0
  87. package/dist/three/internal.js +49 -0
  88. package/dist/three/internal.js.map +1 -0
  89. package/dist/three/motion-limits.json +116 -0
  90. package/dist/three/sequences.d.ts +71 -0
  91. package/dist/three/sequences.d.ts.map +1 -0
  92. package/dist/three/sequences.js +262 -0
  93. package/dist/three/sequences.js.map +1 -0
  94. package/dist/three/tanya.d.ts +35 -0
  95. package/dist/three/tanya.d.ts.map +1 -0
  96. package/dist/three/tanya.js +51 -0
  97. package/dist/three/tanya.js.map +1 -0
  98. package/dist/three/tara-rig.d.ts +403 -0
  99. package/dist/three/tara-rig.d.ts.map +1 -0
  100. package/dist/three/tara-rig.js +1500 -0
  101. package/dist/three/tara-rig.js.map +1 -0
  102. package/dist/three/tara.d.ts +35 -0
  103. package/dist/three/tara.d.ts.map +1 -0
  104. package/dist/three/tara.js +58 -0
  105. package/dist/three/tara.js.map +1 -0
  106. package/dist/three/tushar.d.ts +31 -0
  107. package/dist/three/tushar.d.ts.map +1 -0
  108. package/dist/three/tushar.js +47 -0
  109. package/dist/three/tushar.js.map +1 -0
  110. package/dist/types.d.ts +65 -10
  111. package/dist/types.d.ts.map +1 -1
  112. package/dist/types.js +58 -11
  113. package/dist/types.js.map +1 -1
  114. package/dist/vikram.d.ts +1 -0
  115. package/dist/vikram.d.ts.map +1 -1
  116. package/dist/vikram.js +2 -0
  117. package/dist/vikram.js.map +1 -1
  118. package/package.json +34 -6
  119. package/src/avatar.d.ts +59 -2
  120. package/src/avatar.js +743 -94
  121. package/src/behavior.d.ts +28 -12
  122. package/src/behavior.js +54 -42
  123. package/src/canvas/create-rig.js +1 -1
  124. package/src/canvas/src/live.js +2 -2
  125. package/src/conformance.js +1 -1
  126. package/src/emotions.js +4 -2
  127. package/src/gaze.js +334 -73
  128. package/src/head.js +134 -0
  129. package/src/idle.js +210 -19
  130. package/src/interjections.js +70 -26
  131. package/src/prosody.js +647 -0
  132. package/src/visemes.js +85 -15
@@ -7,17 +7,36 @@
7
7
  * Base Pipecat TTS gives each serialized TTS context an opaque `context_id`.
8
8
  * The server uses it only to group and splice cue chunks. `botStartedSpeaking`
9
9
  * has no context payload, so the browser FIFO-claims the next buffered context
10
- * at that Pipecat output-lifecycle event and anchors its clock there.
11
- * `botStoppedSpeaking` closes the active context. Neither event observes the
12
- * browser's audio device, and no avatar-specific speech marker exists.
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.
13
32
  *
14
33
  * `attach()` subscribes to the avatar server-message channel *and* Pipecat's
15
34
  * standard lifecycle events. Server messages carry only what Pipecat cannot:
16
35
  * correlated speech/cue timing and deliberate application instructions. The
17
36
  * lifecycle events project the factual presence states locally. The server
18
- * supplies only lower-priority `THINKING` / `WORKING` claims and deliberate,
37
+ * supplies only the three lower-priority states and deliberate,
19
38
  * self-completing actions. This keeps the face tied to Pipecat's output truth
20
- * even if a server claim is delayed or stale.
39
+ * even if a server state is delayed or stale.
21
40
  *
22
41
  * ## Cue splice
23
42
  *
@@ -52,8 +71,8 @@
52
71
  */
53
72
 
54
73
  import type { PipecatClient, RTVIEvent } from "@pipecat-ai/client-js";
55
- import type { AvatarActionId, AvatarApi } from "../src/avatar.js";
56
74
  import { BehaviorController } from "../src/behavior.js";
75
+ import { createPlayoutProbe, type PlayoutProbe } from "./playout.js";
57
76
  import {
58
77
  isAvatarMessage,
59
78
  parseAvatarCommand,
@@ -70,6 +89,84 @@ interface Turn {
70
89
  /** Whether Pipecat output has anchored a clock and issued `speak()`. */
71
90
  started: boolean;
72
91
  clock: (() => number) | null;
92
+ /** Timeline zero on the `now()` clock; `null` while waiting to hear the
93
+ * audio begin, during which the clock reads before the first cue. */
94
+ t0: number | null;
95
+ /** Where a re-anchor has placed zero; `t0` slews toward it. */
96
+ target: number | null;
97
+ /** `now()` at the clock's last read, which bounds how far a slew may move. */
98
+ read: number;
99
+ /** How far the clock runs ahead of the sound, fixed for the turn. */
100
+ lead: number;
101
+ /** The last resumption (track ms) already listened for. */
102
+ checked: number;
103
+ }
104
+
105
+ /**
106
+ * Longest the mouth is held for audio `botStartedSpeaking` has announced but
107
+ * the track has not yet carried. The event has been measured leading the first
108
+ * sample by ~160 ms on a local stack; twice that is a stall, and a mouth that
109
+ * starts without its sound is better than one that waits indefinitely.
110
+ */
111
+ const ONSET_WAIT_MS = 350;
112
+ const ONSET_POLL_MS = 10;
113
+
114
+ /**
115
+ * How far the cue clock runs ahead of the sound. The mixer eases every mouth
116
+ * channel toward its target with a 42 ms time constant
117
+ * (`MOUTH_RESPONSE_TAU_S`), which delays a shape by about that much, and a
118
+ * frame is drawn on average half a frame after its time. Picture ahead of sound
119
+ * is the side people forgive (ITU-R BT.1359: sound leading is noticed at about
120
+ * 45 ms, picture leading at about 125 ms), so a small surplus is the safe error.
121
+ */
122
+ export const VISUAL_LEAD_MS = 50;
123
+ /** Output latency the display already matches: a compositor and a screen are
124
+ * late by about as much as a wired speaker, so only the excess counts. */
125
+ const DISPLAY_LATENCY_MS = 40;
126
+ /** A reported latency beyond this is a broken report, not a device. */
127
+ const MAX_OUTPUT_LATENCY_MS = 250;
128
+ /** A pause in the track at least this long is one the audio can be heard to
129
+ * resume after — shorter ones are usually not digital silence on the wire. */
130
+ const REANCHOR_GAP_MS = 250;
131
+ /** When to look, after the resumption is due, and for how long. The probe
132
+ * sees 170 ms back, so one look catches an early sound as well as a late one. */
133
+ const REANCHOR_LOOK_MS = 100;
134
+ const REANCHOR_WINDOW_MS = 150;
135
+ /**
136
+ * Where a resumption is heard, after the cue that marks it. The track opens
137
+ * the mouth before the sound leaves digital silence: by 30–41 ms at most of the
138
+ * demo corpus's silent resumptions (median 32, both voices) and by up to 105 at
139
+ * a few, and the browser hears the sound a few ms after it leaves the wire. A
140
+ * sound heard anywhere in that band is the clock being right. Taking
141
+ * it as the resumption itself set every later phrase of a turn that much late —
142
+ * the direction people notice — and a mouth left early by an underrun smaller
143
+ * than the band is the direction they forgive (see `VISUAL_LEAD_MS`).
144
+ */
145
+ const RESUME_HEARD_MS = 30;
146
+ const RESUME_HEARD_RANGE_MS = [0, 130] as const;
147
+ /** The most a re-anchor may speed or slow the clock, as a fraction of real time. */
148
+ const MAX_SLEW = 0.1;
149
+
150
+ /**
151
+ * The private renderer-side vocabulary the lifecycle driver actually uses.
152
+ *
153
+ * This stays narrower than `AvatarApi` on purpose. The latter is the SVG
154
+ * mixer's driving API; requiring a second renderer to impersonate every one
155
+ * of its inspection and authoring methods would turn that implementation
156
+ * detail into a renderer contract. The public seam remains `createAvatar`.
157
+ */
158
+ export interface AvatarDriver {
159
+ setState(name: string): unknown;
160
+ /**
161
+ * One motion by name — a core intent or one of this avatar's own. **A name
162
+ * it does not have is a no-op, not a throw**: the wire's action vocabulary is
163
+ * open, so a face that cannot do the thing is as expected as a newer server.
164
+ */
165
+ action(id: string): unknown;
166
+ speak(options: { cues: AvatarCue[]; clock: () => number }): unknown;
167
+ pushCues(cues: AvatarCue[]): unknown;
168
+ stopSpeaking(): unknown;
169
+ setUserSpeaking(speaking: boolean | null): unknown;
73
170
  }
74
171
 
75
172
  /**
@@ -89,6 +186,9 @@ export interface AvatarClientOptions {
89
186
  onError?: (err: unknown, msg: AvatarCommand) => void;
90
187
  /** Override for tests. Defaults to `performance.now`. */
91
188
  now?: () => number;
189
+ /** Override for tests. Defaults to listening to the bot's audio track, once
190
+ * `attach()` has found one. */
191
+ playoutProbe?: PlayoutProbe;
92
192
  /** Quiet time in listening before the client-owned idle loop begins. */
93
193
  idleDelayMs?: number;
94
194
  /** Timer seams keep lifecycle behavior deterministic in tests. */
@@ -130,9 +230,12 @@ export const RTVI_EVENTS = {
130
230
  userStoppedSpeaking: "userStoppedSpeaking",
131
231
  botStartedSpeaking: "botStartedSpeaking",
132
232
  botStoppedSpeaking: "botStoppedSpeaking",
133
- // Mute is a Pipecat fact, not a claim: the server's mute strategy emits
134
- // `UserMuteStarted/StoppedFrame`, the RTVI observer forwards them, and the
135
- // browser client raises these. So "has muted you" costs no wire verb —
233
+ // Only to find the bot's audio track once the transport has it; which track
234
+ // is re-read from `tracks()`, the one authority for whose it is.
235
+ trackStarted: "trackStarted",
236
+ // Mute is a Pipecat fact, and there is no server state for it: the server's
237
+ // mute strategy emits `UserMuteStarted/StoppedFrame`, the RTVI observer
238
+ // forwards them, and the browser client raises these. So "has muted you" costs no wire verb —
136
239
  // reading the events the peer already sends is exactly the authority model.
137
240
  userMuteStarted: "userMuteStarted",
138
241
  userMuteStopped: "userMuteStopped",
@@ -140,10 +243,10 @@ export const RTVI_EVENTS = {
140
243
 
141
244
  /** The resolved, factual presence state a host may render around the avatar. */
142
245
  export type AvatarPresenceState =
143
- | "IDLE" | "LISTENING" | "STRAINING" | "THINKING" | "WORKING"
246
+ | "IDLE" | "LISTENING" | "CANT_HEAR" | "THINKING" | "WORKING"
144
247
  | "MUTED" | "SPEAKING" | "DEGRADED" | "OFFLINE";
145
248
  type LifecycleState = AvatarPresenceState;
146
- type ServerClaim = "STRAINING" | "THINKING" | "WORKING" | null;
249
+ type ServerState = "CANT_HEAR" | "THINKING" | "WORKING" | null;
147
250
 
148
251
  /** Defensive unwrap for the `RTVIEvent.ServerMessage` `{ data }` quirk: some
149
252
  * transports deliver the payload directly and some wrap it once more. */
@@ -154,7 +257,7 @@ function unwrapServerMessage(raw: unknown): Record<string, unknown> {
154
257
  }
155
258
 
156
259
  export class AvatarClient {
157
- private readonly avatar: AvatarApi;
260
+ private readonly avatar: AvatarDriver;
158
261
  /** Maps factual/wire intent into the broader client behavior catalog. */
159
262
  private readonly behavior: BehaviorController;
160
263
  private readonly opts: AvatarClientOptions;
@@ -164,7 +267,7 @@ export class AvatarClient {
164
267
  private readonly pendingCtxs: string[] = [];
165
268
  private readonly closedCtxs = new Set<string>();
166
269
  private projected: LifecycleState | null = null;
167
- private serverClaim: ServerClaim = null;
270
+ private serverState: ServerState = null;
168
271
  private userSpeaking = false;
169
272
  private botSpeaking = false;
170
273
  private muted = false;
@@ -179,8 +282,15 @@ export class AvatarClient {
179
282
  private readonly idleDelayMs: number;
180
283
  private readonly setTimer: typeof setTimeout;
181
284
  private readonly clearTimer: typeof clearTimeout;
182
-
183
- constructor(avatar: AvatarApi, opts: AvatarClientOptions = {}) {
285
+ private probe: PlayoutProbe | null;
286
+ private probeTrack: MediaStreamTrack | null = null;
287
+ /** Whether the probe has ever heard an onset. One that has not, and times
288
+ * out, is deaf to this track rather than early to it. */
289
+ private probeHeard = false;
290
+ private onsetTimer: ReturnType<typeof setTimeout> | null = null;
291
+ private reanchorTimer: ReturnType<typeof setTimeout> | null = null;
292
+
293
+ constructor(avatar: AvatarDriver, opts: AvatarClientOptions = {}) {
184
294
  this.avatar = avatar;
185
295
  this.behavior = new BehaviorController(avatar);
186
296
  this.opts = opts;
@@ -188,6 +298,7 @@ export class AvatarClient {
188
298
  this.idleDelayMs = opts.idleDelayMs ?? 12_000;
189
299
  this.setTimer = opts.setTimeout ?? globalThis.setTimeout.bind(globalThis);
190
300
  this.clearTimer = opts.clearTimeout ?? globalThis.clearTimeout.bind(globalThis);
301
+ this.probe = opts.playoutProbe ?? null;
191
302
  }
192
303
 
193
304
  /** The active turn's ctx, or `null` between turns. For tests and telemetry. */
@@ -223,8 +334,8 @@ export class AvatarClient {
223
334
  if (!msg) return;
224
335
  try {
225
336
  switch (msg.cmd) {
226
- case "claim":
227
- this.handleClaim(msg.state);
337
+ case "state":
338
+ this.handleState(msg.state);
228
339
  break;
229
340
  case "action":
230
341
  this.handleAction(msg.id);
@@ -243,14 +354,14 @@ export class AvatarClient {
243
354
  }
244
355
  }
245
356
 
246
- private handleClaim(state: ServerClaim): void {
247
- this.serverClaim = state;
357
+ private handleState(state: ServerState): void {
358
+ this.serverState = state;
248
359
  if (state) this.idle = false;
249
360
  this.applyProjection();
250
361
  this.armIdleIfEligible();
251
362
  }
252
363
 
253
- private handleAction(id: AvatarActionId): void {
364
+ private handleAction(id: string): void {
254
365
  // An interruption is a server-confirmed explanation of a transition, not
255
366
  // authority to steal the mouth while bot audio is still playing. Hold it
256
367
  // until Pipecat output has released the speaking state.
@@ -264,14 +375,17 @@ export class AvatarClient {
264
375
  this.playAction(id);
265
376
  }
266
377
 
267
- private playAction(id: AvatarActionId): void {
378
+ private playAction(id: string): void {
268
379
  this.behavior.wireAction(id);
269
380
  }
270
381
 
271
382
  private ensureTurn(ctx: string): Turn {
272
383
  const existing = this.turns.get(ctx);
273
384
  if (existing) return existing;
274
- const turn = { ctx, cues: [], started: false, clock: null };
385
+ const turn: Turn = {
386
+ ctx, cues: [], started: false, clock: null, t0: null,
387
+ target: null, read: 0, lead: 0, checked: -Infinity,
388
+ };
275
389
  this.turns.set(ctx, turn);
276
390
  this.pendingCtxs.push(ctx);
277
391
  return turn;
@@ -294,6 +408,8 @@ export class AvatarClient {
294
408
  } else {
295
409
  this.avatar.speak({ cues: turn.cues, clock: turn.clock! });
296
410
  }
411
+ // A resumption this chunk added may be the next one worth listening for.
412
+ this.scheduleReanchor(turn);
297
413
  }
298
414
 
299
415
  private activateNextTurn(): void {
@@ -303,11 +419,163 @@ export class AvatarClient {
303
419
  turn = this.turns.get(this.pendingCtxs.shift()!);
304
420
  }
305
421
  if (!turn) return;
306
- const t0 = this.now();
307
- turn.clock = () => this.now() - t0;
308
- turn.started = true;
309
- this.turn = turn;
310
- this.avatar.speak({ cues: turn.cues, clock: turn.clock });
422
+ const active = turn;
423
+ active.lead = this.leadMs();
424
+ active.clock = () => {
425
+ if (active.t0 === null) return -1;
426
+ const now = this.now();
427
+ if (active.target !== null) {
428
+ const room = MAX_SLEW * Math.max(0, now - active.read);
429
+ const off = active.target - active.t0;
430
+ if (Math.abs(off) <= room) {
431
+ active.t0 = active.target;
432
+ active.target = null;
433
+ } else {
434
+ active.t0 += Math.sign(off) * room;
435
+ }
436
+ }
437
+ active.read = now;
438
+ return now - active.t0 + active.lead;
439
+ };
440
+ active.started = true;
441
+ this.turn = active;
442
+ this.anchor(active);
443
+ this.avatar.speak({ cues: active.cues, clock: active.clock });
444
+ }
445
+
446
+ /** Put the turn's zero where its sound began — see the header. */
447
+ private anchor(turn: Turn): void {
448
+ const event = this.now();
449
+ // A context made before any gesture can sit suspended; every turn asks again.
450
+ this.probe?.resume?.();
451
+ const heard = this.probe?.onset();
452
+ if (typeof heard === "number") {
453
+ this.probeHeard = true;
454
+ this.anchored(turn, heard, "the sound", event);
455
+ return;
456
+ }
457
+ if (heard !== null) {
458
+ this.anchored(turn, event, this.probe ? "the event (probe cannot say)" : "the event (no probe)", event);
459
+ return;
460
+ }
461
+ const poll = () => {
462
+ this.onsetTimer = null;
463
+ if (this.turn !== turn) return;
464
+ const onset = this.probe?.onset();
465
+ if (typeof onset === "number") {
466
+ this.probeHeard = true;
467
+ this.anchored(turn, onset, "the sound", event);
468
+ } else if (onset === null && this.now() - event < ONSET_WAIT_MS) {
469
+ this.onsetTimer = this.setTimer(poll, ONSET_POLL_MS);
470
+ } else {
471
+ if (!this.probeHeard) this.dropProbe();
472
+ this.anchored(turn, event, onset === null ? "the event (no sound in time)" : "the event (probe cannot say)", event);
473
+ }
474
+ };
475
+ this.onsetTimer = this.setTimer(poll, ONSET_POLL_MS);
476
+ }
477
+
478
+ private anchored(turn: Turn, t0: number, how: string, event: number): void {
479
+ turn.t0 = t0;
480
+ turn.read = this.now();
481
+ console.info(
482
+ `[avatar] ${turn.ctx} anchored on ${how}: sound ${Math.round(event - t0)} ms before the event, `
483
+ + `mouth ${Math.round(turn.lead)} ms ahead`,
484
+ );
485
+ this.scheduleReanchor(turn);
486
+ }
487
+
488
+ /** The lead for a turn starting now — see `VISUAL_LEAD_MS`. */
489
+ private leadMs(): number {
490
+ const out = this.probe?.outputLatencyMs?.() ?? 0;
491
+ const excess = Math.min(MAX_OUTPUT_LATENCY_MS, Math.max(0, out - DISPLAY_LATENCY_MS));
492
+ return VISUAL_LEAD_MS - excess;
493
+ }
494
+
495
+ /** The first resumption after `after` (track ms) that follows a pause long
496
+ * enough to be heard as one. */
497
+ private nextResumption(turn: Turn, after: number): number | null {
498
+ let silentFrom: number | null = null;
499
+ for (const c of turn.cues) {
500
+ if (c.v === "X") {
501
+ silentFrom ??= c.t;
502
+ continue;
503
+ }
504
+ if (silentFrom !== null && c.t - silentFrom >= REANCHOR_GAP_MS && c.t > after) return c.t;
505
+ silentFrom = null;
506
+ }
507
+ return null;
508
+ }
509
+
510
+ /** Listen for the sound at the turn's next resumption, once. */
511
+ private scheduleReanchor(turn: Turn): void {
512
+ if (this.reanchorTimer !== null || !this.probe || turn.t0 === null || this.turn !== turn) return;
513
+ const zero = turn.target ?? turn.t0;
514
+ const at = this.nextResumption(turn, Math.max(turn.checked, this.now() - zero - REANCHOR_WINDOW_MS));
515
+ if (at === null) return;
516
+ const poll = () => {
517
+ this.reanchorTimer = null;
518
+ if (this.turn !== turn || !this.probe || turn.t0 === null) return;
519
+ const due = (turn.target ?? turn.t0) + at;
520
+ const onset = this.probe.onset();
521
+ if (onset === null && this.now() < due + RESUME_HEARD_MS + REANCHOR_WINDOW_MS) {
522
+ this.reanchorTimer = this.setTimer(poll, ONSET_POLL_MS);
523
+ return;
524
+ }
525
+ turn.checked = at;
526
+ // `undefined` is sound all the way back: the pause was not silence on
527
+ // the wire, or it resumed long before the track says. Either way there
528
+ // is nothing to measure.
529
+ if (typeof onset === "number") {
530
+ const heard = onset - due;
531
+ const [earliest, latest] = RESUME_HEARD_RANGE_MS;
532
+ const agrees = heard >= earliest && heard <= latest;
533
+ if (!agrees && Math.abs(heard - RESUME_HEARD_MS) <= REANCHOR_WINDOW_MS) {
534
+ turn.target = onset - at - RESUME_HEARD_MS;
535
+ }
536
+ }
537
+ this.scheduleReanchor(turn);
538
+ };
539
+ const zeroNow = turn.target ?? turn.t0;
540
+ this.reanchorTimer = this.setTimer(poll, Math.max(0, zeroNow + at + REANCHOR_LOOK_MS - this.now()));
541
+ }
542
+
543
+ private clearReanchorTimer(): void {
544
+ if (this.reanchorTimer !== null) {
545
+ this.clearTimer(this.reanchorTimer);
546
+ this.reanchorTimer = null;
547
+ }
548
+ }
549
+
550
+ private clearOnsetTimer(): void {
551
+ if (this.onsetTimer !== null) {
552
+ this.clearTimer(this.onsetTimer);
553
+ this.onsetTimer = null;
554
+ }
555
+ }
556
+
557
+ private dropProbe(): void {
558
+ this.probe?.dispose();
559
+ this.probe = null;
560
+ this.probeTrack = null;
561
+ this.probeHeard = false;
562
+ }
563
+
564
+ /** Listen to the bot's audio track, if the transport has one yet. */
565
+ private listenTo(client: PipecatClient, heard?: MediaStreamTrack): void {
566
+ if (this.opts.playoutProbe) return;
567
+ let track: MediaStreamTrack | undefined = heard;
568
+ if (!track) {
569
+ try {
570
+ track = client.tracks().bot?.audio;
571
+ } catch {
572
+ return;
573
+ }
574
+ }
575
+ if (!track || track === this.probeTrack) return;
576
+ this.dropProbe();
577
+ this.probe = createPlayoutProbe(track, this.now);
578
+ this.probeTrack = this.probe ? track : null;
311
579
  }
312
580
 
313
581
  private discardQueuedTurns(): void {
@@ -321,7 +589,7 @@ export class AvatarClient {
321
589
  // The one copy of the ladder is docs/pipecat-lifecycle-protocol.md; this is
322
590
  // its implementation, in the same order.
323
591
  //
324
- // Audio truth is the P0 invariant: no lower claim or microphone event may
592
+ // Audio truth is the P0 invariant: no lower state or microphone event may
325
593
  // put the face in a non-speaking pose while bot speech is audible.
326
594
  if (this.botSpeaking) return "SPEAKING";
327
595
  if (this.userSpeaking) return "LISTENING";
@@ -329,13 +597,13 @@ export class AvatarClient {
329
597
  // not the thing worth saying about a dead call.
330
598
  if (this.failure) return this.failure;
331
599
  if (this.muted) return "MUTED";
332
- // Every claim resolves to the presence state of the same name, so there is
333
- // nothing to rank here — a claim is a single value and only one can be in
334
- // flight. STRAINING > THINKING > WORKING is decided where more than one
335
- // condition can hold at once, which is the server: `AvatarStateMachine.
336
- // _resolve()`. Ranking them again on this side would be a second, silent
337
- // copy of that ladder, and the two would drift.
338
- if (this.serverClaim) return this.serverClaim;
600
+ // A server state *is* the presence state of the same name, so there is
601
+ // nothing to rank here — only one can be in flight, and the command sets
602
+ // rather than adds. CANT_HEAR > THINKING > WORKING is decided where more
603
+ // than one condition can hold at once, which is the server:
604
+ // `AvatarStateMachine._resolve()`. Ranking them again on this side would be
605
+ // a second, silent copy of that ladder, and the two would drift.
606
+ if (this.serverState) return this.serverState;
339
607
  if (this.idle) return "IDLE";
340
608
  return "LISTENING";
341
609
  }
@@ -373,7 +641,7 @@ export class AvatarClient {
373
641
 
374
642
  private eligibleForIdle(): boolean {
375
643
  return this.listening && !this.userSpeaking && !this.botSpeaking
376
- && !this.muted && !this.serverClaim && !this.failure;
644
+ && !this.muted && !this.serverState && !this.failure;
377
645
  }
378
646
 
379
647
  private enterListening(): void {
@@ -383,10 +651,10 @@ export class AvatarClient {
383
651
  this.armIdleIfEligible();
384
652
  }
385
653
 
386
- private clearClaimForTurnBoundary(): void {
387
- // Claims are lower-priority hints. A fresh user turn or bot output means
388
- // any prior thinking/work claim is no longer allowed to reappear later.
389
- this.serverClaim = null;
654
+ private clearServerStateForTurnBoundary(): void {
655
+ // A server state is a lower-priority hint. A fresh user turn or bot output
656
+ // means any prior thinking/working one may not reappear later.
657
+ this.serverState = null;
390
658
  }
391
659
 
392
660
  private maybePlayInterrupted(): void {
@@ -406,7 +674,7 @@ export class AvatarClient {
406
674
  private onUserStartedSpeaking = (): void => {
407
675
  this.clearRecoverableFailure();
408
676
  this.userSpeaking = true;
409
- this.clearClaimForTurnBoundary();
677
+ this.clearServerStateForTurnBoundary();
410
678
  this.listening = true;
411
679
  this.idle = false;
412
680
  this.clearIdleTimer();
@@ -423,7 +691,7 @@ export class AvatarClient {
423
691
  private onBotStartedSpeaking = (): void => {
424
692
  this.clearRecoverableFailure();
425
693
  this.botSpeaking = true;
426
- this.clearClaimForTurnBoundary();
694
+ this.clearServerStateForTurnBoundary();
427
695
  this.idle = false;
428
696
  this.clearIdleTimer();
429
697
  this.applyProjection();
@@ -432,6 +700,8 @@ export class AvatarClient {
432
700
 
433
701
  private onBotStoppedSpeaking = (): void => {
434
702
  this.botSpeaking = false;
703
+ this.clearOnsetTimer();
704
+ this.clearReanchorTimer();
435
705
  // Playout truth releases the only active mouth track. A late cue chunk for
436
706
  // this context is ignored rather than reviving a silent mouth.
437
707
  if (this.turn) {
@@ -495,6 +765,14 @@ export class AvatarClient {
495
765
  */
496
766
  attach(client: PipecatClient): () => void {
497
767
  const onServerMessage = (raw: unknown) => this.dispatch(unwrapServerMessage(raw));
768
+ // The bot's track has to come from the event. SmallWebRTC never lists a
769
+ // remote track in `tracks()`; it only hands it to this callback, with no
770
+ // participant. Asking `tracks()` alone meant no probe on that transport,
771
+ // so every turn fell back to the event.
772
+ const onTrackStarted = (track?: MediaStreamTrack, participant?: { local?: boolean }) => {
773
+ if (participant?.local || (track && track.kind !== "audio")) return;
774
+ this.listenTo(client, track);
775
+ };
498
776
  const subscriptions: Array<[string, (...args: any[]) => void]> = [
499
777
  [RTVI_EVENTS.serverMessage, onServerMessage],
500
778
  [RTVI_EVENTS.connected, this.onConnectedOrReady],
@@ -507,10 +785,24 @@ export class AvatarClient {
507
785
  [RTVI_EVENTS.botStoppedSpeaking, this.onBotStoppedSpeaking],
508
786
  [RTVI_EVENTS.userMuteStarted, this.onUserMuteStarted],
509
787
  [RTVI_EVENTS.userMuteStopped, this.onUserMuteStopped],
788
+ [RTVI_EVENTS.trackStarted, onTrackStarted],
510
789
  ];
511
790
  for (const [event, listener] of subscriptions) client.on(event as RTVIEvent, listener as never);
791
+ this.listenTo(client);
792
+ // Some browsers keep an audio context suspended until it is resumed inside
793
+ // a gesture, and the connect click usually lands before the bot's track
794
+ // exists. Any later click or key is the next chance.
795
+ const wake = () => this.probe?.resume?.();
796
+ const doc = typeof document === "undefined" ? null : document;
797
+ doc?.addEventListener("pointerdown", wake, true);
798
+ doc?.addEventListener("keydown", wake, true);
512
799
  return () => {
800
+ doc?.removeEventListener("pointerdown", wake, true);
801
+ doc?.removeEventListener("keydown", wake, true);
513
802
  this.clearIdleTimer();
803
+ this.clearOnsetTimer();
804
+ this.clearReanchorTimer();
805
+ if (!this.opts.playoutProbe) this.dropProbe();
514
806
  for (const [event, listener] of subscriptions) client.off(event as RTVIEvent, listener as never);
515
807
  };
516
808
  }
@@ -518,6 +810,9 @@ export class AvatarClient {
518
810
  /** Dispose controller-owned timers when its mounted avatar is destroyed. */
519
811
  destroy(): void {
520
812
  this.clearIdleTimer();
813
+ this.clearOnsetTimer();
814
+ this.clearReanchorTimer();
815
+ this.dropProbe();
521
816
  this.behavior.destroy();
522
817
  }
523
818
  }
package/client/arjun.ts CHANGED
@@ -13,6 +13,8 @@ const IMAGES = {
13
13
  const FACE = () => import('../src/canvas/avatars/round/face.mjs');
14
14
 
15
15
  export type { CanvasAvatarOptions };
16
+ // Same mixer, same clip library, so the same declaration (`supports.ts`).
17
+ export { supports } from './supports.js';
16
18
 
17
19
  export function createAvatar(options: CanvasAvatarOptions): AvatarInstance {
18
20
  return createCanvasAvatar(options, {
@@ -41,6 +41,7 @@
41
41
  import type { PipecatClient } from "@pipecat-ai/client-js";
42
42
  import { createAvatar as createSvgWidget } from "../src/avatar.js";
43
43
  import type { Face, FaceTheme, Gain, HandSide } from "../src/avatar.js";
44
+ import type { BehaviorStateId } from "../src/behavior.js";
44
45
  import { peep } from "../src/face-peep.js";
45
46
  import { AvatarClient } from "./AvatarClient.js";
46
47
 
@@ -75,6 +76,43 @@ export interface AvatarInstance {
75
76
  export type AvatarFactory<O extends AvatarOptions = AvatarOptions> =
76
77
  (options: O) => AvatarInstance;
77
78
 
79
+ /**
80
+ * What a mounted avatar answers to — the one *optional* export beside
81
+ * `createAvatar`.
82
+ *
83
+ * A page that drives an avatar has a problem an ordinary consumer does not.
84
+ * The wire's action id is open and an unknown one is ignored in silence
85
+ * (docs/contract-wire.md § Action), which is right for a protocol and useless
86
+ * for a control surface: a button per name cannot tell "this face has no such
87
+ * motion" from "nothing happened". This is the avatar answering that question
88
+ * for a driving UI, and nothing else reads it — `createAvatar` does not take
89
+ * it, the library never consults it, and an avatar that omits it is fully
90
+ * conforming. It is a *declaration*: nobody verifies it, so a list that has
91
+ * drifted from the drawing is a bug in the avatar and not something the
92
+ * library can catch.
93
+ */
94
+ export interface AvatarSupport {
95
+ /**
96
+ * Every action id this avatar answers to, the two required ones included.
97
+ * An id absent from here is a documented no-op, never an error.
98
+ */
99
+ readonly actions: readonly string[];
100
+ /**
101
+ * Which of the nine states this avatar draws *distinguishably*.
102
+ *
103
+ * Every avatar must accept all nine; a renderer that draws `CANT_HEAR` as
104
+ * ordinary listening is conforming (docs/contract-behavior.md). So this is
105
+ * the narrower claim — which of them a reviewer can expect to tell apart on
106
+ * the face — and omitting it claims all nine, which is what every avatar in
107
+ * this repo does.
108
+ */
109
+ readonly states?: readonly BehaviorStateId[];
110
+ }
111
+
112
+ /** The bundled avatars' own `supports` value is in `supports.ts`, not here: it
113
+ * is a value, so this module's `peep` import would follow it into the bundle of
114
+ * anything that read it — including the canvas avatars, which draw no SVG face. */
115
+
78
116
  /**
79
117
  * Options for the bundled SVG avatars — ours alone; nothing outside this
80
118
  * package reads them.
package/client/index.ts CHANGED
@@ -23,14 +23,19 @@
23
23
  * because they are an interface.
24
24
  *
25
25
  * To ship your own avatar, publish a module exporting `createAvatar` and import
26
- * that instead — see `createAvatar.ts` and docs/design-avatar-interface.md.
26
+ * that instead — see `createAvatar.ts` and docs/design-avatar-interface.md. A
27
+ * module may also export `supports`, which is how a page that *drives* an
28
+ * avatar learns which action ids this one answers to; every consumer that only
29
+ * mounts a face can ignore it.
27
30
  */
28
31
 
29
32
  export { createAvatar } from "./createAvatar.js";
33
+ export { supports } from "./supports.js";
30
34
  export type {
31
35
  AvatarOptions,
32
36
  AvatarInstance,
33
37
  AvatarFactory,
38
+ AvatarSupport,
34
39
  SvgAvatarOptions,
35
40
  Face,
36
41
  FaceTheme,