Initial commit: Arduino Uno Q USB Audio Synth project

- USB gadget (UAC1 + MIDI) with boot persistence
- USB host mode with auto-detection
- Pure Data synth patch with ALSA + UDP netreceive MIDI input
- MIDI rawmidi-to-UDP bridge (midi-bridge.py)
- LED matrix visualization (led-matrix-viz.py via arduino-router RPC)
- MCU sketch for LED matrix control (Arduino_RouterBridge + ArduinoGraphics)
- Conftest/OPA Rego policy validation suite for .pd files
- gen_synth_pd.py: programmatic .pd file generator
- System services: midi-bridge, led-matrix-viz, pd-synth-auto, usb-role-detect
- Justfile with deploy, enable, diagnostic recipes
- Diagnostic script (diag.sh)
This commit is contained in:
2026-06-22 22:46:36 -07:00
commit 8cd796e3dd
38 changed files with 2681 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# diag.sh — run on the board for diagnostics
set -e
echo "--- System ---"
echo " Kernel: $(uname -r)"
echo " Uptime: $(uptime -p | sed 's/up //')"
echo " Load: $(cat /proc/loadavg | cut -d' ' -f1-3)"
echo " Memory: $(free -h | awk '/^Mem:/{print $3 "/" $2}')"
echo " Disk: $(df -h / | awk 'NR==2{print $3 "/" $2 " (" $5 ")"}')"
echo ""
echo "--- USB Gadget (configfs) ---"
UDC=$(cat /sys/kernel/config/usb_gadget/g1/UDC 2>/dev/null || echo '(not bound)')
echo " UDC bound: $UDC"
for f in /sys/kernel/config/usb_gadget/g1/functions/*/; do
echo " Function: $(basename "$f")"
done
for c in /sys/kernel/config/usb_gadget/g1/configs/*/; do
cfg=$(basename "$c")
for l in "$c"/*.*/; do
[ -L "$l" ] && echo " linked: $(basename "$l") -> config $cfg"
done
done
echo ""
echo "--- UDC Hardware ---"
for u in /sys/class/udc/*/; do
name=$(basename "$u")
echo " Controller: $name"
echo " state: $(cat "$u/state" 2>/dev/null)"
echo " speed: $(cat "$u/current_speed" 2>/dev/null)"
echo " max_speed: $(cat "$u/maximum_speed" 2>/dev/null)"
done
role=$(cat /sys/class/usb_role/*/role 2>/dev/null || echo '(none)')
echo " USB role: $role"
echo " Role sw: $(ls /sys/class/usb_role/ 2>/dev/null | tr '\n' ' ')"
echo ""
echo "--- USB Audio Params ---"
for p in /sys/kernel/config/usb_gadget/g1/functions/uac1.gs0/p_* /sys/kernel/config/usb_gadget/g1/functions/uac1.gs0/c_*; do
name=$(basename "$p")
echo " $name: $(cat "$p" 2>/dev/null)"
done
echo ""
echo "--- USB MIDI Params ---"
for p in in_ports out_ports index; do
val=$(cat /sys/kernel/config/usb_gadget/g1/functions/midi.gs0/$p 2>/dev/null)
echo " $p: $val"
done
echo ""
echo "--- Modules ---"
lsmod | awk '$1 ~ /^(usb_f_|u_audio|libcomposite|snd_usb|snd_rawmidi|snd_soc|dwc3)/ {print " " $0}'
for m in usb_f_uac1 usb_f_midi dwc3-qcom-legacy; do
if lsmod 2>/dev/null | grep -q "^$m "; then
echo " $m: LOADED"
else
echo " $m: not loaded"
fi
done
echo ""
echo "--- ALSA Cards ---"
cat /proc/asound/cards
echo ""
aplay -l 2>/dev/null | awk '{print " " $0}'
echo ""
arecord -l 2>/dev/null | awk '{print " " $0}'
echo ""
echo "--- ALSA MIDI ---"
echo " Raw MIDI devices:"
ls /dev/snd/midi* 2>/dev/null | awk '{print " " $0}' || echo " (none)"
echo " ALSA sequencer:"
aconnect -lio 2>/dev/null | awk '{print " " $0}' || echo " (none)"
echo " amidi ports:"
amidi -l 2>/dev/null | awk '{print " " $0}' || echo " (none)"
echo ""
echo "--- Pd Status ---"
pd_pid=$(pgrep -x pd 2>/dev/null || echo "")
if [ -n "$pd_pid" ]; then
echo " PID: $pd_pid"
echo " Args: $(tr '\0' ' ' < /proc/$pd_pid/cmdline 2>/dev/null)"
echo " Uptime: $(ps -o etime= -p $pd_pid 2>/dev/null | xargs)"
echo " CPU: $(ps -o %cpu= -p $pd_pid 2>/dev/null | xargs)%"
echo " MEM: $(ps -o %mem= -p $pd_pid 2>/dev/null | xargs)%"
else
echo " Pd: not running"
fi
for svc in pd-synth-auto pd-synth pd-synth-i2s usb-role-detect usb-gadget-audio; do
state=$(systemctl is-active "$svc" 2>/dev/null || echo '(not found)')
enabled=$(systemctl is-enabled "$svc" 2>/dev/null || echo '(not found)')
echo " $svc: $state (enabled: $enabled)"
done
echo ""
echo "--- dmesg (USB/gadget errors) ---"
dmesg -l err,warn 2>/dev/null | grep -iE 'usb|gadget|uac1|midi|pd|audio|dwc3' | tail -20 | awk '{print " " $0}'
echo ""
dmesg 2>/dev/null | grep -i 'failed to start\|incorrect sample\|couldn.t find.*UDC\|probe.*failed' | tail -5 | awk '{print " " $0}'
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate synth.pd programmatically."""
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
OUTPUT = SCRIPT_DIR.parent / "pd" / "synth.pd"
def connect(c, src, sout, dst, din):
c.append((src, sout, dst, din))
def obj(o, x, y, *args):
idx = len(o)
o.append((x, y, "obj", *args))
return idx
def gen():
o = [] # objects
c = [] # connections
def Obj(x, y, *args):
return obj(o, x, y, *args)
# ── ALSA MIDI input (gadget mode) ──
notein = Obj(20, 20, "notein")
pack_note = Obj(80, 50, "pack 0 0")
unpack_note = Obj(80, 80, "unpack 0 0")
s_pitch_n = Obj(20, 110, "s midi-pitch")
s_vel_n = Obj(80, 110, "s midi-vel")
connect(c, notein, 0, pack_note, 0)
connect(c, notein, 1, pack_note, 1)
connect(c, pack_note, 0, unpack_note, 0)
connect(c, unpack_note, 0, s_pitch_n, 0)
connect(c, unpack_note, 1, s_vel_n, 0)
# ── UDP MIDI input (host mode via midi-bridge) ──
net = Obj(400, 20, "netreceive -u 8081")
route_midi = Obj(400, 60, "route midi")
route_note = Obj(400, 100, "route 144 128")
unpack2_on = Obj(400, 140, "unpack 0 0")
unpack2_off = Obj(480, 140, "unpack 0 0")
s_pitch_u = Obj(400, 180, "s midi-pitch")
s_vel_u = Obj(480, 180, "s midi-vel")
connect(c, net, 0, route_midi, 0)
connect(c, route_midi, 0, route_note, 0)
connect(c, route_note, 0, unpack2_on, 0)
connect(c, route_note, 1, unpack2_off, 0)
connect(c, unpack2_on, 0, s_pitch_u, 0)
connect(c, unpack2_on, 1, s_vel_u, 0)
connect(c, unpack2_off, 0, s_pitch_u, 0)
connect(c, unpack2_off, 1, s_vel_u, 0)
# ── Synth voice ──
r_vel = Obj(20, 160, "r midi-vel")
r_pitch = Obj(20, 190, "r midi-pitch")
strip = Obj(20, 220, "stripnote")
mtof = Obj(20, 260, "mtof")
env = Obj(20, 290, "env~ 50 200 0.7 400")
osc1 = Obj(140, 260, "osc~")
mul1 = Obj(140, 290, "*~ 0.3")
osc2 = Obj(220, 260, "osc~")
mul2 = Obj(220, 290, "*~ 0.3")
sum = Obj(180, 330, "+~")
scale = Obj(180, 360, "*~ 0.5")
filt = Obj(180, 400, "lop~")
amp = Obj(180, 460, "*~")
panscale = Obj(320, 460, "*~")
dac = Obj(250, 530, "dac~")
connect(c, r_vel, 0, strip, 1)
connect(c, r_pitch, 0, strip, 0)
connect(c, strip, 0, mtof, 0)
connect(c, mtof, 0, osc1, 0)
connect(c, mtof, 0, osc2, 0)
connect(c, osc1, 0, mul1, 0)
connect(c, osc2, 0, mul2, 0)
connect(c, mul1, 0, sum, 0)
connect(c, mul2, 0, sum, 1)
connect(c, sum, 0, scale, 0)
connect(c, scale, 0, filt, 0)
connect(c, filt, 0, amp, 0)
connect(c, strip, 0, env, 0)
connect(c, env, 0, amp, 1)
connect(c, amp, 0, panscale, 0)
# ── Controls ──
cutoff = Obj(350, 330, "hsl 128 15 100 18000 10000 0 0 0 empty Cutoff -2 -8 0 10 -262144 -1 -1 7200 1")
volume = Obj(350, 430, "hsl 128 15 0 100 40 0 0 0 empty Volume -2 -8 0 10 -262144 -1 -1 7200 1")
div = Obj(350, 460, "/ 100")
connect(c, cutoff, 0, filt, 1)
connect(c, volume, 0, div, 0)
connect(c, div, 0, panscale, 1)
# ── Output ──
connect(c, panscale, 0, dac, 0)
connect(c, panscale, 0, dac, 1)
# ── Write ──
lines = ["#N canvas 0 0 900 550 10;"]
for x, y, t, *args in o:
joined = " ".join(str(a) for a in args)
lines.append(f"#X obj {x} {y} {joined};")
for src, sout, dst, din in c:
lines.append(f"#X connect {src} {sout} {dst} {din};")
lines.append("")
return "\n".join(lines)
if __name__ == "__main__":
content = gen()
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(content)
print(f"Wrote {OUTPUT}")
# Print connections for debugging
for line in content.splitlines():
if line.startswith("#X connect"):
print(f" {line}")
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""Listen for MIDI events via UDP and display note activity on LED matrix.
Uses RPC requests (not notifications) for reliability, with rate limiting.
"""
import socket, msgpack, time, os, re
VIZ_PORT = int(os.environ.get("VIZ_PORT", "8082"))
ROUTER_SOCK = os.environ.get("ROUTER_SOCK", "/var/run/arduino-router.sock")
DECAY_FRAMES = int(os.environ.get("DECAY_FRAMES", "8"))
MIN_INTERVAL = 1 / 15
COLS = 13
ROWS = 8
def pack_frame(pixels):
bits = 0
for row in range(ROWS):
for col in range(COLS):
if pixels[row][col]:
bits |= 1 << (row * COLS + col)
return (bits & 0xFFFFFFFF, (bits >> 32) & 0xFFFFFFFF,
(bits >> 64) & 0xFFFFFFFF, (bits >> 96) & 0xFFFFFFFF)
def main():
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("0.0.0.0", VIZ_PORT))
sock.setblocking(False)
router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
router.settimeout(2)
router.connect(ROUTER_SOCK)
packer = msgpack.Packer()
active_notes = {}
last_frame = None
last_send = 0
buf = b""
msg_id = 0
while True:
now = time.monotonic()
try:
while True:
data, _ = sock.recvfrom(1024)
buf += data
except BlockingIOError:
pass
while b";" in buf:
msg, buf = buf.split(b";", 1)
msg = msg.decode().strip()
parts = msg.split()
if len(parts) >= 3 and parts[0] == "midi":
status = int(parts[1])
note = int(parts[2])
vel = int(parts[3]) if len(parts) >= 4 else 0
msg_type = status & 0xF0
if msg_type == 0x90 and vel > 0:
active_notes[note] = DECAY_FRAMES
elif msg_type == 0x80 or (msg_type == 0x90 and vel == 0):
active_notes.pop(note, None)
if now - last_send >= MIN_INTERVAL:
pixels = [[0] * COLS for _ in range(ROWS)]
for note, decay in list(active_notes.items()):
if decay <= 0:
del active_notes[note]
continue
active_notes[note] = decay - 1
note_min, note_max = 36, 96
row = 7 - int((note - note_min) / (note_max - note_min) * 7)
row = max(0, min(7, row))
col = note % 12
pixels[row][col] = 1
frame = pack_frame(pixels)
if frame != last_frame:
msg_id = (msg_id + 1) % 65535
params = [str(v) for v in frame]
req = packer.pack([0, msg_id, "draw_frame", params])
try:
router.sendall(req)
router.settimeout(0.3)
router.recv(128)
router.settimeout(2)
except socket.timeout:
pass
except OSError:
router.close()
router = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
router.settimeout(2)
router.connect(ROUTER_SOCK)
last_frame = frame
last_send = now
if __name__ == "__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Bridge raw MIDI device to Pd via UDP. For host mode with MPK Mini.
Sends parsed MIDI messages as Pd-style 'midi status data1 data2;' strings
to UDP ports for Pd (8081) and LED matrix visualization (8082).
"""
import os, socket, time, select, struct
MIDI_DEV = os.environ.get("MIDI_BRIDGE_DEV", "/dev/snd/midiC0D0")
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"))
def midi_msg(status, data1, data2=None):
if data2 is not None:
return f"midi {status} {data1} {data2};"
return f"midi {status} {data1};"
def main():
while not os.path.exists(MIDI_DEV):
time.sleep(0.5)
fd = os.open(MIDI_DEV, os.O_RDWR | os.O_NONBLOCK)
os.set_blocking(fd, False)
poll = select.poll()
poll.register(fd, select.POLLIN)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
running = True
buf = bytearray()
while running:
try:
events = poll.poll(500)
for f, flags in events:
if flags & select.POLLIN:
data = os.read(fd, 1024)
if not data:
continue
for byte in data:
if byte & 0x80:
if len(buf) > 0 and buf[0] >= 0x80:
_flush(sock, buf, PD_HOST, PD_PORT, VIZ_PORT)
buf = bytearray([byte])
else:
buf.append(byte)
if len(buf) >= 3 and (buf[0] & 0xE0) in (0x80, 0x90, 0xA0, 0xB0, 0xE0):
_flush(sock, buf, PD_HOST, PD_PORT, VIZ_PORT)
buf = bytearray()
elif len(buf) >= 2 and (buf[0] & 0xE0) in (0xC0, 0xD0):
_flush(sock, buf, PD_HOST, PD_PORT, VIZ_PORT)
buf = bytearray()
except (BlockingIOError, OSError) as e:
if isinstance(e, OSError) and e.errno == 19:
time.sleep(1)
continue
if not isinstance(e, BlockingIOError):
raise
except KeyboardInterrupt:
running = False
os.close(fd)
sock.close()
def _flush(sock, buf, pd_host, pd_port, viz_port):
status = buf[0]
if (buf[0] & 0xF0) == 0xF0:
return
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()
+152
View File
@@ -0,0 +1,152 @@
#!/bin/bash
set -euo pipefail
PD_DIR="/home/arduino/pd"
PATCH="$PD_DIR/synth.pd"
MODE="usb"
VERBOSE=false
die() { echo "[ERROR] $*" >&2; exit 1; }
info() { echo "[INFO] $*"; }
while [ $# -gt 0 ]; do
case "$1" in
-a|--auto) MODE="auto"; shift ;;
-i|--i2s) MODE="i2s"; shift ;;
-l|--loopback) MODE="loopback"; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-k|--kill) pkill -x pd 2>/dev/null && info "Pd stopped" || info "Pd not running"; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
if ! command -v pd &>/dev/null; then
die "Pure Data not found. Install: apt install puredata"
fi
if [ ! -f "$PATCH" ]; then
die "Patch not found: $PATCH"
fi
# --- Auto-detect mode ---
if [ "$MODE" = "auto" ]; then
if [ -f /run/usb-role ]; then
ROLE=$(cat /run/usb-role)
else
ROLE=$(/usr/local/bin/detect-usb-role.sh 2>/dev/null && cat /run/usb-role || echo "gadget")
fi
case "$ROLE" in
gadget)
MODE="usb"
if ! aplay -l 2>/dev/null | grep -q "UAC1Gadget"; then
info "Configuring USB gadget..."
/usr/local/bin/configure-usb-audio.sh start 2>/dev/null || true
sleep 1
fi
;;
host)
if [ -f /run/usb-audio-dev ] && [ -s /run/usb-audio-dev ]; then
ALSA_DEV=$(cat /run/usb-audio-dev)
else
info "Detecting USB audio interface..."
/usr/local/bin/configure-usb-host.sh 2>/dev/null || {
die "No USB audio interface found. Plug one in via hub."
}
ALSA_DEV=$(cat /run/usb-audio-dev)
fi
info "Mode: USB host (MIDI controller + USB audio interface)"
MIDI_OPTS=(-alsamidi)
midi_dev=$(amidi -l 2>/dev/null | grep -v "^Dir" | head -1 | awk '{print $2}' | sed 's/\(hw:[0-9]*,[0-9]*\).*/\1/')
if [ -n "$midi_dev" ]; then
MIDI_OPTS+=(-midiadddev "$midi_dev")
info " MIDI device: $midi_dev"
fi
AUDIO_OPTS=(-audiooutdev 4 -audioindev 4)
PD_ARGS=(
-nogui -alsa
-send "pd dsp 1"
"${MIDI_OPTS[@]}"
"${AUDIO_OPTS[@]}"
-audiobuf 20 -blocksize 64 -r 48000
-channels 2 -inchannels 0 -outchannels 2
-path "$PD_DIR" "$PATCH"
)
if [ "$VERBOSE" = true ]; then pd "${PD_ARGS[@]}"
else pd "${PD_ARGS[@]}" > /dev/null 2>&1 &
PID=$!; info "Pd started (PID: $PID)"; info " Stop: kill $PID"
fi
exit 0
;;
*)
info "Auto-detect: unknown role '$ROLE', falling back to gadget"
MODE="usb"
;;
esac
fi
# --- Configure per-mode options ---
AUDIO_OPTS=()
MIDI_OPTS=(-alsamidi)
if [ "$MODE" = "usb" ]; then
info "Mode: USB gadget (UAC1 audio + gmidi MIDI)"
AUDIO_OPTS=(-audiooutdev 2 -audioindev 2)
if aplay -l 2>/dev/null | grep -q "UAC1Gadget"; then
info " USB gadget: OK"
midi_dev=$(amidi -l 2>/dev/null | grep "f_midi" | head -1 | awk '{print $2}')
if [ -n "$midi_dev" ]; then
MIDI_OPTS+=(-midiadddev "$midi_dev")
info " MIDI device: $midi_dev"
fi
else
info " USB gadget: NOT DETECTED (kernel modules needed)"
fi
fi
if [ "$MODE" = "i2s" ]; then
if [ -f /run/i2s-audio-dev ] && [ -s /run/i2s-audio-dev ]; then
ALSA_DEV=$(cat /run/i2s-audio-dev)
else
for dev in "hw:I2S,0" "hw:MI2S,0" "hw:0,0"; do
if aplay -l 2>/dev/null | grep -qi "${dev%,*}"; then
ALSA_DEV="$dev"
break
fi
done
fi
info "Mode: I2S DAC (standalone analog audio)"
AUDIO_OPTS=(-audioadddev "$ALSA_DEV")
fi
if [ "$MODE" = "loopback" ]; then
info "Mode: ALSA loopback (testing)"
AUDIO_OPTS=(-audiodev 2)
fi
PD_ARGS=(
-nogui
-alsa
"${MIDI_OPTS[@]}"
"${AUDIO_OPTS[@]}"
-send "pd dsp 1"
-audiobuf 20
-blocksize 64
-r 48000
-channels 1
-inchannels 0
-outchannels 1
-path "$PD_DIR"
"$PATCH"
)
if [ "$VERBOSE" = true ]; then
pd "${PD_ARGS[@]}" &
else
pd "${PD_ARGS[@]}" > /dev/null 2>&1 &
fi
PID=$!
info "Pd started (PID: $PID)"
info " Stop: kill $PID"
wait $PID
info "Pd exited (code: $?)"