@kattowo/distube 6.1.0 → 6.2.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.
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/constant.ts
5
- var version = "6.1.0";
5
+ var version = "6.2.1";
6
6
  var AUDIO_SAMPLE_RATE = 48e3;
7
7
  var AUDIO_CHANNELS = 2;
8
8
  var DEFAULT_VOLUME = 50;
@@ -30,6 +30,11 @@ var defaultFilters = {
30
30
  earwax: "earwax"
31
31
  };
32
32
  var DEFAULT_PRELOAD_THRESHOLD_SECS = 15;
33
+ var DEFAULT_CROSSFADE_DURATION_SECS = 0;
34
+ var MAX_CROSSFADE_DURATION_SECS = 12;
35
+ var DEFAULT_CROSSFADE_CURVE = "qsin";
36
+ var DEFAULT_CROSSFADE_WARMUP_SECS = 5;
37
+ var CROSSFADE_DRIFT_TOLERANCE_SECS = 0.75;
33
38
  var defaultOptions = {
34
39
  plugins: [],
35
40
  emitNewSongOnly: false,
@@ -40,7 +45,12 @@ var defaultOptions = {
40
45
  joinNewVoiceChannel: true,
41
46
  unhandledError: "log",
42
47
  stuckThreshold: STUCK_THRESHOLD_MS,
43
- preload: false
48
+ preload: false,
49
+ crossfade: {
50
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
51
+ curve: DEFAULT_CROSSFADE_CURVE,
52
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
53
+ }
44
54
  };
45
55
 
46
56
  // src/core/DisTubeBase.ts
@@ -770,7 +780,7 @@ var DisTubeVoice = class extends TypedEmitter {
770
780
  if (this.audioPlayer.state.status !== AudioPlayerStatus.Paused) {
771
781
  this.audioPlayer.play(dtStream.audioResource);
772
782
  this.stream?.kill();
773
- dtStream.spawn();
783
+ if (!dtStream.process) dtStream.spawn();
774
784
  } else if (!this.pausingStream) {
775
785
  this.pausingStream = this.stream;
776
786
  }
@@ -1295,11 +1305,24 @@ var Queue = class extends DisTubeBase {
1295
1305
  * @internal
1296
1306
  */
1297
1307
  _preloadGeneration = 0;
1308
+ #crossfade;
1309
+ /**
1310
+ * Crossfade boundary timer
1311
+ * @internal
1312
+ */
1313
+ _crossfadeTimeout;
1314
+ /**
1315
+ * The merged transition stream that is warming up before the fade window
1316
+ * starts
1317
+ * @internal
1318
+ */
1319
+ _crossfadeWarmup;
1298
1320
  /**
1299
1321
  * Clear any preloaded song and cancel pending preload timer.
1300
1322
  * @internal
1301
1323
  */
1302
1324
  _clearPreload() {
1325
+ this._crossfadeCancel();
1303
1326
  this._preloadGeneration++;
1304
1327
  if (this._preloadTimeout) {
1305
1328
  clearTimeout(this._preloadTimeout);
@@ -1311,6 +1334,20 @@ var Queue = class extends DisTubeBase {
1311
1334
  }
1312
1335
  this._preloadedSong = void 0;
1313
1336
  }
1337
+ /**
1338
+ * Cancel a pending crossfade and kill its warming-up transition stream.
1339
+ * @internal
1340
+ */
1341
+ _crossfadeCancel() {
1342
+ if (this._crossfadeTimeout) {
1343
+ clearTimeout(this._crossfadeTimeout);
1344
+ this._crossfadeTimeout = void 0;
1345
+ }
1346
+ if (this._crossfadeWarmup) {
1347
+ this._crossfadeWarmup.kill();
1348
+ this._crossfadeWarmup = void 0;
1349
+ }
1350
+ }
1314
1351
  /**
1315
1352
  * Create a queue for the guild
1316
1353
  * @param distube - DisTube
@@ -1339,6 +1376,7 @@ var Queue = class extends DisTubeBase {
1339
1376
  input: { ...this.options.ffmpeg.args.input },
1340
1377
  output: { ...this.options.ffmpeg.args.output }
1341
1378
  };
1379
+ this.#crossfade = Number(this.options.crossfade?.duration) || 0;
1342
1380
  this.#initialized = true;
1343
1381
  }
1344
1382
  #addToPreviousSongs(songs) {
@@ -1397,6 +1435,28 @@ var Queue = class extends DisTubeBase {
1397
1435
  get currentTime() {
1398
1436
  return this.voice.playbackTime;
1399
1437
  }
1438
+ /**
1439
+ * The crossfade duration (in seconds) between this song and the next one.
1440
+ *
1441
+ * `0` (the default) disables the crossfade. Changing it takes effect for the
1442
+ * next transition; call {@link Queue#scheduleCrossfade} to re-arm the
1443
+ * transition for the currently playing song immediately.
1444
+ * @throws {@link DisTubeError} When the value is not a number between `0`
1445
+ * and the maximum crossfade duration
1446
+ */
1447
+ get crossfade() {
1448
+ return this.#crossfade;
1449
+ }
1450
+ set crossfade(value) {
1451
+ if (typeof value !== "number" || Number.isNaN(value)) {
1452
+ throw new DisTubeError("INVALID_TYPE", "number", value, "Queue.crossfade");
1453
+ }
1454
+ if (value < 0) throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "bigger or equal to", 0);
1455
+ if (value > MAX_CROSSFADE_DURATION_SECS) {
1456
+ throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "smaller or equal to", MAX_CROSSFADE_DURATION_SECS);
1457
+ }
1458
+ this.#crossfade = value;
1459
+ }
1400
1460
  /**
1401
1461
  * Formatted {@link Queue#currentTime} string.
1402
1462
  */
@@ -1498,6 +1558,7 @@ var Queue = class extends DisTubeBase {
1498
1558
  this.voice.unpause();
1499
1559
  this.emit("resumeSong" /* RESUME_SONG */, this, this.songs[0]);
1500
1560
  this.queues?.schedulePreload?.(this);
1561
+ this.queues?.scheduleCrossfade?.(this);
1501
1562
  return this;
1502
1563
  } finally {
1503
1564
  this._taskQueue.resolve();
@@ -1754,6 +1815,17 @@ var Queue = class extends DisTubeBase {
1754
1815
  if (this.stopped) throw new DisTubeError("QUEUE_STOPPED");
1755
1816
  return this.queues.playSong(this, emitPlaySong);
1756
1817
  }
1818
+ /**
1819
+ * Re-arm the crossfade into the next song for the currently playing one.
1820
+ *
1821
+ * Useful after changing {@link Queue#crossfade} while a song is playing: the
1822
+ * fade may still fit into the remaining time. This is called automatically
1823
+ * after every song switch.
1824
+ * @returns Nothing
1825
+ */
1826
+ scheduleCrossfade() {
1827
+ this.queues.scheduleCrossfade(this);
1828
+ }
1757
1829
  /**
1758
1830
  * Restart the current stream with the current FFmpeg arguments, keeping the
1759
1831
  * playback position.
@@ -2406,6 +2478,11 @@ var Options = class {
2406
2478
  * @default { enabled: false, threshold: 15 }
2407
2479
  */
2408
2480
  preload;
2481
+ /**
2482
+ * The crossfade options of the DisTube instance.
2483
+ * @default { duration: 0, curve: "qsin", warmup: 5 }
2484
+ */
2485
+ crossfade;
2409
2486
  /**
2410
2487
  * The FFmpeg options of the DisTube instance.
2411
2488
  */
@@ -2432,6 +2509,7 @@ var Options = class {
2432
2509
  this.unhandledError = opts.unhandledError;
2433
2510
  this.stuckThreshold = opts.stuckThreshold;
2434
2511
  this.preload = this.#preloadOption(opts.preload);
2512
+ this.crossfade = this.#crossfadeOption(opts.crossfade);
2435
2513
  this.ffmpeg = this.#ffmpegOption(options);
2436
2514
  checkInvalidKey(opts, this, "DisTubeOptions");
2437
2515
  this.#validateOptions();
@@ -2459,6 +2537,47 @@ var Options = class {
2459
2537
  threshold: threshold ?? DEFAULT_PRELOAD_THRESHOLD_SECS
2460
2538
  };
2461
2539
  }
2540
+ #crossfadeOption(value) {
2541
+ if (value === void 0) {
2542
+ return {
2543
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
2544
+ curve: DEFAULT_CROSSFADE_CURVE,
2545
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
2546
+ };
2547
+ }
2548
+ if (typeof value !== "object" || Array.isArray(value)) {
2549
+ throw new DisTubeError("INVALID_TYPE", "object", value, "DisTubeOptions.crossfade");
2550
+ }
2551
+ checkInvalidKey(value, ["duration", "curve", "warmup"], "DisTubeOptions.crossfade");
2552
+ const { duration, curve, warmup } = value;
2553
+ if (duration !== void 0 && (typeof duration !== "number" || Number.isNaN(duration) || duration < 0)) {
2554
+ throw new DisTubeError(
2555
+ "INVALID_TYPE",
2556
+ `number >= 0 (max ${MAX_CROSSFADE_DURATION_SECS})`,
2557
+ duration,
2558
+ "DisTubeOptions.crossfade.duration"
2559
+ );
2560
+ }
2561
+ if (duration !== void 0 && duration > MAX_CROSSFADE_DURATION_SECS) {
2562
+ throw new DisTubeError(
2563
+ "NUMBER_COMPARE",
2564
+ "Crossfade duration",
2565
+ "smaller or equal to",
2566
+ MAX_CROSSFADE_DURATION_SECS
2567
+ );
2568
+ }
2569
+ if (curve !== void 0 && (typeof curve !== "string" || !curve)) {
2570
+ throw new DisTubeError("INVALID_TYPE", "string", curve, "DisTubeOptions.crossfade.curve");
2571
+ }
2572
+ if (warmup !== void 0 && (typeof warmup !== "number" || Number.isNaN(warmup) || warmup < 0)) {
2573
+ throw new DisTubeError("INVALID_TYPE", "number >= 0", warmup, "DisTubeOptions.crossfade.warmup");
2574
+ }
2575
+ return {
2576
+ duration: duration ?? DEFAULT_CROSSFADE_DURATION_SECS,
2577
+ curve: curve ?? DEFAULT_CROSSFADE_CURVE,
2578
+ warmup: warmup ?? DEFAULT_CROSSFADE_WARMUP_SECS
2579
+ };
2580
+ }
2462
2581
  #validateOptions(options = this) {
2463
2582
  const booleanOptions = /* @__PURE__ */ new Set([
2464
2583
  "emitNewSongOnly",
@@ -2470,7 +2589,7 @@ var Options = class {
2470
2589
  ]);
2471
2590
  const numberOptions = /* @__PURE__ */ new Set(["stuckThreshold"]);
2472
2591
  const stringOptions = /* @__PURE__ */ new Set(["unhandledError"]);
2473
- const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg", "preload"]);
2592
+ const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg", "preload", "crossfade"]);
2474
2593
  const optionalOptions = /* @__PURE__ */ new Set(["customFilters"]);
2475
2594
  for (const [key, value] of Object.entries(options)) {
2476
2595
  if (value === void 0 && optionalOptions.has(key)) continue;
@@ -2581,6 +2700,17 @@ var DisTubeStream = class extends TypedEmitter2 {
2581
2700
  * The seek time in seconds that this stream started from
2582
2701
  */
2583
2702
  seekTime;
2703
+ /**
2704
+ * Whether FFmpeg has already produced audio that is waiting to be read.
2705
+ *
2706
+ * The stream is spawned ahead of the playback (crossfade warmup) and the
2707
+ * pipe backs up once its buffers are full, so this is `true` as soon as the
2708
+ * first frame exists. A stream that is still `false` at playback time would
2709
+ * leave the audio player buffering until FFmpeg catches up.
2710
+ */
2711
+ get hasBufferedAudio() {
2712
+ return this.stream.readableLength > 0;
2713
+ }
2584
2714
  /**
2585
2715
  * Create a DisTubeStream to play with {@link DisTubeVoice}
2586
2716
  * @param url - Stream URL
@@ -2588,8 +2718,40 @@ var DisTubeStream = class extends TypedEmitter2 {
2588
2718
  */
2589
2719
  constructor(url, options) {
2590
2720
  super();
2591
- const { ffmpeg, seek } = options;
2721
+ const { ffmpeg, seek, crossfade } = options;
2592
2722
  this.seekTime = typeof seek === "number" && seek > 0 ? seek : 0;
2723
+ this.#ffmpegPath = ffmpeg.path;
2724
+ this.#opts = crossfade ? this.#crossfadeArgs(url, ffmpeg, crossfade) : this.#singleInputArgs(url, ffmpeg, seek);
2725
+ this.stream = new VolumeTransformer();
2726
+ this.stream.on("close", () => this.kill()).on("error", (err) => {
2727
+ this.debug(`[stream] error: ${err.message}`);
2728
+ this.emit("error", err);
2729
+ }).on("finish", () => this.debug("[stream] log: stream finished"));
2730
+ this.audioResource = createAudioResource(this.stream, {
2731
+ inputType: StreamType.Raw,
2732
+ inlineVolume: false
2733
+ });
2734
+ }
2735
+ #flattenArgs(args) {
2736
+ return Object.entries(args).flatMap(
2737
+ ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2738
+ ).flat();
2739
+ }
2740
+ /**
2741
+ * Translate a `file:` URL into a local filesystem path that FFmpeg accepts.
2742
+ *
2743
+ * `file:///C:/x` loses its drive-letter leading slash on Windows (`C:/x`),
2744
+ * UNC shares keep their `//host` prefix, POSIX paths stay untouched, and
2745
+ * other protocols return `undefined`.
2746
+ */
2747
+ #localPath(url) {
2748
+ const fileUrl = new URL(url);
2749
+ if (fileUrl.protocol !== "file:") return void 0;
2750
+ const pathname = decodeURIComponent(fileUrl.pathname);
2751
+ if (fileUrl.hostname) return `//${fileUrl.hostname}${pathname}`;
2752
+ return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
2753
+ }
2754
+ #singleInputArgs(url, ffmpeg, seek) {
2593
2755
  const opts = {
2594
2756
  reconnect: 1,
2595
2757
  reconnect_streamed: 1,
@@ -2605,29 +2767,91 @@ var DisTubeStream = class extends TypedEmitter2 {
2605
2767
  f: "s16le"
2606
2768
  };
2607
2769
  if (typeof seek === "number" && seek > 0) opts.ss = seek.toString();
2608
- const fileUrl = new URL(url);
2609
- if (fileUrl.protocol === "file:") {
2770
+ const localPath = this.#localPath(url);
2771
+ if (localPath !== void 0) {
2610
2772
  opts.reconnect = null;
2611
2773
  opts.reconnect_streamed = null;
2612
2774
  opts.reconnect_delay_max = null;
2613
- opts.i = fileUrl.hostname + fileUrl.pathname;
2775
+ opts.i = localPath;
2614
2776
  }
2615
- this.#ffmpegPath = ffmpeg.path;
2616
- this.#opts = [
2617
- ...Object.entries(opts).flatMap(
2618
- ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2619
- ).flat(),
2777
+ return [...this.#flattenArgs(opts), "pipe:1"];
2778
+ }
2779
+ /**
2780
+ * Build the arguments of a crossfade transition stream: a single FFmpeg
2781
+ * process with two inputs (the fading out song seeked to its final
2782
+ * `duration` seconds and the fading in song from its beginning) mixed by an
2783
+ * `acrossfade` filter graph.
2784
+ *
2785
+ * Both inputs are normalized (48kHz stereo s16) before the fade, and the
2786
+ * queue's `af` filter chain, when present, is folded into the filter complex
2787
+ * after the fade (FFmpeg cannot combine `-af` with `-filter_complex`).
2788
+ */
2789
+ #crossfadeArgs(url, ffmpeg, crossfade) {
2790
+ const {
2791
+ duration,
2792
+ primarySeek,
2793
+ secondaryUrl,
2794
+ outCurve = DEFAULT_CROSSFADE_CURVE,
2795
+ inCurve = DEFAULT_CROSSFADE_CURVE
2796
+ } = crossfade;
2797
+ const outputArgs = { ...ffmpeg.args.output };
2798
+ const extraFilters = typeof outputArgs.af === "string" ? outputArgs.af : Array.isArray(outputArgs.af) ? outputArgs.af.filter(Boolean).join(",") : void 0;
2799
+ delete outputArgs.af;
2800
+ const primaryOpts = {
2801
+ reconnect: 1,
2802
+ reconnect_streamed: 1,
2803
+ reconnect_delay_max: 5,
2804
+ analyzeduration: 0,
2805
+ hide_banner: true,
2806
+ ...ffmpeg.args.global,
2807
+ ...ffmpeg.args.input,
2808
+ ss: typeof primarySeek === "number" && primarySeek > 0 ? primarySeek.toString() : null,
2809
+ i: url
2810
+ };
2811
+ const secondaryOpts = {
2812
+ reconnect: 1,
2813
+ reconnect_streamed: 1,
2814
+ reconnect_delay_max: 5,
2815
+ analyzeduration: 0,
2816
+ ...ffmpeg.args.input,
2817
+ i: secondaryUrl
2818
+ };
2819
+ const primaryLocal = this.#localPath(url);
2820
+ if (primaryLocal !== void 0) {
2821
+ primaryOpts.reconnect = null;
2822
+ primaryOpts.reconnect_streamed = null;
2823
+ primaryOpts.reconnect_delay_max = null;
2824
+ primaryOpts.i = primaryLocal;
2825
+ }
2826
+ const secondaryLocal = this.#localPath(secondaryUrl);
2827
+ if (secondaryLocal !== void 0) {
2828
+ secondaryOpts.reconnect = null;
2829
+ secondaryOpts.reconnect_streamed = null;
2830
+ secondaryOpts.reconnect_delay_max = null;
2831
+ secondaryOpts.i = secondaryLocal;
2832
+ }
2833
+ const normalize = `aresample=${AUDIO_SAMPLE_RATE},aformat=sample_fmts=s16:channel_layouts=stereo`;
2834
+ const fade = `[a0][a1]acrossfade=d=${duration}:c1=${outCurve}:c2=${inCurve}[xf]`;
2835
+ const graph = [
2836
+ `[0:a]${normalize}[a0]`,
2837
+ `[1:a]${normalize}[a1]`,
2838
+ fade,
2839
+ ...extraFilters ? [`[xf]${extraFilters}[aout]`] : []
2840
+ ].join(";");
2841
+ const outputOpts = {
2842
+ ar: AUDIO_SAMPLE_RATE,
2843
+ ac: AUDIO_CHANNELS,
2844
+ ...outputArgs,
2845
+ filter_complex: graph,
2846
+ map: extraFilters ? "[aout]" : "[xf]",
2847
+ f: "s16le"
2848
+ };
2849
+ return [
2850
+ ...this.#flattenArgs(primaryOpts),
2851
+ ...this.#flattenArgs(secondaryOpts),
2852
+ ...this.#flattenArgs(outputOpts),
2620
2853
  "pipe:1"
2621
2854
  ];
2622
- this.stream = new VolumeTransformer();
2623
- this.stream.on("close", () => this.kill()).on("error", (err) => {
2624
- this.debug(`[stream] error: ${err.message}`);
2625
- this.emit("error", err);
2626
- }).on("finish", () => this.debug("[stream] log: stream finished"));
2627
- this.audioResource = createAudioResource(this.stream, {
2628
- inputType: StreamType.Raw,
2629
- inlineVolume: false
2630
- });
2631
2855
  }
2632
2856
  /**
2633
2857
  * Spawn the FFmpeg process that decodes the audio.
@@ -2993,6 +3217,7 @@ var QueueManager = class extends GuildIdManager {
2993
3217
  await queue.voice.play(dtStream);
2994
3218
  if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
2995
3219
  this.schedulePreload(queue);
3220
+ this.scheduleCrossfade(queue);
2996
3221
  } catch (e) {
2997
3222
  const error = e instanceof Error ? e : new Error(String(e));
2998
3223
  if (options?.keepSongOnError) {
@@ -3094,6 +3319,168 @@ var QueueManager = class extends GuildIdManager {
3094
3319
  this.emit("preloadError" /* PRELOAD_ERROR */, queue, nextSong, error);
3095
3320
  }
3096
3321
  }
3322
+ /**
3323
+ * Schedule the crossfade into the next song: a single FFmpeg process mixes
3324
+ * the current song (seeked to its final fade window) with the next song, and
3325
+ * replaces the playback stream exactly when the fade window begins.
3326
+ *
3327
+ * Canceled automatically whenever the preloaded state is invalidated
3328
+ * (pause, seek, stop...), as both schedulers share the same generation.
3329
+ * @param queue - The guild queue
3330
+ */
3331
+ scheduleCrossfade(queue) {
3332
+ queue._crossfadeCancel();
3333
+ if (queue.stopped || queue.paused) return;
3334
+ const currentSong = queue.songs[0];
3335
+ if (!currentSong) return;
3336
+ const songDuration = currentSong.duration ?? 0;
3337
+ if (!songDuration || !Number.isFinite(songDuration)) return;
3338
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3339
+ if (fade <= 0) return;
3340
+ const nextSong = queue.repeatMode === 1 /* SONG */ ? currentSong : queue.songs[1];
3341
+ if (!nextSong) return;
3342
+ const currentTime = queue.currentTime;
3343
+ if (!Number.isFinite(currentTime)) return;
3344
+ const remaining = songDuration - currentTime;
3345
+ if (remaining <= fade) return;
3346
+ const delayMs = Math.max(0, (remaining - fade - this.options.crossfade.warmup) * 1e3);
3347
+ this.debug(
3348
+ `[${queue.id}] Scheduling crossfade (${fade}s fade) in ${(delayMs / 1e3).toFixed(1)}s: ${currentSong} -> ${nextSong}`
3349
+ );
3350
+ queue._crossfadeTimeout = setTimeout(() => {
3351
+ queue._crossfadeTimeout = void 0;
3352
+ void this.#crossfadeWarmupTask(queue);
3353
+ }, delayMs);
3354
+ queue._crossfadeTimeout.unref?.();
3355
+ }
3356
+ /**
3357
+ * Spawn the merged transition stream ahead of the fade window so both of
3358
+ * its inputs are buffered when the fade must start, then arm the boundary
3359
+ * timer that swaps the playback stream.
3360
+ */
3361
+ async #crossfadeWarmupTask(queue) {
3362
+ let transitionStream;
3363
+ try {
3364
+ if (queue.stopped || queue.paused) return;
3365
+ const song = queue.songs[0];
3366
+ const generation = queue._preloadGeneration;
3367
+ const repeatSong = queue.repeatMode === 1 /* SONG */;
3368
+ const nextSong = repeatSong ? song : queue.songs[1];
3369
+ if (!song || !nextSong) return;
3370
+ const songDuration = song.duration ?? 0;
3371
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3372
+ if (fade <= 0) return;
3373
+ const remaining = songDuration - queue.currentTime;
3374
+ if (remaining <= fade) return;
3375
+ if (!repeatSong) {
3376
+ if (this.options.preload.enabled) await this.preloadNextSong(queue);
3377
+ else await this.handler.attachStreamInfo(nextSong);
3378
+ }
3379
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) return;
3380
+ if (repeatSong ? queue.songs[0] !== song : queue.songs[1] !== nextSong) return;
3381
+ const primary = song.stream.playFromSource ? song : song.stream.song;
3382
+ const secondary = repeatSong ? primary : nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
3383
+ if (!primary?.stream.playFromSource || !primary.stream.url) return;
3384
+ if (!secondary?.stream.playFromSource || !secondary.stream.url) return;
3385
+ this.debug(`[${queue.id}] Warming up crossfade stream: ${song} -> ${nextSong}`);
3386
+ transitionStream = new DisTubeStream(primary.stream.url, {
3387
+ ffmpeg: {
3388
+ path: this.options.ffmpeg.path,
3389
+ args: {
3390
+ global: { ...queue.ffmpegArgs.global },
3391
+ input: { ...queue.ffmpegArgs.input },
3392
+ output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
3393
+ }
3394
+ },
3395
+ crossfade: {
3396
+ duration: fade,
3397
+ primarySeek: Math.max(0, songDuration - fade),
3398
+ secondaryUrl: secondary.stream.url,
3399
+ inCurve: this.options.crossfade.curve,
3400
+ outCurve: this.options.crossfade.curve
3401
+ }
3402
+ });
3403
+ transitionStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [crossfade] ${data}`));
3404
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) {
3405
+ transitionStream.kill();
3406
+ return;
3407
+ }
3408
+ queue._crossfadeWarmup = transitionStream;
3409
+ transitionStream.spawn();
3410
+ const boundaryMs = Math.max(0, (songDuration - queue.currentTime - fade) * 1e3);
3411
+ queue._crossfadeTimeout = setTimeout(() => {
3412
+ queue._crossfadeTimeout = void 0;
3413
+ void this.#crossfadeSwapTask(queue, transitionStream);
3414
+ }, boundaryMs);
3415
+ queue._crossfadeTimeout.unref?.();
3416
+ } catch (e) {
3417
+ const errorMessage = e instanceof Error ? e.message : String(e);
3418
+ this.debug(`[${queue.id}] Crossfade warmup failed, using the regular transition: ${errorMessage}`);
3419
+ if (transitionStream) transitionStream.kill();
3420
+ }
3421
+ }
3422
+ /**
3423
+ * Swap the playing stream for the merged transition stream and update the
3424
+ * queue as if the current song had just finished.
3425
+ */
3426
+ async #crossfadeSwapTask(queue, transitionStream) {
3427
+ const abort = /* @__PURE__ */ __name(() => {
3428
+ if (queue._crossfadeWarmup === transitionStream) queue._crossfadeWarmup = void 0;
3429
+ transitionStream.kill();
3430
+ }, "abort");
3431
+ try {
3432
+ if (queue.stopped || queue.paused || queue._crossfadeWarmup !== transitionStream) return abort();
3433
+ const song = queue.songs[0];
3434
+ if (!song) return abort();
3435
+ const songDuration = song.duration ?? 0;
3436
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3437
+ if (fade <= 0) return abort();
3438
+ const drift = queue.currentTime - (songDuration - fade);
3439
+ if (Math.abs(drift) > CROSSFADE_DRIFT_TOLERANCE_SECS) {
3440
+ this.debug(`[${queue.id}] Crossfade aborted: playback drifted ${drift.toFixed(2)}s from the fade boundary`);
3441
+ return abort();
3442
+ }
3443
+ await queue._taskQueue.queuing();
3444
+ try {
3445
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || queue._crossfadeWarmup !== transitionStream) {
3446
+ return abort();
3447
+ }
3448
+ if (!transitionStream.hasBufferedAudio) {
3449
+ this.debug(`[${queue.id}] Crossfade aborted: the transition stream is not ready`);
3450
+ return abort();
3451
+ }
3452
+ this.debug(`[${queue.id}] Starting crossfade: ${song} -> ${queue.songs.length > 1 ? queue.songs[1] : song}`);
3453
+ await queue.voice.play(transitionStream);
3454
+ queue._crossfadeWarmup = void 0;
3455
+ this.emit("finishSong" /* FINISH_SONG */, queue, song);
3456
+ if (queue.repeatMode === 2 /* QUEUE */) queue.songs.push(song);
3457
+ if (queue.repeatMode !== 1 /* SONG */) {
3458
+ const prev = queue.songs.shift();
3459
+ if (this.options.savePreviousSongs) queue.previousSongs.push(prev);
3460
+ else queue.previousSongs.push({ id: prev.id });
3461
+ } else {
3462
+ }
3463
+ if (song !== queue.songs[0]) {
3464
+ const playedSong = song.stream.playFromSource ? song : song.stream.song;
3465
+ if (playedSong?.stream.playFromSource) delete playedSong.stream.url;
3466
+ }
3467
+ if (queue._preloadedStream) {
3468
+ queue._preloadedStream.kill();
3469
+ queue._preloadedStream = void 0;
3470
+ queue._preloadedSong = void 0;
3471
+ }
3472
+ this.emit("playSong" /* PLAY_SONG */, queue, queue.songs[0]);
3473
+ this.schedulePreload(queue);
3474
+ this.scheduleCrossfade(queue);
3475
+ } finally {
3476
+ queue._taskQueue.resolve();
3477
+ }
3478
+ } catch (e) {
3479
+ abort();
3480
+ const errorMessage = e instanceof Error ? e.message : String(e);
3481
+ this.debug(`[${queue.id}] Crossfade swap failed, using the regular transition: ${errorMessage}`);
3482
+ }
3483
+ }
3097
3484
  };
3098
3485
 
3099
3486
  // src/core/StreamServer.ts
@@ -4107,6 +4494,10 @@ export {
4107
4494
  AUDIO_CHANNELS,
4108
4495
  AUDIO_SAMPLE_RATE,
4109
4496
  BaseManager,
4497
+ CROSSFADE_DRIFT_TOLERANCE_SECS,
4498
+ DEFAULT_CROSSFADE_CURVE,
4499
+ DEFAULT_CROSSFADE_DURATION_SECS,
4500
+ DEFAULT_CROSSFADE_WARMUP_SECS,
4110
4501
  DEFAULT_PRELOAD_THRESHOLD_SECS,
4111
4502
  DEFAULT_VOLUME,
4112
4503
  DisTube,
@@ -4123,6 +4514,7 @@ export {
4123
4514
  HTTP_REDIRECT_CODES,
4124
4515
  InfoExtractorPlugin,
4125
4516
  JOIN_TIMEOUT_MS,
4517
+ MAX_CROSSFADE_DURATION_SECS,
4126
4518
  MAX_REDIRECT_DEPTH,
4127
4519
  Options,
4128
4520
  PlayableExtractorPlugin,