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:
+300
-36
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
OPLabs Mixer — Unix domain socket IPC client.
|
||||
|
||||
Connects to the C++ MixerDaemon over a Unix domain socket using a
|
||||
length-prefixed JSON protocol.
|
||||
|
||||
Protocol:
|
||||
Frame: 4-byte big-endian uint32 length prefix + UTF-8 JSON payload
|
||||
|
||||
Request: {"id": <int>, "method": <string>, "params": {<object>}}
|
||||
Response: {"id": <int>, "result": <any>, "error": <string|null>}
|
||||
Push: {"type": "push", "event": <string>, "data": {<object>}}
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import struct
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger("mixer-ipc")
|
||||
|
||||
|
||||
class MixerIpcError(Exception):
|
||||
"""Raised when the mixer daemon returns an error response."""
|
||||
|
||||
|
||||
class MixerIpcClient:
|
||||
"""Async Unix socket IPC client for the OPLabs Mixer Daemon.
|
||||
|
||||
Usage:
|
||||
client = MixerIpcClient(socket_path="/tmp/oplabs-mixer.sock")
|
||||
await client.connect()
|
||||
state = await client.get_state()
|
||||
await client.set_channel_volume(0, -6.0)
|
||||
await client.close()
|
||||
"""
|
||||
|
||||
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock",
|
||||
connect_timeout: float = 5.0,
|
||||
request_timeout: float = 10.0):
|
||||
self._socket_path = socket_path
|
||||
self._connect_timeout = connect_timeout
|
||||
self._request_timeout = request_timeout
|
||||
self._reader: Optional[asyncio.StreamReader] = None
|
||||
self._writer: Optional[asyncio.StreamWriter] = None
|
||||
self._connected = False
|
||||
self._next_id = 1
|
||||
self._pending: dict[int, asyncio.Future] = {}
|
||||
self._read_task: Optional[asyncio.Task] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the Unix domain socket. Returns True on success."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._reader, self._writer = await asyncio.wait_for(
|
||||
asyncio.open_unix_connection(self._socket_path),
|
||||
timeout=self._connect_timeout,
|
||||
)
|
||||
self._connected = True
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
logger.info("Connected to mixer daemon at %s", self._socket_path)
|
||||
return True
|
||||
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
|
||||
OSError) as exc:
|
||||
logger.warning("Failed to connect to mixer daemon: %s", exc)
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
async def close(self):
|
||||
"""Close the connection."""
|
||||
self._connected = False
|
||||
if self._read_task:
|
||||
self._read_task.cancel()
|
||||
try:
|
||||
await self._read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._read_task = None
|
||||
if self._writer:
|
||||
try:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._writer = None
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Low-level send / receive
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _send_frame(self, payload: bytes):
|
||||
"""Send a length-prefixed frame."""
|
||||
length = struct.pack("!I", len(payload))
|
||||
self._writer.write(length + payload)
|
||||
await self._writer.drain()
|
||||
|
||||
async def _read_loop(self):
|
||||
"""Background task: read frames and dispatch responses."""
|
||||
try:
|
||||
while self._connected and self._reader:
|
||||
# Read 4-byte length prefix
|
||||
header = await self._reader.readexactly(4)
|
||||
length = struct.unpack("!I", header)[0]
|
||||
|
||||
if length == 0:
|
||||
continue
|
||||
|
||||
# Read JSON payload
|
||||
data = await self._reader.readexactly(length)
|
||||
payload = data.decode("utf-8")
|
||||
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("Invalid JSON from daemon: %s", payload[:200])
|
||||
continue
|
||||
|
||||
# Dispatch
|
||||
if isinstance(msg, dict):
|
||||
if "type" in msg and msg["type"] == "push":
|
||||
# Push message (VU meter updates, state changes)
|
||||
await self._handle_push(msg)
|
||||
elif "id" in msg:
|
||||
# Response to a request
|
||||
req_id = msg["id"]
|
||||
future = self._pending.pop(req_id, None)
|
||||
if future and not future.done():
|
||||
if msg.get("error"):
|
||||
future.set_exception(
|
||||
MixerIpcError(msg["error"])
|
||||
)
|
||||
else:
|
||||
future.set_result(msg.get("result"))
|
||||
except asyncio.IncompleteReadError:
|
||||
logger.info("Mixer daemon connection closed")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning("Read loop error: %s", exc)
|
||||
finally:
|
||||
self._connected = False
|
||||
# Fail all pending requests
|
||||
for future in self._pending.values():
|
||||
if not future.done():
|
||||
future.set_exception(MixerIpcError("Connection closed"))
|
||||
self._pending.clear()
|
||||
|
||||
async def _send_request(self, method: str, params: dict = None) -> Any:
|
||||
"""Send a request and wait for the response."""
|
||||
if not self._connected or not self._writer:
|
||||
raise MixerIpcError("Not connected to mixer daemon")
|
||||
|
||||
req_id = self._next_id
|
||||
self._next_id += 1
|
||||
|
||||
msg = {
|
||||
"id": req_id,
|
||||
"method": method,
|
||||
"params": params or {},
|
||||
}
|
||||
|
||||
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||
self._pending[req_id] = future
|
||||
|
||||
try:
|
||||
payload = json.dumps(msg).encode("utf-8")
|
||||
await self._send_frame(payload)
|
||||
|
||||
result = await asyncio.wait_for(future, timeout=self._request_timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
self._pending.pop(req_id, None)
|
||||
raise MixerIpcError(f"Request '{method}' timed out")
|
||||
|
||||
async def _handle_push(self, msg: dict):
|
||||
"""Handle an unsolicited push message from the daemon.
|
||||
|
||||
Override in subclass or attach a callback.
|
||||
"""
|
||||
event = msg.get("event", "unknown")
|
||||
data = msg.get("data")
|
||||
logger.debug("Push event: %s", event)
|
||||
# Subclasses can override to handle VU meter updates, etc.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# High-level API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# --- State ---
|
||||
|
||||
async def get_state(self) -> dict:
|
||||
"""Get the full mixer state (channels, buses, routes, output routes)."""
|
||||
return await self._send_request("getState")
|
||||
|
||||
# --- Channel Control ---
|
||||
|
||||
async def set_channel_volume(self, channel_index: int, volume_db: float):
|
||||
"""Set channel volume in dB (-inf to +12)."""
|
||||
await self._send_request("setChannelVolume", {
|
||||
"channelIndex": channel_index,
|
||||
"volumeDb": volume_db,
|
||||
})
|
||||
|
||||
async def set_channel_pan(self, channel_index: int, pan: float):
|
||||
"""Set channel pan (-1.0 left to +1.0 right)."""
|
||||
await self._send_request("setChannelPan", {
|
||||
"channelIndex": channel_index,
|
||||
"pan": pan,
|
||||
})
|
||||
|
||||
async def set_channel_mute(self, channel_index: int, mute: bool):
|
||||
"""Set channel mute state."""
|
||||
await self._send_request("setChannelMute", {
|
||||
"channelIndex": channel_index,
|
||||
"mute": mute,
|
||||
})
|
||||
|
||||
async def set_channel_solo(self, channel_index: int, solo: bool):
|
||||
"""Set channel solo state."""
|
||||
await self._send_request("setChannelSolo", {
|
||||
"channelIndex": channel_index,
|
||||
"solo": solo,
|
||||
})
|
||||
|
||||
# --- Channel Lifecycle ---
|
||||
|
||||
async def add_channel(self, physical_input_index: int = 0) -> int:
|
||||
"""Add a new channel. Returns the channel index."""
|
||||
result = await self._send_request("addChannel", {
|
||||
"physicalInputIndex": physical_input_index,
|
||||
})
|
||||
return result.get("channelIndex", -1)
|
||||
|
||||
async def remove_channel(self, channel_index: int):
|
||||
"""Remove a channel by index."""
|
||||
await self._send_request("removeChannel", {
|
||||
"channelIndex": channel_index,
|
||||
})
|
||||
|
||||
# --- Output Routing ---
|
||||
|
||||
async def get_output_routes(self) -> list[dict]:
|
||||
"""Get the output routing table."""
|
||||
result = await self._send_request("getOutputRoutes")
|
||||
return result.get("routes", [])
|
||||
|
||||
async def set_output_routes(self, routes: list[dict]):
|
||||
"""Set the output routing table.
|
||||
|
||||
Each route dict:
|
||||
{"sourceBusId": int, "sourceStartChannel": int,
|
||||
"targetStartChannel": int, "channels": int}
|
||||
"""
|
||||
await self._send_request("setOutputRoutes", {
|
||||
"routes": routes,
|
||||
})
|
||||
|
||||
# --- Scenes ---
|
||||
|
||||
async def save_scene(self, name: str) -> dict:
|
||||
"""Save the current mixer state as a named scene.
|
||||
Returns {"id": int, "name": str}."""
|
||||
return await self._send_request("saveScene", {"name": name})
|
||||
|
||||
async def load_scene(self, scene_id: str) -> bool:
|
||||
"""Load (recall) a scene by its ID. Returns True on success."""
|
||||
result = await self._send_request("loadScene", {"sceneId": scene_id})
|
||||
return result.get("ok", False)
|
||||
|
||||
async def list_scenes(self) -> list[dict]:
|
||||
"""List all available scenes."""
|
||||
result = await self._send_request("listScenes")
|
||||
return result.get("scenes", [])
|
||||
|
||||
async def delete_scene(self, scene_id: str) -> bool:
|
||||
"""Delete a scene by ID. Returns True on success."""
|
||||
result = await self._send_request("deleteScene", {"sceneId": scene_id})
|
||||
return result.get("ok", False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience context manager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class MixerIpcContext:
|
||||
"""Async context manager for MixerIpcClient."""
|
||||
|
||||
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock"):
|
||||
self.client = MixerIpcClient(socket_path=socket_path)
|
||||
|
||||
async def __aenter__(self) -> MixerIpcClient:
|
||||
await self.client.connect()
|
||||
return self.client
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
await self.client.close()
|
||||
Reference in New Issue
Block a user