@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.js CHANGED
@@ -3122,11 +3122,21 @@ function floatToInt16(float32) {
3122
3122
  }
3123
3123
  return int16;
3124
3124
  }
3125
+ function applyVolumeToInt16(samples, volumePercent) {
3126
+ const vol = (volumePercent ?? 100) / 100;
3127
+ if (vol === 1) return samples;
3128
+ const out = new Int16Array(samples.length);
3129
+ for (let i = 0; i < samples.length; i++) {
3130
+ out[i] = Math.max(-32768, Math.min(32767, Math.round(samples[i] * vol)));
3131
+ }
3132
+ return out;
3133
+ }
3125
3134
  var VOICE_DEBUG = process.env.VOICE_DEBUG === "1" || process.env.VOICE_DEBUG === "true";
3126
3135
  var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3127
3136
  client;
3128
3137
  channel;
3129
3138
  guildId;
3139
+ _volume = 100;
3130
3140
  _playing = false;
3131
3141
  _playingVideo = false;
3132
3142
  _destroyed = false;
@@ -3183,6 +3193,14 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3183
3193
  const ep = (endpoint ?? "").trim();
3184
3194
  return ep === (this.lastServerEndpoint ?? "") && token === (this.lastServerToken ?? "");
3185
3195
  }
3196
+ /** Set playback volume (0-200, 100 = normal). Affects current and future playback. */
3197
+ setVolume(volumePercent) {
3198
+ this._volume = Math.max(0, Math.min(200, volumePercent ?? 100));
3199
+ }
3200
+ /** Get current volume (0-200). */
3201
+ getVolume() {
3202
+ return this._volume ?? 100;
3203
+ }
3186
3204
  playOpus(_stream) {
3187
3205
  this.emit(
3188
3206
  "error",
@@ -3259,7 +3277,8 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3259
3277
  this.emit("error", new Error("LiveKit: not connected"));
3260
3278
  return;
3261
3279
  }
3262
- const useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === "1";
3280
+ let useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === "1";
3281
+ if (options?.resolution) useFFmpeg = true;
3263
3282
  if (useFFmpeg && typeof urlOrBuffer === "string") {
3264
3283
  await this.playVideoFFmpeg(urlOrBuffer, options);
3265
3284
  return;
@@ -3656,8 +3675,9 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3656
3675
  newBuffer.set(int16, sampleBuffer.length);
3657
3676
  sampleBuffer = newBuffer;
3658
3677
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
3659
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
3678
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
3660
3679
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
3680
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
3661
3681
  const audioFrame = new import_rtc_node.AudioFrame(
3662
3682
  outSamples,
3663
3683
  SAMPLE_RATE,
@@ -3724,6 +3744,7 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3724
3744
  const loop = options?.loop ?? true;
3725
3745
  let width = 640;
3726
3746
  let height = 480;
3747
+ let hasAudio = false;
3727
3748
  try {
3728
3749
  const { execFile } = await import("child_process");
3729
3750
  const { promisify } = await import("util");
@@ -3733,10 +3754,9 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3733
3754
  [
3734
3755
  "-v",
3735
3756
  "error",
3736
- "-select_streams",
3737
- "v:0",
3757
+ "-show_streams",
3738
3758
  "-show_entries",
3739
- "stream=width,height",
3759
+ "stream=codec_type,width,height",
3740
3760
  "-of",
3741
3761
  "json",
3742
3762
  url
@@ -3744,10 +3764,19 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3744
3764
  { encoding: "utf8", timeout: 1e4 }
3745
3765
  );
3746
3766
  const parsed = JSON.parse(stdout);
3747
- const stream = parsed?.streams?.[0];
3748
- if (stream?.width && stream?.height) {
3749
- width = stream.width;
3750
- height = stream.height;
3767
+ const streams = parsed?.streams ?? [];
3768
+ for (const s of streams) {
3769
+ if (s.codec_type === "video" && s.width != null && s.height != null) {
3770
+ width = s.width;
3771
+ height = s.height;
3772
+ break;
3773
+ }
3774
+ }
3775
+ for (const s of streams) {
3776
+ if (s.codec_type === "audio") {
3777
+ hasAudio = true;
3778
+ break;
3779
+ }
3751
3780
  }
3752
3781
  } catch (probeErr) {
3753
3782
  this.emit(
@@ -3758,7 +3787,29 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3758
3787
  );
3759
3788
  return;
3760
3789
  }
3761
- if (options?.width && options?.height) {
3790
+ let maxFps = options?.maxFramerate ?? 60;
3791
+ const res = options?.resolution;
3792
+ if (res === "480p") {
3793
+ width = 854;
3794
+ height = 480;
3795
+ maxFps = 60;
3796
+ } else if (res === "720p") {
3797
+ width = 1280;
3798
+ height = 720;
3799
+ maxFps = 60;
3800
+ } else if (res === "1080p") {
3801
+ width = 1920;
3802
+ height = 1080;
3803
+ maxFps = 60;
3804
+ } else if (res === "1440p") {
3805
+ width = 2560;
3806
+ height = 1440;
3807
+ maxFps = 60;
3808
+ } else if (res === "4k") {
3809
+ width = 3840;
3810
+ height = 2160;
3811
+ maxFps = 60;
3812
+ } else if (options?.width != null && options?.height != null) {
3762
3813
  width = options.width;
3763
3814
  height = options.height;
3764
3815
  }
@@ -3770,7 +3821,7 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3770
3821
  source: sourceOption === "screenshare" ? import_rtc_node.TrackSource.SOURCE_SCREENSHARE : import_rtc_node.TrackSource.SOURCE_CAMERA,
3771
3822
  videoEncoding: {
3772
3823
  maxBitrate: BigInt(options?.videoBitrate ?? 25e5),
3773
- maxFramerate: options?.maxFramerate ?? 60
3824
+ maxFramerate: maxFps
3774
3825
  }
3775
3826
  });
3776
3827
  const participant = this.room?.localParticipant;
@@ -3781,24 +3832,29 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3781
3832
  this.emit("error", err instanceof Error ? err : new Error(String(err)));
3782
3833
  return;
3783
3834
  }
3784
- let audioFfmpegProc = null;
3785
- const audioSource = new import_rtc_node.AudioSource(SAMPLE_RATE, CHANNELS2);
3786
- const audioTrack = import_rtc_node.LocalAudioTrack.createAudioTrack(
3787
- "audio",
3788
- audioSource
3789
- );
3790
- this.audioSource = audioSource;
3791
- this.audioTrack = audioTrack;
3792
- try {
3793
- await participant.publishTrack(
3794
- audioTrack,
3795
- new import_rtc_node.TrackPublishOptions({ source: import_rtc_node.TrackSource.SOURCE_MICROPHONE })
3796
- );
3797
- } catch {
3798
- audioTrack.close().catch(() => {
3799
- });
3800
- this.audioTrack = null;
3835
+ let audioSource = null;
3836
+ let audioReady = false;
3837
+ if (hasAudio) {
3838
+ const src = new import_rtc_node.AudioSource(SAMPLE_RATE, CHANNELS2);
3839
+ audioSource = src;
3840
+ this.audioSource = src;
3841
+ const track2 = import_rtc_node.LocalAudioTrack.createAudioTrack("audio", src);
3842
+ this.audioTrack = track2;
3843
+ try {
3844
+ await participant.publishTrack(
3845
+ track2,
3846
+ new import_rtc_node.TrackPublishOptions({ source: import_rtc_node.TrackSource.SOURCE_MICROPHONE })
3847
+ );
3848
+ audioReady = true;
3849
+ } catch {
3850
+ track2.close().catch(() => {
3851
+ });
3852
+ this.audioTrack = null;
3853
+ this.audioSource = null;
3854
+ }
3855
+ } else {
3801
3856
  this.audioSource = null;
3857
+ this.audioTrack = null;
3802
3858
  }
3803
3859
  this._playingVideo = true;
3804
3860
  this.emit("requestVoiceStateSync", {
@@ -3806,7 +3862,6 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3806
3862
  self_video: sourceOption === "camera"
3807
3863
  });
3808
3864
  const frameSize = Math.ceil(width * height * 3 / 2);
3809
- const maxFps = options?.maxFramerate ?? 60;
3810
3865
  const FRAME_INTERVAL_MS = Math.round(1e3 / maxFps);
3811
3866
  let pacingTimeout = null;
3812
3867
  let ffmpegProc = null;
@@ -3824,10 +3879,6 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3824
3879
  ffmpegProc.kill("SIGKILL");
3825
3880
  ffmpegProc = null;
3826
3881
  }
3827
- if (audioFfmpegProc && !audioFfmpegProc.killed) {
3828
- audioFfmpegProc.kill("SIGKILL");
3829
- audioFfmpegProc = null;
3830
- }
3831
3882
  this.emit("requestVoiceStateSync", { self_stream: false, self_video: false });
3832
3883
  this.currentVideoStream = null;
3833
3884
  if (this.audioTrack) {
@@ -3912,32 +3963,38 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3912
3963
  pacingTimeout = setTimeout(scheduleNextPacing, FRAME_INTERVAL_MS);
3913
3964
  };
3914
3965
  scheduleNextPacing();
3915
- const runFFmpeg = () => {
3966
+ const runFFmpeg = async () => {
3916
3967
  const ffmpegArgs = [
3917
3968
  "-loglevel",
3918
3969
  "warning",
3919
3970
  "-re",
3971
+ ...loop ? ["-stream_loop", "-1"] : [],
3920
3972
  "-i",
3921
3973
  url,
3974
+ "-map",
3975
+ "0:v",
3976
+ "-vf",
3977
+ `scale=${width}:${height}`,
3978
+ "-r",
3979
+ String(maxFps),
3922
3980
  "-f",
3923
3981
  "rawvideo",
3924
3982
  "-pix_fmt",
3925
3983
  "yuv420p",
3926
- "-r",
3927
- String(maxFps)
3984
+ "-an",
3985
+ "pipe:1",
3986
+ ...hasAudio ? ["-map", "0:a", "-c:a", "libopus", "-f", "webm", "-vn", "pipe:3"] : []
3928
3987
  ];
3929
- if (options?.width && options?.height) {
3930
- ffmpegArgs.splice(ffmpegArgs.indexOf("-f"), 0, "-vf", `scale=${width}:${height}`);
3931
- }
3932
- ffmpegArgs.push("-");
3933
- ffmpegProc = (0, import_node_child_process.spawn)("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
3988
+ const stdioOpts = hasAudio ? ["ignore", "pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"];
3989
+ const proc = (0, import_node_child_process.spawn)("ffmpeg", ffmpegArgs, { stdio: stdioOpts });
3990
+ ffmpegProc = proc;
3934
3991
  this.currentVideoStream = {
3935
3992
  destroy: () => {
3936
- if (ffmpegProc && !ffmpegProc.killed) ffmpegProc.kill("SIGKILL");
3993
+ if (proc && !proc.killed) proc.kill("SIGKILL");
3937
3994
  }
3938
3995
  };
3939
- const stdout = ffmpegProc.stdout;
3940
- const stderr = ffmpegProc.stderr;
3996
+ const stdout = proc.stdout;
3997
+ const stderr = proc.stderr;
3941
3998
  if (stdout) {
3942
3999
  stdout.on("data", (chunk) => {
3943
4000
  if (!this._playingVideo || cleanupCalled) return;
@@ -3951,105 +4008,79 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
3951
4008
  if (line && VOICE_DEBUG) this.audioDebug("ffmpeg stderr", { line: line.slice(0, 200) });
3952
4009
  });
3953
4010
  }
3954
- ffmpegProc.on("error", (err) => {
4011
+ if (hasAudio && audioReady && audioSource && proc.stdio[3]) {
4012
+ const audioPipe = proc.stdio[3];
4013
+ const { opus: prismOpus } = await import("prism-media");
4014
+ const { OpusDecoder } = await import("opus-decoder");
4015
+ const demuxer = new prismOpus.WebmDemuxer();
4016
+ audioPipe.pipe(demuxer);
4017
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
4018
+ await decoder.ready;
4019
+ let sampleBuffer = new Int16Array(0);
4020
+ let opusBuffer = new Uint8Array(0);
4021
+ let processing = false;
4022
+ const opusFrameQueue = [];
4023
+ const processOneOpusFrame = async (frame) => {
4024
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
4025
+ try {
4026
+ const result = decoder.decodeFrame(frame);
4027
+ if (!result?.channelData?.[0]?.length) return;
4028
+ const int16 = floatToInt16(result.channelData[0]);
4029
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
4030
+ newBuffer.set(sampleBuffer);
4031
+ newBuffer.set(int16, sampleBuffer.length);
4032
+ sampleBuffer = newBuffer;
4033
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
4034
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4035
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
4036
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
4037
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4038
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
4039
+ await audioSource.captureFrame(audioFrame);
4040
+ }
4041
+ } catch {
4042
+ }
4043
+ };
4044
+ const drainQueue = async () => {
4045
+ if (processing || opusFrameQueue.length === 0) return;
4046
+ processing = true;
4047
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
4048
+ const f = opusFrameQueue.shift();
4049
+ await processOneOpusFrame(f);
4050
+ }
4051
+ processing = false;
4052
+ };
4053
+ demuxer.on("data", (chunk) => {
4054
+ if (!this._playingVideo) return;
4055
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
4056
+ while (opusBuffer.length > 0) {
4057
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
4058
+ if (!parsed) break;
4059
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
4060
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
4061
+ }
4062
+ drainQueue().catch(() => {
4063
+ });
4064
+ });
4065
+ }
4066
+ proc.on("error", (err) => {
3955
4067
  this.emit("error", err);
3956
4068
  doCleanup();
3957
4069
  });
3958
- ffmpegProc.on("exit", (code) => {
4070
+ proc.on("exit", (code) => {
3959
4071
  ffmpegProc = null;
3960
4072
  if (cleanupCalled || !this._playingVideo) return;
3961
4073
  if (loop && (code === 0 || code === null)) {
3962
4074
  frameBuffer.length = 0;
3963
4075
  frameBufferBytes = 0;
3964
4076
  frameIndex = 0n;
3965
- setImmediate(runFFmpeg);
4077
+ setImmediate(() => runFFmpeg());
3966
4078
  } else {
3967
4079
  doCleanup();
3968
4080
  }
3969
4081
  });
3970
4082
  };
3971
- runFFmpeg();
3972
- const runAudioFfmpeg = async () => {
3973
- if (!this._playingVideo || cleanupCalled || !audioSource) return;
3974
- const audioProc = (0, import_node_child_process.spawn)(
3975
- "ffmpeg",
3976
- [
3977
- "-loglevel",
3978
- "warning",
3979
- "-re",
3980
- "-i",
3981
- url,
3982
- "-vn",
3983
- "-c:a",
3984
- "libopus",
3985
- "-f",
3986
- "webm",
3987
- ...loop ? ["-stream_loop", "-1"] : [],
3988
- "pipe:1"
3989
- ],
3990
- { stdio: ["ignore", "pipe", "pipe"] }
3991
- );
3992
- audioFfmpegProc = audioProc;
3993
- const { opus: prismOpus } = await import("prism-media");
3994
- const { OpusDecoder } = await import("opus-decoder");
3995
- const demuxer = new prismOpus.WebmDemuxer();
3996
- if (audioProc.stdout) audioProc.stdout.pipe(demuxer);
3997
- const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
3998
- await decoder.ready;
3999
- let sampleBuffer = new Int16Array(0);
4000
- let opusBuffer = new Uint8Array(0);
4001
- let processing = false;
4002
- const opusFrameQueue = [];
4003
- const processOneOpusFrame = async (frame) => {
4004
- if (frame.length < 2 || !audioSource || !this._playingVideo) return;
4005
- try {
4006
- const result = decoder.decodeFrame(frame);
4007
- if (!result?.channelData?.[0]?.length) return;
4008
- const int16 = floatToInt16(result.channelData[0]);
4009
- const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
4010
- newBuffer.set(sampleBuffer);
4011
- newBuffer.set(int16, sampleBuffer.length);
4012
- sampleBuffer = newBuffer;
4013
- while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
4014
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4015
- sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
4016
- const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4017
- if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
4018
- await audioSource.captureFrame(audioFrame);
4019
- }
4020
- } catch {
4021
- }
4022
- };
4023
- const drainQueue = async () => {
4024
- if (processing || opusFrameQueue.length === 0) return;
4025
- processing = true;
4026
- while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
4027
- const f = opusFrameQueue.shift();
4028
- await processOneOpusFrame(f);
4029
- }
4030
- processing = false;
4031
- };
4032
- demuxer.on("data", (chunk) => {
4033
- if (!this._playingVideo) return;
4034
- opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
4035
- while (opusBuffer.length > 0) {
4036
- const parsed = parseOpusPacketBoundaries(opusBuffer);
4037
- if (!parsed) break;
4038
- opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
4039
- for (const frame of parsed.frames) opusFrameQueue.push(frame);
4040
- }
4041
- drainQueue().catch(() => {
4042
- });
4043
- });
4044
- audioProc.on("exit", (code) => {
4045
- if (audioFfmpegProc === audioProc) audioFfmpegProc = null;
4046
- if (loop && this._playingVideo && !cleanupCalled && (code === 0 || code === null)) {
4047
- setImmediate(() => runAudioFfmpeg());
4048
- }
4049
- });
4050
- };
4051
- runAudioFfmpeg().catch(() => {
4052
- });
4083
+ runFFmpeg().catch((e) => this.audioDebug("ffmpeg error", { error: String(e) }));
4053
4084
  }
4054
4085
  /**
4055
4086
  * Play audio from a WebM/Opus URL or readable stream. Publishes to the LiveKit room as an audio track.
@@ -4108,8 +4139,9 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
4108
4139
  newBuffer.set(int16, sampleBuffer.length);
4109
4140
  sampleBuffer = newBuffer;
4110
4141
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
4111
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4142
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4112
4143
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
4144
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
4113
4145
  const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4114
4146
  if (source.queuedDuration > 500) {
4115
4147
  await source.waitForPlayout();
@@ -4164,8 +4196,9 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
4164
4196
  await new Promise((r) => setImmediate(r));
4165
4197
  }
4166
4198
  while (sampleBuffer.length >= FRAME_SAMPLES && this._playing && source) {
4167
- const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4199
+ const rawSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
4168
4200
  sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
4201
+ const outSamples = applyVolumeToInt16(rawSamples, this._volume);
4169
4202
  const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4170
4203
  await source.captureFrame(audioFrame);
4171
4204
  framesCaptured++;
@@ -4173,7 +4206,8 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
4173
4206
  if (sampleBuffer.length > 0 && this._playing && source) {
4174
4207
  const padded = new Int16Array(FRAME_SAMPLES);
4175
4208
  padded.set(sampleBuffer);
4176
- const audioFrame = new import_rtc_node.AudioFrame(padded, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4209
+ const outSamples = applyVolumeToInt16(padded, this._volume);
4210
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
4177
4211
  await source.captureFrame(audioFrame);
4178
4212
  framesCaptured++;
4179
4213
  }
@@ -4263,11 +4297,13 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
4263
4297
  var import_collection = require("@fluxerjs/collection");
4264
4298
  var VoiceManager = class extends import_events3.EventEmitter {
4265
4299
  client;
4300
+ /** channel_id -> connection (Fluxer multi-channel: allows multiple connections per guild) */
4266
4301
  connections = new import_collection.Collection();
4267
- /** guild_id -> connection_id (from VoiceServerUpdate; required for voice state updates when in channel) */
4302
+ /** channel_id -> connection_id (from VoiceServerUpdate; required for voice state updates) */
4268
4303
  connectionIds = /* @__PURE__ */ new Map();
4269
4304
  /** guild_id -> user_id -> channel_id */
4270
4305
  voiceStates = /* @__PURE__ */ new Map();
4306
+ /** channel_id -> pending join */
4271
4307
  pending = /* @__PURE__ */ new Map();
4272
4308
  shardId;
4273
4309
  constructor(client, options = {}) {
@@ -4320,31 +4356,43 @@ var VoiceManager = class extends import_events3.EventEmitter {
4320
4356
  this.voiceStates.set(guildId, guildMap);
4321
4357
  }
4322
4358
  guildMap.set(data.user_id, data.channel_id);
4323
- const pending = this.pending.get(guildId);
4359
+ const channelKey = data.channel_id ?? guildId;
4360
+ const pendingByChannel = this.pending.get(channelKey);
4361
+ const pendingByGuild = this.pending.get(guildId);
4362
+ const pending = pendingByChannel ?? pendingByGuild;
4324
4363
  const isBot = String(data.user_id) === String(this.client.user?.id);
4325
4364
  if (isBot && data.connection_id) {
4326
- this.storeConnectionId(guildId, data.connection_id);
4365
+ this.storeConnectionId(channelKey, data.connection_id);
4327
4366
  }
4328
4367
  if (pending && isBot) {
4329
4368
  this.client.emit?.(
4330
4369
  "debug",
4331
- `[VoiceManager] VoiceStateUpdate for bot - completing pending guild ${guildId}`
4370
+ `[VoiceManager] VoiceStateUpdate for bot - completing pending channel ${channelKey}`
4332
4371
  );
4333
4372
  pending.state = data;
4334
- this.tryCompletePending(guildId);
4373
+ this.tryCompletePending(pendingByChannel ? channelKey : guildId, pending);
4335
4374
  }
4336
4375
  }
4337
4376
  handleVoiceServerUpdate(data) {
4338
4377
  const guildId = data.guild_id;
4339
- const pending = this.pending.get(guildId);
4378
+ let pending = this.pending.get(guildId);
4379
+ if (!pending) {
4380
+ for (const [, p] of this.pending) {
4381
+ if (p.channel?.guildId === guildId) {
4382
+ pending = p;
4383
+ break;
4384
+ }
4385
+ }
4386
+ }
4340
4387
  if (pending) {
4388
+ const channelKey = pending.channel?.id ?? guildId;
4341
4389
  const hasToken = !!(data.token && data.token.length > 0);
4342
4390
  this.client.emit?.(
4343
4391
  "debug",
4344
- `[VoiceManager] VoiceServerUpdate guild=${guildId} endpoint=${data.endpoint ?? "null"} token=${hasToken ? "yes" : "NO"}`
4392
+ `[VoiceManager] VoiceServerUpdate guild=${guildId} channel=${channelKey} endpoint=${data.endpoint ?? "null"} token=${hasToken ? "yes" : "NO"}`
4345
4393
  );
4346
4394
  pending.server = data;
4347
- this.tryCompletePending(guildId);
4395
+ this.tryCompletePending(channelKey, pending);
4348
4396
  return;
4349
4397
  }
4350
4398
  const userId = this.client.user?.id;
@@ -4355,15 +4403,21 @@ var VoiceManager = class extends import_events3.EventEmitter {
4355
4403
  );
4356
4404
  return;
4357
4405
  }
4358
- const conn = this.connections.get(guildId);
4406
+ let conn;
4407
+ for (const [, c] of this.connections) {
4408
+ if (c?.channel?.guildId === guildId) {
4409
+ conn = c;
4410
+ break;
4411
+ }
4412
+ }
4359
4413
  if (!conn) return;
4360
4414
  if (!data.endpoint || !data.token) {
4361
4415
  this.client.emit?.(
4362
4416
  "debug",
4363
- `[VoiceManager] Voice server endpoint null for guild ${guildId}; disconnecting until new allocation`
4417
+ `[VoiceManager] Voice server endpoint null for guild ${guildId}; disconnecting`
4364
4418
  );
4365
4419
  conn.destroy();
4366
- this.connections.delete(guildId);
4420
+ this.connections.delete(conn.channel.id);
4367
4421
  return;
4368
4422
  }
4369
4423
  if (!isLiveKitEndpoint(data.endpoint, data.token)) return;
@@ -4373,14 +4427,15 @@ var VoiceManager = class extends import_events3.EventEmitter {
4373
4427
  const channel = conn.channel;
4374
4428
  this.client.emit?.(
4375
4429
  "debug",
4376
- `[VoiceManager] Voice server migration for guild ${guildId}; reconnecting`
4430
+ `[VoiceManager] Voice server migration for guild ${guildId} channel ${channel.id}; reconnecting`
4377
4431
  );
4378
4432
  conn.destroy();
4379
- this.connections.delete(guildId);
4380
- this.storeConnectionId(guildId, data.connection_id);
4433
+ this.connections.delete(channel.id);
4434
+ this.connectionIds.delete(channel.id);
4435
+ this.storeConnectionId(channel.id, data.connection_id);
4381
4436
  const ConnClass = LiveKitRtcConnection;
4382
4437
  const newConn = new ConnClass(this.client, channel, userId);
4383
- this.registerConnection(guildId, newConn);
4438
+ this.registerConnection(channel.id, newConn);
4384
4439
  const state = {
4385
4440
  guild_id: guildId,
4386
4441
  channel_id: channel.id,
@@ -4388,42 +4443,43 @@ var VoiceManager = class extends import_events3.EventEmitter {
4388
4443
  session_id: ""
4389
4444
  };
4390
4445
  newConn.connect(data, state).catch((e) => {
4391
- this.connections.delete(guildId);
4446
+ this.connections.delete(channel.id);
4392
4447
  newConn.emit("error", e instanceof Error ? e : new Error(String(e)));
4393
4448
  });
4394
4449
  }
4395
- storeConnectionId(guildId, connectionId) {
4450
+ storeConnectionId(channelId, connectionId) {
4396
4451
  const id = connectionId != null ? String(connectionId) : null;
4397
- if (id) this.connectionIds.set(guildId, id);
4398
- else this.connectionIds.delete(guildId);
4452
+ if (id) this.connectionIds.set(channelId, id);
4453
+ else this.connectionIds.delete(channelId);
4399
4454
  }
4400
- registerConnection(guildId, conn) {
4401
- this.connections.set(guildId, conn);
4455
+ registerConnection(channelId, conn) {
4456
+ const cid = conn.channel?.id ?? channelId;
4457
+ this.connections.set(cid, conn);
4402
4458
  conn.once("disconnect", () => {
4403
- this.connections.delete(guildId);
4404
- this.connectionIds.delete(guildId);
4459
+ this.connections.delete(cid);
4460
+ this.connectionIds.delete(cid);
4405
4461
  });
4406
4462
  conn.on("requestVoiceStateSync", (p) => {
4407
- this.updateVoiceState(guildId, p);
4463
+ this.updateVoiceState(cid, p);
4408
4464
  if (p.self_stream) {
4409
- this.uploadStreamPreview(guildId, conn).catch(
4465
+ this.uploadStreamPreview(cid, conn).catch(
4410
4466
  (e) => this.client.emit?.("debug", `[VoiceManager] Stream preview upload failed: ${String(e)}`)
4411
4467
  );
4412
4468
  }
4413
4469
  });
4414
4470
  }
4415
4471
  /** Upload a placeholder stream preview so the preview URL returns 200 instead of 404. */
4416
- async uploadStreamPreview(guildId, conn) {
4417
- const connectionId = this.connectionIds.get(guildId);
4472
+ async uploadStreamPreview(channelId, conn) {
4473
+ const cid = conn.channel?.id ?? channelId;
4474
+ const connectionId = this.connectionIds.get(cid);
4418
4475
  if (!connectionId) return;
4419
- const streamKey = `${guildId}:${conn.channel.id}:${connectionId}`;
4476
+ const streamKey = `${conn.channel.guildId}:${conn.channel.id}:${connectionId}`;
4420
4477
  const route = import_types.Routes.streamPreview(streamKey);
4421
4478
  const body = { channel_id: conn.channel.id, thumbnail, content_type: "image/png" };
4422
4479
  await this.client.rest.post(route, { body, auth: true });
4423
4480
  this.client.emit?.("debug", `[VoiceManager] Uploaded stream preview for ${streamKey}`);
4424
4481
  }
4425
- tryCompletePending(guildId) {
4426
- const pending = this.pending.get(guildId);
4482
+ tryCompletePending(channelId, pending) {
4427
4483
  if (!pending?.server) return;
4428
4484
  const useLiveKit = isLiveKitEndpoint(pending.server.endpoint, pending.server.token);
4429
4485
  const hasState = !!pending.state;
@@ -4442,6 +4498,7 @@ var VoiceManager = class extends import_events3.EventEmitter {
4442
4498
  );
4443
4499
  return;
4444
4500
  }
4501
+ const guildId = pending.channel?.guildId ?? "";
4445
4502
  const state = pending.state ?? {
4446
4503
  guild_id: guildId,
4447
4504
  channel_id: pending.channel.id,
@@ -4449,13 +4506,13 @@ var VoiceManager = class extends import_events3.EventEmitter {
4449
4506
  session_id: ""
4450
4507
  };
4451
4508
  this.storeConnectionId(
4452
- guildId,
4509
+ channelId,
4453
4510
  pending.server.connection_id ?? state.connection_id
4454
4511
  );
4455
- this.pending.delete(guildId);
4512
+ this.pending.delete(channelId);
4456
4513
  const ConnClass = useLiveKit ? LiveKitRtcConnection : VoiceConnection;
4457
4514
  const conn = new ConnClass(this.client, pending.channel, userId);
4458
- this.registerConnection(guildId, conn);
4515
+ this.registerConnection(channelId, conn);
4459
4516
  conn.connect(pending.server, state).then(
4460
4517
  () => pending.resolve(conn),
4461
4518
  (e) => pending.reject(e)
@@ -4463,25 +4520,27 @@ var VoiceManager = class extends import_events3.EventEmitter {
4463
4520
  }
4464
4521
  /**
4465
4522
  * Join a voice channel. Resolves when the connection is ready.
4523
+ * Supports multiple connections per guild (Fluxer multi-channel).
4466
4524
  * @param channel - The voice channel to join
4467
4525
  * @returns The voice connection (LiveKitRtcConnection when Fluxer uses LiveKit)
4468
4526
  */
4469
4527
  async join(channel) {
4470
- const existing = this.connections.get(channel.guildId);
4528
+ const channelId = channel.id;
4529
+ const existing = this.connections.get(channelId);
4471
4530
  if (existing) {
4472
- const isReusable = existing.channel.id === channel.id && (existing instanceof LiveKitRtcConnection ? existing.isConnected() : true);
4531
+ const isReusable = existing instanceof LiveKitRtcConnection ? existing.isConnected() : true;
4473
4532
  if (isReusable) return existing;
4474
4533
  existing.destroy();
4475
- this.connections.delete(channel.guildId);
4534
+ this.connections.delete(channelId);
4476
4535
  }
4477
4536
  return new Promise((resolve, reject) => {
4478
4537
  this.client.emit?.(
4479
4538
  "debug",
4480
- `[VoiceManager] Requesting voice join guild=${channel.guildId} channel=${channel.id}`
4539
+ `[VoiceManager] Requesting voice join guild=${channel.guildId} channel=${channelId}`
4481
4540
  );
4482
4541
  const timeout = setTimeout(() => {
4483
- if (this.pending.has(channel.guildId)) {
4484
- this.pending.delete(channel.guildId);
4542
+ if (this.pending.has(channelId)) {
4543
+ this.pending.delete(channelId);
4485
4544
  reject(
4486
4545
  new Error(
4487
4546
  "Voice connection timeout. Ensure the server has voice enabled and the bot has Connect permissions. The gateway must send VoiceServerUpdate and VoiceStateUpdate in response."
@@ -4489,7 +4548,7 @@ var VoiceManager = class extends import_events3.EventEmitter {
4489
4548
  );
4490
4549
  }
4491
4550
  }, 2e4);
4492
- this.pending.set(channel.guildId, {
4551
+ this.pending.set(channelId, {
4493
4552
  channel,
4494
4553
  resolve: (c) => {
4495
4554
  clearTimeout(timeout);
@@ -4512,56 +4571,92 @@ var VoiceManager = class extends import_events3.EventEmitter {
4512
4571
  });
4513
4572
  }
4514
4573
  /**
4515
- * Leave a guild's voice channel and disconnect.
4574
+ * Leave all voice channels in a guild.
4575
+ * With multi-channel support, disconnects from every channel in the guild.
4516
4576
  * @param guildId - Guild ID to leave
4517
4577
  */
4518
4578
  leave(guildId) {
4519
- const conn = this.connections.get(guildId);
4520
- if (conn) {
4579
+ const toLeave = [];
4580
+ for (const [cid, c] of this.connections) {
4581
+ if (c?.channel?.guildId === guildId) toLeave.push({ channelId: cid, conn: c });
4582
+ }
4583
+ for (const { channelId, conn } of toLeave) {
4521
4584
  conn.destroy();
4522
- this.connections.delete(guildId);
4523
- this.connectionIds.delete(guildId);
4585
+ this.connections.delete(channelId);
4586
+ this.connectionIds.delete(channelId);
4524
4587
  }
4525
- this.client.sendToGateway(this.shardId, {
4526
- op: import_types.GatewayOpcodes.VoiceStateUpdate,
4527
- d: {
4528
- guild_id: guildId,
4529
- channel_id: null,
4530
- self_mute: false,
4531
- self_deaf: false
4588
+ if (toLeave.length > 0) {
4589
+ this.client.sendToGateway(this.shardId, {
4590
+ op: import_types.GatewayOpcodes.VoiceStateUpdate,
4591
+ d: {
4592
+ guild_id: guildId,
4593
+ channel_id: null,
4594
+ self_mute: false,
4595
+ self_deaf: false
4596
+ }
4597
+ });
4598
+ }
4599
+ }
4600
+ /**
4601
+ * Leave a specific voice channel by channel ID.
4602
+ * @param channelId - Channel ID to leave
4603
+ */
4604
+ leaveChannel(channelId) {
4605
+ const conn = this.connections.get(channelId);
4606
+ if (conn) {
4607
+ const guildId = conn.channel?.guildId;
4608
+ conn.destroy();
4609
+ this.connections.delete(channelId);
4610
+ this.connectionIds.delete(channelId);
4611
+ if (guildId) {
4612
+ this.client.sendToGateway(this.shardId, {
4613
+ op: import_types.GatewayOpcodes.VoiceStateUpdate,
4614
+ d: {
4615
+ guild_id: guildId,
4616
+ channel_id: null,
4617
+ self_mute: false,
4618
+ self_deaf: false
4619
+ }
4620
+ });
4532
4621
  }
4533
- });
4622
+ }
4534
4623
  }
4535
4624
  /**
4536
- * Get the active voice connection for a guild, if any.
4537
- * @param guildId - Guild ID to look up
4625
+ * Get the active voice connection for a channel or guild.
4626
+ * @param channelOrGuildId - Channel ID (primary) or guild ID (returns first connection in that guild)
4538
4627
  */
4539
- getConnection(guildId) {
4540
- return this.connections.get(guildId);
4628
+ getConnection(channelOrGuildId) {
4629
+ const byChannel = this.connections.get(channelOrGuildId);
4630
+ if (byChannel) return byChannel;
4631
+ for (const [, c] of this.connections) {
4632
+ if (c?.channel?.guildId === channelOrGuildId) return c;
4633
+ }
4634
+ return void 0;
4541
4635
  }
4542
4636
  /**
4543
4637
  * Update voice state (e.g. self_stream, self_video) while in a channel.
4544
4638
  * Sends a VoiceStateUpdate to the gateway so the server and clients see the change.
4545
4639
  * Requires connection_id (from VoiceServerUpdate); without it, the gateway would treat
4546
4640
  * the update as a new join and trigger a new VoiceServerUpdate, causing connection loops.
4547
- * @param guildId - Guild ID
4641
+ * @param channelId - Channel ID (connection key)
4548
4642
  * @param partial - Partial voice state to update (self_stream, self_video, self_mute, self_deaf)
4549
4643
  */
4550
- updateVoiceState(guildId, partial) {
4551
- const conn = this.connections.get(guildId);
4644
+ updateVoiceState(channelId, partial) {
4645
+ const conn = this.connections.get(channelId);
4552
4646
  if (!conn) return;
4553
- const connectionId = this.connectionIds.get(guildId);
4647
+ const connectionId = this.connectionIds.get(channelId);
4648
+ const guildId = conn.channel?.guildId;
4554
4649
  if (!connectionId) {
4555
4650
  this.client.emit?.(
4556
4651
  "debug",
4557
- `[VoiceManager] Skipping voice state sync: no connection_id for guild ${guildId}`
4652
+ `[VoiceManager] Skipping voice state sync: no connection_id for channel ${channelId}`
4558
4653
  );
4559
4654
  return;
4560
4655
  }
4561
4656
  this.client.sendToGateway(this.shardId, {
4562
4657
  op: import_types.GatewayOpcodes.VoiceStateUpdate,
4563
4658
  d: {
4564
- guild_id: guildId,
4659
+ guild_id: guildId ?? "",
4565
4660
  channel_id: conn.channel.id,
4566
4661
  connection_id: connectionId,
4567
4662
  self_mute: partial.self_mute ?? false,