@fluxerjs/voice 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,925 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ LiveKitRtcConnection: () => LiveKitRtcConnection,
34
+ VoiceConnection: () => VoiceConnection,
35
+ VoiceManager: () => VoiceManager,
36
+ getVoiceManager: () => getVoiceManager,
37
+ joinVoiceChannel: () => joinVoiceChannel
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/VoiceManager.ts
42
+ var import_events3 = require("events");
43
+ var import_core = require("@fluxerjs/core");
44
+ var import_types = require("@fluxerjs/types");
45
+
46
+ // src/VoiceConnection.ts
47
+ var import_events = require("events");
48
+ var nacl = __toESM(require("tweetnacl"));
49
+ var dgram = __toESM(require("dgram"));
50
+ var VOICE_WS_OPCODES = { Identify: 0, SelectProtocol: 1, Ready: 2, Heartbeat: 3, SessionDescription: 4, Speaking: 5 };
51
+ var VOICE_VERSION = 4;
52
+ var CHANNELS = 2;
53
+ var OPUS_FRAME_TICKS = 960 * (CHANNELS === 2 ? 2 : 1);
54
+ var AUDIO_FRAME_INTERVAL_MS = 20;
55
+ async function logFullResponse(url) {
56
+ try {
57
+ const fetchUrl = url.replace(/^wss:\/\//i, "https://").replace(/^ws:\/\//i, "http://");
58
+ const res = await fetch(fetchUrl, { method: "GET" });
59
+ const body = await res.text();
60
+ const headers = {};
61
+ res.headers.forEach((v, k) => {
62
+ headers[k] = v;
63
+ });
64
+ console.error("[voice] Full response from", url, {
65
+ status: res.status,
66
+ statusText: res.statusText,
67
+ headers,
68
+ body: body.slice(0, 2e3) + (body.length > 2e3 ? "..." : "")
69
+ });
70
+ } catch (e) {
71
+ console.error("[voice] Could not fetch URL for logging:", e);
72
+ }
73
+ }
74
+ var VoiceConnection = class extends import_events.EventEmitter {
75
+ client;
76
+ channel;
77
+ guildId;
78
+ _sessionId = null;
79
+ _token = null;
80
+ _endpoint = null;
81
+ _userId;
82
+ voiceWs = null;
83
+ udpSocket = null;
84
+ ssrc = 0;
85
+ secretKey = null;
86
+ heartbeatInterval = null;
87
+ sequence = 0;
88
+ timestamp = 0;
89
+ _playing = false;
90
+ _destroyed = false;
91
+ currentStream = null;
92
+ remoteUdpAddress = "";
93
+ remoteUdpPort = 0;
94
+ audioPacketQueue = [];
95
+ pacingInterval = null;
96
+ constructor(client, channel, userId) {
97
+ super();
98
+ this.client = client;
99
+ this.channel = channel;
100
+ this.guildId = channel.guildId;
101
+ this._userId = userId;
102
+ }
103
+ get sessionId() {
104
+ return this._sessionId;
105
+ }
106
+ get playing() {
107
+ return this._playing;
108
+ }
109
+ /** Called when we have both server update and state update. */
110
+ async connect(server, state) {
111
+ this._token = server.token;
112
+ const raw = (server.endpoint ?? "").trim();
113
+ this._sessionId = state.session_id;
114
+ if (!raw || !this._token || !this._sessionId) {
115
+ this.emit("error", new Error("Missing voice server or session data"));
116
+ return;
117
+ }
118
+ let wsUrl;
119
+ if (raw.includes("?")) {
120
+ wsUrl = /^wss?:\/\//i.test(raw) ? raw : raw.replace(/^https?:\/\//i, "wss://");
121
+ if (!/^wss?:\/\//i.test(wsUrl)) wsUrl = `wss://${wsUrl}`;
122
+ } else {
123
+ const normalized = raw.replace(/^(wss|ws|https?):\/\//i, "").replace(/^\/+/, "") || raw;
124
+ wsUrl = `wss://${normalized}?v=${VOICE_VERSION}`;
125
+ }
126
+ const hostPart = raw.replace(/^(wss|ws|https?):\/\//i, "").replace(/^\/+/, "").split("/")[0] ?? "";
127
+ this._endpoint = hostPart.split("?")[0] || hostPart;
128
+ const WS = await this.getWebSocketConstructor();
129
+ this.voiceWs = new WS(wsUrl);
130
+ return new Promise((resolve, reject) => {
131
+ const resolveReady = () => {
132
+ cleanup();
133
+ resolve();
134
+ this.emit("ready");
135
+ };
136
+ const onOpen = () => {
137
+ this.voiceWs.off("error", onError);
138
+ this.sendVoiceOp(VOICE_WS_OPCODES.Identify, {
139
+ server_id: this.guildId,
140
+ user_id: this._userId,
141
+ session_id: this._sessionId,
142
+ token: this._token
143
+ });
144
+ };
145
+ const onError = (err) => {
146
+ if (err instanceof Error && /Unexpected server response/i.test(err.message)) {
147
+ logFullResponse(wsUrl).catch(() => {
148
+ });
149
+ }
150
+ cleanup();
151
+ reject(err instanceof Error ? err : new Error(String(err)));
152
+ };
153
+ const onMessage = (data) => {
154
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
155
+ const payload = JSON.parse(buf.toString());
156
+ const op = payload.op;
157
+ const d = payload.d;
158
+ if (op === VOICE_WS_OPCODES.Ready) {
159
+ this.ssrc = d.ssrc;
160
+ const port = d.port;
161
+ const address = d.address ?? this._endpoint.split(":")[0];
162
+ this.remoteUdpAddress = address;
163
+ this.remoteUdpPort = port;
164
+ this.setupUDP(address, port, () => {
165
+ });
166
+ } else if (op === VOICE_WS_OPCODES.SessionDescription) {
167
+ this.secretKey = new Uint8Array(d.secret_key);
168
+ if (this.heartbeatInterval) {
169
+ clearInterval(this.heartbeatInterval);
170
+ this.heartbeatInterval = null;
171
+ }
172
+ this.heartbeatInterval = setInterval(() => {
173
+ this.sendVoiceOp(VOICE_WS_OPCODES.Heartbeat, Date.now());
174
+ }, d.heartbeat_interval ?? 5e3);
175
+ resolveReady();
176
+ } else if (op === VOICE_WS_OPCODES.Heartbeat) {
177
+ }
178
+ };
179
+ const cleanup = () => {
180
+ if (this.voiceWs) {
181
+ this.voiceWs.removeAllListeners();
182
+ }
183
+ };
184
+ const ws = this.voiceWs;
185
+ ws.on("open", onOpen);
186
+ ws.on("error", onError);
187
+ ws.on("message", (data) => onMessage(data));
188
+ ws.once("close", () => {
189
+ cleanup();
190
+ if (!this._destroyed) reject(new Error("Voice WebSocket closed"));
191
+ });
192
+ });
193
+ }
194
+ async getWebSocketConstructor() {
195
+ try {
196
+ const ws = await import("ws");
197
+ return ws.default;
198
+ } catch {
199
+ throw new Error('Install "ws" for voice support: pnpm add ws');
200
+ }
201
+ }
202
+ sendVoiceOp(op, d) {
203
+ if (!this.voiceWs || this.voiceWs.readyState !== 1) return;
204
+ this.voiceWs.send(JSON.stringify({ op, d }));
205
+ }
206
+ setupUDP(remoteAddress, remotePort, onReady) {
207
+ const socket = dgram.createSocket("udp4");
208
+ this.udpSocket = socket;
209
+ const discovery = Buffer.alloc(70);
210
+ discovery.writeUInt32BE(1, 0);
211
+ discovery.writeUInt16BE(70, 4);
212
+ discovery.writeUInt32BE(this.ssrc, 6);
213
+ socket.send(discovery, 0, discovery.length, remotePort, remoteAddress, () => {
214
+ socket.once("message", (msg) => {
215
+ const len = msg.readUInt16BE(4);
216
+ let ourIp = "";
217
+ let i = 10;
218
+ while (i < Math.min(70, len + 8) && msg[i] !== 0) {
219
+ ourIp += String.fromCharCode(msg[i]);
220
+ i++;
221
+ }
222
+ const ourPort = msg.readUInt16BE(68);
223
+ this.sendVoiceOp(VOICE_WS_OPCODES.SelectProtocol, {
224
+ protocol: "udp",
225
+ data: {
226
+ address: ourIp,
227
+ port: ourPort,
228
+ mode: "xsalsa20_poly1305"
229
+ }
230
+ });
231
+ onReady();
232
+ });
233
+ });
234
+ }
235
+ /**
236
+ * Play a stream of raw Opus packets
237
+ * Uses the same queue and 20ms pacing as play(). Use this for local files (MP3 → PCM → Opus) or other Opus sources.
238
+ */
239
+ playOpus(stream) {
240
+ this.stop();
241
+ this._playing = true;
242
+ this.currentStream = stream;
243
+ this.audioPacketQueue = [];
244
+ this.sendVoiceOp(VOICE_WS_OPCODES.Speaking, { speaking: 1, delay: 0 });
245
+ const stopPacing = () => {
246
+ if (this.pacingInterval) {
247
+ clearInterval(this.pacingInterval);
248
+ this.pacingInterval = null;
249
+ }
250
+ };
251
+ this.pacingInterval = setInterval(() => {
252
+ const packet = this.audioPacketQueue.shift();
253
+ if (packet && this.secretKey && this.udpSocket) this.sendAudioFrame(packet);
254
+ if (this.audioPacketQueue.length === 0 && !this._playing) stopPacing();
255
+ }, AUDIO_FRAME_INTERVAL_MS);
256
+ stream.on("data", (chunk) => {
257
+ if (!this._playing) return;
258
+ if (Buffer.isBuffer(chunk) && chunk.length > 0) this.audioPacketQueue.push(chunk);
259
+ });
260
+ stream.on("error", (err) => {
261
+ this._playing = false;
262
+ this.currentStream = null;
263
+ stopPacing();
264
+ this.emit("error", err);
265
+ });
266
+ stream.on("end", () => {
267
+ this._playing = false;
268
+ this.currentStream = null;
269
+ if (this.audioPacketQueue.length === 0) stopPacing();
270
+ });
271
+ }
272
+ /**
273
+ * Play a direct WebM/Opus URL or stream. Fetches the URL (if string), demuxes with prism-media WebmDemuxer,
274
+ * and sends Opus packets to the voice connection. No FFmpeg or encoding; input must be WebM with Opus.
275
+ */
276
+ async play(urlOrStream) {
277
+ this.stop();
278
+ const { opus: prismOpus } = await import("prism-media");
279
+ const { Readable } = await import("stream");
280
+ let inputStream;
281
+ if (typeof urlOrStream === "string") {
282
+ try {
283
+ const response = await fetch(urlOrStream);
284
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
285
+ if (!response.body) throw new Error("No response body");
286
+ inputStream = Readable.fromWeb(response.body);
287
+ } catch (e) {
288
+ const err = e instanceof Error ? e : new Error(String(e));
289
+ this.emit("error", err);
290
+ return;
291
+ }
292
+ } else {
293
+ inputStream = urlOrStream;
294
+ }
295
+ const demuxer = new prismOpus.WebmDemuxer();
296
+ inputStream.pipe(demuxer);
297
+ this._playing = true;
298
+ this.currentStream = demuxer;
299
+ this.audioPacketQueue = [];
300
+ this.sendVoiceOp(VOICE_WS_OPCODES.Speaking, { speaking: 1, delay: 0 });
301
+ const stopPacing = () => {
302
+ if (this.pacingInterval) {
303
+ clearInterval(this.pacingInterval);
304
+ this.pacingInterval = null;
305
+ }
306
+ };
307
+ this.pacingInterval = setInterval(() => {
308
+ const packet = this.audioPacketQueue.shift();
309
+ if (packet && this.secretKey && this.udpSocket) this.sendAudioFrame(packet);
310
+ if (this.audioPacketQueue.length === 0 && !this._playing) stopPacing();
311
+ }, AUDIO_FRAME_INTERVAL_MS);
312
+ demuxer.on("data", (chunk) => {
313
+ if (!this._playing) return;
314
+ if (Buffer.isBuffer(chunk) && chunk.length > 0) this.audioPacketQueue.push(chunk);
315
+ });
316
+ demuxer.on("error", (err) => {
317
+ this._playing = false;
318
+ this.currentStream = null;
319
+ stopPacing();
320
+ this.emit("error", err);
321
+ });
322
+ demuxer.on("end", () => {
323
+ this._playing = false;
324
+ this.currentStream = null;
325
+ if (this.audioPacketQueue.length === 0) stopPacing();
326
+ });
327
+ }
328
+ sendAudioFrame(opusPayload) {
329
+ if (!this.udpSocket || !this.secretKey) return;
330
+ const rtpHeader = Buffer.alloc(12);
331
+ rtpHeader[0] = 128;
332
+ rtpHeader[1] = 120;
333
+ rtpHeader.writeUInt16BE(this.sequence++, 2);
334
+ rtpHeader.writeUInt32BE(this.timestamp, 4);
335
+ rtpHeader.writeUInt32BE(this.ssrc, 8);
336
+ this.timestamp += OPUS_FRAME_TICKS;
337
+ const nonce = Buffer.alloc(24);
338
+ rtpHeader.copy(nonce, 0, 0, 12);
339
+ const encrypted = nacl.secretbox(opusPayload, new Uint8Array(nonce), this.secretKey);
340
+ const packet = Buffer.concat([rtpHeader, Buffer.from(encrypted)]);
341
+ if (this.remoteUdpPort && this.remoteUdpAddress && this.udpSocket) {
342
+ this.udpSocket.send(packet, 0, packet.length, this.remoteUdpPort, this.remoteUdpAddress);
343
+ }
344
+ }
345
+ stop() {
346
+ this._playing = false;
347
+ this.audioPacketQueue = [];
348
+ if (this.pacingInterval) {
349
+ clearInterval(this.pacingInterval);
350
+ this.pacingInterval = null;
351
+ }
352
+ if (this.currentStream) {
353
+ if (typeof this.currentStream.destroy === "function") this.currentStream.destroy();
354
+ this.currentStream = null;
355
+ }
356
+ }
357
+ disconnect() {
358
+ this._destroyed = true;
359
+ this.stop();
360
+ if (this.heartbeatInterval) {
361
+ clearInterval(this.heartbeatInterval);
362
+ this.heartbeatInterval = null;
363
+ }
364
+ if (this.voiceWs) {
365
+ this.voiceWs.close();
366
+ this.voiceWs = null;
367
+ }
368
+ if (this.udpSocket) {
369
+ this.udpSocket.close();
370
+ this.udpSocket = null;
371
+ }
372
+ this.emit("disconnect");
373
+ }
374
+ destroy() {
375
+ this.disconnect();
376
+ this.removeAllListeners();
377
+ }
378
+ };
379
+
380
+ // src/LiveKitRtcConnection.ts
381
+ var import_events2 = require("events");
382
+ var import_rtc_node = require("@livekit/rtc-node");
383
+
384
+ // src/livekit.ts
385
+ function isLiveKitEndpoint(endpoint, token) {
386
+ if (!endpoint || typeof endpoint !== "string") return false;
387
+ const s = endpoint.trim();
388
+ if (s.includes("access_token=") || s.includes("/rtc") && s.includes("?"))
389
+ return true;
390
+ if (token && !s.includes("?")) return true;
391
+ return false;
392
+ }
393
+ function buildLiveKitUrlForRtcSdk(endpoint) {
394
+ const base = endpoint.replace(/^(wss|ws|https?):\/\//i, "").replace(/^\/+/, "").split("/")[0] ?? endpoint;
395
+ const scheme = /^wss?:\/\//i.test(endpoint) ? endpoint.startsWith("wss") ? "wss" : "ws" : "wss";
396
+ return `${scheme}://${base.replace(/\/+$/, "")}`;
397
+ }
398
+
399
+ // src/opusUtils.ts
400
+ function parseOpusPacketBoundaries(buffer) {
401
+ if (buffer.length < 1) return null;
402
+ const toc = buffer[0];
403
+ const c = toc & 3;
404
+ const tocSingle = toc & 252 | 0;
405
+ if (c === 0) {
406
+ return { frames: [buffer.slice()], consumed: buffer.length };
407
+ }
408
+ if (c === 1) {
409
+ if (buffer.length < 2) return null;
410
+ const L1 = buffer[1] + 1;
411
+ if (buffer.length < 2 + L1) return null;
412
+ const L2 = buffer.length - 2 - L1;
413
+ const frame0 = new Uint8Array(1 + L1);
414
+ frame0[0] = tocSingle;
415
+ frame0.set(buffer.subarray(2, 2 + L1), 1);
416
+ const frame1 = new Uint8Array(1 + L2);
417
+ frame1[0] = tocSingle;
418
+ frame1.set(buffer.subarray(2 + L1), 1);
419
+ return { frames: [frame0, frame1], consumed: buffer.length };
420
+ }
421
+ if (c === 2) {
422
+ if (buffer.length < 3) return null;
423
+ const frameLen = Math.floor((buffer.length - 2) / 2);
424
+ if (frameLen < 1) return null;
425
+ const frame0 = new Uint8Array(1 + frameLen);
426
+ frame0[0] = tocSingle;
427
+ frame0.set(buffer.subarray(2, 2 + frameLen), 1);
428
+ const frame1 = new Uint8Array(1 + frameLen);
429
+ frame1[0] = tocSingle;
430
+ frame1.set(buffer.subarray(2 + frameLen, 2 + 2 * frameLen), 1);
431
+ return { frames: [frame0, frame1], consumed: 2 + 2 * frameLen };
432
+ }
433
+ if (c === 3) {
434
+ if (buffer.length < 2) return null;
435
+ const N = buffer[1];
436
+ if (N < 1 || N > 255) return null;
437
+ const numLengthBytes = N - 1;
438
+ if (buffer.length < 2 + numLengthBytes) return null;
439
+ const lengths = [];
440
+ for (let i = 0; i < numLengthBytes; i++) {
441
+ lengths.push(buffer[2 + i] + 1);
442
+ }
443
+ const headerLen = 2 + numLengthBytes;
444
+ let offset = headerLen;
445
+ const sumKnown = lengths.reduce((a, b) => a + b, 0);
446
+ const lastLen = buffer.length - headerLen - sumKnown;
447
+ if (lastLen < 0) return null;
448
+ lengths.push(lastLen);
449
+ const frames = [];
450
+ for (let i = 0; i < lengths.length; i++) {
451
+ const L = lengths[i];
452
+ if (offset + L > buffer.length) return null;
453
+ const frame = new Uint8Array(1 + L);
454
+ frame[0] = tocSingle;
455
+ frame.set(buffer.subarray(offset, offset + L), 1);
456
+ frames.push(frame);
457
+ offset += L;
458
+ }
459
+ return { frames, consumed: offset };
460
+ }
461
+ return null;
462
+ }
463
+ function concatUint8Arrays(a, b) {
464
+ const out = new Uint8Array(a.length + b.length);
465
+ out.set(a);
466
+ out.set(b, a.length);
467
+ return out;
468
+ }
469
+
470
+ // src/LiveKitRtcConnection.ts
471
+ var SAMPLE_RATE = 48e3;
472
+ var CHANNELS2 = 1;
473
+ var FRAME_SAMPLES = 480;
474
+ var VOICE_DEBUG = process.env.VOICE_DEBUG === "1" || process.env.VOICE_DEBUG === "true";
475
+ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
476
+ client;
477
+ channel;
478
+ guildId;
479
+ _playing = false;
480
+ _destroyed = false;
481
+ room = null;
482
+ audioSource = null;
483
+ audioTrack = null;
484
+ currentStream = null;
485
+ lastServerEndpoint = null;
486
+ lastServerToken = null;
487
+ _disconnectEmitted = false;
488
+ constructor(client, channel, _userId) {
489
+ super();
490
+ this.client = client;
491
+ this.channel = channel;
492
+ this.guildId = channel.guildId;
493
+ }
494
+ get playing() {
495
+ return this._playing;
496
+ }
497
+ debug(msg, data) {
498
+ console.error("[voice LiveKitRtc]", msg, data ?? "");
499
+ }
500
+ audioDebug(msg, data) {
501
+ if (VOICE_DEBUG) {
502
+ console.error("[voice LiveKitRtc audio]", msg, data ?? "");
503
+ }
504
+ }
505
+ emitDisconnect(source) {
506
+ if (this._disconnectEmitted) return;
507
+ this._disconnectEmitted = true;
508
+ this.debug("emitting disconnect", { source });
509
+ this.emit("disconnect");
510
+ }
511
+ /** Returns true if the LiveKit room is connected and not destroyed. */
512
+ isConnected() {
513
+ return !this._destroyed && this.room != null && this.room.isConnected;
514
+ }
515
+ /** Returns true if we're already connected to the given server (skip migration). */
516
+ isSameServer(endpoint, token) {
517
+ const ep = (endpoint ?? "").trim();
518
+ return ep === (this.lastServerEndpoint ?? "") && token === (this.lastServerToken ?? "");
519
+ }
520
+ playOpus(_stream) {
521
+ this.emit("error", new Error("LiveKit: playOpus not supported; use play(url) with a WebM/Opus URL"));
522
+ }
523
+ async connect(server, _state) {
524
+ const raw = (server.endpoint ?? "").trim();
525
+ const token = server.token;
526
+ if (!raw || !token) {
527
+ this.emit("error", new Error("Missing voice server endpoint or token"));
528
+ return;
529
+ }
530
+ const url = buildLiveKitUrlForRtcSdk(raw);
531
+ this._disconnectEmitted = false;
532
+ try {
533
+ const room = new import_rtc_node.Room();
534
+ this.room = room;
535
+ room.on(import_rtc_node.RoomEvent.Disconnected, () => {
536
+ this.debug("Room disconnected");
537
+ this.lastServerEndpoint = null;
538
+ this.lastServerToken = null;
539
+ setImmediate(() => this.emit("serverLeave"));
540
+ this.emitDisconnect("room_disconnected");
541
+ });
542
+ room.on(import_rtc_node.RoomEvent.Reconnecting, () => {
543
+ this.debug("Room reconnecting");
544
+ });
545
+ room.on(import_rtc_node.RoomEvent.Reconnected, () => {
546
+ this.debug("Room reconnected");
547
+ });
548
+ await room.connect(url, token, { autoSubscribe: false, dynacast: false });
549
+ this.lastServerEndpoint = raw;
550
+ this.lastServerToken = token;
551
+ this.debug("connected to room");
552
+ this.emit("ready");
553
+ } catch (e) {
554
+ this.room = null;
555
+ const err = e instanceof Error ? e : new Error(String(e));
556
+ this.emit("error", err);
557
+ throw err;
558
+ }
559
+ }
560
+ async play(urlOrStream) {
561
+ this.stop();
562
+ if (!this.room || !this.room.isConnected) {
563
+ this.emit("error", new Error("LiveKit: not connected"));
564
+ return;
565
+ }
566
+ const { opus: prismOpus } = await import("prism-media");
567
+ const { Readable } = await import("stream");
568
+ const { OpusDecoder } = await import("opus-decoder");
569
+ let inputStream;
570
+ if (typeof urlOrStream === "string") {
571
+ try {
572
+ const response = await fetch(urlOrStream);
573
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
574
+ if (!response.body) throw new Error("No response body");
575
+ inputStream = Readable.fromWeb(response.body);
576
+ } catch (e) {
577
+ this.emit("error", e instanceof Error ? e : new Error(String(e)));
578
+ return;
579
+ }
580
+ } else {
581
+ inputStream = urlOrStream;
582
+ }
583
+ const source = new import_rtc_node.AudioSource(SAMPLE_RATE, CHANNELS2);
584
+ this.audioSource = source;
585
+ const track = import_rtc_node.LocalAudioTrack.createAudioTrack("audio", source);
586
+ this.audioTrack = track;
587
+ const options = new import_rtc_node.TrackPublishOptions();
588
+ options.source = import_rtc_node.TrackSource.SOURCE_MICROPHONE;
589
+ await this.room.localParticipant.publishTrack(track, options);
590
+ const demuxer = new prismOpus.WebmDemuxer();
591
+ inputStream.pipe(demuxer);
592
+ this.currentStream = demuxer;
593
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
594
+ await decoder.ready;
595
+ this._playing = true;
596
+ function floatToInt16(float32) {
597
+ const int16 = new Int16Array(float32.length);
598
+ for (let i = 0; i < float32.length; i++) {
599
+ let s = float32[i];
600
+ if (!Number.isFinite(s)) {
601
+ int16[i] = 0;
602
+ continue;
603
+ }
604
+ s = Math.max(-1, Math.min(1, s));
605
+ const scale = s < 0 ? 32768 : 32767;
606
+ const dither = (Math.random() + Math.random() - 1) * 0.5;
607
+ const scaled = Math.round(s * scale + dither);
608
+ int16[i] = Math.max(-32768, Math.min(32767, scaled));
609
+ }
610
+ return int16;
611
+ }
612
+ let sampleBuffer = new Int16Array(0);
613
+ let opusBuffer = new Uint8Array(0);
614
+ let streamEnded = false;
615
+ let framesCaptured = 0;
616
+ const processOneOpusFrame = async (frame) => {
617
+ if (frame.length < 2) return;
618
+ try {
619
+ const result = decoder.decodeFrame(frame);
620
+ if (!result?.channelData?.[0]?.length) return;
621
+ const int16 = floatToInt16(result.channelData[0]);
622
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
623
+ newBuffer.set(sampleBuffer);
624
+ newBuffer.set(int16, sampleBuffer.length);
625
+ sampleBuffer = newBuffer;
626
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
627
+ const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
628
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
629
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
630
+ if (source.queuedDuration > 500) {
631
+ await source.waitForPlayout();
632
+ }
633
+ await source.captureFrame(audioFrame);
634
+ framesCaptured++;
635
+ }
636
+ } catch (err) {
637
+ if (VOICE_DEBUG) this.audioDebug("decode error", { error: String(err) });
638
+ }
639
+ };
640
+ let firstChunk = true;
641
+ let processing = false;
642
+ const opusFrameQueue = [];
643
+ const drainOpusQueue = async () => {
644
+ if (processing || opusFrameQueue.length === 0) return;
645
+ processing = true;
646
+ while (opusFrameQueue.length > 0 && this._playing && source) {
647
+ const frame = opusFrameQueue.shift();
648
+ await processOneOpusFrame(frame);
649
+ }
650
+ processing = false;
651
+ };
652
+ demuxer.on("data", (chunk) => {
653
+ if (!this._playing) return;
654
+ if (firstChunk) {
655
+ this.audioDebug("first audio chunk received", { size: chunk.length });
656
+ firstChunk = false;
657
+ }
658
+ opusBuffer = concatUint8Arrays(opusBuffer, new Uint8Array(chunk));
659
+ while (opusBuffer.length > 0) {
660
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
661
+ if (!parsed) break;
662
+ opusBuffer = opusBuffer.slice(parsed.consumed);
663
+ for (const frame of parsed.frames) {
664
+ opusFrameQueue.push(frame);
665
+ }
666
+ }
667
+ drainOpusQueue().catch((e) => this.audioDebug("drainOpusQueue error", { error: String(e) }));
668
+ });
669
+ demuxer.on("error", (err) => {
670
+ this.audioDebug("demuxer error", { error: err.message });
671
+ this._playing = false;
672
+ this.currentStream = null;
673
+ this.emit("error", err);
674
+ });
675
+ demuxer.on("end", async () => {
676
+ streamEnded = true;
677
+ this.audioDebug("stream ended", { framesCaptured });
678
+ while (processing || opusFrameQueue.length > 0) {
679
+ await drainOpusQueue();
680
+ await new Promise((r) => setImmediate(r));
681
+ }
682
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
683
+ const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
684
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
685
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
686
+ await source.captureFrame(audioFrame);
687
+ framesCaptured++;
688
+ }
689
+ if (sampleBuffer.length > 0 && this._playing && source) {
690
+ const padded = new Int16Array(FRAME_SAMPLES);
691
+ padded.set(sampleBuffer);
692
+ const audioFrame = new import_rtc_node.AudioFrame(padded, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
693
+ await source.captureFrame(audioFrame);
694
+ framesCaptured++;
695
+ }
696
+ this.audioDebug("playback complete", { framesCaptured });
697
+ this._playing = false;
698
+ this.currentStream = null;
699
+ if (this.audioTrack) {
700
+ await this.audioTrack.close();
701
+ this.audioTrack = null;
702
+ }
703
+ if (this.audioSource) {
704
+ await this.audioSource.close();
705
+ this.audioSource = null;
706
+ }
707
+ });
708
+ }
709
+ stop() {
710
+ this._playing = false;
711
+ if (this.currentStream?.destroy) this.currentStream.destroy();
712
+ this.currentStream = null;
713
+ if (this.audioTrack) {
714
+ this.audioTrack.close().catch(() => {
715
+ });
716
+ this.audioTrack = null;
717
+ }
718
+ if (this.audioSource) {
719
+ this.audioSource.close().catch(() => {
720
+ });
721
+ this.audioSource = null;
722
+ }
723
+ }
724
+ disconnect() {
725
+ this._destroyed = true;
726
+ this.stop();
727
+ if (this.room) {
728
+ this.room.disconnect().catch(() => {
729
+ });
730
+ this.room = null;
731
+ }
732
+ this.lastServerEndpoint = null;
733
+ this.lastServerToken = null;
734
+ this.emit("disconnect");
735
+ }
736
+ destroy() {
737
+ this.disconnect();
738
+ this.removeAllListeners();
739
+ }
740
+ };
741
+
742
+ // src/VoiceManager.ts
743
+ var import_collection = require("@fluxerjs/collection");
744
+ var VoiceManager = class extends import_events3.EventEmitter {
745
+ client;
746
+ connections = new import_collection.Collection();
747
+ /** guild_id -> user_id -> channel_id */
748
+ voiceStates = /* @__PURE__ */ new Map();
749
+ pending = /* @__PURE__ */ new Map();
750
+ shardId;
751
+ constructor(client, options = {}) {
752
+ super();
753
+ this.client = client;
754
+ this.shardId = options.shardId ?? 0;
755
+ this.client.on(import_core.Events.VoiceStateUpdate, (data) => this.handleVoiceStateUpdate(data));
756
+ this.client.on(import_core.Events.VoiceServerUpdate, (data) => this.handleVoiceServerUpdate(data));
757
+ this.client.on(import_core.Events.VoiceStatesSync, (data) => this.handleVoiceStatesSync(data));
758
+ }
759
+ handleVoiceStatesSync(data) {
760
+ let guildMap = this.voiceStates.get(data.guildId);
761
+ if (!guildMap) {
762
+ guildMap = /* @__PURE__ */ new Map();
763
+ this.voiceStates.set(data.guildId, guildMap);
764
+ }
765
+ for (const vs of data.voiceStates) {
766
+ guildMap.set(vs.user_id, vs.channel_id);
767
+ }
768
+ }
769
+ /** Get the voice channel ID the user is in, or null. */
770
+ getVoiceChannelId(guildId, userId) {
771
+ const guildMap = this.voiceStates.get(guildId);
772
+ if (!guildMap) return null;
773
+ return guildMap.get(userId) ?? null;
774
+ }
775
+ handleVoiceStateUpdate(data) {
776
+ const guildId = data.guild_id ?? "";
777
+ if (!guildId) return;
778
+ let guildMap = this.voiceStates.get(guildId);
779
+ if (!guildMap) {
780
+ guildMap = /* @__PURE__ */ new Map();
781
+ this.voiceStates.set(guildId, guildMap);
782
+ }
783
+ guildMap.set(data.user_id, data.channel_id);
784
+ const pending = this.pending.get(guildId);
785
+ if (pending && data.user_id === this.client.user?.id) {
786
+ pending.state = data;
787
+ this.tryCompletePending(guildId);
788
+ }
789
+ }
790
+ handleVoiceServerUpdate(data) {
791
+ const guildId = data.guild_id;
792
+ const pending = this.pending.get(guildId);
793
+ if (pending) {
794
+ pending.server = data;
795
+ this.tryCompletePending(guildId);
796
+ return;
797
+ }
798
+ const conn = this.connections.get(guildId);
799
+ if (!conn) return;
800
+ if (!data.endpoint || !data.token) {
801
+ this.client.emit?.("debug", `[VoiceManager] Voice server endpoint null for guild ${guildId}; disconnecting until new allocation`);
802
+ conn.destroy();
803
+ this.connections.delete(guildId);
804
+ return;
805
+ }
806
+ if (!isLiveKitEndpoint(data.endpoint, data.token)) return;
807
+ if (conn instanceof LiveKitRtcConnection && conn.isSameServer(data.endpoint, data.token)) {
808
+ return;
809
+ }
810
+ const channel = conn.channel;
811
+ this.client.emit?.("debug", `[VoiceManager] Voice server migration for guild ${guildId}; reconnecting`);
812
+ conn.destroy();
813
+ this.connections.delete(guildId);
814
+ const ConnClass = LiveKitRtcConnection;
815
+ const newConn = new ConnClass(this.client, channel, this.client.user.id);
816
+ this.registerConnection(guildId, newConn);
817
+ const state = {
818
+ guild_id: guildId,
819
+ channel_id: channel.id,
820
+ user_id: this.client.user.id,
821
+ session_id: ""
822
+ };
823
+ newConn.connect(data, state).catch((e) => {
824
+ this.connections.delete(guildId);
825
+ newConn.emit("error", e instanceof Error ? e : new Error(String(e)));
826
+ });
827
+ }
828
+ registerConnection(guildId, conn) {
829
+ this.connections.set(guildId, conn);
830
+ conn.once("disconnect", () => this.connections.delete(guildId));
831
+ }
832
+ tryCompletePending(guildId) {
833
+ const pending = this.pending.get(guildId);
834
+ if (!pending?.server || !pending.state) return;
835
+ this.pending.delete(guildId);
836
+ const ConnClass = isLiveKitEndpoint(pending.server.endpoint, pending.server.token) ? LiveKitRtcConnection : VoiceConnection;
837
+ const conn = new ConnClass(this.client, pending.channel, this.client.user.id);
838
+ this.registerConnection(guildId, conn);
839
+ conn.connect(pending.server, pending.state).then(
840
+ () => pending.resolve(conn),
841
+ (e) => pending.reject(e)
842
+ );
843
+ }
844
+ /** Join a voice channel. Resolves when the connection is ready. */
845
+ async join(channel) {
846
+ const existing = this.connections.get(channel.guildId);
847
+ if (existing) {
848
+ const isReusable = existing.channel.id === channel.id && (existing instanceof LiveKitRtcConnection ? existing.isConnected() : true);
849
+ if (isReusable) return existing;
850
+ existing.destroy();
851
+ this.connections.delete(channel.guildId);
852
+ }
853
+ return new Promise((resolve, reject) => {
854
+ const timeout = setTimeout(() => {
855
+ if (this.pending.has(channel.guildId)) {
856
+ this.pending.delete(channel.guildId);
857
+ reject(new Error("Voice connection timeout"));
858
+ }
859
+ }, 15e3);
860
+ this.pending.set(channel.guildId, {
861
+ channel,
862
+ resolve: (c) => {
863
+ clearTimeout(timeout);
864
+ resolve(c);
865
+ },
866
+ reject: (e) => {
867
+ clearTimeout(timeout);
868
+ reject(e);
869
+ }
870
+ });
871
+ this.client.sendToGateway(this.shardId, {
872
+ op: import_types.GatewayOpcodes.VoiceStateUpdate,
873
+ d: {
874
+ guild_id: channel.guildId,
875
+ channel_id: channel.id,
876
+ self_mute: false,
877
+ self_deaf: false
878
+ }
879
+ });
880
+ });
881
+ }
882
+ /** Leave a guild's voice channel. */
883
+ leave(guildId) {
884
+ const conn = this.connections.get(guildId);
885
+ if (conn) {
886
+ conn.destroy();
887
+ this.connections.delete(guildId);
888
+ }
889
+ this.client.sendToGateway(this.shardId, {
890
+ op: import_types.GatewayOpcodes.VoiceStateUpdate,
891
+ d: {
892
+ guild_id: guildId,
893
+ channel_id: null,
894
+ self_mute: false,
895
+ self_deaf: false
896
+ }
897
+ });
898
+ }
899
+ getConnection(guildId) {
900
+ return this.connections.get(guildId);
901
+ }
902
+ };
903
+
904
+ // src/index.ts
905
+ async function joinVoiceChannel(client, channel, options) {
906
+ const manager = getVoiceManager(client, options);
907
+ return manager.join(channel);
908
+ }
909
+ var voiceManagers = /* @__PURE__ */ new WeakMap();
910
+ function getVoiceManager(client, options) {
911
+ let manager = voiceManagers.get(client);
912
+ if (!manager) {
913
+ manager = new VoiceManager(client, options);
914
+ voiceManagers.set(client, manager);
915
+ }
916
+ return manager;
917
+ }
918
+ // Annotate the CommonJS export names for ESM import in node:
919
+ 0 && (module.exports = {
920
+ LiveKitRtcConnection,
921
+ VoiceConnection,
922
+ VoiceManager,
923
+ getVoiceManager,
924
+ joinVoiceChannel
925
+ });