@fluxerjs/voice 1.2.2 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,12 +11,30 @@ pnpm add @fluxerjs/voice @fluxerjs/core
11
11
  ## Usage
12
12
 
13
13
  ```javascript
14
- import { getVoiceManager } from '@fluxerjs/voice';
14
+ import { getVoiceManager, LiveKitRtcConnection } from '@fluxerjs/voice';
15
15
 
16
16
  const voiceManager = getVoiceManager(client);
17
17
  const connection = await voiceManager.join(channel);
18
18
  await connection.play(streamUrl);
19
19
 
20
+ // Inbound transcription / speech-to-text pipeline
21
+ if (connection instanceof LiveKitRtcConnection) {
22
+ const subs = voiceManager.subscribeChannelParticipants(channel.id);
23
+ connection.on('speakerStart', ({ participantId }) => {
24
+ console.log('speaker start', participantId);
25
+ });
26
+ connection.on('speakerStop', ({ participantId }) => {
27
+ console.log('speaker stop', participantId);
28
+ });
29
+ connection.on('audioFrame', (frame) => {
30
+ // frame.samples is Int16 PCM suitable for WAV/STT pipelines
31
+ console.log(frame.participantId, frame.sampleRate, frame.channels, frame.samples.length);
32
+ });
33
+
34
+ // cleanup subscriptions when done
35
+ for (const sub of subs) sub.stop();
36
+ }
37
+
20
38
  connection.stop();
21
39
  voiceManager.leave(guildId);
22
40
  ```
package/dist/index.d.mts CHANGED
@@ -67,6 +67,27 @@ type LiveKitRtcConnectionEvents = VoiceConnectionEvents & {
67
67
  self_stream?: boolean;
68
68
  self_video?: boolean;
69
69
  }];
70
+ /** Emitted when a remote participant starts speaking. */
71
+ speakerStart: [payload: {
72
+ participantId: string;
73
+ }];
74
+ /** Emitted when a remote participant stops speaking. */
75
+ speakerStop: [payload: {
76
+ participantId: string;
77
+ }];
78
+ /** Emitted for each decoded inbound audio frame. */
79
+ audioFrame: [frame: LiveKitAudioFrame];
80
+ };
81
+ type LiveKitAudioFrame = {
82
+ participantId: string;
83
+ trackSid?: string;
84
+ sampleRate: number;
85
+ channels: number;
86
+ samples: Int16Array;
87
+ };
88
+ type LiveKitReceiveSubscription = {
89
+ participantId: string;
90
+ stop: () => void;
70
91
  };
71
92
  /**
72
93
  * Options for video playback via {@link LiveKitRtcConnection.playVideo}.
@@ -133,6 +154,10 @@ declare class LiveKitRtcConnection extends EventEmitter {
133
154
  private lastServerEndpoint;
134
155
  private lastServerToken;
135
156
  private _disconnectEmitted;
157
+ private readonly receiveSubscriptions;
158
+ private readonly requestedSubscriptions;
159
+ private readonly participantTrackSids;
160
+ private readonly activeSpeakers;
136
161
  /**
137
162
  * @param client - The Fluxer client instance
138
163
  * @param channel - The voice channel to connect to
@@ -156,6 +181,13 @@ declare class LiveKitRtcConnection extends EventEmitter {
156
181
  setVolume(volumePercent: number): void;
157
182
  /** Get current volume (0-200). */
158
183
  getVolume(): number;
184
+ private isAudioTrack;
185
+ private getParticipantId;
186
+ private subscribeParticipantTrack;
187
+ subscribeParticipantAudio(participantId: string, options?: {
188
+ autoResubscribe?: boolean;
189
+ }): LiveKitReceiveSubscription;
190
+ private clearReceiveSubscriptions;
159
191
  playOpus(_stream: NodeJS.ReadableStream): void;
160
192
  /**
161
193
  * Connect to the LiveKit room using voice server and state from the gateway.
@@ -248,6 +280,17 @@ declare class VoiceManager extends EventEmitter {
248
280
  * @param userId - User ID to look up
249
281
  */
250
282
  getVoiceChannelId(guildId: string, userId: string): string | null;
283
+ /**
284
+ * List participant user IDs currently in a specific voice channel.
285
+ */
286
+ listParticipantsInChannel(guildId: string, channelId: string): string[];
287
+ /**
288
+ * Subscribe to inbound audio for all known participants currently in a voice channel.
289
+ * Only supported for LiveKit connections.
290
+ */
291
+ subscribeChannelParticipants(channelId: string, opts?: {
292
+ autoResubscribe?: boolean;
293
+ }): LiveKitReceiveSubscription[];
251
294
  private handleVoiceStateUpdate;
252
295
  private handleVoiceServerUpdate;
253
296
  private storeConnectionId;
@@ -317,4 +360,4 @@ declare function getVoiceManager(client: Client, options?: {
317
360
  shardId?: number;
318
361
  }): VoiceManager;
319
362
 
320
- export { LiveKitRtcConnection, type LiveKitRtcConnectionEvents, type VideoPlayOptions, VoiceConnection, type VoiceConnectionEvents, type VoiceConnectionLike, VoiceManager, type VoiceManagerOptions, type VoiceStateMap, getVoiceManager, joinVoiceChannel };
363
+ export { type LiveKitAudioFrame, type LiveKitReceiveSubscription, LiveKitRtcConnection, type LiveKitRtcConnectionEvents, type VideoPlayOptions, VoiceConnection, type VoiceConnectionEvents, type VoiceConnectionLike, VoiceManager, type VoiceManagerOptions, type VoiceStateMap, getVoiceManager, joinVoiceChannel };
package/dist/index.d.ts CHANGED
@@ -67,6 +67,27 @@ type LiveKitRtcConnectionEvents = VoiceConnectionEvents & {
67
67
  self_stream?: boolean;
68
68
  self_video?: boolean;
69
69
  }];
70
+ /** Emitted when a remote participant starts speaking. */
71
+ speakerStart: [payload: {
72
+ participantId: string;
73
+ }];
74
+ /** Emitted when a remote participant stops speaking. */
75
+ speakerStop: [payload: {
76
+ participantId: string;
77
+ }];
78
+ /** Emitted for each decoded inbound audio frame. */
79
+ audioFrame: [frame: LiveKitAudioFrame];
80
+ };
81
+ type LiveKitAudioFrame = {
82
+ participantId: string;
83
+ trackSid?: string;
84
+ sampleRate: number;
85
+ channels: number;
86
+ samples: Int16Array;
87
+ };
88
+ type LiveKitReceiveSubscription = {
89
+ participantId: string;
90
+ stop: () => void;
70
91
  };
71
92
  /**
72
93
  * Options for video playback via {@link LiveKitRtcConnection.playVideo}.
@@ -133,6 +154,10 @@ declare class LiveKitRtcConnection extends EventEmitter {
133
154
  private lastServerEndpoint;
134
155
  private lastServerToken;
135
156
  private _disconnectEmitted;
157
+ private readonly receiveSubscriptions;
158
+ private readonly requestedSubscriptions;
159
+ private readonly participantTrackSids;
160
+ private readonly activeSpeakers;
136
161
  /**
137
162
  * @param client - The Fluxer client instance
138
163
  * @param channel - The voice channel to connect to
@@ -156,6 +181,13 @@ declare class LiveKitRtcConnection extends EventEmitter {
156
181
  setVolume(volumePercent: number): void;
157
182
  /** Get current volume (0-200). */
158
183
  getVolume(): number;
184
+ private isAudioTrack;
185
+ private getParticipantId;
186
+ private subscribeParticipantTrack;
187
+ subscribeParticipantAudio(participantId: string, options?: {
188
+ autoResubscribe?: boolean;
189
+ }): LiveKitReceiveSubscription;
190
+ private clearReceiveSubscriptions;
159
191
  playOpus(_stream: NodeJS.ReadableStream): void;
160
192
  /**
161
193
  * Connect to the LiveKit room using voice server and state from the gateway.
@@ -248,6 +280,17 @@ declare class VoiceManager extends EventEmitter {
248
280
  * @param userId - User ID to look up
249
281
  */
250
282
  getVoiceChannelId(guildId: string, userId: string): string | null;
283
+ /**
284
+ * List participant user IDs currently in a specific voice channel.
285
+ */
286
+ listParticipantsInChannel(guildId: string, channelId: string): string[];
287
+ /**
288
+ * Subscribe to inbound audio for all known participants currently in a voice channel.
289
+ * Only supported for LiveKit connections.
290
+ */
291
+ subscribeChannelParticipants(channelId: string, opts?: {
292
+ autoResubscribe?: boolean;
293
+ }): LiveKitReceiveSubscription[];
251
294
  private handleVoiceStateUpdate;
252
295
  private handleVoiceServerUpdate;
253
296
  private storeConnectionId;
@@ -317,4 +360,4 @@ declare function getVoiceManager(client: Client, options?: {
317
360
  shardId?: number;
318
361
  }): VoiceManager;
319
362
 
320
- export { LiveKitRtcConnection, type LiveKitRtcConnectionEvents, type VideoPlayOptions, VoiceConnection, type VoiceConnectionEvents, type VoiceConnectionLike, VoiceManager, type VoiceManagerOptions, type VoiceStateMap, getVoiceManager, joinVoiceChannel };
363
+ export { type LiveKitAudioFrame, type LiveKitReceiveSubscription, LiveKitRtcConnection, type LiveKitRtcConnectionEvents, type VideoPlayOptions, VoiceConnection, type VoiceConnectionEvents, type VoiceConnectionLike, VoiceManager, type VoiceManagerOptions, type VoiceStateMap, getVoiceManager, joinVoiceChannel };
package/dist/index.js CHANGED
@@ -499,6 +499,7 @@ var import_node_util = require("util");
499
499
  var import_mp4box = require("mp4box");
500
500
  var SAMPLE_RATE = 48e3;
501
501
  var CHANNELS2 = 1;
502
+ var RECEIVE_READ_TIMEOUT_MS = 100;
502
503
  function getNaluByteLength(nalu) {
503
504
  if (ArrayBuffer.isView(nalu)) return nalu.byteLength;
504
505
  if (nalu instanceof ArrayBuffer) return nalu.byteLength;
@@ -605,6 +606,10 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
605
606
  lastServerEndpoint = null;
606
607
  lastServerToken = null;
607
608
  _disconnectEmitted = false;
609
+ receiveSubscriptions = /* @__PURE__ */ new Map();
610
+ requestedSubscriptions = /* @__PURE__ */ new Map();
611
+ participantTrackSids = /* @__PURE__ */ new Map();
612
+ activeSpeakers = /* @__PURE__ */ new Set();
608
613
  /**
609
614
  * @param client - The Fluxer client instance
610
615
  * @param channel - The voice channel to connect to
@@ -655,6 +660,110 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
655
660
  getVolume() {
656
661
  return this._volume ?? 100;
657
662
  }
663
+ isAudioTrack(track) {
664
+ return track.kind === import_rtc_node.TrackKind.KIND_AUDIO;
665
+ }
666
+ getParticipantId(participant) {
667
+ return participant.identity;
668
+ }
669
+ subscribeParticipantTrack(participant, track, options = {}) {
670
+ if (!this.isAudioTrack(track)) return;
671
+ const participantId = this.getParticipantId(participant);
672
+ if (!options.autoSubscribe && !this.requestedSubscriptions.has(participantId)) return;
673
+ const current = this.receiveSubscriptions.get(participantId);
674
+ if (current) current.stop();
675
+ const audioStream = new import_rtc_node.AudioStream(track, {
676
+ sampleRate: SAMPLE_RATE,
677
+ numChannels: CHANNELS2,
678
+ frameSizeMs: 10
679
+ });
680
+ let stopped = false;
681
+ let reader = null;
682
+ const pump = async () => {
683
+ try {
684
+ reader = audioStream.getReader();
685
+ while (!stopped) {
686
+ let readTimeout = null;
687
+ const next = await Promise.race([
688
+ reader.read(),
689
+ new Promise((resolve) => {
690
+ readTimeout = setTimeout(() => resolve(null), RECEIVE_READ_TIMEOUT_MS);
691
+ })
692
+ ]);
693
+ if (readTimeout) clearTimeout(readTimeout);
694
+ if (next === null) continue;
695
+ const { done, value } = next;
696
+ if (done || !value) break;
697
+ this.emit("audioFrame", {
698
+ participantId,
699
+ trackSid: track.sid,
700
+ sampleRate: value.sampleRate,
701
+ channels: value.channels,
702
+ samples: value.data
703
+ });
704
+ }
705
+ } catch (err) {
706
+ if (!stopped) {
707
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
708
+ }
709
+ } finally {
710
+ if (reader) {
711
+ try {
712
+ reader.releaseLock();
713
+ } catch {
714
+ }
715
+ reader = null;
716
+ }
717
+ }
718
+ };
719
+ const stop = () => {
720
+ if (stopped) return;
721
+ stopped = true;
722
+ if (reader) {
723
+ reader.cancel().catch(() => {
724
+ });
725
+ try {
726
+ reader.releaseLock();
727
+ } catch {
728
+ }
729
+ }
730
+ audioStream.cancel().catch(() => {
731
+ });
732
+ this.receiveSubscriptions.delete(participantId);
733
+ };
734
+ this.receiveSubscriptions.set(participantId, { participantId, stop });
735
+ this.participantTrackSids.set(participantId, track.sid ?? "");
736
+ void pump();
737
+ }
738
+ subscribeParticipantAudio(participantId, options = {}) {
739
+ const autoResubscribe = options.autoResubscribe === true;
740
+ const stop = () => {
741
+ this.receiveSubscriptions.get(participantId)?.stop();
742
+ this.receiveSubscriptions.delete(participantId);
743
+ this.participantTrackSids.delete(participantId);
744
+ this.requestedSubscriptions.delete(participantId);
745
+ };
746
+ this.requestedSubscriptions.set(participantId, autoResubscribe);
747
+ const room = this.room;
748
+ if (!room || !room.isConnected) return { participantId, stop };
749
+ const participant = room.remoteParticipants.get(participantId);
750
+ if (!participant) return { participantId, stop };
751
+ for (const pub of participant.trackPublications.values()) {
752
+ const maybeTrack = pub.track;
753
+ if (maybeTrack && this.isAudioTrack(maybeTrack)) {
754
+ this.subscribeParticipantTrack(participant, maybeTrack);
755
+ break;
756
+ }
757
+ }
758
+ return { participantId, stop };
759
+ }
760
+ clearReceiveSubscriptions() {
761
+ for (const sub of this.receiveSubscriptions.values()) sub.stop();
762
+ this.receiveSubscriptions.clear();
763
+ this.requestedSubscriptions.clear();
764
+ this.participantTrackSids.clear();
765
+ this.activeSpeakers.clear();
766
+ }
658
767
  playOpus(_stream) {
659
768
  this.emit(
660
769
  "error",
@@ -693,7 +802,48 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
693
802
  room.on(import_rtc_node.RoomEvent.Reconnected, () => {
694
803
  this.debug("Room reconnected");
695
804
  });
696
- await room.connect(url, token, { autoSubscribe: false, dynacast: false });
805
+ room.on(import_rtc_node.RoomEvent.TrackSubscribed, (track, _publication, participant) => {
806
+ if (!this.isAudioTrack(track)) return;
807
+ this.subscribeParticipantTrack(participant, track);
808
+ });
809
+ room.on(import_rtc_node.RoomEvent.TrackUnsubscribed, (track, _publication, participant) => {
810
+ if (!this.isAudioTrack(track)) return;
811
+ const participantId = this.getParticipantId(participant);
812
+ this.receiveSubscriptions.get(participantId)?.stop();
813
+ this.receiveSubscriptions.delete(participantId);
814
+ if (this.requestedSubscriptions.get(participantId) !== true) {
815
+ this.requestedSubscriptions.delete(participantId);
816
+ }
817
+ this.participantTrackSids.delete(participantId);
818
+ });
819
+ room.on(import_rtc_node.RoomEvent.ParticipantDisconnected, (participant) => {
820
+ const participantId = this.getParticipantId(participant);
821
+ this.receiveSubscriptions.get(participantId)?.stop();
822
+ this.receiveSubscriptions.delete(participantId);
823
+ if (this.requestedSubscriptions.get(participantId) !== true) {
824
+ this.requestedSubscriptions.delete(participantId);
825
+ }
826
+ this.participantTrackSids.delete(participantId);
827
+ if (this.activeSpeakers.delete(participantId)) {
828
+ this.emit("speakerStop", { participantId });
829
+ }
830
+ });
831
+ room.on(import_rtc_node.RoomEvent.ActiveSpeakersChanged, (speakers) => {
832
+ const next = new Set(speakers.map((speaker) => speaker.identity));
833
+ for (const participantId of next) {
834
+ if (!this.activeSpeakers.has(participantId)) {
835
+ this.emit("speakerStart", { participantId });
836
+ }
837
+ }
838
+ for (const participantId of this.activeSpeakers) {
839
+ if (!next.has(participantId)) {
840
+ this.emit("speakerStop", { participantId });
841
+ }
842
+ }
843
+ this.activeSpeakers.clear();
844
+ for (const participantId of next) this.activeSpeakers.add(participantId);
845
+ });
846
+ await room.connect(url, token, { autoSubscribe: true, dynacast: false });
697
847
  this.lastServerEndpoint = raw;
698
848
  this.lastServerToken = token;
699
849
  this.debug("connected to room");
@@ -1704,6 +1854,7 @@ var LiveKitRtcConnection = class extends import_events2.EventEmitter {
1704
1854
  stop() {
1705
1855
  this._playing = false;
1706
1856
  this.stopVideo();
1857
+ this.clearReceiveSubscriptions();
1707
1858
  if (this.currentStream?.destroy) this.currentStream.destroy();
1708
1859
  this.currentStream = null;
1709
1860
  if (this.audioTrack) {
@@ -1787,6 +1938,31 @@ var VoiceManager = class extends import_events3.EventEmitter {
1787
1938
  if (!guildMap) return null;
1788
1939
  return guildMap.get(userId) ?? null;
1789
1940
  }
1941
+ /**
1942
+ * List participant user IDs currently in a specific voice channel.
1943
+ */
1944
+ listParticipantsInChannel(guildId, channelId) {
1945
+ const guildMap = this.voiceStates.get(guildId);
1946
+ if (!guildMap) return [];
1947
+ const participants = [];
1948
+ for (const [userId, voiceChannelId] of guildMap.entries()) {
1949
+ if (voiceChannelId === channelId) participants.push(userId);
1950
+ }
1951
+ return participants;
1952
+ }
1953
+ /**
1954
+ * Subscribe to inbound audio for all known participants currently in a voice channel.
1955
+ * Only supported for LiveKit connections.
1956
+ */
1957
+ subscribeChannelParticipants(channelId, opts) {
1958
+ const conn = this.connections.get(channelId);
1959
+ if (!(conn instanceof LiveKitRtcConnection)) return [];
1960
+ const guildId = conn.channel.guildId;
1961
+ const participants = this.listParticipantsInChannel(guildId, channelId).filter(
1962
+ (participantId) => participantId !== this.client.user?.id
1963
+ );
1964
+ return participants.map((participantId) => conn.subscribeParticipantAudio(participantId, opts));
1965
+ }
1790
1966
  handleVoiceStateUpdate(data) {
1791
1967
  const guildId = data.guild_id ?? "";
1792
1968
  if (!guildId) return;
package/dist/index.mjs CHANGED
@@ -365,6 +365,7 @@ var VoiceConnection = class extends EventEmitter {
365
365
  import { execFile, spawn } from "child_process";
366
366
  import { EventEmitter as EventEmitter2 } from "events";
367
367
  import {
368
+ AudioStream,
368
369
  Room,
369
370
  RoomEvent,
370
371
  AudioSource,
@@ -375,7 +376,8 @@ import {
375
376
  TrackSource,
376
377
  VideoBufferType,
377
378
  VideoFrame,
378
- VideoSource
379
+ VideoSource,
380
+ TrackKind
379
381
  } from "@livekit/rtc-node";
380
382
 
381
383
  // src/livekit.ts
@@ -471,6 +473,7 @@ import { promisify } from "util";
471
473
  import { createFile } from "mp4box";
472
474
  var SAMPLE_RATE = 48e3;
473
475
  var CHANNELS2 = 1;
476
+ var RECEIVE_READ_TIMEOUT_MS = 100;
474
477
  function getNaluByteLength(nalu) {
475
478
  if (ArrayBuffer.isView(nalu)) return nalu.byteLength;
476
479
  if (nalu instanceof ArrayBuffer) return nalu.byteLength;
@@ -577,6 +580,10 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
577
580
  lastServerEndpoint = null;
578
581
  lastServerToken = null;
579
582
  _disconnectEmitted = false;
583
+ receiveSubscriptions = /* @__PURE__ */ new Map();
584
+ requestedSubscriptions = /* @__PURE__ */ new Map();
585
+ participantTrackSids = /* @__PURE__ */ new Map();
586
+ activeSpeakers = /* @__PURE__ */ new Set();
580
587
  /**
581
588
  * @param client - The Fluxer client instance
582
589
  * @param channel - The voice channel to connect to
@@ -627,6 +634,110 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
627
634
  getVolume() {
628
635
  return this._volume ?? 100;
629
636
  }
637
+ isAudioTrack(track) {
638
+ return track.kind === TrackKind.KIND_AUDIO;
639
+ }
640
+ getParticipantId(participant) {
641
+ return participant.identity;
642
+ }
643
+ subscribeParticipantTrack(participant, track, options = {}) {
644
+ if (!this.isAudioTrack(track)) return;
645
+ const participantId = this.getParticipantId(participant);
646
+ if (!options.autoSubscribe && !this.requestedSubscriptions.has(participantId)) return;
647
+ const current = this.receiveSubscriptions.get(participantId);
648
+ if (current) current.stop();
649
+ const audioStream = new AudioStream(track, {
650
+ sampleRate: SAMPLE_RATE,
651
+ numChannels: CHANNELS2,
652
+ frameSizeMs: 10
653
+ });
654
+ let stopped = false;
655
+ let reader = null;
656
+ const pump = async () => {
657
+ try {
658
+ reader = audioStream.getReader();
659
+ while (!stopped) {
660
+ let readTimeout = null;
661
+ const next = await Promise.race([
662
+ reader.read(),
663
+ new Promise((resolve) => {
664
+ readTimeout = setTimeout(() => resolve(null), RECEIVE_READ_TIMEOUT_MS);
665
+ })
666
+ ]);
667
+ if (readTimeout) clearTimeout(readTimeout);
668
+ if (next === null) continue;
669
+ const { done, value } = next;
670
+ if (done || !value) break;
671
+ this.emit("audioFrame", {
672
+ participantId,
673
+ trackSid: track.sid,
674
+ sampleRate: value.sampleRate,
675
+ channels: value.channels,
676
+ samples: value.data
677
+ });
678
+ }
679
+ } catch (err) {
680
+ if (!stopped) {
681
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
682
+ }
683
+ } finally {
684
+ if (reader) {
685
+ try {
686
+ reader.releaseLock();
687
+ } catch {
688
+ }
689
+ reader = null;
690
+ }
691
+ }
692
+ };
693
+ const stop = () => {
694
+ if (stopped) return;
695
+ stopped = true;
696
+ if (reader) {
697
+ reader.cancel().catch(() => {
698
+ });
699
+ try {
700
+ reader.releaseLock();
701
+ } catch {
702
+ }
703
+ }
704
+ audioStream.cancel().catch(() => {
705
+ });
706
+ this.receiveSubscriptions.delete(participantId);
707
+ };
708
+ this.receiveSubscriptions.set(participantId, { participantId, stop });
709
+ this.participantTrackSids.set(participantId, track.sid ?? "");
710
+ void pump();
711
+ }
712
+ subscribeParticipantAudio(participantId, options = {}) {
713
+ const autoResubscribe = options.autoResubscribe === true;
714
+ const stop = () => {
715
+ this.receiveSubscriptions.get(participantId)?.stop();
716
+ this.receiveSubscriptions.delete(participantId);
717
+ this.participantTrackSids.delete(participantId);
718
+ this.requestedSubscriptions.delete(participantId);
719
+ };
720
+ this.requestedSubscriptions.set(participantId, autoResubscribe);
721
+ const room = this.room;
722
+ if (!room || !room.isConnected) return { participantId, stop };
723
+ const participant = room.remoteParticipants.get(participantId);
724
+ if (!participant) return { participantId, stop };
725
+ for (const pub of participant.trackPublications.values()) {
726
+ const maybeTrack = pub.track;
727
+ if (maybeTrack && this.isAudioTrack(maybeTrack)) {
728
+ this.subscribeParticipantTrack(participant, maybeTrack);
729
+ break;
730
+ }
731
+ }
732
+ return { participantId, stop };
733
+ }
734
+ clearReceiveSubscriptions() {
735
+ for (const sub of this.receiveSubscriptions.values()) sub.stop();
736
+ this.receiveSubscriptions.clear();
737
+ this.requestedSubscriptions.clear();
738
+ this.participantTrackSids.clear();
739
+ this.activeSpeakers.clear();
740
+ }
630
741
  playOpus(_stream) {
631
742
  this.emit(
632
743
  "error",
@@ -665,7 +776,48 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
665
776
  room.on(RoomEvent.Reconnected, () => {
666
777
  this.debug("Room reconnected");
667
778
  });
668
- await room.connect(url, token, { autoSubscribe: false, dynacast: false });
779
+ room.on(RoomEvent.TrackSubscribed, (track, _publication, participant) => {
780
+ if (!this.isAudioTrack(track)) return;
781
+ this.subscribeParticipantTrack(participant, track);
782
+ });
783
+ room.on(RoomEvent.TrackUnsubscribed, (track, _publication, participant) => {
784
+ if (!this.isAudioTrack(track)) return;
785
+ const participantId = this.getParticipantId(participant);
786
+ this.receiveSubscriptions.get(participantId)?.stop();
787
+ this.receiveSubscriptions.delete(participantId);
788
+ if (this.requestedSubscriptions.get(participantId) !== true) {
789
+ this.requestedSubscriptions.delete(participantId);
790
+ }
791
+ this.participantTrackSids.delete(participantId);
792
+ });
793
+ room.on(RoomEvent.ParticipantDisconnected, (participant) => {
794
+ const participantId = this.getParticipantId(participant);
795
+ this.receiveSubscriptions.get(participantId)?.stop();
796
+ this.receiveSubscriptions.delete(participantId);
797
+ if (this.requestedSubscriptions.get(participantId) !== true) {
798
+ this.requestedSubscriptions.delete(participantId);
799
+ }
800
+ this.participantTrackSids.delete(participantId);
801
+ if (this.activeSpeakers.delete(participantId)) {
802
+ this.emit("speakerStop", { participantId });
803
+ }
804
+ });
805
+ room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
806
+ const next = new Set(speakers.map((speaker) => speaker.identity));
807
+ for (const participantId of next) {
808
+ if (!this.activeSpeakers.has(participantId)) {
809
+ this.emit("speakerStart", { participantId });
810
+ }
811
+ }
812
+ for (const participantId of this.activeSpeakers) {
813
+ if (!next.has(participantId)) {
814
+ this.emit("speakerStop", { participantId });
815
+ }
816
+ }
817
+ this.activeSpeakers.clear();
818
+ for (const participantId of next) this.activeSpeakers.add(participantId);
819
+ });
820
+ await room.connect(url, token, { autoSubscribe: true, dynacast: false });
669
821
  this.lastServerEndpoint = raw;
670
822
  this.lastServerToken = token;
671
823
  this.debug("connected to room");
@@ -1676,6 +1828,7 @@ var LiveKitRtcConnection = class extends EventEmitter2 {
1676
1828
  stop() {
1677
1829
  this._playing = false;
1678
1830
  this.stopVideo();
1831
+ this.clearReceiveSubscriptions();
1679
1832
  if (this.currentStream?.destroy) this.currentStream.destroy();
1680
1833
  this.currentStream = null;
1681
1834
  if (this.audioTrack) {
@@ -1759,6 +1912,31 @@ var VoiceManager = class extends EventEmitter3 {
1759
1912
  if (!guildMap) return null;
1760
1913
  return guildMap.get(userId) ?? null;
1761
1914
  }
1915
+ /**
1916
+ * List participant user IDs currently in a specific voice channel.
1917
+ */
1918
+ listParticipantsInChannel(guildId, channelId) {
1919
+ const guildMap = this.voiceStates.get(guildId);
1920
+ if (!guildMap) return [];
1921
+ const participants = [];
1922
+ for (const [userId, voiceChannelId] of guildMap.entries()) {
1923
+ if (voiceChannelId === channelId) participants.push(userId);
1924
+ }
1925
+ return participants;
1926
+ }
1927
+ /**
1928
+ * Subscribe to inbound audio for all known participants currently in a voice channel.
1929
+ * Only supported for LiveKit connections.
1930
+ */
1931
+ subscribeChannelParticipants(channelId, opts) {
1932
+ const conn = this.connections.get(channelId);
1933
+ if (!(conn instanceof LiveKitRtcConnection)) return [];
1934
+ const guildId = conn.channel.guildId;
1935
+ const participants = this.listParticipantsInChannel(guildId, channelId).filter(
1936
+ (participantId) => participantId !== this.client.user?.id
1937
+ );
1938
+ return participants.map((participantId) => conn.subscribeParticipantAudio(participantId, opts));
1939
+ }
1762
1940
  handleVoiceStateUpdate(data) {
1763
1941
  const guildId = data.guild_id ?? "";
1764
1942
  if (!guildId) return;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.2",
6
+ "version": "1.2.3",
7
7
  "description": "Voice support for Fluxer bots",
8
8
  "repository": {
9
9
  "type": "git",
@@ -39,9 +39,9 @@
39
39
  "prism-media": "^1.3.5",
40
40
  "tweetnacl": "^1.0.3",
41
41
  "ws": "^8.18.0",
42
- "@fluxerjs/collection": "1.2.1",
43
- "@fluxerjs/types": "1.2.1",
44
- "@fluxerjs/core": "1.2.1"
42
+ "@fluxerjs/collection": "1.2.3",
43
+ "@fluxerjs/core": "1.2.3",
44
+ "@fluxerjs/types": "1.2.3"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^20.0.0",