diff --git a/scripts/led-matrix-viz.py b/scripts/led-matrix-viz.py index 3512cb2..5a13002 100644 --- a/scripts/led-matrix-viz.py +++ b/scripts/led-matrix-viz.py @@ -1,20 +1,18 @@ #!/usr/bin/env python3 """Listen for MIDI events via UDP and display note activity on LED matrix. -Non-blocking I/O with proper POLLOUT queue, instrumentation, and fast rendering. +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, collections +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") -DECAY_FRAMES = int(os.environ.get("DECAY_FRAMES", "3")) -MAX_NOTE_AGE = int(os.environ.get("MAX_NOTE_AGE", "15")) COLS = 13 ROWS = 8 -frame_interval = 1.0 / 30.0 # 30 FPS target -next_frame_time = 0.0 +REFRESH_INTERVAL = 0.2 # 5 FPS background refresh (catches dropped frames) STATS_FILE = "/tmp/viz-stats.json" _poll = select.poll() @@ -35,25 +33,15 @@ 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 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(): @@ -66,9 +54,20 @@ def connect_router(): return router -def main(): - global next_frame_time +def send_frame(router, packer, msg_id, frame): + if frame is None: + return msg_id + 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) + except (BlockingIOError, OSError): + pass + return msg_id + +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)) @@ -78,29 +77,30 @@ def main(): packer = msgpack.Packer() _poll.register(sock, POLLIN) - - active_notes = {} - last_frame = None - buf = b"" - msg_id = 0 - next_frame_time = time.monotonic() - - pending_frames = FrameQueue(maxsize=5) router_fd = router.fileno() _poll.register(router, POLLIN | POLLOUT) + active_notes = set() + last_sent_frame = None + pending_frame = None + buf = b"" + msg_id = 0 + frames_sent = 0 frames_dropped = 0 router_errors = 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 - timeout = max(0, int((next_frame_time - now) * 1000)) - timeout = min(timeout, 5) # 5ms max to stay responsive + timeout = 5 # ms try: events = _poll.poll(timeout) @@ -126,10 +126,20 @@ def main(): 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: - active_notes[note] = {"decay": DECAY_FRAMES, "age": 0} + if note not in active_notes: + active_notes.add(note) + changed = True elif msg_type == 0x80 or (msg_type == 0x90 and vel == 0): - active_notes.pop(note, None) + if note in active_notes: + active_notes.discard(note) + changed = True + if changed: + events_processed += 1 + frame = render(active_notes) + if frame != last_sent_frame: + pending_frame = frame # Process router readable if fd == router_fd and (flags & POLLIN): @@ -141,84 +151,43 @@ def main(): except BlockingIOError: pass - # 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) - old_fd = router_fd - try: - router.close() - except OSError: - pass - router = connect_router() - router_fd = router.fileno() - try: - _poll.unregister(old_fd) - except (KeyError, OSError): - pass - _poll.register(router, POLLIN | POLLOUT) + # Router writable: send pending frame immediately + if pending_frame is not None: + if any(fd == router_fd and (flags & POLLOUT) for fd, flags in events): + msg_id = send_frame(router, packer, msg_id, pending_frame) + last_sent_frame = pending_frame + pending_frame = None + frames_sent += 1 + else: + frames_dropped += 1 - # Render frame when due - if now >= next_frame_time: - next_frame_time = now + frame_interval + # Background refresh: resend last frame to recover from router drops + if refresh_due: + last_refresh = now fps_sample_frames += 1 + if last_sent_frame is not None: + msg_id = send_frame(router, packer, msg_id, last_sent_frame) - pixels = [[0] * COLS for _ in range(ROWS)] - expired = [] - for note, state in list(active_notes.items()): - state["age"] += 1 - state["decay"] -= 1 - if state["decay"] <= 0 or state["age"] > MAX_NOTE_AGE: - expired.append(note) - continue - note_min, note_max = 36, 96 - row = 7 - int((note - note_min) / (note_max - note_min) * 7) - row = max(0, min(7, row)) - col = note % 12 - pixels[row][col] = 1 - - for note in expired: - del active_notes[note] - - frame = pack_frame(pixels) - if frame != last_frame: - msg_id = (msg_id + 1) % 65535 - params = [str(v) for v in frame] - req = packer.pack([0, msg_id, "draw_frame", params]) - if pending_frames.push(req): - pass # queued - else: - frames_dropped += 1 - last_frame = frame - - # Write stats periodically (every 2s) + # Write stats every 2s 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, pending_frames, fps, len(active_notes)) + _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, pending, fps, active_count): +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, - "pending_queue": len(pending), - "queue_full": pending.full(), "fps": round(fps, 1), "active_notes": active_count, + "midi_events": events, "ts": time.time() }, f) except OSError: @@ -226,4 +195,4 @@ def _write_stats(sent, dropped, pending, fps, active_count): if __name__ == "__main__": - main() \ No newline at end of file + main()