From 2560a4fce62a66d9f88889fcc33d5cc1fe9c8389 Mon Sep 17 00:00:00 2001 From: ltadeu6 Date: Mon, 8 Jun 2026 17:33:18 -0300 Subject: [PATCH] feat: verse-based lyric grouping and active-verse tracking group_into_verses() splits LRC lyrics on empty lines or timestamp gaps >3.5s; find_active_verse() returns the current verse lines and active index with INTER_VERSE_CAP (8s) cap on the last line of each verse. ControlState.set_lyrics() now pre-computes verses; current() exposes verse_lines and verse_active instead of the old 5-line lyric_context. Co-Authored-By: Claude Sonnet 4.6 --- luz-musica.py | 85 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/luz-musica.py b/luz-musica.py index b6bd64d..a4651f4 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -96,6 +96,7 @@ class ControlState: def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None: with self._lock: self._lyrics = lyrics + self._verses = group_into_verses(lyrics) def set_position(self, position: float) -> None: with self._lock: @@ -103,40 +104,17 @@ class ControlState: def current(self) -> dict[str, object]: with self._lock: - lyrics = getattr(self, "_lyrics", []) + verses = getattr(self, "_verses", []) position = getattr(self, "_position", 0.0) - MAX_DISPLAY = 5.0 - - active_idx = -1 - line = "" - for i, (ts, text) in enumerate(lyrics): - if ts > position: - break - next_ts = lyrics[i + 1][0] if i + 1 < len(lyrics) else ts + MAX_DISPLAY - if position <= min(next_ts, ts + MAX_DISPLAY): - active_idx = i - line = text - else: - active_idx = -1 - line = "" - - # Build 5-line context from non-empty lyrics, centered on active - context: list[dict[str, object]] = [] - if active_idx >= 0 and line: - display_idxs = [i for i, (_, t) in enumerate(lyrics) if t.strip()] - if active_idx in display_idxs: - pos = display_idxs.index(active_idx) - for offset in range(-2, 3): - di = pos + offset - t = lyrics[display_idxs[di]][1] if 0 <= di < len(display_idxs) else "" - context.append({"text": t, "offset": offset}) - + verse_lines, verse_active = find_active_verse(verses, position) + line = verse_lines[verse_active] if verse_active >= 0 and verse_lines else "" return { - "art_url": getattr(self, "_art_url", None), - "rgb": list(getattr(self, "_rgb", [0, 200, 255])), - "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), - "lyric_line": line, - "lyric_context": context, + "art_url": getattr(self, "_art_url", None), + "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), + "lyric_line": line, + "verse_lines": verse_lines, + "verse_active": verse_active, } @@ -221,6 +199,9 @@ def start_control_server() -> ThreadingHTTPServer: return server +VERSE_GAP_SECS = 3.5 # gap between lines that splits a verse +INTER_VERSE_CAP = 8.0 # how long to keep last line of a verse visible + _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]') @@ -255,6 +236,46 @@ def clean_title(title: str) -> str: return t +def group_into_verses( + lyrics: list[tuple[float, str]], +) -> list[list[tuple[float, str]]]: + """Split non-empty lyrics into verses on empty LRC lines or large timestamp gaps.""" + verses: list[list[tuple[float, str]]] = [] + current: list[tuple[float, str]] = [] + for ts, text in lyrics: + if not text.strip(): + if current: + verses.append(current) + current = [] + continue + if current and (ts - current[-1][0]) > VERSE_GAP_SECS: + verses.append(current) + current = [] + current.append((ts, text)) + if current: + verses.append(current) + return verses + + +def find_active_verse( + verses: list[list[tuple[float, str]]], + position: float, +) -> tuple[list[str], int]: + """Return (verse_text_lines, active_index) or ([], -1) when in a gap.""" + for v_idx, verse in enumerate(verses): + for l_idx, (ts, _) in enumerate(verse): + if ts > position: + break + if l_idx + 1 < len(verse): + deadline = verse[l_idx + 1][0] + else: + nxt = verses[v_idx + 1][0][0] if v_idx + 1 < len(verses) else ts + INTER_VERSE_CAP + deadline = min(nxt, ts + INTER_VERSE_CAP) + if position <= deadline: + return [t for _, t in verse], l_idx + return [], -1 + + def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]: if not artist or not title: return []