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:
2026-06-23 12:15:09 -04:00
parent 17d423b21f
commit d19e0ea7a8
25 changed files with 4419 additions and 288 deletions
+300 -36
View File
@@ -1,8 +1,12 @@
"""
OPLabs Mixer App FastAPI Backend
OPLabs Mixer App - FastAPI Backend
Serves the React SPA and proxies WebSocket connections to PiPedal.
Provides REST API for plugin discovery and scene management.
Serves the React SPA and provides REST/WebSocket access to the
C++ Mixer Daemon via Unix domain socket IPC.
Architecture: Browser <-> FastAPI (:8081) <-> Unix Socket <-> MixerDaemon
Also retains backward compatibility with PiPedal via WebSocket proxy.
"""
import asyncio
@@ -21,6 +25,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from mixer_ipc import MixerIpcClient, MixerIpcError
from ws_proxy import PiPedalWSProxy
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -34,6 +39,8 @@ PIPEDAL_HOST = "192.168.0.245"
PIPEDAL_PORT = 8080
PIPEDAL_WS_PATH = "/pipedal"
MIXER_SOCKET_PATH = "/tmp/oplabs-mixer.sock"
# OPLabs plugin URIs we care about
OPLABS_PLUGIN_URIS = {
"http://ourpad.casa/plugins/oplabs-band-channel",
@@ -45,7 +52,7 @@ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
SCENES_FILE = Path(__file__).resolve().parent / "scenes.json"
# ---------------------------------------------------------------------------
# Global proxy state
# Global state
# ---------------------------------------------------------------------------
pipedal_proxy: Optional[PiPedalWSProxy] = None
@@ -56,6 +63,10 @@ pipedal_state: dict = {
"oplabs_instances": [], # discovered OPLabs plugin instances
}
# Mixer daemon IPC client (set in lifespan)
mixer_client: Optional[MixerIpcClient] = None
mixer_daemon_connected: bool = False
# ---------------------------------------------------------------------------
# Scene storage helpers
# ---------------------------------------------------------------------------
@@ -81,8 +92,7 @@ def _save_scenes(scenes: list[dict]):
# ---------------------------------------------------------------------------
async def pipedal_request(message_name: str, body=None, timeout: float = 10.0):
"""Connect to PiPedal WS, send a request, wait for reply, then disconnect.
Handles interleaved push messages by discarding non-matching messages."""
"""Connect to PiPedal WS, send a request, wait for reply, then disconnect."""
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
reply_to = hash(message_name + str(time.time())) & 0x7FFFFFFF
@@ -92,6 +102,8 @@ async def pipedal_request(message_name: str, body=None, timeout: float = 10.0):
try:
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
ws.settimeout(timeout)
# Send hello first to register as subscriber
hello_reply_to = 0x10000001
hello_msg = json.dumps([{"message": "hello", "replyTo": hello_reply_to}])
@@ -151,8 +163,7 @@ def is_oplabs_plugin(uri: str) -> bool:
def discover_oplabs_from_pedalboard(pedalboard: dict) -> list[dict]:
"""Extract OPLabs plugin instances from a pedalboard state dict.
Handles split items (which contain nested sub-items)."""
"""Extract OPLabs plugin instances from a pedalboard state dict."""
instances = []
if not pedalboard:
return instances
@@ -200,6 +211,28 @@ def fetch_pedalboard_state():
return False
# ---------------------------------------------------------------------------
# Mixer Daemon IPC helpers
# ---------------------------------------------------------------------------
async def with_mixer_client(method_name: str, *args, **kwargs):
"""Call a method on the MixerIpcClient, handling connection errors."""
global mixer_client, mixer_daemon_connected
if not mixer_client or not mixer_client.is_connected:
mixer_daemon_connected = False
raise MixerIpcError("Mixer daemon not connected")
try:
result = await getattr(mixer_client, method_name)(*args, **kwargs)
mixer_daemon_connected = True
return result
except MixerIpcError:
raise
except Exception as e:
mixer_daemon_connected = False
raise MixerIpcError(f"Mixer daemon communication error: {e}")
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
@@ -207,10 +240,26 @@ def fetch_pedalboard_state():
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown."""
global pipedal_proxy
global pipedal_proxy, mixer_client
# Start PiPedal proxy
pipedal_proxy = PiPedalWSProxy(host=PIPEDAL_HOST, port=PIPEDAL_PORT, path=PIPEDAL_WS_PATH)
logger.info("Mixer app starting PiPedal at %s:%d", PIPEDAL_HOST, PIPEDAL_PORT)
logger.info("Mixer app starting - PiPedal at %s:%d", PIPEDAL_HOST, PIPEDAL_PORT)
# Try connecting to Mixer Daemon
mixer_client = MixerIpcClient(socket_path=MIXER_SOCKET_PATH)
connected = await mixer_client.connect()
if connected:
logger.info("Connected to Mixer Daemon at %s", MIXER_SOCKET_PATH)
else:
logger.warning("Mixer Daemon not available at %s - mixer endpoints disabled",
MIXER_SOCKET_PATH)
yield
# Shutdown
if mixer_client:
await mixer_client.close()
if pipedal_proxy:
await pipedal_proxy.close_upstream()
logger.info("Mixer app shutting down")
@@ -218,13 +267,158 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="OPLabs Mixer App",
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Mixer Daemon REST endpoints (new)
# ---------------------------------------------------------------------------
@app.get("/api/mixer/state")
async def mixer_get_state():
"""Get full mixer state from the mixer daemon."""
try:
state = await with_mixer_client("get_state")
return state
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/volume")
async def mixer_set_channel_volume(channel_index: int, body: dict):
"""Set channel volume in dB."""
volume_db = body.get("volumeDb", body.get("volume", -96.0))
try:
await with_mixer_client("set_channel_volume", channel_index, volume_db)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/pan")
async def mixer_set_channel_pan(channel_index: int, body: dict):
"""Set channel pan (-1.0 to +1.0)."""
pan = body.get("pan", 0.0)
try:
await with_mixer_client("set_channel_pan", channel_index, pan)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/mute")
async def mixer_set_channel_mute(channel_index: int, body: dict):
"""Set channel mute state."""
mute = body.get("mute", True)
try:
await with_mixer_client("set_channel_mute", channel_index, mute)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/solo")
async def mixer_set_channel_solo(channel_index: int, body: dict):
"""Set channel solo state."""
solo = body.get("solo", True)
try:
await with_mixer_client("set_channel_solo", channel_index, solo)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/add")
async def mixer_add_channel(body: dict):
"""Add a new channel. Returns {channelIndex: N}."""
physical_input = body.get("physicalInputIndex", 0)
try:
idx = await with_mixer_client("add_channel", physical_input)
return {"channelIndex": idx}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.delete("/api/mixer/channel/{channel_index}")
async def mixer_remove_channel(channel_index: int):
"""Remove a channel by index."""
try:
await with_mixer_client("remove_channel", channel_index)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.get("/api/mixer/output-routes")
async def mixer_get_output_routes():
"""Get output routing table."""
try:
routes = await with_mixer_client("get_output_routes")
return {"routes": routes}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/output-routes")
async def mixer_set_output_routes(body: dict):
"""Set output routing table."""
routes = body.get("routes", [])
try:
await with_mixer_client("set_output_routes", routes)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/scenes")
async def mixer_save_scene(body: dict):
"""Save current mixer state as a scene."""
name = body.get("name", "Unnamed Scene")
try:
result = await with_mixer_client("save_scene", name)
return {"scene": result}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/scenes/{scene_id}/load")
async def mixer_load_scene(scene_id: str):
"""Load (recall) a scene by ID."""
try:
ok = await with_mixer_client("load_scene", scene_id)
if ok:
return {"status": "ok"}
return JSONResponse({"error": "Scene not found"}, status_code=404)
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.get("/api/mixer/scenes")
async def mixer_list_scenes():
"""List available scenes."""
try:
scenes = await with_mixer_client("list_scenes")
return {"scenes": scenes}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.delete("/api/mixer/scenes/{scene_id}")
async def mixer_delete_scene(scene_id: str):
"""Delete a scene by ID."""
try:
ok = await with_mixer_client("delete_scene", scene_id)
if ok:
return {"status": "deleted"}
return JSONResponse({"error": "Scene not found"}, status_code=404)
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
# ---------------------------------------------------------------------------
# Scene REST endpoints
# Scene REST endpoints (legacy - file-based, via PiPedal)
# ---------------------------------------------------------------------------
class SceneStateChannel(BaseModel):
@@ -248,14 +442,14 @@ class SceneUpdate(BaseModel):
@app.get("/api/scenes")
async def list_scenes():
"""List all saved scenes."""
"""List all saved scenes (legacy file-based)."""
scenes = _load_scenes()
return {"scenes": scenes}
@app.post("/api/scenes")
async def create_scene(scene: SceneCreate):
"""Save a new scene."""
"""Save a new scene (legacy file-based)."""
scenes = _load_scenes()
new_scene = {
"id": str(uuid.uuid4()),
@@ -298,9 +492,7 @@ async def delete_scene(scene_id: str):
@app.post("/api/scenes/{scene_id}/recall")
async def recall_scene(scene_id: str):
"""Recall a scene by sending setControlValue messages to PiPedal for each
channel and bus parameter. Connects fresh to PiPedal and sends sequenced
messages."""
"""Recall a scene via PiPedal."""
scenes = _load_scenes()
scene = None
for sc in scenes:
@@ -314,7 +506,6 @@ async def recall_scene(scene_id: str):
channels = state.get("channels", [])
buses = state.get("buses", [])
# Build all setControlValue messages
messages: list[dict] = []
for ch in channels:
instance_id = ch["instanceId"]
@@ -334,14 +525,11 @@ async def recall_scene(scene_id: str):
if not messages:
return {"status": "recalled", "message_count": 0}
# Connect to PiPedal and send sequenced messages
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
try:
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
# Send hello
hello_reply_to = 0x10000001
await ws.send(json.dumps([{"message": "hello", "replyTo": hello_reply_to}]))
# Wait for hello reply (discard push messages)
deadline = time.time() + 10.0
got_hello = False
while time.time() < deadline:
@@ -357,7 +545,6 @@ async def recall_scene(scene_id: str):
logger.warning("Recall: hello reply not received")
return JSONResponse({"error": "PiPedal not responding"}, status_code=502)
# Send each message with a small delay to let PiPedal process in order
sent = 0
for msg in messages:
reply_to = (hash(str(msg)) & 0x7FFFFFFF)
@@ -367,11 +554,10 @@ async def recall_scene(scene_id: str):
])
await ws.send(wire_msg)
sent += 1
# Small delay between messages to ensure ordering
if sent % 5 == 0:
await asyncio.sleep(0.01)
logger.info("Recalled scene '%s' sent %d control messages", scene["name"], sent)
logger.info("Recalled scene '%s' - sent %d control messages", scene["name"], sent)
return {"status": "recalled", "message_count": sent}
except Exception as e:
logger.warning("Recall failed: %s", e)
@@ -379,13 +565,13 @@ async def recall_scene(scene_id: str):
# ---------------------------------------------------------------------------
# Existing REST endpoints
# PiPedal REST endpoints (legacy)
# ---------------------------------------------------------------------------
@app.get("/api/discover")
async def discover_plugins():
"""Discover OPLabs plugin instances from PiPedal's current pedalboard."""
"""Discover OPLabs plugin instances from PiPedal."""
ok = fetch_pedalboard_state()
return {
"connected": pipedal_state["connected"],
@@ -403,37 +589,39 @@ async def list_pipedal_plugins():
@app.get("/api/ping")
async def ping():
"""Health check + connection status."""
"""Health check."""
return {
"status": "ok",
"pipedal_host": PIPEDAL_HOST,
"pipedal_port": PIPEDAL_PORT,
"connected": pipedal_state["connected"],
"pipedal_connected": pipedal_state["connected"],
"mixer_daemon_connected": mixer_daemon_connected if mixer_client else False,
}
# ---------------------------------------------------------------------------
# WebSocket proxy endpoint
# WebSocket endpoints
# ---------------------------------------------------------------------------
@app.websocket("/ws/pipedal")
async def websocket_proxy(ws: WebSocket):
"""Proxy WebSocket: browser local FastAPI PiPedal."""
"""Proxy WebSocket: browser <-> local FastAPI <-> PiPedal."""
await ws.accept()
logger.info("Browser WS connected")
logger.info("Browser WS connected (PiPedal proxy)")
proxy = PiPedalWSProxy(host=PIPEDAL_HOST, port=PIPEDAL_PORT, path=PIPEDAL_WS_PATH)
connected = await proxy.connect_upstream()
if not connected:
await ws.send_json({"type": "error", "message": f"Cannot connect to PiPedal at {proxy.upstream_url}"})
await ws.send_json({
"type": "error",
"message": f"Cannot connect to PiPedal at {proxy.upstream_url}"
})
await ws.close()
return
pipedal_state["connected"] = True
# Notify browser that upstream is connected
await ws.send_json({"type": "connected", "pipedal": proxy.upstream_url})
try:
@@ -447,6 +635,83 @@ async def websocket_proxy(ws: WebSocket):
await proxy.close_upstream()
@app.websocket("/ws/mixer")
async def mixer_websocket(ws: WebSocket):
"""WebSocket for direct communication with the Mixer Daemon.
Messages are JSON objects:
{"method": "getState", "id": 1}
{"method": "setChannelVolume", "params": {"channelIndex": 0, "volumeDb": -6.0}, "id": 2}
Responses mirror the IPC protocol:
{"id": 1, "result": {...}, "error": null}
"""
await ws.accept()
logger.info("Browser WS connected (Mixer Daemon)")
if not mixer_client or not mixer_client.is_connected:
await ws.send_json({
"type": "error",
"message": "Mixer daemon not available"
})
await ws.close()
return
await ws.send_json({"type": "connected", "daemon": MIXER_SOCKET_PATH})
try:
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await ws.send_json({"id": -1, "error": "Invalid JSON"})
continue
method = msg.get("method", "")
params = msg.get("params", {})
req_id = msg.get("id", -1)
# Map WebSocket methods to MixerIpcClient methods
method_map = {
"getState": ("get_state", None),
"setChannelVolume": ("set_channel_volume", ["channelIndex", "volumeDb"]),
"setChannelPan": ("set_channel_pan", ["channelIndex", "pan"]),
"setChannelMute": ("set_channel_mute", ["channelIndex", "mute"]),
"setChannelSolo": ("set_channel_solo", ["channelIndex", "solo"]),
"addChannel": ("add_channel", ["physicalInputIndex"]),
"removeChannel": ("remove_channel", ["channelIndex"]),
"getOutputRoutes": ("get_output_routes", None),
"setOutputRoutes": ("set_output_routes", ["routes"]),
"saveScene": ("save_scene", ["name"]),
"loadScene": ("load_scene", ["sceneId"]),
"listScenes": ("list_scenes", None),
"deleteScene": ("delete_scene", ["sceneId"]),
}
if method not in method_map:
await ws.send_json({"id": req_id, "error": f"Unknown method: {method}"})
continue
client_method, param_keys = method_map[method]
try:
if param_keys is None:
result = await getattr(mixer_client, client_method)()
else:
args = [params.get(k) for k in param_keys]
result = await getattr(mixer_client, client_method)(*args)
await ws.send_json({"id": req_id, "result": result, "error": None})
except MixerIpcError as e:
await ws.send_json({"id": req_id, "error": str(e)})
except WebSocketDisconnect:
logger.info("Mixer WS disconnected")
except Exception as e:
logger.warning("Mixer WS error: %s", e)
# ---------------------------------------------------------------------------
# Serve SPA (must be last, after all API routes)
# ---------------------------------------------------------------------------
@@ -458,9 +723,8 @@ if FRONTEND_DIR.exists():
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve the SPA return index.html for all non-API routes."""
"""Serve the SPA - return index.html for all non-API routes."""
if full_path.startswith("api/") or full_path.startswith("ws/"):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "not found"}, status_code=404)
if not SPA_INDEX.exists():
return JSONResponse(