@voicethere/agent 0.7.13 → 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/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/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
|
@@ -18,8 +18,37 @@ export const PEER_FIELD_X = 1;
|
|
|
18
18
|
export const PEER_FIELD_Y = 2;
|
|
19
19
|
export const PEER_FIELD_ACTIVE = 3;
|
|
20
20
|
|
|
21
|
+
/** Client → agent position frame: float32le [clientIndex, x, y] (12 bytes). */
|
|
22
|
+
export const POSITION_FLOAT_COUNT = 3;
|
|
23
|
+
export const POSITION_BYTE_LENGTH = POSITION_FLOAT_COUNT * 4;
|
|
24
|
+
|
|
25
|
+
export type PeerPosition = {
|
|
26
|
+
clientIndex: number;
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
21
31
|
export const REDIS_WORLD_KEY = "e2e:redis-sync:world";
|
|
22
32
|
|
|
33
|
+
/** Atomic splice of one peer slot (16 bytes) into the world blob. */
|
|
34
|
+
export const LUA_PATCH_PEER_SLOT = `
|
|
35
|
+
local key = KEYS[1]
|
|
36
|
+
local offset = tonumber(ARGV[1])
|
|
37
|
+
local slot = ARGV[2]
|
|
38
|
+
local size = tonumber(ARGV[3])
|
|
39
|
+
local world = redis.call('GET', key)
|
|
40
|
+
if not world then
|
|
41
|
+
world = string.rep(string.char(0), size)
|
|
42
|
+
elseif #world < size then
|
|
43
|
+
world = world .. string.rep(string.char(0), size - #world)
|
|
44
|
+
elseif #world > size then
|
|
45
|
+
world = string.sub(world, 1, size)
|
|
46
|
+
end
|
|
47
|
+
world = string.sub(world, 1, offset) .. slot .. string.sub(world, offset + #slot + 1)
|
|
48
|
+
redis.call('SET', key, world)
|
|
49
|
+
return size
|
|
50
|
+
`;
|
|
51
|
+
|
|
23
52
|
export type PeerSlot = {
|
|
24
53
|
clientIndex: number;
|
|
25
54
|
x: number;
|
|
@@ -53,6 +82,51 @@ export function clearPeerSlot(world: Float32Array, clientIndex: number): void {
|
|
|
53
82
|
writePeerSlot(world, clientIndex, 0, 0, 0);
|
|
54
83
|
}
|
|
55
84
|
|
|
85
|
+
function asDataView(data: ArrayBufferLike | ArrayBufferView): DataView {
|
|
86
|
+
if (ArrayBuffer.isView(data)) {
|
|
87
|
+
return new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
88
|
+
}
|
|
89
|
+
return new DataView(data);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Decode a 12-byte inbound position frame. */
|
|
93
|
+
export function decodePositionBuffer(
|
|
94
|
+
data: ArrayBufferLike | ArrayBufferView | null | undefined,
|
|
95
|
+
): PeerPosition | null {
|
|
96
|
+
if (data == null) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const view = asDataView(data);
|
|
100
|
+
if (view.byteLength < POSITION_BYTE_LENGTH) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const clientIndex = view.getFloat32(0, true);
|
|
104
|
+
const x = view.getFloat32(4, true);
|
|
105
|
+
const y = view.getFloat32(8, true);
|
|
106
|
+
if (
|
|
107
|
+
!Number.isFinite(clientIndex) ||
|
|
108
|
+
clientIndex < 0 ||
|
|
109
|
+
!Number.isFinite(x) ||
|
|
110
|
+
!Number.isFinite(y)
|
|
111
|
+
) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { clientIndex: Math.trunc(clientIndex), x, y };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Write [clientIndex, x, y] into `dest` (length >= 3) and return it. */
|
|
118
|
+
export function encodePositionInto(
|
|
119
|
+
dest: Float32Array,
|
|
120
|
+
clientIndex: number,
|
|
121
|
+
x: number,
|
|
122
|
+
y: number,
|
|
123
|
+
): Float32Array {
|
|
124
|
+
dest[0] = clientIndex;
|
|
125
|
+
dest[1] = x;
|
|
126
|
+
dest[2] = y;
|
|
127
|
+
return dest;
|
|
128
|
+
}
|
|
129
|
+
|
|
56
130
|
export function readPeerSlot(
|
|
57
131
|
world: Float32Array,
|
|
58
132
|
clientIndex: number,
|
|
@@ -71,46 +145,49 @@ export function readPeerSlot(
|
|
|
71
145
|
}
|
|
72
146
|
|
|
73
147
|
/**
|
|
74
|
-
*
|
|
148
|
+
* Copy Redis / Node Buffer bytes into `dest` (default: a new world).
|
|
75
149
|
*
|
|
76
|
-
* Node Buffer pools
|
|
77
|
-
*
|
|
78
|
-
* "start offset of Float32Array should be a multiple of 4"
|
|
79
|
-
* Copy via `ArrayBuffer.slice` so the view always starts at offset 0.
|
|
150
|
+
* Node Buffer pools can have `byteOffset` not divisible by 4, so we copy via
|
|
151
|
+
* a Uint8Array view onto `dest` instead of slicing a new ArrayBuffer.
|
|
80
152
|
*/
|
|
81
153
|
export function normalizeWorldBuffer(
|
|
82
154
|
raw: Uint8Array | null | undefined,
|
|
155
|
+
dest: Float32Array = createEmptyWorldBuffer(),
|
|
83
156
|
): Float32Array {
|
|
157
|
+
const destBytes = new Uint8Array(
|
|
158
|
+
dest.buffer,
|
|
159
|
+
dest.byteOffset,
|
|
160
|
+
dest.byteLength,
|
|
161
|
+
);
|
|
84
162
|
if (!raw || raw.byteLength === 0) {
|
|
85
|
-
|
|
163
|
+
destBytes.fill(0);
|
|
164
|
+
return dest;
|
|
86
165
|
}
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
166
|
+
const byteCount = Math.min(
|
|
167
|
+
Math.floor(raw.byteLength / 4) * 4,
|
|
168
|
+
destBytes.byteLength,
|
|
169
|
+
);
|
|
170
|
+
if (byteCount > 0) {
|
|
171
|
+
destBytes.set(raw.subarray(0, byteCount));
|
|
90
172
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (decoded.length === WORLD_FLOAT_COUNT) {
|
|
94
|
-
return decoded;
|
|
173
|
+
if (byteCount < destBytes.byteLength) {
|
|
174
|
+
destBytes.fill(0, byteCount);
|
|
95
175
|
}
|
|
96
|
-
|
|
97
|
-
normalized.set(
|
|
98
|
-
decoded.subarray(0, Math.min(decoded.length, WORLD_FLOAT_COUNT)),
|
|
99
|
-
);
|
|
100
|
-
return normalized;
|
|
176
|
+
return dest;
|
|
101
177
|
}
|
|
102
178
|
|
|
103
|
-
export function decodeWorldBuffer(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const normalized = createEmptyWorldBuffer();
|
|
109
|
-
normalized.set(view.subarray(0, Math.min(view.length, WORLD_FLOAT_COUNT)));
|
|
110
|
-
return normalized;
|
|
179
|
+
export function decodeWorldBuffer(
|
|
180
|
+
data: ArrayBuffer,
|
|
181
|
+
dest: Float32Array = createEmptyWorldBuffer(),
|
|
182
|
+
): Float32Array {
|
|
183
|
+
return normalizeWorldBuffer(new Uint8Array(data), dest);
|
|
111
184
|
}
|
|
112
185
|
|
|
186
|
+
/** Same backing ArrayBuffer when the world is a standalone Float32Array. */
|
|
113
187
|
export function worldBufferToArrayBuffer(world: Float32Array): ArrayBuffer {
|
|
188
|
+
if (world.byteOffset === 0 && world.byteLength === world.buffer.byteLength) {
|
|
189
|
+
return world.buffer as ArrayBuffer;
|
|
190
|
+
}
|
|
114
191
|
return world.buffer.slice(
|
|
115
192
|
world.byteOffset,
|
|
116
193
|
world.byteOffset + world.byteLength,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# spatial-showcase
|
|
2
|
+
|
|
3
|
+
Spatial audio showcase for `/showcase`. A `{ type: "join", demo }` DataChannel message selects `orbit`, `soundboard`, or `proximity`.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @voicethere/agent build --entry templates/spatial-showcase/agent.ts --outfile dist/agent.js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Sources
|
|
12
|
+
|
|
13
|
+
- `agent.ts`, `protocol.ts`, `sounds.ts`, `room.ts`, `orbit.ts`, `orbit-session.ts`, `sine.ts`
|
|
@@ -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
|
+
});
|