Wire 4CM routing: JackAudioClient + boot orchestration + set_routing_mode()

- Add JackAudioClient class to src/system/audio.py — real-time JACK
  client owning ports and a process callback that streams audio through
  the DSP pipeline. Handles mono (1ch I/O) and stereo 4CM (2ch I/O).
- Wire PedalApp.boot(): read audio.mode from config, set pipeline
  routing_mode (mono/4cm), create and start JackAudioClient with
  matching channel count.
- Add PedalApp.set_routing_mode(mode) — hot-switch between mono and
  stereo_4cm at runtime: updates pipeline.routing_mode, restarts JACK
  client with new channel count via set_channel_count(), re-connects
  JACK ports.
- Wire shutdown: stop JackAudioClient before MIDI/footswitch.
- Export JackAudioClient from src.system.__init__.
- 53 tests pass (22 integration + 31 audio).
This commit is contained in:
2026-06-08 12:59:47 -04:00
parent 8ff584cea9
commit 44cf978873
3 changed files with 262 additions and 5 deletions
+76 -2
View File
@@ -22,7 +22,7 @@ from typing import Optional
import yaml
# ── Subsystem imports ─────────────────────────────────────────────────────────
from src.system.audio import AudioConfig, AudioSystem, _jack_is_running
from src.system.audio import AudioConfig, AudioSystem, JackAudioClient, _jack_is_running
from src.dsp.pipeline import AudioPipeline
from src.dsp.nam_host import NAMHost
from src.dsp.ir_loader import IRLoader
@@ -77,6 +77,7 @@ class PedalApp:
# ── Subsystem references (set during boot) ─────────────────────
self.audio_config: AudioConfig | None = None
self.audio_system: AudioSystem | None = None
self.jack_audio: JackAudioClient | None = None
self.nam_host: NAMHost | None = None
self.ir_loader: IRLoader | None = None
self.pipeline: AudioPipeline | None = None
@@ -110,9 +111,11 @@ class PedalApp:
try:
# ── 1. Audio config + system ──────────────────────────
acfg = self._config["audio"]
audio_mode = acfg.get("mode", "mono")
self.audio_config = AudioConfig(
hat_type=acfg.get("hat_type", "audioinjector"),
profile=acfg.get("profile", "standard"),
mode=audio_mode,
input_device=acfg.get("input_device", "hw:0,0"),
output_device=acfg.get("output_device", "hw:0,0"),
jack_enabled=acfg.get("jack_enabled", True),
@@ -130,6 +133,32 @@ class PedalApp:
self.ir_loader = IRLoader()
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader)
# Set pipeline routing from config mode
if audio_mode == "stereo_4cm":
self.pipeline.routing_mode = "4cm"
logger.info("Pipeline routing set to 4CM mode")
else:
self.pipeline.routing_mode = "mono"
# ── 2b. JACK audio client (capture → pipeline → playback) ──
if self.audio_config.jack_enabled:
is_stereo = audio_mode == "stereo_4cm"
channels = 2 if is_stereo else 1
self.jack_audio = JackAudioClient(
pipeline=self.pipeline,
client_name="pi-multifx",
input_channels=channels,
output_channels=channels,
)
self.jack_audio.start()
if not self.jack_audio._active:
logger.warning("JACK audio client failed to start — running without real-time audio I/O")
else:
logger.info(
"JACK audio client started: %s mode, %d ch I/O",
audio_mode, channels,
)
# ── 3. Preset manager ─────────────────────────────────
pcfg = self._config["presets"]
self.presets = PresetManager(
@@ -390,6 +419,43 @@ class PedalApp:
self.leds.set_pixel(0, (0, 64, 255)) # Blue = active
logger.info("Active: '%s' (bank=%d, pg=%d)", preset.name, preset.bank, preset.program)
# ═══════════════════════════════════════════════════════════════
# Audio routing mode switching
# ═══════════════════════════════════════════════════════════════
def set_routing_mode(self, mode: str) -> None:
"""Hot-switch between mono and stereo_4cm routing at runtime.
Updates the pipeline routing mode, then restarts the JACK
audio client with the appropriate channel count.
Args:
mode: ``"mono"`` or ``"stereo_4cm"``.
Raises:
ValueError: If *mode* is not recognised.
"""
if mode not in ("mono", "stereo_4cm"):
raise ValueError(f"set_routing_mode: unknown mode {mode!r}")
if self.audio_config is not None:
self.audio_config.mode = mode
if self.pipeline is not None:
self.pipeline.routing_mode = "4cm" if mode == "stereo_4cm" else "mono"
logger.info("Pipeline routing mode switched to %s", self.pipeline.routing_mode)
# Restart JACK client with new channel count
if self.jack_audio is not None:
channels = 2 if mode == "stereo_4cm" else 1
self.jack_audio.set_channel_count(channels, channels)
# Re-connect JACK ports for the new routing
if self.audio_system is not None and self.audio_config is not None:
self.audio_system.connect_fx_ports()
logger.info("Routing mode switched to %s", mode)
# ═══════════════════════════════════════════════════════════════
# Display sync
# ═══════════════════════════════════════════════════════════════
@@ -500,7 +566,15 @@ class PedalApp:
logger.info("═══════ Shutting down ═══════")
shutdown_start = time.monotonic()
# 1. Stop accepting input (MIDI + footswitch)
# 1. Stop JACK audio client (before JACK server)
if self.jack_audio:
try:
self.jack_audio.stop()
logger.debug("JACK audio client stopped")
except Exception as e:
logger.warning("JACK audio client stop error: %s", e)
# 2. Stop accepting input (MIDI + footswitch)
if self.midi:
try:
self.midi.stop()
+2 -1
View File
@@ -4,10 +4,11 @@
- setup (WIP): First-boot setup scripts
"""
from .audio import AudioConfig, AudioSystem, FOCUSRITE_PROFILES
from .audio import AudioConfig, AudioSystem, JackAudioClient, FOCUSRITE_PROFILES
__all__ = [
"AudioConfig",
"AudioSystem",
"JackAudioClient",
"FOCUSRITE_PROFILES",
]
+184 -2
View File
@@ -18,7 +18,12 @@ import time
from dataclasses import dataclass, field
from pathlib import Path
from collections.abc import Callable
from typing import Optional
from typing import TYPE_CHECKING, Optional
import numpy as np
if TYPE_CHECKING:
from ..dsp.pipeline import AudioPipeline
logger = logging.getLogger(__name__)
@@ -757,4 +762,181 @@ def _jack_is_running() -> bool:
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
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)
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")
# ── 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]
# ── 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:
# Never let an exception escape the RT callback
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]