@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.d.mts +177 -2
- package/dist/index.d.ts +177 -2
- package/dist/index.js +404 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +399 -22
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
5
|
+
var version = "6.2.0";
|
|
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;
|
|
@@ -2588,8 +2707,40 @@ var DisTubeStream = class extends TypedEmitter2 {
|
|
|
2588
2707
|
*/
|
|
2589
2708
|
constructor(url, options) {
|
|
2590
2709
|
super();
|
|
2591
|
-
const { ffmpeg, seek } = options;
|
|
2710
|
+
const { ffmpeg, seek, crossfade } = options;
|
|
2592
2711
|
this.seekTime = typeof seek === "number" && seek > 0 ? seek : 0;
|
|
2712
|
+
this.#ffmpegPath = ffmpeg.path;
|
|
2713
|
+
this.#opts = crossfade ? this.#crossfadeArgs(url, ffmpeg, crossfade) : this.#singleInputArgs(url, ffmpeg, seek);
|
|
2714
|
+
this.stream = new VolumeTransformer();
|
|
2715
|
+
this.stream.on("close", () => this.kill()).on("error", (err) => {
|
|
2716
|
+
this.debug(`[stream] error: ${err.message}`);
|
|
2717
|
+
this.emit("error", err);
|
|
2718
|
+
}).on("finish", () => this.debug("[stream] log: stream finished"));
|
|
2719
|
+
this.audioResource = createAudioResource(this.stream, {
|
|
2720
|
+
inputType: StreamType.Raw,
|
|
2721
|
+
inlineVolume: false
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
#flattenArgs(args) {
|
|
2725
|
+
return Object.entries(args).flatMap(
|
|
2726
|
+
([key, value]) => Array.isArray(value) ? value.filter(Boolean).map((v) => [`-${key}`, String(v)]) : value == null || value === false ? [] : [value === true ? `-${key}` : [`-${key}`, String(value)]]
|
|
2727
|
+
).flat();
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Translate a `file:` URL into a local filesystem path that FFmpeg accepts.
|
|
2731
|
+
*
|
|
2732
|
+
* `file:///C:/x` loses its drive-letter leading slash on Windows (`C:/x`),
|
|
2733
|
+
* UNC shares keep their `//host` prefix, POSIX paths stay untouched, and
|
|
2734
|
+
* other protocols return `undefined`.
|
|
2735
|
+
*/
|
|
2736
|
+
#localPath(url) {
|
|
2737
|
+
const fileUrl = new URL(url);
|
|
2738
|
+
if (fileUrl.protocol !== "file:") return void 0;
|
|
2739
|
+
const pathname = decodeURIComponent(fileUrl.pathname);
|
|
2740
|
+
if (fileUrl.hostname) return `//${fileUrl.hostname}${pathname}`;
|
|
2741
|
+
return /^\/[A-Za-z]:\//.test(pathname) ? pathname.slice(1) : pathname;
|
|
2742
|
+
}
|
|
2743
|
+
#singleInputArgs(url, ffmpeg, seek) {
|
|
2593
2744
|
const opts = {
|
|
2594
2745
|
reconnect: 1,
|
|
2595
2746
|
reconnect_streamed: 1,
|
|
@@ -2605,29 +2756,91 @@ var DisTubeStream = class extends TypedEmitter2 {
|
|
|
2605
2756
|
f: "s16le"
|
|
2606
2757
|
};
|
|
2607
2758
|
if (typeof seek === "number" && seek > 0) opts.ss = seek.toString();
|
|
2608
|
-
const
|
|
2609
|
-
if (
|
|
2759
|
+
const localPath = this.#localPath(url);
|
|
2760
|
+
if (localPath !== void 0) {
|
|
2610
2761
|
opts.reconnect = null;
|
|
2611
2762
|
opts.reconnect_streamed = null;
|
|
2612
2763
|
opts.reconnect_delay_max = null;
|
|
2613
|
-
opts.i =
|
|
2764
|
+
opts.i = localPath;
|
|
2614
2765
|
}
|
|
2615
|
-
this.#
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2766
|
+
return [...this.#flattenArgs(opts), "pipe:1"];
|
|
2767
|
+
}
|
|
2768
|
+
/**
|
|
2769
|
+
* Build the arguments of a crossfade transition stream: a single FFmpeg
|
|
2770
|
+
* process with two inputs (the fading out song seeked to its final
|
|
2771
|
+
* `duration` seconds and the fading in song from its beginning) mixed by an
|
|
2772
|
+
* `acrossfade` filter graph.
|
|
2773
|
+
*
|
|
2774
|
+
* Both inputs are normalized (48kHz stereo s16) before the fade, and the
|
|
2775
|
+
* queue's `af` filter chain, when present, is folded into the filter complex
|
|
2776
|
+
* after the fade (FFmpeg cannot combine `-af` with `-filter_complex`).
|
|
2777
|
+
*/
|
|
2778
|
+
#crossfadeArgs(url, ffmpeg, crossfade) {
|
|
2779
|
+
const {
|
|
2780
|
+
duration,
|
|
2781
|
+
primarySeek,
|
|
2782
|
+
secondaryUrl,
|
|
2783
|
+
outCurve = DEFAULT_CROSSFADE_CURVE,
|
|
2784
|
+
inCurve = DEFAULT_CROSSFADE_CURVE
|
|
2785
|
+
} = crossfade;
|
|
2786
|
+
const outputArgs = { ...ffmpeg.args.output };
|
|
2787
|
+
const extraFilters = typeof outputArgs.af === "string" ? outputArgs.af : Array.isArray(outputArgs.af) ? outputArgs.af.filter(Boolean).join(",") : void 0;
|
|
2788
|
+
delete outputArgs.af;
|
|
2789
|
+
const primaryOpts = {
|
|
2790
|
+
reconnect: 1,
|
|
2791
|
+
reconnect_streamed: 1,
|
|
2792
|
+
reconnect_delay_max: 5,
|
|
2793
|
+
analyzeduration: 0,
|
|
2794
|
+
hide_banner: true,
|
|
2795
|
+
...ffmpeg.args.global,
|
|
2796
|
+
...ffmpeg.args.input,
|
|
2797
|
+
ss: typeof primarySeek === "number" && primarySeek > 0 ? primarySeek.toString() : null,
|
|
2798
|
+
i: url
|
|
2799
|
+
};
|
|
2800
|
+
const secondaryOpts = {
|
|
2801
|
+
reconnect: 1,
|
|
2802
|
+
reconnect_streamed: 1,
|
|
2803
|
+
reconnect_delay_max: 5,
|
|
2804
|
+
analyzeduration: 0,
|
|
2805
|
+
...ffmpeg.args.input,
|
|
2806
|
+
i: secondaryUrl
|
|
2807
|
+
};
|
|
2808
|
+
const primaryLocal = this.#localPath(url);
|
|
2809
|
+
if (primaryLocal !== void 0) {
|
|
2810
|
+
primaryOpts.reconnect = null;
|
|
2811
|
+
primaryOpts.reconnect_streamed = null;
|
|
2812
|
+
primaryOpts.reconnect_delay_max = null;
|
|
2813
|
+
primaryOpts.i = primaryLocal;
|
|
2814
|
+
}
|
|
2815
|
+
const secondaryLocal = this.#localPath(secondaryUrl);
|
|
2816
|
+
if (secondaryLocal !== void 0) {
|
|
2817
|
+
secondaryOpts.reconnect = null;
|
|
2818
|
+
secondaryOpts.reconnect_streamed = null;
|
|
2819
|
+
secondaryOpts.reconnect_delay_max = null;
|
|
2820
|
+
secondaryOpts.i = secondaryLocal;
|
|
2821
|
+
}
|
|
2822
|
+
const normalize = `aresample=${AUDIO_SAMPLE_RATE},aformat=sample_fmts=s16:channel_layouts=stereo`;
|
|
2823
|
+
const fade = `[a0][a1]acrossfade=d=${duration}:c1=${outCurve}:c2=${inCurve}[xf]`;
|
|
2824
|
+
const graph = [
|
|
2825
|
+
`[0:a]${normalize}[a0]`,
|
|
2826
|
+
`[1:a]${normalize}[a1]`,
|
|
2827
|
+
fade,
|
|
2828
|
+
...extraFilters ? [`[xf]${extraFilters}[aout]`] : []
|
|
2829
|
+
].join(";");
|
|
2830
|
+
const outputOpts = {
|
|
2831
|
+
ar: AUDIO_SAMPLE_RATE,
|
|
2832
|
+
ac: AUDIO_CHANNELS,
|
|
2833
|
+
...outputArgs,
|
|
2834
|
+
filter_complex: graph,
|
|
2835
|
+
map: extraFilters ? "[aout]" : "[xf]",
|
|
2836
|
+
f: "s16le"
|
|
2837
|
+
};
|
|
2838
|
+
return [
|
|
2839
|
+
...this.#flattenArgs(primaryOpts),
|
|
2840
|
+
...this.#flattenArgs(secondaryOpts),
|
|
2841
|
+
...this.#flattenArgs(outputOpts),
|
|
2620
2842
|
"pipe:1"
|
|
2621
2843
|
];
|
|
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
2844
|
}
|
|
2632
2845
|
/**
|
|
2633
2846
|
* Spawn the FFmpeg process that decodes the audio.
|
|
@@ -2993,6 +3206,7 @@ var QueueManager = class extends GuildIdManager {
|
|
|
2993
3206
|
await queue.voice.play(dtStream);
|
|
2994
3207
|
if (emitPlaySong) this.emit("playSong" /* PLAY_SONG */, queue, song);
|
|
2995
3208
|
this.schedulePreload(queue);
|
|
3209
|
+
this.scheduleCrossfade(queue);
|
|
2996
3210
|
} catch (e) {
|
|
2997
3211
|
const error = e instanceof Error ? e : new Error(String(e));
|
|
2998
3212
|
if (options?.keepSongOnError) {
|
|
@@ -3094,6 +3308,164 @@ var QueueManager = class extends GuildIdManager {
|
|
|
3094
3308
|
this.emit("preloadError" /* PRELOAD_ERROR */, queue, nextSong, error);
|
|
3095
3309
|
}
|
|
3096
3310
|
}
|
|
3311
|
+
/**
|
|
3312
|
+
* Schedule the crossfade into the next song: a single FFmpeg process mixes
|
|
3313
|
+
* the current song (seeked to its final fade window) with the next song, and
|
|
3314
|
+
* replaces the playback stream exactly when the fade window begins.
|
|
3315
|
+
*
|
|
3316
|
+
* Canceled automatically whenever the preloaded state is invalidated
|
|
3317
|
+
* (pause, seek, stop...), as both schedulers share the same generation.
|
|
3318
|
+
* @param queue - The guild queue
|
|
3319
|
+
*/
|
|
3320
|
+
scheduleCrossfade(queue) {
|
|
3321
|
+
queue._crossfadeCancel();
|
|
3322
|
+
if (queue.stopped || queue.paused) return;
|
|
3323
|
+
const currentSong = queue.songs[0];
|
|
3324
|
+
if (!currentSong) return;
|
|
3325
|
+
const songDuration = currentSong.duration ?? 0;
|
|
3326
|
+
if (!songDuration || !Number.isFinite(songDuration)) return;
|
|
3327
|
+
const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
|
|
3328
|
+
if (fade <= 0) return;
|
|
3329
|
+
const nextSong = queue.repeatMode === 1 /* SONG */ ? currentSong : queue.songs[1];
|
|
3330
|
+
if (!nextSong) return;
|
|
3331
|
+
const currentTime = queue.currentTime;
|
|
3332
|
+
if (!Number.isFinite(currentTime)) return;
|
|
3333
|
+
const remaining = songDuration - currentTime;
|
|
3334
|
+
if (remaining <= fade) return;
|
|
3335
|
+
const delayMs = Math.max(0, (remaining - fade - this.options.crossfade.warmup) * 1e3);
|
|
3336
|
+
this.debug(
|
|
3337
|
+
`[${queue.id}] Scheduling crossfade (${fade}s fade) in ${(delayMs / 1e3).toFixed(1)}s: ${currentSong} -> ${nextSong}`
|
|
3338
|
+
);
|
|
3339
|
+
queue._crossfadeTimeout = setTimeout(() => {
|
|
3340
|
+
queue._crossfadeTimeout = void 0;
|
|
3341
|
+
void this.#crossfadeWarmupTask(queue);
|
|
3342
|
+
}, delayMs);
|
|
3343
|
+
queue._crossfadeTimeout.unref?.();
|
|
3344
|
+
}
|
|
3345
|
+
/**
|
|
3346
|
+
* Spawn the merged transition stream ahead of the fade window so both of
|
|
3347
|
+
* its inputs are buffered when the fade must start, then arm the boundary
|
|
3348
|
+
* timer that swaps the playback stream.
|
|
3349
|
+
*/
|
|
3350
|
+
async #crossfadeWarmupTask(queue) {
|
|
3351
|
+
let transitionStream;
|
|
3352
|
+
try {
|
|
3353
|
+
if (queue.stopped || queue.paused) return;
|
|
3354
|
+
const song = queue.songs[0];
|
|
3355
|
+
const generation = queue._preloadGeneration;
|
|
3356
|
+
const repeatSong = queue.repeatMode === 1 /* SONG */;
|
|
3357
|
+
const nextSong = repeatSong ? song : queue.songs[1];
|
|
3358
|
+
if (!song || !nextSong) return;
|
|
3359
|
+
const songDuration = song.duration ?? 0;
|
|
3360
|
+
const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
|
|
3361
|
+
if (fade <= 0) return;
|
|
3362
|
+
const remaining = songDuration - queue.currentTime;
|
|
3363
|
+
if (remaining <= fade) return;
|
|
3364
|
+
if (!repeatSong) {
|
|
3365
|
+
if (this.options.preload.enabled) await this.preloadNextSong(queue);
|
|
3366
|
+
else await this.handler.attachStreamInfo(nextSong);
|
|
3367
|
+
}
|
|
3368
|
+
if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) return;
|
|
3369
|
+
if (repeatSong ? queue.songs[0] !== song : queue.songs[1] !== nextSong) return;
|
|
3370
|
+
const primary = song.stream.playFromSource ? song : song.stream.song;
|
|
3371
|
+
const secondary = repeatSong ? primary : nextSong.stream.playFromSource ? nextSong : nextSong.stream.song;
|
|
3372
|
+
if (!primary?.stream.playFromSource || !primary.stream.url) return;
|
|
3373
|
+
if (!secondary?.stream.playFromSource || !secondary.stream.url) return;
|
|
3374
|
+
this.debug(`[${queue.id}] Warming up crossfade stream: ${song} -> ${nextSong}`);
|
|
3375
|
+
transitionStream = new DisTubeStream(primary.stream.url, {
|
|
3376
|
+
ffmpeg: {
|
|
3377
|
+
path: this.options.ffmpeg.path,
|
|
3378
|
+
args: {
|
|
3379
|
+
global: { ...queue.ffmpegArgs.global },
|
|
3380
|
+
input: { ...queue.ffmpegArgs.input },
|
|
3381
|
+
output: { ...queue.ffmpegArgs.output, ...queue.filters.ffmpegArgs }
|
|
3382
|
+
}
|
|
3383
|
+
},
|
|
3384
|
+
crossfade: {
|
|
3385
|
+
duration: fade,
|
|
3386
|
+
primarySeek: Math.max(0, songDuration - fade),
|
|
3387
|
+
secondaryUrl: secondary.stream.url,
|
|
3388
|
+
inCurve: this.options.crossfade.curve,
|
|
3389
|
+
outCurve: this.options.crossfade.curve
|
|
3390
|
+
}
|
|
3391
|
+
});
|
|
3392
|
+
transitionStream.on("debug", (data) => this.emit("ffmpegDebug" /* FFMPEG_DEBUG */, `[${queue.id}] [crossfade] ${data}`));
|
|
3393
|
+
if (queue.stopped || queue.paused || queue.songs[0] !== song || generation !== queue._preloadGeneration) {
|
|
3394
|
+
transitionStream.kill();
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
queue._crossfadeWarmup = transitionStream;
|
|
3398
|
+
transitionStream.spawn();
|
|
3399
|
+
const boundaryMs = Math.max(0, (remaining - fade) * 1e3);
|
|
3400
|
+
queue._crossfadeTimeout = setTimeout(() => {
|
|
3401
|
+
queue._crossfadeTimeout = void 0;
|
|
3402
|
+
void this.#crossfadeSwapTask(queue, transitionStream);
|
|
3403
|
+
}, boundaryMs);
|
|
3404
|
+
queue._crossfadeTimeout.unref?.();
|
|
3405
|
+
} catch (e) {
|
|
3406
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
3407
|
+
this.debug(`[${queue.id}] Crossfade warmup failed, using the regular transition: ${errorMessage}`);
|
|
3408
|
+
if (transitionStream) transitionStream.kill();
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
/**
|
|
3412
|
+
* Swap the playing stream for the merged transition stream and update the
|
|
3413
|
+
* queue as if the current song had just finished.
|
|
3414
|
+
*/
|
|
3415
|
+
async #crossfadeSwapTask(queue, transitionStream) {
|
|
3416
|
+
const abort = /* @__PURE__ */ __name(() => {
|
|
3417
|
+
if (queue._crossfadeWarmup === transitionStream) queue._crossfadeWarmup = void 0;
|
|
3418
|
+
transitionStream.kill();
|
|
3419
|
+
}, "abort");
|
|
3420
|
+
try {
|
|
3421
|
+
if (queue.stopped || queue.paused || queue._crossfadeWarmup !== transitionStream) return abort();
|
|
3422
|
+
const song = queue.songs[0];
|
|
3423
|
+
if (!song) return abort();
|
|
3424
|
+
const songDuration = song.duration ?? 0;
|
|
3425
|
+
const fade = Math.min(Number(queue.crossfade) || 0, songDuration);
|
|
3426
|
+
if (fade <= 0) return abort();
|
|
3427
|
+
const drift = queue.currentTime - (songDuration - fade);
|
|
3428
|
+
if (Math.abs(drift) > CROSSFADE_DRIFT_TOLERANCE_SECS) {
|
|
3429
|
+
this.debug(`[${queue.id}] Crossfade aborted: playback drifted ${drift.toFixed(2)}s from the fade boundary`);
|
|
3430
|
+
return abort();
|
|
3431
|
+
}
|
|
3432
|
+
await queue._taskQueue.queuing();
|
|
3433
|
+
try {
|
|
3434
|
+
if (queue.stopped || queue.paused || queue.songs[0] !== song || queue._crossfadeWarmup !== transitionStream) {
|
|
3435
|
+
return abort();
|
|
3436
|
+
}
|
|
3437
|
+
this.debug(`[${queue.id}] Starting crossfade: ${song} -> ${queue.songs.length > 1 ? queue.songs[1] : song}`);
|
|
3438
|
+
await queue.voice.play(transitionStream);
|
|
3439
|
+
queue._crossfadeWarmup = void 0;
|
|
3440
|
+
this.emit("finishSong" /* FINISH_SONG */, queue, song);
|
|
3441
|
+
if (queue.repeatMode === 2 /* QUEUE */) queue.songs.push(song);
|
|
3442
|
+
if (queue.repeatMode !== 1 /* SONG */) {
|
|
3443
|
+
const prev = queue.songs.shift();
|
|
3444
|
+
if (this.options.savePreviousSongs) queue.previousSongs.push(prev);
|
|
3445
|
+
else queue.previousSongs.push({ id: prev.id });
|
|
3446
|
+
} else {
|
|
3447
|
+
}
|
|
3448
|
+
if (song !== queue.songs[0]) {
|
|
3449
|
+
const playedSong = song.stream.playFromSource ? song : song.stream.song;
|
|
3450
|
+
if (playedSong?.stream.playFromSource) delete playedSong.stream.url;
|
|
3451
|
+
}
|
|
3452
|
+
if (queue._preloadedStream) {
|
|
3453
|
+
queue._preloadedStream.kill();
|
|
3454
|
+
queue._preloadedStream = void 0;
|
|
3455
|
+
queue._preloadedSong = void 0;
|
|
3456
|
+
}
|
|
3457
|
+
this.emit("playSong" /* PLAY_SONG */, queue, queue.songs[0]);
|
|
3458
|
+
this.schedulePreload(queue);
|
|
3459
|
+
this.scheduleCrossfade(queue);
|
|
3460
|
+
} finally {
|
|
3461
|
+
queue._taskQueue.resolve();
|
|
3462
|
+
}
|
|
3463
|
+
} catch (e) {
|
|
3464
|
+
abort();
|
|
3465
|
+
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
3466
|
+
this.debug(`[${queue.id}] Crossfade swap failed, using the regular transition: ${errorMessage}`);
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3097
3469
|
};
|
|
3098
3470
|
|
|
3099
3471
|
// src/core/StreamServer.ts
|
|
@@ -4107,6 +4479,10 @@ export {
|
|
|
4107
4479
|
AUDIO_CHANNELS,
|
|
4108
4480
|
AUDIO_SAMPLE_RATE,
|
|
4109
4481
|
BaseManager,
|
|
4482
|
+
CROSSFADE_DRIFT_TOLERANCE_SECS,
|
|
4483
|
+
DEFAULT_CROSSFADE_CURVE,
|
|
4484
|
+
DEFAULT_CROSSFADE_DURATION_SECS,
|
|
4485
|
+
DEFAULT_CROSSFADE_WARMUP_SECS,
|
|
4110
4486
|
DEFAULT_PRELOAD_THRESHOLD_SECS,
|
|
4111
4487
|
DEFAULT_VOLUME,
|
|
4112
4488
|
DisTube,
|
|
@@ -4123,6 +4499,7 @@ export {
|
|
|
4123
4499
|
HTTP_REDIRECT_CODES,
|
|
4124
4500
|
InfoExtractorPlugin,
|
|
4125
4501
|
JOIN_TIMEOUT_MS,
|
|
4502
|
+
MAX_CROSSFADE_DURATION_SECS,
|
|
4126
4503
|
MAX_REDIRECT_DEPTH,
|
|
4127
4504
|
Options,
|
|
4128
4505
|
PlayableExtractorPlugin,
|