viz: proper POLLOUT queue to prevent frame drops

- FrameQueue (max 5) buffers frames when router is slow
- POLLOUT drains pending_frames queue
- No silent drops — full queue increments counter
- Router always registered POLLIN| POLLOUT
- Frame interval 30ms (33 FPS)
This commit is contained in:
2026-06-24 02:37:46 -07:00
parent 272a13f8d9
commit 5c95aa6cd0
+92 -39
View File
@@ -1,18 +1,29 @@
#!/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 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 import socket, msgpack, time, os, select, collections
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", "8")) DECAY_FRAMES = int(os.environ.get("DECAY_FRAMES", "8"))
FPS = 30
COLS = 13 COLS = 13
ROWS = 8 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): def pack_frame(pixels):
bits = 0 bits = 0
@@ -24,6 +35,27 @@ def pack_frame(pixels):
(bits >> 64) & 0xFFFFFFFF, (bits >> 96) & 0xFFFFFFFF) (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(): def connect_router():
router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
router.setblocking(False) router.setblocking(False)
@@ -35,6 +67,8 @@ def connect_router():
def main(): def main():
global next_frame_time
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))
@@ -43,40 +77,44 @@ def main():
router = connect_router() router = connect_router()
packer = msgpack.Packer() packer = msgpack.Packer()
_poll.register(sock, POLLIN)
active_notes = {} active_notes = {}
last_frame = None last_frame = None
buf = b"" buf = b""
msg_id = 0 msg_id = 0
frame_interval = 1.0 / FPS next_frame_time = time.monotonic()
next_frame = time.monotonic()
router_buf = b"" pending_frames = FrameQueue(maxsize=5)
router_registered = False
router_fd = router.fileno()
_poll.register(router, POLLIN | POLLOUT)
frames_sent = 0 frames_sent = 0
frames_dropped = 0 frames_dropped = 0
router_errors = 0
poller = select.poll()
poller.register(sock, select.POLLIN)
while True: while True:
# Always poll UDP with short timeout now = time.monotonic()
timeout = max(0, int((next_frame - time.monotonic()) * 1000))
timeout = min(timeout, 10) # cap at 10ms for responsiveness timeout = max(0, int((next_frame_time - now) * 1000))
timeout = min(timeout, 5) # 5ms max to stay responsive
try: try:
events = poller.poll(timeout) events = _poll.poll(timeout)
except OSError: except OSError:
events = [] events = []
# Process all pending UDP data immediately # Process UDP
for fd, flags in events: for fd, flags in events:
if fd == sock.fileno() and flags & select.POLLIN: if fd == sock.fileno() and (flags & POLLIN):
try: try:
while True: while True:
data, _ = sock.recvfrom(1024) data, _ = sock.recvfrom(SOCK_BUFFER)
buf += data buf += data
except BlockingIOError: except BlockingIOError:
pass pass
# Parse all complete messages
while b";" in buf: while b";" in buf:
msg, buf = buf.split(b";", 1) msg, buf = buf.split(b";", 1)
msg = msg.decode().strip() msg = msg.decode().strip()
@@ -91,22 +129,44 @@ def main():
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) active_notes.pop(note, None)
# Also drain any pending router responses # Process router readable
if router.fileno() >= 0: if fd == router_fd and (flags & POLLIN):
try: try:
while True: while True:
router_buf += router.recv(4096) d = router.recv(SOCK_BUFFER)
except BlockingIOError: if not d:
pass break
except BlockingIOError:
pass
# Render frame when it's time # Process router writable — drain pending frames
now = time.monotonic() if fd == router_fd and (flags & POLLOUT):
if now >= next_frame: frame = pending_frames.pop()
next_frame = now + frame_interval 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)] pixels = [[0] * COLS for _ in range(ROWS)]
expired = [] expired = []
for note, decay in active_notes.items(): for note, decay in list(active_notes.items()):
if decay <= 0: if decay <= 0:
expired.append(note) expired.append(note)
continue continue
@@ -125,19 +185,12 @@ def main():
msg_id = (msg_id + 1) % 65535 msg_id = (msg_id + 1) % 65535
params = [str(v) for v in frame] params = [str(v) for v in frame]
req = packer.pack([0, msg_id, "draw_frame", params]) req = packer.pack([0, msg_id, "draw_frame", params])
try: if pending_frames.push(req):
router.sendall(req) pass # queued
frames_sent += 1 else:
except (BlockingIOError, OSError):
frames_dropped += 1 frames_dropped += 1
# Reconnect on error
try:
router.close()
except OSError:
pass
router = connect_router()
last_frame = frame last_frame = frame
if __name__ == "__main__": if __name__ == "__main__":
main() main()