diff --git a/scripts/led-matrix-viz.py b/scripts/led-matrix-viz.py index b4248dd..23569de 100644 --- a/scripts/led-matrix-viz.py +++ b/scripts/led-matrix-viz.py @@ -1,18 +1,29 @@ #!/usr/bin/env python3 """Listen for MIDI events via UDP and display note activity on LED matrix. -Non-blocking I/O with immediate MIDI processing and fast frame rendering. +Non-blocking I/O with proper POLLOUT queue, instrumentation, and fast rendering. """ import socket, msgpack, time, os, select, collections VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082")) ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock") DECAY_FRAMES = int(os.environ.get("DECAY_FRAMES", "8")) -FPS = 30 COLS = 13 ROWS = 8 +frame_interval = 1.0 / 30.0 # 30 FPS target +next_frame_time = 0.0 + +_poll = select.poll() +POLLIN = select.POLLIN +POLLOUT = select.POLLOUT + +SOCK_BUFFER = 4096 +ROUTER_RECONNECT_DELAY = 0.1 + +_frame_hooks = [] # for metrics + def pack_frame(pixels): bits = 0 @@ -24,6 +35,27 @@ def pack_frame(pixels): (bits >> 64) & 0xFFFFFFFF, (bits >> 96) & 0xFFFFFFFF) +class FrameQueue: + def __init__(self, maxsize=10): + self._q = collections.deque() + self._maxsize = maxsize + + def push(self, frame_bytes): + if len(self._q) >= self._maxsize: + return False # dropped + self._q.append(frame_bytes) + return True + + def pop(self): + return self._q.popleft() if self._q else None + + def __len__(self): + return len(self._q) + + def full(self): + return len(self._q) >= self._maxsize + + def connect_router(): router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) router.setblocking(False) @@ -35,6 +67,8 @@ def connect_router(): def main(): + global next_frame_time + 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)) @@ -43,40 +77,44 @@ def main(): router = connect_router() packer = msgpack.Packer() + _poll.register(sock, POLLIN) + active_notes = {} last_frame = None buf = b"" msg_id = 0 - frame_interval = 1.0 / FPS - next_frame = time.monotonic() - router_buf = b"" + next_frame_time = time.monotonic() + + pending_frames = FrameQueue(maxsize=5) + router_registered = False + router_fd = router.fileno() + _poll.register(router, POLLIN | POLLOUT) + frames_sent = 0 frames_dropped = 0 - - poller = select.poll() - poller.register(sock, select.POLLIN) + router_errors = 0 while True: - # Always poll UDP with short timeout - timeout = max(0, int((next_frame - time.monotonic()) * 1000)) - timeout = min(timeout, 10) # cap at 10ms for responsiveness + now = time.monotonic() + + timeout = max(0, int((next_frame_time - now) * 1000)) + timeout = min(timeout, 5) # 5ms max to stay responsive try: - events = poller.poll(timeout) + events = _poll.poll(timeout) except OSError: events = [] - # Process all pending UDP data immediately + # Process UDP for fd, flags in events: - if fd == sock.fileno() and flags & select.POLLIN: + if fd == sock.fileno() and (flags & POLLIN): try: while True: - data, _ = sock.recvfrom(1024) + data, _ = sock.recvfrom(SOCK_BUFFER) buf += data except BlockingIOError: pass - # Parse all complete messages while b";" in buf: msg, buf = buf.split(b";", 1) msg = msg.decode().strip() @@ -91,22 +129,44 @@ def main(): elif msg_type == 0x80 or (msg_type == 0x90 and vel == 0): active_notes.pop(note, None) - # Also drain any pending router responses - if router.fileno() >= 0: - try: - while True: - router_buf += router.recv(4096) - except BlockingIOError: - pass + # Process router readable + if fd == router_fd and (flags & POLLIN): + try: + while True: + d = router.recv(SOCK_BUFFER) + if not d: + break + except BlockingIOError: + pass - # Render frame when it's time - now = time.monotonic() - if now >= next_frame: - next_frame = now + frame_interval + # Process router writable — drain pending frames + if fd == router_fd and (flags & POLLOUT): + frame = pending_frames.pop() + if frame: + try: + router.sendall(frame) + frames_sent += 1 + except (BlockingIOError, OSError): + frames_dropped += 1 + router_errors += 1 + pending_frames.push(frame) + time.sleep(ROUTER_RECONNECT_DELAY) + try: + router.close() + except OSError: + pass + router = connect_router() + router_fd = router.fileno() + _poll.unregister(router) + _poll.register(router, POLLIN | POLLOUT) + + # Render frame when due + if now >= next_frame_time: + next_frame_time = now + frame_interval pixels = [[0] * COLS for _ in range(ROWS)] expired = [] - for note, decay in active_notes.items(): + for note, decay in list(active_notes.items()): if decay <= 0: expired.append(note) continue @@ -125,19 +185,12 @@ def main(): msg_id = (msg_id + 1) % 65535 params = [str(v) for v in frame] req = packer.pack([0, msg_id, "draw_frame", params]) - try: - router.sendall(req) - frames_sent += 1 - except (BlockingIOError, OSError): + if pending_frames.push(req): + pass # queued + else: frames_dropped += 1 - # Reconnect on error - try: - router.close() - except OSError: - pass - router = connect_router() last_frame = frame if __name__ == "__main__": - main() + main() \ No newline at end of file