@areumtecnologia/wasockets 2.0.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1987 -1649
- package/lib/Client/Builders/Buttons.js +127 -0
- package/lib/Client/Builders/EventBuilder.js +122 -0
- package/lib/Client/Builders/InteractiveMessage.js +150 -0
- package/lib/Client/Builders/Lists.js +78 -0
- package/lib/Client/Builders/PollBuilder.js +84 -0
- package/lib/Client/Builders/index.js +21 -0
- package/lib/Client/Constants.js +54 -0
- package/lib/Client/Context/CallContext.js +96 -0
- package/lib/Client/Context/MediaObject.js +155 -0
- package/lib/Client/Context/MessageContext.js +146 -0
- package/lib/Client/Modules/BusinessManager.js +155 -0
- package/lib/Client/Modules/ChannelManager.js +144 -0
- package/lib/Client/Modules/ChatManager.js +113 -0
- package/lib/Client/Modules/CommunityManager.js +136 -0
- package/lib/Client/Modules/GroupManager.js +194 -0
- package/lib/Client/Modules/LabelManager.js +123 -0
- package/lib/Client/Modules/MessageManager.js +198 -0
- package/lib/Client/Modules/PresenceManager.js +68 -0
- package/lib/Client/Modules/PrivacyManager.js +98 -0
- package/lib/Client/Modules/ProfileManager.js +92 -0
- package/lib/Client/Modules/UserManager.js +92 -0
- package/lib/Client/Modules/VoipManager.js +117 -0
- package/lib/Client/Modules/index.js +29 -0
- package/lib/Client/Normalizers/EventNormalizer.js +82 -0
- package/lib/Client/Normalizers/JidNormalizer.js +93 -0
- package/lib/Client/Normalizers/MessageNormalizer.js +134 -0
- package/lib/Client/WaClient.js +438 -0
- package/lib/Client/index.d.ts +464 -0
- package/lib/Client/index.js +76 -0
- package/lib/Defaults/baileys-version.json +1 -1
- package/lib/Defaults/connection.js +14 -1
- package/lib/Socket/index.js +51 -48
- package/lib/Socket/messages-recv.js +74 -13
- package/lib/Socket/messages-send.js +50 -25
- package/lib/Socket/socket.js +13 -0
- package/lib/Socket/voip.js +123 -0
- package/lib/Store/make-cache-manager-store.js +14 -5
- package/lib/Types/index.d.ts +4 -0
- package/lib/Utils/auth-utils.js +16 -1
- package/lib/Utils/browser-utils.js +6 -2
- package/lib/Utils/generics.js +47 -8
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/messages-media.js +44 -21
- package/lib/Utils/pre-key-manager.js +16 -1
- package/lib/Utils/validate-connection.js +10 -11
- package/lib/VoIP/VoIPCrypto.js +150 -0
- package/lib/VoIP/VoIPManager.js +469 -0
- package/lib/VoIP/VoIPMediaEngine.js +224 -0
- package/lib/VoIP/VoIPProtocol.js +354 -0
- package/lib/VoIP/VoIPRelayTransport.js +173 -0
- package/lib/VoIP/VoIPSession.js +314 -0
- package/lib/VoIP/assets/wasm/loader.js +5 -0
- package/lib/VoIP/assets/wasm/whatsapp.wasm +0 -0
- package/lib/VoIP/assets/wasm/worker-modules.js +305 -0
- package/lib/VoIP/engine/audio-feeder.d.mts +15 -0
- package/lib/VoIP/engine/audio-feeder.mjs +243 -0
- package/lib/VoIP/engine/index.d.mts +91 -0
- package/lib/VoIP/engine/index.mjs +1683 -0
- package/lib/VoIP/engine/relay-transport.d.mts +43 -0
- package/lib/VoIP/engine/relay-transport.mjs +607 -0
- package/lib/VoIP/engine/signaling.d.mts +44 -0
- package/lib/VoIP/engine/signaling.mjs +893 -0
- package/lib/VoIP/engine/types.d.mts +183 -0
- package/lib/VoIP/engine/types.mjs +17 -0
- package/lib/VoIP/engine/wasm-engine.d.mts +103 -0
- package/lib/VoIP/engine/wasm-engine.mjs +1397 -0
- package/lib/VoIP/engine/worker-bootstrap.mjs +1049 -0
- package/lib/VoIP/index.js +51 -0
- package/lib/VoIP/utils/audio.d.ts +28 -0
- package/lib/VoIP/utils/audio.js +148 -0
- package/lib/WABinary/generic-utils.js +22 -6
- package/lib/index.d.ts +382 -274
- package/lib/index.js +5 -0
- package/package.json +51 -49
|
@@ -0,0 +1,1683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* baileys-caller — WhatsApp voice calling for Node.js.
|
|
3
|
+
*
|
|
4
|
+
* Wraps WhatsApp Web's official VoIP WASM stack and routes signaling through
|
|
5
|
+
* Baileys. Public surface:
|
|
6
|
+
*
|
|
7
|
+
* const client = new VoipClient({ authDir })
|
|
8
|
+
* await client.connect()
|
|
9
|
+
* const call = await client.call("12345678901", { audioSource: "./hi.mp3" })
|
|
10
|
+
*
|
|
11
|
+
* @author ShellTear
|
|
12
|
+
*/
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
import { randomBytes, createHmac } from "node:crypto";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { Readable } from "node:stream";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { WasmEngine } from "./wasm-engine.mjs";
|
|
20
|
+
import { RelayRtcTransport } from "./relay-transport.mjs";
|
|
21
|
+
import { SignalingBridge } from "./signaling.mjs";
|
|
22
|
+
import { AudioFeeder } from "./audio-feeder.mjs";
|
|
23
|
+
import { CallState } from "./types.mjs";
|
|
24
|
+
export { CallState } from "./types.mjs";
|
|
25
|
+
const SHA256_LEN = 32;
|
|
26
|
+
let _baileysModule = null;
|
|
27
|
+
const loadBaileys = async () => {
|
|
28
|
+
if (_baileysModule) return _baileysModule;
|
|
29
|
+
const candidates = [
|
|
30
|
+
"../../index.js",
|
|
31
|
+
"../../../lib/index.js",
|
|
32
|
+
"../../../../lib/index.js",
|
|
33
|
+
"@areumtecnologia/wasockets",
|
|
34
|
+
"@whiskeysockets/baileys"
|
|
35
|
+
];
|
|
36
|
+
for (const c of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
const mod = await import(c);
|
|
39
|
+
if (mod.useMultiFileAuthState || mod.default?.useMultiFileAuthState) {
|
|
40
|
+
_baileysModule = {
|
|
41
|
+
...mod,
|
|
42
|
+
...(mod.default || {})
|
|
43
|
+
};
|
|
44
|
+
return _baileysModule;
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
return _baileysModule;
|
|
49
|
+
};
|
|
50
|
+
const toBareJid = (jid) => {
|
|
51
|
+
if (!jid)
|
|
52
|
+
return jid;
|
|
53
|
+
const at = jid.indexOf("@");
|
|
54
|
+
if (at < 0)
|
|
55
|
+
return jid;
|
|
56
|
+
const user = jid.slice(0, at).split(":")[0];
|
|
57
|
+
return `${user}@${jid.slice(at + 1)}`;
|
|
58
|
+
};
|
|
59
|
+
const computeHkdf = (key, salt, info, length) => {
|
|
60
|
+
const effectiveSalt = salt && salt.length > 0 ? Buffer.from(salt) : Buffer.alloc(SHA256_LEN, 0);
|
|
61
|
+
const prk = createHmac("sha256", effectiveSalt).update(key).digest();
|
|
62
|
+
const blocks = Math.ceil(length / SHA256_LEN);
|
|
63
|
+
const okm = Buffer.alloc(blocks * SHA256_LEN);
|
|
64
|
+
let prev = Buffer.alloc(0);
|
|
65
|
+
for (let i = 1; i <= blocks; i += 1) {
|
|
66
|
+
prev = createHmac("sha256", prk)
|
|
67
|
+
.update(prev)
|
|
68
|
+
.update(info)
|
|
69
|
+
.update(Buffer.from([i]))
|
|
70
|
+
.digest();
|
|
71
|
+
prev.copy(okm, (i - 1) * SHA256_LEN);
|
|
72
|
+
}
|
|
73
|
+
return new Uint8Array(okm.buffer, okm.byteOffset, length);
|
|
74
|
+
};
|
|
75
|
+
const computeHmacSha256 = (data, key) => {
|
|
76
|
+
const result = createHmac("sha256", Buffer.from(key)).update(data).digest();
|
|
77
|
+
return new Uint8Array(result.buffer, result.byteOffset, result.byteLength);
|
|
78
|
+
};
|
|
79
|
+
const isCallReceiptNode = (node) => {
|
|
80
|
+
if (node?.tag !== "receipt")
|
|
81
|
+
return false;
|
|
82
|
+
const child = Array.isArray(node.content) ? node.content[0] : null;
|
|
83
|
+
return !!(child?.attrs?.["call-id"] || child?.attrs?.call_id);
|
|
84
|
+
};
|
|
85
|
+
function convertAndResampleAudio(input, options = {}) {
|
|
86
|
+
const fromRate = Number(options.sampleRate) || 16000;
|
|
87
|
+
const toRate = 16000;
|
|
88
|
+
|
|
89
|
+
let float32;
|
|
90
|
+
if (input instanceof Float32Array) {
|
|
91
|
+
float32 = input;
|
|
92
|
+
} else if (Buffer.isBuffer(input)) {
|
|
93
|
+
const numSamples = Math.floor(input.length / 2);
|
|
94
|
+
float32 = new Float32Array(numSamples);
|
|
95
|
+
for (let i = 0; i < numSamples; i++) {
|
|
96
|
+
const val = input.readInt16LE(i * 2);
|
|
97
|
+
float32[i] = val < 0 ? val / 32768 : val / 32767;
|
|
98
|
+
}
|
|
99
|
+
} else if (input instanceof Int16Array) {
|
|
100
|
+
float32 = new Float32Array(input.length);
|
|
101
|
+
for (let i = 0; i < input.length; i++) {
|
|
102
|
+
float32[i] = input[i] < 0 ? input[i] / 32768 : input[i] / 32767;
|
|
103
|
+
}
|
|
104
|
+
} else if (Array.isArray(input)) {
|
|
105
|
+
float32 = new Float32Array(input);
|
|
106
|
+
} else {
|
|
107
|
+
throw new Error("Formato de áudio não suportado em pushAudio. Esperado Float32Array, Int16Array ou Buffer PCM.");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (fromRate === toRate) {
|
|
111
|
+
return float32;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const ratio = fromRate / toRate;
|
|
115
|
+
const outLength = Math.round(float32.length / ratio);
|
|
116
|
+
const out = new Float32Array(outLength);
|
|
117
|
+
for (let i = 0; i < outLength; i++) {
|
|
118
|
+
const srcPos = i * ratio;
|
|
119
|
+
const index = Math.floor(srcPos);
|
|
120
|
+
const frac = srcPos - index;
|
|
121
|
+
const s1 = float32[index] || 0;
|
|
122
|
+
const s2 = float32[index + 1] !== undefined ? float32[index + 1] : s1;
|
|
123
|
+
out[i] = s1 + frac * (s2 - s1);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function rgbaToI420(rgbaBuffer, width, height) {
|
|
129
|
+
const ySize = width * height;
|
|
130
|
+
const uvWidth = width >> 1;
|
|
131
|
+
const uvHeight = height >> 1;
|
|
132
|
+
const uvSize = uvWidth * uvHeight;
|
|
133
|
+
const out = Buffer.alloc(ySize + uvSize * 2);
|
|
134
|
+
|
|
135
|
+
let uIndex = ySize;
|
|
136
|
+
let vIndex = ySize + uvSize;
|
|
137
|
+
|
|
138
|
+
for (let y = 0; y < height; y++) {
|
|
139
|
+
const rowOffset = y * width;
|
|
140
|
+
const isEvenRow = (y & 1) === 0;
|
|
141
|
+
for (let x = 0; x < width; x++) {
|
|
142
|
+
const pixelIndex = (rowOffset + x) << 2;
|
|
143
|
+
const r = rgbaBuffer[pixelIndex];
|
|
144
|
+
const g = rgbaBuffer[pixelIndex + 1];
|
|
145
|
+
const b = rgbaBuffer[pixelIndex + 2];
|
|
146
|
+
|
|
147
|
+
out[rowOffset + x] = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
|
|
148
|
+
|
|
149
|
+
if (isEvenRow && ((x & 1) === 0)) {
|
|
150
|
+
out[uIndex++] = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
|
|
151
|
+
out[vIndex++] = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
class WavFileWriter {
|
|
159
|
+
filePath;
|
|
160
|
+
channels;
|
|
161
|
+
sampleRate;
|
|
162
|
+
bytesWritten = 0;
|
|
163
|
+
fd = null;
|
|
164
|
+
source = "mixed";
|
|
165
|
+
|
|
166
|
+
constructor(filePath, channels = 1, sampleRate = 16000) {
|
|
167
|
+
this.filePath = filePath;
|
|
168
|
+
this.channels = channels;
|
|
169
|
+
this.sampleRate = sampleRate;
|
|
170
|
+
try {
|
|
171
|
+
this.fd = fs.openSync(filePath, "w");
|
|
172
|
+
fs.writeSync(this.fd, Buffer.alloc(44, 0), 0, 44, 0);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
this.fd = null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
writeMono(pcmFloat32) {
|
|
179
|
+
if (!this.fd || !pcmFloat32) return;
|
|
180
|
+
try {
|
|
181
|
+
const buf = Buffer.alloc(pcmFloat32.length * 2);
|
|
182
|
+
for (let i = 0; i < pcmFloat32.length; i++) {
|
|
183
|
+
const s = Math.max(-1, Math.min(1, pcmFloat32[i]));
|
|
184
|
+
buf.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, i * 2);
|
|
185
|
+
}
|
|
186
|
+
fs.writeSync(this.fd, buf);
|
|
187
|
+
this.bytesWritten += buf.byteLength;
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
writeStereo(rxFloat32, txFloat32) {
|
|
192
|
+
if (!this.fd) return;
|
|
193
|
+
try {
|
|
194
|
+
const len = Math.max(rxFloat32?.length || 0, txFloat32?.length || 0);
|
|
195
|
+
const buf = Buffer.alloc(len * 4);
|
|
196
|
+
for (let i = 0; i < len; i++) {
|
|
197
|
+
const rxSample = rxFloat32 && i < rxFloat32.length ? rxFloat32[i] : 0;
|
|
198
|
+
const txSample = txFloat32 && i < txFloat32.length ? txFloat32[i] : 0;
|
|
199
|
+
const sRx = Math.max(-1, Math.min(1, rxSample));
|
|
200
|
+
const sTx = Math.max(-1, Math.min(1, txSample));
|
|
201
|
+
buf.writeInt16LE(sRx < 0 ? sRx * 0x8000 : sRx * 0x7fff, i * 4);
|
|
202
|
+
buf.writeInt16LE(sTx < 0 ? sTx * 0x8000 : sTx * 0x7fff, i * 4 + 2);
|
|
203
|
+
}
|
|
204
|
+
fs.writeSync(this.fd, buf);
|
|
205
|
+
this.bytesWritten += buf.byteLength;
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
close() {
|
|
210
|
+
if (!this.fd) return;
|
|
211
|
+
try {
|
|
212
|
+
const header = Buffer.alloc(44);
|
|
213
|
+
const blockAlign = this.channels * 2;
|
|
214
|
+
const byteRate = this.sampleRate * blockAlign;
|
|
215
|
+
const dataLength = this.bytesWritten;
|
|
216
|
+
const riffLength = dataLength + 36;
|
|
217
|
+
|
|
218
|
+
header.write("RIFF", 0);
|
|
219
|
+
header.writeUInt32LE(riffLength, 4);
|
|
220
|
+
header.write("WAVE", 8);
|
|
221
|
+
header.write("fmt ", 12);
|
|
222
|
+
header.writeUInt32LE(16, 16);
|
|
223
|
+
header.writeUInt16LE(1, 20); // PCM format
|
|
224
|
+
header.writeUInt16LE(this.channels, 22);
|
|
225
|
+
header.writeUInt32LE(this.sampleRate, 24);
|
|
226
|
+
header.writeUInt32LE(byteRate, 28);
|
|
227
|
+
header.writeUInt16LE(blockAlign, 32);
|
|
228
|
+
header.writeUInt16LE(16, 34); // 16-bit
|
|
229
|
+
header.write("data", 36);
|
|
230
|
+
header.writeUInt32LE(dataLength, 40);
|
|
231
|
+
|
|
232
|
+
fs.writeSync(this.fd, header, 0, 44, 0);
|
|
233
|
+
fs.closeSync(this.fd);
|
|
234
|
+
} catch {}
|
|
235
|
+
this.fd = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A live or recently-ended call. */
|
|
240
|
+
export class ActiveCall extends EventEmitter {
|
|
241
|
+
callId;
|
|
242
|
+
engine;
|
|
243
|
+
isVideo;
|
|
244
|
+
peer = null;
|
|
245
|
+
caller = null;
|
|
246
|
+
direction = "outbound";
|
|
247
|
+
telemetry = null;
|
|
248
|
+
|
|
249
|
+
#state = CallState.Idle;
|
|
250
|
+
#endResolver;
|
|
251
|
+
#endPromise;
|
|
252
|
+
#endTimer = null;
|
|
253
|
+
#ended = false;
|
|
254
|
+
#feeder = null;
|
|
255
|
+
#relay = null;
|
|
256
|
+
|
|
257
|
+
// Timing
|
|
258
|
+
#offerTimestamp = Date.now();
|
|
259
|
+
#ringingTimestamp = null;
|
|
260
|
+
#connectedTimestamp = null;
|
|
261
|
+
#endedTimestamp = null;
|
|
262
|
+
|
|
263
|
+
// Audio & VAD metrics
|
|
264
|
+
#inputVolumeGain = 1.0;
|
|
265
|
+
#outputVolumeGain = 1.0;
|
|
266
|
+
#vadEnabled = true;
|
|
267
|
+
#vadThresholdDbfs = -38;
|
|
268
|
+
#vadSilenceTimeoutMs = 600;
|
|
269
|
+
#vadSpeechDebounceFrames = 2;
|
|
270
|
+
#consecutiveSpeechFrames = 0;
|
|
271
|
+
#isSpeaking = false;
|
|
272
|
+
#lastSpeechTime = 0;
|
|
273
|
+
|
|
274
|
+
#packetsReceived = 0;
|
|
275
|
+
#packetsSent = 0;
|
|
276
|
+
#bytesReceived = 0;
|
|
277
|
+
#bytesSent = 0;
|
|
278
|
+
#speechFrames = 0;
|
|
279
|
+
#silenceFrames = 0;
|
|
280
|
+
#sumVolumeDbfs = 0;
|
|
281
|
+
#peakVolumeDbfs = -96;
|
|
282
|
+
#lastTxChunk = null;
|
|
283
|
+
|
|
284
|
+
// Video controls
|
|
285
|
+
#videoPaused = false;
|
|
286
|
+
#videoFps = 15;
|
|
287
|
+
|
|
288
|
+
// Streams & Recording
|
|
289
|
+
#rxStreams = [];
|
|
290
|
+
#txStreams = [];
|
|
291
|
+
#recorder = null;
|
|
292
|
+
#statsInterval = null;
|
|
293
|
+
#lastStatsRxBytes = 0;
|
|
294
|
+
#lastStatsTxBytes = 0;
|
|
295
|
+
#lastStatsTime = Date.now();
|
|
296
|
+
|
|
297
|
+
/** @internal mirrors the source path for the audio feeder */
|
|
298
|
+
_audioSource = "silence";
|
|
299
|
+
|
|
300
|
+
constructor(callId, engine, durationMs, isVideo = false) {
|
|
301
|
+
super();
|
|
302
|
+
this.callId = callId;
|
|
303
|
+
this.engine = engine;
|
|
304
|
+
this.isVideo = isVideo;
|
|
305
|
+
this.#endPromise = new Promise((res) => { this.#endResolver = res; });
|
|
306
|
+
if (durationMs > 0) {
|
|
307
|
+
this.#endTimer = setTimeout(() => this.end(), durationMs);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
get state() { return this.#state; }
|
|
312
|
+
|
|
313
|
+
end = () => {
|
|
314
|
+
if (this.#ended)
|
|
315
|
+
return;
|
|
316
|
+
this.#ended = true;
|
|
317
|
+
if (this.#endTimer) {
|
|
318
|
+
clearTimeout(this.#endTimer);
|
|
319
|
+
this.#endTimer = null;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
this.engine.endCall(0, true);
|
|
323
|
+
}
|
|
324
|
+
catch { }
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
mute = (muted) => {
|
|
328
|
+
try {
|
|
329
|
+
this.engine.setMute(muted);
|
|
330
|
+
}
|
|
331
|
+
catch { }
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
waitForEnd = () => this.#endPromise;
|
|
335
|
+
|
|
336
|
+
/** @internal — called by VoipClient on WASM call-state change */
|
|
337
|
+
_updateState = (state) => {
|
|
338
|
+
this.#state = state;
|
|
339
|
+
if (state === CallState.PreacceptReceived) {
|
|
340
|
+
this.#ringingTimestamp = Date.now();
|
|
341
|
+
this.emit("ringing");
|
|
342
|
+
} else if (state === CallState.AcceptReceived || state === CallState.Active) {
|
|
343
|
+
if (!this.#connectedTimestamp) {
|
|
344
|
+
this.#connectedTimestamp = Date.now();
|
|
345
|
+
this.emit("connected");
|
|
346
|
+
}
|
|
347
|
+
if (!this.#statsInterval) {
|
|
348
|
+
this.#statsInterval = setInterval(() => {
|
|
349
|
+
if (this.#state !== CallState.Active && this.#state !== CallState.AcceptReceived) return;
|
|
350
|
+
const stats = this.getStats();
|
|
351
|
+
this.emit("stats", stats);
|
|
352
|
+
}, 1000);
|
|
353
|
+
this.#statsInterval.unref?.();
|
|
354
|
+
}
|
|
355
|
+
} else if (state === CallState.Idle || state === CallState.Ending) {
|
|
356
|
+
this._forceEnd("ended");
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
pushAudio = (audioData, options = {}) => {
|
|
361
|
+
const float32 = convertAndResampleAudio(audioData, options);
|
|
362
|
+
this.emit("audio_tx", float32);
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
pushVideoRgba = (rgbaBuffer, width, height) => {
|
|
366
|
+
const i420 = rgbaToI420(rgbaBuffer, width, height);
|
|
367
|
+
this.pushVideo(i420, width, height);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
pushVideo = (frameBuffer, width, height) => {
|
|
371
|
+
this.emit("video_tx", { frameBuffer, width, height });
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
#screenShareProcess = null;
|
|
375
|
+
|
|
376
|
+
get isSharingScreen() {
|
|
377
|
+
return Boolean(this.#screenShareProcess && !this.#screenShareProcess.killed);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Start transmitting the local desktop screen over video uplink in real-time
|
|
382
|
+
* @param {Object} [options]
|
|
383
|
+
* @param {number} [options.fps=15] Frame rate (10-30 FPS)
|
|
384
|
+
* @param {number} [options.width=640] Frame width
|
|
385
|
+
* @param {number} [options.height=360] Frame height
|
|
386
|
+
* @param {string} [options.screen] Optional display target (e.g. desktop)
|
|
387
|
+
*/
|
|
388
|
+
startScreenShare = (options = {}) => {
|
|
389
|
+
if (this.#ended) {
|
|
390
|
+
throw new Error("Cannot start screen share on an ended call.");
|
|
391
|
+
}
|
|
392
|
+
if (this.isSharingScreen) {
|
|
393
|
+
this.stopScreenShare();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const fps = options.fps ?? 15;
|
|
397
|
+
const width = options.width ?? 640;
|
|
398
|
+
const height = options.height ?? 360;
|
|
399
|
+
const frameSize = width * height * 4; // RGBA = 4 bytes per pixel
|
|
400
|
+
|
|
401
|
+
let inputArgs = [];
|
|
402
|
+
const platform = process.platform;
|
|
403
|
+
if (platform === "win32") {
|
|
404
|
+
inputArgs = ["-f", "gdigrab", "-framerate", String(fps), "-i", options.screen || "desktop"];
|
|
405
|
+
} else if (platform === "darwin") {
|
|
406
|
+
inputArgs = ["-f", "avfoundation", "-framerate", String(fps), "-i", options.screen || "1:none"];
|
|
407
|
+
} else {
|
|
408
|
+
const display = options.screen || process.env.DISPLAY || ":0.0";
|
|
409
|
+
inputArgs = ["-f", "x11grab", "-framerate", String(fps), "-i", display];
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const args = [
|
|
413
|
+
...inputArgs,
|
|
414
|
+
"-vf", `scale=${width}:${height}`,
|
|
415
|
+
"-f", "rawvideo",
|
|
416
|
+
"-pix_fmt", "rgba",
|
|
417
|
+
"pipe:1"
|
|
418
|
+
];
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const child = spawn("ffmpeg", args, {
|
|
422
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
423
|
+
windowsHide: true
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
this.#screenShareProcess = child;
|
|
427
|
+
let accumulator = Buffer.alloc(0);
|
|
428
|
+
|
|
429
|
+
child.stdout?.on?.("data", (chunk) => {
|
|
430
|
+
if (this.#screenShareProcess !== child || this.#ended) return;
|
|
431
|
+
accumulator = Buffer.concat([accumulator, chunk]);
|
|
432
|
+
|
|
433
|
+
while (accumulator.length >= frameSize) {
|
|
434
|
+
const frameRgba = accumulator.subarray(0, frameSize);
|
|
435
|
+
accumulator = accumulator.subarray(frameSize);
|
|
436
|
+
this.pushVideoRgba(frameRgba, width, height);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
child.on?.("error", (err) => {
|
|
441
|
+
if (this.#screenShareProcess === child) {
|
|
442
|
+
this.#screenShareProcess = null;
|
|
443
|
+
}
|
|
444
|
+
this.emit("screenshare:error", err);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
child.on?.("exit", (code) => {
|
|
448
|
+
if (this.#screenShareProcess === child) {
|
|
449
|
+
this.#screenShareProcess = null;
|
|
450
|
+
this.emit("screenshare:stopped", code);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
this.emit("screenshare:started", { width, height, fps });
|
|
455
|
+
return child;
|
|
456
|
+
} catch (err) {
|
|
457
|
+
this.#screenShareProcess = null;
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Stop transmitting desktop screen
|
|
464
|
+
*/
|
|
465
|
+
stopScreenShare = () => {
|
|
466
|
+
if (this.#screenShareProcess) {
|
|
467
|
+
const p = this.#screenShareProcess;
|
|
468
|
+
this.#screenShareProcess = null;
|
|
469
|
+
try {
|
|
470
|
+
p.kill("SIGINT");
|
|
471
|
+
} catch {}
|
|
472
|
+
this.emit("screenshare:stopped", 0);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/** @internal */
|
|
477
|
+
_setFeeder = (feeder) => {
|
|
478
|
+
this.#feeder = feeder;
|
|
479
|
+
if (this.#outputVolumeGain !== 1.0) {
|
|
480
|
+
feeder?.setVolume?.(this.#outputVolumeGain);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/** @internal */
|
|
485
|
+
_setRelay = (relay) => {
|
|
486
|
+
this.#relay = relay;
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
/** @internal */
|
|
490
|
+
_emitVideo = (data) => { this.emit("video", data); };
|
|
491
|
+
|
|
492
|
+
/** @internal */
|
|
493
|
+
_emitVideoOut = (data) => {
|
|
494
|
+
this.emit("video_out", data);
|
|
495
|
+
this.emit("video:out", data);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/** @internal */
|
|
499
|
+
_emitAudio = (pcm) => {
|
|
500
|
+
this.#packetsReceived++;
|
|
501
|
+
this.#bytesReceived += pcm.byteLength;
|
|
502
|
+
|
|
503
|
+
let effectivePcm = pcm;
|
|
504
|
+
if (this.#inputVolumeGain !== 1.0) {
|
|
505
|
+
effectivePcm = new Float32Array(pcm.length);
|
|
506
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
507
|
+
effectivePcm[i] = Math.max(-1, Math.min(1, pcm[i] * this.#inputVolumeGain));
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Calculate RMS and volume in dBFS
|
|
512
|
+
let sumSquares = 0;
|
|
513
|
+
for (let i = 0; i < effectivePcm.length; i++) {
|
|
514
|
+
sumSquares += effectivePcm[i] * effectivePcm[i];
|
|
515
|
+
}
|
|
516
|
+
const rms = Math.sqrt(sumSquares / effectivePcm.length);
|
|
517
|
+
const dbfs = rms > 1e-5 ? 20 * Math.log10(rms) : -96;
|
|
518
|
+
|
|
519
|
+
if (this.#vadEnabled) {
|
|
520
|
+
if (dbfs >= this.#vadThresholdDbfs) {
|
|
521
|
+
this.#speechFrames++;
|
|
522
|
+
this.#sumVolumeDbfs += dbfs;
|
|
523
|
+
if (dbfs > this.#peakVolumeDbfs) this.#peakVolumeDbfs = dbfs;
|
|
524
|
+
|
|
525
|
+
this.#consecutiveSpeechFrames++;
|
|
526
|
+
this.#lastSpeechTime = Date.now();
|
|
527
|
+
|
|
528
|
+
if (this.#consecutiveSpeechFrames >= this.#vadSpeechDebounceFrames && !this.#isSpeaking) {
|
|
529
|
+
this.#isSpeaking = true;
|
|
530
|
+
this.emit("speaking", { volumeDbfs: Number(dbfs.toFixed(1)), rms });
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
this.#silenceFrames++;
|
|
534
|
+
this.#consecutiveSpeechFrames = 0;
|
|
535
|
+
|
|
536
|
+
if (this.#isSpeaking && (Date.now() - this.#lastSpeechTime >= this.#vadSilenceTimeoutMs)) {
|
|
537
|
+
this.#isSpeaking = false;
|
|
538
|
+
this.emit("silence", { durationMs: Date.now() - this.#lastSpeechTime });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Deliver to Rx streams
|
|
544
|
+
if (this.#rxStreams.length > 0) {
|
|
545
|
+
for (const item of this.#rxStreams) {
|
|
546
|
+
if (item.format === "int16") {
|
|
547
|
+
const buf = Buffer.alloc(effectivePcm.length * 2);
|
|
548
|
+
for (let i = 0; i < effectivePcm.length; i++) {
|
|
549
|
+
const s = Math.max(-1, Math.min(1, effectivePcm[i]));
|
|
550
|
+
buf.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, i * 2);
|
|
551
|
+
}
|
|
552
|
+
item.stream.push(buf);
|
|
553
|
+
} else {
|
|
554
|
+
item.stream.push(Buffer.from(effectivePcm.buffer, effectivePcm.byteOffset, effectivePcm.byteLength));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Deliver to file recorder
|
|
560
|
+
if (this.#recorder) {
|
|
561
|
+
if (this.#recorder.channels === 2) {
|
|
562
|
+
this.#recorder.writeStereo(effectivePcm, this.#lastTxChunk);
|
|
563
|
+
} else {
|
|
564
|
+
if (this.#recorder.source === "rx" || this.#recorder.source === "mixed") {
|
|
565
|
+
this.#recorder.writeMono(effectivePcm);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
this.emit("audio", effectivePcm);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/** @internal */
|
|
574
|
+
_emitAudioOut = (pcm) => {
|
|
575
|
+
this.#packetsSent++;
|
|
576
|
+
this.#bytesSent += pcm.byteLength;
|
|
577
|
+
this.#lastTxChunk = pcm;
|
|
578
|
+
|
|
579
|
+
// Deliver to Tx streams
|
|
580
|
+
if (this.#txStreams.length > 0) {
|
|
581
|
+
for (const item of this.#txStreams) {
|
|
582
|
+
if (item.format === "int16") {
|
|
583
|
+
const buf = Buffer.alloc(pcm.length * 2);
|
|
584
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
585
|
+
const s = Math.max(-1, Math.min(1, pcm[i]));
|
|
586
|
+
buf.writeInt16LE(s < 0 ? s * 0x8000 : s * 0x7fff, i * 2);
|
|
587
|
+
}
|
|
588
|
+
item.stream.push(buf);
|
|
589
|
+
} else {
|
|
590
|
+
item.stream.push(Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength));
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Deliver to file recorder if mono and source is tx
|
|
596
|
+
if (this.#recorder && this.#recorder.channels === 1 && this.#recorder.source === "tx") {
|
|
597
|
+
this.#recorder.writeMono(pcm);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
this.emit("audio_out", pcm);
|
|
601
|
+
this.emit("audio:out", pcm);
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
/** @internal */
|
|
605
|
+
_forceEnd = (reason) => {
|
|
606
|
+
if (this.#ended)
|
|
607
|
+
return;
|
|
608
|
+
this.#ended = true;
|
|
609
|
+
this.#endedTimestamp = Date.now();
|
|
610
|
+
|
|
611
|
+
if (this.#endTimer) {
|
|
612
|
+
clearTimeout(this.#endTimer);
|
|
613
|
+
this.#endTimer = null;
|
|
614
|
+
}
|
|
615
|
+
if (this.#statsInterval) {
|
|
616
|
+
clearInterval(this.#statsInterval);
|
|
617
|
+
this.#statsInterval = null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
this.stopRecording();
|
|
621
|
+
this.stopScreenShare();
|
|
622
|
+
|
|
623
|
+
for (const item of this.#rxStreams) item.stream.push(null);
|
|
624
|
+
for (const item of this.#txStreams) item.stream.push(null);
|
|
625
|
+
this.#rxStreams = [];
|
|
626
|
+
this.#txStreams = [];
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
this.engine.endCall(0, false);
|
|
630
|
+
} catch {}
|
|
631
|
+
|
|
632
|
+
const telemetry = this.#buildTelemetry(reason);
|
|
633
|
+
this.telemetry = telemetry;
|
|
634
|
+
this.emit("telemetry", telemetry);
|
|
635
|
+
|
|
636
|
+
this.emit("ended", reason);
|
|
637
|
+
this.#endResolver(reason);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
// ── Streams & Recording ───────────────────────────────────────────────────
|
|
641
|
+
getRxStream = (options = {}) => {
|
|
642
|
+
const format = options.format || "int16";
|
|
643
|
+
const stream = new Readable({ read() {} });
|
|
644
|
+
this.#rxStreams.push({ stream, format });
|
|
645
|
+
return stream;
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
getTxStream = (options = {}) => {
|
|
649
|
+
const format = options.format || "int16";
|
|
650
|
+
const stream = new Readable({ read() {} });
|
|
651
|
+
this.#txStreams.push({ stream, format });
|
|
652
|
+
return stream;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
recordToFile = (filePath, options = {}) => {
|
|
656
|
+
if (this.#recorder) {
|
|
657
|
+
this.#recorder.close();
|
|
658
|
+
}
|
|
659
|
+
const channels = options.channels || 1;
|
|
660
|
+
const sampleRate = options.sampleRate || 16000;
|
|
661
|
+
this.#recorder = new WavFileWriter(filePath, channels, sampleRate);
|
|
662
|
+
this.#recorder.source = options.source || "mixed";
|
|
663
|
+
return this.#recorder;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
stopRecording = () => {
|
|
667
|
+
if (this.#recorder) {
|
|
668
|
+
this.#recorder.close();
|
|
669
|
+
this.#recorder = null;
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
// ── Controls ─────────────────────────────────────────────────────────────
|
|
674
|
+
setInputVolume = (gain) => {
|
|
675
|
+
this.#inputVolumeGain = Math.max(0, Number(gain) || 1.0);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
setOutputVolume = (gain) => {
|
|
679
|
+
this.#outputVolumeGain = Math.max(0, Number(gain) || 1.0);
|
|
680
|
+
this.#feeder?.setVolume?.(this.#outputVolumeGain);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
pauseAudio = () => {
|
|
684
|
+
this.#feeder?.pause?.();
|
|
685
|
+
};
|
|
686
|
+
|
|
687
|
+
resumeAudio = () => {
|
|
688
|
+
this.#feeder?.resume?.();
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
playFile = (filePath) => {
|
|
692
|
+
this._audioSource = filePath;
|
|
693
|
+
this.#feeder?.playFile?.(filePath);
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
pauseVideo = () => {
|
|
697
|
+
this.#videoPaused = true;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
resumeVideo = () => {
|
|
701
|
+
this.#videoPaused = false;
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
get isVideoPaused() {
|
|
705
|
+
return this.#videoPaused;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
setVideoFps = (fps) => {
|
|
709
|
+
this.#videoFps = Math.max(1, Math.min(30, Number(fps) || 15));
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
setVadOptions = (options = {}) => {
|
|
713
|
+
if (options.thresholdDbfs !== undefined) this.#vadThresholdDbfs = Number(options.thresholdDbfs);
|
|
714
|
+
if (options.silenceTimeoutMs !== undefined) this.#vadSilenceTimeoutMs = Number(options.silenceTimeoutMs);
|
|
715
|
+
if (options.speechDebounceFrames !== undefined) this.#vadSpeechDebounceFrames = Number(options.speechDebounceFrames);
|
|
716
|
+
if (options.enabled !== undefined) this.#vadEnabled = !!options.enabled;
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
getStats = () => {
|
|
720
|
+
const now = Date.now();
|
|
721
|
+
const deltaSeconds = Math.max(0.1, (now - this.#lastStatsTime) / 1000);
|
|
722
|
+
const relayStats = this.#relay?.getStats?.() || {};
|
|
723
|
+
const activeRelay = relayStats.connections?.find?.(c => c.state === "open") || relayStats.connections?.[0];
|
|
724
|
+
|
|
725
|
+
const currentRxBytes = relayStats.receivedBytes || this.#bytesReceived;
|
|
726
|
+
const currentTxBytes = relayStats.sentBytes || this.#bytesSent;
|
|
727
|
+
|
|
728
|
+
const downlinkBps = Math.round(((currentRxBytes - this.#lastStatsRxBytes) * 8) / deltaSeconds);
|
|
729
|
+
const uplinkBps = Math.round(((currentTxBytes - this.#lastStatsTxBytes) * 8) / deltaSeconds);
|
|
730
|
+
|
|
731
|
+
this.#lastStatsRxBytes = currentRxBytes;
|
|
732
|
+
this.#lastStatsTxBytes = currentTxBytes;
|
|
733
|
+
this.#lastStatsTime = now;
|
|
734
|
+
|
|
735
|
+
const connTs = this.#connectedTimestamp;
|
|
736
|
+
const talkDurationSeconds = connTs ? Math.max(0, Math.round((now - connTs) / 1000)) : 0;
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
callId: this.callId,
|
|
740
|
+
state: this.#state,
|
|
741
|
+
talkDurationSeconds,
|
|
742
|
+
rttMs: activeRelay?.rttMs || null,
|
|
743
|
+
packetsSent: this.#packetsSent,
|
|
744
|
+
packetsReceived: this.#packetsReceived,
|
|
745
|
+
droppedPackets: relayStats.droppedPackets || 0,
|
|
746
|
+
bytesSent: currentTxBytes,
|
|
747
|
+
bytesReceived: currentRxBytes,
|
|
748
|
+
uplinkBps,
|
|
749
|
+
downlinkBps,
|
|
750
|
+
isSpeaking: this.#isSpeaking,
|
|
751
|
+
audioFeeder: this.#feeder?.getStats?.() || null
|
|
752
|
+
};
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
getTelemetry = () => {
|
|
756
|
+
return this.telemetry || this.#buildTelemetry("in_progress");
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
#buildTelemetry = (endReason) => {
|
|
760
|
+
const now = this.#endedTimestamp || Date.now();
|
|
761
|
+
const offerTs = this.#offerTimestamp;
|
|
762
|
+
const ringTs = this.#ringingTimestamp;
|
|
763
|
+
const connTs = this.#connectedTimestamp;
|
|
764
|
+
const timeToRingMs = ringTs ? ringTs - offerTs : null;
|
|
765
|
+
const timeToAnswerMs = connTs ? connTs - offerTs : null;
|
|
766
|
+
const talkDurationSeconds = connTs ? Math.max(0, Math.round((now - connTs) / 1000)) : 0;
|
|
767
|
+
const totalDurationSeconds = Math.max(0, Math.round((now - offerTs) / 1000));
|
|
768
|
+
|
|
769
|
+
const totalAudioFrames = this.#speechFrames + this.#silenceFrames;
|
|
770
|
+
const speechRatio = totalAudioFrames > 0 ? Number((this.#speechFrames / totalAudioFrames).toFixed(2)) : 0;
|
|
771
|
+
const avgVolumeDbfs = this.#speechFrames > 0 ? Number((this.#sumVolumeDbfs / this.#speechFrames).toFixed(1)) : -96;
|
|
772
|
+
const peakVolumeDbfs = this.#peakVolumeDbfs > -96 ? Number(this.#peakVolumeDbfs.toFixed(1)) : -96;
|
|
773
|
+
|
|
774
|
+
const relayStats = this.#relay?.getStats?.() || {};
|
|
775
|
+
const activeRelay = relayStats.connections?.find?.(c => c.state === "open") || relayStats.connections?.[0];
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
callId: this.callId,
|
|
779
|
+
direction: this.direction,
|
|
780
|
+
peer: this.peer || { phoneNumber: "", pnJid: "", lidJid: "" },
|
|
781
|
+
caller: this.caller || { pnJid: "", lidJid: "", displayName: "" },
|
|
782
|
+
timing: {
|
|
783
|
+
offerTimestamp: offerTs,
|
|
784
|
+
ringingTimestamp: ringTs,
|
|
785
|
+
connectedTimestamp: connTs,
|
|
786
|
+
endedTimestamp: now,
|
|
787
|
+
timeToRingMs,
|
|
788
|
+
timeToAnswerMs,
|
|
789
|
+
talkDurationSeconds,
|
|
790
|
+
totalDurationSeconds
|
|
791
|
+
},
|
|
792
|
+
status: {
|
|
793
|
+
finalState: "ended",
|
|
794
|
+
endReason: endReason || "hangup",
|
|
795
|
+
answered: !!connTs
|
|
796
|
+
},
|
|
797
|
+
audioMetrics: {
|
|
798
|
+
sampleRate: 16000,
|
|
799
|
+
channels: 1,
|
|
800
|
+
frameDurationMs: 20,
|
|
801
|
+
samplesPerChunk: 320,
|
|
802
|
+
packetsReceived: this.#packetsReceived,
|
|
803
|
+
packetsSent: this.#packetsSent,
|
|
804
|
+
bytesReceived: this.#bytesReceived,
|
|
805
|
+
bytesSent: this.#bytesSent,
|
|
806
|
+
voiceActivity: {
|
|
807
|
+
avgVolumeDbfs,
|
|
808
|
+
peakVolumeDbfs,
|
|
809
|
+
speechFrames: this.#speechFrames,
|
|
810
|
+
silenceFrames: this.#silenceFrames,
|
|
811
|
+
speechRatio
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
networkMetrics: {
|
|
815
|
+
transport: "TURN/STUN Relay",
|
|
816
|
+
relayServer: activeRelay?.relay || "edgeray.wt.whatsapp.com",
|
|
817
|
+
iceRttMs: activeRelay?.rttMs || null,
|
|
818
|
+
droppedPackets: relayStats.droppedPackets || 0,
|
|
819
|
+
bytesReceived: relayStats.receivedBytes || this.#bytesReceived,
|
|
820
|
+
bytesSent: relayStats.sentBytes || this.#bytesSent
|
|
821
|
+
}
|
|
822
|
+
};
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
/** Top-level client. Connects to WhatsApp and lets you place calls. */
|
|
826
|
+
export class VoipClient {
|
|
827
|
+
#config;
|
|
828
|
+
#engine = null;
|
|
829
|
+
#relay = null;
|
|
830
|
+
#signaling = null;
|
|
831
|
+
#sock = null;
|
|
832
|
+
#activeCall = null;
|
|
833
|
+
#baileys = null;
|
|
834
|
+
// Capture state populated when WASM negotiates audio params
|
|
835
|
+
#capturePtr = 0;
|
|
836
|
+
#captureChunkBytes = 0;
|
|
837
|
+
#captureSampleRate = 16000;
|
|
838
|
+
#captureChannels = 1;
|
|
839
|
+
#captureFramesPerChunk = 320;
|
|
840
|
+
#feeder = null;
|
|
841
|
+
constructor(config) {
|
|
842
|
+
this.#config = config;
|
|
843
|
+
}
|
|
844
|
+
/** Connect to WhatsApp and bring up the WASM VoIP stack. */
|
|
845
|
+
connect = async () => {
|
|
846
|
+
this.#baileys = await loadBaileys();
|
|
847
|
+
const useMultiFileAuthState = this.#baileys.useMultiFileAuthState || this.#baileys.default?.useMultiFileAuthState;
|
|
848
|
+
const makeSocket = this.#baileys.default?.default || this.#baileys.default || this.#baileys.makeWASocket || this.#baileys;
|
|
849
|
+
const DisconnectReason = this.#baileys.DisconnectReason || this.#baileys.default?.DisconnectReason;
|
|
850
|
+
const authDir = resolve(this.#config.authDir);
|
|
851
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
852
|
+
const silentLogger = {
|
|
853
|
+
level: "silent",
|
|
854
|
+
child: () => silentLogger,
|
|
855
|
+
trace: () => { },
|
|
856
|
+
debug: () => { },
|
|
857
|
+
info: () => { },
|
|
858
|
+
warn: () => { },
|
|
859
|
+
error: () => { },
|
|
860
|
+
fatal: () => { },
|
|
861
|
+
};
|
|
862
|
+
const createSocket = () => makeSocket({
|
|
863
|
+
auth: state,
|
|
864
|
+
emitOwnEvents: true,
|
|
865
|
+
logger: silentLogger,
|
|
866
|
+
});
|
|
867
|
+
// Connect with auto-reconnect on the post-QR 515 stream-error path.
|
|
868
|
+
await new Promise((resolveOpen, rejectOpen) => {
|
|
869
|
+
let opened = false;
|
|
870
|
+
let retries = 0;
|
|
871
|
+
const maxRetries = 5;
|
|
872
|
+
const connectSocket = () => {
|
|
873
|
+
this.#sock = createSocket();
|
|
874
|
+
this.#sock.ev.on("creds.update", saveCreds);
|
|
875
|
+
process.removeAllListeners("uncaughtException");
|
|
876
|
+
process.on("uncaughtException", (err) => {
|
|
877
|
+
const code = err?.output?.statusCode ?? err?.data?.attrs?.code;
|
|
878
|
+
if ((code === 515 || code === "515") && !opened && retries < maxRetries) {
|
|
879
|
+
retries += 1;
|
|
880
|
+
setTimeout(connectSocket, 1500);
|
|
881
|
+
}
|
|
882
|
+
else if (!opened) {
|
|
883
|
+
rejectOpen(err);
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
this.#sock.ev.on("connection.update", (update) => {
|
|
887
|
+
if (update.qr) {
|
|
888
|
+
this.#config.onQr?.(update.qr);
|
|
889
|
+
void import("qrcode-terminal")
|
|
890
|
+
.then((qrt) => (qrt.default ?? qrt).generate(update.qr, { small: true }))
|
|
891
|
+
.catch(() => {
|
|
892
|
+
console.log("Scan this QR code in WhatsApp > Linked Devices:");
|
|
893
|
+
console.log(update.qr);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
this.#config.onConnectionUpdate?.(update);
|
|
897
|
+
if (update.connection === "open") {
|
|
898
|
+
opened = true;
|
|
899
|
+
process.removeAllListeners("uncaughtException");
|
|
900
|
+
resolveOpen();
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (update.connection === "close" && !opened) {
|
|
904
|
+
const statusCode = update.lastDisconnect?.error?.output?.statusCode;
|
|
905
|
+
const shouldReconnect = statusCode === 515 || statusCode === DisconnectReason?.restartRequired;
|
|
906
|
+
if (shouldReconnect && retries < maxRetries) {
|
|
907
|
+
retries += 1;
|
|
908
|
+
setTimeout(connectSocket, 1000);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
rejectOpen(update.lastDisconnect?.error ?? new Error("socket closed before open"));
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
};
|
|
916
|
+
connectSocket();
|
|
917
|
+
});
|
|
918
|
+
this.#signaling = new SignalingBridge({ sock: this.#sock });
|
|
919
|
+
await this.#signaling.init();
|
|
920
|
+
this.#relay = new RelayRtcTransport({
|
|
921
|
+
onTransportMessage: (data, ip, port) => this.#engine?.handleOnTransportMessage(data, ip, port),
|
|
922
|
+
onIceRtt: (rttMs, ip, port) => this.#engine?.updateIceRtt(rttMs, ip, port),
|
|
923
|
+
onRelayConnected: (ip, port) => this.#engine?.markRelayConnected(ip, port),
|
|
924
|
+
});
|
|
925
|
+
this.#engine = new WasmEngine({
|
|
926
|
+
enableLogs: true,
|
|
927
|
+
callbacks: {
|
|
928
|
+
onLog: (level, msg) => console.log(`[VoIP-WASM ${level}] ${msg}`),
|
|
929
|
+
onSignalingXmpp: (peerJid, callId, xmlPayload) => this.#signaling.sendSignaling(peerJid, callId, xmlPayload),
|
|
930
|
+
onCallEvent: (eventType, eventData) => this.#handleCallEvent(eventType, eventData),
|
|
931
|
+
sendDataToRelay: (data, ip, port) => {
|
|
932
|
+
console.log(`[VoIP sendDataToRelay] ${data?.byteLength || 0} bytes to ${ip}:${port}`);
|
|
933
|
+
return this.#relay.send(data, ip, port);
|
|
934
|
+
},
|
|
935
|
+
onAudioCaptureInit: (config) => this.#handleAudioCaptureInit(config),
|
|
936
|
+
onAudioCaptureStart: () => this.#handleAudioCaptureStart(),
|
|
937
|
+
onAudioCaptureStop: () => this.#handleAudioCaptureStop(),
|
|
938
|
+
onAudioPlaybackData: (audioData) => this.#activeCall?._emitAudio(audioData),
|
|
939
|
+
onVideoFrame: (videoData) => this.#activeCall?._emitVideo(videoData),
|
|
940
|
+
cryptoHkdf: computeHkdf,
|
|
941
|
+
hmacSha256: computeHmacSha256,
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
await this.#engine.initialize();
|
|
945
|
+
this.#signaling.attachEngine(this.#engine);
|
|
946
|
+
this.#signaling.attachRelay(this.#relay);
|
|
947
|
+
const selfPnJid = this.#sock.authState.creds.me?.id;
|
|
948
|
+
const selfLidJid = this.#sock.authState.creds.me?.lid;
|
|
949
|
+
this.#engine.initVoipStack(selfPnJid, toBareJid(selfPnJid), selfLidJid);
|
|
950
|
+
await this.#engine.waitForVoipStackReady();
|
|
951
|
+
try {
|
|
952
|
+
this.#engine.updateNetworkMedium(2, 0);
|
|
953
|
+
}
|
|
954
|
+
catch { }
|
|
955
|
+
this.#sock.ws.on("CB:call", (node) => {
|
|
956
|
+
const children = Array.isArray(node?.content) ? node.content : [];
|
|
957
|
+
const tags = children.map(c => c?.tag).join(", ");
|
|
958
|
+
console.log(`[VoIP-Client CB:call] Stanza from=${node.attrs?.from} tags=[${tags}]`);
|
|
959
|
+
const isHangup = children.some(c => c?.tag === "terminate" || c?.tag === "reject");
|
|
960
|
+
if (isHangup) {
|
|
961
|
+
this.#activeCall?._forceEnd("remote_hangup");
|
|
962
|
+
}
|
|
963
|
+
if (children.some(c => c?.tag === "accept")) {
|
|
964
|
+
console.log("[VoIP-Client] Inbound <accept> detected! Transitioning activeCall to AcceptReceived (5)...");
|
|
965
|
+
this.#activeCall?._updateState(CallState.AcceptReceived);
|
|
966
|
+
} else if (children.some(c => c?.tag === "preaccept")) {
|
|
967
|
+
this.#activeCall?._updateState(CallState.PreacceptReceived);
|
|
968
|
+
}
|
|
969
|
+
this.#signaling.processIncomingCall(node, this.#engine, this.#activeCall?.callId ?? "");
|
|
970
|
+
});
|
|
971
|
+
this.#sock.ev.on("call", (calls) => {
|
|
972
|
+
for (const c of calls) {
|
|
973
|
+
console.log(`[VoIP-Client ev:call] status=${c.status} id=${c.id} from=${c.from}`);
|
|
974
|
+
if (c.status === "terminate" || c.status === "reject") {
|
|
975
|
+
this.#activeCall?._forceEnd("remote_hangup");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
this.#sock.ws.on("CB:receipt", (node) => {
|
|
980
|
+
if (!isCallReceiptNode(node))
|
|
981
|
+
return;
|
|
982
|
+
this.#signaling.processIncomingReceipt(node, this.#engine, this.#activeCall?.callId ?? "");
|
|
983
|
+
});
|
|
984
|
+
};
|
|
985
|
+
get sock() {
|
|
986
|
+
return this.#sock;
|
|
987
|
+
}
|
|
988
|
+
/** Place an outbound voice call. */
|
|
989
|
+
call = async (phoneNumber, opts = {}) => {
|
|
990
|
+
if (!this.#engine || !this.#signaling)
|
|
991
|
+
throw new Error("Not connected. Call connect() first.");
|
|
992
|
+
if (this.#activeCall)
|
|
993
|
+
throw new Error("A call is already active.");
|
|
994
|
+
const targetNumber = phoneNumber.replace(/\D/g, "");
|
|
995
|
+
let targetPnJid = `${targetNumber}@s.whatsapp.net`;
|
|
996
|
+
try {
|
|
997
|
+
const waResults = await this.#sock.onWhatsApp(targetNumber);
|
|
998
|
+
if (waResults && waResults[0]?.exists && waResults[0]?.jid) {
|
|
999
|
+
targetPnJid = waResults[0].jid;
|
|
1000
|
+
console.log(`[VoIP-Client] Destination verified via onWhatsApp: ${targetPnJid}`);
|
|
1001
|
+
}
|
|
1002
|
+
} catch {}
|
|
1003
|
+
const durationMs = opts.durationMs ?? 120_000;
|
|
1004
|
+
const audioSource = opts.audioSource ?? "silence";
|
|
1005
|
+
const isVideo = opts.isVideo ?? false;
|
|
1006
|
+
const peerLid = await this.#signaling.resolveLid(targetPnJid);
|
|
1007
|
+
if (!peerLid)
|
|
1008
|
+
throw new Error(`Could not resolve LID for ${targetPnJid}`);
|
|
1009
|
+
for (const jid of [targetPnJid, peerLid]) {
|
|
1010
|
+
try {
|
|
1011
|
+
await this.#sock.presenceSubscribe(jid);
|
|
1012
|
+
}
|
|
1013
|
+
catch { }
|
|
1014
|
+
}
|
|
1015
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
1016
|
+
const peerDeviceJids = await this.#signaling.discoverPeerDevices(peerLid);
|
|
1017
|
+
const deviceList = peerDeviceJids.length ? peerDeviceJids : [toBareJid(peerLid)];
|
|
1018
|
+
await this.#signaling.ensureSessionsForPeers(deviceList);
|
|
1019
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1020
|
+
await this.#signaling.issueTcToken(peerLid);
|
|
1021
|
+
const tcToken = await this.#signaling.ensureTcToken(peerLid, targetPnJid);
|
|
1022
|
+
const callId = ("00" + randomBytes(16).toString("hex").slice(2)).toUpperCase();
|
|
1023
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1024
|
+
const call = new ActiveCall(callId, this.#engine, durationMs, isVideo);
|
|
1025
|
+
call._audioSource = audioSource;
|
|
1026
|
+
call.peer = { phoneNumber, pnJid: targetPnJid, lidJid: peerLid };
|
|
1027
|
+
call.caller = { pnJid: this.#sock?.user?.id ? toBareJid(this.#sock.user.id) : null, lidJid: this.#sock?.user?.lid || null, displayName: this.#sock?.user?.name || "Unity Chat" };
|
|
1028
|
+
call._setRelay(this.#relay);
|
|
1029
|
+
if (this.#feeder) call._setFeeder(this.#feeder);
|
|
1030
|
+
this.#activeCall = call;
|
|
1031
|
+
this.#signaling.registerOutboundCall(callId, peerLid, targetPnJid);
|
|
1032
|
+
call.on("connected", () => {
|
|
1033
|
+
if (!this.#feeder) {
|
|
1034
|
+
this.#handleAudioCaptureStart();
|
|
1035
|
+
}
|
|
1036
|
+
if (isVideo) {
|
|
1037
|
+
startVideoHeartbeat();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
call.on("audio_tx", (pcm) => {
|
|
1041
|
+
this.#feeder?.pushChunk(pcm);
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
let videoHeartbeatInterval = null;
|
|
1045
|
+
let lastManualVideoTime = 0;
|
|
1046
|
+
|
|
1047
|
+
const startVideoHeartbeat = () => {
|
|
1048
|
+
if (!isVideo || videoHeartbeatInterval) return;
|
|
1049
|
+
const blackFrame = Buffer.alloc(320 * 240 * 1.5);
|
|
1050
|
+
blackFrame.fill(16, 0, 320 * 240);
|
|
1051
|
+
blackFrame.fill(128, 320 * 240);
|
|
1052
|
+
|
|
1053
|
+
videoHeartbeatInterval = setInterval(() => {
|
|
1054
|
+
if (this.#activeCall !== call || (call.state !== CallState.Active && call.state !== CallState.AcceptReceived)) {
|
|
1055
|
+
if (call.state === CallState.Idle || call.state === CallState.Ending) {
|
|
1056
|
+
clearInterval(videoHeartbeatInterval);
|
|
1057
|
+
videoHeartbeatInterval = null;
|
|
1058
|
+
}
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (Date.now() - lastManualVideoTime > 250) {
|
|
1062
|
+
this.#engine?.pushVideoFrame(blackFrame, 320, 240, 15, 0);
|
|
1063
|
+
}
|
|
1064
|
+
}, 66);
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
call.on("video_tx", ({ frameBuffer, width, height }) => {
|
|
1068
|
+
if ((call.state !== CallState.Active && call.state !== CallState.AcceptReceived) || call.isVideoPaused) return;
|
|
1069
|
+
lastManualVideoTime = Date.now();
|
|
1070
|
+
this.#engine?.pushVideoFrame(frameBuffer, width, height, 15, 0);
|
|
1071
|
+
call._emitVideoOut?.({ frameBuffer, width, height });
|
|
1072
|
+
});
|
|
1073
|
+
call.on("ended", async () => {
|
|
1074
|
+
if (callId) this.#signaling?.unregisterCall?.(callId);
|
|
1075
|
+
if (videoHeartbeatInterval) {
|
|
1076
|
+
clearInterval(videoHeartbeatInterval);
|
|
1077
|
+
videoHeartbeatInterval = null;
|
|
1078
|
+
}
|
|
1079
|
+
this.#handleAudioCaptureStop(true);
|
|
1080
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1081
|
+
try { this.#engine?.endCall(0, false); } catch {}
|
|
1082
|
+
await this.#relay?.closeAll?.();
|
|
1083
|
+
if (this.#activeCall === call) {
|
|
1084
|
+
this.#activeCall = null;
|
|
1085
|
+
}
|
|
1086
|
+
});
|
|
1087
|
+
this.#engine.startCall({
|
|
1088
|
+
peerJid: peerLid,
|
|
1089
|
+
peerPn: targetPnJid,
|
|
1090
|
+
peerList: deviceList,
|
|
1091
|
+
callId,
|
|
1092
|
+
isVideo,
|
|
1093
|
+
isLidCall: true,
|
|
1094
|
+
isFromDialer: false,
|
|
1095
|
+
extraData: tcToken,
|
|
1096
|
+
});
|
|
1097
|
+
return call;
|
|
1098
|
+
};
|
|
1099
|
+
/** Tear down the WhatsApp socket and release resources. */
|
|
1100
|
+
disconnect = () => {
|
|
1101
|
+
this.#activeCall?._forceEnd("disconnect");
|
|
1102
|
+
this.#activeCall = null;
|
|
1103
|
+
this.#relay?.closeAll();
|
|
1104
|
+
this.#engine?.destroy();
|
|
1105
|
+
this.#sock?.end?.();
|
|
1106
|
+
this.#engine = null;
|
|
1107
|
+
this.#relay = null;
|
|
1108
|
+
this.#signaling = null;
|
|
1109
|
+
this.#sock = null;
|
|
1110
|
+
};
|
|
1111
|
+
get sock() { return this.#sock; }
|
|
1112
|
+
get activeCall() { return this.#activeCall; }
|
|
1113
|
+
clearActiveCall = async () => {
|
|
1114
|
+
this.#handleAudioCaptureStop(true);
|
|
1115
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1116
|
+
try { this.#engine?.endCall(0, false); } catch {}
|
|
1117
|
+
await this.#relay?.closeAll?.();
|
|
1118
|
+
this.#activeCall = null;
|
|
1119
|
+
};
|
|
1120
|
+
// ─── private ──────────────────────────────────────────────────────────────
|
|
1121
|
+
#handleCallEvent = (eventType, eventData) => {
|
|
1122
|
+
console.log(`[VoIP onCallEvent] type=${eventType} data=${eventData ? eventData.substring(0, 140) : "null"}`);
|
|
1123
|
+
if (eventType === 16 && eventData) {
|
|
1124
|
+
try {
|
|
1125
|
+
const parsed = JSON.parse(eventData);
|
|
1126
|
+
const info = parsed.call_info ?? parsed.callInfo ?? {};
|
|
1127
|
+
const callState = Number(info.call_state ?? info.callState ?? 0);
|
|
1128
|
+
const stateName = Object.keys(CallState).find(k => CallState[k] === callState) || ("Unknown(" + callState + ")");
|
|
1129
|
+
console.log(`[VoIP State] call_state -> ${callState} (${stateName})`);
|
|
1130
|
+
this.#activeCall?._updateState(callState);
|
|
1131
|
+
}
|
|
1132
|
+
catch { }
|
|
1133
|
+
}
|
|
1134
|
+
else if (eventType === 156 && eventData) {
|
|
1135
|
+
try {
|
|
1136
|
+
console.log(`[VoIP] Relay list update received (event 156)! Updating relay transport...`);
|
|
1137
|
+
const update = JSON.parse(eventData);
|
|
1138
|
+
this.#relay?.updateRelayList(update);
|
|
1139
|
+
}
|
|
1140
|
+
catch (err) {
|
|
1141
|
+
console.error(`[VoIP] Error processing event 156:`, err);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
else if (eventType === 2) {
|
|
1145
|
+
console.log(`[VoIP] Remote end received (event 2)`);
|
|
1146
|
+
this.#activeCall?._forceEnd("remote_end");
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
#handleAudioCaptureInit = (config) => {
|
|
1150
|
+
if (!this.#engine)
|
|
1151
|
+
return;
|
|
1152
|
+
this.#captureSampleRate = config.sampleRate || 16000;
|
|
1153
|
+
this.#captureChannels = config.channels || 1;
|
|
1154
|
+
this.#captureFramesPerChunk = config.framesPerChunk || 320;
|
|
1155
|
+
const chunkSamples = this.#captureFramesPerChunk * this.#captureChannels;
|
|
1156
|
+
this.#captureChunkBytes = chunkSamples * Float32Array.BYTES_PER_ELEMENT;
|
|
1157
|
+
this.#capturePtr = this.#engine.malloc(this.#captureChunkBytes);
|
|
1158
|
+
};
|
|
1159
|
+
#handleAudioCaptureStart = () => {
|
|
1160
|
+
if (!this.#engine)
|
|
1161
|
+
return;
|
|
1162
|
+
if (!this.#capturePtr) {
|
|
1163
|
+
const chunkSamples = (this.#captureFramesPerChunk || 320) * (this.#captureChannels || 1);
|
|
1164
|
+
this.#captureChunkBytes = chunkSamples * Float32Array.BYTES_PER_ELEMENT;
|
|
1165
|
+
this.#capturePtr = this.#engine.malloc(this.#captureChunkBytes);
|
|
1166
|
+
}
|
|
1167
|
+
if (!this.#capturePtr)
|
|
1168
|
+
return;
|
|
1169
|
+
const audioSource = this.#activeCall?._audioSource ?? "silence";
|
|
1170
|
+
if (this.#feeder) {
|
|
1171
|
+
console.log(`[VoIP] AudioFeeder already running, keeping active`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
this.#feeder = new AudioFeeder(this.#captureSampleRate || 16000, this.#captureChannels || 1, this.#captureFramesPerChunk || 320, (chunk) => {
|
|
1175
|
+
this.#activeCall?._emitAudioOut?.(chunk);
|
|
1176
|
+
if (this.#engine && this.#capturePtr)
|
|
1177
|
+
this.#engine.sendAudioData(chunk, this.#capturePtr);
|
|
1178
|
+
}, audioSource);
|
|
1179
|
+
this.#activeCall?._setFeeder(this.#feeder);
|
|
1180
|
+
this.#feeder.start();
|
|
1181
|
+
console.log(`[VoIP] AudioFeeder uplink running (source: ${audioSource})`);
|
|
1182
|
+
};
|
|
1183
|
+
#handleAudioCaptureStop = (force = false) => {
|
|
1184
|
+
if (!force && this.#activeCall && this.#activeCall.state !== CallState.Idle && this.#activeCall.state !== CallState.Ending) {
|
|
1185
|
+
console.log(`[VoIP] Ignoring stopCaptureJS while call is in progress! (callId: ${this.#activeCall.callId})`);
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
console.log("[VoIP] AudioFeeder stopped");
|
|
1189
|
+
this.#feeder?.stop();
|
|
1190
|
+
this.#feeder = null;
|
|
1191
|
+
// Keep this.#capturePtr allocated for reuse across calls
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* VoipEngine — Attached directly to a live WaSockets instance (makeWASocket).
|
|
1198
|
+
* Shares the socket's connection, signal sessions, auth state, and message queue.
|
|
1199
|
+
*/
|
|
1200
|
+
export class VoipEngine {
|
|
1201
|
+
#sock;
|
|
1202
|
+
#options;
|
|
1203
|
+
#engine = null;
|
|
1204
|
+
#relay = null;
|
|
1205
|
+
#signaling = null;
|
|
1206
|
+
#activeCall = null;
|
|
1207
|
+
#capturePtr = 0;
|
|
1208
|
+
#captureChunkBytes = 0;
|
|
1209
|
+
#captureSampleRate = 16000;
|
|
1210
|
+
#captureChannels = 1;
|
|
1211
|
+
#captureFramesPerChunk = 320;
|
|
1212
|
+
#feeder = null;
|
|
1213
|
+
#initPromise = null;
|
|
1214
|
+
#destroyed = false;
|
|
1215
|
+
|
|
1216
|
+
constructor(sock, options = {}) {
|
|
1217
|
+
this.#sock = sock;
|
|
1218
|
+
this.#options = options;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
init = async () => {
|
|
1222
|
+
if (this.#initPromise) return this.#initPromise;
|
|
1223
|
+
this.#initPromise = this.#doInit();
|
|
1224
|
+
return this.#initPromise;
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
#doInit = async () => {
|
|
1228
|
+
if (!this.#sock.authState?.creds?.me) {
|
|
1229
|
+
await new Promise((resolve) => {
|
|
1230
|
+
if (this.#sock.authState?.creds?.me) return resolve();
|
|
1231
|
+
const check = () => {
|
|
1232
|
+
if (this.#sock.authState?.creds?.me) {
|
|
1233
|
+
this.#sock.ev.off("connection.update", check);
|
|
1234
|
+
resolve();
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
this.#sock.ev.on("connection.update", check);
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
this.#signaling = new SignalingBridge({ sock: this.#sock });
|
|
1242
|
+
await this.#signaling.init();
|
|
1243
|
+
|
|
1244
|
+
this.#relay = new RelayRtcTransport({
|
|
1245
|
+
onTransportMessage: (data, ip, port) => this.#engine?.handleOnTransportMessage(data, ip, port),
|
|
1246
|
+
onIceRtt: (rttMs, ip, port) => this.#engine?.updateIceRtt(rttMs, ip, port),
|
|
1247
|
+
onRelayConnected: (ip, port) => this.#engine?.markRelayConnected(ip, port),
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
this.#engine = new WasmEngine({
|
|
1251
|
+
enableLogs: true,
|
|
1252
|
+
callbacks: {
|
|
1253
|
+
onLog: (level, msg) => console.log(`[VoIP-WASM ${level}] ${msg}`),
|
|
1254
|
+
onSignalingXmpp: (peerJid, callId, xmlPayload) => this.#signaling.sendSignaling(peerJid, callId, xmlPayload),
|
|
1255
|
+
onCallEvent: (eventType, eventData) => this.#handleCallEvent(eventType, eventData),
|
|
1256
|
+
sendDataToRelay: (data, ip, port) => {
|
|
1257
|
+
console.log(`[VoIP sendDataToRelay] ${data?.byteLength || 0} bytes to ${ip}:${port}`);
|
|
1258
|
+
return this.#relay.send(data, ip, port);
|
|
1259
|
+
},
|
|
1260
|
+
onAudioCaptureInit: (config) => this.#handleAudioCaptureInit(config),
|
|
1261
|
+
onAudioCaptureStart: () => this.#handleAudioCaptureStart(),
|
|
1262
|
+
onAudioCaptureStop: () => this.#handleAudioCaptureStop(),
|
|
1263
|
+
onAudioPlaybackData: (audioData) => this.#activeCall?._emitAudio(audioData),
|
|
1264
|
+
onVideoFrame: (videoData) => this.#activeCall?._emitVideo(videoData),
|
|
1265
|
+
cryptoHkdf: computeHkdf,
|
|
1266
|
+
hmacSha256: computeHmacSha256,
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
await this.#engine.initialize();
|
|
1271
|
+
this.#signaling.attachEngine(this.#engine);
|
|
1272
|
+
this.#signaling.attachRelay(this.#relay);
|
|
1273
|
+
|
|
1274
|
+
const selfPnJid = this.#sock.authState.creds.me?.id;
|
|
1275
|
+
const selfLidJid = this.#sock.authState.creds.me?.lid;
|
|
1276
|
+
this.#engine.initVoipStack(selfPnJid, toBareJid(selfPnJid), selfLidJid);
|
|
1277
|
+
await this.#engine.waitForVoipStackReady();
|
|
1278
|
+
|
|
1279
|
+
try {
|
|
1280
|
+
this.#engine.updateNetworkMedium(2, 0);
|
|
1281
|
+
} catch {}
|
|
1282
|
+
|
|
1283
|
+
if (this.#sock.ws) {
|
|
1284
|
+
this.#sock.ws.on("CB:call", (node) => {
|
|
1285
|
+
const children = Array.isArray(node?.content) ? node.content : [];
|
|
1286
|
+
const tags = children.map(c => c?.tag).join(", ");
|
|
1287
|
+
console.log("[VoIP-Engine CB:call] Stanza tags: [" + tags + "]");
|
|
1288
|
+
|
|
1289
|
+
const hangupChild = children.find(c => c?.tag === "terminate" || c?.tag === "reject");
|
|
1290
|
+
if (hangupChild && this.#activeCall) {
|
|
1291
|
+
const stanzaCallId = String(hangupChild.attrs?.["call-id"] || hangupChild.attrs?.call_id || node.attrs?.id || "");
|
|
1292
|
+
const fromJid = String(node.attrs?.from || "");
|
|
1293
|
+
const myPn = this.#sock.authState?.creds?.me?.id?.split(":")[0];
|
|
1294
|
+
const myLid = this.#sock.authState?.creds?.me?.lid?.split(":")[0];
|
|
1295
|
+
const isFromSelf = (myPn && fromJid.includes(myPn)) || (myLid && fromJid.includes(myLid));
|
|
1296
|
+
|
|
1297
|
+
if (!isFromSelf && (!stanzaCallId || stanzaCallId.toLowerCase() === this.#activeCall.callId.toLowerCase())) {
|
|
1298
|
+
console.log("[VoIP-Engine CB:call] Hangup received for active call! Forcing call end.");
|
|
1299
|
+
this.#activeCall._forceEnd("remote_hangup");
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (children.some(c => c?.tag === "accept")) {
|
|
1303
|
+
console.log("[VoIP-Engine] Inbound <accept> detected! Transitioning activeCall to AcceptReceived (5)...");
|
|
1304
|
+
this.#activeCall?._updateState(CallState.AcceptReceived);
|
|
1305
|
+
} else if (children.some(c => c?.tag === "preaccept")) {
|
|
1306
|
+
this.#activeCall?._updateState(CallState.PreacceptReceived);
|
|
1307
|
+
}
|
|
1308
|
+
this.#signaling.processIncomingCall(node, this.#engine, this.#activeCall?.callId ?? "");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
this.#sock.ws.on("CB:receipt", (node) => {
|
|
1312
|
+
if (!isCallReceiptNode(node)) return;
|
|
1313
|
+
this.#signaling.processIncomingReceipt(node, this.#engine, this.#activeCall?.callId ?? "");
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
this.#sock.ev.on("call", (calls) => {
|
|
1318
|
+
for (const c of calls) {
|
|
1319
|
+
console.log(`[VoIP-Engine ev:call] status=${c.status} id=${c.id} from=${c.from}`);
|
|
1320
|
+
if (c.status === "terminate" || c.status === "reject") {
|
|
1321
|
+
if (this.#activeCall && c.id && this.#activeCall.callId.toLowerCase() === c.id.toLowerCase()) {
|
|
1322
|
+
this.#activeCall._forceEnd("remote_hangup");
|
|
1323
|
+
}
|
|
1324
|
+
} else if (c.status === "offer") {
|
|
1325
|
+
const incomingCall = {
|
|
1326
|
+
id: c.id,
|
|
1327
|
+
callId: c.id,
|
|
1328
|
+
from: c.from,
|
|
1329
|
+
chatId: c.chatId,
|
|
1330
|
+
isVideo: Boolean(c.isVideo),
|
|
1331
|
+
isGroup: Boolean(c.isGroup),
|
|
1332
|
+
date: c.date || new Date(),
|
|
1333
|
+
accept: async (opts = {}) => {
|
|
1334
|
+
return this.acceptCall(c.id, c.from, { ...opts, isVideo: opts.isVideo ?? c.isVideo });
|
|
1335
|
+
},
|
|
1336
|
+
reject: async (reason = 0) => {
|
|
1337
|
+
return this.rejectCall(c.id, c.from, reason);
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
try {
|
|
1341
|
+
this.#sock.ev.emit("call.received", incomingCall);
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
console.error("[VoIP-Engine] Error emitting call.received:", err);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
get activeCall() { return this.#activeCall; }
|
|
1351
|
+
get sock() { return this.#sock; }
|
|
1352
|
+
|
|
1353
|
+
startCall = async (phoneNumber, opts = {}) => {
|
|
1354
|
+
await this.init();
|
|
1355
|
+
if (this.#activeCall) {
|
|
1356
|
+
throw new Error("A call is already active.");
|
|
1357
|
+
}
|
|
1358
|
+
const targetNumber = phoneNumber.replace(/\D/g, "");
|
|
1359
|
+
let targetPnJid = `${targetNumber}@s.whatsapp.net`;
|
|
1360
|
+
try {
|
|
1361
|
+
const waResults = await this.#sock.onWhatsApp(targetNumber);
|
|
1362
|
+
if (waResults && waResults[0]?.exists && waResults[0]?.jid) {
|
|
1363
|
+
targetPnJid = waResults[0].jid;
|
|
1364
|
+
console.log(`[VoIP-Client] Destination verified via onWhatsApp: ${targetPnJid}`);
|
|
1365
|
+
}
|
|
1366
|
+
} catch {}
|
|
1367
|
+
const durationMs = opts.durationMs ?? 120_000;
|
|
1368
|
+
const audioSource = opts.audioSource ?? "silence";
|
|
1369
|
+
const isVideo = opts.isVideo ?? false;
|
|
1370
|
+
|
|
1371
|
+
const peerLid = await this.#signaling.resolveLid(targetPnJid);
|
|
1372
|
+
if (!peerLid) throw new Error(`Could not resolve LID for ${targetPnJid}`);
|
|
1373
|
+
|
|
1374
|
+
for (const jid of [targetPnJid, peerLid]) {
|
|
1375
|
+
try { await this.#sock.presenceSubscribe(jid); } catch {}
|
|
1376
|
+
}
|
|
1377
|
+
await new Promise((r) => setTimeout(r, 750));
|
|
1378
|
+
|
|
1379
|
+
const peerDeviceJids = await this.#signaling.discoverPeerDevices(peerLid);
|
|
1380
|
+
const deviceList = peerDeviceJids.length ? peerDeviceJids : [toBareJid(peerLid)];
|
|
1381
|
+
await this.#signaling.ensureSessionsForPeers(deviceList);
|
|
1382
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1383
|
+
|
|
1384
|
+
await this.#signaling.issueTcToken(peerLid);
|
|
1385
|
+
const tcToken = await this.#signaling.ensureTcToken(peerLid, targetPnJid);
|
|
1386
|
+
|
|
1387
|
+
const callId = ("00" + randomBytes(16).toString("hex").slice(2)).toUpperCase();
|
|
1388
|
+
const call = new ActiveCall(callId, this.#engine, durationMs, isVideo);
|
|
1389
|
+
call._audioSource = audioSource;
|
|
1390
|
+
call.peer = { phoneNumber: targetNumber, pnJid: targetPnJid, lidJid: peerLid };
|
|
1391
|
+
call.caller = { pnJid: this.#sock?.user?.id ? toBareJid(this.#sock.user.id) : null, lidJid: this.#sock?.user?.lid || null, displayName: this.#sock?.user?.name || "Unity Chat" };
|
|
1392
|
+
call._setRelay(this.#relay);
|
|
1393
|
+
if (this.#feeder) call._setFeeder(this.#feeder);
|
|
1394
|
+
this.#activeCall = call;
|
|
1395
|
+
this.#signaling.registerOutboundCall(callId, peerLid, targetPnJid);
|
|
1396
|
+
|
|
1397
|
+
// Emit events on sock.ev
|
|
1398
|
+
try {
|
|
1399
|
+
this.#sock.ev.emit("call.created", { callId, to: targetPnJid, isVideo });
|
|
1400
|
+
} catch {}
|
|
1401
|
+
call.on("ringing", () => {
|
|
1402
|
+
try { this.#sock.ev.emit("call.ringing", { callId, to: targetPnJid }); } catch {}
|
|
1403
|
+
});
|
|
1404
|
+
call.on("connected", () => {
|
|
1405
|
+
if (!this.#feeder) {
|
|
1406
|
+
this.#handleAudioCaptureStart();
|
|
1407
|
+
}
|
|
1408
|
+
if (isVideo) {
|
|
1409
|
+
startVideoHeartbeat();
|
|
1410
|
+
}
|
|
1411
|
+
try { this.#sock.ev.emit("call.connected", { callId, to: targetPnJid, isVideo }); } catch {}
|
|
1412
|
+
});
|
|
1413
|
+
call.on("ended", (reason) => {
|
|
1414
|
+
try { this.#sock.ev.emit("call.ended", { callId, to: targetPnJid, reason }); } catch {}
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1417
|
+
call.on("audio_tx", (pcm) => {
|
|
1418
|
+
this.#feeder?.pushChunk(pcm);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
let videoHeartbeatInterval = null;
|
|
1422
|
+
let lastManualVideoTime = 0;
|
|
1423
|
+
|
|
1424
|
+
const startVideoHeartbeat = () => {
|
|
1425
|
+
if (!isVideo || videoHeartbeatInterval) return;
|
|
1426
|
+
const blackFrame = Buffer.alloc(320 * 240 * 1.5);
|
|
1427
|
+
blackFrame.fill(16, 0, 320 * 240);
|
|
1428
|
+
blackFrame.fill(128, 320 * 240);
|
|
1429
|
+
|
|
1430
|
+
videoHeartbeatInterval = setInterval(() => {
|
|
1431
|
+
if (this.#activeCall !== call || (call.state !== CallState.Active && call.state !== CallState.AcceptReceived)) {
|
|
1432
|
+
if (call.state === CallState.Idle || call.state === CallState.Ending) {
|
|
1433
|
+
clearInterval(videoHeartbeatInterval);
|
|
1434
|
+
videoHeartbeatInterval = null;
|
|
1435
|
+
}
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
if (Date.now() - lastManualVideoTime > 250) {
|
|
1439
|
+
this.#engine?.pushVideoFrame(blackFrame, 320, 240, 15, 0);
|
|
1440
|
+
}
|
|
1441
|
+
}, 66);
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
call.on("video_tx", ({ frameBuffer, width, height }) => {
|
|
1445
|
+
if ((call.state !== CallState.Active && call.state !== CallState.AcceptReceived) || call.isVideoPaused) return;
|
|
1446
|
+
lastManualVideoTime = Date.now();
|
|
1447
|
+
this.#engine?.pushVideoFrame(frameBuffer, width, height, 15, 0);
|
|
1448
|
+
call._emitVideoOut?.({ frameBuffer, width, height });
|
|
1449
|
+
});
|
|
1450
|
+
call.on("ended", async () => {
|
|
1451
|
+
if (callId) this.#signaling?.unregisterCall?.(callId);
|
|
1452
|
+
if (videoHeartbeatInterval) {
|
|
1453
|
+
clearInterval(videoHeartbeatInterval);
|
|
1454
|
+
videoHeartbeatInterval = null;
|
|
1455
|
+
}
|
|
1456
|
+
this.#handleAudioCaptureStop(true);
|
|
1457
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1458
|
+
try { this.#engine?.endCall(0, false); } catch {}
|
|
1459
|
+
await this.#relay?.closeAll?.();
|
|
1460
|
+
if (this.#activeCall === call) {
|
|
1461
|
+
this.#activeCall = null;
|
|
1462
|
+
}
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
this.#engine.startCall({
|
|
1466
|
+
peerJid: peerLid,
|
|
1467
|
+
peerPn: targetPnJid,
|
|
1468
|
+
peerList: deviceList,
|
|
1469
|
+
callId,
|
|
1470
|
+
isVideo,
|
|
1471
|
+
isLidCall: true,
|
|
1472
|
+
isFromDialer: false,
|
|
1473
|
+
extraData: tcToken,
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
return call;
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
acceptCall = async (callId, from, opts = {}) => {
|
|
1480
|
+
await this.init();
|
|
1481
|
+
if (this.#activeCall && this.#activeCall.callId.toLowerCase() !== String(callId).toLowerCase()) {
|
|
1482
|
+
throw new Error(`A different call is already active (${this.#activeCall.callId}).`);
|
|
1483
|
+
}
|
|
1484
|
+
const isVideo = Boolean(opts.isVideo);
|
|
1485
|
+
const durationMs = opts.durationMs ?? 180_000;
|
|
1486
|
+
const audioSource = opts.audioSource ?? "live";
|
|
1487
|
+
|
|
1488
|
+
const targetNumber = String(from || "").replace(/\D/g, "");
|
|
1489
|
+
const targetPnJid = from?.includes("@") ? from : `${targetNumber}@s.whatsapp.net`;
|
|
1490
|
+
|
|
1491
|
+
let call = this.#activeCall;
|
|
1492
|
+
if (!call) {
|
|
1493
|
+
call = new ActiveCall(callId, this.#engine, durationMs, isVideo);
|
|
1494
|
+
call.direction = "inbound";
|
|
1495
|
+
call._audioSource = audioSource;
|
|
1496
|
+
call.peer = { phoneNumber: targetNumber, pnJid: targetPnJid };
|
|
1497
|
+
call.caller = { pnJid: this.#sock?.user?.id ? toBareJid(this.#sock.user.id) : null, lidJid: this.#sock?.user?.lid || null, displayName: this.#sock?.user?.name || "Unity Chat" };
|
|
1498
|
+
call._setRelay(this.#relay);
|
|
1499
|
+
if (this.#feeder) call._setFeeder(this.#feeder);
|
|
1500
|
+
this.#activeCall = call;
|
|
1501
|
+
|
|
1502
|
+
call.on("ringing", () => {
|
|
1503
|
+
try { this.#sock.ev.emit("call.ringing", { callId, to: targetPnJid }); } catch {}
|
|
1504
|
+
});
|
|
1505
|
+
call.on("connected", () => {
|
|
1506
|
+
if (!this.#feeder) {
|
|
1507
|
+
this.#handleAudioCaptureStart();
|
|
1508
|
+
}
|
|
1509
|
+
if (isVideo) {
|
|
1510
|
+
startVideoHeartbeat();
|
|
1511
|
+
}
|
|
1512
|
+
try { this.#sock.ev.emit("call.connected", { callId, to: targetPnJid, isVideo }); } catch {}
|
|
1513
|
+
});
|
|
1514
|
+
call.on("ended", (reason) => {
|
|
1515
|
+
try { this.#sock.ev.emit("call.ended", { callId, to: targetPnJid, reason }); } catch {}
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
call.on("audio_tx", (pcm) => {
|
|
1519
|
+
this.#feeder?.pushChunk(pcm);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
let videoHeartbeatInterval = null;
|
|
1523
|
+
let lastManualVideoTime = 0;
|
|
1524
|
+
|
|
1525
|
+
const startVideoHeartbeat = () => {
|
|
1526
|
+
if (!isVideo || videoHeartbeatInterval) return;
|
|
1527
|
+
const blackFrame = Buffer.alloc(320 * 240 * 1.5);
|
|
1528
|
+
blackFrame.fill(16, 0, 320 * 240);
|
|
1529
|
+
blackFrame.fill(128, 320 * 240);
|
|
1530
|
+
|
|
1531
|
+
videoHeartbeatInterval = setInterval(() => {
|
|
1532
|
+
if (this.#activeCall !== call || (call.state !== CallState.Active && call.state !== CallState.AcceptReceived)) {
|
|
1533
|
+
if (call.state === CallState.Idle || call.state === CallState.Ending) {
|
|
1534
|
+
clearInterval(videoHeartbeatInterval);
|
|
1535
|
+
videoHeartbeatInterval = null;
|
|
1536
|
+
}
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (Date.now() - lastManualVideoTime > 250) {
|
|
1540
|
+
this.#engine?.pushVideoFrame(blackFrame, 320, 240, 15, 0);
|
|
1541
|
+
}
|
|
1542
|
+
}, 66);
|
|
1543
|
+
};
|
|
1544
|
+
|
|
1545
|
+
call.on("video_tx", ({ frameBuffer, width, height }) => {
|
|
1546
|
+
if ((call.state !== CallState.Active && call.state !== CallState.AcceptReceived) || call.isVideoPaused) return;
|
|
1547
|
+
lastManualVideoTime = Date.now();
|
|
1548
|
+
this.#engine?.pushVideoFrame(frameBuffer, width, height, 15, 0);
|
|
1549
|
+
call._emitVideoOut?.({ frameBuffer, width, height });
|
|
1550
|
+
});
|
|
1551
|
+
call.on("ended", async () => {
|
|
1552
|
+
if (callId) this.#signaling?.unregisterCall?.(callId);
|
|
1553
|
+
if (videoHeartbeatInterval) {
|
|
1554
|
+
clearInterval(videoHeartbeatInterval);
|
|
1555
|
+
videoHeartbeatInterval = null;
|
|
1556
|
+
}
|
|
1557
|
+
this.#handleAudioCaptureStop(true);
|
|
1558
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1559
|
+
try { this.#engine?.endCall(0, false); } catch {}
|
|
1560
|
+
await this.#relay?.closeAll?.();
|
|
1561
|
+
if (this.#activeCall === call) {
|
|
1562
|
+
this.#activeCall = null;
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const peerDeviceJid = from?.includes(":") ? from : (from?.includes("@") ? from.replace("@", ":0@") : from);
|
|
1568
|
+
this.#signaling?.registerInboundCall?.(callId, peerDeviceJid, targetPnJid);
|
|
1569
|
+
// Delegate to WASM acceptCall(isMicEnabled, isVideo)
|
|
1570
|
+
this.#engine.acceptCall(true, isVideo);
|
|
1571
|
+
return call;
|
|
1572
|
+
};
|
|
1573
|
+
|
|
1574
|
+
rejectCall = async (callId, from, reason = 0) => {
|
|
1575
|
+
await this.init();
|
|
1576
|
+
try {
|
|
1577
|
+
this.#engine?.rejectCall(callId, typeof reason === "number" ? reason : 0);
|
|
1578
|
+
} catch {}
|
|
1579
|
+
if (this.#activeCall && (!callId || this.#activeCall.callId.toLowerCase() === String(callId).toLowerCase())) {
|
|
1580
|
+
this.#activeCall.end();
|
|
1581
|
+
this.#activeCall = null;
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1585
|
+
clearActiveCall = async () => {
|
|
1586
|
+
const callId = this.#activeCall?.callId;
|
|
1587
|
+
if (callId) this.#signaling?.unregisterCall?.(callId);
|
|
1588
|
+
this.#handleAudioCaptureStop(true);
|
|
1589
|
+
try { this.#engine?.resetVideoUplink?.(); } catch {}
|
|
1590
|
+
try { this.#engine?.endCall(0, false); } catch {}
|
|
1591
|
+
await this.#relay?.closeAll?.();
|
|
1592
|
+
this.#activeCall = null;
|
|
1593
|
+
};
|
|
1594
|
+
|
|
1595
|
+
destroy = () => {
|
|
1596
|
+
if (this.#destroyed) return;
|
|
1597
|
+
this.#destroyed = true;
|
|
1598
|
+
this.#handleAudioCaptureStop(true);
|
|
1599
|
+
this.#activeCall?._forceEnd("destroy");
|
|
1600
|
+
this.#activeCall = null;
|
|
1601
|
+
this.#relay?.closeAll();
|
|
1602
|
+
this.#engine?.destroy();
|
|
1603
|
+
this.#engine = null;
|
|
1604
|
+
this.#relay = null;
|
|
1605
|
+
this.#signaling = null;
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
#handleCallEvent = (eventType, eventData) => {
|
|
1609
|
+
console.log("[VoIP-Engine Event] type=" + eventType + ": " + (eventData ? eventData.slice(0, 160) : ""));
|
|
1610
|
+
if (eventType === 16 && eventData) {
|
|
1611
|
+
try {
|
|
1612
|
+
const parsed = JSON.parse(eventData);
|
|
1613
|
+
const info = parsed.call_info ?? parsed.callInfo ?? {};
|
|
1614
|
+
const callState = Number(info.call_state ?? info.callState ?? 0);
|
|
1615
|
+
const stateName = Object.keys(CallState).find(k => CallState[k] === callState) || ("Unknown(" + callState + ")");
|
|
1616
|
+
console.log("[VoIP-Engine State] call_state -> " + callState + " (" + stateName + ")");
|
|
1617
|
+
this.#activeCall?._updateState(callState);
|
|
1618
|
+
} catch {}
|
|
1619
|
+
} else if (eventType === 156 && eventData) {
|
|
1620
|
+
try {
|
|
1621
|
+
console.log("[VoIP-Engine] Relay list update received (event 156)");
|
|
1622
|
+
const update = JSON.parse(eventData);
|
|
1623
|
+
this.#relay?.updateRelayList(update);
|
|
1624
|
+
} catch {}
|
|
1625
|
+
} else if (eventType === 2) {
|
|
1626
|
+
console.log("[VoIP-Engine] Remote end received (event 2)");
|
|
1627
|
+
this.#activeCall?._forceEnd("remote_end");
|
|
1628
|
+
}
|
|
1629
|
+
};
|
|
1630
|
+
|
|
1631
|
+
#handleAudioCaptureInit = (config) => {
|
|
1632
|
+
if (!this.#engine) return;
|
|
1633
|
+
this.#captureSampleRate = config.sampleRate || 16000;
|
|
1634
|
+
this.#captureChannels = config.channels || 1;
|
|
1635
|
+
this.#captureFramesPerChunk = config.framesPerChunk || 320;
|
|
1636
|
+
const chunkSamples = this.#captureFramesPerChunk * this.#captureChannels;
|
|
1637
|
+
this.#captureChunkBytes = chunkSamples * Float32Array.BYTES_PER_ELEMENT;
|
|
1638
|
+
if (!this.#capturePtr) {
|
|
1639
|
+
this.#capturePtr = this.#engine.malloc(this.#captureChunkBytes);
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
#handleAudioCaptureStart = () => {
|
|
1644
|
+
if (!this.#engine) return;
|
|
1645
|
+
if (!this.#capturePtr) {
|
|
1646
|
+
const chunkSamples = (this.#captureFramesPerChunk || 320) * (this.#captureChannels || 1);
|
|
1647
|
+
this.#captureChunkBytes = chunkSamples * Float32Array.BYTES_PER_ELEMENT;
|
|
1648
|
+
this.#capturePtr = this.#engine.malloc(this.#captureChunkBytes);
|
|
1649
|
+
}
|
|
1650
|
+
if (!this.#capturePtr) return;
|
|
1651
|
+
const audioSource = this.#activeCall?._audioSource ?? "silence";
|
|
1652
|
+
if (this.#feeder) {
|
|
1653
|
+
console.log(`[VoIP] AudioFeeder already running, keeping active`);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
this.#feeder = new AudioFeeder(
|
|
1657
|
+
this.#captureSampleRate || 16000,
|
|
1658
|
+
this.#captureChannels || 1,
|
|
1659
|
+
this.#captureFramesPerChunk || 320,
|
|
1660
|
+
(chunk) => {
|
|
1661
|
+
this.#activeCall?._emitAudioOut?.(chunk);
|
|
1662
|
+
if (this.#engine && this.#capturePtr) {
|
|
1663
|
+
this.#engine.sendAudioData(chunk, this.#capturePtr);
|
|
1664
|
+
}
|
|
1665
|
+
},
|
|
1666
|
+
audioSource
|
|
1667
|
+
);
|
|
1668
|
+
this.#activeCall?._setFeeder(this.#feeder);
|
|
1669
|
+
this.#feeder.start();
|
|
1670
|
+
console.log(`[VoIP] AudioFeeder uplink running (source: ${audioSource})`);
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1673
|
+
#handleAudioCaptureStop = (force = false) => {
|
|
1674
|
+
if (!force && this.#activeCall && this.#activeCall.state !== CallState.Idle && this.#activeCall.state !== CallState.Ending) {
|
|
1675
|
+
console.log(`[VoIP] Ignoring stopCaptureJS while call is in progress! (callId: ${this.#activeCall.callId})`);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
console.log("[VoIP] AudioFeeder stopped");
|
|
1679
|
+
this.#feeder?.stop();
|
|
1680
|
+
this.#feeder = null;
|
|
1681
|
+
// Keep this.#capturePtr allocated across calls for reuse
|
|
1682
|
+
};
|
|
1683
|
+
}
|