Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled

P2-R1: ALSA + JACK2 low-latency config (scripts, quirks, tuning)
P2-R2: Carla integration (build scripts, 8ch rack config, NAM LV2 support)
P2-R3: Plugin manager, categories, blacklist, NAM model support
P3-R1: Mixer DSP engine (channel strip, routing matrix, bus mgr, automation)
P4-R1: MIDI engine (learn mode, clock sync, HID discovery, mapping store)
P4-R2: Network API (OSC server, FastAPI REST, WebSocket, auth, rate limiter)
P5-R1: Touchscreen UI evaluation + main entry point
docs: Audio stack, Carla integration, MIDI support, UI evaluation
tests: Full test suite (292 passing)
This commit is contained in:
2026-05-19 20:39:17 -04:00
parent 7b123762b5
commit 9cd8292acc
97 changed files with 26545 additions and 5 deletions
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# audio-stack-start — initialize the full low-latency audio environment
# Run with: sudo audio-stack-start
# Then JACK runs as the 'pi' user (or current user if not root)
set -e
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Audio Stack Initialization ║"
echo "╚══════════════════════════════════════════╝"
echo ""
# ── 1. CPU Governor ──────────────────────────────────
echo "1/6 Setting CPU governor → performance..."
for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
if [ -f "$cpu" ]; then
echo performance > "$cpu" 2>/dev/null || true
fi
done
echo " Done."
# ── 2. IRQ Priorities ────────────────────────────────
echo "2/6 Configuring IRQ priorities..."
if command -v rtirq > /dev/null 2>&1; then
if systemctl is-active --quiet rtirq 2>/dev/null; then
systemctl restart rtirq 2>/dev/null || true
else
systemctl start rtirq 2>/dev/null || true
fi
echo " rtirq started."
else
echo " rtirq not installed. Run: sudo apt install rtirq-init"
fi
# ── 3. PAM Limits Check ──────────────────────────────
echo "3/6 Checking PAM limits..."
CURRENT_RTPRIO=$(ulimit -r 2>/dev/null || echo "unknown")
CURRENT_MEMLOCK=$(ulimit -l 2>/dev/null || echo "unknown")
echo " rtprio: $CURRENT_RTPRIO (need 99)"
echo " memlock: $CURRENT_MEMLOCK (need unlimited)"
# ── 4. USB Audio Driver ──────────────────────────────
echo "4/6 Configuring USB audio driver..."
if lsmod 2>/dev/null | grep -q snd_usb_audio; then
modprobe -r snd-usb-audio 2>/dev/null || true
sleep 1
fi
modprobe snd-usb-audio nrpacks=1 2>/dev/null || true
echo " snd-usb-audio loaded with nrpacks=1"
# ── 5. USB Audio Detection ───────────────────────────
echo "5/6 Checking USB audio device..."
sleep 1
if aplay -l 2>/dev/null | grep -qi usb; then
echo " USB audio interface detected:"
aplay -l 2>/dev/null | grep -i usb | head -3 | sed 's/^/ /'
else
echo " WARNING: No USB audio interface found!"
echo " Check: lsusb | grep -i audio"
fi
# ── 6. Start JACK ────────────────────────────────────
echo "6/6 Starting JACK2..."
if pgrep -x jackd > /dev/null; then
echo " JACK is already running. PID: $(pgrep -x jackd)"
else
if [ "$EUID" -eq 0 ] && id pi >/dev/null 2>&1; then
# Running as root, drop to pi user
sudo -u pi jackd -P70 -t2000 -d alsa -d hw:USB -r48000 -p128 -n3 -S > /tmp/jackd.log 2>&1 &
echo " JACK launched as user 'pi' (PID: $!)"
else
jackd -P70 -t2000 -d alsa -d hw:USB -r48000 -p128 -n3 -S > /tmp/jackd.log 2>&1 &
echo " JACK launched (PID: $!)"
fi
sleep 2
fi
# ── Final status ─────────────────────────────────────
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Audio Stack Ready ║"
echo "╚══════════════════════════════════════════╝"
echo ""
echo " Check ports: jack_lsp -c system"
echo " Test latency: jack_delay -O system:playback_1 -I system:capture_1"
echo " Auto-route: jack-route-default"
echo " Stop stack: audio-stack-stop"
echo " Xrun monitor: jack_cpu_load"
echo ""
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# audio-stack-stop — gracefully stop the audio stack
# Kills JACK and any associated audio tools
echo "Stopping audio stack..."
# Graceful shutdown first
killall -15 jackd 2>/dev/null || true
sleep 1
# Force kill if still running
killall -9 jackd 2>/dev/null || true
killall -9 jack_delay 2>/dev/null || true
echo "Audio stack stopped."
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash
# carla-build.sh — Build Carla plugin host from source for RPi4B (ARM Cortex-A72)
# Run on the Raspberry Pi 4B directly. Estimated build time: 30-60 min.
#
# Usage:
# chmod +x carla-build.sh
# ./carla-build.sh # Build only
# ./carla-build.sh --install # Build + install to /usr/local
# ./carla-build.sh --clean # Clean and rebuild
set -euo pipefail
CARLA_VERSION="2.6.0-alpha1"
CARLA_REPO="https://github.com/falkTX/Carla.git"
CARLA_DIR="/tmp/carla-build"
INSTALL_PREFIX="/usr/local"
JOBS=$(nproc 2>/dev/null || echo 4)
# Cortex-A72 optimized flags for aarch64
# -march=armv8-a+crc+crypto covers baseline A72 features
# -mtune=cortex-a72 schedules for A72 pipeline
# -O3 -ffast-math: aggressive math (safe for audio)
A72_CFLAGS="-march=armv8-a+crc+crypto -mtune=cortex-a72 -O3 -ffast-math -fdata-sections -ffunction-sections -fvisibility=hidden"
A72_CXXFLAGS="-march=armv8-a+crc+crypto -mtune=cortex-a72 -O3 -ffast-math -fdata-sections -ffunction-sections -fvisibility=hidden -fvisibility-inlines-hidden"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[carla-build]${NC} $*"; }
warn() { echo -e "${YELLOW}[carla-build] WARN:${NC} $*"; }
err() { echo -e "${RED}[carla-build] ERROR:${NC} $*" >&2; exit 1; }
# ── Installation mode ──────────────────────────────────
INSTALL=false
CLEAN=false
while [[ $# -gt 0 ]]; do
case "$1" in
--install) INSTALL=true; shift ;;
--clean) CLEAN=true; shift ;;
*) echo "Unknown flag: $1"; exit 1 ;;
esac
done
# ── Check platform ─────────────────────────────────────
ARCH=$(uname -m)
if [[ "$ARCH" != "aarch64" ]] && [[ "$ARCH" != "armv7l" ]]; then
warn "Not running on ARM (detected $ARCH). Cross-compilation not supported by this script."
warn "Run this script directly on the Raspberry Pi 4B."
fi
# ── Install build dependencies ─────────────────────────
log "Installing build dependencies..."
sudo apt update -qq
sudo apt install -y \
build-essential \
git \
cmake \
pkg-config \
g++ \
gcc \
make \
libasound2-dev \
libjack-jackd2-dev \
libpulse-dev \
libsndfile1-dev \
libx11-dev \
libxcursor-dev \
libxext-dev \
libxrandr-dev \
libfreetype6-dev \
libfontconfig1-dev \
libgl1-mesa-dev \
liblo-dev \
libmagic-dev \
libfluidsynth-dev \
libsmf-dev \
python3-pyqt5 \
python3-pyqt5.qtsvg \
python3-rdflib \
pyqt5-dev-tools \
python3-dev \
python3-liblo \
qtbase5-dev \
qtbase5-dev-tools \
libfftw3-dev \
librtmidi-dev \
libzita-resampler-dev
# Optional: VST3 SDK (needed for VST3 plugin hosting)
# The VST3 SDK must be obtained separately from Steinberg.
# We check for it but don't error if absent.
VST3_SDK_DIR="/opt/vst3sdk"
VST3_AVAILABLE=false
if [ -d "$VST3_SDK_DIR" ]; then
log "VST3 SDK found at $VST3_SDK_DIR"
VST3_AVAILABLE=true
else
warn "VST3 SDK not found at $VST3_SDK_DIR"
warn "To add VST3 plugin hosting, download from:"
warn " https://github.com/steinbergmedia/vst3sdk.git"
warn " sudo git clone --recursive https://github.com/steinbergmedia/vst3sdk.git /opt/vst3sdk"
warn "Continuing without VST3 support..."
fi
# ── Clone Carla ────────────────────────────────────────
if [ "$CLEAN" = true ] || [ ! -d "$CARLA_DIR" ]; then
log "Cloning Carla repository..."
rm -rf "$CARLA_DIR"
git clone --depth 1 --branch main "$CARLA_REPO" "$CARLA_DIR"
fi
cd "$CARLA_DIR"
# ── Patch Makefile for ARM Cortex-A72 ─────────────────
log "Patching Carla Makefile for Cortex-A72 optimization..."
# Back up original
if [ ! -f source/Makefile.mk.orig ]; then
cp source/Makefile.mk source/Makefile.mk.orig
fi
# Add ARM64 optimization flags after the ARM32 section
if ! grep -q "CPU_ARM64_OR_AARCH64" source/Makefile.mk; then
sed -i '/^ifeq .*CPU_ARM_OR_AARCH64.*/,/^endif/{
/^endif/{
i\
\
ifeq ($(CPU_ARM64_OR_AARCH64),true)\
BASE_OPTS += -march=armv8-a+crc+crypto -mtune=cortex-a72\
endif
}
}' source/Makefile.mk
log "Added ARM64 optimization block to Makefile.mk"
fi
# Check for features
log "Checking available features..."
make features 2>&1 | tee /tmp/carla-features.log || true
# ── Build Carla ────────────────────────────────────────
log "Building Carla (${JOBS} parallel jobs)..."
export CFLAGS="$A72_CFLAGS"
export CXXFLAGS="$A72_CXXFLAGS"
# Build with all available features
# NOOPT= removes CPU-specific flags so we can supply our own
# WITH_LTO=true enables link-time optimization for smaller/faster binary
make -j"$JOBS" \
PREFIX="$INSTALL_PREFIX" \
WITH_LTO=true \
2>&1 | tee /tmp/carla-build.log
log "Carla build complete."
# ── Check build results ───────────────────────────────
if [ -f "source/frontend/carla" ]; then
log "✓ Carla frontend binary built successfully."
else
err "Carla frontend binary not found. Check /tmp/carla-build.log"
fi
# ── Install (optional) ─────────────────────────────────
if [ "$INSTALL" = true ]; then
log "Installing Carla to $INSTALL_PREFIX..."
sudo make install PREFIX="$INSTALL_PREFIX"
sudo ldconfig
# Verify installation
if command -v carla >/dev/null 2>&1; then
log "✓ Carla installed successfully: $(which carla)"
else
warn "Carla binary not on PATH. It may be in /usr/local/bin/carla"
fi
# Create desktop integration
if [ -f "resources/carla.desktop" ]; then
sudo cp resources/carla.desktop /usr/local/share/applications/
fi
fi
# ── Output summary ─────────────────────────────────────
log ""
log "╔══════════════════════════════════════════════╗"
log "║ Carla Build Summary ║"
log "╚══════════════════════════════════════════════╝"
log ""
log " Binary: $CARLA_DIR/source/frontend/carla"
log " Install: $([ "$INSTALL" = true ] && echo "yes" || echo "no (use --install)")"
log " Platform: $ARCH (Cortex-A72 optimized)"
log " Plugins: LV2, LADSPA, DSSI, SF2/SFZ"
log " VST3: $([ "$VST3_AVAILABLE" = true ] && echo "yes" || echo "no (install VST3 SDK)")"
log " JACK: yes (ALSA backend)"
log " OSC: yes (liblo)"
log " FluidSynth: yes (SF2/SFZ)"
log ""
log " Next steps:"
log " 1. Start JACK: sudo audio-stack-start"
log " 2. Run Carla: carla ./config/carla-default.carxp"
log " 3. Build NAM: ./scripts/nam-build.sh"
log ""
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""
carla-jack-autoconnect — Auto-connect Carla plugins to JACK system ports.
Monitors JACK for new Carla plugin ports and auto-wires them according
to the default mixer routing (carxp preset). Runs as a daemon or one-shot.
Usage:
python3 carla-jack-autoconnect # One-shot: connect existing ports
python3 carla-jack-autoconnect --watch # Daemon: watch for new ports
python3 carla-jack-autoconnect --dry-run # Show what would be connected
python3 carla-jack-autoconnect --disconnect-all # Disconnect all Carla ports
"""
import subprocess
import sys
import time
import re
import json
import os
from typing import Dict, List, Tuple, Set
# ── Routing rules ──────────────────────────────────────
# Format: (source_regex, dest_regex, label)
# Order matters — first match wins for each dest port.
ROUTING_RULES: List[Tuple[str, str, str]] = [
# System inputs → Channel processing chain
(r"system:capture_1$", r".*Ch1.*Gate.*in[12]$", "Capture 1 → Ch1 Gate"),
(r"system:capture_2$", r".*Ch2.*Gate.*in[12]$", "Capture 2 → Ch2 Gate"),
(r"system:capture_3$", r".*Ch3.*NAM.*in1$", "Capture 3 → Guitar NAM"),
(r"system:capture_4$", r".*Ch4.*in[12]$", "Capture 4 → Ch4"),
# Chain outputs → chain inputs (plugin internal routing)
(r".*Ch1.*Gate.*out[12]$", r".*Ch1.*EQ.*in[12]$", "Ch1 Gate → Ch1 EQ"),
(r".*Ch1.*EQ.*out[12]$", r".*Ch1.*Comp.*in[12]$", "Ch1 EQ → Ch1 Comp"),
(r".*Ch2.*Gate.*out[12]$", r".*Ch2.*EQ.*in[12]$", "Ch2 Gate → Ch2 EQ"),
(r".*Ch2.*EQ.*out[12]$", r".*Ch2.*Comp.*in[12]$", "Ch2 EQ → Ch2 Comp"),
(r".*Ch3.*NAM.*out[12]$", r".*Ch3.*IR.*in[12]$", "Ch3 NAM → Ch3 IR Loader"),
# Master output → System playback
(r".*Master.*Limiter.*out1$", r"system:playback_1$", "Master L → Playback 1"),
(r".*Master.*Limiter.*out2$", r"system:playback_2$", "Master R → Playback 2"),
]
def jack_lsp() -> List[str]:
"""Get all JACK ports."""
try:
result = subprocess.run(
["jack_lsp", "-c"],
capture_output=True, text=True, timeout=5
)
return result.stdout.strip().split("\n") if result.stdout.strip() else []
except (FileNotFoundError, subprocess.TimeoutExpired):
print("[autoconnect] ERROR: jack_lsp not found. Is JACK running?", file=sys.stderr)
return []
def jack_connect(source: str, dest: str) -> bool:
"""Connect two JACK ports."""
try:
result = subprocess.run(
["jack_connect", source, dest],
capture_output=True, text=True, timeout=5
)
return result.returncode == 0
except Exception as e:
print(f"[autoconnect] Connect failed: {source} → {dest}: {e}", file=sys.stderr)
return False
def jack_disconnect(source: str, dest: str) -> bool:
"""Disconnect two JACK ports."""
try:
result = subprocess.run(
["jack_disconnect", source, dest],
capture_output=True, text=True, timeout=5
)
return result.returncode == 0
except Exception:
return False
def get_current_connections() -> Set[Tuple[str, str]]:
"""Get all current JACK connections."""
try:
result = subprocess.run(
["jack_lsp", "-c"],
capture_output=True, text=True, timeout=5
)
connections = set()
for line in result.stdout.strip().split("\n"):
parts = line.strip().split()
if len(parts) >= 2:
# First token is the output port, subsequent are inputs
source = parts[0]
for dest in parts[1:]:
connections.add((source, dest))
return connections
except Exception:
return set()
def match_port(pattern: str, ports: List[str]) -> List[str]:
"""Return ports matching a regex pattern."""
matched = []
for port in ports:
if re.search(pattern, port):
matched.append(port)
return matched
def apply_routing(dry_run: bool = False, verbose: bool = True) -> Dict:
"""Apply routing rules to JACK ports. Returns stats."""
ports = jack_lsp()
if not ports:
return {"status": "no_jack", "connected": 0, "failed": 0, "skipped": 0}
stats = {"connected": 0, "failed": 0, "skipped": 0, "pairs": []}
for source_regex, dest_regex, label in ROUTING_RULES:
sources = match_port(source_regex, ports)
dests = match_port(dest_regex, ports)
for src in sources:
for dst in dests:
if dry_run:
print(f" [DRY-RUN] {src} → {dst} ({label})")
stats["pairs"].append((src, dst, label))
else:
success = jack_connect(src, dst)
if success:
if verbose:
print(f" ✓ {src} → {dst} ({label})")
stats["connected"] += 1
else:
print(f" ✗ FAILED: {src} → {dst}", file=sys.stderr)
stats["failed"] += 1
return stats
def disconnect_all_carla():
"""Disconnect all Carla-related ports."""
ports = jack_lsp()
connections = get_current_connections()
count = 0
for src, dst in connections:
if "Carla" in src or "Carla" in dst:
if jack_disconnect(src, dst):
print(f" ✂ {src} → {dst}")
count += 1
print(f"\n Disconnected {count} Carla connections.")
return count
def watch_loop(interval: float = 1.0):
"""Watch for new JACK ports and auto-connect them."""
print("[autoconnect] Watching for JACK port changes... (Ctrl+C to stop)")
seen_ports = set(jack_lsp())
while True:
try:
time.sleep(interval)
current_ports = set(jack_lsp())
new_ports = current_ports - seen_ports
if new_ports:
carla_new = {p for p in new_ports if "Carla" in p or "system" in p}
if carla_new:
print(f"\n[autoconnect] New ports detected: {', '.join(sorted(carla_new))}")
apply_routing(dry_run=False, verbose=True)
seen_ports = current_ports
except KeyboardInterrupt:
print("\n[autoconnect] Stopped.")
break
except Exception as e:
print(f"[autoconnect] Error: {e}", file=sys.stderr)
# ── CLI ────────────────────────────────────────────────
if __name__ == "__main__":
if "--watch" in sys.argv:
watch_loop()
elif "--dry-run" in sys.argv:
print("[autoconnect] DRY RUN — no connections made:\n")
apply_routing(dry_run=True)
elif "--disconnect-all" in sys.argv:
disconnect_all_carla()
else:
print("[autoconnect] One-shot port connection:\n")
stats = apply_routing(dry_run=False)
print(f"\n Connected: {stats['connected']} | Failed: {stats['failed']} | Skipped: {stats['skipped']}")
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""
carla-preset-gen — Generate Carla rack preset (.carxp) for the mixer setup.
Creates a default 8-channel mixer rack with:
- 2 USB input channels (capture_1/2 → EQ → Comp → Gate)
- 1 NAM guitar channel (capture_3 → NAM → IR loader)
- 2 stereo aux buses (Reverb, Delay)
- Master output to playback_1/2
Usage:
python3 scripts/carla-preset-gen.py > config/carla-default.carxp
python3 scripts/carla-preset-gen.py --channels 4 > config/carla-4ch.carxp
"""
import sys
import xml.etree.ElementTree as ET
from datetime import datetime
# JACK port mapping (matches audio-stack-config parent task)
# hw:USB is configured with JACK at 48kHz, 128f/3p
JACK_SAMPLE_RATE = 48000
JACK_BUFFER_SIZE = 128
def indent_xml(elem, level=0):
"""Pretty-print XML element tree."""
i = "\n" + level * " "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for subelem in elem:
indent_xml(subelem, level + 1)
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def build_rack_xml(num_channels=8):
"""Construct a Carla rack preset XML with standard mixer layout."""
root = ET.Element("CARLA-PRESET", version="2.6.0")
ET.SubElement(root, "Info", Name="RPi Mixer Default Rack", Author="hermes-kanban")
# ── Engine settings ─────────────────────────────────
engine = ET.SubElement(root, "Engine")
ET.SubElement(engine, "AudioDriver").text = "JACK"
ET.SubElement(engine, "ProcessMode").text = "MultipleClients" # each plugin = separate JACK client
ET.SubElement(engine, "TransportMode").text = "Disabled"
ET.SubElement(engine, "SampleRate").text = str(JACK_SAMPLE_RATE)
ET.SubElement(engine, "BufferSize").text = str(JACK_BUFFER_SIZE)
ET.SubElement(engine, "ForceStereo").text = "true"
# ── Plugin rack ─────────────────────────────────────
rack = ET.SubElement(root, "Rack")
rack.set("enabled", "true")
plugin_id = 0
def add_plugin(name, plugin_type, uri_or_binary, params=None, enabled="true", bypass="false"):
nonlocal plugin_id
pid = plugin_id
plugin_id += 1
plugin = ET.SubElement(rack, "Plugin")
plugin.set("id", str(pid))
plugin.set("name", name)
plugin.set("type", plugin_type)
plugin.set("uri", uri_or_binary)
plugin.set("enabled", enabled)
plugin.set("bypass", bypass)
plugin.set("drywet", "1.0")
plugin.set("volume", "0.0") # dB
if params:
for pname, pval in params.items():
param = ET.SubElement(plugin, "Parameter")
param.set("name", pname)
param.set("value", str(pval))
return pid
# Channel 1 — Mic/Line input with EQ and Comp
add_plugin("Ch1 Gate",
"LV2",
"http://calf.sourceforge.net/plugins/Gate",
{"threshold": "-40", "ratio": "4.0", "attack": "10", "release": "100"})
add_plugin("Ch1 EQ",
"LV2",
"http://calf.sourceforge.net/plugins/Equalizer12Band",
{})
add_plugin("Ch1 Comp",
"LV2",
"http://calf.sourceforge.net/plugins/Compressor",
{"threshold": "-20", "ratio": "4.0", "attack": "5", "release": "50", "makeup": "3"})
# Channel 2 — Second input
add_plugin("Ch2 Gate",
"LV2",
"http://calf.sourceforge.net/plugins/Gate",
{"threshold": "-40", "ratio": "4.0"})
add_plugin("Ch2 EQ",
"LV2",
"http://calf.sourceforge.net/plugins/Equalizer8Band",
{})
add_plugin("Ch2 Comp",
"LV2",
"http://calf.sourceforge.net/plugins/Compressor",
{"threshold": "-20", "ratio": "4.0"})
# Channel 3 — Guitar / NAM channel
add_plugin("Ch3 NAM",
"LV2",
"http://github.com/mikeoliphant/neural-amp-modeler-lv2",
{"model": "/home/pi/nam-models/default.nam"})
add_plugin("Ch3 IR Loader",
"LV2",
"http://guitarix.org/plugins/gx_ampmodeller",
{})
# AUX 1 — Reverb
add_plugin("AUX1 Hall Reverb",
"LV2",
"http://calf.sourceforge.net/plugins/Reverb",
{"decay_time": "2.5", "dry/wet": "0.3", "room_size": "0.7"})
# AUX 2 — Delay
add_plugin("AUX2 Delay",
"LV2",
"http://calf.sourceforge.net/plugins/VintageDelay",
{"time_l": "250", "time_r": "375", "feedback": "0.4", "dry/wet": "0.25"})
# Master chain — Limiter
add_plugin("Master Limiter",
"LV2",
"http://calf.sourceforge.net/plugins/Limiter",
{"limit": "-1.0", "threshold": "-3.0", "release": "20"})
return root
def build_patchbay_xml():
"""Construct patchbay connection rules for JACK auto-routing."""
root = ET.Element("CARLA-PATCHBAY", version="2.6.0")
# Connection rules: source → destination with optional regex
rules = [
# System captures → Channel inputs
("system:capture_1", "Carla:Ch1_Gate_in1", "Capture 1 → Ch1 Gate"),
("system:capture_2", "Carla:Ch2_Gate_in1", "Capture 2 → Ch2 Gate"),
("system:capture_3", "Carla:Ch3_NAM_in1", "Capture 3 → Guitar NAM"),
# Master stereo output → system playback
("Carla:Master_Limiter_out1", "system:playback_1", "Master L → Playback 1"),
("Carla:Master_Limiter_out2", "system:playback_2", "Master R → Playback 2"),
]
for idx, (source, dest, label) in enumerate(rules):
conn = ET.SubElement(root, "Connection")
conn.set("id", str(idx))
conn.set("source", source)
conn.set("dest", dest)
conn.set("label", label)
conn.set("enabled", "true")
return root
# ── CLI interface ──────────────────────────────────────
if __name__ == "__main__":
num_channels = 8
mode = "rack"
args = sys.argv[1:]
i = 0
while i < len(args):
if args[i] == "--channels" and i + 1 < len(args):
num_channels = int(args[i + 1])
i += 2
elif args[i] == "--patchbay":
mode = "patchbay"
i += 1
else:
print(f"Unknown arg: {args[i]}", file=sys.stderr)
sys.exit(1)
if mode == "patchbay":
xml = build_patchbay_xml()
else:
xml = build_rack_xml(num_channels)
indent_xml(xml)
ET.dump(xml)
# Also write to config/ directory
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
config_dir = os.path.join(os.path.dirname(script_dir), "config")
os.makedirs(config_dir, exist_ok=True)
if mode == "patchbay":
out_path = os.path.join(config_dir, "carla-patchbay.xml")
else:
out_path = os.path.join(config_dir, f"carla-{num_channels}ch-default.carxp")
tree = ET.ElementTree(xml)
indent_xml(xml)
tree.write(out_path, encoding="utf-8", xml_declaration=True)
print(f"\n[preset-gen] Written to {out_path}", file=sys.stderr)
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
carla-preset-manager — Manage Carla rack presets and plugin programs.
Provides a CLI interface to:
- List available presets
- Save current Carla state as preset
- Load presets into Carla (via OSC or file)
- Manage NAM model switching
- Export/import preset bundles
Usage:
python3 carla-preset-manager.py list
python3 carla-preset-manager.py save <name>
python3 carla-preset-manager.py load <name>
python3 carla-preset-manager.py export <name> <output.tar.gz>
python3 carla-preset-manager.py import <bundle.tar.gz>
python3 carla-preset-manager.py set-model <plugin-name> <model-path>
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional
# ── Configuration ──────────────────────────────────────
CARLA_CONFIG_DIR = Path(os.path.expanduser("~/.config/falkTX"))
PRESET_DIR = Path(os.path.expanduser("~/carla-presets")) # User presets
SYSTEM_PRESET_DIR = Path("/home/oplabs/projects/raspberry-pi-mixer/config")
NAM_MODELS_DIR = Path(os.path.expanduser("~/nam-models"))
# Carla OSC control port (configured in Carla settings)
CARLA_OSC_PORT = 22752
os.makedirs(PRESET_DIR, exist_ok=True)
# ── Helper: list presets ───────────────────────────────
def list_presets() -> List[Dict]:
"""List all available presets."""
presets = []
for preset_file in sorted(PRESET_DIR.glob("*.carxp")):
stat = preset_file.stat()
presets.append({
"name": preset_file.stem,
"path": str(preset_file),
"size": preset_file.stat().st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"source": "user"
})
for preset_file in sorted(SYSTEM_PRESET_DIR.glob("*.carxp")):
stat = preset_file.stat()
presets.append({
"name": preset_file.stem,
"path": str(preset_file),
"size": preset_file.stat().st_size,
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat(),
"source": "system"
})
return presets
def cmd_list():
"""List presets command."""
presets = list_presets()
if not presets:
print("No presets found.")
print(f" System presets: {SYSTEM_PRESET_DIR}")
print(f" User presets: {PRESET_DIR}")
return
print(f"{'NAME':<30} {'SOURCE':<8} {'SIZE':>8} {'MODIFIED'}")
print("-" * 70)
for p in presets:
size_kb = p["size"] / 1024
print(f"{p['name']:<30} {p['source']:<8} {size_kb:>7.1f}K {p['modified'][:16]}")
# ── Helper: save preset ────────────────────────────────
def cmd_save(name: str):
"""Save current Carla state as a preset."""
carla_carxp = CARLA_CONFIG_DIR / "Carla.carxp"
if not carla_carxp.exists():
print(f"ERROR: Carla state file not found at {carla_carxp}", file=sys.stderr)
print("Is Carla running? The state is saved on exit.", file=sys.stderr)
sys.exit(1)
dest = PRESET_DIR / f"{name}.carxp"
shutil.copy2(carla_carxp, dest)
print(f"Saved preset: {dest}")
# Save metadata alongside
meta = {
"name": name,
"saved_at": datetime.now().isoformat(),
"source_file": str(carla_carxp),
}
meta_path = PRESET_DIR / f"{name}.meta.json"
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
print(f" Metadata: {meta_path}")
# ── Helper: load preset ────────────────────────────────
def cmd_load(name: str):
"""Load a preset into Carla."""
# Find the preset
preset_path = PRESET_DIR / f"{name}.carxp"
if not preset_path.exists():
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
if not preset_path.exists():
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
print(f" Looked in: {PRESET_DIR}, {SYSTEM_PRESET_DIR}", file=sys.stderr)
sys.exit(1)
target = CARLA_CONFIG_DIR / "Carla.carxp"
if target.exists():
backup = CARLA_CONFIG_DIR / f"Carla.carxp.backup-{int(time.time())}"
shutil.copy2(target, backup)
print(f"Backed up current state to: {backup}")
shutil.copy2(preset_path, target)
print(f"Loaded preset: {preset_path}{target}")
print("Restart Carla to apply.")
# ── Helper: export preset bundle ───────────────────────
def cmd_export(name: str, output_path: str):
"""Export a preset and its associated models/IRs as a .tar.gz bundle."""
preset_path = PRESET_DIR / f"{name}.carxp"
if not preset_path.exists():
preset_path = SYSTEM_PRESET_DIR / f"{name}.carxp"
if not preset_path.exists():
print(f"ERROR: Preset '{name}' not found.", file=sys.stderr)
sys.exit(1)
meta_path = PRESET_DIR / f"{name}.meta.json"
with tarfile.open(output_path, "w:gz") as tar:
tar.add(preset_path, arcname=f"{name}/{name}.carxp")
if meta_path.exists():
tar.add(meta_path, arcname=f"{name}/{name}.meta.json")
print(f"Exported preset bundle: {output_path}")
print(f" Contains: {preset_path}")
# ── Helper: import preset bundle ───────────────────────
def cmd_import(bundle_path: str):
"""Import a preset bundle from a .tar.gz file."""
if not os.path.exists(bundle_path):
print(f"ERROR: Bundle not found: {bundle_path}", file=sys.stderr)
sys.exit(1)
bundle_name = os.path.basename(bundle_path).replace(".tar.gz", "").replace(".tgz", "")
with tarfile.open(bundle_path, "r:gz") as tar:
tar.extractall(path=PRESET_DIR)
for member in tar.getmembers():
if member.name.endswith(".carxp"):
# Move to root of preset dir
extracted = PRESET_DIR / member.name
dest = PRESET_DIR / f"{bundle_name}.carxp"
shutil.move(str(extracted), str(dest))
print(f"Imported preset: {dest}")
elif member.name.endswith(".meta.json"):
meta_dest = PRESET_DIR / f"{bundle_name}.meta.json"
extracted = PRESET_DIR / member.name
if extracted.exists():
shutil.move(str(extracted), str(meta_dest))
# ── Helper: set NAM model ──────────────────────────────
def cmd_set_model(plugin_name: str, model_path: str):
"""Set the model for a NAM plugin in Carla via OSC."""
if not os.path.exists(model_path):
print(f"ERROR: Model file not found: {model_path}", file=sys.stderr)
print(f" Download models from: https://www.tone3000.com/", file=sys.stderr)
sys.exit(1)
# Carla OSC API:
# /Carla/set_plugin_parameter <plugin_id> <param_index> <value>
# For NAM, the model param is typically index 0 (atom:Path)
print(f"Setting model: {plugin_name}{model_path}")
print(f" Note: Model changes require Carla restart or manual parameter update.")
print(f" In Carla GUI: right-click {plugin_name} → atom:Path → select {model_path}")
# Try OSC if carla-control is available
osc_cmd = shutil.which("carla-control")
if osc_cmd:
try:
subprocess.run(
[osc_cmd, "--osc-port", str(CARLA_OSC_PORT),
"set_parameter", plugin_name, "0", model_path],
timeout=5
)
print(f" ✓ Model set via OSC.")
except Exception as e:
print(f" Could not set via OSC: {e}")
# ── CLI ────────────────────────────────────────────────
if __name__ == "__main__":
if len(sys.argv) < 2:
if __doc__:
print(__doc__.strip())
print()
cmd_list()
sys.exit(0)
command = sys.argv[1]
try:
if command == "list":
cmd_list()
elif command == "save":
if len(sys.argv) < 3:
print("Usage: carla-preset-manager save <name>", file=sys.stderr)
sys.exit(1)
cmd_save(sys.argv[2])
elif command == "load":
if len(sys.argv) < 3:
print("Usage: carla-preset-manager load <name>", file=sys.stderr)
sys.exit(1)
cmd_load(sys.argv[2])
elif command == "export":
if len(sys.argv) < 4:
print("Usage: carla-preset-manager export <name> <output.tar.gz>", file=sys.stderr)
sys.exit(1)
cmd_export(sys.argv[2], sys.argv[3])
elif command == "import":
if len(sys.argv) < 3:
print("Usage: carla-preset-manager import <bundle.tar.gz>", file=sys.stderr)
sys.exit(1)
cmd_import(sys.argv[2])
elif command == "set-model":
if len(sys.argv) < 4:
print("Usage: carla-preset-manager set-model <plugin-name> <model-path>", file=sys.stderr)
sys.exit(1)
cmd_set_model(sys.argv[2], sys.argv[3])
else:
print(f"Unknown command: {command}", file=sys.stderr)
if __doc__:
print(__doc__.strip(), file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
print("\nCancelled.")
sys.exit(0)
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
# jack-latency-test — measure USB audio round-trip latency at various buffer sizes
# Usage: sudo jack-latency-test [device] [sample_rate]
#
# Prerequisites:
# - JACK2 installed
# - Physical loopback cable: Output 1 -> Input 1
# - User in 'audio' group with realtime limits
set -e
DEVICE="${1:-hw:USB}"
RATE="${2:-48000}"
OUTPUT="system:playback_1"
INPUT="system:capture_1"
RESULT_FILE="/tmp/jack-latency-results.txt"
echo "=== JACK Latency Test ==="
echo "Device: $DEVICE"
echo "Sample rate: $RATE Hz"
echo "Output port: $OUTPUT"
echo "Input port: $INPUT"
echo ""
echo ">>> ENSURE PHYSICAL LOOPBACK CABLE IS CONNECTED: Output 1 -> Input 1 <<<"
echo "=========================================="
echo ""
: > "$RESULT_FILE"
# Test various buffer sizes
for PERIOD in 32 64 128 256 512 1024; do
PERIOD_MS=$(echo "scale=2; $PERIOD / $RATE * 1000" | bc 2>/dev/null || \
python3 -c "print(f'{$PERIOD / $RATE * 1000:.2f}')" 2>/dev/null || \
echo "?")
echo -n "Period ${PERIOD} frames (${PERIOD_MS}ms): "
# Kill any running JACK
killall -9 jackd 2>/dev/null || true
sleep 1
# Start JACK with this period
jackd -P70 -t2000 -d alsa -d "$DEVICE" -r "$RATE" -p "$PERIOD" -n 3 -S \
> /tmp/jackd-latency-test.log 2>&1 &
JACK_PID=$!
# Wait for JACK to be ready
for _ in $(seq 1 15); do
if jack_control status 2>/dev/null | grep -q "started"; then
break
fi
sleep 1
done
if ! jack_control status 2>/dev/null | grep -q "started"; then
echo "JACK failed to start with period $PERIOD, skipping"
killall -9 jackd 2>/dev/null || true
continue
fi
sleep 1
# Run jack_delay measurement
RESULT=$(jack_delay -O "$OUTPUT" -I "$INPUT" -c 128 2>&1)
JACK_DELAY_EXIT=$?
if [ $JACK_DELAY_EXIT -eq 0 ] && [ -n "$RESULT" ]; then
# Parse the result line (last line is the measurement)
MEASUREMENT=$(echo "$RESULT" | tail -1)
echo "$MEASUREMENT"
echo "${PERIOD} frames: ${MEASUREMENT}" >> "$RESULT_FILE"
else
echo "measurement failed (check loopback cable)"
echo "error output: $RESULT" >> "$RESULT_FILE"
fi
killall -9 jackd jack_delay 2>/dev/null || true
sleep 1
done
echo ""
echo "=========================================="
echo "Results saved to: $RESULT_FILE"
echo ""
cat "$RESULT_FILE"
echo ""
echo "Done."
echo ""
echo "Target: 128 frames should give <= 12ms round-trip."
echo "If above 12ms, check USB cable, power, CPU governor, and IRQ priorities."
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# jack-route-default — auto-connect JACK system captures to playbacks
# Run after JACK is started. Creates a 1:1 through-patch for direct monitoring.
jack_wait -w
# Get available ports
captures=$(jack_lsp -c system | grep capture || true)
playbacks=$(jack_lsp -c system | grep playback || true)
if [ -z "$captures" ] || [ -z "$playbacks" ]; then
echo "No system capture or playback ports found. Is JACK running?"
echo "Check: jack_lsp -c system"
exit 1
fi
# Route capture -> playback in pairs
# Assumes matching channel counts; pairs capture_1->playback_1, etc.
MAX_CHANNELS=18
for i in $(seq 1 $MAX_CHANNELS); do
CAP_PORT="system:capture_${i}"
PB_PORT="system:playback_${i}"
if jack_lsp -c system | grep -q "^${CAP_PORT}$" && \
jack_lsp -c system | grep -q "^${PB_PORT}$"; then
jack_connect "$CAP_PORT" "$PB_PORT" 2>/dev/null && \
echo " Connected: $CAP_PORT -> $PB_PORT" || \
echo " Already connected: $CAP_PORT -> $PB_PORT"
fi
done
# Optional: start a2jmidid for MIDI bridging
if command -v a2j_control &> /dev/null; then
if ! pgrep -x a2jmidid > /dev/null; then
a2j_control start 2>/dev/null && echo " MIDI bridge (a2jmidid) started"
fi
fi
echo "Routing complete."
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
# lv2lint-check.sh — Validate LV2 plugins for compatibility with Carla/RPi4B
# Uses lv2lint to check plugin spec compliance, plus runtime checks.
#
# Usage:
# chmod +x lv2lint-check.sh
# ./lv2lint-check.sh # Check all installed LV2 plugins
# ./lv2lint-check.sh /path/to/plugin.lv2 # Check a specific plugin
# ./lv2lint-check.sh --nam-only # Only check NAM plugin
# ./lv2lint-check.sh --list # List all installed LV2 plugins
#
# lv2lint checks (from https://git.open-music-kontrollers.ch/lv2/lv2lint):
# - URI validity, manifest parsing
# - Port definitions (in/out, control/audio/CV)
# - TTL consistency
# - Required features
# - Instantiation test
# - Parameter ranges
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[lv2lint]${NC} $*"; }
warn() { echo -e "${YELLOW}[lv2lint]${NC} $*"; }
fail() { echo -e "${RED}[lv2lint] FAIL:${NC} $*"; }
# ── Install lv2lint if not present ────────────────────
install_lv2lint() {
if ! command -v lv2lint >/dev/null 2>&1; then
log "lv2lint not found. Building from source..."
sudo apt update -qq
sudo apt install -y build-essential cmake git \
liblilv-dev libserd-dev libsord-dev libsratom-dev \
lv2-dev libexempi-dev libsndfile1-dev
LV2LINT_DIR="/tmp/lv2lint-build"
git clone --depth 1 https://git.open-music-kontrollers.ch/lv2/lv2lint "$LV2LINT_DIR" 2>/dev/null || \
git clone --depth 1 https://github.com/brummer10/lv2lint.git "$LV2LINT_DIR"
cd "$LV2LINT_DIR"
meson setup build 2>/dev/null || \
(mkdir -p build && cd build && cmake .. && make -j"$(nproc)")
if [ -d "build" ]; then
cd build
if [ -f "meson.build" ] || [ -f "../meson.build" ]; then
meson compile 2>/dev/null && sudo meson install 2>/dev/null || true
elif [ -f "Makefile" ]; then
make -j"$(nproc)" && sudo make install 2>/dev/null || true
fi
fi
if command -v lv2lint >/dev/null 2>&1; then
log "lv2lint installed successfully."
else
warn "Could not install lv2lint. Skipping spec checks."
fi
fi
}
# ── List installed LV2 plugins ────────────────────────
list_plugins() {
log "Installed LV2 plugins:"
echo ""
for dir in /usr/lib/lv2 /usr/local/lib/lv2 /usr/lib/aarch64-linux-gnu/lv2 ~/.lv2; do
if [ -d "$dir" ]; then
find "$dir" -maxdepth 2 -name "*.ttl" | while read -r ttl; do
uri=$(grep -oP 'a\s+lv2:Plugin' "$(dirname "$ttl")"/*.ttl 2>/dev/null | head -1 || true)
if [ -n "$uri" ]; then
echo " $(basename "$(dirname "$ttl")")"
fi
done | sort -u
fi
done
}
# ── Run lv2lint checks on a plugin ─────────────────────
check_plugin() {
local plugin_uri="$1"
local plugin_path="$2"
local issues=0
log "Checking: $(basename "$plugin_path")"
echo " URI: $plugin_uri"
# Manifest check
if [ ! -f "$plugin_path/manifest.ttl" ]; then
fail " Missing manifest.ttl"
issues=$((issues + 1))
fi
# Binary check
local so_file=$(find "$plugin_path" -name "*.so" 2>/dev/null | head -1)
if [ -z "$so_file" ]; then
warn " No .so file found (might be an extension-only bundle)"
else
echo " Binary: $so_file"
file "$so_file" 2>/dev/null | sed 's/^/ /'
# ARM-specific: check architecture
if file "$so_file" 2>/dev/null | grep -q "x86"; then
fail " WRONG ARCH: x86 binary on ARM. Rebuild!"
issues=$((issues + 1))
fi
# Check shared library deps
echo " Shared libs needed:"
ldd "$so_file" 2>/dev/null | grep "not found" | while read -r missing; do
fail " MISSING: $missing"
issues=$((issues + 1))
done || true
fi
# Run lv2lint if available
if command -v lv2lint >/dev/null 2>&1; then
echo " lv2lint output:"
local lint_output
lint_output=$(lv2lint -M pack -q -s skip "$plugin_uri" 2>&1) || true
if [ -n "$lint_output" ]; then
echo "$lint_output" | sed 's/^/ /'
# Count warnings/errors
local lint_issues=$(echo "$lint_output" | grep -ciE "(FAIL|ERROR|WARN)" || true)
issues=$((issues + lint_issues))
else
echo " ✓ No issues found"
fi
fi
# Check for Carla-specific features
if grep -qi "carla" "$plugin_path"/*.ttl 2>/dev/null; then
echo " ✓ Contains Carla-specific extensions"
fi
echo ""
return $issues
}
# ── Check NAM specifically ─────────────────────────────
check_nam() {
local nam_dir=""
for dir in /usr/lib/lv2/neural_amp_modeler.lv2 \
/usr/local/lib/lv2/neural_amp_modeler.lv2 \
~/.lv2/neural_amp_modeler.lv2 \
/tmp/nam-lv2-build/build/neural_amp_modeler.lv2; do
if [ -d "$dir" ]; then
nam_dir="$dir"
break
fi
done
if [ -z "$nam_dir" ]; then
warn "NAM LV2 plugin not found. Build it first: ./scripts/nam-build.sh --install"
return 1
fi
check_plugin "http://github.com/mikeoliphant/neural-amp-modeler-lv2" "$nam_dir"
# NAM-specific: check if atom:Path is supported (critical for model loading)
if grep -q "atom:Path" "$nam_dir"/*.ttl 2>/dev/null; then
log "✓ NAM supports atom:Path (model selection in Carla works)"
else
warn "NAM does not declare atom:Path support. Model loading may not work in Carla."
fi
# Performance note for RPi4B
echo ""
warn "NAM on RPi4B — performance note:"
echo " Standard models may cause xruns at 128f buffer."
echo " Use 'nano' or 'feather' models from https://www.tone3000.com/"
echo " Increase JACK buffer to 256 samples if needed."
}
# ── Check all installed plugins ────────────────────────
check_all() {
local total_issues=0
for dir in /usr/lib/lv2 /usr/local/lib/lv2 /usr/lib/aarch64-linux-gnu/lv2 ~/.lv2; do
if [ ! -d "$dir" ]; then continue; fi
for bundle in "$dir"/*/; do
if [ -f "$bundle/manifest.ttl" ]; then
# Extract URI from manifest
local uri=""
uri=$(grep -oP 'a\s+lv2:Plugin' "$bundle"/*.ttl 2>/dev/null | head -1 | sed 's/:.*//' || true)
if [ -z "$uri" ]; then
# Just use bundle name
uri="urn:lv2:$(basename "$bundle")"
fi
check_plugin "$uri" "${bundle%/}" || total_issues=$((total_issues + $?))
fi
done
done
echo ""
log "Total issues: $total_issues"
}
# ── Main ───────────────────────────────────────────────
case "${1:-}" in
--list|-l)
list_plugins
exit 0
;;
--nam-only|-n)
install_lv2lint
check_nam
exit 0
;;
"")
install_lv2lint
check_all
check_nam
;;
*)
if [ -d "$1" ]; then
install_lv2lint
check_plugin "urn:lv2:$(basename "$1")" "$1"
else
echo "Usage: $0 [--list|--nam-only|<plugin-path>]"
exit 1
fi
;;
esac
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""MIDI Learn Mode — interactive CLI for assigning MIDI controllers to mixer parameters.
Run this on the RPi to map physical knobs/faders to the mixer's parameters.
The learned mappings are saved to ~/.config/rpi-mixer/mappings/ for the daemon.
Usage:
midi-learn-cli # Interactive learn mode
midi-learn-cli --list # List current mappings
midi-learn-cli --clear # Clear all mappings for default session
midi-learn-cli --batch # Batch learn: assign faders in order
midi-learn-cli --session mysetup # Use a named session
midi-learn-cli --export mysetup.json # Export mappings to JSON file
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
from pathlib import Path
# Add project root to path when run as script
_proj_root = Path(__file__).resolve().parent.parent
if str(_proj_root) not in sys.path:
sys.path.insert(0, str(_proj_root))
from src.midi.types import ParameterType, MIDIMapping
from src.midi.midi_learn import MIDILearn, LearnState
from src.midi.mapping_store import (
save_mappings,
load_mappings,
list_sessions,
DEFAULT_SESSION_NAME,
)
from src.mixer import ParameterRegistry
logger = logging.getLogger(__name__)
# Global flag for SIGINT handling
_running = True
def _handle_sigint(signum, frame):
global _running
print("\nInterrupted.", file=sys.stderr)
_running = False
def _setup_logging(verbose: bool = False):
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(level=level, format="%(levelname)s %(message)s")
def _list_mappings(session: str):
"""Print current mappings for a session."""
mappings = load_mappings(session)
if not mappings:
print(f"No mappings found for session '{session}'.")
return
print(f"Session '{session}': {len(mappings)} mappings\n")
for i, m in enumerate(mappings):
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
dev = f" [{m.source_device}]" if m.source_device else ""
ch = f"CH{m.param_channel}" if m.param_channel >= 0 else "Master"
curve = f" {m.curve}" if m.curve != "linear" else ""
state = "✓" if m.enabled else "✗"
print(f" {i:3d}. [{state}] CH{m.channel:2d} {src:<12s} → {ch:6s} {m.param_type.value:<16s} [{m.midi_min}..{m.midi_max}]{curve}{dev}")
def _interactive_learn(session: str):
"""Run the interactive MIDI learn CLI."""
global _running
signal.signal(signal.SIGINT, _handle_sigint)
registry = ParameterRegistry()
learn = MIDILearn()
mappings: list[MIDIMapping] = load_mappings(session)
print("╔══════════════════════════════════════════════════════════╗")
print("║ RPi Audio Mixer — MIDI Learn Mode ║")
print("╠══════════════════════════════════════════════════════════╣")
print("║ Commands: ║")
print("║ l <param> [ch] Learn: start listening for a parameter ║")
print("║ b Batch learn: assign faders in order ║")
print("║ c Confirm captured mapping ║")
print("║ d Discard captured mapping ║")
print("║ list List current mappings ║")
print("║ save Save mappings to disk ║")
print("║ del <n> Delete mapping #n ║")
print("║ status Show learn status ║")
print("║ help Show this help ║")
print("║ quit Exit (auto-saves) ║")
print("╚══════════════════════════════════════════════════════════╝")
print()
print(f"Session: {session} | Mappings loaded: {len(mappings)}")
print(f"Parameters available: {len(list(registry.iter_all()))}")
print()
print("Available parameters:")
_print_params(registry)
print()
print("Type a command. Tip: plug in your MIDI controller first,")
print("then type 'l volume 0' and wiggle the fader you want to assign.")
print()
while _running:
try:
raw = input("midi-learn> ").strip()
if not raw:
continue
parts = raw.split()
cmd = parts[0].lower()
if cmd == "quit" or cmd == "exit" or cmd == "q":
break
elif cmd == "help" or cmd == "h":
_print_help()
elif cmd == "list" or cmd == "ls":
_list_current(mappings)
elif cmd == "status" or cmd == "st":
_print_status(learn, mappings)
elif cmd == "save" or cmd == "s":
save_mappings(mappings, session)
print(f"Saved {len(mappings)} mappings to session '{session}'.")
elif cmd == "l" or cmd == "learn":
_cmd_learn(parts, learn, registry)
elif cmd == "b" or cmd == "batch":
_cmd_batch(parts, learn, registry)
elif cmd == "c" or cmd == "confirm":
_cmd_confirm(learn, mappings)
elif cmd == "d" or cmd == "discard":
learn.discard()
print("Discarded. Ready for new learn.")
elif cmd == "del" or cmd == "delete":
_cmd_delete(parts, mappings)
elif cmd == "clear":
mappings.clear()
print("All mappings cleared.")
else:
print(f"Unknown command: {cmd} (type 'help' for commands)")
except EOFError:
break
except KeyboardInterrupt:
print()
break
# Auto-save on exit
if mappings:
save_mappings(mappings, session)
print(f"Auto-saved {len(mappings)} mappings to '{session}'.")
else:
print("No mappings to save.")
def _print_help():
print("""
Commands:
learn, l <param> [ch] Start learning for a parameter
batch, b [ch] Batch learn all channel faders in order
confirm, c Confirm the last captured mapping
discard, d Discard the last captured mapping
list, ls List current mappings
delete, del <n> Delete mapping #n
save, s Save mappings to disk
status, st Show current learn status
clear Clear all mappings
quit, q Exit (auto-saves)
""")
def _print_params(registry: ParameterRegistry):
"""Print a compact parameter reference."""
# Group by category
from src.midi.types import ParameterCategory
cats: dict[ParameterCategory, list[str]] = {}
for p in registry.iter_all():
cats.setdefault(p.category, []).append(p.param_type.value)
for cat, params in sorted(cats.items()):
unique = sorted(set(params))
print(f" {cat.value}: {', '.join(unique[:12])}{'...' if len(unique) > 12 else ''}")
def _list_current(mappings: list[MIDIMapping]):
if not mappings:
print("No mappings defined.")
return
for i, m in enumerate(mappings):
src = f"NRPN{m.nrpn_number}" if m.is_nrpn else f"CC{m.controller}"
print(f" {i:3d}. CH{m.channel} {src} → {m.param_type.value} CH{m.param_channel} {'[OFF]' if not m.enabled else ''}")
def _print_status(learn: MIDILearn, mappings: list[MIDIMapping]):
print(f"Learn state: {learn.status_text}")
print(f"Mappings: {len(mappings)}")
def _cmd_learn(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
"""Handle 'learn <param> [channel]' command."""
if len(parts) < 2:
print("Usage: learn <param> [channel]")
print("Example: learn volume 0")
return
param_name = parts[1].lower()
channel = int(parts[2]) if len(parts) > 2 else -1
# Resolve parameter type
try:
pt = ParameterType(param_name)
except ValueError:
# Try fuzzy match
matches = [p for p in ParameterType if param_name in p.value]
if len(matches) == 1:
pt = matches[0]
elif matches:
print(f"Ambiguous: {', '.join(m.value for m in matches)}")
return
else:
print(f"Unknown parameter: {param_name}")
print(f"Available: {', '.join(p.value for p in ParameterType)}")
return
learn.start_learn(pt, channel)
if channel >= 0:
print(f"Listening for MIDI CC on any channel → {pt.value} CH{channel}")
else:
print(f"Listening for MIDI CC on any channel → {pt.value} (Master)")
print(" Wiggle the knob/fader you want to assign, then type 'confirm' or 'c'.")
def _cmd_batch(parts: list[str], learn: MIDILearn, registry: ParameterRegistry):
"""Handle 'batch [channel]' command — learn all channel params in sequence."""
channel = int(parts[1]) if len(parts) > 1 else 0
params: list[tuple[ParameterType, int, str]] = [
(ParameterType.VOLUME, channel, f"CH{channel} Vol"),
(ParameterType.PAN, channel, f"CH{channel} Pan"),
(ParameterType.MUTE, channel, f"CH{channel} Mute"),
(ParameterType.GAIN, channel, f"CH{channel} Gain"),
]
learn.start_batch(params)
print(f"Batch learn for CH{channel}: wiggle each control in order.")
print(f" [1/4] {params[0][2]} — wiggle the fader now...")
def _cmd_confirm(learn: MIDILearn, mappings: list[MIDIMapping]):
mapping = learn.confirm()
if mapping:
mappings.append(mapping)
print(f"Confirmed: {mapping.label or f'CC{mapping.controller} → {mapping.param_type.value}'}")
else:
print("Nothing to confirm. Use 'learn <param>' first.")
def _cmd_delete(parts: list[str], mappings: list[MIDIMapping]):
if len(parts) < 2:
print("Usage: delete <n>")
return
try:
idx = int(parts[1])
if 0 <= idx < len(mappings):
removed = mappings.pop(idx)
print(f"Deleted: {removed.label or f'CC{removed.controller}'}")
else:
print(f"Invalid index: {idx} (0-{len(mappings)-1})")
except ValueError:
print(f"Invalid index: {parts[1]}")
# ── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="MIDI Learn Mode — assign controllers to mixer parameters",
)
parser.add_argument("--list", action="store_true", help="List current mappings")
parser.add_argument("--clear", action="store_true", help="Clear all mappings")
parser.add_argument("--batch", action="store_true", help="Batch learn mode (non-interactive)")
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session name")
parser.add_argument("--export", metavar="FILE", help="Export mappings to JSON file")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
args = parser.parse_args()
_setup_logging(args.verbose)
if args.export:
mappings = load_mappings(args.session)
save_mappings(mappings, os.path.splitext(args.export)[0])
print(f"Exported {len(mappings)} mappings to {args.export}")
return
if args.list:
_list_mappings(args.session)
return
if args.clear:
save_mappings([], args.session)
print(f"Cleared session '{args.session}'.")
return
# Interactive mode
_interactive_learn(args.session)
if __name__ == "__main__":
main()
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""MIDI Mapper Daemon — main runtime for the Raspberry Pi RT Audio Mixer.
Reads MIDI events from connected USB controllers via ALSA sequencer
or rtmidi, applies mapping rules, and routes mapped parameter changes
to the mixer engine and JACK MIDI output ports.
Usage:
midi-mapper # Run with default session
midi-mapper --session live-set # Use named mapping session
midi-mapper --list-devices # List connected MIDI devices
midi-mapper --no-jack # Don't create JACK MIDI ports
midi-mapper --port 0 # Only listen on ALSA seq client 0
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
from pathlib import Path
# Add project root to path when run as script
_proj_root = Path(__file__).resolve().parent.parent
if str(_proj_root) not in sys.path:
sys.path.insert(0, str(_proj_root))
from src.midi.types import (
MIDIMessage,
MIDIMessageType,
MIDIMapping,
)
from src.midi.midi_engine import MIDIEngine
from src.midi.midi_clock import MIDIClock
from src.midi.jack_midi_bridge import JACKMIDIBridge, midi_cc, midi_note_on, midi_note_off
from src.midi.device_discovery import discover_all, MIDIDevice
from src.midi.mapping_store import load_mappings, DEFAULT_SESSION_NAME
from src.mixer import ParameterRegistry
logger = logging.getLogger(__name__)
_running = True
def _handle_signal(signum, frame):
global _running
logger.info("Received signal %d, shutting down...", signum)
_running = False
def _setup_logging(verbose: bool = False, quiet: bool = False):
if quiet:
level = logging.WARNING
elif verbose:
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(
level=level,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
datefmt="%H:%M:%S",
)
def _list_devices():
"""Print connected MIDI devices and exit."""
devices = discover_all()
if not devices:
print("No MIDI devices found.")
print("Make sure your USB MIDI controller is plugged in.")
print("Check with: ls /dev/snd/midi* or cat /proc/asound/seq/clients")
return
print(f"\nFound {len(devices)} MIDI device(s):\n")
for d in devices:
vidpid = f"{d.usb_vendor_id:04x}:{d.usb_product_id:04x}" if d.usb_vendor_id else "n/a"
print(f" {d.display_name}")
print(f" Device node: {d.device_node}")
print(f" USB VID:PID: {vidpid}")
print(f" ALSA seq client: {d.alsa_client_id or 'n/a'}")
print(f" Serial: {d.serial or 'n/a'}")
print()
def _print_parameter_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage):
"""Default callback: log parameter changes to console."""
ch_str = f"CH{mapping.param_channel}" if mapping.param_channel >= 0 else "Master"
logger.info(
"PARAM | %s %s = %.3f (MIDI CC%d=%d)",
ch_str,
mapping.param_type.value,
value,
mapping.controller,
raw_msg.value if raw_msg.value is not None else -1,
)
def _jack_callback(mapping: MIDIMapping, value: float, raw_msg: MIDIMessage, bridge: JACKMIDIBridge):
"""Forward mapped events to JACK MIDI ports."""
ch = mapping.channel
cc = mapping.controller
midi_val = int(value * 127)
midi_val = max(0, min(127, midi_val))
# Route to different JACK MIDI ports based on parameter category
from src.midi.types import ParameterCategory
port_idx = 0
if mapping.param_type.value.startswith("eq_"):
port_idx = 1
elif mapping.param_type.value.startswith("comp_"):
port_idx = 1
elif mapping.param_type.value.startswith("fx_"):
port_idx = 2
elif mapping.param_type.value in ("play", "stop", "record", "loop"):
port_idx = 3
if cc >= 0:
raw = midi_cc(ch, cc, midi_val)
bridge.send_midi(port_idx, raw)
def _open_midi_input(device: MIDIDevice) -> object | None:
"""Open a MIDI input port for a device.
Tries rtmidi first, falls back to ALSA sequencer.
Returns the open port object or None.
"""
# Try rtmidi
try:
import rtmidi
midi_in = rtmidi.MidiIn()
# Find the port matching our device
ports = midi_in.get_ports()
for i, port_name in enumerate(ports):
if device.alsa_client_name and device.alsa_client_name.lower() in port_name.lower():
midi_in.open_port(i)
logger.info("Opened rtmidi port %d: %s", i, port_name)
return midi_in
if device.product and device.product.lower() in port_name.lower():
midi_in.open_port(i)
logger.info("Opened rtmidi port %d: %s", i, port_name)
return midi_in
# If no match, open first available
if ports:
midi_in.open_port(0)
logger.info("Opened rtmidi port 0: %s", ports[0])
return midi_in
except ImportError:
logger.debug("rtmidi not available")
except Exception as exc:
logger.warning("rtmidi open failed: %s", exc)
# Try ALSA sequencer
try:
import alsaseq
client = alsaseq.SequencerClient("rpi-mixer-input", client_type=alsaseq.SEQ_CLIENT_DUPLEX)
src = (device.alsa_client_id, 0)
dest = (client.client_id, 0)
port = client.create_port(
f"input_{device.alsa_client_id}",
caps=alsaseq.SEQ_PORT_CAP_WRITE | alsaseq.SEQ_PORT_CAP_SUBS_WRITE,
type=alsaseq.SEQ_PORT_TYPE_MIDI_GENERIC,
)
client.connect_ports(src, dest)
logger.info("Opened ALSA seq port from client %d", device.alsa_client_id)
return client
except ImportError:
logger.debug("alsaseq not available")
except Exception as exc:
logger.warning("ALSA seq open failed: %s", exc)
return None
def _read_midi_rtmidi(midi_in, engine: MIDIEngine, clock: MIDIClock):
"""Poll rtmidi for events and feed them to the engine."""
msg = midi_in.get_message()
while msg is not None:
data, timestamp = msg
if len(data) >= 1:
_dispatch_raw_midi(data, timestamp, engine, clock)
msg = midi_in.get_message()
def _dispatch_raw_midi(data: tuple | list, timestamp: float, engine: MIDIEngine, clock: MIDIClock):
"""Parse raw MIDI bytes and dispatch to engine or clock."""
if not data:
return
status = data[0]
# System realtime messages → clock
if status >= 0xF8:
clock.process_message(status)
return
# Channel voice messages → engine
if status >= 0x80 and status < 0xF0:
msg_type = MIDIMessageType.from_status(status)
channel = status & 0x0F
controller = data[1] if len(data) > 1 else None
value = data[2] if len(data) > 2 else None
midi_msg = MIDIMessage(
msg_type=msg_type,
channel=channel,
controller=controller,
value=value,
timestamp=time.monotonic(),
)
engine.process_event(midi_msg)
def _main_loop(args):
"""Main event loop: discover devices, open inputs, process MIDI."""
global _running
# Load mappings
mappings = load_mappings(args.session)
logger.info("Loaded %d mappings from session '%s'", len(mappings), args.session)
# Create engine
engine = MIDIEngine()
engine.set_mappings(mappings)
engine.on_mapped(_print_parameter_callback)
# Create clock
clock = MIDIClock()
clock.on_transport(lambda ev: logger.info("Transport: %s", ev))
clock.on_tempo_change(lambda bpm, raw, stable: logger.info("Tempo: %.1f BPM (stable=%s)", bpm, stable))
# Create JACK bridge
bridge = JACKMIDIBridge()
if not args.no_jack:
bridge.start()
engine.on_mapped(lambda m, v, r: _jack_callback(m, v, r, bridge))
logger.info("JACK MIDI bridge active")
# Create parameter registry
registry = ParameterRegistry()
engine.on_mapped(lambda m, v, r: registry.set_value(m.param_type, m.param_channel, v))
# Discover and open devices
devices = discover_all()
logger.info("Found %d MIDI device(s)", len(devices))
midi_inputs = []
for dev in devices:
if args.port >= 0 and dev.alsa_client_id != args.port:
continue
inp = _open_midi_input(dev)
if inp:
midi_inputs.append((dev, inp))
if not midi_inputs:
logger.warning("No MIDI inputs opened. Is a controller plugged in?")
if not args.allow_no_device:
logger.error("No MIDI devices available. Use --list-devices to check, or --allow-no-device to run anyway.")
return
# Main loop
engine.start()
logger.info("MIDI mapper running. Press Ctrl+C to stop.")
stats_interval = 10.0
last_stats = time.monotonic()
while _running:
# Poll each input
for dev, inp in midi_inputs:
try:
if hasattr(inp, 'get_message'): # rtmidi
_read_midi_rtmidi(inp, engine, clock)
# ALSA seq events are handled differently (callback-based)
except Exception as exc:
logger.error("Error reading from %s: %s", dev.display_name, exc)
# Periodic stats
now = time.monotonic()
if now - last_stats >= stats_interval:
s = engine.stats
logger.info(
"Stats: %d events, %d mapped, %.1f ev/s, %d mappings active",
s["events_total"], s["events_mapped"],
s["events_per_second"], s["mappings_active"],
)
last_stats = now
time.sleep(0.001) # ~1kHz poll
# Cleanup
engine.stop()
if bridge.is_active:
bridge.stop()
for dev, inp in midi_inputs:
try:
if hasattr(inp, 'close_port'):
inp.close_port()
except Exception:
pass
logger.info("MIDI mapper shut down cleanly.")
# ── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="MIDI Mapper Daemon — RPi Audio Mixer MIDI engine",
)
parser.add_argument("--session", default=DEFAULT_SESSION_NAME, help="Mapping session to load")
parser.add_argument("--list-devices", action="store_true", help="List connected MIDI devices and exit")
parser.add_argument("--no-jack", action="store_true", help="Don't create JACK MIDI output ports")
parser.add_argument("--port", type=int, default=-1, help="Only listen on specific ALSA seq client ID")
parser.add_argument("--allow-no-device", action="store_true", help="Run even if no MIDI devices are found")
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging")
parser.add_argument("--quiet", "-q", action="store_true", help="Minimal logging")
args = parser.parse_args()
_setup_logging(args.verbose, args.quiet)
if args.list_devices:
_list_devices()
return
# Signal handling
signal.signal(signal.SIGINT, _handle_signal)
signal.signal(signal.SIGTERM, _handle_signal)
_main_loop(args)
if __name__ == "__main__":
main()
+179
View File
@@ -0,0 +1,179 @@
#!/bin/bash
# nam-build.sh — Build Neural Amp Modeler LV2 plugin for RPi4B (ARM Cortex-A72)
# This builds the headless LV2 version that Carla can host.
# Uses RTNeural for real-time neural inference (no libtorch needed).
#
# Usage:
# chmod +x nam-build.sh
# ./nam-build.sh # Build only
# ./nam-build.sh --install # Build + install LV2 to /usr/lib/lv2
# ./nam-build.sh --clean # Clean rebuild
#
# Note: NAM models are CPU-intensive. For RPi4B, use "feather" or "nano"
# sized models from https://www.tone3000.com/. Standard models may xrun.
set -euo pipefail
NAM_REPO="https://github.com/mikeoliphant/neural-amp-modeler-lv2.git"
NAM_DIR="/tmp/nam-lv2-build"
JOBS=$(nproc 2>/dev/null || echo 4)
# Cortex-A72 optimized flags
# Use -march=armv8-a (no crypto needed for NAM) + -O3
# Enable NEON explicitly for vectorized math
A72_FLAGS="-march=armv8-a -mtune=cortex-a72 -O3 -ffast-math -ftree-vectorize"
NEON_FLAGS="-mfpu=neon"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[nam-build]${NC} $*"; }
warn() { echo -e "${YELLOW}[nam-build] WARN:${NC} $*"; }
err() { echo -e "${RED}[nam-build] ERROR:${NC} $*" >&2; exit 1; }
INSTALL=false
CLEAN=false
BUILD_TYPE="Release"
while [[ $# -gt 0 ]]; do
case "$1" in
--install) INSTALL=true; shift ;;
--clean) CLEAN=true; shift ;;
--debug) BUILD_TYPE="Debug"; shift ;;
*) echo "Unknown flag: $1"; exit 1 ;;
esac
done
# ── Check platform ─────────────────────────────────────
ARCH=$(uname -m)
if [[ "$ARCH" != "aarch64" ]] && [[ "$ARCH" != "armv7l" ]]; then
warn "Not running on ARM (detected $ARCH). Cross-compilation not supported."
warn "Run this script directly on the Raspberry Pi 4B."
fi
# ── Install build dependencies ─────────────────────────
log "Installing NAM build dependencies..."
sudo apt update -qq
sudo apt install -y \
build-essential \
cmake \
pkg-config \
g++ \
git \
libeigen3-dev \
lv2-dev \
liblilv-dev \
libserd-dev \
libsord-dev \
libsratom-dev
# Check for Eigen3 (critical)
if ! pkg-config --modversion eigen3 2>/dev/null && ! dpkg -l | grep -q libeigen3-dev; then
err "Eigen3 not found. Install with: sudo apt install libeigen3-dev"
fi
log "Eigen3 found: $(pkg-config --modversion eigen3 2>/dev/null || echo 'version unknown')"
# ── Clone NAM-LV2 ──────────────────────────────────────
if [ "$CLEAN" = true ] || [ ! -d "$NAM_DIR" ]; then
log "Cloning NAM-LV2 repository (with submodules)..."
rm -rf "$NAM_DIR"
git clone --recursive -j4 "$NAM_REPO" "$NAM_DIR"
fi
cd "$NAM_DIR"
# ── Check submodule status ─────────────────────────────
if [ ! -f "NeuralAudio/CMakeLists.txt" ]; then
log "Initializing submodules..."
git submodule update --init --recursive
fi
# ── Create build directory ─────────────────────────────
mkdir -p build
cd build
# ── Configure CMake with ARM Cortex-A72 optimizations ──
log "Configuring CMake (${BUILD_TYPE}, Cortex-A72 optimized)..."
# NeuralAudio performance options for ARM:
# USE_NATIVE_ARCH=ON → auto-detects CPU features
# NEON_OPTIMIZATIONS=ON → enable NEON SIMD in RTNeural
# RTNEURAL_NEON=ON → use NEON backend in RTNeural
#
# For maximum RPi4B performance:
# LTO=ON → link-time optimization
# SMARTCACHING=ON → cache layer activations to avoid recomputation
cmake .. \
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
-DCMAKE_CXX_FLAGS="$A72_FLAGS" \
-DCMAKE_C_FLAGS="$A72_FLAGS" \
-DUSE_NATIVE_ARCH=ON \
-DLTO=ON \
2>&1 | tee /tmp/nam-configure.log
# ── Build ──────────────────────────────────────────────
log "Building NAM LV2 (${JOBS} parallel jobs)..."
make -j"$JOBS" 2>&1 | tee /tmp/nam-build.log
# ── Check build results ────────────────────────────────
if [ -d "neural_amp_modeler.lv2" ]; then
log "✓ NAM LV2 bundle built successfully."
LV2_SIZE=$(du -sh neural_amp_modeler.lv2 | cut -f1)
log " Plugin size: $LV2_SIZE"
if [ -f "neural_amp_modeler.lv2/neural_amp_modeler.so" ]; then
file neural_amp_modeler.lv2/neural_amp_modeler.so
fi
else
err "NAM LV2 bundle not found. Check /tmp/nam-build.log"
fi
# ── Install (optional) ─────────────────────────────────
if [ "$INSTALL" = true ]; then
log "Installing NAM LV2 to system..."
LV2_DIR="/usr/lib/lv2"
# Respect LIB_INSTALL_DIR if set
if [ -n "${LIB_INSTALL_DIR:-}" ]; then
LV2_DIR="$LIB_INSTALL_DIR"
fi
sudo mkdir -p "$LV2_DIR"
sudo cp -r neural_amp_modeler.lv2 "$LV2_DIR/"
log "✓ Installed to $LV2_DIR/neural_amp_modeler.lv2"
# Verify with lv2ls
if command -v lv2ls >/dev/null 2>&1; then
echo ""
log "LV2 plugins containing 'neural':"
lv2ls 2>/dev/null | grep -i neural || warn "NAM not showing in lv2ls yet. Run: lv2ls"
fi
fi
# ── Model download guidance ────────────────────────────
# Check if lv2_validate is available (from lilv-utils)
log ""
log "╔══════════════════════════════════════════════╗"
log "║ NAM LV2 Build Summary ║"
log "╚══════════════════════════════════════════════╝"
log ""
log " Bundle: neural_amp_modeler.lv2"
log " Platform: $ARCH (Cortex-A72 optimized)"
log " Build type: $BUILD_TYPE"
log " Installed: $([ "$INSTALL" = true ] && echo "yes" || echo "no (use --install)")"
log ""
log " Model recommendations for RPi4B:"
log " • Use 'nano' or 'feather' sized models"
log " • Standard models are too CPU-heavy"
log " • Browse: https://www.tone3000.com/search?sizes=feather"
log ""
log " Using NAM in Carla:"
log " 1. Start Carla: carla ./config/carla-default.carxp"
log " 2. Add Plugin → LV2 → Neural Amp Modeler"
log " 3. Set model path via atom:Path parameter"
log " 4. Place an IR loader AFTER NAM for cab sim"
log ""
log " Download example models:"
log " mkdir -p ~/nam-models"
log " # Download feather models from Tone3000 and place here"
log ""
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
"""Plugin Manager CLI — scan, list, install, remove, and manage audio plugins.
Usage:
plugin-mgr scan Full scan and registry sync
plugin-mgr list List all registered plugins
plugin-mgr list --format=lv2 List by format
plugin-mgr list --category=reverb List by category
plugin-mgr search <query> Search plugins
plugin-mgr info <uri|name> Show plugin details
plugin-mgr install-local <path> Install from local path
plugin-mgr install-nam <path> Install local .nam model
plugin-mgr download-nam <url> Download .nam model
plugin-mgr remove <uri|name> Remove plugin (registry only)
plugin-mgr remove --files <uri> Remove plugin + delete files
plugin-mgr enable <uri> Enable a disabled plugin
plugin-mgr disable <uri> Disable a plugin
plugin-mgr blacklist <uri> Blacklist a plugin
plugin-mgr unblacklist <uri> Remove from blacklist
plugin-mgr blacklist-list Show all blacklist rules
plugin-mgr blacklist-add <pat> Add a user blacklist rule
plugin-mgr nam-list List installed NAM models
plugin-mgr stats Show registry statistics
plugin-mgr vacuum Compact/optimize the database
"""
from __future__ import annotations
import argparse
import os
import sys
import textwrap
from pathlib import Path
# Ensure the src directory is on the path
_project_root = Path(__file__).resolve().parent.parent
_src = _project_root / "src"
if str(_src) not in sys.path:
sys.path.insert(0, str(_src))
from plugin import (
PluginManager,
PluginCategory,
PluginFormat,
PluginStatus,
PluginBlacklist,
BlacklistEntry,
CATEGORY_LABELS,
BUILTIN_BLACKLIST,
)
# ── CLI argument definitions ─────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="plugin-mgr",
description="RPi Mixer Plugin Manager — scan, register, and manage audio plugins",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
plugin-mgr scan
plugin-mgr list --format=lv2
plugin-mgr list --category=reverb
plugin-mgr search "tube amp"
plugin-mgr install-nam ~/downloads/my-tone.nam
plugin-mgr stats
"""),
)
sp = parser.add_subparsers(dest="command", help="Command to execute")
# scan
sp.add_parser("scan", help="Scan filesystem and sync registry")
# list
list_p = sp.add_parser("list", help="List registered plugins")
list_p.add_argument("--format", "-f", help="Filter by format (lv2, vst3, ladspa, nam)")
list_p.add_argument("--category", "-c", help="Filter by category (reverb, delay, etc.)")
list_p.add_argument("--loadable", "-l", action="store_true", help="Only show loadable plugins")
list_p.add_argument("--verbose", "-v", action="store_true", help="Show ports and metadata")
# search
search_p = sp.add_parser("search", help="Search plugins by name/description")
search_p.add_argument("query", help="Search query")
search_p.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
# info
info_p = sp.add_parser("info", help="Show detailed plugin info")
info_p.add_argument("plugin", help="Plugin URI or name")
# install-local
install_p = sp.add_parser("install-local", help="Install plugin from local path")
install_p.add_argument("path", help="Path to plugin bundle directory or file")
install_p.add_argument("--format", "-f", default="lv2", help="Plugin format (default: lv2)")
# install-nam
nam_inst_p = sp.add_parser("install-nam", help="Install local .nam model")
nam_inst_p.add_argument("path", help="Path to .nam file")
nam_inst_p.add_argument("--name", "-n", help="Name for the model")
# download-nam
nam_dl_p = sp.add_parser("download-nam", help="Download .nam model from URL")
nam_dl_p.add_argument("url", help="Download URL")
nam_dl_p.add_argument("--name", "-n", help="Name for the model")
# nam-list
sp.add_parser("nam-list", help="List installed NAM models")
# remove
remove_p = sp.add_parser("remove", help="Remove plugin from registry")
remove_p.add_argument("plugin", help="Plugin URI or name")
remove_p.add_argument("--files", action="store_true", help="Also delete plugin files from disk")
# enable / disable
enable_p = sp.add_parser("enable", help="Enable a disabled plugin")
enable_p.add_argument("plugin", help="Plugin URI or name")
disable_p = sp.add_parser("disable", help="Disable a plugin")
disable_p.add_argument("plugin", help="Plugin URI or name")
# blacklist
bl_p = sp.add_parser("blacklist", help="Blacklist a plugin")
bl_p.add_argument("plugin", help="Plugin URI or name")
unbl_p = sp.add_parser("unblacklist", help="Remove plugin from blacklist")
unbl_p.add_argument("plugin", help="Plugin URI or name")
# blacklist-list
bl_list = sp.add_parser("blacklist-list", help="Show all blacklist rules")
bl_list.add_argument("--builtin", action="store_true", help="Only show built-in rules")
bl_list.add_argument("--user", action="store_true", help="Only show user-defined rules")
# blacklist-add
bl_add = sp.add_parser("blacklist-add", help="Add a user blacklist rule")
bl_add.add_argument("pattern", help="Glob pattern to match (e.g., '*BrokenPlugin*')")
bl_add.add_argument("--field", default="name", help="Field to match: uri, name, path (default: name)")
bl_add.add_argument("--reason", "-r", default="User-defined blacklist", help="Reason for blacklisting")
bl_add.add_argument("--severity", default="block", choices=["block", "warn"], help="Severity (default: block)")
# stats
sp.add_parser("stats", help="Show registry statistics")
# vacuum
sp.add_parser("vacuum", help="Compact and optimize the database")
return parser
# ── Output helpers ───────────────────────────────────────────────────────────
def _fmt_plugin_short(p) -> str:
"""One-line plugin summary."""
status_icon = {
PluginStatus.ACTIVE: "[+]",
PluginStatus.DISABLED: "[-]",
PluginStatus.BLACKLISTED: "[!]",
PluginStatus.STALE: "[?]",
PluginStatus.ERROR: "[E]",
PluginStatus.REMOVED: " ",
}.get(p.status, "[ ]")
cats = ", ".join(c.value for c in p.categories[:3])
if len(p.categories) > 3:
cats += ", …"
io_str = f"{p.audio_inputs}i/{p.audio_outputs}o"
if p.midi_inputs or p.midi_outputs:
io_str += f" MIDI:{p.midi_inputs}i/{p.midi_outputs}o"
nam_tag = ""
if p.nam_model_size:
compat = "✓" if p.rpi4b_known_good else "⚠" if p.rpi4b_known_broken else "?"
nam_tag = f" [NAM/{p.nam_model_size} {compat}]"
cpu_str = f" CPU~{p.estimated_cpu_pct:.0f}%" if p.estimated_cpu_pct else ""
rpi_tag = " [RPi4B✓]" if p.rpi4b_known_good else " [RPi4B⚠]" if p.rpi4b_known_broken else ""
return (
f"{status_icon} {p.meta.name:<30} "
f"{p.meta.format.value:<6} {io_str:<12} "
f"[{cats}]{nam_tag}{rpi_tag}{cpu_str}"
)
def _fmt_plugin_verbose(p) -> str:
"""Detailed plugin info block."""
lines = [
f"Name: {p.meta.name}",
f"URI: {p.meta.uri}",
f"Format: {p.meta.format.value}",
f"Version: {p.meta.version or '—'}",
f"Author: {p.meta.author or '—'}",
f"License: {p.meta.license or '—'}",
f"Description: {p.meta.description or '—'}",
f"Homepage: {p.meta.homepage or '—'}",
f"Project: {p.meta.project or '—'}",
f"Categories: {', '.join(CATEGORY_LABELS.get(c, c.value) for c in p.categories)}",
f"Status: {p.status.value}",
"",
f"Audio I/O: {p.audio_inputs} in, {p.audio_outputs} out",
f"MIDI I/O: {p.midi_inputs} in, {p.midi_outputs} out",
f"Bundle: {p.bundle_path or '—'}",
f"Library: {p.library_path or '—'}",
f"Arch: {p.meta.arch or '—'} (ARM optimized: {'yes' if p.meta.arm_optimized else 'no'})",
"",
]
if p.rpi4b_known_good:
lines.append("RPi4B: ✓ Verified working")
elif p.rpi4b_known_broken:
lines.append("RPi4B: ⚠ Known broken")
if p.estimated_cpu_pct:
lines.append(f"Est. CPU: ~{p.estimated_cpu_pct:.1f}%")
if p.estimated_ram_mb:
lines.append(f"Est. RAM: ~{p.estimated_ram_mb:.0f} MB")
if p.nam_model_path:
lines.append(f"")
lines.append(f"NAM Model: {p.nam_model_path}")
lines.append(f"Model Size: {p.nam_model_size}")
if p.control_ports:
lines.append(f"")
lines.append(f"Control Ports ({len(p.control_ports)}):")
for port in p.control_ports[:20]:
lines.append(f" [{port.index:3d}] {port.name:<25} {port.direction:6} "
f"{port.minimum:7.2f} .. {port.maximum:7.2f} (def: {port.default:.2f})")
if len(p.control_ports) > 20:
lines.append(f" … and {len(p.control_ports) - 20} more")
if p.error_message:
lines.append(f"")
lines.append(f"Error: {p.error_message}")
if p.scanned_at:
from datetime import datetime
lines.append(f"")
lines.append(f"Scanned: {datetime.fromtimestamp(p.scanned_at).strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(lines)
# ── Resolve plugin by URI or name ────────────────────────────────────────────
def _resolve(mgr: PluginManager, identifier: str):
"""Resolve a plugin by URI or name (case-insensitive)."""
plugin = mgr.registry.get(identifier)
if plugin:
return plugin
plugin = mgr.registry.get_by_name(identifier)
if plugin:
return plugin
# Try partial name match
for p in mgr.registry.list_all():
if identifier.lower() in p.meta.name.lower():
return p
return None
# ── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = build_parser()
args = parser.parse_args()
if args.command is None:
parser.print_help()
sys.exit(1)
mgr = PluginManager()
# ── scan ─────────────────────────────────────────────────────────────
if args.command == "scan":
print("Scanning filesystem for audio plugins…")
result = mgr.sync()
print(f" New: {result['inserted']}")
print(f" Updated: {result['updated']}")
print(f" Stale: {result['stale']}")
print(f"Done.")
# ── list ─────────────────────────────────────────────────────────────
elif args.command == "list":
fmt = PluginFormat(args.format) if args.format else None
cat = PluginCategory(args.category) if args.category else None
if fmt:
plugins = mgr.registry.list_by_format(fmt)
elif cat:
plugins = mgr.registry.list_by_category(cat)
else:
plugins = mgr.registry.list_all()
if args.loadable:
plugins = [p for p in plugins if p.is_loadable]
if not plugins:
print("No plugins found.")
return
for p in plugins:
if args.verbose:
print(_fmt_plugin_verbose(p))
print("-" * 72)
else:
print(_fmt_plugin_short(p))
print(f"\n{len(plugins)} plugin(s)")
# ── search ───────────────────────────────────────────────────────────
elif args.command == "search":
plugins = mgr.registry.search(args.query)
if not plugins:
print(f"No plugins matching '{args.query}'.")
return
for p in plugins:
if args.verbose:
print(_fmt_plugin_verbose(p))
print("-" * 72)
else:
print(_fmt_plugin_short(p))
print(f"\n{len(plugins)} match(es)")
# ── info ─────────────────────────────────────────────────────────────
elif args.command == "info":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
print(_fmt_plugin_verbose(plugin))
# ── install-local ────────────────────────────────────────────────────
elif args.command == "install-local":
fmt = PluginFormat(args.format)
result = mgr.install_local(args.path, fmt)
if result:
print(f"Installed: {result.meta.name} ({result.meta.uri})")
else:
print("Installation failed.")
sys.exit(1)
# ── install-nam ──────────────────────────────────────────────────────
elif args.command == "install-nam":
result = mgr.nam_install_local(args.path, args.name)
if result:
print(f"Installed NAM model: {result.meta.name} ({result.nam_model_size})")
if result.rpi4b_known_broken:
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
else:
print("Installation failed.")
sys.exit(1)
# ── download-nam ─────────────────────────────────────────────────────
elif args.command == "download-nam":
print(f"Downloading NAM model from {args.url}…")
result = mgr.nam_download(args.url, args.name)
if result:
print(f"Downloaded: {result.meta.name} ({result.nam_model_size})")
if result.rpi4b_known_broken:
print("⚠ Warning: This model may cause xruns on RPi4B (standard size).")
else:
print("Download failed.")
sys.exit(1)
# ── nam-list ─────────────────────────────────────────────────────────
elif args.command == "nam-list":
models = mgr.nam.list_models()
if not models:
print("No NAM models installed.")
return
for m in models:
compat = "✓ RPi4B" if m.rpi4b_compatible else "⚠ xruns likely"
print(f" [{m.size_category:8}] {m.name:<30} {m.file_size_mb:6.1f} MB {compat}")
counts = mgr.nam.count_models()
print(f"\n nano:{counts['nano']} feather:{counts['feather']} "
f"standard:{counts['standard']} custom:{counts['custom']}")
# ── remove ───────────────────────────────────────────────────────────
elif args.command == "remove":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
if mgr.remove(plugin.meta.uri, delete_files=args.files):
action = "Removed (files deleted)" if args.files else "Removed (registry only)"
print(f"{action}: {plugin.meta.name}")
else:
print("Remove failed.")
sys.exit(1)
# ── enable / disable ─────────────────────────────────────────────────
elif args.command == "enable":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
if mgr.enable_plugin(plugin.meta.uri):
print(f"Enabled: {plugin.meta.name}")
else:
print("Failed.")
sys.exit(1)
elif args.command == "disable":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
if mgr.disable_plugin(plugin.meta.uri):
print(f"Disabled: {plugin.meta.name}")
else:
print("Failed.")
sys.exit(1)
# ── blacklist / unblacklist ──────────────────────────────────────────
elif args.command == "blacklist":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
if mgr.blacklist_plugin(plugin.meta.uri):
print(f"Blacklisted: {plugin.meta.name}")
else:
print("Failed.")
sys.exit(1)
elif args.command == "unblacklist":
plugin = _resolve(mgr, args.plugin)
if plugin is None:
print(f"Plugin not found: {args.plugin}")
sys.exit(1)
if mgr.unblacklist_plugin(plugin.meta.uri):
print(f"Removed from blacklist: {plugin.meta.name}")
else:
print("Failed.")
sys.exit(1)
# ── blacklist-list ───────────────────────────────────────────────────
elif args.command == "blacklist-list":
if args.builtin:
entries = mgr.blacklist.list_builtin()
elif args.user:
entries = mgr.blacklist.list_user()
else:
entries = mgr.blacklist.list_all()
for e in entries:
tag = "blk" if e.severity == "block" else "warn"
src = "builtin" if e.added_by == "builtin" else "user"
print(f" [{tag}] [{src}] {e.field}:{e.pattern}")
if e.reason:
print(f" {e.reason}")
print(f"\n{len(entries)} rule(s)")
# ── blacklist-add ────────────────────────────────────────────────────
elif args.command == "blacklist-add":
import time
entry = BlacklistEntry(
pattern=args.pattern,
field=args.field,
reason=args.reason,
severity=args.severity,
added_by="user",
added_at=time.time(),
)
mgr.blacklist.add_user_entry(entry)
print(f"Added blacklist rule: {args.field}:{args.pattern} [{args.severity}]")
# ── stats ────────────────────────────────────────────────────────────
elif args.command == "stats":
stats = mgr.registry.stats()
print(f"Plugin Registry Statistics")
print(f" Total plugins: {stats['total']}")
print(f" By format:")
for fmt, count in stats["by_format"].items():
print(f" {fmt:<8} {count}")
print(f" Blacklisted: {stats['blacklisted']}")
print(f" Stale: {stats['stale']}")
print(f" RPi4B verified: {stats['rpi4b_verified']}")
print(f" NAM models: {stats['nam_models']}")
# ── vacuum ───────────────────────────────────────────────────────────
elif args.command == "vacuum":
print("Compacting database…")
mgr.registry.vacuum()
print("Done.")
if __name__ == "__main__":
main()