""" JACK port management endpoints. Provides REST API for querying and managing JACK audio port connections via the PiPedal WebSocket API. PiPedal runs on a separate machine (the Pi) and manages JACK internally, so all JACK operations go through PiPedal's WS. PiPedal JACK message name notes: The message names below are based on common PiPedal / mod-host conventions. Adjust MSG_* constants if PiPedal uses different names in your version. """ import asyncio import json import logging from typing import Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel logger = logging.getLogger("jack-endpoints") # --------------------------------------------------------------------------- # PiPedal JACK message names — adjust these if PiPedal uses different names # --------------------------------------------------------------------------- # Message to list all JACK ports (returns port list with types/directions) MSG_LIST_PORTS = "getAudioPorts" # Message to list current JACK connections (returns port→connected mapping) MSG_LIST_CONNECTIONS = "getAudioConnections" # Message to connect two JACK ports: body = {"source": "...", "destination": "..."} MSG_CONNECT = "connectPorts" # Message to disconnect two JACK ports: body = {"source": "...", "destination": "..."} MSG_DISCONNECT = "disconnectPorts" # Alternative message names to try if the primary ones don't work ALT_MSG_LIST_PORTS = ["getPorts", "jack_get_ports", "listAudioPorts"] ALT_MSG_LIST_CONNECTIONS = ["getConnections", "jack_get_connections", "listAudioConnections"] ALT_MSG_CONNECT = ["jack_connect", "connectAudioPorts"] ALT_MSG_DISCONNECT = ["jack_disconnect", "disconnectAudioPorts"] # --------------------------------------------------------------------------- # Data models # --------------------------------------------------------------------------- class JackPortModel(BaseModel): """A single JACK port.""" name: str # full JACK port name, e.g. "system:capture_1" client: str # client name, e.g. "system" port: str # port short name, e.g. "capture_1" port_type: str # "audio" | "midi" direction: str # "output" = audio source (produces audio), "input" = destination (receives audio) is_physical: bool = False connections: list[str] = [] # names of connected ports class JackConnectRequest(BaseModel): source: str # source port name (output) destination: str # destination port name (input) # --------------------------------------------------------------------------- # Helper: try calling PiPedal with a message name, falling back to alternatives # --------------------------------------------------------------------------- # Import pipedal_request lazily — it's defined in app.py which imports us _pipedal_request = None async def _get_pipedal_request(): """Get the pipedal_request function, importing from app on first call.""" global _pipedal_request if _pipedal_request is None: from app import pipedal_request _pipedal_request = pipedal_request return _pipedal_request async def _call_pipedal(message: str, body=None, alt_messages: list[str] | None = None) -> Optional[dict]: """ Call PiPedal with *message*, falling back to *alt_messages* if None is returned. Returns the response body dict, or None if all variants failed. """ req = await _get_pipedal_request() result = await req(message, body) if result is not None: return result # Try alternates if alt_messages: for alt in alt_messages: result = await req(alt, body) if result is not None: logger.info("Fell back to alternative message: %s (primary was %s)", alt, message) return result return None # --------------------------------------------------------------------------- # Port / connection parsing (handles various PiPedal response shapes) # --------------------------------------------------------------------------- def _parse_ports_from_pedalboard() -> list[dict]: """ Derive port information from the cached pedalboard state as a fallback when PiPedal JACK port listing is unavailable. """ from app import pipedal_state, discover_oplabs_from_pedalboard ports = [] # System physical ports (generic — actual ports depend on audio interface) ports.append({ "name": "system:capture_1", "client": "system", "port": "capture_1", "port_type": "audio", "direction": "output", "is_physical": True, "connections": [], }) ports.append({ "name": "system:capture_2", "client": "system", "port": "capture_2", "port_type": "audio", "direction": "output", "is_physical": True, "connections": [], }) ports.append({ "name": "system:playback_1", "client": "system", "port": "playback_1", "port_type": "audio", "direction": "input", "is_physical": True, "connections": [], }) ports.append({ "name": "system:playback_2", "client": "system", "port": "playback_2", "port_type": "audio", "direction": "input", "is_physical": True, "connections": [], }) # PiPedal client ports (derived from pedalboard state) pb = pipedal_state.get("pedalboard") or {} items = pb.get("items") or [] seen_clients = set() for item in items: uri = item.get("uri", "") instance_id = item.get("instanceId") title = item.get("title", "") or item.get("pluginName", "") or f"plugin_{instance_id}" client_name = f"pipedal:{instance_id}" if instance_id else f"pipedal:{title}" if client_name in seen_clients: continue seen_clients.add(client_name) audio_ins = item.get("audioInputs", 2) or 2 audio_outs = item.get("audioOutputs", 2) or 2 # Input ports (the plugin's audio inputs = JACK destinations) for j in range(audio_ins): pname = f"{client_name}:audio_in_{j + 1}" ports.append({ "name": pname, "client": client_name, "port": f"audio_in_{j + 1}", "port_type": "audio", "direction": "input", "is_physical": False, "connections": [], }) # Output ports (the plugin's audio outputs = JACK sources) for j in range(audio_outs): pname = f"{client_name}:audio_out_{j + 1}" ports.append({ "name": pname, "client": client_name, "port": f"audio_out_{j + 1}", "port_type": "audio", "direction": "output", "is_physical": False, "connections": [], }) # Generic PiPedal engine output ports engine_client = "pipedal" for j in range(2): pname = f"{engine_client}:out_{j + 1}" ports.append({ "name": pname, "client": engine_client, "port": f"out_{j + 1}", "port_type": "audio", "direction": "output", "is_physical": False, "connections": [], }) return ports def _normalize_port_entry(raw: dict) -> dict: """ Normalize a port entry from PiPedal's response to our standard format. Handles various key naming conventions. """ name = raw.get("name") or raw.get("portName") or raw.get("fullName") or "" client = raw.get("client") or raw.get("clientName") or name.split(":")[0] if ":" in name else "" port = raw.get("port") or raw.get("shortName") or (name.split(":", 1)[1] if ":" in name else name) port_type = raw.get("type") or raw.get("portType") or "audio" direction = raw.get("direction") or raw.get("mode") or "output" is_physical = raw.get("isPhysical") or raw.get("physical") or raw.get("is_physical") or False connections = raw.get("connections") or raw.get("connectedTo") or [] return { "name": name, "client": client, "port": port, "port_type": port_type, "direction": direction, "is_physical": bool(is_physical), "connections": connections if isinstance(connections, list) else [], } # --------------------------------------------------------------------------- # Router # --------------------------------------------------------------------------- router = APIRouter(prefix="/api/jack", tags=["jack"]) @router.get("/ports") async def list_jack_ports(): """ List all JACK audio ports and their current connections. Returns: { "ports": [...], # list of port dicts "connections": {...}, # port_name → [connected_port_names] "source": "pipedal" | "fallback" } """ # Try PiPedal for real port data ports_data = await _call_pipedal( MSG_LIST_PORTS, alt_messages=ALT_MSG_LIST_PORTS, ) connections_data = await _call_pipedal( MSG_LIST_CONNECTIONS, alt_messages=ALT_MSG_LIST_CONNECTIONS, ) if ports_data is not None: # Normalize port entries raw_ports = ports_data if isinstance(ports_data, list) else (ports_data.get("ports") or ports_data.get("items") or []) ports = [_normalize_port_entry(p) for p in raw_ports] # Merge connection info if connections_data: conn_map = connections_data if isinstance(connections_data, dict): conn_map = connections_data.get("connections") or connections_data.get("items") or connections_data for port in ports: pname = port["name"] port["connections"] = conn_map.get(pname) or conn_map.get(pname, []) return { "ports": ports, "connections": _build_connection_map(ports), "source": "pipedal", } # Fallback: derive from cached pedalboard state from app import pipedal_state if pipedal_state.get("pedalboard"): ports = _parse_ports_from_pedalboard() return { "ports": ports, "connections": _build_connection_map(ports), "source": "fallback", "note": "PiPedal JACK API unavailable — ports derived from pedalboard state, connections unknown", } # No data available at all return { "ports": [], "connections": {}, "source": "none", "note": "PiPedal is unreachable. No JACK port information available.", } @router.get("/ports/refresh") async def refresh_jack_ports(): """ Force-refresh the pedalboard state and re-query JACK ports. """ from app import fetch_pedalboard_state fetch_pedalboard_state() return await list_jack_ports() @router.post("/connect") async def connect_ports(req: JackConnectRequest): """ Connect two JACK ports (source → destination). Body: {"source": "...", "destination": "..."} """ result = await _call_pipedal( MSG_CONNECT, body={"source": req.source, "destination": req.destination}, alt_messages=ALT_MSG_CONNECT, ) if result is None: # If connect message failed, try as positional args result = await _call_pipedal( MSG_CONNECT, body=[req.source, req.destination], alt_messages=ALT_MSG_CONNECT, ) if result is not None: logger.info("JACK connect: %s → %s", req.source, req.destination) return {"status": "connected", "source": req.source, "destination": req.destination} raise HTTPException( status_code=502, detail=f"Failed to connect {req.source} → {req.destination}: PiPedal did not respond", ) @router.post("/disconnect") async def disconnect_ports(req: JackConnectRequest): """ Disconnect two JACK ports. Body: {"source": "...", "destination": "..."} """ result = await _call_pipedal( MSG_DISCONNECT, body={"source": req.source, "destination": req.destination}, alt_messages=ALT_MSG_DISCONNECT, ) if result is None: result = await _call_pipedal( MSG_DISCONNECT, body=[req.source, req.destination], alt_messages=ALT_MSG_DISCONNECT, ) if result is not None: logger.info("JACK disconnect: %s → %s", req.source, req.destination) return {"status": "disconnected", "source": req.source, "destination": req.destination} raise HTTPException( status_code=502, detail=f"Failed to disconnect {req.source} → {req.destination}: PiPedal did not respond", ) @router.post("/disconnect-all") async def disconnect_all(): """ Disconnect all JACK port connections. """ ports_data = await list_jack_ports() conns = ports_data.get("connections", {}) disconnected = 0 for src, dests in conns.items(): for dst in dests: try: await _call_pipedal( MSG_DISCONNECT, body={"source": src, "destination": dst}, alt_messages=ALT_MSG_DISCONNECT, ) disconnected += 1 except Exception: pass return {"status": "disconnected_all", "count": disconnected} # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _build_connection_map(ports: list[dict]) -> dict[str, list[str]]: """Build a port_name → [connected_port_names] map from port list.""" conn_map = {} for p in ports: pname = p["name"] if p["connections"]: conn_map[pname] = p["connections"] # Also add reverse entries for c in p.get("connections", []): if c not in conn_map: conn_map[c] = [] if pname not in conn_map[c]: conn_map[c].append(pname) return conn_map