music-light/luz-musica.py
ltadeu6 ad35be37c7
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>
2026-06-08 16:53:23 -03:00

748 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
from __future__ import annotations
import colorsys
import json
import math
import os
import re
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from urllib.parse import parse_qs, urlparse
import requests
from PIL import Image
HA_URL = os.environ.get("HA_URL", "http://127.0.0.1:8123")
HA_TOKEN = os.environ.get(
"HA_TOKEN",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkZjM1MmRhNjg1ZWM0MGJiYTc4YTZlNWY3ZmQ0YTA1YSIsImlhdCI6MTc3NjI4MTI0MiwiZXhwIjoyMDkxNjQxMjQyfQ.rR0Kfwk5Smr06_9KJSDn3YPXiRlokEZZFiAa_naNs78",
)
LIGHT_ENTITY = os.environ.get("LIGHT_ENTITY", "light.luzsala_luz_sala")
# Ajustes visuais
BRIGHTNESS_PCT = 70
SATURATION_BOOST = 1.15
MIN_SATURATION = 0.20
MIN_VALUE = 0.18
REQUEST_TIMEOUT = 10
MAX_VALUE = 0.82
PALETTE_SIZE = 32
OKLCH_LIGHTNESS = 0.72
OKLCH_CHROMA = 0.24
MAX_BRIGHTNESS_STEP = 0.10
PLAYERCTL_BIN = os.environ.get("PLAYERCTL_BIN", "playerctl")
CONTROL_HOST = os.environ.get("CONTROL_HOST", "0.0.0.0")
CONTROL_PORT = int(os.environ.get("CONTROL_PORT", "8765"))
CONTROL_TOKEN = os.environ.get("CONTROL_TOKEN", "")
YELLOW_HUE_START = 0.12
YELLOW_HUE_END = 0.18
AMBER_TARGET_HUE = 0.10
YELLOW_CHROMA_SCALE = 0.82
YELLOW_LIGHTNESS_SCALE = 0.94
NEUTRAL_CHROMA_THRESHOLD = 0.05
NEUTRAL_SATURATION_THRESHOLD = 0.18
PURPLE_FALLBACK_HUE = 0.78
class ControlState:
def __init__(self) -> None:
self._lock = threading.Lock()
self._paused = False
self._force_update = False
def pause(self) -> None:
with self._lock:
self._paused = True
def resume(self) -> None:
with self._lock:
self._paused = False
self._force_update = True
def toggle(self) -> bool:
with self._lock:
self._paused = not self._paused
if not self._paused:
self._force_update = True
return self._paused
def is_paused(self) -> bool:
with self._lock:
return self._paused
def consume_force_update(self) -> bool:
with self._lock:
force_update = self._force_update
self._force_update = False
return force_update
def snapshot(self) -> dict[str, object]:
with self._lock:
return {
"paused": self._paused,
"running": True,
}
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])),
"rgb2": list(getattr(self, "_rgb2", [255, 0, 150])),
"lyric_line": line,
"lyric_context": context,
}
CONTROL_STATE = ControlState()
class ControlHandler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None:
return
def do_GET(self) -> None:
if self.path == "/status":
self.write_json(200, CONTROL_STATE.snapshot())
return
if self.path == "/current":
self.write_json(200, CONTROL_STATE.current())
return
if self.path.startswith("/art"):
art_url = CONTROL_STATE.current().get("art_url")
if not art_url:
self.write_json(404, {"error": "no art"})
return
try:
if art_url.startswith("file://"):
body = open(art_url[7:], "rb").read()
ctype = "image/png" if art_url.endswith(".png") else "image/jpeg"
else:
resp = requests.get(art_url, timeout=5)
body = resp.content
ctype = resp.headers.get("Content-Type", "image/jpeg")
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
except Exception:
self.write_json(502, {"error": "fetch failed"})
return
self.write_json(404, {"error": "not found"})
def do_POST(self) -> None:
if not self.authorized():
self.write_json(401, {"error": "unauthorized"})
return
if self.path == "/pause":
CONTROL_STATE.pause()
elif self.path == "/resume":
CONTROL_STATE.resume()
elif self.path == "/toggle":
CONTROL_STATE.toggle()
else:
self.write_json(404, {"error": "not found"})
return
self.write_json(200, CONTROL_STATE.snapshot())
def authorized(self) -> bool:
if not CONTROL_TOKEN:
return True
header_token = self.headers.get("X-Music-Light-Token", "")
bearer_token = self.headers.get("Authorization", "").removeprefix("Bearer ")
return CONTROL_TOKEN in {header_token, bearer_token}
def write_json(self, status: int, payload: dict[str, object]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def start_control_server() -> ThreadingHTTPServer:
server = ThreadingHTTPServer((CONTROL_HOST, CONTROL_PORT), ControlHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"Controle HTTP ouvindo em {CONTROL_HOST}:{CONTROL_PORT}")
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:
command.extend(["--player", player])
command.extend(["metadata", field])
result = subprocess.run(
command,
capture_output=True,
text=True,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def list_players() -> list[str]:
result = subprocess.run(
[PLAYERCTL_BIN, "-l"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def player_status(player: str) -> str:
result = subprocess.run(
[PLAYERCTL_BIN, "--player", player, "status"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def choose_active_player() -> str:
for player in list_players():
if player_status(player) == "Playing":
return player
return ""
def get_track_url(player: str) -> str:
return run_playerctl("xesam:url", player)
def get_player_name(player: str) -> str:
result = subprocess.run(
[PLAYERCTL_BIN, "--player", player, "metadata", "--format", "{{playerName}}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
return player
return result.stdout.strip() or player
def youtube_video_id(url: str) -> str:
if not url:
return ""
parsed = urlparse(url)
host = parsed.netloc.lower()
path = parsed.path.strip("/")
if host in {"youtu.be", "www.youtu.be"}:
return path.split("/", 1)[0]
if "youtube.com" not in host:
return ""
if path == "watch":
return parse_qs(parsed.query).get("v", [""])[0]
parts = path.split("/")
if len(parts) >= 2 and parts[0] in {"shorts", "live", "embed", "v"}:
return parts[1]
return ""
def youtube_thumbnail_url(player: str) -> str:
video_id = youtube_video_id(get_track_url(player))
if not video_id:
return ""
return f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"
def get_current_track_id(player: str) -> str:
title = run_playerctl("title", player)
artist = run_playerctl("artist", player)
art = run_playerctl("mpris:artUrl", player)
url = get_track_url(player)
player_name = get_player_name(player)
return f"{player_name}::{artist}::{title}::{art or url}"
def get_album_art_url(player: str) -> str:
art_url = run_playerctl("mpris:artUrl", player)
if art_url:
return art_url
track_url = get_track_url(player)
if youtube_video_id(track_url):
thumb_url = youtube_thumbnail_url(player)
if thumb_url:
print(f"Usando thumbnail do YouTube: {thumb_url}")
return thumb_url
return ""
def load_image_from_url(url: str) -> Image.Image:
if url.startswith("file://"):
local_path = url[7:]
return Image.open(local_path).convert("RGB")
resp = requests.get(url, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
return Image.open(BytesIO(resp.content)).convert("RGB")
def image_pixels(img: Image.Image) -> list[tuple[int, int, int]]:
if hasattr(img, "get_flattened_data"):
return list(img.get_flattened_data())
return list(img.getdata())
def hue_distance(a: float, b: float) -> float:
diff = abs(a - b)
return min(diff, 1.0 - diff)
def rgb_to_hsv(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
r, g, b = rgb
return colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
def hsv_to_rgb(h: float, s: float, v: float) -> tuple[int, int, int]:
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return (int(r * 255), int(g * 255), int(b * 255))
def tame_yellow_oklch(lightness: float, chroma: float, hue: float) -> tuple[float, float, float]:
if YELLOW_HUE_START <= hue <= YELLOW_HUE_END:
return (
lightness * YELLOW_LIGHTNESS_SCALE,
chroma * YELLOW_CHROMA_SCALE,
AMBER_TARGET_HUE,
)
return (lightness, chroma, hue)
def srgb_channel_to_linear(channel: float) -> float:
if channel <= 0.04045:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
def linear_channel_to_srgb(channel: float) -> float:
if channel <= 0.0031308:
return 12.92 * channel
return 1.055 * (channel ** (1 / 2.4)) - 0.055
def rgb_to_oklab(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
r, g, b = rgb
rl = srgb_channel_to_linear(r / 255.0)
gl = srgb_channel_to_linear(g / 255.0)
bl = srgb_channel_to_linear(b / 255.0)
l = 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl
m = 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl
s = 0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl
l_ = l ** (1 / 3)
m_ = m ** (1 / 3)
s_ = s ** (1 / 3)
return (
0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
)
def oklab_to_rgb(lightness: float, a: float, b: float) -> tuple[int, int, int]:
l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
s_ = lightness - 0.0894841775 * a - 1.2914855480 * b
l = l_ ** 3
m = m_ ** 3
s = s_ ** 3
rl = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
gl = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
r = min(1.0, max(0.0, linear_channel_to_srgb(rl)))
g = min(1.0, max(0.0, linear_channel_to_srgb(gl)))
b = min(1.0, max(0.0, linear_channel_to_srgb(bl)))
return (int(r * 255), int(g * 255), int(b * 255))
def rgb_to_oklch(rgb: tuple[int, int, int]) -> tuple[float, float, float]:
lightness, a, b = rgb_to_oklab(rgb)
chroma = (a * a + b * b) ** 0.5
hue = (0.0 if chroma == 0 else (math.atan2(b, a) / (2 * math.pi)) % 1.0)
return (lightness, chroma, hue)
def oklch_to_rgb(lightness: float, chroma: float, hue: float) -> tuple[int, int, int]:
angle = 2 * math.pi * hue
a = chroma * math.cos(angle)
b = chroma * math.sin(angle)
return oklab_to_rgb(lightness, a, b)
def pure_tone_palette() -> list[tuple[int, int, int]]:
return [
oklch_to_rgb(*tame_yellow_oklch(OKLCH_LIGHTNESS, OKLCH_CHROMA, index / PALETTE_SIZE))
for index in range(PALETTE_SIZE)
]
PURE_TONE_PALETTE = pure_tone_palette()
PURPLE_FALLBACK_RGB = oklch_to_rgb(OKLCH_LIGHTNESS, OKLCH_CHROMA, PURPLE_FALLBACK_HUE)
def clamp_brightness(rgb: tuple[int, int, int], previous_rgb: tuple[int, int, int] | None) -> tuple[int, int, int]:
if previous_rgb is None:
return rgb
h, s, v = rgb_to_hsv(rgb)
_, _, prev_v = rgb_to_hsv(previous_rgb)
limited_v = max(prev_v - MAX_BRIGHTNESS_STEP, min(prev_v + MAX_BRIGHTNESS_STEP, v))
return hsv_to_rgb(h, s, limited_v)
def snap_to_palette(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
_, _, hue = rgb_to_oklch(rgb)
best = min(
PURE_TONE_PALETTE,
key=lambda candidate: hue_distance(hue, rgb_to_oklch(candidate)[2]),
)
best_lightness, _, best_hue = rgb_to_oklch(best)
return oklch_to_rgb(*tame_yellow_oklch(best_lightness, OKLCH_CHROMA, best_hue))
def good_pixels(img: Image.Image) -> list[tuple[int, int, int]]:
img = img.resize((80, 80))
pixels = image_pixels(img)
filtered: list[tuple[int, int, int]] = []
for r, g, b in pixels:
rf, gf, bf = r / 255.0, g / 255.0, b / 255.0
h, s, v = colorsys.rgb_to_hsv(rf, gf, bf)
# ignora pixels muito escuros e quase cinza
if v < MIN_VALUE:
continue
if s < MIN_SATURATION:
continue
if v > MAX_VALUE and s < 0.45:
continue
filtered.append((r, g, b))
return filtered
def color_information_score(pixels: list[tuple[int, int, int]]) -> tuple[float, float]:
if not pixels:
return (0.0, 0.0)
chroma_total = 0.0
saturation_total = 0.0
for pixel in pixels:
_, chroma, _ = rgb_to_oklch(pixel)
_, saturation, _ = rgb_to_hsv(pixel)
chroma_total += chroma
saturation_total += saturation
count = len(pixels)
return (chroma_total / count, saturation_total / count)
def should_use_purple_fallback(pixels: list[tuple[int, int, int]]) -> bool:
avg_chroma, avg_saturation = color_information_score(pixels)
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:
return PURPLE_FALLBACK_RGB
if should_use_purple_fallback(pixels):
return PURPLE_FALLBACK_RGB
buckets = build_buckets(img)
best = max(
buckets.items(),
key=lambda item: item[1] * bucket_weight(item[0]),
)[0]
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)
saturation_score = 0.6 + saturation
brightness_midpoint = 0.62
brightness_penalty = abs(value - brightness_midpoint) * 1.2
return max(0.2, saturation_score - brightness_penalty)
def boost_color(rgb: tuple[int, int, int]) -> tuple[int, int, int]:
h, s, v = rgb_to_hsv(rgb)
s = min(1.0, s * SATURATION_BOOST)
v = min(0.74, max(v, 0.48))
return hsv_to_rgb(h, s, v)
def set_light_color(rgb: tuple[int, int, int]) -> None:
response = requests.post(
f"{HA_URL}/api/services/light/turn_on",
headers={
"Authorization": f"Bearer {HA_TOKEN}",
"Content-Type": "application/json",
},
json={
"entity_id": LIGHT_ENTITY,
"rgb_color": list(rgb),
"brightness_pct": BRIGHTNESS_PCT,
},
timeout=REQUEST_TIMEOUT,
)
response.raise_for_status()
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
while True:
try:
if CONTROL_STATE.is_paused():
time.sleep(2)
continue
player = choose_active_player()
if not player:
time.sleep(2)
continue
track_id = get_current_track_id(player)
if not track_id or track_id == "::":
time.sleep(2)
continue
if CONTROL_STATE.consume_force_update() or track_id != last_track:
art_url = get_album_art_url(player)
if not art_url:
print("Sem capa disponível para a faixa atual.")
last_track = track_id
time.sleep(2)
continue
print(f"Nova faixa detectada: {track_id}")
img = load_image_from_url(art_url)
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} secundária: {rgb2}")
set_light_color(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
time.sleep(2)
except KeyboardInterrupt:
print("\nEncerrado.")
control_server.shutdown()
control_server.server_close()
break
except Exception as exc:
print(f"Erro: {exc}")
time.sleep(3)
if __name__ == "__main__":
main()