fix: rewrite viz as event-driven with zero-latency MIDI rendering
Validate and Test / validate-patches (push) Failing after 13s

- Remove frame loop latency: render and send immediately on MIDI event
- Remove decay: notes on/off based purely on MIDI note-on/note-off
- Background 5 FPS refresh: catch router-dropped frames
- active_notes uses set instead of dict with decay counters
- Simpler stats: FPS, active notes, midi events processed
This commit is contained in:
2026-06-24 03:31:12 -07:00
parent 5115518b5f
commit 68d38de9a5
+68 -99
View File
@@ -1,20 +1,18 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Listen for MIDI events via UDP and display note activity on LED matrix. """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")) VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock") 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 COLS = 13
ROWS = 8 ROWS = 8
frame_interval = 1.0 / 30.0 # 30 FPS target REFRESH_INTERVAL = 0.2 # 5 FPS background refresh (catches dropped frames)
next_frame_time = 0.0
STATS_FILE = "/tmp/viz-stats.json" STATS_FILE = "/tmp/viz-stats.json"
_poll = select.poll() _poll = select.poll()
@@ -35,25 +33,15 @@ def pack_frame(pixels):
(bits >> 64) & 0xFFFFFFFF, (bits >> 96) & 0xFFFFFFFF) (bits >> 64) & 0xFFFFFFFF, (bits >> 96) & 0xFFFFFFFF)
class FrameQueue: def render(active_notes):
def __init__(self, maxsize=10): pixels = [[0] * COLS for _ in range(ROWS)]
self._q = collections.deque() note_min, note_max = 36, 96
self._maxsize = maxsize for note in active_notes:
row = 7 - int((note - note_min) / (note_max - note_min) * 7)
def push(self, frame_bytes): row = max(0, min(7, row))
if len(self._q) >= self._maxsize: col = note % 12
return False # dropped pixels[row][col] = 1
self._q.append(frame_bytes) return pack_frame(pixels)
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(): def connect_router():
@@ -66,9 +54,20 @@ def connect_router():
return router return router
def main(): def send_frame(router, packer, msg_id, frame):
global next_frame_time 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 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", VIZ_PORT)) sock.bind(("0.0.0.0", VIZ_PORT))
@@ -78,29 +77,30 @@ def main():
packer = msgpack.Packer() packer = msgpack.Packer()
_poll.register(sock, POLLIN) _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() router_fd = router.fileno()
_poll.register(router, POLLIN | POLLOUT) _poll.register(router, POLLIN | POLLOUT)
active_notes = set()
last_sent_frame = None
pending_frame = None
buf = b""
msg_id = 0
frames_sent = 0 frames_sent = 0
frames_dropped = 0 frames_dropped = 0
router_errors = 0 router_errors = 0
last_refresh = time.monotonic()
stats_last_write = time.monotonic() stats_last_write = time.monotonic()
fps_sample_start = time.monotonic() fps_sample_start = time.monotonic()
fps_sample_frames = 0 fps_sample_frames = 0
events_processed = 0
while True: while True:
now = time.monotonic() now = time.monotonic()
refresh_due = (now - last_refresh) >= REFRESH_INTERVAL
timeout = max(0, int((next_frame_time - now) * 1000)) timeout = 5 # ms
timeout = min(timeout, 5) # 5ms max to stay responsive
try: try:
events = _poll.poll(timeout) events = _poll.poll(timeout)
@@ -126,10 +126,20 @@ def main():
note = int(parts[2]) note = int(parts[2])
vel = int(parts[3]) if len(parts) >= 4 else 0 vel = int(parts[3]) if len(parts) >= 4 else 0
msg_type = status & 0xF0 msg_type = status & 0xF0
changed = False
if msg_type == 0x90 and vel > 0: 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): 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 # Process router readable
if fd == router_fd and (flags & POLLIN): if fd == router_fd and (flags & POLLIN):
@@ -141,84 +151,43 @@ def main():
except BlockingIOError: except BlockingIOError:
pass pass
# Process router writable — drain pending frames # Router writable: send pending frame immediately
if fd == router_fd and (flags & POLLOUT): if pending_frame is not None:
frame = pending_frames.pop() if any(fd == router_fd and (flags & POLLOUT) for fd, flags in events):
if frame: msg_id = send_frame(router, packer, msg_id, pending_frame)
try: last_sent_frame = pending_frame
router.sendall(frame) pending_frame = None
frames_sent += 1 frames_sent += 1
except (BlockingIOError, OSError): else:
frames_dropped += 1 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)
# Render frame when due # Background refresh: resend last frame to recover from router drops
if now >= next_frame_time: if refresh_due:
next_frame_time = now + frame_interval last_refresh = now
fps_sample_frames += 1 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)] # Write stats every 2s
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)
if now - stats_last_write >= 2.0: if now - stats_last_write >= 2.0:
fps = fps_sample_frames / (now - fps_sample_start) if (now - fps_sample_start) > 0 else 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 stats_last_write = now
fps_sample_start = now fps_sample_start = now
fps_sample_frames = 0 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 import json
try: try:
with open(STATS_FILE, "w") as f: with open(STATS_FILE, "w") as f:
json.dump({ json.dump({
"frames_sent": sent, "frames_sent": sent,
"frames_dropped": dropped, "frames_dropped": dropped,
"pending_queue": len(pending),
"queue_full": pending.full(),
"fps": round(fps, 1), "fps": round(fps, 1),
"active_notes": active_count, "active_notes": active_count,
"midi_events": events,
"ts": time.time() "ts": time.time()
}, f) }, f)
except OSError: except OSError: