#!/usr/bin/env python3 """ carla-jack-autoconnect — Auto-connect Carla plugins to JACK system ports. Monitors JACK for new Carla plugin ports and auto-wires them according to the default mixer routing (carxp preset). Runs as a daemon or one-shot. Usage: python3 carla-jack-autoconnect # One-shot: connect existing ports python3 carla-jack-autoconnect --watch # Daemon: watch for new ports python3 carla-jack-autoconnect --dry-run # Show what would be connected python3 carla-jack-autoconnect --disconnect-all # Disconnect all Carla ports """ import subprocess import sys import time import re import json import os from typing import Dict, List, Tuple, Set # ── Routing rules ────────────────────────────────────── # Format: (source_regex, dest_regex, label) # Order matters — first match wins for each dest port. ROUTING_RULES: List[Tuple[str, str, str]] = [ # System inputs → Channel processing chain (r"system:capture_1$", r".*Ch1.*Gate.*in[12]$", "Capture 1 → Ch1 Gate"), (r"system:capture_2$", r".*Ch2.*Gate.*in[12]$", "Capture 2 → Ch2 Gate"), (r"system:capture_3$", r".*Ch3.*NAM.*in1$", "Capture 3 → Guitar NAM"), (r"system:capture_4$", r".*Ch4.*in[12]$", "Capture 4 → Ch4"), # Chain outputs → chain inputs (plugin internal routing) (r".*Ch1.*Gate.*out[12]$", r".*Ch1.*EQ.*in[12]$", "Ch1 Gate → Ch1 EQ"), (r".*Ch1.*EQ.*out[12]$", r".*Ch1.*Comp.*in[12]$", "Ch1 EQ → Ch1 Comp"), (r".*Ch2.*Gate.*out[12]$", r".*Ch2.*EQ.*in[12]$", "Ch2 Gate → Ch2 EQ"), (r".*Ch2.*EQ.*out[12]$", r".*Ch2.*Comp.*in[12]$", "Ch2 EQ → Ch2 Comp"), (r".*Ch3.*NAM.*out[12]$", r".*Ch3.*IR.*in[12]$", "Ch3 NAM → Ch3 IR Loader"), # Master output → System playback (r".*Master.*Limiter.*out1$", r"system:playback_1$", "Master L → Playback 1"), (r".*Master.*Limiter.*out2$", r"system:playback_2$", "Master R → Playback 2"), ] def jack_lsp() -> List[str]: """Get all JACK ports.""" try: result = subprocess.run( ["jack_lsp", "-c"], capture_output=True, text=True, timeout=5 ) return result.stdout.strip().split("\n") if result.stdout.strip() else [] except (FileNotFoundError, subprocess.TimeoutExpired): print("[autoconnect] ERROR: jack_lsp not found. Is JACK running?", file=sys.stderr) return [] def jack_connect(source: str, dest: str) -> bool: """Connect two JACK ports.""" try: result = subprocess.run( ["jack_connect", source, dest], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except Exception as e: print(f"[autoconnect] Connect failed: {source} → {dest}: {e}", file=sys.stderr) return False def jack_disconnect(source: str, dest: str) -> bool: """Disconnect two JACK ports.""" try: result = subprocess.run( ["jack_disconnect", source, dest], capture_output=True, text=True, timeout=5 ) return result.returncode == 0 except Exception: return False def get_current_connections() -> Set[Tuple[str, str]]: """Get all current JACK connections.""" try: result = subprocess.run( ["jack_lsp", "-c"], capture_output=True, text=True, timeout=5 ) connections = set() for line in result.stdout.strip().split("\n"): parts = line.strip().split() if len(parts) >= 2: # First token is the output port, subsequent are inputs source = parts[0] for dest in parts[1:]: connections.add((source, dest)) return connections except Exception: return set() def match_port(pattern: str, ports: List[str]) -> List[str]: """Return ports matching a regex pattern.""" matched = [] for port in ports: if re.search(pattern, port): matched.append(port) return matched def apply_routing(dry_run: bool = False, verbose: bool = True) -> Dict: """Apply routing rules to JACK ports. Returns stats.""" ports = jack_lsp() if not ports: return {"status": "no_jack", "connected": 0, "failed": 0, "skipped": 0} stats = {"connected": 0, "failed": 0, "skipped": 0, "pairs": []} for source_regex, dest_regex, label in ROUTING_RULES: sources = match_port(source_regex, ports) dests = match_port(dest_regex, ports) for src in sources: for dst in dests: if dry_run: print(f" [DRY-RUN] {src} → {dst} ({label})") stats["pairs"].append((src, dst, label)) else: success = jack_connect(src, dst) if success: if verbose: print(f" ✓ {src} → {dst} ({label})") stats["connected"] += 1 else: print(f" ✗ FAILED: {src} → {dst}", file=sys.stderr) stats["failed"] += 1 return stats def disconnect_all_carla(): """Disconnect all Carla-related ports.""" ports = jack_lsp() connections = get_current_connections() count = 0 for src, dst in connections: if "Carla" in src or "Carla" in dst: if jack_disconnect(src, dst): print(f" ✂ {src} → {dst}") count += 1 print(f"\n Disconnected {count} Carla connections.") return count def watch_loop(interval: float = 1.0): """Watch for new JACK ports and auto-connect them.""" print("[autoconnect] Watching for JACK port changes... (Ctrl+C to stop)") seen_ports = set(jack_lsp()) while True: try: time.sleep(interval) current_ports = set(jack_lsp()) new_ports = current_ports - seen_ports if new_ports: carla_new = {p for p in new_ports if "Carla" in p or "system" in p} if carla_new: print(f"\n[autoconnect] New ports detected: {', '.join(sorted(carla_new))}") apply_routing(dry_run=False, verbose=True) seen_ports = current_ports except KeyboardInterrupt: print("\n[autoconnect] Stopped.") break except Exception as e: print(f"[autoconnect] Error: {e}", file=sys.stderr) # ── CLI ──────────────────────────────────────────────── if __name__ == "__main__": if "--watch" in sys.argv: watch_loop() elif "--dry-run" in sys.argv: print("[autoconnect] DRY RUN — no connections made:\n") apply_routing(dry_run=True) elif "--disconnect-all" in sys.argv: disconnect_all_carla() else: print("[autoconnect] One-shot port connection:\n") stats = apply_routing(dry_run=False) print(f"\n Connected: {stats['connected']} | Failed: {stats['failed']} | Skipped: {stats['skipped']}")