@remedia/node 0.1.0-dev.0 → 0.1.0-dev.2
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 +55 -0
- package/dist/discord.d.ts +84 -4
- package/dist/discord.js +183 -12
- package/dist/events.d.ts +11 -1
- package/dist/events.js +11 -1
- package/dist/filters.d.ts +13 -2
- package/dist/filters.js +19 -3
- package/dist/gateway.d.ts +49 -6
- package/dist/gateway.js +24 -5
- package/dist/index.d.ts +1 -1
- package/dist/input.d.ts +26 -0
- package/dist/input.js +11 -0
- package/dist/lifecycle.d.ts +156 -7
- package/dist/lifecycle.js +37 -1
- package/dist/playback.d.ts +47 -3
- package/dist/playback.js +63 -5
- package/dist/remedia.d.ts +41 -6
- package/dist/remedia.js +70 -6
- package/dist/types.d.ts +49 -0
- package/dist/types.js +10 -0
- package/native.d.ts +18 -0
- package/native.js +56 -56
- package/package.json +14 -14
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
219
|
-
|
|
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
|
-
/**
|
|
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?.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|