@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.
- package/dist/agent.js +64 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +120 -2
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +4 -0
- package/dist/protocol.js.map +1 -1
- package/dist/runtime.d.ts +63 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +259 -0
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +62 -2
- package/dist/templates/echo-dc/agent.js +62 -2
- package/dist/templates/game-sync/agent.js +62 -2
- package/dist/templates/positional-tts/agent.js +811 -0
- package/dist/templates/recording-consent/agent.js +62 -2
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +16 -0
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/voice-showcase/agent.js +62 -2
- package/dist/templates/voice-starter/agent.js +62 -2
- package/dist/templates/webhooks/agent.js +62 -2
- package/dist/templates/webhooks-redis/agent.js +62 -2
- package/package.json +4 -4
- package/templates/README.md +10 -4
- package/templates/mix-smoke.ts +352 -0
- package/templates/positional-tts/agent.ts +115 -0
- package/templates/positional-tts/orbit.ts +11 -0
|
@@ -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.
|
|
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.
|
|
99
|
-
"@node-webrtc-rust/sdk": "0.
|
|
100
|
-
"@node-webrtc-rust/signaling": "0.
|
|
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",
|
package/templates/README.md
CHANGED
|
@@ -15,10 +15,10 @@ import {
|
|
|
15
15
|
|
|
16
16
|
## Product vs e2e
|
|
17
17
|
|
|
18
|
-
| Kind | Dashboard create
|
|
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
|
|
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
|
+
}
|