@fluxerjs/voice 1.0.6 → 1.0.7

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
@@ -43,6 +43,10 @@ var import_events3 = require("events");
43
43
  var import_core = require("@fluxerjs/core");
44
44
  var import_types = require("@fluxerjs/types");
45
45
 
46
+ // src/streamPreviewPlaceholder.ts
47
+ var MINIMAL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
48
+ var thumbnail = MINIMAL_PNG_BASE64;
49
+
46
50
  // src/VoiceConnection.ts
47
51
  var import_events = require("events");
48
52
  var nacl = __toESM(require("tweetnacl"));
@@ -100,9 +104,11 @@ var VoiceConnection = class extends import_events.EventEmitter {
100
104
  this.guildId = channel.guildId;
101
105
  this._userId = userId;
102
106
  }
107
+ /** Discord voice session ID. */
103
108
  get sessionId() {
104
109
  return this._sessionId;
105
110
  }
111
+ /** Whether audio is currently playing. */
106
112
  get playing() {
107
113
  return this._playing;
108
114
  }
@@ -342,6 +348,7 @@ var VoiceConnection = class extends import_events.EventEmitter {
342
348
  this.udpSocket.send(packet, 0, packet.length, this.remoteUdpPort, this.remoteUdpAddress);
343
349
  }
344
350
  }
351
+ /** Stop playback and clear the queue. */
345
352
  stop() {
346
353
  this._playing = false;
347
354
  this.audioPacketQueue = [];
@@ -354,6 +361,7 @@ var VoiceConnection = class extends import_events.EventEmitter {
354
361
  this.currentStream = null;
355
362
  }
356
363
  }
364
+ /** Disconnect from voice (closes WebSocket and UDP). */
357
365
  disconnect() {
358
366
  this._destroyed = true;
359
367
  this.stop();
@@ -371,6 +379,7 @@ var VoiceConnection = class extends import_events.EventEmitter {
371
379
  }
372
380
  this.emit("disconnect");
373
381
  }
382
+ /** Disconnect and remove all listeners. */
374
383
  destroy() {
375
384
  this.disconnect();
376
385
  this.removeAllListeners();
@@ -378,6 +387,7 @@ var VoiceConnection = class extends import_events.EventEmitter {
378
387
  };
379
388
 
380
389
  // src/LiveKitRtcConnection.ts
390
+ var import_node_child_process = require("child_process");
381
391
  var import_events2 = require("events");
382
392
  var import_rtc_node = require("@livekit/rtc-node");
383
393
 
@@ -470,27 +480,113 @@ function concatUint8Arrays(a, b) {
470
480
  // src/LiveKitRtcConnection.ts
471
481
  var SAMPLE_RATE = 48e3;
472
482
  var CHANNELS2 = 1;
483
+ function getNaluByteLength(nalu) {
484
+ if (ArrayBuffer.isView(nalu)) return nalu.byteLength;
485
+ if (nalu instanceof ArrayBuffer) return nalu.byteLength;
486
+ if (Array.isArray(nalu)) return nalu.length;
487
+ return 0;
488
+ }
489
+ function toUint8Array(nalu) {
490
+ if (nalu instanceof Uint8Array) return nalu;
491
+ if (ArrayBuffer.isView(nalu)) return new Uint8Array(nalu.buffer, nalu.byteOffset, nalu.byteLength);
492
+ if (nalu instanceof ArrayBuffer) return new Uint8Array(nalu);
493
+ if (Array.isArray(nalu)) return new Uint8Array(nalu);
494
+ return new Uint8Array(0);
495
+ }
496
+ function buildAvcDecoderConfig(avcC) {
497
+ try {
498
+ let size = 6;
499
+ for (const s of avcC.SPS) size += 2 + getNaluByteLength(s.nalu);
500
+ size += 1;
501
+ for (const p of avcC.PPS) size += 2 + getNaluByteLength(p.nalu);
502
+ if (avcC.ext) size += getNaluByteLength(avcC.ext);
503
+ const buf = new ArrayBuffer(size);
504
+ const view = new DataView(buf);
505
+ const arr = new Uint8Array(buf);
506
+ let offset = 0;
507
+ view.setUint8(offset++, avcC.configurationVersion);
508
+ view.setUint8(offset++, avcC.AVCProfileIndication);
509
+ view.setUint8(offset++, avcC.profile_compatibility);
510
+ view.setUint8(offset++, avcC.AVCLevelIndication);
511
+ view.setUint8(offset++, avcC.lengthSizeMinusOne & 3 | 252);
512
+ view.setUint8(offset++, avcC.SPS.length & 31 | 224);
513
+ for (const s of avcC.SPS) {
514
+ const naluBytes = toUint8Array(s.nalu);
515
+ const naluLen = naluBytes.byteLength;
516
+ if (offset + 2 + naluLen > size) return void 0;
517
+ view.setUint16(offset, naluLen, false);
518
+ offset += 2;
519
+ arr.set(naluBytes, offset);
520
+ offset += naluLen;
521
+ }
522
+ view.setUint8(offset++, avcC.PPS.length);
523
+ for (const p of avcC.PPS) {
524
+ const naluBytes = toUint8Array(p.nalu);
525
+ const naluLen = naluBytes.byteLength;
526
+ if (offset + 2 + naluLen > size) return void 0;
527
+ view.setUint16(offset, naluLen, false);
528
+ offset += 2;
529
+ arr.set(naluBytes, offset);
530
+ offset += naluLen;
531
+ }
532
+ if (avcC.ext) {
533
+ const extBytes = toUint8Array(avcC.ext);
534
+ if (offset + extBytes.byteLength > size) return void 0;
535
+ arr.set(extBytes, offset);
536
+ }
537
+ return buf;
538
+ } catch {
539
+ return void 0;
540
+ }
541
+ }
473
542
  var FRAME_SAMPLES = 480;
543
+ function floatToInt16(float32) {
544
+ const int16 = new Int16Array(float32.length);
545
+ for (let i = 0; i < float32.length; i++) {
546
+ let s = float32[i];
547
+ if (!Number.isFinite(s)) {
548
+ int16[i] = 0;
549
+ continue;
550
+ }
551
+ s = Math.max(-1, Math.min(1, s));
552
+ const scale = s < 0 ? 32768 : 32767;
553
+ const dither = (Math.random() + Math.random() - 1) * 0.5;
554
+ const scaled = Math.round(s * scale + dither);
555
+ int16[i] = Math.max(-32768, Math.min(32767, scaled));
556
+ }
557
+ return int16;
558
+ }
474
559
  var VOICE_DEBUG = process.env.VOICE_DEBUG === "1" || process.env.VOICE_DEBUG === "true";
475
560
  var LiveKitRtcConnection = class extends import_events2.EventEmitter {
476
561
  client;
477
562
  channel;
478
563
  guildId;
479
564
  _playing = false;
565
+ _playingVideo = false;
480
566
  _destroyed = false;
481
567
  room = null;
482
568
  audioSource = null;
483
569
  audioTrack = null;
570
+ videoSource = null;
571
+ videoTrack = null;
484
572
  currentStream = null;
573
+ currentVideoStream = null;
574
+ _videoCleanup = null;
485
575
  lastServerEndpoint = null;
486
576
  lastServerToken = null;
487
577
  _disconnectEmitted = false;
578
+ /**
579
+ * @param client - The Fluxer client instance
580
+ * @param channel - The voice channel to connect to
581
+ * @param _userId - The user ID (reserved for future use)
582
+ */
488
583
  constructor(client, channel, _userId) {
489
584
  super();
490
585
  this.client = client;
491
586
  this.channel = channel;
492
587
  this.guildId = channel.guildId;
493
588
  }
589
+ /** Whether audio is currently playing. */
494
590
  get playing() {
495
591
  return this._playing;
496
592
  }
@@ -512,7 +608,11 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
512
608
  isConnected() {
513
609
  return !this._destroyed && this.room != null && this.room.isConnected;
514
610
  }
515
- /** Returns true if we're already connected to the given server (skip migration). */
611
+ /**
612
+ * Returns true if we're already connected to the given server (skip migration).
613
+ * @param endpoint - Voice server endpoint from the gateway
614
+ * @param token - Voice server token
615
+ */
516
616
  isSameServer(endpoint, token) {
517
617
  const ep = (endpoint ?? "").trim();
518
618
  return ep === (this.lastServerEndpoint ?? "") && token === (this.lastServerToken ?? "");
@@ -520,6 +620,13 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
520
620
  playOpus(_stream) {
521
621
  this.emit("error", new Error("LiveKit: playOpus not supported; use play(url) with a WebM/Opus URL"));
522
622
  }
623
+ /**
624
+ * Connect to the LiveKit room using voice server and state from the gateway.
625
+ * Called internally by VoiceManager; typically not used directly.
626
+ *
627
+ * @param server - Voice server update data (endpoint, token)
628
+ * @param _state - Voice state update data (session, channel)
629
+ */
523
630
  async connect(server, _state) {
524
631
  const raw = (server.endpoint ?? "").trim();
525
632
  const token = server.token;
@@ -557,6 +664,751 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
557
664
  throw err;
558
665
  }
559
666
  }
667
+ /** Whether a video track is currently playing in the voice channel. */
668
+ get playingVideo() {
669
+ return this._playingVideo;
670
+ }
671
+ /**
672
+ * Play video from an MP4 URL or buffer. Streams decoded frames to the LiveKit room as a video track.
673
+ * Uses node-webcodecs for decoding (no ffmpeg). Supports H.264 (avc1) and H.265 (hvc1/hev1) codecs.
674
+ *
675
+ * @param urlOrBuffer - Video source: HTTP(S) URL to an MP4 file, or raw ArrayBuffer/Uint8Array of MP4 data
676
+ * @param options - Optional playback options (see {@link VideoPlayOptions})
677
+ * @emits error - On fetch failure, missing video track, or decode errors
678
+ *
679
+ * @example
680
+ * ```ts
681
+ * const conn = await voiceManager.join(channel);
682
+ * if (conn instanceof LiveKitRtcConnection && conn.isConnected()) {
683
+ * await conn.playVideo('https://example.com/video.mp4', { source: 'camera' });
684
+ * }
685
+ * ```
686
+ */
687
+ async playVideo(urlOrBuffer, options) {
688
+ this.stopVideo();
689
+ if (!this.room || !this.room.isConnected) {
690
+ this.emit("error", new Error("LiveKit: not connected"));
691
+ return;
692
+ }
693
+ const useFFmpeg = options?.useFFmpeg ?? process.env.FLUXER_VIDEO_FFMPEG === "1";
694
+ if (useFFmpeg && typeof urlOrBuffer === "string") {
695
+ await this.playVideoFFmpeg(urlOrBuffer, options);
696
+ return;
697
+ }
698
+ if (useFFmpeg && (urlOrBuffer instanceof ArrayBuffer || urlOrBuffer instanceof Uint8Array)) {
699
+ this.emit("error", new Error("useFFmpeg requires a URL; buffer/ArrayBuffer not supported"));
700
+ return;
701
+ }
702
+ const { createFile } = await import("mp4box");
703
+ const { VideoDecoder, EncodedVideoChunk } = await import("node-webcodecs");
704
+ const videoUrl = typeof urlOrBuffer === "string" ? urlOrBuffer : null;
705
+ let arrayBuffer;
706
+ if (typeof urlOrBuffer === "string") {
707
+ try {
708
+ const response = await fetch(urlOrBuffer);
709
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
710
+ const buf = await response.arrayBuffer();
711
+ arrayBuffer = buf;
712
+ } catch (e) {
713
+ this.emit("error", e instanceof Error ? e : new Error(String(e)));
714
+ return;
715
+ }
716
+ } else if (urlOrBuffer instanceof Uint8Array) {
717
+ arrayBuffer = urlOrBuffer.buffer.slice(urlOrBuffer.byteOffset, urlOrBuffer.byteOffset + urlOrBuffer.byteLength);
718
+ } else {
719
+ arrayBuffer = urlOrBuffer;
720
+ }
721
+ const file = createFile();
722
+ const sourceOption = options?.source ?? "camera";
723
+ const loop = options?.loop ?? true;
724
+ file.onError = (e) => {
725
+ this._playingVideo = false;
726
+ this.emit("error", e);
727
+ };
728
+ file.onReady = (info) => {
729
+ const tracks = info.tracks ?? [];
730
+ const videoTrack = tracks.find((t) => t.type === "video");
731
+ if (!videoTrack) {
732
+ this.emit("error", new Error("No video track in MP4"));
733
+ return;
734
+ }
735
+ const audioTrackInfo = tracks.find((t) => t.type === "audio" && t.codec.startsWith("mp4a"));
736
+ const width = videoTrack.video?.width ?? 640;
737
+ const height = videoTrack.video?.height ?? 480;
738
+ const totalSamples = videoTrack.nb_samples ?? Number.POSITIVE_INFINITY;
739
+ const source = new import_rtc_node.VideoSource(width, height);
740
+ this.videoSource = source;
741
+ const track = import_rtc_node.LocalVideoTrack.createVideoTrack("video", source);
742
+ this.videoTrack = track;
743
+ let audioSource = null;
744
+ let audioTrack = null;
745
+ let audioFfmpegProc = null;
746
+ const decoderCodec = videoTrack.codec.startsWith("avc1") ? videoTrack.codec : videoTrack.codec.startsWith("hvc1") || videoTrack.codec.startsWith("hev1") ? videoTrack.codec : "avc1.42E01E";
747
+ let decoderDescription;
748
+ if (videoTrack.codec.startsWith("avc1") || videoTrack.codec.startsWith("avc3")) {
749
+ const isoFile = file;
750
+ const trak = isoFile.moov?.traks?.find((t) => t.tkhd.track_id === videoTrack.id);
751
+ const sampleEntry = trak?.mdia?.minf?.stbl?.stsd?.entries?.[0];
752
+ const avcC = sampleEntry?.avcC;
753
+ if (avcC) {
754
+ decoderDescription = buildAvcDecoderConfig(avcC);
755
+ }
756
+ }
757
+ if (videoUrl && audioTrackInfo) {
758
+ audioSource = new import_rtc_node.AudioSource(SAMPLE_RATE, CHANNELS2);
759
+ this.audioSource = audioSource;
760
+ audioTrack = import_rtc_node.LocalAudioTrack.createAudioTrack("audio", audioSource);
761
+ this.audioTrack = audioTrack;
762
+ }
763
+ const frameQueue = [];
764
+ let playbackStartMs = null;
765
+ const maxFps = options?.maxFramerate ?? 60;
766
+ const FRAME_INTERVAL_MS = Math.round(1e3 / maxFps);
767
+ const MAX_QUEUED_FRAMES = 30;
768
+ let pacingInterval = null;
769
+ const decoder = new VideoDecoder({
770
+ output: async (frame) => {
771
+ if (!this._playingVideo || !source) return;
772
+ const { codedWidth, codedHeight } = frame;
773
+ if (codedWidth <= 0 || codedHeight <= 0) {
774
+ frame.close();
775
+ if (VOICE_DEBUG) this.audioDebug("video frame skipped (invalid dimensions)", { codedWidth, codedHeight });
776
+ return;
777
+ }
778
+ try {
779
+ if (playbackStartMs === null) playbackStartMs = Date.now();
780
+ const frameTimestampUs = frame.timestamp ?? 0;
781
+ const frameTimeMs = frameTimestampUs / 1e3;
782
+ const copyOptions = frame.format !== "I420" ? { format: "I420" } : void 0;
783
+ const size = frame.allocationSize(copyOptions);
784
+ const buffer = new Uint8Array(size);
785
+ await frame.copyTo(buffer, copyOptions);
786
+ frame.close();
787
+ const expectedI420Size = Math.ceil(codedWidth * codedHeight * 3 / 2);
788
+ if (buffer.byteLength < expectedI420Size) {
789
+ if (VOICE_DEBUG) this.audioDebug("video frame skipped (buffer too small)", { codedWidth, codedHeight });
790
+ return;
791
+ }
792
+ while (frameQueue.length >= MAX_QUEUED_FRAMES) {
793
+ frameQueue.shift();
794
+ }
795
+ frameQueue.push({ buffer, width: codedWidth, height: codedHeight, timestampMs: frameTimeMs });
796
+ } catch (err) {
797
+ if (VOICE_DEBUG) this.audioDebug("video frame error", { error: String(err) });
798
+ }
799
+ },
800
+ error: (e) => {
801
+ this.emit("error", e);
802
+ doCleanup();
803
+ }
804
+ });
805
+ decoder.configure({
806
+ codec: decoderCodec,
807
+ codedWidth: width,
808
+ codedHeight: height,
809
+ ...decoderDescription && { description: decoderDescription }
810
+ });
811
+ let samplesReceived = 0;
812
+ let cleanupCalled = false;
813
+ let currentFile = file;
814
+ const doCleanup = () => {
815
+ if (cleanupCalled) return;
816
+ cleanupCalled = true;
817
+ this._videoCleanup = null;
818
+ this._playingVideo = false;
819
+ if (pacingInterval) {
820
+ clearInterval(pacingInterval);
821
+ pacingInterval = null;
822
+ }
823
+ this.emit("requestVoiceStateSync", { self_stream: false, self_video: false });
824
+ const fileObj = currentFile;
825
+ if (typeof fileObj.stop === "function") {
826
+ fileObj.stop();
827
+ }
828
+ try {
829
+ decoder.close();
830
+ } catch (_) {
831
+ }
832
+ if (audioFfmpegProc && !audioFfmpegProc.killed) {
833
+ audioFfmpegProc.kill("SIGKILL");
834
+ audioFfmpegProc = null;
835
+ }
836
+ this.currentVideoStream = null;
837
+ if (this.videoTrack) {
838
+ this.videoTrack.close().catch(() => {
839
+ });
840
+ this.videoTrack = null;
841
+ }
842
+ if (this.videoSource) {
843
+ this.videoSource.close().catch(() => {
844
+ });
845
+ this.videoSource = null;
846
+ }
847
+ if (audioTrack) {
848
+ audioTrack.close().catch(() => {
849
+ });
850
+ this.audioTrack = null;
851
+ }
852
+ if (audioSource) {
853
+ audioSource.close().catch(() => {
854
+ });
855
+ this.audioSource = null;
856
+ }
857
+ };
858
+ const flushAndCleanup = () => {
859
+ decoder.flush().then(doCleanup).catch(doCleanup);
860
+ };
861
+ const scheduleLoop = (mp4File) => {
862
+ setImmediate(async () => {
863
+ if (!this._playingVideo || cleanupCalled) return;
864
+ try {
865
+ await decoder.flush();
866
+ decoder.reset();
867
+ decoder.configure({
868
+ codec: decoderCodec,
869
+ codedWidth: width,
870
+ codedHeight: height,
871
+ ...decoderDescription && { description: decoderDescription }
872
+ });
873
+ const fileObj = mp4File;
874
+ if (typeof fileObj.stop === "function") fileObj.stop();
875
+ } catch (e) {
876
+ if (VOICE_DEBUG) this.audioDebug("loop reset error", { error: String(e) });
877
+ }
878
+ if (!this._playingVideo || cleanupCalled) return;
879
+ playbackStartMs = null;
880
+ frameQueue.length = 0;
881
+ samplesReceived = 0;
882
+ const loopFile = createFile();
883
+ loopFile.onError = (e) => {
884
+ this._playingVideo = false;
885
+ this.emit("error", e);
886
+ };
887
+ loopFile.onReady = (loopInfo) => {
888
+ const loopTracks = loopInfo.tracks ?? [];
889
+ const loopVt = loopTracks.find((t) => t.type === "video");
890
+ if (!loopVt || loopVt.id !== videoTrack.id) return;
891
+ currentFile = loopFile;
892
+ this.currentVideoStream = loopFile;
893
+ loopFile.setExtractionOptions(loopVt.id, null, { nbSamples: 16 });
894
+ loopFile.onSamples = (tid, _u, samp) => {
895
+ if (!this._playingVideo) return;
896
+ if (tid === videoTrack.id) {
897
+ try {
898
+ for (const sample of samp) {
899
+ const isKeyFrame = sample.is_sync ?? sample.is_rap ?? sample.dts === 0;
900
+ const chunk = new EncodedVideoChunk({
901
+ type: isKeyFrame ? "key" : "delta",
902
+ timestamp: Math.round(sample.dts / sample.timescale * 1e6),
903
+ duration: Math.round(sample.duration / sample.timescale * 1e6),
904
+ data: sample.data
905
+ });
906
+ decoder.decode(chunk);
907
+ }
908
+ } catch (decodeErr) {
909
+ this.emit("error", decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr)));
910
+ doCleanup();
911
+ return;
912
+ }
913
+ samplesReceived += samp.length;
914
+ if (samplesReceived >= totalSamples) {
915
+ if (loop) scheduleLoop(loopFile);
916
+ else flushAndCleanup();
917
+ }
918
+ }
919
+ };
920
+ loopFile.start();
921
+ };
922
+ arrayBuffer.fileStart = 0;
923
+ loopFile.appendBuffer(arrayBuffer);
924
+ loopFile.flush();
925
+ });
926
+ };
927
+ this._videoCleanup = () => {
928
+ doCleanup();
929
+ };
930
+ file.onSamples = (trackId, _user, samples) => {
931
+ if (!this._playingVideo) return;
932
+ if (trackId === videoTrack.id) {
933
+ try {
934
+ for (const sample of samples) {
935
+ const isKeyFrame = sample.is_sync ?? sample.is_rap ?? sample.dts === 0;
936
+ const chunk = new EncodedVideoChunk({
937
+ type: isKeyFrame ? "key" : "delta",
938
+ timestamp: Math.round(sample.dts / sample.timescale * 1e6),
939
+ duration: Math.round(sample.duration / sample.timescale * 1e6),
940
+ data: sample.data
941
+ });
942
+ decoder.decode(chunk);
943
+ }
944
+ } catch (decodeErr) {
945
+ this.emit("error", decodeErr instanceof Error ? decodeErr : new Error(String(decodeErr)));
946
+ doCleanup();
947
+ return;
948
+ }
949
+ samplesReceived += samples.length;
950
+ if (samplesReceived >= totalSamples) {
951
+ if (loop) scheduleLoop(file);
952
+ else flushAndCleanup();
953
+ }
954
+ }
955
+ };
956
+ const participant = this.room?.localParticipant;
957
+ if (!participant) return;
958
+ const publishOptions = new import_rtc_node.TrackPublishOptions({
959
+ source: sourceOption === "screenshare" ? import_rtc_node.TrackSource.SOURCE_SCREENSHARE : import_rtc_node.TrackSource.SOURCE_CAMERA,
960
+ videoEncoding: {
961
+ maxBitrate: BigInt(options?.videoBitrate ?? 25e5),
962
+ maxFramerate: options?.maxFramerate ?? 60
963
+ }
964
+ });
965
+ const publishVideo = participant.publishTrack(track, publishOptions);
966
+ const audioPublishOptions = new import_rtc_node.TrackPublishOptions();
967
+ audioPublishOptions.source = import_rtc_node.TrackSource.SOURCE_MICROPHONE;
968
+ const publishAudio = audioTrack ? participant.publishTrack(audioTrack, audioPublishOptions) : Promise.resolve();
969
+ Promise.all([publishVideo, publishAudio]).then(async () => {
970
+ this._playingVideo = true;
971
+ this.currentVideoStream = file;
972
+ file.setExtractionOptions(videoTrack.id, null, { nbSamples: 16 });
973
+ pacingInterval = setInterval(() => {
974
+ if (!this._playingVideo || !source || playbackStartMs === null) return;
975
+ const elapsed = Date.now() - playbackStartMs;
976
+ if (frameQueue.length > 10) {
977
+ while (frameQueue.length > 1 && frameQueue[1].timestampMs <= elapsed) {
978
+ frameQueue.shift();
979
+ }
980
+ }
981
+ if (frameQueue.length > 0 && frameQueue[0].timestampMs <= elapsed) {
982
+ const f = frameQueue.shift();
983
+ try {
984
+ const livekitFrame = new import_rtc_node.VideoFrame(f.buffer, f.width, f.height, import_rtc_node.VideoBufferType.I420);
985
+ source.captureFrame(livekitFrame);
986
+ } catch (captureErr) {
987
+ if (VOICE_DEBUG) this.audioDebug("captureFrame error", { error: String(captureErr) });
988
+ this.emit("error", captureErr instanceof Error ? captureErr : new Error(String(captureErr)));
989
+ }
990
+ }
991
+ }, FRAME_INTERVAL_MS);
992
+ setImmediate(() => {
993
+ if (!this._playingVideo) return;
994
+ file.start();
995
+ });
996
+ if (videoUrl && audioSource && audioTrack) {
997
+ const { opus: prismOpus } = await import("prism-media");
998
+ const { OpusDecoder } = await import("opus-decoder");
999
+ const runAudioFfmpeg = async () => {
1000
+ if (!this._playingVideo || cleanupCalled || !audioSource) return;
1001
+ const audioProc = (0, import_node_child_process.spawn)("ffmpeg", [
1002
+ "-loglevel",
1003
+ "warning",
1004
+ "-re",
1005
+ "-i",
1006
+ videoUrl,
1007
+ "-vn",
1008
+ "-c:a",
1009
+ "libopus",
1010
+ "-f",
1011
+ "webm",
1012
+ ...loop ? ["-stream_loop", "-1"] : [],
1013
+ "pipe:1"
1014
+ ], { stdio: ["ignore", "pipe", "pipe"] });
1015
+ audioFfmpegProc = audioProc;
1016
+ const demuxer = new prismOpus.WebmDemuxer();
1017
+ if (audioProc.stdout) audioProc.stdout.pipe(demuxer);
1018
+ const decoder2 = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
1019
+ await decoder2.ready;
1020
+ let sampleBuffer = new Int16Array(0);
1021
+ let opusBuffer = new Uint8Array(0);
1022
+ let processing = false;
1023
+ const opusFrameQueue = [];
1024
+ const processOneOpusFrame = async (frame) => {
1025
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1026
+ try {
1027
+ const result = decoder2.decodeFrame(frame);
1028
+ if (!result?.channelData?.[0]?.length) return;
1029
+ const int16 = floatToInt16(result.channelData[0]);
1030
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1031
+ newBuffer.set(sampleBuffer);
1032
+ newBuffer.set(int16, sampleBuffer.length);
1033
+ sampleBuffer = newBuffer;
1034
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1035
+ const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1036
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1037
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1038
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1039
+ await audioSource.captureFrame(audioFrame);
1040
+ }
1041
+ } catch (_) {
1042
+ }
1043
+ };
1044
+ const drainQueue = async () => {
1045
+ if (processing || opusFrameQueue.length === 0) return;
1046
+ processing = true;
1047
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1048
+ const f = opusFrameQueue.shift();
1049
+ await processOneOpusFrame(f);
1050
+ }
1051
+ processing = false;
1052
+ };
1053
+ demuxer.on("data", (chunk) => {
1054
+ if (!this._playingVideo) return;
1055
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1056
+ while (opusBuffer.length > 0) {
1057
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1058
+ if (!parsed) break;
1059
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1060
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
1061
+ }
1062
+ drainQueue().catch(() => {
1063
+ });
1064
+ });
1065
+ audioProc.on("exit", (code) => {
1066
+ if (audioFfmpegProc === audioProc) audioFfmpegProc = null;
1067
+ if (loop && this._playingVideo && !cleanupCalled && (code === 0 || code === null)) {
1068
+ setImmediate(() => runAudioFfmpeg());
1069
+ }
1070
+ });
1071
+ };
1072
+ runAudioFfmpeg().catch((e) => this.audioDebug("audio ffmpeg error", { error: String(e) }));
1073
+ }
1074
+ this.emit("requestVoiceStateSync", {
1075
+ self_stream: sourceOption === "screenshare",
1076
+ self_video: sourceOption === "camera"
1077
+ });
1078
+ }).catch((err) => {
1079
+ this._playingVideo = false;
1080
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
1081
+ });
1082
+ };
1083
+ arrayBuffer.fileStart = 0;
1084
+ file.appendBuffer(arrayBuffer);
1085
+ file.flush();
1086
+ }
1087
+ /**
1088
+ * FFmpeg-based video playback. Bypasses node-webcodecs to avoid libc++abi crashes on macOS.
1089
+ * Requires ffmpeg and ffprobe in PATH. URL input only.
1090
+ */
1091
+ async playVideoFFmpeg(url, options) {
1092
+ const sourceOption = options?.source ?? "camera";
1093
+ const loop = options?.loop ?? true;
1094
+ let width = 640;
1095
+ let height = 480;
1096
+ try {
1097
+ const { execFile } = await import("child_process");
1098
+ const { promisify } = await import("util");
1099
+ const exec = promisify(execFile);
1100
+ const { stdout } = await exec("ffprobe", [
1101
+ "-v",
1102
+ "error",
1103
+ "-select_streams",
1104
+ "v:0",
1105
+ "-show_entries",
1106
+ "stream=width,height",
1107
+ "-of",
1108
+ "json",
1109
+ url
1110
+ ], { encoding: "utf8", timeout: 1e4 });
1111
+ const parsed = JSON.parse(stdout);
1112
+ const stream = parsed?.streams?.[0];
1113
+ if (stream?.width && stream?.height) {
1114
+ width = stream.width;
1115
+ height = stream.height;
1116
+ }
1117
+ } catch (probeErr) {
1118
+ this.emit("error", new Error(`ffprobe failed: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`));
1119
+ return;
1120
+ }
1121
+ if (options?.width && options?.height) {
1122
+ width = options.width;
1123
+ height = options.height;
1124
+ }
1125
+ const source = new import_rtc_node.VideoSource(width, height);
1126
+ this.videoSource = source;
1127
+ const track = import_rtc_node.LocalVideoTrack.createVideoTrack("video", source);
1128
+ this.videoTrack = track;
1129
+ const publishOptions = new import_rtc_node.TrackPublishOptions({
1130
+ source: sourceOption === "screenshare" ? import_rtc_node.TrackSource.SOURCE_SCREENSHARE : import_rtc_node.TrackSource.SOURCE_CAMERA,
1131
+ videoEncoding: {
1132
+ maxBitrate: BigInt(options?.videoBitrate ?? 25e5),
1133
+ maxFramerate: options?.maxFramerate ?? 60
1134
+ }
1135
+ });
1136
+ const participant = this.room?.localParticipant;
1137
+ if (!participant) return;
1138
+ try {
1139
+ await participant.publishTrack(track, publishOptions);
1140
+ } catch (err) {
1141
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
1142
+ return;
1143
+ }
1144
+ let audioFfmpegProc = null;
1145
+ let audioSource = new import_rtc_node.AudioSource(SAMPLE_RATE, CHANNELS2);
1146
+ let audioTrack = import_rtc_node.LocalAudioTrack.createAudioTrack("audio", audioSource);
1147
+ this.audioSource = audioSource;
1148
+ this.audioTrack = audioTrack;
1149
+ try {
1150
+ await participant.publishTrack(audioTrack, new import_rtc_node.TrackPublishOptions({ source: import_rtc_node.TrackSource.SOURCE_MICROPHONE }));
1151
+ } catch {
1152
+ audioTrack.close().catch(() => {
1153
+ });
1154
+ this.audioTrack = null;
1155
+ this.audioSource = null;
1156
+ audioSource = null;
1157
+ audioTrack = null;
1158
+ }
1159
+ this._playingVideo = true;
1160
+ this.emit("requestVoiceStateSync", {
1161
+ self_stream: sourceOption === "screenshare",
1162
+ self_video: sourceOption === "camera"
1163
+ });
1164
+ const frameSize = Math.ceil(width * height * 3 / 2);
1165
+ const maxFps = options?.maxFramerate ?? 60;
1166
+ const FRAME_INTERVAL_MS = Math.round(1e3 / maxFps);
1167
+ let pacingTimeout = null;
1168
+ let ffmpegProc = null;
1169
+ let cleanupCalled = false;
1170
+ const doCleanup = () => {
1171
+ if (cleanupCalled) return;
1172
+ cleanupCalled = true;
1173
+ this._videoCleanup = null;
1174
+ this._playingVideo = false;
1175
+ if (pacingTimeout !== null) {
1176
+ clearTimeout(pacingTimeout);
1177
+ pacingTimeout = null;
1178
+ }
1179
+ if (ffmpegProc && !ffmpegProc.killed) {
1180
+ ffmpegProc.kill("SIGKILL");
1181
+ ffmpegProc = null;
1182
+ }
1183
+ if (audioFfmpegProc && !audioFfmpegProc.killed) {
1184
+ audioFfmpegProc.kill("SIGKILL");
1185
+ audioFfmpegProc = null;
1186
+ }
1187
+ this.emit("requestVoiceStateSync", { self_stream: false, self_video: false });
1188
+ this.currentVideoStream = null;
1189
+ if (this.audioTrack) {
1190
+ this.audioTrack.close().catch(() => {
1191
+ });
1192
+ this.audioTrack = null;
1193
+ }
1194
+ if (this.audioSource) {
1195
+ this.audioSource.close().catch(() => {
1196
+ });
1197
+ this.audioSource = null;
1198
+ }
1199
+ if (this.videoTrack) {
1200
+ this.videoTrack.close().catch(() => {
1201
+ });
1202
+ this.videoTrack = null;
1203
+ }
1204
+ if (this.videoSource) {
1205
+ this.videoSource.close().catch(() => {
1206
+ });
1207
+ this.videoSource = null;
1208
+ }
1209
+ };
1210
+ this._videoCleanup = () => doCleanup();
1211
+ const frameBuffer = [];
1212
+ let frameBufferBytes = 0;
1213
+ const MAX_QUEUED_FRAMES = 60;
1214
+ const FRAME_DURATION_US = BigInt(Math.round(1e6 / maxFps));
1215
+ let frameIndex = 0n;
1216
+ const pushFramesFromBuffer = () => {
1217
+ if (!this._playingVideo || !source || cleanupCalled) return;
1218
+ if (frameBufferBytes < frameSize) return;
1219
+ if (frameBufferBytes > frameSize * MAX_QUEUED_FRAMES) {
1220
+ const framesToDrop = Math.floor((frameBufferBytes - frameSize * 2) / frameSize);
1221
+ let toDropBytes = framesToDrop * frameSize;
1222
+ while (toDropBytes > 0 && frameBuffer.length > 0) {
1223
+ const c = frameBuffer[0];
1224
+ if (c.length <= toDropBytes) {
1225
+ toDropBytes -= c.length;
1226
+ frameBufferBytes -= c.length;
1227
+ frameBuffer.shift();
1228
+ } else {
1229
+ frameBuffer[0] = c.subarray(toDropBytes);
1230
+ frameBufferBytes -= toDropBytes;
1231
+ toDropBytes = 0;
1232
+ }
1233
+ }
1234
+ }
1235
+ let remaining = frameSize;
1236
+ const parts = [];
1237
+ while (remaining > 0 && frameBuffer.length > 0) {
1238
+ const c = frameBuffer[0];
1239
+ const take = Math.min(remaining, c.length);
1240
+ parts.push(c.subarray(0, take));
1241
+ remaining -= take;
1242
+ if (take >= c.length) {
1243
+ frameBuffer.shift();
1244
+ } else {
1245
+ frameBuffer[0] = c.subarray(take);
1246
+ }
1247
+ }
1248
+ frameBufferBytes -= frameSize;
1249
+ const frameData = Buffer.concat(parts, frameSize);
1250
+ if (frameData.length !== frameSize) return;
1251
+ try {
1252
+ const frame = new import_rtc_node.VideoFrame(
1253
+ new Uint8Array(frameData.buffer, frameData.byteOffset, frameSize),
1254
+ width,
1255
+ height,
1256
+ import_rtc_node.VideoBufferType.I420
1257
+ );
1258
+ const timestampUs = frameIndex * FRAME_DURATION_US;
1259
+ frameIndex += 1n;
1260
+ source.captureFrame(frame, timestampUs);
1261
+ } catch (e) {
1262
+ if (VOICE_DEBUG) this.audioDebug("captureFrame error", { error: String(e) });
1263
+ }
1264
+ };
1265
+ const scheduleNextPacing = () => {
1266
+ if (!this._playingVideo || cleanupCalled) return;
1267
+ pushFramesFromBuffer();
1268
+ pacingTimeout = setTimeout(scheduleNextPacing, FRAME_INTERVAL_MS);
1269
+ };
1270
+ scheduleNextPacing();
1271
+ const runFFmpeg = () => {
1272
+ const ffmpegArgs = [
1273
+ "-loglevel",
1274
+ "warning",
1275
+ "-re",
1276
+ "-i",
1277
+ url,
1278
+ "-f",
1279
+ "rawvideo",
1280
+ "-pix_fmt",
1281
+ "yuv420p",
1282
+ "-r",
1283
+ String(maxFps)
1284
+ ];
1285
+ if (options?.width && options?.height) {
1286
+ ffmpegArgs.splice(ffmpegArgs.indexOf("-f"), 0, "-vf", `scale=${width}:${height}`);
1287
+ }
1288
+ ffmpegArgs.push("-");
1289
+ ffmpegProc = (0, import_node_child_process.spawn)("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
1290
+ this.currentVideoStream = {
1291
+ destroy: () => {
1292
+ if (ffmpegProc && !ffmpegProc.killed) ffmpegProc.kill("SIGKILL");
1293
+ }
1294
+ };
1295
+ const stdout = ffmpegProc.stdout;
1296
+ const stderr = ffmpegProc.stderr;
1297
+ if (stdout) {
1298
+ stdout.on("data", (chunk) => {
1299
+ if (!this._playingVideo || cleanupCalled) return;
1300
+ frameBuffer.push(chunk);
1301
+ frameBufferBytes += chunk.length;
1302
+ });
1303
+ }
1304
+ if (stderr) {
1305
+ stderr.on("data", (data) => {
1306
+ const line = data.toString().trim();
1307
+ if (line && VOICE_DEBUG) this.audioDebug("ffmpeg stderr", { line: line.slice(0, 200) });
1308
+ });
1309
+ }
1310
+ ffmpegProc.on("error", (err) => {
1311
+ this.emit("error", err);
1312
+ doCleanup();
1313
+ });
1314
+ ffmpegProc.on("exit", (code) => {
1315
+ ffmpegProc = null;
1316
+ if (cleanupCalled || !this._playingVideo) return;
1317
+ if (loop && (code === 0 || code === null)) {
1318
+ frameBuffer.length = 0;
1319
+ frameBufferBytes = 0;
1320
+ frameIndex = 0n;
1321
+ setImmediate(runFFmpeg);
1322
+ } else {
1323
+ doCleanup();
1324
+ }
1325
+ });
1326
+ };
1327
+ runFFmpeg();
1328
+ const runAudioFfmpeg = async () => {
1329
+ if (!this._playingVideo || cleanupCalled || !audioSource) return;
1330
+ const audioProc = (0, import_node_child_process.spawn)("ffmpeg", [
1331
+ "-loglevel",
1332
+ "warning",
1333
+ "-re",
1334
+ "-i",
1335
+ url,
1336
+ "-vn",
1337
+ "-c:a",
1338
+ "libopus",
1339
+ "-f",
1340
+ "webm",
1341
+ ...loop ? ["-stream_loop", "-1"] : [],
1342
+ "pipe:1"
1343
+ ], { stdio: ["ignore", "pipe", "pipe"] });
1344
+ audioFfmpegProc = audioProc;
1345
+ const { opus: prismOpus } = await import("prism-media");
1346
+ const { OpusDecoder } = await import("opus-decoder");
1347
+ const demuxer = new prismOpus.WebmDemuxer();
1348
+ if (audioProc.stdout) audioProc.stdout.pipe(demuxer);
1349
+ const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
1350
+ await decoder.ready;
1351
+ let sampleBuffer = new Int16Array(0);
1352
+ let opusBuffer = new Uint8Array(0);
1353
+ let processing = false;
1354
+ const opusFrameQueue = [];
1355
+ const processOneOpusFrame = async (frame) => {
1356
+ if (frame.length < 2 || !audioSource || !this._playingVideo) return;
1357
+ try {
1358
+ const result = decoder.decodeFrame(frame);
1359
+ if (!result?.channelData?.[0]?.length) return;
1360
+ const int16 = floatToInt16(result.channelData[0]);
1361
+ const newBuffer = new Int16Array(sampleBuffer.length + int16.length);
1362
+ newBuffer.set(sampleBuffer);
1363
+ newBuffer.set(int16, sampleBuffer.length);
1364
+ sampleBuffer = newBuffer;
1365
+ while (sampleBuffer.length >= FRAME_SAMPLES && this._playingVideo && audioSource) {
1366
+ const outSamples = sampleBuffer.subarray(0, FRAME_SAMPLES);
1367
+ sampleBuffer = sampleBuffer.subarray(FRAME_SAMPLES).slice();
1368
+ const audioFrame = new import_rtc_node.AudioFrame(outSamples, SAMPLE_RATE, CHANNELS2, FRAME_SAMPLES);
1369
+ if (audioSource.queuedDuration > 500) await audioSource.waitForPlayout();
1370
+ await audioSource.captureFrame(audioFrame);
1371
+ }
1372
+ } catch (_) {
1373
+ }
1374
+ };
1375
+ const drainQueue = async () => {
1376
+ if (processing || opusFrameQueue.length === 0) return;
1377
+ processing = true;
1378
+ while (opusFrameQueue.length > 0 && this._playingVideo && audioSource) {
1379
+ const f = opusFrameQueue.shift();
1380
+ await processOneOpusFrame(f);
1381
+ }
1382
+ processing = false;
1383
+ };
1384
+ demuxer.on("data", (chunk) => {
1385
+ if (!this._playingVideo) return;
1386
+ opusBuffer = new Uint8Array(concatUint8Arrays(opusBuffer, new Uint8Array(chunk)));
1387
+ while (opusBuffer.length > 0) {
1388
+ const parsed = parseOpusPacketBoundaries(opusBuffer);
1389
+ if (!parsed) break;
1390
+ opusBuffer = new Uint8Array(opusBuffer.subarray(parsed.consumed));
1391
+ for (const frame of parsed.frames) opusFrameQueue.push(frame);
1392
+ }
1393
+ drainQueue().catch(() => {
1394
+ });
1395
+ });
1396
+ audioProc.on("exit", (code) => {
1397
+ if (audioFfmpegProc === audioProc) audioFfmpegProc = null;
1398
+ if (loop && this._playingVideo && !cleanupCalled && (code === 0 || code === null)) {
1399
+ setImmediate(() => runAudioFfmpeg());
1400
+ }
1401
+ });
1402
+ };
1403
+ runAudioFfmpeg().catch(() => {
1404
+ });
1405
+ }
1406
+ /**
1407
+ * Play audio from a WebM/Opus URL or readable stream. Publishes to the LiveKit room as an audio track.
1408
+ *
1409
+ * @param urlOrStream - Audio source: HTTP(S) URL to a WebM/Opus file, or a Node.js ReadableStream
1410
+ * @emits error - On fetch failure or decode errors
1411
+ */
560
1412
  async play(urlOrStream) {
561
1413
  this.stop();
562
1414
  if (!this.room || !this.room.isConnected) {
@@ -593,22 +1445,6 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
593
1445
  const decoder = new OpusDecoder({ sampleRate: SAMPLE_RATE, channels: CHANNELS2 });
594
1446
  await decoder.ready;
595
1447
  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
1448
  let sampleBuffer = new Int16Array(0);
613
1449
  let opusBuffer = new Uint8Array(0);
614
1450
  let streamEnded = false;
@@ -706,8 +1542,34 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
706
1542
  }
707
1543
  });
708
1544
  }
1545
+ /**
1546
+ * Stop video playback and unpublish the video track from the LiveKit room.
1547
+ * Safe to call even when no video is playing.
1548
+ */
1549
+ stopVideo() {
1550
+ if (this._videoCleanup) {
1551
+ this._videoCleanup();
1552
+ return;
1553
+ }
1554
+ this._playingVideo = false;
1555
+ this.emit("requestVoiceStateSync", { self_stream: false, self_video: false });
1556
+ if (this.currentVideoStream?.destroy) this.currentVideoStream.destroy();
1557
+ this.currentVideoStream = null;
1558
+ if (this.videoTrack) {
1559
+ this.videoTrack.close().catch(() => {
1560
+ });
1561
+ this.videoTrack = null;
1562
+ }
1563
+ if (this.videoSource) {
1564
+ this.videoSource.close().catch(() => {
1565
+ });
1566
+ this.videoSource = null;
1567
+ }
1568
+ }
1569
+ /** Stop playback and clear both audio and video tracks. */
709
1570
  stop() {
710
1571
  this._playing = false;
1572
+ this.stopVideo();
711
1573
  if (this.currentStream?.destroy) this.currentStream.destroy();
712
1574
  this.currentStream = null;
713
1575
  if (this.audioTrack) {
@@ -721,6 +1583,7 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
721
1583
  this.audioSource = null;
722
1584
  }
723
1585
  }
1586
+ /** Disconnect from the LiveKit room and stop all playback. */
724
1587
  disconnect() {
725
1588
  this._destroyed = true;
726
1589
  this.stop();
@@ -733,6 +1596,7 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
733
1596
  this.lastServerToken = null;
734
1597
  this.emit("disconnect");
735
1598
  }
1599
+ /** Disconnect from the room and remove all event listeners. */
736
1600
  destroy() {
737
1601
  this.disconnect();
738
1602
  this.removeAllListeners();
@@ -744,6 +1608,8 @@ var import_collection = require("@fluxerjs/collection");
744
1608
  var VoiceManager = class extends import_events3.EventEmitter {
745
1609
  client;
746
1610
  connections = new import_collection.Collection();
1611
+ /** guild_id -> connection_id (from VoiceServerUpdate; required for voice state updates when in channel) */
1612
+ connectionIds = /* @__PURE__ */ new Map();
747
1613
  /** guild_id -> user_id -> channel_id */
748
1614
  voiceStates = /* @__PURE__ */ new Map();
749
1615
  pending = /* @__PURE__ */ new Map();
@@ -766,7 +1632,11 @@ var VoiceManager = class extends import_events3.EventEmitter {
766
1632
  guildMap.set(vs.user_id, vs.channel_id);
767
1633
  }
768
1634
  }
769
- /** Get the voice channel ID the user is in, or null. */
1635
+ /**
1636
+ * Get the voice channel ID the user is currently in, or null if not in voice.
1637
+ * @param guildId - Guild ID to look up
1638
+ * @param userId - User ID to look up
1639
+ */
770
1640
  getVoiceChannelId(guildId, userId) {
771
1641
  const guildMap = this.voiceStates.get(guildId);
772
1642
  if (!guildMap) return null;
@@ -775,6 +1645,7 @@ var VoiceManager = class extends import_events3.EventEmitter {
775
1645
  handleVoiceStateUpdate(data) {
776
1646
  const guildId = data.guild_id ?? "";
777
1647
  if (!guildId) return;
1648
+ this.client.emit?.("debug", `[VoiceManager] VoiceStateUpdate guild=${guildId} user=${data.user_id} channel=${data.channel_id ?? "null"} (bot=${this.client.user?.id})`);
778
1649
  let guildMap = this.voiceStates.get(guildId);
779
1650
  if (!guildMap) {
780
1651
  guildMap = /* @__PURE__ */ new Map();
@@ -782,7 +1653,12 @@ var VoiceManager = class extends import_events3.EventEmitter {
782
1653
  }
783
1654
  guildMap.set(data.user_id, data.channel_id);
784
1655
  const pending = this.pending.get(guildId);
785
- if (pending && data.user_id === this.client.user?.id) {
1656
+ const isBot = String(data.user_id) === String(this.client.user?.id);
1657
+ if (isBot && data.connection_id) {
1658
+ this.storeConnectionId(guildId, data.connection_id);
1659
+ }
1660
+ if (pending && isBot) {
1661
+ this.client.emit?.("debug", `[VoiceManager] VoiceStateUpdate for bot - completing pending guild ${guildId}`);
786
1662
  pending.state = data;
787
1663
  this.tryCompletePending(guildId);
788
1664
  }
@@ -791,6 +1667,8 @@ var VoiceManager = class extends import_events3.EventEmitter {
791
1667
  const guildId = data.guild_id;
792
1668
  const pending = this.pending.get(guildId);
793
1669
  if (pending) {
1670
+ const hasToken = !!(data.token && data.token.length > 0);
1671
+ this.client.emit?.("debug", `[VoiceManager] VoiceServerUpdate guild=${guildId} endpoint=${data.endpoint ?? "null"} token=${hasToken ? "yes" : "NO"}`);
794
1672
  pending.server = data;
795
1673
  this.tryCompletePending(guildId);
796
1674
  return;
@@ -811,6 +1689,7 @@ var VoiceManager = class extends import_events3.EventEmitter {
811
1689
  this.client.emit?.("debug", `[VoiceManager] Voice server migration for guild ${guildId}; reconnecting`);
812
1690
  conn.destroy();
813
1691
  this.connections.delete(guildId);
1692
+ this.storeConnectionId(guildId, data.connection_id);
814
1693
  const ConnClass = LiveKitRtcConnection;
815
1694
  const newConn = new ConnClass(this.client, channel, this.client.user.id);
816
1695
  this.registerConnection(guildId, newConn);
@@ -825,23 +1704,66 @@ var VoiceManager = class extends import_events3.EventEmitter {
825
1704
  newConn.emit("error", e instanceof Error ? e : new Error(String(e)));
826
1705
  });
827
1706
  }
1707
+ storeConnectionId(guildId, connectionId) {
1708
+ const id = connectionId != null ? String(connectionId) : null;
1709
+ if (id) this.connectionIds.set(guildId, id);
1710
+ else this.connectionIds.delete(guildId);
1711
+ }
828
1712
  registerConnection(guildId, conn) {
829
1713
  this.connections.set(guildId, conn);
830
- conn.once("disconnect", () => this.connections.delete(guildId));
1714
+ conn.once("disconnect", () => {
1715
+ this.connections.delete(guildId);
1716
+ this.connectionIds.delete(guildId);
1717
+ });
1718
+ conn.on("requestVoiceStateSync", (p) => {
1719
+ this.updateVoiceState(guildId, p);
1720
+ if (p.self_stream) {
1721
+ this.uploadStreamPreview(guildId, conn).catch(
1722
+ (e) => this.client.emit?.("debug", `[VoiceManager] Stream preview upload failed: ${String(e)}`)
1723
+ );
1724
+ }
1725
+ });
1726
+ }
1727
+ /** Upload a placeholder stream preview so the preview URL returns 200 instead of 404. */
1728
+ async uploadStreamPreview(guildId, conn) {
1729
+ const connectionId = this.connectionIds.get(guildId);
1730
+ if (!connectionId) return;
1731
+ const streamKey = `${guildId}:${conn.channel.id}:${connectionId}`;
1732
+ const route = import_types.Routes.streamPreview(streamKey);
1733
+ const body = { channel_id: conn.channel.id, thumbnail, content_type: "image/png" };
1734
+ await this.client.rest.post(route, { body, auth: true });
1735
+ this.client.emit?.("debug", `[VoiceManager] Uploaded stream preview for ${streamKey}`);
831
1736
  }
832
1737
  tryCompletePending(guildId) {
833
1738
  const pending = this.pending.get(guildId);
834
- if (!pending?.server || !pending.state) return;
1739
+ if (!pending?.server) return;
1740
+ const useLiveKit = isLiveKitEndpoint(pending.server.endpoint, pending.server.token);
1741
+ const hasState = !!pending.state;
1742
+ if (!useLiveKit && !hasState) return;
1743
+ if (useLiveKit && !hasState) {
1744
+ this.client.emit?.("debug", `[VoiceManager] Proceeding with VoiceServerUpdate only (LiveKit does not require VoiceStateUpdate)`);
1745
+ }
1746
+ const state = pending.state ?? {
1747
+ guild_id: guildId,
1748
+ channel_id: pending.channel.id,
1749
+ user_id: this.client.user.id,
1750
+ session_id: ""
1751
+ };
1752
+ this.storeConnectionId(guildId, pending.server.connection_id ?? state.connection_id);
835
1753
  this.pending.delete(guildId);
836
- const ConnClass = isLiveKitEndpoint(pending.server.endpoint, pending.server.token) ? LiveKitRtcConnection : VoiceConnection;
1754
+ const ConnClass = useLiveKit ? LiveKitRtcConnection : VoiceConnection;
837
1755
  const conn = new ConnClass(this.client, pending.channel, this.client.user.id);
838
1756
  this.registerConnection(guildId, conn);
839
- conn.connect(pending.server, pending.state).then(
1757
+ conn.connect(pending.server, state).then(
840
1758
  () => pending.resolve(conn),
841
1759
  (e) => pending.reject(e)
842
1760
  );
843
1761
  }
844
- /** Join a voice channel. Resolves when the connection is ready. */
1762
+ /**
1763
+ * Join a voice channel. Resolves when the connection is ready.
1764
+ * @param channel - The voice channel to join
1765
+ * @returns The voice connection (LiveKitRtcConnection when Fluxer uses LiveKit)
1766
+ */
845
1767
  async join(channel) {
846
1768
  const existing = this.connections.get(channel.guildId);
847
1769
  if (existing) {
@@ -851,12 +1773,17 @@ var VoiceManager = class extends import_events3.EventEmitter {
851
1773
  this.connections.delete(channel.guildId);
852
1774
  }
853
1775
  return new Promise((resolve, reject) => {
1776
+ this.client.emit?.("debug", `[VoiceManager] Requesting voice join guild=${channel.guildId} channel=${channel.id}`);
854
1777
  const timeout = setTimeout(() => {
855
1778
  if (this.pending.has(channel.guildId)) {
856
1779
  this.pending.delete(channel.guildId);
857
- reject(new Error("Voice connection timeout"));
1780
+ reject(
1781
+ new Error(
1782
+ "Voice connection timeout. Ensure the server has voice enabled and the bot has Connect permissions. The gateway must send VoiceServerUpdate and VoiceStateUpdate in response."
1783
+ )
1784
+ );
858
1785
  }
859
- }, 15e3);
1786
+ }, 2e4);
860
1787
  this.pending.set(channel.guildId, {
861
1788
  channel,
862
1789
  resolve: (c) => {
@@ -879,12 +1806,16 @@ var VoiceManager = class extends import_events3.EventEmitter {
879
1806
  });
880
1807
  });
881
1808
  }
882
- /** Leave a guild's voice channel. */
1809
+ /**
1810
+ * Leave a guild's voice channel and disconnect.
1811
+ * @param guildId - Guild ID to leave
1812
+ */
883
1813
  leave(guildId) {
884
1814
  const conn = this.connections.get(guildId);
885
1815
  if (conn) {
886
1816
  conn.destroy();
887
1817
  this.connections.delete(guildId);
1818
+ this.connectionIds.delete(guildId);
888
1819
  }
889
1820
  this.client.sendToGateway(this.shardId, {
890
1821
  op: import_types.GatewayOpcodes.VoiceStateUpdate,
@@ -896,9 +1827,42 @@ var VoiceManager = class extends import_events3.EventEmitter {
896
1827
  }
897
1828
  });
898
1829
  }
1830
+ /**
1831
+ * Get the active voice connection for a guild, if any.
1832
+ * @param guildId - Guild ID to look up
1833
+ */
899
1834
  getConnection(guildId) {
900
1835
  return this.connections.get(guildId);
901
1836
  }
1837
+ /**
1838
+ * Update voice state (e.g. self_stream, self_video) while in a channel.
1839
+ * Sends a VoiceStateUpdate to the gateway so the server and clients see the change.
1840
+ * Requires connection_id (from VoiceServerUpdate); without it, the gateway would treat
1841
+ * the update as a new join and trigger a new VoiceServerUpdate, causing connection loops.
1842
+ * @param guildId - Guild ID
1843
+ * @param partial - Partial voice state to update (self_stream, self_video, self_mute, self_deaf)
1844
+ */
1845
+ updateVoiceState(guildId, partial) {
1846
+ const conn = this.connections.get(guildId);
1847
+ if (!conn) return;
1848
+ const connectionId = this.connectionIds.get(guildId);
1849
+ if (!connectionId) {
1850
+ this.client.emit?.("debug", `[VoiceManager] Skipping voice state sync: no connection_id for guild ${guildId}`);
1851
+ return;
1852
+ }
1853
+ this.client.sendToGateway(this.shardId, {
1854
+ op: import_types.GatewayOpcodes.VoiceStateUpdate,
1855
+ d: {
1856
+ guild_id: guildId,
1857
+ channel_id: conn.channel.id,
1858
+ connection_id: connectionId,
1859
+ self_mute: partial.self_mute ?? false,
1860
+ self_deaf: partial.self_deaf ?? false,
1861
+ self_video: partial.self_video ?? false,
1862
+ self_stream: partial.self_stream ?? false
1863
+ }
1864
+ });
1865
+ }
902
1866
  };
903
1867
 
904
1868
  // src/index.ts