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 <noreply@anthropic.com>
This commit is contained in:
ltadeu6 2026-06-08 17:33:18 -03:00
parent ad35be37c7
commit 2560a4fce6
No known key found for this signature in database
GPG key ID: FB9FDAB6B6F3418D

View file

@ -96,6 +96,7 @@ class ControlState:
def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None: def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None:
with self._lock: with self._lock:
self._lyrics = lyrics self._lyrics = lyrics
self._verses = group_into_verses(lyrics)
def set_position(self, position: float) -> None: def set_position(self, position: float) -> None:
with self._lock: with self._lock:
@ -103,40 +104,17 @@ class ControlState:
def current(self) -> dict[str, object]: def current(self) -> dict[str, object]:
with self._lock: with self._lock:
lyrics = getattr(self, "_lyrics", []) verses = getattr(self, "_verses", [])
position = getattr(self, "_position", 0.0) position = getattr(self, "_position", 0.0)
MAX_DISPLAY = 5.0 verse_lines, verse_active = find_active_verse(verses, position)
line = verse_lines[verse_active] if verse_active >= 0 and verse_lines else ""
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})
return { return {
"art_url": getattr(self, "_art_url", None), "art_url": getattr(self, "_art_url", None),
"rgb": list(getattr(self, "_rgb", [0, 200, 255])), "rgb": list(getattr(self, "_rgb", [0, 200, 255])),
"rgb2": list(getattr(self, "_rgb2", [255, 0, 150])), "rgb2": list(getattr(self, "_rgb2", [255, 0, 150])),
"lyric_line": line, "lyric_line": line,
"lyric_context": context, "verse_lines": verse_lines,
"verse_active": verse_active,
} }
@ -221,6 +199,9 @@ def start_control_server() -> ThreadingHTTPServer:
return server 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+)?)\]') _LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]')
@ -255,6 +236,46 @@ def clean_title(title: str) -> str:
return t 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]]: def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]:
if not artist or not title: if not artist or not title:
return [] return []