@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.d.mts +168 -7
- package/dist/index.d.ts +168 -7
- package/dist/index.js +991 -27
- package/dist/index.mjs +997 -29
- package/package.json +9 -4
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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", () =>
|
|
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
|
|
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 =
|
|
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,
|
|
1757
|
+
conn.connect(pending.server, state).then(
|
|
840
1758
|
() => pending.resolve(conn),
|
|
841
1759
|
(e) => pending.reject(e)
|
|
842
1760
|
);
|
|
843
1761
|
}
|
|
844
|
-
/**
|
|
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(
|
|
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
|
-
},
|
|
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
|
-
/**
|
|
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
|