@crowdedkingdoms/construct 0.0.0 → 0.2.1-dev.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.
Files changed (69) hide show
  1. package/README.md +76 -1
  2. package/node/copy-dsh-web.mjs +71 -0
  3. package/node/security-headers.d.mts +23 -0
  4. package/node/security-headers.mjs +243 -0
  5. package/node/vite-plugins.d.mts +6 -0
  6. package/node/vite-plugins.mjs +102 -0
  7. package/package.json +48 -3
  8. package/sandbox/boot.ts +74 -0
  9. package/src/engine/GameLoop.ts +106 -0
  10. package/src/engine/GameScene.ts +74 -0
  11. package/src/engine/Input.test.ts +129 -0
  12. package/src/engine/Input.ts +257 -0
  13. package/src/engine/SceneRouter.test.ts +80 -0
  14. package/src/engine/SceneRouter.ts +113 -0
  15. package/src/engine/controls.test.ts +26 -0
  16. package/src/engine/controls.ts +63 -0
  17. package/src/grid/GridProgramRunner.ts +205 -0
  18. package/src/grid/sandboxProtocol.ts +9 -0
  19. package/src/index.ts +88 -0
  20. package/src/platform/GameSession.ts +239 -0
  21. package/src/platform/auth/AuthService.ts +45 -0
  22. package/src/platform/config.test.ts +110 -0
  23. package/src/platform/config.ts +212 -0
  24. package/src/platform/envScope.test.ts +42 -0
  25. package/src/platform/envScope.ts +76 -0
  26. package/src/platform/exec/worldHub.test.ts +61 -0
  27. package/src/platform/exec/worldHub.ts +86 -0
  28. package/src/platform/media/VoiceService.ts +423 -0
  29. package/src/platform/media/WebcamService.ts +346 -0
  30. package/src/platform/media/mulaw.test.ts +20 -0
  31. package/src/platform/media/mulaw.ts +56 -0
  32. package/src/platform/media/videoFrames.test.ts +197 -0
  33. package/src/platform/media/voiceFrames.test.ts +82 -0
  34. package/src/platform/model/ModelService.test.ts +59 -0
  35. package/src/platform/model/ModelService.ts +124 -0
  36. package/src/platform/network/NetworkManager.ts +519 -0
  37. package/src/platform/onboarding/steps.d.mts +154 -0
  38. package/src/platform/onboarding/steps.mjs +616 -0
  39. package/src/platform/onboarding/steps.test.ts +486 -0
  40. package/src/platform/realtime/WorldStores.ts +153 -0
  41. package/src/platform/realtime/actorCodec.test.ts +62 -0
  42. package/src/platform/realtime/actorCodec.ts +142 -0
  43. package/src/platform/realtime/space.test.ts +33 -0
  44. package/src/platform/realtime/space.ts +63 -0
  45. package/src/platform/social/ChatService.ts +92 -0
  46. package/src/platform/studio/ClientModLifecycle.test.ts +56 -0
  47. package/src/platform/studio/ClientModLifecycle.ts +102 -0
  48. package/src/platform/studio/ConstructPlayerHostAdapter.test.ts +159 -0
  49. package/src/platform/studio/ConstructPlayerHostAdapter.ts +392 -0
  50. package/src/platform/studio/GridService.test.ts +131 -0
  51. package/src/platform/studio/GridService.ts +295 -0
  52. package/src/platform/studio/HumanInputMonitor.test.ts +51 -0
  53. package/src/platform/studio/HumanInputMonitor.ts +48 -0
  54. package/src/platform/studio/StudioService.ts +686 -0
  55. package/src/platform/studio/agentLocomotion.test.ts +31 -0
  56. package/src/platform/studio/agentLocomotion.ts +97 -0
  57. package/src/platform/studio/clientModHost.test.ts +199 -0
  58. package/src/platform/studio/clientModHost.ts +369 -0
  59. package/src/platform/studio/instanceSchema.test.ts +280 -0
  60. package/src/platform/studio/instanceSchema.ts +746 -0
  61. package/src/platform/studio/instanceStore.test.ts +97 -0
  62. package/src/platform/studio/instanceStore.ts +120 -0
  63. package/src/platform/studio/modOverlay.test.ts +56 -0
  64. package/src/platform/studio/modOverlay.ts +51 -0
  65. package/src/platform/studio/permissions.test.ts +51 -0
  66. package/src/platform/studio/permissions.ts +134 -0
  67. package/src/platform/studio/pointerClicks.test.ts +23 -0
  68. package/src/platform/studio/pointerClicks.ts +132 -0
  69. package/src/platform/util/Emitter.ts +32 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Browser-storage namespacing per API environment.
3
+ *
4
+ * A developer may point one browser profile at more than one Crowded Kingdoms
5
+ * tier (their production app and a dev tier during SDK work). Session tokens,
6
+ * remembered app ids and actor uuids must never leak between them, so every
7
+ * key this game writes is suffixed with a handle derived from the API origin
8
+ * the bundle was built against. The handle is the origin string itself, not a
9
+ * regex over the hostname: the fleet has renamed its hosts before, and a
10
+ * derivation that "recognises" a shape fails silently the day the shape
11
+ * changes.
12
+ */
13
+ import { API_HTTP_URL } from './config';
14
+
15
+ const ENV_MARKER_KEY = 'construct:env-handle';
16
+
17
+ export function envHandleFor(apiOrigin: string): string {
18
+ try {
19
+ const url = new URL(apiOrigin);
20
+ return `${url.hostname}${url.port ? `_${url.port}` : ''}`;
21
+ } catch {
22
+ return apiOrigin.replace(/[^a-zA-Z0-9_.-]/g, '_');
23
+ }
24
+ }
25
+
26
+ export const ENV_HANDLE: string = envHandleFor(API_HTTP_URL);
27
+
28
+ /** Namespaced key: `<base>:<env-handle>`. */
29
+ export function envScopedKey(base: string, handle: string = ENV_HANDLE): string {
30
+ return `${base}:${handle}`;
31
+ }
32
+
33
+ /** Keys that describe the previous environment and must not survive a switch. */
34
+ const SCOPED_LOCAL_PREFIXES = ['construct:app-id', 'construct:last-program'];
35
+
36
+ /**
37
+ * Record which environment this profile last ran against, and drop cached
38
+ * per-environment values when it changes. Tokens are already keyed per env by
39
+ * their store names, so they simply become unreachable rather than deleted.
40
+ */
41
+ export function ensureEnvScope(storage: Storage | undefined = globalThis.localStorage): void {
42
+ if (!storage) return;
43
+ try {
44
+ const previous = storage.getItem(ENV_MARKER_KEY);
45
+ if (previous && previous !== ENV_HANDLE) {
46
+ for (const base of SCOPED_LOCAL_PREFIXES) storage.removeItem(envScopedKey(base, previous));
47
+ }
48
+ storage.setItem(ENV_MARKER_KEY, ENV_HANDLE);
49
+ } catch {
50
+ // Storage may be unavailable (private mode quotas); the game still runs.
51
+ }
52
+ }
53
+
54
+ export function readScoped(
55
+ base: string,
56
+ storage: Storage | undefined = globalThis.localStorage,
57
+ ): string | null {
58
+ try {
59
+ return storage?.getItem(envScopedKey(base)) ?? null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export function writeScoped(
66
+ base: string,
67
+ value: string | null,
68
+ storage: Storage | undefined = globalThis.localStorage,
69
+ ): void {
70
+ try {
71
+ if (value === null) storage?.removeItem(envScopedKey(base));
72
+ else storage?.setItem(envScopedKey(base), value);
73
+ } catch {
74
+ // ignore
75
+ }
76
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { WorldHub, configureWorldHub, worldHubAddress } from './worldHub';
4
+
5
+ function fakeClient(connectResults: Array<'fail' | 'ok'> = ['ok']) {
6
+ const connection = {
7
+ call: vi.fn(async (_type: string, _key: string, method: string) => ({ method })),
8
+ close: vi.fn(),
9
+ };
10
+ const exec = {
11
+ connect: vi.fn(async () => {
12
+ if (connectResults.shift() === 'fail') throw new Error('Unavailable: no host');
13
+ return connection;
14
+ }),
15
+ };
16
+ return { client: { exec } as never, exec, connection };
17
+ }
18
+
19
+ describe('WorldHub', () => {
20
+ it('opens one connection, on the host running the hub, for every call', async () => {
21
+ const { client, exec, connection } = fakeClient();
22
+ const hub = new WorldHub(client, '77');
23
+ const [status] = await Promise.all([hub.call('status'), hub.call('claims', { mine: true })]);
24
+ expect(status).toEqual({ method: 'status' });
25
+ expect(exec.connect).toHaveBeenCalledTimes(1);
26
+ expect(exec.connect).toHaveBeenCalledWith('77', { nodeType: 'world', key: 'main' });
27
+ expect(connection.call).toHaveBeenCalledWith('world', 'main', 'claims', { mine: true });
28
+ });
29
+
30
+ it('waits before dialling again after a failed connect, and never dials after close', async () => {
31
+ vi.useFakeTimers();
32
+ try {
33
+ const { client, exec, connection } = fakeClient(['fail', 'ok']);
34
+ const hub = new WorldHub(client, '77');
35
+ await expect(hub.call('world')).rejects.toThrow(/no host/);
36
+ await expect(hub.call('world')).rejects.toThrow(/no host/);
37
+ expect(exec.connect).toHaveBeenCalledTimes(1);
38
+ vi.advanceTimersByTime(15_000);
39
+ await hub.call('world');
40
+ expect(exec.connect).toHaveBeenCalledTimes(2);
41
+ hub.close();
42
+ await Promise.resolve();
43
+ expect(connection.close).toHaveBeenCalledTimes(1);
44
+ await expect(hub.call('world')).rejects.toThrow(/closed/);
45
+ } finally {
46
+ vi.useRealTimers();
47
+ }
48
+ });
49
+
50
+ it('calls the hub a game names at boot', async () => {
51
+ configureWorldHub({ key: 'eu' });
52
+ try {
53
+ expect(worldHubAddress()).toEqual({ nodeType: 'world', key: 'eu' });
54
+ const { client, connection } = fakeClient();
55
+ await new WorldHub(client, '77').call('status');
56
+ expect(connection.call).toHaveBeenCalledWith('world', 'eu', 'status', undefined);
57
+ } finally {
58
+ configureWorldHub({ nodeType: 'world', key: 'main' });
59
+ }
60
+ });
61
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The game's world hub on ck-exec: the server this game's services ask for what the platform
3
+ * does not keep for it (the world's pulse, the program catalog, the claim registry, each
4
+ * player's progression). One connection per page, opened by the first call on the host that
5
+ * runs the hub (`execConnect` with the app token) and closed with the game client.
6
+ *
7
+ * It is a keyed hub rather than the app's root, which the platform limits to 50 calls a
8
+ * second, so per-player reads belong here. A game built on the framework runs its own hub and
9
+ * names it once at boot with {@link configureWorldHub}; the defaults are the Construct
10
+ * starter's (`exec/ckx.json`: type `world`, key `main`), so the starter needs no call.
11
+ */
12
+ import type { CrowdyClient, ExecConnection } from '@crowdedkingdoms/crowdyjs';
13
+
14
+ export interface WorldHubAddress {
15
+ nodeType: string;
16
+ key: string;
17
+ }
18
+
19
+ let address: WorldHubAddress = { nodeType: 'world', key: 'main' };
20
+
21
+ /** After a failed connect, calls fail with its error for this long instead of dialling again. */
22
+ const REDIAL_AFTER_MS = 15_000;
23
+
24
+ /** Call once at boot, before the session starts. */
25
+ export function configureWorldHub(next: Partial<WorldHubAddress>): void {
26
+ address = { ...address, ...next };
27
+ }
28
+
29
+ export function worldHubAddress(): WorldHubAddress {
30
+ return address;
31
+ }
32
+
33
+ export class WorldHub {
34
+ private connection: Promise<ExecConnection> | null = null;
35
+ private failure: { at: number; error: unknown } | null = null;
36
+ private closed = false;
37
+
38
+ constructor(
39
+ private readonly client: Pick<CrowdyClient, 'exec'>,
40
+ private readonly appId: string,
41
+ ) {}
42
+
43
+ /**
44
+ * One of the hub's endpoints. A refusal from the hub (not your claim, a missing argument)
45
+ * throws a `CrowdyExecError` with status `AppError` and the hub's message; the platform's
46
+ * own refusals throw it with their status.
47
+ */
48
+ async call<T>(method: string, args?: Record<string, unknown>): Promise<T> {
49
+ const { nodeType, key } = address;
50
+ const connection = await this.connect();
51
+ return connection.call<T>(nodeType, key, method, args);
52
+ }
53
+
54
+ close(): void {
55
+ this.closed = true;
56
+ const pending = this.connection;
57
+ this.connection = null;
58
+ void pending?.then(
59
+ (connection) => connection.close(),
60
+ () => undefined,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The HUD and the Studio poll on timers, so a hub that is not there (not deployed, or a tier
66
+ * without ck-exec) is not asked for again on every poll.
67
+ */
68
+ private connect(): Promise<ExecConnection> {
69
+ if (this.closed) return Promise.reject(new Error('The world hub connection is closed'));
70
+ if (!this.connection && this.failure && Date.now() - this.failure.at < REDIAL_AFTER_MS) {
71
+ return Promise.reject(this.failure.error);
72
+ }
73
+ this.connection ??= this.client.exec
74
+ .connect(this.appId, { nodeType: address.nodeType, key: address.key })
75
+ .then((connection) => {
76
+ this.failure = null;
77
+ return connection;
78
+ })
79
+ .catch((error: unknown) => {
80
+ this.connection = null;
81
+ this.failure = { at: Date.now(), error };
82
+ throw error;
83
+ });
84
+ return this.connection;
85
+ }
86
+ }
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Proximity voice over the realtime plane — same transport shape as WebcamService.
3
+ *
4
+ * Capture: mic → AudioWorklet (ScriptProcessor fallback) → 8 kHz mono →
5
+ * G.711 µ-law → 60 ms frames → `udp.sendAudioPacket`. Playback: sequence
6
+ * check, then a pluggable sink (Web Audio in the browser; a recorder in tests).
7
+ *
8
+ * Permission: `use_voice_chat`. One UNAUTHORIZED toast, then stop.
9
+ */
10
+ import { Emitter } from '../util/Emitter';
11
+ import { acceptVoiceSequence, decodeSamples, encodeSamples } from './mulaw';
12
+
13
+ export interface VoiceEvents {
14
+ /** Local capture state changed (HUD toggle). */
15
+ local: { live: boolean; error: string | null };
16
+ }
17
+
18
+ export const CAPTURE_RATE = 8000;
19
+ export const FRAME_MS = 60;
20
+ export const FRAME_SAMPLES = (CAPTURE_RATE * FRAME_MS) / 1000;
21
+ export const VOICE_DISTANCE = 1;
22
+ export const SEQUENCE_RESET_MS = 1_500;
23
+
24
+ export interface VoiceTransport {
25
+ onAudio(
26
+ handler: (n: { uuid: string; audioData: string; sequenceNumber?: number }) => void,
27
+ ): () => void;
28
+ onActorLeft(handler: (n: { uuid: string }) => void): () => void;
29
+ onLaneLeave(handler: (uuid: string) => void): () => void;
30
+ selfUuid(): string | null;
31
+ selfChunk(): { x: string; y: string; z: string } | null;
32
+ selfPosition(): { x: number; y: number; z: number } | null;
33
+ remotePositions(): Array<{ uuid: string; x: number; y: number; z: number }>;
34
+ appId(): string;
35
+ sendAudioPacket(input: {
36
+ appId: string;
37
+ chunk: { x: string; y: string; z: string };
38
+ uuid: string;
39
+ audioData: string;
40
+ distance: number;
41
+ sequenceNumber: number;
42
+ }): Promise<boolean>;
43
+ log(message: string): void;
44
+ }
45
+
46
+ export interface VoicePlayback {
47
+ play(uuid: string, samples: Float32Array, sampleRate: number, gain: number, pan: number): void;
48
+ forget(uuid: string): void;
49
+ dispose(): void;
50
+ resume(): Promise<void>;
51
+ }
52
+
53
+ const CAPTURE_WORKLET = `
54
+ class CrowdyVoiceCapture extends AudioWorkletProcessor {
55
+ constructor() {
56
+ super();
57
+ this.buffer = new Float32Array(2048);
58
+ this.length = 0;
59
+ }
60
+ process(inputs) {
61
+ const channel = inputs[0] && inputs[0][0];
62
+ if (!channel) return true;
63
+ let offset = 0;
64
+ while (offset < channel.length) {
65
+ const take = Math.min(channel.length - offset, this.buffer.length - this.length);
66
+ this.buffer.set(channel.subarray(offset, offset + take), this.length);
67
+ this.length += take;
68
+ offset += take;
69
+ if (this.length === this.buffer.length) {
70
+ const out = this.buffer.slice(0);
71
+ this.port.postMessage(out, [out.buffer]);
72
+ this.length = 0;
73
+ }
74
+ }
75
+ return true;
76
+ }
77
+ }
78
+ registerProcessor("crowdy-voice-capture", CrowdyVoiceCapture);
79
+ `;
80
+
81
+ export class RecordingPlayback implements VoicePlayback {
82
+ readonly played: Array<{ uuid: string; samples: Float32Array; gain: number; pan: number }> = [];
83
+ readonly forgotten: string[] = [];
84
+
85
+ play(uuid: string, samples: Float32Array, _rate: number, gain: number, pan: number): void {
86
+ this.played.push({ uuid, samples, gain, pan });
87
+ }
88
+ forget(uuid: string): void {
89
+ this.forgotten.push(uuid);
90
+ }
91
+ dispose(): void {}
92
+ async resume(): Promise<void> {}
93
+ }
94
+
95
+ export class WebAudioPlayback implements VoicePlayback {
96
+ private context: AudioContext | null = null;
97
+ private readonly speakers = new Map<
98
+ string,
99
+ { nextAt: number; panner: StereoPannerNode; gain: GainNode }
100
+ >();
101
+
102
+ private ensure(): AudioContext {
103
+ this.context ??= new AudioContext({ latencyHint: 'interactive' });
104
+ return this.context;
105
+ }
106
+
107
+ async resume(): Promise<void> {
108
+ const ctx = this.ensure();
109
+ if (ctx.state === 'suspended') await ctx.resume();
110
+ }
111
+
112
+ play(
113
+ uuid: string,
114
+ samples: Float32Array,
115
+ sampleRate: number,
116
+ gainValue: number,
117
+ pan: number,
118
+ ): void {
119
+ const ctx = this.ensure();
120
+ if (ctx.state === 'suspended') void ctx.resume();
121
+ let speaker = this.speakers.get(uuid);
122
+ if (!speaker) {
123
+ const panner = ctx.createStereoPanner();
124
+ const gain = ctx.createGain();
125
+ panner.connect(gain).connect(ctx.destination);
126
+ speaker = { nextAt: ctx.currentTime, panner, gain };
127
+ this.speakers.set(uuid, speaker);
128
+ }
129
+ speaker.gain.gain.setTargetAtTime(gainValue, ctx.currentTime, 0.05);
130
+ speaker.panner.pan.setTargetAtTime(pan, ctx.currentTime, 0.05);
131
+ const buffer = ctx.createBuffer(1, samples.length, sampleRate);
132
+ buffer.getChannelData(0).set(samples);
133
+ const startAt = Math.max(ctx.currentTime + 0.06, speaker.nextAt);
134
+ if (startAt - ctx.currentTime > 0.25) return;
135
+ const source = ctx.createBufferSource();
136
+ source.buffer = buffer;
137
+ source.connect(speaker.panner);
138
+ source.start(startAt);
139
+ speaker.nextAt = startAt + buffer.duration;
140
+ }
141
+
142
+ forget(uuid: string): void {
143
+ const speaker = this.speakers.get(uuid);
144
+ if (!speaker) return;
145
+ this.speakers.delete(uuid);
146
+ speaker.panner.disconnect();
147
+ speaker.gain.disconnect();
148
+ }
149
+
150
+ dispose(): void {
151
+ for (const uuid of [...this.speakers.keys()]) this.forget(uuid);
152
+ void this.context?.close();
153
+ this.context = null;
154
+ }
155
+ }
156
+
157
+ export class VoiceService {
158
+ readonly events = new Emitter<VoiceEvents>();
159
+ framesSent = 0;
160
+
161
+ private readonly playback: VoicePlayback;
162
+ private readonly lastSequence = new Map<string, { seq: number; at: number }>();
163
+ private unsubscribes: Array<() => void> = [];
164
+ private stream: MediaStream | null = null;
165
+ private audioContext: AudioContext | null = null;
166
+ private captureNode: AudioWorkletNode | ScriptProcessorNode | null = null;
167
+ private source: MediaStreamAudioSourceNode | null = null;
168
+ private transmitting = false;
169
+ private errorValue: string | null = null;
170
+ private refusedOnce = false;
171
+ private sendSequence = 0;
172
+ private decimAcc = 0;
173
+ private decimCount = 0;
174
+ private decimPhase = 0;
175
+ private frame = new Float32Array(FRAME_SAMPLES);
176
+ private frameLength = 0;
177
+
178
+ constructor(
179
+ private readonly transport: VoiceTransport,
180
+ options: { playback?: VoicePlayback; now?: () => number } = {},
181
+ ) {
182
+ this.playback = options.playback ?? new WebAudioPlayback();
183
+ this.now = options.now ?? (() => Date.now());
184
+ }
185
+
186
+ private readonly now: () => number;
187
+
188
+ listen(): void {
189
+ if (this.unsubscribes.length > 0) return;
190
+ this.unsubscribes.push(
191
+ this.transport.onAudio((n) => this.ingest(n.uuid, n.audioData, n.sequenceNumber ?? -1)),
192
+ this.transport.onActorLeft((n) => this.end(n.uuid)),
193
+ this.transport.onLaneLeave((uuid) => this.end(uuid)),
194
+ );
195
+ }
196
+
197
+ get isTransmitting(): boolean {
198
+ return this.transmitting;
199
+ }
200
+
201
+ get error(): string | null {
202
+ return this.errorValue;
203
+ }
204
+
205
+ async toggle(): Promise<void> {
206
+ if (this.transmitting) this.stop();
207
+ else await this.start();
208
+ }
209
+
210
+ async start(): Promise<void> {
211
+ if (this.transmitting) return;
212
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
213
+ this.fail('This browser has no microphone access');
214
+ return;
215
+ }
216
+ try {
217
+ this.audioContext ??= new AudioContext({ latencyHint: 'interactive' });
218
+ if (this.audioContext.state === 'suspended') await this.audioContext.resume();
219
+ this.stream ??= await navigator.mediaDevices.getUserMedia({
220
+ audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
221
+ });
222
+ this.source ??= this.audioContext.createMediaStreamSource(this.stream);
223
+ if (!this.captureNode) {
224
+ this.captureNode = await this.createCaptureNode(this.audioContext, this.source);
225
+ }
226
+ this.resetCaptureState();
227
+ this.transmitting = true;
228
+ this.errorValue = null;
229
+ this.refusedOnce = false;
230
+ this.events.emit('local', { live: true, error: null });
231
+ } catch (error) {
232
+ this.fail(`Microphone unavailable: ${messageOf(error)}`);
233
+ }
234
+ }
235
+
236
+ stop(): void {
237
+ this.transmitting = false;
238
+ this.resetCaptureState();
239
+ // Release the microphone, not just the transmit flag: while the track kept
240
+ // running the browser's recording indicator stayed lit after V, and the
241
+ // next start() re-acquires everything through its `??=` chain anyway.
242
+ this.captureNode?.disconnect();
243
+ this.captureNode = null;
244
+ this.source?.disconnect();
245
+ this.source = null;
246
+ for (const track of this.stream?.getTracks() ?? []) track.stop();
247
+ this.stream = null;
248
+ this.events.emit('local', { live: false, error: this.errorValue });
249
+ }
250
+
251
+ private async createCaptureNode(
252
+ context: AudioContext,
253
+ source: MediaStreamAudioSourceNode,
254
+ ): Promise<AudioWorkletNode | ScriptProcessorNode> {
255
+ const silent = context.createGain();
256
+ silent.gain.value = 0;
257
+ if (context.audioWorklet && typeof AudioWorkletNode !== 'undefined') {
258
+ const url = URL.createObjectURL(
259
+ new Blob([CAPTURE_WORKLET], { type: 'application/javascript' }),
260
+ );
261
+ try {
262
+ await context.audioWorklet.addModule(url);
263
+ const node = new AudioWorkletNode(context, 'crowdy-voice-capture', {
264
+ numberOfInputs: 1,
265
+ numberOfOutputs: 1,
266
+ channelCount: 1,
267
+ });
268
+ node.port.onmessage = (event: MessageEvent<Float32Array>) => this.ingestCapture(event.data);
269
+ source.connect(node);
270
+ node.connect(silent).connect(context.destination);
271
+ return node;
272
+ } catch (error) {
273
+ this.transport.log(`AudioWorklet unavailable, falling back: ${messageOf(error)}`);
274
+ } finally {
275
+ URL.revokeObjectURL(url);
276
+ }
277
+ }
278
+ const processor = context.createScriptProcessor(2048, 1, 1);
279
+ processor.onaudioprocess = (event) => this.ingestCapture(event.inputBuffer.getChannelData(0));
280
+ source.connect(processor);
281
+ processor.connect(silent).connect(context.destination);
282
+ return processor;
283
+ }
284
+
285
+ private resetCaptureState(): void {
286
+ this.decimAcc = 0;
287
+ this.decimCount = 0;
288
+ this.decimPhase = 0;
289
+ this.frameLength = 0;
290
+ }
291
+
292
+ private ingestCapture(input: Float32Array): void {
293
+ if (!this.transmitting || !this.audioContext) return;
294
+ const ratio = this.audioContext.sampleRate / CAPTURE_RATE;
295
+ for (let i = 0; i < input.length; i++) {
296
+ this.decimAcc += input[i] ?? 0;
297
+ this.decimCount++;
298
+ this.decimPhase += 1;
299
+ if (this.decimPhase >= ratio) {
300
+ this.decimPhase -= ratio;
301
+ this.frame[this.frameLength++] = this.decimAcc / this.decimCount;
302
+ this.decimAcc = 0;
303
+ this.decimCount = 0;
304
+ if (this.frameLength === FRAME_SAMPLES) {
305
+ const samples = this.frame;
306
+ this.frame = new Float32Array(FRAME_SAMPLES);
307
+ this.frameLength = 0;
308
+ void this.sendFrame(samples);
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ private async sendFrame(samples: Float32Array): Promise<void> {
315
+ const chunk = this.transport.selfChunk();
316
+ const uuid = this.transport.selfUuid();
317
+ if (!chunk || !uuid) return;
318
+ const encoded = encodeSamples(samples);
319
+ this.sendSequence = (this.sendSequence + 1) & 0xff;
320
+ try {
321
+ await this.transport.sendAudioPacket({
322
+ appId: this.transport.appId(),
323
+ chunk,
324
+ uuid,
325
+ audioData: bytesToBase64(encoded),
326
+ distance: VOICE_DISTANCE,
327
+ sequenceNumber: this.sendSequence,
328
+ });
329
+ this.framesSent += 1;
330
+ } catch (error) {
331
+ const message = messageOf(error);
332
+ if (/UNAUTHORIZED|permission/i.test(message)) {
333
+ if (!this.refusedOnce) {
334
+ this.refusedOnce = true;
335
+ this.fail('Mic refused: this app does not grant use_voice_chat here');
336
+ this.stop();
337
+ }
338
+ } else {
339
+ this.transport.log(`audio send failed: ${message}`);
340
+ }
341
+ }
342
+ }
343
+
344
+ private ingest(uuid: string, audioData: string, sequence: number): void {
345
+ if (uuid === this.transport.selfUuid()) return;
346
+ const prev = this.lastSequence.get(uuid);
347
+ const decision = acceptVoiceSequence(
348
+ prev?.seq ?? -1,
349
+ sequence,
350
+ prev ? this.now() - prev.at : SEQUENCE_RESET_MS + 1,
351
+ SEQUENCE_RESET_MS,
352
+ );
353
+ if (!decision.accept) return;
354
+ this.lastSequence.set(uuid, { seq: decision.nextSequence, at: this.now() });
355
+ let bytes: Uint8Array;
356
+ try {
357
+ bytes = base64ToBytes(audioData);
358
+ } catch {
359
+ return;
360
+ }
361
+ const samples = decodeSamples(bytes);
362
+ const me = this.transport.selfPosition();
363
+ const other = this.transport.remotePositions().find((p) => p.uuid === uuid);
364
+ let gain = 0.9;
365
+ let pan = 0;
366
+ if (me && other) {
367
+ const dx = other.x - me.x;
368
+ const dz = other.z - me.z;
369
+ const distance = Math.hypot(dx, dz);
370
+ gain = Math.max(0.05, Math.min(0.9, 1.2 - distance / 24));
371
+ pan = Math.max(-0.8, Math.min(0.8, dx / 14));
372
+ }
373
+ void this.playback.resume();
374
+ this.playback.play(uuid, samples, CAPTURE_RATE, gain, pan);
375
+ }
376
+
377
+ private end(uuid: string): void {
378
+ this.lastSequence.delete(uuid);
379
+ this.playback.forget(uuid);
380
+ }
381
+
382
+ private fail(message: string): void {
383
+ this.errorValue = message;
384
+ this.transport.log(message);
385
+ this.events.emit('local', { live: false, error: message });
386
+ }
387
+
388
+ dispose(): void {
389
+ this.stop();
390
+ for (const unsubscribe of this.unsubscribes) unsubscribe();
391
+ this.unsubscribes = [];
392
+ this.captureNode?.disconnect();
393
+ this.captureNode = null;
394
+ this.source?.disconnect();
395
+ this.source = null;
396
+ for (const track of this.stream?.getTracks() ?? []) track.stop();
397
+ this.stream = null;
398
+ void this.audioContext?.close();
399
+ this.audioContext = null;
400
+ for (const uuid of [...this.lastSequence.keys()]) this.end(uuid);
401
+ this.playback.dispose();
402
+ }
403
+ }
404
+
405
+ function bytesToBase64(bytes: Uint8Array): string {
406
+ let binary = '';
407
+ const CHUNK = 0x2000;
408
+ for (let i = 0; i < bytes.length; i += CHUNK) {
409
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK) as unknown as number[]);
410
+ }
411
+ return btoa(binary);
412
+ }
413
+
414
+ function base64ToBytes(base64: string): Uint8Array {
415
+ const binary = atob(base64);
416
+ const bytes = new Uint8Array(binary.length);
417
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
418
+ return bytes;
419
+ }
420
+
421
+ function messageOf(error: unknown): string {
422
+ return error instanceof Error ? error.message : String(error);
423
+ }