From 8cd796e3dd53d6c492b01f4e2a718074c434b921 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 22 Jun 2026 22:46:36 -0700 Subject: [PATCH] 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) --- .arduino_config.lua | 5 + .clang-format | 20 ++ .gitignore | 7 + .markdownlint.json | 5 + README.md | 157 +++++++++++ install.sh | 27 ++ justfile | 448 +++++++++++++++++++++++++++++++ mise.toml | 14 + pd-validator/pd.rego | 271 +++++++++++++++++++ pd-validator/pd2json.py | 161 +++++++++++ pd-validator/test_bad.pd | 14 + pd-validator/validate.py | 121 +++++++++ pd/controller.pd | 79 ++++++ pd/synth.pd | 64 +++++ scripts/diag.sh | 103 +++++++ scripts/gen_synth_pd.py | 115 ++++++++ scripts/led-matrix-viz.py | 100 +++++++ scripts/midi-bridge.py | 76 ++++++ scripts/start-synth.sh | 152 +++++++++++ sketch/led-matrix-bridge.ino | 125 +++++++++ sketch/sketch.yaml | 9 + system/99-usb-role.rules | 15 ++ system/asound-i2s.conf | 68 +++++ system/asound.conf | 44 +++ system/configure-usb-audio.sh | 132 +++++++++ system/configure-usb-host.sh | 41 +++ system/detect-i2s-device.service | 11 + system/detect-i2s-device.sh | 35 +++ system/detect-usb-role.sh | 94 +++++++ system/led-matrix-viz.service | 15 ++ system/midi-bridge.service | 14 + system/on-usb-role-change.sh | 26 ++ system/pd-synth-auto.service | 17 ++ system/pd-synth-i2s.service | 16 ++ system/pd-synth.service | 16 ++ system/reset-usb-gadget.sh | 38 +++ system/usb-gadget-audio.service | 12 + system/usb-role-detect.service | 14 + 38 files changed, 2681 insertions(+) create mode 100644 .arduino_config.lua create mode 100644 .clang-format create mode 100644 .gitignore create mode 100644 .markdownlint.json create mode 100644 README.md create mode 100755 install.sh create mode 100644 justfile create mode 100644 mise.toml create mode 100644 pd-validator/pd.rego create mode 100644 pd-validator/pd2json.py create mode 100644 pd-validator/test_bad.pd create mode 100644 pd-validator/validate.py create mode 100644 pd/controller.pd create mode 100644 pd/synth.pd create mode 100644 scripts/diag.sh create mode 100644 scripts/gen_synth_pd.py create mode 100644 scripts/led-matrix-viz.py create mode 100644 scripts/midi-bridge.py create mode 100755 scripts/start-synth.sh create mode 100644 sketch/led-matrix-bridge.ino create mode 100644 sketch/sketch.yaml create mode 100644 system/99-usb-role.rules create mode 100644 system/asound-i2s.conf create mode 100644 system/asound.conf create mode 100755 system/configure-usb-audio.sh create mode 100755 system/configure-usb-host.sh create mode 100644 system/detect-i2s-device.service create mode 100644 system/detect-i2s-device.sh create mode 100755 system/detect-usb-role.sh create mode 100644 system/led-matrix-viz.service create mode 100644 system/midi-bridge.service create mode 100755 system/on-usb-role-change.sh create mode 100644 system/pd-synth-auto.service create mode 100644 system/pd-synth-i2s.service create mode 100644 system/pd-synth.service create mode 100644 system/reset-usb-gadget.sh create mode 100644 system/usb-gadget-audio.service create mode 100644 system/usb-role-detect.service diff --git a/.arduino_config.lua b/.arduino_config.lua new file mode 100644 index 0000000..eb15a04 --- /dev/null +++ b/.arduino_config.lua @@ -0,0 +1,5 @@ +local M = {} +M.board = 'arduino:qualcomm:uno_q' +M.port = '/dev/ttyACM0' +M.baudrate = 115200 +return M diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..937f89a --- /dev/null +++ b/.clang-format @@ -0,0 +1,20 @@ +# Arduino/C++ friendly format +BasedOnStyle: WebKit + +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +IndentCaseLabels: false + +BreakBeforeBraces: Attach + +SpaceBeforeParens: Never +SpaceAfterCStyleCast: false +PointerAlignment: Left + +ColumnLimit: 100 + +SortIncludes: true +IncludeBlocks: Regroup + +KeepEmptyLinesAtTheStartOfBlocks: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d994db2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.swp +.DS_Store +*.log +/tmp/ +build/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..f283069 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD013": false, + "MD060": false, + "MD025": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd76456 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# Arduino Uno Q — USB Audio Synth + +A 2-oscillator subtractive synthesizer running Pure Data on the Arduino Uno Q. +**Auto-detects** its USB role at boot and works in three modes — no config switching needed. + +## Modes + +| Mode | Connection | Audio Out | MIDI In | Use Case | +|------|-----------|-----------|---------|----------| +| **Gadget** | USB-C → Host computer | UAC1 gadget (virtual sound card) | gmidi gadget (over same cable) | Plug-and-play with DAWs | +| **Host** | USB hub → MIDI controller + USB audio interface | USB audio interface (your interface) | MIDI controller via hub | Standalone jamming | +| **I2S DAC** | I2S DAC on GPIO pins (e.g. PCM5102) | Analog line out via DAC | USB MIDI controller (gadget) | Embedded installs | + +## Auto-Detection + +At boot, `detect-usb-role.sh` probes the USB controller to determine the role: + +``` +USB cable plugged into Host? ─→ gadget mode (UAC1 + gmidi) +USB device plugged into Uno? ─→ host mode (drives USB audio interface + MIDI) +Neither? ─→ defaults to gadget (safe fallback) +``` + +A udev rule (`99-usb-role.rules`) re-runs detection on hotplug events and +restarts Pd with the new config — unplug from computer and plug into a hub, +it just works. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ GADGET MODE │ +│ [Host DAW] ←USB-C→ ┌──────────┐ │ +│ │ gmidi │←─ ALSA seq ──→ Pd │ +│ │ UAC1 │── ALSA pcm ──→ Pd │ +│ └──────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ HOST MODE │ +│ [MIDI Ctrl] ─┐ │ +│ [USB Audio] ─┼─USB hub─→ ┌──────────┐ │ +│ │ │ USB host │ │ +│ └──────────→ │ driver │──→ Pd │ +│ └──────────┘ │ +├─────────────────────────────────────────────────────────┤ +│ I2S DAC MODE │ +│ [PCM5102] ←GPIO(I2S)→ ALSA I2S driver ──→ Pd │ +└─────────────────────────────────────────────────────────┘ +``` + +## Project Structure + +``` +uno-q-audio-synth/ +├── pd/ +│ └── synth.pd # Pure Data synthesizer patch +├── system/ +│ ├── configure-usb-audio.sh # UAC1 + gmidi gadget setup +│ ├── configure-usb-host.sh # Host-mode USB audio discovery +│ ├── detect-usb-role.sh # Boot-time USB role detection +│ ├── detect-i2s-device.sh # I2S device auto-detection +│ ├── on-usb-role-change.sh # udev hotplug handler +│ ├── asound.conf # ALSA config (gadget mode) +│ ├── asound-i2s.conf # ALSA config (I2S DAC mode) +│ ├── 99-usb-role.rules # udev rules for hotplug +│ ├── usb-role-detect.service # Early-boot role detection +│ ├── usb-gadget-audio.service # Gadget-only service +│ ├── pd-synth-auto.service # Unified auto-detect service +│ ├── pd-synth.service # Gadget-only Pd service +│ ├── pd-synth-i2s.service # I2S-only Pd service +│ └── detect-i2s-device.service # I2S probe service +├── scripts/ +│ └── start-synth.sh # Manual dev startup (all modes) +├── install.sh # Board-side dependency install +├── justfile # Task runner +└── mise.toml # Tool version management +``` + +## Quick Start + +### Deploy to board + +```bash +export BOARD_HOST=uno-q.local # or IP if mDNS not working + +just deploy # copy all files +just enable-auto # enable auto-detect mode +just reboot # apply +``` + +Once booted, plug into a computer — it appears as "Uno Q Audio Synth" with +audio sink + MIDI port. Or plug a USB hub with MIDI controller + audio +interface for standalone play. + +### Development cycle + +```bash +just cycle # deploy patch + restart Pd +just log # tail Pd logs +just status # role, ALSA cards, MIDI ports +``` + +### Manual testing + +```bash +just test-audio # test gadget audio out +just test-audio-host # test host-mode USB audio interface +just run-remote # run Pd in foreground with auto-detect +``` + +## I2S DAC Wiring + +Add a DAC later without reconfiguring. Common I2S DAC modules: + +| DAC | Pins | Voltage | +|-----|------|---------| +| PCM5102 | BCK, DIN, LCK, 5V, GND | 3.3V–5V | +| MAX98357 (speaker amp) | BCLK, DIN, LRCLK, Vin, GND | 2.7V–5.5V | +| PCM5242 (balanced out) | I2S + I2C config | 3.3V | + +Typical PCM5102 wiring to Uno Q GPIO: + +``` +PCM5102 → Uno Q GPIO +VIN → 5V +GND → GND +BCK → I2S_BCK (GPIO pin — check board docs) +DIN → I2S_DIN +LCK → I2S_LCK +``` + +Activate I2S mode: `just enable-i2s && just reboot` + +## Controls (MIDI) + +| Control | Mapping | +|---------|---------| +| MIDI Note On/Off | Play notes | +| Velocity | Volume | +| CC 74 | Filter cutoff | +| GUI sliders (Pd) | Detune, volume, ADSR | + +## Requirements (on the Uno Q) + +- Pure Data (`apt install puredata`) +- ALSA utilities (`apt install alsa-utils`) +- Linux kernel with UAC + gmidi gadget support (Qualcomm DragonWing) + +## Pd Patch: synth.pd + +- **OSC 1**: Saw wave, direct pitch +- **OSC 2**: Saw wave, detunable (±50 cents) +- **Mix**: Balanced stereo mix +- **Filter**: Low-pass (lop~) with adjustable cutoff +- **Envelope**: env~ (attack, decay, sustain, release) +- **Output**: Stereo 48kHz 16-bit via dac~ + +Open in Pd GUI to adjust slider positions and save. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..cb025b7 --- /dev/null +++ b/install.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# install.sh — Install system dependencies on the Uno Q +# Run this on the board: ssh arduino@uno-q.local 'bash -s' < install.sh + +set -euo pipefail + +echo "[INSTALL] Updating packages..." +sudo apt update -qq + +echo "[INSTALL] Installing Pure Data..." +sudo apt install -y -qq puredata alsa-utils + +echo "[INSTALL] Installing USB gadget config..." +sudo cp system/configure-usb-audio.sh /usr/local/bin/ +sudo chmod +x /usr/local/bin/configure-usb-audio.sh +sudo cp system/asound.conf /etc/asound.conf + +echo "[INSTALL] Installing systemd services..." +sudo cp system/usb-gadget-audio.service /etc/systemd/system/ +sudo cp system/pd-synth.service /etc/systemd/system/ +sudo systemctl daemon-reload + +echo "[INSTALL] Enabling services..." +sudo systemctl enable usb-gadget-audio +sudo systemctl enable pd-synth + +echo "[INSTALL] Done. Reboot with: sudo reboot" diff --git a/justfile b/justfile new file mode 100644 index 0000000..c205f19 --- /dev/null +++ b/justfile @@ -0,0 +1,448 @@ +# Arduino Uno Q — USB Audio Synth +# Run with: just +# Override board host: just BOARD_HOST=192.168.1.100 deploy + +BOARD_HOST := env_var_or_default('BOARD_HOST', 'uno-q.local') +BOARD_USER := env_var_or_default('BOARD_USER', 'arduino') +BOARD_PASS := env_var_or_default('BOARD_PASS', 'arduino') +SSH_KEY := env_var_or_default('SSH_KEY', '/home/david/.ssh/unoq_deploy_key') +PD_DIR := "/home/" + BOARD_USER + "/pd" +SYSTEM_DIR := "/usr/local" +SSH_OPTS := "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i " + SSH_KEY + +default: + @just --list + +# ───────────────────────────────────────────────────────── +# FACTORY SETUP — first-time board configuration +# ───────────────────────────────────────────────────────── + +# Full factory setup: connect to board via USB (ADB), set password, +# connect WiFi, enable SSH, install Pd, deploy project +factory-setup: + #!/usr/bin/env bash + set -e + echo "[SETUP] Arduino Uno Q — Factory to Synth" + echo "" + + # Check ADB + if ! command -v adb &>/dev/null; then + echo "[ERROR] adb not found. Install: sudo apt install adb" + exit 1 + fi + + DEVICE=$(adb devices | grep -v "List" | grep "device$" | head -1 | awk '{print $1}') + if [ -z "$DEVICE" ]; then + echo "[ERROR] No ADB device found. Connect Uno Q via USB." + exit 1 + fi + echo "[OK] Found ADB device: $DEVICE" + + # Reset expired password + echo "[STEP] Setting board user password..." + adb -s "$DEVICE" shell 'printf "{{BOARD_PASS}}\n{{BOARD_PASS}}\n" | passwd' 2>/dev/null + + # Connect WiFi + read -p "WiFi SSID: " WIFI_SSID + read -rsp "WiFi password: " WIFI_PASS + echo + adb -s "$DEVICE" shell "echo '{{BOARD_PASS}}' | sudo -S nmcli device wifi connect \"$WIFI_SSID\" password \"$WIFI_PASS\"" + + # Get IP + IP=$(adb -s "$DEVICE" shell "ip addr show wlan0 2>/dev/null | grep 'inet ' | awk '{print \$2}' | cut -d/ -f1") + if [ -z "$IP" ]; then + echo "[ERROR] Could not get IP address" + exit 1 + fi + echo "[OK] Board IP: $IP" + + # Generate SSH host keys + enable SSH + adb -s "$DEVICE" shell 'echo "{{BOARD_PASS}}" | sudo -S ssh-keygen -A 2>/dev/null' + adb -s "$DEVICE" shell 'echo "{{BOARD_PASS}}" | sudo -S systemctl enable --now ssh 2>/dev/null' + + # Install SSH key + KEY=$(cat "{{SSH_KEY}}.pub" 2>/dev/null || echo "") + if [ -n "$KEY" ]; then + adb -s "$DEVICE" shell "mkdir -p ~/.ssh && echo '$KEY' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && chmod 700 ~/.ssh" + echo "[OK] SSH key installed" + else + echo "[WARN] No SSH key at {{SSH_KEY}}.pub — generate with: ssh-keygen -t ed25519 -f {{SSH_KEY}}" + fi + + # Install packages + echo "[STEP] Installing puredata (this may take a while)..." + ssh {{SSH_OPTS}} "{{BOARD_USER}}@$IP" 'echo "{{BOARD_PASS}}" | sudo -S apt-get install -y -qq puredata alsa-utils 2>&1 | tail -3' + + echo "" + echo "[DONE] Board is ready at $IP" + echo " Run: just BOARD_HOST=$IP setup" + +# ───────────────────────────────────────────────────────── +# DEPLOY +# ───────────────────────────────────────────────────────── + +# Copy Pd patch to board +deploy-patch: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[DEPLOY] Patching to {{BOARD_USER}}@{{BOARD_HOST}}:{{PD_DIR}}" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "mkdir -p {{PD_DIR}}" + scp $SCP_OPTS pd/synth.pd "{{BOARD_USER}}@{{BOARD_HOST}}:{{PD_DIR}}/" + +# Copy system config files + scripts to board +deploy-system: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[DEPLOY] System config to {{BOARD_USER}}@{{BOARD_HOST}}" + for script in configure-usb-audio.sh configure-usb-host.sh detect-usb-role.sh detect-i2s-device.sh on-usb-role-change.sh; do + scp $SCP_OPTS "system/$script" "{{BOARD_USER}}@{{BOARD_HOST}}:{{SYSTEM_DIR}}/bin/" + done + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" 'echo "{{BOARD_PASS}}" | sudo -S chmod +x {{SYSTEM_DIR}}/bin/configure-usb-*.sh {{SYSTEM_DIR}}/bin/detect-*.sh {{SYSTEM_DIR}}/bin/on-usb-role-change.sh' + scp $SCP_OPTS system/asound.conf "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + scp $SCP_OPTS system/asound-i2s.conf "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" 'echo "{{BOARD_PASS}}" | sudo -S mv /tmp/asound.conf /etc/asound.conf && echo "{{BOARD_PASS}}" | sudo -S mv /tmp/asound-i2s.conf /etc/asound-i2s.conf' + scp $SCP_OPTS scripts/start-synth.sh "{{BOARD_USER}}@{{BOARD_HOST}}:{{SYSTEM_DIR}}/bin/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" 'echo "{{BOARD_PASS}}" | sudo -S chmod +x {{SYSTEM_DIR}}/bin/start-synth.sh' + +# Deploy everything +deploy: deploy-patch deploy-system + @echo "[OK] All files deployed" + +# Deploy midi-bridge script and service to board +deploy-midi-bridge: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[DEPLOY] midi-bridge to {{BOARD_USER}}@{{BOARD_HOST}}" + scp $SCP_OPTS scripts/midi-bridge.py "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo mv /tmp/midi-bridge.py {{SYSTEM_DIR}}/bin/ && sudo chmod +x {{SYSTEM_DIR}}/bin/midi-bridge.py" + scp $SCP_OPTS system/midi-bridge.service "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo mv /tmp/midi-bridge.service /etc/systemd/system/ && sudo systemctl daemon-reload" + echo "[OK] midi-bridge deployed" + +# Deploy LED matrix visualization script and service +deploy-led-matrix-viz: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[DEPLOY] led-matrix-viz to {{BOARD_USER}}@{{BOARD_HOST}}" + scp $SCP_OPTS scripts/led-matrix-viz.py "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo mv /tmp/led-matrix-viz.py {{SYSTEM_DIR}}/bin/ && sudo chmod +x {{SYSTEM_DIR}}/bin/led-matrix-viz.py" + scp $SCP_OPTS system/led-matrix-viz.service "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo mv /tmp/led-matrix-viz.service /etc/systemd/system/ && sudo systemctl daemon-reload" + echo "[OK] led-matrix-viz deployed" + +# Deploy everything (patch + system + midi-bridge + led-matrix-viz) +deploy-all: deploy deploy-midi-bridge deploy-led-matrix-viz + @echo "[OK] All files and scripts deployed" + +# Enable midi-bridge service +enable-midi-bridge: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo systemctl enable midi-bridge && echo '[OK] midi-bridge enabled'" + +# Enable led-matrix-viz service +enable-led-matrix-viz: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo systemctl enable led-matrix-viz && echo '[OK] led-matrix-viz enabled'" + +# Start & enable host mode services (midi-bridge + led-matrix-viz) +enable-host-mode: deploy-midi-bridge deploy-led-matrix-viz + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo systemctl enable midi-bridge led-matrix-viz && \ + sudo systemctl start midi-bridge led-matrix-viz && \ + echo '[OK] Host mode services enabled and started'" + +# Install all systemd services + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[INSTALL] Services on {{BOARD_USER}}@{{BOARD_HOST}}" + for svc in usb-role-detect usb-gadget-audio pd-synth-auto pd-synth detect-i2s-device pd-synth-i2s; do + scp $SCP_OPTS "system/$svc.service" "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + done + scp $SCP_OPTS system/99-usb-role.rules "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/" + ssh $SCP_OPTS "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + for svc in usb-role-detect usb-gadget-audio pd-synth-auto pd-synth detect-i2s-device pd-synth-i2s; do \ + sudo mv /tmp/\$svc.service /etc/systemd/system/; \ + done && \ + sudo mv /tmp/99-usb-role.rules /etc/udev/rules.d/ && \ + sudo udevadm control --reload-rules && \ + sudo systemctl daemon-reload" + +# Enable auto-detect mode (default — works for both gadget and host) +enable-auto: install-services + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo systemctl disable usb-gadget-audio pd-synth detect-i2s-device pd-synth-i2s 2>/dev/null; \ + sudo systemctl enable usb-role-detect pd-synth-auto && \ + echo '[OK] Auto-detect mode enabled — reboot to apply'" + +# Enable fixed USB gadget mode (always acts as peripheral) +enable-gadget: install-services + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo systemctl disable usb-role-detect pd-synth-auto detect-i2s-device pd-synth-i2s 2>/dev/null; \ + sudo systemctl enable usb-gadget-audio pd-synth && \ + echo '[OK] USB gadget mode enabled — reboot to apply'" + +# Enable fixed I2S DAC mode +enable-i2s: install-services + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo systemctl disable usb-role-detect pd-synth-auto usb-gadget-audio pd-synth 2>/dev/null; \ + sudo systemctl enable detect-i2s-device pd-synth-i2s && \ + sudo cp /etc/asound-i2s.conf /etc/asound.conf && \ + echo '[OK] I2S DAC mode enabled — reboot to apply'" + +# Start Pd (auto-detect or whichever service is enabled) +start: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo systemctl start pd-synth-auto 2>/dev/null || \ + sudo systemctl start pd-synth 2>/dev/null || \ + sudo systemctl start pd-synth-i2s 2>/dev/null || \ + echo '[ERROR] No Pd service found'" + +# Stop Pd +stop: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo killall pd 2>/dev/null; echo '[OK] Pd stopped'" + +# Restart Pd +restart: stop start + +# Reboot board +reboot: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "echo '{{BOARD_PASS}}' | sudo -S reboot" + +# Run Pd manually in auto-detect mode (for testing) +run-remote: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + echo "[RUN] Starting Pd on {{BOARD_USER}}@{{BOARD_HOST}} (auto-detect)" + ssh $SCP_OPTS -t "{{BOARD_USER}}@{{BOARD_HOST}}" "killall pd 2>/dev/null; sudo {{SYSTEM_DIR}}/bin/start-synth.sh --auto --verbose" + +# Test audio (gadget mode) +test-audio: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "speaker-test -D hw:UAC1Gadget,0 -c 2 -t sine -f 440 -l 1" + +# Test audio (host mode — first USB audio interface) +test-audio-host: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + card=\$(aplay -l 2>/dev/null | grep -i usb | grep -vi gadget | head -1 | awk '{print \$2}' | tr -d ':'); \ + if [ -n \"\$card\" ]; then \ + speaker-test -D hw:\$card,0 -c 2 -t sine -f 440 -l 1; \ + else \ + echo '[ERROR] No USB audio interface found'; \ + fi" + +# Deploy diagnostic script to board +deploy-diag: + @scp {{SSH_OPTS}} scripts/diag.sh "{{BOARD_USER}}@{{BOARD_HOST}}:{{SYSTEM_DIR}}/bin/diag.sh" + +# Show system status (quick — uses diag.sh on board) +status: + @scp {{SSH_OPTS}} -q scripts/diag.sh "{{BOARD_USER}}@{{BOARD_HOST}}:/tmp/diag.sh" && \ + ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "echo '{{BOARD_PASS}}' | sudo -S bash /tmp/diag.sh 2>/dev/null" | head -60 + +# Test MIDI input (list ALSA sequencer ports) +midi-test: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "aconnect -i -o -l" + +# Quick deploy and restart cycle +cycle: deploy-patch + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo killall -s TERM pd 2>/dev/null; sleep 2; echo '[OK] Pd restarted with new patch'" + +# Tail Pd logs (auto-detect mode) +log: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + for svc in pd-synth-auto pd-synth pd-synth-i2s; do \ + if systemctl is-active \$svc &>/dev/null; then \ + sudo journalctl -u \$svc -f -n 50; \ + exit 0; \ + fi; \ + done; \ + echo '[ERROR] No Pd service is running'" + +# Configure USB gadget manually +init-usb-audio: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "sudo configure-usb-audio.sh start" + +# Full setup: deploy + enable auto-detect + reboot +setup: deploy enable-auto + @echo "[SETUP] Deployed and auto-detect enabled. Run 'just reboot' to start." + +# ───────────────────────────────────────────────────────── +# LOCAL TESTING (on your computer) +# ───────────────────────────────────────────────────────── + +# Open local plugdata controller + monitor for Uno Q synth +play-local: + #!/usr/bin/env bash + set -e + echo "[PLAY] Opening plugdata controller for Uno Q synth..." + echo "" + + if ! command -v plugdata &>/dev/null; then + echo "[ERROR] plugdata not found" + exit 1 + fi + if ! aconnect -l 2>/dev/null | grep -q "Uno Q Audio Synth"; then + echo "[ERROR] Uno Q not found in ALSA sequencer — is it connected?" + exit 1 + fi + + MIDI_PORT=$(aconnect -l 2>/dev/null | grep "Uno Q Audio Synth" | head -1 | awk '{print $2}' | tr -d ':') + + echo " MIDI port: $MIDI_PORT (Uno Q Audio Synth)" + echo " Audio in: hw:Synth,0 (UAC1 gadget — synth return)" + echo " Patch: pd/controller.pd" + echo "" + echo " IN PLUGDATA, configure:" + echo " Settings → Audio → Device Type: ALSA" + echo " Settings → Audio → Input: hw:Synth,0" + echo " Settings → Audio → Output: Default" + echo " Settings → MIDI Input: all — MIDI Output: Uno Q" + echo " Enable DSP (Ctrl+D)" + echo "" + + plugdata pd/controller.pd + +# Start interactive play session: on-screen keyboard + audio monitor +play: + #!/usr/bin/env bash + set -e + echo "[PLAY] Starting Uno Q synth test environment..." + echo "" + + # Install vmpk if missing + if ! command -v vmpk &>/dev/null; then + echo "[STEP] Installing vmpk..." + sudo apt-get install -y -qq vmpk + fi + + # Verify Uno Q is connected via ALSA (should be visible through PipeWire) + if ! aconnect -l 2>/dev/null | grep -q "Uno Q Audio Synth"; then + echo "[ERROR] Uno Q not found in ALSA sequencer" + exit 1 + fi + if ! pw-cli list-objects 2>/dev/null | grep -q "Uno Q Audio Synth"; then + echo "[ERROR] Uno Q not found via PipeWire" + [ -z "$(lsusb -d 2341:006a 2>/dev/null)" ] && echo " Device not showing in lsusb either!" + exit 1 + fi + + # Start vmpk in background + echo "[STEP] Starting on-screen keyboard..." + vmpk & + VMPK_PID=$! + sleep 1 + + # Route vmpk MIDI -> Uno Q via ALSA sequencer + VMPK_PORT=$(aconnect -l 2>/dev/null | grep -B1 "Virtual MIDI Piano" | head -1 | awk '{print $2}' | tr -d ':') + UNOQ_PORT=$(aconnect -l 2>/dev/null | grep "Uno Q Audio Synth" | head -1 | awk '{print $2}' | tr -d ':') + if [ -n "$VMPK_PORT" ] && [ -n "$UNOQ_PORT" ]; then + aconnect "$VMPK_PORT" "$UNOQ_PORT" 2>/dev/null && echo " MIDI: vmpk -> Uno Q Synth" + else + echo " [WARN] Auto-connect failed. Use: aconnect \$VMPK_PORT \$UNOQ_PORT" + fi + + # Route Uno Q audio -> host speakers via PipeWire + UNOQ_CAPTURE=$(pw-cli list-objects 2>/dev/null | grep -B1 "alsa_input.*Uno_Q" | grep "node.name" | head -1 | awk -F'"' '{print $2}') + HOST_OUTPUT="alsa_output.pci-0000_00_1f.3.analog-stereo" + if [ -n "$UNOQ_CAPTURE" ]; then + pw-link "$UNOQ_CAPTURE:capture_MONO" "$HOST_OUTPUT:playback_FL" 2>/dev/null && \ + pw-link "$UNOQ_CAPTURE:capture_MONO" "$HOST_OUTPUT:playback_FR" 2>/dev/null && \ + echo " Audio: Uno Q Synth -> host speakers" + else + echo " [WARN] Could not find Uno Q audio capture node" + fi + + echo "" + echo "==============================" + echo " Synth is LIVE!" + echo " Click keys in the vmpk window" + echo " Close vmpk or Ctrl+C to stop" + echo "==============================" + echo "" + + wait $VMPK_PID 2>/dev/null + echo "[STOP] Cleaning up..." + pw-disconnect "$UNOQ_CAPTURE:capture_MONO" "$HOST_OUTPUT:playback_FL" 2>/dev/null || true + pw-disconnect "$UNOQ_CAPTURE:capture_MONO" "$HOST_OUTPUT:playback_FR" 2>/dev/null || true + kill $VMPK_PID 2>/dev/null || true + aconnect -x 2>/dev/null || true + echo "[OK] Done" + +# Stop test session +stop-play: + @pw-disconnect "$(pw-cli list-objects 2>/dev/null | grep -B1 "alsa_input.*Uno_Q" | grep "node.name" | head -1 | awk -F'"' '{print $2}'):capture_MONO" "alsa_output.pci-0000_00_1f.3.analog-stereo:playback_FL" 2>/dev/null; \ + pw-disconnect "$(pw-cli list-objects 2>/dev/null | grep -B1 "alsa_input.*Uno_Q" | grep "node.name" | head -1 | awk -F'"' '{print $2}'):capture_MONO" "alsa_output.pci-0000_00_1f.3.analog-stereo:playback_FR" 2>/dev/null; \ + killall vmpk 2>/dev/null; aconnect -x 2>/dev/null; echo "[OK] Stopped" + +# Show current MIDI routing +midi-route: + @echo "=== ALSA MIDI Connections ===" && aconnect -l && echo "" && echo "=== Active connections ===" && aconnect -lio 2>/dev/null | grep -A1 "Connected From\|Connected To" || echo "(none)" + +# ───────────────────────────────────────────────────────── +# DIAGNOSTICS +# ───────────────────────────────────────────────────────── + +# Full diagnostic dump of the board +diag: + #!/usr/bin/env bash + set -e + SCP_OPTS="{{SSH_OPTS}}" + HOST="{{BOARD_USER}}@{{BOARD_HOST}}" + + echo "==============================" + echo " Uno Q Audio Synth Diagnostics" + echo "==============================" + + # Push and run the diagnostic script on the board + scp $SCP_OPTS scripts/diag.sh "$HOST:/tmp/diag.sh" >/dev/null + ssh $SCP_OPTS "$HOST" "echo '{{BOARD_PASS}}' | sudo -S bash /tmp/diag.sh" + + echo "" + echo "--- USB bus (host-side) ---" + if command -v lsusb &>/dev/null; then + lsusb -d 2341:006a 2>/dev/null | awk '{print " " $0}' || echo " (no Uno Q device on host bus)" + lsusb -d 2341:006a -t 2>/dev/null | awk '{print " " $0}' + else + echo " (lsusb not available on host)" + fi + + echo "--- Host ALSA ---" + echo " Audio:" + aplay -l 2>/dev/null | grep -i "synth\|uno" | awk '{print " " $0}' || echo " (not found on host)" + echo " MIDI:" + aconnect -l 2>/dev/null | grep -i "synth\|uno\|midi" | awk '{print " " $0}' || echo " (not found on host)" + amidi -l 2>/dev/null | grep -i "synth\|uno" | awk '{print " " $0}' || echo " (not found on host)" + + echo "" + echo "==============================" + echo " Diagnostics Complete" + echo "==============================" + +# Show kernel module info for custom modules +modinfo: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + for m in usb_f_uac1 usb_f_midi; do \ + echo \"=== \$m ===\"; \ + modinfo \$m 2>/dev/null | grep -E 'filename|description|version|parm' | awk '{print \" \" \$0}'; \ + echo ''; \ + done" + +# Check kernel config for USB gadget features +kconfig: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + zgrep -E 'CONFIG_USB_(GADGET|CONFIGFS|F_UAC1|F_MIDI|DWC3|F_ACC|F_SERIAL)' /proc/config.gz 2>/dev/null | awk -F= '{printf \" %-45s %s\\n\", \$1, \$2}'" + +# Validate all .pd files using Conftest/OPA Rego policies +validate: + python3 pd-validator/validate.py pd/*.pd + +# Install udev rules for hotplug role changes +install-udev-rules: + @ssh {{SSH_OPTS}} "{{BOARD_USER}}@{{BOARD_HOST}}" "\ + sudo cp /etc/udev/rules.d/99-usb-role.rules /etc/udev/rules.d/99-usb-role.rules.bak 2>/dev/null; \ + sudo cp /tmp/99-usb-role.rules /etc/udev/rules.d/ && \ + sudo udevadm control --reload-rules && \ + echo '[OK] udev rules installed'" diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..445806b --- /dev/null +++ b/mise.toml @@ -0,0 +1,14 @@ +# ═══════════════════════════════════════════════════════════ +# Arduino Uno Q — USB Audio Synth +# +# Tasks are managed by just (see justfile), not mise. +# Run `just --list` for available commands. +# ═══════════════════════════════════════════════════════════ + +[tools] +just = "latest" + +[env] +BOARD_HOST = "uno-q.local" +BOARD_USER = "arduino" +PD_PATCH = "synth.pd" diff --git a/pd-validator/pd.rego b/pd-validator/pd.rego new file mode 100644 index 0000000..e4dcf1b --- /dev/null +++ b/pd-validator/pd.rego @@ -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, +} diff --git a/pd-validator/pd2json.py b/pd-validator/pd2json.py new file mode 100644 index 0000000..11f1317 --- /dev/null +++ b/pd-validator/pd2json.py @@ -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 [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)) diff --git a/pd-validator/test_bad.pd b/pd-validator/test_bad.pd new file mode 100644 index 0000000..e2c2139 --- /dev/null +++ b/pd-validator/test_bad.pd @@ -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; diff --git a/pd-validator/validate.py b/pd-validator/validate.py new file mode 100644 index 0000000..054f66d --- /dev/null +++ b/pd-validator/validate.py @@ -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 [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() diff --git a/pd/controller.pd b/pd/controller.pd new file mode 100644 index 0000000..c3a58af --- /dev/null +++ b/pd/controller.pd @@ -0,0 +1,79 @@ +#N canvas 0 0 900 500 10; +#X obj 5 5 loadbang; +#X msg 5 25 pd dsp 1; +#X connect 0 0 1 0; + +#X text 20 10 -- NOTE BUTTONS (click to play) --; + +#X text 22 50 C3; +#X obj 20 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 20 100 48 100; +#X obj 20 130 noteout; +#X connect 4 0 5 0; +#X connect 5 0 6 0; + +#X text 62 50 D3; +#X obj 60 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 60 100 50 100; +#X obj 60 130 noteout; +#X connect 8 0 9 0; +#X connect 9 0 10 0; + +#X text 102 50 E3; +#X obj 100 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 100 100 52 100; +#X obj 100 130 noteout; +#X connect 12 0 13 0; +#X connect 13 0 14 0; + +#X text 142 50 F3; +#X obj 140 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 140 100 53 100; +#X obj 140 130 noteout; +#X connect 16 0 17 0; +#X connect 17 0 18 0; + +#X text 182 50 G3; +#X obj 180 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 180 100 55 100; +#X obj 180 130 noteout; +#X connect 20 0 21 0; +#X connect 21 0 22 0; + +#X text 222 50 A3; +#X obj 220 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 220 100 57 100; +#X obj 220 130 noteout; +#X connect 24 0 25 0; +#X connect 25 0 26 0; + +#X text 262 50 B3; +#X obj 260 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 260 100 59 100; +#X obj 260 130 noteout; +#X connect 28 0 29 0; +#X connect 29 0 30 0; + +#X text 302 50 C4; +#X obj 300 70 bng 15 15 0 1 0 0 empty empty -2 -8 0 10 -262144 -1 -1; +#X msg 300 100 60 100; +#X obj 300 130 noteout; +#X connect 32 0 33 0; +#X connect 33 0 34 0; + +#X text 20 210 -- CONTROLS --; +#X obj 30 230 hsl 128 15 0 127 64 0 0 0 empty Filter-CC -2 -8 0 10 -262144 -1 -1 7200 1; +#X obj 30 270 ctlout 74; +#X connect 36 0 37 0; + +#X text 20 310 -- AUDIO MONITOR --; +#X obj 30 330 adc~ 1; +#X obj 30 370 *~; +#X obj 30 410 dac~ 1 2; +#X obj 140 330 hsl 128 15 0 100 50 0 0 0 empty Monitor-Volume -2 -8 0 10 -262144 -1 -1 7200 1; +#X obj 140 370 / 100; +#X connect 39 0 40 0; +#X connect 40 0 41 0; +#X connect 40 0 41 1; +#X connect 42 0 43 0; +#X connect 43 0 40 1; diff --git a/pd/synth.pd b/pd/synth.pd new file mode 100644 index 0000000..ed65f71 --- /dev/null +++ b/pd/synth.pd @@ -0,0 +1,64 @@ +#N canvas 0 0 900 550 10; +#X obj 20 20 notein; +#X obj 80 50 pack 0 0; +#X obj 80 80 unpack 0 0; +#X obj 20 110 s midi-pitch; +#X obj 80 110 s midi-vel; +#X obj 400 20 netreceive -u 8081; +#X obj 400 60 route midi; +#X obj 400 100 route 144 128; +#X obj 400 140 unpack 0 0; +#X obj 480 140 unpack 0 0; +#X obj 400 180 s midi-pitch; +#X obj 480 180 s midi-vel; +#X obj 20 160 r midi-vel; +#X obj 20 190 r midi-pitch; +#X obj 20 220 stripnote; +#X obj 20 260 mtof; +#X obj 20 290 env~ 50 200 0.7 400; +#X obj 140 260 osc~; +#X obj 140 290 *~ 0.3; +#X obj 220 260 osc~; +#X obj 220 290 *~ 0.3; +#X obj 180 330 +~; +#X obj 180 360 *~ 0.5; +#X obj 180 400 lop~; +#X obj 180 460 *~; +#X obj 320 460 *~; +#X obj 250 530 dac~; +#X obj 350 330 hsl 128 15 100 18000 10000 0 0 0 empty Cutoff -2 -8 0 10 -262144 -1 -1 7200 1; +#X obj 350 430 hsl 128 15 0 100 40 0 0 0 empty Volume -2 -8 0 10 -262144 -1 -1 7200 1; +#X obj 350 460 / 100; +#X connect 0 0 1 0; +#X connect 0 1 1 1; +#X connect 1 0 2 0; +#X connect 2 0 3 0; +#X connect 2 1 4 0; +#X connect 5 0 6 0; +#X connect 6 0 7 0; +#X connect 7 0 8 0; +#X connect 7 1 9 0; +#X connect 8 0 10 0; +#X connect 8 1 11 0; +#X connect 9 0 10 0; +#X connect 9 1 11 0; +#X connect 12 0 14 1; +#X connect 13 0 14 0; +#X connect 14 0 15 0; +#X connect 15 0 17 0; +#X connect 15 0 19 0; +#X connect 17 0 18 0; +#X connect 19 0 20 0; +#X connect 18 0 21 0; +#X connect 20 0 21 1; +#X connect 21 0 22 0; +#X connect 22 0 23 0; +#X connect 23 0 24 0; +#X connect 14 0 16 0; +#X connect 16 0 24 1; +#X connect 24 0 25 0; +#X connect 27 0 23 1; +#X connect 28 0 29 0; +#X connect 29 0 25 1; +#X connect 25 0 26 0; +#X connect 25 0 26 1; diff --git a/scripts/diag.sh b/scripts/diag.sh new file mode 100644 index 0000000..988f5cb --- /dev/null +++ b/scripts/diag.sh @@ -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}' diff --git a/scripts/gen_synth_pd.py b/scripts/gen_synth_pd.py new file mode 100644 index 0000000..bb71397 --- /dev/null +++ b/scripts/gen_synth_pd.py @@ -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}") diff --git a/scripts/led-matrix-viz.py b/scripts/led-matrix-viz.py new file mode 100644 index 0000000..c0a5d39 --- /dev/null +++ b/scripts/led-matrix-viz.py @@ -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() diff --git a/scripts/midi-bridge.py b/scripts/midi-bridge.py new file mode 100644 index 0000000..fa67938 --- /dev/null +++ b/scripts/midi-bridge.py @@ -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() diff --git a/scripts/start-synth.sh b/scripts/start-synth.sh new file mode 100755 index 0000000..bf72fe0 --- /dev/null +++ b/scripts/start-synth.sh @@ -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: $?)" diff --git a/sketch/led-matrix-bridge.ino b/sketch/led-matrix-bridge.ino new file mode 100644 index 0000000..2b85355 --- /dev/null +++ b/sketch/led-matrix-bridge.ino @@ -0,0 +1,125 @@ +#include "ArduinoGraphics.h" +#include "Arduino_LED_Matrix.h" +#include "Arduino_RouterBridge.h" + +Arduino_LED_Matrix matrix; +String last_displayed = ""; + +String clear_matrix(String arg) { + matrix.clear(); + last_displayed = ""; + return "OK"; +} + +String draw_frame(String a_str, String b_str, String c_str, String d_str) { + uint32_t f[4]; + f[0] = (uint32_t)a_str.toInt(); + f[1] = (uint32_t)b_str.toInt(); + f[2] = (uint32_t)c_str.toInt(); + f[3] = (uint32_t)d_str.toInt(); + matrix.loadFrame(f); + last_displayed = "frame"; + return "OK"; +} + +String show_text(String text) { + matrix.clear(); + matrix.beginText(0, 0, 0xFFFFFF); + matrix.text(text); + matrix.endText(SCROLL_LEFT); + last_displayed = "text:" + text; + return "OK"; +} + +String show_char(String c) { + if (c.length() == 0) return "ERROR: no char"; + matrix.clear(); + matrix.beginText(0, 0, 0xFFFFFF); + matrix.text(c); + matrix.endText(NO_SCROLL); + last_displayed = "char:" + c; + return "OK"; +} + +String show_two(String a, String b) { + matrix.clear(); + matrix.beginText(0, 0, 0xFFFFFF); + matrix.text(a); + matrix.beginText(4, 0, 0xFFFFFF); + matrix.text(b); + matrix.endText(NO_SCROLL); + last_displayed = "two:" + a + b; + return "OK"; +} + +String set_brightness(String level) { + int l = level.toInt(); + if (l < 1) l = 1; + if (l > 8) l = 8; + matrix.setGrayscaleBits(l); + matrix.clear(); + return "OK"; +} + +String get_status(String arg) { + if (last_displayed.length() == 0) return "Matrix: cleared"; + return "Matrix shows: " + last_displayed; +} + +String play_heart(String arg) { + matrix.clear(); + uint32_t hframes[][5] = { + {0x38022020, 0x810408a0, 0x2200e800, 0x20000000, 66}, + {0x1c011010, 0x40820450, 0x11007400, 0x10000000, 66}, + {0x0e008808, 0x20410228, 0x08803a00, 0x08000000, 66}, + {0x07004404, 0x10208114, 0x04401d00, 0x04000000, 66}, + {0x03802202, 0x0810408a, 0x02200e80, 0x02000000, 66}, + {0x01c01101, 0x04082045, 0x01100740, 0x01000000, 66}, + {0x00e00880, 0x82041022, 0x808803a0, 0x00000000, 66}, + {0x00700440, 0x40020011, 0x004401c0, 0x00000000, 66}, + {0x00380200, 0x20010008, 0x802000e0, 0x00000000, 66}, + {0x00180100, 0x10008004, 0x00100060, 0x00000000, 66}, + {0x00080080, 0x08004002, 0x00080020, 0x00000000, 66}, + }; + int n = sizeof(hframes) / sizeof(hframes[0]); + for (int i = 0; i < n * 3; i++) { + matrix.loadFrame(hframes[i % n]); + delay(66); + } + matrix.clear(); + last_displayed = "heart animation"; + return "OK"; +} + +void setup() { + matrix.begin(); + matrix.textFont(Font_5x7); + matrix.textScrollSpeed(100); + + matrix.clear(); + matrix.beginText(0, 0, 0xFFFFFF); + matrix.text("."); + matrix.endText(NO_SCROLL); + + delay(15000); + + Bridge.begin(); + Monitor.begin(); + + Bridge.provide_safe("draw_frame", draw_frame); + Bridge.provide_safe("show_text", show_text); + Bridge.provide_safe("show_char", show_char); + Bridge.provide_safe("show_two", show_two); + Bridge.provide_safe("clear", clear_matrix); + Bridge.provide_safe("set_brightness", set_brightness); + Bridge.provide("get_status", get_status); + Bridge.provide("play_heart", play_heart); + + matrix.clear(); + play_heart(""); + Monitor.println("LED Matrix Bridge ready"); +} + +void loop() { + delay(1000); +} diff --git a/sketch/sketch.yaml b/sketch/sketch.yaml new file mode 100644 index 0000000..ff5b17f --- /dev/null +++ b/sketch/sketch.yaml @@ -0,0 +1,9 @@ +profiles: + default: + fqbn: arduino:zephyr:unoq + platforms: + - platform: arduino:zephyr + libraries: + - Arduino_RouterBridge + - ArduinoGraphics +default_profile: default diff --git a/system/99-usb-role.rules b/system/99-usb-role.rules new file mode 100644 index 0000000..f566de3 --- /dev/null +++ b/system/99-usb-role.rules @@ -0,0 +1,15 @@ +# USB role change udev rule +# Triggers role re-detection when USB-C cable is plugged/unplugged +# or when USB devices are connected/disconnected in host mode. +# +# Install: sudo cp 99-usb-role.rules /etc/udev/rules.d/ +# Reload: sudo udevadm control --reload-rules + +# USB gadget/UDC events (dwc3 role changes) +ACTION=="change", SUBSYSTEM=="udc", RUN+="/usr/local/bin/on-usb-role-change.sh" + +# USB device connect/disconnect (host mode) +ACTION=="add|remove", SUBSYSTEM=="usb", RUN+="/usr/local/bin/on-usb-role-change.sh" + +# USB audio card (host mode audio interface) +ACTION=="add|remove", SUBSYSTEM=="sound", RUN+="/usr/local/bin/on-usb-role-change.sh" diff --git a/system/asound-i2s.conf b/system/asound-i2s.conf new file mode 100644 index 0000000..23aebd5 --- /dev/null +++ b/system/asound-i2s.conf @@ -0,0 +1,68 @@ +# ALSA configuration for I2S DAC (e.g. PCM5102, PCM5242, MAX98357) +# Installed alongside asound.conf — activate via: +# sudo mv /etc/asound.conf /etc/asound.conf.bak +# sudo cp /etc/asound-i2s.conf /etc/asound.conf +# +# The I2S interface on the Qualcomm DragonWing typically appears as: +# hw:0,0 (Primary MI2S — default audio path) +# hw:MI2S,0 (Named MI2S device) +# +# Find available devices: aplay -l + +# Default PCM — try common I2S device names +pcm.!default { + type plug + slave { + pcm "hw:I2S,0" + format S16_LE + channels 2 + rate 48000 + } + hint.description "I2S DAC (48kHz stereo)" +} + +# Fallback: try hw:0,0 if named device not found +pcm.i2s-default { + type plug + slave { + pcm "hw:0,0" + format S16_LE + channels 2 + rate 48000 + } +} + +# Software volume control (I2S DACs typically have no hardware mixer) +pcm.i2s-softvol { + type softvol + slave.pcm "hw:I2S,0" + control { + name "I2S Playback Volume" + card I2S + } + min_dB -51.0 + max_dB 0.0 + resolution 256 +} + +# Direct hardware access (bypass plug/softvol for low latency) +pcm.i2s-direct { + type plug + slave { + pcm "hw:I2S,0" + format S16_LE + channels 2 + rate 48000 + } +} + +# Loopback test (no I2S hardware connected) +pcm.loopback { + type plug + slave { + pcm "hw:Loopback,0,0" + format S16_LE + channels 2 + rate 48000 + } +} diff --git a/system/asound.conf b/system/asound.conf new file mode 100644 index 0000000..b23d470 --- /dev/null +++ b/system/asound.conf @@ -0,0 +1,44 @@ +# ALSA configuration for USB gadget audio +# The UAC1 gadget appears as a ALSA card (typically card 1 or 2). +# This config routes Pd audio output to the USB gadget. + +# Default PCM device for USB gadget audio +pcm.!default { + type plug + slave { + pcm "hw:UAC1Gadget,0" + format S16_LE + channels 2 + rate 48000 + } +} + +# Control device +ctl.!default { + type hw + card UAC1Gadget +} + +# Software volume control via ALSA (hardware may not have mixer) +pcm.softvol { + type softvol + slave.pcm "hw:UAC1Gadget,0" + control { + name "Master Playback Volume" + card UAC1Gadget + } + min_dB -51.0 + max_dB 0.0 + resolution 256 +} + +# Null sink for testing when no USB gadget is connected +pcm.loopback { + type plug + slave { + pcm "hw:Loopback,0,0" + format S16_LE + channels 2 + rate 48000 + } +} diff --git a/system/configure-usb-audio.sh b/system/configure-usb-audio.sh new file mode 100755 index 0000000..8a2be3f --- /dev/null +++ b/system/configure-usb-audio.sh @@ -0,0 +1,132 @@ +#!/bin/bash +# configure-usb-audio.sh +# Configure the Arduino Uno Q as a composite USB gadget: +# UAC1 (USB Audio Class) + gmidi (USB MIDI) +# +# The host sees one device with both an audio sink and a MIDI port, +# allowing MIDI input over the same USB connection as audio output. +# +# Based on Linux USB gadget configfs setup. +# The Uno Q's USB peripheral controller is exposed via configfs. + +set -euo pipefail + +GADGET_DIR="/sys/kernel/config/usb_gadget" +GADGET_NAME="g1" +UDC_DIR="/sys/class/udc" + +# Gadget config +VID="0x2341" # Arduino VID +PID="0x006A" # Custom PID (Uno Q audio + MIDI) +SERIAL="$(cat /sys/firmware/devicetree/base/serial-number 2>/dev/null | tr -d '\0' || cat /proc/cpuinfo 2>/dev/null | grep Serial | head -1 | awk '{print $3}' || echo 'UnoQ')" +MANUFACTURER="Arduino" +PRODUCT="Uno Q Audio Synth" + +# USB audio params +CHANNELS=2 # stereo out +SAMPLE_RATE=48000 +SAMPLE_SIZE=2 # bytes (2 = 16-bit; kernel UAC1 gadget expects bytes, not bits) + +# Find UDC (USB Device Controller) +find_udc() { + for controller in "$UDC_DIR"/*; do + if [ -d "$controller" ]; then + basename "$controller" + return 0 + fi + done + echo "ERROR: No UDC found" >&2 + exit 1 +} + +create_gadget() { + local udc="$1" + + # Remove existing gadget if present + if [ -d "$GADGET_DIR/$GADGET_NAME" ]; then + echo "" > "$GADGET_DIR/$GADGET_NAME/UDC" 2>/dev/null || true + for _ in $(seq 1 50); do + if [ ! -f "$GADGET_DIR/$GADGET_NAME/UDC" ] || [ -z "$(cat "$GADGET_DIR/$GADGET_NAME/UDC" 2>/dev/null)" ]; then + break + fi + sleep 0.1 + done + for config in "$GADGET_DIR/$GADGET_NAME/configs"/*/; do + config=$(basename "$config") + for func in "$GADGET_DIR/$GADGET_NAME/configs/$config/"*.*/; do + func=$(basename "$func") + [ -L "$GADGET_DIR/$GADGET_NAME/configs/$config/$func" ] && rm -f "$GADGET_DIR/$GADGET_NAME/configs/$config/$func" + done + rmdir "$GADGET_DIR/$GADGET_NAME/configs/$config" 2>/dev/null || true + done + for func in "$GADGET_DIR/$GADGET_NAME/functions"/*/; do + func=$(basename "$func") + rmdir "$GADGET_DIR/$GADGET_NAME/functions/$func" 2>/dev/null || true + done + rmdir "$GADGET_DIR/$GADGET_NAME" 2>/dev/null || true + fi + + mkdir -p "$GADGET_DIR/$GADGET_NAME" + echo "$VID" > "$GADGET_DIR/$GADGET_NAME/idVendor" + echo "$PID" > "$GADGET_DIR/$GADGET_NAME/idProduct" + echo "0x0100" > "$GADGET_DIR/$GADGET_NAME/bcdDevice" + echo "0x0200" > "$GADGET_DIR/$GADGET_NAME/bcdUSB" + + mkdir -p "$GADGET_DIR/$GADGET_NAME/strings/0x409" + echo "$SERIAL" > "$GADGET_DIR/$GADGET_NAME/strings/0x409/serialnumber" + echo "$MANUFACTURER" > "$GADGET_DIR/$GADGET_NAME/strings/0x409/manufacturer" + echo "$PRODUCT" > "$GADGET_DIR/$GADGET_NAME/strings/0x409/product" + + # Create UAC1 function (audio output, wide host compatibility) + mkdir -p "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0" + echo "$CHANNELS" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/p_chmask" + echo "$SAMPLE_RATE" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/p_srate" + echo "$SAMPLE_SIZE" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/p_ssize" + echo "$CHANNELS" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/c_chmask" + echo "$SAMPLE_RATE" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/c_srate" + echo "$SAMPLE_SIZE" > "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0/c_ssize" + + # Create MIDI function (MIDI input from host) + mkdir -p "$GADGET_DIR/$GADGET_NAME/functions/midi.gs0" + echo 1 > "$GADGET_DIR/$GADGET_NAME/functions/midi.gs0/in_ports" + echo 1 > "$GADGET_DIR/$GADGET_NAME/functions/midi.gs0/out_ports" + + # Create configuration + mkdir -p "$GADGET_DIR/$GADGET_NAME/configs/c.1" + mkdir -p "$GADGET_DIR/$GADGET_NAME/configs/c.1/strings/0x409" + echo "Audio + MIDI" > "$GADGET_DIR/$GADGET_NAME/configs/c.1/strings/0x409/configuration" + echo 250 > "$GADGET_DIR/$GADGET_NAME/configs/c.1/MaxPower" + + # Link both functions to the same config (composite device) + ln -s "$GADGET_DIR/$GADGET_NAME/functions/uac1.gs0" "$GADGET_DIR/$GADGET_NAME/configs/c.1/" + ln -s "$GADGET_DIR/$GADGET_NAME/functions/midi.gs0" "$GADGET_DIR/$GADGET_NAME/configs/c.1/" + + # Bind to UDC + echo "$udc" > "$GADGET_DIR/$GADGET_NAME/UDC" + echo "USB gadget '$GADGET_NAME' bound to $udc (UAC1 audio + gmidi)" +} + +case "${1:-start}" in + start) + udc=$(find_udc) + create_gadget "$udc" + ;; + stop) + if [ -d "$GADGET_DIR/$GADGET_NAME" ]; then + udc=$(cat "$GADGET_DIR/$GADGET_NAME/UDC" 2>/dev/null || true) + [ -n "$udc" ] && echo "" > "$GADGET_DIR/$GADGET_NAME/UDC" 2>/dev/null || true + fi + echo "USB gadget '$GADGET_NAME' stopped" + ;; + status) + if [ -d "$GADGET_DIR/$GADGET_NAME/UDC" ]; then + echo "Gadget: $(cat "$GADGET_DIR/$GADGET_NAME/UDC" 2>/dev/null || echo 'not bound')" + else + echo "Gadget: not configured" + fi + ;; + *) + echo "Usage: $0 {start|stop|status}" + exit 1 + ;; +esac diff --git a/system/configure-usb-host.sh b/system/configure-usb-host.sh new file mode 100755 index 0000000..dc0a2cb --- /dev/null +++ b/system/configure-usb-host.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# configure-usb-host.sh — Wait for USB audio interface in host mode. +# Finds the first USB audio capture/playback device and writes device +# info to /run/usb-audio-dev for Pd to use. +# +# When the Uno Q acts as USB host (MIDI controller + USB audio interface +# connected via hub), the USB audio interface appears as a ALSA card +# driven by snd-usb-audio. + +set -euo pipefail + +DEV_FILE="/run/usb-audio-dev" +POLL_SECONDS=10 + +die() { echo "[USB-HOST] $*" >&2; exit 1; } +info() { echo "[USB-HOST] $*"; } + +info "Waiting for USB audio interface..." + +for ((i = 0; i < POLL_SECONDS; i++)); do + # Look for USB audio cards (not UAC1Gadget, not internal) + cards=$(aplay -l 2>/dev/null | grep -i "usb" | grep -vi "gadget" || true) + if [ -n "$cards" ]; then + # Pick the first USB audio card + card=$(echo "$cards" | head -1 | awk '{print $2}' | tr -d ':') + info "Found USB audio card: $card" + echo "hw:$card,0" > "$DEV_FILE" + cat "$DEV_FILE" + exit 0 + fi + # Also check if any MIDI devices appeared + if command -v aseqdump &>/dev/null; then + midi_ports=$(aconnect -i -o -l 2>/dev/null | grep -i "usb" | head -1 || true) + if [ -n "$midi_ports" ]; then + info "Found USB MIDI device: $midi_ports" + fi + fi + sleep 1 +done + +die "No USB audio interface found after ${POLL_SECONDS}s (connect one and restart)" diff --git a/system/detect-i2s-device.service b/system/detect-i2s-device.service new file mode 100644 index 0000000..3f4dfd9 --- /dev/null +++ b/system/detect-i2s-device.service @@ -0,0 +1,11 @@ +[Unit] +Description=Determine and set I2S audio ALSA device +Before=pd-synth-i2s.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/detect-i2s-device.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/system/detect-i2s-device.sh b/system/detect-i2s-device.sh new file mode 100644 index 0000000..0bdb2f9 --- /dev/null +++ b/system/detect-i2s-device.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# detect-i2s-device.sh — Probe for available I2S audio devices +# and create /run/i2s-audio-dev containing the best candidate. +# Called by detect-i2s-device.service before pd-synth-i2s starts. + +set -euo pipefail + +DEVICE_FILE="/run/i2s-audio-dev" +write_dev() { echo "$1" > "$DEVICE_FILE"; echo "[DETECT] I2S device: $1"; exit 0; } + +# Try named I2S devices first (most reliable) +for dev in "hw:I2S,0" "hw:MI2S,0" "hw:i2s,0" "hw:mi2s,0"; do + if aplay -l 2>/dev/null | grep -qi "${dev%,*}"; then + write_dev "$dev" + fi +done + +# Try probing ALSA device list for anything that looks like I2S +while read -r card; do + id=$(echo "$card" | awk '{print $2}' | tr -d ':') + name=$(echo "$card" | cut -d: -f2- | xargs) + if echo "$name" | grep -qi -E "(i2s|mi2s|quat|tert|dac|audio)"; then + write_dev "hw:$id,0" + fi +done < <(cat /proc/asound/cards 2>/dev/null || true) + +# Fallback to hw:0,0 (first audio device, may be HDMI or analog) +if aplay -l 2>/dev/null | grep -q "^card 0"; then + write_dev "hw:0,0" +fi + +# Nothing found +echo "[DETECT] No I2S audio device found" >&2 +echo "" > "$DEVICE_FILE" +exit 1 diff --git a/system/detect-usb-role.sh b/system/detect-usb-role.sh new file mode 100755 index 0000000..ffcfd58 --- /dev/null +++ b/system/detect-usb-role.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# detect-usb-role.sh — Detect whether USB-C should be gadget or host. +# Writes result to /run/usb-role for consumption by pd-synth-auto.service. +# +# Detection methods (tried in order): +# 1. extcon — USB cable detection (most reliable on Qualcomm) +# 2. dwc3 debug — USB controller mode +# 3. UDC state — if a UDC is bound as configured, we're in gadget mode +# 4. lsusb — if USB devices appear (after host controller init), we're host +# +# Exit code: 0 if role detected, 1 if unknown + +set -euo pipefail + +ROLE_FILE="/run/usb-role" +unset ROLE + +die() { echo "[USB-ROLE] $*" >&2; exit 1; } + +# --- Method 1: extcon --- +check_extcon() { + for path in /sys/class/extcon/*/state; do + [ -f "$path" ] || continue + local state + state=$(cat "$path" 2>/dev/null || true) + if echo "$state" | grep -q "USB=1"; then + echo "gadget" + return 0 + fi + done + return 1 +} + +# --- Method 2: dwc3 debugfs --- +check_dwc3() { + local mode_file + for mode_file in /sys/kernel/debug/usb/dwc3*/mode; do + [ -f "$mode_file" ] || continue + local mode + mode=$(cat "$mode_file" 2>/dev/null || true) + case "$mode" in + *peripheral*) echo "gadget"; return 0 ;; + *host*) echo "host"; return 0 ;; + esac + done + return 1 +} + +# --- Method 3: UDC state --- +check_udc() { + for path in /sys/class/udc/*/state; do + [ -f "$path" ] || continue + local state + state=$(cat "$path" 2>/dev/null || true) + case "$state" in + configured) echo "gadget"; return 0 ;; + esac + done + return 1 +} + +# --- Method 4: USB host devices --- +check_lsusb() { + if command -v lsusb &>/dev/null; then + local count + count=$(lsusb 2>/dev/null | grep -v "Linux Foundation" | wc -l) + # If we see non-root-hub devices, likely we're the host + if [ "$count" -gt 1 ]; then + echo "host" + return 0 + fi + fi + return 1 +} + +# Try methods in order +ROLE=$(check_extcon || check_dwc3 || check_udc || echo "") + +# If still unknown, check lsusb after brief delay (host controller needs time) +if [ -z "$ROLE" ]; then + sleep 2 + ROLE=$(check_lsusb || echo "") +fi + +# Write result +if [ -n "$ROLE" ]; then + echo "$ROLE" > "$ROLE_FILE" + echo "[USB-ROLE] Detected: $ROLE" + exit 0 +else + echo "[USB-ROLE] Unknown — defaulting to gadget" + echo "gadget" > "$ROLE_FILE" + exit 0 +fi diff --git a/system/led-matrix-viz.service b/system/led-matrix-viz.service new file mode 100644 index 0000000..fa5ba84 --- /dev/null +++ b/system/led-matrix-viz.service @@ -0,0 +1,15 @@ +[Unit] +Description=LED Matrix MIDI Visualization +After=arduino-router.service + +[Service] +User=arduino +Type=simple +ExecStart=/usr/local/bin/led-matrix-viz.py +Restart=on-failure +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/system/midi-bridge.service b/system/midi-bridge.service new file mode 100644 index 0000000..8fe51d5 --- /dev/null +++ b/system/midi-bridge.service @@ -0,0 +1,14 @@ +[Unit] +Description=MIDI rawmidi to Pd UDP bridge (MPK Mini → Pd) +After=multi-user.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/midi-bridge.py +Restart=on-failure +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/system/on-usb-role-change.sh b/system/on-usb-role-change.sh new file mode 100755 index 0000000..6567a94 --- /dev/null +++ b/system/on-usb-role-change.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# on-usb-role-change.sh — Called by udev on USB role/connect events. +# Re-detects role, stops/restarts Pd with appropriate config. + +set -euo pipefail + +ROLE_FILE="/run/usb-role" +LOG_TAG="[USB-HOTPLUG]" + +# Debounce: skip if we just ran (within 3s) +LOCK="/tmp/usb-role-change.lock" +if [ -f "$LOCK" ]; then + age=$(($(date +%s) - $(stat -c %Y "$LOCK" 2>/dev/null || echo 0))) + [ "$age" -lt 3 ] && exit 0 +fi +touch "$LOCK" + +logger -t "$LOG_TAG" "USB role change event — re-detecting..." + +/usr/local/bin/detect-usb-role.sh + +# Restart Pd if it's running +if systemctl is-active pd-synth-auto &>/dev/null; then + logger -t "$LOG_TAG" "Restarting Pd with new role" + systemctl restart pd-synth-auto +fi diff --git a/system/pd-synth-auto.service b/system/pd-synth-auto.service new file mode 100644 index 0000000..3f58000 --- /dev/null +++ b/system/pd-synth-auto.service @@ -0,0 +1,17 @@ +[Unit] +Description=Pure Data Synth (auto-detect: gadget or host) +Documentation=https://github.com/arduino/uno-q-audio-synth +After=usb-role-detect.service +Wants=usb-role-detect.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/start-synth.sh --auto --verbose +ExecStop=/usr/bin/killall -s TERM pd +StandardOutput=journal +StandardError=journal +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/system/pd-synth-i2s.service b/system/pd-synth-i2s.service new file mode 100644 index 0000000..b267d00 --- /dev/null +++ b/system/pd-synth-i2s.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pure Data I2S DAC Synth (standalone) +After=network.target sound.target +Wants=configure-usb-audio.service + +[Service] +Type=simple +User=arduino +ExecStart=/usr/local/bin/start-synth.sh --i2s +ExecStop=/usr/bin/killall pd +WorkingDirectory=/home/arduino/pd +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/system/pd-synth.service b/system/pd-synth.service new file mode 100644 index 0000000..a9dd7ef --- /dev/null +++ b/system/pd-synth.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pure Data USB Audio Synth +After=network.target sound.target +Wants=usb-gadget-audio.service + +[Service] +Type=simple +User=arduino +ExecStart=/usr/local/bin/start-synth.sh +ExecStop=/usr/bin/killall pd +WorkingDirectory=/home/arduino/pd +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/system/reset-usb-gadget.sh b/system/reset-usb-gadget.sh new file mode 100644 index 0000000..e3b4945 --- /dev/null +++ b/system/reset-usb-gadget.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +# Reset USB controller and re-bind gadget +UDC="4e00000.usb" +GADGET="/sys/kernel/config/usb_gadget/g1" + +# Find the driver binding +DRIVER="" +for d in /sys/bus/platform/drivers/*/; do + dname=$(basename "$d") + if [ -L "$d/$UDC" ] 2>/dev/null; then + DRIVER="$dname" + break + fi +done + +if [ -z "$DRIVER" ]; then + # Try looking at the UDC device itself + UDC_PATH=$(readlink -f /sys/class/udc/$UDC/device 2>/dev/null || echo "") + if [ -n "$UDC_PATH" ]; then + DRIVER=$(basename "$(dirname "$UDC_PATH")" 2>/dev/null || echo "") + fi +fi + +echo "Driver: ${DRIVER:-unknown}" + +# Unbind +if [ -n "$DRIVER" ]; then + echo "$UDC" > "/sys/bus/platform/drivers/$DRIVER/unbind" 2>/dev/null && echo "Unbound" || echo "Unbind failed" + sleep 2 + echo "$UDC" > "/sys/bus/platform/drivers/$DRIVER/bind" 2>/dev/null && echo "Rebound" || echo "Rebind failed" + sleep 1 +fi + +# Bind gadget +echo "$UDC" > "$GADGET/UDC" 2>&1 && echo "Gadget bound" || echo "Gadget bind failed" +cat /sys/class/udc/$UDC/state diff --git a/system/usb-gadget-audio.service b/system/usb-gadget-audio.service new file mode 100644 index 0000000..104ef76 --- /dev/null +++ b/system/usb-gadget-audio.service @@ -0,0 +1,12 @@ +[Unit] +Description=Configure USB Audio Gadget +Before=pd-synth.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/configure-usb-audio.sh start +ExecStop=/usr/local/bin/configure-usb-audio.sh stop +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/system/usb-role-detect.service b/system/usb-role-detect.service new file mode 100644 index 0000000..febf581 --- /dev/null +++ b/system/usb-role-detect.service @@ -0,0 +1,14 @@ +[Unit] +Description=Detect USB role (gadget vs host) +DefaultDependencies=no +After=sysinit.target +Before=pd-synth-auto.service + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/detect-usb-role.sh +RemainAfterExit=yes +StandardOutput=journal + +[Install] +WantedBy=sysinit.target