From caef6f04c87e7a02f4268cab9aba4ae0247f8f5f Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 24 Jun 2026 02:25:31 -0700 Subject: [PATCH] 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) --- scripts/midi-bridge.py | 130 +++++++++++++++++++++++++++++---- scripts/prometheus-exporter.py | 17 ----- 2 files changed, 114 insertions(+), 33 deletions(-) diff --git a/scripts/midi-bridge.py b/scripts/midi-bridge.py index 884baee..4fc3967 100644 --- a/scripts/midi-bridge.py +++ b/scripts/midi-bridge.py @@ -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() diff --git a/scripts/prometheus-exporter.py b/scripts/prometheus-exporter.py index 93275c8..6251609 100644 --- a/scripts/prometheus-exporter.py +++ b/scripts/prometheus-exporter.py @@ -11,11 +11,6 @@ SSH_KEY = os.environ.get("SSH_KEY", "/home/david/.ssh/unoq_deploy_key") BOARD = os.environ.get("BOARD_HOST", "127.0.0.1") COLLECT_INTERVAL = 5 # seconds between metric collections -# ── Counters ── -midi_notes_on = 0 -midi_notes_off = 0 -midi_errors = 0 - # ── Gauges (updated by collector) ── metrics = { "synth_alsa_running": 0, @@ -119,18 +114,6 @@ def collect_metrics(): def format_metrics(): lines = [] - lines.append("# HELP synth_midi_notes_on_total Total MIDI note-on messages received") - lines.append("# TYPE synth_midi_notes_on_total counter") - lines.append(f"synth_midi_notes_on_total {midi_notes_on}") - - lines.append("# HELP synth_midi_notes_off_total Total MIDI note-off messages received") - lines.append("# TYPE synth_midi_notes_off_total counter") - lines.append(f"synth_midi_notes_off_total {midi_notes_off}") - - lines.append("# HELP synth_midi_errors_total Total MIDI parse errors") - lines.append("# TYPE synth_midi_errors_total counter") - lines.append(f"synth_midi_errors_total {midi_errors}") - lines.append("# HELP synth_alsa_running Whether ALSA device is RUNNING") lines.append("# TYPE synth_alsa_running gauge") lines.append(f"synth_alsa_running {metrics['synth_alsa_running']}")