diff --git a/README.md b/README.md
index 212d35d..d1128f6 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
` | Search lyrics by song title |
-| `!lyrics - ` | Search lyrics by title and artist |
+| `!lyrics` | Follow the current song's lyrics live, in one self-updating message |
+| `!lyrics ` | Look up lyrics by song title |
+| `!lyrics - ` | Look up lyrics by title and artist |
| `!help` | Show the full command list |
---
@@ -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
diff --git a/cogs/effects.py b/cogs/effects.py
index 6241805..ec669c4 100644
--- a/cogs/effects.py
+++ b/cogs/effects.py
@@ -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
@@ -35,9 +39,9 @@ class Effect:
# the speed factor becomes 48000*N/ 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 🎤"),
@@ -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."))
@@ -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()
diff --git a/cogs/lyrics.py b/cogs/lyrics.py
index db2af4e..c4ed139 100644
--- a/cogs/lyrics.py
+++ b/cogs/lyrics.py
@@ -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 → search by title
- !lyrics - → 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 `"
))
- 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):
diff --git a/docs/superpowers/specs/2026-09-12-synced-lyrics-design.md b/docs/superpowers/specs/2026-09-12-synced-lyrics-design.md
new file mode 100644
index 0000000..04e8cfd
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-12-synced-lyrics-design.md
@@ -0,0 +1,113 @@
+# Synced lyrics — design
+
+`!lyrics` answers with the whole song at once, which for a long track means
+several messages in a row (`Rap God` is 8,065 characters — three of them). It is
+also static: it says nothing about where the song currently is.
+
+This replaces it with one message that follows the music, and makes the
+unsynced case a single navigable message instead of a wall.
+
+## What it does
+
+- `!lyrics` with something playing — posts **one** message showing the line that
+ is playing with two lines of context either side, and keeps editing that same
+ message as the song moves. It follows the queue: when the next track starts,
+ the same message loads its lyrics and carries on.
+- `!lyrics ` — the lyrics for that search, static, one message, paged
+ with buttons when long.
+- No synced lyrics available — falls back to plain text in the same paged
+ message, labelled so it is clear why it is not moving.
+- A **Stop** button ends the follower. So does `!stop`, an empty queue, or the
+ player being destroyed.
+
+## Where the timings come from
+
+Genius has no timestamps, so it cannot do this. [LRCLIB](https://lrclib.net) is
+free, needs no API key, and returns LRC-format lyrics with `[mm:ss.xx]` marks.
+Checked against the music this bot actually plays — KAROL G, Bad Bunny, Queen,
+Eminem all returned synced lyrics.
+
+Lookups pass **artist, title and duration**. The duration is the part that
+matters: without it a query matches a live version or an extended edit whose
+timings are wrong for the audio actually playing. The bot already has it in
+`track["duration"]`.
+
+Fallback order: LRCLIB synced → LRCLIB plain → Genius (the existing path).
+
+## Pieces
+
+| Piece | Responsibility |
+|---|---|
+| `services/synced_lyrics.py` | Talk to LRCLIB; parse LRC into `[(seconds, text)]` |
+| `utils/embeds.py` | Render the window and the static pages — pure functions |
+| `cogs/lyrics.py` | The command, the follower task, the buttons |
+
+`aiohttp` is used directly rather than `lyricsgenius`. It is already a
+dependency of discord.py and it does not block, so the lookup needs no executor
+thread — unlike Genius, which does.
+
+**One session, not one per request.** The cog opens an `aiohttp.ClientSession`
+on load and closes it on unload. A session per request is what leaked in #34.
+
+## Following the song
+
+A `LyricsFollower` per guild owns one task and one message.
+
+**It does not poll.** It works out when the next line begins and sleeps until
+then, capped at 2 seconds so a pause, skip or stop is noticed promptly. That is
+at most one wakeup every two seconds, rather than a tight loop — this runs on a
+768 MB box.
+
+It edits only when the visible window actually changes, and never more often
+than every 2.5 seconds. Discord allows roughly five edits per five seconds per
+channel, and fast songs change lines more often than that; the window always
+renders the *current* line, so skipping intermediate ones costs nothing.
+
+**No cache.** The follower reloads lyrics only when `player.current` changes
+identity, and `loop track` replays the same dict — so a cache would never be
+read.
+
+## The speed effects
+
+`nightcore` plays at 1.25x and `vaporwave` at 0.8x, but `MusicPlayer.elapsed` is
+wall-clock. At 1.25x the audio is a quarter ahead of the clock, and the lyrics
+would visibly drift.
+
+`Effect` gains a `rate` field, `apply_effect` records it, and the player exposes:
+
+```python
+position = seek_base + (elapsed - seek_base) * effect_rate
+```
+
+`seek_base` is the offset the current stream was spawned at, which is already
+what `_start_ts` is backdated by when an effect change resumes in place.
+
+## Failure
+
+Nothing here can take the bot down. LRCLIB unreachable, a malformed LRC body, a
+deleted message, a lost permission — each degrades to the static path or ends
+the follower quietly, and is logged rather than raised. A missing lyric is not a
+reason to stop the music.
+
+## Testing
+
+The parser and the renderer are pure functions and get table tests: multiple
+timestamps on one line (`[00:10][01:20] chorus`), `[ar:]`/`[ti:]` metadata,
+blank lines between verses, out-of-order marks, a body with no marks at all.
+
+The follower is tested against a fake player and a fake message: that it edits
+on a line change, that it does not edit when the window is unchanged, that it
+respects the minimum interval, that it follows a track change, that it stops on
+destroy, and that it leaves no task behind.
+
+The LRCLIB client is tested against recorded payloads — no network in the suite.
+
+One end-to-end check against the real LRCLIB and the real bot before calling it
+done.
+
+## Not doing
+
+- Prefetching the next track's lyrics. One request per track change is already
+ cheap, and it would add a second lifecycle to get wrong.
+- Translations, romanisation, or contributor credits.
+- Per-user followers. One per guild; the second `!lyrics` replaces the first.
diff --git a/services/lyrics_api.py b/services/lyrics_api.py
index 50e4950..252511d 100644
--- a/services/lyrics_api.py
+++ b/services/lyrics_api.py
@@ -41,7 +41,7 @@ def _client() -> lyricsgenius.Genius:
)
-async def fetch(title: str, artist: str = "", *, loop=None) -> Optional[dict]:
+async def fetch(title: str, artist: str = "") -> Optional[dict]:
"""
Search Genius for lyrics.
@@ -56,7 +56,9 @@ async def fetch(title: str, artist: str = "", *, loop=None) -> Optional[dict]:
log.debug("No GENIUS_TOKEN configured; skipping lookup for %r", title)
return None
- loop = loop or asyncio.get_event_loop()
+ # The running loop, not a passed-in one: reaching for `bot.loop` before
+ # the gateway is up raises, and this never needs a different loop anyway.
+ loop = asyncio.get_running_loop()
def _search():
client = _client()
diff --git a/services/synced_lyrics.py b/services/synced_lyrics.py
new file mode 100644
index 0000000..f5ca5f8
--- /dev/null
+++ b/services/synced_lyrics.py
@@ -0,0 +1,130 @@
+"""
+Time-synced lyrics, from LRCLIB.
+
+Genius has no timestamps, so it cannot say *when* a line is sung. LRCLIB does,
+needs no API key, and returns the LRC format: one line per lyric, each prefixed
+with the time it starts.
+
+ [00:07.13] Caught in a landslide, no escape from reality
+"""
+
+import logging
+import re
+
+import aiohttp
+from bisect import bisect_right
+from dataclasses import dataclass
+from typing import Optional
+
+log = logging.getLogger("loopify.synced")
+
+API_URL = "https://lrclib.net/api/get"
+# LRCLIB asks clients to identify themselves.
+USER_AGENT = "LoopifyBot (https://github.com/Isma-L154/LoopifyBot)"
+# A lyric is a nicety; it must never hold up the music for long.
+REQUEST_TIMEOUT = 6.0 # seconds; passed as an aiohttp.ClientTimeout below
+
+# [mm:ss], [mm:ss.xx] or [mm:ss.xxx]. Minutes are not capped at 60 — a long
+# track just keeps counting. Metadata tags like [ar: Queen] never match, which
+# is how they get ignored.
+_STAMP = re.compile(r"\[(\d+):(\d{2})(?:[.:](\d{1,3}))?\]")
+
+Line = tuple[float, str]
+
+
+def parse_lrc(body: str) -> tuple[Line, ...]:
+ """
+ Read an LRC body into ``(seconds, text)`` pairs, in time order.
+
+ A line may carry several timestamps — that is how a repeated chorus is
+ written — and each becomes its own entry. A timestamp with no words is kept
+ rather than dropped: LRCLIB marks instrumental gaps that way, and losing
+ them would leave the previous line on screen through the whole break.
+ """
+ lines: list[Line] = []
+ for raw in body.splitlines():
+ stamps = list(_STAMP.finditer(raw))
+ if not stamps:
+ continue
+ text = raw[stamps[-1].end():].strip()
+ for stamp in stamps:
+ minutes, seconds, fraction = stamp.groups()
+ at = int(minutes) * 60 + int(seconds)
+ if fraction:
+ at += int(fraction) / 10 ** len(fraction)
+ lines.append((at, text))
+ return tuple(sorted(lines, key=lambda line: line[0]))
+
+
+def index_at(lines: tuple[Line, ...], position: float) -> int:
+ """
+ Index of the line playing at ``position``, or -1 before the first one.
+
+ Exactly on a timestamp counts as that line having started.
+ """
+ return bisect_right(lines, position, key=lambda line: line[0]) - 1
+
+
+@dataclass(frozen=True)
+class Lyrics:
+ """What LRCLIB knows about one track."""
+
+ title: str
+ artist: str
+ lines: tuple[Line, ...] = () # empty unless the body carried timestamps
+ plain: str = ""
+ instrumental: bool = False
+
+ @property
+ def synced(self) -> bool:
+ return bool(self.lines)
+
+
+async def fetch(session, title: str, artist: str,
+ duration: Optional[float]) -> Optional[Lyrics]:
+ """
+ Look a track up on LRCLIB. Returns None when there is nothing to show.
+
+ ``session`` is an ``aiohttp.ClientSession`` owned by the caller. Nothing
+ here raises: LRCLIB being unreachable, slow or wrong is not a reason to
+ interrupt playback, so every failure becomes None and a log line.
+ """
+ params = {"track_name": title, "artist_name": artist}
+ if duration:
+ params["duration"] = int(duration)
+
+ try:
+ async with session.get(
+ API_URL, params=params,
+ headers={"User-Agent": USER_AGENT},
+ timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT),
+ ) as response:
+ if response.status != 200:
+ log.debug("LRCLIB returned %s for %r", response.status, title)
+ return None
+ payload = await response.json(content_type=None)
+ except Exception as e:
+ log.warning("LRCLIB lookup failed for %r: %s", title, e)
+ return None
+
+ return _build(payload, title, artist)
+
+
+def _build(payload: dict, title: str, artist: str) -> Optional[Lyrics]:
+ """Turn a payload into Lyrics, or None when it holds nothing worth showing."""
+ lines = parse_lrc(payload.get("syncedLyrics") or "")
+ plain = (payload.get("plainLyrics") or "").strip()
+ if not plain and lines:
+ # A payload can carry timings and no plain copy. `!lyrics ` has
+ # no clock to follow, so it needs the words on their own.
+ plain = "\n".join(text for _, text in lines)
+ found = Lyrics(
+ title=payload.get("trackName") or title,
+ artist=payload.get("artistName") or artist,
+ lines=lines,
+ plain=plain,
+ instrumental=bool(payload.get("instrumental")),
+ )
+ if found.synced or found.plain or found.instrumental:
+ return found
+ return None
diff --git a/tests/test_effect_filters.py b/tests/test_effect_filters.py
index 4929f82..c9826c7 100644
--- a/tests/test_effect_filters.py
+++ b/tests/test_effect_filters.py
@@ -144,3 +144,17 @@ def test_seeking_and_an_effect_apply_together(tone_file):
handle, ffmpeg_filter=EFFECTS["bassboost"].filter, seek_seconds=20,
)
assert played_seconds(source) == pytest.approx(10.0, abs=0.3)
+
+
+# -- the declared rate must match the filter ----------------------------
+
+@pytest.mark.parametrize("name", sorted(EFFECTS))
+def test_the_declared_rate_is_what_ffmpeg_actually_does(name):
+ """
+ `Effect.rate` is how synced lyrics know where the song is: at 1.25x the audio
+ runs a quarter ahead of the wall clock. A rate that disagrees with its filter
+ would drift the lyrics further out with every passing minute, so it is
+ measured against real FFmpeg rather than taken on trust.
+ """
+ effect = EFFECTS[name]
+ assert speed_factor(effect.filter, 44100) == pytest.approx(effect.rate, rel=0.05)
diff --git a/tests/test_lyrics_follower.py b/tests/test_lyrics_follower.py
new file mode 100644
index 0000000..d678a40
--- /dev/null
+++ b/tests/test_lyrics_follower.py
@@ -0,0 +1,246 @@
+"""
+The follower: one message kept in step with the music.
+
+Driven by a scripted clock rather than real time, so the loop runs to completion
+in microseconds. Nothing here touches Discord or LRCLIB — the follower is given
+a message to edit and a way to load lyrics, and both are plain fakes.
+"""
+
+import discord
+import pytest
+
+from cogs.lyrics import MAX_SLEEP, MIN_EDIT_INTERVAL, LyricsFollower
+from services.synced_lyrics import Lyrics
+
+SONG = Lyrics(title="Song", artist="Artist",
+ lines=((0.0, "one"), (10.0, "two"), (20.0, "three"), (30.0, "four")))
+OTHER = Lyrics(title="Other", artist="Artist", lines=((0.0, "otra"), (5.0, "linea")))
+UNSYNCED = Lyrics(title="Song", artist="Artist", plain="just words")
+
+
+def shown_text(edit: dict) -> str:
+ """The lyrics an edit actually rendered, not the repr of the Embed object."""
+ return edit["embed"].description or ""
+
+
+class FakeMessage:
+ def __init__(self, raises: Exception | None = None) -> None:
+ self.edits: list[dict] = []
+ self._raises = raises
+
+ async def edit(self, **kwargs) -> None:
+ if self._raises is not None:
+ raise self._raises
+ self.edits.append(kwargs)
+
+
+class FakePlayer:
+ def __init__(self, current=None, position: float = 0.0, rate: float = 1.0) -> None:
+ self.current = current
+ self.position = position
+ self.effect_rate = rate
+ self.is_destroyed = False
+
+
+class Conductor:
+ """Advances the fake clock and position, then stops the follower."""
+
+ def __init__(self, player: FakePlayer, script: list) -> None:
+ self.player = player
+ self.script = list(script)
+ self.now = 1000.0
+ self.slept: list[float] = []
+ self.follower: LyricsFollower | None = None
+
+ async def sleep(self, seconds: float) -> None:
+ self.slept.append(seconds)
+ self.now += max(seconds, 0.01)
+ if not self.script:
+ return self.follower.stop()
+ step = self.script.pop(0)
+ if callable(step):
+ step(self.player)
+ else:
+ self.player.position = step
+
+ def clock(self) -> float:
+ return self.now
+
+
+def build(player, script, *, lyrics=SONG, message=None):
+ """A follower wired to a scripted clock, ready to run."""
+ loaded = []
+
+ async def load(track):
+ loaded.append(track)
+ return lyrics(track) if callable(lyrics) else lyrics
+
+ conductor = Conductor(player, script)
+ follower = LyricsFollower(message or FakeMessage(), player, load,
+ sleep=conductor.sleep, now=conductor.clock)
+ conductor.follower = follower
+ return follower, conductor, loaded
+
+
+# -- following ---------------------------------------------------------
+
+async def test_it_edits_when_the_line_changes():
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ follower, conductor, _ = build(player, [15.0, 25.0])
+
+ await follower.run()
+
+ assert len(follower.message.edits) == 3, "one per line reached"
+
+
+async def test_it_does_not_edit_while_the_same_line_plays():
+ """Editing on every wakeup would burn the channel's rate limit for nothing."""
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ follower, _, _ = build(player, [2.0, 4.0, 6.0, 8.0])
+
+ await follower.run()
+
+ assert len(follower.message.edits) == 1, "the line never changed"
+
+
+async def test_the_minimum_interval_stays_under_the_wakeup_cap():
+ """
+ A threshold above the longest sleep would delay every ordinary line change
+ by a whole wakeup, which on lyrics is plainly visible.
+ """
+ assert MIN_EDIT_INTERVAL < MAX_SLEEP
+
+
+async def test_edits_are_spaced_out():
+ """
+ Discord allows roughly five edits per five seconds per channel, and a fast
+ song changes lines more often than that.
+ """
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ fast = Lyrics(title="F", artist="A",
+ lines=tuple((i * 0.5, f"line {i}") for i in range(20)))
+ follower, _, _ = build(player, [0.5, 1.0, 1.5, 2.0, 2.5], lyrics=fast)
+
+ await follower.run()
+
+ assert len(follower.message.edits) <= 2, "the minimum interval was ignored"
+
+
+async def test_it_picks_up_the_next_track():
+ player = FakePlayer(current={"t": 1}, position=0.0)
+
+ def switch(p):
+ p.current = {"t": 2}
+ p.position = 0.0
+
+ follower, _, loaded = build(
+ player, [switch, 6.0],
+ lyrics=lambda track: SONG if track["t"] == 1 else OTHER)
+
+ await follower.run()
+
+ assert loaded == [{"t": 1}, {"t": 2}], "the new track's lyrics were fetched"
+ assert any("otra" in shown_text(edit) for edit in follower.message.edits), "the message never showed the new song"
+
+
+async def test_an_unsynced_track_is_not_followed():
+ """Nothing to sync to, so it waits quietly rather than editing on a loop."""
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ follower, _, _ = build(player, [5.0, 10.0], lyrics=UNSYNCED)
+
+ await follower.run()
+
+ assert follower.message.edits == []
+
+
+# -- stopping ----------------------------------------------------------
+
+async def test_it_stops_when_the_player_is_destroyed():
+ player = FakePlayer(current={"t": 1}, position=0.0)
+
+ def destroy(p):
+ p.is_destroyed = True
+
+ follower, conductor, _ = build(player, [destroy, 15.0, 25.0, 35.0])
+
+ await follower.run()
+
+ assert len(conductor.script) > 0, "it kept running after the player died"
+
+
+async def test_it_stops_when_the_music_stops():
+ player = FakePlayer(current={"t": 1}, position=0.0)
+
+ def clear(p):
+ p.current = None
+
+ follower, conductor, _ = build(player, [clear, 15.0, 25.0])
+
+ await follower.run()
+
+ assert len(conductor.script) > 0
+
+
+async def test_a_deleted_message_ends_it_quietly():
+ """Someone tidying the channel must not leave a task spinning forever."""
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ gone = FakeMessage(raises=discord.NotFound(_response(404), "gone"))
+ follower, conductor, _ = build(player, [15.0, 25.0], message=gone)
+
+ await follower.run() # must not raise
+
+ assert len(conductor.script) > 0
+
+
+async def test_stop_is_idempotent():
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ follower, _, _ = build(player, [])
+
+ follower.stop()
+ follower.stop()
+
+ await follower.run()
+
+
+# -- how long it waits -------------------------------------------------
+
+async def test_it_sleeps_until_the_next_line_not_on_a_tick():
+ """Polling would burn CPU on a small box for no benefit."""
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ follower, conductor, _ = build(player, [15.0])
+
+ await follower.run()
+
+ assert conductor.slept[0] == pytest.approx(MAX_SLEEP), \
+ "the next line is 10s away, so it waits the cap and re-checks"
+
+
+async def test_the_wait_never_exceeds_the_cap():
+ """A pause or a skip has to be noticed promptly, even mid-verse."""
+ player = FakePlayer(current={"t": 1}, position=0.0)
+ sparse = Lyrics(title="S", artist="A", lines=((0.0, "one"), (600.0, "much later")))
+ follower, conductor, _ = build(player, [1.0, 2.0], lyrics=sparse)
+
+ await follower.run()
+
+ assert all(seconds <= MAX_SLEEP for seconds in conductor.slept)
+
+
+async def test_the_wait_accounts_for_a_speed_effect():
+ """At 1.25x a line ten song-seconds away arrives in eight real seconds."""
+ player = FakePlayer(current={"t": 1}, position=0.0, rate=1.25)
+ near = Lyrics(title="N", artist="A", lines=((0.0, "one"), (1.25, "two")))
+ follower, conductor, _ = build(player, [1.5], lyrics=near)
+
+ await follower.run()
+
+ assert conductor.slept[0] == pytest.approx(1.0), "1.25 song-seconds at 1.25x"
+
+
+def _response(status: int):
+ """Minimal stand-in for the aiohttp response discord.NotFound wants."""
+ class R:
+ def __init__(self) -> None:
+ self.status = status
+ self.reason = "Not Found"
+ return R()
diff --git a/tests/test_lyrics_view.py b/tests/test_lyrics_view.py
new file mode 100644
index 0000000..3561a64
--- /dev/null
+++ b/tests/test_lyrics_view.py
@@ -0,0 +1,134 @@
+"""
+Rendering lyrics: the moving window, and the static pages.
+
+Both are pure functions over text. Discord is not involved in any of it, which
+is what makes the awkward cases — the first line, the last line, an instrumental
+gap — cheap to pin down.
+"""
+
+import pytest
+
+from utils.embeds import clock, lyrics_pages, lyrics_window
+
+LINES = (
+ (0.0, "one"), (10.0, "two"), (20.0, "three"),
+ (30.0, "four"), (40.0, "five"), (50.0, "six"), (60.0, "seven"),
+)
+
+
+# -- the moving window -------------------------------------------------
+
+def test_the_playing_line_stands_out():
+ window = lyrics_window(LINES, index=3)
+
+ assert "**▶ four**" in window
+ assert "four" in window and "**▶ three**" not in window
+
+
+def test_context_on_both_sides():
+ window = lyrics_window(LINES, index=3, context=2)
+
+ for word in ("two", "three", "four", "five", "six"):
+ assert word in window
+ assert "one" not in window and "seven" not in window
+
+
+def test_the_start_of_a_song_does_not_pad_with_blanks():
+ """At line 0 there is nothing before it; the window just starts shorter."""
+ window = lyrics_window(LINES, index=0, context=2)
+
+ assert window.splitlines()[0].strip() == "▶ one".replace("▶ ", "**▶ ") + "**"
+
+
+def test_the_end_of_a_song_does_not_run_off():
+ window = lyrics_window(LINES, index=6, context=2)
+
+ assert "**▶ seven**" in window
+ assert "five" in window and "six" in window
+
+
+def test_before_the_first_line_nothing_is_playing_yet():
+ """
+ A song with an instrumental intro should not pretend line one is singing.
+ """
+ window = lyrics_window(LINES, index=-1, context=2)
+
+ assert "**▶" not in window
+ assert "one" in window, "what is coming should still be visible"
+
+
+def test_an_instrumental_gap_reads_as_one():
+ """A timed line with no words is a pause, and should look like a pause."""
+ lines = ((0.0, "sing"), (10.0, ""), (20.0, "sing again"))
+
+ assert "♪" in lyrics_window(lines, index=1)
+
+
+def test_no_lyrics_at_all_renders_nothing():
+ assert lyrics_window((), index=-1) == ""
+
+
+def test_a_single_line_song_works():
+ assert "**▶ only**" in lyrics_window(((0.0, "only"),), index=0)
+
+
+# -- the progress readout ----------------------------------------------
+
+@pytest.mark.parametrize("seconds,expected", [
+ (0, "0:00"),
+ (9, "0:09"),
+ (61, "1:01"),
+ (226, "3:46"),
+ (3600, "60:00"), # an hour-long mix stays in minutes, not "1:00:00"
+])
+def test_the_clock_reads_like_a_music_player(seconds, expected):
+ assert clock(seconds) == expected
+
+
+def test_a_negative_position_does_not_render_a_minus():
+ assert clock(-3) == "0:00"
+
+
+# -- static pages ------------------------------------------------------
+
+def test_short_lyrics_are_one_page():
+ assert lyrics_pages("a\nb\nc") == ["a\nb\nc"]
+
+
+def test_long_lyrics_are_split():
+ text = "\n".join(f"line {i}" for i in range(2000))
+
+ pages = lyrics_pages(text, limit=1000)
+
+ assert len(pages) > 1
+ assert all(len(page) <= 1000 for page in pages)
+
+
+def test_a_page_break_never_lands_mid_line():
+ """
+ The old version sliced at a fixed character count, which cut words in half.
+ """
+ text = "\n".join(f"line number {i}" for i in range(500))
+
+ for page in lyrics_pages(text, limit=200):
+ for line in page.splitlines():
+ assert line == "" or line.startswith("line number"), \
+ f"a line was cut in half: {line!r}"
+
+
+def test_nothing_is_lost_across_the_split():
+ text = "\n".join(f"line {i}" for i in range(300))
+
+ assert "\n".join(lyrics_pages(text, limit=250)) == text
+
+
+def test_a_single_line_longer_than_the_limit_is_split_anyway():
+ """One enormous line must not produce a page Discord will reject."""
+ pages = lyrics_pages("x" * 500, limit=100)
+
+ assert all(len(page) <= 100 for page in pages)
+ assert "".join(pages) == "x" * 500
+
+
+def test_empty_lyrics_give_one_empty_page():
+ assert lyrics_pages("") == [""]
diff --git a/tests/test_player_seek.py b/tests/test_player_seek.py
index 621b31c..ece9228 100644
--- a/tests/test_player_seek.py
+++ b/tests/test_player_seek.py
@@ -243,3 +243,55 @@ def test_the_filter_and_the_seek_coexist(captured_ffmpeg):
def test_no_filter_still_strips_video(captured_ffmpeg):
media.make_pipe_source(MagicMock())
assert captured_ffmpeg.last["options"] == "-vn"
+
+
+# -- position: where the audio really is -------------------------------
+#
+# `elapsed` is wall-clock. The two pitch effects change how fast the audio is
+# consumed, so at 1.25x the song is a quarter further along than the clock says.
+# Synced lyrics read `position`, and would drift visibly without this.
+
+def test_position_is_the_wall_clock_when_no_effect_is_on(playing):
+ assert playing.position == pytest.approx(playing.elapsed)
+
+
+def test_nightcore_puts_the_song_ahead_of_the_clock(playing):
+ playing.effect_rate = 1.25
+
+ assert playing.position == pytest.approx(180 * 1.25)
+
+
+def test_vaporwave_puts_the_song_behind_the_clock(playing):
+ playing.effect_rate = 0.8
+
+ assert playing.position == pytest.approx(180 * 0.8)
+
+
+def test_a_resumed_stream_counts_the_rate_only_from_the_resume_point(playing, clock):
+ """
+ An effect change respawns FFmpeg partway in. The seconds before that point
+ were already played at the old speed, so only what follows is scaled.
+ """
+ playing._seek_base = 180.0 # respawned at 3:00
+ playing._start_ts = clock.now - 180.0
+ clock.advance(40) # 40s of wall time since the respawn
+ playing.effect_rate = 1.25
+
+ assert playing.position == pytest.approx(180 + 40 * 1.25)
+
+
+def test_applying_an_effect_records_its_rate(playing):
+ playing.apply_effect("nightcore", "aresample=48000,asetrate=48000*1.25", rate=1.25)
+
+ assert playing.effect_rate == 1.25
+
+
+def test_clearing_the_effect_puts_the_rate_back(playing):
+ playing.apply_effect("nightcore", "filter", rate=1.25)
+ playing.apply_effect(None, "")
+
+ assert playing.effect_rate == 1.0
+
+
+def test_position_never_runs_backwards_before_playback(player):
+ assert player.position == 0.0
diff --git a/tests/test_synced_lyrics.py b/tests/test_synced_lyrics.py
new file mode 100644
index 0000000..375e1d0
--- /dev/null
+++ b/tests/test_synced_lyrics.py
@@ -0,0 +1,286 @@
+"""
+Reading LRC, the format LRCLIB returns.
+
+The parser is where the awkward cases live, so it is a pure function with no
+network and no Discord anywhere near it. The samples below are shaped like real
+LRCLIB bodies, including the ones that trip a naive `split("]")`.
+"""
+
+import pytest
+
+from services import synced_lyrics
+from services.synced_lyrics import index_at, parse_lrc
+
+# Trimmed from the real payload for Queen — Bohemian Rhapsody (LRCLIB id 19079).
+REAL = """[00:00.15] Is this the real life? Is this just fantasy?
+[00:07.13] Caught in a landslide, no escape from reality
+[00:14.77] Open your eyes, look up to the skies and see
+[00:25.37] I'm just a poor boy, I need no sympathy
+[03:37.85] No, we will not let you go (let him go)
+[03:40.52] بِسْمِ ٱللَّٰهِ"""
+
+
+# -- parsing -----------------------------------------------------------
+
+def test_a_timestamp_becomes_seconds_and_text():
+ assert parse_lrc("[01:23.45] hello") == ((83.45, "hello"),)
+
+
+def test_minutes_are_not_capped_at_sixty():
+ """A ten-minute track keeps counting in minutes, not hours."""
+ assert parse_lrc("[12:05.00] late") == ((725.0, "late"),)
+
+
+def test_centiseconds_are_optional():
+ assert parse_lrc("[00:09] no fraction") == ((9.0, "no fraction"),)
+
+
+def test_milliseconds_are_read_at_the_right_scale():
+ """Three digits is thousandths, two is hundredths — not the same number."""
+ assert parse_lrc("[00:01.5] a") == ((1.5, "a"),)
+ assert parse_lrc("[00:01.50] b") == ((1.5, "b"),)
+ assert parse_lrc("[00:01.500] c") == ((1.5, "c"),)
+
+
+def test_one_line_can_carry_several_timestamps():
+ """A repeated chorus is written once with every time it occurs."""
+ assert parse_lrc("[00:10.00][01:20.00] chorus") == (
+ (10.0, "chorus"), (80.0, "chorus"))
+
+
+def test_metadata_tags_are_not_lyrics():
+ body = "[ar: Queen]\n[ti: Bohemian Rhapsody]\n[length: 5:55]\n[00:01.00] real line"
+ assert parse_lrc(body) == ((1.0, "real line"),)
+
+
+def test_untimed_lines_are_dropped():
+ assert parse_lrc("no timestamp here\n[00:01.00] kept") == ((1.0, "kept"),)
+
+
+def test_an_empty_line_is_kept_as_a_pause():
+ """
+ LRCLIB marks instrumental gaps with a timestamp and no words. Dropping them
+ would leave the previous line on screen through the whole break, and the
+ window would jump when singing resumed.
+ """
+ assert parse_lrc("[00:01.00] a\n[00:05.00]\n[00:09.00] b") == (
+ (1.0, "a"), (5.0, ""), (9.0, "b"))
+
+
+def test_lines_come_back_in_time_order():
+ assert parse_lrc("[00:09.00] third\n[00:01.00] first\n[00:05.00] second") == (
+ (1.0, "first"), (5.0, "second"), (9.0, "third"))
+
+
+def test_leading_space_after_the_stamp_is_not_part_of_the_lyric():
+ assert parse_lrc("[00:01.00] padded ") == ((1.0, "padded"),)
+
+
+def test_a_body_with_no_timestamps_parses_to_nothing():
+ """Plain lyrics must not be mistaken for a synced body."""
+ assert parse_lrc("Is this the real life?\nIs this just fantasy?") == ()
+
+
+def test_an_empty_body_parses_to_nothing():
+ assert parse_lrc("") == ()
+
+
+def test_the_real_payload_parses():
+ lines = parse_lrc(REAL)
+ assert len(lines) == 6
+ assert lines[0] == (0.15, "Is this the real life? Is this just fantasy?")
+ assert lines[-1][0] == pytest.approx(220.52)
+ assert lines[-1][1] == "بِسْمِ ٱللَّٰهِ", "unicode must survive the parse"
+
+
+# -- finding the line that is playing ----------------------------------
+
+LINES = ((0.0, "zero"), (10.0, "ten"), (20.0, "twenty"))
+
+
+@pytest.mark.parametrize("position,expected", [
+ (0.0, 0), # exactly on the first mark
+ (5.0, 0),
+ (9.99, 0),
+ (10.0, 1), # exactly on a mark belongs to that line
+ (15.0, 1),
+ (20.0, 2),
+ (999.0, 2), # past the end, the last line stays
+])
+def test_the_line_playing_at_a_position(position, expected):
+ assert index_at(LINES, position) == expected
+
+
+def test_before_the_first_line_there_is_none():
+ """A song with an intro should show nothing rather than the first line."""
+ assert index_at(((5.0, "first"),), 1.0) == -1
+
+
+def test_no_lines_at_all():
+ assert index_at((), 12.0) == -1
+
+
+# -- the LRCLIB client -------------------------------------------------
+#
+# Shaped like the real response for Queen — Bohemian Rhapsody (LRCLIB id 19079).
+# Nothing here touches the network.
+
+SYNCED_PAYLOAD = {
+ "id": 19079,
+ "trackName": "Bohemian Rhapsody",
+ "artistName": "Queen",
+ "albumName": "Stone Cold Classics",
+ "duration": 355.0,
+ "instrumental": False,
+ "plainLyrics": "Is this the real life?\nIs this just fantasy?",
+ "syncedLyrics": "[00:00.15] Is this the real life?\n[00:07.13] Caught in a landslide",
+}
+
+
+class FakeResponse:
+ def __init__(self, status: int, payload=None, raises: Exception | None = None):
+ self.status = status
+ self._payload = payload
+ self._raises = raises
+
+ async def json(self, **kwargs):
+ if self._raises is not None:
+ raise self._raises
+ return self._payload
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+
+class FakeSession:
+ """Stands in for aiohttp, recording what was asked for."""
+
+ def __init__(self, response):
+ self._response = response
+ self.calls: list[dict] = []
+
+ def get(self, url, *, params=None, **kwargs):
+ self.calls.append({"url": url, "params": params})
+ if isinstance(self._response, Exception):
+ raise self._response
+ return self._response
+
+
+async def test_a_synced_payload_comes_back_parsed():
+ session = FakeSession(FakeResponse(200, SYNCED_PAYLOAD))
+
+ result = await synced_lyrics.fetch(session, "Bohemian Rhapsody", "Queen", 355)
+
+ assert result.synced is True
+ assert result.title == "Bohemian Rhapsody"
+ assert result.artist == "Queen"
+ assert result.lines[0] == (0.15, "Is this the real life?")
+
+
+async def test_the_duration_is_sent_when_known():
+ """It is what separates a studio cut from a nine-minute live version."""
+ session = FakeSession(FakeResponse(200, SYNCED_PAYLOAD))
+
+ await synced_lyrics.fetch(session, "Bohemian Rhapsody", "Queen", 355)
+
+ assert session.calls[0]["params"] == {
+ "track_name": "Bohemian Rhapsody", "artist_name": "Queen", "duration": 355,
+ }
+
+
+async def test_a_live_stream_has_no_duration_to_send():
+ session = FakeSession(FakeResponse(200, SYNCED_PAYLOAD))
+
+ await synced_lyrics.fetch(session, "Bohemian Rhapsody", "Queen", None)
+
+ assert "duration" not in session.calls[0]["params"]
+
+
+async def test_a_plain_only_payload_is_not_synced():
+ payload = {**SYNCED_PAYLOAD, "syncedLyrics": None}
+ session = FakeSession(FakeResponse(200, payload))
+
+ result = await synced_lyrics.fetch(session, "x", "y", None)
+
+ assert result.synced is False
+ assert result.plain.startswith("Is this the real life?")
+
+
+async def test_a_synced_body_with_no_usable_stamps_falls_back_to_plain():
+ payload = {**SYNCED_PAYLOAD, "syncedLyrics": "no stamps in here at all"}
+ session = FakeSession(FakeResponse(200, payload))
+
+ result = await synced_lyrics.fetch(session, "x", "y", None)
+
+ assert result.synced is False
+ assert result.plain
+
+
+async def test_an_instrumental_track_says_so():
+ """Answering "no lyrics found" for an instrumental is a worse answer."""
+ payload = {**SYNCED_PAYLOAD, "instrumental": True,
+ "syncedLyrics": None, "plainLyrics": None}
+ session = FakeSession(FakeResponse(200, payload))
+
+ result = await synced_lyrics.fetch(session, "x", "y", None)
+
+ assert result.instrumental is True
+ assert result.synced is False
+
+
+async def test_an_unknown_track_is_not_an_error():
+ session = FakeSession(FakeResponse(404))
+
+ assert await synced_lyrics.fetch(session, "zxqwv", "nobody", None) is None
+
+
+async def test_a_server_error_is_not_an_error_here():
+ session = FakeSession(FakeResponse(503))
+
+ assert await synced_lyrics.fetch(session, "x", "y", None) is None
+
+
+async def test_a_network_failure_never_reaches_the_caller():
+ """LRCLIB being down is not a reason to stop the music."""
+ session = FakeSession(OSError("connection refused"))
+
+ assert await synced_lyrics.fetch(session, "x", "y", None) is None
+
+
+async def test_a_malformed_body_is_not_an_error():
+ session = FakeSession(FakeResponse(200, raises=ValueError("not json")))
+
+ assert await synced_lyrics.fetch(session, "x", "y", None) is None
+
+
+async def test_a_payload_with_nothing_in_it_is_no_result():
+ payload = {**SYNCED_PAYLOAD, "syncedLyrics": None,
+ "plainLyrics": None, "instrumental": False}
+ session = FakeSession(FakeResponse(200, payload))
+
+ assert await synced_lyrics.fetch(session, "x", "y", None) is None
+
+
+async def test_plain_text_is_derived_when_only_the_synced_body_exists():
+ """
+ `!lyrics ` shows static text, and a payload can carry timings with
+ no plain copy. Without this the page would render empty.
+ """
+ payload = {**SYNCED_PAYLOAD, "plainLyrics": None}
+ session = FakeSession(FakeResponse(200, payload))
+
+ result = await synced_lyrics.fetch(session, "x", "y", None)
+
+ assert result.synced is True
+ assert result.plain == "Is this the real life?\nCaught in a landslide"
+
+
+async def test_derived_plain_text_keeps_instrumental_gaps_as_blank_lines():
+ payload = {**SYNCED_PAYLOAD, "plainLyrics": None,
+ "syncedLyrics": "[00:01.00] sing\n[00:05.00]\n[00:09.00] again"}
+ session = FakeSession(FakeResponse(200, payload))
+
+ assert (await synced_lyrics.fetch(session, "x", "y", None)).plain == "sing\n\nagain"
diff --git a/utils/embeds.py b/utils/embeds.py
index 2b12816..0a5e4fd 100644
--- a/utils/embeds.py
+++ b/utils/embeds.py
@@ -97,15 +97,84 @@ def info_embed(title: str, message: str) -> discord.Embed:
return discord.Embed(title=title, description=message, color=BLURPLE)
-def lyrics_embed(title: str, artist: str, lyrics: str) -> list[discord.Embed]:
- """Split lyrics across as many embeds as Discord's length limit requires."""
- max_len = 4000
- chunks = [lyrics[i:i + max_len] for i in range(0, len(lyrics), max_len)]
- return [
- discord.Embed(
- title=f"🎤 {title} — {artist}" if i == 0 else f"🎤 {title} (cont.)",
- description=chunk,
- color=GOLD,
- )
- for i, chunk in enumerate(chunks)
- ]
+EMBED_LIMIT = 4000 # Discord's cap on an embed description
+CONTEXT_LINES = 2 # lines shown either side of the one playing
+
+
+def clock(seconds: float) -> str:
+ """``3:46`` — a position readout, not a duration. Minutes never roll over."""
+ seconds = max(0, int(seconds))
+ return f"{seconds // 60}:{seconds % 60:02d}"
+
+
+def lyrics_window(lines, index: int, context: int = CONTEXT_LINES) -> str:
+ """
+ The line playing now, with a little of what came before and what is next.
+
+ ``index`` of -1 means the song has not reached its first line yet — during an
+ intro nothing is highlighted, but what is coming is still shown. A timed line
+ with no words is an instrumental gap and reads as one.
+ """
+ if not lines:
+ return ""
+ first = max(0, index - context)
+ rendered = []
+ for position in range(first, min(len(lines), max(index, 0) + context + 1)):
+ text = lines[position][1] or "♪"
+ rendered.append(f"**▶ {text}**" if position == index else f" {text}")
+ return "\n".join(rendered)
+
+
+def lyrics_pages(text: str, limit: int = EMBED_LIMIT) -> list[str]:
+ """
+ Split lyrics into embed-sized pages, breaking between lines.
+
+ Slicing at a fixed character count cuts words, and sometimes whole verses,
+ in half. A single line longer than the limit still has to be broken, but
+ that is rare enough to be worth handling bluntly.
+ """
+ pages: list[str] = []
+ current = ""
+ for line in text.split("\n"):
+ while len(line) > limit:
+ if current:
+ pages.append(current)
+ current = ""
+ pages.append(line[:limit])
+ line = line[limit:]
+ candidate = f"{current}\n{line}" if current else line
+ if len(candidate) > limit:
+ pages.append(current)
+ current = line
+ else:
+ current = candidate
+ pages.append(current)
+ return pages
+
+
+def synced_lyrics_embed(title: str, artist: str, lines, index: int,
+ position: float, duration: Optional[float]) -> discord.Embed:
+ """The live view: a window on the lyrics plus where the song is."""
+ embed = discord.Embed(
+ title=f"🎤 {title} — {artist}",
+ description=lyrics_window(lines, index),
+ color=GOLD,
+ )
+ total = f" / {clock(duration)}" if duration else ""
+ embed.set_footer(text=f"{clock(position)}{total}")
+ return embed
+
+
+def lyrics_embed(title: str, artist: str, lyrics: str,
+ page: int = 0, note: str = "") -> discord.Embed:
+ """One page of static lyrics."""
+ pages = lyrics_pages(lyrics)
+ page = max(0, min(page, len(pages) - 1))
+ embed = discord.Embed(
+ title=f"🎤 {title} — {artist}",
+ description=pages[page],
+ color=GOLD,
+ )
+ footer = f"Page {page + 1}/{len(pages)}" if len(pages) > 1 else ""
+ embed.set_footer(text=" • ".join(part for part in (footer, note) if part))
+ return embed
diff --git a/utils/player.py b/utils/player.py
index a426b59..c3ce7bc 100644
--- a/utils/player.py
+++ b/utils/player.py
@@ -63,11 +63,15 @@ def __init__(self, bot: discord.Client, guild: discord.Guild,
self.volume: float = 0.5
self.effect_name: Optional[str] = None
self.effect_filter: str = ""
+ # How fast the active effect consumes audio: nightcore 1.25, vaporwave
+ # 0.8, everything else 1.0. `position` needs it; `elapsed` does not.
+ self.effect_rate: float = 1.0
self._start_ts: float = 0.0 # monotonic clock when current started
self._paused_at: Optional[float] = None # when the current pause began
self._paused_total: float = 0.0 # paused seconds, this track
self._resume_at: float = 0.0 # seek offset for the next spawn
+ self._seek_base: float = 0.0 # offset the live stream started at
self._stream: Optional[media.AudioStream] = None # active yt-dlp stream
# Next track's stream, fetched while the current one plays.
self._prefetch: Optional[tuple[dict, media.AudioStream]] = None
@@ -169,6 +173,20 @@ def elapsed(self) -> float:
paused += time.monotonic() - self._paused_at
return max(0.0, time.monotonic() - self._start_ts - paused)
+ @property
+ def position(self) -> float:
+ """
+ Where the audio actually is, which is not always where the clock is.
+
+ The pitch effects change playback speed, so at 1.25x the song is a
+ quarter further along than wall time. Only the stretch since the current
+ stream was spawned is scaled — whatever came before it was heard at
+ whatever speed was in force then, and `_seek_base` is where it resumed.
+ """
+ if self.effect_rate == 1.0:
+ return self.elapsed
+ return self._seek_base + (self.elapsed - self._seek_base) * self.effect_rate
+
def pause(self) -> bool:
"""Pause playback and stop the clock, so ``elapsed`` stays honest."""
vc = self.voice
@@ -189,7 +207,8 @@ def resume(self) -> bool:
self._paused_at = None
return True
- def apply_effect(self, name: Optional[str], filter_str: str) -> bool:
+ def apply_effect(self, name: Optional[str], filter_str: str,
+ rate: float = 1.0) -> bool:
"""
Switch the current track to a new FFmpeg filter, resuming in place.
@@ -203,6 +222,7 @@ def apply_effect(self, name: Optional[str], filter_str: str) -> bool:
return False
self.effect_name = name
self.effect_filter = filter_str
+ self.effect_rate = rate
self._resume_at = self._seek_target()
self._replay = True
vc.stop()
@@ -353,6 +373,7 @@ async def _player_loop(self) -> None:
self._replay = False
seek_to = self._resume_at
self._resume_at = 0.0
+ self._seek_base = seek_to
source = media.make_pipe_source(
stream.stdout, volume=self.volume,
ffmpeg_filter=self.effect_filter, seek_seconds=seek_to,