@voicethere/agent 0.7.11 → 0.7.13

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Spatial audio showcase — orbit TTS, positional soundboard, proximity room.
2
+ * Spatial audio showcase — orbiting sine + TTS, positional soundboard, proximity room.
3
3
  *
4
4
  * Browser joins with `{ type: "join", demo, assetOrigin? }` then sends demo-specific
5
5
  * commands. Clip URLs are resolved server-side from clipId + allowlisted assetOrigin.
@@ -21,7 +21,9 @@ import {
21
21
  sendToClient,
22
22
  setClientPose,
23
23
  setListenerMute,
24
+ setPlayPose,
24
25
  setPositionalMixing,
26
+ setSttEnabled,
25
27
  setTtsPose,
26
28
  speak,
27
29
  stopPlay,
@@ -42,16 +44,26 @@ import {
42
44
  } from "./protocol.js";
43
45
  import { ProximityRoom } from "./room.js";
44
46
  import {
47
+ isShowcaseClipId,
45
48
  resolveClipUrl,
46
49
  SHOWCASE_SOUND_FILES,
47
50
  type ShowcaseClipId,
48
51
  } from "./sounds.js";
52
+ import {
53
+ buildOrbitSineInlineClipBase64,
54
+ ORBIT_SINE_DURATION_MS,
55
+ ORBIT_SINE_INLINE_URL,
56
+ orbitSineClipDurationMs,
57
+ } from "./sine.js";
49
58
 
50
59
  const ORBIT_TICK_MS = 50;
51
60
  const ORBIT_POSE_EMIT_MS = 100;
52
61
  const ROOM_STATE_TICK_MS = 100;
53
62
  const LOOP_PAD_POLL_MS = 250;
54
- const MAX_ACTIVE_PLAYS = 4;
63
+ const ORBIT_SINE_POLL_MS = 250;
64
+ const ORBIT_SINE_GAPLESS_LEAD_MS = 200;
65
+ const ORBIT_SINE_VOLUME = 0.25;
66
+ export const MAX_ACTIVE_PLAYS = 12;
55
67
  const SHOWCASE_ROOM_GROUP_ID = "showcase-room";
56
68
 
57
69
  type LoopPadState = {
@@ -73,6 +85,13 @@ type SessionState = {
73
85
  playIds: Set<string>;
74
86
  loopPads: Map<string, LoopPadState>;
75
87
  loopPollTimers: Map<string, ReturnType<typeof setInterval>>;
88
+ orbitSinePlayId?: string;
89
+ orbitSinePollTimer?: ReturnType<typeof setInterval>;
90
+ orbitSineRestartTimer?: ReturnType<typeof setTimeout>;
91
+ orbitSineFrequencyHz: number;
92
+ orbitSineEnabled: boolean;
93
+ orbitSinePhaseRad: number;
94
+ orbitManualPose?: { x: number; z: number };
76
95
  inProximityRoom: boolean;
77
96
  lastOrbitPoseEmitMs?: number;
78
97
  };
@@ -95,11 +114,15 @@ function ensureShowcaseRoomGroup(): Promise<boolean> {
95
114
  return showcaseRoomGroupReady;
96
115
  }
97
116
 
98
- function ack(
99
- sessionId: string,
100
- action: string,
101
- extra?: { playId?: string },
102
- ): void {
117
+ type PadAckExtra = {
118
+ playId?: string;
119
+ clipId?: ShowcaseClipId;
120
+ x?: number;
121
+ z?: number;
122
+ placement?: PlayOptions["placement"];
123
+ };
124
+
125
+ function ack(sessionId: string, action: string, extra?: PadAckExtra): void {
103
126
  sendToClient(sessionId, {
104
127
  type: "showcase_ack",
105
128
  action,
@@ -108,12 +131,18 @@ function ack(
108
131
  });
109
132
  }
110
133
 
111
- function ackError(sessionId: string, action: string, error: string): void {
134
+ function ackError(
135
+ sessionId: string,
136
+ action: string,
137
+ error: string,
138
+ extra?: { clipId?: ShowcaseClipId },
139
+ ): void {
112
140
  sendToClient(sessionId, {
113
141
  type: "showcase_ack",
114
142
  action,
115
143
  ok: false,
116
144
  error,
145
+ ...(extra?.clipId !== undefined ? { clipId: extra.clipId } : {}),
117
146
  });
118
147
  }
119
148
 
@@ -154,6 +183,9 @@ function defaultSessionState(
154
183
  playIds: new Set(),
155
184
  loopPads: new Map(),
156
185
  loopPollTimers: new Map(),
186
+ orbitSineFrequencyHz: 440,
187
+ orbitSineEnabled: true,
188
+ orbitSinePhaseRad: 0,
157
189
  inProximityRoom: false,
158
190
  };
159
191
  }
@@ -165,6 +197,19 @@ function clearRoomStateTimer(state: SessionState): void {
165
197
  }
166
198
  }
167
199
 
200
+ function stopLoopPadsForClip(
201
+ sessionId: string,
202
+ state: SessionState,
203
+ clipId: ShowcaseClipId,
204
+ ): void {
205
+ for (const [playId, pad] of [...state.loopPads.entries()]) {
206
+ if (pad.clipId === clipId) {
207
+ void stopPlay(playId);
208
+ clearLoopPad(sessionId, state, playId);
209
+ }
210
+ }
211
+ }
212
+
168
213
  function clearLoopPad(
169
214
  sessionId: string,
170
215
  state: SessionState,
@@ -194,8 +239,188 @@ function clearAllPlays(sessionId: string, state: SessionState): void {
194
239
  state.loopPollTimers.clear();
195
240
  }
196
241
 
197
- function emitOrbitPose(sessionId: string, state: SessionState): void {
242
+ function currentOrbitPose(state: SessionState) {
243
+ if (state.orbitManualPose) {
244
+ const elevation = state.orbit.elevation ?? 0;
245
+ return {
246
+ position: {
247
+ x: state.orbitManualPose.x,
248
+ y: elevation,
249
+ z: state.orbitManualPose.z,
250
+ },
251
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
252
+ };
253
+ }
198
254
  if (!state.orbitStartMs) {
255
+ return orbitTtsPose(0, state.orbit);
256
+ }
257
+ const elapsedSec = (Date.now() - state.orbitStartMs) / 1000;
258
+ return orbitTtsPose(elapsedSec, state.orbit);
259
+ }
260
+
261
+ function orbitPoseFromXz(
262
+ state: SessionState,
263
+ x: number,
264
+ z: number,
265
+ ): ReturnType<typeof orbitTtsPose> {
266
+ const elevation = state.orbit.elevation ?? 0;
267
+ return {
268
+ position: { x, y: elevation, z },
269
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
270
+ };
271
+ }
272
+
273
+ function syncOrbitClockFromManualPose(state: SessionState): void {
274
+ const manual = state.orbitManualPose;
275
+ if (!manual) {
276
+ return;
277
+ }
278
+ const periodSec = state.orbit.periodSec ?? 2 * Math.PI;
279
+ const angleRad = Math.atan2(manual.z, manual.x);
280
+ const elapsedSec = (angleRad * periodSec) / (2 * Math.PI);
281
+ state.orbitStartMs = Date.now() - elapsedSec * 1000;
282
+ state.orbitManualPose = undefined;
283
+ }
284
+
285
+ function clearOrbitSinePoll(state: SessionState): void {
286
+ if (state.orbitSinePollTimer) {
287
+ clearInterval(state.orbitSinePollTimer);
288
+ state.orbitSinePollTimer = undefined;
289
+ }
290
+ }
291
+
292
+ function clearOrbitSineRestartTimer(state: SessionState): void {
293
+ if (state.orbitSineRestartTimer) {
294
+ clearTimeout(state.orbitSineRestartTimer);
295
+ state.orbitSineRestartTimer = undefined;
296
+ }
297
+ }
298
+
299
+ async function stopOrbitSine(state: SessionState): Promise<void> {
300
+ clearOrbitSinePoll(state);
301
+ clearOrbitSineRestartTimer(state);
302
+ const playId = state.orbitSinePlayId;
303
+ state.orbitSinePlayId = undefined;
304
+ if (playId) {
305
+ await stopPlay(playId);
306
+ }
307
+ }
308
+
309
+ function orbitSineClipBytes(state: SessionState): string {
310
+ return buildOrbitSineInlineClipBase64(
311
+ ORBIT_SINE_DURATION_MS,
312
+ state.orbitSineFrequencyHz,
313
+ undefined,
314
+ state.orbitSinePhaseRad,
315
+ );
316
+ }
317
+
318
+ function orbitSineClipDuration(state: SessionState): number {
319
+ return orbitSineClipDurationMs(
320
+ ORBIT_SINE_DURATION_MS,
321
+ state.orbitSineFrequencyHz,
322
+ );
323
+ }
324
+
325
+ function scheduleOrbitSineGaplessRestart(
326
+ sessionId: string,
327
+ state: SessionState,
328
+ ): void {
329
+ clearOrbitSineRestartTimer(state);
330
+ const durationMs = orbitSineClipDuration(state);
331
+ const delayMs = Math.max(0, durationMs - ORBIT_SINE_GAPLESS_LEAD_MS);
332
+ state.orbitSineRestartTimer = setTimeout(() => {
333
+ state.orbitSineRestartTimer = undefined;
334
+ void restartOrbitSineGapless(sessionId, state);
335
+ }, delayMs);
336
+ }
337
+
338
+ async function restartOrbitSineGapless(
339
+ sessionId: string,
340
+ state: SessionState,
341
+ ): Promise<void> {
342
+ if (!state.orbitSineEnabled || !state.orbitSinePlayId) {
343
+ return;
344
+ }
345
+ const previousPlayId = state.orbitSinePlayId;
346
+ const result = await play({
347
+ url: ORBIT_SINE_INLINE_URL,
348
+ bytes: orbitSineClipBytes(state),
349
+ sessionIds: [sessionId],
350
+ volume: ORBIT_SINE_VOLUME,
351
+ pose: currentOrbitPose(state),
352
+ });
353
+ if (!result.ok || !result.playId) {
354
+ agentLog(
355
+ "warn",
356
+ `spatial-showcase orbit sine gapless restart failed: ${result.reason ?? "play_failed"}`,
357
+ sessionId,
358
+ );
359
+ startOrbitSineLoopPoll(sessionId, state);
360
+ return;
361
+ }
362
+ state.orbitSinePlayId = result.playId;
363
+ void stopPlay(previousPlayId);
364
+ scheduleOrbitSineGaplessRestart(sessionId, state);
365
+ }
366
+
367
+ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
368
+ if (state.orbitSineRestartTimer) {
369
+ return;
370
+ }
371
+ clearOrbitSinePoll(state);
372
+ const playId = state.orbitSinePlayId;
373
+ if (!playId) {
374
+ return;
375
+ }
376
+
377
+ state.orbitSinePollTimer = setInterval(() => {
378
+ void (async () => {
379
+ if (!state.orbitSinePlayId || state.orbitSineRestartTimer) {
380
+ clearOrbitSinePoll(state);
381
+ return;
382
+ }
383
+ const status = await getPlay(state.orbitSinePlayId);
384
+ if (!status.ok) {
385
+ return;
386
+ }
387
+ if (status.status === "ended") {
388
+ await restartOrbitSineGapless(sessionId, state);
389
+ }
390
+ })();
391
+ }, ORBIT_SINE_POLL_MS);
392
+ }
393
+
394
+ async function startOrbitSinePlay(
395
+ sessionId: string,
396
+ state: SessionState,
397
+ ): Promise<void> {
398
+ if (!state.orbitSineEnabled) {
399
+ return;
400
+ }
401
+ await stopOrbitSine(state);
402
+ const result = await play({
403
+ url: ORBIT_SINE_INLINE_URL,
404
+ bytes: orbitSineClipBytes(state),
405
+ sessionIds: [sessionId],
406
+ volume: ORBIT_SINE_VOLUME,
407
+ pose: currentOrbitPose(state),
408
+ });
409
+ if (!result.ok || !result.playId) {
410
+ agentLog(
411
+ "warn",
412
+ `spatial-showcase orbit sine play failed: ${result.reason ?? "play_failed"}`,
413
+ sessionId,
414
+ );
415
+ return;
416
+ }
417
+ state.orbitSinePlayId = result.playId;
418
+ scheduleOrbitSineGaplessRestart(sessionId, state);
419
+ startOrbitSineLoopPoll(sessionId, state);
420
+ }
421
+
422
+ function emitOrbitPose(sessionId: string, state: SessionState): void {
423
+ if (!state.orbitStartMs && !state.orbitManualPose) {
199
424
  return;
200
425
  }
201
426
  const now = Date.now();
@@ -206,10 +431,12 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
206
431
  return;
207
432
  }
208
433
  state.lastOrbitPoseEmitMs = now;
209
- const elapsedSec = (now - state.orbitStartMs) / 1000;
210
- const pose = orbitTtsPose(elapsedSec, state.orbit);
211
- const angleRad =
212
- (2 * Math.PI * elapsedSec) / (state.orbit.periodSec ?? 2 * Math.PI);
434
+ const pose = currentOrbitPose(state);
435
+ const angleRad = state.orbitManualPose
436
+ ? Math.atan2(state.orbitManualPose.z, state.orbitManualPose.x)
437
+ : (2 * Math.PI * (now - state.orbitStartMs!)) /
438
+ 1000 /
439
+ (state.orbit.periodSec ?? 2 * Math.PI);
213
440
  sendToClient(sessionId, {
214
441
  type: "orbit_pose",
215
442
  angleDeg: (angleRad * 180) / Math.PI,
@@ -218,16 +445,38 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
218
445
  });
219
446
  }
220
447
 
448
+ function emitOrbitPlacePose(
449
+ sessionId: string,
450
+ state: SessionState,
451
+ x: number,
452
+ z: number,
453
+ ): void {
454
+ state.lastOrbitPoseEmitMs = Date.now();
455
+ const angleRad = Math.atan2(z, x);
456
+ sendToClient(sessionId, {
457
+ type: "orbit_pose",
458
+ angleDeg: (angleRad * 180) / Math.PI,
459
+ x,
460
+ z,
461
+ });
462
+ }
463
+
221
464
  function startOrbitDemo(sessionId: string, state: SessionState): void {
222
465
  stopOrbitInterval(state);
223
466
  beginOrbitClock(state);
467
+ void startOrbitSinePlay(sessionId, state);
224
468
  state.orbitTimer = setInterval(() => {
225
- if (state.orbit.paused || !state.orbitStartMs) {
469
+ if (state.orbit.paused || (!state.orbitStartMs && !state.orbitManualPose)) {
226
470
  return;
227
471
  }
228
- const elapsedSec = (Date.now() - state.orbitStartMs) / 1000;
472
+ const pose = currentOrbitPose(state);
473
+ if (state.orbitSinePlayId) {
474
+ void safeMixCall(sessionId, "setPlayPose", () =>
475
+ setPlayPose(state.orbitSinePlayId!, pose),
476
+ );
477
+ }
229
478
  void safeMixCall(sessionId, "setTtsPose", () =>
230
- setTtsPose(sessionId, orbitTtsPose(elapsedSec, state.orbit)),
479
+ setTtsPose(sessionId, pose),
231
480
  );
232
481
  emitOrbitPose(sessionId, state);
233
482
  }, ORBIT_TICK_MS);
@@ -312,6 +561,15 @@ async function startLoopPadPoll(
312
561
  clearLoopPad(sessionId, state, playId);
313
562
  state.playIds.add(replay.playId);
314
563
  state.loopPads.set(replay.playId, pad);
564
+ ack(sessionId, "pad", {
565
+ playId: replay.playId,
566
+ clipId: pad.clipId,
567
+ x: pad.x,
568
+ z: pad.z,
569
+ ...(pad.placement !== undefined
570
+ ? { placement: pad.placement }
571
+ : {}),
572
+ });
315
573
  void startLoopPadPoll(sessionId, state, replay.playId, pad);
316
574
  }
317
575
  } else if (status.status) {
@@ -336,24 +594,27 @@ export async function handlePadPlay(
336
594
  volume?: number,
337
595
  placement?: PlayOptions["placement"],
338
596
  ): Promise<void> {
339
- if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
340
- ackError(sessionId, "pad", "busy");
341
- return;
342
- }
343
-
344
597
  const assetOrigin = state.assetOrigin;
345
598
  if (!assetOrigin) {
346
- ackError(sessionId, "pad", "missing_asset_origin");
599
+ ackError(sessionId, "pad", "missing_asset_origin", { clipId });
347
600
  return;
348
601
  }
349
602
 
350
603
  const url = resolveClipUrl(assetOrigin, clipId);
351
604
  if (!url) {
352
- ackError(sessionId, "pad", "invalid_asset_origin");
605
+ ackError(sessionId, "pad", "invalid_asset_origin", { clipId });
353
606
  return;
354
607
  }
355
608
 
356
609
  const sound = SHOWCASE_SOUND_FILES[clipId];
610
+ if (sound.loop) {
611
+ stopLoopPadsForClip(sessionId, state, clipId);
612
+ }
613
+
614
+ if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
615
+ ackError(sessionId, "pad", "busy", { clipId });
616
+ return;
617
+ }
357
618
  const playVolume = volume ?? sound.defaultVolume;
358
619
  const options: PlayOptions = {
359
620
  url,
@@ -364,12 +625,18 @@ export async function handlePadPlay(
364
625
 
365
626
  const result = await play(options);
366
627
  if (!result.ok || !result.playId) {
367
- ackError(sessionId, "pad", result.reason ?? "play_failed");
628
+ ackError(sessionId, "pad", result.reason ?? "play_failed", { clipId });
368
629
  return;
369
630
  }
370
631
 
371
632
  state.playIds.add(result.playId);
372
- ack(sessionId, "pad", { playId: result.playId });
633
+ ack(sessionId, "pad", {
634
+ playId: result.playId,
635
+ clipId,
636
+ x,
637
+ z,
638
+ ...(placement !== undefined ? { placement } : {}),
639
+ });
373
640
 
374
641
  if (sound.loop) {
375
642
  const pad: LoopPadState = {
@@ -385,6 +652,34 @@ export async function handlePadPlay(
385
652
  }
386
653
  }
387
654
 
655
+ export async function handlePadMove(
656
+ sessionId: string,
657
+ state: SessionState,
658
+ playId: string,
659
+ x: number,
660
+ z: number,
661
+ ): Promise<void> {
662
+ if (!state.playIds.has(playId)) {
663
+ ackError(sessionId, "pad_move", "unknown_play");
664
+ return;
665
+ }
666
+
667
+ const poseResult = await setPlayPose(playId, poseAt(x, z));
668
+ if (!poseResult.ok) {
669
+ ackError(sessionId, "pad_move", poseResult.reason ?? "set_pose_failed");
670
+ return;
671
+ }
672
+
673
+ const loopPad = state.loopPads.get(playId);
674
+ if (loopPad) {
675
+ loopPad.x = x;
676
+ loopPad.z = z;
677
+ delete loopPad.placement;
678
+ }
679
+
680
+ ack(sessionId, "pad_move", { playId, x, z });
681
+ }
682
+
388
683
  async function handleJoin(
389
684
  sessionId: string,
390
685
  demo: ShowcaseDemo,
@@ -406,9 +701,13 @@ async function handleJoin(
406
701
  ack(sessionId, "join");
407
702
 
408
703
  switch (demo) {
409
- case "orbit":
704
+ case "orbit": {
705
+ void safeMixCall(sessionId, "setSttEnabled", () =>
706
+ setSttEnabled({ enabled: false, sessionId }),
707
+ );
410
708
  startOrbitDemo(sessionId, state);
411
709
  break;
710
+ }
412
711
  case "soundboard":
413
712
  break;
414
713
  case "proximity": {
@@ -451,8 +750,12 @@ function teardownSession(sessionId: string): void {
451
750
 
452
751
  resetOrbitSession(state);
453
752
  clearRoomStateTimer(state);
753
+ void stopOrbitSine(state);
454
754
  clearAllPlays(sessionId, state);
455
755
  void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
756
+ void safeMixCall(sessionId, "setSttEnabled", () =>
757
+ setSttEnabled({ enabled: true, sessionId }),
758
+ );
456
759
 
457
760
  if (state.inProximityRoom) {
458
761
  proximityRoom.leave(sessionId);
@@ -510,11 +813,50 @@ async function dispatchMessage(
510
813
  state.orbit.elevation = message.elevation;
511
814
  }
512
815
  if (message.paused !== undefined) {
816
+ if (message.paused === false && state.orbitManualPose) {
817
+ syncOrbitClockFromManualPose(state);
818
+ }
513
819
  state.orbit.paused = message.paused;
514
820
  }
821
+ if (message.frequencyHz !== undefined) {
822
+ state.orbitSineFrequencyHz = message.frequencyHz;
823
+ if (state.orbitSineEnabled && state.orbitSinePlayId) {
824
+ void startOrbitSinePlay(sessionId, state);
825
+ }
826
+ }
827
+ if (message.sinePlaying !== undefined) {
828
+ state.orbitSineEnabled = message.sinePlaying;
829
+ if (message.sinePlaying) {
830
+ if (!state.orbitSinePlayId) {
831
+ void startOrbitSinePlay(sessionId, state);
832
+ }
833
+ } else {
834
+ void stopOrbitSine(state);
835
+ }
836
+ }
515
837
  ack(sessionId, "orbit_set");
516
838
  return;
517
839
  }
840
+ if (message.action === "place") {
841
+ state.orbit.paused = true;
842
+ state.orbitManualPose = { x: message.x, z: message.z };
843
+ const pose = orbitPoseFromXz(state, message.x, message.z);
844
+ if (state.orbitSinePlayId) {
845
+ void safeMixCall(sessionId, "setPlayPose", () =>
846
+ setPlayPose(state.orbitSinePlayId!, pose),
847
+ );
848
+ }
849
+ void safeMixCall(sessionId, "setTtsPose", () =>
850
+ setTtsPose(sessionId, pose),
851
+ );
852
+ emitOrbitPlacePose(sessionId, state, message.x, message.z);
853
+ ack(sessionId, "orbit_place");
854
+ return;
855
+ }
856
+ sendToClient(sessionId, {
857
+ type: "orbit_say_status",
858
+ status: "playing",
859
+ });
518
860
  speak(sessionId, message.text);
519
861
  ack(sessionId, "orbit_say");
520
862
  return;
@@ -524,6 +866,10 @@ async function dispatchMessage(
524
866
  ackError(sessionId, "pad", "wrong_demo");
525
867
  return;
526
868
  }
869
+ if (!isShowcaseClipId(message.clipId)) {
870
+ ackError(sessionId, "pad", "invalid_clip_id");
871
+ return;
872
+ }
527
873
  await handlePadPlay(
528
874
  sessionId,
529
875
  state,
@@ -567,6 +913,20 @@ async function dispatchMessage(
567
913
  }
568
914
  return;
569
915
  }
916
+ case "pad_move": {
917
+ if (state.demo !== "soundboard") {
918
+ ackError(sessionId, "pad_move", "wrong_demo");
919
+ return;
920
+ }
921
+ await handlePadMove(
922
+ sessionId,
923
+ state,
924
+ message.playId,
925
+ message.x,
926
+ message.z,
927
+ );
928
+ return;
929
+ }
570
930
  case "move": {
571
931
  if (state.demo !== "proximity") {
572
932
  ackError(sessionId, "move", "wrong_demo");
@@ -641,14 +1001,6 @@ defineAgent({
641
1001
  });
642
1002
  },
643
1003
 
644
- onUserSpeechFinal({ sessionId, text }) {
645
- const state = sessions.get(sessionId);
646
- if (!state || state.demo !== "orbit") {
647
- return;
648
- }
649
- speak(sessionId, `You said: ${text}`);
650
- },
651
-
652
1004
  onDataChannelMessage(ctx) {
653
1005
  const message = parseShowcaseMessage(ctx.message);
654
1006
  if (!message) {
@@ -657,6 +1009,24 @@ defineAgent({
657
1009
  void dispatchMessage(ctx.sessionId, message);
658
1010
  },
659
1011
 
1012
+ onSpeechEvent(ctx, event) {
1013
+ const state = sessions.get(ctx.sessionId);
1014
+ if (!state || state.demo !== "orbit") {
1015
+ return;
1016
+ }
1017
+ if (event.type === "agent_speaking_start") {
1018
+ sendToClient(ctx.sessionId, {
1019
+ type: "orbit_say_status",
1020
+ status: "playing",
1021
+ });
1022
+ } else if (event.type === "agent_speaking_end") {
1023
+ sendToClient(ctx.sessionId, {
1024
+ type: "orbit_say_status",
1025
+ status: "ended",
1026
+ });
1027
+ }
1028
+ },
1029
+
660
1030
  onSessionEnd({ sessionId }) {
661
1031
  teardownSession(sessionId);
662
1032
  agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);