Commit c0fd5d42 by PLN (Algolia)

feat(bridge): live MIDI dashboard panel — SSE wired end to end (#16)

Completes the parked MIDI feature (midimon.py + midistream.py were done/tested in
270a01d6; this wires them into the always-on Bridge). The "do better than a raw
aseqdump in a terminal" monitor now lives in the web dashboard.

server.py: a module-global MidiStream fan-out (lazy — opens aseqdump only while a
tab is watching, closes on the last unsubscribe). Two GET routes:
  /api/midi/ports  → list ALSA seq ports
  /api/midi/stream → Server-Sent Events of parsed events, ?port=addr to pick one.
ThreadingHTTPServer gives each SSE connection its own thread, so blocking on the
subscriber queue is fine; a ': ping' every 15 s holds the pipe open, and
BrokenPipe/ConnectionReset on tab-close is swallowed and triggers unsubscribe.

ui/index.html: a MIDI panel — port picker + connect/disconnect (EventSource) +
rescan, and a live log. Rows are colour-coded by event (note-on green, note-off
faint, control-change amber, pitch-bend magenta), show source/event/channel/
note-name and a velocity bar (val/127), newest on top, capped at 200. Never hue
alone: the event word carries the meaning, colour reinforces (DESIGN principle 4).

Verified end to end: systemd --user restart, /api/midi/ports lists the seq ports,
/api/midi/stream emits ': connected' then live events, and the UI connects (green
"live") and renders rows — no console errors. With no hardware controller attached
the feed shows system/subscription events; note/CC rows appear once a controller
port is selected and played.
parent 24606d49
...@@ -23,22 +23,26 @@ from __future__ import annotations ...@@ -23,22 +23,26 @@ from __future__ import annotations
import json import json
import os import os
import queue
import socket import socket
import sys import sys
from functools import partial from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse, parse_qs
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE)) sys.path.insert(0, str(HERE))
import perf as P # noqa: E402 import perf as P # noqa: E402
import launchers as L # noqa: E402 import launchers as L # noqa: E402
import midistream as MS # noqa: E402
UI = HERE / "ui" UI = HERE / "ui"
# One Thermals instance per process → throttle_delta is relative to Bridge start. # One Thermals instance per process → throttle_delta is relative to Bridge start.
_THERM = P.Thermals() _THERM = P.Thermals()
# One MIDI fan-out for the whole Bridge (lazy: opens aseqdump only while watched).
_MIDI = MS.MidiStream()
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
...@@ -54,6 +58,10 @@ class Handler(BaseHTTPRequestHandler): ...@@ -54,6 +58,10 @@ class Handler(BaseHTTPRequestHandler):
return self._json(P.snapshot(_THERM)) return self._json(P.snapshot(_THERM))
if p == "/api/launchers": if p == "/api/launchers":
return self._json(L.snapshot()) return self._json(L.snapshot())
if p == "/api/midi/ports":
return self._json(_MIDI.list_ports())
if p == "/api/midi/stream":
return self._sse_midi(parse_qs(urlparse(self.path).query))
# static assets under ui/ # static assets under ui/
asset = (UI / p.lstrip("/")).resolve() asset = (UI / p.lstrip("/")).resolve()
if UI in asset.parents and asset.is_file(): if UI in asset.parents and asset.is_file():
...@@ -74,6 +82,36 @@ class Handler(BaseHTTPRequestHandler): ...@@ -74,6 +82,36 @@ class Handler(BaseHTTPRequestHandler):
return self._json(r, 200 if r["ok"] else 400) return self._json(r, 200 if r["ok"] else 400)
return self.send_error(404) return self.send_error(404)
# ── live MIDI (SSE) ──────────────────────────────────────────────────
def _sse_midi(self, q):
"""Stream parsed MIDI events as Server-Sent Events until the client leaves.
ThreadingHTTPServer gives each connection its own thread, so blocking on
the subscriber queue here is fine. A ': ping' every 15 s keeps the
connection (and any proxy) from idling out.
"""
port = (q.get("port") or [None])[0] or None
sub = _MIDI.subscribe(port)
try:
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
self.wfile.write(b": connected\n\n")
self.wfile.flush()
while True:
try:
ev = sub.get(timeout=15)
self.wfile.write(("data: " + json.dumps(ev) + "\n\n").encode())
except queue.Empty:
self.wfile.write(b": ping\n\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError, OSError):
pass # client navigated away / closed the tab — normal
finally:
_MIDI.unsubscribe(sub)
# ── helpers ───────────────────────────────────────────────────────── # ── helpers ─────────────────────────────────────────────────────────
def _body(self): def _body(self):
n = int(self.headers.get("Content-Length", 0)) n = int(self.headers.get("Content-Length", 0))
......
...@@ -49,6 +49,29 @@ ...@@ -49,6 +49,29 @@
.dot.on{background:var(--cool);box-shadow:0 0 6px var(--cool)} .dot.on{background:var(--cool);box-shadow:0 0 6px var(--cool)}
.toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px} .toast{font-family:var(--mono);font-size:12px;color:var(--mute);padding:0 20px 8px;min-height:16px}
.toast.err{color:var(--critical)} .toast.err{color:var(--critical)}
/* ── live MIDI ───────────────────────────────────────────── */
.midi-ctl{display:flex;gap:10px;align-items:center;margin-bottom:12px;flex-wrap:wrap}
.midi-ctl select{background:var(--raised);border:1px solid var(--hairline);color:var(--ink);
border-radius:8px;padding:8px 10px;font-family:var(--mono);font-size:12px;max-width:340px}
.midi-ctl button{background:var(--brand-deep);border:1px solid var(--brand);color:#fff;
border-radius:8px;padding:8px 15px;font-family:var(--sans);font-weight:600;cursor:pointer}
.midi-ctl button.off{background:transparent;border-color:var(--hairline);color:var(--mute)}
.midi-ctl .live{font-family:var(--mono);font-size:11px;color:var(--faint)}
.midi-ctl .live.on{color:var(--cool)}
#midiLog{background:var(--raised);border:1px solid var(--hairline);border-radius:12px;
height:300px;overflow-y:auto;padding:6px}
.mev{display:grid;grid-template-columns:48px 96px 22px 1fr 64px;gap:9px;align-items:center;
padding:3px 8px;border-radius:5px;font-family:var(--mono);font-size:12px}
.mev:nth-child(odd){background:#ffffff06}
.mev .src{color:var(--faint)} .mev .ev{font-weight:600;color:var(--mute)}
.mev .ch{color:var(--faint);text-align:right} .mev .nn{color:var(--ink)}
.mev .vel{height:6px;border-radius:3px;background:#ffffff14;position:relative;overflow:hidden}
.mev .vel span{position:absolute;inset:0 auto 0 0;background:var(--mute);border-radius:3px}
.mev.on .ev{color:var(--cool)} .mev.on .vel span{background:var(--cool)}
.mev.off .ev{color:var(--faint)}
.mev.cc .ev{color:var(--warm)} .mev.cc .vel span{background:var(--warm)}
.mev.pitch .ev{color:var(--brand)}
.midi-empty{color:var(--faint);text-align:center;padding:34px;font-family:var(--mono);font-size:12px}
</style> </style>
</head> </head>
<body> <body>
...@@ -67,6 +90,14 @@ ...@@ -67,6 +90,14 @@
<div class="hub" id="web"></div> <div class="hub" id="web"></div>
<h2>Launch</h2> <h2>Launch</h2>
<div class="hub" id="apps"></div> <div class="hub" id="apps"></div>
<h2>MIDI</h2>
<div class="midi-ctl">
<select id="midiPort" aria-label="MIDI input port"></select>
<button id="midiToggle" class="off">Connect</button>
<button id="midiRefresh" class="off" title="rescan ports"></button>
<span class="live" id="midiLive">idle</span>
</div>
<div id="midiLog"><div class="midi-empty">pick a port, connect, and play — live, parsed MIDI</div></div>
</main> </main>
<script> <script>
const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json()); const $=s=>document.querySelector(s), api=(u,o)=>fetch(u,o).then(r=>r.json());
...@@ -136,7 +167,53 @@ async function doLaunch(btn){ ...@@ -136,7 +167,53 @@ async function doLaunch(btn){
setTimeout(loadLaunchers,900); setTimeout(loadLaunchers,900);
}catch(e){ toast("✗ "+e,true); if(win)win.close(); } }catch(e){ toast("✗ "+e,true); if(win)win.close(); }
} }
loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000); // ── live MIDI ────────────────────────────────────────────────
let es=null;
async function loadMidiPorts(){
const ports=await api("/api/midi/ports").catch(()=>[]);
const sel=$("#midiPort"), cur=sel.value;
sel.innerHTML = ports.length
? ports.map(p=>`<option value="${p.addr}">${p.addr} · ${p.client}</option>`).join("")
: `<option value="">no MIDI ports</option>`;
if(cur)[...sel.options].forEach(o=>{if(o.value===cur)sel.value=cur;});
}
function midiRow(ev){
const e=ev.event||"";
const cls = (/note/i.test(e)&&/on/i.test(e))?"on" : /off/i.test(e)?"off"
: /control/i.test(e)?"cc" : /pitch/i.test(e)?"pitch" : "";
const nn = ev.note_name || (ev.controller!=null?("cc "+ev.controller):"");
const val = ev.velocity!=null?ev.velocity : (ev.value!=null?ev.value:null);
const vel = val!=null ? `<span class="vel"><span style="width:${Math.round(val/127*100)}%"></span></span>`
: `<span></span>`;
const row=document.createElement("div"); row.className="mev "+cls;
row.innerHTML=`<span class="src">${ev.source||""}</span><span class="ev">${e}</span>`+
`<span class="ch">${ev.ch!=null?ev.ch:""}</span><span class="nn">${nn}</span>${vel}`;
return row;
}
function addMidi(ev){
const log=$("#midiLog"); const empty=log.querySelector(".midi-empty");
if(empty)log.innerHTML="";
log.prepend(midiRow(ev)); // newest on top
while(log.children.length>200)log.removeChild(log.lastChild);
}
function midiDisconnect(){
if(es){es.close();es=null;}
$("#midiLive").textContent="idle"; $("#midiLive").classList.remove("on");
const t=$("#midiToggle"); t.textContent="Connect"; t.classList.add("off");
}
function midiConnect(){
const port=$("#midiPort").value;
if(!port){toast("no MIDI port to watch",true);return;}
es=new EventSource("/api/midi/stream?port="+encodeURIComponent(port));
es.onopen=()=>{$("#midiLive").textContent="live"; $("#midiLive").classList.add("on");};
es.onmessage=e=>{ try{addMidi(JSON.parse(e.data));}catch(_){} };
es.onerror=()=>{$("#midiLive").textContent="reconnecting…"; $("#midiLive").classList.remove("on");};
const t=$("#midiToggle"); t.textContent="Disconnect"; t.classList.remove("off");
}
$("#midiToggle").onclick=()=>{ es?midiDisconnect():midiConnect(); };
$("#midiRefresh").onclick=loadMidiPorts;
loadLaunchers(); setInterval(loadLaunchers,5000); tick(); setInterval(tick,2000); loadMidiPorts();
</script> </script>
</body> </body>
</html> </html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment