diff --git a/CHANGELOG.md b/CHANGELOG.md index c2d2ad1..0151d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Muse now normalizes playback volume across tracks. Thanks to @UniversalSuperBox for sponsoring this feature! ## [2.2.4] - 2023-04-17 ### Fixed diff --git a/README.md b/README.md index 1300a07..d474a53 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Muse is a **highly-opinionated midwestern self-hosted** Discord music bot **that - ↔️ Autoconverts playlists / artists / albums / songs from Spotify - ↗️ Users can add custom shortcuts (aliases) - 1️⃣ Muse instance supports multiple guilds +- 🔊 Normalizes volume across tracks - ✍️ Written in TypeScript, easily extendable - ❤️ Loyal Packers fan diff --git a/src/services/player.ts b/src/services/player.ts index 9c1a122..239953d 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -1,7 +1,7 @@ import {VoiceChannel, Snowflake} from 'discord.js'; import {Readable} from 'stream'; import hasha from 'hasha'; -import ytdl from 'ytdl-core'; +import ytdl, {videoFormat} from 'ytdl-core'; import {WriteStream} from 'fs-capacitor'; import ffmpeg from 'fluent-ffmpeg'; import shuffle from 'array-shuffle'; @@ -56,6 +56,8 @@ export interface PlayerEvents { statusChange: (oldStatus: STATUS, newStatus: STATUS) => void; } +type YTDLVideoFormat = videoFormat & {loudnessDb?: number}; + export default class { public voiceConnection: VoiceConnection | null = null; public status = STATUS.PAUSED; @@ -415,7 +417,7 @@ export default class { const ffmpegInputOptions: string[] = []; let shouldCacheVideo = false; - let format: ytdl.videoFormat | undefined; + let format: YTDLVideoFormat | undefined; try { ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url)); @@ -431,7 +433,7 @@ export default class { // Not yet cached, must download const info = await ytdl.getInfo(song.url); - const {formats} = info; + const formats = info.formats as YTDLVideoFormat[]; const filter = (format: ytdl.videoFormat): boolean => format.codecs === 'opus' && format.container === 'webm' && format.audioSampleRate !== undefined && parseInt(format.audioSampleRate, 10) === 48000; @@ -465,12 +467,16 @@ export default class { } } + debug('Using format', format); + ffmpegInput = format.url; // Don't cache livestreams or long videos const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes shouldCacheVideo = !info.player_response.videoDetails.isLiveContent && parseInt(info.videoDetails.lengthSeconds, 10) < MAX_CACHE_LENGTH_SECONDS && !options.seek; + debug(shouldCacheVideo ? 'Caching video' : 'Not caching video'); + ffmpegInputOptions.push(...[ '-reconnect', '1', @@ -489,7 +495,11 @@ export default class { } } - return this.createReadStream(ffmpegInput, {ffmpegInputOptions, cache: shouldCacheVideo}); + return this.createReadStream(ffmpegInput, { + ffmpegInputOptions, + cache: shouldCacheVideo, + volumeAdjustment: format?.loudnessDb ? `${-format.loudnessDb}dB` : undefined, + }); } private startTrackingPosition(initalPosition?: number): void { @@ -546,7 +556,7 @@ export default class { } } - private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean} = {}): Promise { + private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean; volumeAdjustment?: string} = {}): Promise { return new Promise((resolve, reject) => { const capacitor = new WriteStream(); @@ -563,6 +573,7 @@ export default class { .noVideo() .audioCodec('libopus') .outputFormat('webm') + .addOutputOption(['-filter:a', `volume=${options?.volumeAdjustment ?? '1'}`]) .on('error', error => { if (!hasReturnedStreamClosed) { reject(error);