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:
@@ -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
@@ -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]
|
||||
Reference in New Issue
Block a user