Initial scaffold: Pi Multi-FX Pedal with NAM A2, IR cab, multi-FX, MIDI, stomp UI
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user