diff --git a/scripts/midi-bridge.py b/scripts/midi-bridge.py index 4fc3967..6a3e9e0 100644 --- a/scripts/midi-bridge.py +++ b/scripts/midi-bridge.py @@ -4,16 +4,16 @@ 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. +Writes metrics to /tmp/midi-stats.json for prometheus-exporter to collect. """ -import os, socket, time, select, pathlib, threading, http.server +import os, socket, time, select, pathlib, json 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")) +STATS_FILE = os.environ.get("MIDI_STATS_FILE", "/tmp/midi-stats.json") +STATS_INTERVAL = 2.0 -# ── Metrics ── midi_notes_on = 0 midi_notes_off = 0 midi_messages_total = 0 @@ -22,7 +22,7 @@ last_status = 0 last_note = 0 last_velocity = 0 last_time = 0.0 -_metrics_lock = threading.Lock() +_stats_lock = object() def _find_midi_dev(): @@ -74,7 +74,7 @@ def _flush(sock, buf): sock.sendto(msg.encode(), (PD_HOST, PD_PORT)) sock.sendto(msg.encode(), (PD_HOST, VIZ_PORT)) - with _metrics_lock: + with _stats_lock: midi_messages_total += 1 last_status = status last_note = data1 @@ -87,75 +87,28 @@ def _flush(sock, buf): 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): +def _write_stats(): + try: + with open(STATS_FILE, "w") as f: + json.dump({ + "midi_messages_total": midi_messages_total, + "midi_notes_on_total": midi_notes_on, + "midi_notes_off_total": midi_notes_off, + "midi_errors_total": midi_errors, + "midi_last_status": last_status, + "midi_last_note": last_note, + "midi_last_velocity": last_velocity, + "midi_last_time_unix": last_time, + }, f) + except OSError: 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 + last_stats_write = time.monotonic() + while running: try: fd = _open_midi() @@ -163,8 +116,16 @@ def main(): poll.register(fd, select.POLLIN) buf = bytearray() while running: + now = time.monotonic() + + # Write stats periodically + if now - last_stats_write >= STATS_INTERVAL: + with _stats_lock: + _write_stats() + last_stats_write = now + try: - events = poll.poll(500) + events = poll.poll(100) for f, flags in events: if flags & select.POLLIN: data = os.read(fd, 1024) @@ -200,4 +161,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/scripts/prometheus-exporter.py b/scripts/prometheus-exporter.py index 0f495c6..7220a28 100644 --- a/scripts/prometheus-exporter.py +++ b/scripts/prometheus-exporter.py @@ -1,20 +1,18 @@ #!/usr/bin/env python3 """Prometheus exporter for uno-q-audio-synth pipeline. -Exposes MIDI, audio, and service health metrics on :9090/metrics. +Exposes MIDI, audio, viz, and service health metrics on :9090/metrics. +Reads stats from /tmp/midi-stats.json (midi-bridge) and /tmp/viz-stats.json (viz). No external dependencies — uses only stdlib. """ -import http.server, time, os, re, subprocess, socket, threading, json +import http.server, time, os, re, subprocess, json PORT = int(os.environ.get("METRICS_PORT", "9090")) -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 +COLLECT_INTERVAL = 5 -# ── Gauges (updated by collector) ── metrics = { "synth_alsa_running": 0, - "synth_hw_ptr_bytes_per_sec": 0, + "synth_hw_ptr_bytes_per_sec": 0.0, "synth_alsa_hw_ptr": 0, "synth_pd_running": 0, "synth_midi_bridge_running": 0, @@ -24,6 +22,14 @@ metrics = { "synth_hphl_volume": 0, "synth_hphr_volume": 0, "synth_up": 1, + "midi_messages_total": 0, + "midi_notes_on_total": 0, + "midi_notes_off_total": 0, + "midi_errors_total": 0, + "midi_last_status": 0, + "midi_last_note": 0, + "midi_last_velocity": 0, + "midi_last_time_unix": 0.0, "viz_frames_sent": 0, "viz_frames_dropped": 0, "viz_pending_queue": 0, @@ -33,12 +39,13 @@ metrics = { prev_hw_ptr = 0 prev_hw_ptr_time = 0 +MIDI_STATS_FILE = "/tmp/midi-stats.json" VIZ_STATS_FILE = "/tmp/viz-stats.json" -def read_viz_stats(): +def read_json(path): try: - with open(VIZ_STATS_FILE) as f: + with open(path) as f: return json.load(f) except (OSError, ValueError): return {} @@ -53,7 +60,6 @@ def read_local_file(path): def get_alsa_status(): - """Read ALSA PCM status from /proc.""" global prev_hw_ptr, prev_hw_ptr_time status = read_local_file("/proc/asound/card0/pcm0p/sub0/status") @@ -76,7 +82,6 @@ def get_alsa_status(): def check_service(name): - """Check if a systemd service is active (local only).""" try: r = subprocess.run( ["systemctl", "is-active", name], @@ -88,7 +93,6 @@ def check_service(name): def get_mixer_value(name): - """Read an ALSA mixer value via amixer.""" try: r = subprocess.run( ["amixer", "-c", "0", "cget", f"name={name}"], @@ -106,100 +110,136 @@ def get_mixer_value(name): return 0 -def collect_metrics(): - """Background thread: poll system state periodically.""" - while True: - try: - get_alsa_status() +def collect(): + get_alsa_status() - metrics["synth_pd_running"] = check_service("pd-synth-onboard") - metrics["synth_midi_bridge_running"] = check_service("midi-bridge") - metrics["synth_viz_running"] = check_service("led-matrix-viz") + metrics["synth_pd_running"] = check_service("pd-synth-onboard") + metrics["synth_midi_bridge_running"] = check_service("midi-bridge") + metrics["synth_viz_running"] = check_service("led-matrix-viz") - metrics["synth_hphl_switch"] = get_mixer_value("HPHL Switch") - metrics["synth_hphr_switch"] = get_mixer_value("HPHR Switch") - metrics["synth_hphl_volume"] = get_mixer_value("HPHL Volume") - metrics["synth_hphr_volume"] = get_mixer_value("HPHR Volume") + metrics["synth_hphl_switch"] = get_mixer_value("HPHL Switch") + metrics["synth_hphr_switch"] = get_mixer_value("HPHR Switch") + metrics["synth_hphl_volume"] = get_mixer_value("HPHL Volume") + metrics["synth_hphr_volume"] = get_mixer_value("HPHR Volume") - viz = read_viz_stats() - metrics["viz_frames_sent"] = viz.get("frames_sent", 0) - metrics["viz_frames_dropped"] = viz.get("frames_dropped", 0) - metrics["viz_pending_queue"] = viz.get("pending_queue", 0) - metrics["viz_queue_full"] = 1 if viz.get("queue_full", False) else 0 - except Exception: - pass + midi = read_json(MIDI_STATS_FILE) + metrics["midi_messages_total"] = midi.get("midi_messages_total", 0) + metrics["midi_notes_on_total"] = midi.get("midi_notes_on_total", 0) + metrics["midi_notes_off_total"] = midi.get("midi_notes_off_total", 0) + metrics["midi_errors_total"] = midi.get("midi_errors_total", 0) + metrics["midi_last_status"] = midi.get("midi_last_status", 0) + metrics["midi_last_note"] = midi.get("midi_last_note", 0) + metrics["midi_last_velocity"] = midi.get("midi_last_velocity", 0) + metrics["midi_last_time_unix"] = midi.get("midi_last_time_unix", 0.0) - time.sleep(COLLECT_INTERVAL) + viz = read_json(VIZ_STATS_FILE) + metrics["viz_frames_sent"] = viz.get("frames_sent", 0) + metrics["viz_frames_dropped"] = viz.get("frames_dropped", 0) + metrics["viz_pending_queue"] = viz.get("pending_queue", 0) + metrics["viz_queue_full"] = 1 if viz.get("queue_full", False) else 0 def format_metrics(): - lines = [] - 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']}") + lines = [ + "# HELP synth_alsa_running Whether ALSA device is RUNNING", + "# TYPE synth_alsa_running gauge", + f"synth_alsa_running {metrics['synth_alsa_running']}", - lines.append("# HELP synth_hw_ptr_bytes_per_sec Audio throughput in bytes/sec") - lines.append("# TYPE synth_hw_ptr_bytes_per_sec gauge") - lines.append(f"synth_hw_ptr_bytes_per_sec {metrics['synth_hw_ptr_bytes_per_sec']:.0f}") + "# HELP synth_hw_ptr_bytes_per_sec Audio throughput in bytes/sec", + "# TYPE synth_hw_ptr_bytes_per_sec gauge", + f"synth_hw_ptr_bytes_per_sec {metrics['synth_hw_ptr_bytes_per_sec']:.0f}", - lines.append("# HELP synth_alsa_hw_ptr Current ALSA hardware pointer") - lines.append("# TYPE synth_alsa_hw_ptr gauge") - lines.append(f"synth_alsa_hw_ptr {metrics['synth_alsa_hw_ptr']}") + "# HELP synth_alsa_hw_ptr Current ALSA hardware pointer", + "# TYPE synth_alsa_hw_ptr gauge", + f"synth_alsa_hw_ptr {metrics['synth_alsa_hw_ptr']}", - lines.append("# HELP synth_pd_running Whether Pd service is active") - lines.append("# TYPE synth_pd_running gauge") - lines.append(f"synth_pd_running {metrics['synth_pd_running']}") + "# HELP synth_pd_running Whether Pd service is active", + "# TYPE synth_pd_running gauge", + f"synth_pd_running {metrics['synth_pd_running']}", - lines.append("# HELP synth_midi_bridge_running Whether midi-bridge service is active") - lines.append("# TYPE synth_midi_bridge_running gauge") - lines.append(f"synth_midi_bridge_running {metrics['synth_midi_bridge_running']}") + "# HELP synth_midi_bridge_running Whether midi-bridge service is active", + "# TYPE synth_midi_bridge_running gauge", + f"synth_midi_bridge_running {metrics['synth_midi_bridge_running']}", - lines.append("# HELP synth_viz_running Whether led-matrix-viz service is active") - lines.append("# TYPE synth_viz_running gauge") - lines.append(f"synth_viz_running {metrics['synth_viz_running']}") + "# HELP synth_viz_running Whether led-matrix-viz service is active", + "# TYPE synth_viz_running gauge", + f"synth_viz_running {metrics['synth_viz_running']}", - lines.append("# HELP synth_hphl_switch Headphone left switch state") - lines.append("# TYPE synth_hphl_switch gauge") - lines.append(f"synth_hphl_switch {metrics['synth_hphl_switch']}") + "# HELP synth_hphl_switch Headphone left switch state", + "# TYPE synth_hphl_switch gauge", + f"synth_hphl_switch {metrics['synth_hphl_switch']}", - lines.append("# HELP synth_hphr_switch Headphone right switch state") - lines.append("# TYPE synth_hphr_switch gauge") - lines.append(f"synth_hphr_switch {metrics['synth_hphr_switch']}") + "# HELP synth_hphr_switch Headphone right switch state", + "# TYPE synth_hphr_switch gauge", + f"synth_hphr_switch {metrics['synth_hphr_switch']}", - lines.append("# HELP synth_hphl_volume Headphone left volume (0-20)") - lines.append("# TYPE synth_hphl_volume gauge") - lines.append(f"synth_hphl_volume {metrics['synth_hphl_volume']}") + "# HELP synth_hphl_volume Headphone left volume (0-20)", + "# TYPE synth_hphl_volume gauge", + f"synth_hphl_volume {metrics['synth_hphl_volume']}", - lines.append("# HELP synth_hphr_volume Headphone right volume (0-20)") - lines.append("# TYPE synth_hphr_volume gauge") - lines.append(f"synth_hphr_volume {metrics['synth_hphr_volume']}") + "# HELP synth_hphr_volume Headphone right volume (0-20)", + "# TYPE synth_hphr_volume gauge", + f"synth_hphr_volume {metrics['synth_hphr_volume']}", - lines.append("# HELP synth_up Exporter is running") - lines.append("# TYPE synth_up gauge") - lines.append(f"synth_up {metrics['synth_up']}") + "# HELP synth_up Exporter is running", + "# TYPE synth_up gauge", + f"synth_up {metrics['synth_up']}", - lines.append("# HELP viz_frames_sent_total Total frames sent to router") - lines.append("# TYPE viz_frames_sent_total counter") - lines.append(f"viz_frames_sent_total {metrics['viz_frames_sent']}") + "# HELP midi_messages_total Total MIDI messages received from hardware", + "# TYPE midi_messages_total counter", + f"midi_messages_total {metrics['midi_messages_total']}", - lines.append("# HELP viz_frames_dropped_total Total frames dropped (queue full)") - lines.append("# TYPE viz_frames_dropped_total counter") - lines.append(f"viz_frames_dropped_total {metrics['viz_frames_dropped']}") + "# HELP midi_notes_on_total Total MIDI note-on messages", + "# TYPE midi_notes_on_total counter", + f"midi_notes_on_total {metrics['midi_notes_on_total']}", - lines.append("# HELP viz_pending_queue Current pending frame queue depth") - lines.append("# TYPE viz_pending_queue gauge") - lines.append(f"viz_pending_queue {metrics['viz_pending_queue']}") + "# HELP midi_notes_off_total Total MIDI note-off messages", + "# TYPE midi_notes_off_total counter", + f"midi_notes_off_total {metrics['midi_notes_off_total']}", - lines.append("# HELP viz_queue_full Router write buffer was full") - lines.append("# TYPE viz_queue_full gauge") - lines.append(f"viz_queue_full {metrics['viz_queue_full']}") + "# HELP midi_errors_total Total MIDI parse errors", + "# TYPE midi_errors_total counter", + f"midi_errors_total {metrics['midi_errors_total']}", + "# HELP midi_last_status Status byte of last message", + "# TYPE midi_last_status gauge", + f"midi_last_status {metrics['midi_last_status']}", + + "# HELP midi_last_note Note number of last message", + "# TYPE midi_last_note gauge", + f"midi_last_note {metrics['midi_last_note']}", + + "# HELP midi_last_velocity Velocity of last message", + "# TYPE midi_last_velocity gauge", + f"midi_last_velocity {metrics['midi_last_velocity']}", + + "# HELP midi_last_time_unix Epoch of last MIDI message", + "# TYPE midi_last_time_unix gauge", + f"midi_last_time_unix {metrics['midi_last_time_unix']:.3f}", + + "# HELP viz_frames_sent_total Total frames sent to router", + "# TYPE viz_frames_sent_total counter", + f"viz_frames_sent_total {metrics['viz_frames_sent']}", + + "# HELP viz_frames_dropped_total Total frames dropped (queue full)", + "# TYPE viz_frames_dropped_total counter", + f"viz_frames_dropped_total {metrics['viz_frames_dropped']}", + + "# HELP viz_pending_queue Current pending frame queue depth", + "# TYPE viz_pending_queue gauge", + f"viz_pending_queue {metrics['viz_pending_queue']}", + + "# HELP viz_queue_full Router write buffer was full", + "# TYPE viz_queue_full gauge", + f"viz_queue_full {metrics['viz_queue_full']}", + ] return "\n".join(lines) + "\n" -class MetricsHandler(http.server.BaseHTTPRequestHandler): +class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/metrics": + collect() body = format_metrics().encode() self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4; charset=utf-8") @@ -217,18 +257,25 @@ class MetricsHandler(http.server.BaseHTTPRequestHandler): self.end_headers() def log_message(self, format, *args): - pass # silent + pass def main(): - # Start background collector - t = threading.Thread(target=collect_metrics, daemon=True) + import threading + t = threading.Thread(target=lambda: ( + setInterval(COLLECT_INTERVAL, collect), + ), daemon=True) t.start() - - server = http.server.HTTPServer(("0.0.0.0", PORT), MetricsHandler) + server = http.server.HTTPServer(("0.0.0.0", PORT), Handler) print(f"[metrics] Listening on :{PORT}/metrics") server.serve_forever() +def setInterval(secs, func): + while True: + func() + time.sleep(secs) + + if __name__ == "__main__": - main() + main() \ No newline at end of file