@voicethere/agent 0.7.12 → 0.8.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 (62) hide show
  1. package/README.md +14 -3
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime.d.ts.map +1 -1
  7. package/dist/runtime.js +11 -2
  8. package/dist/runtime.js.map +1 -1
  9. package/dist/templates/echo/agent.js +1 -1
  10. package/dist/templates/echo-dc/agent.js +1 -1
  11. package/dist/templates/game-sync/agent.js +162 -125
  12. package/dist/templates/registry.d.ts +1 -1
  13. package/dist/templates/registry.d.ts.map +1 -1
  14. package/dist/templates/registry.js +42 -26
  15. package/dist/templates/registry.js.map +1 -1
  16. package/dist/templates/spatial-showcase/agent.js +261 -22
  17. package/dist/templates/voice-starter/agent.js +1 -1
  18. package/dist/templates/webhooks/agent.js +2 -2
  19. package/dist/templates/webhooks-redis/agent.js +11 -4
  20. package/dist/templates/world-sync/agent.js +829 -0
  21. package/dist/templates/world-sync-binary/agent.js +1031 -0
  22. package/package.json +3 -2
  23. package/templates/README.md +41 -69
  24. package/templates/crash/README.md +13 -0
  25. package/templates/echo/README.md +13 -0
  26. package/templates/{echo.ts → echo/agent.ts} +1 -1
  27. package/templates/echo-dc/README.md +13 -0
  28. package/templates/{echo-dc.ts → echo-dc/agent.ts} +1 -1
  29. package/templates/echo-smoke/README.md +13 -0
  30. package/templates/game-sync/README.md +21 -0
  31. package/templates/{game-sync.ts → game-sync/agent.ts} +126 -94
  32. package/templates/{game-sync-redis.ts → game-sync/redis.ts} +4 -13
  33. package/templates/{game-sync-sim.ts → game-sync/sim.ts} +14 -7
  34. package/templates/{game-sync-world-layout.ts → game-sync/world-layout.ts} +72 -30
  35. package/templates/game-sync-smoke/README.md +13 -0
  36. package/templates/mix-smoke/README.md +13 -0
  37. package/templates/positional-tts/README.md +13 -0
  38. package/templates/recording-consent/README.md +13 -0
  39. package/templates/redis-sync/README.md +23 -0
  40. package/templates/redis-sync/agent.ts +72 -95
  41. package/templates/redis-sync/world-layout.ts +103 -26
  42. package/templates/spatial-showcase/README.md +13 -0
  43. package/templates/spatial-showcase/agent.ts +245 -18
  44. package/templates/spatial-showcase/protocol.ts +79 -0
  45. package/templates/spatial-showcase/sine.ts +28 -5
  46. package/templates/voice-showcase/README.md +13 -0
  47. package/templates/voice-starter/README.md +13 -0
  48. package/templates/webhooks/README.md +13 -0
  49. package/templates/{webhooks.ts → webhooks/agent.ts} +1 -1
  50. package/templates/webhooks-redis/README.md +13 -0
  51. package/templates/{webhooks-redis.ts → webhooks-redis/agent.ts} +9 -9
  52. package/templates/world-sync/README.md +20 -0
  53. package/templates/world-sync/agent.ts +62 -0
  54. package/templates/world-sync-binary/README.md +21 -0
  55. package/templates/world-sync-binary/agent.ts +107 -0
  56. package/templates/world-sync-binary/protocol.ts +281 -0
  57. /package/templates/{crash.ts → crash/agent.ts} +0 -0
  58. /package/templates/{echo-smoke.ts → echo-smoke/agent.ts} +0 -0
  59. /package/templates/{game-sync-protocol.ts → game-sync/protocol.ts} +0 -0
  60. /package/templates/{game-sync-smoke.ts → game-sync-smoke/agent.ts} +0 -0
  61. /package/templates/{mix-smoke.ts → mix-smoke/agent.ts} +0 -0
  62. /package/templates/{agent.ts → voice-starter/agent.ts} +0 -0
@@ -18,8 +18,37 @@ export const PEER_FIELD_X = 1;
18
18
  export const PEER_FIELD_Y = 2;
19
19
  export const PEER_FIELD_ACTIVE = 3;
20
20
 
21
+ /** Client → agent position frame: float32le [clientIndex, x, y] (12 bytes). */
22
+ export const POSITION_FLOAT_COUNT = 3;
23
+ export const POSITION_BYTE_LENGTH = POSITION_FLOAT_COUNT * 4;
24
+
25
+ export type PeerPosition = {
26
+ clientIndex: number;
27
+ x: number;
28
+ y: number;
29
+ };
30
+
21
31
  export const REDIS_WORLD_KEY = "e2e:redis-sync:world";
22
32
 
33
+ /** Atomic splice of one peer slot (16 bytes) into the world blob. */
34
+ export const LUA_PATCH_PEER_SLOT = `
35
+ local key = KEYS[1]
36
+ local offset = tonumber(ARGV[1])
37
+ local slot = ARGV[2]
38
+ local size = tonumber(ARGV[3])
39
+ local world = redis.call('GET', key)
40
+ if not world then
41
+ world = string.rep(string.char(0), size)
42
+ elseif #world < size then
43
+ world = world .. string.rep(string.char(0), size - #world)
44
+ elseif #world > size then
45
+ world = string.sub(world, 1, size)
46
+ end
47
+ world = string.sub(world, 1, offset) .. slot .. string.sub(world, offset + #slot + 1)
48
+ redis.call('SET', key, world)
49
+ return size
50
+ `;
51
+
23
52
  export type PeerSlot = {
24
53
  clientIndex: number;
25
54
  x: number;
@@ -53,6 +82,51 @@ export function clearPeerSlot(world: Float32Array, clientIndex: number): void {
53
82
  writePeerSlot(world, clientIndex, 0, 0, 0);
54
83
  }
55
84
 
85
+ function asDataView(data: ArrayBufferLike | ArrayBufferView): DataView {
86
+ if (ArrayBuffer.isView(data)) {
87
+ return new DataView(data.buffer, data.byteOffset, data.byteLength);
88
+ }
89
+ return new DataView(data);
90
+ }
91
+
92
+ /** Decode a 12-byte inbound position frame. */
93
+ export function decodePositionBuffer(
94
+ data: ArrayBufferLike | ArrayBufferView | null | undefined,
95
+ ): PeerPosition | null {
96
+ if (data == null) {
97
+ return null;
98
+ }
99
+ const view = asDataView(data);
100
+ if (view.byteLength < POSITION_BYTE_LENGTH) {
101
+ return null;
102
+ }
103
+ const clientIndex = view.getFloat32(0, true);
104
+ const x = view.getFloat32(4, true);
105
+ const y = view.getFloat32(8, true);
106
+ if (
107
+ !Number.isFinite(clientIndex) ||
108
+ clientIndex < 0 ||
109
+ !Number.isFinite(x) ||
110
+ !Number.isFinite(y)
111
+ ) {
112
+ return null;
113
+ }
114
+ return { clientIndex: Math.trunc(clientIndex), x, y };
115
+ }
116
+
117
+ /** Write [clientIndex, x, y] into `dest` (length >= 3) and return it. */
118
+ export function encodePositionInto(
119
+ dest: Float32Array,
120
+ clientIndex: number,
121
+ x: number,
122
+ y: number,
123
+ ): Float32Array {
124
+ dest[0] = clientIndex;
125
+ dest[1] = x;
126
+ dest[2] = y;
127
+ return dest;
128
+ }
129
+
56
130
  export function readPeerSlot(
57
131
  world: Float32Array,
58
132
  clientIndex: number,
@@ -71,46 +145,49 @@ export function readPeerSlot(
71
145
  }
72
146
 
73
147
  /**
74
- * Decode a Redis / Node Buffer (or any Uint8Array) into a world Float32Array.
148
+ * Copy Redis / Node Buffer bytes into `dest` (default: a new world).
75
149
  *
76
- * Node Buffer pools often hand out views whose `byteOffset` is not a multiple of
77
- * 4. `new Float32Array(buf.buffer, buf.byteOffset, …)` then throws:
78
- * "start offset of Float32Array should be a multiple of 4"
79
- * Copy via `ArrayBuffer.slice` so the view always starts at offset 0.
150
+ * Node Buffer pools can have `byteOffset` not divisible by 4, so we copy via
151
+ * a Uint8Array view onto `dest` instead of slicing a new ArrayBuffer.
80
152
  */
81
153
  export function normalizeWorldBuffer(
82
154
  raw: Uint8Array | null | undefined,
155
+ dest: Float32Array = createEmptyWorldBuffer(),
83
156
  ): Float32Array {
157
+ const destBytes = new Uint8Array(
158
+ dest.buffer,
159
+ dest.byteOffset,
160
+ dest.byteLength,
161
+ );
84
162
  if (!raw || raw.byteLength === 0) {
85
- return createEmptyWorldBuffer();
163
+ destBytes.fill(0);
164
+ return dest;
86
165
  }
87
- const bytes = Math.floor(raw.byteLength / 4) * 4;
88
- if (bytes === 0) {
89
- return createEmptyWorldBuffer();
166
+ const byteCount = Math.min(
167
+ Math.floor(raw.byteLength / 4) * 4,
168
+ destBytes.byteLength,
169
+ );
170
+ if (byteCount > 0) {
171
+ destBytes.set(raw.subarray(0, byteCount));
90
172
  }
91
- const aligned = raw.buffer.slice(raw.byteOffset, raw.byteOffset + bytes);
92
- const decoded = new Float32Array(aligned);
93
- if (decoded.length === WORLD_FLOAT_COUNT) {
94
- return decoded;
173
+ if (byteCount < destBytes.byteLength) {
174
+ destBytes.fill(0, byteCount);
95
175
  }
96
- const normalized = createEmptyWorldBuffer();
97
- normalized.set(
98
- decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
99
- );
100
- return normalized;
176
+ return dest;
101
177
  }
102
178
 
103
- export function decodeWorldBuffer(data: ArrayBuffer): Float32Array {
104
- const view = new Float32Array(data);
105
- if (view.length === WORLD_FLOAT_COUNT) {
106
- return view;
107
- }
108
- const normalized = createEmptyWorldBuffer();
109
- normalized.set(view.subarray(0, Math.min(view.length, WORLD_FLOAT_COUNT)));
110
- return normalized;
179
+ export function decodeWorldBuffer(
180
+ data: ArrayBuffer,
181
+ dest: Float32Array = createEmptyWorldBuffer(),
182
+ ): Float32Array {
183
+ return normalizeWorldBuffer(new Uint8Array(data), dest);
111
184
  }
112
185
 
186
+ /** Same backing ArrayBuffer when the world is a standalone Float32Array. */
113
187
  export function worldBufferToArrayBuffer(world: Float32Array): ArrayBuffer {
188
+ if (world.byteOffset === 0 && world.byteLength === world.buffer.byteLength) {
189
+ return world.buffer as ArrayBuffer;
190
+ }
114
191
  return world.buffer.slice(
115
192
  world.byteOffset,
116
193
  world.byteOffset + world.byteLength,
@@ -0,0 +1,13 @@
1
+ # spatial-showcase
2
+
3
+ Spatial audio showcase for `/showcase`. A `{ type: "join", demo }` DataChannel message selects `orbit`, `soundboard`, or `proximity`.
4
+
5
+ ## Build
6
+
7
+ ```bash
8
+ npx @voicethere/agent build --entry templates/spatial-showcase/agent.ts --outfile dist/agent.js
9
+ ```
10
+
11
+ ## Sources
12
+
13
+ - `agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`, `orbit-session.ts`, `sine.ts`
@@ -51,7 +51,9 @@ import {
51
51
  } from "./sounds.js";
52
52
  import {
53
53
  buildOrbitSineInlineClipBase64,
54
+ ORBIT_SINE_DURATION_MS,
54
55
  ORBIT_SINE_INLINE_URL,
56
+ orbitSineClipDurationMs,
55
57
  } from "./sine.js";
56
58
 
57
59
  const ORBIT_TICK_MS = 50;
@@ -59,6 +61,7 @@ const ORBIT_POSE_EMIT_MS = 100;
59
61
  const ROOM_STATE_TICK_MS = 100;
60
62
  const LOOP_PAD_POLL_MS = 250;
61
63
  const ORBIT_SINE_POLL_MS = 250;
64
+ const ORBIT_SINE_GAPLESS_LEAD_MS = 200;
62
65
  const ORBIT_SINE_VOLUME = 0.25;
63
66
  export const MAX_ACTIVE_PLAYS = 12;
64
67
  const SHOWCASE_ROOM_GROUP_ID = "showcase-room";
@@ -84,6 +87,11 @@ type SessionState = {
84
87
  loopPollTimers: Map<string, ReturnType<typeof setInterval>>;
85
88
  orbitSinePlayId?: string;
86
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 };
87
95
  inProximityRoom: boolean;
88
96
  lastOrbitPoseEmitMs?: number;
89
97
  };
@@ -175,6 +183,9 @@ function defaultSessionState(
175
183
  playIds: new Set(),
176
184
  loopPads: new Map(),
177
185
  loopPollTimers: new Map(),
186
+ orbitSineFrequencyHz: 440,
187
+ orbitSineEnabled: true,
188
+ orbitSinePhaseRad: 0,
178
189
  inProximityRoom: false,
179
190
  };
180
191
  }
@@ -229,6 +240,17 @@ function clearAllPlays(sessionId: string, state: SessionState): void {
229
240
  }
230
241
 
231
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
+ }
232
254
  if (!state.orbitStartMs) {
233
255
  return orbitTtsPose(0, state.orbit);
234
256
  }
@@ -236,6 +258,30 @@ function currentOrbitPose(state: SessionState) {
236
258
  return orbitTtsPose(elapsedSec, state.orbit);
237
259
  }
238
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
+
239
285
  function clearOrbitSinePoll(state: SessionState): void {
240
286
  if (state.orbitSinePollTimer) {
241
287
  clearInterval(state.orbitSinePollTimer);
@@ -243,8 +289,16 @@ function clearOrbitSinePoll(state: SessionState): void {
243
289
  }
244
290
  }
245
291
 
292
+ function clearOrbitSineRestartTimer(state: SessionState): void {
293
+ if (state.orbitSineRestartTimer) {
294
+ clearTimeout(state.orbitSineRestartTimer);
295
+ state.orbitSineRestartTimer = undefined;
296
+ }
297
+ }
298
+
246
299
  async function stopOrbitSine(state: SessionState): Promise<void> {
247
300
  clearOrbitSinePoll(state);
301
+ clearOrbitSineRestartTimer(state);
248
302
  const playId = state.orbitSinePlayId;
249
303
  state.orbitSinePlayId = undefined;
250
304
  if (playId) {
@@ -252,7 +306,68 @@ async function stopOrbitSine(state: SessionState): Promise<void> {
252
306
  }
253
307
  }
254
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
+
255
367
  function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
368
+ if (state.orbitSineRestartTimer) {
369
+ return;
370
+ }
256
371
  clearOrbitSinePoll(state);
257
372
  const playId = state.orbitSinePlayId;
258
373
  if (!playId) {
@@ -261,7 +376,7 @@ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
261
376
 
262
377
  state.orbitSinePollTimer = setInterval(() => {
263
378
  void (async () => {
264
- if (!state.orbitSinePlayId) {
379
+ if (!state.orbitSinePlayId || state.orbitSineRestartTimer) {
265
380
  clearOrbitSinePoll(state);
266
381
  return;
267
382
  }
@@ -270,16 +385,7 @@ function startOrbitSineLoopPoll(sessionId: string, state: SessionState): void {
270
385
  return;
271
386
  }
272
387
  if (status.status === "ended") {
273
- const replay = await play({
274
- url: ORBIT_SINE_INLINE_URL,
275
- bytes: buildOrbitSineInlineClipBase64(),
276
- sessionIds: [sessionId],
277
- volume: ORBIT_SINE_VOLUME,
278
- pose: currentOrbitPose(state),
279
- });
280
- if (replay.ok && replay.playId) {
281
- state.orbitSinePlayId = replay.playId;
282
- }
388
+ await restartOrbitSineGapless(sessionId, state);
283
389
  }
284
390
  })();
285
391
  }, ORBIT_SINE_POLL_MS);
@@ -289,10 +395,13 @@ async function startOrbitSinePlay(
289
395
  sessionId: string,
290
396
  state: SessionState,
291
397
  ): Promise<void> {
398
+ if (!state.orbitSineEnabled) {
399
+ return;
400
+ }
292
401
  await stopOrbitSine(state);
293
402
  const result = await play({
294
403
  url: ORBIT_SINE_INLINE_URL,
295
- bytes: buildOrbitSineInlineClipBase64(),
404
+ bytes: orbitSineClipBytes(state),
296
405
  sessionIds: [sessionId],
297
406
  volume: ORBIT_SINE_VOLUME,
298
407
  pose: currentOrbitPose(state),
@@ -306,11 +415,12 @@ async function startOrbitSinePlay(
306
415
  return;
307
416
  }
308
417
  state.orbitSinePlayId = result.playId;
418
+ scheduleOrbitSineGaplessRestart(sessionId, state);
309
419
  startOrbitSineLoopPoll(sessionId, state);
310
420
  }
311
421
 
312
422
  function emitOrbitPose(sessionId: string, state: SessionState): void {
313
- if (!state.orbitStartMs) {
423
+ if (!state.orbitStartMs && !state.orbitManualPose) {
314
424
  return;
315
425
  }
316
426
  const now = Date.now();
@@ -321,10 +431,12 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
321
431
  return;
322
432
  }
323
433
  state.lastOrbitPoseEmitMs = now;
324
- const elapsedSec = (now - state.orbitStartMs) / 1000;
325
- const pose = orbitTtsPose(elapsedSec, state.orbit);
326
- const angleRad =
327
- (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);
328
440
  sendToClient(sessionId, {
329
441
  type: "orbit_pose",
330
442
  angleDeg: (angleRad * 180) / Math.PI,
@@ -333,12 +445,28 @@ function emitOrbitPose(sessionId: string, state: SessionState): void {
333
445
  });
334
446
  }
335
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
+
336
464
  function startOrbitDemo(sessionId: string, state: SessionState): void {
337
465
  stopOrbitInterval(state);
338
466
  beginOrbitClock(state);
339
467
  void startOrbitSinePlay(sessionId, state);
340
468
  state.orbitTimer = setInterval(() => {
341
- if (state.orbit.paused || !state.orbitStartMs) {
469
+ if (state.orbit.paused || (!state.orbitStartMs && !state.orbitManualPose)) {
342
470
  return;
343
471
  }
344
472
  const pose = currentOrbitPose(state);
@@ -524,6 +652,34 @@ export async function handlePadPlay(
524
652
  }
525
653
  }
526
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
+
527
683
  async function handleJoin(
528
684
  sessionId: string,
529
685
  demo: ShowcaseDemo,
@@ -657,11 +813,50 @@ async function dispatchMessage(
657
813
  state.orbit.elevation = message.elevation;
658
814
  }
659
815
  if (message.paused !== undefined) {
816
+ if (message.paused === false && state.orbitManualPose) {
817
+ syncOrbitClockFromManualPose(state);
818
+ }
660
819
  state.orbit.paused = message.paused;
661
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
+ }
662
837
  ack(sessionId, "orbit_set");
663
838
  return;
664
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
+ });
665
860
  speak(sessionId, message.text);
666
861
  ack(sessionId, "orbit_say");
667
862
  return;
@@ -718,6 +913,20 @@ async function dispatchMessage(
718
913
  }
719
914
  return;
720
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
+ }
721
930
  case "move": {
722
931
  if (state.demo !== "proximity") {
723
932
  ackError(sessionId, "move", "wrong_demo");
@@ -800,6 +1009,24 @@ defineAgent({
800
1009
  void dispatchMessage(ctx.sessionId, message);
801
1010
  },
802
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
+
803
1030
  onSessionEnd({ sessionId }) {
804
1031
  teardownSession(sessionId);
805
1032
  agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);