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 import yaml
# ── Subsystem imports ───────────────────────────────────────────────────────── # ── 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.pipeline import AudioPipeline
from src.dsp.nam_host import NAMHost from src.dsp.nam_host import NAMHost
from src.dsp.ir_loader import IRLoader from src.dsp.ir_loader import IRLoader
@@ -77,6 +77,7 @@ class PedalApp:
# ── Subsystem references (set during boot) ───────────────────── # ── Subsystem references (set during boot) ─────────────────────
self.audio_config: AudioConfig | None = None self.audio_config: AudioConfig | None = None
self.audio_system: AudioSystem | None = None self.audio_system: AudioSystem | None = None
self.jack_audio: JackAudioClient | None = None
self.nam_host: NAMHost | None = None self.nam_host: NAMHost | None = None
self.ir_loader: IRLoader | None = None self.ir_loader: IRLoader | None = None
self.pipeline: AudioPipeline | None = None self.pipeline: AudioPipeline | None = None
@@ -110,9 +111,11 @@ class PedalApp:
try: try:
# ── 1. Audio config + system ────────────────────────── # ── 1. Audio config + system ──────────────────────────
acfg = self._config["audio"] acfg = self._config["audio"]
audio_mode = acfg.get("mode", "mono")
self.audio_config = AudioConfig( self.audio_config = AudioConfig(
hat_type=acfg.get("hat_type", "audioinjector"), hat_type=acfg.get("hat_type", "audioinjector"),
profile=acfg.get("profile", "standard"), profile=acfg.get("profile", "standard"),
mode=audio_mode,
input_device=acfg.get("input_device", "hw:0,0"), input_device=acfg.get("input_device", "hw:0,0"),
output_device=acfg.get("output_device", "hw:0,0"), output_device=acfg.get("output_device", "hw:0,0"),
jack_enabled=acfg.get("jack_enabled", True), jack_enabled=acfg.get("jack_enabled", True),
@@ -130,6 +133,32 @@ class PedalApp:
self.ir_loader = IRLoader() self.ir_loader = IRLoader()
self.pipeline = AudioPipeline(nam_host=self.nam_host, ir_loader=self.ir_loader) 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 ───────────────────────────────── # ── 3. Preset manager ─────────────────────────────────
pcfg = self._config["presets"] pcfg = self._config["presets"]
self.presets = PresetManager( self.presets = PresetManager(
@@ -390,6 +419,43 @@ class PedalApp:
self.leds.set_pixel(0, (0, 64, 255)) # Blue = active self.leds.set_pixel(0, (0, 64, 255)) # Blue = active
logger.info("Active: '%s' (bank=%d, pg=%d)", preset.name, preset.bank, preset.program) 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 # Display sync
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
@@ -500,7 +566,15 @@ class PedalApp:
logger.info("═══════ Shutting down ═══════") logger.info("═══════ Shutting down ═══════")
shutdown_start = time.monotonic() 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: if self.midi:
try: try:
self.midi.stop() self.midi.stop()
+2 -1
View File
@@ -4,10 +4,11 @@
- setup (WIP): First-boot setup scripts - setup (WIP): First-boot setup scripts
""" """
from .audio import AudioConfig, AudioSystem, FOCUSRITE_PROFILES from .audio import AudioConfig, AudioSystem, JackAudioClient, FOCUSRITE_PROFILES
__all__ = [ __all__ = [
"AudioConfig", "AudioConfig",
"AudioSystem", "AudioSystem",
"JackAudioClient",
"FOCUSRITE_PROFILES", "FOCUSRITE_PROFILES",
] ]
+184 -2
View File
@@ -18,7 +18,12 @@ import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from collections.abc import Callable 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__) logger = logging.getLogger(__name__)
@@ -757,4 +762,181 @@ def _jack_is_running() -> bool:
) )
return result.returncode == 0 return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired): 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]