@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.
- package/README.md +14 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +11 -2
- package/dist/runtime.js.map +1 -1
- package/dist/templates/echo/agent.js +1 -1
- package/dist/templates/echo-dc/agent.js +1 -1
- package/dist/templates/game-sync/agent.js +162 -125
- package/dist/templates/registry.d.ts +1 -1
- package/dist/templates/registry.d.ts.map +1 -1
- package/dist/templates/registry.js +42 -26
- package/dist/templates/registry.js.map +1 -1
- package/dist/templates/spatial-showcase/agent.js +261 -22
- package/dist/templates/voice-starter/agent.js +1 -1
- package/dist/templates/webhooks/agent.js +2 -2
- package/dist/templates/webhooks-redis/agent.js +11 -4
- package/dist/templates/world-sync/agent.js +829 -0
- package/dist/templates/world-sync-binary/agent.js +1031 -0
- package/package.json +3 -2
- package/templates/README.md +41 -69
- package/templates/crash/README.md +13 -0
- package/templates/echo/README.md +13 -0
- package/templates/{echo.ts → echo/agent.ts} +1 -1
- package/templates/echo-dc/README.md +13 -0
- package/templates/{echo-dc.ts → echo-dc/agent.ts} +1 -1
- package/templates/echo-smoke/README.md +13 -0
- package/templates/game-sync/README.md +21 -0
- package/templates/{game-sync.ts → game-sync/agent.ts} +126 -94
- package/templates/{game-sync-redis.ts → game-sync/redis.ts} +4 -13
- package/templates/{game-sync-sim.ts → game-sync/sim.ts} +14 -7
- package/templates/{game-sync-world-layout.ts → game-sync/world-layout.ts} +72 -30
- package/templates/game-sync-smoke/README.md +13 -0
- package/templates/mix-smoke/README.md +13 -0
- package/templates/positional-tts/README.md +13 -0
- package/templates/recording-consent/README.md +13 -0
- package/templates/redis-sync/README.md +23 -0
- package/templates/redis-sync/agent.ts +72 -95
- package/templates/redis-sync/world-layout.ts +103 -26
- package/templates/spatial-showcase/README.md +13 -0
- package/templates/spatial-showcase/agent.ts +245 -18
- package/templates/spatial-showcase/protocol.ts +79 -0
- package/templates/spatial-showcase/sine.ts +28 -5
- package/templates/voice-showcase/README.md +13 -0
- package/templates/voice-starter/README.md +13 -0
- package/templates/webhooks/README.md +13 -0
- package/templates/{webhooks.ts → webhooks/agent.ts} +1 -1
- package/templates/webhooks-redis/README.md +13 -0
- package/templates/{webhooks-redis.ts → webhooks-redis/agent.ts} +9 -9
- package/templates/world-sync/README.md +20 -0
- package/templates/world-sync/agent.ts +62 -0
- package/templates/world-sync-binary/README.md +21 -0
- package/templates/world-sync-binary/agent.ts +107 -0
- package/templates/world-sync-binary/protocol.ts +281 -0
- /package/templates/{crash.ts → crash/agent.ts} +0 -0
- /package/templates/{echo-smoke.ts → echo-smoke/agent.ts} +0 -0
- /package/templates/{game-sync-protocol.ts → game-sync/protocol.ts} +0 -0
- /package/templates/{game-sync-smoke.ts → game-sync-smoke/agent.ts} +0 -0
- /package/templates/{mix-smoke.ts → mix-smoke/agent.ts} +0 -0
- /package/templates/{agent.ts → voice-starter/agent.ts} +0 -0
|
@@ -8,6 +8,8 @@ const SHOWCASE_DEMOS = new Set<string>(["orbit", "soundboard", "proximity"]);
|
|
|
8
8
|
|
|
9
9
|
export const MAX_SAY_TEXT_LENGTH = 200;
|
|
10
10
|
export const MAX_XZ = 5;
|
|
11
|
+
export const ORBIT_SINE_MIN_FREQUENCY_HZ = 110;
|
|
12
|
+
export const ORBIT_SINE_MAX_FREQUENCY_HZ = 1760;
|
|
11
13
|
|
|
12
14
|
export type ShowcaseJoinMessage = {
|
|
13
15
|
type: "join";
|
|
@@ -22,6 +24,8 @@ export type ShowcaseOrbitSetMessage = {
|
|
|
22
24
|
periodSec?: number;
|
|
23
25
|
elevation?: number;
|
|
24
26
|
paused?: boolean;
|
|
27
|
+
frequencyHz?: number;
|
|
28
|
+
sinePlaying?: boolean;
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
export type ShowcaseOrbitSayMessage = {
|
|
@@ -30,6 +34,13 @@ export type ShowcaseOrbitSayMessage = {
|
|
|
30
34
|
text: string;
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
export type ShowcaseOrbitPlaceMessage = {
|
|
38
|
+
type: "orbit";
|
|
39
|
+
action: "place";
|
|
40
|
+
x: number;
|
|
41
|
+
z: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
33
44
|
export type ShowcasePadMessage = {
|
|
34
45
|
type: "pad";
|
|
35
46
|
clipId: string;
|
|
@@ -49,6 +60,13 @@ export type ShowcasePadStatusMessage = {
|
|
|
49
60
|
playId: string;
|
|
50
61
|
};
|
|
51
62
|
|
|
63
|
+
export type ShowcasePadMoveMessage = {
|
|
64
|
+
type: "pad_move";
|
|
65
|
+
playId: string;
|
|
66
|
+
x: number;
|
|
67
|
+
z: number;
|
|
68
|
+
};
|
|
69
|
+
|
|
52
70
|
export type ShowcaseMoveMessage = {
|
|
53
71
|
type: "move";
|
|
54
72
|
x: number;
|
|
@@ -74,9 +92,11 @@ export type ShowcaseInbound =
|
|
|
74
92
|
| ShowcaseJoinMessage
|
|
75
93
|
| ShowcaseOrbitSetMessage
|
|
76
94
|
| ShowcaseOrbitSayMessage
|
|
95
|
+
| ShowcaseOrbitPlaceMessage
|
|
77
96
|
| ShowcasePadMessage
|
|
78
97
|
| ShowcasePadStopMessage
|
|
79
98
|
| ShowcasePadStatusMessage
|
|
99
|
+
| ShowcasePadMoveMessage
|
|
80
100
|
| ShowcaseMoveMessage
|
|
81
101
|
| ShowcaseMutePeerMessage
|
|
82
102
|
| ShowcaseLeaveMessage
|
|
@@ -98,6 +118,11 @@ export type ShowcaseOrbitPose = {
|
|
|
98
118
|
z: number;
|
|
99
119
|
};
|
|
100
120
|
|
|
121
|
+
export type ShowcaseOrbitSayStatus = {
|
|
122
|
+
type: "orbit_say_status";
|
|
123
|
+
status: "playing" | "ended";
|
|
124
|
+
};
|
|
125
|
+
|
|
101
126
|
export type ShowcasePadProgress = {
|
|
102
127
|
type: "pad_progress";
|
|
103
128
|
playId: string;
|
|
@@ -127,6 +152,7 @@ export type ShowcaseError = {
|
|
|
127
152
|
export type ShowcaseOutbound =
|
|
128
153
|
| ShowcaseAck
|
|
129
154
|
| ShowcaseOrbitPose
|
|
155
|
+
| ShowcaseOrbitSayStatus
|
|
130
156
|
| ShowcasePadProgress
|
|
131
157
|
| ShowcaseRoomState
|
|
132
158
|
| ShowcaseRoomFull
|
|
@@ -144,6 +170,12 @@ function isInXzRange(value: number): boolean {
|
|
|
144
170
|
return value >= -MAX_XZ && value <= MAX_XZ;
|
|
145
171
|
}
|
|
146
172
|
|
|
173
|
+
function isOrbitSineFrequencyHz(value: number): boolean {
|
|
174
|
+
return (
|
|
175
|
+
value >= ORBIT_SINE_MIN_FREQUENCY_HZ && value <= ORBIT_SINE_MAX_FREQUENCY_HZ
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
function isMixPlacement(value: unknown): value is MixPlacement {
|
|
148
180
|
return (
|
|
149
181
|
value === "center" ||
|
|
@@ -221,6 +253,19 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
|
|
|
221
253
|
if (set.paused !== undefined && typeof set.paused !== "boolean") {
|
|
222
254
|
return null;
|
|
223
255
|
}
|
|
256
|
+
if (
|
|
257
|
+
set.frequencyHz !== undefined &&
|
|
258
|
+
(!isFiniteNumber(set.frequencyHz) ||
|
|
259
|
+
!isOrbitSineFrequencyHz(set.frequencyHz))
|
|
260
|
+
) {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
if (
|
|
264
|
+
set.sinePlaying !== undefined &&
|
|
265
|
+
typeof set.sinePlaying !== "boolean"
|
|
266
|
+
) {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
224
269
|
return {
|
|
225
270
|
type: "orbit",
|
|
226
271
|
action: "set",
|
|
@@ -228,8 +273,24 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
|
|
|
228
273
|
...(set.periodSec !== undefined ? { periodSec: set.periodSec } : {}),
|
|
229
274
|
...(set.elevation !== undefined ? { elevation: set.elevation } : {}),
|
|
230
275
|
...(set.paused !== undefined ? { paused: set.paused } : {}),
|
|
276
|
+
...(set.frequencyHz !== undefined
|
|
277
|
+
? { frequencyHz: set.frequencyHz }
|
|
278
|
+
: {}),
|
|
279
|
+
...(set.sinePlaying !== undefined
|
|
280
|
+
? { sinePlaying: set.sinePlaying }
|
|
281
|
+
: {}),
|
|
231
282
|
};
|
|
232
283
|
}
|
|
284
|
+
if (orbit.action === "place") {
|
|
285
|
+
const place = message as ShowcaseOrbitPlaceMessage;
|
|
286
|
+
if (!isFiniteNumber(place.x) || !isInXzRange(place.x)) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
if (!isFiniteNumber(place.z) || !isInXzRange(place.z)) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
return { type: "orbit", action: "place", x: place.x, z: place.z };
|
|
293
|
+
}
|
|
233
294
|
if (orbit.action === "say") {
|
|
234
295
|
const say = message as ShowcaseOrbitSayMessage;
|
|
235
296
|
if (
|
|
@@ -287,6 +348,24 @@ export function parseShowcaseMessage(message: unknown): ShowcaseInbound | null {
|
|
|
287
348
|
}
|
|
288
349
|
return { type: "pad_status", playId: status.playId.trim() };
|
|
289
350
|
}
|
|
351
|
+
case "pad_move": {
|
|
352
|
+
const padMove = message as ShowcasePadMoveMessage;
|
|
353
|
+
if (!isNonEmptyString(padMove.playId)) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
if (!isFiniteNumber(padMove.x) || !isInXzRange(padMove.x)) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
if (!isFiniteNumber(padMove.z) || !isInXzRange(padMove.z)) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
type: "pad_move",
|
|
364
|
+
playId: padMove.playId.trim(),
|
|
365
|
+
x: padMove.x,
|
|
366
|
+
z: padMove.z,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
290
369
|
case "move": {
|
|
291
370
|
const move = message as ShowcaseMoveMessage;
|
|
292
371
|
if (!isFiniteNumber(move.x) || !isInXzRange(move.x)) {
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
/** Inline orbit sine clip — 16 kHz mono s16le, decoded size under 64 KiB play cap. */
|
|
2
2
|
|
|
3
|
-
/** 2 s keeps loop-restart gaps
|
|
3
|
+
/** 2 s keeps loop-restart gaps infrequent; 16 kHz mono still fits under 64 KiB. */
|
|
4
4
|
export const ORBIT_SINE_DURATION_MS = 2000;
|
|
5
5
|
export const ORBIT_SINE_FREQUENCY_HZ = 440;
|
|
6
6
|
export const ORBIT_SINE_AMPLITUDE = 8000;
|
|
7
|
+
export const ORBIT_SINE_SAMPLE_RATE = 16_000;
|
|
7
8
|
export const ORBIT_SINE_INLINE_URL =
|
|
8
9
|
"https://example.com/inline-orbit-sine.wav";
|
|
9
10
|
|
|
10
|
-
/**
|
|
11
|
+
/** Snap duration to an integer number of cycles so the clip loops at a zero crossing. */
|
|
12
|
+
export function orbitSineSampleCount(
|
|
13
|
+
durationMs = ORBIT_SINE_DURATION_MS,
|
|
14
|
+
frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
|
|
15
|
+
sampleRate = ORBIT_SINE_SAMPLE_RATE,
|
|
16
|
+
): number {
|
|
17
|
+
const targetCycles = (durationMs / 1000) * frequencyHz;
|
|
18
|
+
const cycles = Math.max(1, Math.floor(targetCycles));
|
|
19
|
+
return Math.round((cycles * sampleRate) / frequencyHz);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Duration in ms for a clip built with {@link orbitSineSampleCount}. */
|
|
23
|
+
export function orbitSineClipDurationMs(
|
|
24
|
+
durationMs = ORBIT_SINE_DURATION_MS,
|
|
25
|
+
frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
|
|
26
|
+
sampleRate = ORBIT_SINE_SAMPLE_RATE,
|
|
27
|
+
): number {
|
|
28
|
+
const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
|
|
29
|
+
return (numSamples / sampleRate) * 1000;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Looping sine WAV as base64 for {@link play} inline bytes. */
|
|
11
33
|
export function buildOrbitSineInlineClipBase64(
|
|
12
34
|
durationMs = ORBIT_SINE_DURATION_MS,
|
|
13
35
|
frequencyHz = ORBIT_SINE_FREQUENCY_HZ,
|
|
14
36
|
amplitude = ORBIT_SINE_AMPLITUDE,
|
|
37
|
+
phaseRad = 0,
|
|
15
38
|
): string {
|
|
16
|
-
const sampleRate =
|
|
39
|
+
const sampleRate = ORBIT_SINE_SAMPLE_RATE;
|
|
17
40
|
const channels = 1;
|
|
18
41
|
const bytesPerSample = 2;
|
|
19
|
-
const numSamples =
|
|
42
|
+
const numSamples = orbitSineSampleCount(durationMs, frequencyHz, sampleRate);
|
|
20
43
|
const dataSize = numSamples * channels * bytesPerSample;
|
|
21
44
|
const buffer = Buffer.alloc(44 + dataSize);
|
|
22
45
|
buffer.write("RIFF", 0);
|
|
@@ -35,7 +58,7 @@ export function buildOrbitSineInlineClipBase64(
|
|
|
35
58
|
for (let i = 0; i < numSamples; i++) {
|
|
36
59
|
const t = i / sampleRate;
|
|
37
60
|
const sample = Math.round(
|
|
38
|
-
amplitude * Math.sin(2 * Math.PI * frequencyHz * t),
|
|
61
|
+
amplitude * Math.sin(2 * Math.PI * frequencyHz * t + phaseRad),
|
|
39
62
|
);
|
|
40
63
|
buffer.writeInt16LE(sample, 44 + i * 2);
|
|
41
64
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# voice-showcase
|
|
2
|
+
|
|
3
|
+
Conversational voice demo for landing and dashboard previews — greeting, name, then a menu (weather, count, recipes, fun facts).
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/voice-showcase/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`, `conversation.ts`, `delivery.ts`, `weather.ts`, `recipes.ts`, `fun-facts.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# voice-starter
|
|
2
|
+
|
|
3
|
+
Starter bundle covering every speech event from `@node-webrtc-rust/sdk/voice`. Customize `onUserSpeechFinal` for your LLM or tools.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/voice-starter/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# webhooks
|
|
2
|
+
|
|
3
|
+
Inbound HTTP webhook sample — verifies `x-agent-webhook-signature` HMAC on the raw body, then fans out to connected sessions via DataChannel + `speak`.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/webhooks/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `AGENT_WEBHOOK_SIGNING_SECRET` **before** `JSON.parse`.
|
|
7
7
|
*
|
|
8
8
|
* Build:
|
|
9
|
-
* npx @voicethere/agent build --entry templates/webhooks.ts --outfile dist/agent.js
|
|
9
|
+
* npx @voicethere/agent build --entry templates/webhooks/agent.ts --outfile dist/agent.js
|
|
10
10
|
*/
|
|
11
11
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
12
12
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# webhooks-redis
|
|
2
|
+
|
|
3
|
+
Same HMAC verify path as `webhooks`, plus an atomic Redis counter (`AGENT_REDIS_URL`) before DataChannel fan-out.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/webhooks-redis/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`
|
|
@@ -8,22 +8,18 @@
|
|
|
8
8
|
* before `JSON.parse`. Atomic counter uses Redis Lua (read-modify-write in one round trip).
|
|
9
9
|
*
|
|
10
10
|
* Build:
|
|
11
|
-
* npx @voicethere/agent build --entry templates/webhooks-redis.ts --outfile dist/agent.js
|
|
11
|
+
* npx @voicethere/agent build --entry templates/webhooks-redis/agent.ts --outfile dist/agent.js
|
|
12
12
|
*/
|
|
13
13
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
14
|
-
import Redis from "ioredis";
|
|
14
|
+
import { Redis } from "ioredis";
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
agentLog,
|
|
18
|
-
broadcastToClients,
|
|
19
|
-
defineAgent,
|
|
20
|
-
} from "@voicethere/agent";
|
|
16
|
+
import { agentLog, broadcastToClients, defineAgent } from "@voicethere/agent";
|
|
21
17
|
|
|
22
18
|
const WEBHOOK_SIGNATURE_HEADER = "x-agent-webhook-signature";
|
|
23
19
|
const REDIS_COUNTER_KEY = "agent:webhook:event_count";
|
|
24
20
|
|
|
25
21
|
/** Atomic increment — safe under concurrent webhook delivery on one pod. */
|
|
26
|
-
const LUA_INCREMENT_COUNTER = `
|
|
22
|
+
export const LUA_INCREMENT_COUNTER = `
|
|
27
23
|
local key = KEYS[1]
|
|
28
24
|
local n = redis.call('INCR', key)
|
|
29
25
|
return n
|
|
@@ -114,7 +110,11 @@ defineAgent({
|
|
|
114
110
|
|
|
115
111
|
let eventCount: number | null = null;
|
|
116
112
|
if (redis) {
|
|
117
|
-
const result = await redis.eval(
|
|
113
|
+
const result = await redis.eval(
|
|
114
|
+
LUA_INCREMENT_COUNTER,
|
|
115
|
+
1,
|
|
116
|
+
REDIS_COUNTER_KEY,
|
|
117
|
+
);
|
|
118
118
|
eventCount = typeof result === "number" ? result : Number(result);
|
|
119
119
|
}
|
|
120
120
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# world-sync
|
|
2
|
+
|
|
3
|
+
Super-simple positional sync for a **data-only** project. One in-memory agent (no Redis) stores the latest `{ x, y, z }` per session and broadcasts a JSON world snapshot.
|
|
4
|
+
|
|
5
|
+
Use this when you want `onDataChannelMessage` plus `sendToClient` and nothing else.
|
|
6
|
+
|
|
7
|
+
## Protocol
|
|
8
|
+
|
|
9
|
+
- **Inbound:** `{ "type": "pose", "x": 1, "y": 0, "z": -2 }` (or `{ "x", "y", "z" }`)
|
|
10
|
+
- **Outbound:** `{ "type": "world", "poses": { "<sessionId>": { "x", "y", "z" } } }`
|
|
11
|
+
|
|
12
|
+
## Build
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @voicethere/agent build --entry templates/world-sync/agent.ts --outfile dist/agent.js
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Sources
|
|
19
|
+
|
|
20
|
+
- `agent.ts` — `defineAgent` wiring and pose parse helper
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Super-simple world sync — one in-memory agent, JSON DataChannel messages, no Redis.
|
|
3
|
+
*
|
|
4
|
+
* Clients send `{ type: "pose", x, y, z }` (or `{ x, y, z }`) on the sync channel.
|
|
5
|
+
* The agent stores the latest pose per session and broadcasts
|
|
6
|
+
* `{ type: "world", poses }` to every connected peer.
|
|
7
|
+
*
|
|
8
|
+
* Build:
|
|
9
|
+
* npx @voicethere/agent build --entry templates/world-sync/agent.ts --outfile dist/agent.js
|
|
10
|
+
*/
|
|
11
|
+
import { defineAgent, sendToClient } from "@voicethere/agent";
|
|
12
|
+
|
|
13
|
+
export type Pose = { x: number; y: number; z: number };
|
|
14
|
+
|
|
15
|
+
const poses = new Map<string, Pose>();
|
|
16
|
+
|
|
17
|
+
export function isFiniteNumber(value: unknown): value is number {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parsePoseMessage(message: unknown): Pose | null {
|
|
22
|
+
if (!message || typeof message !== "object") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const record = message as { type?: unknown; x?: unknown; y?: unknown; z?: unknown };
|
|
26
|
+
if (record.type !== undefined && record.type !== "pose") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
if (!isFiniteNumber(record.x) || !isFiniteNumber(record.y) || !isFiniteNumber(record.z)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { x: record.x, y: record.y, z: record.z };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function broadcastWorld(): void {
|
|
36
|
+
const payload = {
|
|
37
|
+
type: "world",
|
|
38
|
+
poses: Object.fromEntries(poses),
|
|
39
|
+
};
|
|
40
|
+
for (const sessionId of poses.keys()) {
|
|
41
|
+
sendToClient(sessionId, payload);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default defineAgent({
|
|
46
|
+
onClientJoin({ sessionId }) {
|
|
47
|
+
poses.set(sessionId, { x: 0, y: 0, z: 0 });
|
|
48
|
+
broadcastWorld();
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
onClientLeave({ sessionId }) {
|
|
52
|
+
poses.delete(sessionId);
|
|
53
|
+
broadcastWorld();
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
onDataChannelMessage({ sessionId, message }) {
|
|
57
|
+
const pose = parsePoseMessage(message);
|
|
58
|
+
if (!pose) return;
|
|
59
|
+
poses.set(sessionId, pose);
|
|
60
|
+
broadcastWorld();
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# world-sync-binary
|
|
2
|
+
|
|
3
|
+
Single-agent positional sync using **binary** DataChannel frames. No Redis.
|
|
4
|
+
|
|
5
|
+
Clients send an `ArrayBuffer` of three little-endian float32 values `[x, y, z]`. Reuse one `Float32Array(3)` on the client. The agent stores xyz in one `Float32Array`, rewrites a single `WorldSnapshotBuffer` in place, and fans the resulting view out with `broadCastBinaryToClients` — one `Buffer` wrap per tick, zero byte copies.
|
|
6
|
+
|
|
7
|
+
## Protocol
|
|
8
|
+
|
|
9
|
+
- **Inbound (12 bytes):** `Float32Array([x, y, z]).buffer`
|
|
10
|
+
- **Outbound snapshot:** `uint32le peerCount`, then per peer `uint16le idLength` + UTF-8 `sessionId` + `float32le x,y,z`
|
|
11
|
+
|
|
12
|
+
## Build
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx @voicethere/agent build --entry templates/world-sync-binary/agent.ts --outfile dist/agent.js
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Sources
|
|
19
|
+
|
|
20
|
+
- `agent.ts` — `onDataChannelBinary` + `broadCastBinaryToClients`
|
|
21
|
+
- `protocol.ts` — encode/decode helpers; `WorldSnapshotBuffer` reuses one backing array
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-agent binary world sync — no Redis.
|
|
3
|
+
*
|
|
4
|
+
* Clients send an ArrayBuffer of 3 float32 values `[x, y, z]` on the sync
|
|
5
|
+
* DataChannel. Poses live in one Float32Array; the outbound snapshot is one
|
|
6
|
+
* growable buffer rewritten in place and fanned out as a single Buffer view
|
|
7
|
+
* (no per-tick or per-peer copies).
|
|
8
|
+
*
|
|
9
|
+
* Build:
|
|
10
|
+
* npx @voicethere/agent build --entry templates/world-sync-binary/agent.ts --outfile dist/agent.js
|
|
11
|
+
*/
|
|
12
|
+
import { broadCastBinaryToClients, defineAgent } from "@voicethere/agent";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
decodePoseInto,
|
|
16
|
+
POSE_FLOAT_COUNT,
|
|
17
|
+
WorldSnapshotBuffer,
|
|
18
|
+
} from "./protocol.js";
|
|
19
|
+
|
|
20
|
+
const snapshot = new WorldSnapshotBuffer();
|
|
21
|
+
const indexBySession = new Map<string, number>();
|
|
22
|
+
const sessionIds: string[] = [];
|
|
23
|
+
let xyz = new Float32Array(8 * POSE_FLOAT_COUNT);
|
|
24
|
+
let peerCount = 0;
|
|
25
|
+
|
|
26
|
+
function ensurePeerCapacity(count: number): void {
|
|
27
|
+
const needed = count * POSE_FLOAT_COUNT;
|
|
28
|
+
if (xyz.length >= needed) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
let nextLength = xyz.length;
|
|
32
|
+
while (nextLength < needed) {
|
|
33
|
+
nextLength *= 2;
|
|
34
|
+
}
|
|
35
|
+
const grown = new Float32Array(nextLength);
|
|
36
|
+
grown.set(xyz);
|
|
37
|
+
xyz = grown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setPose(sessionId: string, x: number, y: number, z: number): void {
|
|
41
|
+
let index = indexBySession.get(sessionId);
|
|
42
|
+
if (index === undefined) {
|
|
43
|
+
index = peerCount;
|
|
44
|
+
peerCount += 1;
|
|
45
|
+
sessionIds[index] = sessionId;
|
|
46
|
+
indexBySession.set(sessionId, index);
|
|
47
|
+
ensurePeerCapacity(peerCount);
|
|
48
|
+
}
|
|
49
|
+
const offset = index * POSE_FLOAT_COUNT;
|
|
50
|
+
xyz[offset] = x;
|
|
51
|
+
xyz[offset + 1] = y;
|
|
52
|
+
xyz[offset + 2] = z;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function removePeer(sessionId: string): void {
|
|
56
|
+
const index = indexBySession.get(sessionId);
|
|
57
|
+
if (index === undefined) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const last = peerCount - 1;
|
|
61
|
+
if (index !== last) {
|
|
62
|
+
const lastId = sessionIds[last]!;
|
|
63
|
+
sessionIds[index] = lastId;
|
|
64
|
+
indexBySession.set(lastId, index);
|
|
65
|
+
const dest = index * POSE_FLOAT_COUNT;
|
|
66
|
+
const src = last * POSE_FLOAT_COUNT;
|
|
67
|
+
xyz[dest] = xyz[src]!;
|
|
68
|
+
xyz[dest + 1] = xyz[src + 1]!;
|
|
69
|
+
xyz[dest + 2] = xyz[src + 2]!;
|
|
70
|
+
}
|
|
71
|
+
indexBySession.delete(sessionId);
|
|
72
|
+
peerCount -= 1;
|
|
73
|
+
sessionIds.length = peerCount;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function broadcastWorld(): void {
|
|
77
|
+
// sessionIds.length === peerCount, so one Buffer view is shared by every send.
|
|
78
|
+
const payload = snapshot.encodePacked(sessionIds, xyz, peerCount);
|
|
79
|
+
broadCastBinaryToClients(payload, sessionIds, "sync");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default defineAgent({
|
|
83
|
+
onClientJoin({ sessionId }) {
|
|
84
|
+
setPose(sessionId, 0, 0, 0);
|
|
85
|
+
broadcastWorld();
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
onClientLeave({ sessionId }) {
|
|
89
|
+
removePeer(sessionId);
|
|
90
|
+
broadcastWorld();
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
onDataChannelBinary({ sessionId, rawBinary }) {
|
|
94
|
+
let index = indexBySession.get(sessionId);
|
|
95
|
+
if (index === undefined) {
|
|
96
|
+
setPose(sessionId, 0, 0, 0);
|
|
97
|
+
index = indexBySession.get(sessionId);
|
|
98
|
+
if (index === undefined) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!decodePoseInto(rawBinary, xyz, index * POSE_FLOAT_COUNT)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
broadcastWorld();
|
|
106
|
+
},
|
|
107
|
+
});
|