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.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user