@remedia/node 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +144 -2
- package/THIRD_PARTY_LICENSES.txt +15655 -0
- package/THIRD_PARTY_NOTICES.md +126 -0
- package/dist/discord.d.ts +149 -0
- package/dist/discord.js +1168 -0
- package/dist/events.d.ts +21 -0
- package/dist/events.js +28 -0
- package/dist/filters.d.ts +22 -0
- package/dist/filters.js +52 -0
- package/dist/gateway.d.ts +88 -0
- package/dist/gateway.js +100 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +46 -0
- package/dist/input.js +135 -0
- package/dist/lifecycle.d.ts +315 -0
- package/dist/lifecycle.js +109 -0
- package/dist/playback.d.ts +73 -0
- package/dist/playback.js +445 -0
- package/dist/remedia.d.ts +51 -0
- package/dist/remedia.js +150 -0
- package/dist/types.d.ts +97 -0
- package/dist/types.js +75 -0
- package/native.d.ts +162 -0
- package/native.js +778 -0
- package/package.json +86 -3
package/dist/playback.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { RemediaEventEmitter } from './events.js';
|
|
2
|
+
import { createReadableInput, toNativeSource, } from './input.js';
|
|
3
|
+
import { lifecycleContext, PlaybackHooks, PlaybackSessionEvents as PlaybackEvent, } from './lifecycle.js';
|
|
4
|
+
import { toStreamInfo, } from './types.js';
|
|
5
|
+
import { FilterControls, validateVolume } from './filters.js';
|
|
6
|
+
/**
|
|
7
|
+
* Controls one playback session. Local sessions decode into a no-op output.
|
|
8
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
9
|
+
*/
|
|
10
|
+
export class PlaybackSession extends RemediaEventEmitter {
|
|
11
|
+
#native;
|
|
12
|
+
#playbackId;
|
|
13
|
+
#onDispose;
|
|
14
|
+
#terminalGenerations = new Set();
|
|
15
|
+
#metadata = new Map();
|
|
16
|
+
#startedGenerations = new Set();
|
|
17
|
+
/** Ordered, awaited local pre-play hooks; listeners are separate from hooks. */
|
|
18
|
+
hooks;
|
|
19
|
+
/** Persistent raw FFmpeg filtergraph controls for this session. */
|
|
20
|
+
filters;
|
|
21
|
+
#input;
|
|
22
|
+
#disposed = false;
|
|
23
|
+
#starting = false;
|
|
24
|
+
#playController;
|
|
25
|
+
#signal;
|
|
26
|
+
#abortListener;
|
|
27
|
+
#latestGeneration = 0;
|
|
28
|
+
#guildId;
|
|
29
|
+
/**
|
|
30
|
+
* Construct this wrapper with its owning services. Use the owning engine or client factory for application code.
|
|
31
|
+
*
|
|
32
|
+
* @internal
|
|
33
|
+
* @param native - Owning native engine instance; its resources must outlive this wrapper.
|
|
34
|
+
* @param playbackId - Opaque native playback identity owned by this wrapper.
|
|
35
|
+
* @param onDispose - Callback invoked when this wrapper releases its owning registration.
|
|
36
|
+
* @param guildId - Discord guild snowflake used to route the operation.
|
|
37
|
+
* @param hooks - Optional shared pre-play hooks for the owning Discord session.
|
|
38
|
+
*/
|
|
39
|
+
constructor(native, playbackId, onDispose, guildId, hooks) {
|
|
40
|
+
super();
|
|
41
|
+
this.#native = native;
|
|
42
|
+
this.#playbackId = playbackId;
|
|
43
|
+
this.#onDispose = onDispose;
|
|
44
|
+
this.#guildId = guildId;
|
|
45
|
+
this.hooks = hooks ?? new PlaybackHooks();
|
|
46
|
+
this.filters = new FilterControls(() => this.#native.playbackFilterGraph(this.#playbackId) ?? null, (graph) => this.#replaceFilterGraph(graph), () => this.#replaceFilterGraph(null));
|
|
47
|
+
}
|
|
48
|
+
/** Stable opaque identity for this wrapper, suitable for correlating lifecycle events. */
|
|
49
|
+
get sessionId() {
|
|
50
|
+
return this.#playbackId;
|
|
51
|
+
}
|
|
52
|
+
/** @internal Latest native source generation observed by this wrapper. */
|
|
53
|
+
get currentGeneration() {
|
|
54
|
+
return this.#latestGeneration;
|
|
55
|
+
}
|
|
56
|
+
/** Current playback lifecycle state. */
|
|
57
|
+
get state() {
|
|
58
|
+
return this.#native.playbackState(this.#playbackId);
|
|
59
|
+
}
|
|
60
|
+
/** Latest source and output timeline measurements in milliseconds. */
|
|
61
|
+
get position() {
|
|
62
|
+
const position = this.#native.playbackPosition(this.#playbackId);
|
|
63
|
+
return {
|
|
64
|
+
mediaMs: position.mediaMs ?? null,
|
|
65
|
+
mediaPositionAccuracy: position.mediaPositionAccuracy === 'timestamp' ||
|
|
66
|
+
position.mediaPositionAccuracy === 'estimated'
|
|
67
|
+
? position.mediaPositionAccuracy
|
|
68
|
+
: null,
|
|
69
|
+
submittedMs: position.submittedMs,
|
|
70
|
+
playedMs: position.playedMs ?? null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** Source duration in milliseconds, or null when unknown. */
|
|
74
|
+
get durationMs() {
|
|
75
|
+
return this.#native.playbackDurationMs(this.#playbackId) ?? null;
|
|
76
|
+
}
|
|
77
|
+
/** Most recent playback or voice failure message, or null when no error is available. */
|
|
78
|
+
get error() {
|
|
79
|
+
return this.#native.playbackError(this.#playbackId) ?? null;
|
|
80
|
+
}
|
|
81
|
+
/** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
|
|
82
|
+
get volume() {
|
|
83
|
+
return this.#native.playbackVolume(this.#playbackId);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Set master gain independently of the raw filtergraph.
|
|
87
|
+
* @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
|
|
88
|
+
* @returns Resolves after the gain is stored or applied; rejects for non-finite gain or values outside 0 through 2.
|
|
89
|
+
* @throws {RangeError} If gain is non-finite or outside the inclusive range 0 through 2.
|
|
90
|
+
*/
|
|
91
|
+
async setVolume(gain) {
|
|
92
|
+
this.#assertActive();
|
|
93
|
+
validateVolume(gain);
|
|
94
|
+
const previousVolume = this.volume;
|
|
95
|
+
if (previousVolume === gain)
|
|
96
|
+
return;
|
|
97
|
+
await this.#native.playbackSetVolume(this.#playbackId, gain);
|
|
98
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
99
|
+
...this.#eventContext(),
|
|
100
|
+
previousVolume,
|
|
101
|
+
volume: gain,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Open one media source and begin a new playback generation using the persistent session configuration. Stop active playback before starting another source.
|
|
106
|
+
* @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
|
|
107
|
+
* @param options - Optional metadata for this generation and a cancellation signal.
|
|
108
|
+
* @returns Selected stream metadata once startup succeeds; rejects if hooks, input opening, or playback startup fail.
|
|
109
|
+
*/
|
|
110
|
+
async play(source, options = {}) {
|
|
111
|
+
this.#assertActive();
|
|
112
|
+
if (this.#starting || isPlaybackActive(this.state)) {
|
|
113
|
+
throw new Error('playback is already active; stop it before starting another source');
|
|
114
|
+
}
|
|
115
|
+
if (options.signal?.aborted)
|
|
116
|
+
throw abortError(options.signal.reason);
|
|
117
|
+
this.#starting = true;
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
let nativeAttempted = false;
|
|
120
|
+
this.#playController = controller;
|
|
121
|
+
this.#signal = options.signal;
|
|
122
|
+
const abortListener = () => {
|
|
123
|
+
controller.abort(options.signal?.reason);
|
|
124
|
+
// This native operation is asynchronous and cancellation-aware. It is never invoked from a
|
|
125
|
+
// Rust callback or while the core holds a playback-state lock.
|
|
126
|
+
if (nativeAttempted)
|
|
127
|
+
void this.#native.stop(this.#playbackId).catch(() => undefined);
|
|
128
|
+
};
|
|
129
|
+
this.#abortListener = abortListener;
|
|
130
|
+
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
131
|
+
let input;
|
|
132
|
+
const previousGeneration = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
133
|
+
try {
|
|
134
|
+
await this.hooks.runBeforePlay({
|
|
135
|
+
source,
|
|
136
|
+
customMetadata: options.customMetadata,
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
});
|
|
139
|
+
this.#assertActive();
|
|
140
|
+
if (controller.signal.aborted)
|
|
141
|
+
throw abortError(controller.signal.reason);
|
|
142
|
+
await this.#releaseInput();
|
|
143
|
+
let nativeSource;
|
|
144
|
+
if ('stream' in source) {
|
|
145
|
+
input = createReadableInput(this.#native, source.stream);
|
|
146
|
+
nativeSource = input.source;
|
|
147
|
+
this.#input = input;
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
nativeSource = toNativeSource(source);
|
|
151
|
+
}
|
|
152
|
+
if (controller.signal.aborted)
|
|
153
|
+
throw abortError(controller.signal.reason);
|
|
154
|
+
nativeAttempted = true;
|
|
155
|
+
const info = toStreamInfo(await this.#native.play(this.#playbackId, nativeSource));
|
|
156
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
157
|
+
this.#latestGeneration = generation;
|
|
158
|
+
this.#metadata.set(generation, options.customMetadata);
|
|
159
|
+
this.#boundMetadata(generation);
|
|
160
|
+
this.#startedGenerations.add(generation);
|
|
161
|
+
this.emit(PlaybackEvent.playbackStarted, {
|
|
162
|
+
...this.#eventContext(generation),
|
|
163
|
+
state: 'playing',
|
|
164
|
+
info,
|
|
165
|
+
});
|
|
166
|
+
this.#starting = false;
|
|
167
|
+
void this.#observeGeneration(generation, input);
|
|
168
|
+
return info;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (input && this.#input === input)
|
|
172
|
+
await this.#releaseInput();
|
|
173
|
+
if (nativeAttempted && !(error instanceof Error && error.name === 'AbortError')) {
|
|
174
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
175
|
+
this.#latestGeneration = generation;
|
|
176
|
+
if (generation > previousGeneration && this.state === 'failed') {
|
|
177
|
+
this.#metadata.set(generation, options.customMetadata);
|
|
178
|
+
this.#emitTerminal('failed', generation, error);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (controller.signal.aborted)
|
|
182
|
+
throw abortError(controller.signal.reason);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
this.#starting = false;
|
|
187
|
+
if (!nativeAttempted || this.state === 'failed' || controller.signal.aborted) {
|
|
188
|
+
this.#clearAbortBinding(controller);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Pause the current playback without replacing its decoder or source generation.
|
|
194
|
+
* @returns Resolves when the backend accepts the pause request.
|
|
195
|
+
*/
|
|
196
|
+
async pause() {
|
|
197
|
+
this.#assertActive();
|
|
198
|
+
const previousState = this.state;
|
|
199
|
+
await this.#native.pause(this.#playbackId);
|
|
200
|
+
if (previousState === 'playing' && this.state === 'paused') {
|
|
201
|
+
this.emit(PlaybackEvent.playbackPaused, {
|
|
202
|
+
...this.#eventContext(),
|
|
203
|
+
previousState,
|
|
204
|
+
state: 'paused',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Resume paused playback on its existing source generation.
|
|
210
|
+
* @returns Resolves when the backend accepts the resume request.
|
|
211
|
+
*/
|
|
212
|
+
async resume() {
|
|
213
|
+
this.#assertActive();
|
|
214
|
+
const previousState = this.state;
|
|
215
|
+
await this.#native.resume(this.#playbackId);
|
|
216
|
+
if (previousState === 'paused' && this.state === 'playing') {
|
|
217
|
+
this.emit(PlaybackEvent.playbackResumed, {
|
|
218
|
+
...this.#eventContext(),
|
|
219
|
+
previousState,
|
|
220
|
+
state: 'playing',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Seek on the original media timeline; non-seekable and one-shot sources may reject.
|
|
226
|
+
* @param positionMs - Non-negative finite position on the original media timeline, in milliseconds.
|
|
227
|
+
* @returns Resolves after the backend accepts the seek; rejects for invalid positions or unsupported inputs.
|
|
228
|
+
*/
|
|
229
|
+
async seek(positionMs) {
|
|
230
|
+
this.#assertActive();
|
|
231
|
+
const previousPositionMs = this.position.mediaMs;
|
|
232
|
+
await this.#native.seek(this.#playbackId, positionMs);
|
|
233
|
+
if (previousPositionMs !== positionMs) {
|
|
234
|
+
this.emit(PlaybackEvent.playbackSeeked, {
|
|
235
|
+
...this.#eventContext(),
|
|
236
|
+
positionMs,
|
|
237
|
+
previousPositionMs,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Stop the active playback generation and release its input without leaving the voice channel.
|
|
243
|
+
* @returns Resolves after the backend stops the active source.
|
|
244
|
+
*/
|
|
245
|
+
async stop() {
|
|
246
|
+
this.#assertActive();
|
|
247
|
+
const previousState = this.state;
|
|
248
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
249
|
+
try {
|
|
250
|
+
await this.#native.stop(this.#playbackId);
|
|
251
|
+
if (isPlaybackActive(previousState) && this.state === 'stopped') {
|
|
252
|
+
this.#emitTerminal('stopped', generation, undefined, 'stop');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
await this.#releaseInput();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Wait for the current playback generation to reach a terminal state.
|
|
261
|
+
* @returns The terminal playback state for the generation captured by this wait.
|
|
262
|
+
*/
|
|
263
|
+
async wait() {
|
|
264
|
+
this.#assertActive();
|
|
265
|
+
// Capture synchronously so the native blocking waiter stays bound to the source
|
|
266
|
+
// that was active when wait() was called, even if play() replaces it first.
|
|
267
|
+
const generation = this.#native.playbackGeneration(this.#playbackId);
|
|
268
|
+
try {
|
|
269
|
+
return (await this.#native.wait(this.#playbackId, generation));
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await this.#releaseInput();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Stop and release this standalone playback session.
|
|
277
|
+
* @returns Resolves after disposal; rejects if resource cleanup fails.
|
|
278
|
+
*/
|
|
279
|
+
async dispose() {
|
|
280
|
+
if (this.#disposed)
|
|
281
|
+
return;
|
|
282
|
+
this.#playController?.abort(new Error('playback session disposed'));
|
|
283
|
+
this.#signal?.removeEventListener('abort', this.#abortListener);
|
|
284
|
+
try {
|
|
285
|
+
await this.#native.disposePlaybackSession(this.#playbackId);
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await this.#releaseInput();
|
|
289
|
+
this.#disposed = true;
|
|
290
|
+
this.hooks.clear();
|
|
291
|
+
this.#metadata.clear();
|
|
292
|
+
this.emit(PlaybackEvent.sessionDisposed, {
|
|
293
|
+
sessionId: this.sessionId,
|
|
294
|
+
...(this.#guildId === undefined ? {} : { guildId: this.#guildId }),
|
|
295
|
+
nodeId: null,
|
|
296
|
+
generation: this.#latestGeneration,
|
|
297
|
+
});
|
|
298
|
+
this.#onDispose?.();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Detach and cancel this wrapper's bounded input pump without destroying the caller-owned Readable.
|
|
303
|
+
*
|
|
304
|
+
* @internal
|
|
305
|
+
* @returns Resolves after the input pump has released its listeners and native queue.
|
|
306
|
+
*/
|
|
307
|
+
async releaseInput() {
|
|
308
|
+
await this.#releaseInput();
|
|
309
|
+
}
|
|
310
|
+
async #replaceFilterGraph(graph) {
|
|
311
|
+
this.#assertActive();
|
|
312
|
+
const previousGraph = this.filters.graph;
|
|
313
|
+
if (previousGraph === graph)
|
|
314
|
+
return;
|
|
315
|
+
if (graph === null)
|
|
316
|
+
await this.#native.playbackClearFilterGraph(this.#playbackId);
|
|
317
|
+
else
|
|
318
|
+
await this.#native.playbackSetFilterGraph(this.#playbackId, graph);
|
|
319
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
320
|
+
...this.#eventContext(),
|
|
321
|
+
previousGraph,
|
|
322
|
+
graph,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
#eventContext(generation) {
|
|
326
|
+
const activeGeneration = generation ?? this.#latestGeneration;
|
|
327
|
+
const context = lifecycleContext(this.sessionId, activeGeneration, this.#metadata.get(activeGeneration));
|
|
328
|
+
return this.#guildId === undefined ? context : { ...context, guildId: this.#guildId };
|
|
329
|
+
}
|
|
330
|
+
async #observeGeneration(generation, input) {
|
|
331
|
+
try {
|
|
332
|
+
const state = (await this.#native.wait(this.#playbackId, String(generation)));
|
|
333
|
+
if (state === 'completed' || state === 'failed' || state === 'stopped') {
|
|
334
|
+
const error = state === 'failed' ? new Error(this.error ?? 'playback failed') : undefined;
|
|
335
|
+
this.#emitTerminal(state, generation, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (!this.#disposed) {
|
|
340
|
+
// Native disposal can remove the session before this generation-bound
|
|
341
|
+
// wait settles. In that race, reading playbackState also rejects; the
|
|
342
|
+
// disposal lifecycle event is authoritative and no terminal event is
|
|
343
|
+
// needed for the removed session.
|
|
344
|
+
let state;
|
|
345
|
+
try {
|
|
346
|
+
state = this.state;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
state = undefined;
|
|
350
|
+
}
|
|
351
|
+
if (state === 'failed')
|
|
352
|
+
this.#emitTerminal('failed', generation, asError(error));
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.#metadata.delete(generation);
|
|
357
|
+
this.#startedGenerations.delete(generation);
|
|
358
|
+
if (this.#latestGeneration === generation) {
|
|
359
|
+
this.#clearAbortBinding(this.#playController);
|
|
360
|
+
}
|
|
361
|
+
await this.#releaseInput(input, generation);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
#emitTerminal(state, generation, error, reason) {
|
|
365
|
+
if ((state !== 'failed' && !this.#startedGenerations.has(generation)) ||
|
|
366
|
+
this.#terminalGenerations.has(generation))
|
|
367
|
+
return;
|
|
368
|
+
this.#terminalGenerations.add(generation);
|
|
369
|
+
while (this.#terminalGenerations.size > 16) {
|
|
370
|
+
const oldest = this.#terminalGenerations.values().next().value;
|
|
371
|
+
if (oldest === undefined)
|
|
372
|
+
break;
|
|
373
|
+
this.#terminalGenerations.delete(oldest);
|
|
374
|
+
}
|
|
375
|
+
const base = this.#eventContext(generation);
|
|
376
|
+
if (state === 'completed') {
|
|
377
|
+
this.emit(PlaybackEvent.playbackCompleted, { ...base, state, position: this.position });
|
|
378
|
+
}
|
|
379
|
+
else if (state === 'stopped') {
|
|
380
|
+
this.emit(PlaybackEvent.playbackStopped, reason ? { ...base, state, reason } : { ...base, state });
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
this.emit(PlaybackEvent.playbackFailed, { ...base, state, error: asError(error) });
|
|
384
|
+
}
|
|
385
|
+
this.#metadata.delete(generation);
|
|
386
|
+
this.#clearAbortBinding(this.#playController);
|
|
387
|
+
}
|
|
388
|
+
#boundMetadata(generation) {
|
|
389
|
+
while (this.#metadata.size > 4) {
|
|
390
|
+
const oldest = this.#metadata.keys().next().value;
|
|
391
|
+
if (oldest === undefined || oldest === generation)
|
|
392
|
+
break;
|
|
393
|
+
this.#metadata.delete(oldest);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
#clearAbortBinding(controller) {
|
|
397
|
+
if (!controller || this.#playController !== controller)
|
|
398
|
+
return;
|
|
399
|
+
this.#signal?.removeEventListener('abort', this.#abortListener);
|
|
400
|
+
this.#playController = undefined;
|
|
401
|
+
this.#signal = undefined;
|
|
402
|
+
this.#abortListener = undefined;
|
|
403
|
+
}
|
|
404
|
+
async #releaseInput(expected, generation) {
|
|
405
|
+
if (generation !== undefined && this.#latestGeneration !== generation) {
|
|
406
|
+
await expected?.cancel();
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const input = this.#input;
|
|
410
|
+
if (expected !== undefined && input !== expected) {
|
|
411
|
+
await expected.cancel();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
this.#input = undefined;
|
|
415
|
+
if (input)
|
|
416
|
+
await input.cancel();
|
|
417
|
+
}
|
|
418
|
+
#assertActive() {
|
|
419
|
+
if (this.#disposed)
|
|
420
|
+
throw new Error('Playback session has been disposed');
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function isPlaybackActive(state) {
|
|
424
|
+
return (state === 'starting' ||
|
|
425
|
+
state === 'playing' ||
|
|
426
|
+
state === 'pausing' ||
|
|
427
|
+
state === 'resuming' ||
|
|
428
|
+
state === 'paused' ||
|
|
429
|
+
state === 'stopping');
|
|
430
|
+
}
|
|
431
|
+
function numericGeneration(value) {
|
|
432
|
+
const generation = Number(value);
|
|
433
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
434
|
+
throw new RangeError('native playback generation exceeds the JavaScript safe integer range');
|
|
435
|
+
}
|
|
436
|
+
return generation;
|
|
437
|
+
}
|
|
438
|
+
function asError(error) {
|
|
439
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
440
|
+
}
|
|
441
|
+
function abortError(reason) {
|
|
442
|
+
const error = new Error(reason instanceof Error ? reason.message : 'playback was cancelled');
|
|
443
|
+
error.name = 'AbortError';
|
|
444
|
+
return error;
|
|
445
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { RemediaEventEmitter } from './events.js';
|
|
2
|
+
import { DiscordSession, type DiscordSessionOptions } from './discord.js';
|
|
3
|
+
import { type DiscordGatewaySend } from './gateway.js';
|
|
4
|
+
import type { RemediaLifecycleEvents } from './lifecycle.js';
|
|
5
|
+
import { PlaybackSession } from './playback.js';
|
|
6
|
+
import { type DebugListener, type DiagnosticEvent } from './types.js';
|
|
7
|
+
/** Optional Gateway integration for the embedded engine; required when creating Discord sessions. */
|
|
8
|
+
export interface RemediaOptions {
|
|
9
|
+
/** Send raw Gateway payloads through the application's existing Discord client or shard. */
|
|
10
|
+
send: DiscordGatewaySend;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One embedded Remedia context. Create instances with `await Remedia.create()` so native
|
|
14
|
+
* observability and optional asynchronous OpenTelemetry exporters initialize on NAPI-RS's runtime.
|
|
15
|
+
*/
|
|
16
|
+
export declare class Remedia extends RemediaEventEmitter<RemediaLifecycleEvents> {
|
|
17
|
+
#private;
|
|
18
|
+
private constructor();
|
|
19
|
+
/**
|
|
20
|
+
* Create an independent native context using REMEDIA_* and OTEL_* environment settings.
|
|
21
|
+
* @param options - Optional bot Gateway callback; required when creating Discord sessions.
|
|
22
|
+
* @returns An initialized independent native engine context.
|
|
23
|
+
*/
|
|
24
|
+
static create(options?: RemediaOptions): Promise<Remedia>;
|
|
25
|
+
/** Number of diagnostic events dropped by either bounded event queue. */
|
|
26
|
+
get droppedDebugEvents(): number;
|
|
27
|
+
/**
|
|
28
|
+
* Create a local decoder session that discards its PCM output.
|
|
29
|
+
* @returns An independent playback session owned by this engine until disposal.
|
|
30
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
31
|
+
*/
|
|
32
|
+
createPlaybackSession<Metadata = unknown>(): PlaybackSession<Metadata>;
|
|
33
|
+
/**
|
|
34
|
+
* Create a Gateway-managed voice session. Call `connect()` to send OP 4 and join the channel.
|
|
35
|
+
* @param options - Guild and bot identity, requested channel, initial processing settings, and voice flags.
|
|
36
|
+
* @returns The unconnected session; call connect() to request a Gateway voice join.
|
|
37
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
38
|
+
*/
|
|
39
|
+
createDiscordSession<Metadata = unknown>(options: DiscordSessionOptions): DiscordSession<Metadata>;
|
|
40
|
+
/**
|
|
41
|
+
* Forward a raw Discord Gateway packet to the matching in-process voice session.
|
|
42
|
+
* @param packet - Raw packet from this bot's Gateway dispatch stream.
|
|
43
|
+
*/
|
|
44
|
+
receive(packet: unknown): void;
|
|
45
|
+
/**
|
|
46
|
+
* Stop all playbacks, disconnect transports, flush telemetry, and release native resources.
|
|
47
|
+
* @returns Resolves after cleanup; rejects when a resource fails to close.
|
|
48
|
+
*/
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export type { DebugListener, DiagnosticEvent };
|
package/dist/remedia.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { RemediaEventEmitter } from './events.js';
|
|
2
|
+
import { NativeRemedia } from '../native.js';
|
|
3
|
+
import { DiscordSession } from './discord.js';
|
|
4
|
+
import { DiscordGatewayAdapter } from './gateway.js';
|
|
5
|
+
import { PlaybackSessionEvents as PlaybackEvent, RemediaEvents as RemediaEvent, VoiceSessionEvents as VoiceEvent, } from './lifecycle.js';
|
|
6
|
+
import { PlaybackSession } from './playback.js';
|
|
7
|
+
import { toDiagnosticEvent } from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* One embedded Remedia context. Create instances with `await Remedia.create()` so native
|
|
10
|
+
* observability and optional asynchronous OpenTelemetry exporters initialize on NAPI-RS's runtime.
|
|
11
|
+
*/
|
|
12
|
+
export class Remedia extends RemediaEventEmitter {
|
|
13
|
+
#native;
|
|
14
|
+
#sessions = new Set();
|
|
15
|
+
#discordSessions = new Map();
|
|
16
|
+
#gateway;
|
|
17
|
+
#closed = false;
|
|
18
|
+
constructor(native, options) {
|
|
19
|
+
super();
|
|
20
|
+
this.#native = native;
|
|
21
|
+
this.#gateway = options ? new DiscordGatewayAdapter(options.send) : undefined;
|
|
22
|
+
native.setDebugCallback((event) => {
|
|
23
|
+
const diagnostic = toDiagnosticEvent(event);
|
|
24
|
+
this.emit(RemediaEvent.debug, diagnostic);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create an independent native context using REMEDIA_* and OTEL_* environment settings.
|
|
29
|
+
* @param options - Optional bot Gateway callback; required when creating Discord sessions.
|
|
30
|
+
* @returns An initialized independent native engine context.
|
|
31
|
+
*/
|
|
32
|
+
static async create(options) {
|
|
33
|
+
return new Remedia(await NativeRemedia.create(), options);
|
|
34
|
+
}
|
|
35
|
+
/** Number of diagnostic events dropped by either bounded event queue. */
|
|
36
|
+
get droppedDebugEvents() {
|
|
37
|
+
return this.#native.droppedDebugEvents;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create a local decoder session that discards its PCM output.
|
|
41
|
+
* @returns An independent playback session owned by this engine until disposal.
|
|
42
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
43
|
+
*/
|
|
44
|
+
createPlaybackSession() {
|
|
45
|
+
this.#assertOpen();
|
|
46
|
+
let playback;
|
|
47
|
+
playback = new PlaybackSession(this.#native, this.#native.createPlaybackSession(), () => {
|
|
48
|
+
this.#sessions.delete(playback);
|
|
49
|
+
});
|
|
50
|
+
forwardLifecycleEvents(playback, this);
|
|
51
|
+
this.#sessions.add(playback);
|
|
52
|
+
return playback;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a Gateway-managed voice session. Call `connect()` to send OP 4 and join the channel.
|
|
56
|
+
* @param options - Guild and bot identity, requested channel, initial processing settings, and voice flags.
|
|
57
|
+
* @returns The unconnected session; call connect() to request a Gateway voice join.
|
|
58
|
+
* @typeParam Metadata - Application-owned metadata attached to playback generations.
|
|
59
|
+
*/
|
|
60
|
+
createDiscordSession(options) {
|
|
61
|
+
this.#assertOpen();
|
|
62
|
+
if (!this.#gateway) {
|
|
63
|
+
throw new Error('Remedia.create() requires a Gateway send function for Discord sessions');
|
|
64
|
+
}
|
|
65
|
+
if (this.#discordSessions.has(options.guildId)) {
|
|
66
|
+
throw new Error(`a Discord voice session already exists for guild ${options.guildId}`);
|
|
67
|
+
}
|
|
68
|
+
let session;
|
|
69
|
+
session = new DiscordSession(this.#native, this.#gateway, options, () => {
|
|
70
|
+
this.#discordSessions.delete(session.guildId);
|
|
71
|
+
});
|
|
72
|
+
forwardLifecycleEvents(session, this);
|
|
73
|
+
this.#discordSessions.set(session.guildId, session);
|
|
74
|
+
return session;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Forward a raw Discord Gateway packet to the matching in-process voice session.
|
|
78
|
+
* @param packet - Raw packet from this bot's Gateway dispatch stream.
|
|
79
|
+
*/
|
|
80
|
+
receive(packet) {
|
|
81
|
+
if (this.#closed || !this.#gateway)
|
|
82
|
+
return;
|
|
83
|
+
const dispatch = this.#gateway.receive(packet);
|
|
84
|
+
if (!dispatch)
|
|
85
|
+
return;
|
|
86
|
+
this.#discordSessions
|
|
87
|
+
.get(dispatch.guildId)
|
|
88
|
+
?.receiveGatewayDispatch(dispatch.event, dispatch.data, dispatch.sequence, dispatch.epoch);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Stop all playbacks, disconnect transports, flush telemetry, and release native resources.
|
|
92
|
+
* @returns Resolves after cleanup; rejects when a resource fails to close.
|
|
93
|
+
*/
|
|
94
|
+
async close() {
|
|
95
|
+
if (this.#closed)
|
|
96
|
+
return;
|
|
97
|
+
this.#closed = true;
|
|
98
|
+
const voiceCleanup = await Promise.allSettled([...this.#discordSessions.values()].map((session) => session.dispose()));
|
|
99
|
+
try {
|
|
100
|
+
await Promise.all([...this.#sessions].map((session) => session.dispose()));
|
|
101
|
+
await this.#native.close();
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await Promise.all([...this.#sessions].map((session) => session.releaseInput()));
|
|
105
|
+
this.#sessions.clear();
|
|
106
|
+
this.#discordSessions.clear();
|
|
107
|
+
}
|
|
108
|
+
const failures = voiceCleanup.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
109
|
+
if (failures.length > 0) {
|
|
110
|
+
throw new AggregateError(failures, 'one or more Discord voice sessions failed to close');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
#assertOpen() {
|
|
114
|
+
if (this.#closed)
|
|
115
|
+
throw new Error('Remedia instance is closed');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function forwardLifecycleEvents(source, target) {
|
|
119
|
+
const sourceEvents = source;
|
|
120
|
+
const targetEvents = target;
|
|
121
|
+
const names = [
|
|
122
|
+
PlaybackEvent.playbackStarted,
|
|
123
|
+
PlaybackEvent.playbackCompleted,
|
|
124
|
+
PlaybackEvent.playbackStopped,
|
|
125
|
+
PlaybackEvent.playbackFailed,
|
|
126
|
+
PlaybackEvent.playbackPaused,
|
|
127
|
+
PlaybackEvent.playbackResumed,
|
|
128
|
+
PlaybackEvent.playbackSeeked,
|
|
129
|
+
PlaybackEvent.filterChanged,
|
|
130
|
+
PlaybackEvent.volumeChanged,
|
|
131
|
+
PlaybackEvent.sessionDisposed,
|
|
132
|
+
...(source instanceof DiscordSession
|
|
133
|
+
? [VoiceEvent.voiceStateChanged, VoiceEvent.voiceFailed]
|
|
134
|
+
: []),
|
|
135
|
+
];
|
|
136
|
+
const unsubs = [];
|
|
137
|
+
for (const name of names) {
|
|
138
|
+
const listener = (event) => {
|
|
139
|
+
targetEvents.emit(name, event);
|
|
140
|
+
};
|
|
141
|
+
sourceEvents.on(name, listener);
|
|
142
|
+
unsubs.push(() => sourceEvents.off(name, listener));
|
|
143
|
+
}
|
|
144
|
+
const detach = () => {
|
|
145
|
+
sourceEvents.off(PlaybackEvent.sessionDisposed, detach);
|
|
146
|
+
for (const unsubscribe of unsubs.splice(0))
|
|
147
|
+
unsubscribe();
|
|
148
|
+
};
|
|
149
|
+
sourceEvents.once(PlaybackEvent.sessionDisposed, detach);
|
|
150
|
+
}
|