@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.js CHANGED
@@ -34,6 +34,10 @@ __export(index_exports, {
34
34
  AUDIO_CHANNELS: () => AUDIO_CHANNELS,
35
35
  AUDIO_SAMPLE_RATE: () => AUDIO_SAMPLE_RATE,
36
36
  BaseManager: () => BaseManager,
37
+ CROSSFADE_DRIFT_TOLERANCE_SECS: () => CROSSFADE_DRIFT_TOLERANCE_SECS,
38
+ DEFAULT_CROSSFADE_CURVE: () => DEFAULT_CROSSFADE_CURVE,
39
+ DEFAULT_CROSSFADE_DURATION_SECS: () => DEFAULT_CROSSFADE_DURATION_SECS,
40
+ DEFAULT_CROSSFADE_WARMUP_SECS: () => DEFAULT_CROSSFADE_WARMUP_SECS,
37
41
  DEFAULT_PRELOAD_THRESHOLD_SECS: () => DEFAULT_PRELOAD_THRESHOLD_SECS,
38
42
  DEFAULT_VOLUME: () => DEFAULT_VOLUME,
39
43
  DisTube: () => DisTube,
@@ -50,6 +54,7 @@ __export(index_exports, {
50
54
  HTTP_REDIRECT_CODES: () => HTTP_REDIRECT_CODES,
51
55
  InfoExtractorPlugin: () => InfoExtractorPlugin,
52
56
  JOIN_TIMEOUT_MS: () => JOIN_TIMEOUT_MS,
57
+ MAX_CROSSFADE_DURATION_SECS: () => MAX_CROSSFADE_DURATION_SECS,
53
58
  MAX_REDIRECT_DEPTH: () => MAX_REDIRECT_DEPTH,
54
59
  Options: () => Options,
55
60
  PlayableExtractorPlugin: () => PlayableExtractorPlugin,
@@ -97,7 +102,7 @@ __export(index_exports, {
97
102
  module.exports = __toCommonJS(index_exports);
98
103
 
99
104
  // src/constant.ts
100
- var version = "6.1.0";
105
+ var version = "6.2.1";
101
106
  var AUDIO_SAMPLE_RATE = 48e3;
102
107
  var AUDIO_CHANNELS = 2;
103
108
  var DEFAULT_VOLUME = 50;
@@ -125,6 +130,11 @@ var defaultFilters = {
125
130
  earwax: "earwax"
126
131
  };
127
132
  var DEFAULT_PRELOAD_THRESHOLD_SECS = 15;
133
+ var DEFAULT_CROSSFADE_DURATION_SECS = 0;
134
+ var MAX_CROSSFADE_DURATION_SECS = 12;
135
+ var DEFAULT_CROSSFADE_CURVE = "qsin";
136
+ var DEFAULT_CROSSFADE_WARMUP_SECS = 5;
137
+ var CROSSFADE_DRIFT_TOLERANCE_SECS = 0.75;
128
138
  var defaultOptions = {
129
139
  plugins: [],
130
140
  emitNewSongOnly: false,
@@ -135,7 +145,12 @@ var defaultOptions = {
135
145
  joinNewVoiceChannel: true,
136
146
  unhandledError: "log",
137
147
  stuckThreshold: STUCK_THRESHOLD_MS,
138
- preload: false
148
+ preload: false,
149
+ crossfade: {
150
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
151
+ curve: DEFAULT_CROSSFADE_CURVE,
152
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
153
+ }
139
154
  };
140
155
 
141
156
  // src/core/DisTubeBase.ts
@@ -858,7 +873,7 @@ var DisTubeVoice = class extends import_tiny_typed_emitter.TypedEmitter {
858
873
  if (this.audioPlayer.state.status !== import_voice.AudioPlayerStatus.Paused) {
859
874
  this.audioPlayer.play(dtStream.audioResource);
860
875
  this.stream?.kill();
861
- dtStream.spawn();
876
+ if (!dtStream.process) dtStream.spawn();
862
877
  } else if (!this.pausingStream) {
863
878
  this.pausingStream = this.stream;
864
879
  }
@@ -1383,11 +1398,24 @@ var Queue = class extends DisTubeBase {
1383
1398
  * @internal
1384
1399
  */
1385
1400
  _preloadGeneration = 0;
1401
+ #crossfade;
1402
+ /**
1403
+ * Crossfade boundary timer
1404
+ * @internal
1405
+ */
1406
+ _crossfadeTimeout;
1407
+ /**
1408
+ * The merged transition stream that is warming up before the fade window
1409
+ * starts
1410
+ * @internal
1411
+ */
1412
+ _crossfadeWarmup;
1386
1413
  /**
1387
1414
  * Clear any preloaded song and cancel pending preload timer.
1388
1415
  * @internal
1389
1416
  */
1390
1417
  _clearPreload() {
1418
+ this._crossfadeCancel();
1391
1419
  this._preloadGeneration++;
1392
1420
  if (this._preloadTimeout) {
1393
1421
  clearTimeout(this._preloadTimeout);
@@ -1399,6 +1427,20 @@ var Queue = class extends DisTubeBase {
1399
1427
  }
1400
1428
  this._preloadedSong = void 0;
1401
1429
  }
1430
+ /**
1431
+ * Cancel a pending crossfade and kill its warming-up transition stream.
1432
+ * @internal
1433
+ */
1434
+ _crossfadeCancel() {
1435
+ if (this._crossfadeTimeout) {
1436
+ clearTimeout(this._crossfadeTimeout);
1437
+ this._crossfadeTimeout = void 0;
1438
+ }
1439
+ if (this._crossfadeWarmup) {
1440
+ this._crossfadeWarmup.kill();
1441
+ this._crossfadeWarmup = void 0;
1442
+ }
1443
+ }
1402
1444
  /**
1403
1445
  * Create a queue for the guild
1404
1446
  * @param distube - DisTube
@@ -1427,6 +1469,7 @@ var Queue = class extends DisTubeBase {
1427
1469
  input: { ...this.options.ffmpeg.args.input },
1428
1470
  output: { ...this.options.ffmpeg.args.output }
1429
1471
  };
1472
+ this.#crossfade = Number(this.options.crossfade?.duration) || 0;
1430
1473
  this.#initialized = true;
1431
1474
  }
1432
1475
  #addToPreviousSongs(songs) {
@@ -1485,6 +1528,28 @@ var Queue = class extends DisTubeBase {
1485
1528
  get currentTime() {
1486
1529
  return this.voice.playbackTime;
1487
1530
  }
1531
+ /**
1532
+ * The crossfade duration (in seconds) between this song and the next one.
1533
+ *
1534
+ * `0` (the default) disables the crossfade. Changing it takes effect for the
1535
+ * next transition; call {@link Queue#scheduleCrossfade} to re-arm the
1536
+ * transition for the currently playing song immediately.
1537
+ * @throws {@link DisTubeError} When the value is not a number between `0`
1538
+ * and the maximum crossfade duration
1539
+ */
1540
+ get crossfade() {
1541
+ return this.#crossfade;
1542
+ }
1543
+ set crossfade(value) {
1544
+ if (typeof value !== "number" || Number.isNaN(value)) {
1545
+ throw new DisTubeError("INVALID_TYPE", "number", value, "Queue.crossfade");
1546
+ }
1547
+ if (value < 0) throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "bigger or equal to", 0);
1548
+ if (value > MAX_CROSSFADE_DURATION_SECS) {
1549
+ throw new DisTubeError("NUMBER_COMPARE", "Crossfade", "smaller or equal to", MAX_CROSSFADE_DURATION_SECS);
1550
+ }
1551
+ this.#crossfade = value;
1552
+ }
1488
1553
  /**
1489
1554
  * Formatted {@link Queue#currentTime} string.
1490
1555
  */
@@ -1586,6 +1651,7 @@ var Queue = class extends DisTubeBase {
1586
1651
  this.voice.unpause();
1587
1652
  this.emit("resumeSong" /* RESUME_SONG */, this, this.songs[0]);
1588
1653
  this.queues?.schedulePreload?.(this);
1654
+ this.queues?.scheduleCrossfade?.(this);
1589
1655
  return this;
1590
1656
  } finally {
1591
1657
  this._taskQueue.resolve();
@@ -1842,6 +1908,17 @@ var Queue = class extends DisTubeBase {
1842
1908
  if (this.stopped) throw new DisTubeError("QUEUE_STOPPED");
1843
1909
  return this.queues.playSong(this, emitPlaySong);
1844
1910
  }
1911
+ /**
1912
+ * Re-arm the crossfade into the next song for the currently playing one.
1913
+ *
1914
+ * Useful after changing {@link Queue#crossfade} while a song is playing: the
1915
+ * fade may still fit into the remaining time. This is called automatically
1916
+ * after every song switch.
1917
+ * @returns Nothing
1918
+ */
1919
+ scheduleCrossfade() {
1920
+ this.queues.scheduleCrossfade(this);
1921
+ }
1845
1922
  /**
1846
1923
  * Restart the current stream with the current FFmpeg arguments, keeping the
1847
1924
  * playback position.
@@ -2494,6 +2571,11 @@ var Options = class {
2494
2571
  * @default { enabled: false, threshold: 15 }
2495
2572
  */
2496
2573
  preload;
2574
+ /**
2575
+ * The crossfade options of the DisTube instance.
2576
+ * @default { duration: 0, curve: "qsin", warmup: 5 }
2577
+ */
2578
+ crossfade;
2497
2579
  /**
2498
2580
  * The FFmpeg options of the DisTube instance.
2499
2581
  */
@@ -2520,6 +2602,7 @@ var Options = class {
2520
2602
  this.unhandledError = opts.unhandledError;
2521
2603
  this.stuckThreshold = opts.stuckThreshold;
2522
2604
  this.preload = this.#preloadOption(opts.preload);
2605
+ this.crossfade = this.#crossfadeOption(opts.crossfade);
2523
2606
  this.ffmpeg = this.#ffmpegOption(options);
2524
2607
  checkInvalidKey(opts, this, "DisTubeOptions");
2525
2608
  this.#validateOptions();
@@ -2547,6 +2630,47 @@ var Options = class {
2547
2630
  threshold: threshold ?? DEFAULT_PRELOAD_THRESHOLD_SECS
2548
2631
  };
2549
2632
  }
2633
+ #crossfadeOption(value) {
2634
+ if (value === void 0) {
2635
+ return {
2636
+ duration: DEFAULT_CROSSFADE_DURATION_SECS,
2637
+ curve: DEFAULT_CROSSFADE_CURVE,
2638
+ warmup: DEFAULT_CROSSFADE_WARMUP_SECS
2639
+ };
2640
+ }
2641
+ if (typeof value !== "object" || Array.isArray(value)) {
2642
+ throw new DisTubeError("INVALID_TYPE", "object", value, "DisTubeOptions.crossfade");
2643
+ }
2644
+ checkInvalidKey(value, ["duration", "curve", "warmup"], "DisTubeOptions.crossfade");
2645
+ const { duration, curve, warmup } = value;
2646
+ if (duration !== void 0 && (typeof duration !== "number" || Number.isNaN(duration) || duration < 0)) {
2647
+ throw new DisTubeError(
2648
+ "INVALID_TYPE",
2649
+ `number >= 0 (max ${MAX_CROSSFADE_DURATION_SECS})`,
2650
+ duration,
2651
+ "DisTubeOptions.crossfade.duration"
2652
+ );
2653
+ }
2654
+ if (duration !== void 0 && duration > MAX_CROSSFADE_DURATION_SECS) {
2655
+ throw new DisTubeError(
2656
+ "NUMBER_COMPARE",
2657
+ "Crossfade duration",
2658
+ "smaller or equal to",
2659
+ MAX_CROSSFADE_DURATION_SECS
2660
+ );
2661
+ }
2662
+ if (curve !== void 0 && (typeof curve !== "string" || !curve)) {
2663
+ throw new DisTubeError("INVALID_TYPE", "string", curve, "DisTubeOptions.crossfade.curve");
2664
+ }
2665
+ if (warmup !== void 0 && (typeof warmup !== "number" || Number.isNaN(warmup) || warmup < 0)) {
2666
+ throw new DisTubeError("INVALID_TYPE", "number >= 0", warmup, "DisTubeOptions.crossfade.warmup");
2667
+ }
2668
+ return {
2669
+ duration: duration ?? DEFAULT_CROSSFADE_DURATION_SECS,
2670
+ curve: curve ?? DEFAULT_CROSSFADE_CURVE,
2671
+ warmup: warmup ?? DEFAULT_CROSSFADE_WARMUP_SECS
2672
+ };
2673
+ }
2550
2674
  #validateOptions(options = this) {
2551
2675
  const booleanOptions = /* @__PURE__ */ new Set([
2552
2676
  "emitNewSongOnly",
@@ -2558,7 +2682,7 @@ var Options = class {
2558
2682
  ]);
2559
2683
  const numberOptions = /* @__PURE__ */ new Set(["stuckThreshold"]);
2560
2684
  const stringOptions = /* @__PURE__ */ new Set(["unhandledError"]);
2561
- const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg", "preload"]);
2685
+ const objectOptions = /* @__PURE__ */ new Set(["customFilters", "ffmpeg", "preload", "crossfade"]);
2562
2686
  const optionalOptions = /* @__PURE__ */ new Set(["customFilters"]);
2563
2687
  for (const [key, value] of Object.entries(options)) {
2564
2688
  if (value === void 0 && optionalOptions.has(key)) continue;
@@ -2669,6 +2793,17 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2669
2793
  * The seek time in seconds that this stream started from
2670
2794
  */
2671
2795
  seekTime;
2796
+ /**
2797
+ * Whether FFmpeg has already produced audio that is waiting to be read.
2798
+ *
2799
+ * The stream is spawned ahead of the playback (crossfade warmup) and the
2800
+ * pipe backs up once its buffers are full, so this is `true` as soon as the
2801
+ * first frame exists. A stream that is still `false` at playback time would
2802
+ * leave the audio player buffering until FFmpeg catches up.
2803
+ */
2804
+ get hasBufferedAudio() {
2805
+ return this.stream.readableLength > 0;
2806
+ }
2672
2807
  /**
2673
2808
  * Create a DisTubeStream to play with {@link DisTubeVoice}
2674
2809
  * @param url - Stream URL
@@ -2676,8 +2811,40 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2676
2811
  */
2677
2812
  constructor(url, options) {
2678
2813
  super();
2679
- const { ffmpeg, seek } = options;
2814
+ const { ffmpeg, seek, crossfade } = options;
2680
2815
  this.seekTime = typeof seek === "number" && seek > 0 ? seek : 0;
2816
+ this.#ffmpegPath = ffmpeg.path;
2817
+ this.#opts = crossfade ? this.#crossfadeArgs(url, ffmpeg, crossfade) : this.#singleInputArgs(url, ffmpeg, seek);
2818
+ this.stream = new VolumeTransformer();
2819
+ this.stream.on("close", () => this.kill()).on("error", (err) => {
2820
+ this.debug(`[stream] error: ${err.message}`);
2821
+ this.emit("error", err);
2822
+ }).on("finish", () => this.debug("[stream] log: stream finished"));
2823
+ this.audioResource = (0, import_voice2.createAudioResource)(this.stream, {
2824
+ inputType: import_voice2.StreamType.Raw,
2825
+ inlineVolume: false
2826
+ });
2827
+ }
2828
+ #flattenArgs(args) {
2829
+ return Object.entries(args).flatMap(
2830
+ ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2831
+ ).flat();
2832
+ }
2833
+ /**
2834
+ * Translate a `file:` URL into a local filesystem path that FFmpeg accepts.
2835
+ *
2836
+ * `file:///C:/x` loses its drive-letter leading slash on Windows (`C:/x`),
2837
+ * UNC shares keep their `//host` prefix, POSIX paths stay untouched, and
2838
+ * other protocols return `undefined`.
2839
+ */
2840
+ #localPath(url) {
2841
+ const fileUrl = new URL(url);
2842
+ if (fileUrl.protocol !== "file:") return void 0;
2843
+ const pathname = decodeURIComponent(fileUrl.pathname);
2844
+ if (fileUrl.hostname) return `//${fileUrl.hostname}${pathname}`;
2845
+ return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
2846
+ }
2847
+ #singleInputArgs(url, ffmpeg, seek) {
2681
2848
  const opts = {
2682
2849
  reconnect: 1,
2683
2850
  reconnect_streamed: 1,
@@ -2693,29 +2860,91 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2693
2860
  f: "s16le"
2694
2861
  };
2695
2862
  if (typeof seek === "number" && seek > 0) opts.ss = seek.toString();
2696
- const fileUrl = new URL(url);
2697
- if (fileUrl.protocol === "file:") {
2863
+ const localPath = this.#localPath(url);
2864
+ if (localPath !== void 0) {
2698
2865
  opts.reconnect = null;
2699
2866
  opts.reconnect_streamed = null;
2700
2867
  opts.reconnect_delay_max = null;
2701
- opts.i = fileUrl.hostname + fileUrl.pathname;
2868
+ opts.i = localPath;
2702
2869
  }
2703
- this.#ffmpegPath = ffmpeg.path;
2704
- this.#opts = [
2705
- ...Object.entries(opts).flatMap(
2706
- ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2707
- ).flat(),
2870
+ return [...this.#flattenArgs(opts), "pipe:1"];
2871
+ }
2872
+ /**
2873
+ * Build the arguments of a crossfade transition stream: a single FFmpeg
2874
+ * process with two inputs (the fading out song seeked to its final
2875
+ * `duration` seconds and the fading in song from its beginning) mixed by an
2876
+ * `acrossfade` filter graph.
2877
+ *
2878
+ * Both inputs are normalized (48kHz stereo s16) before the fade, and the
2879
+ * queue's `af` filter chain, when present, is folded into the filter complex
2880
+ * after the fade (FFmpeg cannot combine `-af` with `-filter_complex`).
2881
+ */
2882
+ #crossfadeArgs(url, ffmpeg, crossfade) {
2883
+ const {
2884
+ duration,
2885
+ primarySeek,
2886
+ secondaryUrl,
2887
+ outCurve = DEFAULT_CROSSFADE_CURVE,
2888
+ inCurve = DEFAULT_CROSSFADE_CURVE
2889
+ } = crossfade;
2890
+ const outputArgs = { ...ffmpeg.args.output };
2891
+ const extraFilters = typeof outputArgs.af === "string" ? outputArgs.af : Array.isArray(outputArgs.af) ? outputArgs.af.filter(Boolean).join(",") : void 0;
2892
+ delete outputArgs.af;
2893
+ const primaryOpts = {
2894
+ reconnect: 1,
2895
+ reconnect_streamed: 1,
2896
+ reconnect_delay_max: 5,
2897
+ analyzeduration: 0,
2898
+ hide_banner: true,
2899
+ ...ffmpeg.args.global,
2900
+ ...ffmpeg.args.input,
2901
+ ss: typeof primarySeek === "number" && primarySeek > 0 ? primarySeek.toString() : null,
2902
+ i: url
2903
+ };
2904
+ const secondaryOpts = {
2905
+ reconnect: 1,
2906
+ reconnect_streamed: 1,
2907
+ reconnect_delay_max: 5,
2908
+ analyzeduration: 0,
2909
+ ...ffmpeg.args.input,
2910
+ i: secondaryUrl
2911
+ };
2912
+ const primaryLocal = this.#localPath(url);
2913
+ if (primaryLocal !== void 0) {
2914
+ primaryOpts.reconnect = null;
2915
+ primaryOpts.reconnect_streamed = null;
2916
+ primaryOpts.reconnect_delay_max = null;
2917
+ primaryOpts.i = primaryLocal;
2918
+ }
2919
+ const secondaryLocal = this.#localPath(secondaryUrl);
2920
+ if (secondaryLocal !== void 0) {
2921
+ secondaryOpts.reconnect = null;
2922
+ secondaryOpts.reconnect_streamed = null;
2923
+ secondaryOpts.reconnect_delay_max = null;
2924
+ secondaryOpts.i = secondaryLocal;
2925
+ }
2926
+ const normalize = `aresample=${AUDIO_SAMPLE_RATE},aformat=sample_fmts=s16:channel_layouts=stereo`;
2927
+ const fade = `[a0][a1]acrossfade=d=${duration}:c1=${outCurve}:c2=${inCurve}[xf]`;
2928
+ const graph = [
2929
+ `[0:a]${normalize}[a0]`,
2930
+ `[1:a]${normalize}[a1]`,
2931
+ fade,
2932
+ ...extraFilters ? [`[xf]${extraFilters}[aout]`] : []
2933
+ ].join(";");
2934
+ const outputOpts = {
2935
+ ar: AUDIO_SAMPLE_RATE,
2936
+ ac: AUDIO_CHANNELS,
2937
+ ...outputArgs,
2938
+ filter_complex: graph,
2939
+ map: extraFilters ? "[aout]" : "[xf]",
2940
+ f: "s16le"
2941
+ };
2942
+ return [
2943
+ ...this.#flattenArgs(primaryOpts),
2944
+ ...this.#flattenArgs(secondaryOpts),
2945
+ ...this.#flattenArgs(outputOpts),
2708
2946
  "pipe:1"
2709
2947
  ];
2710
- this.stream = new VolumeTransformer();
2711
- this.stream.on("close", () => this.kill()).on("error", (err) => {
2712
- this.debug(`[stream] error: ${err.message}`);
2713
- this.emit("error", err);
2714
- }).on("finish", () => this.debug("[stream] log: stream finished"));
2715
- this.audioResource = (0, import_voice2.createAudioResource)(this.stream, {
2716
- inputType: import_voice2.StreamType.Raw,
2717
- inlineVolume: false
2718
- });
2719
2948
  }
2720
2949
  /**
2721
2950
  * Spawn the FFmpeg process that decodes the audio.
@@ -3081,6 +3310,7 @@ var QueueManager = class extends GuildIdManager {
3081
3310
  await queue.voice.play(dtStream);
3082
3311
  if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
3083
3312
  this.schedulePreload(queue);
3313
+ this.scheduleCrossfade(queue);
3084
3314
  } catch (e) {
3085
3315
  const error = e instanceof Error ? e : new Error(String(e));
3086
3316
  if (options?.keepSongOnError) {
@@ -3182,6 +3412,168 @@ var QueueManager = class extends GuildIdManager {
3182
3412
  this.emit("preloadError" /* PRELOAD_ERROR */, queue, nextSong, error);
3183
3413
  }
3184
3414
  }
3415
+ /**
3416
+ * Schedule the crossfade into the next song: a single FFmpeg process mixes
3417
+ * the current song (seeked to its final fade window) with the next song, and
3418
+ * replaces the playback stream exactly when the fade window begins.
3419
+ *
3420
+ * Canceled automatically whenever the preloaded state is invalidated
3421
+ * (pause, seek, stop...), as both schedulers share the same generation.
3422
+ * @param queue - The guild queue
3423
+ */
3424
+ scheduleCrossfade(queue) {
3425
+ queue._crossfadeCancel();
3426
+ if (queue.stopped || queue.paused) return;
3427
+ const currentSong = queue.songs[0];
3428
+ if (!currentSong) return;
3429
+ const songDuration = currentSong.duration ?? 0;
3430
+ if (!songDuration || !Number.isFinite(songDuration)) return;
3431
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3432
+ if (fade <= 0) return;
3433
+ const nextSong = queue.repeatMode === 1 /* SONG */ ? currentSong : queue.songs[1];
3434
+ if (!nextSong) return;
3435
+ const currentTime = queue.currentTime;
3436
+ if (!Number.isFinite(currentTime)) return;
3437
+ const remaining = songDuration - currentTime;
3438
+ if (remaining <= fade) return;
3439
+ const delayMs = Math.max(0, (remaining - fade - this.options.crossfade.warmup) * 1e3);
3440
+ this.debug(
3441
+ `[${queue.id}] Scheduling crossfade (${fade}s fade) in ${(delayMs / 1e3).toFixed(1)}s: ${currentSong} -> ${nextSong}`
3442
+ );
3443
+ queue._crossfadeTimeout = setTimeout(() => {
3444
+ queue._crossfadeTimeout = void 0;
3445
+ void this.#crossfadeWarmupTask(queue);
3446
+ }, delayMs);
3447
+ queue._crossfadeTimeout.unref?.();
3448
+ }
3449
+ /**
3450
+ * Spawn the merged transition stream ahead of the fade window so both of
3451
+ * its inputs are buffered when the fade must start, then arm the boundary
3452
+ * timer that swaps the playback stream.
3453
+ */
3454
+ async #crossfadeWarmupTask(queue) {
3455
+ let transitionStream;
3456
+ try {
3457
+ if (queue.stopped || queue.paused) return;
3458
+ const song = queue.songs[0];
3459
+ const generation = queue._preloadGeneration;
3460
+ const repeatSong = queue.repeatMode === 1 /* SONG */;
3461
+ const nextSong = repeatSong ? song : queue.songs[1];
3462
+ if (!song || !nextSong) return;
3463
+ const songDuration = song.duration ?? 0;
3464
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3465
+ if (fade <= 0) return;
3466
+ const remaining = songDuration - queue.currentTime;
3467
+ if (remaining <= fade) return;
3468
+ if (!repeatSong) {
3469
+ if (this.options.preload.enabled) await this.preloadNextSong(queue);
3470
+ else await this.handler.attachStreamInfo(nextSong);
3471
+ }
3472
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) return;
3473
+ if (repeatSong ? queue.songs[0] !== song : queue.songs[1] !== nextSong) return;
3474
+ const primary = song.stream.playFromSource ? song : song.stream.song;
3475
+ const secondary = repeatSong ? primary : nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
3476
+ if (!primary?.stream.playFromSource || !primary.stream.url) return;
3477
+ if (!secondary?.stream.playFromSource || !secondary.stream.url) return;
3478
+ this.debug(`[${queue.id}] Warming up crossfade stream: ${song} -> ${nextSong}`);
3479
+ transitionStream = new DisTubeStream(primary.stream.url, {
3480
+ ffmpeg: {
3481
+ path: this.options.ffmpeg.path,
3482
+ args: {
3483
+ global: { ...queue.ffmpegArgs.global },
3484
+ input: { ...queue.ffmpegArgs.input },
3485
+ output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
3486
+ }
3487
+ },
3488
+ crossfade: {
3489
+ duration: fade,
3490
+ primarySeek: Math.max(0, songDuration - fade),
3491
+ secondaryUrl: secondary.stream.url,
3492
+ inCurve: this.options.crossfade.curve,
3493
+ outCurve: this.options.crossfade.curve
3494
+ }
3495
+ });
3496
+ transitionStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [crossfade] ${data}`));
3497
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) {
3498
+ transitionStream.kill();
3499
+ return;
3500
+ }
3501
+ queue._crossfadeWarmup = transitionStream;
3502
+ transitionStream.spawn();
3503
+ const boundaryMs = Math.max(0, (songDuration - queue.currentTime - fade) * 1e3);
3504
+ queue._crossfadeTimeout = setTimeout(() => {
3505
+ queue._crossfadeTimeout = void 0;
3506
+ void this.#crossfadeSwapTask(queue, transitionStream);
3507
+ }, boundaryMs);
3508
+ queue._crossfadeTimeout.unref?.();
3509
+ } catch (e) {
3510
+ const errorMessage = e instanceof Error ? e.message : String(e);
3511
+ this.debug(`[${queue.id}] Crossfade warmup failed, using the regular transition: ${errorMessage}`);
3512
+ if (transitionStream) transitionStream.kill();
3513
+ }
3514
+ }
3515
+ /**
3516
+ * Swap the playing stream for the merged transition stream and update the
3517
+ * queue as if the current song had just finished.
3518
+ */
3519
+ async #crossfadeSwapTask(queue, transitionStream) {
3520
+ const abort = /* @__PURE__ */ __name(() => {
3521
+ if (queue._crossfadeWarmup === transitionStream) queue._crossfadeWarmup = void 0;
3522
+ transitionStream.kill();
3523
+ }, "abort");
3524
+ try {
3525
+ if (queue.stopped || queue.paused || queue._crossfadeWarmup !== transitionStream) return abort();
3526
+ const song = queue.songs[0];
3527
+ if (!song) return abort();
3528
+ const songDuration = song.duration ?? 0;
3529
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3530
+ if (fade <= 0) return abort();
3531
+ const drift = queue.currentTime - (songDuration - fade);
3532
+ if (Math.abs(drift) > CROSSFADE_DRIFT_TOLERANCE_SECS) {
3533
+ this.debug(`[${queue.id}] Crossfade aborted: playback drifted ${drift.toFixed(2)}s from the fade boundary`);
3534
+ return abort();
3535
+ }
3536
+ await queue._taskQueue.queuing();
3537
+ try {
3538
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || queue._crossfadeWarmup !== transitionStream) {
3539
+ return abort();
3540
+ }
3541
+ if (!transitionStream.hasBufferedAudio) {
3542
+ this.debug(`[${queue.id}] Crossfade aborted: the transition stream is not ready`);
3543
+ return abort();
3544
+ }
3545
+ this.debug(`[${queue.id}] Starting crossfade: ${song} -> ${queue.songs.length > 1 ? queue.songs[1] : song}`);
3546
+ await queue.voice.play(transitionStream);
3547
+ queue._crossfadeWarmup = void 0;
3548
+ this.emit("finishSong" /* FINISH_SONG */, queue, song);
3549
+ if (queue.repeatMode === 2 /* QUEUE */) queue.songs.push(song);
3550
+ if (queue.repeatMode !== 1 /* SONG */) {
3551
+ const prev = queue.songs.shift();
3552
+ if (this.options.savePreviousSongs) queue.previousSongs.push(prev);
3553
+ else queue.previousSongs.push({ id: prev.id });
3554
+ } else {
3555
+ }
3556
+ if (song !== queue.songs[0]) {
3557
+ const playedSong = song.stream.playFromSource ? song : song.stream.song;
3558
+ if (playedSong?.stream.playFromSource) delete playedSong.stream.url;
3559
+ }
3560
+ if (queue._preloadedStream) {
3561
+ queue._preloadedStream.kill();
3562
+ queue._preloadedStream = void 0;
3563
+ queue._preloadedSong = void 0;
3564
+ }
3565
+ this.emit("playSong" /* PLAY_SONG */, queue, queue.songs[0]);
3566
+ this.schedulePreload(queue);
3567
+ this.scheduleCrossfade(queue);
3568
+ } finally {
3569
+ queue._taskQueue.resolve();
3570
+ }
3571
+ } catch (e) {
3572
+ abort();
3573
+ const errorMessage = e instanceof Error ? e.message : String(e);
3574
+ this.debug(`[${queue.id}] Crossfade swap failed, using the regular transition: ${errorMessage}`);
3575
+ }
3576
+ }
3185
3577
  };
3186
3578
 
3187
3579
  // src/core/StreamServer.ts
@@ -4196,6 +4588,10 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
4196
4588
  AUDIO_CHANNELS,
4197
4589
  AUDIO_SAMPLE_RATE,
4198
4590
  BaseManager,
4591
+ CROSSFADE_DRIFT_TOLERANCE_SECS,
4592
+ DEFAULT_CROSSFADE_CURVE,
4593
+ DEFAULT_CROSSFADE_DURATION_SECS,
4594
+ DEFAULT_CROSSFADE_WARMUP_SECS,
4199
4595
  DEFAULT_PRELOAD_THRESHOLD_SECS,
4200
4596
  DEFAULT_VOLUME,
4201
4597
  DisTube,
@@ -4212,6 +4608,7 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
4212
4608
  HTTP_REDIRECT_CODES,
4213
4609
  InfoExtractorPlugin,
4214
4610
  JOIN_TIMEOUT_MS,
4611
+ MAX_CROSSFADE_DURATION_SECS,
4215
4612
  MAX_REDIRECT_DEPTH,
4216
4613
  Options,
4217
4614
  PlayableExtractorPlugin,