@cloudfort/callum-voice 0.0.3

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 @@
1
+ {"version":3,"file":"VoiceEventListener.d.ts","sourceRoot":"","sources":["../src/VoiceEventListener.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,kBAAkB;IACjC,6BAA6B;IAC7B,WAAW,CAAC,IAAI,IAAI,CAAC;IAErB,sBAAsB;IACtB,cAAc,CAAC,IAAI,IAAI,CAAC;IAExB,uCAAuC;IACvC,cAAc,CAAC,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAEvC,4BAA4B;IAC5B,YAAY,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAEpC,oBAAoB;IACpB,YAAY,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAEpC,kBAAkB;IAClB,UAAU,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAElC,+BAA+B;IAC/B,YAAY,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAEpC,yBAAyB;IACzB,UAAU,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAElC,4BAA4B;IAC5B,cAAc,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAEtC,4BAA4B;IAC5B,aAAa,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IAErC,yBAAyB;IACzB,YAAY,CAAC,IAAI,IAAI,CAAC;IAEtB,0BAA0B;IAC1B,aAAa,CAAC,IAAI,IAAI,CAAC;IAEvB,sBAAsB;IACtB,gBAAgB,CAAC,IAAI,IAAI,CAAC;IAE1B,uBAAuB;IACvB,iBAAiB,CAAC,IAAI,IAAI,CAAC;IAE3B,qBAAqB;IACrB,OAAO,CAAC,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI,CAAC;IAE7B,+BAA+B;IAC/B,cAAc,CAAC,CAAC,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;CACnD"}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ // Copyright Cloudfort. All Rights Reserved.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ //# sourceMappingURL=VoiceEventListener.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoiceEventListener.js","sourceRoot":"","sources":["../src/VoiceEventListener.ts"],"names":[],"mappings":";AAAA,4CAA4C"}
package/lib/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { VoiceClientState } from './VoiceClientState';
2
+ export { VoiceConfig, createVoiceConfig } from './VoiceConfig';
3
+ export { VoiceEventListener } from './VoiceEventListener';
4
+ export { VoiceClient } from './VoiceClient';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAC/D,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC"}
package/lib/index.js ADDED
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ // Copyright Cloudfort. All Rights Reserved.
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.VoiceClient = exports.createVoiceConfig = exports.VoiceClientState = void 0;
5
+ var VoiceClientState_1 = require("./VoiceClientState");
6
+ Object.defineProperty(exports, "VoiceClientState", { enumerable: true, get: function () { return VoiceClientState_1.VoiceClientState; } });
7
+ var VoiceConfig_1 = require("./VoiceConfig");
8
+ Object.defineProperty(exports, "createVoiceConfig", { enumerable: true, get: function () { return VoiceConfig_1.createVoiceConfig; } });
9
+ var VoiceClient_1 = require("./VoiceClient");
10
+ Object.defineProperty(exports, "VoiceClient", { enumerable: true, get: function () { return VoiceClient_1.VoiceClient; } });
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,4CAA4C;;;AAE5C,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,6CAA+D;AAAzC,gHAAA,iBAAiB,OAAA;AAEvC,6CAA4C;AAAnC,0GAAA,WAAW,OAAA"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cloudfort/callum-voice",
3
+ "version": "0.0.3",
4
+ "description": "Real-time voice chat SDK for React Native apps",
5
+ "main": "lib/index.js",
6
+ "types": "lib/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "prepare": "tsc"
10
+ },
11
+ "keywords": [
12
+ "react-native",
13
+ "voice-chat",
14
+ "voice",
15
+ "realtime",
16
+ "audio",
17
+ "webrtc",
18
+ "websocket"
19
+ ],
20
+ "author": "Cloudfort",
21
+ "license": "MIT",
22
+ "homepage": "https://cloudfort.ir",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/cloudfort/callum-voice.git"
26
+ },
27
+ "peerDependencies": {
28
+ "react": ">=17.0.0",
29
+ "react-native": ">=0.64.0"
30
+ },
31
+ "devDependencies": {
32
+ "@types/react": "^18.0.0",
33
+ "@types/react-native": "^0.72.0",
34
+ "react": "^18.2.0",
35
+ "react-native": "^0.72.0",
36
+ "typescript": "^5.0.0"
37
+ },
38
+ "files": [
39
+ "lib/",
40
+ "src/",
41
+ "android/",
42
+ "ios/",
43
+ "callum-voice.podspec"
44
+ ],
45
+ "react-native": "src/index.ts",
46
+ "source": "src/index.ts"
47
+ }
48
+
@@ -0,0 +1,568 @@
1
+ // Copyright Cloudfort. All Rights Reserved.
2
+
3
+ import { VoiceClientState } from './VoiceClientState';
4
+ import { VoiceConfig } from './VoiceConfig';
5
+ import { VoiceEventListener } from './VoiceEventListener';
6
+
7
+ // ── Constants ────────────────────────────────────────────────────────
8
+ const SPEAKING_CHECK_INTERVAL_MS = 80;
9
+ const SPEAKING_RMS_THRESHOLD = 0.012;
10
+ const PEER_RING_CAPACITY = 96000; // ~2s at 48 kHz
11
+
12
+ // ── Peer State ───────────────────────────────────────────────────────
13
+
14
+ interface PeerState {
15
+ peerId: string;
16
+ isSpeaking: boolean;
17
+ rmsLevel: number;
18
+ ring: Int16Array;
19
+ writePos: number;
20
+ length: number;
21
+ }
22
+
23
+ function createPeerState(peerId: string): PeerState {
24
+ return {
25
+ peerId,
26
+ isSpeaking: false,
27
+ rmsLevel: 0,
28
+ ring: new Int16Array(PEER_RING_CAPACITY),
29
+ writePos: 0,
30
+ length: 0,
31
+ };
32
+ }
33
+
34
+ function peerEnqueue(peer: PeerState, samples: Int16Array): void {
35
+ if (peer.length + samples.length > PEER_RING_CAPACITY) {
36
+ const drop = peer.length + samples.length - PEER_RING_CAPACITY;
37
+ peer.length -= drop;
38
+ }
39
+ for (let i = 0; i < samples.length; i++) {
40
+ peer.ring[(peer.writePos + i) % PEER_RING_CAPACITY] = samples[i];
41
+ }
42
+ peer.writePos = (peer.writePos + samples.length) % PEER_RING_CAPACITY;
43
+ peer.length += samples.length;
44
+ }
45
+
46
+ function peerComputeRms(peer: PeerState): number {
47
+ const count = Math.min(peer.length, 480); // ~10ms at 48 kHz
48
+ if (count === 0) return 0;
49
+
50
+ let sum = 0;
51
+ const start = (peer.writePos - count + PEER_RING_CAPACITY) % PEER_RING_CAPACITY;
52
+ for (let i = 0; i < count; i++) {
53
+ const v = peer.ring[(start + i) % PEER_RING_CAPACITY] / 32768.0;
54
+ sum += v * v;
55
+ }
56
+ return Math.sqrt(sum / count);
57
+ }
58
+
59
+ function peerClear(peer: PeerState): void {
60
+ peer.writePos = 0;
61
+ peer.length = 0;
62
+ peer.isSpeaking = false;
63
+ peer.rmsLevel = 0;
64
+ }
65
+
66
+ // ── VoiceClient ──────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Cross-platform voice-chat client for React Native.
70
+ *
71
+ * Connects to the Go voice service over WebSocket, captures microphone audio
72
+ * via a native audio engine, and plays back remote peer audio.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * const client = new VoiceClient(createVoiceConfig({
77
+ * server: 'callem.cloudfort.ir',
78
+ * apiKey: 'vc_live_...',
79
+ * peerId: 'user_123',
80
+ * }));
81
+ *
82
+ * client.setListener({
83
+ * onConnected: () => console.log('Connected!'),
84
+ * onPeerSpeaking: (id) => console.log(`${id} is speaking`),
85
+ * });
86
+ *
87
+ * await client.connect();
88
+ * await client.joinRoom('game_room_1');
89
+ * await client.enableMic();
90
+ * ```
91
+ */
92
+ export class VoiceClient {
93
+ private readonly config: VoiceConfig;
94
+ private listener: VoiceEventListener | null = null;
95
+
96
+ private ws: WebSocket | null = null;
97
+ private _state: VoiceClientState = VoiceClientState.Disconnected;
98
+ private currentRoom: string | null = null;
99
+ private micEnabled = false;
100
+ private speakerEnabled = false;
101
+ private reconnectAttempts = 0;
102
+ private prevRoom: string | null = null;
103
+ private prevMicEnabled = false;
104
+
105
+ private peers: Map<string, PeerState> = new Map();
106
+ private speakingTimer: ReturnType<typeof setInterval> | null = null;
107
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
108
+
109
+ constructor(config: VoiceConfig) {
110
+ this.config = config;
111
+ }
112
+
113
+ // ── Public Properties ────────────────────────────────────────────
114
+
115
+ /** Current connection state */
116
+ get state(): VoiceClientState {
117
+ return this._state;
118
+ }
119
+
120
+ /** Whether connected to server */
121
+ get isConnected(): boolean {
122
+ return this._state >= VoiceClientState.Connected;
123
+ }
124
+
125
+ /** Whether joined a room */
126
+ get isInRoom(): boolean {
127
+ return this._state === VoiceClientState.InRoom;
128
+ }
129
+
130
+ /** Whether microphone is active */
131
+ get isMicEnabled(): boolean {
132
+ return this.micEnabled;
133
+ }
134
+
135
+ /** Whether speaker output is active */
136
+ get isSpeakerEnabled(): boolean {
137
+ return this.speakerEnabled;
138
+ }
139
+
140
+ /** Current room ID (null if not in room) */
141
+ get currentRoomId(): string | null {
142
+ return this.currentRoom;
143
+ }
144
+
145
+ /** List of peer IDs in the current room */
146
+ get peerIds(): string[] {
147
+ return Array.from(this.peers.keys());
148
+ }
149
+
150
+ // ── Listener ─────────────────────────────────────────────────────
151
+
152
+ /** Set the event listener */
153
+ setListener(listener: VoiceEventListener | null): void {
154
+ this.listener = listener;
155
+ }
156
+
157
+ // ── Connection ───────────────────────────────────────────────────
158
+
159
+ /** Connect to the voice server */
160
+ async connect(): Promise<void> {
161
+ if (this._state >= VoiceClientState.Connected) return;
162
+
163
+ this.setState(VoiceClientState.Connecting);
164
+
165
+ const scheme = this.resolveScheme();
166
+ const url =
167
+ `${scheme}://${this.config.server}/ws` +
168
+ `?room=__lobby__` +
169
+ `&peer=${encodeURIComponent(this.config.peerId)}` +
170
+ `&api_key=${encodeURIComponent(this.config.apiKey)}`;
171
+
172
+ try {
173
+ this.ws = new WebSocket(url);
174
+ this.ws.binaryType = 'arraybuffer';
175
+
176
+ this.ws.onopen = () => {
177
+ this.handleWebSocketConnected();
178
+ };
179
+
180
+ this.ws.onmessage = (event) => {
181
+ if (typeof event.data === 'string') {
182
+ this.handleTextMessage(event.data);
183
+ } else if (event.data instanceof ArrayBuffer) {
184
+ this.handleBinaryMessage(new Uint8Array(event.data));
185
+ }
186
+ };
187
+
188
+ this.ws.onclose = (event) => {
189
+ this.handleClose(event.code ?? 1006, event.reason ?? '');
190
+ this.handleDisconnect();
191
+ };
192
+
193
+ this.ws.onerror = () => {
194
+ this.raiseError(new Error('WebSocket error'));
195
+ this.handleDisconnect();
196
+ };
197
+ } catch (e) {
198
+ this.raiseError(e instanceof Error ? e : new Error(String(e)));
199
+ this.setState(VoiceClientState.Disconnected);
200
+ }
201
+ }
202
+
203
+ /** Disconnect from the server */
204
+ disconnect(): void {
205
+ this.config.autoReconnect = false;
206
+ this.cleanup();
207
+ this.setState(VoiceClientState.Disconnected);
208
+ this.listener?.onDisconnected?.();
209
+ }
210
+
211
+ // ── Room Management ──────────────────────────────────────────────
212
+
213
+ /** Join a voice room */
214
+ async joinRoom(roomId: string): Promise<void> {
215
+ if (this._state < VoiceClientState.Connected) {
216
+ this.raiseError(new Error('Not connected'));
217
+ return;
218
+ }
219
+
220
+ if (this.currentRoom !== null) {
221
+ await this.leaveRoom();
222
+ }
223
+
224
+ // TODO: Start native audio playback engine via NativeModules
225
+
226
+ const joinMsg = JSON.stringify({
227
+ type: 'join',
228
+ room: roomId,
229
+ peer: this.config.peerId,
230
+ sampleRate: this.config.sampleRate,
231
+ });
232
+
233
+ this.sendText(joinMsg);
234
+
235
+ this.currentRoom = roomId;
236
+ this.setState(VoiceClientState.InRoom);
237
+
238
+ // Start speaking detection timer
239
+ this.speakingTimer = setInterval(() => {
240
+ this.checkSpeaking();
241
+ }, SPEAKING_CHECK_INTERVAL_MS);
242
+
243
+ this.listener?.onRoomJoined?.(roomId);
244
+ }
245
+
246
+ /** Leave the current room */
247
+ async leaveRoom(): Promise<void> {
248
+ if (this.currentRoom === null) return;
249
+
250
+ this.stopMic();
251
+ if (this.speakingTimer !== null) {
252
+ clearInterval(this.speakingTimer);
253
+ this.speakingTimer = null;
254
+ }
255
+
256
+ const room = this.currentRoom;
257
+
258
+ // Notify peers leaving
259
+ const peerIds = Array.from(this.peers.keys());
260
+ this.peers.clear();
261
+ for (const peerId of peerIds) {
262
+ this.listener?.onPeerLeft?.(peerId);
263
+ }
264
+
265
+ // TODO: Clear peers and stop playback via native audio engine
266
+
267
+ this.currentRoom = null;
268
+ this.setState(VoiceClientState.Connected);
269
+ this.listener?.onRoomLeft?.(room);
270
+ }
271
+
272
+ // ── Microphone Control ───────────────────────────────────────────
273
+
274
+ /** Enable microphone (must be in a room) */
275
+ async enableMic(): Promise<void> {
276
+ if (this.micEnabled) return;
277
+ if (this._state !== VoiceClientState.InRoom) {
278
+ this.raiseError(new Error('Must be in a room to enable mic'));
279
+ return;
280
+ }
281
+
282
+ // TODO: Request microphone permission via NativeModules
283
+ // TODO: Start native audio capture via NativeModules
284
+
285
+ this.micEnabled = true;
286
+ this.listener?.onMicEnabled?.();
287
+ }
288
+
289
+ /** Disable microphone */
290
+ disableMic(): void {
291
+ if (!this.micEnabled) return;
292
+ this.stopMic();
293
+ this.listener?.onMicDisabled?.();
294
+ }
295
+
296
+ /** Toggle microphone, returns new state */
297
+ async toggleMic(): Promise<boolean> {
298
+ if (this.micEnabled) {
299
+ this.disableMic();
300
+ return false;
301
+ }
302
+ await this.enableMic();
303
+ return true;
304
+ }
305
+
306
+ // ── Speaker Control ──────────────────────────────────────────────
307
+
308
+ /** Enable speaker output */
309
+ enableSpeaker(): void {
310
+ if (this.speakerEnabled) return;
311
+ this.speakerEnabled = true;
312
+ // TODO: Set speaker routing via NativeModules
313
+ this.listener?.onSpeakerEnabled?.();
314
+ }
315
+
316
+ /** Disable speaker output */
317
+ disableSpeaker(): void {
318
+ if (!this.speakerEnabled) return;
319
+ this.speakerEnabled = false;
320
+ // TODO: Set speaker routing via NativeModules
321
+ this.listener?.onSpeakerDisabled?.();
322
+ }
323
+
324
+ /** Toggle speaker, returns new state */
325
+ toggleSpeaker(): boolean {
326
+ if (this.speakerEnabled) {
327
+ this.disableSpeaker();
328
+ return false;
329
+ }
330
+ this.enableSpeaker();
331
+ return true;
332
+ }
333
+
334
+ // ── Speaking Detection ───────────────────────────────────────────
335
+
336
+ /** Check if a specific peer is speaking */
337
+ isPeerSpeaking(peerId: string): boolean {
338
+ return this.peers.get(peerId)?.isSpeaking ?? false;
339
+ }
340
+
341
+ /** Get peer speaking state with RMS level */
342
+ getPeerSpeakingInfo(peerId: string): { speaking: boolean; rms: number } {
343
+ const peer = this.peers.get(peerId);
344
+ if (!peer) return { speaking: false, rms: 0 };
345
+ return { speaking: peer.isSpeaking, rms: peer.rmsLevel };
346
+ }
347
+
348
+ // ── Cleanup ──────────────────────────────────────────────────────
349
+
350
+ /** Dispose and release all resources */
351
+ dispose(): void {
352
+ this.config.autoReconnect = false;
353
+ this.cleanup();
354
+ }
355
+
356
+ // ── Private: WebSocket Event Handlers ────────────────────────────
357
+
358
+ private handleWebSocketConnected(): void {
359
+ this.setState(VoiceClientState.Connected);
360
+ this.reconnectAttempts = 0;
361
+ this.listener?.onConnected?.();
362
+
363
+ // Re-join room if we were in one before disconnect
364
+ if (this.prevRoom !== null) {
365
+ const restoreMic = this.prevMicEnabled;
366
+ const room = this.prevRoom;
367
+ this.prevRoom = null;
368
+ this.prevMicEnabled = false;
369
+
370
+ this.joinRoom(room).then(() => {
371
+ if (restoreMic) {
372
+ this.enableMic();
373
+ }
374
+ }).catch((e) => {
375
+ this.raiseError(e instanceof Error ? e : new Error(String(e)));
376
+ });
377
+ }
378
+ }
379
+
380
+ private handleTextMessage(text: string): void {
381
+ try {
382
+ const json = JSON.parse(text);
383
+ if (json && typeof json === 'object') {
384
+ const type = json['type'];
385
+ if (type === 'udp_token') {
386
+ // Handle UDP token if needed
387
+ }
388
+ }
389
+ } catch (e) {
390
+ this.raiseError(e instanceof Error ? e : new Error(String(e)));
391
+ }
392
+ }
393
+
394
+ // ── Private: Binary Packet Parsing ───────────────────────────────
395
+ // Format: [1B RoomLen][RoomID][1B PeerLen][PeerID][PCM Audio Data]
396
+
397
+ private handleBinaryMessage(data: Uint8Array): void {
398
+ if (data.length < 4) return;
399
+
400
+ const roomLen = data[0];
401
+ if (data.length < 2 + roomLen) return;
402
+
403
+ const peerLen = data[1 + roomLen];
404
+ if (data.length < 2 + roomLen + peerLen) return;
405
+
406
+ const peerIdBytes = data.slice(2 + roomLen, 2 + roomLen + peerLen);
407
+ const peerId = new TextDecoder().decode(peerIdBytes);
408
+
409
+ if (peerId === this.config.peerId) return; // ignore own echo
410
+
411
+ const audioOffset = 2 + roomLen + peerLen;
412
+ const audioLen = data.length - audioOffset;
413
+ if (audioLen === 0) return;
414
+
415
+ // Convert PCM bytes → Int16Array
416
+ const sampleCount = Math.floor(audioLen / 2);
417
+ const samples = new Int16Array(sampleCount);
418
+ const view = new DataView(data.buffer, data.byteOffset + audioOffset, sampleCount * 2);
419
+ for (let i = 0; i < sampleCount; i++) {
420
+ samples[i] = view.getInt16(i * 2, true); // little-endian
421
+ }
422
+
423
+ this.handlePeerAudio(peerId, samples);
424
+ }
425
+
426
+ private handlePeerAudio(peerId: string, samples: Int16Array): void {
427
+ let peer = this.peers.get(peerId);
428
+ if (!peer) {
429
+ peer = createPeerState(peerId);
430
+ this.peers.set(peerId, peer);
431
+ this.listener?.onPeerJoined?.(peerId);
432
+ }
433
+
434
+ // TODO: Enqueue audio to native playback engine
435
+ peerEnqueue(peer, samples);
436
+ }
437
+
438
+ private checkSpeaking(): void {
439
+ for (const [peerId, peer] of this.peers) {
440
+ const rms = peerComputeRms(peer);
441
+ const wasSpeaking = peer.isSpeaking;
442
+ const isSpeaking = rms > SPEAKING_RMS_THRESHOLD;
443
+
444
+ if (isSpeaking !== wasSpeaking) {
445
+ peer.isSpeaking = isSpeaking;
446
+ if (isSpeaking) {
447
+ this.listener?.onPeerSpeaking?.(peerId);
448
+ } else {
449
+ this.listener?.onPeerStopped?.(peerId);
450
+ }
451
+ }
452
+
453
+ peer.rmsLevel = rms;
454
+ }
455
+ }
456
+
457
+ // ── Private: Close / Disconnect / Reconnect ──────────────────────
458
+
459
+ private handleClose(code: number, reason: string): void {
460
+ // Close codes 1008 / 4001 indicate auth failure
461
+ if (code === 1008 || code === 4001) {
462
+ this.listener?.onAuthFailed?.(reason || 'Authentication failed');
463
+ this.config.autoReconnect = false;
464
+ }
465
+ }
466
+
467
+ private handleDisconnect(): void {
468
+ const wasConnected = this._state >= VoiceClientState.Connected;
469
+
470
+ // Save state for reconnect restoration
471
+ this.prevRoom = this.currentRoom;
472
+ this.prevMicEnabled = this.micEnabled;
473
+
474
+ this.cleanup();
475
+
476
+ if (!wasConnected) return;
477
+
478
+ this.setState(VoiceClientState.Disconnected);
479
+ this.listener?.onDisconnected?.();
480
+
481
+ if (
482
+ this.config.autoReconnect &&
483
+ this.reconnectAttempts < this.config.maxReconnectAttempts
484
+ ) {
485
+ this.reconnectAttempts++;
486
+ this.setState(VoiceClientState.Reconnecting);
487
+ this.listener?.onReconnecting?.(this.reconnectAttempts);
488
+
489
+ this.reconnectTimer = setTimeout(() => {
490
+ this.connect();
491
+ }, this.config.reconnectDelayMs);
492
+ }
493
+ }
494
+
495
+ // ── Private: Send Helpers ────────────────────────────────────────
496
+
497
+ private sendText(text: string): void {
498
+ if (this.ws?.readyState === WebSocket.OPEN) {
499
+ this.ws.send(text);
500
+ }
501
+ }
502
+
503
+ /** Send captured audio to server (called from native audio engine) */
504
+ sendAudio(samples: Int16Array): void {
505
+ if (!this.micEnabled || this.ws?.readyState !== WebSocket.OPEN) return;
506
+
507
+ const bytes = new Uint8Array(samples.buffer, samples.byteOffset, samples.byteLength);
508
+ this.ws.send(bytes.slice());
509
+ }
510
+
511
+ // ── Private: Cleanup ─────────────────────────────────────────────
512
+
513
+ private cleanup(): void {
514
+ this.stopMic();
515
+
516
+ if (this.speakingTimer !== null) {
517
+ clearInterval(this.speakingTimer);
518
+ this.speakingTimer = null;
519
+ }
520
+
521
+ if (this.reconnectTimer !== null) {
522
+ clearTimeout(this.reconnectTimer);
523
+ this.reconnectTimer = null;
524
+ }
525
+
526
+ // Notify peers leaving
527
+ const peerIds = Array.from(this.peers.keys());
528
+ this.peers.clear();
529
+ for (const peerId of peerIds) {
530
+ this.listener?.onPeerLeft?.(peerId);
531
+ }
532
+
533
+ if (this.ws !== null) {
534
+ try {
535
+ this.ws.close(1000, 'bye');
536
+ } catch {
537
+ // ignore
538
+ }
539
+ this.ws = null;
540
+ }
541
+
542
+ this.currentRoom = null;
543
+ }
544
+
545
+ private stopMic(): void {
546
+ if (!this.micEnabled) return;
547
+ // TODO: Stop native audio capture
548
+ this.micEnabled = false;
549
+ }
550
+
551
+ // ── Private: Helpers ─────────────────────────────────────────────
552
+
553
+ private resolveScheme(): string {
554
+ if (this.config.useTls === true) return 'wss';
555
+ if (this.config.useTls === false) return 'ws';
556
+ return 'ws';
557
+ }
558
+
559
+ private setState(newState: VoiceClientState): void {
560
+ if (this._state === newState) return;
561
+ this._state = newState;
562
+ this.listener?.onStateChanged?.(newState);
563
+ }
564
+
565
+ private raiseError(error: Error): void {
566
+ this.listener?.onError?.(error);
567
+ }
568
+ }
@@ -0,0 +1,21 @@
1
+ // Copyright Cloudfort. All Rights Reserved.
2
+
3
+ /**
4
+ * Connection state of the VoiceClient.
5
+ */
6
+ export enum VoiceClientState {
7
+ /** Not connected, not attempting to connect */
8
+ Disconnected = 0,
9
+
10
+ /** Establishing WebSocket connection */
11
+ Connecting = 1,
12
+
13
+ /** Connected to server, not yet in a room */
14
+ Connected = 2,
15
+
16
+ /** Joined a room and ready for audio */
17
+ InRoom = 3,
18
+
19
+ /** Attempting to reconnect after an unexpected disconnect */
20
+ Reconnecting = 4,
21
+ }