@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.
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from 'node:events';
2
+ type EventMap<Events> = {
3
+ [Key in keyof Events]: unknown[];
4
+ };
5
+ type EventKey<Key, Events> = Events extends [never] ? string | symbol : Key | keyof Events;
6
+ type EventArgs<Key, Events> = Events extends [never] ? any[] : Key extends keyof Events ? Events[Key] : never;
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
+ */
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
+ */
19
+ emit<Key>(event: EventKey<Key, Events>, ...args: EventArgs<Key, Events>): boolean;
20
+ }
21
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1,28 @@
1
+ import { EventEmitter } from 'node:events';
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
+ */
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
+ */
14
+ emit(event, ...args) {
15
+ try {
16
+ return super.emit(event, ...args);
17
+ }
18
+ catch {
19
+ try {
20
+ process.emitWarning(`A listener for Remedia event ${String(event)} threw; the exception was isolated.`, { code: 'REMEDIA_LISTENER_ERROR' });
21
+ }
22
+ catch {
23
+ // A custom process warning handler must not re-enter native or lifecycle operations.
24
+ }
25
+ return false;
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,22 @@
1
+ /** Controls one playback session's trusted raw FFmpeg audio filtergraph. */
2
+ export declare class FilterControls {
3
+ #private;
4
+ /** Current configured raw filtergraph, or null when filtering is disabled. */
5
+ get graph(): string | null;
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
+ */
11
+ set(graph: string): Promise<void>;
12
+ /**
13
+ * Disable the raw filtergraph without changing master volume.
14
+ * @returns Resolves when filtering is disabled.
15
+ */
16
+ clear(): Promise<void>;
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
+ */
22
+ export declare function validateVolume(gain: number): void;
@@ -0,0 +1,52 @@
1
+ /** Controls one playback session's trusted raw FFmpeg audio filtergraph. */
2
+ export class FilterControls {
3
+ #read;
4
+ #replace;
5
+ #clear;
6
+ /**
7
+ * @internal Constructed by playback session wrappers.
8
+ * @param read - Callback returning the currently configured raw filtergraph.
9
+ * @param replace - Callback that validates and applies a replacement raw filtergraph.
10
+ * @param clear - Callback that disables the current raw filtergraph.
11
+ */
12
+ constructor(read, replace, clear) {
13
+ this.#read = read;
14
+ this.#replace = replace;
15
+ this.#clear = clear;
16
+ }
17
+ /** Current configured raw filtergraph, or null when filtering is disabled. */
18
+ get graph() {
19
+ return this.#read();
20
+ }
21
+ /**
22
+ * Validate and replace the graph at a safe PCM boundary.
23
+ * @param graph - Trusted, non-empty raw FFmpeg audio filtergraph; native validation errors reject the operation.
24
+ * @returns Resolves after configuration is applied; rejects for an empty or invalid filtergraph.
25
+ */
26
+ set(graph) {
27
+ if (typeof graph !== 'string')
28
+ throw new TypeError('filtergraph must be a string');
29
+ if (graph.trim().length === 0) {
30
+ throw new RangeError('filtergraph must not be empty or whitespace-only');
31
+ }
32
+ if (this.graph === graph)
33
+ return Promise.resolve();
34
+ return this.#replace(graph);
35
+ }
36
+ /**
37
+ * Disable the raw filtergraph without changing master volume.
38
+ * @returns Resolves when filtering is disabled.
39
+ */
40
+ clear() {
41
+ return this.#clear();
42
+ }
43
+ }
44
+ /**
45
+ * Validate a finite linear gain in the inclusive range 0 through 2; invalid values throw RangeError.
46
+ * @param gain - Finite linear master gain from 0 through 2; 1 leaves amplitude unchanged.
47
+ */
48
+ export function validateVolume(gain) {
49
+ if (!Number.isFinite(gain) || gain < 0 || gain > 2) {
50
+ throw new RangeError('volume must be a finite linear gain between 0.0 and 2.0');
51
+ }
52
+ }
@@ -0,0 +1,88 @@
1
+ /** Minimal Discord Gateway integration used by the Node.js voice adapter. */
2
+ export interface DiscordGatewayVoicePayload {
3
+ /** Discord Gateway operation code; voice state requests use 4. */
4
+ op: 4;
5
+ /** Gateway payload data; voice requests contain guild, channel, and self-state fields. */
6
+ d: {
7
+ /** Discord guild snowflake to route through the bot Gateway. */
8
+ guild_id: string;
9
+ /** Voice channel snowflake, or null to leave voice. */
10
+ channel_id: string | null;
11
+ /** Discord self-mute flag; independent of playback volume and pause. */
12
+ self_mute: boolean;
13
+ /** Discord self-deaf flag; independent of playback volume and pause. */
14
+ self_deaf: boolean;
15
+ };
16
+ }
17
+ /**
18
+ * Send a Gateway payload through the application's existing Discord client or shard.
19
+ * @param guildId - Discord guild snowflake used to route the operation.
20
+ * @param payload - Discord Gateway OP 4 payload to send through the guild shard.
21
+ */
22
+ export type DiscordGatewaySend = (guildId: string, payload: DiscordGatewayVoicePayload) => void | Promise<void>;
23
+ /** A raw Discord Gateway packet, such as the value emitted by a client's `raw` event. */
24
+ export interface DiscordGatewayPacket {
25
+ /** Gateway dispatch event name, when the packet represents a dispatch. */
26
+ t?: string;
27
+ /** Raw event-specific Gateway payload, validated only for supported voice dispatches. */
28
+ d?: unknown;
29
+ /** Gateway operation code, when supplied by the host application. */
30
+ op?: number;
31
+ /**
32
+ * Additional Gateway packet fields forwarded unchanged by the host application.
33
+ * @param key - Additional Gateway packet field name.
34
+ */
35
+ [key: string]: unknown;
36
+ }
37
+ /** Gateway dispatch names used to obtain voice transport credentials. */
38
+ export type DiscordVoiceGatewayEvent = 'VOICE_STATE_UPDATE' | 'VOICE_SERVER_UPDATE';
39
+ /** Validated guild-scoped voice dispatch with sequence and Gateway epoch correlation. */
40
+ export interface DiscordVoiceGatewayDispatch {
41
+ /** Gateway voice dispatch name. */
42
+ event: DiscordVoiceGatewayEvent;
43
+ /** Event-specific structured payload. */
44
+ data: Record<string, unknown>;
45
+ /** Discord guild snowflake as a decimal string; unique within the owning client. */
46
+ guildId: string;
47
+ /** Host Gateway dispatch sequence used to reject stale voice credentials. */
48
+ sequence?: number;
49
+ /** Gateway READY epoch used to distinguish independent dispatch sequence spaces. */
50
+ epoch: number;
51
+ }
52
+ /**
53
+ * Adapts the caller-owned Discord Gateway to Remedia's voice lifecycle. The adapter only sends
54
+ * Gateway OP 4 and parses the two voice dispatches; voice WebSocket, UDP, RTP, and DAVE remain in
55
+ * the native transport.
56
+ */
57
+ export declare class DiscordGatewayAdapter {
58
+ #private;
59
+ /**
60
+ * Construct this wrapper with its owning services. Use the owning engine or client factory for application code.
61
+ * @param send - Application callback routing Gateway OP 4 through the correct guild shard.
62
+ */
63
+ constructor(send: DiscordGatewaySend);
64
+ /** Most recently observed Gateway dispatch sequence, when supplied by the host client. */
65
+ get lastSequence(): number | undefined;
66
+ /** Changes when the host Gateway starts a new READY session and resets its sequence. */
67
+ get epoch(): number;
68
+ /**
69
+ * Request a voice channel join or leave through Gateway OP 4.
70
+ * @param guildId - Discord guild snowflake used to route the operation.
71
+ * @param channelId - Destination Discord voice channel snowflake; null requests a leave where supported.
72
+ * @param selfDeaf - Discord self-deaf flag; defaults to true.
73
+ * @param selfMute - Discord self-mute flag; defaults to false.
74
+ */
75
+ setVoiceChannel(guildId: string, channelId: string | null, selfDeaf?: boolean, selfMute?: boolean): void | Promise<void>;
76
+ /**
77
+ * Parse only relevant voice dispatches; malformed and unrelated packets are ignored.
78
+ * @param packet - Raw packet from this bot's Gateway dispatch stream.
79
+ * @returns Validated voice dispatch, or undefined for an unrelated or malformed packet.
80
+ */
81
+ receive(packet: unknown): DiscordVoiceGatewayDispatch | undefined;
82
+ }
83
+ /**
84
+ * Check whether a string contains a positive decimal Discord snowflake that fits u64.
85
+ * @param value - Discord snowflake string to validate.
86
+ * @returns True for a valid positive u64 snowflake string.
87
+ */
88
+ export declare function isSnowflake(value: string): boolean;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Adapts the caller-owned Discord Gateway to Remedia's voice lifecycle. The adapter only sends
3
+ * Gateway OP 4 and parses the two voice dispatches; voice WebSocket, UDP, RTP, and DAVE remain in
4
+ * the native transport.
5
+ */
6
+ export class DiscordGatewayAdapter {
7
+ #send;
8
+ #lastSequence;
9
+ #epoch = 0;
10
+ /**
11
+ * Construct this wrapper with its owning services. Use the owning engine or client factory for application code.
12
+ * @param send - Application callback routing Gateway OP 4 through the correct guild shard.
13
+ */
14
+ constructor(send) {
15
+ this.#send = send;
16
+ }
17
+ /** Most recently observed Gateway dispatch sequence, when supplied by the host client. */
18
+ get lastSequence() {
19
+ return this.#lastSequence;
20
+ }
21
+ /** Changes when the host Gateway starts a new READY session and resets its sequence. */
22
+ get epoch() {
23
+ return this.#epoch;
24
+ }
25
+ /**
26
+ * Request a voice channel join or leave through Gateway OP 4.
27
+ * @param guildId - Discord guild snowflake used to route the operation.
28
+ * @param channelId - Destination Discord voice channel snowflake; null requests a leave where supported.
29
+ * @param selfDeaf - Discord self-deaf flag; defaults to true.
30
+ * @param selfMute - Discord self-mute flag; defaults to false.
31
+ */
32
+ setVoiceChannel(guildId, channelId, selfDeaf = true, selfMute = false) {
33
+ return this.#send(guildId, {
34
+ op: 4,
35
+ d: {
36
+ guild_id: guildId,
37
+ channel_id: channelId,
38
+ self_mute: selfMute,
39
+ self_deaf: selfDeaf,
40
+ },
41
+ });
42
+ }
43
+ /**
44
+ * Parse only relevant voice dispatches; malformed and unrelated packets are ignored.
45
+ * @param packet - Raw packet from this bot's Gateway dispatch stream.
46
+ * @returns Validated voice dispatch, or undefined for an unrelated or malformed packet.
47
+ */
48
+ receive(packet) {
49
+ if (!isRecord(packet) || typeof packet.t !== 'string')
50
+ return undefined;
51
+ const sequence = typeof packet.s === 'number' && Number.isSafeInteger(packet.s) && packet.s >= 0
52
+ ? packet.s
53
+ : undefined;
54
+ if (packet.t === 'READY') {
55
+ this.#epoch += 1;
56
+ this.#lastSequence = sequence;
57
+ return undefined;
58
+ }
59
+ if (sequence !== undefined) {
60
+ if (this.#lastSequence !== undefined && sequence <= this.#lastSequence)
61
+ return undefined;
62
+ this.#lastSequence = sequence;
63
+ }
64
+ if (!isRecord(packet.d))
65
+ return undefined;
66
+ if (packet.t !== 'VOICE_STATE_UPDATE' && packet.t !== 'VOICE_SERVER_UPDATE')
67
+ return undefined;
68
+ const guildId = packet.d.guild_id;
69
+ if (typeof guildId !== 'string' || !isSnowflake(guildId))
70
+ return undefined;
71
+ const dispatch = {
72
+ event: packet.t,
73
+ data: packet.d,
74
+ guildId,
75
+ epoch: this.#epoch,
76
+ };
77
+ if (sequence !== undefined)
78
+ dispatch.sequence = sequence;
79
+ return dispatch;
80
+ }
81
+ }
82
+ /**
83
+ * Check whether a string contains a positive decimal Discord snowflake that fits u64.
84
+ * @param value - Discord snowflake string to validate.
85
+ * @returns True for a valid positive u64 snowflake string.
86
+ */
87
+ export function isSnowflake(value) {
88
+ if (!/^\d{1,20}$/.test(value))
89
+ return false;
90
+ try {
91
+ const id = BigInt(value);
92
+ return id > 0n && id <= 18446744073709551615n;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ function isRecord(value) {
99
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
100
+ }
@@ -0,0 +1,9 @@
1
+ export { DiscordSession } from './discord.js';
2
+ export type { DiscordConnectOptions, DiscordSessionOptions } from './discord.js';
3
+ export type { DiscordGatewayPacket, DiscordGatewaySend, DiscordGatewayVoicePayload, } from './gateway.js';
4
+ export { PlaybackSession } from './playback.js';
5
+ export { PlaybackHooks, PlaybackSessionEvents, RemediaEvents, VoiceSessionEvents, } from './lifecycle.js';
6
+ export { Remedia, type RemediaOptions } from './remedia.js';
7
+ export type { MediaSource } from './input.js';
8
+ export type { AudioStreamInfo, DebugListener, DiagnosticEvent, DiagnosticFieldValue, DiagnosticSeverity, PlaybackPosition, PlaybackState, VoiceConnectionState, } from './types.js';
9
+ export type { BeforePlayContext, BeforePlayHook, BeforePlayResult, LifecycleVoiceState, PlayOptions, PlaybackEventIdentity, PlaybackLifecycleEvents, PlaybackSessionEventMap, RemediaLifecycleEvents, SessionLifecycleEvents, VoiceEventIdentity, VoiceLifecycleEvents, } from './lifecycle.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { DiscordSession } from './discord.js';
2
+ export { PlaybackSession } from './playback.js';
3
+ export { PlaybackHooks, PlaybackSessionEvents, RemediaEvents, VoiceSessionEvents, } from './lifecycle.js';
4
+ export { Remedia } from './remedia.js';
@@ -0,0 +1,46 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import type { Readable } from 'node:stream';
3
+ import type { NativeMediaSource, NativeRemedia } from '../native.js';
4
+ /**
5
+ * Encoded media input. Files and HTTP(S) URLs work in both modes; owned bytes and caller-owned binary Readables require embedded playback.
6
+ */
7
+ export type MediaSource = {
8
+ /** Local media file path; remote mode resolves it on the server and enforces configured file roots. */
9
+ file: string;
10
+ } | {
11
+ /** HTTP(S) media URL opened by the selected engine; remote nodes enforce server URL policy. */
12
+ url: string;
13
+ } | {
14
+ /** Owned encoded media bytes, supported by embedded nodes only. */
15
+ buffer: Buffer | Uint8Array;
16
+ } | {
17
+ /**
18
+ * Caller-owned binary Readable, consumed with bounded backpressure in embedded mode; Remedia does not destroy it.
19
+ */
20
+ stream: Readable;
21
+ };
22
+ /** Internal bounded byte-stream input and cancellation handle owned by a playback wrapper. */
23
+ export interface ReadableInputHandle {
24
+ /** Native byte-stream source fed by the bounded input pump. */
25
+ readonly source: NativeMediaSource;
26
+ /**
27
+ * Cancel the native byte queue and detach input-pump listeners without destroying the caller-owned stream.
28
+ * @returns Resolves after the input pump has released its resources.
29
+ */
30
+ cancel(): Promise<void>;
31
+ }
32
+ /**
33
+ * Convert a file, HTTP(S) URL, or owned byte input to the native media representation.
34
+ * @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
35
+ * @returns A native source; bytes are copied into an owned Buffer.
36
+ */
37
+ export declare function toNativeSource(source: Exclude<MediaSource, {
38
+ stream: Readable;
39
+ }>): NativeMediaSource;
40
+ /**
41
+ * Start a bounded native input pump for a caller-owned binary Readable.
42
+ * @param native - Owning native engine instance; its resources must outlive this wrapper.
43
+ * @param stream - Caller-owned binary Readable to consume with bounded native backpressure.
44
+ * @returns Native stream source and an idempotent cancellation handle.
45
+ */
46
+ export declare function createReadableInput(native: NativeRemedia, stream: Readable): ReadableInputHandle;
package/dist/input.js ADDED
@@ -0,0 +1,135 @@
1
+ import { Buffer } from 'node:buffer';
2
+ /**
3
+ * Convert a file, HTTP(S) URL, or owned byte input to the native media representation.
4
+ * @param source - Encoded media input; Buffer and caller-owned binary Readable inputs require embedded mode.
5
+ * @returns A native source; bytes are copied into an owned Buffer.
6
+ */
7
+ export function toNativeSource(source) {
8
+ if ('file' in source)
9
+ return { kind: 'file', value: source.file };
10
+ if ('url' in source)
11
+ return { kind: 'url', value: source.url };
12
+ return { kind: 'buffer', bytes: Buffer.from(source.buffer) };
13
+ }
14
+ /**
15
+ * Start a bounded native input pump for a caller-owned binary Readable.
16
+ * @param native - Owning native engine instance; its resources must outlive this wrapper.
17
+ * @param stream - Caller-owned binary Readable to consume with bounded native backpressure.
18
+ * @returns Native stream source and an idempotent cancellation handle.
19
+ */
20
+ export function createReadableInput(native, stream) {
21
+ const streamId = native.createByteStream();
22
+ const cancellation = new AbortController();
23
+ const pump = pumpReadable(native, streamId, stream, cancellation.signal);
24
+ let cleanup;
25
+ return {
26
+ source: { kind: 'stream', value: streamId },
27
+ cancel() {
28
+ cleanup ??= (async () => {
29
+ cancellation.abort();
30
+ native.cancelByteStream(streamId);
31
+ await pump;
32
+ })();
33
+ return cleanup;
34
+ },
35
+ };
36
+ }
37
+ async function pumpReadable(native, streamId, stream, signal) {
38
+ let sourceError;
39
+ let failureSent = false;
40
+ const failSource = (error) => {
41
+ sourceError ??= error;
42
+ if (failureSent || signal.aborted)
43
+ return;
44
+ failureSent = true;
45
+ void native.failByteStream(streamId, sourceError.message).catch(() => { });
46
+ };
47
+ const onError = (error) => failSource(error);
48
+ stream.pause();
49
+ stream.on('error', onError);
50
+ try {
51
+ while (!signal.aborted) {
52
+ if (sourceError)
53
+ throw sourceError;
54
+ if (stream.errored)
55
+ throw stream.errored;
56
+ const chunk = stream.read();
57
+ if (chunk === null) {
58
+ const wake = await waitForReadable(stream, signal, () => sourceError);
59
+ if (wake === 'abort')
60
+ return;
61
+ if (wake === 'end')
62
+ break;
63
+ if (wake === 'close')
64
+ throw sourceError ?? new Error('Readable closed before ending');
65
+ continue;
66
+ }
67
+ if (!(chunk instanceof Uint8Array)) {
68
+ throw new TypeError('Readable media sources must produce Buffer or Uint8Array chunks');
69
+ }
70
+ // This copy gives native code an independent snapshot of the chunk. The
71
+ // byte queue then takes ownership and waits for decoder backpressure.
72
+ await native.writeByteStream(streamId, Buffer.from(chunk));
73
+ }
74
+ if (!signal.aborted && !sourceError)
75
+ await native.finishByteStream(streamId);
76
+ }
77
+ catch (error) {
78
+ if (!signal.aborted) {
79
+ failSource(error instanceof Error ? error : new Error(String(error)));
80
+ }
81
+ }
82
+ finally {
83
+ stream.pause();
84
+ stream.off('error', onError);
85
+ }
86
+ }
87
+ function waitForReadable(stream, signal, getError) {
88
+ return new Promise((resolve, reject) => {
89
+ let settled = false;
90
+ const cleanup = () => {
91
+ stream.off('readable', onReadable);
92
+ stream.off('end', onEnd);
93
+ stream.off('close', onClose);
94
+ stream.off('error', onError);
95
+ signal.removeEventListener('abort', onAbort);
96
+ };
97
+ const finish = (wake) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ cleanup();
102
+ resolve(wake);
103
+ };
104
+ const fail = (error) => {
105
+ if (settled)
106
+ return;
107
+ settled = true;
108
+ cleanup();
109
+ reject(error);
110
+ };
111
+ const onReadable = () => finish('readable');
112
+ const onEnd = () => finish('end');
113
+ const onClose = () => finish('close');
114
+ const onError = (error) => fail(error);
115
+ const onAbort = () => finish('abort');
116
+ stream.once('readable', onReadable);
117
+ stream.once('end', onEnd);
118
+ stream.once('close', onClose);
119
+ stream.once('error', onError);
120
+ signal.addEventListener('abort', onAbort, { once: true });
121
+ // An event can arrive between read() returning null and listener setup.
122
+ if (signal.aborted)
123
+ onAbort();
124
+ else if (getError())
125
+ onError(getError());
126
+ else if (stream.errored)
127
+ onError(stream.errored);
128
+ else if (stream.readableEnded)
129
+ onEnd();
130
+ else if (stream.destroyed)
131
+ onClose();
132
+ else if (stream.readableLength > 0)
133
+ onReadable();
134
+ });
135
+ }