@kattowo/distube 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.0";
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;
@@ -2676,8 +2800,40 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2676
2800
  */
2677
2801
  constructor(url, options) {
2678
2802
  super();
2679
- const { ffmpeg, seek } = options;
2803
+ const { ffmpeg, seek, crossfade } = options;
2680
2804
  this.seekTime = typeof seek === "number" && seek > 0 ? seek : 0;
2805
+ this.#ffmpegPath = ffmpeg.path;
2806
+ this.#opts = crossfade ? this.#crossfadeArgs(url, ffmpeg, crossfade) : this.#singleInputArgs(url, ffmpeg, seek);
2807
+ this.stream = new VolumeTransformer();
2808
+ this.stream.on("close", () => this.kill()).on("error", (err) => {
2809
+ this.debug(`[stream] error: ${err.message}`);
2810
+ this.emit("error", err);
2811
+ }).on("finish", () => this.debug("[stream] log: stream finished"));
2812
+ this.audioResource = (0, import_voice2.createAudioResource)(this.stream, {
2813
+ inputType: import_voice2.StreamType.Raw,
2814
+ inlineVolume: false
2815
+ });
2816
+ }
2817
+ #flattenArgs(args) {
2818
+ return Object.entries(args).flatMap(
2819
+ ([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
2820
+ ).flat();
2821
+ }
2822
+ /**
2823
+ * Translate a `file:` URL into a local filesystem path that FFmpeg accepts.
2824
+ *
2825
+ * `file:///C:/x` loses its drive-letter leading slash on Windows (`C:/x`),
2826
+ * UNC shares keep their `//host` prefix, POSIX paths stay untouched, and
2827
+ * other protocols return `undefined`.
2828
+ */
2829
+ #localPath(url) {
2830
+ const fileUrl = new URL(url);
2831
+ if (fileUrl.protocol !== "file:") return void 0;
2832
+ const pathname = decodeURIComponent(fileUrl.pathname);
2833
+ if (fileUrl.hostname) return `//${fileUrl.hostname}${pathname}`;
2834
+ return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
2835
+ }
2836
+ #singleInputArgs(url, ffmpeg, seek) {
2681
2837
  const opts = {
2682
2838
  reconnect: 1,
2683
2839
  reconnect_streamed: 1,
@@ -2693,29 +2849,91 @@ var DisTubeStream = class extends import_tiny_typed_emitter2.TypedEmitter {
2693
2849
  f: "s16le"
2694
2850
  };
2695
2851
  if (typeof seek === "number" && seek > 0) opts.ss = seek.toString();
2696
- const fileUrl = new URL(url);
2697
- if (fileUrl.protocol === "file:") {
2852
+ const localPath = this.#localPath(url);
2853
+ if (localPath !== void 0) {
2698
2854
  opts.reconnect = null;
2699
2855
  opts.reconnect_streamed = null;
2700
2856
  opts.reconnect_delay_max = null;
2701
- opts.i = fileUrl.hostname + fileUrl.pathname;
2857
+ opts.i = localPath;
2702
2858
  }
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(),
2859
+ return [...this.#flattenArgs(opts), "pipe:1"];
2860
+ }
2861
+ /**
2862
+ * Build the arguments of a crossfade transition stream: a single FFmpeg
2863
+ * process with two inputs (the fading out song seeked to its final
2864
+ * `duration` seconds and the fading in song from its beginning) mixed by an
2865
+ * `acrossfade` filter graph.
2866
+ *
2867
+ * Both inputs are normalized (48kHz stereo s16) before the fade, and the
2868
+ * queue's `af` filter chain, when present, is folded into the filter complex
2869
+ * after the fade (FFmpeg cannot combine `-af` with `-filter_complex`).
2870
+ */
2871
+ #crossfadeArgs(url, ffmpeg, crossfade) {
2872
+ const {
2873
+ duration,
2874
+ primarySeek,
2875
+ secondaryUrl,
2876
+ outCurve = DEFAULT_CROSSFADE_CURVE,
2877
+ inCurve = DEFAULT_CROSSFADE_CURVE
2878
+ } = crossfade;
2879
+ const outputArgs = { ...ffmpeg.args.output };
2880
+ const extraFilters = typeof outputArgs.af === "string" ? outputArgs.af : Array.isArray(outputArgs.af) ? outputArgs.af.filter(Boolean).join(",") : void 0;
2881
+ delete outputArgs.af;
2882
+ const primaryOpts = {
2883
+ reconnect: 1,
2884
+ reconnect_streamed: 1,
2885
+ reconnect_delay_max: 5,
2886
+ analyzeduration: 0,
2887
+ hide_banner: true,
2888
+ ...ffmpeg.args.global,
2889
+ ...ffmpeg.args.input,
2890
+ ss: typeof primarySeek === "number" && primarySeek > 0 ? primarySeek.toString() : null,
2891
+ i: url
2892
+ };
2893
+ const secondaryOpts = {
2894
+ reconnect: 1,
2895
+ reconnect_streamed: 1,
2896
+ reconnect_delay_max: 5,
2897
+ analyzeduration: 0,
2898
+ ...ffmpeg.args.input,
2899
+ i: secondaryUrl
2900
+ };
2901
+ const primaryLocal = this.#localPath(url);
2902
+ if (primaryLocal !== void 0) {
2903
+ primaryOpts.reconnect = null;
2904
+ primaryOpts.reconnect_streamed = null;
2905
+ primaryOpts.reconnect_delay_max = null;
2906
+ primaryOpts.i = primaryLocal;
2907
+ }
2908
+ const secondaryLocal = this.#localPath(secondaryUrl);
2909
+ if (secondaryLocal !== void 0) {
2910
+ secondaryOpts.reconnect = null;
2911
+ secondaryOpts.reconnect_streamed = null;
2912
+ secondaryOpts.reconnect_delay_max = null;
2913
+ secondaryOpts.i = secondaryLocal;
2914
+ }
2915
+ const normalize = `aresample=${AUDIO_SAMPLE_RATE},aformat=sample_fmts=s16:channel_layouts=stereo`;
2916
+ const fade = `[a0][a1]acrossfade=d=${duration}:c1=${outCurve}:c2=${inCurve}[xf]`;
2917
+ const graph = [
2918
+ `[0:a]${normalize}[a0]`,
2919
+ `[1:a]${normalize}[a1]`,
2920
+ fade,
2921
+ ...extraFilters ? [`[xf]${extraFilters}[aout]`] : []
2922
+ ].join(";");
2923
+ const outputOpts = {
2924
+ ar: AUDIO_SAMPLE_RATE,
2925
+ ac: AUDIO_CHANNELS,
2926
+ ...outputArgs,
2927
+ filter_complex: graph,
2928
+ map: extraFilters ? "[aout]" : "[xf]",
2929
+ f: "s16le"
2930
+ };
2931
+ return [
2932
+ ...this.#flattenArgs(primaryOpts),
2933
+ ...this.#flattenArgs(secondaryOpts),
2934
+ ...this.#flattenArgs(outputOpts),
2708
2935
  "pipe:1"
2709
2936
  ];
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
2937
  }
2720
2938
  /**
2721
2939
  * Spawn the FFmpeg process that decodes the audio.
@@ -3081,6 +3299,7 @@ var QueueManager = class extends GuildIdManager {
3081
3299
  await queue.voice.play(dtStream);
3082
3300
  if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
3083
3301
  this.schedulePreload(queue);
3302
+ this.scheduleCrossfade(queue);
3084
3303
  } catch (e) {
3085
3304
  const error = e instanceof Error ? e : new Error(String(e));
3086
3305
  if (options?.keepSongOnError) {
@@ -3182,6 +3401,164 @@ var QueueManager = class extends GuildIdManager {
3182
3401
  this.emit("preloadError" /* PRELOAD_ERROR */, queue, nextSong, error);
3183
3402
  }
3184
3403
  }
3404
+ /**
3405
+ * Schedule the crossfade into the next song: a single FFmpeg process mixes
3406
+ * the current song (seeked to its final fade window) with the next song, and
3407
+ * replaces the playback stream exactly when the fade window begins.
3408
+ *
3409
+ * Canceled automatically whenever the preloaded state is invalidated
3410
+ * (pause, seek, stop...), as both schedulers share the same generation.
3411
+ * @param queue - The guild queue
3412
+ */
3413
+ scheduleCrossfade(queue) {
3414
+ queue._crossfadeCancel();
3415
+ if (queue.stopped || queue.paused) return;
3416
+ const currentSong = queue.songs[0];
3417
+ if (!currentSong) return;
3418
+ const songDuration = currentSong.duration ?? 0;
3419
+ if (!songDuration || !Number.isFinite(songDuration)) return;
3420
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3421
+ if (fade <= 0) return;
3422
+ const nextSong = queue.repeatMode === 1 /* SONG */ ? currentSong : queue.songs[1];
3423
+ if (!nextSong) return;
3424
+ const currentTime = queue.currentTime;
3425
+ if (!Number.isFinite(currentTime)) return;
3426
+ const remaining = songDuration - currentTime;
3427
+ if (remaining <= fade) return;
3428
+ const delayMs = Math.max(0, (remaining - fade - this.options.crossfade.warmup) * 1e3);
3429
+ this.debug(
3430
+ `[${queue.id}] Scheduling crossfade (${fade}s fade) in ${(delayMs / 1e3).toFixed(1)}s: ${currentSong} -> ${nextSong}`
3431
+ );
3432
+ queue._crossfadeTimeout = setTimeout(() => {
3433
+ queue._crossfadeTimeout = void 0;
3434
+ void this.#crossfadeWarmupTask(queue);
3435
+ }, delayMs);
3436
+ queue._crossfadeTimeout.unref?.();
3437
+ }
3438
+ /**
3439
+ * Spawn the merged transition stream ahead of the fade window so both of
3440
+ * its inputs are buffered when the fade must start, then arm the boundary
3441
+ * timer that swaps the playback stream.
3442
+ */
3443
+ async #crossfadeWarmupTask(queue) {
3444
+ let transitionStream;
3445
+ try {
3446
+ if (queue.stopped || queue.paused) return;
3447
+ const song = queue.songs[0];
3448
+ const generation = queue._preloadGeneration;
3449
+ const repeatSong = queue.repeatMode === 1 /* SONG */;
3450
+ const nextSong = repeatSong ? song : queue.songs[1];
3451
+ if (!song || !nextSong) return;
3452
+ const songDuration = song.duration ?? 0;
3453
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3454
+ if (fade <= 0) return;
3455
+ const remaining = songDuration - queue.currentTime;
3456
+ if (remaining <= fade) return;
3457
+ if (!repeatSong) {
3458
+ if (this.options.preload.enabled) await this.preloadNextSong(queue);
3459
+ else await this.handler.attachStreamInfo(nextSong);
3460
+ }
3461
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) return;
3462
+ if (repeatSong ? queue.songs[0] !== song : queue.songs[1] !== nextSong) return;
3463
+ const primary = song.stream.playFromSource ? song : song.stream.song;
3464
+ const secondary = repeatSong ? primary : nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
3465
+ if (!primary?.stream.playFromSource || !primary.stream.url) return;
3466
+ if (!secondary?.stream.playFromSource || !secondary.stream.url) return;
3467
+ this.debug(`[${queue.id}] Warming up crossfade stream: ${song} -> ${nextSong}`);
3468
+ transitionStream = new DisTubeStream(primary.stream.url, {
3469
+ ffmpeg: {
3470
+ path: this.options.ffmpeg.path,
3471
+ args: {
3472
+ global: { ...queue.ffmpegArgs.global },
3473
+ input: { ...queue.ffmpegArgs.input },
3474
+ output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
3475
+ }
3476
+ },
3477
+ crossfade: {
3478
+ duration: fade,
3479
+ primarySeek: Math.max(0, songDuration - fade),
3480
+ secondaryUrl: secondary.stream.url,
3481
+ inCurve: this.options.crossfade.curve,
3482
+ outCurve: this.options.crossfade.curve
3483
+ }
3484
+ });
3485
+ transitionStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [crossfade] ${data}`));
3486
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) {
3487
+ transitionStream.kill();
3488
+ return;
3489
+ }
3490
+ queue._crossfadeWarmup = transitionStream;
3491
+ transitionStream.spawn();
3492
+ const boundaryMs = Math.max(0, (remaining - fade) * 1e3);
3493
+ queue._crossfadeTimeout = setTimeout(() => {
3494
+ queue._crossfadeTimeout = void 0;
3495
+ void this.#crossfadeSwapTask(queue, transitionStream);
3496
+ }, boundaryMs);
3497
+ queue._crossfadeTimeout.unref?.();
3498
+ } catch (e) {
3499
+ const errorMessage = e instanceof Error ? e.message : String(e);
3500
+ this.debug(`[${queue.id}] Crossfade warmup failed, using the regular transition: ${errorMessage}`);
3501
+ if (transitionStream) transitionStream.kill();
3502
+ }
3503
+ }
3504
+ /**
3505
+ * Swap the playing stream for the merged transition stream and update the
3506
+ * queue as if the current song had just finished.
3507
+ */
3508
+ async #crossfadeSwapTask(queue, transitionStream) {
3509
+ const abort = /* @__PURE__ */ __name(() => {
3510
+ if (queue._crossfadeWarmup === transitionStream) queue._crossfadeWarmup = void 0;
3511
+ transitionStream.kill();
3512
+ }, "abort");
3513
+ try {
3514
+ if (queue.stopped || queue.paused || queue._crossfadeWarmup !== transitionStream) return abort();
3515
+ const song = queue.songs[0];
3516
+ if (!song) return abort();
3517
+ const songDuration = song.duration ?? 0;
3518
+ const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
3519
+ if (fade <= 0) return abort();
3520
+ const drift = queue.currentTime - (songDuration - fade);
3521
+ if (Math.abs(drift) > CROSSFADE_DRIFT_TOLERANCE_SECS) {
3522
+ this.debug(`[${queue.id}] Crossfade aborted: playback drifted ${drift.toFixed(2)}s from the fade boundary`);
3523
+ return abort();
3524
+ }
3525
+ await queue._taskQueue.queuing();
3526
+ try {
3527
+ if (queue.stopped || queue.paused || queue.songs[0] !== song || queue._crossfadeWarmup !== transitionStream) {
3528
+ return abort();
3529
+ }
3530
+ this.debug(`[${queue.id}] Starting crossfade: ${song} -> ${queue.songs.length > 1 ? queue.songs[1] : song}`);
3531
+ await queue.voice.play(transitionStream);
3532
+ queue._crossfadeWarmup = void 0;
3533
+ this.emit("finishSong" /* FINISH_SONG */, queue, song);
3534
+ if (queue.repeatMode === 2 /* QUEUE */) queue.songs.push(song);
3535
+ if (queue.repeatMode !== 1 /* SONG */) {
3536
+ const prev = queue.songs.shift();
3537
+ if (this.options.savePreviousSongs) queue.previousSongs.push(prev);
3538
+ else queue.previousSongs.push({ id: prev.id });
3539
+ } else {
3540
+ }
3541
+ if (song !== queue.songs[0]) {
3542
+ const playedSong = song.stream.playFromSource ? song : song.stream.song;
3543
+ if (playedSong?.stream.playFromSource) delete playedSong.stream.url;
3544
+ }
3545
+ if (queue._preloadedStream) {
3546
+ queue._preloadedStream.kill();
3547
+ queue._preloadedStream = void 0;
3548
+ queue._preloadedSong = void 0;
3549
+ }
3550
+ this.emit("playSong" /* PLAY_SONG */, queue, queue.songs[0]);
3551
+ this.schedulePreload(queue);
3552
+ this.scheduleCrossfade(queue);
3553
+ } finally {
3554
+ queue._taskQueue.resolve();
3555
+ }
3556
+ } catch (e) {
3557
+ abort();
3558
+ const errorMessage = e instanceof Error ? e.message : String(e);
3559
+ this.debug(`[${queue.id}] Crossfade swap failed, using the regular transition: ${errorMessage}`);
3560
+ }
3561
+ }
3185
3562
  };
3186
3563
 
3187
3564
  // src/core/StreamServer.ts
@@ -4196,6 +4573,10 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
4196
4573
  AUDIO_CHANNELS,
4197
4574
  AUDIO_SAMPLE_RATE,
4198
4575
  BaseManager,
4576
+ CROSSFADE_DRIFT_TOLERANCE_SECS,
4577
+ DEFAULT_CROSSFADE_CURVE,
4578
+ DEFAULT_CROSSFADE_DURATION_SECS,
4579
+ DEFAULT_CROSSFADE_WARMUP_SECS,
4199
4580
  DEFAULT_PRELOAD_THRESHOLD_SECS,
4200
4581
  DEFAULT_VOLUME,
4201
4582
  DisTube,
@@ -4212,6 +4593,7 @@ var PlayableExtractorPlugin = class extends ExtractorPlugin {
4212
4593
  HTTP_REDIRECT_CODES,
4213
4594
  InfoExtractorPlugin,
4214
4595
  JOIN_TIMEOUT_MS,
4596
+ MAX_CROSSFADE_DURATION_SECS,
4215
4597
  MAX_REDIRECT_DEPTH,
4216
4598
  Options,
4217
4599
  PlayableExtractorPlugin,