@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.
- package/README.md +672 -0
- package/android/build.gradle +38 -0
- package/android/src/main/AndroidManifest.xml +9 -0
- package/android/src/main/java/com/callumvoice/CallumVoiceModule.kt +289 -0
- package/android/src/main/java/com/callumvoice/CallumVoicePackage.kt +32 -0
- package/callum-voice.podspec +21 -0
- package/ios/CallumVoiceBridge.m +7 -0
- package/ios/CallumVoiceModule-Bridging-Header.h +7 -0
- package/ios/CallumVoiceModule.swift +198 -0
- package/lib/VoiceClient.d.ts +104 -0
- package/lib/VoiceClient.d.ts.map +1 -0
- package/lib/VoiceClient.js +493 -0
- package/lib/VoiceClient.js.map +1 -0
- package/lib/VoiceClientState.d.ts +16 -0
- package/lib/VoiceClientState.d.ts.map +1 -0
- package/lib/VoiceClientState.js +21 -0
- package/lib/VoiceClientState.js.map +1 -0
- package/lib/VoiceConfig.d.ts +32 -0
- package/lib/VoiceConfig.d.ts.map +1 -0
- package/lib/VoiceConfig.js +11 -0
- package/lib/VoiceConfig.js.map +1 -0
- package/lib/VoiceEventListener.d.ts +49 -0
- package/lib/VoiceEventListener.d.ts.map +1 -0
- package/lib/VoiceEventListener.js +4 -0
- package/lib/VoiceEventListener.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/package.json +48 -0
- package/src/VoiceClient.ts +568 -0
- package/src/VoiceClientState.ts +21 -0
- package/src/VoiceConfig.ts +58 -0
- package/src/VoiceEventListener.ts +66 -0
- package/src/index.ts +6 -0
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"VoiceEventListener.js","sourceRoot":"","sources":["../src/VoiceEventListener.ts"],"names":[],"mappings":";AAAA,4CAA4C"}
|
package/lib/index.d.ts
ADDED
|
@@ -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
|
package/lib/index.js.map
ADDED
|
@@ -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
|
+
}
|