fix: rewrite viz as event-driven with zero-latency MIDI rendering
Validate and Test / validate-patches (push) Failing after 13s
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:
+68
-99
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user