@remedia/node 0.1.0-dev.0 → 0.1.0-dev.1

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
@@ -88,3 +88,58 @@ service terms.
88
88
  Remedia is provided under the [MIT License](./LICENSE) without warranty. Dependency terms remain
89
89
  separate; see [third-party notices](./THIRD_PARTY_NOTICES.md), including bundled libopus and
90
90
  independently installed FFmpeg.
91
+
92
+ ## Session configuration and channel movement
93
+
94
+ A client owns one session per guild. Separate clients (including separate bot users) can use the
95
+ same guild and channel with independent playback, transport generations, and events. `userId` is
96
+ the Discord bot user ID, not a Remedia client session ID. Channel IDs are mutable destinations.
97
+ Duplicate guild registration on the same client throws; dispose the old session before replacing it.
98
+
99
+ ```ts
100
+ const session = remedia.createDiscordSession({
101
+ guildId,
102
+ channelId,
103
+ userId,
104
+ selfDeaf: true,
105
+ selfMute: false,
106
+ volume: 0.75,
107
+ filterGraph: 'bass=gain=3',
108
+ });
109
+ await session.connect();
110
+ await session.move(otherChannelId);
111
+ await session.setSelfDeaf(false);
112
+ await session.setSelfMute(true);
113
+ await session.dispose();
114
+ ```
115
+
116
+ `selfDeaf` defaults to `true` (previously joins always sent `false`); `selfMute` defaults to
117
+ `false`. These flags affect Discord voice state, independently of playback pause and gain.
118
+ Setters retain values before connection and update Gateway OP 4 after joining. Unchanged values
119
+ send nothing. Joins, credential refreshes, and moves preserve the flags. Keep the library-agnostic
120
+ `send(guildId, payload)` and `receive(payload)` Gateway integration wired throughout the session.
121
+
122
+ Initial `volume` is finite linear gain from 0 through 2, default 1. `filterGraph` is a trusted raw
123
+ FFmpeg audio filtergraph or `null` (default). Invalid gain and empty/non-string graphs are rejected
124
+ at creation; native FFmpeg validates graph syntax when the backend is allocated/configured, before
125
+ playback is allowed. Configuration is serialized with runtime setters and applied before audio
126
+ starts. Both values persist across tracks and transport replacement; existing `setVolume()` and
127
+ `filters.set()`/`clear()` remain available with their existing change events.
128
+
129
+ `move(channelId): Promise<void>` requests fresh Gateway credentials and replaces the existing voice
130
+ transport without restarting or seeking playback. Decoder input (including one-shot Readables),
131
+ filters, gain, playback generation, and paused state are retained. A move to the current destination
132
+ shares a pending connection or resolves immediately when connected. A newer destination supersedes
133
+ a pending attempt with an `AbortError`; `connect()` shares the latest attempt. Moving while explicit
134
+ disconnection or disposal is underway rejects. `disconnect()`/`dispose()` cancel a pending move.
135
+ `channelId` reports the latest requested destination, including after a failed move.
136
+
137
+ Intentional moves do not use the automatic recovery retry policy. A failed move reports a failed
138
+ voice state and leaves playback parked with bounded buffering; call `connect()` to retry the
139
+ requested destination, `move()` to choose another, or `disconnect()`/`dispose()` to release it.
140
+ Ordinary transport recovery retains its existing bounded credential-refresh retries. Transport
141
+ replacement itself does not emit playback stopped/completed/failed events.
142
+
143
+ Gateway mocks verify orchestration and local native tests verify media lifecycles. Real Discord
144
+ channel movement, same-channel multi-bot voice, credential ordering, and self flags still require
145
+ an application integration test with Discord. Forward only each bot's own Gateway dispatch stream.
package/dist/discord.d.ts CHANGED
@@ -3,6 +3,9 @@ import { PlaybackHooks, type PlayOptions, type SessionLifecycleEvents } from './
3
3
  import { FilterControls } from './filters.js';
4
4
  import type { MediaSource } from './input.js';
5
5
  import type { AudioStreamInfo, PlaybackPosition, PlaybackState, VoiceConnectionState } from './types.js';
6
+ /**
7
+ * Identity, initial playback configuration, and Gateway voice flags for one guild session. The channel is a mutable destination, while userId identifies the bot.
8
+ */
6
9
  export interface DiscordSessionOptions {
7
10
  /** Discord server snowflake as a decimal string. */
8
11
  guildId: string;
@@ -10,9 +13,18 @@ export interface DiscordSessionOptions {
10
13
  channelId: string;
11
14
  /** Bot user snowflake as a decimal string. */
12
15
  userId: string;
16
+ /** Discord Gateway self-deaf state; defaults to true. */
17
+ selfDeaf?: boolean;
18
+ /** Discord Gateway self-mute state; defaults to false. */
19
+ selfMute?: boolean;
20
+ /** Session master linear gain, finite from 0 through 2; defaults to 1. */
21
+ volume?: number;
22
+ /** Trusted raw FFmpeg audio filtergraph; defaults to null. Native syntax validation occurs during backend configuration before playback. */
23
+ filterGraph?: string | null;
13
24
  }
25
+ /** Cancellation and timeout controls for obtaining credentials and establishing a voice transport. */
14
26
  export interface DiscordConnectOptions {
15
- /** Abort the Gateway request and any pending connection attempt. */
27
+ /** Optional signal that cancels Gateway negotiation and pending transport setup. */
16
28
  signal?: AbortSignal;
17
29
  /** Maximum time to receive Gateway credentials and establish voice transport/DAVE. */
18
30
  timeoutMs?: number;
@@ -21,49 +33,117 @@ export interface DiscordConnectOptions {
21
33
  * One Discord voice session. The caller supplies a Gateway send adapter to `Remedia.create()` and
22
34
  * forwards raw dispatch packets to `Remedia.receive()`; this session collects the matching voice
23
35
  * credentials and opens the native transport after both dispatches arrive.
36
+ * @typeParam Metadata - Application-owned metadata attached to playback generations.
24
37
  */
25
38
  export declare class DiscordSession<Metadata = unknown> extends RemediaEventEmitter<SessionLifecycleEvents<Metadata>> {
26
39
  #private;
40
+ /** Opaque Remedia session identity used to correlate events; distinct from the Discord bot user ID. */
27
41
  readonly sessionId: `${string}-${string}-${string}-${string}-${string}`;
42
+ /** Discord guild snowflake as a decimal string; unique within the owning client. */
28
43
  readonly guildId: string;
29
- readonly channelId: string;
44
+ /** Discord bot user snowflake as a decimal string; separate from Remedia session identity. */
30
45
  readonly userId: string;
46
+ /** Persistent raw FFmpeg filtergraph controls for this session. */
31
47
  readonly filters: FilterControls;
48
+ /** Ordered, awaited local pre-play hooks; listeners are separate from hooks. */
32
49
  readonly hooks: PlaybackHooks<Metadata>;
50
+ /** Current requested voice destination; independent of playback identity. */
51
+ get channelId(): string;
52
+ /** Discord Gateway self-deaf state; defaults to true and persists across moves and recovery. */
53
+ get selfDeaf(): boolean;
54
+ /** Discord Gateway self-mute state; defaults to false and persists across moves and recovery. */
55
+ get selfMute(): boolean;
56
+ /**
57
+ * Replace only the voice transport. A newer move supersedes a pending move.
58
+ * @param channelId - Destination Discord voice channel snowflake as a positive decimal string.
59
+ * @returns Resolves when the requested destination connects; rejects on supersession, cancellation, or connection failure.
60
+ */
61
+ move(channelId: string): Promise<void>;
62
+ /**
63
+ * Update Discord self-deaf state without rebuilding playback. Unchanged values send nothing.
64
+ * @param enabled - The requested Discord self-state flag.
65
+ * @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
66
+ */
67
+ setSelfDeaf(enabled: boolean): Promise<void>;
68
+ /**
69
+ * Update Discord self-mute state without changing audio gain or playback pause. Unchanged values send nothing.
70
+ * @param enabled - The requested Discord self-state flag.
71
+ * @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
72
+ */
73
+ setSelfMute(enabled: boolean): Promise<void>;
33
74
  /** Current voice transport state. A connected voice WebSocket may temporarily be reconnecting. */
34
75
  get connectionState(): VoiceConnectionState;
35
76
  /** Most recent Gateway or native transport failure, if any. */
36
77
  get connectionError(): string | null;
78
+ /** Current playback lifecycle state. */
37
79
  get state(): PlaybackState;
80
+ /** Latest source and output timeline measurements in milliseconds. */
38
81
  get position(): PlaybackPosition;
82
+ /** Source duration in milliseconds, or null when unknown. */
39
83
  get durationMs(): number | null;
84
+ /** Most recent playback or voice failure message, or null when no error is available. */
40
85
  get error(): string | null;
41
86
  /** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
42
87
  get volume(): number;
43
88
  /** Current native source generation, useful to adapters that preserve event correlation. */
44
89
  get generation(): number;
45
- /** Set master gain independently of this session's configured filtergraph. */
90
+ /**
91
+ * Set master gain independently of this session's configured filtergraph.
92
+ * @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
93
+ * @returns Resolves after the gain is stored or applied; rejects for non-finite gain or values outside 0 through 2.
94
+ * @throws {RangeError} If gain is non-finite or outside the inclusive range 0 through 2.
95
+ */
46
96
  setVolume(gain: number): Promise<void>;
47
97
  /**
48
98
  * Request a Gateway voice join and wait until both voice dispatches have arrived and the native
49
99
  * voice transport has completed its DAVE setup. Repeated calls while connecting share a promise.
100
+ * @param options - Optional cancellation signal and positive timeout in milliseconds; defaults to 45000.
101
+ * @returns Resolves when connected; rejects on timeout, cancellation, session loss, or transport failure.
50
102
  */
51
103
  connect(options?: DiscordConnectOptions): Promise<void>;
52
104
  /**
53
105
  * Begin playback on this connected voice session. The decoder and Opus encoder remain in core;
54
106
  * this object only associates their output with the native Discord transport.
107
+ * @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
108
+ * @param options - Optional metadata for this generation and a cancellation signal.
109
+ * @returns Selected stream metadata once startup succeeds; rejects if hooks, input opening, or playback startup fail.
55
110
  */
56
111
  play(source: MediaSource, options?: PlayOptions<Metadata>): Promise<AudioStreamInfo>;
112
+ /**
113
+ * Pause the current playback without replacing its decoder or source generation.
114
+ * @returns Resolves when the backend accepts the pause request.
115
+ */
57
116
  pause(): Promise<void>;
117
+ /**
118
+ * Resume paused playback on its existing source generation.
119
+ * @returns Resolves when the backend accepts the resume request.
120
+ */
58
121
  resume(): Promise<void>;
122
+ /**
123
+ * Seek on the original media timeline; non-seekable and one-shot sources may reject.
124
+ * @param positionMs - Non-negative finite position on the original media timeline, in milliseconds.
125
+ * @returns Resolves after the backend accepts the seek; rejects for invalid positions or unsupported inputs.
126
+ */
59
127
  seek(positionMs: number): Promise<void>;
128
+ /**
129
+ * Stop the active playback generation and release its input without leaving the voice channel.
130
+ * @returns Resolves after the backend stops the active source.
131
+ */
60
132
  stop(): Promise<void>;
133
+ /**
134
+ * Wait for the current playback generation to reach a terminal state.
135
+ * @returns The terminal playback state for the generation captured by this wait.
136
+ */
61
137
  wait(): Promise<PlaybackState>;
62
138
  /**
63
139
  * Leave the Discord channel through Gateway OP 4 and close the native transport. This does not
64
140
  * dispose the wrapper, so `connect()` can be called again with fresh Gateway dispatches.
141
+ * @returns Resolves after cleanup; concurrent disconnects share cleanup work.
65
142
  */
66
143
  disconnect(): Promise<void>;
67
- /** Disconnect and unregister this session. */
144
+ /**
145
+ * Disconnect and unregister this session.
146
+ * @returns Resolves after disposal; rejects if resource cleanup fails.
147
+ */
68
148
  dispose(): Promise<void>;
69
149
  }
package/dist/discord.js CHANGED
@@ -9,16 +9,26 @@ const DEFAULT_CONNECT_TIMEOUT_MS = 45_000;
9
9
  * One Discord voice session. The caller supplies a Gateway send adapter to `Remedia.create()` and
10
10
  * forwards raw dispatch packets to `Remedia.receive()`; this session collects the matching voice
11
11
  * credentials and opens the native transport after both dispatches arrive.
12
+ * @typeParam Metadata - Application-owned metadata attached to playback generations.
12
13
  */
13
14
  export class DiscordSession extends RemediaEventEmitter {
15
+ /** Opaque Remedia session identity used to correlate events; distinct from the Discord bot user ID. */
14
16
  sessionId = randomUUID();
17
+ /** Discord guild snowflake as a decimal string; unique within the owning client. */
15
18
  guildId;
16
- channelId;
19
+ #channelId;
20
+ #selfDeaf;
21
+ #selfMute;
22
+ #closing = false;
23
+ #disposeWork;
24
+ /** Discord bot user snowflake as a decimal string; separate from Remedia session identity. */
17
25
  userId;
18
26
  #native;
19
27
  #gateway;
20
28
  #onDispose;
29
+ /** Persistent raw FFmpeg filtergraph controls for this session. */
21
30
  filters;
31
+ /** Ordered, awaited local pre-play hooks; listeners are separate from hooks. */
22
32
  hooks = new PlaybackHooks();
23
33
  #playback;
24
34
  #playbackSubscriptions;
@@ -43,17 +53,113 @@ export class DiscordSession extends RemediaEventEmitter {
43
53
  #volume = 1;
44
54
  #configurationWork = Promise.resolve();
45
55
  #nativeStateTimer;
46
- /** @internal Constructed by `Remedia.createDiscordSession()`. */
56
+ /**
57
+ * @internal Constructed by `Remedia.createDiscordSession()`.
58
+ * @param native - Owning native engine instance; its resources must outlive this wrapper.
59
+ * @param gateway - Adapter for the application-owned Discord Gateway connection.
60
+ * @param options - Configuration and lifecycle options for this operation.
61
+ * @param onDispose - Callback invoked when this wrapper releases its owning registration.
62
+ */
47
63
  constructor(native, gateway, options, onDispose) {
48
64
  super();
49
65
  this.guildId = validateSnowflake('guildId', options.guildId);
50
- this.channelId = validateSnowflake('channelId', options.channelId);
66
+ this.#channelId = validateSnowflake('channelId', options.channelId);
51
67
  this.userId = validateSnowflake('userId', options.userId);
68
+ validateVolume(options.volume ?? 1);
69
+ if (options.filterGraph !== undefined &&
70
+ options.filterGraph !== null &&
71
+ (typeof options.filterGraph !== 'string' || !options.filterGraph.trim()))
72
+ throw new TypeError('filterGraph must be a non-empty string or null');
73
+ for (const value of [options.selfDeaf, options.selfMute]) {
74
+ if (value !== undefined && typeof value !== 'boolean')
75
+ throw new TypeError('voice flags must be boolean');
76
+ }
77
+ this.#volume = options.volume ?? 1;
78
+ this.#filterGraph = options.filterGraph ?? null;
79
+ this.#selfDeaf = options.selfDeaf ?? true;
80
+ this.#selfMute = options.selfMute ?? false;
52
81
  this.#native = native;
53
82
  this.#gateway = gateway;
54
83
  this.#onDispose = onDispose;
55
84
  this.filters = new FilterControls(() => this.#filterGraph, (graph) => this.#replaceFilterGraph(graph), () => this.#replaceFilterGraph(null));
56
85
  }
86
+ /** Current requested voice destination; independent of playback identity. */
87
+ get channelId() {
88
+ return this.#channelId;
89
+ }
90
+ /** Discord Gateway self-deaf state; defaults to true and persists across moves and recovery. */
91
+ get selfDeaf() {
92
+ return this.#selfDeaf;
93
+ }
94
+ /** Discord Gateway self-mute state; defaults to false and persists across moves and recovery. */
95
+ get selfMute() {
96
+ return this.#selfMute;
97
+ }
98
+ /**
99
+ * Replace only the voice transport. A newer move supersedes a pending move.
100
+ * @param channelId - Destination Discord voice channel snowflake as a positive decimal string.
101
+ * @returns Resolves when the requested destination connects; rejects on supersession, cancellation, or connection failure.
102
+ */
103
+ move(channelId) {
104
+ this.#assertNotDisposed();
105
+ validateSnowflake('channelId', channelId);
106
+ if (this.#connectionState === 'disconnecting')
107
+ return Promise.reject(new Error('Discord session is disconnecting'));
108
+ if (channelId === this.channelId)
109
+ return this.connect();
110
+ const previous = this.#attempt;
111
+ if (previous) {
112
+ this.#finishAttempt(previous);
113
+ previous.reject(abortError('Discord voice connection was superseded by move()'));
114
+ }
115
+ this.#channelId = channelId;
116
+ if (!this.#nativeSession)
117
+ return this.connect();
118
+ const attempt = this.#createAttempt(DEFAULT_CONNECT_TIMEOUT_MS, undefined, true);
119
+ attempt.moving = true;
120
+ this.#attempt = attempt;
121
+ this.#voiceState = undefined;
122
+ this.#voiceServer = undefined;
123
+ this.#voiceServerUnavailable = false;
124
+ this.#setConnectionState('connecting');
125
+ this.#suspendCurrentNativeTransport(attempt);
126
+ this.#requestGatewayJoin(attempt);
127
+ return attempt.promise;
128
+ }
129
+ /**
130
+ * Update Discord self-deaf state without rebuilding playback. Unchanged values send nothing.
131
+ * @param enabled - The requested Discord self-state flag.
132
+ * @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
133
+ */
134
+ setSelfDeaf(enabled) {
135
+ return this.#setVoiceFlag('deaf', enabled);
136
+ }
137
+ /**
138
+ * Update Discord self-mute state without changing audio gain or playback pause. Unchanged values send nothing.
139
+ * @param enabled - The requested Discord self-state flag.
140
+ * @returns Resolves after storing the flag or sending its Gateway update; rejects if the update fails.
141
+ */
142
+ setSelfMute(enabled) {
143
+ return this.#setVoiceFlag('mute', enabled);
144
+ }
145
+ async #setVoiceFlag(flag, enabled) {
146
+ this.#assertNotDisposed();
147
+ if (typeof enabled !== 'boolean')
148
+ throw new TypeError('voice flag must be boolean');
149
+ await this.#serializeConfiguration(async () => {
150
+ this.#assertNotDisposed();
151
+ if (this.#connectionState === 'disconnecting')
152
+ throw new Error('Discord session is disconnecting');
153
+ if ((flag === 'deaf' ? this.#selfDeaf : this.#selfMute) === enabled)
154
+ return;
155
+ if (this.#joinRequested)
156
+ await this.#gateway.setVoiceChannel(this.guildId, this.channelId, flag === 'deaf' ? enabled : this.#selfDeaf, flag === 'mute' ? enabled : this.#selfMute);
157
+ if (flag === 'deaf')
158
+ this.#selfDeaf = enabled;
159
+ else
160
+ this.#selfMute = enabled;
161
+ });
162
+ }
57
163
  /** Current voice transport state. A connected voice WebSocket may temporarily be reconnecting. */
58
164
  get connectionState() {
59
165
  if (this.#disposed)
@@ -81,11 +187,13 @@ export class DiscordSession extends RemediaEventEmitter {
81
187
  return null;
82
188
  }
83
189
  }
190
+ /** Current playback lifecycle state. */
84
191
  get state() {
85
192
  if (this.#playback)
86
193
  return this.#playback.state;
87
194
  return this.connectionState === 'failed' ? 'failed' : 'idle';
88
195
  }
196
+ /** Latest source and output timeline measurements in milliseconds. */
89
197
  get position() {
90
198
  return (this.#playback?.position ?? {
91
199
  mediaMs: null,
@@ -94,9 +202,11 @@ export class DiscordSession extends RemediaEventEmitter {
94
202
  playedMs: null,
95
203
  });
96
204
  }
205
+ /** Source duration in milliseconds, or null when unknown. */
97
206
  get durationMs() {
98
207
  return this.#playback?.durationMs ?? null;
99
208
  }
209
+ /** Most recent playback or voice failure message, or null when no error is available. */
100
210
  get error() {
101
211
  return this.#playback?.error ?? this.connectionError;
102
212
  }
@@ -108,7 +218,12 @@ export class DiscordSession extends RemediaEventEmitter {
108
218
  get generation() {
109
219
  return this.#playback?.currentGeneration ?? this.#playbackGeneration;
110
220
  }
111
- /** Set master gain independently of this session's configured filtergraph. */
221
+ /**
222
+ * Set master gain independently of this session's configured filtergraph.
223
+ * @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
224
+ * @returns Resolves after the gain is stored or applied; rejects for non-finite gain or values outside 0 through 2.
225
+ * @throws {RangeError} If gain is non-finite or outside the inclusive range 0 through 2.
226
+ */
112
227
  async setVolume(gain) {
113
228
  this.#assertNotDisposed();
114
229
  validateVolume(gain);
@@ -138,6 +253,8 @@ export class DiscordSession extends RemediaEventEmitter {
138
253
  /**
139
254
  * Request a Gateway voice join and wait until both voice dispatches have arrived and the native
140
255
  * voice transport has completed its DAVE setup. Repeated calls while connecting share a promise.
256
+ * @param options - Optional cancellation signal and positive timeout in milliseconds; defaults to 45000.
257
+ * @returns Resolves when connected; rejects on timeout, cancellation, session loss, or transport failure.
141
258
  */
142
259
  connect(options = {}) {
143
260
  this.#assertNotDisposed();
@@ -176,8 +293,12 @@ export class DiscordSession extends RemediaEventEmitter {
176
293
  /**
177
294
  * Begin playback on this connected voice session. The decoder and Opus encoder remain in core;
178
295
  * this object only associates their output with the native Discord transport.
296
+ * @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
297
+ * @param options - Optional metadata for this generation and a cancellation signal.
298
+ * @returns Selected stream metadata once startup succeeds; rejects if hooks, input opening, or playback startup fail.
179
299
  */
180
- play(source, options = {}) {
300
+ async play(source, options = {}) {
301
+ await this.#configurationWork;
181
302
  return this.#requirePlayback()
182
303
  .play(source, options)
183
304
  .then((info) => {
@@ -185,24 +306,46 @@ export class DiscordSession extends RemediaEventEmitter {
185
306
  return info;
186
307
  });
187
308
  }
309
+ /**
310
+ * Pause the current playback without replacing its decoder or source generation.
311
+ * @returns Resolves when the backend accepts the pause request.
312
+ */
188
313
  pause() {
189
314
  return this.#requireExistingPlayback().pause();
190
315
  }
316
+ /**
317
+ * Resume paused playback on its existing source generation.
318
+ * @returns Resolves when the backend accepts the resume request.
319
+ */
191
320
  resume() {
192
321
  return this.#requireExistingPlayback().resume();
193
322
  }
323
+ /**
324
+ * Seek on the original media timeline; non-seekable and one-shot sources may reject.
325
+ * @param positionMs - Non-negative finite position on the original media timeline, in milliseconds.
326
+ * @returns Resolves after the backend accepts the seek; rejects for invalid positions or unsupported inputs.
327
+ */
194
328
  seek(positionMs) {
195
329
  return this.#requireExistingPlayback().seek(positionMs);
196
330
  }
331
+ /**
332
+ * Stop the active playback generation and release its input without leaving the voice channel.
333
+ * @returns Resolves after the backend stops the active source.
334
+ */
197
335
  stop() {
198
336
  return this.#requireExistingPlayback().stop();
199
337
  }
338
+ /**
339
+ * Wait for the current playback generation to reach a terminal state.
340
+ * @returns The terminal playback state for the generation captured by this wait.
341
+ */
200
342
  wait() {
201
343
  return this.#requireExistingPlayback().wait();
202
344
  }
203
345
  /**
204
346
  * Leave the Discord channel through Gateway OP 4 and close the native transport. This does not
205
347
  * dispose the wrapper, so `connect()` can be called again with fresh Gateway dispatches.
348
+ * @returns Resolves after cleanup; concurrent disconnects share cleanup work.
206
349
  */
207
350
  disconnect() {
208
351
  if (this.#disposed)
@@ -215,10 +358,20 @@ export class DiscordSession extends RemediaEventEmitter {
215
358
  });
216
359
  return this.#disconnectPromise;
217
360
  }
218
- /** Disconnect and unregister this session. */
219
- async dispose() {
361
+ /**
362
+ * Disconnect and unregister this session.
363
+ * @returns Resolves after disposal; rejects if resource cleanup fails.
364
+ */
365
+ dispose() {
366
+ if (this.#disposeWork)
367
+ return this.#disposeWork;
368
+ this.#disposeWork = this.#dispose();
369
+ return this.#disposeWork;
370
+ }
371
+ async #dispose() {
220
372
  if (this.#disposed)
221
373
  return;
374
+ this.#closing = true;
222
375
  try {
223
376
  await this.disconnect();
224
377
  }
@@ -233,7 +386,13 @@ export class DiscordSession extends RemediaEventEmitter {
233
386
  this.#onDispose();
234
387
  }
235
388
  }
236
- /** @internal Routes a parsed raw Gateway dispatch to this guild's active voice lifecycle. */
389
+ /**
390
+ * @internal Routes a parsed raw Gateway dispatch to this guild's active voice lifecycle.
391
+ * @param event - Event name identifying the typed notification or Gateway voice dispatch.
392
+ * @param data - Validated guild-scoped Gateway dispatch data.
393
+ * @param sequence - Gateway dispatch sequence, when supplied by the host application.
394
+ * @param epoch - Gateway READY epoch used to reset stale-sequence tracking.
395
+ */
237
396
  receiveGatewayDispatch(event, data, sequence, epoch) {
238
397
  if (this.#disposed || data.guild_id !== this.guildId)
239
398
  return;
@@ -313,6 +472,8 @@ export class DiscordSession extends RemediaEventEmitter {
313
472
  return;
314
473
  }
315
474
  if (data.channel_id === null) {
475
+ if (this.#attempt?.moving)
476
+ return;
316
477
  if (this.#connectionState === 'connected' || this.#connectionState === 'reconnecting') {
317
478
  this.#handleGatewayDisconnect();
318
479
  }
@@ -395,6 +556,7 @@ export class DiscordSession extends RemediaEventEmitter {
395
556
  resolve = res;
396
557
  reject = rej;
397
558
  });
559
+ void promise.catch(() => { });
398
560
  let attempt;
399
561
  const onTimeout = () => {
400
562
  const error = new Error(`Discord voice connection timed out after ${timeoutMs} ms`);
@@ -426,7 +588,7 @@ export class DiscordSession extends RemediaEventEmitter {
426
588
  #requestGatewayJoin(attempt) {
427
589
  let sendPromise;
428
590
  try {
429
- sendPromise = this.#gateway.setVoiceChannel(this.guildId, this.channelId);
591
+ sendPromise = this.#gateway.setVoiceChannel(this.guildId, this.channelId, this.#selfDeaf, this.#selfMute);
430
592
  }
431
593
  catch (error) {
432
594
  if (!this.#retryCredentialRefresh(attempt, asError(error))) {
@@ -645,7 +807,10 @@ export class DiscordSession extends RemediaEventEmitter {
645
807
  this.#voiceServer = undefined;
646
808
  this.#voiceServerUnavailable = false;
647
809
  }
648
- if (attempt?.internal && this.#nativeSession) {
810
+ if (attempt?.moving) {
811
+ // Keep playback suspended for an explicit retry or disconnect.
812
+ }
813
+ else if (attempt?.internal && this.#nativeSession) {
649
814
  try {
650
815
  this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery failed');
651
816
  }
@@ -673,6 +838,8 @@ export class DiscordSession extends RemediaEventEmitter {
673
838
  #failAttempt(attempt, error, leaveGateway) {
674
839
  if (this.#attempt !== attempt)
675
840
  return;
841
+ if (attempt.moving)
842
+ this.#suspendCurrentNativeTransport(attempt);
676
843
  this.#generation += 1;
677
844
  this.#finishAttempt(attempt);
678
845
  this.#connectionError = error.name === 'AbortError' ? undefined : error.message;
@@ -683,7 +850,10 @@ export class DiscordSession extends RemediaEventEmitter {
683
850
  this.#voiceServer = undefined;
684
851
  this.#voiceServerUnavailable = false;
685
852
  }
686
- if (attempt.internal && this.#nativeSession) {
853
+ if (attempt.moving) {
854
+ // Keep playback suspended for an explicit retry or disconnect.
855
+ }
856
+ else if (attempt.internal && this.#nativeSession) {
687
857
  try {
688
858
  this.#native.failVoiceRecovery(this.#nativeSession.voiceId, 'Discord voice recovery timed out');
689
859
  }
@@ -702,6 +872,7 @@ export class DiscordSession extends RemediaEventEmitter {
702
872
  }
703
873
  #retryCredentialRefresh(attempt, _error) {
704
874
  if (!attempt.internal ||
875
+ attempt.moving ||
705
876
  this.#attempt !== attempt ||
706
877
  attempt.settled ||
707
878
  attempt.retryCount >= 2 ||
@@ -864,7 +1035,7 @@ export class DiscordSession extends RemediaEventEmitter {
864
1035
  return result;
865
1036
  }
866
1037
  #assertNotDisposed() {
867
- if (this.#disposed)
1038
+ if (this.#disposed || this.#closing)
868
1039
  throw new Error('Discord voice session has been disposed');
869
1040
  }
870
1041
  #setConnectionState(state) {
package/dist/events.d.ts CHANGED
@@ -4,8 +4,18 @@ type EventMap<Events> = {
4
4
  };
5
5
  type EventKey<Key, Events> = Events extends [never] ? string | symbol : Key | keyof Events;
6
6
  type EventArgs<Key, Events> = Events extends [never] ? any[] : Key extends keyof Events ? Events[Key] : never;
7
- /** EventEmitter-compatible base that isolates synchronous listener failures from engine work. */
7
+ /**
8
+ * EventEmitter-compatible base that isolates synchronous listener failures from engine work.
9
+ * @typeParam Events - Type used to correlate typed event keys and argument tuples.
10
+ */
8
11
  export declare class RemediaEventEmitter<Events extends EventMap<Events>> extends EventEmitter<Events> {
12
+ /**
13
+ * Emit a typed lifecycle event while isolating exceptions thrown by application listeners.
14
+ * @param event - Event name identifying the typed notification or Gateway voice dispatch.
15
+ * @param args - Typed event arguments delivered to registered listeners.
16
+ * @returns Whether at least one listener handled the event, or false when a listener threw.
17
+ * @typeParam Key - Type used to correlate typed event keys and argument tuples.
18
+ */
9
19
  emit<Key>(event: EventKey<Key, Events>, ...args: EventArgs<Key, Events>): boolean;
10
20
  }
11
21
  export {};
package/dist/events.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { EventEmitter } from 'node:events';
2
- /** EventEmitter-compatible base that isolates synchronous listener failures from engine work. */
2
+ /**
3
+ * EventEmitter-compatible base that isolates synchronous listener failures from engine work.
4
+ * @typeParam Events - Type used to correlate typed event keys and argument tuples.
5
+ */
3
6
  export class RemediaEventEmitter extends EventEmitter {
7
+ /**
8
+ * Emit a typed lifecycle event while isolating exceptions thrown by application listeners.
9
+ * @param event - Event name identifying the typed notification or Gateway voice dispatch.
10
+ * @param args - Typed event arguments delivered to registered listeners.
11
+ * @returns Whether at least one listener handled the event, or false when a listener threw.
12
+ * @typeParam Key - Type used to correlate typed event keys and argument tuples.
13
+ */
4
14
  emit(event, ...args) {
5
15
  try {
6
16
  return super.emit(event, ...args);
package/dist/filters.d.ts CHANGED
@@ -3,9 +3,20 @@ export declare class FilterControls {
3
3
  #private;
4
4
  /** Current configured raw filtergraph, or null when filtering is disabled. */
5
5
  get graph(): string | null;
6
- /** Validate and replace the graph at a safe PCM boundary. */
6
+ /**
7
+ * Validate and replace the graph at a safe PCM boundary.
8
+ * @param graph - Trusted, non-empty raw FFmpeg audio filtergraph; native validation errors reject the operation.
9
+ * @returns Resolves after configuration is applied; rejects for an empty or invalid filtergraph.
10
+ */
7
11
  set(graph: string): Promise<void>;
8
- /** Disable the raw filtergraph without changing master volume. */
12
+ /**
13
+ * Disable the raw filtergraph without changing master volume.
14
+ * @returns Resolves when filtering is disabled.
15
+ */
9
16
  clear(): Promise<void>;
10
17
  }
18
+ /**
19
+ * Validate a finite linear gain in the inclusive range 0 through 2; invalid values throw RangeError.
20
+ * @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
21
+ */
11
22
  export declare function validateVolume(gain: number): void;