@remedia/node 0.0.0-stage → 0.1.0-dev.0
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 +89 -2
- package/THIRD_PARTY_LICENSES.txt +15655 -0
- package/THIRD_PARTY_NOTICES.md +126 -0
- package/dist/discord.d.ts +69 -0
- package/dist/discord.js +997 -0
- package/dist/events.d.ts +11 -0
- package/dist/events.js +18 -0
- package/dist/filters.d.ts +11 -0
- package/dist/filters.js +36 -0
- package/dist/gateway.d.ts +45 -0
- package/dist/gateway.js +81 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/input.d.ts +20 -0
- package/dist/input.js +124 -0
- package/dist/lifecycle.d.ts +166 -0
- package/dist/lifecycle.js +73 -0
- package/dist/playback.d.ts +29 -0
- package/dist/playback.js +387 -0
- package/dist/remedia.d.ts +32 -0
- package/dist/remedia.js +132 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +65 -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,387 @@
|
|
|
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
|
+
/** Controls one playback session. Local sessions decode into a no-op output. */
|
|
7
|
+
export class PlaybackSession extends RemediaEventEmitter {
|
|
8
|
+
#native;
|
|
9
|
+
#playbackId;
|
|
10
|
+
#onDispose;
|
|
11
|
+
#terminalGenerations = new Set();
|
|
12
|
+
#metadata = new Map();
|
|
13
|
+
#startedGenerations = new Set();
|
|
14
|
+
hooks;
|
|
15
|
+
filters;
|
|
16
|
+
#input;
|
|
17
|
+
#disposed = false;
|
|
18
|
+
#starting = false;
|
|
19
|
+
#playController;
|
|
20
|
+
#signal;
|
|
21
|
+
#abortListener;
|
|
22
|
+
#latestGeneration = 0;
|
|
23
|
+
#guildId;
|
|
24
|
+
/** @internal */
|
|
25
|
+
constructor(native, playbackId, onDispose, guildId, hooks) {
|
|
26
|
+
super();
|
|
27
|
+
this.#native = native;
|
|
28
|
+
this.#playbackId = playbackId;
|
|
29
|
+
this.#onDispose = onDispose;
|
|
30
|
+
this.#guildId = guildId;
|
|
31
|
+
this.hooks = hooks ?? new PlaybackHooks();
|
|
32
|
+
this.filters = new FilterControls(() => this.#native.playbackFilterGraph(this.#playbackId) ?? null, (graph) => this.#replaceFilterGraph(graph), () => this.#replaceFilterGraph(null));
|
|
33
|
+
}
|
|
34
|
+
/** Stable opaque identity for this wrapper, suitable for correlating lifecycle events. */
|
|
35
|
+
get sessionId() {
|
|
36
|
+
return this.#playbackId;
|
|
37
|
+
}
|
|
38
|
+
/** @internal Latest native source generation observed by this wrapper. */
|
|
39
|
+
get currentGeneration() {
|
|
40
|
+
return this.#latestGeneration;
|
|
41
|
+
}
|
|
42
|
+
get state() {
|
|
43
|
+
return this.#native.playbackState(this.#playbackId);
|
|
44
|
+
}
|
|
45
|
+
get position() {
|
|
46
|
+
const position = this.#native.playbackPosition(this.#playbackId);
|
|
47
|
+
return {
|
|
48
|
+
mediaMs: position.mediaMs ?? null,
|
|
49
|
+
mediaPositionAccuracy: position.mediaPositionAccuracy === 'timestamp' ||
|
|
50
|
+
position.mediaPositionAccuracy === 'estimated'
|
|
51
|
+
? position.mediaPositionAccuracy
|
|
52
|
+
: null,
|
|
53
|
+
submittedMs: position.submittedMs,
|
|
54
|
+
playedMs: position.playedMs ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
get durationMs() {
|
|
58
|
+
return this.#native.playbackDurationMs(this.#playbackId) ?? null;
|
|
59
|
+
}
|
|
60
|
+
get error() {
|
|
61
|
+
return this.#native.playbackError(this.#playbackId) ?? null;
|
|
62
|
+
}
|
|
63
|
+
/** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
|
|
64
|
+
get volume() {
|
|
65
|
+
return this.#native.playbackVolume(this.#playbackId);
|
|
66
|
+
}
|
|
67
|
+
/** Set master gain independently of the raw filtergraph. */
|
|
68
|
+
async setVolume(gain) {
|
|
69
|
+
this.#assertActive();
|
|
70
|
+
validateVolume(gain);
|
|
71
|
+
const previousVolume = this.volume;
|
|
72
|
+
if (previousVolume === gain)
|
|
73
|
+
return;
|
|
74
|
+
await this.#native.playbackSetVolume(this.#playbackId, gain);
|
|
75
|
+
this.emit(PlaybackEvent.volumeChanged, {
|
|
76
|
+
...this.#eventContext(),
|
|
77
|
+
previousVolume,
|
|
78
|
+
volume: gain,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async play(source, options = {}) {
|
|
82
|
+
this.#assertActive();
|
|
83
|
+
if (this.#starting || isPlaybackActive(this.state)) {
|
|
84
|
+
throw new Error('playback is already active; stop it before starting another source');
|
|
85
|
+
}
|
|
86
|
+
if (options.signal?.aborted)
|
|
87
|
+
throw abortError(options.signal.reason);
|
|
88
|
+
this.#starting = true;
|
|
89
|
+
const controller = new AbortController();
|
|
90
|
+
let nativeAttempted = false;
|
|
91
|
+
this.#playController = controller;
|
|
92
|
+
this.#signal = options.signal;
|
|
93
|
+
const abortListener = () => {
|
|
94
|
+
controller.abort(options.signal?.reason);
|
|
95
|
+
// This native operation is asynchronous and cancellation-aware. It is never invoked from a
|
|
96
|
+
// Rust callback or while the core holds a playback-state lock.
|
|
97
|
+
if (nativeAttempted)
|
|
98
|
+
void this.#native.stop(this.#playbackId).catch(() => undefined);
|
|
99
|
+
};
|
|
100
|
+
this.#abortListener = abortListener;
|
|
101
|
+
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
102
|
+
let input;
|
|
103
|
+
const previousGeneration = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
104
|
+
try {
|
|
105
|
+
await this.hooks.runBeforePlay({
|
|
106
|
+
source,
|
|
107
|
+
customMetadata: options.customMetadata,
|
|
108
|
+
signal: controller.signal,
|
|
109
|
+
});
|
|
110
|
+
this.#assertActive();
|
|
111
|
+
if (controller.signal.aborted)
|
|
112
|
+
throw abortError(controller.signal.reason);
|
|
113
|
+
await this.#releaseInput();
|
|
114
|
+
let nativeSource;
|
|
115
|
+
if ('stream' in source) {
|
|
116
|
+
input = createReadableInput(this.#native, source.stream);
|
|
117
|
+
nativeSource = input.source;
|
|
118
|
+
this.#input = input;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
nativeSource = toNativeSource(source);
|
|
122
|
+
}
|
|
123
|
+
if (controller.signal.aborted)
|
|
124
|
+
throw abortError(controller.signal.reason);
|
|
125
|
+
nativeAttempted = true;
|
|
126
|
+
const info = toStreamInfo(await this.#native.play(this.#playbackId, nativeSource));
|
|
127
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
128
|
+
this.#latestGeneration = generation;
|
|
129
|
+
this.#metadata.set(generation, options.customMetadata);
|
|
130
|
+
this.#boundMetadata(generation);
|
|
131
|
+
this.#startedGenerations.add(generation);
|
|
132
|
+
this.emit(PlaybackEvent.playbackStarted, {
|
|
133
|
+
...this.#eventContext(generation),
|
|
134
|
+
state: 'playing',
|
|
135
|
+
info,
|
|
136
|
+
});
|
|
137
|
+
this.#starting = false;
|
|
138
|
+
void this.#observeGeneration(generation, input);
|
|
139
|
+
return info;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (input && this.#input === input)
|
|
143
|
+
await this.#releaseInput();
|
|
144
|
+
if (nativeAttempted && !(error instanceof Error && error.name === 'AbortError')) {
|
|
145
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
146
|
+
this.#latestGeneration = generation;
|
|
147
|
+
if (generation > previousGeneration && this.state === 'failed') {
|
|
148
|
+
this.#metadata.set(generation, options.customMetadata);
|
|
149
|
+
this.#emitTerminal('failed', generation, error);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (controller.signal.aborted)
|
|
153
|
+
throw abortError(controller.signal.reason);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
this.#starting = false;
|
|
158
|
+
if (!nativeAttempted || this.state === 'failed' || controller.signal.aborted) {
|
|
159
|
+
this.#clearAbortBinding(controller);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async pause() {
|
|
164
|
+
this.#assertActive();
|
|
165
|
+
const previousState = this.state;
|
|
166
|
+
await this.#native.pause(this.#playbackId);
|
|
167
|
+
if (previousState === 'playing' && this.state === 'paused') {
|
|
168
|
+
this.emit(PlaybackEvent.playbackPaused, {
|
|
169
|
+
...this.#eventContext(),
|
|
170
|
+
previousState,
|
|
171
|
+
state: 'paused',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async resume() {
|
|
176
|
+
this.#assertActive();
|
|
177
|
+
const previousState = this.state;
|
|
178
|
+
await this.#native.resume(this.#playbackId);
|
|
179
|
+
if (previousState === 'paused' && this.state === 'playing') {
|
|
180
|
+
this.emit(PlaybackEvent.playbackResumed, {
|
|
181
|
+
...this.#eventContext(),
|
|
182
|
+
previousState,
|
|
183
|
+
state: 'playing',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async seek(positionMs) {
|
|
188
|
+
this.#assertActive();
|
|
189
|
+
const previousPositionMs = this.position.mediaMs;
|
|
190
|
+
await this.#native.seek(this.#playbackId, positionMs);
|
|
191
|
+
if (previousPositionMs !== positionMs) {
|
|
192
|
+
this.emit(PlaybackEvent.playbackSeeked, {
|
|
193
|
+
...this.#eventContext(),
|
|
194
|
+
positionMs,
|
|
195
|
+
previousPositionMs,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async stop() {
|
|
200
|
+
this.#assertActive();
|
|
201
|
+
const previousState = this.state;
|
|
202
|
+
const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
|
|
203
|
+
try {
|
|
204
|
+
await this.#native.stop(this.#playbackId);
|
|
205
|
+
if (isPlaybackActive(previousState) && this.state === 'stopped') {
|
|
206
|
+
this.#emitTerminal('stopped', generation, undefined, 'stop');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await this.#releaseInput();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async wait() {
|
|
214
|
+
this.#assertActive();
|
|
215
|
+
// Capture synchronously so the native blocking waiter stays bound to the source
|
|
216
|
+
// that was active when wait() was called, even if play() replaces it first.
|
|
217
|
+
const generation = this.#native.playbackGeneration(this.#playbackId);
|
|
218
|
+
try {
|
|
219
|
+
return (await this.#native.wait(this.#playbackId, generation));
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
await this.#releaseInput();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** Stop and release this standalone playback session. */
|
|
226
|
+
async dispose() {
|
|
227
|
+
if (this.#disposed)
|
|
228
|
+
return;
|
|
229
|
+
this.#playController?.abort(new Error('playback session disposed'));
|
|
230
|
+
this.#signal?.removeEventListener('abort', this.#abortListener);
|
|
231
|
+
try {
|
|
232
|
+
await this.#native.disposePlaybackSession(this.#playbackId);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await this.#releaseInput();
|
|
236
|
+
this.#disposed = true;
|
|
237
|
+
this.hooks.clear();
|
|
238
|
+
this.#metadata.clear();
|
|
239
|
+
this.emit(PlaybackEvent.sessionDisposed, {
|
|
240
|
+
sessionId: this.sessionId,
|
|
241
|
+
...(this.#guildId === undefined ? {} : { guildId: this.#guildId }),
|
|
242
|
+
nodeId: null,
|
|
243
|
+
generation: this.#latestGeneration,
|
|
244
|
+
});
|
|
245
|
+
this.#onDispose?.();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** @internal */
|
|
249
|
+
async releaseInput() {
|
|
250
|
+
await this.#releaseInput();
|
|
251
|
+
}
|
|
252
|
+
async #replaceFilterGraph(graph) {
|
|
253
|
+
this.#assertActive();
|
|
254
|
+
const previousGraph = this.filters.graph;
|
|
255
|
+
if (previousGraph === graph)
|
|
256
|
+
return;
|
|
257
|
+
if (graph === null)
|
|
258
|
+
await this.#native.playbackClearFilterGraph(this.#playbackId);
|
|
259
|
+
else
|
|
260
|
+
await this.#native.playbackSetFilterGraph(this.#playbackId, graph);
|
|
261
|
+
this.emit(PlaybackEvent.filterChanged, {
|
|
262
|
+
...this.#eventContext(),
|
|
263
|
+
previousGraph,
|
|
264
|
+
graph,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
#eventContext(generation) {
|
|
268
|
+
const activeGeneration = generation ?? this.#latestGeneration;
|
|
269
|
+
const context = lifecycleContext(this.sessionId, activeGeneration, this.#metadata.get(activeGeneration));
|
|
270
|
+
return this.#guildId === undefined ? context : { ...context, guildId: this.#guildId };
|
|
271
|
+
}
|
|
272
|
+
async #observeGeneration(generation, input) {
|
|
273
|
+
try {
|
|
274
|
+
const state = (await this.#native.wait(this.#playbackId, String(generation)));
|
|
275
|
+
if (state === 'completed' || state === 'failed' || state === 'stopped') {
|
|
276
|
+
const error = state === 'failed' ? new Error(this.error ?? 'playback failed') : undefined;
|
|
277
|
+
this.#emitTerminal(state, generation, error);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (!this.#disposed) {
|
|
282
|
+
// Native disposal can remove the session before this generation-bound
|
|
283
|
+
// wait settles. In that race, reading playbackState also rejects; the
|
|
284
|
+
// disposal lifecycle event is authoritative and no terminal event is
|
|
285
|
+
// needed for the removed session.
|
|
286
|
+
let state;
|
|
287
|
+
try {
|
|
288
|
+
state = this.state;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
state = undefined;
|
|
292
|
+
}
|
|
293
|
+
if (state === 'failed')
|
|
294
|
+
this.#emitTerminal('failed', generation, asError(error));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
this.#metadata.delete(generation);
|
|
299
|
+
this.#startedGenerations.delete(generation);
|
|
300
|
+
if (this.#latestGeneration === generation) {
|
|
301
|
+
this.#clearAbortBinding(this.#playController);
|
|
302
|
+
}
|
|
303
|
+
await this.#releaseInput(input, generation);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
#emitTerminal(state, generation, error, reason) {
|
|
307
|
+
if ((state !== 'failed' && !this.#startedGenerations.has(generation)) ||
|
|
308
|
+
this.#terminalGenerations.has(generation))
|
|
309
|
+
return;
|
|
310
|
+
this.#terminalGenerations.add(generation);
|
|
311
|
+
while (this.#terminalGenerations.size > 16) {
|
|
312
|
+
const oldest = this.#terminalGenerations.values().next().value;
|
|
313
|
+
if (oldest === undefined)
|
|
314
|
+
break;
|
|
315
|
+
this.#terminalGenerations.delete(oldest);
|
|
316
|
+
}
|
|
317
|
+
const base = this.#eventContext(generation);
|
|
318
|
+
if (state === 'completed') {
|
|
319
|
+
this.emit(PlaybackEvent.playbackCompleted, { ...base, state, position: this.position });
|
|
320
|
+
}
|
|
321
|
+
else if (state === 'stopped') {
|
|
322
|
+
this.emit(PlaybackEvent.playbackStopped, reason ? { ...base, state, reason } : { ...base, state });
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
this.emit(PlaybackEvent.playbackFailed, { ...base, state, error: asError(error) });
|
|
326
|
+
}
|
|
327
|
+
this.#metadata.delete(generation);
|
|
328
|
+
this.#clearAbortBinding(this.#playController);
|
|
329
|
+
}
|
|
330
|
+
#boundMetadata(generation) {
|
|
331
|
+
while (this.#metadata.size > 4) {
|
|
332
|
+
const oldest = this.#metadata.keys().next().value;
|
|
333
|
+
if (oldest === undefined || oldest === generation)
|
|
334
|
+
break;
|
|
335
|
+
this.#metadata.delete(oldest);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
#clearAbortBinding(controller) {
|
|
339
|
+
if (!controller || this.#playController !== controller)
|
|
340
|
+
return;
|
|
341
|
+
this.#signal?.removeEventListener('abort', this.#abortListener);
|
|
342
|
+
this.#playController = undefined;
|
|
343
|
+
this.#signal = undefined;
|
|
344
|
+
this.#abortListener = undefined;
|
|
345
|
+
}
|
|
346
|
+
async #releaseInput(expected, generation) {
|
|
347
|
+
if (generation !== undefined && this.#latestGeneration !== generation) {
|
|
348
|
+
await expected?.cancel();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const input = this.#input;
|
|
352
|
+
if (expected !== undefined && input !== expected) {
|
|
353
|
+
await expected.cancel();
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
this.#input = undefined;
|
|
357
|
+
if (input)
|
|
358
|
+
await input.cancel();
|
|
359
|
+
}
|
|
360
|
+
#assertActive() {
|
|
361
|
+
if (this.#disposed)
|
|
362
|
+
throw new Error('Playback session has been disposed');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function isPlaybackActive(state) {
|
|
366
|
+
return (state === 'starting' ||
|
|
367
|
+
state === 'playing' ||
|
|
368
|
+
state === 'pausing' ||
|
|
369
|
+
state === 'resuming' ||
|
|
370
|
+
state === 'paused' ||
|
|
371
|
+
state === 'stopping');
|
|
372
|
+
}
|
|
373
|
+
function numericGeneration(value) {
|
|
374
|
+
const generation = Number(value);
|
|
375
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
376
|
+
throw new RangeError('native playback generation exceeds the JavaScript safe integer range');
|
|
377
|
+
}
|
|
378
|
+
return generation;
|
|
379
|
+
}
|
|
380
|
+
function asError(error) {
|
|
381
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
382
|
+
}
|
|
383
|
+
function abortError(reason) {
|
|
384
|
+
const error = new Error(reason instanceof Error ? reason.message : 'playback was cancelled');
|
|
385
|
+
error.name = 'AbortError';
|
|
386
|
+
return error;
|
|
387
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
export interface RemediaOptions {
|
|
8
|
+
/** Send raw Gateway payloads through the application's existing Discord client or shard. */
|
|
9
|
+
send: DiscordGatewaySend;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One embedded Remedia context. Create instances with `await Remedia.create()` so native
|
|
13
|
+
* observability and optional asynchronous OpenTelemetry exporters initialize on NAPI-RS's runtime.
|
|
14
|
+
*/
|
|
15
|
+
export declare class Remedia extends RemediaEventEmitter<RemediaLifecycleEvents> {
|
|
16
|
+
#private;
|
|
17
|
+
private constructor();
|
|
18
|
+
/** Create an independent native context using REMEDIA_* and OTEL_* environment settings. */
|
|
19
|
+
static create(options?: RemediaOptions): Promise<Remedia>;
|
|
20
|
+
/** Number of diagnostic events dropped by either bounded event queue. */
|
|
21
|
+
get droppedDebugEvents(): number;
|
|
22
|
+
/** Register a structured native diagnostic listener. */
|
|
23
|
+
/** Create a local decoder session that discards its PCM output. */
|
|
24
|
+
createPlaybackSession<Metadata = unknown>(): PlaybackSession<Metadata>;
|
|
25
|
+
/** Create a Gateway-managed voice session. Call `connect()` to send OP 4 and join the channel. */
|
|
26
|
+
createDiscordSession<Metadata = unknown>(options: DiscordSessionOptions): DiscordSession<Metadata>;
|
|
27
|
+
/** Forward a raw Discord Gateway packet to the matching in-process voice session. */
|
|
28
|
+
receive(packet: unknown): void;
|
|
29
|
+
/** Stop all playbacks, disconnect transports, flush telemetry, and release native resources. */
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export type { DebugListener, DiagnosticEvent };
|
package/dist/remedia.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
/** Create an independent native context using REMEDIA_* and OTEL_* environment settings. */
|
|
28
|
+
static async create(options) {
|
|
29
|
+
return new Remedia(await NativeRemedia.create(), options);
|
|
30
|
+
}
|
|
31
|
+
/** Number of diagnostic events dropped by either bounded event queue. */
|
|
32
|
+
get droppedDebugEvents() {
|
|
33
|
+
return this.#native.droppedDebugEvents;
|
|
34
|
+
}
|
|
35
|
+
/** Register a structured native diagnostic listener. */
|
|
36
|
+
/** Create a local decoder session that discards its PCM output. */
|
|
37
|
+
createPlaybackSession() {
|
|
38
|
+
this.#assertOpen();
|
|
39
|
+
let playback;
|
|
40
|
+
playback = new PlaybackSession(this.#native, this.#native.createPlaybackSession(), () => {
|
|
41
|
+
this.#sessions.delete(playback);
|
|
42
|
+
});
|
|
43
|
+
forwardLifecycleEvents(playback, this);
|
|
44
|
+
this.#sessions.add(playback);
|
|
45
|
+
return playback;
|
|
46
|
+
}
|
|
47
|
+
/** Create a Gateway-managed voice session. Call `connect()` to send OP 4 and join the channel. */
|
|
48
|
+
createDiscordSession(options) {
|
|
49
|
+
this.#assertOpen();
|
|
50
|
+
if (!this.#gateway) {
|
|
51
|
+
throw new Error('Remedia.create() requires a Gateway send function for Discord sessions');
|
|
52
|
+
}
|
|
53
|
+
if (this.#discordSessions.has(options.guildId)) {
|
|
54
|
+
throw new Error(`a Discord voice session already exists for guild ${options.guildId}`);
|
|
55
|
+
}
|
|
56
|
+
let session;
|
|
57
|
+
session = new DiscordSession(this.#native, this.#gateway, options, () => {
|
|
58
|
+
this.#discordSessions.delete(session.guildId);
|
|
59
|
+
});
|
|
60
|
+
forwardLifecycleEvents(session, this);
|
|
61
|
+
this.#discordSessions.set(session.guildId, session);
|
|
62
|
+
return session;
|
|
63
|
+
}
|
|
64
|
+
/** Forward a raw Discord Gateway packet to the matching in-process voice session. */
|
|
65
|
+
receive(packet) {
|
|
66
|
+
if (this.#closed || !this.#gateway)
|
|
67
|
+
return;
|
|
68
|
+
const dispatch = this.#gateway.receive(packet);
|
|
69
|
+
if (!dispatch)
|
|
70
|
+
return;
|
|
71
|
+
this.#discordSessions
|
|
72
|
+
.get(dispatch.guildId)
|
|
73
|
+
?.receiveGatewayDispatch(dispatch.event, dispatch.data, dispatch.sequence, dispatch.epoch);
|
|
74
|
+
}
|
|
75
|
+
/** Stop all playbacks, disconnect transports, flush telemetry, and release native resources. */
|
|
76
|
+
async close() {
|
|
77
|
+
if (this.#closed)
|
|
78
|
+
return;
|
|
79
|
+
this.#closed = true;
|
|
80
|
+
const voiceCleanup = await Promise.allSettled([...this.#discordSessions.values()].map((session) => session.dispose()));
|
|
81
|
+
try {
|
|
82
|
+
await Promise.all([...this.#sessions].map((session) => session.dispose()));
|
|
83
|
+
await this.#native.close();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await Promise.all([...this.#sessions].map((session) => session.releaseInput()));
|
|
87
|
+
this.#sessions.clear();
|
|
88
|
+
this.#discordSessions.clear();
|
|
89
|
+
}
|
|
90
|
+
const failures = voiceCleanup.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
|
|
91
|
+
if (failures.length > 0) {
|
|
92
|
+
throw new AggregateError(failures, 'one or more Discord voice sessions failed to close');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
#assertOpen() {
|
|
96
|
+
if (this.#closed)
|
|
97
|
+
throw new Error('Remedia instance is closed');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function forwardLifecycleEvents(source, target) {
|
|
101
|
+
const sourceEvents = source;
|
|
102
|
+
const targetEvents = target;
|
|
103
|
+
const names = [
|
|
104
|
+
PlaybackEvent.playbackStarted,
|
|
105
|
+
PlaybackEvent.playbackCompleted,
|
|
106
|
+
PlaybackEvent.playbackStopped,
|
|
107
|
+
PlaybackEvent.playbackFailed,
|
|
108
|
+
PlaybackEvent.playbackPaused,
|
|
109
|
+
PlaybackEvent.playbackResumed,
|
|
110
|
+
PlaybackEvent.playbackSeeked,
|
|
111
|
+
PlaybackEvent.filterChanged,
|
|
112
|
+
PlaybackEvent.volumeChanged,
|
|
113
|
+
PlaybackEvent.sessionDisposed,
|
|
114
|
+
...(source instanceof DiscordSession
|
|
115
|
+
? [VoiceEvent.voiceStateChanged, VoiceEvent.voiceFailed]
|
|
116
|
+
: []),
|
|
117
|
+
];
|
|
118
|
+
const unsubs = [];
|
|
119
|
+
for (const name of names) {
|
|
120
|
+
const listener = (event) => {
|
|
121
|
+
targetEvents.emit(name, event);
|
|
122
|
+
};
|
|
123
|
+
sourceEvents.on(name, listener);
|
|
124
|
+
unsubs.push(() => sourceEvents.off(name, listener));
|
|
125
|
+
}
|
|
126
|
+
const detach = () => {
|
|
127
|
+
sourceEvents.off(PlaybackEvent.sessionDisposed, detach);
|
|
128
|
+
for (const unsubscribe of unsubs.splice(0))
|
|
129
|
+
unsubscribe();
|
|
130
|
+
};
|
|
131
|
+
sourceEvents.once(PlaybackEvent.sessionDisposed, detach);
|
|
132
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { NativeDiagnosticEvent, NativeStreamInfo } from '../native.js';
|
|
2
|
+
export type PlaybackState = 'idle' | 'starting' | 'playing' | 'pausing' | 'resuming' | 'paused' | 'stopping' | 'stopped' | 'completed' | 'failed';
|
|
3
|
+
export type VoiceConnectionState = 'connecting' | 'negotiatingDave' | 'connected' | 'reconnecting' | 'disconnecting' | 'disconnected' | 'failed';
|
|
4
|
+
export interface AudioStreamInfo {
|
|
5
|
+
streamIndex: number;
|
|
6
|
+
codec: string;
|
|
7
|
+
sampleRate: number | null;
|
|
8
|
+
channels: number | null;
|
|
9
|
+
channelLayout: string | null;
|
|
10
|
+
durationMs: number | null;
|
|
11
|
+
bitRate: number | null;
|
|
12
|
+
metadata: Readonly<Record<string, string>>;
|
|
13
|
+
}
|
|
14
|
+
export interface PlaybackPosition {
|
|
15
|
+
/** Original media timeline associated with the latest submitted PCM block. */
|
|
16
|
+
mediaMs: number | null;
|
|
17
|
+
/** `timestamp` when derived from source PTS, `estimated` when inferred, or null. */
|
|
18
|
+
mediaPositionAccuracy: 'timestamp' | 'estimated' | null;
|
|
19
|
+
submittedMs: number;
|
|
20
|
+
playedMs: number | null;
|
|
21
|
+
}
|
|
22
|
+
export type DiagnosticSeverity = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
|
23
|
+
export type DiagnosticFieldValue = {
|
|
24
|
+
kind: 'string' | 'debug';
|
|
25
|
+
value: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'integer';
|
|
28
|
+
value: string;
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'boolean';
|
|
31
|
+
value: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'number';
|
|
34
|
+
value: number;
|
|
35
|
+
};
|
|
36
|
+
export interface DiagnosticEvent {
|
|
37
|
+
timestamp: number;
|
|
38
|
+
severity: DiagnosticSeverity;
|
|
39
|
+
target: string;
|
|
40
|
+
event: string;
|
|
41
|
+
message: string | null;
|
|
42
|
+
playbackId: string | null;
|
|
43
|
+
voiceConnectionId: string | null;
|
|
44
|
+
fields: Readonly<Record<string, DiagnosticFieldValue>>;
|
|
45
|
+
}
|
|
46
|
+
export type DebugListener = (event: DiagnosticEvent) => void;
|
|
47
|
+
export declare function toStreamInfo(info: NativeStreamInfo): AudioStreamInfo;
|
|
48
|
+
export declare function toDiagnosticEvent(event: NativeDiagnosticEvent): DiagnosticEvent;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function toStreamInfo(info) {
|
|
2
|
+
return {
|
|
3
|
+
streamIndex: info.streamIndex,
|
|
4
|
+
codec: info.codec,
|
|
5
|
+
sampleRate: info.sampleRate ?? null,
|
|
6
|
+
channels: info.channels ?? null,
|
|
7
|
+
channelLayout: info.channelLayout ?? null,
|
|
8
|
+
durationMs: info.durationMs ?? null,
|
|
9
|
+
bitRate: info.bitRate ?? null,
|
|
10
|
+
metadata: info.metadata,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function toDiagnosticEvent(event) {
|
|
14
|
+
const severity = event.severity;
|
|
15
|
+
if (!isDiagnosticSeverity(severity)) {
|
|
16
|
+
throw new Error(`Unknown native diagnostic severity: ${severity}`);
|
|
17
|
+
}
|
|
18
|
+
const fields = {};
|
|
19
|
+
for (const [key, field] of Object.entries(event.fields)) {
|
|
20
|
+
if (field.kind === 'string' || field.kind === 'debug') {
|
|
21
|
+
if (field.stringValue === undefined) {
|
|
22
|
+
throw new Error(`Invalid native diagnostic field: ${key}`);
|
|
23
|
+
}
|
|
24
|
+
fields[key] = { kind: field.kind, value: field.stringValue };
|
|
25
|
+
}
|
|
26
|
+
else if (field.kind === 'integer') {
|
|
27
|
+
if (field.stringValue === undefined) {
|
|
28
|
+
throw new Error(`Invalid native diagnostic field: ${key}`);
|
|
29
|
+
}
|
|
30
|
+
fields[key] = { kind: 'integer', value: field.stringValue };
|
|
31
|
+
}
|
|
32
|
+
else if (field.kind === 'boolean') {
|
|
33
|
+
if (field.boolValue === undefined) {
|
|
34
|
+
throw new Error(`Invalid native diagnostic field: ${key}`);
|
|
35
|
+
}
|
|
36
|
+
fields[key] = { kind: 'boolean', value: field.boolValue };
|
|
37
|
+
}
|
|
38
|
+
else if (field.kind === 'number') {
|
|
39
|
+
if (field.numberValue === undefined) {
|
|
40
|
+
throw new Error(`Invalid native diagnostic field: ${key}`);
|
|
41
|
+
}
|
|
42
|
+
fields[key] = { kind: 'number', value: field.numberValue };
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
throw new Error(`Unknown native diagnostic field kind: ${field.kind}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
timestamp: event.timestamp,
|
|
50
|
+
severity,
|
|
51
|
+
target: event.target,
|
|
52
|
+
event: event.event,
|
|
53
|
+
message: event.message ?? null,
|
|
54
|
+
playbackId: event.playbackId ?? null,
|
|
55
|
+
voiceConnectionId: event.voiceConnectionId ?? null,
|
|
56
|
+
fields,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function isDiagnosticSeverity(value) {
|
|
60
|
+
return (value === 'trace' ||
|
|
61
|
+
value === 'debug' ||
|
|
62
|
+
value === 'info' ||
|
|
63
|
+
value === 'warn' ||
|
|
64
|
+
value === 'error');
|
|
65
|
+
}
|