@voicethere/agent 0.6.0 → 0.7.1

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.
@@ -10891,6 +10891,18 @@ function isRecordingControlAckMessage(value) {
10891
10891
  const msg = value;
10892
10892
  return msg.type === "recording_control_ack" && typeof msg.requestId === "string";
10893
10893
  }
10894
+ function isMixControlAckMessage(value) {
10895
+ if (!value || typeof value !== "object")
10896
+ return false;
10897
+ const msg = value;
10898
+ return msg.type === "mix_control_ack" && typeof msg.requestId === "string";
10899
+ }
10900
+ function isSttControlAckMessage(value) {
10901
+ if (!value || typeof value !== "object")
10902
+ return false;
10903
+ const msg = value;
10904
+ return msg.type === "stt_control_ack" && typeof msg.requestId === "string";
10905
+ }
10894
10906
  function isWebhookMessage(value) {
10895
10907
  if (!value || typeof value !== "object")
10896
10908
  return false;
@@ -10933,7 +10945,7 @@ function isParentMessage(value) {
10933
10945
  if (!value || typeof value !== "object")
10934
10946
  return false;
10935
10947
  const msg = value;
10936
- return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "webhook";
10948
+ return msg.type === "session_start" || msg.type === "speech_event" || msg.type === "session_end" || msg.type === "data_channel_message" || msg.type === "data_channel_binary" || msg.type === "idle_timeout" || msg.type === "recording_control_ack" || msg.type === "mix_control_ack" || msg.type === "stt_control_ack" || msg.type === "webhook";
10937
10949
  }
10938
10950
  function isSessionScopedParentMessage(value) {
10939
10951
  return isParentMessage(value) && !isWebhookMessage(value);
@@ -10947,8 +10959,12 @@ function parseDataChannelPayload(raw) {
10947
10959
  }
10948
10960
  var peerEnvBySessionId = /* @__PURE__ */ new Map();
10949
10961
  var recordingAvailableBySessionId = /* @__PURE__ */ new Map();
10962
+ var mixAvailableBySessionId = /* @__PURE__ */ new Map();
10963
+ var ttsPoseAvailableBySessionId = /* @__PURE__ */ new Map();
10950
10964
  var endedSessionIds = /* @__PURE__ */ new Set();
10951
10965
  var pendingRecordingAcks = /* @__PURE__ */ new Map();
10966
+ var pendingMixAcks = /* @__PURE__ */ new Map();
10967
+ var pendingSttAcks = /* @__PURE__ */ new Map();
10952
10968
  function handleRecordingControlAck(message) {
10953
10969
  const pending = pendingRecordingAcks.get(message.requestId);
10954
10970
  if (!pending)
@@ -10961,6 +10977,31 @@ function handleRecordingControlAck(message) {
10961
10977
  requestId: message.requestId
10962
10978
  });
10963
10979
  }
10980
+ function handleMixControlAck(message) {
10981
+ const pending = pendingMixAcks.get(message.requestId);
10982
+ if (!pending)
10983
+ return;
10984
+ clearTimeout(pending.timer);
10985
+ pendingMixAcks.delete(message.requestId);
10986
+ pending.resolve({
10987
+ ok: message.ok,
10988
+ reason: message.reason,
10989
+ requestId: message.requestId,
10990
+ ...message.statuses ? { statuses: message.statuses } : {}
10991
+ });
10992
+ }
10993
+ function handleSttControlAck(message) {
10994
+ const pending = pendingSttAcks.get(message.requestId);
10995
+ if (!pending)
10996
+ return;
10997
+ clearTimeout(pending.timer);
10998
+ pendingSttAcks.delete(message.requestId);
10999
+ pending.resolve({
11000
+ ok: message.ok,
11001
+ reason: message.reason,
11002
+ requestId: message.requestId
11003
+ });
11004
+ }
10964
11005
  function clearPendingRecordingAcksForSession(sessionId, reason) {
10965
11006
  for (const [requestId, pending] of pendingRecordingAcks) {
10966
11007
  if (pending.sessionId !== sessionId)
@@ -11016,6 +11057,11 @@ function allowOutboundForSession(sessionId) {
11016
11057
  return queue.isLive(sessionId);
11017
11058
  }
11018
11059
  function sendParentMessage(message) {
11060
+ const msgType = message && typeof message === "object" && "type" in message ? message.type : void 0;
11061
+ if (msgType === "mix_control" || msgType === "stt_control") {
11062
+ process.send?.(message);
11063
+ return;
11064
+ }
11019
11065
  const sessionId = message && typeof message === "object" && "sessionId" in message && typeof message.sessionId === "string" ? message.sessionId : void 0;
11020
11066
  if (!allowOutboundForSession(sessionId))
11021
11067
  return;
@@ -11097,6 +11143,8 @@ async function handleParentMessage(message, handlers) {
11097
11143
  endedSessionIds.delete(message.sessionId);
11098
11144
  peerEnvBySessionId.set(message.sessionId, message.env);
11099
11145
  recordingAvailableBySessionId.set(message.sessionId, message.recordingAvailable ?? false);
11146
+ mixAvailableBySessionId.set(message.sessionId, message.mixAvailable ?? false);
11147
+ ttsPoseAvailableBySessionId.set(message.sessionId, message.ttsPoseAvailable ?? false);
11100
11148
  const sessionStartInitDelayMs = resolveSessionStartInitDelayMs();
11101
11149
  if (sessionStartInitDelayMs > 0) {
11102
11150
  await new Promise((resolve) => setTimeout(resolve, sessionStartInitDelayMs));
@@ -11104,7 +11152,9 @@ async function handleParentMessage(message, handlers) {
11104
11152
  await (handlers.onClientJoin ?? handlers.onSessionStart)?.({
11105
11153
  sessionId: message.sessionId,
11106
11154
  env: message.env,
11107
- recordingAvailable: message.recordingAvailable ?? false
11155
+ recordingAvailable: message.recordingAvailable ?? false,
11156
+ mixAvailable: message.mixAvailable ?? false,
11157
+ ttsPoseAvailable: message.ttsPoseAvailable ?? false
11108
11158
  });
11109
11159
  sendParentMessage({
11110
11160
  type: "session_start_ack",
@@ -11142,6 +11192,8 @@ async function handleParentMessage(message, handlers) {
11142
11192
  clearPendingRecordingAcksForSession(message.sessionId, "session_ended");
11143
11193
  peerEnvBySessionId.delete(message.sessionId);
11144
11194
  recordingAvailableBySessionId.delete(message.sessionId);
11195
+ mixAvailableBySessionId.delete(message.sessionId);
11196
+ ttsPoseAvailableBySessionId.delete(message.sessionId);
11145
11197
  await (handlers.onClientLeave ?? handlers.onSessionEnd)?.({
11146
11198
  sessionId: message.sessionId
11147
11199
  });
@@ -11161,6 +11213,14 @@ function defineAgent(handlers) {
11161
11213
  handleRecordingControlAck(message);
11162
11214
  return;
11163
11215
  }
11216
+ if (isMixControlAckMessage(message)) {
11217
+ handleMixControlAck(message);
11218
+ return;
11219
+ }
11220
+ if (isSttControlAckMessage(message)) {
11221
+ handleSttControlAck(message);
11222
+ return;
11223
+ }
11164
11224
  if (isWebhookMessage(message)) {
11165
11225
  void agentStartReady.then(() => handleWebhookMessage(message, handlers));
11166
11226
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voicethere/agent",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "VoiceThere customer agent SDK — IPC types and runtime helpers for sandboxed child bundles",
5
5
  "type": "module",
6
6
  "exports": {
@@ -95,9 +95,9 @@
95
95
  "@node-webrtc-rust/sdk": ">=0.6.5"
96
96
  },
97
97
  "devDependencies": {
98
- "@node-webrtc-rust/helpers": "0.7.4",
99
- "@node-webrtc-rust/sdk": "0.7.4",
100
- "@node-webrtc-rust/signaling": "0.7.4",
98
+ "@node-webrtc-rust/helpers": "0.8.0",
99
+ "@node-webrtc-rust/sdk": "0.8.0",
100
+ "@node-webrtc-rust/signaling": "0.8.0",
101
101
  "@types/node": "22.20.1",
102
102
  "http-server": "14.1.1",
103
103
  "ioredis": "5.11.1",
@@ -15,10 +15,10 @@ import {
15
15
 
16
16
  ## Product vs e2e
17
17
 
18
- | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
- | ----------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
- | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
- | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
18
+ | Kind | Dashboard create | Prebuilt seed bundle | Typical consumer |
19
+ | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | ----------------------- |
20
+ | **product** | Yes (`echo`, `echo-dc`, `voice-starter`, `game-sync`, `voice-showcase`, `recording-consent`, `positional-tts`, `webhooks`, `webhooks-redis`) | Yes — `dist/templates/<id>/agent.js` | Platform project create |
21
+ | **e2e** | No | No — build from sources at test time | `voicethere/e2e` smokes |
22
22
 
23
23
  Product templates always set `seedOnCreate: true`. CI fails if a product template is missing its prebuilt bundle after `npm run build`.
24
24
 
@@ -73,6 +73,12 @@ Demonstrates conversation recording consent on connect: asks whether recording i
73
73
 
74
74
  Sources: `recording-consent/agent.ts` (defineAgent wiring), `conversation.ts` (pure state machine).
75
75
 
76
+ ### `positional-tts/` (`positional-tts`)
77
+
78
+ Voice+Data demo — enables positional mixing and orbits each listener’s TTS speaker with `setTtsPose` on a ~50 ms timer. Speaks a short greeting on connect and echoes voice finals / chat so you hear panning while TTS plays. Requires Voice+Data runner mode (`isMixAvailable`).
79
+
80
+ Sources: `positional-tts/agent.ts` (defineAgent wiring), `positional-tts/orbit.ts` (pure circle helper for tests).
81
+
76
82
  ### `webhooks.ts` (`webhooks`)
77
83
 
78
84
  Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the **raw body** with `AGENT_WEBHOOK_SIGNING_SECRET`, then `JSON.parse` and fans out to connected sessions via DataChannel + `speak`.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * E2E agent for voice-data-mix-smoke — positional mix via data-channel commands.
3
+ *
4
+ * Protocol (JSON on control DC):
5
+ * - `{ type: "mix", action: "whoami" }`
6
+ * - `{ type: "mix", action: "create_group", groupId, clientIds }`
7
+ * - `{ type: "mix", action: "set_pose", clientId, pose }`
8
+ * - `{ type: "mix", action: "set_positional", enabled }`
9
+ * - `{ type: "mix", action: "list_clients" }` (optional)
10
+ * - `{ type: "mix", action: "set_global_mute", clientId, muted, sttEnabled? }`
11
+ * - `{ type: "mix", action: "set_listener_mute", listenerId, targetId, muted }`
12
+ * - `{ type: "mix", action: "get_status", clientId? }`
13
+ *
14
+ * Acks: `{ type: "mix_ack", action, ok: true, statuses?, ... }` or `{ ok: false, error }`.
15
+ * Ignores `ping` / chat strings (voice-control readiness). No TTS during smoke.
16
+ */
17
+ import {
18
+ MIX_REQUIRES_VOICE_PLUS_DATA,
19
+ createMixGroup,
20
+ defineAgent,
21
+ getClientMixStatus,
22
+ sendToClient,
23
+ setClientPose,
24
+ setGlobalMute,
25
+ setListenerMute,
26
+ setPositionalMixing,
27
+ } from "@voicethere/agent";
28
+
29
+ /** E2E fixture marker — rewritten before each fixture upload when needed. */
30
+ export const FIXTURE_MARKER = "mix-smoke-fixture-a";
31
+
32
+ export type MixPose = {
33
+ position: { x: number; y: number; z: number };
34
+ orientation: { x: number; y: number; z: number; w: number };
35
+ };
36
+
37
+ export type MixCommand =
38
+ | { type: "mix"; action: "whoami" }
39
+ | {
40
+ type: "mix";
41
+ action: "create_group";
42
+ groupId: string;
43
+ clientIds: string[];
44
+ }
45
+ | { type: "mix"; action: "set_pose"; clientId: string; pose: MixPose }
46
+ | { type: "mix"; action: "set_positional"; enabled: boolean }
47
+ | { type: "mix"; action: "list_clients" }
48
+ | {
49
+ type: "mix";
50
+ action: "set_global_mute";
51
+ clientId: string;
52
+ muted: boolean;
53
+ sttEnabled?: boolean;
54
+ }
55
+ | {
56
+ type: "mix";
57
+ action: "set_listener_mute";
58
+ listenerId: string;
59
+ targetId: string;
60
+ muted: boolean;
61
+ }
62
+ | { type: "mix"; action: "get_status"; clientId?: string };
63
+
64
+ export type MixClientStatus = {
65
+ clientId?: string;
66
+ globallyMuted?: boolean;
67
+ mutedBy?: string[];
68
+ sttEnabled?: boolean;
69
+ groupId?: string | null;
70
+ };
71
+
72
+ export type MixAck =
73
+ | {
74
+ type: "mix_ack";
75
+ action: string;
76
+ ok: true;
77
+ sessionId?: string;
78
+ clientIds?: string[];
79
+ statuses?: MixClientStatus[];
80
+ }
81
+ | { type: "mix_ack"; action: string; ok: false; error: string };
82
+
83
+ const connectedSessions = new Set<string>();
84
+ let mixAvailableForChild = false;
85
+
86
+ export function isMixCommand(message: unknown): message is MixCommand {
87
+ if (!message || typeof message !== "object") return false;
88
+ const record = message as { type?: unknown; action?: unknown };
89
+ if (record.type !== "mix" || typeof record.action !== "string") {
90
+ return false;
91
+ }
92
+ switch (record.action) {
93
+ case "whoami":
94
+ case "list_clients":
95
+ return true;
96
+ case "create_group": {
97
+ const group = message as {
98
+ groupId?: unknown;
99
+ clientIds?: unknown;
100
+ };
101
+ return (
102
+ typeof group.groupId === "string" &&
103
+ group.groupId.trim().length > 0 &&
104
+ Array.isArray(group.clientIds) &&
105
+ group.clientIds.every((id) => typeof id === "string")
106
+ );
107
+ }
108
+ case "set_pose": {
109
+ const poseMsg = message as { clientId?: unknown; pose?: unknown };
110
+ return (
111
+ typeof poseMsg.clientId === "string" &&
112
+ poseMsg.clientId.trim().length > 0 &&
113
+ isMixPose(poseMsg.pose)
114
+ );
115
+ }
116
+ case "set_positional": {
117
+ const positional = message as { enabled?: unknown };
118
+ return typeof positional.enabled === "boolean";
119
+ }
120
+ case "set_global_mute": {
121
+ const mute = message as {
122
+ clientId?: unknown;
123
+ muted?: unknown;
124
+ sttEnabled?: unknown;
125
+ };
126
+ if (
127
+ typeof mute.clientId !== "string" ||
128
+ mute.clientId.trim().length === 0 ||
129
+ typeof mute.muted !== "boolean"
130
+ ) {
131
+ return false;
132
+ }
133
+ if (
134
+ mute.sttEnabled !== undefined &&
135
+ typeof mute.sttEnabled !== "boolean"
136
+ ) {
137
+ return false;
138
+ }
139
+ return true;
140
+ }
141
+ case "set_listener_mute": {
142
+ const listener = message as {
143
+ listenerId?: unknown;
144
+ targetId?: unknown;
145
+ muted?: unknown;
146
+ };
147
+ return (
148
+ typeof listener.listenerId === "string" &&
149
+ listener.listenerId.trim().length > 0 &&
150
+ typeof listener.targetId === "string" &&
151
+ listener.targetId.trim().length > 0 &&
152
+ typeof listener.muted === "boolean"
153
+ );
154
+ }
155
+ case "get_status": {
156
+ const status = message as { clientId?: unknown };
157
+ if (status.clientId === undefined) {
158
+ return true;
159
+ }
160
+ return (
161
+ typeof status.clientId === "string" && status.clientId.trim().length > 0
162
+ );
163
+ }
164
+ default:
165
+ return false;
166
+ }
167
+ }
168
+
169
+ export function isMixPose(value: unknown): value is MixPose {
170
+ if (!value || typeof value !== "object") return false;
171
+ const pose = value as MixPose;
172
+ return isVec3(pose.position) && isQuat(pose.orientation);
173
+ }
174
+
175
+ function isVec3(value: unknown): value is { x: number; y: number; z: number } {
176
+ if (!value || typeof value !== "object") return false;
177
+ const v = value as { x?: unknown; y?: unknown; z?: unknown };
178
+ return (
179
+ typeof v.x === "number" &&
180
+ typeof v.y === "number" &&
181
+ typeof v.z === "number"
182
+ );
183
+ }
184
+
185
+ function isQuat(
186
+ value: unknown,
187
+ ): value is { x: number; y: number; z: number; w: number } {
188
+ if (!value || typeof value !== "object") return false;
189
+ const q = value as { x?: unknown; y?: unknown; z?: unknown; w?: unknown };
190
+ return (
191
+ typeof q.x === "number" &&
192
+ typeof q.y === "number" &&
193
+ typeof q.z === "number" &&
194
+ typeof q.w === "number"
195
+ );
196
+ }
197
+
198
+ function ackOk(
199
+ sessionId: string,
200
+ action: string,
201
+ extra?: {
202
+ sessionId?: string;
203
+ clientIds?: string[];
204
+ statuses?: MixClientStatus[];
205
+ },
206
+ ): void {
207
+ sendToClient(sessionId, {
208
+ type: "mix_ack",
209
+ action,
210
+ ok: true,
211
+ ...extra,
212
+ } satisfies MixAck);
213
+ }
214
+
215
+ function ackError(sessionId: string, action: string, error: string): void {
216
+ sendToClient(sessionId, {
217
+ type: "mix_ack",
218
+ action,
219
+ ok: false,
220
+ error,
221
+ } satisfies MixAck);
222
+ }
223
+
224
+ function requireMix(sessionId: string, action: string): boolean {
225
+ if (mixAvailableForChild) {
226
+ return true;
227
+ }
228
+ ackError(sessionId, action, MIX_REQUIRES_VOICE_PLUS_DATA);
229
+ return false;
230
+ }
231
+
232
+ async function handleMixCommand(
233
+ sessionId: string,
234
+ command: MixCommand,
235
+ ): Promise<void> {
236
+ const { action } = command;
237
+
238
+ switch (action) {
239
+ case "whoami":
240
+ ackOk(sessionId, action, { sessionId });
241
+ return;
242
+ case "list_clients":
243
+ ackOk(sessionId, action, {
244
+ clientIds: [...connectedSessions],
245
+ });
246
+ return;
247
+ case "create_group": {
248
+ if (!requireMix(sessionId, action)) return;
249
+ const result = await createMixGroup({
250
+ id: command.groupId,
251
+ clientIds: command.clientIds,
252
+ });
253
+ if (!result.ok) {
254
+ ackError(sessionId, action, result.reason ?? "create_group failed");
255
+ return;
256
+ }
257
+ ackOk(sessionId, action);
258
+ return;
259
+ }
260
+ case "set_pose": {
261
+ if (!requireMix(sessionId, action)) return;
262
+ const result = await setClientPose(command.clientId, command.pose);
263
+ if (!result.ok) {
264
+ ackError(sessionId, action, result.reason ?? "set_pose failed");
265
+ return;
266
+ }
267
+ ackOk(sessionId, action);
268
+ return;
269
+ }
270
+ case "set_positional": {
271
+ if (!requireMix(sessionId, action)) return;
272
+ const result = await setPositionalMixing(command.enabled);
273
+ if (!result.ok) {
274
+ ackError(sessionId, action, result.reason ?? "set_positional failed");
275
+ return;
276
+ }
277
+ ackOk(sessionId, action);
278
+ return;
279
+ }
280
+ case "set_global_mute": {
281
+ if (!requireMix(sessionId, action)) return;
282
+ const result = await setGlobalMute({
283
+ clientId: command.clientId,
284
+ muted: command.muted,
285
+ ...(command.sttEnabled !== undefined
286
+ ? { sttEnabled: command.sttEnabled }
287
+ : {}),
288
+ });
289
+ if (!result.ok) {
290
+ ackError(sessionId, action, result.reason ?? "set_global_mute failed");
291
+ return;
292
+ }
293
+ ackOk(sessionId, action, {
294
+ ...(result.statuses ? { statuses: result.statuses } : {}),
295
+ });
296
+ return;
297
+ }
298
+ case "set_listener_mute": {
299
+ if (!requireMix(sessionId, action)) return;
300
+ const result = await setListenerMute({
301
+ listenerId: command.listenerId,
302
+ targetId: command.targetId,
303
+ muted: command.muted,
304
+ });
305
+ if (!result.ok) {
306
+ ackError(
307
+ sessionId,
308
+ action,
309
+ result.reason ?? "set_listener_mute failed",
310
+ );
311
+ return;
312
+ }
313
+ ackOk(sessionId, action, {
314
+ ...(result.statuses ? { statuses: result.statuses } : {}),
315
+ });
316
+ return;
317
+ }
318
+ case "get_status": {
319
+ if (!requireMix(sessionId, action)) return;
320
+ const result = await getClientMixStatus(command.clientId);
321
+ if (!result.ok) {
322
+ ackError(sessionId, action, result.reason ?? "get_status failed");
323
+ return;
324
+ }
325
+ ackOk(sessionId, action, {
326
+ ...(result.statuses ? { statuses: result.statuses } : {}),
327
+ });
328
+ return;
329
+ }
330
+ default:
331
+ ackError(sessionId, "unknown", "unsupported mix action");
332
+ }
333
+ }
334
+
335
+ defineAgent({
336
+ onClientJoin(ctx) {
337
+ mixAvailableForChild = ctx.mixAvailable;
338
+ connectedSessions.add(ctx.sessionId);
339
+ },
340
+
341
+ onClientLeave({ sessionId }) {
342
+ connectedSessions.delete(sessionId);
343
+ },
344
+
345
+ onDataChannelMessage(ctx) {
346
+ if (isMixCommand(ctx.message)) {
347
+ void handleMixCommand(ctx.sessionId, ctx.message);
348
+ return;
349
+ }
350
+ // Ignore ping / chat — voice-control readiness only.
351
+ },
352
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Positional TTS template — each listener hears TTS orbiting their own pose.
3
+ *
4
+ * Requires voice or Voice+Data runner mode ({@link isTtsPoseAvailable}). Mix group
5
+ * APIs still require Voice+Data ({@link isMixAvailable}).
6
+ *
7
+ * Build:
8
+ * npx @voicethere/agent build --entry templates/positional-tts/agent.ts
9
+ */
10
+ import {
11
+ agentLog,
12
+ clearTtsPose,
13
+ defineAgent,
14
+ isTtsPoseAvailable,
15
+ parseChatText,
16
+ setPositionalMixing,
17
+ setTtsPose,
18
+ speak,
19
+ } from "@voicethere/agent";
20
+
21
+ import { orbitTtsPose } from "./orbit.js";
22
+
23
+ const ORBIT_INTERVAL_MS = 50;
24
+
25
+ const orbitTimers = new Map<string, ReturnType<typeof setInterval>>();
26
+ const sessionStartTimes = new Map<string, number>();
27
+
28
+ function clearOrbit(sessionId: string): void {
29
+ const timer = orbitTimers.get(sessionId);
30
+ if (timer) {
31
+ clearInterval(timer);
32
+ orbitTimers.delete(sessionId);
33
+ }
34
+ sessionStartTimes.delete(sessionId);
35
+ }
36
+
37
+ async function safeMixCall(
38
+ sessionId: string,
39
+ label: string,
40
+ fn: () => Promise<{ ok: boolean; reason?: string }>,
41
+ ): Promise<void> {
42
+ try {
43
+ const result = await fn();
44
+ if (!result.ok) {
45
+ agentLog(
46
+ "warn",
47
+ `positional-tts ${label} failed: ${result.reason}`,
48
+ sessionId,
49
+ );
50
+ }
51
+ } catch (err) {
52
+ agentLog(
53
+ "warn",
54
+ `positional-tts ${label} error: ${String(err)}`,
55
+ sessionId,
56
+ );
57
+ }
58
+ }
59
+
60
+ defineAgent({
61
+ onAgentStart() {
62
+ orbitTimers.clear();
63
+ sessionStartTimes.clear();
64
+ },
65
+
66
+ onSessionStart(ctx) {
67
+ const { sessionId } = ctx;
68
+
69
+ if (!isTtsPoseAvailable(ctx)) {
70
+ agentLog(
71
+ "warn",
72
+ "positional-tts requires voice or Voice+Data — TTS pose APIs unavailable",
73
+ sessionId,
74
+ );
75
+ return;
76
+ }
77
+
78
+ void (async () => {
79
+ await safeMixCall(sessionId, "setPositionalMixing", () =>
80
+ setPositionalMixing(true),
81
+ );
82
+
83
+ const startMs = Date.now();
84
+ sessionStartTimes.set(sessionId, startMs);
85
+
86
+ const timer = setInterval(() => {
87
+ const started = sessionStartTimes.get(sessionId);
88
+ if (!started) return;
89
+ const elapsedSec = (Date.now() - started) / 1000;
90
+ void safeMixCall(sessionId, "setTtsPose", () =>
91
+ setTtsPose(sessionId, orbitTtsPose(elapsedSec)),
92
+ );
93
+ }, ORBIT_INTERVAL_MS);
94
+
95
+ orbitTimers.set(sessionId, timer);
96
+ speak(sessionId, "I'll circle around you.");
97
+ })();
98
+ },
99
+
100
+ onUserSpeechFinal({ sessionId, text }) {
101
+ speak(sessionId, `You said: ${text}`);
102
+ },
103
+
104
+ onDataChannelMessage(ctx) {
105
+ const text = parseChatText(ctx.message);
106
+ if (!text) return;
107
+ speak(ctx.sessionId, `You said: ${text}`);
108
+ },
109
+
110
+ onSessionEnd({ sessionId }) {
111
+ clearOrbit(sessionId);
112
+ void safeMixCall(sessionId, "clearTtsPose", () => clearTtsPose(sessionId));
113
+ agentLog("info", `positional-tts session_end ${sessionId}`, sessionId);
114
+ },
115
+ });
@@ -0,0 +1,11 @@
1
+ import type { MixPose } from "@voicethere/agent";
2
+
3
+ /** Circle in the XZ plane around the listener origin (Y-up, look −Z). */
4
+ export function orbitTtsPose(elapsedSec: number, radius = 2): MixPose {
5
+ const x = Math.cos(elapsedSec) * radius;
6
+ const z = Math.sin(elapsedSec) * radius;
7
+ return {
8
+ position: { x, y: 0, z },
9
+ orientation: { x: 0, y: 0, z: 0, w: 1 },
10
+ };
11
+ }