Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI

This commit is contained in:
2026-06-07 23:22:43 -04:00
commit ed29748a62
24 changed files with 6215 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
"""System integration subpackage.
- audio: ALSA/JACK/I2S configuration and lifecycle
- setup (WIP): First-boot setup scripts
"""
from .audio import AudioConfig, AudioSystem
__all__ = [
"AudioConfig",
"AudioSystem",
]
+597
View File
@@ -0,0 +1,597 @@
"""System-level audio configuration for the Pi Multi-FX Pedal.
Manages ALSA / JACK / I2S setup on RPi 4B:
- I2S DAC/ADC initialization with known-good overlays
- JACK audio server configuration with PREEMPT_RT priority
- ALSA device discovery and naming
- JACK port auto-connect for FX pipeline
- XRun monitoring
- Round-trip latency measurement
"""
from __future__ import annotations
import logging
import re
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
# ── Known-good I2S overlays ───────────────────────────────────────
# Map: short key → (dtoverlay line, expected ALSA card index, description)
# Card index 0 assumes the I2S HAT is the only sound card; adjust if HDMI/USB audio present.
I2S_CONFIGS: dict[str, tuple[str, int, str]] = {
"audioinjector": (
"dtoverlay=audioinjector-wm8731",
0,
"AudioInjector Stereo HAT — Cirrus Logic CS5343 ADC + CS4344 DAC",
),
"pcm1808_pcm5102": (
"dtoverlay=audiosense-pi",
0,
"PCM1808 ADC + PCM5102 DAC breakout combo (budget, ~$12)",
),
"iqaudio_codec": (
"dtoverlay=iqaudio-codec",
0,
"IQaudio Codec Zero — ADC+DAC, 48 kHz max (BCKL limitation)",
),
"justboom": (
"dtoverlay=justboom-dac",
0,
"JustBoom DAC/ADC HAT — full 192 kHz/24-bit",
),
"wm8731": (
"dtoverlay=wm8731",
0,
"Waveshare / PHAT DAC — WM8731 codec, 48 kHz",
),
}
# ── JACK latency profiles ─────────────────────────────────────────
# 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT
# 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B
LATENCY_PROFILES: dict[str, dict] = {
"standard": {
"period": 128,
"nperiods": 2,
"rate": 48000,
"rt_priority": 70,
},
"low": {
"period": 64,
"nperiods": 2,
"rate": 48000,
"rt_priority": 80,
},
}
# ── System paths ──────────────────────────────────────────────────
CONFIG_TXT = Path("/boot/config.txt")
JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service")
LIMITS_CONF = Path("/etc/security/limits.d/99-audio.conf")
# ═══════════════════════════════════════════════════════════════════
# Configuration
# ═══════════════════════════════════════════════════════════════════
@dataclass
class AudioConfig:
"""Audio system configuration.
Attributes:
hat_type: I2S HAT type key from I2S_CONFIGS.
profile: Latency profile key from LATENCY_PROFILES.
input_device: ALSA hardware device for capture (e.g. hw:0,0).
output_device: ALSA hardware device for playback (e.g. hw:0,0).
jack_enabled: Whether to start the JACK server.
auto_connect: Auto-connect JACK capture→FX pipeline→playback ports.
xrun_warn_only: If True, log xruns instead of restarting JACK.
"""
hat_type: str = "audioinjector"
profile: str = "standard"
input_device: str = "hw:0,0"
output_device: str = "hw:0,0"
jack_enabled: bool = True
auto_connect: bool = True
xrun_warn_only: bool = True
@property
def latency_profile(self) -> dict:
"""Resolve the latency profile dict."""
p = LATENCY_PROFILES.get(self.profile)
if p is None:
logger.warning("Unknown profile %r, falling back to standard", self.profile)
return LATENCY_PROFILES["standard"]
return p
@property
def overlay_line(self) -> Optional[str]:
"""Get the dtoverlay line for the configured HAT, or None."""
entry = I2S_CONFIGS.get(self.hat_type)
if entry is None:
logger.warning("Unknown HAT type %r", self.hat_type)
return None
return entry[0]
@property
def jack_device_arg(self) -> str:
"""JACK ALSA device argument (output device drives JACK master)."""
return f"d{self.output_device}"
# ═══════════════════════════════════════════════════════════════════
# Audio system manager
# ═══════════════════════════════════════════════════════════════════
class AudioSystem:
"""Manages the audio subsystem: I2S, ALSA, and JACK.
Usage:
sys = AudioSystem(AudioConfig(hat_type="audioinjector"))
sys.setup_i2s()
sys.start_jack()
"""
def __init__(self, config: Optional[AudioConfig] = None) -> None:
self.config = config or AudioConfig()
# ──────────────────────────────────────────────────────────────
# I2S overlay management
# ──────────────────────────────────────────────────────────────
def setup_i2s(self, reboot_hint: bool = True) -> bool:
"""Verify the I2S overlay is present in config.txt.
If missing, appends the line (dry-run unless run as root).
Args:
reboot_hint: If True, log a message that a reboot is needed.
Returns:
True if overlay is already active or was successfully added.
"""
overlay = self.config.overlay_line
if overlay is None:
return False
# Check if it exists
try:
txt = CONFIG_TXT.read_text()
except (OSError, PermissionError) as exc:
logger.error("Cannot read %s: %s", CONFIG_TXT, exc)
return False
if overlay in txt:
logger.info("I2S overlay already enabled: %s", overlay)
return True
# Append
try:
with CONFIG_TXT.open("a") as f:
f.write(f"\n# Pi Multi-FX Pedal — {self.config.hat_type}\n{overlay}\n")
logger.info("Appended %s to %s", overlay, CONFIG_TXT)
except PermissionError:
logger.warning(
"Need root to edit %s. "
"Run: echo '%s' | sudo tee -a %s",
CONFIG_TXT, overlay, CONFIG_TXT,
)
return False
except OSError as exc:
logger.error("Failed to write %s: %s", CONFIG_TXT, exc)
return False
if reboot_hint:
logger.info("Reboot required for I2S overlay to take effect")
return True
@staticmethod
def get_active_overlay() -> Optional[str]:
"""Detect which I2S overlay is currently set in config.txt.
Returns the overlay line if found, or None.
"""
try:
txt = CONFIG_TXT.read_text()
except OSError:
return None
for match in re.finditer(r"^dtoverlay=(.+)", txt, re.MULTILINE):
return match.group(0)
return None
# ──────────────────────────────────────────────────────────────
# JACK server lifecycle
# ──────────────────────────────────────────────────────────────
def start_jack(self, timeout: int = 10) -> bool:
"""Start the JACK audio server with optimal settings.
Blocks up to *timeout* seconds waiting for JACK to report ready.
Args:
timeout: Max seconds to wait for JACK to start.
Returns:
True if JACK is running.
"""
if not self.config.jack_enabled:
logger.info("JACK disabled in config")
return False
# Already running?
if _jack_is_running():
logger.info("JACK already running")
return True
profile = self.config.latency_profile
cmd = [
"jackd",
f"-P{profile['rt_priority']}",
f"-p{profile['period']}",
f"-n{profile['nperiods']}",
f"-r{profile['rate']}",
"-dalsa",
f"-d{self.config.output_device}",
f"-i2", f"-o2",
]
logger.info("Starting JACK: %s", " ".join(cmd))
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except FileNotFoundError:
logger.error("jackd not found — install jackd2")
return False
# Wait for readiness via jack_wait
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(0.3)
if _jack_is_running():
logger.info(
"JACK started: period=%d, nperiods=%d, rate=%d",
profile["period"], profile["nperiods"], profile["rate"],
)
# Auto-connect ports if enabled
if self.config.auto_connect:
self.connect_fx_ports()
return True
# Timed out — check for common issues
poll = proc.poll()
if poll is not None:
logger.error("jackd exited early with code %d", poll)
else:
logger.error("JACK failed to become ready within %ds", timeout)
proc.kill()
return False
def stop_jack(self) -> None:
"""Gracefully stop the JACK server."""
try:
subprocess.run(
["killall", "jackd"],
capture_output=True, timeout=5,
)
logger.info("JACK stopped")
except subprocess.TimeoutExpired:
logger.warning("JACK stop timed out — sending SIGKILL")
subprocess.run(["killall", "-9", "jackd"], capture_output=True)
except FileNotFoundError:
pass
def restart_jack(self, timeout: int = 10) -> bool:
"""Restart JACK server."""
self.stop_jack()
time.sleep(0.5)
return self.start_jack(timeout=timeout)
# ──────────────────────────────────────────────────────────────
# JACK port connections
# ──────────────────────────────────────────────────────────────
def connect_fx_ports(self) -> None:
"""Connect JACK ports for the FX pipeline.
Default wiring:
system:capture_1 → fx_in:input_0 (guitar → FX chain)
fx_out:output_0 → system:playback_1 (FX chain → output)
This is a no-op if jack_connect is unavailable (not in PATH)
or if any of the target ports don't exist yet.
"""
connections = [
("system:capture_1", "fx_in:input_0"),
("fx_out:output_0", "system:playback_1"),
]
for src, dst in connections:
try:
subprocess.run(
["jack_connect", src, dst],
capture_output=True, text=True, timeout=3,
)
logger.debug("Connected %s%s", src, dst)
except FileNotFoundError:
logger.warning("jack_connect not found — skipping port connections")
return
except subprocess.TimeoutExpired:
logger.warning("Timeout connecting %s%s", src, dst)
# ──────────────────────────────────────────────────────────────
# ALSA device listing
# ──────────────────────────────────────────────────────────────
@staticmethod
def list_devices() -> list[dict]:
"""List available ALSA audio devices with structured info.
Returns:
List of dicts with keys: card, device, name, type, description.
"""
try:
result = subprocess.run(
["aplay", "-l"],
capture_output=True, text=True, timeout=5,
)
except FileNotFoundError:
return []
devices: list[dict] = []
for line in result.stdout.splitlines():
m = re.match(
r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
line,
)
if m:
devices.append({
"card": int(m.group(1)),
"device": int(m.group(3)),
"short_name": m.group(2).strip(),
"full_name": m.group(4).strip(),
"type": "playback",
})
# Also capture devices
try:
result = subprocess.run(
["arecord", "-l"],
capture_output=True, text=True, timeout=5,
)
except FileNotFoundError:
pass
else:
for line in result.stdout.splitlines():
m = re.match(
r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]",
line,
)
if m:
devices.append({
"card": int(m.group(1)),
"device": int(m.group(3)),
"short_name": m.group(2).strip(),
"full_name": m.group(4).strip(),
"type": "capture",
})
return devices
# ──────────────────────────────────────────────────────────────
# XRun monitoring
# ──────────────────────────────────────────────────────────────
@staticmethod
def read_xrun_count() -> Optional[int]:
"""Read JACK xrun counter from jack_showtime.
Returns:
Number of xruns since JACK started, or None if unavailable.
"""
try:
result = subprocess.run(
["jack_showtime", "-c"],
capture_output=True, text=True, timeout=3,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
m = re.search(r"xruns\s*=\s*(\d+)", result.stdout)
if m:
return int(m.group(1))
return None
def monitor_xruns(self, duration: int = 300, interval: int = 10) -> dict:
"""Monitor xruns over a test period.
Args:
duration: Test duration in seconds (default 300 = 5 min).
interval: Poll interval in seconds.
Returns:
Dict with xrun_total, xrun_rate_per_min, duration, stable.
"""
logger.info("Starting xrun monitor: duration=%ds, interval=%ds", duration, interval)
start_xruns = self.read_xrun_count()
if start_xruns is None:
logger.warning("Cannot read xrun count — jack_showtime not available")
return {"xrun_total": None, "xrun_rate_per_min": None, "duration": duration, "stable": None}
deadline = time.monotonic() + duration
last_val = start_xruns
peak = 0
while time.monotonic() < deadline:
time.sleep(interval)
val = self.read_xrun_count()
if val is None:
continue
delta = val - last_val
if delta > 0:
logger.warning("XRUN detected: +%d (total: %d)", delta, val)
peak = max(peak, delta)
last_val = val
total = last_val - start_xruns
rate = total / (duration / 60.0)
stable = total == 0
logger.info(
"XRun monitor complete: %d total (%.2f/min), stable=%s",
total, rate, stable,
)
return {
"xrun_total": total,
"xrun_rate_per_min": round(rate, 2),
"duration": duration,
"stable": stable,
}
# ──────────────────────────────────────────────────────────────
# Round-trip latency measurement
# ──────────────────────────────────────────────────────────────
@staticmethod
def measure_roundtrip_latency(samples: int = 8, timeout: int = 30) -> Optional[float]:
"""Measure JACK round-trip latency using jack_iodelay.
Args:
samples: Number of measurements to take.
timeout: Max seconds per measurement.
Returns:
Average round-trip latency in milliseconds, or None on failure.
"""
try:
subprocess.run(
["jack_iodelay", "--help"],
capture_output=True, timeout=3,
)
except FileNotFoundError:
logger.warning("jack_iodelay not found — install jack-tools")
return None
latencies: list[float] = []
for i in range(samples):
try:
result = subprocess.run(
["jack_iodelay"],
capture_output=True, text=True, timeout=timeout,
)
m = re.search(r"round.trip\s+latency[:\s]+([\d.]+)\s*ms", result.stdout, re.IGNORECASE)
if m:
val = float(m.group(1))
latencies.append(val)
logger.info("Latency measurement %d/%d: %.2f ms", i + 1, samples, val)
else:
logger.debug("jack_iodelay output did not match: %s", result.stdout[:200])
except subprocess.TimeoutExpired:
logger.warning("Latency measurement %d timed out", i + 1)
except subprocess.CalledProcessError as exc:
logger.warning("Latency measurement %d failed: %s", i + 1, exc)
if not latencies:
logger.error("No valid latency measurements")
return None
avg = sum(latencies) / len(latencies)
logger.info(
"Round-trip latency: avg=%.2f ms, min=%.2f ms, max=%.2f ms (n=%d)",
avg, min(latencies), max(latencies), len(latencies),
)
return avg
# ──────────────────────────────────────────────────────────────
# Systemd service management
# ──────────────────────────────────────────────────────────────
@staticmethod
def systemd_service_content(config: AudioConfig) -> str:
"""Generate a systemd unit file for JACK.
Args:
config: Audio configuration for the service parameters.
Returns:
Systemd unit file content as a string.
"""
profile = config.latency_profile
exec_start = (
f"/usr/bin/jackd -P{profile['rt_priority']} "
f"-p{profile['period']} -n{profile['nperiods']} "
f"-r{profile['rate']} -dalsa -d{config.output_device} -i2 -o2"
)
return f"""[Unit]
Description=JACK Audio Server — Pi Multi-FX Pedal
After=sound.target network.target
Wants=multi-fx-pedal.target
[Service]
Type=simple
User=pi
ExecStart={exec_start}
Restart=on-failure
RestartSec=5
LimitRTPRIO=95
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
"""
def install_systemd_service(self) -> bool:
"""Install the JACK systemd service.
Requires root.
Returns:
True if installed successfully.
"""
content = self.systemd_service_content(self.config)
try:
JACK_SERVICE_PATH.write_text(content)
subprocess.run(
["systemctl", "daemon-reload"],
capture_output=True, timeout=10,
)
logger.info("Installed systemd service: %s", JACK_SERVICE_PATH)
return True
except PermissionError:
logger.warning(
"Need root to install systemd service. "
"Run setup_audio.sh as root, or manually: "
"sudo cp the unit file to %s && sudo systemctl daemon-reload",
JACK_SERVICE_PATH,
)
return False
except OSError as exc:
logger.error("Failed to install service: %s", exc)
return False
# ═══════════════════════════════════════════════════════════════════
# Internal helpers
# ═══════════════════════════════════════════════════════════════════
def _jack_is_running() -> bool:
"""Check if JACK is running via jack_wait."""
try:
result = subprocess.run(
["jack_wait", "-c"],
capture_output=True, text=True, timeout=5,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False