diff --git a/luz-musica.py b/luz-musica.py index e5fd54a..b6bd64d 100644 --- a/luz-musica.py +++ b/luz-musica.py @@ -5,6 +5,7 @@ import colorsys import json import math import os +import re import subprocess import threading import time @@ -86,16 +87,56 @@ class ControlState: "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: self._art_url = art_url 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]: 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 { - "art_url": getattr(self, "_art_url", None), - "rgb": list(getattr(self, "_rgb", [0, 200, 255])), + "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, } @@ -180,6 +221,89 @@ def start_control_server() -> ThreadingHTTPServer: 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: command = [PLAYERCTL_BIN] 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 +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]: pixels = good_pixels(img) if not pixels: - # fallback simples return PURPLE_FALLBACK_RGB if should_use_purple_fallback(pixels): return PURPLE_FALLBACK_RGB - # Quantiza para agrupar tons parecidos - 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 - + buckets = build_buckets(img) best = max( buckets.items(), 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) +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: _, saturation, value = rgb_to_hsv(rgb) @@ -539,6 +686,7 @@ def set_light_color(rgb: tuple[int, int, int]) -> None: def main() -> None: print("Monitorando música atual. Ctrl+C para sair.") control_server = start_control_server() + start_position_thread() last_track = "" last_rgb: tuple[int, int, int] | None = None @@ -572,10 +720,15 @@ def main() -> None: raw_rgb = dominant_color(img) palette_rgb = snap_to_palette(raw_rgb) rgb = clamp_brightness(palette_rgb, last_rgb) + rgb2 = secondary_color(img, 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) - 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_rgb = rgb