@remedia/node 0.1.0-dev.3 → 0.1.0-dev.5

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,26 @@
1
+ import { type FfmpegRuntimeInfo } from './ffmpeg-runtime.js';
2
+ import type { NativeRemedia as NativeRemediaClass } from '../native.js';
3
+ /** The Remedia native engine class, loaded after FFmpeg 9 was located. */
4
+ export declare const NativeRemedia: typeof NativeRemediaClass;
5
+ /** Instance type of the Remedia native engine. */
6
+ export type NativeRemedia = NativeRemediaClass;
7
+ /**
8
+ * Report the FFmpeg 9 installation `@remedia/node` loaded.
9
+ *
10
+ * Importing the package already selects and loads FFmpeg, so this never triggers a search or a
11
+ * download and is cheap to call. It is independent of Discord, playback and the standalone server.
12
+ *
13
+ * @returns The selected source and directory, the loaded library files and versions, and every
14
+ * candidate discovery examined. When the platform has no library bootstrap the source is
15
+ * `unmanaged` and the library list is empty.
16
+ * @throws {Error} Only if called before the native addon finished loading, which cannot happen
17
+ * after the package has been imported successfully.
18
+ * @example
19
+ * ```ts
20
+ * import { getFfmpegRuntime } from '@remedia/node';
21
+ *
22
+ * const { source, directory, libraries } = getFfmpegRuntime();
23
+ * console.log(source, directory, libraries.map((library) => `${library.name} ${library.version}`));
24
+ * ```
25
+ */
26
+ export declare function getFfmpegRuntime(): FfmpegRuntimeInfo;
@@ -0,0 +1,80 @@
1
+ import { createRequire } from 'node:module';
2
+ import { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { UNIX_INSTALL_COMMAND, WINDOWS_INSTALL_COMMAND, selectFfmpeg, } from './ffmpeg-runtime.js';
5
+ import { bootstrapCandidates, platformSuffixes } from './bootstrap-locator.js';
6
+ const require = createRequire(import.meta.url);
7
+ /** Interface version of the bootstrap addon this loader was written against. */
8
+ const BOOTSTRAP_VERSION = 1;
9
+ function locateBootstrap() {
10
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
11
+ const suffixes = platformSuffixes(process.platform, process.arch);
12
+ for (const candidate of bootstrapCandidates(packageRoot, suffixes)) {
13
+ try {
14
+ const binding = require(candidate);
15
+ if (binding.bootstrapVersion() === BOOTSTRAP_VERSION)
16
+ return binding;
17
+ }
18
+ catch {
19
+ // An unusable bootstrap is treated like a missing one; the OS loader is the fallback.
20
+ }
21
+ }
22
+ return null;
23
+ }
24
+ function looksLikeMissingLibrary(error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return /libav|libsw|avcodec|avformat|avutil|avfilter|swresample|ffmpeg|\.dylib|\.so\b|specified module|dll/i.test(message);
27
+ }
28
+ let runtime = null;
29
+ async function loadNativeBinding() {
30
+ const bootstrap = locateBootstrap();
31
+ const selected = bootstrap
32
+ ? selectFfmpeg(bootstrap)
33
+ : { source: 'unmanaged', directory: null, libraries: [], considered: [] };
34
+ let binding;
35
+ try {
36
+ binding = await import('../native.js');
37
+ }
38
+ catch (error) {
39
+ const hint = selected.source === 'unmanaged' && looksLikeMissingLibrary(error)
40
+ ? `\n\nThis platform has no Remedia library bootstrap, so the system library loader must find FFmpeg 9 itself. ` +
41
+ `Install it with the Remedia installer (${process.platform === 'win32' ? WINDOWS_INSTALL_COMMAND : UNIX_INSTALL_COMMAND}) and add its library directory to your loader search path.`
42
+ : '';
43
+ const origin = selected.source === 'unmanaged'
44
+ ? 'The Remedia native addon could not be loaded'
45
+ : `FFmpeg 9 was loaded from ${selected.directory} (${selected.source}), but the Remedia native addon could not be loaded`;
46
+ throw new Error(`${origin}: ${error instanceof Error ? error.message : String(error)}${hint}`, {
47
+ cause: error,
48
+ });
49
+ }
50
+ // Only now that the engine addon has loaded against the libraries is the runtime reported.
51
+ runtime = Object.freeze({ ...selected });
52
+ return binding;
53
+ }
54
+ const binding = await loadNativeBinding();
55
+ /** The Remedia native engine class, loaded after FFmpeg 9 was located. */
56
+ export const NativeRemedia = binding.NativeRemedia;
57
+ /**
58
+ * Report the FFmpeg 9 installation `@remedia/node` loaded.
59
+ *
60
+ * Importing the package already selects and loads FFmpeg, so this never triggers a search or a
61
+ * download and is cheap to call. It is independent of Discord, playback and the standalone server.
62
+ *
63
+ * @returns The selected source and directory, the loaded library files and versions, and every
64
+ * candidate discovery examined. When the platform has no library bootstrap the source is
65
+ * `unmanaged` and the library list is empty.
66
+ * @throws {Error} Only if called before the native addon finished loading, which cannot happen
67
+ * after the package has been imported successfully.
68
+ * @example
69
+ * ```ts
70
+ * import { getFfmpegRuntime } from '@remedia/node';
71
+ *
72
+ * const { source, directory, libraries } = getFfmpegRuntime();
73
+ * console.log(source, directory, libraries.map((library) => `${library.name} ${library.version}`));
74
+ * ```
75
+ */
76
+ export function getFfmpegRuntime() {
77
+ if (!runtime)
78
+ throw new Error('The Remedia native addon has not finished loading.');
79
+ return runtime;
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { RemediaEventEmitter } from './events.js';
2
2
  import { type MediaSource } from './input.js';
3
- import { PlaybackHooks, type PlayOptions, type PlaybackSessionEventMap } from './lifecycle.js';
3
+ import { PlaybackHooks, type PlayOptions, type PlaybackSessionEventMap, type StageNextOptions, type CrossfadeOptions, type StageSnapshot } from './lifecycle.js';
4
4
  import { type AudioStreamInfo, type PlaybackPosition, type PlaybackState } from './types.js';
5
5
  import { FilterControls } from './filters.js';
6
6
  /**
@@ -15,6 +15,24 @@ export declare class PlaybackSession<Metadata = unknown> extends RemediaEventEmi
15
15
  readonly filters: FilterControls;
16
16
  /** Stable opaque identity for this wrapper, suitable for correlating lifecycle events. */
17
17
  get sessionId(): string;
18
+ /** Enable continuous PCM playback before starting a source. */
19
+ /**
20
+ * Configure overlapping PCM playback while idle.
21
+ * @param options - Explicit opt-in settings, or false to retain ordinary playback.
22
+ * @returns Nothing; throws if settings are invalid or playback is active.
23
+ */
24
+ configureCrossfade(options: CrossfadeOptions | false): void;
25
+ /** Current preparation snapshot, or null when no stage is pending. */
26
+ get stagedSource(): StageSnapshot | null;
27
+ /** Cancel the queued stage and release its input. @returns Resolves after cancellation. */
28
+ clearStage(): Promise<void>;
29
+ /**
30
+ * Prepare one upcoming source without restarting playback or its output.
31
+ * @param source - Fresh media input; Readables and buffers require embedded execution.
32
+ * @param options - Queue occurrence, revision fencing, expiry and locally retained metadata.
33
+ * @returns Preparation snapshot; rejects disabled sessions, stale identities or invalid inputs.
34
+ */
35
+ stageNext(source: MediaSource, options: StageNextOptions<Metadata>): Promise<StageSnapshot>;
18
36
  /** Current playback lifecycle state. */
19
37
  get state(): PlaybackState;
20
38
  /** Latest source and output timeline measurements in milliseconds. */
package/dist/playback.js CHANGED
@@ -26,6 +26,14 @@ export class PlaybackSession extends RemediaEventEmitter {
26
26
  #abortListener;
27
27
  #latestGeneration = 0;
28
28
  #guildId;
29
+ #crossfadeEnabled = false;
30
+ #fadeDurationMs = 5000;
31
+ #crossfadeTimer;
32
+ #stageInput;
33
+ #stageEntryId;
34
+ #stageWork = Promise.resolve();
35
+ #stages = new Map();
36
+ #outgoingInput;
29
37
  /**
30
38
  * Construct this wrapper with its owning services. Use the owning engine or client factory for application code.
31
39
  *
@@ -53,6 +61,123 @@ export class PlaybackSession extends RemediaEventEmitter {
53
61
  get currentGeneration() {
54
62
  return this.#latestGeneration;
55
63
  }
64
+ /** Enable continuous PCM playback before starting a source. */
65
+ /**
66
+ * Configure overlapping PCM playback while idle.
67
+ * @param options - Explicit opt-in settings, or false to retain ordinary playback.
68
+ * @returns Nothing; throws if settings are invalid or playback is active.
69
+ */
70
+ configureCrossfade(options) {
71
+ this.#assertActive();
72
+ if (options) {
73
+ if (options.enabled !== true ||
74
+ (options.curve !== undefined && options.curve !== 'equalPower')) {
75
+ throw new TypeError('crossfade requires enabled: true and the equalPower curve');
76
+ }
77
+ const durationMs = options.durationMs ?? 5000;
78
+ this.#native.playbackConfigureCrossfade(this.#playbackId, true, durationMs, options.prepareBeforeMs ?? 30000);
79
+ this.#fadeDurationMs = durationMs;
80
+ this.#crossfadeEnabled = true;
81
+ }
82
+ else {
83
+ this.#native.playbackConfigureCrossfade(this.#playbackId, false, 0, 0);
84
+ this.#crossfadeEnabled = false;
85
+ }
86
+ }
87
+ /** Current preparation snapshot, or null when no stage is pending. */
88
+ get stagedSource() {
89
+ const stage = this.#native.playbackStagedSource(this.#playbackId);
90
+ return stage ? this.#normalizeStage(stage) : null;
91
+ }
92
+ /** Cancel the queued stage and release its input. @returns Resolves after cancellation. */
93
+ async clearStage() {
94
+ const work = this.#stageWork.then(() => this.#clearStage());
95
+ this.#stageWork = work.then(() => { }, () => { });
96
+ return work;
97
+ }
98
+ async #clearStage() {
99
+ await this.#native.playbackClearStage(this.#playbackId);
100
+ this.#drainCrossfadeEvents();
101
+ await this.#stageInput?.cancel();
102
+ this.#stageInput = undefined;
103
+ this.#stageEntryId = undefined;
104
+ }
105
+ /**
106
+ * Prepare one upcoming source without restarting playback or its output.
107
+ * @param source - Fresh media input; Readables and buffers require embedded execution.
108
+ * @param options - Queue occurrence, revision fencing, expiry and locally retained metadata.
109
+ * @returns Preparation snapshot; rejects disabled sessions, stale identities or invalid inputs.
110
+ */
111
+ async stageNext(source, options) {
112
+ const work = this.#stageWork.then(() => this.#submitStage(source, options));
113
+ this.#stageWork = work.then(() => { }, () => { });
114
+ return work;
115
+ }
116
+ async #submitStage(source, options) {
117
+ this.#assertActive();
118
+ this.#drainCrossfadeEvents();
119
+ if (!this.#crossfadeEnabled)
120
+ throw new Error('crossfade is disabled for this session');
121
+ if (!options.entryId?.trim())
122
+ throw new TypeError('entryId is required');
123
+ if (!Number.isSafeInteger(options.queueRevision) || options.queueRevision < 0) {
124
+ throw new RangeError('queueRevision must be a non-negative safe integer');
125
+ }
126
+ const sourceRevision = options.sourceRevision ?? 0;
127
+ if (!Number.isSafeInteger(sourceRevision) || sourceRevision < 0) {
128
+ throw new RangeError('sourceRevision must be a non-negative safe integer');
129
+ }
130
+ if (options.expiresAtMs !== undefined &&
131
+ (!Number.isSafeInteger(options.expiresAtMs) || options.expiresAtMs < 0)) {
132
+ throw new RangeError('expiresAtMs must be a non-negative safe integer');
133
+ }
134
+ const expectedGeneration = options.expectedGeneration ??
135
+ numericGeneration(this.#native.playbackSourceGeneration(this.#playbackId));
136
+ const existing = this.stagedSource;
137
+ if (existing &&
138
+ existing.expectedGeneration === expectedGeneration &&
139
+ existing.entryId === options.entryId &&
140
+ existing.queueRevision === options.queueRevision &&
141
+ existing.sourceRevision === sourceRevision)
142
+ return existing;
143
+ let input;
144
+ const nativeSource = 'stream' in source
145
+ ? (input = createReadableInput(this.#native, source.stream)).source
146
+ : toNativeSource(source);
147
+ const key = JSON.stringify([
148
+ expectedGeneration,
149
+ options.entryId,
150
+ options.queueRevision,
151
+ sourceRevision,
152
+ ]);
153
+ this.#stages.set(key, { input, metadata: options.customMetadata });
154
+ try {
155
+ const result = await this.#native.playbackStageNext(this.#playbackId, nativeSource, String(expectedGeneration), options.entryId, String(options.queueRevision), String(sourceRevision), options.expiresAtMs === undefined ? undefined : String(options.expiresAtMs));
156
+ this.#drainCrossfadeEvents();
157
+ if (this.#stages.has(key)) {
158
+ this.#stageInput = input;
159
+ this.#stageEntryId = key;
160
+ }
161
+ return this.#normalizeStage(result);
162
+ }
163
+ catch (error) {
164
+ this.#stages.delete(key);
165
+ await input?.cancel();
166
+ throw error;
167
+ }
168
+ }
169
+ #normalizeStage(stage) {
170
+ return {
171
+ expectedGeneration: numericGeneration(stage.expectedGeneration),
172
+ entryId: stage.entryId,
173
+ queueRevision: numericGeneration(stage.queueRevision),
174
+ sourceRevision: numericGeneration(stage.sourceRevision),
175
+ state: stage.state,
176
+ bufferedMs: stage.bufferedMs,
177
+ info: stage.info ? toStreamInfo(stage.info) : null,
178
+ error: stage.error ?? null,
179
+ };
180
+ }
56
181
  /** Current playback lifecycle state. */
57
182
  get state() {
58
183
  return this.#native.playbackState(this.#playbackId);
@@ -153,7 +278,8 @@ export class PlaybackSession extends RemediaEventEmitter {
153
278
  throw abortError(controller.signal.reason);
154
279
  nativeAttempted = true;
155
280
  const info = toStreamInfo(await this.#native.play(this.#playbackId, nativeSource));
156
- const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
281
+ const workerGeneration = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
282
+ const generation = numericGeneration(this.#native.playbackSourceGeneration(this.#playbackId));
157
283
  this.#latestGeneration = generation;
158
284
  this.#metadata.set(generation, options.customMetadata);
159
285
  this.#boundMetadata(generation);
@@ -164,7 +290,9 @@ export class PlaybackSession extends RemediaEventEmitter {
164
290
  info,
165
291
  });
166
292
  this.#starting = false;
167
- void this.#observeGeneration(generation, input);
293
+ if (this.#crossfadeEnabled)
294
+ this.#startCrossfadePoll();
295
+ void this.#observeGeneration(workerGeneration, input);
168
296
  return info;
169
297
  }
170
298
  catch (error) {
@@ -232,7 +360,8 @@ export class PlaybackSession extends RemediaEventEmitter {
232
360
  this.#assertActive();
233
361
  const previousPositionMs = this.position.mediaMs;
234
362
  await this.#native.seek(this.#playbackId, positionMs);
235
- if (previousPositionMs !== positionMs) {
363
+ this.#drainCrossfadeEvents();
364
+ if (previousPositionMs !== positionMs || this.#crossfadeEnabled) {
236
365
  this.emit(PlaybackEvent.playbackSeeked, {
237
366
  ...this.#eventContext(),
238
367
  positionMs,
@@ -247,7 +376,7 @@ export class PlaybackSession extends RemediaEventEmitter {
247
376
  async stop() {
248
377
  this.#assertActive();
249
378
  const previousState = this.state;
250
- const generation = numericGeneration(this.#native.playbackGeneration(this.#playbackId));
379
+ const generation = this.#latestGeneration;
251
380
  try {
252
381
  await this.#native.stop(this.#playbackId);
253
382
  if (isPlaybackActive(previousState) && this.state === 'stopped') {
@@ -289,6 +418,9 @@ export class PlaybackSession extends RemediaEventEmitter {
289
418
  finally {
290
419
  await this.#releaseInput();
291
420
  this.#disposed = true;
421
+ this.#stopCrossfadePoll();
422
+ await this.#stageInput?.cancel();
423
+ await this.#outgoingInput?.cancel();
292
424
  this.hooks.clear();
293
425
  this.#metadata.clear();
294
426
  this.emit(PlaybackEvent.sessionDisposed, {
@@ -333,8 +465,9 @@ export class PlaybackSession extends RemediaEventEmitter {
333
465
  try {
334
466
  const state = (await this.#native.wait(this.#playbackId, String(generation)));
335
467
  if (state === 'completed' || state === 'failed' || state === 'stopped') {
468
+ this.#drainCrossfadeEvents();
336
469
  const error = state === 'failed' ? new Error(this.error ?? 'playback failed') : undefined;
337
- this.#emitTerminal(state, generation, error);
470
+ this.#emitTerminal(state, this.#latestGeneration, error);
338
471
  }
339
472
  }
340
473
  catch (error) {
@@ -351,19 +484,168 @@ export class PlaybackSession extends RemediaEventEmitter {
351
484
  state = undefined;
352
485
  }
353
486
  if (state === 'failed')
354
- this.#emitTerminal('failed', generation, asError(error));
487
+ this.#emitTerminal('failed', this.#latestGeneration, asError(error));
355
488
  }
356
489
  }
357
490
  finally {
358
- this.#metadata.delete(generation);
359
- this.#startedGenerations.delete(generation);
360
- if (this.#latestGeneration === generation) {
361
- this.#clearAbortBinding(this.#playController);
491
+ this.#stopCrossfadePoll();
492
+ this.#clearAbortBinding(this.#playController);
493
+ await this.#outgoingInput?.cancel();
494
+ this.#outgoingInput = undefined;
495
+ await this.#stageInput?.cancel();
496
+ this.#stageInput = undefined;
497
+ for (const stage of this.#stages.values())
498
+ await stage.input?.cancel();
499
+ this.#stages.clear();
500
+ await this.#releaseInput();
501
+ if (input !== this.#input)
502
+ await input?.cancel();
503
+ }
504
+ }
505
+ #startCrossfadePoll() {
506
+ if (this.#crossfadeTimer)
507
+ return;
508
+ this.#crossfadeTimer = setInterval(() => this.#drainCrossfadeEvents(), 25);
509
+ this.#crossfadeTimer.unref?.();
510
+ }
511
+ #stopCrossfadePoll() {
512
+ if (this.#crossfadeTimer)
513
+ clearInterval(this.#crossfadeTimer);
514
+ this.#crossfadeTimer = undefined;
515
+ }
516
+ #drainCrossfadeEvents() {
517
+ if (!this.#crossfadeEnabled || this.#disposed)
518
+ return;
519
+ let events;
520
+ try {
521
+ events = this.#native.playbackTakeCrossfadeEvents(this.#playbackId);
522
+ }
523
+ catch {
524
+ return;
525
+ }
526
+ for (const event of events) {
527
+ const generation = numericGeneration(event.generation);
528
+ const stage = event.entryId === undefined
529
+ ? null
530
+ : {
531
+ entryId: event.entryId,
532
+ queueRevision: numericGeneration(event.queueRevision ?? '0'),
533
+ sourceRevision: numericGeneration(event.sourceRevision ?? '0'),
534
+ };
535
+ const stageKey = JSON.stringify([
536
+ event.kind === 'transition_started'
537
+ ? numericGeneration(event.previousGeneration ?? '0')
538
+ : generation,
539
+ stage?.entryId,
540
+ stage?.queueRevision,
541
+ stage?.sourceRevision,
542
+ ]);
543
+ switch (event.kind) {
544
+ case 'timeline_changed': {
545
+ const previous = numericGeneration(event.previousGeneration ?? '0');
546
+ this.#metadata.set(generation, this.#metadata.get(previous));
547
+ this.#startedGenerations.add(generation);
548
+ this.#latestGeneration = generation;
549
+ break;
550
+ }
551
+ case 'prepare_requested': {
552
+ const remainingMs = event.remainingMs ?? 0;
553
+ this.emit(PlaybackEvent.prepareRequested, {
554
+ ...this.#eventContext(generation),
555
+ remainingMs,
556
+ transitionInMs: Math.max(0, remainingMs - this.#fadeDurationMs),
557
+ });
558
+ break;
559
+ }
560
+ case 'stage_ready':
561
+ if (stage)
562
+ this.emit(PlaybackEvent.stageReady, {
563
+ ...this.#eventContext(generation),
564
+ ...stage,
565
+ });
566
+ break;
567
+ case 'stage_failed':
568
+ void this.#stages.get(stageKey)?.input?.cancel();
569
+ this.#stages.delete(stageKey);
570
+ if (stage)
571
+ this.emit(PlaybackEvent.stageFailed, {
572
+ ...this.#eventContext(generation),
573
+ ...stage,
574
+ reason: event.reason ?? 'staged source failed',
575
+ });
576
+ break;
577
+ case 'stage_invalidated':
578
+ void this.#stages.get(stageKey)?.input?.cancel();
579
+ this.#stages.delete(stageKey);
580
+ if (stage) {
581
+ this.emit(PlaybackEvent.stageInvalidated, {
582
+ ...this.#eventContext(generation),
583
+ ...stage,
584
+ });
585
+ if (this.#stageEntryId === stageKey) {
586
+ void this.#stageInput?.cancel();
587
+ this.#stageInput = undefined;
588
+ this.#stageEntryId = undefined;
589
+ }
590
+ }
591
+ break;
592
+ case 'transition_started': {
593
+ if (!stage || !event.info)
594
+ break;
595
+ const previous = numericGeneration(event.previousGeneration ?? '0');
596
+ const prepared = this.#stages.get(stageKey);
597
+ this.#stages.delete(stageKey);
598
+ this.#metadata.set(generation, prepared?.metadata);
599
+ this.#boundMetadata(generation);
600
+ this.#startedGenerations.add(generation);
601
+ this.#latestGeneration = generation;
602
+ this.#outgoingInput = this.#input;
603
+ this.#input = prepared?.input;
604
+ if (this.#stageEntryId === stageKey) {
605
+ this.#stageInput = undefined;
606
+ this.#stageEntryId = undefined;
607
+ }
608
+ this.emit(PlaybackEvent.playbackStarted, {
609
+ ...this.#eventContext(generation),
610
+ state: 'playing',
611
+ info: toStreamInfo(event.info),
612
+ transition: { type: 'crossfade', outgoingGeneration: previous, entryId: stage.entryId },
613
+ });
614
+ break;
615
+ }
616
+ case 'transition_completed': {
617
+ if (!stage)
618
+ break;
619
+ const promoted = numericGeneration(event.previousGeneration ?? '0');
620
+ this.#emitTerminal('completed', generation, undefined, undefined, {
621
+ type: 'promoted',
622
+ generation: promoted,
623
+ entryId: stage.entryId,
624
+ });
625
+ void this.#outgoingInput?.cancel();
626
+ this.#outgoingInput = undefined;
627
+ break;
628
+ }
629
+ case 'transition_cancelled': {
630
+ if (!stage)
631
+ break;
632
+ const failedGeneration = numericGeneration(event.previousGeneration ?? '0');
633
+ const abandoned = this.#input;
634
+ this.#input = this.#outgoingInput;
635
+ this.#outgoingInput = undefined;
636
+ void abandoned?.cancel();
637
+ this.#latestGeneration = generation;
638
+ this.emit(PlaybackEvent.transitionCancelled, {
639
+ ...this.#eventContext(generation),
640
+ failedGeneration,
641
+ entryId: stage.entryId,
642
+ });
643
+ break;
644
+ }
362
645
  }
363
- await this.#releaseInput(input, generation);
364
646
  }
365
647
  }
366
- #emitTerminal(state, generation, error, reason) {
648
+ #emitTerminal(state, generation, error, reason, continuation) {
367
649
  if ((state !== 'failed' && !this.#startedGenerations.has(generation)) ||
368
650
  this.#terminalGenerations.has(generation))
369
651
  return;
@@ -376,7 +658,12 @@ export class PlaybackSession extends RemediaEventEmitter {
376
658
  }
377
659
  const base = this.#eventContext(generation);
378
660
  if (state === 'completed') {
379
- this.emit(PlaybackEvent.playbackCompleted, { ...base, state, position: this.position });
661
+ this.emit(PlaybackEvent.playbackCompleted, {
662
+ ...base,
663
+ state,
664
+ position: this.position,
665
+ ...(continuation ? { continuation } : {}),
666
+ });
380
667
  }
381
668
  else if (state === 'stopped') {
382
669
  this.emit(PlaybackEvent.playbackStopped, reason ? { ...base, state, reason } : { ...base, state });
package/dist/remedia.d.ts CHANGED
@@ -31,6 +31,12 @@ export declare class Remedia extends RemediaEventEmitter<RemediaLifecycleEvents>
31
31
  * @returns An initialized independent native engine context.
32
32
  */
33
33
  static create(options?: RemediaOptions): Promise<Remedia>;
34
+ /**
35
+ * Read cached native resources for the shared Node.js process. Refresh runs off
36
+ * the calling thread at most once per second; the first snapshot has null counters.
37
+ * @returns Process-wide byte and CPU-time counters, shared by all embedded instances.
38
+ */
39
+ processResources(): import('./resources.js').ProcessResourceStatistics;
34
40
  /**
35
41
  * Inspect a disposable source without creating playback or voice resources. Readables may be partially or fully consumed.
36
42
  * @param source - Encoded input; acquire a fresh source for subsequent playback.
package/dist/remedia.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createReadableInput, toNativeSource } from './input.js';
2
2
  import { toStreamInfo } from './types.js';
3
3
  import { RemediaEventEmitter } from './events.js';
4
- import { NativeRemedia } from '../native.js';
4
+ import { NativeRemedia } from './native-binding.js';
5
5
  import { DiscordSession } from './discord.js';
6
6
  import { DiscordGatewayAdapter } from './gateway.js';
7
7
  import { PlaybackSessionEvents as PlaybackEvent, RemediaEvents as RemediaEvent, VoiceSessionEvents as VoiceEvent, } from './lifecycle.js';
@@ -35,6 +35,15 @@ export class Remedia extends RemediaEventEmitter {
35
35
  static async create(options) {
36
36
  return new Remedia(await NativeRemedia.create(), options);
37
37
  }
38
+ /**
39
+ * Read cached native resources for the shared Node.js process. Refresh runs off
40
+ * the calling thread at most once per second; the first snapshot has null counters.
41
+ * @returns Process-wide byte and CPU-time counters, shared by all embedded instances.
42
+ */
43
+ processResources() {
44
+ this.#assertOpen();
45
+ return JSON.parse(this.#native.processResourcesJson());
46
+ }
38
47
  /**
39
48
  * Inspect a disposable source without creating playback or voice resources. Readables may be partially or fully consumed.
40
49
  * @param source - Encoded input; acquire a fresh source for subsequent playback.
@@ -172,6 +181,11 @@ function forwardLifecycleEvents(source, target) {
172
181
  PlaybackEvent.playbackPaused,
173
182
  PlaybackEvent.playbackResumed,
174
183
  PlaybackEvent.playbackSeeked,
184
+ PlaybackEvent.prepareRequested,
185
+ PlaybackEvent.stageReady,
186
+ PlaybackEvent.stageFailed,
187
+ PlaybackEvent.stageInvalidated,
188
+ PlaybackEvent.transitionCancelled,
175
189
  PlaybackEvent.filterChanged,
176
190
  PlaybackEvent.volumeChanged,
177
191
  PlaybackEvent.sessionDisposed,
@@ -0,0 +1,51 @@
1
+ /** Native process memory accounting in bytes. Missing fields indicate an older server; null indicates unavailable data. */
2
+ export interface ResourceMemory {
3
+ /** Resident bytes including shared pages. */
4
+ residentBytes?: number | null;
5
+ /** Private resident bytes (USS); excludes shared pages, distinct from private commit. */
6
+ privateResidentBytes?: number | null;
7
+ /** Proportional resident bytes (PSS), including shared pages proportionally. */
8
+ proportionalResidentBytes?: number | null;
9
+ /** Private committed bytes, including potentially nonresident pages. */
10
+ committedPrivateBytes?: number | null;
11
+ /** Mapped virtual address bytes; not physical RAM. */
12
+ virtualBytes?: number | null;
13
+ /** Reserved but uncommitted virtual address bytes; not RAM. */
14
+ reservedVirtualBytes?: number | null;
15
+ /** Lifetime peak resident bytes. */
16
+ peakResidentBytes?: number | null;
17
+ /** macOS physical footprint bytes; distinct from USS and RSS. */
18
+ footprintBytes?: number | null;
19
+ }
20
+ /** Native process CPU accounting. Missing fields indicate an older server; null indicates unavailable data. */
21
+ export interface ResourceCpu {
22
+ /** Process utilization: 100 means one logical CPU; can exceed 100. Null on the first sample. */
23
+ processPercent?: number | null;
24
+ /** Cumulative process user CPU time in milliseconds. */
25
+ userTimeMs?: number | null;
26
+ /** Cumulative process system CPU time in milliseconds. */
27
+ systemTimeMs?: number | null;
28
+ /** Available logical CPU parallelism estimate. */
29
+ availableParallelism?: number | null;
30
+ /** Actual process OS thread count, including nonaudio threads. */
31
+ threadCount?: number | null;
32
+ /** System one-minute load average; not process CPU usage. */
33
+ loadAverage1m?: number | null;
34
+ }
35
+ /** Cached native measurements for the whole host process, shared by all embedded instances. */
36
+ export interface ProcessResourceStatistics {
37
+ /** Process memory counters in bytes. */
38
+ memory: ResourceMemory;
39
+ /** Process CPU and system load counters. */
40
+ cpu: ResourceCpu;
41
+ /** Collection completion timestamp as Unix milliseconds; null before the first collection. */
42
+ collectedAtUnixMs: number | null;
43
+ /** Wall interval used for process utilization in milliseconds; null on the first sample. */
44
+ cpuSampleIntervalMs: number | null;
45
+ /** Demand-driven cache refresh interval in milliseconds; concurrent requests share stale snapshots. */
46
+ samplingIntervalMs: number;
47
+ /** Native collection duration in milliseconds, or null before collection. */
48
+ collectionDurationMs: number | null;
49
+ /** Unix descriptors or Windows handles owned by the process, or null if unavailable. */
50
+ openHandles: number | null;
51
+ }
@@ -0,0 +1 @@
1
+ export {};