"""System-level audio configuration for the Pi Multi-FX Pedal. Manages ALSA / JACK / I2S setup on RPi 4B: - I2S DAC/ADC initialization with known-good overlays - JACK audio server configuration with PREEMPT_RT priority - ALSA device discovery and naming - JACK port auto-connect for FX pipeline - XRun monitoring - Round-trip latency measurement """ from __future__ import annotations import logging import os import re import subprocess import time from dataclasses import dataclass, field from pathlib import Path from collections.abc import Callable from typing import TYPE_CHECKING, Optional import numpy as np if TYPE_CHECKING: from ..dsp.pipeline import AudioPipeline logger = logging.getLogger(__name__) # ── Known-good I2S overlays ─────────────────────────────────────── # Map: short key → (dtoverlay line, expected ALSA card index, description) # Card index 0 assumes the I2S HAT is the only sound card; adjust if HDMI/USB audio present. I2S_CONFIGS: dict[str, tuple[str, int, str]] = { "audioinjector": ( "dtoverlay=audioinjector-wm8731", 0, "AudioInjector Stereo HAT — Cirrus Logic CS5343 ADC + CS4344 DAC", ), "pcm1808_pcm5102": ( "dtoverlay=audiosense-pi", 0, "PCM1808 ADC + PCM5102 DAC breakout combo (budget, ~$12)", ), "iqaudio_codec": ( "dtoverlay=iqaudio-codec", 0, "IQaudio Codec Zero — ADC+DAC, 48 kHz max (BCKL limitation)", ), "justboom": ( "dtoverlay=justboom-dac", 0, "JustBoom DAC/ADC HAT — full 192 kHz/24-bit", ), "wm8731": ( "dtoverlay=wm8731", 0, "Waveshare / PHAT DAC — WM8731 codec, 48 kHz", ), } # ── USB audio interface profiles (non-I2S, e.g. Focusrite) ──────── # Map: short key → (description, expected capture ports, expected playback ports) FOCUSRITE_PROFILES: dict[str, tuple[str, int, int]] = { "focusrite_2i2_3gen": ( "Focusrite Scarlett 2i2 3rd gen — USB, 2-in / 2-out, 48 kHz, 24-bit", 2, 2, ), "focusrite_2i2_1gen": ( "Focusrite Scarlett 2i2 1st gen — USB, 2-in / 2-out, 48 kHz, 24-bit", 2, 2, ), } # ── JACK latency profiles ───────────────────────────────────────── # 48 kHz / 128 frames = 2.67 ms buffer → well under 10 ms RT # 48 kHz / 64 frames = 1.33 ms buffer → aggressive, may xrun on RPi 4B LATENCY_PROFILES: dict[str, dict] = { "standard": { "period": 256, "nperiods": 2, "rate": 48000, "rt_priority": 70, }, "low": { "period": 64, "nperiods": 2, "rate": 48000, "rt_priority": 80, }, "stable": { "period": 512, "nperiods": 2, "rate": 48000, "rt_priority": 60, }, } # ── System paths ────────────────────────────────────────────────── CONFIG_TXT = Path("/boot/config.txt") JACK_SERVICE_PATH = Path("/etc/systemd/system/jackd.service") LIMITS_CONF = Path("/etc/security/limits.d/99-audio.conf") # ═══════════════════════════════════════════════════════════════════ # Configuration # ═══════════════════════════════════════════════════════════════════ @dataclass class AudioConfig: """Audio system configuration. Attributes: hat_type: I2S HAT type key from I2S_CONFIGS. profile: Latency profile key from LATENCY_PROFILES. mode: I/O routing mode: "mono" (default, hw:0,0) or "stereo_4cm" (Focusrite 2i2 4-cable-method, 2ch I/O). input_device: ALSA hardware device for capture (e.g. hw:0,0). output_device: ALSA hardware device for playback (e.g. hw:0,0). jack_enabled: Whether to start the JACK server. auto_connect: Auto-connect JACK capture→FX pipeline→playback ports. xrun_warn_only: If True, log xruns instead of restarting JACK. """ hat_type: str = "audioinjector" profile: str = "standard" mode: str = "mono" input_device: str = "hw:0,0" output_device: str = "hw:0,0" jack_enabled: bool = True auto_connect: bool = True xrun_warn_only: bool = True @property def latency_profile(self) -> dict: """Resolve the latency profile dict.""" p = LATENCY_PROFILES.get(self.profile) if p is None: logger.warning("Unknown profile %r, falling back to standard", self.profile) return LATENCY_PROFILES["standard"] return p @property def overlay_line(self) -> Optional[str]: """Get the dtoverlay line for the configured HAT, or None.""" entry = I2S_CONFIGS.get(self.hat_type) if entry is None: logger.warning("Unknown HAT type %r", self.hat_type) return None return entry[0] @property def jack_device_arg(self) -> str: """JACK ALSA device argument (output device drives JACK master).""" return f"d{self.output_device}" @property def capture_channels(self) -> int: """Number of JACK capture channels (1 for mono, 2 for stereo_4cm).""" return 2 if self.mode == "stereo_4cm" else 1 @property def playback_channels(self) -> int: """Number of JACK playback channels (1 for mono, 2 for stereo_4cm).""" return 2 if self.mode == "stereo_4cm" else 1 @property def focusrite_profile(self) -> Optional[tuple[str, int, int]]: """Resolve Focusrite profile from hat_type, or None if not a Focusrite.""" return FOCUSRITE_PROFILES.get(self.hat_type) # ═══════════════════════════════════════════════════════════════════ # Audio system manager # ═══════════════════════════════════════════════════════════════════ class AudioSystem: """Manages the audio subsystem: I2S, ALSA, and JACK. Usage: sys = AudioSystem(AudioConfig(hat_type="audioinjector")) sys.setup_i2s() sys.start_jack() """ def __init__(self, config: Optional[AudioConfig] = None) -> None: self.config = config or AudioConfig() self._tempo_bpm: float = 120.0 self._tempo_source: str = "default" # "default", "midi_clock", "manual" self._midi_clock_enabled: bool = False self._tempo_callback: Optional[Callable[[float], None]] = None # ────────────────────────────────────────────────────────────── # I2S overlay management # ────────────────────────────────────────────────────────────── def setup_i2s(self, reboot_hint: bool = True) -> bool: """Verify the I2S overlay is present in config.txt. If missing, appends the line (dry-run unless run as root). Args: reboot_hint: If True, log a message that a reboot is needed. Returns: True if overlay is already active or was successfully added. """ overlay = self.config.overlay_line if overlay is None: return False # Check if it exists try: txt = CONFIG_TXT.read_text() except (OSError, PermissionError) as exc: logger.error("Cannot read %s: %s", CONFIG_TXT, exc) return False if overlay in txt: logger.info("I2S overlay already enabled: %s", overlay) return True # Append try: with CONFIG_TXT.open("a") as f: f.write(f"\n# Pi Multi-FX Pedal — {self.config.hat_type}\n{overlay}\n") logger.info("Appended %s to %s", overlay, CONFIG_TXT) except PermissionError: logger.warning( "Need root to edit %s. " "Run: echo '%s' | sudo tee -a %s", CONFIG_TXT, overlay, CONFIG_TXT, ) return False except OSError as exc: logger.error("Failed to write %s: %s", CONFIG_TXT, exc) return False if reboot_hint: logger.info("Reboot required for I2S overlay to take effect") return True @staticmethod def get_active_overlay() -> Optional[str]: """Detect which I2S overlay is currently set in config.txt. Returns the overlay line if found, or None. """ try: txt = CONFIG_TXT.read_text() except OSError: return None for match in re.finditer(r"^dtoverlay=(.+)", txt, re.MULTILINE): return match.group(0) return None # ────────────────────────────────────────────────────────────── # JACK server lifecycle # ────────────────────────────────────────────────────────────── def start_jack(self, timeout: int = 10) -> bool: """Start the JACK audio server with optimal settings. Blocks up to *timeout* seconds waiting for JACK to report ready. Args: timeout: Max seconds to wait for JACK to start. Returns: True if JACK is running. """ if not self.config.jack_enabled: logger.info("JACK disabled in config") return False # Already running? if _jack_is_running(): logger.info("JACK already running") return True profile = self.config.latency_profile jack_env = os.environ.copy() jack_env["JACK_NO_AUDIO_RESERVATION"] = "1" # Scarlett 2i2 needs 2 channels even in mono mode n_in = max(2, self.config.capture_channels) n_out = max(2, self.config.playback_channels) cmd = [ "jackd", "-P", str(profile['rt_priority']), "-d", "alsa", "-d", self.config.output_device, "-r", str(profile['rate']), "-p", str(profile['period']), "-n", str(profile['nperiods']), "-i", str(n_in), "-o", str(n_out), ] logger.info("Starting JACK: %s", " ".join(cmd)) try: proc = subprocess.Popen( cmd, env=jack_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except FileNotFoundError: logger.error("jackd not found — install jackd2") return False # Wait for readiness via jack_wait deadline = time.monotonic() + timeout while time.monotonic() < deadline: time.sleep(0.3) if _jack_is_running(): logger.info( "JACK started: period=%d, nperiods=%d, rate=%d", profile["period"], profile["nperiods"], profile["rate"], ) # Auto-connect ports if enabled if self.config.auto_connect: self.connect_fx_ports() return True # Timed out — check for common issues poll = proc.poll() if poll is not None: logger.error("jackd exited early with code %d", poll) else: logger.error("JACK failed to become ready within %ds", timeout) proc.kill() return False def stop_jack(self) -> None: """Gracefully stop the JACK server.""" try: subprocess.run( ["killall", "jackd"], capture_output=True, timeout=5, ) logger.info("JACK stopped") except subprocess.TimeoutExpired: logger.warning("JACK stop timed out — sending SIGKILL") subprocess.run(["killall", "-9", "jackd"], capture_output=True) except FileNotFoundError: pass def restart_jack(self, timeout: int = 10) -> bool: """Restart JACK server.""" self.stop_jack() time.sleep(0.5) return self.start_jack(timeout=timeout) # ────────────────────────────────────────────────────────────── # Channel mapping helpers # ────────────────────────────────────────────────────────────── @staticmethod def channel_mapping_help(mode: str = "mono") -> list[dict]: """Describe the JACK port-to-pipeline channel routing. Args: mode: ``"mono"`` or ``"stereo_4cm"``. Returns: List of dicts with keys: capture_port, pipeline_input, pipeline_output, playback_port, description. Raises: ValueError: If *mode* is not recognised. """ if mode == "mono": return [{ "capture_port": "system:capture_1", "pipeline_input": "fx_in:input_0", "pipeline_output": "fx_out:output_0", "playback_port": "system:playback_1", "description": "Guitar → FX chain → Output", }] elif mode == "stereo_4cm": return [ { "capture_port": "system:capture_1", "pipeline_input": "fx_in:input_0", "pipeline_output": "fx_out:output_0", "playback_port": "system:playback_1", "description": "Guitar (Input 1) → Pre-amp FX chain → Amp Input (Send)", }, { "capture_port": "system:capture_2", "pipeline_input": "fx_in:input_1", "pipeline_output": "fx_out:output_1", "playback_port": "system:playback_2", "description": "FX Send (Input 2) → Post-amp FX chain → Amp FX Return", }, ] else: raise ValueError(f"Unknown mode: {mode!r}") # ────────────────────────────────────────────────────────────── # JACK port connections # ────────────────────────────────────────────────────────────── def connect_fx_ports(self) -> None: """Connect JACK ports for the FX pipeline. Mono (default): system:capture_1 → fx_in:input_0 (guitar → FX chain) fx_out:output_0 → system:playback_1 (FX chain → output) Stereo 4CM (Focusrite 2i2): system:capture_1 → fx_in:input_0 (guitar → pre-amp path) system:capture_2 → fx_in:input_1 (FX send → post-amp path) fx_out:output_0 → system:playback_1 (pre-amp → amp input) fx_out:output_1 → system:playback_2 (post-amp → FX return) This is a no-op if jack_connect is unavailable (not in PATH) or if any of the target ports don't exist yet. """ # Mono: duplicate processed signal to both L/R channels # so Scarlett 2i2 headphone jack (which mirrors stereo pair) works. mono_connections = [ ("system:capture_1", "fx_in:input_0"), ("fx_out:output_0", "system:playback_1"), ("fx_out:output_0", "system:playback_2"), ] stereo_4cm_connections = [ ("system:capture_1", "fx_in:input_0"), ("system:capture_2", "fx_in:input_1"), ("fx_out:output_0", "system:playback_1"), ("fx_out:output_1", "system:playback_2"), ] connections = ( stereo_4cm_connections if self.config.mode == "stereo_4cm" else mono_connections ) for src, dst in connections: try: subprocess.run( ["jack_connect", src, dst], capture_output=True, text=True, timeout=3, ) logger.debug("Connected %s → %s", src, dst) except FileNotFoundError: logger.warning("jack_connect not found — skipping port connections") return except subprocess.TimeoutExpired: logger.warning("Timeout connecting %s → %s", src, dst) # ────────────────────────────────────────────────────────────── # ALSA device listing # ────────────────────────────────────────────────────────────── @staticmethod def list_devices() -> list[dict]: """List available ALSA audio devices with structured info. Returns: List of dicts with keys: card, device, name, type, description. """ try: result = subprocess.run( ["aplay", "-l"], capture_output=True, text=True, timeout=5, ) except FileNotFoundError: return [] devices: list[dict] = [] for line in result.stdout.splitlines(): m = re.match( r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]", line, ) if m: devices.append({ "card": int(m.group(1)), "device": int(m.group(3)), "short_name": m.group(2).strip(), "full_name": m.group(4).strip(), "type": "playback", }) # Also capture devices try: result = subprocess.run( ["arecord", "-l"], capture_output=True, text=True, timeout=5, ) except FileNotFoundError: pass else: for line in result.stdout.splitlines(): m = re.match( r"card\s+(\d+):\s+\S+\s+\[([^\]]*)\]\s+device\s+(\d+):\s+\S+\s+\[([^\]]*)\]", line, ) if m: devices.append({ "card": int(m.group(1)), "device": int(m.group(3)), "short_name": m.group(2).strip(), "full_name": m.group(4).strip(), "type": "capture", }) return devices # ────────────────────────────────────────────────────────────── # XRun monitoring # ────────────────────────────────────────────────────────────── @staticmethod def read_xrun_count() -> Optional[int]: """Read JACK xrun counter from jack_showtime. Returns: Number of xruns since JACK started, or None if unavailable. """ try: result = subprocess.run( ["jack_showtime", "-c"], capture_output=True, text=True, timeout=3, ) except (FileNotFoundError, subprocess.TimeoutExpired): return None m = re.search(r"xruns\s*=\s*(\d+)", result.stdout) if m: return int(m.group(1)) return None def monitor_xruns(self, duration: int = 300, interval: int = 10) -> dict: """Monitor xruns over a test period. Args: duration: Test duration in seconds (default 300 = 5 min). interval: Poll interval in seconds. Returns: Dict with xrun_total, xrun_rate_per_min, duration, stable. """ logger.info("Starting xrun monitor: duration=%ds, interval=%ds", duration, interval) start_xruns = self.read_xrun_count() if start_xruns is None: logger.warning("Cannot read xrun count — jack_showtime not available") return {"xrun_total": None, "xrun_rate_per_min": None, "duration": duration, "stable": None} deadline = time.monotonic() + duration last_val = start_xruns peak = 0 while time.monotonic() < deadline: time.sleep(interval) val = self.read_xrun_count() if val is None: continue delta = val - last_val if delta > 0: logger.warning("XRUN detected: +%d (total: %d)", delta, val) peak = max(peak, delta) last_val = val total = last_val - start_xruns rate = total / (duration / 60.0) stable = total == 0 logger.info( "XRun monitor complete: %d total (%.2f/min), stable=%s", total, rate, stable, ) return { "xrun_total": total, "xrun_rate_per_min": round(rate, 2), "duration": duration, "stable": stable, } # ────────────────────────────────────────────────────────────── # Round-trip latency measurement # ────────────────────────────────────────────────────────────── @staticmethod def measure_roundtrip_latency(samples: int = 8, timeout: int = 30) -> Optional[float]: """Measure JACK round-trip latency using jack_iodelay. Args: samples: Number of measurements to take. timeout: Max seconds per measurement. Returns: Average round-trip latency in milliseconds, or None on failure. """ try: subprocess.run( ["jack_iodelay", "--help"], capture_output=True, timeout=3, ) except FileNotFoundError: logger.warning("jack_iodelay not found — install jack-tools") return None latencies: list[float] = [] for i in range(samples): try: result = subprocess.run( ["jack_iodelay"], capture_output=True, text=True, timeout=timeout, ) m = re.search(r"round.trip\s+latency[:\s]+([\d.]+)\s*ms", result.stdout, re.IGNORECASE) if m: val = float(m.group(1)) latencies.append(val) logger.info("Latency measurement %d/%d: %.2f ms", i + 1, samples, val) else: logger.debug("jack_iodelay output did not match: %s", result.stdout[:200]) except subprocess.TimeoutExpired: logger.warning("Latency measurement %d timed out", i + 1) except subprocess.CalledProcessError as exc: logger.warning("Latency measurement %d failed: %s", i + 1, exc) if not latencies: logger.error("No valid latency measurements") return None avg = sum(latencies) / len(latencies) logger.info( "Round-trip latency: avg=%.2f ms, min=%.2f ms, max=%.2f ms (n=%d)", avg, min(latencies), max(latencies), len(latencies), ) return avg # ────────────────────────────────────────────────────────────── # Systemd service management # ────────────────────────────────────────────────────────────── @staticmethod def systemd_service_content(config: AudioConfig) -> str: """Generate a systemd unit file for JACK. Args: config: Audio configuration for the service parameters. Returns: Systemd unit file content as a string. """ profile = config.latency_profile exec_start = ( f"/usr/bin/jackd -P{profile['rt_priority']} " f"-p{profile['period']} -n{profile['nperiods']} " f"-r{profile['rate']} -dalsa -d{config.output_device} " f"-i{config.capture_channels} -o{config.playback_channels}" ) return f"""[Unit] Description=JACK Audio Server — Pi Multi-FX Pedal After=sound.target network.target Wants=multi-fx-pedal.target [Service] Type=simple User=pi ExecStart={exec_start} Restart=on-failure RestartSec=5 LimitRTPRIO=95 LimitMEMLOCK=infinity [Install] WantedBy=multi-user.target """ def install_systemd_service(self) -> bool: """Install the JACK systemd service. Requires root. Returns: True if installed successfully. """ content = self.systemd_service_content(self.config) try: JACK_SERVICE_PATH.write_text(content) subprocess.run( ["systemctl", "daemon-reload"], capture_output=True, timeout=10, ) logger.info("Installed systemd service: %s", JACK_SERVICE_PATH) return True except PermissionError: logger.warning( "Need root to install systemd service. " "Run setup_audio.sh as root, or manually: " "sudo cp the unit file to %s && sudo systemctl daemon-reload", JACK_SERVICE_PATH, ) return False except OSError as exc: logger.error("Failed to install service: %s", exc) return False # ────────────────────────────────────────────────────────────── # MIDI clock sync (tempo integration for time-based FX) # ────────────────────────────────────────────────────────────── @property def tempo_bpm(self) -> float: """Current tempo in BPM for time-based effects (delay, reverb).""" return self._tempo_bpm @tempo_bpm.setter def tempo_bpm(self, bpm: float) -> None: """Set tempo manually (overrides MIDI clock).""" self._tempo_source = "manual" self._tempo_bpm = max(20.0, min(300.0, bpm)) if self._tempo_callback: self._tempo_callback(self._tempo_bpm) logger.info("Tempo set manually: %.1f BPM", self._tempo_bpm) @property def tempo_source(self) -> str: return self._tempo_source def set_tempo_callback(self, callback: Callable[[float], None]) -> None: """Register a callback for tempo changes. Args: callback: Called with new BPM value when tempo changes. """ self._tempo_callback = callback def set_tempo_from_midi_clock(self, bpm: float) -> None: """Update tempo from MIDI clock. Called by MIDIHandler's clock callback. Only updates if MIDI clock sync is enabled. Args: bpm: Detected BPM from MIDI clock (20-300). """ if not self._midi_clock_enabled: return bpm = max(20.0, min(300.0, bpm)) if abs(self._tempo_bpm - bpm) > 0.5 or self._tempo_source != "midi_clock": self._tempo_bpm = bpm self._tempo_source = "midi_clock" if self._tempo_callback: self._tempo_callback(bpm) logger.debug("Tempo synced from MIDI clock: %.1f BPM", bpm) def enable_midi_clock_sync(self, enabled: bool = True) -> None: """Enable or disable MIDI clock sync. When enabled, tempo follows MIDI clock. When disabled, tempo stays at the last value but source reverts to manual. Args: enabled: Whether to follow MIDI clock. """ self._midi_clock_enabled = enabled if not enabled and self._tempo_source == "midi_clock": self._tempo_source = "manual" logger.info("MIDI clock sync %s", "enabled" if enabled else "disabled") # ═══════════════════════════════════════════════════════════════════ # Internal helpers # ═══════════════════════════════════════════════════════════════════ def _jack_is_running() -> bool: """Check if JACK is running via process list or PID file.""" try: result = subprocess.run( ["pgrep", "-x", "jackd"], capture_output=True, timeout=3, ) return result.returncode == 0 except FileNotFoundError: # pgrep not available — fall back to pidof try: result = subprocess.run( ["pidof", "jackd"], capture_output=True, timeout=3, ) return result.returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False except subprocess.TimeoutExpired: 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) # Explicitly connect ports to system (JACK auto-connect is unreliable) self._connect_ports() 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") # ── Port connection helpers ────────────────────────────────── def _connect_ports(self) -> None: """Explicitly connect our ports to the system physical ports. JACK's auto-connect is unreliable across systemd service restarts. This ensures audio always flows even when the JACK server was started before our client. """ if self._client is None: return # Connect each capture port to the corresponding system capture for i, port in enumerate(self._inports): suffix = f"capture_{i + 1}" if self._input_channels > 1 else "capture_1" sys_port_name = f"system:{suffix}" try: sys_ports = self._client.get_ports(sys_port_name) if sys_ports: self._client.connect(sys_ports[0], port) logger.debug("Connected %s -> %s", sys_port_name, port.name) except Exception as exc: logger.warning("Failed to connect %s -> %s: %s", sys_port_name, port.name, exc) # Connect each playback port to the corresponding system playback. # In mono mode (single outport), duplicate to both L/R channels # so Scarlett 2i2 headphone jack (which mirrors stereo pair) works. for i, port in enumerate(self._outports): if self._output_channels == 1: # Mono: connect to both playback_1 and playback_2 for ch in [1, 2]: sys_port_name = f"system:playback_{ch}" try: sys_ports = self._client.get_ports(sys_port_name) if sys_ports: self._client.connect(port, sys_ports[0]) logger.debug("Mono: connected %s -> %s", port.name, sys_port_name) except Exception as exc: logger.warning("Failed to connect %s -> %s: %s", port.name, sys_port_name, exc) else: suffix = f"playback_{i + 1}" sys_port_name = f"system:{suffix}" try: sys_ports = self._client.get_ports(sys_port_name) if sys_ports: self._client.connect(port, sys_ports[0]) logger.debug("Connected %s -> %s", port.name, sys_port_name) except Exception as exc: logger.warning("Failed to connect %s -> %s: %s", port.name, sys_port_name, exc) # ── 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] # DEBUG: always log import os os.system('echo "CB frames=' + str(frames) + '" >> /tmp/debug_audio.log') # ── 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 as exc: # Never let an exception escape the RT callback logger.error("Pipeline error: %s", str(exc)) import traceback logger.error("Traceback:\n%s", traceback.format_exc()) 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]