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
+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]