@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.
package/README.md CHANGED
@@ -34,18 +34,32 @@ Requires Node.js **22.18.0 or newer** (ESM).
34
34
  npm install @remedia/node
35
35
  ```
36
36
 
37
- Install FFmpeg 9 shared libraries separately before importing the package. npm does not install
38
- FFmpeg or compile the addon. Required ABI majors are avcodec 63, avformat 63, avutil 61,
39
- avfilter 12, and swresample 7. libopus is statically included; FFmpeg is dynamically linked.
37
+ Install FFmpeg 9 shared libraries separately on the Node.js host. npm does not install FFmpeg, and
38
+ importing the package never downloads anything. The Remedia installer provisions FFmpeg 9.0.2 only
39
+ (no server) into `~/.remedia/ffmpeg9`, which the package finds automatically:
40
+
41
+ ```sh
42
+ curl -fsSL https://raw.githubusercontent.com/twlite/remedia/main/scripts/install.sh | sh -s -- --ffmpeg-only
43
+ ```
44
+
45
+ ```powershell
46
+ & ([scriptblock]::Create((irm https://raw.githubusercontent.com/twlite/remedia/main/scripts/install.ps1))) -FFmpegOnly
47
+ ```
48
+
49
+ No `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `PATH` change, or launcher is needed. Discovery order:
50
+ `REMEDIA_FFMPEG_DIR` (an explicit override that is never silently replaced), an existing system
51
+ FFmpeg 9, then `~/.remedia/ffmpeg9`. Required ABI majors are avcodec 63, avformat 63, avutil 61,
52
+ avfilter 12, and swresample 7. `getFfmpegRuntime()` reports which installation was loaded, and a
53
+ failed import lists the paths considered. libopus is statically included; FFmpeg is dynamically linked.
54
+ FFmpeg is needed on the Node.js host in embedded mode; in remote mode it is needed on the
55
+ `remedia-server` host instead.
40
56
 
41
57
  Native runtime CI covers Linux x64/ARM64 glibc, macOS x64/ARM64, Windows x64 MSVC, and
42
58
  FreeBSD x64. Additional build-only packages cover Linux x64/ARM64 musl, Linux ARMv7 hard-float GNU,
43
59
  Windows x86/ARM64 MSVC, and Android ARM64/ARMv7 (API 24). Build-only targets have no native runtime
44
60
  integration coverage. See the [distribution guide](https://github.com/twlite/remedia/blob/main/apps/docs/content/docs/node/distribution.mdx)
45
- for verification limits and library discovery: `LD_LIBRARY_PATH` on Linux/FreeBSD,
46
- `DYLD_LIBRARY_PATH` on macOS, and DLL directories on `PATH` on Windows. For load errors, check
47
- matching architecture/ABI, transitive FFmpeg dependencies, and that optional npm dependencies were
48
- installed. Windows also needs the Visual C++ v14 runtime.
61
+ and [native library troubleshooting](https://github.com/twlite/remedia/blob/main/apps/docs/content/docs/node/troubleshooting.mdx)
62
+ for verification limits and load errors. Windows also needs the Visual C++ v14 runtime.
49
63
 
50
64
  ```js
51
65
  import { Remedia } from '@remedia/node';
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join } from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ /**
6
+ * Whether Linux uses musl rather than glibc, mirroring the generated napi-rs loader's decision so
7
+ * that the bootstrap comes from the same platform package as the engine addon.
8
+ */
9
+ function isMusl() {
10
+ // @types/node does not declare `excludeNetwork`, which keeps the report from doing DNS lookups.
11
+ const report = process.report;
12
+ if (typeof report?.getReport === 'function') {
13
+ const previous = report.excludeNetwork;
14
+ try {
15
+ report.excludeNetwork = true;
16
+ const data = report.getReport();
17
+ if (data.header?.glibcVersionRuntime)
18
+ return false;
19
+ if (data.sharedObjects?.some((object) => object.includes('libc.musl-') || object.includes('ld-musl-'))) {
20
+ return true;
21
+ }
22
+ }
23
+ catch {
24
+ // Fall through to the filesystem check.
25
+ }
26
+ finally {
27
+ report.excludeNetwork = previous;
28
+ }
29
+ }
30
+ try {
31
+ return readFileSync('/usr/bin/ldd', 'utf8').includes('musl');
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /**
38
+ * napi-rs platform suffixes that may hold this machine's addons, in the generated loader's order.
39
+ *
40
+ * @param platform - Node.js platform string.
41
+ * @param arch - Node.js architecture string.
42
+ * @param musl - Whether a Linux system uses musl; only consulted on Linux.
43
+ * @returns Suffixes such as `linux-x64-gnu` or `darwin-arm64`; empty when unsupported.
44
+ * @internal
45
+ */
46
+ export function platformSuffixes(platform, arch, musl = isMusl) {
47
+ switch (platform) {
48
+ case 'linux': {
49
+ const libc = musl() ? 'musl' : 'gnu';
50
+ if (arch === 'x64')
51
+ return [`linux-x64-${libc}`];
52
+ if (arch === 'arm64')
53
+ return [`linux-arm64-${libc}`];
54
+ if (arch === 'arm')
55
+ return [libc === 'musl' ? 'linux-arm-musleabihf' : 'linux-arm-gnueabihf'];
56
+ return [];
57
+ }
58
+ case 'darwin':
59
+ return ['darwin-universal', `darwin-${arch}`];
60
+ case 'win32':
61
+ return arch === 'x64'
62
+ ? ['win32-x64-msvc']
63
+ : arch === 'ia32'
64
+ ? ['win32-ia32-msvc']
65
+ : arch === 'arm64'
66
+ ? ['win32-arm64-msvc']
67
+ : [];
68
+ case 'freebsd':
69
+ return [`freebsd-${arch}`];
70
+ case 'android':
71
+ return arch === 'arm64' ? ['android-arm64'] : arch === 'arm' ? ['android-arm-eabi'] : [];
72
+ default:
73
+ return [];
74
+ }
75
+ }
76
+ /**
77
+ * File locations where this platform's bootstrap addon could be: next to `native.js` in a source
78
+ * checkout, then inside the installed `@remedia/node-<suffix>` platform package.
79
+ *
80
+ * @param packageRoot - Directory of the `@remedia/node` package.
81
+ * @param suffixes - Platform suffixes from {@link platformSuffixes}.
82
+ * @param resolvePackage - Resolves a module specifier to a file path; throws if absent.
83
+ * @returns Existing file paths in preference order.
84
+ * @internal
85
+ */
86
+ export function bootstrapCandidates(packageRoot, suffixes, resolvePackage = (specifier) => require.resolve(specifier)) {
87
+ const candidates = [];
88
+ for (const suffix of suffixes) {
89
+ const fileName = `bootstrap.${suffix}.node`;
90
+ const local = join(packageRoot, fileName);
91
+ if (existsSync(local))
92
+ candidates.push(local);
93
+ try {
94
+ const manifest = resolvePackage(`@remedia/node-${suffix}/package.json`);
95
+ const packaged = join(dirname(manifest), fileName);
96
+ if (existsSync(packaged))
97
+ candidates.push(packaged);
98
+ }
99
+ catch {
100
+ // The optional platform package is not installed.
101
+ }
102
+ }
103
+ return candidates;
104
+ }
package/dist/discord.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { RemediaEventEmitter } from './events.js';
2
- import { PlaybackHooks, type PlayOptions, type SessionLifecycleEvents } from './lifecycle.js';
2
+ import { PlaybackHooks, type PlayOptions, type SessionLifecycleEvents, type CrossfadeOptions, type StageNextOptions, type StageSnapshot } from './lifecycle.js';
3
3
  import { FilterControls } from './filters.js';
4
4
  import type { MediaSource } from './input.js';
5
5
  import type { AudioStreamInfo, PlaybackPosition, PlaybackState, VoiceConnectionState } from './types.js';
@@ -21,6 +21,8 @@ export interface DiscordSessionOptions {
21
21
  volume?: number;
22
22
  /** Trusted raw FFmpeg audio filtergraph; defaults to null. Native syntax validation occurs during backend configuration before playback. */
23
23
  filterGraph?: string | null;
24
+ /** Explicit PCM crossfade opt-in; disabled by default. */
25
+ crossfade?: CrossfadeOptions | false;
24
26
  }
25
27
  /** Cancellation and timeout controls for obtaining credentials and establishing a voice transport. */
26
28
  export interface DiscordConnectOptions {
@@ -113,6 +115,17 @@ export declare class DiscordSession<Metadata = unknown> extends RemediaEventEmit
113
115
  * Pause the current playback without replacing its decoder or source generation.
114
116
  * @returns Resolves when the backend accepts the pause request.
115
117
  */
118
+ /**
119
+ * Prepare one upcoming source without restarting playback or its output.
120
+ * @param source - Fresh media input; Readables and buffers require embedded execution.
121
+ * @param options - Queue occurrence, revision fencing, expiry and locally retained metadata.
122
+ * @returns Preparation snapshot; rejects disabled sessions, stale identities or invalid inputs.
123
+ */
124
+ stageNext(source: MediaSource, options: StageNextOptions<Metadata>): Promise<StageSnapshot>;
125
+ /** Cancel the queued stage and release its input. @returns Resolves after cancellation. */
126
+ clearStage(): Promise<void>;
127
+ /** Current preparation snapshot, or null when no stage is pending. */
128
+ get stagedSource(): StageSnapshot | null;
116
129
  pause(): Promise<void>;
117
130
  /**
118
131
  * Resume paused playback on its existing source generation.
package/dist/discord.js CHANGED
@@ -51,6 +51,7 @@ export class DiscordSession extends RemediaEventEmitter {
51
51
  #disposed = false;
52
52
  #filterGraph = null;
53
53
  #volume = 1;
54
+ #crossfade;
54
55
  #configurationWork = Promise.resolve();
55
56
  #nativeStateTimer;
56
57
  /**
@@ -66,6 +67,7 @@ export class DiscordSession extends RemediaEventEmitter {
66
67
  this.#channelId = validateSnowflake('channelId', options.channelId);
67
68
  this.userId = validateSnowflake('userId', options.userId);
68
69
  validateVolume(options.volume ?? 1);
70
+ this.#crossfade = options.crossfade;
69
71
  if (options.filterGraph !== undefined &&
70
72
  options.filterGraph !== null &&
71
73
  (typeof options.filterGraph !== 'string' || !options.filterGraph.trim()))
@@ -310,6 +312,24 @@ export class DiscordSession extends RemediaEventEmitter {
310
312
  * Pause the current playback without replacing its decoder or source generation.
311
313
  * @returns Resolves when the backend accepts the pause request.
312
314
  */
315
+ /**
316
+ * Prepare one upcoming source without restarting playback or its output.
317
+ * @param source - Fresh media input; Readables and buffers require embedded execution.
318
+ * @param options - Queue occurrence, revision fencing, expiry and locally retained metadata.
319
+ * @returns Preparation snapshot; rejects disabled sessions, stale identities or invalid inputs.
320
+ */
321
+ async stageNext(source, options) {
322
+ await this.#configurationWork;
323
+ return this.#requireExistingPlayback().stageNext(source, options);
324
+ }
325
+ /** Cancel the queued stage and release its input. @returns Resolves after cancellation. */
326
+ clearStage() {
327
+ return this.#requireExistingPlayback().clearStage();
328
+ }
329
+ /** Current preparation snapshot, or null when no stage is pending. */
330
+ get stagedSource() {
331
+ return this.#playback?.stagedSource ?? null;
332
+ }
313
333
  pause() {
314
334
  return this.#requireExistingPlayback().pause();
315
335
  }
@@ -694,6 +714,8 @@ export class DiscordSession extends RemediaEventEmitter {
694
714
  const createdPlayback = new PlaybackSession(this.#native, nativeSession.playbackId, undefined, this.guildId, this.hooks);
695
715
  playback = createdPlayback;
696
716
  this.#playback = createdPlayback;
717
+ if (this.#crossfade)
718
+ createdPlayback.configureCrossfade(this.#crossfade);
697
719
  await this.#serializeConfiguration(async () => {
698
720
  if (this.#playback !== createdPlayback)
699
721
  return;
@@ -1133,6 +1155,41 @@ export class DiscordSession extends RemediaEventEmitter {
1133
1155
  guildId: this.guildId,
1134
1156
  });
1135
1157
  });
1158
+ forward(PlaybackEvent.prepareRequested, (event) => {
1159
+ this.emit(PlaybackEvent.prepareRequested, {
1160
+ ...event,
1161
+ sessionId: this.sessionId,
1162
+ guildId: this.guildId,
1163
+ });
1164
+ });
1165
+ forward(PlaybackEvent.stageReady, (event) => {
1166
+ this.emit(PlaybackEvent.stageReady, {
1167
+ ...event,
1168
+ sessionId: this.sessionId,
1169
+ guildId: this.guildId,
1170
+ });
1171
+ });
1172
+ forward(PlaybackEvent.stageFailed, (event) => {
1173
+ this.emit(PlaybackEvent.stageFailed, {
1174
+ ...event,
1175
+ sessionId: this.sessionId,
1176
+ guildId: this.guildId,
1177
+ });
1178
+ });
1179
+ forward(PlaybackEvent.stageInvalidated, (event) => {
1180
+ this.emit(PlaybackEvent.stageInvalidated, {
1181
+ ...event,
1182
+ sessionId: this.sessionId,
1183
+ guildId: this.guildId,
1184
+ });
1185
+ });
1186
+ forward(PlaybackEvent.transitionCancelled, (event) => {
1187
+ this.emit(PlaybackEvent.transitionCancelled, {
1188
+ ...event,
1189
+ sessionId: this.sessionId,
1190
+ guildId: this.guildId,
1191
+ });
1192
+ });
1136
1193
  this.#playbackSubscriptions = {
1137
1194
  playback,
1138
1195
  detach: () => {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Where the loaded FFmpeg installation came from.
3
+ *
4
+ * - `explicit`: the `REMEDIA_FFMPEG_DIR` environment variable.
5
+ * - `system`: an installation the platform's library search finds (package manager, Homebrew,
6
+ * `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`, `PATH`, ...).
7
+ * - `managed`: the Remedia installer's `~/.remedia/ffmpeg9` prefix.
8
+ * - `unmanaged`: the platform has no bootstrap addon, so the operating system's loader resolved
9
+ * FFmpeg on its own and Remedia cannot report which installation it chose.
10
+ */
11
+ export type FfmpegSource = 'explicit' | 'system' | 'managed' | 'unmanaged';
12
+ /**
13
+ * Why an FFmpeg installation candidate was rejected, where that can be determined.
14
+ *
15
+ * - `not-found`: the directory does not exist.
16
+ * - `missing-libraries`: required FFmpeg libraries are absent from the directory.
17
+ * - `abi-mismatch`: libraries exist but have a different ABI major than FFmpeg 9 requires.
18
+ * - `architecture-mismatch`: the libraries were built for a different CPU architecture.
19
+ * - `unresolved-dependency`: a library or one of its own dependencies could not be resolved.
20
+ * - `mixed-installations`: the five libraries did not all come from one directory.
21
+ * - `load-failed`: the loader rejected a library for another reason.
22
+ */
23
+ export type FfmpegFailureReason = 'not-found' | 'missing-libraries' | 'abi-mismatch' | 'architecture-mismatch' | 'unresolved-dependency' | 'mixed-installations' | 'load-failed';
24
+ /** One FFmpeg shared library loaded by Remedia. */
25
+ export interface FfmpegLibraryInfo {
26
+ /** FFmpeg library name without the `lib` prefix, for example `avcodec`. */
27
+ readonly name: string;
28
+ /** Absolute path of the loaded file as reported by the operating system. */
29
+ readonly path: string;
30
+ /** Library version reported by the library itself, as `major.minor.micro`. */
31
+ readonly version: string;
32
+ /** ABI major version, which must match the FFmpeg 9 requirement for `name`. */
33
+ readonly major: number;
34
+ }
35
+ /** What discovery concluded about one FFmpeg installation candidate. */
36
+ export interface FfmpegCandidateReport {
37
+ /** Which discovery step produced this candidate. */
38
+ readonly source: Exclude<FfmpegSource, 'unmanaged'>;
39
+ /** Directory that was inspected, or `null` when the step looked at several directories. */
40
+ readonly directory: string | null;
41
+ /** Whether this candidate was loaded (`selected`) or not. */
42
+ readonly outcome: 'selected' | 'rejected';
43
+ /** Classified rejection cause when `outcome` is `rejected` and the cause is known. */
44
+ readonly reason?: FfmpegFailureReason;
45
+ /** Human-readable explanation, including the loader's own message where available. */
46
+ readonly detail?: string;
47
+ }
48
+ /** The FFmpeg runtime that `@remedia/node` loaded. */
49
+ export interface FfmpegRuntimeInfo {
50
+ /** How the installation was found. */
51
+ readonly source: FfmpegSource;
52
+ /** Directory containing the loaded libraries, or `null` when `source` is `unmanaged`. */
53
+ readonly directory: string | null;
54
+ /** The five loaded libraries in load order; empty when `source` is `unmanaged`. */
55
+ readonly libraries: readonly FfmpegLibraryInfo[];
56
+ /** Every candidate discovery examined, in order, including the selected one. */
57
+ readonly considered: readonly FfmpegCandidateReport[];
58
+ }
59
+ /** Command that installs FFmpeg 9 on Linux and macOS with the Remedia installer. */
60
+ export declare const UNIX_INSTALL_COMMAND = "curl -fsSL https://raw.githubusercontent.com/twlite/remedia/main/scripts/install.sh | sh -s -- --ffmpeg-only";
61
+ /** Command that installs FFmpeg 9 on Windows PowerShell with the Remedia installer. */
62
+ export declare const WINDOWS_INSTALL_COMMAND = "& ([scriptblock]::Create((irm https://raw.githubusercontent.com/twlite/remedia/main/scripts/install.ps1))) -FFmpegOnly";
63
+ /**
64
+ * Raised when no compatible FFmpeg 9 installation could be loaded, or when an explicit
65
+ * `REMEDIA_FFMPEG_DIR` override is unusable. The message lists every path considered and how to
66
+ * install or point to FFmpeg.
67
+ */
68
+ export declare class FfmpegLoadError extends Error {
69
+ /** Every candidate that was examined, in order. */
70
+ readonly candidates: readonly FfmpegCandidateReport[];
71
+ /**
72
+ * @param candidates - Reports for each installation candidate that was examined.
73
+ * @param explicitOverride - Whether `REMEDIA_FFMPEG_DIR` was set, which disables fallbacks.
74
+ * @param platform - Platform used to choose the install command shown in the message.
75
+ */
76
+ constructor(candidates: readonly FfmpegCandidateReport[], explicitOverride: boolean, platform?: NodeJS.Platform);
77
+ }
78
+ /** FFmpeg 9 shared libraries in dependency order (leaf libraries first) with their ABI majors. */
79
+ export declare const FFMPEG_LIBRARIES: readonly [{
80
+ readonly name: "avutil";
81
+ readonly major: 61;
82
+ }, {
83
+ readonly name: "swresample";
84
+ readonly major: 7;
85
+ }, {
86
+ readonly name: "avcodec";
87
+ readonly major: 63;
88
+ }, {
89
+ readonly name: "avformat";
90
+ readonly major: 63;
91
+ }, {
92
+ readonly name: "avfilter";
93
+ readonly major: 12;
94
+ }];