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
+271
View File
@@ -0,0 +1,271 @@
package main
# ─── Pd .pd file format validator (OPA v1.x syntax) ───
# Based on puredata.info/docs/developer/PdFileFormat
# OPA version: 1.15.2 (bundled with conftest 0.68.2)
# Input structure (from pd2json parser):
# {
# "x_record_count": N,
# "records": [{ "type": "obj"|"connect"|..., "x_index": N, ... }],
# "parse_errors": [...]
# }
# Get a record by its x_index
record_by_idx(idx) := r if {
some r_rec in input.records
r_rec.x_index == idx
r := r_rec
}
xcount := input.x_record_count
# Check if an object is a signal (~) type
is_signal(name) if {
endswith(name, "~")
}
# ═══════════════════════════════════════════════════════
# VIOLATIONS
# ═══════════════════════════════════════════════════════
# Conftest evaluates rules named `violation`, `violations`, `deny`, `warn`
# Out-of-bounds source index
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.src >= xcount
msg := sprintf("connect[%d]: src index %d out of range (max %d)", [rec.x_index, rec.src, xcount - 1])
}
# Out-of-bounds destination index
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.dst >= xcount
msg := sprintf("connect[%d]: dst index %d out of range (max %d)", [rec.x_index, rec.dst, xcount - 1])
}
# Negative source
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.src < 0
msg := sprintf("connect[%d]: negative src index %d", [rec.x_index, rec.src])
}
# Negative destination
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.dst < 0
msg := sprintf("connect[%d]: negative dst index %d", [rec.x_index, rec.dst])
}
# Negative outlet
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.outlet < 0
msg := sprintf("connect[%d]: negative outlet %d", [rec.x_index, rec.outlet])
}
# Negative inlet
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.inlet < 0
msg := sprintf("connect[%d]: negative inlet %d", [rec.x_index, rec.inlet])
}
# Self-loop
violation contains msg if {
some rec in input.records
rec.type == "connect"
rec.src == rec.dst
msg := sprintf("connect[%d]: self-loop (src=%d dst=%d)", [rec.x_index, rec.src, rec.dst])
}
# Duplicate connections
violation contains msg if {
some a, b in input.records
a.type == "connect"
b.type == "connect"
a.x_index < b.x_index
a.src == b.src
a.outlet == b.outlet
a.dst == b.dst
a.inlet == b.inlet
msg := sprintf("connect[%d]/[%d]: duplicate (%d,%d)->(%d,%d)", [a.x_index, b.x_index, a.src, a.outlet, a.dst, a.inlet])
}
# Signal-to-control connection
violation contains msg if {
some rec in input.records
rec.type == "connect"
src_rec := record_by_idx(rec.src)
dst_rec := record_by_idx(rec.dst)
src_rec.type == "obj"
dst_rec.type == "obj"
is_signal(src_rec.name)
not is_signal(dst_rec.name)
msg := sprintf("connect[%d]: signal (~) outlet %d (%s) to control inlet %d (%s)", [rec.x_index, rec.src, src_rec.name, rec.dst, dst_rec.name])
}
# No canvas definition
violation contains msg if {
not has_canvas
msg := "No #N canvas definition found"
}
has_canvas if {
some rec in input.records
rec.type == "canvas"
}
# Outlet exceeds known count (skip -1 = variable)
violation contains msg if {
some rec in input.records
rec.type == "connect"
src_rec := record_by_idx(rec.src)
src_rec.type == "obj"
known_outlets[src_rec.name]
known_outlets[src_rec.name] != -1
rec.outlet >= known_outlets[src_rec.name]
msg := sprintf("connect[%d]: obj %d (%s) has %d outlets, referenced outlet %d", [rec.x_index, rec.src, src_rec.name, known_outlets[src_rec.name], rec.outlet])
}
# Inlet exceeds known count (skip -1 = variable)
violation contains msg if {
some rec in input.records
rec.type == "connect"
dst_rec := record_by_idx(rec.dst)
dst_rec.type == "obj"
known_inlets[dst_rec.name]
known_inlets[dst_rec.name] != -1
rec.inlet >= known_inlets[dst_rec.name]
msg := sprintf("connect[%d]: obj %d (%s) has %d inlets, referenced inlet %d", [rec.x_index, rec.dst, dst_rec.name, known_inlets[dst_rec.name], rec.inlet])
}
# ═══════════════════════════════════════════════════════
# KNOWLEDGE BASE: inlet/outlet counts by object name
# ═══════════════════════════════════════════════════════
# -1 means variable/unknown
known_inlets := {
# Audio signal objects
"osc~": 1, "phasor~": 1, "saw~": 1, "noise~": 0,
"*~": 2, "+~": 2, "-~": 2, "/~": 2, "pow~": 2,
"lop~": 2, "hip~": 2, "bp~": 3, "vcf~": 3,
"cos~": 1, "wrap~": 1, "abs~": 1,
"clip~": 3, "env~": 4,
"dbtopower~": 1, "powertodb~": 1,
"mtof": 1, "ftom": 1,
"dac~": 2, "adc~": 0,
"sig~": 1, "snapshot~": 1,
"line~": 2, "vline~": 2,
"tabread4~": 2, "tabwrite~": 2, "tabread~": 2,
"send~": 1, "receive~": 1,
"delwrite~": 2, "delread~": 1, "vd~": 2,
"rfft~": 1, "rifft~": 1,
"inlet~": 0, "outlet~": 0,
"block~": 0, "switch~": 1,
# MIDI
"notein": 0, "noteout": 1,
"ctlin": 0, "ctlout": 1,
"midiin": 0, "midiout": 1,
# Data flow
"pack": -1, "unpack": 1,
"makenote": 2, "stripnote": 2,
"route": 1, "select": 1, "spigot": 2,
"trigger": 1, "t": 1,
"float": 1, "f": 1, "symbol": 1, "int": 1,
# Math
"+": 2, "-": 2, "*": 2, "/": 2, "pow": 2,
"div": 2, "mod": 2,
"max": 2, "min": 2,
"moses": 2, "until": 1,
"timer": 1, "random": 1,
# Data
"array": 1, "tabread": 1, "tabwrite": 2,
"text": 1, "textfile": 1,
"value": 0, "v": 0,
"soundfiler": 1,
# GUI
"openpanel": 0, "savepanel": 0,
"cnv": 0, "hsl": 1, "vsl": 1, "hradio": 1, "vradio": 1,
"bng": 0, "tgl": 0, "nbx": 1, "vu": 0,
"print": 1,
# Time
"pipe": 1, "delay": 1, "del": 1,
"metro": 1, "tempo": 1,
"line": 2, "poly": 2,
# Send/receive
"s": 1, "send": 1, "r": 0, "receive": 0,
"netsend": 2, "netreceive": 1,
# Misc
"loadbang": 0, "loadb": 0, "bang": 0,
"inlet": 0, "outlet": 0,
"expr": 1, "clone": 1,
}
# Conftest checks deny, warn, and violation (singular)
known_outlets := {
# Audio signal
"osc~": 1, "phasor~": 1, "saw~": 1, "noise~": 1,
"*~": 1, "+~": 1, "-~": 1, "/~": 1, "pow~": 1,
"lop~": 1, "hip~": 1, "bp~": 1, "vcf~": 1,
"cos~": 1, "wrap~": 1, "abs~": 1,
"clip~": 1, "env~": 1,
"dbtopower~": 1, "powertodb~": 1,
"mtof": 1, "ftom": 1,
"dac~": 0, "adc~": 2,
"sig~": 1, "snapshot~": 1,
"line~": 1, "vline~": 1,
"tabread4~": 1, "tabwrite~": 0, "tabread~": 1,
"send~": 0, "receive~": 1,
"delwrite~": 0, "delread~": 1, "vd~": 1,
"rfft~": 2, "rifft~": 1,
"inlet~": 1, "outlet~": 0,
"block~": 0, "switch~": 0,
# MIDI
"notein": 2, "noteout": 0,
"ctlin": 1, "ctlout": 0,
"midiin": 1, "midiout": 0,
# Data flow
"pack": 1, "unpack": -1,
"makenote": 0, "stripnote": 2,
"route": -1, "select": 1, "spigot": 2,
"trigger": -1, "t": -1,
"float": 1, "f": 1, "symbol": 1, "int": 1,
# Math
"+": 1, "-": 1, "*": 1, "/": 1,
"div": 1, "mod": 1, "pow": 1,
"max": 1, "min": 1,
"moses": 2, "until": 1,
"timer": 2, "random": 1,
# Data
"array": 2, "tabread": 1, "tabwrite": 0,
"text": 2, "textfile": 2,
"value": 1, "v": 1,
"soundfiler": 0,
# GUI
"openpanel": 1, "savepanel": 1,
"cnv": 1,
"hsl": 1, "vsl": 1, "hradio": 1, "vradio": 1,
"bng": 1, "tgl": 1, "nbx": 2, "vu": 0,
"print": 0,
# Time
"pipe": 1, "delay": 1, "del": 1,
"metro": 1, "tempo": 2,
"line": 1, "poly": 1,
# Send/receive
"s": 0, "send": 0, "r": 1, "receive": 1,
"netsend": 0, "netreceive": 1,
# Misc
"loadbang": 1, "loadb": 1, "bang": 1,
"inlet": 0, "outlet": 0,
"expr": 1, "clone": -1,
}
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Parse a .pd file into JSON for Conftest/Rego validation."""
import json, sys, re
from pathlib import Path
def parse_pd(filepath):
with open(filepath) as f:
text = f.read()
lines = text.split("\n")
records = []
x_index = 0
errors = []
for i, raw in enumerate(lines):
line = raw.strip()
if not line or line == ";":
continue
if not line.startswith("#"):
errors.append(f"line {i+1}: expected # prefix")
continue
raw = line[1:].strip()
if not raw.endswith(";"):
errors.append(f"line {i+1}: missing terminating semicolon")
continue
body = raw[:-1].strip()
if line.startswith("#N"):
nbody = body[1:].strip() # remove 'N'
parts = nbody.split(None, 1)
if parts[0] == "canvas":
tokens = parts[1].split()
rec = {
"type": "canvas",
"x": safe_int(tokens[0]),
"y": safe_int(tokens[1]),
"width": safe_int(tokens[2]),
"height": safe_int(tokens[3]),
"name": tokens[4] if len(tokens) > 4 else "",
}
records.append(rec)
else:
records.append({"type": "new", "data": rest})
elif line.startswith("#A"):
values = [safe_float(v) for v in rest.split()]
records.append({"type": "array_data", "values": values})
elif line.startswith("#X connect"):
xbody = body[1:].strip()
elems = xbody.split()
if len(elems) >= 5:
rec = {
"type": "connect",
"x_index": x_index,
"src": safe_int(elems[1]),
"outlet": safe_int(elems[2]),
"dst": safe_int(elems[3]),
"inlet": safe_int(elems[4]),
}
records.append(rec)
# Connect records do NOT increment Pd's object index
elif line.startswith("#X obj"):
xbody = body[1:].strip()
# xbody = "obj 20 20 notein"
elem, x_str, y_str, rest = xbody.split(None, 3)
obj_parts = rest.split()
rec = {
"type": "obj",
"x_index": x_index,
"x": safe_int(x_str),
"y": safe_int(y_str),
"name": obj_parts[0] if obj_parts else "",
"args": obj_parts[1:],
}
records.append(rec)
x_index += 1
elif line.startswith("#X msg"):
xbody = body[1:].strip()
elem, x_str, y_str, content = xbody.split(None, 3)
records.append({
"type": "msg",
"x_index": x_index,
"x": safe_int(x_str),
"y": safe_int(y_str),
"content": content,
})
x_index += 1
elif line.startswith("#X floatatom"):
xbody = body[1:].strip()
elems = xbody.split()
rec = {"type": "floatatom", "x_index": x_index, "x": safe_int(elems[1]), "y": safe_int(elems[2])}
if len(elems) > 3: rec["width"] = safe_int(elems[3])
if len(elems) > 4: rec["min"] = safe_float(elems[4])
if len(elems) > 5: rec["max"] = safe_float(elems[5])
records.append(rec)
x_index += 1
elif line.startswith("#X symbolatom"):
records.append({"type": "symbolatom", "x_index": x_index})
x_index += 1
elif line.startswith("#X text"):
xbody = body[1:].strip()
records.append({"type": "text", "x_index": x_index, "content": xbody})
x_index += 1
elif line.startswith("#X restore"):
xbody = body[1:].strip()
records.append({"type": "restore", "x_index": x_index, "data": xbody})
x_index += 1
elif line.startswith("#X coords"):
xbody = body[1:].strip()
records.append({"type": "coords", "x_index": x_index, "data": xbody})
x_index += 1
elif line.startswith("#X array"):
xbody = body[1:].strip()
records.append({"type": "array", "x_index": x_index, "data": xbody})
x_index += 1
else:
errors.append(f"line {i+1}: unknown record type: {line[:60]}")
return {
"file": str(filepath),
"x_record_count": x_index,
"records": records,
"parse_errors": errors,
}
def safe_int(s):
try:
# Handle Pd special values like "-" for empty
if s == "-":
return 0
return int(s)
except (ValueError, IndexError):
return 0
def safe_float(s):
try:
if s == "-":
return 0.0
return float(s)
except (ValueError, IndexError):
return 0.0
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: pd2json.py <file.pd> [file2.pd ...]")
sys.exit(1)
results = []
for arg in sys.argv[1:]:
results.append(parse_pd(arg))
print(json.dumps(results if len(results) > 1 else results[0], indent=2))
+14
View File
@@ -0,0 +1,14 @@
#N canvas 0 0 400 300 10;
#X obj 20 20 notein;
#X obj 80 20 osc~;
#X connect 0 0 1 0;
#X connect 0 1 1 1;
#X connect 0 0 1 0;
#X connect 99 0 1 0;
#X connect 0 0 99 0;
#X connect 0 0 0 0;
#X connect 0 -1 1 0;
#X obj 20 100 dac~;
#X connect 1 99 3 0;
#X connect 1 0 3 99;
#X connect 1 0 3 0;
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Validate .pd files using Conftest/OPA Rego policies."""
import json, sys, subprocess, tempfile, os, glob
from pathlib import Path
from pd2json import parse_pd
def main():
policy_dir = Path(__file__).parent
# Find conftest/opa (check mise install dirs first)
mise_dir = os.path.expanduser("~/.local/share/mise/installs")
for tool in ["conftest", "opa"]:
for ver_dir in sorted(glob.glob(os.path.join(mise_dir, tool, "*")), reverse=True):
for p in [os.path.join(ver_dir, tool), os.path.join(ver_dir, "bin", tool)]:
if os.path.isfile(p):
os.environ["PATH"] = os.path.dirname(p) + ":" + os.environ.get("PATH", "")
break
else:
continue
break
runner = None
for cmd in ["conftest", "opa"]:
try:
subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
runner = cmd
break
except (FileNotFoundError, subprocess.TimeoutExpired):
continue
if not runner:
print("ERROR: neither 'conftest' nor 'opa' found in PATH.")
print("Install: pip install conftest or https://www.conftest.dev/")
sys.exit(1)
pd_files = sys.argv[1:] or list(Path(".").glob("*.pd"))
if not pd_files:
print("Usage: validate.py <file.pd> [file2.pd ...]")
sys.exit(1)
all_passed = True
for pf in pd_files:
pf = Path(pf)
data = parse_pd(pf)
if runner == "conftest":
result = run_conftest(data, policy_dir)
else:
result = run_opa(data, policy_dir)
if result["passed"]:
print(f"{pf.name} — all checks passed")
else:
all_passed = False
print(f"{pf.name}{len(result['violations'])} violation(s):")
for v in result["violations"]:
print(f"{v}")
sys.exit(0 if all_passed else 1)
def run_conftest(data, policy_dir):
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
json.dump(data, f)
json_path = f.name
try:
result = subprocess.run(
["conftest", "test", "--policy", str(policy_dir), json_path],
capture_output=True, text=True, timeout=30
)
out = result.stdout + result.stderr
violations = []
for l in out.split("\n"):
if "FAIL" in l or "failed" in l.lower():
violations.append(l.strip())
# Also parse structured output if json
if result.stdout and not violations:
try:
import json as j
report = j.loads(result.stdout)
for fname, checks in report.get("failures", {}).items():
for c in checks:
violations.append(c)
except:
pass
return {"passed": result.returncode == 0, "violations": violations}
finally:
os.unlink(json_path)
def run_opa(data, policy_dir):
pkg = "main"
query = "data.main.violations"
with tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) as f:
json.dump(data, f)
json_path = f.name
try:
result = subprocess.run(
["opa", "eval", "-d", str(policy_dir), "-d", json_path, "--format", "json", query],
capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
return {"passed": False, "violations": [result.stderr.strip()]}
out = json.loads(result.stdout)
violations = []
for expr in out.get("result", []):
for binding in expr.get("expressions", []):
vals = binding.get("value", [])
if isinstance(vals, list):
violations.extend(vals)
return {"passed": len(violations) == 0, "violations": violations}
finally:
os.unlink(json_path)
if __name__ == "__main__":
main()