"""WebSocket + static-HTTP server for the live visualizer. Push model: the WS handler polls engine.taps_snapshot at `fps` Hz and sends a JSON frame to every connected client. The audio thread is never blocked — it just replaces the snapshot dict ref each block. Frame schema: { "sr": 48000, "block_size": 512, "patch": "", "taps": { "": [float, float, ...], ... } } """ import asyncio import functools import http.server import json import threading def _serve_http(directory, port): handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory) server = http.server.ThreadingHTTPServer(('localhost', port), handler) t = threading.Thread(target=server.serve_forever, daemon=True) t.start() return server def _serve_ws(engine, port, fps): import websockets async def handler(ws): try: interval = 1.0 / fps while True: snap = engine.taps_snapshot or {} # arr.tolist() is slow per-tap; OK for a proof. ~10 taps × 512 floats × 30 Hz # is ~150k floats/sec, well under WS budget. taps = {name: arr.tolist() for name, arr in snap.items()} msg = { 'sr': engine.sr, 'block_size': engine.block_size, 'patch': engine.current_patch_text, 'taps': taps, } await ws.send(json.dumps(msg)) await asyncio.sleep(interval) except websockets.exceptions.ConnectionClosed: pass async def run(): async with websockets.serve(handler, 'localhost', port): await asyncio.Future() # serve forever threading.Thread(target=lambda: asyncio.run(run()), daemon=True).start() def serve(engine, viz_dir, http_port=9001, ws_port=9000, fps=30): """Start HTTP (static) + WS (taps) servers in background threads. Returns the HTTP server so the caller can shut it down if desired.""" server = _serve_http(viz_dir, http_port) _serve_ws(engine, ws_port, fps) print(f'[viz] open http://localhost:{http_port}/ (ws://localhost:{ws_port})') return server