SFX -> Sfx

This commit is contained in:
Chris Ham
2025-08-16 22:21:10 -07:00
parent 46b78dd6b3
commit b2821d412c
4 changed files with 167 additions and 129 deletions

View File

@@ -1,24 +1,24 @@
const axios = require('axios');
const { chunkSubstr } = require('../../utils/helpers');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
const axios = require("axios");
const { chunkSubstr } = require("../../utils/helpers");
const sfxManager = require("../../services/sfxManager");
const voiceService = require("../../services/voiceService");
module.exports = {
name: 'sfx',
description: 'Play a sound effect',
name: "sfx",
description: "Play a sound effect",
/**
* Smart chunking that respects markdown block boundaries
* @param {string} content
* @param {number} maxLength
* @param {string} content
* @param {number} maxLength
* @returns {Array<string>}
*/
smartChunkMarkdown(content, maxLength) {
const chunks = [];
const sections = content.split(/(\*\*[^*]+\*\*)/); // Split on headers while keeping them
let currentChunk = '';
let currentChunk = "";
for (const section of sections) {
// If adding this section would exceed the limit
if (currentChunk.length + section.length > maxLength) {
@@ -31,58 +31,69 @@ module.exports = {
currentChunk += section;
}
}
// Add the final chunk
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
},
async execute(message, args, guildConfig) {
const sfxName = args[0];
// If no SFX specified, show the list
if (!sfxName) {
try {
const fs = require('fs');
const path = require('path');
const sfxReadmePath = path.join(__dirname, '..', '..', '..', 'sfx', 'README.md');
const fs = require("fs");
const path = require("path");
const sfxReadmePath = path.join(
__dirname,
"..",
"..",
"..",
"sfx",
"README.md"
);
if (fs.existsSync(sfxReadmePath)) {
const sfxListContent = fs.readFileSync(sfxReadmePath, 'utf-8');
const sfxListContent = fs.readFileSync(sfxReadmePath, "utf-8");
// Break into chunks if too long (Discord limit is 2000 characters)
if (sfxListContent.length <= 2000) {
await message.channel.send(sfxListContent);
} else {
// Smart chunking that respects markdown block boundaries
const chunks = this.smartChunkMarkdown(sfxListContent, 1900);
for (const chunk of chunks) {
await message.channel.send(chunk);
}
}
} else {
// Fallback to generated list if README doesn't exist
const sfxNames = sfxManager.getSFXNames();
const sfxList = `**Available Sound Effects (${sfxNames.length}):**\n\`\`\`\n${sfxNames.join(', ')}\n\`\`\``;
const sfxNames = sfxManager.getSfxNames();
const sfxList = `**Available Sound Effects (${
sfxNames.length
}):**\n\`\`\`\n${sfxNames.join(", ")}\n\`\`\``;
await message.channel.send(sfxList);
}
} catch (error) {
console.error('Error reading SFX list:', error);
await message.reply('Could not load the SFX list.');
console.error("Error reading SFX list:", error);
await message.reply("Could not load the SFX list.");
}
return;
}
// Check if user is in a voice channel
if (!message.member.voice.channel) {
return message.reply('You need to be in a voice channel to use this command!');
return message.reply(
"You need to be in a voice channel to use this command!"
);
}
// Use the reusable SFX playing method for messages
await sfxManager.playSFXMessage(message, sfxName, guildConfig);
}
};
await sfxManager.playSfxMessage(message, sfxName, guildConfig);
},
};

View File

@@ -1,50 +1,56 @@
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
const sfxManager = require('../../services/sfxManager');
const voiceService = require('../../services/voiceService');
const { SlashCommandBuilder, MessageFlags } = require("discord.js");
const sfxManager = require("../../services/sfxManager");
const voiceService = require("../../services/voiceService");
module.exports = {
data: new SlashCommandBuilder()
.setName('sfx')
.setDescription('Play a sound effect')
.addStringOption(option =>
option.setName('sound')
.setDescription('The sound effect to play')
.setName("sfx")
.setDescription("Play a sound effect")
.addStringOption((option) =>
option
.setName("sound")
.setDescription("The sound effect to play")
.setRequired(true)
.setAutocomplete(true)
),
async execute(interaction, guildConfig) {
const sfxName = interaction.options.getString('sound');
const sfxName = interaction.options.getString("sound");
// Check if user is in a voice channel
if (!interaction.member.voice.channel) {
return interaction.reply({
content: 'You need to be in a voice channel to use this command!',
flags: [MessageFlags.Ephemeral]
return interaction.reply({
content: "You need to be in a voice channel to use this command!",
flags: [MessageFlags.Ephemeral],
});
}
// Use the reusable SFX playing method
await sfxManager.playSFXInteraction(interaction, sfxName, guildConfig, 'slash');
await sfxManager.playSfxInteraction(
interaction,
sfxName,
guildConfig,
"slash"
);
},
async autocomplete(interaction, guildConfig) {
const focusedValue = interaction.options.getFocused().toLowerCase();
// Get all SFX names
const choices = sfxManager.getSFXNames();
const choices = sfxManager.getSfxNames();
// Filter based on what the user has typed
const filtered = choices
.filter(choice => choice.toLowerCase().includes(focusedValue))
.filter((choice) => choice.toLowerCase().includes(focusedValue))
.slice(0, 25); // Discord limits autocomplete to 25 choices
// Respond with the filtered choices
await interaction.respond(
filtered.map(choice => ({
name: choice,
value: choice
filtered.map((choice) => ({
name: choice,
value: choice,
}))
);
}
};
},
};

View File

@@ -9,10 +9,9 @@ const {
const fs = require("fs");
const path = require("path");
const sfxManager = require("../../services/sfxManager");
const voiceService = require("../../services/voiceService");
// Parse categories from README.md
function getSFXCategories() {
function getSfxCategories() {
try {
const sfxReadmePath = path.join(
__dirname,
@@ -70,7 +69,7 @@ module.exports = {
});
}
const categories = getSFXCategories();
const categories = getSfxCategories();
if (!categories) {
return interaction.reply({
@@ -112,8 +111,7 @@ module.exports = {
const embed = new EmbedBuilder()
.setTitle("🎛️ Interactive Soundboard")
.setDescription("Choose a category to browse sound effects:")
.setColor(0x21c629)
.setFooter({ text: "Click a category button to browse sounds" });
.setColor(0x21c629);
await interaction.reply({
embeds: [embed],
@@ -145,7 +143,7 @@ module.exports = {
.toUpperCase();
}
const categories = getSFXCategories();
const categories = getSfxCategories();
if (!categories || !categories[categoryKey]) {
return interaction.reply({
@@ -155,7 +153,7 @@ module.exports = {
}
const allSounds = categories[categoryKey].filter((sound) =>
sfxManager.hasSFX(sound)
sfxManager.hasSfx(sound)
);
const soundsPerPage = 16; // 4 sounds per row × 4 rows = 16 sounds per page
const totalPages = Math.ceil(allSounds.length / soundsPerPage);
@@ -252,7 +250,7 @@ module.exports = {
const soundName = interaction.customId.replace("soundboard_play_", "");
// Use the reusable SFX playing method
await sfxManager.playSFXInteraction(
await sfxManager.playSfxInteraction(
interaction,
soundName,
guildConfig,

View File

@@ -1,66 +1,66 @@
const fs = require('fs');
const path = require('path');
const { MessageFlags } = require('discord.js');
const voiceService = require('./voiceService');
const fs = require("fs");
const path = require("path");
const { MessageFlags } = require("discord.js");
const voiceService = require("./voiceService");
class SFXManager {
class SfxManager {
constructor() {
this.sfxPath = path.join(__dirname, '..', '..', 'sfx');
this.sfxPath = path.join(__dirname, "..", "..", "sfx");
this.sfxList = [];
this.cachedNames = [];
this.searchCache = new Map(); // Cache for autocomplete searches
// Load SFX list initially
this.loadSFXList();
this.loadSfxList();
// Watch for changes
this.watchSFXDirectory();
this.watchSfxDirectory();
}
/**
* Load the list of available SFX files
*/
loadSFXList() {
loadSfxList() {
try {
if (!fs.existsSync(this.sfxPath)) {
console.log('SFX directory not found, creating...');
console.log("SFX directory not found, creating...");
fs.mkdirSync(this.sfxPath, { recursive: true });
}
const files = fs.readdirSync(this.sfxPath);
this.sfxList = files
.filter(file => file.endsWith('.mp3') || file.endsWith('.wav'))
.map(file => {
.filter((file) => file.endsWith(".mp3") || file.endsWith(".wav"))
.map((file) => {
const ext = path.extname(file);
return {
name: file.replace(ext, ''),
name: file.replace(ext, ""),
filename: file,
path: path.join(this.sfxPath, file)
path: path.join(this.sfxPath, file),
};
});
// Cache sorted names for autocomplete
this.cachedNames = this.sfxList
.map(sfx => sfx.name)
.map((sfx) => sfx.name)
.sort((a, b) => a.localeCompare(b));
// Clear search cache when SFX list changes
this.searchCache.clear();
console.log(`Loaded ${this.sfxList.length} sound effects`);
} catch (error) {
console.error('Error loading SFX list:', error);
console.error("Error loading SFX list:", error);
}
}
/**
* Watch the SFX directory for changes
*/
watchSFXDirectory() {
watchSfxDirectory() {
fs.watch(this.sfxPath, (eventType, filename) => {
if (eventType === 'rename') {
console.log('SFX directory changed, reloading...');
this.loadSFXList();
if (eventType === "rename") {
console.log("SFX directory changed, reloading...");
this.loadSfxList();
}
});
}
@@ -69,7 +69,7 @@ class SFXManager {
* Get all available SFX
* @returns {Array} List of SFX objects
*/
getAllSFX() {
getAllSfx() {
return this.sfxList;
}
@@ -77,59 +77,61 @@ class SFXManager {
* Get SFX names for autocomplete (cached and sorted)
* @returns {Array} List of SFX names
*/
getSFXNames() {
getSfxNames() {
return this.cachedNames;
}
/**
* Find an SFX by name
* @param {string} name
* @param {string} name
* @returns {Object|undefined} SFX object or undefined
*/
findSFX(name) {
return this.sfxList.find(sfx => sfx.name.toLowerCase() === name.toLowerCase());
findSfx(name) {
return this.sfxList.find(
(sfx) => sfx.name.toLowerCase() === name.toLowerCase()
);
}
/**
* Check if an SFX exists
* @param {string} name
* @param {string} name
* @returns {boolean}
*/
hasSFX(name) {
return this.findSFX(name) !== undefined;
hasSfx(name) {
return this.findSfx(name) !== undefined;
}
/**
* Get the file path for an SFX
* @param {string} name
* @param {string} name
* @returns {string|null}
*/
getSFXPath(name) {
const sfx = this.findSFX(name);
getSfxPath(name) {
const sfx = this.findSfx(name);
return sfx ? sfx.path : null;
}
/**
* Search SFX names (for autocomplete) with caching
* @param {string} query
* @param {string} query
* @returns {Array} Matching SFX names
*/
searchSFX(query) {
searchSfx(query) {
const lowerQuery = query.toLowerCase();
// Check cache first
if (this.searchCache.has(lowerQuery)) {
return this.searchCache.get(lowerQuery);
}
// Perform search on cached names (already sorted)
const results = this.cachedNames
.filter(name => name.toLowerCase().includes(lowerQuery))
.filter((name) => name.toLowerCase().includes(lowerQuery))
.slice(0, 25); // Discord autocomplete limit
// Cache the result for future use
this.searchCache.set(lowerQuery, results);
return results;
}
@@ -141,18 +143,25 @@ class SFXManager {
* @param {string} commandType - Type of command ('slash' or 'soundboard')
* @returns {Promise<void>}
*/
async playSFXInteraction(interaction, sfxName, guildConfig, commandType = 'slash') {
async playSfxInteraction(
interaction,
sfxName,
guildConfig,
commandType = "slash"
) {
// Log the request
const logPrefix = commandType === 'soundboard' ? 'Soundboard' : '/sfx';
const logPrefix = commandType === "soundboard" ? "Soundboard" : "/sfx";
console.log(
`${logPrefix} '${sfxName}' requested in ${guildConfig.internalName || interaction.guild.name}#${interaction.channel.name} from @${interaction.user.username}`
`${logPrefix} '${sfxName}' requested in ${
guildConfig.internalName || interaction.guild.name
}#${interaction.channel.name} from @${interaction.user.username}`
);
// Check if SFX exists
if (!this.hasSFX(sfxName)) {
if (!this.hasSfx(sfxName)) {
await interaction.reply({
content: `❌ This sound effect does not exist!`,
flags: [MessageFlags.Ephemeral]
flags: [MessageFlags.Ephemeral],
});
return;
}
@@ -161,14 +170,14 @@ class SFXManager {
// Immediately reply with playing status
await interaction.reply({
content: `🔊 Playing: **${sfxName}**`,
flags: [MessageFlags.Ephemeral]
//flags: [MessageFlags.Ephemeral],
});
// Join the voice channel
await voiceService.join(interaction.member.voice.channel);
// Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName);
const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(interaction.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5,
});
@@ -176,10 +185,13 @@ class SFXManager {
// Update the interaction to show completion
try {
await interaction.editReply({
content: `✅ Finished playing: **${sfxName}**`
content: `✅ Finished playing: **${sfxName}**`,
});
} catch (editError) {
console.error('Error updating interaction with completion message:', editError);
console.error(
"Error updating interaction with completion message:",
editError
);
}
// Leave the voice channel after playing
@@ -187,18 +199,26 @@ class SFXManager {
voiceService.leave(interaction.guild.id);
}, 500);
console.log(`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`);
console.log(
`✅ Successfully played ${logPrefix.toLowerCase()} '${sfxName}'`
);
} catch (error) {
console.error(`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`, error);
console.error(
`❌ Error playing ${logPrefix.toLowerCase()} '${sfxName}':`,
error
);
// Update the reply with error message
try {
await interaction.editReply({
content: "❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!"
content:
"❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!",
});
} catch (editError) {
console.error('Error updating interaction with error message:', editError);
console.error(
"Error updating interaction with error message:",
editError
);
}
}
}
@@ -210,33 +230,35 @@ class SFXManager {
* @param {Object} guildConfig - Guild configuration
* @returns {Promise<void>}
*/
async playSFXMessage(message, sfxName, guildConfig) {
async playSfxMessage(message, sfxName, guildConfig) {
// Log the request
console.log(
`SFX '${sfxName}' requested in ${guildConfig.internalName || message.guild.name}#${message.channel.name} from @${message.author.username}`
`SFX '${sfxName}' requested in ${
guildConfig.internalName || message.guild.name
}#${message.channel.name} from @${message.author.username}`
);
// Check if SFX exists
if (!this.hasSFX(sfxName)) {
await message.reply('❌ This sound effect does not exist!');
if (!this.hasSfx(sfxName)) {
await message.reply("❌ This sound effect does not exist!");
return;
}
try {
// React with speaker icon to show playing status
await message.react('🔊');
await message.react("🔊");
// Join the voice channel
await voiceService.join(message.member.voice.channel);
// Get the SFX file path and play
const sfxPath = this.getSFXPath(sfxName);
const sfxPath = this.getSfxPath(sfxName);
await voiceService.play(message.guild.id, sfxPath, {
volume: guildConfig.sfxVolume || 0.5,
});
// Add completion reaction (keep both speaker and checkmark)
await message.react('✅');
await message.react("✅");
// Leave the voice channel after playing
setTimeout(() => {
@@ -244,19 +266,20 @@ class SFXManager {
}, 500);
console.log(`✅ Successfully played SFX '${sfxName}'`);
} catch (error) {
console.error(`❌ Error playing SFX '${sfxName}':`, error);
// Add error reaction
try {
await message.react('❌');
await message.react("❌");
} catch (reactionError) {
// If reactions fail, fall back to reply
await message.reply("❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!");
await message.reply(
"❌ Couldn't play that sound effect. Make sure I have permission to join your voice channel!"
);
}
}
}
}
module.exports = new SFXManager();
module.exports = new SfxManager();