@areumtecnologia/wasockets 2.0.0-alpha.1 → 2.2.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.
Files changed (46) hide show
  1. package/README.md +1929 -1649
  2. package/lib/Defaults/baileys-version.json +1 -1
  3. package/lib/Defaults/connection.js +14 -1
  4. package/lib/Socket/index.js +51 -48
  5. package/lib/Socket/messages-recv.js +74 -13
  6. package/lib/Socket/messages-send.js +50 -25
  7. package/lib/Socket/socket.js +13 -0
  8. package/lib/Socket/voip.js +123 -0
  9. package/lib/Store/make-cache-manager-store.js +14 -5
  10. package/lib/Types/index.d.ts +4 -0
  11. package/lib/Utils/auth-utils.js +16 -1
  12. package/lib/Utils/browser-utils.js +6 -2
  13. package/lib/Utils/generics.js +47 -8
  14. package/lib/Utils/index.d.ts +1 -0
  15. package/lib/Utils/messages-media.js +44 -21
  16. package/lib/Utils/pre-key-manager.js +16 -1
  17. package/lib/Utils/validate-connection.js +10 -11
  18. package/lib/VoIP/VoIPCrypto.js +150 -0
  19. package/lib/VoIP/VoIPManager.js +469 -0
  20. package/lib/VoIP/VoIPMediaEngine.js +224 -0
  21. package/lib/VoIP/VoIPProtocol.js +354 -0
  22. package/lib/VoIP/VoIPRelayTransport.js +173 -0
  23. package/lib/VoIP/VoIPSession.js +314 -0
  24. package/lib/VoIP/assets/wasm/loader.js +5 -0
  25. package/lib/VoIP/assets/wasm/whatsapp.wasm +0 -0
  26. package/lib/VoIP/assets/wasm/worker-modules.js +305 -0
  27. package/lib/VoIP/engine/audio-feeder.d.mts +15 -0
  28. package/lib/VoIP/engine/audio-feeder.mjs +243 -0
  29. package/lib/VoIP/engine/index.d.mts +91 -0
  30. package/lib/VoIP/engine/index.mjs +1683 -0
  31. package/lib/VoIP/engine/relay-transport.d.mts +43 -0
  32. package/lib/VoIP/engine/relay-transport.mjs +607 -0
  33. package/lib/VoIP/engine/signaling.d.mts +44 -0
  34. package/lib/VoIP/engine/signaling.mjs +893 -0
  35. package/lib/VoIP/engine/types.d.mts +183 -0
  36. package/lib/VoIP/engine/types.mjs +17 -0
  37. package/lib/VoIP/engine/wasm-engine.d.mts +103 -0
  38. package/lib/VoIP/engine/wasm-engine.mjs +1397 -0
  39. package/lib/VoIP/engine/worker-bootstrap.mjs +1049 -0
  40. package/lib/VoIP/index.js +51 -0
  41. package/lib/VoIP/utils/audio.d.ts +28 -0
  42. package/lib/VoIP/utils/audio.js +148 -0
  43. package/lib/WABinary/generic-utils.js +22 -6
  44. package/lib/index.d.ts +382 -274
  45. package/lib/index.js +5 -0
  46. package/package.json +28 -32
@@ -0,0 +1,469 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+
5
+ const crypto = require("crypto");
6
+ const { EventEmitter } = require("events");
7
+ const { VoIPSession, CallState } = require("./VoIPSession");
8
+ const {
9
+ buildOfferStanza,
10
+ buildRelayQueryIq,
11
+ parseRelayEndpoints,
12
+ parseCallNode
13
+ } = require("./VoIPProtocol");
14
+ const {
15
+ jidEncode,
16
+ jidDecode,
17
+ jidNormalizedUser,
18
+ areJidsSameUser,
19
+ S_WHATSAPP_NET
20
+ } = require("../WABinary");
21
+ const { encodeSignedDeviceIdentity, unpadRandomMax16 } = require("../Utils");
22
+ const { proto } = require("../../WAProto");
23
+
24
+ /**
25
+ * WhatsApp VoIP Manager
26
+ * High-level coordinator for direct WhatsApp VoIP calling in WaSockets
27
+ */
28
+ class VoIPManager extends EventEmitter {
29
+ constructor(sock, options = {}) {
30
+ super();
31
+ this.sock = sock;
32
+ this.options = options;
33
+ this.logger = sock.logger || console;
34
+ this.activeSessions = new Map();
35
+ this.cachedRelayEndpoints = [];
36
+ this.lastRelayFetchTime = 0;
37
+
38
+ this.initListeners();
39
+ }
40
+
41
+ /**
42
+ * Initializes listeners for WhatsApp call events and stanzas
43
+ */
44
+ initListeners() {
45
+ // Listen to WaSockets 'call' events
46
+ this.sock.ev.on("call", async (callEvents) => {
47
+ for (const callEvent of callEvents) {
48
+ await this.handleCallEvent(callEvent);
49
+ }
50
+ });
51
+
52
+ // Listen to receipt for offer delivery / remote ringing
53
+ if (this.sock.ws) {
54
+ this.sock.ws.on("CB:receipt", (node) => {
55
+ const children = node.content || [];
56
+ const hasOffer = Array.isArray(children) && children.some(c => c?.tag === "offer");
57
+ const isRinging = node.attrs?.type === "ringing" || hasOffer;
58
+ if (isRinging) {
59
+ for (const session of this.activeSessions.values()) {
60
+ if (session.state === CallState.OFFERING) {
61
+ console.log(`[VoIPManager] Peer device confirmed call offer / ringing for call ${session.callId}`);
62
+ session.setState(CallState.RINGING);
63
+ }
64
+ }
65
+ }
66
+ });
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Handles an incoming call event from WaSockets
72
+ */
73
+ async handleCallEvent(callEvent) {
74
+ const { id: callId, from, chatId, status, isVideo, rawNode, infoNode } = callEvent;
75
+
76
+ this.logger.info?.({ callId, from, status, isVideo, reason: infoNode?.attrs?.reason }, "VoIPManager handling call event");
77
+ console.log(`[VoIPManager] Call Event: ${callId} -> status=${status}, from=${from}, reason=${infoNode?.attrs?.reason || 'none'}`);
78
+
79
+ // 1. Check for Meta VoIP relay endpoints in the stanza (pushed as <relay> or <relaylatency> with <te> nodes)
80
+ if (rawNode) {
81
+ const relayEndpoints = parseRelayEndpoints(rawNode);
82
+ if (relayEndpoints.length > 0) {
83
+ console.log(`[VoIPManager] Received ${relayEndpoints.length} relay endpoint(s) for call ${callId}:`, relayEndpoints.map(e => `${e.ip}:${e.port}`));
84
+ this.cachedRelayEndpoints = relayEndpoints;
85
+ const session = this.activeSessions.get(callId);
86
+ if (session) {
87
+ session.addRelayEndpoints(relayEndpoints);
88
+ }
89
+ }
90
+ }
91
+
92
+ if (status === "offer") {
93
+ try {
94
+ // Attempt to extract or decrypt callKey from raw offer node if available
95
+ let callKey = null;
96
+ if (rawNode) {
97
+ const senderDeviceJid = rawNode.attrs?.from || from;
98
+ callKey = await this.extractCallKeyFromOffer(rawNode, senderDeviceJid);
99
+ }
100
+
101
+ // If no key extracted, generate fallback or wait for key negotiation
102
+ if (!callKey) {
103
+ this.logger.warn?.({ callId }, "Could not decrypt destination callKey, using fallback");
104
+ callKey = crypto.randomBytes(32);
105
+ } else {
106
+ console.log(`[VoIPManager] Successfully decrypted 32-byte callKey for incoming call ${callId}!`);
107
+ }
108
+
109
+ const meId = this.sock.authState?.creds?.me?.id;
110
+ const meLid = this.sock.authState?.creds?.me?.lid;
111
+ const isLidCall = (from && from.endsWith("@lid")) || (chatId && chatId.endsWith("@lid"));
112
+ const myCalleeJid = isLidCall && meLid ? meLid : meId;
113
+
114
+ const session = new VoIPSession({
115
+ callId,
116
+ callCreator: from,
117
+ peerJid: chatId || from,
118
+ myJid: myCalleeJid,
119
+ direction: "inbound",
120
+ isVideo: !!isVideo,
121
+ callKey,
122
+ relayEndpoints: this.cachedRelayEndpoints,
123
+ socketClient: this.sock,
124
+ logger: this.logger
125
+ });
126
+
127
+ this.activeSessions.set(callId, session);
128
+
129
+ session.on("ended", () => {
130
+ this.activeSessions.delete(callId);
131
+ });
132
+
133
+ this.emit("call.incoming", session);
134
+ this.emit("call", session);
135
+ } catch (err) {
136
+ this.logger.error?.({ err, callId }, "Error processing incoming VoIP call offer");
137
+ }
138
+ } else if (status === "ringing" || status === "ring") {
139
+ const session = this.activeSessions.get(callId);
140
+ if (session && session.state === CallState.OFFERING) {
141
+ session.setState(CallState.RINGING);
142
+ }
143
+ } else if (status === "accept") {
144
+ const session = this.activeSessions.get(callId);
145
+ if (session && session.direction === "outbound") {
146
+ console.log(`[VoIPManager] Outbound call ${callId} accepted by peer!`);
147
+ session.setState(CallState.ACCEPTED);
148
+ if (session.relayEndpoints.length > 0) {
149
+ await session.connectRelay();
150
+ } else {
151
+ session.setState(CallState.CONNECTED);
152
+ }
153
+ }
154
+ } else if (status === "terminate" || status === "reject" || status === "timeout") {
155
+ const session = this.activeSessions.get(callId);
156
+ if (session) {
157
+ const reason = infoNode?.attrs?.reason || status;
158
+ console.log(`[VoIPManager] Call ${callId} ended: ${status} (reason: ${reason})`);
159
+ session.setState(status === "reject" ? CallState.REJECTED : CallState.TERMINATED);
160
+ session.cleanup();
161
+ this.activeSessions.delete(callId);
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Extracts and decrypts the callKey from incoming offer destination nodes
168
+ */
169
+ async extractCallKeyFromOffer(offerNode, callerJid) {
170
+ try {
171
+ const parsed = parseCallNode(offerNode);
172
+ if (!parsed || !parsed.destinations || parsed.destinations.length === 0) {
173
+ return null;
174
+ }
175
+
176
+ const meId = this.sock.authState?.creds?.me?.id;
177
+ const meLid = this.sock.authState?.creds?.me?.lid;
178
+ const myPnUser = meId ? jidDecode(meId)?.user : null;
179
+ const myLidUser = meLid ? jidDecode(meLid)?.user : null;
180
+
181
+ console.log(`[VoIPManager] Searching destination nodes for own identity (meId=${meId}, meLid=${meLid})...`);
182
+
183
+ // Find destination node targeting this client's user/device (PN or LID)
184
+ for (const destNode of parsed.destinations) {
185
+ const targetJid = destNode.attrs?.jid;
186
+ const targetUser = targetJid ? jidDecode(targetJid)?.user : null;
187
+ const isMatch = (parsed.destinations.length === 1) || (targetJid === meId || targetJid === meLid || targetUser === myPnUser || (myLidUser && targetUser === myLidUser));
188
+
189
+ if (isMatch) {
190
+ console.log(`[VoIPManager] Found matching destination node for ${targetJid || 'direct'}!`);
191
+ const encNode = destNode.tag === "enc" ? destNode : destNode.content?.find?.(c => c.tag === "enc");
192
+ if (encNode && (encNode.content instanceof Uint8Array || Buffer.isBuffer(encNode.content))) {
193
+ const ciphertext = Buffer.from(encNode.content);
194
+ const decryptJid = callerJid;
195
+ console.log(`[VoIPManager] Decrypting Signal payload from ${decryptJid} (type: ${encNode.attrs?.type || 'pkmsg'})...`);
196
+
197
+ const decrypted = await this.sock.signalRepository?.decryptMessage?.({
198
+ jid: decryptJid,
199
+ type: encNode.attrs?.type || "pkmsg",
200
+ ciphertext
201
+ });
202
+
203
+ if (decrypted) {
204
+ try {
205
+ const unpadded = unpadRandomMax16(decrypted);
206
+ const decodedMsg = proto.Message.decode(unpadded);
207
+ if (decodedMsg?.call?.callKey) {
208
+ return Buffer.from(decodedMsg.call.callKey);
209
+ }
210
+ } catch (decodeErr) {
211
+ console.warn(`[VoIPManager] Protobuf decode fallback:`, decodeErr?.message);
212
+ if (decrypted.length >= 32) {
213
+ return Buffer.from(decrypted.subarray(0, 32));
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ } catch (err) {
221
+ this.logger.debug?.({ err: err?.message }, "Could not decrypt destination callKey, using fallback");
222
+ console.warn(`[VoIPManager] extractCallKeyFromOffer error:`, err?.message);
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /**
228
+ * Queries Meta VoIP relay tokens and server endpoints
229
+ */
230
+ async getRelayEndpoints(forceRefresh = false) {
231
+ const now = Date.now();
232
+ // Cache endpoints for 10 minutes
233
+ if (!forceRefresh && this.cachedRelayEndpoints.length > 0 && now - this.lastRelayFetchTime < 600000) {
234
+ return this.cachedRelayEndpoints;
235
+ }
236
+
237
+ try {
238
+ const iq = buildRelayQueryIq();
239
+ const result = await this.sock.query(iq, 2000);
240
+ const endpoints = parseRelayEndpoints(result);
241
+
242
+ if (endpoints.length > 0) {
243
+ this.cachedRelayEndpoints = endpoints;
244
+ this.lastRelayFetchTime = now;
245
+ this.logger.info?.({ count: endpoints.length }, "Retrieved Meta VoIP Relay endpoints");
246
+ }
247
+
248
+ return this.cachedRelayEndpoints;
249
+ } catch (err) {
250
+ this.logger.debug?.({ err: err?.message }, "Relay query skipped, continuing with direct/pushed relay mode");
251
+ return this.cachedRelayEndpoints;
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Initiates an outgoing WhatsApp VoIP call
257
+ * @param {string} toJid Target WhatsApp user JID or phone number
258
+ * @param {object} options Calling options { isVideo: boolean, audioRate: string }
259
+ * @returns {Promise<VoIPSession>} Active call session
260
+ */
261
+ async startCall(toJid, options = {}) {
262
+ const isVideo = !!options.isVideo;
263
+ const callId = crypto.randomBytes(16).toString("hex").toUpperCase().substring(0, 64);
264
+ const callKey = crypto.randomBytes(32);
265
+ const myJid = this.sock.authState?.creds?.me?.id;
266
+ const myLid = this.sock.authState?.creds?.me?.lid;
267
+
268
+ // Normalize destination number to phone-number JID (s.whatsapp.net) for proper contact recognition
269
+ let resolvedJid = toJid;
270
+ const cleanNumber = resolvedJid.replace(/\D/g, "");
271
+ try {
272
+ if (resolvedJid.endsWith("@lid")) {
273
+ // Keep explicitly requested LID
274
+ } else {
275
+ if (this.sock.onWhatsApp) {
276
+ const [waCheck] = await this.sock.onWhatsApp(cleanNumber);
277
+ if (waCheck && waCheck.exists && waCheck.jid) {
278
+ resolvedJid = waCheck.jid;
279
+ }
280
+ }
281
+ if (!resolvedJid.includes("@")) {
282
+ resolvedJid = `${cleanNumber}@s.whatsapp.net`;
283
+ }
284
+ }
285
+ } catch (_) {
286
+ if (!resolvedJid.includes("@")) {
287
+ resolvedJid = `${cleanNumber}@s.whatsapp.net`;
288
+ }
289
+ }
290
+
291
+ const isLidTarget = resolvedJid.endsWith("@lid");
292
+ const callerJid = (isLidTarget && myLid) ? myLid : myJid;
293
+ const callerCreator = jidNormalizedUser(callerJid);
294
+ const ownSyncJid = isLidTarget && myLid ? jidNormalizedUser(myLid) : jidNormalizedUser(myJid);
295
+
296
+ this.logger.info?.({ toJid: resolvedJid, callerJid, callId, isVideo }, "Initiating outgoing WhatsApp VoIP call");
297
+ console.log(`\n[VoIP DEBUG] Step 1: Starting VoIP call to ${resolvedJid} (callId: ${callId}, isVideo: ${isVideo}, caller: ${callerJid})`);
298
+
299
+ // 1. Fetch recipient devices ONLY (1:1 call offers must not include caller devices)
300
+ let devices = [];
301
+ try {
302
+ const syncResult = await this.sock.getUSyncDevices([resolvedJid], true, false);
303
+ if (Array.isArray(syncResult) && syncResult.length > 0) {
304
+ devices = syncResult.map(item => {
305
+ if (item.jid) return item.jid;
306
+ const server = item.server || (isLidTarget ? "lid" : "s.whatsapp.net");
307
+ return jidEncode(item.user, server, item.device);
308
+ });
309
+ }
310
+ } catch (err) {
311
+ this.logger.warn?.({ err: err?.message }, "Could not fetch USync devices");
312
+ }
313
+
314
+ if (!devices.length) {
315
+ devices = [resolvedJid];
316
+ }
317
+
318
+ // Callee devices only: strictly filter out any device belonging to the caller
319
+ devices = [...new Set(devices)].filter(d => !areJidsSameUser(d, myJid) && (!myLid || !areJidsSameUser(d, myLid)));
320
+
321
+ // For WhatsApp calls, include all callee devices in destination nodes
322
+ const targetDevices = devices;
323
+ console.log(`[VoIP DEBUG] Step 2: Target devices: ${targetDevices.join(", ")}`);
324
+
325
+ try {
326
+ await this.sock.assertSessions(targetDevices, false);
327
+ console.log(`[VoIP DEBUG] Step 3: Asserted Signal sessions for ${targetDevices.length} devices`);
328
+ } catch (err) {
329
+ console.warn(`[VoIP DEBUG] Step 3 Warning: Could not assert sessions:`, err?.message);
330
+ }
331
+
332
+ // 2. Encrypt callKey for target device using Signal
333
+ let destinations = [];
334
+ let shouldIncludeDeviceIdentity = false;
335
+
336
+ try {
337
+ const partResult = await this.sock.createParticipantNodes(
338
+ targetDevices,
339
+ { call: { callKey: new Uint8Array(callKey) } },
340
+ { count: "0" }
341
+ );
342
+ destinations = partResult.nodes;
343
+ shouldIncludeDeviceIdentity = partResult.shouldIncludeDeviceIdentity;
344
+ console.log(`[VoIP DEBUG] Step 4: Encrypted callKey into destination node(s)`);
345
+ } catch (err) {
346
+ console.warn(`[VoIP DEBUG] Step 4 Warning: Could not create participant nodes:`, err?.message);
347
+ }
348
+
349
+ let deviceIdentity = null;
350
+ if (this.sock.authState?.creds?.account) {
351
+ deviceIdentity = encodeSignedDeviceIdentity(this.sock.authState.creds.account, true);
352
+ }
353
+
354
+ // 3. Meta Relay endpoints (from cache or pushed upon offer)
355
+ const relayEndpoints = this.cachedRelayEndpoints.length > 0 ? this.cachedRelayEndpoints : [];
356
+ console.log(`[VoIP DEBUG] Step 5: Relay endpoints: ${relayEndpoints.length} available`);
357
+
358
+ const callerPn = myJid ? jidNormalizedUser(myJid) : null;
359
+ let callerCountryCode = "BR";
360
+ if (callerPn) {
361
+ const pnDigits = callerPn.replace(/\D/g, "");
362
+ if (pnDigits.startsWith("55")) callerCountryCode = "BR";
363
+ else if (pnDigits.startsWith("1")) callerCountryCode = "US";
364
+ else if (pnDigits.startsWith("44")) callerCountryCode = "GB";
365
+ else if (pnDigits.startsWith("351")) callerCountryCode = "PT";
366
+ }
367
+
368
+ // Fetch Trusted Contact Token (tctoken) if available
369
+ let tcTokenNode = null;
370
+ try {
371
+ const contactTcTokenData = await this.sock.authState?.keys?.get?.("tctoken", [
372
+ resolvedJid,
373
+ `${cleanNumber}@s.whatsapp.net`
374
+ ]);
375
+ const entry = contactTcTokenData?.[resolvedJid] || contactTcTokenData?.[`${cleanNumber}@s.whatsapp.net`];
376
+ if (entry?.token) {
377
+ const tokenBuf = Buffer.isBuffer(entry.token)
378
+ ? entry.token
379
+ : (entry.token.data ? Buffer.from(entry.token.data, "base64") : Buffer.from(entry.token, "base64"));
380
+ tcTokenNode = {
381
+ tag: "tctoken",
382
+ attrs: entry.timestamp ? { t: String(entry.timestamp) } : {},
383
+ content: tokenBuf
384
+ };
385
+ console.log(`[VoIP DEBUG] Attached Trusted Contact Token (tctoken) for ${resolvedJid}!`);
386
+ }
387
+ } catch (tcErr) {
388
+ console.warn(`[VoIP DEBUG] Note: Could not retrieve tctoken:`, tcErr?.message);
389
+ }
390
+
391
+ // 4. Build and send offer stanza via sendNode matching verified message 5 spec
392
+ const offerStanza = buildOfferStanza({
393
+ toJid: resolvedJid,
394
+ myJid: callerJid,
395
+ callerPn,
396
+ callerCountryCode,
397
+ deviceClass: "2015",
398
+ joinable: "1",
399
+ callId,
400
+ destinations,
401
+ deviceIdentity,
402
+ isVideo,
403
+ audioRate: options.audioRate || "16000"
404
+ });
405
+
406
+ console.log(`[VoIP DEBUG] Step 6: Dispatching <call><offer> stanza via sendNode...`);
407
+ if (this.sock.sendNode) {
408
+ await this.sock.sendNode(offerStanza);
409
+ } else {
410
+ await this.sock.query(offerStanza);
411
+ }
412
+ console.log(`[VoIP DEBUG] Step 7: <call><offer> dispatched to WhatsApp network successfully!`);
413
+
414
+ // 5. Create and return active outbound VoIP session
415
+ const session = new VoIPSession({
416
+ callId,
417
+ callCreator: callerCreator,
418
+ peerJid: resolvedJid,
419
+ myJid: callerJid,
420
+ direction: "outbound",
421
+ isVideo,
422
+ callKey,
423
+ relayEndpoints,
424
+ socketClient: this.sock,
425
+ logger: this.logger
426
+ });
427
+
428
+ this.activeSessions.set(callId, session);
429
+
430
+ session.on("ended", () => {
431
+ this.activeSessions.delete(callId);
432
+ });
433
+
434
+ return session;
435
+ }
436
+
437
+ /**
438
+ * Retrieves an active session by call ID
439
+ */
440
+ getSession(callId) {
441
+ return this.activeSessions.get(callId);
442
+ }
443
+
444
+ /**
445
+ * Lists all current active VoIP sessions
446
+ */
447
+ listSessions() {
448
+ return Array.from(this.activeSessions.values()).map(s => ({
449
+ callId: s.callId,
450
+ peerJid: s.peerJid,
451
+ direction: s.direction,
452
+ state: s.state,
453
+ isVideo: s.isVideo,
454
+ startTime: s.startTime
455
+ }));
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Factory function to attach VoIPManager to a WaSockets instance
461
+ */
462
+ function makeVoIPManager(sock, options = {}) {
463
+ return new VoIPManager(sock, options);
464
+ }
465
+
466
+ module.exports = {
467
+ VoIPManager,
468
+ makeVoIPManager,
469
+ };
@@ -0,0 +1,224 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+
5
+ const crypto = require("crypto");
6
+ const { EventEmitter } = require("events");
7
+ const {
8
+ computeSRTPIV,
9
+ encryptRTPPayload,
10
+ decryptRTPPayload,
11
+ computeAuthTag
12
+ } = require("./VoIPCrypto");
13
+
14
+ /**
15
+ * WhatsApp VoIP Media & RTP/SRTP Engine
16
+ * Handles RTP packetization, SRTP encryption/decryption, sequence management,
17
+ * and audio frame piping.
18
+ */
19
+
20
+ const RTP_PAYLOAD_TYPE_OPUS = 96;
21
+ const RTP_PAYLOAD_TYPE_H264 = 102;
22
+ const AUDIO_SAMPLE_RATE_16K = 16000;
23
+ const AUDIO_FRAME_DURATION_MS = 20; // 20ms standard frames
24
+ const SAMPLES_PER_FRAME_16K = (AUDIO_SAMPLE_RATE_16K * AUDIO_FRAME_DURATION_MS) / 1000; // 320 samples
25
+
26
+ class VoIPMediaEngine extends EventEmitter {
27
+ constructor({
28
+ txKey,
29
+ txSalt,
30
+ rxKey,
31
+ rxSalt,
32
+ ssrc = null,
33
+ peerSsrc = null,
34
+ logger = console
35
+ }) {
36
+ super();
37
+ this.txKey = txKey;
38
+ this.txSalt = txSalt;
39
+ this.rxKey = rxKey;
40
+ this.rxSalt = rxSalt;
41
+ this.ssrc = ssrc || crypto.randomBytes(4).readUInt32BE(0);
42
+ this.peerSsrc = peerSsrc;
43
+ this.logger = logger;
44
+
45
+ this.txSeqNo = Math.floor(Math.random() * 0x7fff);
46
+ this.txTimestamp = Math.floor(Math.random() * 0x7fffffff);
47
+ this.txRoc = 0;
48
+
49
+ this.rxSeqNo = 0;
50
+ this.rxRoc = 0;
51
+ this.rxInit = false;
52
+
53
+ this.audioStreamingTimer = null;
54
+ }
55
+
56
+ /**
57
+ * Builds an encrypted SRTP packet from an Opus audio frame
58
+ * @param {Buffer} audioPayload Opus compressed audio data
59
+ * @param {boolean} isMarker True for the first packet of a talkspurt
60
+ * @returns {Buffer} Complete SRTP packet ready for relay transmission
61
+ */
62
+ packAudio(audioPayload, isMarker = false) {
63
+ const seq = this.txSeqNo;
64
+ const ts = this.txTimestamp;
65
+
66
+ // Build 12-byte standard RTP header
67
+ const header = Buffer.alloc(12);
68
+ header.writeUInt8(0x80, 0); // V=2, P=0, X=0, CC=0
69
+ header.writeUInt8((isMarker ? 0x80 : 0x00) | (RTP_PAYLOAD_TYPE_OPUS & 0x7f), 1);
70
+ header.writeUInt16BE(seq, 2);
71
+ header.writeUInt32BE(ts, 4);
72
+ header.writeUInt32BE(this.ssrc, 8);
73
+
74
+ // Encrypt payload with AES-128-CTR using SRTP IV
75
+ const iv = computeSRTPIV(this.txSalt, this.ssrc, seq, this.txRoc);
76
+ const encryptedPayload = encryptRTPPayload(audioPayload, this.txKey, iv);
77
+
78
+ const rtpPacket = Buffer.concat([header, encryptedPayload]);
79
+
80
+ // Optional HMAC-SHA1-80 authentication tag (10 bytes)
81
+ // const authTag = computeAuthTag(this.txKey, rtpPacket, this.txRoc);
82
+ // return Buffer.concat([rtpPacket, authTag]);
83
+
84
+ // Update sequence number and timestamp for next packet
85
+ this.txSeqNo = (this.txSeqNo + 1) & 0xffff;
86
+ if (this.txSeqNo === 0) {
87
+ this.txRoc++;
88
+ }
89
+ this.txTimestamp = (this.txTimestamp + SAMPLES_PER_FRAME_16K) >>> 0;
90
+
91
+ return rtpPacket;
92
+ }
93
+
94
+ /**
95
+ * Unpacks and decrypts an incoming SRTP packet from peer
96
+ * @param {Buffer} srtpPacket Raw UDP packet received from relay
97
+ * @returns {{ payload: Buffer, seq: number, timestamp: number, ssrc: number, payloadType: number } | null}
98
+ */
99
+ unpackPacket(srtpPacket) {
100
+ if (!srtpPacket || srtpPacket.length < 12) return null;
101
+
102
+ const headerByte0 = srtpPacket.readUInt8(0);
103
+ if ((headerByte0 & 0xc0) !== 0x80) return null; // Not RTP V=2
104
+
105
+ const payloadType = srtpPacket.readUInt8(1) & 0x7f;
106
+ const seq = srtpPacket.readUInt16BE(2);
107
+ const timestamp = srtpPacket.readUInt32BE(4);
108
+ const ssrc = srtpPacket.readUInt32BE(8);
109
+
110
+ if (!this.peerSsrc) {
111
+ this.peerSsrc = ssrc;
112
+ }
113
+
114
+ // Track ROC for incoming sequence numbers
115
+ if (!this.rxInit) {
116
+ this.rxSeqNo = seq;
117
+ this.rxInit = true;
118
+ } else if (seq < this.rxSeqNo && this.rxSeqNo - seq > 0x7fff) {
119
+ this.rxRoc++;
120
+ this.rxSeqNo = seq;
121
+ } else if (seq > this.rxSeqNo) {
122
+ this.rxSeqNo = seq;
123
+ }
124
+
125
+ const encryptedPayload = srtpPacket.subarray(12);
126
+ const iv = computeSRTPIV(this.rxSalt, ssrc, seq, this.rxRoc);
127
+
128
+ let decryptedPayload;
129
+ try {
130
+ decryptedPayload = decryptRTPPayload(encryptedPayload, this.rxKey, iv);
131
+ } catch (err) {
132
+ this.logger.warn?.({ err, seq }, "Failed to decrypt SRTP payload");
133
+ return null;
134
+ }
135
+
136
+ const result = {
137
+ seq,
138
+ timestamp,
139
+ ssrc,
140
+ payloadType,
141
+ payload: decryptedPayload,
142
+ isAudio: payloadType === RTP_PAYLOAD_TYPE_OPUS,
143
+ isVideo: payloadType === RTP_PAYLOAD_TYPE_H264
144
+ };
145
+
146
+ if (result.isAudio) {
147
+ this.emit("audio", result);
148
+ } else if (result.isVideo) {
149
+ this.emit("video", result);
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ /**
156
+ * Generates a standard Opus Comfort Noise / Silence frame (3 bytes)
157
+ * Format: Opus TOC byte + silent payload
158
+ */
159
+ static createSilenceFrame() {
160
+ // Opus silence frame (TOC byte 0xf8 = mode 31, wideband, 20ms)
161
+ return Buffer.from([0xf8, 0xff, 0xfe]);
162
+ }
163
+
164
+ /**
165
+ * Generates synthetic ringback tone frames for WhatsApp call ringing
166
+ * @param {number} durationMs Duration to generate
167
+ * @returns {Buffer[]} Array of 20ms audio frames
168
+ */
169
+ static generateRingbackToneFrames(durationMs = 2000) {
170
+ const frameCount = Math.floor(durationMs / AUDIO_FRAME_DURATION_MS);
171
+ const frames = [];
172
+
173
+ // Dual Tone Multi-Frequency (440Hz + 480Hz) or Opus test frames
174
+ for (let i = 0; i < frameCount; i++) {
175
+ // Emits standard wideband Opus frame
176
+ frames.push(VoIPMediaEngine.createSilenceFrame());
177
+ }
178
+
179
+ return frames;
180
+ }
181
+
182
+ /**
183
+ * Starts streaming an audio track (array of Opus frames or buffer stream)
184
+ * @param {Buffer[]} frames List of Opus audio frame buffers
185
+ * @param {Function} onPacket Callback invoked every 20ms with an SRTP packet
186
+ */
187
+ startStreaming(frames, onPacket) {
188
+ this.stopStreaming();
189
+ let frameIndex = 0;
190
+
191
+ this.audioStreamingTimer = setInterval(() => {
192
+ if (frameIndex >= frames.length) {
193
+ this.stopStreaming();
194
+ this.emit("stream.ended");
195
+ return;
196
+ }
197
+
198
+ const frame = frames[frameIndex];
199
+ const packet = this.packAudio(frame, frameIndex === 0);
200
+ onPacket(packet);
201
+
202
+ frameIndex++;
203
+ }, AUDIO_FRAME_DURATION_MS);
204
+ }
205
+
206
+ /**
207
+ * Stops any ongoing audio streaming
208
+ */
209
+ stopStreaming() {
210
+ if (this.audioStreamingTimer) {
211
+ clearInterval(this.audioStreamingTimer);
212
+ this.audioStreamingTimer = null;
213
+ }
214
+ }
215
+ }
216
+
217
+ module.exports = {
218
+ VoIPMediaEngine,
219
+ RTP_PAYLOAD_TYPE_OPUS,
220
+ RTP_PAYLOAD_TYPE_H264,
221
+ AUDIO_SAMPLE_RATE_16K,
222
+ AUDIO_FRAME_DURATION_MS,
223
+ SAMPLES_PER_FRAME_16K,
224
+ };