@voicethere/agent 0.7.8 → 0.7.10
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/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +15 -0
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/spatial-showcase/agent.js +1807 -0
- package/package.json +4 -4
- package/templates/README.md +17 -1
- package/templates/crash.ts +7 -4
- package/templates/echo-smoke.ts +6 -2
- package/templates/spatial-showcase/agent.ts +652 -0
- package/templates/spatial-showcase/orbit-session.ts +24 -0
- package/templates/spatial-showcase/orbit.ts +24 -0
- package/templates/spatial-showcase/protocol.ts +339 -0
- package/templates/spatial-showcase/room.ts +80 -0
- package/templates/spatial-showcase/sounds.ts +68 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial audio showcase — orbit TTS, positional soundboard, proximity room.
|
|
3
|
+
*
|
|
4
|
+
* Browser joins with `{ type: "join", demo, assetOrigin? }` then sends demo-specific
|
|
5
|
+
* commands. Clip URLs are resolved server-side from clipId + allowlisted assetOrigin.
|
|
6
|
+
*
|
|
7
|
+
* Build:
|
|
8
|
+
* npx @voicethere/agent build --entry templates/spatial-showcase/agent.ts
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
addClientToMix,
|
|
12
|
+
agentLog,
|
|
13
|
+
clearTtsPose,
|
|
14
|
+
createMixGroup,
|
|
15
|
+
defineAgent,
|
|
16
|
+
getPlay,
|
|
17
|
+
isMixAvailable,
|
|
18
|
+
isTtsPoseAvailable,
|
|
19
|
+
play,
|
|
20
|
+
removeClientFromMix,
|
|
21
|
+
sendToClient,
|
|
22
|
+
setClientPose,
|
|
23
|
+
setListenerMute,
|
|
24
|
+
setPositionalMixing,
|
|
25
|
+
setTtsPose,
|
|
26
|
+
speak,
|
|
27
|
+
stopPlay,
|
|
28
|
+
type PlayOptions,
|
|
29
|
+
} from "@voicethere/agent";
|
|
30
|
+
|
|
31
|
+
import { orbitTtsPose, type OrbitOptions } from "./orbit.js";
|
|
32
|
+
import {
|
|
33
|
+
beginOrbitClock,
|
|
34
|
+
resetOrbitSession,
|
|
35
|
+
stopOrbitInterval,
|
|
36
|
+
} from "./orbit-session.js";
|
|
37
|
+
import {
|
|
38
|
+
parseShowcaseMessage,
|
|
39
|
+
poseAt,
|
|
40
|
+
type ShowcaseInbound,
|
|
41
|
+
type ShowcaseDemo,
|
|
42
|
+
} from "./protocol.js";
|
|
43
|
+
import { ProximityRoom } from "./room.js";
|
|
44
|
+
import {
|
|
45
|
+
resolveClipUrl,
|
|
46
|
+
SHOWCASE_SOUND_FILES,
|
|
47
|
+
type ShowcaseClipId,
|
|
48
|
+
} from "./sounds.js";
|
|
49
|
+
|
|
50
|
+
const ORBIT_TICK_MS = 50;
|
|
51
|
+
const ORBIT_POSE_EMIT_MS = 100;
|
|
52
|
+
const ROOM_STATE_TICK_MS = 100;
|
|
53
|
+
const LOOP_PAD_POLL_MS = 250;
|
|
54
|
+
const MAX_ACTIVE_PLAYS = 4;
|
|
55
|
+
const SHOWCASE_ROOM_GROUP_ID = "showcase-room";
|
|
56
|
+
|
|
57
|
+
type LoopPadState = {
|
|
58
|
+
clipId: ShowcaseClipId;
|
|
59
|
+
volume: number;
|
|
60
|
+
x: number;
|
|
61
|
+
z: number;
|
|
62
|
+
placement?: PlayOptions["placement"];
|
|
63
|
+
loop: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type SessionState = {
|
|
67
|
+
demo: ShowcaseDemo;
|
|
68
|
+
assetOrigin?: string;
|
|
69
|
+
orbit: OrbitOptions & { paused: boolean };
|
|
70
|
+
orbitStartMs?: number;
|
|
71
|
+
orbitTimer?: ReturnType<typeof setInterval>;
|
|
72
|
+
roomStateTimer?: ReturnType<typeof setInterval>;
|
|
73
|
+
playIds: Set<string>;
|
|
74
|
+
loopPads: Map<string, LoopPadState>;
|
|
75
|
+
loopPollTimers: Map<string, ReturnType<typeof setInterval>>;
|
|
76
|
+
inProximityRoom: boolean;
|
|
77
|
+
lastOrbitPoseEmitMs?: number;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const sessions = new Map<string, SessionState>();
|
|
81
|
+
const proximityRoom = new ProximityRoom();
|
|
82
|
+
|
|
83
|
+
let showcaseRoomGroupReady: Promise<boolean> | null = null;
|
|
84
|
+
const lastRoomSnapshotByListener = new Map<string, string>();
|
|
85
|
+
|
|
86
|
+
function ensureShowcaseRoomGroup(): Promise<boolean> {
|
|
87
|
+
if (!showcaseRoomGroupReady) {
|
|
88
|
+
showcaseRoomGroupReady = createMixGroup({
|
|
89
|
+
id: SHOWCASE_ROOM_GROUP_ID,
|
|
90
|
+
clientIds: [],
|
|
91
|
+
})
|
|
92
|
+
.then((result) => result.ok)
|
|
93
|
+
.catch(() => false);
|
|
94
|
+
}
|
|
95
|
+
return showcaseRoomGroupReady;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ack(
|
|
99
|
+
sessionId: string,
|
|
100
|
+
action: string,
|
|
101
|
+
extra?: { playId?: string },
|
|
102
|
+
): void {
|
|
103
|
+
sendToClient(sessionId, {
|
|
104
|
+
type: "showcase_ack",
|
|
105
|
+
action,
|
|
106
|
+
ok: true,
|
|
107
|
+
...extra,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ackError(sessionId: string, action: string, error: string): void {
|
|
112
|
+
sendToClient(sessionId, {
|
|
113
|
+
type: "showcase_ack",
|
|
114
|
+
action,
|
|
115
|
+
ok: false,
|
|
116
|
+
error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function safeMixCall(
|
|
121
|
+
sessionId: string,
|
|
122
|
+
label: string,
|
|
123
|
+
fn: () => Promise<{ ok: boolean; reason?: string }>,
|
|
124
|
+
): Promise<boolean> {
|
|
125
|
+
try {
|
|
126
|
+
const result = await fn();
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
agentLog(
|
|
129
|
+
"warn",
|
|
130
|
+
`spatial-showcase ${label} failed: ${result.reason}`,
|
|
131
|
+
sessionId,
|
|
132
|
+
);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
agentLog(
|
|
138
|
+
"warn",
|
|
139
|
+
`spatial-showcase ${label} error: ${String(err)}`,
|
|
140
|
+
sessionId,
|
|
141
|
+
);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function defaultSessionState(demo: ShowcaseDemo, assetOrigin?: string): SessionState {
|
|
147
|
+
return {
|
|
148
|
+
demo,
|
|
149
|
+
assetOrigin,
|
|
150
|
+
orbit: { radius: 2, periodSec: 2 * Math.PI, elevation: 0, paused: false },
|
|
151
|
+
playIds: new Set(),
|
|
152
|
+
loopPads: new Map(),
|
|
153
|
+
loopPollTimers: new Map(),
|
|
154
|
+
inProximityRoom: false,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function clearRoomStateTimer(state: SessionState): void {
|
|
159
|
+
if (state.roomStateTimer) {
|
|
160
|
+
clearInterval(state.roomStateTimer);
|
|
161
|
+
state.roomStateTimer = undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function clearLoopPad(sessionId: string, state: SessionState, playId: string): void {
|
|
166
|
+
const timer = state.loopPollTimers.get(playId);
|
|
167
|
+
if (timer) {
|
|
168
|
+
clearInterval(timer);
|
|
169
|
+
state.loopPollTimers.delete(playId);
|
|
170
|
+
}
|
|
171
|
+
state.loopPads.delete(playId);
|
|
172
|
+
state.playIds.delete(playId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clearAllPlays(sessionId: string, state: SessionState): void {
|
|
176
|
+
for (const playId of [...state.playIds]) {
|
|
177
|
+
state.loopPads.delete(playId);
|
|
178
|
+
const timer = state.loopPollTimers.get(playId);
|
|
179
|
+
if (timer) {
|
|
180
|
+
clearInterval(timer);
|
|
181
|
+
state.loopPollTimers.delete(playId);
|
|
182
|
+
}
|
|
183
|
+
void stopPlay(playId);
|
|
184
|
+
}
|
|
185
|
+
state.playIds.clear();
|
|
186
|
+
state.loopPads.clear();
|
|
187
|
+
state.loopPollTimers.clear();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function emitOrbitPose(sessionId: string, state: SessionState): void {
|
|
191
|
+
if (!state.orbitStartMs) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const now = Date.now();
|
|
195
|
+
if (
|
|
196
|
+
state.lastOrbitPoseEmitMs !== undefined &&
|
|
197
|
+
now - state.lastOrbitPoseEmitMs < ORBIT_POSE_EMIT_MS
|
|
198
|
+
) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
state.lastOrbitPoseEmitMs = now;
|
|
202
|
+
const elapsedSec = (now - state.orbitStartMs) / 1000;
|
|
203
|
+
const pose = orbitTtsPose(elapsedSec, state.orbit);
|
|
204
|
+
const angleRad = (2 * Math.PI * elapsedSec) / (state.orbit.periodSec ?? 2 * Math.PI);
|
|
205
|
+
sendToClient(sessionId, {
|
|
206
|
+
type: "orbit_pose",
|
|
207
|
+
angleDeg: (angleRad * 180) / Math.PI,
|
|
208
|
+
x: pose.position.x,
|
|
209
|
+
z: pose.position.z,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function startOrbitDemo(sessionId: string, state: SessionState): void {
|
|
214
|
+
stopOrbitInterval(state);
|
|
215
|
+
beginOrbitClock(state);
|
|
216
|
+
state.orbitTimer = setInterval(() => {
|
|
217
|
+
if (state.orbit.paused || !state.orbitStartMs) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const elapsedSec = (Date.now() - state.orbitStartMs) / 1000;
|
|
221
|
+
void safeMixCall(sessionId, "setTtsPose", () =>
|
|
222
|
+
setTtsPose(sessionId, orbitTtsPose(elapsedSec, state.orbit)),
|
|
223
|
+
);
|
|
224
|
+
emitOrbitPose(sessionId, state);
|
|
225
|
+
}, ORBIT_TICK_MS);
|
|
226
|
+
speak(sessionId, "I'll circle around you.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function broadcastRoomStateIfChanged(): void {
|
|
230
|
+
const memberIds = proximityRoom.memberIds();
|
|
231
|
+
if (memberIds.length === 0) {
|
|
232
|
+
lastRoomSnapshotByListener.clear();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
for (const listenerId of memberIds) {
|
|
237
|
+
const peers = proximityRoom.snapshotFor(listenerId);
|
|
238
|
+
const payload = JSON.stringify(peers);
|
|
239
|
+
if (lastRoomSnapshotByListener.get(listenerId) === payload) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
lastRoomSnapshotByListener.set(listenerId, payload);
|
|
243
|
+
sendToClient(listenerId, { type: "room_state", peers });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const listenerId of [...lastRoomSnapshotByListener.keys()]) {
|
|
247
|
+
if (!memberIds.includes(listenerId)) {
|
|
248
|
+
lastRoomSnapshotByListener.delete(listenerId);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function startProximityRoomBroadcast(sessionId: string, state: SessionState): void {
|
|
254
|
+
clearRoomStateTimer(state);
|
|
255
|
+
state.roomStateTimer = setInterval(() => {
|
|
256
|
+
broadcastRoomStateIfChanged();
|
|
257
|
+
}, ROOM_STATE_TICK_MS);
|
|
258
|
+
lastRoomSnapshotByListener.clear();
|
|
259
|
+
broadcastRoomStateIfChanged();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function startLoopPadPoll(
|
|
263
|
+
sessionId: string,
|
|
264
|
+
state: SessionState,
|
|
265
|
+
playId: string,
|
|
266
|
+
pad: LoopPadState,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
const assetOrigin = state.assetOrigin;
|
|
269
|
+
if (!assetOrigin) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const timer = setInterval(() => {
|
|
274
|
+
void (async () => {
|
|
275
|
+
const loopState = state.loopPads.get(playId);
|
|
276
|
+
if (!loopState) {
|
|
277
|
+
clearInterval(timer);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const status = await getPlay(playId);
|
|
281
|
+
if (!status.ok) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (status.status === "ended") {
|
|
285
|
+
const url = resolveClipUrl(assetOrigin, pad.clipId);
|
|
286
|
+
if (!url) {
|
|
287
|
+
ackError(sessionId, "pad", "invalid_asset_origin");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const options: PlayOptions = {
|
|
291
|
+
url,
|
|
292
|
+
sessionIds: [sessionId],
|
|
293
|
+
volume: pad.volume,
|
|
294
|
+
...(pad.placement
|
|
295
|
+
? { placement: pad.placement }
|
|
296
|
+
: { pose: poseAt(pad.x, pad.z) }),
|
|
297
|
+
};
|
|
298
|
+
const replay = await play(options);
|
|
299
|
+
if (replay.ok && replay.playId) {
|
|
300
|
+
clearLoopPad(sessionId, state, playId);
|
|
301
|
+
state.playIds.add(replay.playId);
|
|
302
|
+
state.loopPads.set(replay.playId, pad);
|
|
303
|
+
void startLoopPadPoll(sessionId, state, replay.playId, pad);
|
|
304
|
+
}
|
|
305
|
+
} else if (status.status) {
|
|
306
|
+
sendToClient(sessionId, {
|
|
307
|
+
type: "pad_progress",
|
|
308
|
+
playId,
|
|
309
|
+
status: status.status,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
})();
|
|
313
|
+
}, LOOP_PAD_POLL_MS);
|
|
314
|
+
|
|
315
|
+
state.loopPollTimers.set(playId, timer);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function handlePadPlay(
|
|
319
|
+
sessionId: string,
|
|
320
|
+
state: SessionState,
|
|
321
|
+
clipId: ShowcaseClipId,
|
|
322
|
+
x: number,
|
|
323
|
+
z: number,
|
|
324
|
+
volume?: number,
|
|
325
|
+
placement?: PlayOptions["placement"],
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
if (state.playIds.size >= MAX_ACTIVE_PLAYS) {
|
|
328
|
+
ackError(sessionId, "pad", "busy");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const assetOrigin = state.assetOrigin;
|
|
333
|
+
if (!assetOrigin) {
|
|
334
|
+
ackError(sessionId, "pad", "missing_asset_origin");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const url = resolveClipUrl(assetOrigin, clipId);
|
|
339
|
+
if (!url) {
|
|
340
|
+
ackError(sessionId, "pad", "invalid_asset_origin");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const sound = SHOWCASE_SOUND_FILES[clipId];
|
|
345
|
+
const playVolume = volume ?? sound.defaultVolume;
|
|
346
|
+
const options: PlayOptions = {
|
|
347
|
+
url,
|
|
348
|
+
sessionIds: [sessionId],
|
|
349
|
+
volume: playVolume,
|
|
350
|
+
...(placement ? { placement } : { pose: poseAt(x, z) }),
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const result = await play(options);
|
|
354
|
+
if (!result.ok || !result.playId) {
|
|
355
|
+
ackError(sessionId, "pad", result.reason ?? "play_failed");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
state.playIds.add(result.playId);
|
|
360
|
+
ack(sessionId, "pad", { playId: result.playId });
|
|
361
|
+
|
|
362
|
+
if (sound.loop) {
|
|
363
|
+
const pad: LoopPadState = {
|
|
364
|
+
clipId,
|
|
365
|
+
volume: playVolume,
|
|
366
|
+
x,
|
|
367
|
+
z,
|
|
368
|
+
placement,
|
|
369
|
+
loop: true,
|
|
370
|
+
};
|
|
371
|
+
state.loopPads.set(result.playId, pad);
|
|
372
|
+
void startLoopPadPoll(sessionId, state, result.playId, pad);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function handleJoin(
|
|
377
|
+
sessionId: string,
|
|
378
|
+
demo: ShowcaseDemo,
|
|
379
|
+
assetOrigin?: string,
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
teardownSession(sessionId);
|
|
382
|
+
|
|
383
|
+
const state = defaultSessionState(demo, assetOrigin);
|
|
384
|
+
sessions.set(sessionId, state);
|
|
385
|
+
|
|
386
|
+
const mixOk = await safeMixCall(sessionId, "setPositionalMixing", () =>
|
|
387
|
+
setPositionalMixing(true),
|
|
388
|
+
);
|
|
389
|
+
if (!mixOk) {
|
|
390
|
+
ackError(sessionId, "join", "positional_mixing_unavailable");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
ack(sessionId, "join");
|
|
395
|
+
|
|
396
|
+
switch (demo) {
|
|
397
|
+
case "orbit":
|
|
398
|
+
startOrbitDemo(sessionId, state);
|
|
399
|
+
break;
|
|
400
|
+
case "soundboard":
|
|
401
|
+
break;
|
|
402
|
+
case "proximity": {
|
|
403
|
+
const groupOk = await ensureShowcaseRoomGroup();
|
|
404
|
+
if (!groupOk) {
|
|
405
|
+
ackError(sessionId, "join", "mix_group_unavailable");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const joined = proximityRoom.join(sessionId);
|
|
409
|
+
if (joined === "full") {
|
|
410
|
+
sendToClient(sessionId, { type: "room_full" });
|
|
411
|
+
sessions.delete(sessionId);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const added = await safeMixCall(sessionId, "addClientToMix", () =>
|
|
415
|
+
addClientToMix(SHOWCASE_ROOM_GROUP_ID, sessionId),
|
|
416
|
+
);
|
|
417
|
+
if (!added) {
|
|
418
|
+
proximityRoom.leave(sessionId);
|
|
419
|
+
sessions.delete(sessionId);
|
|
420
|
+
ackError(sessionId, "join", "add_to_mix_failed");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
state.inProximityRoom = true;
|
|
424
|
+
speak(sessionId, "Welcome to the proximity room.");
|
|
425
|
+
await safeMixCall(sessionId, "setTtsPose", () =>
|
|
426
|
+
setTtsPose(sessionId, poseAt(0, 0)),
|
|
427
|
+
);
|
|
428
|
+
startProximityRoomBroadcast(sessionId, state);
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function teardownSession(sessionId: string): void {
|
|
435
|
+
const state = sessions.get(sessionId);
|
|
436
|
+
if (!state) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
resetOrbitSession(state);
|
|
441
|
+
clearRoomStateTimer(state);
|
|
442
|
+
clearAllPlays(sessionId, state);
|
|
443
|
+
void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
|
|
444
|
+
|
|
445
|
+
if (state.inProximityRoom) {
|
|
446
|
+
proximityRoom.leave(sessionId);
|
|
447
|
+
void safeMixCall(sessionId, "removeClientFromMix", () =>
|
|
448
|
+
removeClientFromMix(SHOWCASE_ROOM_GROUP_ID, sessionId),
|
|
449
|
+
);
|
|
450
|
+
state.inProximityRoom = false;
|
|
451
|
+
broadcastRoomStateIfChanged();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
sessions.delete(sessionId);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function dispatchMessage(
|
|
458
|
+
sessionId: string,
|
|
459
|
+
message: ShowcaseInbound,
|
|
460
|
+
): Promise<void> {
|
|
461
|
+
const state = sessions.get(sessionId);
|
|
462
|
+
|
|
463
|
+
switch (message.type) {
|
|
464
|
+
case "join":
|
|
465
|
+
await handleJoin(sessionId, message.demo, message.assetOrigin);
|
|
466
|
+
return;
|
|
467
|
+
case "ping":
|
|
468
|
+
return;
|
|
469
|
+
case "leave":
|
|
470
|
+
teardownSession(sessionId);
|
|
471
|
+
ack(sessionId, "leave");
|
|
472
|
+
return;
|
|
473
|
+
default:
|
|
474
|
+
if (!state) {
|
|
475
|
+
ackError(sessionId, message.type, "join_required");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (!state) {
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
switch (message.type) {
|
|
485
|
+
case "orbit": {
|
|
486
|
+
if (state.demo !== "orbit") {
|
|
487
|
+
ackError(sessionId, "orbit", "wrong_demo");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (message.action === "set") {
|
|
491
|
+
if (message.radius !== undefined) {
|
|
492
|
+
state.orbit.radius = message.radius;
|
|
493
|
+
}
|
|
494
|
+
if (message.periodSec !== undefined) {
|
|
495
|
+
state.orbit.periodSec = message.periodSec;
|
|
496
|
+
}
|
|
497
|
+
if (message.elevation !== undefined) {
|
|
498
|
+
state.orbit.elevation = message.elevation;
|
|
499
|
+
}
|
|
500
|
+
if (message.paused !== undefined) {
|
|
501
|
+
state.orbit.paused = message.paused;
|
|
502
|
+
}
|
|
503
|
+
ack(sessionId, "orbit_set");
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
speak(sessionId, message.text);
|
|
507
|
+
ack(sessionId, "orbit_say");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
case "pad": {
|
|
511
|
+
if (state.demo !== "soundboard") {
|
|
512
|
+
ackError(sessionId, "pad", "wrong_demo");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
await handlePadPlay(
|
|
516
|
+
sessionId,
|
|
517
|
+
state,
|
|
518
|
+
message.clipId,
|
|
519
|
+
message.x,
|
|
520
|
+
message.z,
|
|
521
|
+
message.volume,
|
|
522
|
+
message.placement,
|
|
523
|
+
);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
case "pad_stop": {
|
|
527
|
+
const loopPad = state.loopPads.get(message.playId);
|
|
528
|
+
if (loopPad) {
|
|
529
|
+
clearLoopPad(sessionId, state, message.playId);
|
|
530
|
+
} else {
|
|
531
|
+
state.playIds.delete(message.playId);
|
|
532
|
+
}
|
|
533
|
+
const stopped = await stopPlay(message.playId);
|
|
534
|
+
if (!stopped.ok) {
|
|
535
|
+
ackError(sessionId, "pad_stop", stopped.reason ?? "stop_failed");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
ack(sessionId, "pad_stop");
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
case "pad_status": {
|
|
542
|
+
const status = await getPlay(message.playId);
|
|
543
|
+
if (!status.ok) {
|
|
544
|
+
ackError(sessionId, "pad_status", status.reason ?? "status_failed");
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (status.status) {
|
|
548
|
+
sendToClient(sessionId, {
|
|
549
|
+
type: "pad_progress",
|
|
550
|
+
playId: message.playId,
|
|
551
|
+
status: status.status,
|
|
552
|
+
});
|
|
553
|
+
} else {
|
|
554
|
+
ack(sessionId, "pad_status");
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
case "move": {
|
|
559
|
+
if (state.demo !== "proximity") {
|
|
560
|
+
ackError(sessionId, "move", "wrong_demo");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const yawDeg = message.yawDeg ?? 0;
|
|
564
|
+
proximityRoom.setPose(sessionId, message.x, message.z, yawDeg);
|
|
565
|
+
const poseOk = await safeMixCall(sessionId, "setClientPose", () =>
|
|
566
|
+
setClientPose(sessionId, poseAt(message.x, message.z, yawDeg)),
|
|
567
|
+
);
|
|
568
|
+
if (!poseOk) {
|
|
569
|
+
ackError(sessionId, "move", "set_pose_failed");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
lastRoomSnapshotByListener.clear();
|
|
573
|
+
broadcastRoomStateIfChanged();
|
|
574
|
+
ack(sessionId, "move");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
case "mute_peer": {
|
|
578
|
+
if (state.demo !== "proximity") {
|
|
579
|
+
ackError(sessionId, "mute_peer", "wrong_demo");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
proximityRoom.setPeerMuted(sessionId, message.peerId, message.muted);
|
|
583
|
+
const muteOk = await safeMixCall(sessionId, "setListenerMute", () =>
|
|
584
|
+
setListenerMute({
|
|
585
|
+
listenerId: sessionId,
|
|
586
|
+
targetId: message.peerId,
|
|
587
|
+
muted: message.muted,
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
if (!muteOk) {
|
|
591
|
+
ackError(sessionId, "mute_peer", "mute_failed");
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
lastRoomSnapshotByListener.clear();
|
|
595
|
+
broadcastRoomStateIfChanged();
|
|
596
|
+
ack(sessionId, "mute_peer");
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
default:
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
defineAgent({
|
|
605
|
+
onAgentStart() {
|
|
606
|
+
for (const sessionId of [...sessions.keys()]) {
|
|
607
|
+
teardownSession(sessionId);
|
|
608
|
+
}
|
|
609
|
+
sessions.clear();
|
|
610
|
+
proximityRoom.clear();
|
|
611
|
+
showcaseRoomGroupReady = null;
|
|
612
|
+
lastRoomSnapshotByListener.clear();
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
onSessionStart(ctx) {
|
|
616
|
+
const { sessionId } = ctx;
|
|
617
|
+
if (!isMixAvailable(ctx) && !isTtsPoseAvailable(ctx)) {
|
|
618
|
+
agentLog(
|
|
619
|
+
"warn",
|
|
620
|
+
"spatial-showcase requires voice or Voice+Data — mix/TTS pose APIs unavailable",
|
|
621
|
+
sessionId,
|
|
622
|
+
);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
sendToClient(sessionId, {
|
|
626
|
+
type: "showcase_ack",
|
|
627
|
+
action: "hello",
|
|
628
|
+
ok: true,
|
|
629
|
+
});
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
onUserSpeechFinal({ sessionId, text }) {
|
|
633
|
+
const state = sessions.get(sessionId);
|
|
634
|
+
if (!state || state.demo !== "orbit") {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
speak(sessionId, `You said: ${text}`);
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
onDataChannelMessage(ctx) {
|
|
641
|
+
const message = parseShowcaseMessage(ctx.message);
|
|
642
|
+
if (!message) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
void dispatchMessage(ctx.sessionId, message);
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
onSessionEnd({ sessionId }) {
|
|
649
|
+
teardownSession(sessionId);
|
|
650
|
+
agentLog("info", `spatial-showcase session_end ${sessionId}`, sessionId);
|
|
651
|
+
},
|
|
652
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Orbit timer vs clock — split so pause/restart does not wipe elapsed time. */
|
|
2
|
+
export type OrbitSessionTimers = {
|
|
3
|
+
orbitStartMs?: number;
|
|
4
|
+
orbitTimer?: ReturnType<typeof setInterval>;
|
|
5
|
+
lastOrbitPoseEmitMs?: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function stopOrbitInterval(state: OrbitSessionTimers): void {
|
|
9
|
+
if (state.orbitTimer) {
|
|
10
|
+
clearInterval(state.orbitTimer);
|
|
11
|
+
state.orbitTimer = undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resetOrbitSession(state: OrbitSessionTimers): void {
|
|
16
|
+
stopOrbitInterval(state);
|
|
17
|
+
state.orbitStartMs = undefined;
|
|
18
|
+
state.lastOrbitPoseEmitMs = undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function beginOrbitClock(state: OrbitSessionTimers): void {
|
|
22
|
+
state.orbitStartMs = Date.now();
|
|
23
|
+
state.lastOrbitPoseEmitMs = undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MixPose } from "@voicethere/agent";
|
|
2
|
+
|
|
3
|
+
export type OrbitOptions = {
|
|
4
|
+
radius?: number;
|
|
5
|
+
periodSec?: number;
|
|
6
|
+
elevation?: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Circle in the XZ plane around the listener origin (Y-up, look −Z). */
|
|
10
|
+
export function orbitTtsPose(
|
|
11
|
+
elapsedSec: number,
|
|
12
|
+
options: OrbitOptions = {},
|
|
13
|
+
): MixPose {
|
|
14
|
+
const radius = options.radius ?? 2;
|
|
15
|
+
const periodSec = options.periodSec ?? 2 * Math.PI;
|
|
16
|
+
const elevation = options.elevation ?? 0;
|
|
17
|
+
const angle = (2 * Math.PI * elapsedSec) / periodSec;
|
|
18
|
+
const x = Math.cos(angle) * radius;
|
|
19
|
+
const z = Math.sin(angle) * radius;
|
|
20
|
+
return {
|
|
21
|
+
position: { x, y: elevation, z },
|
|
22
|
+
orientation: { x: 0, y: 0, z: 0, w: 1 },
|
|
23
|
+
};
|
|
24
|
+
}
|