midi-bridge: add prometheus metrics on :9091/metrics

- midi_messages_total: all MIDI messages
- midi_notes_on/off_total: note-on/off counts
- midi_last_status/note/velocity: last message tokens
- midi_last_time_unix: epoch of last message
- Removed MIDI counters from prometheus-exporter (bridge owns them)
This commit is contained in:
2026-06-24 02:25:31 -07:00
parent 163ad4145b
commit caef6f04c8
2 changed files with 114 additions and 33 deletions
+114 -16
View File
@@ -3,19 +3,33 @@
Sends parsed MIDI messages as Pd-style 'midi status data1 data2;' strings
to UDP ports for Pd (8081) and LED matrix visualization (8082).
Exposes Prometheus metrics on :9091/metrics.
"""
import os, socket, time, select, pathlib
import os, socket, time, select, pathlib, threading, http.server
PD_HOST = os.environ.get("MIDI_BRIDGE_HOST", "127.0.0.1")
PD_PORT = int(os.environ.get("MIDI_BRIDGE_PORT", "8081"))
VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
METRICS_PORT = int(os.environ.get("MIDI_METRICS_PORT", "9091"))
# ── Metrics ──
midi_notes_on = 0
midi_notes_off = 0
midi_messages_total = 0
midi_errors = 0
last_status = 0
last_note = 0
last_velocity = 0
last_time = 0.0
_metrics_lock = threading.Lock()
def _find_midi_dev():
"""Auto-detect rawmidi device. Preference: gadget f_midi > env override > host mode."""
dev = os.environ.get("MIDI_BRIDGE_DEV")
if dev:
return dev
# Look for gadget f_midi card first
cards = pathlib.Path("/proc/asound").glob("card*")
for card_dir in cards:
cmi = card_dir / "id"
@@ -24,17 +38,18 @@ def _find_midi_dev():
midi_dev = f"/dev/snd/midiC{card_num}D0"
if os.path.exists(midi_dev):
return midi_dev
# Fallback: any rawmidi device
rawdevs = sorted(pathlib.Path("/dev/snd").glob("midiC?D0"))
if rawdevs:
return str(rawdevs[0])
return "/dev/snd/midiC0D0" # fallback for host mode
return "/dev/snd/midiC0D0"
def midi_msg(status, data1, data2=None):
if data2 is not None:
return f"midi {status} {data1} {data2};"
return f"midi {status} {data1};"
def _open_midi():
midi_dev = _find_midi_dev()
while not os.path.exists(midi_dev):
@@ -44,9 +59,102 @@ def _open_midi():
os.set_blocking(fd, False)
return fd
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
def _flush(sock, buf):
global midi_notes_on, midi_notes_off, midi_messages_total, midi_errors
global last_status, last_note, last_velocity, last_time
status = buf[0]
if (buf[0] & 0xF0) == 0xF0:
return
status = (status & 0xF0) | 0
data1 = buf[1]
data2 = buf[2] if len(buf) >= 3 else None
msg = midi_msg(status, data1, data2)
sock.sendto(msg.encode(), (PD_HOST, PD_PORT))
sock.sendto(msg.encode(), (PD_HOST, VIZ_PORT))
with _metrics_lock:
midi_messages_total += 1
last_status = status
last_note = data1
last_velocity = data2 if data2 is not None else 0
last_time = time.time()
msg_type = status & 0xF0
if msg_type == 0x90 and (data2 or 0) > 0:
midi_notes_on += 1
elif msg_type == 0x80 or (msg_type == 0x90 and (data2 or 0) == 0):
midi_notes_off += 1
# ── Prometheus HTTP ──
def _format_metrics():
with _metrics_lock:
lines = [
"# HELP midi_messages_total Total MIDI messages received",
"# TYPE midi_messages_total counter",
f"midi_messages_total {midi_messages_total}",
"",
"# HELP midi_notes_on_total Total note-on messages",
"# TYPE midi_notes_on_total counter",
f"midi_notes_on_total {midi_notes_on}",
"",
"# HELP midi_notes_off_total Total note-off messages",
"# TYPE midi_notes_off_total counter",
f"midi_notes_off_total {midi_notes_off}",
"",
"# HELP midi_errors_total Total MIDI parse errors",
"# TYPE midi_errors_total counter",
f"midi_errors_total {midi_errors}",
"",
"# HELP midi_last_status Status byte of last message",
"# TYPE midi_last_status gauge",
f"midi_last_status {last_status}",
"",
"# HELP midi_last_note Note number of last message",
"# TYPE midi_last_note gauge",
f"midi_last_note {last_note}",
"",
"# HELP midi_last_velocity Velocity of last message",
"# TYPE midi_last_velocity gauge",
f"midi_last_velocity {last_velocity}",
"",
"# HELP midi_last_time_unix Epoch of last MIDI message",
"# TYPE midi_last_time_unix gauge",
f"midi_last_time_unix {last_time:.3f}",
"",
]
return "\n".join(lines) + "\n"
class _MetricsHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/metrics":
body = _format_metrics().encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
def _start_metrics_server():
server = http.server.HTTPServer(("0.0.0.0", METRICS_PORT), _MetricsHandler)
t = threading.Thread(target=server.serve_forever, daemon=True)
t.start()
def main():
_start_metrics_server()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
running = True
while running:
try:
@@ -90,16 +198,6 @@ def main():
sock.close()
def _flush(sock, buf):
status = buf[0]
if (buf[0] & 0xF0) == 0xF0:
return
status = (status & 0xF0) | 0 # normalize to channel 1
data1 = buf[1]
data2 = buf[2] if len(buf) >= 3 else None
msg = midi_msg(status, data1, data2)
sock.sendto(msg.encode(), (PD_HOST, PD_PORT))
sock.sendto(msg.encode(), (PD_HOST, VIZ_PORT))
if __name__ == "__main__":
main()