1035 lines
41 KiB
Python
1035 lines
41 KiB
Python
"""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 os
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from collections.abc import Callable
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from ..dsp.pipeline import AudioPipeline
|
|
|
|
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",
|
|
),
|
|
}
|
|
|
|
# ── USB audio interface profiles (non-I2S, e.g. Focusrite) ────────
|
|
# Map: short key → (description, expected capture ports, expected playback ports)
|
|
FOCUSRITE_PROFILES: dict[str, tuple[str, int, int]] = {
|
|
"focusrite_2i2_3gen": (
|
|
"Focusrite Scarlett 2i2 3rd gen — USB, 2-in / 2-out, 48 kHz, 24-bit",
|
|
2,
|
|
2,
|
|
),
|
|
"focusrite_2i2_1gen": (
|
|
"Focusrite Scarlett 2i2 1st gen — USB, 2-in / 2-out, 48 kHz, 24-bit",
|
|
2,
|
|
2,
|
|
),
|
|
}
|
|
|
|
# ── 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": 256,
|
|
"nperiods": 2,
|
|
"rate": 48000,
|
|
"rt_priority": 70,
|
|
},
|
|
"low": {
|
|
"period": 64,
|
|
"nperiods": 2,
|
|
"rate": 48000,
|
|
"rt_priority": 80,
|
|
},
|
|
"stable": {
|
|
"period": 512,
|
|
"nperiods": 2,
|
|
"rate": 48000,
|
|
"rt_priority": 60,
|
|
},
|
|
}
|
|
|
|
# ── 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.
|
|
mode: I/O routing mode: "mono" (default, hw:0,0) or
|
|
"stereo_4cm" (Focusrite 2i2 4-cable-method, 2ch I/O).
|
|
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"
|
|
mode: str = "mono"
|
|
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}"
|
|
|
|
@property
|
|
def capture_channels(self) -> int:
|
|
"""Number of JACK capture channels (1 for mono, 2 for stereo_4cm)."""
|
|
return 2 if self.mode == "stereo_4cm" else 1
|
|
|
|
@property
|
|
def playback_channels(self) -> int:
|
|
"""Number of JACK playback channels (1 for mono, 2 for stereo_4cm)."""
|
|
return 2 if self.mode == "stereo_4cm" else 1
|
|
|
|
@property
|
|
def focusrite_profile(self) -> Optional[tuple[str, int, int]]:
|
|
"""Resolve Focusrite profile from hat_type, or None if not a Focusrite."""
|
|
return FOCUSRITE_PROFILES.get(self.hat_type)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# 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()
|
|
self._tempo_bpm: float = 120.0
|
|
self._tempo_source: str = "default" # "default", "midi_clock", "manual"
|
|
self._midi_clock_enabled: bool = False
|
|
self._tempo_callback: Optional[Callable[[float], None]] = None
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# 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
|
|
jack_env = os.environ.copy()
|
|
jack_env["JACK_NO_AUDIO_RESERVATION"] = "1"
|
|
# Scarlett 2i2 needs 2 channels even in mono mode
|
|
n_in = max(2, self.config.capture_channels)
|
|
n_out = max(2, self.config.playback_channels)
|
|
cmd = [
|
|
"jackd",
|
|
"-P", str(profile['rt_priority']),
|
|
"-d", "alsa",
|
|
"-d", self.config.output_device,
|
|
"-r", str(profile['rate']),
|
|
"-p", str(profile['period']),
|
|
"-n", str(profile['nperiods']),
|
|
"-i", str(n_in),
|
|
"-o", str(n_out),
|
|
]
|
|
logger.info("Starting JACK: %s", " ".join(cmd))
|
|
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd,
|
|
env=jack_env,
|
|
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)
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# Channel mapping helpers
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
@staticmethod
|
|
def channel_mapping_help(mode: str = "mono") -> list[dict]:
|
|
"""Describe the JACK port-to-pipeline channel routing.
|
|
|
|
Args:
|
|
mode: ``"mono"`` or ``"stereo_4cm"``.
|
|
|
|
Returns:
|
|
List of dicts with keys: capture_port, pipeline_input, pipeline_output,
|
|
playback_port, description.
|
|
|
|
Raises:
|
|
ValueError: If *mode* is not recognised.
|
|
"""
|
|
if mode == "mono":
|
|
return [{
|
|
"capture_port": "system:capture_1",
|
|
"pipeline_input": "fx_in:input_0",
|
|
"pipeline_output": "fx_out:output_0",
|
|
"playback_port": "system:playback_1",
|
|
"description": "Guitar → FX chain → Output",
|
|
}]
|
|
elif mode == "stereo_4cm":
|
|
return [
|
|
{
|
|
"capture_port": "system:capture_1",
|
|
"pipeline_input": "fx_in:input_0",
|
|
"pipeline_output": "fx_out:output_0",
|
|
"playback_port": "system:playback_1",
|
|
"description": "Guitar (Input 1) → Pre-amp FX chain → Amp Input (Send)",
|
|
},
|
|
{
|
|
"capture_port": "system:capture_2",
|
|
"pipeline_input": "fx_in:input_1",
|
|
"pipeline_output": "fx_out:output_1",
|
|
"playback_port": "system:playback_2",
|
|
"description": "FX Send (Input 2) → Post-amp FX chain → Amp FX Return",
|
|
},
|
|
]
|
|
else:
|
|
raise ValueError(f"Unknown mode: {mode!r}")
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# JACK port connections
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
def connect_fx_ports(self) -> None:
|
|
"""Connect JACK ports for the FX pipeline.
|
|
|
|
Mono (default):
|
|
system:capture_1 → fx_in:input_0 (guitar → FX chain)
|
|
fx_out:output_0 → system:playback_1 (FX chain → output)
|
|
|
|
Stereo 4CM (Focusrite 2i2):
|
|
system:capture_1 → fx_in:input_0 (guitar → pre-amp path)
|
|
system:capture_2 → fx_in:input_1 (FX send → post-amp path)
|
|
fx_out:output_0 → system:playback_1 (pre-amp → amp input)
|
|
fx_out:output_1 → system:playback_2 (post-amp → FX return)
|
|
|
|
This is a no-op if jack_connect is unavailable (not in PATH)
|
|
or if any of the target ports don't exist yet.
|
|
"""
|
|
# Mono: duplicate processed signal to both L/R channels
|
|
# so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
|
|
mono_connections = [
|
|
("system:capture_1", "fx_in:input_0"),
|
|
("fx_out:output_0", "system:playback_1"),
|
|
("fx_out:output_0", "system:playback_2"),
|
|
]
|
|
stereo_4cm_connections = [
|
|
("system:capture_1", "fx_in:input_0"),
|
|
("system:capture_2", "fx_in:input_1"),
|
|
("fx_out:output_0", "system:playback_1"),
|
|
("fx_out:output_1", "system:playback_2"),
|
|
]
|
|
|
|
connections = (
|
|
stereo_4cm_connections
|
|
if self.config.mode == "stereo_4cm"
|
|
else mono_connections
|
|
)
|
|
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} "
|
|
f"-i{config.capture_channels} -o{config.playback_channels}"
|
|
)
|
|
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
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────
|
|
# MIDI clock sync (tempo integration for time-based FX)
|
|
# ──────────────────────────────────────────────────────────────
|
|
|
|
@property
|
|
def tempo_bpm(self) -> float:
|
|
"""Current tempo in BPM for time-based effects (delay, reverb)."""
|
|
return self._tempo_bpm
|
|
|
|
@tempo_bpm.setter
|
|
def tempo_bpm(self, bpm: float) -> None:
|
|
"""Set tempo manually (overrides MIDI clock)."""
|
|
self._tempo_source = "manual"
|
|
self._tempo_bpm = max(20.0, min(300.0, bpm))
|
|
if self._tempo_callback:
|
|
self._tempo_callback(self._tempo_bpm)
|
|
logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm)
|
|
|
|
@property
|
|
def tempo_source(self) -> str:
|
|
return self._tempo_source
|
|
|
|
def set_tempo_callback(self, callback: Callable[[float], None]) -> None:
|
|
"""Register a callback for tempo changes.
|
|
|
|
Args:
|
|
callback: Called with new BPM value when tempo changes.
|
|
"""
|
|
self._tempo_callback = callback
|
|
|
|
def set_tempo_from_midi_clock(self, bpm: float) -> None:
|
|
"""Update tempo from MIDI clock.
|
|
|
|
Called by MIDIHandler's clock callback. Only updates if MIDI
|
|
clock sync is enabled.
|
|
|
|
Args:
|
|
bpm: Detected BPM from MIDI clock (20-300).
|
|
"""
|
|
if not self._midi_clock_enabled:
|
|
return
|
|
bpm = max(20.0, min(300.0, bpm))
|
|
if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock":
|
|
self._tempo_bpm = bpm
|
|
self._tempo_source = "midi_clock"
|
|
if self._tempo_callback:
|
|
self._tempo_callback(bpm)
|
|
logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm)
|
|
|
|
def enable_midi_clock_sync(self, enabled: bool = True) -> None:
|
|
"""Enable or disable MIDI clock sync.
|
|
|
|
When enabled, tempo follows MIDI clock. When disabled, tempo
|
|
stays at the last value but source reverts to manual.
|
|
|
|
Args:
|
|
enabled: Whether to follow MIDI clock.
|
|
"""
|
|
self._midi_clock_enabled = enabled
|
|
if not enabled and self._tempo_source == "midi_clock":
|
|
self._tempo_source = "manual"
|
|
logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled")
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# Internal helpers
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
def _jack_is_running() -> bool:
|
|
"""Check if JACK is running via process list or PID file."""
|
|
try:
|
|
result = subprocess.run(
|
|
["pgrep", "-x", "jackd"],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
return result.returncode == 0
|
|
except FileNotFoundError:
|
|
# pgrep not available — fall back to pidof
|
|
try:
|
|
result = subprocess.run(
|
|
["pidof", "jackd"],
|
|
capture_output=True, timeout=3,
|
|
)
|
|
return result.returncode == 0
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
return False
|
|
except subprocess.TimeoutExpired:
|
|
return False
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
# JACK audio client (real-time I/O with pipeline)
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class JackAudioClient:
|
|
"""JACK client that streams audio through the DSP pipeline.
|
|
|
|
Owns JACK ports and a real-time process callback. Handles both
|
|
mono (1ch I/O) and stereo 4CM (2ch I/O) routing transparently.
|
|
|
|
Data flow:
|
|
|
|
Mono:
|
|
capture_1 → pipeline.process() → playback_1
|
|
|
|
Stereo 4CM:
|
|
capture_1 (guitar) → pipeline.process((2, N)) → playback_1 (send)
|
|
capture_2 (return) → playback_2 (return)
|
|
|
|
The pipeline's ``process()`` method handles routing internally
|
|
based on ``pipeline.routing_mode`` (``"mono"`` or ``"4cm"``).
|
|
|
|
.. warning:: The process callback runs in a real-time thread.
|
|
No blocking calls (alloc, I/O, locks) are allowed inside it.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
pipeline: AudioPipeline,
|
|
client_name: str = "pi-multifx",
|
|
input_channels: int = 2,
|
|
output_channels: int = 2,
|
|
) -> None:
|
|
self._pipeline = pipeline
|
|
self._client_name = client_name
|
|
self._input_channels = input_channels
|
|
self._output_channels = output_channels
|
|
|
|
# JACK client — created on start()
|
|
self._client: Optional[object] = None
|
|
self._inports: list[object] = []
|
|
self._outports: list[object] = []
|
|
self._active = False
|
|
|
|
# Scratch buffers (reused per callback to avoid allocations)
|
|
self._in_buf: Optional[np.ndarray] = None
|
|
self._out_buf: Optional[np.ndarray] = None
|
|
|
|
# ── Lifecycle ──────────────────────────────────────────────────
|
|
|
|
def start(self) -> None:
|
|
"""Create a JACK client, register ports, activate processing."""
|
|
if self._active:
|
|
logger.debug("JackAudioClient already active")
|
|
return
|
|
|
|
import jack # lazy import — only needed at runtime
|
|
|
|
try:
|
|
self._client = jack.Client(self._client_name)
|
|
except jack.JackOpenError as exc:
|
|
logger.error("Cannot open JACK client: %s", exc)
|
|
return
|
|
|
|
# Register capture ports
|
|
self._inports.clear()
|
|
suffix_map = {1: "", 2: ("_1", "_2")}
|
|
suffixes = suffix_map.get(self._input_channels, tuple(f"_{i+1}" for i in range(self._input_channels)))
|
|
for i in range(self._input_channels):
|
|
suffix = suffixes[i] if isinstance(suffixes, tuple) else ""
|
|
port = self._client.inports.register(f"capture{suffix}")
|
|
self._inports.append(port)
|
|
|
|
# Register playback ports
|
|
self._outports.clear()
|
|
suffixes = suffix_map.get(self._output_channels, tuple(f"_{i+1}" for i in range(self._output_channels)))
|
|
for i in range(self._output_channels):
|
|
suffix = suffixes[i] if isinstance(suffixes, tuple) else ""
|
|
port = self._client.outports.register(f"playback{suffix}")
|
|
self._outports.append(port)
|
|
|
|
logger.info(
|
|
"JACK client '%s': %d in port(s), %d out port(s)",
|
|
self._client_name, self._input_channels, self._output_channels,
|
|
)
|
|
|
|
# Register process callback
|
|
self._client.set_process_callback(self._process_callback)
|
|
|
|
# Pre-allocate scratch buffers
|
|
block_size = self._client.blocksize
|
|
self._in_buf = np.zeros((self._input_channels, block_size), dtype=np.float32)
|
|
self._out_buf = np.zeros((self._output_channels, block_size), dtype=np.float32)
|
|
|
|
self._client.activate()
|
|
self._active = True
|
|
logger.info("JACK client activated (blocksize=%d)", block_size)
|
|
|
|
# Explicitly connect ports to system (JACK auto-connect is unreliable)
|
|
self._connect_ports()
|
|
|
|
def stop(self) -> None:
|
|
"""Deactivate and close the JACK client."""
|
|
if not self._active or self._client is None:
|
|
return
|
|
try:
|
|
self._client.deactivate()
|
|
except Exception as exc:
|
|
logger.warning("JACK deactivate error: %s", exc)
|
|
try:
|
|
self._client.close()
|
|
except Exception as exc:
|
|
logger.warning("JACK close error: %s", exc)
|
|
self._client = None
|
|
self._inports.clear()
|
|
self._outports.clear()
|
|
self._active = False
|
|
self._in_buf = None
|
|
self._out_buf = None
|
|
logger.info("JACK client stopped")
|
|
|
|
# ── Port connection helpers ──────────────────────────────────
|
|
|
|
def _connect_ports(self) -> None:
|
|
"""Explicitly connect our ports to the system physical ports.
|
|
|
|
JACK's auto-connect is unreliable across systemd service restarts.
|
|
This ensures audio always flows even when the JACK server was
|
|
started before our client.
|
|
"""
|
|
if self._client is None:
|
|
return
|
|
|
|
# Connect each capture port to the corresponding system capture
|
|
for i, port in enumerate(self._inports):
|
|
suffix = f"capture_{i + 1}" if self._input_channels > 1 else "capture_1"
|
|
sys_port_name = f"system:{suffix}"
|
|
try:
|
|
sys_ports = self._client.get_ports(sys_port_name)
|
|
if sys_ports:
|
|
self._client.connect(sys_ports[0], port)
|
|
logger.debug("Connected %s -> %s", sys_port_name, port.name)
|
|
except Exception as exc:
|
|
logger.warning("Failed to connect %s -> %s: %s",
|
|
sys_port_name, port.name, exc)
|
|
|
|
# Connect each playback port to the corresponding system playback.
|
|
# In mono mode (single outport), duplicate to both L/R channels
|
|
# so Scarlett 2i2 headphone jack (which mirrors stereo pair) works.
|
|
for i, port in enumerate(self._outports):
|
|
if self._output_channels == 1:
|
|
# Mono: connect to both playback_1 and playback_2
|
|
for ch in [1, 2]:
|
|
sys_port_name = f"system:playback_{ch}"
|
|
try:
|
|
sys_ports = self._client.get_ports(sys_port_name)
|
|
if sys_ports:
|
|
self._client.connect(port, sys_ports[0])
|
|
logger.debug("Mono: connected %s -> %s", port.name, sys_port_name)
|
|
except Exception as exc:
|
|
logger.warning("Failed to connect %s -> %s: %s",
|
|
port.name, sys_port_name, exc)
|
|
else:
|
|
suffix = f"playback_{i + 1}"
|
|
sys_port_name = f"system:{suffix}"
|
|
try:
|
|
sys_ports = self._client.get_ports(sys_port_name)
|
|
if sys_ports:
|
|
self._client.connect(port, sys_ports[0])
|
|
logger.debug("Connected %s -> %s", port.name, sys_port_name)
|
|
except Exception as exc:
|
|
logger.warning("Failed to connect %s -> %s: %s",
|
|
port.name, sys_port_name, exc)
|
|
|
|
# ── Runtime reconfiguration ────────────────────────────────────
|
|
|
|
def set_channel_count(self, input_channels: int, output_channels: int) -> None:
|
|
"""Change channel count (restart required)."""
|
|
if self._active:
|
|
logger.info("Changing channel count — restarting JACK client")
|
|
self.stop()
|
|
self._input_channels = input_channels
|
|
self._output_channels = output_channels
|
|
self.start()
|
|
|
|
def set_pipeline(self, pipeline: AudioPipeline) -> None:
|
|
"""Swap pipeline reference (safe while JACK is stopped)."""
|
|
self._pipeline = pipeline
|
|
|
|
# ── Process callback (REAL-TIME — no blocking calls) ──────────
|
|
|
|
def _process_callback(self, frames: int) -> None:
|
|
"""Real-time JACK process callback.
|
|
|
|
Reads audio from capture ports, runs through pipeline,
|
|
writes results to playback ports.
|
|
|
|
Args:
|
|
frames: Number of samples in this block.
|
|
"""
|
|
# ── Ensure scratch buffers are large enough ──────────────
|
|
if self._in_buf is None or self._in_buf.shape[1] < frames:
|
|
self._in_buf = np.zeros((self._input_channels, frames), dtype=np.float32)
|
|
if self._out_buf is None or self._out_buf.shape[1] < frames:
|
|
self._out_buf = np.zeros((self._output_channels, frames), dtype=np.float32)
|
|
|
|
# ── Read from capture ports ──────────────────────────────
|
|
for ch in range(self._input_channels):
|
|
port_buf = self._inports[ch].get_array()
|
|
self._in_buf[ch, :frames] = port_buf[:frames]
|
|
# DEBUG: always log
|
|
import os
|
|
os.system('echo "CB frames=' + str(frames) + '" >> /tmp/debug_audio.log')
|
|
|
|
# ── Run through pipeline ─────────────────────────────────
|
|
if self._input_channels == 1:
|
|
audio_in = self._in_buf[0, :frames]
|
|
else:
|
|
audio_in = self._in_buf[:, :frames]
|
|
|
|
try:
|
|
audio_out = self._pipeline.process(audio_in)
|
|
except Exception as exc:
|
|
# Never let an exception escape the RT callback
|
|
logger.error("Pipeline error: %s", str(exc))
|
|
import traceback
|
|
logger.error("Traceback:\n%s", traceback.format_exc())
|
|
audio_out = np.zeros(frames, dtype=np.float32) if self._output_channels == 1 else \
|
|
np.zeros((self._output_channels, frames), dtype=np.float32)
|
|
|
|
# ── Write to playback ports ──────────────────────────────
|
|
if self._output_channels == 1:
|
|
self._outports[0].get_array()[:frames] = audio_out[:frames]
|
|
else:
|
|
for ch in range(self._output_channels):
|
|
self._outports[ch].get_array()[:frames] = audio_out[ch, :frames] |