Files
pi-multifx-pedal/src/system/audio.py
T

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]