d19e0ea7a8
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.
421 lines
14 KiB
Python
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
|