Files
oplabs-mixer-app/backend/jack_endpoints.py
T
shawn d19e0ea7a8 Add MasterBus component + enhance BusStrip with routing selector
MasterBus: stereo master fader, mute, level meters in PiPedal theme
(purple accent per mixer-engine branch reference).

BusStrip: added per-channel routing selector (ch1Route..ch8Route)
for selecting output bus target (Main, Bus 2-8).

MixerPage: integrated MasterBus with levels derived from bus
master data. Local state for volume/mute until backend exposes
master output.
2026-06-23 12:15:09 -04:00

421 lines
14 KiB
Python

"""
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