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