@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.
@@ -0,0 +1,339 @@
1
+ import type { MixPlacement, MixPose } from "@voicethere/agent";
2
+
3
+ import { isShowcaseClipId } from "./sounds.js";
4
+
5
+ export type ShowcaseDemo = "orbit" | "soundboard" | "proximity";
6
+
7
+ const SHOWCASE_DEMOS = new Set<string>(["orbit", "soundboard", "proximity"]);
8
+
9
+ export const MAX_SAY_TEXT_LENGTH = 200;
10
+ export const MAX_XZ = 5;
11
+
12
+ export type ShowcaseJoinMessage = {
13
+ type: "join";
14
+ demo: ShowcaseDemo;
15
+ assetOrigin?: string;
16
+ };
17
+
18
+ export type ShowcaseOrbitSetMessage = {
19
+ type: "orbit";
20
+ action: "set";
21
+ radius?: number;
22
+ periodSec?: number;
23
+ elevation?: number;
24
+ paused?: boolean;
25
+ };
26
+
27
+ export type ShowcaseOrbitSayMessage = {
28
+ type: "orbit";
29
+ action: "say";
30
+ text: string;
31
+ };
32
+
33
+ export type ShowcasePadMessage = {
34
+ type: "pad";
35
+ clipId: string;
36
+ x: number;
37
+ z: number;
38
+ volume?: number;
39
+ placement?: MixPlacement;
40
+ };
41
+
42
+ export type ShowcasePadStopMessage = {
43
+ type: "pad_stop";
44
+ playId: string;
45
+ };
46
+
47
+ export type ShowcasePadStatusMessage = {
48
+ type: "pad_status";
49
+ playId: string;
50
+ };
51
+
52
+ export type ShowcaseMoveMessage = {
53
+ type: "move";
54
+ x: number;
55
+ z: number;
56
+ yawDeg?: number;
57
+ };
58
+
59
+ export type ShowcaseMutePeerMessage = {
60
+ type: "mute_peer";
61
+ peerId: string;
62
+ muted: boolean;
63
+ };
64
+
65
+ export type ShowcaseLeaveMessage = {
66
+ type: "leave";
67
+ };
68
+
69
+ export type ShowcasePingMessage = {
70
+ type: "ping";
71
+ };
72
+
73
+ export type ShowcaseInbound =
74
+ | ShowcaseJoinMessage
75
+ | ShowcaseOrbitSetMessage
76
+ | ShowcaseOrbitSayMessage
77
+ | ShowcasePadMessage
78
+ | ShowcasePadStopMessage
79
+ | ShowcasePadStatusMessage
80
+ | ShowcaseMoveMessage
81
+ | ShowcaseMutePeerMessage
82
+ | ShowcaseLeaveMessage
83
+ | ShowcasePingMessage;
84
+
85
+ export type ShowcaseAck =
86
+ | {
87
+ type: "showcase_ack";
88
+ action: string;
89
+ ok: true;
90
+ playId?: string;
91
+ }
92
+ | { type: "showcase_ack"; action: string; ok: false; error: string };
93
+
94
+ export type ShowcaseOrbitPose = {
95
+ type: "orbit_pose";
96
+ angleDeg: number;
97
+ x: number;
98
+ z: number;
99
+ };
100
+
101
+ export type ShowcasePadProgress = {
102
+ type: "pad_progress";
103
+ playId: string;
104
+ status: string;
105
+ };
106
+
107
+ export type ShowcaseRoomState = {
108
+ type: "room_state";
109
+ peers: Array<{
110
+ id: string;
111
+ x: number;
112
+ z: number;
113
+ yawDeg: number;
114
+ muted: boolean;
115
+ }>;
116
+ };
117
+
118
+ export type ShowcaseRoomFull = {
119
+ type: "room_full";
120
+ };
121
+
122
+ export type ShowcaseError = {
123
+ type: "error";
124
+ message: string;
125
+ };
126
+
127
+ export type ShowcaseOutbound =
128
+ | ShowcaseAck
129
+ | ShowcaseOrbitPose
130
+ | ShowcasePadProgress
131
+ | ShowcaseRoomState
132
+ | ShowcaseRoomFull
133
+ | ShowcaseError;
134
+
135
+ function isFiniteNumber(value: unknown): value is number {
136
+ return typeof value === "number" && Number.isFinite(value);
137
+ }
138
+
139
+ function clampVolume(value: number): number {
140
+ return Math.min(1, Math.max(0, value));
141
+ }
142
+
143
+ function isInXzRange(value: number): boolean {
144
+ return value >= -MAX_XZ && value <= MAX_XZ;
145
+ }
146
+
147
+ function isMixPlacement(value: unknown): value is MixPlacement {
148
+ return (
149
+ value === "center" ||
150
+ value === "left" ||
151
+ value === "right" ||
152
+ value === "front" ||
153
+ value === "behind" ||
154
+ value === "below" ||
155
+ value === "above"
156
+ );
157
+ }
158
+
159
+ function isNonEmptyString(value: unknown): value is string {
160
+ return typeof value === "string" && value.trim().length > 0;
161
+ }
162
+
163
+ export function isShowcaseDemo(value: unknown): value is ShowcaseDemo {
164
+ return typeof value === "string" && SHOWCASE_DEMOS.has(value);
165
+ }
166
+
167
+ export function isShowcaseInbound(message: unknown): message is ShowcaseInbound {
168
+ return parseShowcaseMessage(message) !== null;
169
+ }
170
+
171
+ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
172
+ if (!message || typeof message !== "object") {
173
+ return null;
174
+ }
175
+ const record = message as { type?: unknown };
176
+ if (typeof record.type !== "string") {
177
+ return null;
178
+ }
179
+
180
+ switch (record.type) {
181
+ case "join": {
182
+ const join = message as ShowcaseJoinMessage & { assetOrigin?: unknown };
183
+ if (!isShowcaseDemo(join.demo)) {
184
+ return null;
185
+ }
186
+ if (
187
+ join.assetOrigin !== undefined &&
188
+ typeof join.assetOrigin !== "string"
189
+ ) {
190
+ return null;
191
+ }
192
+ return {
193
+ type: "join",
194
+ demo: join.demo,
195
+ ...(typeof join.assetOrigin === "string"
196
+ ? { assetOrigin: join.assetOrigin }
197
+ : {}),
198
+ };
199
+ }
200
+ case "orbit": {
201
+ const orbit = message as { action?: unknown };
202
+ if (orbit.action === "set") {
203
+ const set = message as ShowcaseOrbitSetMessage;
204
+ if (
205
+ set.radius !== undefined &&
206
+ (!isFiniteNumber(set.radius) || set.radius <= 0)
207
+ ) {
208
+ return null;
209
+ }
210
+ if (
211
+ set.periodSec !== undefined &&
212
+ (!isFiniteNumber(set.periodSec) || set.periodSec <= 0)
213
+ ) {
214
+ return null;
215
+ }
216
+ if (set.elevation !== undefined && !isFiniteNumber(set.elevation)) {
217
+ return null;
218
+ }
219
+ if (set.paused !== undefined && typeof set.paused !== "boolean") {
220
+ return null;
221
+ }
222
+ return {
223
+ type: "orbit",
224
+ action: "set",
225
+ ...(set.radius !== undefined ? { radius: set.radius } : {}),
226
+ ...(set.periodSec !== undefined ? { periodSec: set.periodSec } : {}),
227
+ ...(set.elevation !== undefined ? { elevation: set.elevation } : {}),
228
+ ...(set.paused !== undefined ? { paused: set.paused } : {}),
229
+ };
230
+ }
231
+ if (orbit.action === "say") {
232
+ const say = message as ShowcaseOrbitSayMessage;
233
+ if (
234
+ typeof say.text !== "string" ||
235
+ say.text.length === 0 ||
236
+ say.text.length > MAX_SAY_TEXT_LENGTH
237
+ ) {
238
+ return null;
239
+ }
240
+ return { type: "orbit", action: "say", text: say.text };
241
+ }
242
+ return null;
243
+ }
244
+ case "pad": {
245
+ const pad = message as ShowcasePadMessage;
246
+ if (!isShowcaseClipId(pad.clipId)) {
247
+ return null;
248
+ }
249
+ if (!isFiniteNumber(pad.x) || !isInXzRange(pad.x)) {
250
+ return null;
251
+ }
252
+ if (!isFiniteNumber(pad.z) || !isInXzRange(pad.z)) {
253
+ return null;
254
+ }
255
+ if (pad.volume !== undefined) {
256
+ if (!isFiniteNumber(pad.volume)) {
257
+ return null;
258
+ }
259
+ }
260
+ if (pad.placement !== undefined && !isMixPlacement(pad.placement)) {
261
+ return null;
262
+ }
263
+ return {
264
+ type: "pad",
265
+ clipId: pad.clipId,
266
+ x: pad.x,
267
+ z: pad.z,
268
+ ...(pad.volume !== undefined
269
+ ? { volume: clampVolume(pad.volume) }
270
+ : {}),
271
+ ...(pad.placement !== undefined ? { placement: pad.placement } : {}),
272
+ };
273
+ }
274
+ case "pad_stop": {
275
+ const stop = message as ShowcasePadStopMessage;
276
+ if (!isNonEmptyString(stop.playId)) {
277
+ return null;
278
+ }
279
+ return { type: "pad_stop", playId: stop.playId.trim() };
280
+ }
281
+ case "pad_status": {
282
+ const status = message as ShowcasePadStatusMessage;
283
+ if (!isNonEmptyString(status.playId)) {
284
+ return null;
285
+ }
286
+ return { type: "pad_status", playId: status.playId.trim() };
287
+ }
288
+ case "move": {
289
+ const move = message as ShowcaseMoveMessage;
290
+ if (!isFiniteNumber(move.x) || !isInXzRange(move.x)) {
291
+ return null;
292
+ }
293
+ if (!isFiniteNumber(move.z) || !isInXzRange(move.z)) {
294
+ return null;
295
+ }
296
+ if (move.yawDeg !== undefined && !isFiniteNumber(move.yawDeg)) {
297
+ return null;
298
+ }
299
+ return {
300
+ type: "move",
301
+ x: move.x,
302
+ z: move.z,
303
+ ...(move.yawDeg !== undefined ? { yawDeg: move.yawDeg } : {}),
304
+ };
305
+ }
306
+ case "mute_peer": {
307
+ const mute = message as ShowcaseMutePeerMessage;
308
+ if (!isNonEmptyString(mute.peerId) || typeof mute.muted !== "boolean") {
309
+ return null;
310
+ }
311
+ return {
312
+ type: "mute_peer",
313
+ peerId: mute.peerId.trim(),
314
+ muted: mute.muted,
315
+ };
316
+ }
317
+ case "leave":
318
+ return { type: "leave" };
319
+ case "ping":
320
+ return { type: "ping" };
321
+ default:
322
+ return null;
323
+ }
324
+ }
325
+
326
+ /** Y-up world pose; forward −Z. Yaw is degrees about Y. */
327
+ export function poseAt(x: number, z: number, yawDeg = 0): MixPose {
328
+ const yawRad = (yawDeg * Math.PI) / 180;
329
+ const half = yawRad / 2;
330
+ return {
331
+ position: { x, y: 0, z },
332
+ orientation: {
333
+ x: 0,
334
+ y: Math.sin(half),
335
+ z: 0,
336
+ w: Math.cos(half),
337
+ },
338
+ };
339
+ }
@@ -0,0 +1,80 @@
1
+ export type RoomPeer = {
2
+ id: string;
3
+ x: number;
4
+ z: number;
5
+ yawDeg: number;
6
+ muted: boolean;
7
+ };
8
+
9
+ export const MAX_ROOM_PEERS = 8;
10
+
11
+ type PeerPose = {
12
+ x: number;
13
+ z: number;
14
+ yawDeg: number;
15
+ };
16
+
17
+ /** In-memory proximity room — pure TS, no agent runtime imports. */
18
+ export class ProximityRoom {
19
+ private readonly members = new Map<string, PeerPose>();
20
+ private readonly listenerMutes = new Map<string, Map<string, boolean>>();
21
+
22
+ join(sessionId: string): "ok" | "full" {
23
+ if (this.members.has(sessionId)) {
24
+ return "ok";
25
+ }
26
+ if (this.members.size >= MAX_ROOM_PEERS) {
27
+ return "full";
28
+ }
29
+ this.members.set(sessionId, { x: 0, z: 0, yawDeg: 0 });
30
+ return "ok";
31
+ }
32
+
33
+ leave(sessionId: string): void {
34
+ this.members.delete(sessionId);
35
+ this.listenerMutes.delete(sessionId);
36
+ for (const targets of this.listenerMutes.values()) {
37
+ targets.delete(sessionId);
38
+ }
39
+ }
40
+
41
+ setPose(sessionId: string, x: number, z: number, yawDeg: number): void {
42
+ const peer = this.members.get(sessionId);
43
+ if (!peer) {
44
+ return;
45
+ }
46
+ peer.x = x;
47
+ peer.z = z;
48
+ peer.yawDeg = yawDeg;
49
+ }
50
+
51
+ memberIds(): string[] {
52
+ return [...this.members.keys()];
53
+ }
54
+
55
+ /** Per-listener view — `muted` is whether this listener muted the peer (never true for self). */
56
+ snapshotFor(listenerId: string): RoomPeer[] {
57
+ const mutesForListener = this.listenerMutes.get(listenerId);
58
+ return [...this.members.entries()].map(([id, pose]) => ({
59
+ id,
60
+ x: pose.x,
61
+ z: pose.z,
62
+ yawDeg: pose.yawDeg,
63
+ muted: id !== listenerId && mutesForListener?.get(id) === true,
64
+ }));
65
+ }
66
+
67
+ setPeerMuted(listenerId: string, targetId: string, muted: boolean): void {
68
+ let targets = this.listenerMutes.get(listenerId);
69
+ if (!targets) {
70
+ targets = new Map();
71
+ this.listenerMutes.set(listenerId, targets);
72
+ }
73
+ targets.set(targetId, muted);
74
+ }
75
+
76
+ clear(): void {
77
+ this.members.clear();
78
+ this.listenerMutes.clear();
79
+ }
80
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Showcase clip catalog — browser sends clipId only; the agent resolves URLs here.
3
+ *
4
+ * Production agents receive `assetOrigin` from the browser's `window.location.origin`
5
+ * (typically https://app.voicethere.io or https://app.voicethere.dev). Local dev may
6
+ * use http://localhost:3000 — also allowlisted for resolveClipUrl.
7
+ */
8
+ export type ShowcaseClipId =
9
+ | "chime"
10
+ | "bell"
11
+ | "laser"
12
+ | "impact"
13
+ | "footsteps"
14
+ | "rain-loop"
15
+ | "cafe-loop"
16
+ | "jingle";
17
+
18
+ export type ShowcaseSoundFile = {
19
+ file: string;
20
+ loop: boolean;
21
+ defaultVolume: number;
22
+ };
23
+
24
+ export const SHOWCASE_SOUND_FILES: Record<ShowcaseClipId, ShowcaseSoundFile> = {
25
+ chime: { file: "chime.wav", loop: false, defaultVolume: 0.8 },
26
+ bell: { file: "bell.wav", loop: false, defaultVolume: 0.8 },
27
+ laser: { file: "laser.wav", loop: false, defaultVolume: 0.7 },
28
+ impact: { file: "impact.wav", loop: false, defaultVolume: 0.85 },
29
+ footsteps: { file: "footsteps.wav", loop: false, defaultVolume: 0.75 },
30
+ "rain-loop": { file: "rain-loop.wav", loop: true, defaultVolume: 0.5 },
31
+ "cafe-loop": { file: "cafe-loop.wav", loop: true, defaultVolume: 0.5 },
32
+ jingle: { file: "jingle.wav", loop: false, defaultVolume: 0.8 },
33
+ };
34
+
35
+ export const SHOWCASE_ASSET_ORIGINS = [
36
+ "https://app.voicethere.io",
37
+ "https://app.voicethere.dev",
38
+ "http://localhost:3000",
39
+ ] as const;
40
+
41
+ const CLIP_IDS = new Set<string>(Object.keys(SHOWCASE_SOUND_FILES));
42
+
43
+ export function isShowcaseClipId(clipId: string): clipId is ShowcaseClipId {
44
+ return CLIP_IDS.has(clipId);
45
+ }
46
+
47
+ function normalizeOrigin(origin: string): string {
48
+ return origin.replace(/\/+$/, "");
49
+ }
50
+
51
+ function isAllowedOrigin(origin: string): boolean {
52
+ const normalized = normalizeOrigin(origin);
53
+ return SHOWCASE_ASSET_ORIGINS.some(
54
+ (allowed) => normalizeOrigin(allowed) === normalized,
55
+ );
56
+ }
57
+
58
+ /** Resolve a clip URL from an allowlisted asset origin and clipId — never accept arbitrary URLs. */
59
+ export function resolveClipUrl(
60
+ assetOrigin: string,
61
+ clipId: string,
62
+ ): string | null {
63
+ if (!isAllowedOrigin(assetOrigin) || !isShowcaseClipId(clipId)) {
64
+ return null;
65
+ }
66
+ const { file } = SHOWCASE_SOUND_FILES[clipId];
67
+ return `${normalizeOrigin(assetOrigin)}/showcase/sounds/${file}`;
68
+ }