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:
parent
ad35be37c7
commit
2560a4fce6
1 changed files with 53 additions and 32 deletions
|
|
@ -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 []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue