feat: return lyric_context (5-line window) from /current endpoint
current() now finds the active lyric in a non-empty-only display list
and returns a lyric_context array: [{text, offset}] for offsets -2..+2
centered on the active line. lyric_line kept for backwards compat.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9738b4a6af
commit
ad35be37c7
1 changed files with 165 additions and 12 deletions
177
luz-musica.py
177
luz-musica.py
|
|
@ -5,6 +5,7 @@ import colorsys
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
@ -86,16 +87,56 @@ class ControlState:
|
||||||
"running": True,
|
"running": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_current(self, art_url: str, rgb: tuple[int, int, int]) -> None:
|
def set_current(self, art_url: str, rgb: tuple[int, int, int], rgb2: tuple[int, int, int]) -> None:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._art_url = art_url
|
self._art_url = art_url
|
||||||
self._rgb = rgb
|
self._rgb = rgb
|
||||||
|
self._rgb2 = rgb2
|
||||||
|
|
||||||
|
def set_lyrics(self, lyrics: list[tuple[float, str]]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._lyrics = lyrics
|
||||||
|
|
||||||
|
def set_position(self, position: float) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._position = position
|
||||||
|
|
||||||
def current(self) -> dict[str, object]:
|
def current(self) -> dict[str, object]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
lyrics = getattr(self, "_lyrics", [])
|
||||||
|
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})
|
||||||
|
|
||||||
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])),
|
||||||
|
"lyric_line": line,
|
||||||
|
"lyric_context": context,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,6 +221,89 @@ def start_control_server() -> ThreadingHTTPServer:
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
|
_LRC_TS = re.compile(r'\[(\d{1,2}):(\d{2}(?:\.\d+)?)\]')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(text: str) -> list[tuple[float, str]]:
|
||||||
|
lines = []
|
||||||
|
for raw in text.splitlines():
|
||||||
|
m = _LRC_TS.match(raw)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
ts = int(m.group(1)) * 60 + float(m.group(2))
|
||||||
|
line = raw[m.end():].strip()
|
||||||
|
lines.append((ts, line))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
_TITLE_JUNK = re.compile(
|
||||||
|
r"[\s\-–]+("
|
||||||
|
r"\d{4}\s*remaster(ed)?|remaster(ed)?(\s+\d{4})?|"
|
||||||
|
r"deluxe(\s+edition)?|expanded(\s+edition)?|"
|
||||||
|
r"anniversary(\s+edition)?|bonus\s+track|"
|
||||||
|
r"live(\s+version)?|acoustic(\s+version)?|"
|
||||||
|
r"radio\s+edit|single\s+version|album\s+version"
|
||||||
|
r")[\s\)\]]*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title(title: str) -> str:
|
||||||
|
# strip parenthesised/bracketed suffixes first, then dash-separated junk
|
||||||
|
t = re.sub(r"\s*[\(\[][^\)\]]*[\)\]]$", "", title).strip()
|
||||||
|
t = _TITLE_JUNK.sub("", t).strip()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lyrics(artist: str, title: str) -> list[tuple[float, str]]:
|
||||||
|
if not artist or not title:
|
||||||
|
return []
|
||||||
|
clean = clean_title(title)
|
||||||
|
for t in ([clean, title] if clean != title else [title]):
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://lrclib.net/api/get",
|
||||||
|
params={"artist_name": artist, "track_name": t},
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
synced = resp.json().get("syncedLyrics") or ""
|
||||||
|
if synced:
|
||||||
|
return parse_lrc(synced)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_lyrics_async(artist: str, title: str) -> None:
|
||||||
|
def _run() -> None:
|
||||||
|
lyrics = fetch_lyrics(artist, title)
|
||||||
|
CONTROL_STATE.set_lyrics(lyrics)
|
||||||
|
print(f"Letras: {len(lyrics)} linhas")
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def start_position_thread() -> threading.Thread:
|
||||||
|
def _run() -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
player = choose_active_player()
|
||||||
|
if player:
|
||||||
|
result = subprocess.run(
|
||||||
|
[PLAYERCTL_BIN, "--player", player, "position"],
|
||||||
|
capture_output=True, text=True, timeout=1,
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
CONTROL_STATE.set_position(float(result.stdout.strip()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
def run_playerctl(field: str, player: str | None = None) -> str:
|
def run_playerctl(field: str, player: str | None = None) -> str:
|
||||||
command = [PLAYERCTL_BIN]
|
command = [PLAYERCTL_BIN]
|
||||||
if player:
|
if player:
|
||||||
|
|
@ -479,21 +603,24 @@ def should_use_purple_fallback(pixels: list[tuple[int, int, int]]) -> bool:
|
||||||
return avg_chroma < NEUTRAL_CHROMA_THRESHOLD or avg_saturation < NEUTRAL_SATURATION_THRESHOLD
|
return avg_chroma < NEUTRAL_CHROMA_THRESHOLD or avg_saturation < NEUTRAL_SATURATION_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def build_buckets(img: Image.Image) -> dict[tuple[int, int, int], int]:
|
||||||
|
pixels = good_pixels(img)
|
||||||
|
buckets: dict[tuple[int, int, int], int] = {}
|
||||||
|
for r, g, b in pixels:
|
||||||
|
key = (r // 16 * 16, g // 16 * 16, b // 16 * 16)
|
||||||
|
buckets[key] = buckets.get(key, 0) + 1
|
||||||
|
return buckets
|
||||||
|
|
||||||
|
|
||||||
def dominant_color(img: Image.Image) -> tuple[int, int, int]:
|
def dominant_color(img: Image.Image) -> tuple[int, int, int]:
|
||||||
pixels = good_pixels(img)
|
pixels = good_pixels(img)
|
||||||
if not pixels:
|
if not pixels:
|
||||||
# fallback simples
|
|
||||||
return PURPLE_FALLBACK_RGB
|
return PURPLE_FALLBACK_RGB
|
||||||
|
|
||||||
if should_use_purple_fallback(pixels):
|
if should_use_purple_fallback(pixels):
|
||||||
return PURPLE_FALLBACK_RGB
|
return PURPLE_FALLBACK_RGB
|
||||||
|
|
||||||
# Quantiza para agrupar tons parecidos
|
buckets = build_buckets(img)
|
||||||
buckets: dict[tuple[int, int, int], int] = {}
|
|
||||||
for r, g, b in pixels:
|
|
||||||
key = (r // 16 * 16, g // 16 * 16, b // 16 * 16)
|
|
||||||
buckets[key] = buckets.get(key, 0) + 1
|
|
||||||
|
|
||||||
best = max(
|
best = max(
|
||||||
buckets.items(),
|
buckets.items(),
|
||||||
key=lambda item: item[1] * bucket_weight(item[0]),
|
key=lambda item: item[1] * bucket_weight(item[0]),
|
||||||
|
|
@ -501,6 +628,26 @@ def dominant_color(img: Image.Image) -> tuple[int, int, int]:
|
||||||
return boost_color(best)
|
return boost_color(best)
|
||||||
|
|
||||||
|
|
||||||
|
def secondary_color(img: Image.Image, primary: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||||
|
buckets = build_buckets(img)
|
||||||
|
primary_h = rgb_to_hsv(primary)[0]
|
||||||
|
MIN_HUE_DIST = 0.12 # at least ~43° apart
|
||||||
|
|
||||||
|
ranked = sorted(
|
||||||
|
buckets.items(),
|
||||||
|
key=lambda item: item[1] * bucket_weight(item[0]),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for bucket, _ in ranked:
|
||||||
|
h = rgb_to_hsv(bucket)[0]
|
||||||
|
if hue_distance(h, primary_h) >= MIN_HUE_DIST:
|
||||||
|
return boost_color(bucket)
|
||||||
|
|
||||||
|
# fallback: complementary hue of primary
|
||||||
|
h, s, v = rgb_to_hsv(primary)
|
||||||
|
return hsv_to_rgb((h + 0.5) % 1.0, max(s, 0.5), max(v, 0.5))
|
||||||
|
|
||||||
|
|
||||||
def bucket_weight(rgb: tuple[int, int, int]) -> float:
|
def bucket_weight(rgb: tuple[int, int, int]) -> float:
|
||||||
_, saturation, value = rgb_to_hsv(rgb)
|
_, saturation, value = rgb_to_hsv(rgb)
|
||||||
|
|
||||||
|
|
@ -539,6 +686,7 @@ def set_light_color(rgb: tuple[int, int, int]) -> None:
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
print("Monitorando música atual. Ctrl+C para sair.")
|
print("Monitorando música atual. Ctrl+C para sair.")
|
||||||
control_server = start_control_server()
|
control_server = start_control_server()
|
||||||
|
start_position_thread()
|
||||||
last_track = ""
|
last_track = ""
|
||||||
last_rgb: tuple[int, int, int] | None = None
|
last_rgb: tuple[int, int, int] | None = None
|
||||||
|
|
||||||
|
|
@ -572,10 +720,15 @@ def main() -> None:
|
||||||
raw_rgb = dominant_color(img)
|
raw_rgb = dominant_color(img)
|
||||||
palette_rgb = snap_to_palette(raw_rgb)
|
palette_rgb = snap_to_palette(raw_rgb)
|
||||||
rgb = clamp_brightness(palette_rgb, last_rgb)
|
rgb = clamp_brightness(palette_rgb, last_rgb)
|
||||||
|
rgb2 = secondary_color(img, rgb)
|
||||||
print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}")
|
print(f"Cor base: {raw_rgb} -> paleta: {palette_rgb}")
|
||||||
print(f"Aplicando cor: {rgb}")
|
print(f"Aplicando cor: {rgb} secundária: {rgb2}")
|
||||||
set_light_color(rgb)
|
set_light_color(rgb)
|
||||||
CONTROL_STATE.set_current(art_url, rgb)
|
CONTROL_STATE.set_current(art_url, rgb, rgb2)
|
||||||
|
artist = run_playerctl("artist", player)
|
||||||
|
title = run_playerctl("title", player)
|
||||||
|
CONTROL_STATE.set_lyrics([])
|
||||||
|
fetch_lyrics_async(artist, title)
|
||||||
last_track = track_id
|
last_track = track_id
|
||||||
last_rgb = rgb
|
last_rgb = rgb
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue