Initial: Standalone mixer app with MixerPage, scene management, WS proxy
This commit is contained in:
+485
@@ -0,0 +1,485 @@
|
||||
"""
|
||||
OPLabs Mixer App — FastAPI Backend
|
||||
|
||||
Serves the React SPA and proxies WebSocket connections to PiPedal.
|
||||
Provides REST API for plugin discovery and scene management.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import websockets
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ws_proxy import PiPedalWSProxy
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("mixer-app")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PIPEDAL_HOST = "192.168.0.245"
|
||||
PIPEDAL_PORT = 8080
|
||||
PIPEDAL_WS_PATH = "/pipedal"
|
||||
|
||||
# OPLabs plugin URIs we care about
|
||||
OPLABS_PLUGIN_URIS = {
|
||||
"http://ourpad.casa/plugins/oplabs-band-channel",
|
||||
"http://ourpad.casa/plugins/oplabs-band-bus",
|
||||
"http://ourpad.casa/plugins/oplabs-band-bus#", # alternate form
|
||||
}
|
||||
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
SCENES_FILE = Path(__file__).resolve().parent / "scenes.json"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global proxy state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
pipedal_proxy: Optional[PiPedalWSProxy] = None
|
||||
pipedal_state: dict = {
|
||||
"connected": False,
|
||||
"plugins": [], # plugin descriptors from PiPedal
|
||||
"pedalboard": None, # current pedalboard state
|
||||
"oplabs_instances": [], # discovered OPLabs plugin instances
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene storage helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _load_scenes() -> list[dict]:
|
||||
"""Load scenes from the scenes.json file."""
|
||||
if not SCENES_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(SCENES_FILE.read_text())
|
||||
return data.get("scenes", [])
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.warning("Failed to load scenes: %s", e)
|
||||
return []
|
||||
|
||||
def _save_scenes(scenes: list[dict]):
|
||||
"""Save scenes to the scenes.json file."""
|
||||
SCENES_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
SCENES_FILE.write_text(json.dumps({"scenes": scenes}, indent=2))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PiPedal WS client for backend-internal use
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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."""
|
||||
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
|
||||
reply_to = hash(message_name + str(time.time())) & 0x7FFFFFFF
|
||||
|
||||
msg = [{"message": message_name, "replyTo": reply_to}]
|
||||
if body is not None:
|
||||
msg.append(body)
|
||||
|
||||
try:
|
||||
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
|
||||
# Send hello first to register as subscriber
|
||||
hello_reply_to = 0x10000001
|
||||
hello_msg = json.dumps([{"message": "hello", "replyTo": hello_reply_to}])
|
||||
await ws.send(hello_msg)
|
||||
|
||||
# Wait for hello reply (discard other push messages)
|
||||
deadline = time.time() + timeout
|
||||
got_hello = False
|
||||
while time.time() < deadline:
|
||||
remaining = deadline - time.time()
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list) and len(data) >= 1:
|
||||
hdr = data[0]
|
||||
if hdr.get("reply") == hello_reply_to:
|
||||
got_hello = True
|
||||
break
|
||||
if not got_hello:
|
||||
logger.warning("Hello reply not received")
|
||||
return None
|
||||
|
||||
# Send actual request
|
||||
await ws.send(json.dumps(msg))
|
||||
|
||||
# Wait for matching reply (discard interleaved push messages)
|
||||
while time.time() < deadline:
|
||||
remaining = deadline - time.time()
|
||||
if remaining <= 0:
|
||||
break
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list) and len(data) >= 1:
|
||||
hdr = data[0]
|
||||
if hdr.get("reply") == reply_to:
|
||||
body_result = data[1] if len(data) > 1 else None
|
||||
return body_result
|
||||
logger.warning("Request '%s' timed out (no matching reply)", message_name)
|
||||
return None
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Request '%s' timed out", message_name)
|
||||
return None
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("Request '%s': connection closed", message_name)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning("Request '%s' failed: %s", message_name, e)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OPLabs plugin discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_oplabs_plugin(uri: str) -> bool:
|
||||
"""Check if a plugin URI is one of our OPLabs plugins."""
|
||||
return uri in OPLABS_PLUGIN_URIS
|
||||
|
||||
|
||||
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)."""
|
||||
instances = []
|
||||
if not pedalboard:
|
||||
return instances
|
||||
|
||||
def extract_from_item(item: dict):
|
||||
uri = item.get("uri", "")
|
||||
if is_oplabs_plugin(uri):
|
||||
control_values = item.get("controlValues", [])
|
||||
cv_map = {cv["key"]: cv["value"] for cv in control_values if "key" in cv}
|
||||
instances.append({
|
||||
"instanceId": item.get("instanceId"),
|
||||
"uri": uri,
|
||||
"pluginName": item.get("pluginName", ""),
|
||||
"title": item.get("title", ""),
|
||||
"isEnabled": item.get("isEnabled", True),
|
||||
"controlValues": cv_map,
|
||||
"portValues": cv_map, # alias for clarity
|
||||
})
|
||||
# Handle split items (nested chains)
|
||||
if "chaining" in item:
|
||||
for chain in item["chaining"]:
|
||||
if isinstance(chain, dict) and "items" in chain:
|
||||
for sub_item in chain["items"]:
|
||||
extract_from_item(sub_item)
|
||||
|
||||
items = pedalboard.get("items", [])
|
||||
for item in items:
|
||||
extract_from_item(item)
|
||||
return instances
|
||||
|
||||
|
||||
def fetch_pedalboard_state():
|
||||
"""Fetch pedalboard state from PiPedal (blocking, for REST API)."""
|
||||
global pipedal_state
|
||||
try:
|
||||
result = asyncio.run(pipedal_request("currentPedalboard"))
|
||||
if result is not None:
|
||||
pipedal_state["pedalboard"] = result
|
||||
pipedal_state["oplabs_instances"] = discover_oplabs_from_pedalboard(result)
|
||||
pipedal_state["connected"] = True
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch pedalboard state: %s", e)
|
||||
pipedal_state["connected"] = False
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup / shutdown."""
|
||||
global 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)
|
||||
yield
|
||||
if pipedal_proxy:
|
||||
await pipedal_proxy.close_upstream()
|
||||
logger.info("Mixer app shutting down")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="OPLabs Mixer App",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scene REST endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SceneStateChannel(BaseModel):
|
||||
instanceId: int
|
||||
params: dict
|
||||
|
||||
class SceneState(BaseModel):
|
||||
channels: list[SceneStateChannel] = []
|
||||
buses: list[SceneStateChannel] = []
|
||||
|
||||
class SceneCreate(BaseModel):
|
||||
name: str
|
||||
midiPc: Optional[int] = None
|
||||
state: SceneState
|
||||
|
||||
class SceneUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
midiPc: Optional[int] = None
|
||||
state: Optional[SceneState] = None
|
||||
|
||||
|
||||
@app.get("/api/scenes")
|
||||
async def list_scenes():
|
||||
"""List all saved scenes."""
|
||||
scenes = _load_scenes()
|
||||
return {"scenes": scenes}
|
||||
|
||||
|
||||
@app.post("/api/scenes")
|
||||
async def create_scene(scene: SceneCreate):
|
||||
"""Save a new scene."""
|
||||
scenes = _load_scenes()
|
||||
new_scene = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": scene.name,
|
||||
"midiPc": scene.midiPc,
|
||||
"state": scene.state.model_dump(),
|
||||
}
|
||||
scenes.append(new_scene)
|
||||
_save_scenes(scenes)
|
||||
return {"scene": new_scene}
|
||||
|
||||
|
||||
@app.put("/api/scenes/{scene_id}")
|
||||
async def update_scene(scene_id: str, update: SceneUpdate):
|
||||
"""Update an existing scene."""
|
||||
scenes = _load_scenes()
|
||||
for sc in scenes:
|
||||
if sc["id"] == scene_id:
|
||||
if update.name is not None:
|
||||
sc["name"] = update.name
|
||||
if update.midiPc is not None:
|
||||
sc["midiPc"] = update.midiPc
|
||||
if update.state is not None:
|
||||
sc["state"] = update.state.model_dump()
|
||||
_save_scenes(scenes)
|
||||
return {"scene": sc}
|
||||
return JSONResponse({"error": "Scene not found"}, status_code=404)
|
||||
|
||||
|
||||
@app.delete("/api/scenes/{scene_id}")
|
||||
async def delete_scene(scene_id: str):
|
||||
"""Delete a scene."""
|
||||
scenes = _load_scenes()
|
||||
filtered = [sc for sc in scenes if sc["id"] != scene_id]
|
||||
if len(filtered) == len(scenes):
|
||||
return JSONResponse({"error": "Scene not found"}, status_code=404)
|
||||
_save_scenes(filtered)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@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."""
|
||||
scenes = _load_scenes()
|
||||
scene = None
|
||||
for sc in scenes:
|
||||
if sc["id"] == scene_id:
|
||||
scene = sc
|
||||
break
|
||||
if not scene:
|
||||
return JSONResponse({"error": "Scene not found"}, status_code=404)
|
||||
|
||||
state = scene.get("state", {})
|
||||
channels = state.get("channels", [])
|
||||
buses = state.get("buses", [])
|
||||
|
||||
# Build all setControlValue messages
|
||||
messages: list[dict] = []
|
||||
for ch in channels:
|
||||
instance_id = ch["instanceId"]
|
||||
for key, value in ch["params"].items():
|
||||
messages.append({
|
||||
"message": "setControlValue",
|
||||
"body": {"instanceId": instance_id, "key": key, "value": value},
|
||||
})
|
||||
for bus in buses:
|
||||
instance_id = bus["instanceId"]
|
||||
for key, value in bus["params"].items():
|
||||
messages.append({
|
||||
"message": "setControlValue",
|
||||
"body": {"instanceId": instance_id, "key": key, "value": value},
|
||||
})
|
||||
|
||||
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:
|
||||
remaining = deadline - time.time()
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
||||
data = json.loads(raw)
|
||||
if isinstance(data, list) and len(data) >= 1:
|
||||
hdr = data[0]
|
||||
if hdr.get("reply") == hello_reply_to:
|
||||
got_hello = True
|
||||
break
|
||||
if not got_hello:
|
||||
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)
|
||||
wire_msg = json.dumps([
|
||||
{"message": msg["message"], "replyTo": reply_to},
|
||||
msg["body"],
|
||||
])
|
||||
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)
|
||||
return {"status": "recalled", "message_count": sent}
|
||||
except Exception as e:
|
||||
logger.warning("Recall failed: %s", e)
|
||||
return JSONResponse({"error": f"Recall failed: {str(e)}"}, status_code=502)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Existing REST endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/discover")
|
||||
async def discover_plugins():
|
||||
"""Discover OPLabs plugin instances from PiPedal's current pedalboard."""
|
||||
ok = fetch_pedalboard_state()
|
||||
return {
|
||||
"connected": pipedal_state["connected"],
|
||||
"oplabs_instances": pipedal_state["oplabs_instances"],
|
||||
"pedalboard_name": pipedal_state["pedalboard"].get("name") if pipedal_state["pedalboard"] else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/plugins")
|
||||
async def list_pipedal_plugins():
|
||||
"""List all available plugins from PiPedal."""
|
||||
result = await pipedal_request("plugins")
|
||||
return {"plugins": result if result else []}
|
||||
|
||||
|
||||
@app.get("/api/ping")
|
||||
async def ping():
|
||||
"""Health check + connection status."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"pipedal_host": PIPEDAL_HOST,
|
||||
"pipedal_port": PIPEDAL_PORT,
|
||||
"connected": pipedal_state["connected"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WebSocket proxy endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.websocket("/ws/pipedal")
|
||||
async def websocket_proxy(ws: WebSocket):
|
||||
"""Proxy WebSocket: browser ↔ local FastAPI ↔ PiPedal."""
|
||||
await ws.accept()
|
||||
logger.info("Browser WS connected")
|
||||
|
||||
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.close()
|
||||
return
|
||||
|
||||
pipedal_state["connected"] = True
|
||||
|
||||
# Notify browser that upstream is connected
|
||||
await ws.send_json({"type": "connected", "pipedal": proxy.upstream_url})
|
||||
|
||||
try:
|
||||
await proxy.relay_loop(ws)
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Browser WS disconnected")
|
||||
except Exception as e:
|
||||
logger.warning("WebSocket proxy error: %s", e)
|
||||
finally:
|
||||
pipedal_state["connected"] = False
|
||||
await proxy.close_upstream()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serve SPA (must be last, after all API routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SPA_INDEX = FRONTEND_DIR / "index.html"
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIR / "assets")), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
"""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(
|
||||
{"error": "Frontend not built. Run 'npm run build' in frontend/"},
|
||||
status_code=503,
|
||||
)
|
||||
return FileResponse(str(SPA_INDEX))
|
||||
else:
|
||||
logger.warning("Frontend dist not found at %s. SPA serving disabled.", FRONTEND_DIR)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "OPLabs Mixer App API", "docs": "/docs"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("app:app", host="0.0.0.0", port=8081, reload=True)
|
||||
Reference in New Issue
Block a user