@fluxerjs/voice 1.1.7 → 1.1.9

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.mjs CHANGED
@@ -545,11 +545,21 @@ function floatToInt16(float32) {
545
545
  }
546
546
  return int16;
547
547
  }
548
+ function applyVolumeToInt16(samples, volumePercent) {
549
+ const vol = (volumePercent ?? 100) / 100;
550
+ if (vol === 1) return samples;
551
+ const out = new Int16Array(samples.length);
552
+ for (let i = 0; i < samples.length; i++) {
553
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(samples[i] * vol)));
554
+ }
555
+ return out;
556
+ }
548
557
  var VOICE_DEBUG = process.env.VOICE_DEBUG === "1" || process.env.VOICE_DEBUG === "true";
549
558
  var LiveKitRtcConnection = class extends EventEmitter2 {
550
559
  client;
551
560
  channel;
552
561
  guildId;
562
+ _volume = 100;
553
563
  _playing = false;
554
564
  _playingVideo = false;
555
565
  _destroyed = false;
@@ -606,6 +616,14 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
606
616
  const ep = (endpoint ?? "").trim();
607
617
  return ep === (this.lastServerEndpoint ?? "") && token === (this.lastServerToken ?? "");
608
618
  }
619
+ /** Set playback volume (0-200, 100 = normal). Affects current and future playback. */
620
+ setVolume(volumePercent) {
621
+ this._volume = Math.max(0, Math.min(200, volumePercent ?? 100));
622
+ }
623
+ /** Get current volume (0-200). */
624
+ getVolume() {
625
+ return this._volume ?? 100;
626
+ }
609
627
  playOpus(_stream) {
610
628
  this.emit(
611
629
  "error",
@@ -682,7 +700,8 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
682
700
  this.emit("error", new Error("LiveKit: not connected"));
683
701
  return;
684
702
  }
685
- const useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === "1";
703
+ let useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === "1";
704
+ if (options?.resolution) useFFmpeg = true;
686
705
  if (useFFmpeg && typeof urlOrBuffer === "string") {
687
706
  await this.playVideoFFmpeg(urlOrBuffer, options);
688
707
  return;
@@ -1079,8 +1098,9 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1079
1098
  newBuffer.set(int16, sampleBuffer.length);
1080
1099
  sampleBuffer = newBuffer;
1081
1100
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1082
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1101
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1083
1102
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1103
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1084
1104
  const audioFrame = new AudioFrame(
1085
1105
  outSamples,
1086
1106
  SAMPLE_RATE,
@@ -1147,6 +1167,7 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1147
1167
  const loop = options?.loop ?? true;
1148
1168
  let width = 640;
1149
1169
  let height = 480;
1170
+ let hasAudio = false;
1150
1171
  try {
1151
1172
  const { execFile } = await import("child_process");
1152
1173
  const { promisify } = await import("util");
@@ -1156,10 +1177,9 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1156
1177
  [
1157
1178
  "-v",
1158
1179
  "error",
1159
- "-select_streams",
1160
- "v:0",
1180
+ "-show_streams",
1161
1181
  "-show_entries",
1162
- "stream=width,height",
1182
+ "stream=codec_type,width,height",
1163
1183
  "-of",
1164
1184
  "json",
1165
1185
  url
@@ -1167,10 +1187,19 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1167
1187
  { encoding: "utf8", timeout: 1e4 }
1168
1188
  );
1169
1189
  const parsed = JSON.parse(stdout);
1170
- const stream = parsed?.streams?.[0];
1171
- if (stream?.width && stream?.height) {
1172
- width = stream.width;
1173
- height = stream.height;
1190
+ const streams = parsed?.streams ?? [];
1191
+ for (const s of streams) {
1192
+ if (s.codec_type === "video" && s.width != null && s.height != null) {
1193
+ width = s.width;
1194
+ height = s.height;
1195
+ break;
1196
+ }
1197
+ }
1198
+ for (const s of streams) {
1199
+ if (s.codec_type === "audio") {
1200
+ hasAudio = true;
1201
+ break;
1202
+ }
1174
1203
  }
1175
1204
  } catch (probeErr) {
1176
1205
  this.emit(
@@ -1181,7 +1210,29 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1181
1210
  );
1182
1211
  return;
1183
1212
  }
1184
- if (options?.width && options?.height) {
1213
+ let maxFps = options?.maxFramerate ?? 60;
1214
+ const res = options?.resolution;
1215
+ if (res === "480p") {
1216
+ width = 854;
1217
+ height = 480;
1218
+ maxFps = 60;
1219
+ } else if (res === "720p") {
1220
+ width = 1280;
1221
+ height = 720;
1222
+ maxFps = 60;
1223
+ } else if (res === "1080p") {
1224
+ width = 1920;
1225
+ height = 1080;
1226
+ maxFps = 60;
1227
+ } else if (res === "1440p") {
1228
+ width = 2560;
1229
+ height = 1440;
1230
+ maxFps = 60;
1231
+ } else if (res === "4k") {
1232
+ width = 3840;
1233
+ height = 2160;
1234
+ maxFps = 60;
1235
+ } else if (options?.width != null && options?.height != null) {
1185
1236
  width = options.width;
1186
1237
  height = options.height;
1187
1238
  }
@@ -1193,7 +1244,7 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1193
1244
  source: sourceOption === "screenshare" ? TrackSource.SOURCE_SCREENSHARE : TrackSource.SOURCE_CAMERA,
1194
1245
  videoEncoding: {
1195
1246
  maxBitrate: BigInt(options?.videoBitrate ?? 25e5),
1196
- maxFramerate: options?.maxFramerate ?? 60
1247
+ maxFramerate: maxFps
1197
1248
  }
1198
1249
  });
1199
1250
  const participant = this.room?.localParticipant;
@@ -1204,24 +1255,29 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1204
1255
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
1205
1256
  return;
1206
1257
  }
1207
- let audioFfmpegProc = null;
1208
- const audioSource = new AudioSource(SAMPLE_RATE, CHANNELS2);
1209
- const audioTrack = LocalAudioTrack.createAudioTrack(
1210
- "audio",
1211
- audioSource
1212
- );
1213
- this.audioSource = audioSource;
1214
- this.audioTrack = audioTrack;
1215
- try {
1216
- await participant.publishTrack(
1217
- audioTrack,
1218
- new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE })
1219
- );
1220
- } catch {
1221
- audioTrack.close().catch(() => {
1222
- });
1223
- this.audioTrack = null;
1258
+ let audioSource = null;
1259
+ let audioReady = false;
1260
+ if (hasAudio) {
1261
+ const src = new AudioSource(SAMPLE_RATE, CHANNELS2);
1262
+ audioSource = src;
1263
+ this.audioSource = src;
1264
+ const track2 = LocalAudioTrack.createAudioTrack("audio", src);
1265
+ this.audioTrack = track2;
1266
+ try {
1267
+ await participant.publishTrack(
1268
+ track2,
1269
+ new TrackPublishOptions({ source: TrackSource.SOURCE_MICROPHONE })
1270
+ );
1271
+ audioReady = true;
1272
+ } catch {
1273
+ track2.close().catch(() => {
1274
+ });
1275
+ this.audioTrack = null;
1276
+ this.audioSource = null;
1277
+ }
1278
+ } else {
1224
1279
  this.audioSource = null;
1280
+ this.audioTrack = null;
1225
1281
  }
1226
1282
  this._playingVideo = true;
1227
1283
  this.emit("requestVoiceStateSync", {
@@ -1229,7 +1285,6 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1229
1285
  self_video: sourceOption === "camera"
1230
1286
  });
1231
1287
  const frameSize = Math.ceil(width * height * 3 / 2);
1232
- const maxFps = options?.maxFramerate ?? 60;
1233
1288
  const FRAME_INTERVAL_MS = Math.round(1e3 / maxFps);
1234
1289
  let pacingTimeout = null;
1235
1290
  let ffmpegProc = null;
@@ -1247,10 +1302,6 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1247
1302
  ffmpegProc.kill("SIGKILL");
1248
1303
  ffmpegProc = null;
1249
1304
  }
1250
- if (audioFfmpegProc && !audioFfmpegProc.killed) {
1251
- audioFfmpegProc.kill("SIGKILL");
1252
- audioFfmpegProc = null;
1253
- }
1254
1305
  this.emit("requestVoiceStateSync", { self_stream: false, self_video: false });
1255
1306
  this.currentVideoStream = null;
1256
1307
  if (this.audioTrack) {
@@ -1335,32 +1386,38 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1335
1386
  pacingTimeout = setTimeout(scheduleNextPacing, FRAME_INTERVAL_MS);
1336
1387
  };
1337
1388
  scheduleNextPacing();
1338
- const runFFmpeg = () => {
1389
+ const runFFmpeg = async () => {
1339
1390
  const ffmpegArgs = [
1340
1391
  "-loglevel",
1341
1392
  "warning",
1342
1393
  "-re",
1394
+ ...loop ? ["-stream_loop", "-1"] : [],
1343
1395
  "-i",
1344
1396
  url,
1397
+ "-map",
1398
+ "0:v",
1399
+ "-vf",
1400
+ `scale=${width}:${height}`,
1401
+ "-r",
1402
+ String(maxFps),
1345
1403
  "-f",
1346
1404
  "rawvideo",
1347
1405
  "-pix_fmt",
1348
1406
  "yuv420p",
1349
- "-r",
1350
- String(maxFps)
1407
+ "-an",
1408
+ "pipe:1",
1409
+ ...hasAudio ? ["-map", "0:a", "-c:a", "libopus", "-f", "webm", "-vn", "pipe:3"] : []
1351
1410
  ];
1352
- if (options?.width && options?.height) {
1353
- ffmpegArgs.splice(ffmpegArgs.indexOf("-f"), 0, "-vf", `scale=${width}:${height}`);
1354
- }
1355
- ffmpegArgs.push("-");
1356
- ffmpegProc = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
1411
+ const stdioOpts = hasAudio ? ["ignore", "pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"];
1412
+ const proc = spawn("ffmpeg", ffmpegArgs, { stdio: stdioOpts });
1413
+ ffmpegProc = proc;
1357
1414
  this.currentVideoStream = {
1358
1415
  destroy: () => {
1359
- if (ffmpegProc && !ffmpegProc.killed) ffmpegProc.kill("SIGKILL");
1416
+ if (proc && !proc.killed) proc.kill("SIGKILL");
1360
1417
  }
1361
1418
  };
1362
- const stdout = ffmpegProc.stdout;
1363
- const stderr = ffmpegProc.stderr;
1419
+ const stdout = proc.stdout;
1420
+ const stderr = proc.stderr;
1364
1421
  if (stdout) {
1365
1422
  stdout.on("data", (chunk) => {
1366
1423
  if (!this._playingVideo || cleanupCalled) return;
@@ -1374,105 +1431,79 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1374
1431
  if (line && VOICE_DEBUG) this.audioDebug("ffmpeg stderr", { line: line.slice(0, 200) });
1375
1432
  });
1376
1433
  }
1377
- ffmpegProc.on("error", (err) => {
1434
+ if (hasAudio && audioReady && audioSource && proc.stdio[3]) {
1435
+ const audioPipe = proc.stdio[3];
1436
+ const { opus: prismOpus } = await import("prism-media");
1437
+ const { OpusDecoder } = await import("opus-decoder");
1438
+ const demuxer = new prismOpus.WebmDemuxer();
1439
+ audioPipe.pipe(demuxer);
1440
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
1441
+ await decoder.ready;
1442
+ let sampleBuffer = new Int16Array(0);
1443
+ let opusBuffer = new Uint8Array(0);
1444
+ let processing = false;
1445
+ const opusFrameQueue = [];
1446
+ const processOneOpusFrame = async (frame) => {
1447
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1448
+ try {
1449
+ const result = decoder.decodeFrame(frame);
1450
+ if (!result?.channelData?.[0]?.length) return;
1451
+ const int16 = floatToInt16(result.channelData[0]);
1452
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1453
+ newBuffer.set(sampleBuffer);
1454
+ newBuffer.set(int16, sampleBuffer.length);
1455
+ sampleBuffer = newBuffer;
1456
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1457
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1458
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1459
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1460
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1461
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1462
+ await audioSource.captureFrame(audioFrame);
1463
+ }
1464
+ } catch {
1465
+ }
1466
+ };
1467
+ const drainQueue = async () => {
1468
+ if (processing || opusFrameQueue.length === 0) return;
1469
+ processing = true;
1470
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1471
+ const f = opusFrameQueue.shift();
1472
+ await processOneOpusFrame(f);
1473
+ }
1474
+ processing = false;
1475
+ };
1476
+ demuxer.on("data", (chunk) => {
1477
+ if (!this._playingVideo) return;
1478
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1479
+ while (opusBuffer.length > 0) {
1480
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1481
+ if (!parsed) break;
1482
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1483
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
1484
+ }
1485
+ drainQueue().catch(() => {
1486
+ });
1487
+ });
1488
+ }
1489
+ proc.on("error", (err) => {
1378
1490
  this.emit("error", err);
1379
1491
  doCleanup();
1380
1492
  });
1381
- ffmpegProc.on("exit", (code) => {
1493
+ proc.on("exit", (code) => {
1382
1494
  ffmpegProc = null;
1383
1495
  if (cleanupCalled || !this._playingVideo) return;
1384
1496
  if (loop && (code === 0 || code === null)) {
1385
1497
  frameBuffer.length = 0;
1386
1498
  frameBufferBytes = 0;
1387
1499
  frameIndex = 0n;
1388
- setImmediate(runFFmpeg);
1500
+ setImmediate(() => runFFmpeg());
1389
1501
  } else {
1390
1502
  doCleanup();
1391
1503
  }
1392
1504
  });
1393
1505
  };
1394
- runFFmpeg();
1395
- const runAudioFfmpeg = async () => {
1396
- if (!this._playingVideo || cleanupCalled || !audioSource) return;
1397
- const audioProc = spawn(
1398
- "ffmpeg",
1399
- [
1400
- "-loglevel",
1401
- "warning",
1402
- "-re",
1403
- "-i",
1404
- url,
1405
- "-vn",
1406
- "-c:a",
1407
- "libopus",
1408
- "-f",
1409
- "webm",
1410
- ...loop ? ["-stream_loop", "-1"] : [],
1411
- "pipe:1"
1412
- ],
1413
- { stdio: ["ignore", "pipe", "pipe"] }
1414
- );
1415
- audioFfmpegProc = audioProc;
1416
- const { opus: prismOpus } = await import("prism-media");
1417
- const { OpusDecoder } = await import("opus-decoder");
1418
- const demuxer = new prismOpus.WebmDemuxer();
1419
- if (audioProc.stdout) audioProc.stdout.pipe(demuxer);
1420
- const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
1421
- await decoder.ready;
1422
- let sampleBuffer = new Int16Array(0);
1423
- let opusBuffer = new Uint8Array(0);
1424
- let processing = false;
1425
- const opusFrameQueue = [];
1426
- const processOneOpusFrame = async (frame) => {
1427
- if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1428
- try {
1429
- const result = decoder.decodeFrame(frame);
1430
- if (!result?.channelData?.[0]?.length) return;
1431
- const int16 = floatToInt16(result.channelData[0]);
1432
- const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1433
- newBuffer.set(sampleBuffer);
1434
- newBuffer.set(int16, sampleBuffer.length);
1435
- sampleBuffer = newBuffer;
1436
- while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1437
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1438
- sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1439
- const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1440
- if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1441
- await audioSource.captureFrame(audioFrame);
1442
- }
1443
- } catch {
1444
- }
1445
- };
1446
- const drainQueue = async () => {
1447
- if (processing || opusFrameQueue.length === 0) return;
1448
- processing = true;
1449
- while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1450
- const f = opusFrameQueue.shift();
1451
- await processOneOpusFrame(f);
1452
- }
1453
- processing = false;
1454
- };
1455
- demuxer.on("data", (chunk) => {
1456
- if (!this._playingVideo) return;
1457
- opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1458
- while (opusBuffer.length > 0) {
1459
- const parsed = parseOpusPacketBoundaries(opusBuffer);
1460
- if (!parsed) break;
1461
- opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1462
- for (const frame of parsed.frames) opusFrameQueue.push(frame);
1463
- }
1464
- drainQueue().catch(() => {
1465
- });
1466
- });
1467
- audioProc.on("exit", (code) => {
1468
- if (audioFfmpegProc === audioProc) audioFfmpegProc = null;
1469
- if (loop && this._playingVideo && !cleanupCalled && (code === 0 || code === null)) {
1470
- setImmediate(() => runAudioFfmpeg());
1471
- }
1472
- });
1473
- };
1474
- runAudioFfmpeg().catch(() => {
1475
- });
1506
+ runFFmpeg().catch((e) => this.audioDebug("ffmpeg error", { error: String(e) }));
1476
1507
  }
1477
1508
  /**
1478
1509
  * Play audio from a WebM/Opus URL or readable stream. Publishes to the LiveKit room as an audio track.
@@ -1531,8 +1562,9 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1531
1562
  newBuffer.set(int16, sampleBuffer.length);
1532
1563
  sampleBuffer = newBuffer;
1533
1564
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
1534
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1565
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1535
1566
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1567
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1536
1568
  const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1537
1569
  if (source.queuedDuration > 500) {
1538
1570
  await source.waitForPlayout();
@@ -1587,8 +1619,9 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1587
1619
  await new Promise((r) => setImmediate(r));
1588
1620
  }
1589
1621
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
1590
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1622
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1591
1623
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1624
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
1592
1625
  const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1593
1626
  await source.captureFrame(audioFrame);
1594
1627
  framesCaptured++;
@@ -1596,7 +1629,8 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1596
1629
  if (sampleBuffer.length > 0 && this._playing && source) {
1597
1630
  const padded = new Int16Array(FRAME_SAMPLES);
1598
1631
  padded.set(sampleBuffer);
1599
- const audioFrame = new AudioFrame(padded, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1632
+ const outSamples = applyVolumeToInt16(padded, this._volume);
1633
+ const audioFrame = new AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1600
1634
  await source.captureFrame(audioFrame);
1601
1635
  framesCaptured++;
1602
1636
  }
@@ -1686,11 +1720,13 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1686
1720
  import { Collection } from "@fluxerjs/collection";
1687
1721
  var VoiceManager = class extends EventEmitter3 {
1688
1722
  client;
1723
+ /** channel_id -> connection (Fluxer multi-channel: allows multiple connections per guild) */
1689
1724
  connections = new Collection();
1690
- /** guild_id -> connection_id (from VoiceServerUpdate; required for voice state updates when in channel) */
1725
+ /** channel_id -> connection_id (from VoiceServerUpdate; required for voice state updates) */
1691
1726
  connectionIds = /* @__PURE__ */ new Map();
1692
1727
  /** guild_id -> user_id -> channel_id */
1693
1728
  voiceStates = /* @__PURE__ */ new Map();
1729
+ /** channel_id -> pending join */
1694
1730
  pending = /* @__PURE__ */ new Map();
1695
1731
  shardId;
1696
1732
  constructor(client, options = {}) {
@@ -1743,31 +1779,43 @@ var VoiceManager = class extends EventEmitter3 {
1743
1779
  this.voiceStates.set(guildId, guildMap);
1744
1780
  }
1745
1781
  guildMap.set(data.user_id, data.channel_id);
1746
- const pending = this.pending.get(guildId);
1782
+ const channelKey = data.channel_id ?? guildId;
1783
+ const pendingByChannel = this.pending.get(channelKey);
1784
+ const pendingByGuild = this.pending.get(guildId);
1785
+ const pending = pendingByChannel ?? pendingByGuild;
1747
1786
  const isBot = String(data.user_id) === String(this.client.user?.id);
1748
1787
  if (isBot && data.connection_id) {
1749
- this.storeConnectionId(guildId, data.connection_id);
1788
+ this.storeConnectionId(channelKey, data.connection_id);
1750
1789
  }
1751
1790
  if (pending && isBot) {
1752
1791
  this.client.emit?.(
1753
1792
  "debug",
1754
- `[VoiceManager] VoiceStateUpdate for bot - completing pending guild ${guildId}`
1793
+ `[VoiceManager] VoiceStateUpdate for bot - completing pending channel ${channelKey}`
1755
1794
  );
1756
1795
  pending.state = data;
1757
- this.tryCompletePending(guildId);
1796
+ this.tryCompletePending(pendingByChannel ? channelKey : guildId, pending);
1758
1797
  }
1759
1798
  }
1760
1799
  handleVoiceServerUpdate(data) {
1761
1800
  const guildId = data.guild_id;
1762
- const pending = this.pending.get(guildId);
1801
+ let pending = this.pending.get(guildId);
1802
+ if (!pending) {
1803
+ for (const [, p] of this.pending) {
1804
+ if (p.channel?.guildId === guildId) {
1805
+ pending = p;
1806
+ break;
1807
+ }
1808
+ }
1809
+ }
1763
1810
  if (pending) {
1811
+ const channelKey = pending.channel?.id ?? guildId;
1764
1812
  const hasToken = !!(data.token && data.token.length > 0);
1765
1813
  this.client.emit?.(
1766
1814
  "debug",
1767
- `[VoiceManager] VoiceServerUpdate guild=${guildId} endpoint=${data.endpoint ?? "null"} token=${hasToken ? "yes" : "NO"}`
1815
+ `[VoiceManager] VoiceServerUpdate guild=${guildId} channel=${channelKey} endpoint=${data.endpoint ?? "null"} token=${hasToken ? "yes" : "NO"}`
1768
1816
  );
1769
1817
  pending.server = data;
1770
- this.tryCompletePending(guildId);
1818
+ this.tryCompletePending(channelKey, pending);
1771
1819
  return;
1772
1820
  }
1773
1821
  const userId = this.client.user?.id;
@@ -1778,15 +1826,21 @@ var VoiceManager = class extends EventEmitter3 {
1778
1826
  );
1779
1827
  return;
1780
1828
  }
1781
- const conn = this.connections.get(guildId);
1829
+ let conn;
1830
+ for (const [, c] of this.connections) {
1831
+ if (c?.channel?.guildId === guildId) {
1832
+ conn = c;
1833
+ break;
1834
+ }
1835
+ }
1782
1836
  if (!conn) return;
1783
1837
  if (!data.endpoint || !data.token) {
1784
1838
  this.client.emit?.(
1785
1839
  "debug",
1786
- `[VoiceManager] Voice server endpoint null for guild ${guildId}; disconnecting until new allocation`
1840
+ `[VoiceManager] Voice server endpoint null for guild ${guildId}; disconnecting`
1787
1841
  );
1788
1842
  conn.destroy();
1789
- this.connections.delete(guildId);
1843
+ this.connections.delete(conn.channel.id);
1790
1844
  return;
1791
1845
  }
1792
1846
  if (!isLiveKitEndpoint(data.endpoint, data.token)) return;
@@ -1796,14 +1850,15 @@ var VoiceManager = class extends EventEmitter3 {
1796
1850
  const channel = conn.channel;
1797
1851
  this.client.emit?.(
1798
1852
  "debug",
1799
- `[VoiceManager] Voice server migration for guild ${guildId}; reconnecting`
1853
+ `[VoiceManager] Voice server migration for guild ${guildId} channel ${channel.id}; reconnecting`
1800
1854
  );
1801
1855
  conn.destroy();
1802
- this.connections.delete(guildId);
1803
- this.storeConnectionId(guildId, data.connection_id);
1856
+ this.connections.delete(channel.id);
1857
+ this.connectionIds.delete(channel.id);
1858
+ this.storeConnectionId(channel.id, data.connection_id);
1804
1859
  const ConnClass = LiveKitRtcConnection;
1805
1860
  const newConn = new ConnClass(this.client, channel, userId);
1806
- this.registerConnection(guildId, newConn);
1861
+ this.registerConnection(channel.id, newConn);
1807
1862
  const state = {
1808
1863
  guild_id: guildId,
1809
1864
  channel_id: channel.id,
@@ -1811,42 +1866,43 @@ var VoiceManager = class extends EventEmitter3 {
1811
1866
  session_id: ""
1812
1867
  };
1813
1868
  newConn.connect(data, state).catch((e) => {
1814
- this.connections.delete(guildId);
1869
+ this.connections.delete(channel.id);
1815
1870
  newConn.emit("error", e instanceof Error ? e : new Error(String(e)));
1816
1871
  });
1817
1872
  }
1818
- storeConnectionId(guildId, connectionId) {
1873
+ storeConnectionId(channelId, connectionId) {
1819
1874
  const id = connectionId != null ? String(connectionId) : null;
1820
- if (id) this.connectionIds.set(guildId, id);
1821
- else this.connectionIds.delete(guildId);
1875
+ if (id) this.connectionIds.set(channelId, id);
1876
+ else this.connectionIds.delete(channelId);
1822
1877
  }
1823
- registerConnection(guildId, conn) {
1824
- this.connections.set(guildId, conn);
1878
+ registerConnection(channelId, conn) {
1879
+ const cid = conn.channel?.id ?? channelId;
1880
+ this.connections.set(cid, conn);
1825
1881
  conn.once("disconnect", () => {
1826
- this.connections.delete(guildId);
1827
- this.connectionIds.delete(guildId);
1882
+ this.connections.delete(cid);
1883
+ this.connectionIds.delete(cid);
1828
1884
  });
1829
1885
  conn.on("requestVoiceStateSync", (p) => {
1830
- this.updateVoiceState(guildId, p);
1886
+ this.updateVoiceState(cid, p);
1831
1887
  if (p.self_stream) {
1832
- this.uploadStreamPreview(guildId, conn).catch(
1888
+ this.uploadStreamPreview(cid, conn).catch(
1833
1889
  (e) => this.client.emit?.("debug", `[VoiceManager] Stream preview upload failed: ${String(e)}`)
1834
1890
  );
1835
1891
  }
1836
1892
  });
1837
1893
  }
1838
1894
  /** Upload a placeholder stream preview so the preview URL returns 200 instead of 404. */
1839
- async uploadStreamPreview(guildId, conn) {
1840
- const connectionId = this.connectionIds.get(guildId);
1895
+ async uploadStreamPreview(channelId, conn) {
1896
+ const cid = conn.channel?.id ?? channelId;
1897
+ const connectionId = this.connectionIds.get(cid);
1841
1898
  if (!connectionId) return;
1842
- const streamKey = `${guildId}:${conn.channel.id}:${connectionId}`;
1899
+ const streamKey = `${conn.channel.guildId}:${conn.channel.id}:${connectionId}`;
1843
1900
  const route = Routes.streamPreview(streamKey);
1844
1901
  const body = { channel_id: conn.channel.id, thumbnail, content_type: "image/png" };
1845
1902
  await this.client.rest.post(route, { body, auth: true });
1846
1903
  this.client.emit?.("debug", `[VoiceManager] Uploaded stream preview for ${streamKey}`);
1847
1904
  }
1848
- tryCompletePending(guildId) {
1849
- const pending = this.pending.get(guildId);
1905
+ tryCompletePending(channelId, pending) {
1850
1906
  if (!pending?.server) return;
1851
1907
  const useLiveKit = isLiveKitEndpoint(pending.server.endpoint, pending.server.token);
1852
1908
  const hasState = !!pending.state;
@@ -1865,6 +1921,7 @@ var VoiceManager = class extends EventEmitter3 {
1865
1921
  );
1866
1922
  return;
1867
1923
  }
1924
+ const guildId = pending.channel?.guildId ?? "";
1868
1925
  const state = pending.state ?? {
1869
1926
  guild_id: guildId,
1870
1927
  channel_id: pending.channel.id,
@@ -1872,13 +1929,13 @@ var VoiceManager = class extends EventEmitter3 {
1872
1929
  session_id: ""
1873
1930
  };
1874
1931
  this.storeConnectionId(
1875
- guildId,
1932
+ channelId,
1876
1933
  pending.server.connection_id ?? state.connection_id
1877
1934
  );
1878
- this.pending.delete(guildId);
1935
+ this.pending.delete(channelId);
1879
1936
  const ConnClass = useLiveKit ? LiveKitRtcConnection : VoiceConnection;
1880
1937
  const conn = new ConnClass(this.client, pending.channel, userId);
1881
- this.registerConnection(guildId, conn);
1938
+ this.registerConnection(channelId, conn);
1882
1939
  conn.connect(pending.server, state).then(
1883
1940
  () => pending.resolve(conn),
1884
1941
  (e) => pending.reject(e)
@@ -1886,25 +1943,27 @@ var VoiceManager = class extends EventEmitter3 {
1886
1943
  }
1887
1944
  /**
1888
1945
  * Join a voice channel. Resolves when the connection is ready.
1946
+ * Supports multiple connections per guild (Fluxer multi-channel).
1889
1947
  * @param channel - The voice channel to join
1890
1948
  * @returns The voice connection (LiveKitRtcConnection when Fluxer uses LiveKit)
1891
1949
  */
1892
1950
  async join(channel) {
1893
- const existing = this.connections.get(channel.guildId);
1951
+ const channelId = channel.id;
1952
+ const existing = this.connections.get(channelId);
1894
1953
  if (existing) {
1895
- const isReusable = existing.channel.id === channel.id && (existing instanceof LiveKitRtcConnection ? existing.isConnected() : true);
1954
+ const isReusable = existing instanceof LiveKitRtcConnection ? existing.isConnected() : true;
1896
1955
  if (isReusable) return existing;
1897
1956
  existing.destroy();
1898
- this.connections.delete(channel.guildId);
1957
+ this.connections.delete(channelId);
1899
1958
  }
1900
1959
  return new Promise((resolve, reject) => {
1901
1960
  this.client.emit?.(
1902
1961
  "debug",
1903
- `[VoiceManager] Requesting voice join guild=${channel.guildId} channel=${channel.id}`
1962
+ `[VoiceManager] Requesting voice join guild=${channel.guildId} channel=${channelId}`
1904
1963
  );
1905
1964
  const timeout = setTimeout(() => {
1906
- if (this.pending.has(channel.guildId)) {
1907
- this.pending.delete(channel.guildId);
1965
+ if (this.pending.has(channelId)) {
1966
+ this.pending.delete(channelId);
1908
1967
  reject(
1909
1968
  new Error(
1910
1969
  "Voice connection timeout. Ensure the server has voice enabled and the bot has Connect permissions. The gateway must send VoiceServerUpdate and VoiceStateUpdate in response."
@@ -1912,7 +1971,7 @@ var VoiceManager = class extends EventEmitter3 {
1912
1971
  );
1913
1972
  }
1914
1973
  }, 2e4);
1915
- this.pending.set(channel.guildId, {
1974
+ this.pending.set(channelId, {
1916
1975
  channel,
1917
1976
  resolve: (c) => {
1918
1977
  clearTimeout(timeout);
@@ -1935,56 +1994,92 @@ var VoiceManager = class extends EventEmitter3 {
1935
1994
  });
1936
1995
  }
1937
1996
  /**
1938
- * Leave a guild's voice channel and disconnect.
1997
+ * Leave all voice channels in a guild.
1998
+ * With multi-channel support, disconnects from every channel in the guild.
1939
1999
  * @param guildId - Guild ID to leave
1940
2000
  */
1941
2001
  leave(guildId) {
1942
- const conn = this.connections.get(guildId);
1943
- if (conn) {
2002
+ const toLeave = [];
2003
+ for (const [cid, c] of this.connections) {
2004
+ if (c?.channel?.guildId === guildId) toLeave.push({ channelId: cid, conn: c });
2005
+ }
2006
+ for (const { channelId, conn } of toLeave) {
1944
2007
  conn.destroy();
1945
- this.connections.delete(guildId);
1946
- this.connectionIds.delete(guildId);
2008
+ this.connections.delete(channelId);
2009
+ this.connectionIds.delete(channelId);
1947
2010
  }
1948
- this.client.sendToGateway(this.shardId, {
1949
- op: GatewayOpcodes.VoiceStateUpdate,
1950
- d: {
1951
- guild_id: guildId,
1952
- channel_id: null,
1953
- self_mute: false,
1954
- self_deaf: false
2011
+ if (toLeave.length > 0) {
2012
+ this.client.sendToGateway(this.shardId, {
2013
+ op: GatewayOpcodes.VoiceStateUpdate,
2014
+ d: {
2015
+ guild_id: guildId,
2016
+ channel_id: null,
2017
+ self_mute: false,
2018
+ self_deaf: false
2019
+ }
2020
+ });
2021
+ }
2022
+ }
2023
+ /**
2024
+ * Leave a specific voice channel by channel ID.
2025
+ * @param channelId - Channel ID to leave
2026
+ */
2027
+ leaveChannel(channelId) {
2028
+ const conn = this.connections.get(channelId);
2029
+ if (conn) {
2030
+ const guildId = conn.channel?.guildId;
2031
+ conn.destroy();
2032
+ this.connections.delete(channelId);
2033
+ this.connectionIds.delete(channelId);
2034
+ if (guildId) {
2035
+ this.client.sendToGateway(this.shardId, {
2036
+ op: GatewayOpcodes.VoiceStateUpdate,
2037
+ d: {
2038
+ guild_id: guildId,
2039
+ channel_id: null,
2040
+ self_mute: false,
2041
+ self_deaf: false
2042
+ }
2043
+ });
1955
2044
  }
1956
- });
2045
+ }
1957
2046
  }
1958
2047
  /**
1959
- * Get the active voice connection for a guild, if any.
1960
- * @param guildId - Guild ID to look up
2048
+ * Get the active voice connection for a channel or guild.
2049
+ * @param channelOrGuildId - Channel ID (primary) or guild ID (returns first connection in that guild)
1961
2050
  */
1962
- getConnection(guildId) {
1963
- return this.connections.get(guildId);
2051
+ getConnection(channelOrGuildId) {
2052
+ const byChannel = this.connections.get(channelOrGuildId);
2053
+ if (byChannel) return byChannel;
2054
+ for (const [, c] of this.connections) {
2055
+ if (c?.channel?.guildId === channelOrGuildId) return c;
2056
+ }
2057
+ return void 0;
1964
2058
  }
1965
2059
  /**
1966
2060
  * Update voice state (e.g. self_stream, self_video) while in a channel.
1967
2061
  * Sends a VoiceStateUpdate to the gateway so the server and clients see the change.
1968
2062
  * Requires connection_id (from VoiceServerUpdate); without it, the gateway would treat
1969
2063
  * the update as a new join and trigger a new VoiceServerUpdate, causing connection loops.
1970
- * @param guildId - Guild ID
2064
+ * @param channelId - Channel ID (connection key)
1971
2065
  * @param partial - Partial voice state to update (self_stream, self_video, self_mute, self_deaf)
1972
2066
  */
1973
- updateVoiceState(guildId, partial) {
1974
- const conn = this.connections.get(guildId);
2067
+ updateVoiceState(channelId, partial) {
2068
+ const conn = this.connections.get(channelId);
1975
2069
  if (!conn) return;
1976
- const connectionId = this.connectionIds.get(guildId);
2070
+ const connectionId = this.connectionIds.get(channelId);
2071
+ const guildId = conn.channel?.guildId;
1977
2072
  if (!connectionId) {
1978
2073
  this.client.emit?.(
1979
2074
  "debug",
1980
- `[VoiceManager] Skipping voice state sync: no connection_id for guild ${guildId}`
2075
+ `[VoiceManager] Skipping voice state sync: no connection_id for channel ${channelId}`
1981
2076
  );
1982
2077
  return;
1983
2078
  }
1984
2079
  this.client.sendToGateway(this.shardId, {
1985
2080
  op: GatewayOpcodes.VoiceStateUpdate,
1986
2081
  d: {
1987
- guild_id: guildId,
2082
+ guild_id: guildId ?? "",
1988
2083
  channel_id: conn.channel.id,
1989
2084
  connection_id: connectionId,
1990
2085
  self_mute: partial.self_mute ?? false,