@4players/odin-nodejs 0.10.3 → 0.11.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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +603 -44
- package/binding.gyp +29 -13
- package/cppsrc/binding.cpp +3 -6
- package/cppsrc/odinbindings.cpp +9 -45
- package/cppsrc/odincipher.cpp +92 -0
- package/cppsrc/odincipher.h +32 -0
- package/cppsrc/odinclient.cpp +19 -158
- package/cppsrc/odinclient.h +2 -5
- package/cppsrc/odinmedia.cpp +144 -186
- package/cppsrc/odinmedia.h +51 -18
- package/cppsrc/odinroom.cpp +675 -635
- package/cppsrc/odinroom.h +76 -26
- package/cppsrc/utilities.cpp +11 -81
- package/cppsrc/utilities.h +25 -140
- package/index.cjs +829 -0
- package/index.d.ts +3 -4
- package/libs/bin/linux/arm64/libodin.so +0 -0
- package/libs/bin/linux/arm64/libodin_crypto.so +0 -0
- package/libs/bin/linux/ia32/libodin.so +0 -0
- package/libs/bin/linux/ia32/libodin_crypto.so +0 -0
- package/libs/bin/linux/x64/libodin.so +0 -0
- package/libs/bin/linux/x64/libodin_crypto.so +0 -0
- package/{prebuilds/darwin-x64/node.napi.node → libs/bin/macos/universal/libodin.dylib} +0 -0
- package/libs/bin/macos/universal/libodin_crypto.dylib +0 -0
- package/libs/bin/windows/arm64/odin.dll +0 -0
- package/libs/bin/windows/arm64/odin.lib +0 -0
- package/libs/bin/windows/arm64/odin_crypto.dll +0 -0
- package/libs/bin/windows/arm64/odin_crypto.lib +0 -0
- package/libs/bin/windows/ia32/odin.dll +0 -0
- package/libs/bin/windows/ia32/odin.lib +0 -0
- package/libs/bin/windows/ia32/odin_crypto.dll +0 -0
- package/libs/bin/windows/ia32/odin_crypto.lib +0 -0
- package/libs/bin/windows/x64/odin.dll +0 -0
- package/libs/bin/windows/x64/odin.lib +0 -0
- package/libs/bin/windows/x64/odin_crypto.dll +0 -0
- package/libs/bin/windows/x64/odin_crypto.lib +0 -0
- package/libs/include/odin.h +665 -567
- package/libs/include/odin_crypto.h +46 -0
- package/odin.cipher.d.ts +31 -0
- package/odin.media.d.ts +69 -19
- package/odin.room.d.ts +348 -7
- package/package.json +5 -4
- package/prebuilds/{darwin-arm64/node.napi.node → darwin-x64+arm64/libodin.dylib} +0 -0
- package/prebuilds/darwin-x64+arm64/libodin_crypto.dylib +0 -0
- package/prebuilds/darwin-x64+arm64/node.napi.node +0 -0
- package/prebuilds/linux-x64/libodin.so +0 -0
- package/prebuilds/linux-x64/libodin_crypto.so +0 -0
- package/prebuilds/linux-x64/node.napi.node +0 -0
- package/prebuilds/win32-x64/node.napi.node +0 -0
- package/prebuilds/win32-x64/odin.dll +0 -0
- package/prebuilds/win32-x64/odin_crypto.dll +0 -0
- package/scripts/postbuild.cjs +133 -0
- package/tests/audio-recording/README.md +97 -12
- package/tests/audio-recording/index.js +238 -130
- package/tests/connection-test/README.md +97 -0
- package/tests/connection-test/index.js +273 -0
- package/tests/lifecycle/test-room-cycle.js +169 -0
- package/tests/sending-audio/README.md +178 -9
- package/tests/sending-audio/canBounce.mp3 +0 -0
- package/tests/sending-audio/index.js +250 -87
- package/tests/sending-audio/test-kiss-api.js +149 -0
- package/tests/sending-audio/test-loop-audio.js +142 -0
- package/CMakeLists.txt +0 -25
- package/libs/bin/linux/arm64/libodin_static.a +0 -0
- package/libs/bin/linux/ia32/libodin_static.a +0 -0
- package/libs/bin/linux/x64/libodin_static.a +0 -0
- package/libs/bin/macos/arm64/libodin_static.a +0 -0
- package/libs/bin/macos/x64/libodin_static.a +0 -0
- package/libs/bin/windows/arm64/odin_static.lib +0 -0
- package/libs/bin/windows/ia32/odin_static.lib +0 -0
- package/libs/bin/windows/x64/odin_static.lib +0 -0
package/index.cjs
CHANGED
|
@@ -1,2 +1,831 @@
|
|
|
1
1
|
var binding = require('node-gyp-build')(__dirname);
|
|
2
|
+
const { decode, encode } = require('@msgpack/msgpack');
|
|
3
|
+
const { TokenGenerator } = require('@4players/odin-tokens');
|
|
4
|
+
const NativeOdinRoom = binding.OdinRoom;
|
|
5
|
+
const NativeOdinMedia = binding.OdinMedia;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Event types emitted by OdinRoom:
|
|
9
|
+
* - RoomStatusChanged: { status: 'Connecting'|'Connected'|'Disconnected'|'Joining'|'Joined'|'Leaving'|'Closed', message?: string }
|
|
10
|
+
* - Joined: { room: Room, mediaIds: number[], ownPeerId: number }
|
|
11
|
+
* - Left: { reason: string }
|
|
12
|
+
* - RoomUserDataChanged: { userData: Uint8Array }
|
|
13
|
+
* - PeerJoined: { peer: Peer }
|
|
14
|
+
* - PeerLeft: { peerId: number }
|
|
15
|
+
* - PeerUserDataChanged: { peerId: number, userData: Uint8Array }
|
|
16
|
+
* - MediaStarted: { peerId: number, media: Media }
|
|
17
|
+
* - MediaStopped: { peerId: number, mediaId: number }
|
|
18
|
+
* - MediaActivity: { peerId: number, mediaId: number, state: boolean }
|
|
19
|
+
* - MessageReceived: { senderPeerId: number, message: Uint8Array } * - AudioDataReceived: { peerId: number, mediaId: number, samples16: Buffer, samples32: Buffer }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
class OdinRoomWrapper extends NativeOdinRoom {
|
|
23
|
+
constructor(token) {
|
|
24
|
+
super(token);
|
|
25
|
+
this._listeners = {};
|
|
26
|
+
this._connected = false;
|
|
27
|
+
this._ownPeerId = null;
|
|
28
|
+
this._roomId = null;
|
|
29
|
+
this._availableMediaIds = []; // Store media IDs from Joined event
|
|
30
|
+
this._activeMediaStreams = []; // Track created media streams
|
|
31
|
+
|
|
32
|
+
// Listen to raw RPC bytes from native layer
|
|
33
|
+
super.setEventListener((data) => this._onRPC(data));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parse incoming RPC messages (MessagePack format)
|
|
38
|
+
* Format: [type, eventName, eventData] where type=2 for notifications
|
|
39
|
+
*/
|
|
40
|
+
_onRPC(data) {
|
|
41
|
+
if (!data || !(data instanceof ArrayBuffer)) return;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const bytes = new Uint8Array(data);
|
|
45
|
+
const rpc = decode(bytes);
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(rpc) || rpc.length < 3) return;
|
|
48
|
+
|
|
49
|
+
const [type, eventName, eventData] = rpc;
|
|
50
|
+
|
|
51
|
+
// Type 2 = Notification
|
|
52
|
+
if (type === 2) {
|
|
53
|
+
this._handleEvent(eventName, eventData);
|
|
54
|
+
}
|
|
55
|
+
// Type 1 = Response (for commands)
|
|
56
|
+
else if (type === 1) {
|
|
57
|
+
// Handle command responses if needed
|
|
58
|
+
}
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error('Failed to parse RPC:', e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Handle different event types
|
|
66
|
+
*/
|
|
67
|
+
_handleEvent(eventName, eventData) {
|
|
68
|
+
switch (eventName) {
|
|
69
|
+
case 'RoomStatusChanged':
|
|
70
|
+
this._handleRoomStatusChanged(eventData);
|
|
71
|
+
break;
|
|
72
|
+
case 'RoomUpdated':
|
|
73
|
+
this._handleRoomUpdated(eventData);
|
|
74
|
+
break;
|
|
75
|
+
case 'PeerUpdated':
|
|
76
|
+
this._handlePeerUpdated(eventData);
|
|
77
|
+
break;
|
|
78
|
+
case 'MessageReceived':
|
|
79
|
+
this._handleMessageReceived(eventData);
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
// Emit raw event for any unknown types
|
|
83
|
+
this._emit(eventName, eventData);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Handle RoomStatusChanged events
|
|
89
|
+
*/
|
|
90
|
+
_handleRoomStatusChanged(data) {
|
|
91
|
+
const status = data.status;
|
|
92
|
+
const message = data.message;
|
|
93
|
+
|
|
94
|
+
this._emit('RoomStatusChanged', { status, message });
|
|
95
|
+
|
|
96
|
+
// Also emit connection state for convenience
|
|
97
|
+
this._emit('ConnectionStateChanged', { state: status, message });
|
|
98
|
+
|
|
99
|
+
if (status === 'Joined') {
|
|
100
|
+
this._connected = true;
|
|
101
|
+
} else if (status === 'Closed' || status === 'Disconnected') {
|
|
102
|
+
this._connected = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle RoomUpdated events which contain multiple update types
|
|
108
|
+
*/
|
|
109
|
+
_handleRoomUpdated(data) {
|
|
110
|
+
if (!data.updates || !Array.isArray(data.updates)) return;
|
|
111
|
+
|
|
112
|
+
for (const update of data.updates) {
|
|
113
|
+
const kind = update.kind;
|
|
114
|
+
|
|
115
|
+
switch (kind) {
|
|
116
|
+
case 'Joined':
|
|
117
|
+
this._connected = true;
|
|
118
|
+
this._ownPeerId = update.own_peer_id;
|
|
119
|
+
// Inform native layer of own peer ID
|
|
120
|
+
if (update.own_peer_id != null) {
|
|
121
|
+
super.setOwnPeerId(update.own_peer_id);
|
|
122
|
+
}
|
|
123
|
+
// Store available media IDs for auto-selection
|
|
124
|
+
this._availableMediaIds = update.media_ids ? [...update.media_ids] : [];
|
|
125
|
+
// Emit Joined event (matches core SDK naming)
|
|
126
|
+
this._emit('Joined', {
|
|
127
|
+
roomId: update.room?.id,
|
|
128
|
+
ownPeerId: update.own_peer_id,
|
|
129
|
+
room: update.room,
|
|
130
|
+
mediaIds: update.media_ids
|
|
131
|
+
});
|
|
132
|
+
// Emit PeerJoined for existing peers and register their media
|
|
133
|
+
if (update.room?.peers) {
|
|
134
|
+
for (const peer of update.room.peers) {
|
|
135
|
+
this._emit('PeerJoined', {
|
|
136
|
+
peerId: peer.id,
|
|
137
|
+
peer: peer,
|
|
138
|
+
userId: peer.user_id,
|
|
139
|
+
userData: peer.user_data
|
|
140
|
+
});
|
|
141
|
+
// Register existing media for this peer in native layer
|
|
142
|
+
if (peer.medias) {
|
|
143
|
+
for (const media of peer.medias) {
|
|
144
|
+
super.registerMediaPeer(media.id, peer.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
break;
|
|
150
|
+
|
|
151
|
+
case 'Left':
|
|
152
|
+
this._connected = false;
|
|
153
|
+
// Emit Left event (matches core SDK naming)
|
|
154
|
+
this._emit('Left', { roomId: this._roomId, reason: update.reason });
|
|
155
|
+
break;
|
|
156
|
+
|
|
157
|
+
case 'UserDataChanged':
|
|
158
|
+
this._emit('RoomUserDataChanged', { userData: update.user_data });
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case 'PeerJoined':
|
|
162
|
+
this._emit('PeerJoined', {
|
|
163
|
+
peerId: update.peer?.id,
|
|
164
|
+
peer: update.peer,
|
|
165
|
+
userId: update.peer?.user_id,
|
|
166
|
+
userData: update.peer?.user_data
|
|
167
|
+
});
|
|
168
|
+
break;
|
|
169
|
+
|
|
170
|
+
case 'PeerLeft':
|
|
171
|
+
this._emit('PeerLeft', { peerId: update.peer_id });
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Handle PeerUpdated events
|
|
179
|
+
*/
|
|
180
|
+
_handlePeerUpdated(data) {
|
|
181
|
+
const kind = data.kind;
|
|
182
|
+
|
|
183
|
+
switch (kind) {
|
|
184
|
+
case 'UserDataChanged':
|
|
185
|
+
this._emit('PeerUserDataChanged', {
|
|
186
|
+
peerId: data.peer_id,
|
|
187
|
+
userData: data.user_data
|
|
188
|
+
});
|
|
189
|
+
break;
|
|
190
|
+
|
|
191
|
+
case 'MediaStarted':
|
|
192
|
+
// Register media-to-peer mapping in native layer for audio data events
|
|
193
|
+
if (data.media?.id != null && data.peer_id != null) {
|
|
194
|
+
super.registerMediaPeer(data.media.id, data.peer_id);
|
|
195
|
+
}
|
|
196
|
+
this._emit('MediaStarted', {
|
|
197
|
+
peerId: data.peer_id,
|
|
198
|
+
media: data.media
|
|
199
|
+
});
|
|
200
|
+
// Legacy MediaAdded event
|
|
201
|
+
this._emit('MediaAdded', {
|
|
202
|
+
peerId: data.peer_id,
|
|
203
|
+
mediaId: data.media?.id
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
206
|
+
|
|
207
|
+
case 'MediaStopped':
|
|
208
|
+
// Unregister media from native layer
|
|
209
|
+
if (data.media_id != null) {
|
|
210
|
+
super.unregisterMedia(data.media_id);
|
|
211
|
+
}
|
|
212
|
+
this._emit('MediaStopped', {
|
|
213
|
+
peerId: data.peer_id,
|
|
214
|
+
mediaId: data.media_id
|
|
215
|
+
});
|
|
216
|
+
// Legacy MediaRemoved event
|
|
217
|
+
this._emit('MediaRemoved', {
|
|
218
|
+
peerId: data.peer_id,
|
|
219
|
+
mediaId: data.media_id
|
|
220
|
+
});
|
|
221
|
+
break;
|
|
222
|
+
|
|
223
|
+
case 'TagsChanged':
|
|
224
|
+
this._emit('PeerTagsChanged', {
|
|
225
|
+
peerId: data.peer_id,
|
|
226
|
+
tags: data.tags
|
|
227
|
+
});
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
case 'MediaActivity':
|
|
231
|
+
this._emit('MediaActivity', {
|
|
232
|
+
peerId: data.peer_id,
|
|
233
|
+
mediaId: data.media_id,
|
|
234
|
+
state: data.state
|
|
235
|
+
});
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Handle MessageReceived events
|
|
242
|
+
*/
|
|
243
|
+
_handleMessageReceived(data) {
|
|
244
|
+
this._emit('MessageReceived', {
|
|
245
|
+
senderPeerId: data.sender_peer_id,
|
|
246
|
+
message: data.message
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Emit an event to registered listeners
|
|
252
|
+
*/
|
|
253
|
+
_emit(event, data) {
|
|
254
|
+
if (this._listeners[event]) {
|
|
255
|
+
this._listeners[event].forEach(cb => {
|
|
256
|
+
try { cb(data); } catch (e) { console.error(e); }
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Add an event listener
|
|
263
|
+
* @param {string} event - Event name
|
|
264
|
+
* @param {Function} callback - Callback function
|
|
265
|
+
*/
|
|
266
|
+
addEventListener(event, callback) {
|
|
267
|
+
// AudioDataReceived goes directly to native
|
|
268
|
+
if (event === "AudioDataReceived") {
|
|
269
|
+
return super.addEventListener(event, callback);
|
|
270
|
+
}
|
|
271
|
+
if (!this._listeners[event]) this._listeners[event] = [];
|
|
272
|
+
this._listeners[event].push(callback);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Remove an event listener
|
|
277
|
+
* @param {string} event - Event name
|
|
278
|
+
* @param {Function} [callback] - Optional specific callback to remove
|
|
279
|
+
*/
|
|
280
|
+
removeEventListener(event, callback) {
|
|
281
|
+
if (event === "AudioDataReceived") {
|
|
282
|
+
return super.removeEventListener(event);
|
|
283
|
+
}
|
|
284
|
+
if (callback && this._listeners[event]) {
|
|
285
|
+
this._listeners[event] = this._listeners[event].filter(cb => cb !== callback);
|
|
286
|
+
} else {
|
|
287
|
+
delete this._listeners[event];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Convenience method: Set handler for when room is joined
|
|
293
|
+
* @param {Function} handler - Handler receiving { roomId, ownPeerId, room }
|
|
294
|
+
*/
|
|
295
|
+
onJoined(handler) {
|
|
296
|
+
this.addEventListener('Joined', handler);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Convenience method: Set handler for when room is left
|
|
301
|
+
* @param {Function} handler - Handler receiving { reason }
|
|
302
|
+
*/
|
|
303
|
+
onLeft(handler) {
|
|
304
|
+
this.addEventListener('Left', handler);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Convenience method: Set handler for peer joined events
|
|
309
|
+
* @param {Function} handler - Handler receiving { peerId, peer, userId, userData }
|
|
310
|
+
*/
|
|
311
|
+
onPeerJoined(handler) {
|
|
312
|
+
this.addEventListener('PeerJoined', handler);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Convenience method: Set handler for peer left events
|
|
317
|
+
* @param {Function} handler - Handler receiving { peerId }
|
|
318
|
+
*/
|
|
319
|
+
onPeerLeft(handler) {
|
|
320
|
+
this.addEventListener('PeerLeft', handler);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Convenience method: Set handler for media started events
|
|
325
|
+
* @param {Function} handler - Handler receiving { peerId, media }
|
|
326
|
+
*/
|
|
327
|
+
onMediaStarted(handler) {
|
|
328
|
+
this.addEventListener('MediaStarted', handler);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Convenience method: Set handler for media stopped events
|
|
333
|
+
* @param {Function} handler - Handler receiving { peerId, mediaId }
|
|
334
|
+
*/
|
|
335
|
+
onMediaStopped(handler) {
|
|
336
|
+
this.addEventListener('MediaStopped', handler);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Convenience method: Set handler for message received events
|
|
341
|
+
* @param {Function} handler - Handler receiving { senderPeerId, message }
|
|
342
|
+
*/
|
|
343
|
+
onMessageReceived(handler) {
|
|
344
|
+
this.addEventListener('MessageReceived', handler);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Convenience method: Set handler for connection state changes
|
|
349
|
+
* @param {Function} handler - Handler receiving { state, message }
|
|
350
|
+
*/
|
|
351
|
+
onConnectionStateChanged(handler) {
|
|
352
|
+
this.addEventListener('ConnectionStateChanged', handler);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Convenience method: Set handler for audio data events
|
|
357
|
+
* @param {Function} handler - Handler receiving { peerId, mediaId, samples16, samples32 }
|
|
358
|
+
*/
|
|
359
|
+
onAudioDataReceived(handler) {
|
|
360
|
+
this.addEventListener('AudioDataReceived', handler);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Convenience method: Set handler for peer user data changed events
|
|
365
|
+
* @param {Function} handler - Handler receiving { peerId, userData }
|
|
366
|
+
*/
|
|
367
|
+
onPeerUserDataChanged(handler) {
|
|
368
|
+
this.addEventListener('PeerUserDataChanged', handler);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Convenience method: Set handler for room user data changed events
|
|
373
|
+
* @param {Function} handler - Handler receiving { userData }
|
|
374
|
+
*/
|
|
375
|
+
onRoomUserDataChanged(handler) {
|
|
376
|
+
this.addEventListener('RoomUserDataChanged', handler);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Convenience method: Set handler for media activity events (voice activity)
|
|
381
|
+
* @param {Function} handler - Handler receiving { peerId, mediaId, state }
|
|
382
|
+
*/
|
|
383
|
+
onMediaActivity(handler) {
|
|
384
|
+
this.addEventListener('MediaActivity', handler);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Convenience method: Set handler for peer tags changed events
|
|
389
|
+
* @param {Function} handler - Handler receiving { peerId, tags }
|
|
390
|
+
*/
|
|
391
|
+
onPeerTagsChanged(handler) {
|
|
392
|
+
this.addEventListener('PeerTagsChanged', handler);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Closes the room connection and releases all resources.
|
|
397
|
+
*
|
|
398
|
+
* This method emits the 'Left' event before closing the native connection,
|
|
399
|
+
* ensuring that cleanup callbacks registered via onLeft() are triggered
|
|
400
|
+
* regardless of whether the disconnect is client-initiated (via close()) or
|
|
401
|
+
* server-initiated. This provides a single, consistent cleanup path for
|
|
402
|
+
* session state management.
|
|
403
|
+
*
|
|
404
|
+
* The reason provided in the Left event will be 'ClientDisconnect' to
|
|
405
|
+
* distinguish it from server-initiated disconnects.
|
|
406
|
+
*/
|
|
407
|
+
close() {
|
|
408
|
+
// Only emit Left if we're currently connected
|
|
409
|
+
// This avoids duplicate events if the server also sent a Left update
|
|
410
|
+
if (this._connected) {
|
|
411
|
+
this._connected = false;
|
|
412
|
+
// Emit Left event with a reason indicating client-initiated disconnect
|
|
413
|
+
this._emit('Left', { roomId: this._roomId, reason: 'ClientDisconnect' });
|
|
414
|
+
}
|
|
415
|
+
// Close all active media streams before closing the room
|
|
416
|
+
for (const media of this._activeMediaStreams) {
|
|
417
|
+
try {
|
|
418
|
+
media.close();
|
|
419
|
+
} catch (e) {
|
|
420
|
+
// Ignore errors during cleanup
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
this._activeMediaStreams = [];
|
|
424
|
+
// Call native close
|
|
425
|
+
super.close();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Get current connection state
|
|
430
|
+
*/
|
|
431
|
+
get connected() {
|
|
432
|
+
return this._connected;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Get own peer ID (available after joining)
|
|
437
|
+
*/
|
|
438
|
+
get ownPeerId() {
|
|
439
|
+
return this._ownPeerId;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Get available media IDs (populated after Joined event)
|
|
444
|
+
*/
|
|
445
|
+
get availableMediaIds() {
|
|
446
|
+
return [...this._availableMediaIds];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Claim the next available media ID for a new stream.
|
|
451
|
+
* @returns {number|null} The claimed media ID, or null if none available
|
|
452
|
+
* @private
|
|
453
|
+
*/
|
|
454
|
+
_claimMediaId() {
|
|
455
|
+
if (this._availableMediaIds.length === 0) return null;
|
|
456
|
+
return this._availableMediaIds.shift();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Return a media ID to the available pool (when stream is closed).
|
|
461
|
+
* @param {number} mediaId - The media ID to return
|
|
462
|
+
* @private
|
|
463
|
+
*/
|
|
464
|
+
_returnMediaId(mediaId) {
|
|
465
|
+
if (mediaId && !this._availableMediaIds.includes(mediaId)) {
|
|
466
|
+
this._availableMediaIds.push(mediaId);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Creates a new audio stream for sending audio data.
|
|
472
|
+
* Returns a wrapped OdinMedia instance with convenience methods.
|
|
473
|
+
* @param {number} sampleRate - The sample rate (e.g., 48000, 44100)
|
|
474
|
+
* @param {number} channels - Number of channels (1 or 2)
|
|
475
|
+
* @param {object} [apmSettings] - Optional APM settings
|
|
476
|
+
* @returns {OdinMediaWrapper} The wrapped media stream
|
|
477
|
+
*/
|
|
478
|
+
createAudioStream(sampleRate, channels, apmSettings) {
|
|
479
|
+
// Call native createAudioStream to get native media object
|
|
480
|
+
const nativeMedia = super.createAudioStream(sampleRate, channels, apmSettings);
|
|
481
|
+
// Wrap it with JS convenience layer
|
|
482
|
+
const wrapper = new OdinMediaWrapper(nativeMedia, this, sampleRate, channels);
|
|
483
|
+
this._activeMediaStreams.push(wrapper);
|
|
484
|
+
return wrapper;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* OdinMediaWrapper provides a high-level API for sending audio to ODIN rooms.
|
|
490
|
+
*
|
|
491
|
+
* It wraps the native OdinMedia object and adds convenience methods like sendMP3,
|
|
492
|
+
* sendWAV, and sendBuffer that handle all the setup (media ID, StartMedia RPC,
|
|
493
|
+
* timing) automatically.
|
|
494
|
+
*
|
|
495
|
+
* Low-level API (manual control):
|
|
496
|
+
* media.setMediaId(id); // From Joined event
|
|
497
|
+
* media.start();
|
|
498
|
+
* media.sendAudioData(chunk); // Each 20ms chunk
|
|
499
|
+
* media.stop();
|
|
500
|
+
* media.close();
|
|
501
|
+
*
|
|
502
|
+
* High-level API (convenience):
|
|
503
|
+
* await media.sendMP3('./file.mp3'); // Auto handles everything
|
|
504
|
+
* await media.sendWAV('./file.wav');
|
|
505
|
+
* await media.sendBuffer(audioBuffer);
|
|
506
|
+
*
|
|
507
|
+
* Note: start() and stop() methods have been removed as they were not
|
|
508
|
+
* needed - they matched a pattern that doesn't exist in the core SDK.
|
|
509
|
+
* Just call sendAudioData() to send audio, and close() when done.
|
|
510
|
+
*/
|
|
511
|
+
class OdinMediaWrapper {
|
|
512
|
+
/**
|
|
513
|
+
* Creates a new OdinMediaWrapper.
|
|
514
|
+
* @param {object} nativeMedia - The native OdinMedia object
|
|
515
|
+
* @param {OdinRoomWrapper} room - The parent room wrapper
|
|
516
|
+
* @param {number} sampleRate - Sample rate in Hz
|
|
517
|
+
* @param {number} channels - Number of channels (1 or 2)
|
|
518
|
+
*/
|
|
519
|
+
constructor(nativeMedia, room, sampleRate, channels) {
|
|
520
|
+
this._native = nativeMedia;
|
|
521
|
+
this._room = room;
|
|
522
|
+
this._sampleRate = sampleRate;
|
|
523
|
+
this._channels = channels;
|
|
524
|
+
this._mediaId = null;
|
|
525
|
+
this._rpcSent = false; // Track if StartMedia RPC was sent (for convenience API)
|
|
526
|
+
this._closed = false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ========== Native method proxies ==========
|
|
530
|
+
|
|
531
|
+
/** Get the media ID for this stream */
|
|
532
|
+
get id() {
|
|
533
|
+
if (this._closed || !this._native) return null;
|
|
534
|
+
return this._native.id;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Check if this media stream has been closed */
|
|
538
|
+
get closed() {
|
|
539
|
+
return this._closed;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Set the server-assigned media ID.
|
|
544
|
+
* This must be called before sending audio data.
|
|
545
|
+
*
|
|
546
|
+
* @param {number} mediaId - From Joined event's mediaIds array
|
|
547
|
+
* @returns {OdinMediaWrapper} This instance for chaining
|
|
548
|
+
*/
|
|
549
|
+
setMediaId(mediaId) {
|
|
550
|
+
if (this._closed) return this;
|
|
551
|
+
this._mediaId = mediaId;
|
|
552
|
+
if (this._native && typeof this._native.setMediaId === 'function') {
|
|
553
|
+
this._native.setMediaId(mediaId);
|
|
554
|
+
}
|
|
555
|
+
return this;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Close and release the media stream.
|
|
560
|
+
* Sends a StopMedia RPC to notify the server, then frees local resources.
|
|
561
|
+
* After calling this, the media stream cannot be used.
|
|
562
|
+
*/
|
|
563
|
+
close() {
|
|
564
|
+
if (this._closed) return;
|
|
565
|
+
this._closed = true;
|
|
566
|
+
|
|
567
|
+
// Send StopMedia RPC if we previously sent StartMedia
|
|
568
|
+
// This notifies the server that the media has stopped
|
|
569
|
+
if (this._rpcSent && this._mediaId && this._room) {
|
|
570
|
+
try {
|
|
571
|
+
const rpc = encode([0, 1, "StopMedia", { media_id: this._mediaId }]);
|
|
572
|
+
this._room.sendRpc(new Uint8Array(rpc));
|
|
573
|
+
} catch (e) {
|
|
574
|
+
// Ignore errors during cleanup (room may be closing)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Return media ID to pool
|
|
579
|
+
if (this._mediaId && this._room) {
|
|
580
|
+
try {
|
|
581
|
+
this._room._returnMediaId(this._mediaId);
|
|
582
|
+
} catch (e) {
|
|
583
|
+
// Ignore errors during cleanup
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
this._mediaId = null;
|
|
587
|
+
this._rpcSent = false;
|
|
588
|
+
|
|
589
|
+
// Call native close
|
|
590
|
+
if (this._native) {
|
|
591
|
+
try {
|
|
592
|
+
this._native.close();
|
|
593
|
+
} catch (e) {
|
|
594
|
+
// Ignore errors during cleanup
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// Clear references
|
|
599
|
+
this._native = null;
|
|
600
|
+
this._room = null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Send raw audio samples.
|
|
605
|
+
* @param {Float32Array} samples - Interleaved audio samples in range [-1, 1]
|
|
606
|
+
*/
|
|
607
|
+
sendAudioData(samples) {
|
|
608
|
+
if (this._closed || !this._native) return;
|
|
609
|
+
return this._native.sendAudioData(samples);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ========== Convenience methods ==========
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Ensures the stream is set up for the convenience API.
|
|
616
|
+
* Auto-claims media ID and sends StartMedia RPC if not done yet.
|
|
617
|
+
* @private
|
|
618
|
+
*/
|
|
619
|
+
async _ensureStarted() {
|
|
620
|
+
if (this._rpcSent) return;
|
|
621
|
+
if (this._closed) throw new Error('Media stream has been closed');
|
|
622
|
+
|
|
623
|
+
// Claim a media ID if not already set
|
|
624
|
+
if (!this._mediaId) {
|
|
625
|
+
this._mediaId = this._room._claimMediaId();
|
|
626
|
+
if (!this._mediaId) {
|
|
627
|
+
throw new Error('No available media IDs. Wait for Joined event before sending audio.');
|
|
628
|
+
}
|
|
629
|
+
// Set on native binding
|
|
630
|
+
if (this._native && typeof this._native.setMediaId === 'function') {
|
|
631
|
+
this._native.setMediaId(this._mediaId);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Send StartMedia RPC to notify the server
|
|
636
|
+
const rpc = encode([0, 1, "StartMedia", {
|
|
637
|
+
media_id: this._mediaId,
|
|
638
|
+
properties: { kind: "audio" }
|
|
639
|
+
}]);
|
|
640
|
+
if (this._room) {
|
|
641
|
+
this._room.sendRpc(new Uint8Array(rpc));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
this._rpcSent = true;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Send an MP3 file with automatic decoding and real-time streaming.
|
|
649
|
+
* Handles all setup (media ID, StartMedia RPC, timing) automatically.
|
|
650
|
+
*
|
|
651
|
+
* @param {string} filePath - Path to the MP3 file
|
|
652
|
+
* @returns {Promise<void>} Resolves when audio streaming is complete
|
|
653
|
+
*/
|
|
654
|
+
async sendMP3(filePath) {
|
|
655
|
+
await this._ensureStarted();
|
|
656
|
+
return this._streamAudioFile(filePath);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Send a WAV file with automatic decoding and real-time streaming.
|
|
661
|
+
* Handles all setup (media ID, StartMedia RPC, timing) automatically.
|
|
662
|
+
*
|
|
663
|
+
* @param {string} filePath - Path to the WAV file
|
|
664
|
+
* @returns {Promise<void>} Resolves when audio streaming is complete
|
|
665
|
+
*/
|
|
666
|
+
async sendWAV(filePath) {
|
|
667
|
+
await this._ensureStarted();
|
|
668
|
+
return this._streamAudioFile(filePath);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Send an AudioBuffer with real-time streaming.
|
|
673
|
+
* Handles all setup (media ID, StartMedia RPC, timing) automatically.
|
|
674
|
+
*
|
|
675
|
+
* @param {AudioBuffer} audioBuffer - Decoded audio from audio-decode
|
|
676
|
+
* @returns {Promise<void>} Resolves when audio streaming is complete
|
|
677
|
+
*/
|
|
678
|
+
async sendBuffer(audioBuffer) {
|
|
679
|
+
await this._ensureStarted();
|
|
680
|
+
return this._streamBuffer(audioBuffer);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Decode and stream an audio file.
|
|
685
|
+
* @param {string} filePath - Path to the audio file
|
|
686
|
+
* @private
|
|
687
|
+
*/
|
|
688
|
+
async _streamAudioFile(filePath) {
|
|
689
|
+
const fs = require('fs');
|
|
690
|
+
// audio-decode is an ESM module, but we can use dynamic import
|
|
691
|
+
const audioDecode = await import('audio-decode');
|
|
692
|
+
const decode = audioDecode.default || audioDecode;
|
|
693
|
+
const audioBuffer = await decode(fs.readFileSync(filePath));
|
|
694
|
+
return this._streamBuffer(audioBuffer);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Stream an AudioBuffer with proper timing (20ms chunks).
|
|
699
|
+
* Includes guards to stop streaming if the media is closed during playback.
|
|
700
|
+
* @param {AudioBuffer} audioBuffer - The decoded audio
|
|
701
|
+
* @private
|
|
702
|
+
*/
|
|
703
|
+
async _streamBuffer(audioBuffer) {
|
|
704
|
+
// Early exit if already closed
|
|
705
|
+
if (this._closed) return;
|
|
706
|
+
|
|
707
|
+
const numChannels = this._channels;
|
|
708
|
+
const sampleRate = audioBuffer.sampleRate;
|
|
709
|
+
|
|
710
|
+
// Get interleaved audio data
|
|
711
|
+
let audioData;
|
|
712
|
+
if (numChannels === 2 && audioBuffer.numberOfChannels >= 2) {
|
|
713
|
+
const left = audioBuffer.getChannelData(0);
|
|
714
|
+
const right = audioBuffer.getChannelData(1);
|
|
715
|
+
audioData = new Float32Array(left.length * 2);
|
|
716
|
+
for (let i = 0; i < left.length; i++) {
|
|
717
|
+
audioData[i * 2] = left[i];
|
|
718
|
+
audioData[i * 2 + 1] = right[i];
|
|
719
|
+
}
|
|
720
|
+
} else {
|
|
721
|
+
audioData = audioBuffer.getChannelData(0);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 20ms chunks at source sample rate
|
|
725
|
+
const chunkDurationMs = 20;
|
|
726
|
+
const samplesPerChunk = Math.floor(sampleRate * chunkDurationMs / 1000);
|
|
727
|
+
const floatsPerChunk = samplesPerChunk * numChannels;
|
|
728
|
+
|
|
729
|
+
// Split into chunks
|
|
730
|
+
const chunks = [];
|
|
731
|
+
for (let offset = 0; offset < audioData.length; offset += floatsPerChunk) {
|
|
732
|
+
const end = Math.min(offset + floatsPerChunk, audioData.length);
|
|
733
|
+
chunks.push(audioData.slice(offset, end));
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Send chunks with precise timing
|
|
737
|
+
const startTime = Date.now();
|
|
738
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
739
|
+
// Check if closed before each chunk to allow early termination
|
|
740
|
+
if (this._closed) return;
|
|
741
|
+
|
|
742
|
+
// Use the guarded sendAudioData method instead of calling native directly
|
|
743
|
+
this.sendAudioData(chunks[i]);
|
|
744
|
+
|
|
745
|
+
// Calculate when the next chunk should be sent
|
|
746
|
+
const nextChunkTime = startTime + (i + 1) * chunkDurationMs;
|
|
747
|
+
const now = Date.now();
|
|
748
|
+
const waitTime = nextChunkTime - now;
|
|
749
|
+
|
|
750
|
+
if (waitTime > 0 && i < chunks.length - 1) {
|
|
751
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
const NativeOdinClient = binding.OdinClient;
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* JavaScript wrapper for OdinClient that ensures rooms are created
|
|
761
|
+
* with the full JavaScript wrapper (OdinRoomWrapper) instead of native objects.
|
|
762
|
+
*/
|
|
763
|
+
class OdinClientWrapper extends NativeOdinClient {
|
|
764
|
+
constructor() {
|
|
765
|
+
super();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Create a room with the given token.
|
|
770
|
+
* @param {string} token - The authentication token
|
|
771
|
+
* @returns {OdinRoomWrapper} A wrapped room instance with event handling
|
|
772
|
+
*/
|
|
773
|
+
createRoom(token) {
|
|
774
|
+
return new OdinRoomWrapper(token);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Creates a new local room instance with the given room token. Use `join` on the returned OdinRoom instance to
|
|
779
|
+
* connect to that room. Use this method if you already have an access token, either created elsewhere or by calling
|
|
780
|
+
* `generateAccessToken`.
|
|
781
|
+
* @param {string} token - The access token to use to join the room.
|
|
782
|
+
*/
|
|
783
|
+
createRoomWithToken(token) {
|
|
784
|
+
return new OdinRoomWrapper(token);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Generates a room token for the given access key, room and user ID. This token can be used to join the room.
|
|
789
|
+
* @param {string} accessKey - The access key to use to generate the token. You can get a free access token in our developer center
|
|
790
|
+
* @param {string} roomId - The ID of the room to generate the token for.
|
|
791
|
+
* @param {string} userId - The ID of the user to generate the token for.
|
|
792
|
+
* @returns {string} The generated token
|
|
793
|
+
*/
|
|
794
|
+
generateToken(accessKey, roomId, userId) {
|
|
795
|
+
const generator = new TokenGenerator(accessKey);
|
|
796
|
+
return generator.createToken(roomId, userId);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Generates a token for the given access key, room and user ID. This token can be used to join the room.
|
|
801
|
+
* To be more consistent with other SDKs, this method is deprecated. Use generateToken instead.
|
|
802
|
+
* @param {string} accessKey - The access key to use to generate the token. You can get a free access token in our developer center
|
|
803
|
+
* @param {string} roomId - The ID of the room to generate the token for.
|
|
804
|
+
* @param {string} userId - The ID of the user to generate the token for.
|
|
805
|
+
* @returns {string} The generated token
|
|
806
|
+
* @deprecated Use generateToken instead
|
|
807
|
+
*/
|
|
808
|
+
generateAccessToken(accessKey, roomId, userId) {
|
|
809
|
+
return this.generateToken(accessKey, roomId, userId);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Generates an access token for a room and user ID.
|
|
815
|
+
* @param {string} accessKey - The access key to use to generate the token.
|
|
816
|
+
* @param {string} roomId - The ID of the room to generate the token for.
|
|
817
|
+
* @param {string} userId - The ID of the user to generate the token for.
|
|
818
|
+
* @returns {string} The generated token
|
|
819
|
+
* @deprecated Use OdinClient.generateToken instead.
|
|
820
|
+
*/
|
|
821
|
+
function generateAccessToken(accessKey, roomId, userId) {
|
|
822
|
+
const generator = new TokenGenerator(accessKey);
|
|
823
|
+
return generator.createToken(roomId, userId);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
binding.OdinRoom = OdinRoomWrapper;
|
|
827
|
+
binding.OdinMedia = OdinMediaWrapper;
|
|
828
|
+
binding.OdinClient = OdinClientWrapper;
|
|
829
|
+
binding.generateAccessToken = generateAccessToken;
|
|
830
|
+
|
|
2
831
|
module.exports = binding;
|