Normalize playback volume across tracks (#939)

This commit is contained in:
Max Isom 2023-05-13 18:16:21 -07:00 committed by GitHub
parent b40d072488
commit f54d7caa72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 19 additions and 5 deletions

View file

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- Muse now normalizes playback volume across tracks. Thanks to @UniversalSuperBox for sponsoring this feature!
## [2.2.4] - 2023-04-17 ## [2.2.4] - 2023-04-17
### Fixed ### Fixed

View file

@ -19,6 +19,7 @@ Muse is a **highly-opinionated midwestern self-hosted** Discord music bot **that
- ↔️ Autoconverts playlists / artists / albums / songs from Spotify - ↔️ Autoconverts playlists / artists / albums / songs from Spotify
- ↗️ Users can add custom shortcuts (aliases) - ↗️ Users can add custom shortcuts (aliases)
- 1⃣ Muse instance supports multiple guilds - 1⃣ Muse instance supports multiple guilds
- 🔊 Normalizes volume across tracks
- ✍️ Written in TypeScript, easily extendable - ✍️ Written in TypeScript, easily extendable
- ❤️ Loyal Packers fan - ❤️ Loyal Packers fan

View file

@ -1,7 +1,7 @@
import {VoiceChannel, Snowflake} from 'discord.js'; import {VoiceChannel, Snowflake} from 'discord.js';
import {Readable} from 'stream'; import {Readable} from 'stream';
import hasha from 'hasha'; import hasha from 'hasha';
import ytdl from 'ytdl-core'; import ytdl, {videoFormat} from 'ytdl-core';
import {WriteStream} from 'fs-capacitor'; import {WriteStream} from 'fs-capacitor';
import ffmpeg from 'fluent-ffmpeg'; import ffmpeg from 'fluent-ffmpeg';
import shuffle from 'array-shuffle'; import shuffle from 'array-shuffle';
@ -56,6 +56,8 @@ export interface PlayerEvents {
statusChange: (oldStatus: STATUS, newStatus: STATUS) => void; statusChange: (oldStatus: STATUS, newStatus: STATUS) => void;
} }
type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
export default class { export default class {
public voiceConnection: VoiceConnection | null = null; public voiceConnection: VoiceConnection | null = null;
public status = STATUS.PAUSED; public status = STATUS.PAUSED;
@ -415,7 +417,7 @@ export default class {
const ffmpegInputOptions: string[] = []; const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false; let shouldCacheVideo = false;
let format: ytdl.videoFormat | undefined; let format: YTDLVideoFormat | undefined;
try { try {
ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url)); ffmpegInput = await this.fileCache.getPathFor(this.getHashForCache(song.url));
@ -431,7 +433,7 @@ export default class {
// Not yet cached, must download // Not yet cached, must download
const info = await ytdl.getInfo(song.url); 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; 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; ffmpegInput = format.url;
// Don't cache livestreams or long videos // Don't cache livestreams or long videos
const MAX_CACHE_LENGTH_SECONDS = 30 * 60; // 30 minutes 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; 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(...[ ffmpegInputOptions.push(...[
'-reconnect', '-reconnect',
'1', '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 { private startTrackingPosition(initalPosition?: number): void {
@ -546,7 +556,7 @@ export default class {
} }
} }
private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean} = {}): Promise<Readable> { private async createReadStream(url: string, options: {ffmpegInputOptions?: string[]; cache?: boolean; volumeAdjustment?: string} = {}): Promise<Readable> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const capacitor = new WriteStream(); const capacitor = new WriteStream();
@ -563,6 +573,7 @@ export default class {
.noVideo() .noVideo()
.audioCodec('libopus') .audioCodec('libopus')
.outputFormat('webm') .outputFormat('webm')
.addOutputOption(['-filter:a', `volume=${options?.volumeAdjustment ?? '1'}`])
.on('error', error => { .on('error', error => {
if (!hasReturnedStreamClosed) { if (!hasReturnedStreamClosed) {
reject(error); reject(error);