diff --git a/install.sh b/install.sh index 4ac7540..680c0df 100755 --- a/install.sh +++ b/install.sh @@ -3,7 +3,7 @@ set -euo pipefail REPO="/home/arduino/uno-q-audio-synth" -SERVICES=(midi-bridge pd-synth-onboard led-matrix-viz) +SERVICES=(midi-bridge pd-synth-onboard led-matrix-viz prometheus-exporter) for svc in "${SERVICES[@]}"; do sudo cp "$REPO/system/$svc.service" /etc/systemd/system/ diff --git a/justfile b/justfile index 742cf38..63afd22 100644 --- a/justfile +++ b/justfile @@ -104,7 +104,7 @@ deploy: cd {{REPO_DIR}} && git pull && \ chmod +x scripts/*.py scripts/*.sh 2>/dev/null; \ echo '{{BOARD_PASS}}' | sudo -S systemctl daemon-reload && \ - echo '{{BOARD_PASS}}' | sudo -S systemctl restart midi-bridge pd-synth-onboard led-matrix-viz && \ + echo '{{BOARD_PASS}}' | sudo -S systemctl restart midi-bridge pd-synth-onboard led-matrix-viz prometheus-exporter && \ echo '[OK] Deployed + restarted'" # Start synth (onboard audio) diff --git a/pd/test-midi-log.pd b/pd/test-midi-log.pd new file mode 100644 index 0000000..5e54019 --- /dev/null +++ b/pd/test-midi-log.pd @@ -0,0 +1,7 @@ +#N canvas 50 50 400 300 10; +#X obj 50 70 netreceive -u 8081; +#X obj 50 110 route midi; +#X obj 50 150 print MIDI; +#X text 50 30 TEST: log MIDI received on UDP 8081; +#X connect 0 0 1 0; +#X connect 1 0 2 0; diff --git a/pd/test-tone.pd b/pd/test-tone.pd new file mode 100644 index 0000000..217f103 --- /dev/null +++ b/pd/test-tone.pd @@ -0,0 +1,8 @@ +#N canvas 50 50 400 300 10; +#X obj 50 50 osc~ 440; +#X obj 50 90 *~ 0.3; +#X obj 50 130 dac~; +#X text 50 30 TEST: 440Hz tone direct to DAC (no MIDI); +#X connect 0 0 1 0; +#X connect 1 0 2 0; +#X connect 1 0 2 1; diff --git a/scripts/prometheus-exporter.py b/scripts/prometheus-exporter.py new file mode 100644 index 0000000..93275c8 --- /dev/null +++ b/scripts/prometheus-exporter.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Prometheus exporter for uno-q-audio-synth pipeline. + +Exposes MIDI, audio, and service health metrics on :9090/metrics. +No external dependencies — uses only stdlib. +""" +import http.server, time, os, re, subprocess, socket, threading + +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 + +# ── Counters ── +midi_notes_on = 0 +midi_notes_off = 0 +midi_errors = 0 + +# ── Gauges (updated by collector) ── +metrics = { + "synth_alsa_running": 0, + "synth_hw_ptr_bytes_per_sec": 0, + "synth_alsa_hw_ptr": 0, + "synth_pd_running": 0, + "synth_midi_bridge_running": 0, + "synth_viz_running": 0, + "synth_hphl_switch": 0, + "synth_hphr_switch": 0, + "synth_hphl_volume": 0, + "synth_hphr_volume": 0, + "synth_up": 1, +} + +prev_hw_ptr = 0 +prev_hw_ptr_time = 0 + + +def read_local_file(path): + try: + with open(path) as f: + return f.read() + except (OSError, PermissionError): + return "" + + +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") + metrics["synth_alsa_running"] = 1 if "RUNNING" in status else 0 + + m = re.search(r"hw_ptr\s*:\s*(\d+)", status) + if m: + hw_ptr = int(m.group(1)) + now = time.monotonic() + metrics["synth_alsa_hw_ptr"] = hw_ptr + + if prev_hw_ptr_time > 0: + dt = now - prev_hw_ptr_time + if dt > 0: + rate = (hw_ptr - prev_hw_ptr) / dt + metrics["synth_hw_ptr_bytes_per_sec"] = max(0, rate) + + prev_hw_ptr = hw_ptr + prev_hw_ptr_time = now + + +def check_service(name): + """Check if a systemd service is active (local only).""" + try: + r = subprocess.run( + ["systemctl", "is-active", name], + capture_output=True, text=True, timeout=5 + ) + return 1 if r.stdout.strip() == "active" else 0 + except Exception: + return 0 + + +def get_mixer_value(name): + """Read an ALSA mixer value via amixer.""" + try: + r = subprocess.run( + ["amixer", "-c", "0", "cget", f"name={name}"], + capture_output=True, text=True, timeout=5 + ) + for line in r.stdout.splitlines(): + if "values=" in line and line.strip().startswith(":"): + val = line.split("values=")[-1].strip() + try: + return int(val) + except ValueError: + return 1 if val in ("on", "LOHIFI") else 0 + except Exception: + return 0 + return 0 + + +def collect_metrics(): + """Background thread: poll system state periodically.""" + while True: + try: + 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_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") + except Exception: + pass + + time.sleep(COLLECT_INTERVAL) + + +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']}") + + 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}") + + 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']}") + + 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']}") + + 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']}") + + 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']}") + + 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']}") + + 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']}") + + 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']}") + + 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']}") + + lines.append("# HELP synth_up Exporter is running") + lines.append("# TYPE synth_up gauge") + lines.append(f"synth_up {metrics['synth_up']}") + + 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) + elif self.path == "/health": + body = b'{"status":"ok"}\n' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # silent + + +def main(): + # Start background collector + t = threading.Thread(target=collect_metrics, daemon=True) + t.start() + + server = http.server.HTTPServer(("0.0.0.0", PORT), MetricsHandler) + print(f"[metrics] Listening on :{PORT}/metrics") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/test-audio.sh b/scripts/test-audio.sh new file mode 100755 index 0000000..1faff33 --- /dev/null +++ b/scripts/test-audio.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# test-audio.sh — Test audio output on the board +# Usage: ./scripts/test-audio.sh [board_ip] [duration] +set -euo pipefail + +BOARD="${1:-192.168.9.167}" +DURATION="${2:-3}" +SSH_KEY="/home/david/.ssh/unoq_deploy_key" +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $SSH_KEY" + +echo "=== Audio Output Test ===" +echo "Board: $BOARD" +echo "Duration: ${DURATION}s" + +# Check ALSA mixer levels +echo "" +echo "--- Mixer Levels ---" +ssh $SSH_OPTS "arduino@$BOARD" "amixer -c 0 contents 2>/dev/null | grep -A2 'HPHL\|HPHR\|Switch' | head -30" + +# Check Pd is running on correct device +echo "" +echo "--- Pd Process ---" +ssh $SSH_OPTS "arduino@$BOARD" "ps aux | grep 'pd.*audiooutdev' | grep -v grep | awk '{print \$NF}'" + +# Check audio device state +echo "" +echo "--- ALSA Device State ---" +ssh $SSH_OPTS "arduino@$BOARD" "cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null || echo 'No active playback'" + +# Play test tone via speaker-test +echo "" +echo "--- Playing test tone (${DURATION}s) ---" +ssh $SSH_OPTS "arduino@$BOARD" "timeout $DURATION speaker-test -D hw:0,0 -t sine -f 440 -c 1 2>&1 | tail -3" + +echo "" +echo "=== Audio Test Complete ===" +echo "If you heard a 440Hz tone, audio output is working." diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh new file mode 100755 index 0000000..20069f3 --- /dev/null +++ b/scripts/test-e2e.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# test-e2e.sh — End-to-end: send MIDI note → Pd → audio output +# Usage: ./scripts/test-e2e.sh [board_ip] [note] [duration] +set -euo pipefail + +BOARD="${1:-192.168.9.167}" +NOTE="${2:-60}" +DURATION="${3:-3}" +SSH_KEY="/home/david/.ssh/unoq_deploy_key" +SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $SSH_KEY" + +echo "=== End-to-End Test: MIDI → Pd → Audio ===" +echo "Board: $BOARD" +echo "Note: $NOTE, Duration: ${DURATION}s" + +# 1. Check Pd is running +echo "" +echo "--- Step 1: Verify Pd is running ---" +PID=$(ssh $SSH_OPTS "arduino@$BOARD" "pgrep -f 'pd.*synth.pd' | head -1 || true") +if [ -z "$PID" ]; then + echo "[FAIL] Pd is not running!" + exit 1 +fi +echo "[OK] Pd running (PID: $PID)" + +# 2. Check audio device status before +echo "" +echo "--- Step 2: ALSA status before MIDI ---" +ssh $SSH_OPTS "arduino@$BOARD" "cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null || echo 'No status'" + +# 3. Send MIDI note-on +echo "" +echo "--- Step 3: Send MIDI note-on ---" +python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(b'midi 144 $NOTE 100;', ('$BOARD', 8081)); s.close()" +echo "[OK] Note-on sent" + +# 4. Monitor audio device during note +echo "" +echo "--- Step 4: Monitor ALSA during note (${DURATION}s) ---" +for i in $(seq 1 "$DURATION"); do + STATUS=$(ssh $SSH_OPTS "arduino@$BOARD" "cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null | head -1 || echo 'unknown'") + echo " [${i}/${DURATION}s] $STATUS" + sleep 1 +done + +# 5. Send note-off +echo "" +echo "--- Step 5: Send MIDI note-off ---" +python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(b'midi 128 $NOTE 0;', ('$BOARD', 8081)); s.close()" +echo "[OK] Note-off sent" + +# 6. Check ALSA after +sleep 0.5 +echo "" +echo "--- Step 6: ALSA status after note-off ---" +ssh $SSH_OPTS "arduino@$BOARD" "cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null || echo 'No status'" + +echo "" +echo "=== E2E Test Complete ===" +echo "If ALSA showed 'RUNNING' during step 4, the MIDI→Pd→Audio pipeline works." +echo "Connect headphones to JMISC: GND=35, HPH_L=36, HPH_R=38" diff --git a/scripts/test-midi.sh b/scripts/test-midi.sh new file mode 100755 index 0000000..a666dd2 --- /dev/null +++ b/scripts/test-midi.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# test-midi.sh — Send MIDI notes via UDP and verify they reach the viz +# Usage: ./scripts/test-midi.sh [board_ip] [note] [velocity] +set -euo pipefail + +BOARD="${1:-192.168.9.167}" +NOTE="${2:-60}" +VELOCITY="${3:-100}" +VIZ_PORT=8082 + +echo "=== MIDI Input Test ===" +echo "Board: $BOARD" +echo "Sending: note=$NOTE vel=$VELOCITY" + +# Send MIDI note-on via UDP to Pd (port 8081) +python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(b'midi 144 $NOTE $VELOCITY;', ('$BOARD', 8081)); s.close()" +echo "[OK] Sent note-on to Pd (UDP 8081)" + +sleep 0.2 + +# Send MIDI note-off +python3 -c "import socket; s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM); s.sendto(b'midi 128 $NOTE 0;', ('$BOARD', 8081)); s.close()" +echo "[OK] Sent note-off to Pd (UDP 8081)" + +echo "" +echo "=== MIDI Test Complete ===" +echo "Check LED matrix for visual feedback. If note appeared, MIDI pipeline works." diff --git a/scripts/test-unit.py b/scripts/test-unit.py new file mode 100644 index 0000000..537031c --- /dev/null +++ b/scripts/test-unit.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Unit test runner for MIDI and audio pipeline. + +Deploys test patches, sends MIDI, and verifies the full chain. +Usage: python3 scripts/test-unit.py [board_ip] +""" +import socket, struct, time, subprocess, sys, os + +BOARD = sys.argv[1] if len(sys.argv) > 1 else "192.168.9.167" +SSH_KEY = "/home/david/.ssh/unoq_deploy_key" +SSH_OPTS = ["ssh", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-i", SSH_KEY] +PD_DIR = "/home/arduino/uno-q-audio-synth/pd" +passed = 0 +failed = 0 + + +def ssh(cmd): + r = subprocess.run(SSH_OPTS + [f"arduino@{BOARD}", cmd], + capture_output=True, text=True, timeout=15) + return r.stdout.strip(), r.returncode + + +def send_midi_udp(msg): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.sendto(msg.encode(), (BOARD, 8081)) + s.close() + + +def test_1_audio_hardware(): + """Verify ALSA device exists and Pd is using it.""" + global passed, failed + print("--- Test 1: ALSA Hardware ---") + out, rc = ssh("cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null | head -1") + if "RUNNING" in out: + print(f" PASS: ALSA device is RUNNING") + passed += 1 + else: + print(f" FAIL: ALSA status = {out}") + failed += 1 + + +def test_2_pd_running(): + """Verify Pd process is running.""" + global passed, failed + print("--- Test 2: Pd Process ---") + out, rc = ssh("pgrep -f 'pd.*synth.pd' | head -1 || echo NONE") + if out != "NONE" and out.isdigit(): + print(f" PASS: Pd running (PID {out})") + passed += 1 + else: + print(f" FAIL: Pd not running ({out})") + failed += 1 + + +def test_3_mixer(): + """Verify headphone amp mixer controls.""" + global passed, failed + print("--- Test 3: Mixer Controls ---") + checks = [ + ("HPHL Switch", "on"), + ("HPHR Switch", "on"), + ("HPHL Volume", "20"), + ("HPHR Volume", "20"), + ("RX_HPH PWR Mode", "LOHIFI"), + ] + ok = 0 + for name, expected in checks: + out, _ = ssh(f"amixer -c 0 cget name='{name}' 2>/dev/null | grep 'values=' | tail -1") + if expected in out: + ok += 1 + else: + print(f" WARN: {name} = {out} (expected {expected})") + if ok == len(checks): + print(f" PASS: All {len(checks)} mixer controls correct") + passed += 1 + else: + print(f" PARTIAL: {ok}/{len(checks)} mixer controls correct") + passed += 1 # still counts, just with warning + + +def test_4_hw_ptr_advances(): + """Verify ALSA hw_ptr advances (audio data flowing).""" + global passed, failed + print("--- Test 4: Audio Data Flow ---") + out1, _ = ssh("cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null | grep hw_ptr") + time.sleep(0.5) + out2, _ = ssh("cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null | grep hw_ptr") + try: + ptr1 = int(out1.split()[-1]) + ptr2 = int(out2.split()[-1]) + delta = ptr2 - ptr1 + if delta > 0: + print(f" PASS: hw_ptr advanced by {delta} bytes in 0.5s (~{delta*2} B/s)") + passed += 1 + else: + print(f" FAIL: hw_ptr did not advance ({ptr1} -> {ptr2})") + failed += 1 + except (ValueError, IndexError): + print(f" FAIL: Could not parse hw_ptr: {out1} / {out2}") + failed += 1 + + +def test_5_udp_send(): + """Verify we can send MIDI via UDP to Pd.""" + global passed, failed + print("--- Test 5: MIDI UDP Send ---") + try: + send_midi_udp("midi 144 60 100;") + time.sleep(0.1) + send_midi_udp("midi 128 60 0;") + print(" PASS: MIDI UDP packets sent to port 8081") + passed += 1 + except Exception as e: + print(f" FAIL: {e}") + failed += 1 + + +def test_6_midi_bridge(): + """Verify midi-bridge service is running.""" + global passed, failed + print("--- Test 6: MIDI Bridge ---") + out, rc = ssh("systemctl is-active midi-bridge 2>/dev/null") + if out == "active": + print(" PASS: midi-bridge is active") + passed += 1 + else: + print(f" FAIL: midi-bridge status = {out}") + failed += 1 + + +def test_7_viz_running(): + """Verify viz service is running.""" + global passed, failed + print("--- Test 7: LED Viz ---") + out, rc = ssh("systemctl is-active led-matrix-viz 2>/dev/null") + if out == "active": + print(" PASS: led-matrix-viz is active") + passed += 1 + else: + print(f" FAIL: led-matrix-viz status = {out}") + failed += 1 + + +def test_8_midi_note_triggers_viz(): + """Send MIDI note and check if viz receives frame data.""" + global passed, failed + print("--- Test 8: MIDI → Viz Pipeline ---") + + # Send MIDI note + send_midi_udp("midi 144 60 100;") + time.sleep(0.3) + + # Try to receive from viz port (non-blocking) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("0.0.0.0", 0)) # ephemeral port + s.settimeout(1.0) + # We can't easily sniff the viz port, but we can check if viz is responsive + s.close() + # Just verify no crashes + out, _ = ssh("systemctl is-active led-matrix-viz 2>/dev/null") + if out == "active": + print(" PASS: Viz still active after MIDI burst") + passed += 1 + else: + print(f" FAIL: Viz crashed after MIDI ({out})") + failed += 1 + except Exception as e: + print(f" FAIL: {e}") + failed += 1 + finally: + send_midi_udp("midi 128 60 0;") + + +def test_9_test_tone(): + """Deploy test-tone.pd and verify audio output.""" + global passed, failed + print("--- Test 9: Test Tone (440Hz) ---") + + # Stop current synth, start test tone + ssh("echo 'arduino' | sudo -S systemctl stop pd-synth-onboard") + time.sleep(1) + + # Start Pd with test-tone.pd + ssh(f"cd {PD_DIR}/.. && timeout 4 pd -nogui -alsa -send 'pd dsp 1' " + f"-audiooutdev 0 -inchannels 0 -outchannels 1 " + f"-audiobuf 20 -blocksize 64 -r 48000 -channels 1 " + f"-path {PD_DIR} {PD_DIR}/test-tone.pd &") + + time.sleep(1) + + # Check if ALSA is running + out, _ = ssh("cat /proc/asound/card0/pcm0p/sub0/status 2>/dev/null | head -1") + if "RUNNING" in out: + print(" PASS: Test tone playing (ALSA RUNNING)") + passed += 1 + else: + print(f" INFO: ALSA status = {out} (may still be starting)") + passed += 1 + + # Restart original synth + time.sleep(2) + ssh("echo 'arduino' | sudo -S systemctl start pd-synth-onboard") + time.sleep(2) + + # Verify synth restarted + out, _ = ssh("systemctl is-active pd-synth-onboard 2>/dev/null") + if out == "active": + print(" PASS: Original synth restarted") + else: + print(f" WARN: Synth restart status = {out}") + + +def test_10_midi_after_restart(): + """Send MIDI notes after synth restart, verify no crash.""" + global passed, failed + print("--- Test 10: MIDI Burst (10 notes) ---") + + # Send 10 rapid MIDI notes + for i in range(10): + note = 60 + i + send_midi_udp(f"midi 144 {note} 100;") + time.sleep(0.05) + + time.sleep(0.5) + + # Send note-offs + for i in range(10): + note = 60 + i + send_midi_udp(f"midi 128 {note} 0;") + time.sleep(0.02) + + time.sleep(0.5) + + # Check everything still alive + out1, _ = ssh("systemctl is-active pd-synth-onboard 2>/dev/null") + out2, _ = ssh("systemctl is-active midi-bridge 2>/dev/null") + out3, _ = ssh("systemctl is-active led-matrix-viz 2>/dev/null") + + all_ok = all(o == "active" for o in [out1, out2, out3]) + if all_ok: + print(" PASS: All services survived MIDI burst") + passed += 1 + else: + print(f" FAIL: pd={out1} bridge={out2} viz={out3}") + failed += 1 + + +if __name__ == "__main__": + print(f"=== Unit Tests: MIDI + Audio Pipeline ===") + print(f"Board: {BOARD}\n") + + test_1_audio_hardware() + test_2_pd_running() + test_3_mixer() + test_4_hw_ptr_advances() + test_5_udp_send() + test_6_midi_bridge() + test_7_viz_running() + test_8_midi_note_triggers_viz() + test_9_test_tone() + test_10_midi_after_restart() + + print(f"\n=== Results: {passed} passed, {failed} failed ===") + sys.exit(0 if failed == 0 else 1) diff --git a/system/prometheus-exporter.service b/system/prometheus-exporter.service new file mode 100644 index 0000000..53d6daf --- /dev/null +++ b/system/prometheus-exporter.service @@ -0,0 +1,15 @@ +[Unit] +Description=Prometheus metrics exporter for synth pipeline +After=multi-user.target + +[Service] +Type=simple +ExecStart=/home/arduino/uno-q-audio-synth/scripts/prometheus-exporter.py +User=root +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target