From 44cf9788734d0d029eb0c54cb6ca178523610424 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 8 Jun 2026 12:59:47 -0400 Subject: [PATCH] Wire 4CM routing: JackAudioClient + boot orchestration + set_routing_mode() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- main.py | 78 ++++++++++++++++- src/system/__init__.py | 3 +- src/system/audio.py | 186 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 262 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index d9cfad7..77b34c2 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/src/system/__init__.py b/src/system/__init__.py index f370482..087b83b 100644 --- a/src/system/__init__.py +++ b/src/system/__init__.py @@ -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", ] \ No newline at end of file diff --git a/src/system/audio.py b/src/system/audio.py index e79a78b..6fc8dbb 100644 --- a/src/system/audio.py +++ b/src/system/audio.py @@ -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 \ No newline at end of file + 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] \ No newline at end of file