@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.
@@ -0,0 +1,11 @@
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
+ /** EventEmitter-compatible base that isolates synchronous listener failures from engine work. */
8
+ export declare class RemediaEventEmitter<Events extends EventMap<Events>> extends EventEmitter<Events> {
9
+ emit<Key>(event: EventKey<Key, Events>, ...args: EventArgs<Key, Events>): boolean;
10
+ }
11
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1,18 @@
1
+ import { EventEmitter } from 'node:events';
2
+ /** EventEmitter-compatible base that isolates synchronous listener failures from engine work. */
3
+ export class RemediaEventEmitter extends EventEmitter {
4
+ emit(event, ...args) {
5
+ try {
6
+ return super.emit(event, ...args);
7
+ }
8
+ catch {
9
+ try {
10
+ process.emitWarning(`A listener for Remedia event ${String(event)} threw; the exception was isolated.`, { code: 'REMEDIA_LISTENER_ERROR' });
11
+ }
12
+ catch {
13
+ // A custom process warning handler must not re-enter native or lifecycle operations.
14
+ }
15
+ return false;
16
+ }
17
+ }
18
+ }
@@ -0,0 +1,11 @@
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
+ /** Validate and replace the graph at a safe PCM boundary. */
7
+ set(graph: string): Promise<void>;
8
+ /** Disable the raw filtergraph without changing master volume. */
9
+ clear(): Promise<void>;
10
+ }
11
+ export declare function validateVolume(gain: number): void;
@@ -0,0 +1,36 @@
1
+ /** Controls one playback session's trusted raw FFmpeg audio filtergraph. */
2
+ export class FilterControls {
3
+ #read;
4
+ #replace;
5
+ #clear;
6
+ /** @internal Constructed by playback session wrappers. */
7
+ constructor(read, replace, clear) {
8
+ this.#read = read;
9
+ this.#replace = replace;
10
+ this.#clear = clear;
11
+ }
12
+ /** Current configured raw filtergraph, or null when filtering is disabled. */
13
+ get graph() {
14
+ return this.#read();
15
+ }
16
+ /** Validate and replace the graph at a safe PCM boundary. */
17
+ set(graph) {
18
+ if (typeof graph !== 'string')
19
+ throw new TypeError('filtergraph must be a string');
20
+ if (graph.trim().length === 0) {
21
+ throw new RangeError('filtergraph must not be empty or whitespace-only');
22
+ }
23
+ if (this.graph === graph)
24
+ return Promise.resolve();
25
+ return this.#replace(graph);
26
+ }
27
+ /** Disable the raw filtergraph without changing master volume. */
28
+ clear() {
29
+ return this.#clear();
30
+ }
31
+ }
32
+ export function validateVolume(gain) {
33
+ if (!Number.isFinite(gain) || gain < 0 || gain > 2) {
34
+ throw new RangeError('volume must be a finite linear gain between 0.0 and 2.0');
35
+ }
36
+ }
@@ -0,0 +1,45 @@
1
+ /** Minimal Discord Gateway integration used by the Node.js voice adapter. */
2
+ export interface DiscordGatewayVoicePayload {
3
+ op: 4;
4
+ d: {
5
+ guild_id: string;
6
+ channel_id: string | null;
7
+ self_mute: false;
8
+ self_deaf: false;
9
+ };
10
+ }
11
+ /** Send a Gateway payload through the application's existing Discord client or shard. */
12
+ export type DiscordGatewaySend = (guildId: string, payload: DiscordGatewayVoicePayload) => void | Promise<void>;
13
+ /** A raw Discord Gateway packet, such as the value emitted by a client's `raw` event. */
14
+ export interface DiscordGatewayPacket {
15
+ t?: string;
16
+ d?: unknown;
17
+ op?: number;
18
+ [key: string]: unknown;
19
+ }
20
+ export type DiscordVoiceGatewayEvent = 'VOICE_STATE_UPDATE' | 'VOICE_SERVER_UPDATE';
21
+ export interface DiscordVoiceGatewayDispatch {
22
+ event: DiscordVoiceGatewayEvent;
23
+ data: Record<string, unknown>;
24
+ guildId: string;
25
+ sequence?: number;
26
+ epoch: number;
27
+ }
28
+ /**
29
+ * Adapts the caller-owned Discord Gateway to Remedia's voice lifecycle. The adapter only sends
30
+ * Gateway OP 4 and parses the two voice dispatches; voice WebSocket, UDP, RTP, and DAVE remain in
31
+ * the native transport.
32
+ */
33
+ export declare class DiscordGatewayAdapter {
34
+ #private;
35
+ constructor(send: DiscordGatewaySend);
36
+ /** Most recently observed Gateway dispatch sequence, when supplied by the host client. */
37
+ get lastSequence(): number | undefined;
38
+ /** Changes when the host Gateway starts a new READY session and resets its sequence. */
39
+ get epoch(): number;
40
+ /** Request a voice channel join or leave through Gateway OP 4. */
41
+ setVoiceChannel(guildId: string, channelId: string | null): void | Promise<void>;
42
+ /** Parse only relevant voice dispatches; malformed and unrelated packets are ignored. */
43
+ receive(packet: unknown): DiscordVoiceGatewayDispatch | undefined;
44
+ }
45
+ export declare function isSnowflake(value: string): boolean;
@@ -0,0 +1,81 @@
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
+ constructor(send) {
11
+ this.#send = send;
12
+ }
13
+ /** Most recently observed Gateway dispatch sequence, when supplied by the host client. */
14
+ get lastSequence() {
15
+ return this.#lastSequence;
16
+ }
17
+ /** Changes when the host Gateway starts a new READY session and resets its sequence. */
18
+ get epoch() {
19
+ return this.#epoch;
20
+ }
21
+ /** Request a voice channel join or leave through Gateway OP 4. */
22
+ setVoiceChannel(guildId, channelId) {
23
+ return this.#send(guildId, {
24
+ op: 4,
25
+ d: {
26
+ guild_id: guildId,
27
+ channel_id: channelId,
28
+ self_mute: false,
29
+ self_deaf: false,
30
+ },
31
+ });
32
+ }
33
+ /** Parse only relevant voice dispatches; malformed and unrelated packets are ignored. */
34
+ receive(packet) {
35
+ if (!isRecord(packet) || typeof packet.t !== 'string')
36
+ return undefined;
37
+ const sequence = typeof packet.s === 'number' && Number.isSafeInteger(packet.s) && packet.s >= 0
38
+ ? packet.s
39
+ : undefined;
40
+ if (packet.t === 'READY') {
41
+ this.#epoch += 1;
42
+ this.#lastSequence = sequence;
43
+ return undefined;
44
+ }
45
+ if (sequence !== undefined) {
46
+ if (this.#lastSequence !== undefined && sequence <= this.#lastSequence)
47
+ return undefined;
48
+ this.#lastSequence = sequence;
49
+ }
50
+ if (!isRecord(packet.d))
51
+ return undefined;
52
+ if (packet.t !== 'VOICE_STATE_UPDATE' && packet.t !== 'VOICE_SERVER_UPDATE')
53
+ return undefined;
54
+ const guildId = packet.d.guild_id;
55
+ if (typeof guildId !== 'string' || !isSnowflake(guildId))
56
+ return undefined;
57
+ const dispatch = {
58
+ event: packet.t,
59
+ data: packet.d,
60
+ guildId,
61
+ epoch: this.#epoch,
62
+ };
63
+ if (sequence !== undefined)
64
+ dispatch.sequence = sequence;
65
+ return dispatch;
66
+ }
67
+ }
68
+ export function isSnowflake(value) {
69
+ if (!/^\d{1,20}$/.test(value))
70
+ return false;
71
+ try {
72
+ const id = BigInt(value);
73
+ return id > 0n && id <= 18446744073709551615n;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function isRecord(value) {
80
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
81
+ }
@@ -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,20 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import type { Readable } from 'node:stream';
3
+ import type { NativeMediaSource, NativeRemedia } from '../native.js';
4
+ export type MediaSource = {
5
+ file: string;
6
+ } | {
7
+ url: string;
8
+ } | {
9
+ buffer: Buffer | Uint8Array;
10
+ } | {
11
+ stream: Readable;
12
+ };
13
+ export interface ReadableInputHandle {
14
+ readonly source: NativeMediaSource;
15
+ cancel(): Promise<void>;
16
+ }
17
+ export declare function toNativeSource(source: Exclude<MediaSource, {
18
+ stream: Readable;
19
+ }>): NativeMediaSource;
20
+ export declare function createReadableInput(native: NativeRemedia, stream: Readable): ReadableInputHandle;
package/dist/input.js ADDED
@@ -0,0 +1,124 @@
1
+ import { Buffer } from 'node:buffer';
2
+ export function toNativeSource(source) {
3
+ if ('file' in source)
4
+ return { kind: 'file', value: source.file };
5
+ if ('url' in source)
6
+ return { kind: 'url', value: source.url };
7
+ return { kind: 'buffer', bytes: Buffer.from(source.buffer) };
8
+ }
9
+ export function createReadableInput(native, stream) {
10
+ const streamId = native.createByteStream();
11
+ const cancellation = new AbortController();
12
+ const pump = pumpReadable(native, streamId, stream, cancellation.signal);
13
+ let cleanup;
14
+ return {
15
+ source: { kind: 'stream', value: streamId },
16
+ cancel() {
17
+ cleanup ??= (async () => {
18
+ cancellation.abort();
19
+ native.cancelByteStream(streamId);
20
+ await pump;
21
+ })();
22
+ return cleanup;
23
+ },
24
+ };
25
+ }
26
+ async function pumpReadable(native, streamId, stream, signal) {
27
+ let sourceError;
28
+ let failureSent = false;
29
+ const failSource = (error) => {
30
+ sourceError ??= error;
31
+ if (failureSent || signal.aborted)
32
+ return;
33
+ failureSent = true;
34
+ void native.failByteStream(streamId, sourceError.message).catch(() => { });
35
+ };
36
+ const onError = (error) => failSource(error);
37
+ stream.pause();
38
+ stream.on('error', onError);
39
+ try {
40
+ while (!signal.aborted) {
41
+ if (sourceError)
42
+ throw sourceError;
43
+ if (stream.errored)
44
+ throw stream.errored;
45
+ const chunk = stream.read();
46
+ if (chunk === null) {
47
+ const wake = await waitForReadable(stream, signal, () => sourceError);
48
+ if (wake === 'abort')
49
+ return;
50
+ if (wake === 'end')
51
+ break;
52
+ if (wake === 'close')
53
+ throw sourceError ?? new Error('Readable closed before ending');
54
+ continue;
55
+ }
56
+ if (!(chunk instanceof Uint8Array)) {
57
+ throw new TypeError('Readable media sources must produce Buffer or Uint8Array chunks');
58
+ }
59
+ // This copy gives native code an independent snapshot of the chunk. The
60
+ // byte queue then takes ownership and waits for decoder backpressure.
61
+ await native.writeByteStream(streamId, Buffer.from(chunk));
62
+ }
63
+ if (!signal.aborted && !sourceError)
64
+ await native.finishByteStream(streamId);
65
+ }
66
+ catch (error) {
67
+ if (!signal.aborted) {
68
+ failSource(error instanceof Error ? error : new Error(String(error)));
69
+ }
70
+ }
71
+ finally {
72
+ stream.pause();
73
+ stream.off('error', onError);
74
+ }
75
+ }
76
+ function waitForReadable(stream, signal, getError) {
77
+ return new Promise((resolve, reject) => {
78
+ let settled = false;
79
+ const cleanup = () => {
80
+ stream.off('readable', onReadable);
81
+ stream.off('end', onEnd);
82
+ stream.off('close', onClose);
83
+ stream.off('error', onError);
84
+ signal.removeEventListener('abort', onAbort);
85
+ };
86
+ const finish = (wake) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ cleanup();
91
+ resolve(wake);
92
+ };
93
+ const fail = (error) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ cleanup();
98
+ reject(error);
99
+ };
100
+ const onReadable = () => finish('readable');
101
+ const onEnd = () => finish('end');
102
+ const onClose = () => finish('close');
103
+ const onError = (error) => fail(error);
104
+ const onAbort = () => finish('abort');
105
+ stream.once('readable', onReadable);
106
+ stream.once('end', onEnd);
107
+ stream.once('close', onClose);
108
+ stream.once('error', onError);
109
+ signal.addEventListener('abort', onAbort, { once: true });
110
+ // An event can arrive between read() returning null and listener setup.
111
+ if (signal.aborted)
112
+ onAbort();
113
+ else if (getError())
114
+ onError(getError());
115
+ else if (stream.errored)
116
+ onError(stream.errored);
117
+ else if (stream.readableEnded)
118
+ onEnd();
119
+ else if (stream.destroyed)
120
+ onClose();
121
+ else if (stream.readableLength > 0)
122
+ onReadable();
123
+ });
124
+ }
@@ -0,0 +1,166 @@
1
+ import type { MediaSource } from './input.js';
2
+ import type { AudioStreamInfo, PlaybackPosition, PlaybackState } from './types.js';
3
+ /** Stable names for playback/session event subscriptions and emission. */
4
+ export declare const PlaybackSessionEvents: {
5
+ readonly playbackStarted: "playbackStarted";
6
+ readonly playbackCompleted: "playbackCompleted";
7
+ readonly playbackStopped: "playbackStopped";
8
+ readonly playbackFailed: "playbackFailed";
9
+ readonly playbackPaused: "playbackPaused";
10
+ readonly playbackResumed: "playbackResumed";
11
+ readonly playbackSeeked: "playbackSeeked";
12
+ readonly filterChanged: "filterChanged";
13
+ readonly volumeChanged: "volumeChanged";
14
+ readonly sessionDisposed: "sessionDisposed";
15
+ };
16
+ export type PlaybackSessionEvents = (typeof PlaybackSessionEvents)[keyof typeof PlaybackSessionEvents];
17
+ export declare const VoiceSessionEvents: {
18
+ readonly voiceStateChanged: "voiceStateChanged";
19
+ readonly voiceFailed: "voiceFailed";
20
+ };
21
+ export type VoiceSessionEvents = (typeof VoiceSessionEvents)[keyof typeof VoiceSessionEvents];
22
+ export declare const RemediaEvents: {
23
+ readonly debug: "debug";
24
+ readonly voiceStateChanged: "voiceStateChanged";
25
+ readonly voiceFailed: "voiceFailed";
26
+ readonly playbackStarted: "playbackStarted";
27
+ readonly playbackCompleted: "playbackCompleted";
28
+ readonly playbackStopped: "playbackStopped";
29
+ readonly playbackFailed: "playbackFailed";
30
+ readonly playbackPaused: "playbackPaused";
31
+ readonly playbackResumed: "playbackResumed";
32
+ readonly playbackSeeked: "playbackSeeked";
33
+ readonly filterChanged: "filterChanged";
34
+ readonly volumeChanged: "volumeChanged";
35
+ readonly sessionDisposed: "sessionDisposed";
36
+ };
37
+ export type RemediaEvents = (typeof RemediaEvents)[keyof typeof RemediaEvents];
38
+ export interface PlaybackEventIdentity<Metadata = unknown> {
39
+ sessionId: string;
40
+ guildId?: string;
41
+ nodeId: string | null;
42
+ generation: number;
43
+ customMetadata?: Metadata;
44
+ /** True only when a terminal event was reconstructed from an authoritative remote snapshot. */
45
+ reconstructed?: boolean;
46
+ }
47
+ export type PlaybackLifecycleEvents<Metadata = unknown> = {
48
+ [PlaybackSessionEvents.playbackStarted]: [
49
+ PlaybackEventIdentity<Metadata> & {
50
+ state: 'playing';
51
+ info: AudioStreamInfo;
52
+ }
53
+ ];
54
+ [PlaybackSessionEvents.playbackCompleted]: [
55
+ PlaybackEventIdentity<Metadata> & {
56
+ state: 'completed';
57
+ position: PlaybackPosition;
58
+ }
59
+ ];
60
+ [PlaybackSessionEvents.playbackStopped]: [
61
+ PlaybackEventIdentity<Metadata> & {
62
+ state: 'stopped';
63
+ reason?: string;
64
+ }
65
+ ];
66
+ [PlaybackSessionEvents.playbackFailed]: [
67
+ PlaybackEventIdentity<Metadata> & {
68
+ state: 'failed';
69
+ error: Error;
70
+ }
71
+ ];
72
+ [PlaybackSessionEvents.playbackPaused]: [
73
+ PlaybackEventIdentity<Metadata> & {
74
+ previousState: PlaybackState;
75
+ state: 'paused';
76
+ }
77
+ ];
78
+ [PlaybackSessionEvents.playbackResumed]: [
79
+ PlaybackEventIdentity<Metadata> & {
80
+ previousState: PlaybackState;
81
+ state: 'playing';
82
+ }
83
+ ];
84
+ [PlaybackSessionEvents.playbackSeeked]: [
85
+ PlaybackEventIdentity<Metadata> & {
86
+ positionMs: number;
87
+ previousPositionMs: number | null;
88
+ }
89
+ ];
90
+ [PlaybackSessionEvents.filterChanged]: [
91
+ PlaybackEventIdentity<Metadata> & {
92
+ previousGraph: string | null;
93
+ graph: string | null;
94
+ }
95
+ ];
96
+ [PlaybackSessionEvents.volumeChanged]: [
97
+ PlaybackEventIdentity<Metadata> & {
98
+ previousVolume: number;
99
+ volume: number;
100
+ }
101
+ ];
102
+ };
103
+ export interface VoiceEventIdentity {
104
+ sessionId: string;
105
+ guildId: string;
106
+ nodeId: string | null;
107
+ }
108
+ export type LifecycleVoiceState = 'connecting' | 'negotiating_dave' | 'connected' | 'reconnecting' | 'disconnecting' | 'disconnected' | 'failed';
109
+ export type VoiceLifecycleEvents = {
110
+ [VoiceSessionEvents.voiceStateChanged]: [
111
+ VoiceEventIdentity & {
112
+ previousState: LifecycleVoiceState;
113
+ state: LifecycleVoiceState;
114
+ }
115
+ ];
116
+ [VoiceSessionEvents.voiceFailed]: [VoiceEventIdentity & {
117
+ state: 'failed';
118
+ error: Error;
119
+ }];
120
+ };
121
+ export type SessionLifecycleEvents<Metadata = unknown> = PlaybackLifecycleEvents<Metadata> & VoiceLifecycleEvents & {
122
+ [PlaybackSessionEvents.sessionDisposed]: [
123
+ {
124
+ sessionId: string;
125
+ guildId?: string;
126
+ nodeId: string | null;
127
+ generation: number;
128
+ }
129
+ ];
130
+ };
131
+ export type PlaybackSessionEventMap<Metadata = unknown> = PlaybackLifecycleEvents<Metadata> & {
132
+ [PlaybackSessionEvents.sessionDisposed]: [
133
+ {
134
+ sessionId: string;
135
+ guildId?: string;
136
+ nodeId: string | null;
137
+ generation: number;
138
+ }
139
+ ];
140
+ };
141
+ export interface RemediaLifecycleEvents extends SessionLifecycleEvents<unknown> {
142
+ [RemediaEvents.debug]: [import('./types.js').DiagnosticEvent];
143
+ }
144
+ export interface PlayOptions<Metadata = unknown> {
145
+ customMetadata?: Metadata;
146
+ /** Aborting before startup cancels hooks; aborting after startup stops this playback. */
147
+ signal?: AbortSignal;
148
+ }
149
+ export interface BeforePlayContext<Metadata = unknown> {
150
+ readonly source: MediaSource;
151
+ readonly customMetadata: Metadata | undefined;
152
+ /** Aborted when the operation signal fires or the session is disposed. */
153
+ readonly signal: AbortSignal;
154
+ }
155
+ export type BeforePlayResult = void | {
156
+ cancel: true;
157
+ reason?: string;
158
+ };
159
+ export type BeforePlayHook<Metadata = unknown> = (context: BeforePlayContext<Metadata>) => BeforePlayResult | Promise<BeforePlayResult>;
160
+ /** Ordered, awaited local middleware. It is not a server authorization mechanism. */
161
+ export declare class PlaybackHooks<Metadata = unknown> {
162
+ #private;
163
+ beforePlay(hook: BeforePlayHook<Metadata>): () => void;
164
+ runBeforePlay(context: BeforePlayContext<Metadata>): Promise<void>;
165
+ }
166
+ export declare function lifecycleContext<Metadata>(sessionId: string, generation: number, customMetadata: Metadata | undefined): PlaybackEventIdentity<Metadata>;
@@ -0,0 +1,73 @@
1
+ /** Stable names for playback/session event subscriptions and emission. */
2
+ export const PlaybackSessionEvents = {
3
+ playbackStarted: 'playbackStarted',
4
+ playbackCompleted: 'playbackCompleted',
5
+ playbackStopped: 'playbackStopped',
6
+ playbackFailed: 'playbackFailed',
7
+ playbackPaused: 'playbackPaused',
8
+ playbackResumed: 'playbackResumed',
9
+ playbackSeeked: 'playbackSeeked',
10
+ filterChanged: 'filterChanged',
11
+ volumeChanged: 'volumeChanged',
12
+ sessionDisposed: 'sessionDisposed',
13
+ };
14
+ export const VoiceSessionEvents = {
15
+ voiceStateChanged: 'voiceStateChanged',
16
+ voiceFailed: 'voiceFailed',
17
+ };
18
+ export const RemediaEvents = {
19
+ ...PlaybackSessionEvents,
20
+ ...VoiceSessionEvents,
21
+ debug: 'debug',
22
+ };
23
+ /** Ordered, awaited local middleware. It is not a server authorization mechanism. */
24
+ export class PlaybackHooks {
25
+ #beforePlay = [];
26
+ beforePlay(hook) {
27
+ if (typeof hook !== 'function')
28
+ throw new TypeError('beforePlay hook must be a function');
29
+ this.#beforePlay.push(hook);
30
+ return () => {
31
+ const index = this.#beforePlay.indexOf(hook);
32
+ if (index >= 0)
33
+ this.#beforePlay.splice(index, 1);
34
+ };
35
+ }
36
+ async runBeforePlay(context) {
37
+ for (const hook of [...this.#beforePlay]) {
38
+ if (context.signal.aborted)
39
+ throw abortError(context.signal.reason);
40
+ const result = await raceAbort(Promise.resolve().then(() => hook(context)), context.signal);
41
+ if (context.signal.aborted)
42
+ throw abortError(context.signal.reason);
43
+ if (result?.cancel) {
44
+ const error = new Error(result.reason ?? 'playback was cancelled by a beforePlay hook');
45
+ error.name = 'AbortError';
46
+ throw error;
47
+ }
48
+ }
49
+ }
50
+ /** @internal Release application hook references when the owning session is disposed. */
51
+ clear() {
52
+ this.#beforePlay.length = 0;
53
+ }
54
+ }
55
+ export function lifecycleContext(sessionId, generation, customMetadata) {
56
+ return customMetadata === undefined
57
+ ? { sessionId, nodeId: null, generation }
58
+ : { sessionId, nodeId: null, generation, customMetadata };
59
+ }
60
+ function abortError(reason) {
61
+ const error = new Error(reason instanceof Error ? reason.message : 'playback was cancelled');
62
+ error.name = 'AbortError';
63
+ return error;
64
+ }
65
+ function raceAbort(operation, signal) {
66
+ if (signal.aborted)
67
+ return Promise.reject(abortError(signal.reason));
68
+ return new Promise((resolve, reject) => {
69
+ const onAbort = () => reject(abortError(signal.reason));
70
+ signal.addEventListener('abort', onAbort, { once: true });
71
+ operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
72
+ });
73
+ }
@@ -0,0 +1,29 @@
1
+ import { RemediaEventEmitter } from './events.js';
2
+ import { type MediaSource } from './input.js';
3
+ import { PlaybackHooks, type PlayOptions, type PlaybackSessionEventMap } from './lifecycle.js';
4
+ import { type AudioStreamInfo, type PlaybackPosition, type PlaybackState } from './types.js';
5
+ import { FilterControls } from './filters.js';
6
+ /** Controls one playback session. Local sessions decode into a no-op output. */
7
+ export declare class PlaybackSession<Metadata = unknown> extends RemediaEventEmitter<PlaybackSessionEventMap<Metadata>> {
8
+ #private;
9
+ readonly hooks: PlaybackHooks<Metadata>;
10
+ readonly filters: FilterControls;
11
+ /** Stable opaque identity for this wrapper, suitable for correlating lifecycle events. */
12
+ get sessionId(): string;
13
+ get state(): PlaybackState;
14
+ get position(): PlaybackPosition;
15
+ get durationMs(): number | null;
16
+ get error(): string | null;
17
+ /** Current normalized linear master gain, from 0.0 (mute) through 2.0. */
18
+ get volume(): number;
19
+ /** Set master gain independently of the raw filtergraph. */
20
+ setVolume(gain: number): Promise<void>;
21
+ play(source: MediaSource, options?: PlayOptions<Metadata>): Promise<AudioStreamInfo>;
22
+ pause(): Promise<void>;
23
+ resume(): Promise<void>;
24
+ seek(positionMs: number): Promise<void>;
25
+ stop(): Promise<void>;
26
+ wait(): Promise<PlaybackState>;
27
+ /** Stop and release this standalone playback session. */
28
+ dispose(): Promise<void>;
29
+ }