@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.d.mts +186 -2
- package/dist/index.d.ts +186 -2
- package/dist/index.js +419 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +414 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
2697
|
-
if (
|
|
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 =
|
|
2868
|
+
opts.i = localPath;
|
|
2702
2869
|
}
|
|
2703
|
-
this.#
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
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,
|