diff --git a/CHANGELOG.md b/CHANGELOG.md index 054a6f95..f6d8b5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ 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] +- New `/config set-reduce-vol-when-voice` command to automatically turn down the volume when people are speaking in the channel +- New `/config set-reduce-vol-when-voice-target` command to set the target volume percentage (0-100) when people are speaking in the channel + ## [2.9.5] - 2024-10-29 - Dependency update diff --git a/README.md b/README.md index b21bb528..31b75e52 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,12 @@ In the default state, Muse has the status "Online" and the text "Listening to Mu ### Bot-wide commands If you have Muse running in a lot of guilds (10+) you may want to switch to registering commands bot-wide rather than for each guild. (The downside to this is that command updates can take up to an hour to propagate.) To do this, set the environment variable `REGISTER_COMMANDS_ON_BOT` to `true`. + +### Automatically turn down volume when people speak + +You can configure the bot to automatically turn down the volume when people are speaking in the channel using the following commands: + +- `/config set-reduce-vol-when-voice true` - Enable automatic volume reduction +- `/config set-reduce-vol-when-voice false` - Disable automatic volume reduction +- `/config set-reduce-vol-when-voice-target ` - Set the target volume percentage when people speak (0-100, default is 70) + diff --git a/migrations/20241031084730_add_turn_down_volume_when_people_speak/migration.sql b/migrations/20241031084730_add_turn_down_volume_when_people_speak/migration.sql new file mode 100644 index 00000000..07be5d98 --- /dev/null +++ b/migrations/20241031084730_add_turn_down_volume_when_people_speak/migration.sql @@ -0,0 +1,21 @@ +-- 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, + "queueAddResponseEphemeral" BOOLEAN NOT NULL DEFAULT false, + "autoAnnounceNextSong" BOOLEAN NOT NULL DEFAULT false, + "defaultVolume" INTEGER NOT NULL DEFAULT 100, + "defaultQueuePageSize" INTEGER NOT NULL DEFAULT 10, + "turnDownVolumeWhenPeopleSpeak" BOOLEAN NOT NULL DEFAULT false, + "turnDownVolumeWhenPeopleSpeakTarget" INTEGER NOT NULL DEFAULT 20, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_Setting" ("autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt") SELECT "autoAnnounceNextSong", "createdAt", "defaultQueuePageSize", "defaultVolume", "guildId", "leaveIfNoListeners", "playlistLimit", "queueAddResponseEphemeral", "secondsToWaitAfterQueueEmpties", "updatedAt" FROM "Setting"; +DROP TABLE "Setting"; +ALTER TABLE "new_Setting" RENAME TO "Setting"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/schema.prisma b/schema.prisma index 65c3aee1..4723196a 100644 --- a/schema.prisma +++ b/schema.prisma @@ -24,16 +24,18 @@ model KeyValueCache { } model Setting { - guildId String @id - playlistLimit Int @default(50) - secondsToWaitAfterQueueEmpties Int @default(30) - leaveIfNoListeners Boolean @default(true) - queueAddResponseEphemeral Boolean @default(false) - autoAnnounceNextSong Boolean @default(false) - defaultVolume Int @default(100) - defaultQueuePageSize Int @default(10) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + guildId String @id + playlistLimit Int @default(50) + secondsToWaitAfterQueueEmpties Int @default(30) + leaveIfNoListeners Boolean @default(true) + queueAddResponseEphemeral Boolean @default(false) + autoAnnounceNextSong Boolean @default(false) + defaultVolume Int @default(100) + defaultQueuePageSize Int @default(10) + turnDownVolumeWhenPeopleSpeak Boolean @default(false) + turnDownVolumeWhenPeopleSpeakTarget Int @default(20) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model FavoriteQuery { diff --git a/src/commands/config.ts b/src/commands/config.ts index 91b25788..01d9fe9d 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -40,6 +40,22 @@ export default class implements Command { .setName('value') .setDescription('whether bot responses to queue additions are only displayed to the requester') .setRequired(true))) + .addSubcommand(subcommand => subcommand + .setName('set-reduce-vol-when-voice') + .setDescription('set whether to turn down the volume when people speak') + .addBooleanOption(option => option + .setName('value') + .setDescription('whether to turn down the volume when people speak') + .setRequired(true))) + .addSubcommand(subcommand => subcommand + .setName('set-reduce-vol-when-voice-target') + .setDescription('set the target volume when people speak') + .addIntegerOption(option => option + .setName('volume') + .setDescription('volume percentage (0 is muted, 100 is max & default)') + .setMinValue(0) + .setMaxValue(100) + .setRequired(true))) .addSubcommand(subcommand => subcommand .setName('set-auto-announce-next-song') .setDescription('set whether to announce the next song in the queue automatically') @@ -197,6 +213,40 @@ export default class implements Command { break; } + case 'set-reduce-vol-when-voice': { + const value = interaction.options.getBoolean('value')!; + + await prisma.setting.update({ + where: { + guildId: interaction.guild!.id, + }, + data: { + turnDownVolumeWhenPeopleSpeak: value, + }, + }); + + await interaction.reply('👍 turn down volume setting updated'); + + break; + } + + case 'set-reduce-vol-when-voice-target': { + const value = interaction.options.getInteger('volume')!; + + await prisma.setting.update({ + where: { + guildId: interaction.guild!.id, + }, + data: { + turnDownVolumeWhenPeopleSpeakTarget: value, + }, + }); + + await interaction.reply('👍 turn down volume target setting updated'); + + break; + } + case 'get': { const embed = new EmbedBuilder().setTitle('Config'); @@ -212,6 +262,7 @@ export default class implements Command { 'Add to queue reponses show for requester only': config.autoAnnounceNextSong ? 'yes' : 'no', 'Default Volume': config.defaultVolume, 'Default queue page size': config.defaultQueuePageSize, + 'Reduce volume when people speak': config.turnDownVolumeWhenPeopleSpeak ? 'yes' : 'no', }; let description = ''; diff --git a/src/services/player.ts b/src/services/player.ts index 5e284a66..b8330224 100644 --- a/src/services/player.ts +++ b/src/services/player.ts @@ -20,6 +20,7 @@ import FileCacheProvider from './file-cache.js'; import debug from '../utils/debug.js'; import {getGuildSettings} from '../utils/get-guild-settings.js'; import {buildPlayingMessageEmbed} from '../utils/build-embed.js'; +import {Setting} from '@prisma/client'; export enum MediaSource { Youtube, @@ -82,6 +83,8 @@ export default class { private readonly fileCache: FileCacheProvider; private disconnectTimer: NodeJS.Timeout | null = null; + private readonly channelToSpeakingUsers: Map> = new Map(); + constructor(fileCache: FileCacheProvider, guildId: string) { this.fileCache = fileCache; this.guildId = guildId; @@ -96,9 +99,12 @@ export default class { this.voiceConnection = joinVoiceChannel({ channelId: channel.id, guildId: channel.guild.id, + selfDeaf: false, adapterCreator: channel.guild.voiceAdapterCreator as DiscordGatewayAdapterCreator, }); + const guildSettings = await getGuildSettings(this.guildId); + // Workaround to disable keepAlive this.voiceConnection.on('stateChange', (oldState, newState) => { /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ @@ -115,6 +121,9 @@ export default class { /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ this.currentChannel = channel; + if (newState.status === VoiceConnectionStatus.Ready) { + this.registerVoiceActivityListener(guildSettings); + } }); } @@ -302,6 +311,63 @@ export default class { } } + registerVoiceActivityListener(guildSettings: Setting) { + const {turnDownVolumeWhenPeopleSpeak, turnDownVolumeWhenPeopleSpeakTarget} = guildSettings; + if (!turnDownVolumeWhenPeopleSpeak || !this.voiceConnection) { + return; + } + + this.voiceConnection.receiver.speaking.on('start', (userId: string) => { + if (!this.currentChannel) { + return; + } + + const member = this.currentChannel.members.get(userId); + const channelId = this.currentChannel?.id; + + if (member) { + if (!this.channelToSpeakingUsers.has(channelId)) { + this.channelToSpeakingUsers.set(channelId, new Set()); + } + + this.channelToSpeakingUsers.get(channelId)?.add(member.id); + } + + this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget); + }); + + this.voiceConnection.receiver.speaking.on('end', (userId: string) => { + if (!this.currentChannel) { + return; + } + + const member = this.currentChannel.members.get(userId); + const channelId = this.currentChannel.id; + if (member) { + if (!this.channelToSpeakingUsers.has(channelId)) { + this.channelToSpeakingUsers.set(channelId, new Set()); + } + + this.channelToSpeakingUsers.get(channelId)?.delete(member.id); + } + + this.suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget); + }); + } + + suppressVoiceWhenPeopleAreSpeaking(turnDownVolumeWhenPeopleSpeakTarget: number): void { + if (!this.currentChannel) { + return; + } + + const speakingUsers = this.channelToSpeakingUsers.get(this.currentChannel.id); + if (speakingUsers && speakingUsers.size > 0) { + this.setVolume(turnDownVolumeWhenPeopleSpeakTarget); + } else { + this.setVolume(this.defaultVolume); + } + } + canGoForward(skip: number) { return (this.queuePosition + skip - 1) < this.queue.length; }