@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +603 -44
  4. package/binding.gyp +29 -13
  5. package/cppsrc/binding.cpp +3 -6
  6. package/cppsrc/odinbindings.cpp +9 -45
  7. package/cppsrc/odincipher.cpp +92 -0
  8. package/cppsrc/odincipher.h +32 -0
  9. package/cppsrc/odinclient.cpp +19 -158
  10. package/cppsrc/odinclient.h +2 -5
  11. package/cppsrc/odinmedia.cpp +144 -186
  12. package/cppsrc/odinmedia.h +51 -18
  13. package/cppsrc/odinroom.cpp +675 -635
  14. package/cppsrc/odinroom.h +76 -26
  15. package/cppsrc/utilities.cpp +11 -81
  16. package/cppsrc/utilities.h +25 -140
  17. package/index.cjs +829 -0
  18. package/index.d.ts +3 -4
  19. package/libs/bin/linux/arm64/libodin.so +0 -0
  20. package/libs/bin/linux/arm64/libodin_crypto.so +0 -0
  21. package/libs/bin/linux/ia32/libodin.so +0 -0
  22. package/libs/bin/linux/ia32/libodin_crypto.so +0 -0
  23. package/libs/bin/linux/x64/libodin.so +0 -0
  24. package/libs/bin/linux/x64/libodin_crypto.so +0 -0
  25. package/{prebuilds/darwin-x64/node.napi.node → libs/bin/macos/universal/libodin.dylib} +0 -0
  26. package/libs/bin/macos/universal/libodin_crypto.dylib +0 -0
  27. package/libs/bin/windows/arm64/odin.dll +0 -0
  28. package/libs/bin/windows/arm64/odin.lib +0 -0
  29. package/libs/bin/windows/arm64/odin_crypto.dll +0 -0
  30. package/libs/bin/windows/arm64/odin_crypto.lib +0 -0
  31. package/libs/bin/windows/ia32/odin.dll +0 -0
  32. package/libs/bin/windows/ia32/odin.lib +0 -0
  33. package/libs/bin/windows/ia32/odin_crypto.dll +0 -0
  34. package/libs/bin/windows/ia32/odin_crypto.lib +0 -0
  35. package/libs/bin/windows/x64/odin.dll +0 -0
  36. package/libs/bin/windows/x64/odin.lib +0 -0
  37. package/libs/bin/windows/x64/odin_crypto.dll +0 -0
  38. package/libs/bin/windows/x64/odin_crypto.lib +0 -0
  39. package/libs/include/odin.h +665 -567
  40. package/libs/include/odin_crypto.h +46 -0
  41. package/odin.cipher.d.ts +31 -0
  42. package/odin.media.d.ts +69 -19
  43. package/odin.room.d.ts +348 -7
  44. package/package.json +5 -4
  45. package/prebuilds/{darwin-arm64/node.napi.node → darwin-x64+arm64/libodin.dylib} +0 -0
  46. package/prebuilds/darwin-x64+arm64/libodin_crypto.dylib +0 -0
  47. package/prebuilds/darwin-x64+arm64/node.napi.node +0 -0
  48. package/prebuilds/linux-x64/libodin.so +0 -0
  49. package/prebuilds/linux-x64/libodin_crypto.so +0 -0
  50. package/prebuilds/linux-x64/node.napi.node +0 -0
  51. package/prebuilds/win32-x64/node.napi.node +0 -0
  52. package/prebuilds/win32-x64/odin.dll +0 -0
  53. package/prebuilds/win32-x64/odin_crypto.dll +0 -0
  54. package/scripts/postbuild.cjs +133 -0
  55. package/tests/audio-recording/README.md +97 -12
  56. package/tests/audio-recording/index.js +238 -130
  57. package/tests/connection-test/README.md +97 -0
  58. package/tests/connection-test/index.js +273 -0
  59. package/tests/lifecycle/test-room-cycle.js +169 -0
  60. package/tests/sending-audio/README.md +178 -9
  61. package/tests/sending-audio/canBounce.mp3 +0 -0
  62. package/tests/sending-audio/index.js +250 -87
  63. package/tests/sending-audio/test-kiss-api.js +149 -0
  64. package/tests/sending-audio/test-loop-audio.js +142 -0
  65. package/CMakeLists.txt +0 -25
  66. package/libs/bin/linux/arm64/libodin_static.a +0 -0
  67. package/libs/bin/linux/ia32/libodin_static.a +0 -0
  68. package/libs/bin/linux/x64/libodin_static.a +0 -0
  69. package/libs/bin/macos/arm64/libodin_static.a +0 -0
  70. package/libs/bin/macos/x64/libodin_static.a +0 -0
  71. package/libs/bin/windows/arm64/odin_static.lib +0 -0
  72. package/libs/bin/windows/ia32/odin_static.lib +0 -0
  73. 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;