From 6350cdcc6ae78a3802cd4dae78e7eaf285573e63 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sat, 12 Sep 2026 01:15:14 -0600 Subject: [PATCH 1/2] Add the synced-lyrics design --- .../specs/2026-09-12-synced-lyrics-design.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-12-synced-lyrics-design.md 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. From 64745a6a950e24add56106ec8d604dab317ab618 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Sat, 12 Sep 2026 01:32:51 -0600 Subject: [PATCH 2/2] Follow the lyrics while the song plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `!lyrics` answered with the whole song at once, which for a long track meant several messages in a row — `Rap God` is 7,936 characters and arrived as three. It was also static: it said nothing about where the song actually was. It is now one message that follows the music. The line being sung is shown with two lines of context either side, and the same message keeps editing itself as the song moves and as the queue advances. A Stop button ends it, and so does `!stop`, an empty queue, or the player being destroyed. Genius has no timestamps, so it cannot do this. LRCLIB can: free, no API key, LRC bodies with `[mm:ss.xx]` marks. It was checked against the music this bot actually plays before anything was built — KAROL G, Bad Bunny, Queen and Eminem all came back synced. Lookups send the duration too, which is what keeps a query from matching a live version whose timings are wrong for the audio playing. Genius stays as the fallback for what LRCLIB does not have. Two decisions were made for the sake of the small host it runs on: - **It does not poll.** The follower works out when the next line begins and sleeps until then, capped at two seconds so a pause or a skip is noticed promptly. Measured on the server: 0.018s of CPU over twelve seconds of following, which is 0.15% of one core. - **There is no cache.** One was drafted and dropped: the follower reloads only when `player.current` changes identity, and `loop track` replays the same dict, so it would never have been read. Edits are throttled — Discord allows about five per five seconds per channel and a fast song changes lines more often than that. The first threshold tried was 2.5s, which a test caught as wrong: above the two-second wakeup cap, it delayed ordinary line changes by a whole wakeup, plainly visible on lyrics. It is 1.5s, and a test now asserts the two constants stay in that order. The pitch effects would have broken the sync outright. `nightcore` plays at 1.25x while `elapsed` counts wall-clock, so the song runs a quarter ahead of the clock and the lyrics would drift further out every minute. `Effect` gains a `rate`, the player exposes a `position` that accounts for it, and — because a declared rate that disagrees with its filter is worse than none — a test measures each rate against real FFmpeg rather than trusting the number. The static path is fixed too, since it is what songs without timings fall back to. It is one message with page buttons instead of several, and pages now break between lines: the old version sliced at a fixed character count and cut words in half. Two bugs turned up while testing against the real thing: - `lyrics_api.fetch` took a `loop` argument, and the cog was passing `self.bot.loop` — which raises if the gateway is not up yet. The parameter was never needed; inside a coroutine `get_running_loop()` is the answer. Removed. - A payload can carry timings and no plain copy, which would have rendered an empty page for `!lyrics `. The words are now derived from the timed lines when that happens. The parser and the renderer are pure functions and carry the awkward cases: several timestamps on one line, `[ar:]` metadata, out-of-order marks, an instrumental gap that must stay a gap rather than leave the previous line up through the whole break. The follower is driven by a scripted clock, so its tests run in microseconds and none of them touch Discord or the network. Verified end to end on the server against the real LRCLIB: 69 lines for BbY WOW, the right window at every position, the Genius fallback working with no gateway, the follower stopping on destroy, and no aiohttp session or pending task left behind. Tests: 427, up from 411. --- README.md | 12 +- cogs/effects.py | 20 ++- cogs/lyrics.py | 270 +++++++++++++++++++++++++++++--- services/lyrics_api.py | 6 +- services/synced_lyrics.py | 130 ++++++++++++++++ tests/test_effect_filters.py | 14 ++ tests/test_lyrics_follower.py | 246 +++++++++++++++++++++++++++++ tests/test_lyrics_view.py | 134 ++++++++++++++++ tests/test_player_seek.py | 52 +++++++ tests/test_synced_lyrics.py | 286 ++++++++++++++++++++++++++++++++++ utils/embeds.py | 93 +++++++++-- utils/player.py | 23 ++- 12 files changed, 1236 insertions(+), 50 deletions(-) create mode 100644 services/synced_lyrics.py create mode 100644 tests/test_lyrics_follower.py create mode 100644 tests/test_lyrics_view.py create mode 100644 tests/test_synced_lyrics.py 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 <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 | --- @@ -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/<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 🎤"), @@ -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 <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): 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 <search>` 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 <search>` 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,