Implement volume control #830 (#994)

Co-authored-by: Max Isom <hi@maxisom.me>
This commit is contained in:
Matt Foxx 2024-03-12 22:25:45 -04:00 committed by GitHub
parent 786e6fd8f5
commit 6baaffb730
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 141 additions and 11 deletions

View file

@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added 🔊
- A `/volume` command is now available.
- Set the default volume with `/config set-default-volume`
## [2.6.0] - 2024-03-03 ## [2.6.0] - 2024-03-03
### Added ### Added

View file

@ -0,0 +1,17 @@
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Setting" (
"guildId" TEXT NOT NULL PRIMARY KEY,
"playlistLimit" INTEGER NOT NULL DEFAULT 50,
"secondsToWaitAfterQueueEmpties" INTEGER NOT NULL DEFAULT 30,
"leaveIfNoListeners" BOOLEAN NOT NULL DEFAULT true,
"autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false,
"defaultVolume" INTEGER NOT NULL DEFAULT 100,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "guildId", "leaveIfNoListeners", "playlistLimit", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting";
DROP TABLE "Setting";
ALTER TABLE "new_Setting" RENAME TO "Setting";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View file

@ -29,6 +29,7 @@ model Setting {
secondsToWaitAfterQueueEmpties Int @default(30) secondsToWaitAfterQueueEmpties Int @default(30)
leaveIfNoListeners Boolean @default(true) leaveIfNoListeners Boolean @default(true)
autoAnnounceNextSong Boolean @default(false) autoAnnounceNextSong Boolean @default(false)
defaultVolume Int @default(100)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }

View file

@ -40,6 +40,15 @@ export default class implements Command {
.setName('value') .setName('value')
.setDescription('whether to announce the next song in the queue automatically') .setDescription('whether to announce the next song in the queue automatically')
.setRequired(true))) .setRequired(true)))
.addSubcommand(subcommand => subcommand
.setName('set-default-volume')
.setDescription('set default volume used when entering the voice channel')
.addIntegerOption(option => option
.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true)))
.addSubcommand(subcommand => subcommand .addSubcommand(subcommand => subcommand
.setName('get') .setName('get')
.setDescription('show all settings')); .setDescription('show all settings'));
@ -121,6 +130,23 @@ export default class implements Command {
break; break;
} }
case 'set-default-volume': {
const value = interaction.options.getInteger('level')!;
await prisma.setting.update({
where: {
guildId: interaction.guild!.id,
},
data: {
defaultVolume: value,
},
});
await interaction.reply('👍 volume setting updated');
break;
}
case 'get': { case 'get': {
const embed = new EmbedBuilder().setTitle('Config'); const embed = new EmbedBuilder().setTitle('Config');
@ -133,6 +159,7 @@ export default class implements Command {
: `${config.secondsToWaitAfterQueueEmpties}s`, : `${config.secondsToWaitAfterQueueEmpties}s`,
'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no', 'Leave if there are no listeners': config.leaveIfNoListeners ? 'yes' : 'no',
'Auto announce next song in queue': config.autoAnnounceNextSong ? 'yes' : 'no', 'Auto announce next song in queue': config.autoAnnounceNextSong ? 'yes' : 'no',
'Default Volume': config.defaultVolume,
}; };
let description = ''; let description = '';

42
src/commands/volume.ts Normal file
View file

@ -0,0 +1,42 @@
import {ChatInputCommandInteraction} from 'discord.js';
import {TYPES} from '../types.js';
import {inject, injectable} from 'inversify';
import PlayerManager from '../managers/player.js';
import Command from '.';
import {SlashCommandBuilder} from '@discordjs/builders';
@injectable()
export default class implements Command {
public readonly slashCommand = new SlashCommandBuilder()
.setName('volume')
.setDescription('set current player volume level')
.addIntegerOption(option =>
option.setName('level')
.setDescription('volume percentage (0 is muted, 100 is max & default)')
.setMinValue(0)
.setMaxValue(100)
.setRequired(true),
);
public requiresVC = true;
private readonly playerManager: PlayerManager;
constructor(@inject(TYPES.Managers.Player) playerManager: PlayerManager) {
this.playerManager = playerManager;
}
public async execute(interaction: ChatInputCommandInteraction): Promise<void> {
const player = this.playerManager.get(interaction.guild!.id);
const currentSong = player.getCurrent();
if (!currentSong) {
throw new Error('nothing is playing');
}
const level = interaction.options.getInteger('level') ?? 100;
player.setVolume(level);
await interaction.reply(`Set volume to ${level}%`);
}
}

View file

@ -37,6 +37,7 @@ import Shuffle from './commands/shuffle.js';
import Skip from './commands/skip.js'; import Skip from './commands/skip.js';
import Stop from './commands/stop.js'; import Stop from './commands/stop.js';
import Unskip from './commands/unskip.js'; import Unskip from './commands/unskip.js';
import Volume from './commands/volume.js';
import ThirdParty from './services/third-party.js'; import ThirdParty from './services/third-party.js';
import FileCacheProvider from './services/file-cache.js'; import FileCacheProvider from './services/file-cache.js';
import KeyValueCacheProvider from './services/key-value-cache.js'; import KeyValueCacheProvider from './services/key-value-cache.js';
@ -85,6 +86,7 @@ container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingleton
Skip, Skip,
Stop, Stop,
Unskip, Unskip,
Volume,
].forEach(command => { ].forEach(command => {
container.bind<Command>(TYPES.Command).to(command).inSingletonScope(); container.bind<Command>(TYPES.Command).to(command).inSingletonScope();
}); });

View file

@ -8,7 +8,7 @@ import shuffle from 'array-shuffle';
import { import {
AudioPlayer, AudioPlayer,
AudioPlayerState, AudioPlayerState,
AudioPlayerStatus, AudioPlayerStatus, AudioResource,
createAudioPlayer, createAudioPlayer,
createAudioResource, DiscordGatewayAdapterCreator, createAudioResource, DiscordGatewayAdapterCreator,
joinVoiceChannel, joinVoiceChannel,
@ -59,6 +59,8 @@ export interface PlayerEvents {
type YTDLVideoFormat = videoFormat & {loudnessDb?: number}; type YTDLVideoFormat = videoFormat & {loudnessDb?: number};
export const DEFAULT_VOLUME = 100;
export default class { export default class {
public voiceConnection: VoiceConnection | null = null; public voiceConnection: VoiceConnection | null = null;
public status = STATUS.PAUSED; public status = STATUS.PAUSED;
@ -69,6 +71,9 @@ export default class {
private queue: QueuedSong[] = []; private queue: QueuedSong[] = [];
private queuePosition = 0; private queuePosition = 0;
private audioPlayer: AudioPlayer | null = null; private audioPlayer: AudioPlayer | null = null;
private audioResource: AudioResource | null = null;
private volume?: number;
private defaultVolume: number = DEFAULT_VOLUME;
private nowPlaying: QueuedSong | null = null; private nowPlaying: QueuedSong | null = null;
private playPositionInterval: NodeJS.Timeout | undefined; private playPositionInterval: NodeJS.Timeout | undefined;
private lastSongURL = ''; private lastSongURL = '';
@ -83,6 +88,11 @@ export default class {
} }
async connect(channel: VoiceChannel): Promise<void> { async connect(channel: VoiceChannel): Promise<void> {
// Always get freshest default volume setting value
const settings = await getGuildSettings(this.guildId);
const {defaultVolume = DEFAULT_VOLUME} = settings;
this.defaultVolume = defaultVolume;
this.voiceConnection = joinVoiceChannel({ this.voiceConnection = joinVoiceChannel({
channelId: channel.id, channelId: channel.id,
guildId: channel.guild.id, guildId: channel.guild.id,
@ -120,6 +130,7 @@ export default class {
this.voiceConnection = null; this.voiceConnection = null;
this.audioPlayer = null; this.audioPlayer = null;
this.audioResource = null;
} }
} }
@ -155,9 +166,7 @@ export default class {
}, },
}); });
this.voiceConnection.subscribe(this.audioPlayer); this.voiceConnection.subscribe(this.audioPlayer);
this.audioPlayer.play(createAudioResource(stream, { this.playAudioPlayerResource(this.createAudioStream(stream));
inputType: StreamType.WebmOpus,
}));
this.attachListeners(); this.attachListeners();
this.startTrackingPosition(positionSeconds); this.startTrackingPosition(positionSeconds);
@ -220,11 +229,7 @@ export default class {
}, },
}); });
this.voiceConnection.subscribe(this.audioPlayer); this.voiceConnection.subscribe(this.audioPlayer);
const resource = createAudioResource(stream, { this.playAudioPlayerResource(this.createAudioStream(stream));
inputType: StreamType.WebmOpus,
});
this.audioPlayer.play(resource);
this.attachListeners(); this.attachListeners();
@ -408,6 +413,17 @@ export default class {
return this.queue[this.queuePosition + to]; return this.queue[this.queuePosition + to];
} }
setVolume(level: number): void {
// Level should be a number between 0 and 100 = 0% => 100%
this.volume = level;
this.setAudioPlayerVolume(level);
}
getVolume(): number {
// Only use default volume if player volume is not already set (in the event of a reconnect we shouldn't reset)
return this.volume ?? this.defaultVolume;
}
private getHashForCache(url: string): string { private getHashForCache(url: string): string {
return hasha(url); return hasha(url);
} }
@ -610,4 +626,24 @@ export default class {
resolve(returnedStream); resolve(returnedStream);
}); });
} }
private createAudioStream(stream: Readable) {
return createAudioResource(stream, {
inputType: StreamType.WebmOpus,
inlineVolume: true,
});
}
private playAudioPlayerResource(resource: AudioResource) {
if (this.audioPlayer !== null) {
this.audioResource = resource;
this.setAudioPlayerVolume();
this.audioPlayer.play(this.audioResource);
}
}
private setAudioPlayerVolume(level?: number) {
// Audio resource expects a float between 0 and 1 to represent level percentage
this.audioResource?.volume?.setVolume((level ?? this.getVolume()) / 100);
}
} }

View file

@ -44,10 +44,11 @@ const getPlayerUI = (player: Player) => {
const position = player.getPosition(); const position = player.getPosition();
const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️'; const button = player.status === STATUS.PLAYING ? '⏹️' : '▶️';
const progressBar = getProgressBar(15, position / song.length); const progressBar = getProgressBar(10, position / song.length);
const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`; const elapsedTime = song.isLive ? 'live' : `${prettyTime(position)}/${prettyTime(song.length)}`;
const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : ''; const loop = player.loopCurrentSong ? '🔂' : player.loopCurrentQueue ? '🔁' : '';
return `${button} ${progressBar} \`[${elapsedTime}]\` 🔉 ${loop}`; const vol: string = typeof player.getVolume() === 'number' ? `${player.getVolume()!}%` : '';
return `${button} ${progressBar} \`[${elapsedTime}]\`🔉 ${vol} ${loop}`;
}; };
export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => { export const buildPlayingMessageEmbed = (player: Player): EmbedBuilder => {