Phase 1-4: Audio stack, mixer engine, MIDI, and network API
Lint & Validate / lint (push) Has been cancelled
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:
Executable
+90
@@ -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 ""
|
||||
Executable
+15
@@ -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."
|
||||
Executable
+202
@@ -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 ""
|
||||
Executable
+184
@@ -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']}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Executable
+89
@@ -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."
|
||||
Executable
+40
@@ -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."
|
||||
@@ -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
|
||||
Executable
+313
@@ -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()
|
||||
Executable
+336
@@ -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()
|
||||
Executable
+179
@@ -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 ""
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user