Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
- 🔗 **Direct links** — Any of the 1000+ sites yt-dlp supports, plus raw audio URLs
- 📋 **Queue system** — Full queue management with shuffle, loop, and history
- 🎛️ **Audio effects** — Bass boost, nightcore, vaporwave, 8D audio, echo, and more
- 🎤 **Lyrics** — Fetch lyrics for any song via Genius
- 🎤 **Live lyrics** — One message that follows the song line by line, karaoke-style
- 🔊 **Volume control** — Per-server volume adjustment
- 🔁 **Loop modes** — Loop a single track or the entire queue
- ▶️ **Autoplay** — Automatically queue related tracks when the queue ends
Expand Down Expand Up @@ -65,9 +65,9 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound

| Command | Description |
|---|---|
| `!lyrics` | Get lyrics for the current song |
| `!lyrics <title>` | Search lyrics by song title |
| `!lyrics <title> - <artist>` | Search lyrics by title and artist |
| `!lyrics` | Follow the current song's lyrics live, in one self-updating message |
| `!lyrics <title>` | Look up lyrics by song title |
| `!lyrics <title> - <artist>` | Look up lyrics by title and artist |
| `!help` | Show the full command list |

---
Expand Down Expand Up @@ -95,7 +95,9 @@ cp .env.example .env # then fill in DISCORD_TOKEN
python main.py
```

`!lyrics` is optional — set `GENIUS_TOKEN` in `.env` to enable it.
Lyrics need no credentials: timings come from [LRCLIB](https://lrclib.net),
which is free and needs no key. `GENIUS_TOKEN` in `.env` is optional and only
adds a fallback for songs LRCLIB does not have.

### Running the tests
```bash
Expand Down
20 changes: 13 additions & 7 deletions cogs/effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@

@dataclass(frozen=True)
class Effect:
"""An FFmpeg filter chain and what the bot says when it is applied."""
"""An FFmpeg filter chain, what the bot says, and how fast it plays."""
filter: str
label: str
# How fast this filter consumes audio. Only the pitch effects move it, and
# synced lyrics need it to know where the song really is — wall-clock time
# is a quarter short at 1.25x. Measured against FFmpeg in the tests.
rate: float = 1.0


# The single source of truth for effects: the filter, the reply, and — via the
Expand All @@ -35,9 +39,9 @@ class Effect:
# the speed factor becomes 48000*N/<source rate> and differs per track. A
# 44.1 kHz upload ran at 1.36x and a 22 kHz one at 2.72x, not 1.25x.
"nightcore": Effect("aresample=48000,asetrate=48000*1.25,aresample=48000",
"Nightcore effect applied 🌙✨"),
"Nightcore effect applied 🌙✨", rate=1.25),
"vaporwave": Effect("aresample=48000,asetrate=48000*0.8,aresample=48000",
"Vaporwave effect applied 🌊🎶"),
"Vaporwave effect applied 🌊🎶", rate=0.8),
"treble": Effect("equalizer=f=8000:width_type=o:width=2:g=5", "Treble boost applied 🎵"),
"echo": Effect("aecho=0.8:0.88:60:0.4", "Echo effect applied 🔔"),
"karaoke": Effect("pan=stereo|c0=c0-c1|c1=c1-c0", "Karaoke mode on 🎤"),
Expand All @@ -63,14 +67,15 @@ def _throttled(self, ctx) -> bool:
self._last_change[ctx.guild.id] = now
return False

async def _switch_to(self, ctx, name: str | None, filter_str: str, label: str) -> None:
async def _switch_to(self, ctx, name: str | None, filter_str: str, label: str,
rate: float = 1.0) -> None:
"""Throttle, apply, and report — the whole path every effect command takes."""
if self._throttled(ctx):
return await ctx.send(embed=error_embed(
f"Easy — wait {_EFFECT_COOLDOWN:.0f}s between effect changes."
))
player = players.get(ctx.guild.id)
if player and player.apply_effect(name, filter_str):
if player and player.apply_effect(name, filter_str, rate):
await ctx.send(embed=success_embed(label))
else:
await ctx.send(embed=error_embed("Nothing is playing."))
Expand All @@ -79,8 +84,9 @@ async def _switch_to(self, ctx, name: str | None, filter_str: str, label: str) -
help="Apply an audio effect. Use !effects to see them all.")
@same_voice_channel()
async def apply_effect(self, ctx):
effect = EFFECTS[ctx.invoked_with.lower()]
await self._switch_to(ctx, ctx.invoked_with.lower(), effect.filter, effect.label)
name = ctx.invoked_with.lower()
effect = EFFECTS[name]
await self._switch_to(ctx, name, effect.filter, effect.label, effect.rate)

@commands.command(name="reset", aliases=["fxreset", "noeffect"])
@same_voice_channel()
Expand Down
270 changes: 247 additions & 23 deletions cogs/lyrics.py
Original file line number Diff line number Diff line change
@@ -1,53 +1,277 @@
import asyncio
import logging
import time
from typing import Awaitable, Callable, Optional

import aiohttp
import discord
from discord.ext import commands

from services import lyrics_api
from utils.embeds import error_embed, lyrics_embed
from services import lyrics_api, synced_lyrics
from services.synced_lyrics import Lyrics, index_at
from utils.embeds import (error_embed, info_embed, lyrics_embed, lyrics_pages,
synced_lyrics_embed)
from utils.player import players

log = logging.getLogger("loopify.lyrics")

# Discord allows roughly five message edits per five seconds per channel, and a
# fast song changes lines more often than that. The window always renders the
# current line, so skipping intermediate ones loses nothing.
#
# It has to stay below MAX_SLEEP: a threshold above the longest wakeup gap would
# delay ordinary line changes by a wakeup, which on lyrics is plainly visible.
MIN_EDIT_INTERVAL = 1.5
# Longest the follower ever sleeps. It normally waits exactly until the next
# line; capping it is how a pause, a skip or a stop gets noticed promptly
# without polling in a tight loop.
MAX_SLEEP = 2.0
MIN_SLEEP = 0.25

Loader = Callable[[dict], Awaitable[Optional[Lyrics]]]


class LyricsFollower:
"""
Keeps one message in step with what a guild is playing.

It owns no task of its own, and it reads the player rather than being
pushed to — so a pause, a skip, an effect change or a new track all show up
simply as a different position on the next wakeup.
"""

def __init__(self, message, player, load: Loader, *,
sleep=asyncio.sleep, now=time.monotonic) -> None:
self.message = message
self.player = player
self._load = load
self._sleep = sleep
self._now = now
self._stopped = False

def stop(self) -> None:
self._stopped = True

async def run(self) -> None:
"""Follow the music until the song, the player or the message runs out."""
track: Optional[dict] = None
lyrics: Optional[Lyrics] = None
shown: Optional[int] = None
last_edit = float("-inf")

while not self._stopped:
current = self.player.current
if current is None or self.player.is_destroyed:
return
if current is not track:
track, shown = current, None
lyrics = await self._load(current)

if lyrics is None or not lyrics.synced:
await self._sleep(MAX_SLEEP) # wait for a track we can follow
continue

position = self.player.position
index = index_at(lyrics.lines, position)
if index != shown and self._now() - last_edit >= MIN_EDIT_INTERVAL:
if not await self._show(lyrics, index, position, track):
return
shown, last_edit = index, self._now()

await self._sleep(self._until_next_line(lyrics.lines, index, position))

async def _show(self, lyrics: Lyrics, index: int, position: float,
track: Optional[dict]) -> bool:
"""Redraw the message. False means it is gone and we should stop."""
try:
await self.message.edit(embed=synced_lyrics_embed(
lyrics.title, lyrics.artist, lyrics.lines, index,
position, (track or {}).get("duration"),
))
return True
except (discord.NotFound, discord.Forbidden) as e:
log.debug("Lyrics message is no longer editable: %s", e)
return False
except discord.HTTPException as e:
log.warning("Could not update lyrics: %s", e)
return True # a rate limit or a blip, not a reason to stop

def _until_next_line(self, lines, index: int, position: float) -> float:
"""
How long until the next line, in real seconds.

The gap is in song-seconds, and a speed effect makes those pass faster
or slower than wall time: at 1.25x a line ten song-seconds away arrives
in eight.
"""
following = index + 1
if following >= len(lines):
return MAX_SLEEP
rate = self.player.effect_rate or 1.0
gap = (lines[following][0] - position) / rate
return min(MAX_SLEEP, max(MIN_SLEEP, gap))


class LyricsPages(discord.ui.View):
"""Page buttons for lyrics that cannot be followed."""

def __init__(self, title: str, artist: str, text: str, note: str) -> None:
super().__init__(timeout=600)
self.song_title = title
self.artist = artist
self.text = text
self.note = note
self.page = 0
self.total = len(lyrics_pages(text))

def embed(self) -> discord.Embed:
return lyrics_embed(self.song_title, self.artist, self.text,
self.page, self.note)

class Lyrics(commands.Cog, name="🎤 Lyrics"):
async def _turn(self, interaction: discord.Interaction, by: int) -> None:
self.page = (self.page + by) % self.total
await interaction.response.edit_message(embed=self.embed(), view=self)

@discord.ui.button(emoji="◀", style=discord.ButtonStyle.secondary)
async def previous(self, interaction: discord.Interaction, _button) -> None:
await self._turn(interaction, -1)

@discord.ui.button(emoji="▶", style=discord.ButtonStyle.secondary)
async def next(self, interaction: discord.Interaction, _button) -> None:
await self._turn(interaction, 1)


class FollowControls(discord.ui.View):
"""A stop button on the live message, so no command has to be typed."""

def __init__(self, on_stop: Callable[[], None]) -> None:
super().__init__(timeout=None)
self._on_stop = on_stop

@discord.ui.button(label="Stop", emoji="⏹",
style=discord.ButtonStyle.secondary)
async def stop_following(self, interaction: discord.Interaction,
_button) -> None:
self._on_stop()
await interaction.response.edit_message(view=None)


class Lyrics(commands.Cog, name="\U0001f3a4 Lyrics"):
def __init__(self, bot):
self.bot = bot
self._session: Optional[aiohttp.ClientSession] = None
self._following: dict[int, tuple[LyricsFollower, asyncio.Task]] = {}

async def cog_load(self) -> None:
# One session for the cog. Building one per request is what leaked in #34.
self._session = aiohttp.ClientSession()

async def cog_unload(self) -> None:
for guild_id in list(self._following):
self.stop_following(guild_id)
if self._session is not None:
await self._session.close()

# -- Looking lyrics up ---------------------------------------------

@staticmethod
def _split_query(query: str) -> tuple[str, str]:
"""Split a ``title - artist`` query. A query without the separator is all title."""
"""Split a ``title - artist`` query; one without the separator is all title."""
if " - " in query:
title, artist = query.split(" - ", 1)
return title.strip(), artist.strip()
return query.strip(), ""

def _current_track(self, ctx) -> tuple[str, str]:
"""Title and artist of whatever is playing, or ``("", "")`` if nothing is."""
player = players.get(ctx.guild.id)
if not player or not player.current:
return "", ""
return player.current["title"], player.current.get("uploader", "")
async def _find(self, title: str, artist: str,
duration: Optional[float]) -> Optional[Lyrics]:
"""LRCLIB first, since it is the only source with timings, then Genius."""
found = await synced_lyrics.fetch(self._session, title, artist, duration)
if found is not None:
return found
fallback = await lyrics_api.fetch(title, artist)
if fallback is None:
return None
return Lyrics(title=fallback["title"], artist=fallback["artist"],
plain=fallback["lyrics"])

async def _for_track(self, track: dict) -> Optional[Lyrics]:
return await self._find(track.get("title", ""),
track.get("uploader") or "",
track.get("duration"))

# -- Following -----------------------------------------------------

def stop_following(self, guild_id: int) -> None:
"""End a guild's follower, if it has one. Safe to call twice."""
entry = self._following.pop(guild_id, None)
if entry is None:
return
follower, task = entry
follower.stop()
if not task.done():
task.cancel()

async def _follow(self, ctx, player, lyrics: Lyrics) -> None:
"""Post the live message and start keeping it up to date."""
self.stop_following(ctx.guild.id) # one per guild; the newest wins
index = index_at(lyrics.lines, player.position)
message = await ctx.send(
embed=synced_lyrics_embed(
lyrics.title, lyrics.artist, lyrics.lines, index,
player.position, (player.current or {}).get("duration")),
view=FollowControls(lambda: self.stop_following(ctx.guild.id)),
)
follower = LyricsFollower(message, player, self._for_track)
task = self.bot.loop.create_task(self._run_follower(ctx.guild.id, follower))
self._following[ctx.guild.id] = (follower, task)

async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None:
try:
await follower.run()
except asyncio.CancelledError:
raise
except Exception:
log.exception("Lyrics follower crashed for guild %s", guild_id)
finally:
self._following.pop(guild_id, None)

# -- The command ---------------------------------------------------

@commands.command(aliases=["ly"])
async def lyrics(self, ctx, *, query: str = None):
"""
Fetch lyrics for the current song or a specific query.
Usage: !lyrics → current song
!lyrics <title> → search by title
!lyrics <title> - <artist> → title + artist
"""
"""Follow the lyrics of the current song, or look a song up."""
async with ctx.typing():
title, artist = (self._split_query(query) if query
else self._current_track(ctx))
if not title:
player = players.get(ctx.guild.id)
if query:
title, artist = self._split_query(query)
found = await self._find(title, artist, None)
elif player and player.current:
title = player.current.get("title", "")
found = await self._for_track(player.current)
else:
return await ctx.send(embed=error_embed(
"Nothing is playing. Provide a song name: `!lyrics <title>`"
))

result = await lyrics_api.fetch(title, artist, loop=self.bot.loop)
if not result:
if found is None:
return await ctx.send(embed=error_embed(
f"Couldn't find lyrics for **{title}**."
))
if found.instrumental:
return await ctx.send(embed=info_embed(
"\U0001f3b5 Instrumental",
f"**{found.title}** has no lyrics to show."
))

# Only the playing track can be followed: a lyric needs a clock, and
# a search result has none.
if found.synced and not query and player and player.current:
return await self._follow(ctx, player, found)

for embed in lyrics_embed(result["title"], result["artist"], result["lyrics"]):
await ctx.send(embed=embed)
note = "" if query else "not synced - showing the full lyrics"
view = LyricsPages(found.title, found.artist, found.plain, note)
await ctx.send(embed=view.embed(),
view=view if view.total > 1 else None)


async def setup(bot):
Expand Down
Loading
Loading