diff --git a/cogs/music/main.py b/cogs/music/main.py index 682ddf9..a2cce90 100644 --- a/cogs/music/main.py +++ b/cogs/music/main.py @@ -175,4 +175,171 @@ class music(commands.Cog): await queue.pop(server.id, True) # Safe to ignore error for now - ctx.voice_client.stop() \ No newline at end of file + ctx.voice_client.stop() + + + @commands.command( + help="Toggle loop mode: off -> song -> queue -> off", + aliases=['l', 'repeat']) + async def loop(self, ctx: Context, mode: str = None): + """Toggle between loop modes or set a specific mode""" + server = ctx.guild + + if server is None: + raise commands.CommandError("Command must be issued in a server") + + current_mode = await queue.get_loop_mode(server.id) + + # If no mode specified, cycle through modes + if mode is None: + if current_mode == 'off': + new_mode = 'song' + elif current_mode == 'song': + new_mode = 'queue' + else: + new_mode = 'off' + else: + # Set specific mode + mode = mode.lower() + if mode not in ['off', 'song', 'queue']: + raise commands.CommandError("Loop mode must be: off, song, or queue") + new_mode = mode + + await queue.set_loop_mode(server.id, new_mode) + + # Response messages + emojis = {'off': 'âšī¸', 'song': '🔂', 'queue': '🔁'} + messages = { + 'off': 'Loop disabled', + 'song': 'Looping current song 🔂', + 'queue': 'Looping entire queue 🔁' + } + + await ctx.send(f"{emojis[new_mode]} {messages[new_mode]}") + + + @commands.command( + help="Shuffle the queue randomly", + aliases=['mix', 'randomize']) + async def shuffle(self, ctx: Context): + """Shuffle all songs in the queue""" + server = ctx.guild + + if server is None: + raise commands.CommandError("Command must be issued in a server") + + success = await queue.shuffle_queue(server.id) + + if not success: + await ctx.send("đŸšĢ Not enough songs in the queue to shuffle!") + else: + await ctx.message.add_reaction('🔀') + await ctx.send("🔀 Queue shuffled!") + + + @commands.command( + help="Set playback volume (0-200%)", + aliases=['vol', 'v']) + async def volume(self, ctx: Context, vol: str = None): + """Set or display the current volume""" + server = ctx.guild + + if server is None: + raise commands.CommandError("Command must be issued in a server") + + if vol is None: + # Display current volume + current_vol = await queue.get_volume(server.id) + await ctx.send(f"🔊 Current volume: {current_vol}%") + return + + # Set volume + if not vol.isdigit(): + raise commands.CommandError("Volume must be a number (0-200)") + + vol = int(vol) + if vol < 0 or vol > 200: + raise commands.CommandError("Volume must be between 0 and 200") + + new_vol = await queue.set_volume(server.id, vol) + + # Update the current playing song's volume if something is playing + if ctx.voice_client and ctx.voice_client.source: + ctx.voice_client.source.volume = new_vol / 100.0 + + # Pick an emoji based on volume + if new_vol == 0: + emoji = '🔇' + elif new_vol < 50: + emoji = '🔉' + elif new_vol < 100: + emoji = '🔊' + else: + emoji = 'đŸ“ĸ' + + await ctx.send(f"{emoji} Volume set to {new_vol}%") + + + @commands.command( + help="Apply audio effects to playback", + aliases=['fx', 'filter']) + async def effect(self, ctx: Context, effect_name: str = None): + """Apply or list audio effects""" + server = ctx.guild + + if server is None: + raise commands.CommandError("Command must be issued in a server") + + # If no effect specified, show current effect and list options + if effect_name is None: + current = await queue.get_effect(server.id) + emoji = queue.get_effect_emoji(current) + desc = queue.get_effect_description(current) + + effects_list = '\n'.join([ + f"`{e}` - {queue.get_effect_description(e)}" + for e in queue.list_all_effects()[:7] # Show first 7 + ]) + more_effects = '\n'.join([ + f"`{e}` - {queue.get_effect_description(e)}" + for e in queue.list_all_effects()[7:] # Show rest + ]) + + await ctx.send( + f"{emoji} **Current effect:** {current} - {desc}\n\n" + f"**Available effects:**\n{effects_list}\n\n" + f"**More effects:**\n{more_effects}\n\n" + f"Use `=effect ` to apply an effect!" + ) + return + + # Set effect + effect_name = effect_name.lower() + + if effect_name not in queue.list_all_effects(): + raise commands.CommandError( + f"Unknown effect! Use `=effect` to see available effects." + ) + + await queue.set_effect(server.id, effect_name) + + emoji = queue.get_effect_emoji(effect_name) + desc = queue.get_effect_description(effect_name) + + # Special warning for earrape + if effect_name == 'earrape': + await ctx.send( + f"âš ī¸ **EARRAPE MODE ACTIVATED** âš ī¸\n" + f"RIP your eardrums. Effect will apply to next song.\n" + f"Use `=effect none` to disable." + ) + else: + await ctx.send( + f"{emoji} Effect set to **{effect_name}**\n" + f"{desc}\n" + f"Effect will apply to next song!" + ) + + # If something is currently playing, notify about skip + if ctx.voice_client and ctx.voice_client.is_playing(): + await ctx.send("💡 Tip: Use `=skip` to apply effect to current song immediately!") diff --git a/cogs/music/queue.py b/cogs/music/queue.py index 34a054e..ba14da5 100644 --- a/cogs/music/queue.py +++ b/cogs/music/queue.py @@ -1,5 +1,6 @@ from http import server import sqlite3 +import random import discord import asyncio @@ -8,14 +9,98 @@ from .translate import search_song db_path = "./data/music.db" -FFMPEG_OPTS = { - 'before_options': - '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', - - 'options': - '-vn' +# Base FFmpeg options (will be modified by effects) +BASE_FFMPEG_OPTS = { + 'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', + 'options': '-vn' } +# Audio effects configurations +def get_effect_options(effect_name): + """Get FFmpeg options for a specific audio effect""" + + effects = { + 'none': { + **BASE_FFMPEG_OPTS + }, + 'bassboost': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "bass=g=20,dynaudnorm"' + }, + 'nightcore': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "asetrate=48000*1.25,atempo=1.25,bass=g=5"' + }, + 'slowed': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "atempo=0.8,asetrate=48000*0.8,aecho=0.8:0.9:1000:0.3"' + }, + 'earrape': { + **BASE_FFMPEG_OPTS, + # Aggressive compression + hard clipping + bitcrushing for maximum distortion + 'options': '-vn -af "volume=8,acompressor=threshold=0.001:ratio=30:attack=0.1:release=5,acrusher=bits=8:mix=0.7,volume=2,alimiter=limit=0.8"' + }, + 'deepfry': { + **BASE_FFMPEG_OPTS, + # Extreme bitcrushing + bass boost + compression (meme audio effect) + 'options': '-vn -af "acrusher=bits=4:mode=log:aa=1,bass=g=15,acompressor=threshold=0.001:ratio=20,volume=3"' + }, + 'distortion': { + **BASE_FFMPEG_OPTS, + # Pure bitcrushing distortion + 'options': '-vn -af "acrusher=bits=6:mix=0.9,acompressor=threshold=0.01:ratio=15,volume=2"' + }, + 'reverse': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "areverse"' + }, + 'chipmunk': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "asetrate=48000*1.5,atempo=1.5"' + }, + 'demonic': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "asetrate=48000*0.7,atempo=0.7,aecho=0.8:0.9:1000:0.5"' + }, + 'underwater': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "lowpass=f=800,aecho=0.8:0.88:60:0.4"' + }, + 'robot': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "afftfilt=real=\'hypot(re,im)*sin(0)\':imag=\'hypot(re,im)*cos(0)\':win_size=512:overlap=0.75"' + }, + '8d': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "apulsator=hz=0.08"' + }, + 'vibrato': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "vibrato=f=7:d=0.5"' + }, + 'tremolo': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "tremolo=f=5:d=0.9"' + }, + 'echo': { + **BASE_FFMPEG_OPTS, + 'options': '-vn -af "aecho=0.8:0.88:60:0.4"' + }, + 'phone': { + **BASE_FFMPEG_OPTS, + # Sounds like a phone call (bandpass filter) + 'options': '-vn -af "bandpass=f=1500:width_type=h:w=1000,volume=2"' + }, + 'megaphone': { + **BASE_FFMPEG_OPTS, + # Megaphone/radio effect + 'options': '-vn -af "highpass=f=300,lowpass=f=3000,volume=2,acompressor=threshold=0.1:ratio=8"' + } + } + + return effects.get(effect_name, effects['none']) + + # Creates the tables if they don't exist def initialize_tables(): # Connect to the database @@ -26,11 +111,30 @@ def initialize_tables(): cursor.execute('''CREATE TABLE IF NOT EXISTS servers ( server_id TEXT PRIMARY KEY, is_playing INTEGER DEFAULT 0, - song_name TEXT + song_name TEXT, + loop_mode TEXT DEFAULT 'off', + volume INTEGER DEFAULT 100, + effect TEXT DEFAULT 'none' );''') # Set all to not playing cursor.execute("UPDATE servers SET is_playing = 0;") + + # Add new columns if they don't exist (for existing databases) + try: + cursor.execute("ALTER TABLE servers ADD COLUMN loop_mode TEXT DEFAULT 'off';") + except sqlite3.OperationalError: + pass # Column already exists + + try: + cursor.execute("ALTER TABLE servers ADD COLUMN volume INTEGER DEFAULT 100;") + except sqlite3.OperationalError: + pass # Column already exists + + try: + cursor.execute("ALTER TABLE servers ADD COLUMN effect TEXT DEFAULT 'none';") + except sqlite3.OperationalError: + pass # Column already exists # Create queue table if it doesn't exist cursor.execute('''CREATE TABLE IF NOT EXISTS songs ( @@ -105,7 +209,7 @@ async def add_song(server_id, details, queued_by): return max_order_num -# Pop song from server +# Pop song from server (respects loop mode) async def pop(server_id, ignore=False): # Connect to db conn = sqlite3.connect(db_path) @@ -138,11 +242,20 @@ async def pop(server_id, ignore=False): song = song[0] await set_current_song(server_id, song['title']) - await mark_song_as_finished(server_id, result[3]) + + # Check loop mode before removing + loop_mode = await get_loop_mode(server_id) + if loop_mode != 'song': # Only remove if not looping song + await mark_song_as_finished(server_id, result[3]) + return song['url'] await set_current_song(server_id, result[4]) - await mark_song_as_finished(server_id, result[3]) + + # Check loop mode before removing + loop_mode = await get_loop_mode(server_id) + if loop_mode != 'song': # Only remove if not looping song + await mark_song_as_finished(server_id, result[3]) return result[1] @@ -159,8 +272,8 @@ async def add_server(server_id, cursor, conn): # If the server doesn't exist, add it if not server_exists: - cursor.execute('''INSERT INTO servers (server_id) - VALUES (?)''', (server_id,)) + cursor.execute('''INSERT INTO servers (server_id, loop_mode, volume, effect) + VALUES (?, 'off', 100, 'none')''', (server_id,)) conn.commit() @@ -206,7 +319,7 @@ async def get_current_song(server_id): WHERE server_id = ? LIMIT 1;''', (server_id,)) - + result = cursor.fetchone() # Close connection @@ -313,9 +426,223 @@ async def grab_songs(server_id): return max, songs +# ============= LOOP/SHUFFLE/VOLUME FEATURES ============= + +# Get/Set loop mode +async def get_loop_mode(server_id): + """Get the current loop mode: 'off', 'song', or 'queue'""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""SELECT loop_mode + FROM servers + WHERE server_id = ?""", + (server_id,)) + result = cursor.fetchone() + + conn.commit() + conn.close() + + return result[0] if result else 'off' + + +async def set_loop_mode(server_id, mode): + """Set loop mode: 'off', 'song', or 'queue'""" + if mode not in ['off', 'song', 'queue']: + return False + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""UPDATE servers + SET loop_mode = ? + WHERE server_id = ?""", + (mode, server_id)) + + conn.commit() + conn.close() + return True + + +# Get/Set volume +async def get_volume(server_id): + """Get the current volume (0-200)""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""SELECT volume + FROM servers + WHERE server_id = ?""", + (server_id,)) + result = cursor.fetchone() + + conn.commit() + conn.close() + + return result[0] if result else 100 + + +async def set_volume(server_id, volume): + """Set volume (0-200)""" + volume = max(0, min(200, volume)) # Clamp between 0-200 + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""UPDATE servers + SET volume = ? + WHERE server_id = ?""", + (volume, server_id)) + + conn.commit() + conn.close() + return volume + + +# Shuffle the queue +async def shuffle_queue(server_id): + """Randomize the order of songs in the queue""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + # Get all songs + cursor.execute('''SELECT position, song_link, queued_by, title, thumbnail, duration + FROM songs + WHERE server_id = ? + ORDER BY position''', (server_id,)) + songs = cursor.fetchall() + + if len(songs) <= 1: + conn.close() + return False # Nothing to shuffle + + # Shuffle the songs (keep positions but randomize order) + random.shuffle(songs) + + # Delete all current songs + cursor.execute('''DELETE FROM songs WHERE server_id = ?''', (server_id,)) + + # Re-insert in shuffled order + for i, song in enumerate(songs): + cursor.execute("""INSERT INTO songs (server_id, song_link, queued_by, position, title, thumbnail, duration) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (server_id, song[1], song[2], i, song[3], song[4], song[5])) + + conn.commit() + conn.close() + return True + + +# ============= AUDIO EFFECTS FEATURES ============= + +async def get_effect(server_id): + """Get the current audio effect""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""SELECT effect + FROM servers + WHERE server_id = ?""", + (server_id,)) + result = cursor.fetchone() + + conn.commit() + conn.close() + + return result[0] if result else 'none' + + +async def set_effect(server_id, effect_name): + """Set the audio effect""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + await add_server(server_id, cursor, conn) + + cursor.execute("""UPDATE servers + SET effect = ? + WHERE server_id = ?""", + (effect_name, server_id)) + + conn.commit() + conn.close() + return True + + +def list_all_effects(): + """Return a list of all available effects""" + return [ + 'none', 'bassboost', 'nightcore', 'slowed', 'earrape', 'deepfry', 'distortion', + 'reverse', 'chipmunk', 'demonic', 'underwater', 'robot', + '8d', 'vibrato', 'tremolo', 'echo', 'phone', 'megaphone' + ] + + +def get_effect_emoji(effect_name): + """Get emoji representation for each effect""" + emojis = { + 'none': '🔊', + 'bassboost': '🔉đŸ’Ĩ', + 'nightcore': '⚡🎀', + 'slowed': '🐌💤', + 'earrape': '💀đŸ“ĸ', + 'deepfry': '🍟đŸ’Ĩ', + 'distortion': '⚡🔊', + 'reverse': 'âĒ🔄', + 'chipmunk': 'đŸŋī¸', + 'demonic': '😈đŸ”Ĩ', + 'underwater': '🌊đŸ’Ļ', + 'robot': '🤖', + '8d': '🎧🌀', + 'vibrato': 'ã€°ī¸', + 'tremolo': 'đŸ“ŗ', + 'echo': 'đŸ—Ŗī¸đŸ’­', + 'phone': '📞', + 'megaphone': 'đŸ“ĸđŸ“Ŗ' + } + return emojis.get(effect_name, '🔊') + + +def get_effect_description(effect_name): + """Get user-friendly description for each effect""" + descriptions = { + 'none': 'Normal audio', + 'bassboost': 'MAXIMUM BASS 🔊', + 'nightcore': 'Speed + pitch up (anime vibes)', + 'slowed': 'Slowed + reverb (TikTok aesthetic)', + 'earrape': 'âš ī¸ Aggressive compression + distortion + clipping âš ī¸', + 'deepfry': '🍟 EXTREME bitcrushing + bass (meme audio) 🍟', + 'distortion': 'Heavy bitcrushing distortion', + 'reverse': 'Plays audio BACKWARDS', + 'chipmunk': 'High pitched and fast (Alvin mode)', + 'demonic': 'Low pitched and slow (cursed)', + 'underwater': 'Muffled underwater sound', + 'robot': 'Robotic vocoder', + '8d': 'Panning audio (use headphones!)', + 'vibrato': 'Warbling pitch effect', + 'tremolo': 'Volume oscillation', + 'echo': 'Echo/reverb effect', + 'phone': 'Sounds like a phone call', + 'megaphone': 'Megaphone/radio effect' + } + return descriptions.get(effect_name, 'Unknown effect') + + # Play and loop songs in server async def play(ctx): - """Main playback loop - plays songs from queue sequentially""" + """Main playback loop - plays songs from queue sequentially with effects""" server_id = ctx.guild.id voice_client = ctx.voice_client @@ -337,9 +664,17 @@ async def play(ctx): return try: - # Create audio source - audio_source = discord.FFmpegPCMAudio(url, **FFMPEG_OPTS) - + # Get volume and effect settings + volume_percent = await get_volume(server_id) + volume = volume_percent / 100.0 # Convert to 0.0-2.0 range + + current_effect = await get_effect(server_id) + ffmpeg_opts = get_effect_options(current_effect) + + # Create audio source with effect and volume control + audio_source = discord.FFmpegPCMAudio(url, **ffmpeg_opts) + audio_source = discord.PCMVolumeTransformer(audio_source, volume=volume) + # Play with callback to continue queue def after_playing(error): if error: @@ -355,7 +690,7 @@ async def play(ctx): print(f"Error playing next song: {e}") voice_client.play(audio_source, after=after_playing) - + except Exception as e: print(f"Error starting playback: {e}") # Try to continue with next song diff --git a/cogs/music/util.py b/cogs/music/util.py index 0009752..f1b1c9a 100644 --- a/cogs/music/util.py +++ b/cogs/music/util.py @@ -3,10 +3,6 @@ from discord.ext.commands.context import Context from discord.ext.commands.converter import CommandError import config from . import queue -import asyncio - -# Track last activity time for each server -last_activity = {} # Joining/moving to the user's vc in a guild async def join_vc(ctx: Context): @@ -24,14 +20,11 @@ async def join_vc(ctx: Context): # Join or move to the user's vc if ctx.voice_client is None: - vc = await vc.connect(reconnect=True, timeout=60.0) + vc = await vc.connect() else: # Safe to ignore type error for now vc = await ctx.voice_client.move_to(vc) - # Update last activity - last_activity[ctx.guild.id] = asyncio.get_event_loop().time() - return vc @@ -52,60 +45,8 @@ async def leave_vc(ctx: Context): if author_vc is None or vc != author_vc: raise CommandError("You are not in this voice channel") - # Clear the queue for this server - await queue.clear(ctx.guild.id) - - # Stop any currently playing audio - if ctx.voice_client.is_playing(): - ctx.voice_client.stop() - - # Disconnect with force to ensure it actually leaves - try: - await ctx.voice_client.disconnect(force=True) - except Exception as e: - print(f"Error disconnecting: {e}") - # If regular disconnect fails, try cleanup - await ctx.voice_client.cleanup() - - # Remove from activity tracker - if ctx.guild.id in last_activity: - del last_activity[ctx.guild.id] - - -# Auto-disconnect if inactive -async def check_inactivity(bot): - """Background task to check for inactive voice connections""" - while True: - try: - current_time = asyncio.get_event_loop().time() - - for guild_id, last_time in list(last_activity.items()): - # If inactive for more than 5 minutes - if current_time - last_time > 300: # 300 seconds = 5 minutes - # Find the guild and voice client - guild = bot.get_guild(guild_id) - if guild and guild.voice_client: - # Check if not playing - if not guild.voice_client.is_playing(): - print(f"Auto-disconnecting from {guild.name} due to inactivity") - await queue.clear(guild_id) - try: - await guild.voice_client.disconnect(force=True) - except: - pass - del last_activity[guild_id] - - # Check every 30 seconds - await asyncio.sleep(30) - except Exception as e: - print(f"Error in inactivity checker: {e}") - await asyncio.sleep(30) - - -# Update activity timestamp when playing -def update_activity(guild_id): - """Call this when a song starts playing""" - last_activity[guild_id] = asyncio.get_event_loop().time() + # Disconnect + await ctx.voice_client.disconnect(force=False) # Build a display message for queuing a new song @@ -131,7 +72,17 @@ async def display_server_queue(ctx: Context, songs, n): color=config.get_color("main")) current_song = await queue.get_current_song(ctx.guild.id) - display = f"🔊 Currently playing: ``{current_song}``\n\n" + loop_mode = await queue.get_loop_mode(ctx.guild.id) + volume = await queue.get_volume(ctx.guild.id) + effect = await queue.get_effect(ctx.guild.id) + + # Add loop emoji based on mode + loop_emojis = {'off': '', 'song': '🔂', 'queue': '🔁'} + loop_emoji = loop_emojis.get(loop_mode, '') + effect_emoji = queue.get_effect_emoji(effect) + + display = f"🔊 Currently playing: ``{current_song}`` {loop_emoji}\n" + display += f"Volume: {volume}% | Loop: {loop_mode} | Effect: {effect_emoji} {effect}\n\n" for i, song in enumerate(songs):