diff --git a/scripts/led-matrix-viz.py b/scripts/led-matrix-viz.py index 92be3d5..92649a7 100755 --- a/scripts/led-matrix-viz.py +++ b/scripts/led-matrix-viz.py @@ -1,24 +1,45 @@ #!/usr/bin/env python3 -"""Listen for MIDI events via UDP and display note activity on LED matrix. - -Event-driven: renders and sends frame immediately on each MIDI event. -Background 1 FPS refresh catches any lost frames from the router. -""" -import socket, msgpack, time, os, select +import socket, msgpack, time, os, select, json, subprocess VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082")) ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock") COLS = 13 ROWS = 8 - REFRESH_INTERVAL = 1.0 - STATS_FILE = "/tmp/viz-stats.json" +DIAG_INTERVAL = 10.0 +DIAG_DURATION = 0.3 _poll = select.poll() POLLIN = select.POLLIN - SOCK_BUFFER = 4096 +SERVICES = ["midi-bridge", "pd-synth-onboard", "led-matrix-viz", "prometheus-exporter"] +STATUS_CACHE = {} +LAST_STATUS_CHECK = 0.0 + +STATUS_ROW = 0 +SERVICE_COLS = {0: 0, 1: 1, 2: 2, 3: 3} +AUDIO_COL = 4 +NOTE_ROWS = 7 +NOTE_ROW_OFFSET = 1 + +AUDIO_SIGNAL_FILE = "/tmp/audio-signal-detect" + + +def _check_services(): + global STATUS_CACHE, LAST_STATUS_CHECK + now = time.monotonic() + if now - LAST_STATUS_CHECK < 5.0: + return STATUS_CACHE + for svc in SERVICES: + try: + r = subprocess.run(["systemctl", "is-active", svc], capture_output=True, text=True, timeout=3) + STATUS_CACHE[svc] = r.stdout.strip() == "active" + except Exception: + STATUS_CACHE[svc] = False + STATUS_CACHE["audio"] = os.path.exists(AUDIO_SIGNAL_FILE) and (time.time() - os.path.getmtime(AUDIO_SIGNAL_FILE) < 5.0) + LAST_STATUS_CHECK = now + return STATUS_CACHE def pack_frame(pixels): @@ -38,10 +59,31 @@ def render(active_notes): pixels = [[0] * COLS for _ in range(ROWS)] note_min, note_max = 36, 96 for note in active_notes: - row = 7 - int((note - note_min) / (note_max - note_min) * 7) - row = max(0, min(7, row)) + row = NOTE_ROWS - 1 - int((note - note_min) / (note_max - note_min) * (NOTE_ROWS - 1)) + row = max(0, min(NOTE_ROWS - 1, row)) + NOTE_ROW_OFFSET col = note % 12 pixels[row][col] = 1 + status = _check_services() + for idx, svc in enumerate(SERVICES): + col = SERVICE_COLS[idx] + if status.get(svc): + pixels[STATUS_ROW][col] = 1 + if status.get("audio"): + pixels[STATUS_ROW][AUDIO_COL] = 1 + return pack_frame(pixels) + + +def _diag_frame(): + pixels = [[0] * COLS for _ in range(ROWS)] + status = _check_services() + for idx, svc in enumerate(SERVICES): + col = SERVICE_COLS[idx] + if status.get(svc): + for r in range(ROWS): + pixels[r][col] = 1 + if status.get("audio"): + for r in range(ROWS): + pixels[r][AUDIO_COL] = 1 return pack_frame(pixels) @@ -63,6 +105,24 @@ def try_send(router, data): return router, False +def _send_frame(router, packer, params, msg_id): + data = packer.pack([0, msg_id, "draw_frame", params]) + router, ok = try_send(router, data) + if ok: + router.setblocking(False) + try: + while True: + chunk = router.recv(4096) + if not chunk: + break + except (BlockingIOError, OSError): + pass + finally: + router.setblocking(True) + router.settimeout(0.01) + return router, ok + + def main(): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -85,6 +145,11 @@ def main(): bytes_received = 0 last_refresh = time.monotonic() + last_diag = time.monotonic() + diag_active = False + diag_start = 0.0 + pre_diag_params = None + stats_last_write = time.monotonic() fps_sample_start = time.monotonic() fps_sample_frames = 0 @@ -94,6 +159,27 @@ def main(): while True: now = time.monotonic() refresh_due = (now - last_refresh) >= REFRESH_INTERVAL + diag_due = (now - last_diag) >= DIAG_INTERVAL and not diag_active and len(active_notes) == 0 + + if diag_due: + diag_active = True + diag_start = now + pre_diag_params = last_sent_params + msg_id = (msg_id + 1) % 65535 + router, ok = _send_frame(router, packer, _diag_frame(), msg_id) + if ok: + frames_sent += 1 + + if diag_active and (now - diag_start) >= DIAG_DURATION: + diag_active = False + last_diag = now + if pre_diag_params is not None: + msg_id = (msg_id + 1) % 65535 + params = render(active_notes) + router, ok = _send_frame(router, packer, params, msg_id) + if ok: + frames_sent += 1 + last_sent_params = params try: events = _poll.poll(5) @@ -130,6 +216,9 @@ def main(): active_notes.discard(note) changed = True if changed: + if diag_active: + diag_active = False + last_diag = now events_processed += 1 params = render(active_notes) if params != last_sent_params: @@ -137,13 +226,11 @@ def main(): pending_bytes = packer.pack([0, msg_id, "draw_frame", params]) last_sent_params = params - # Try to send pending frame — no POLLOUT dependency if pending_bytes is not None: router, ok = try_send(router, pending_bytes) if ok: pending_bytes = None frames_sent += 1 - # Drain pending responses so router buffer stays clear router.setblocking(False) try: while True: @@ -158,11 +245,10 @@ def main(): else: frames_dropped += 1 - # Background refresh: resend last params to recover from router drops if refresh_due: last_refresh = now fps_sample_frames += 1 - if last_sent_params is not None and pending_bytes is None: + if not diag_active and last_sent_params is not None and pending_bytes is None: msg_id = (msg_id + 1) % 65535 refresh_bytes = packer.pack([0, msg_id, "draw_frame", last_sent_params]) router, ok = try_send(router, refresh_bytes) @@ -179,7 +265,6 @@ def main(): def _write_stats(sent, dropped, fps, active_count, events, bytes_rcv, packets): - import json try: with open(STATS_FILE, "w") as f: json.dump({