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/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 <query>` — 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 <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,