#!/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)