Files
uno-q-audio-synth/scripts/led-matrix-viz.py
T
david a94ac07a5c
Validate and Test / validate-patches (push) Failing after 13s
fix: pack frame at render time, send without POLLOUT dependency
- pending_bytes stores already-packed msgpack, sendable immediately
- Try sendall() directly every iteration — no POLLOUT gate
- Refresh path resends last_sent_params (not raw frame tuple)
- Router socket uses settimeout instead of setblocking for simpler I/O
2026-06-24 03:56:50 -07:00

177 lines
5.6 KiB
Python

#!/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 5 FPS refresh catches any lost frames from the router.
"""
import socket, msgpack, time, os, select
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 = 0.2
STATS_FILE = "/tmp/viz-stats.json"
_poll = select.poll()
POLLIN = select.POLLIN
SOCK_BUFFER = 4096
def pack_frame(pixels):
bits = 0
for row in range(ROWS):
for col in range(COLS):
if pixels[row][col]:
bits |= 1 << (row * COLS + col)
w0 = bits & 0xFFFFFFFF
w1 = (bits >> 32) & 0xFFFFFFFF
w2 = (bits >> 64) & 0xFFFFFFFF
w3 = (bits >> 96) & 0xFFFFFFFF
return [str(w0), str(w1), str(w2), str(w3)]
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))
col = note % 12
pixels[row][col] = 1
return pack_frame(pixels)
def connect_router():
router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
router.settimeout(0.3)
try:
router.connect(ROUTER_SOCK)
except OSError:
pass
return router
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", VIZ_PORT))
sock.setblocking(False)
router = connect_router()
packer = msgpack.Packer()
_poll.register(sock, POLLIN)
active_notes = set()
last_sent_params = None
pending_bytes = None
buf = b""
msg_id = 0
frames_sent = 0
frames_dropped = 0
last_refresh = time.monotonic()
stats_last_write = time.monotonic()
fps_sample_start = time.monotonic()
fps_sample_frames = 0
events_processed = 0
while True:
now = time.monotonic()
refresh_due = (now - last_refresh) >= REFRESH_INTERVAL
try:
events = _poll.poll(5)
except OSError:
events = []
for fd, flags in events:
if fd == sock.fileno() and (flags & POLLIN):
try:
while True:
data, _ = sock.recvfrom(SOCK_BUFFER)
buf += data
except BlockingIOError:
pass
while b";" in buf:
msg, buf = buf.split(b";", 1)
msg = msg.decode().strip()
parts = msg.split()
if len(parts) >= 3 and parts[0] == "midi":
status = int(parts[1])
note = int(parts[2])
vel = int(parts[3]) if len(parts) >= 4 else 0
msg_type = status & 0xF0
changed = False
if msg_type == 0x90 and vel > 0:
if note not in active_notes:
active_notes.add(note)
changed = True
elif msg_type == 0x80 or (msg_type == 0x90 and vel == 0):
if note in active_notes:
active_notes.discard(note)
changed = True
if changed:
events_processed += 1
params = render(active_notes)
if params != last_sent_params:
msg_id = (msg_id + 1) % 65535
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:
try:
router.sendall(pending_bytes)
pending_bytes = None
frames_sent += 1
except (BlockingIOError, OSError):
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:
msg_id = (msg_id + 1) % 65535
refresh_bytes = packer.pack([0, msg_id, "draw_frame", last_sent_params])
try:
router.sendall(refresh_bytes)
frames_sent += 1
except (BlockingIOError, OSError):
pass
if now - stats_last_write >= 2.0:
fps = fps_sample_frames / (now - fps_sample_start) if (now - fps_sample_start) > 0 else 0
_write_stats(frames_sent, frames_dropped, fps, len(active_notes), events_processed)
stats_last_write = now
fps_sample_start = now
fps_sample_frames = 0
events_processed = 0
def _write_stats(sent, dropped, fps, active_count, events):
import json
try:
with open(STATS_FILE, "w") as f:
json.dump({
"frames_sent": sent,
"frames_dropped": dropped,
"fps": round(fps, 1),
"active_notes": active_count,
"midi_events": events,
"ts": time.time()
}, f)
except OSError:
pass
if __name__ == "__main__":
main()