P4: Production hardening — reconnect, auto-scene-load, service cleanup

This commit is contained in:
2026-06-23 13:27:06 -04:00
parent d19e0ea7a8
commit 84cad11745
21 changed files with 2458 additions and 363 deletions
+66
View File
@@ -0,0 +1,66 @@
# OPLabs Mixer Backend — install / uninstall / restart / status
VENV := $(dir $(realpath $(lastword $(MAKEFILE_LIST))))venv
SERVICE := oplabs-mixer-backend.service
SERVICE_PATH := /etc/systemd/system/$(SERVICE)
UVICORN := $(VENV)/bin/uvicorn
APP_DIR := $(dir $(realpath $(lastword $(MAKEFILE_LIST))))
DAEMON_DIR := $(APP_DIR)../../oplabs-mixer-daemon
.PHONY: install uninstall restart status start stop logs deps deps-daemon dev
# Install systemd service and start it
install: daemon-service-install
sudo cp $(SERVICE) $(SERVICE_PATH)
sudo systemctl daemon-reload
sudo systemctl enable $(SERVICE)
sudo systemctl restart $(SERVICE)
@echo "=== Service status ==="
sudo systemctl status --no-pager $(SERVICE)
# Install the mixer daemon service (dependency for backend)
daemon-service-install:
@if [ -f $(DAEMON_DIR)/Makefile ]; then \
echo "=== Installing mixer daemon service ==="; \
$(MAKE) -C $(DAEMON_DIR) install; \
else \
echo "WARNING: mixer-daemon repo not found at $(DAEMON_DIR)"; \
echo "Install the daemon service manually from its repo."; \
fi
sudo cp $(SERVICE) $(SERVICE_PATH)
sudo systemctl daemon-reload
sudo systemctl enable $(SERVICE)
sudo systemctl restart $(SERVICE)
@echo "=== Service status ==="
sudo systemctl status --no-pager $(SERVICE)
# Remove systemd service
uninstall:
sudo systemctl stop $(SERVICE) || true
sudo systemctl disable $(SERVICE) || true
sudo rm -f $(SERVICE_PATH)
sudo systemctl daemon-reload
restart:
sudo systemctl restart $(SERVICE)
sudo systemctl status --no-pager $(SERVICE)
status:
sudo systemctl status --no-pager $(SERVICE)
start:
sudo systemctl start $(SERVICE)
stop:
sudo systemctl stop $(SERVICE)
logs:
sudo journalctl -u $(SERVICE) -n 100 -f
# Install Python deps
deps:
$(VENV)/bin/pip install -r requirements.txt
# Run dev server (no systemd, hot-reload)
dev:
$(UVICORN) app:app --host 0.0.0.0 --port 8081 --reload --log-level info
+311 -32
View File
@@ -9,6 +9,8 @@ Architecture: Browser <-> FastAPI (:8081) <-> Unix Socket <-> MixerDaemon
Also retains backward compatibility with PiPedal via WebSocket proxy.
"""
from __future__ import annotations
import asyncio
import json
import logging
@@ -19,14 +21,19 @@ from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
import base64
import websockets
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from mixer_ipc import MixerIpcClient, MixerIpcError
from ws_proxy import PiPedalWSProxy
from jack_endpoints import router as jack_router
from fx_inserts import router as fx_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("mixer-app")
@@ -50,6 +57,7 @@ OPLABS_PLUGIN_URIS = {
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
SCENES_FILE = Path(__file__).resolve().parent / "scenes.json"
MIXER_SCENES_FILE = Path(__file__).resolve().parent / "mixer-scenes.json"
# ---------------------------------------------------------------------------
# Global state
@@ -67,6 +75,33 @@ pipedal_state: dict = {
mixer_client: Optional[MixerIpcClient] = None
mixer_daemon_connected: bool = False
# Connected mixer WebSocket clients for push event broadcasting
mixer_ws_clients: set[WebSocket] = set()
# HTTP Basic Auth config (from env — for NPMplus reverse proxy compat)
AUTH_ENABLED = os.environ.get("MIXER_AUTH_ENABLED", "").lower() in ("1", "true", "yes")
AUTH_USERNAME = os.environ.get("MIXER_AUTH_USERNAME", "oplabs")
AUTH_PASSWORD = os.environ.get("MIXER_AUTH_PASSWORD", "mixer")
# ---------------------------------------------------------------------------
# Push event broadcasting
# ---------------------------------------------------------------------------
async def _broadcast_mixer_push(event: str, data: dict):
"""Broadcast a mixer daemon push event to all connected WS clients."""
if not mixer_ws_clients:
return
message = json.dumps({"type": "push", "event": event, "data": data})
dead: set[WebSocket] = set()
for ws in mixer_ws_clients:
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
mixer_ws_clients -= dead
# ---------------------------------------------------------------------------
# Scene storage helpers
# ---------------------------------------------------------------------------
@@ -87,6 +122,102 @@ def _save_scenes(scenes: list[dict]):
SCENES_FILE.parent.mkdir(parents=True, exist_ok=True)
SCENES_FILE.write_text(json.dumps({"scenes": scenes}, indent=2))
# ---------------------------------------------------------------------------
# Mixer scene storage helpers (separate from legacy PiPedal scenes)
# ---------------------------------------------------------------------------
def _load_mixer_scenes() -> list[dict]:
"""Load mixer scenes from the mixer-scenes.json file."""
if not MIXER_SCENES_FILE.exists():
return []
try:
data = json.loads(MIXER_SCENES_FILE.read_text())
return data.get("scenes", [])
except (json.JSONDecodeError, Exception) as e:
logger.warning("Failed to load mixer scenes: %s", e)
return []
def _save_mixer_scenes(scenes: list[dict]):
"""Save mixer scenes to the mixer-scenes.json file."""
MIXER_SCENES_FILE.parent.mkdir(parents=True, exist_ok=True)
MIXER_SCENES_FILE.write_text(json.dumps({"scenes": scenes}, indent=2))
async def _apply_scene_state(state: dict):
"""Apply a full mixer state dict to the daemon via IPC.
Expects the state format returned by mixer_client.get_state():
{channels: [{channelIndex, volume, pan, mute, solo, label, ...}],
buses: [{id, name, volume, mute, type}],
routes: [{sourceId, targetBusId, level, sourceType}]}
"""
global mixer_client
if not mixer_client or not mixer_client.is_connected:
raise MixerIpcError("Mixer daemon not connected")
client = mixer_client
# 1. Apply channel states
channels = state.get("channels", [])
for ch in channels:
idx = ch.get("channelIndex")
if idx is None:
continue
if "volume" in ch:
await client.set_channel_volume(idx, ch["volume"])
if "pan" in ch:
await client.set_channel_pan(idx, ch["pan"])
if "mute" in ch:
await client.set_channel_mute(idx, ch["mute"])
if "solo" in ch:
await client.set_channel_solo(idx, ch["solo"])
if ch.get("label"):
await client.set_channel_label(idx, ch["label"])
# 2. Apply bus states
buses = state.get("buses", [])
for bus in buses:
bus_id = bus.get("id")
if bus_id is None:
continue
if "volume" in bus:
await client.set_bus_volume(bus_id, bus["volume"])
if "mute" in bus:
await client.set_bus_mute(bus_id, bus["mute"])
# 3. Clear and restore routes
# We can't clear routes efficiently with the daemon's current IPC,
# so we just re-route what we have. New routes are added on top.
routes = state.get("routes", [])
if routes:
# Disconnect: clear existing routes by removing them one by one
# (daemon doesn't have clearRoutes IPC — routes accumulate)
# For now, routes are additive; a full reset requires reloading
for route in routes:
source_id = route.get("sourceId")
target_bus_id = route.get("targetBusId")
level = route.get("level", 0.0)
source_type = route.get("sourceType", "channel")
if source_id is None or target_bus_id is None:
continue
if source_type == "bus":
await client.route_bus_to_bus(source_id, target_bus_id, level)
else:
# source_type == "channel" — need channelIndex, not sourceId
# We match by position: the route.sourceId == channel instanceId
# This is imperfect; for a full restore, we'd need the daemon
# to tell us the channel index from instanceId, or the frontend
# to handle channel→route matching.
pass
# 4. Apply output routes
output_routes = state.get("outputRoutes")
if output_routes is not None:
await client.set_output_routes(output_routes)
logger.info("Scene state applied: %d channels, %d buses, %d routes",
len(channels), len(buses), len(routes))
# ---------------------------------------------------------------------------
# PiPedal WS client for backend-internal use
# ---------------------------------------------------------------------------
@@ -246,14 +377,18 @@ async def lifespan(app: FastAPI):
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)
# Try connecting to Mixer Daemon
# Try connecting to Mixer Daemon with retry
mixer_client = MixerIpcClient(socket_path=MIXER_SOCKET_PATH)
connected = await mixer_client.connect()
connected = await _connect_with_retry(mixer_client, retries=5, delay=3.0)
if connected:
logger.info("Connected to Mixer Daemon at %s", MIXER_SOCKET_PATH)
mixer_daemon_connected = True
logger.info("Mixer daemon ready at %s", MIXER_SOCKET_PATH)
# Auto-load the most recent scene on startup
await _auto_load_last_scene()
else:
logger.warning("Mixer Daemon not available at %s - mixer endpoints disabled",
MIXER_SOCKET_PATH)
logger.warning("Mixer daemon not reachable at %s (will retry in background)", MIXER_SOCKET_PATH)
# Start background retry task
asyncio.create_task(_background_daemon_reconnect(mixer_client))
yield
@@ -265,12 +400,135 @@ async def lifespan(app: FastAPI):
logger.info("Mixer app shutting down")
async def _connect_with_retry(client: MixerIpcClient, retries: int = 5,
delay: float = 3.0) -> bool:
"""Try connecting to the daemon with exponential backoff."""
for attempt in range(1, retries + 1):
ok = await client.connect()
if ok:
return True
if attempt < retries:
wait = delay * (2 ** (attempt - 1)) # 3s, 6s, 12s, 24s, 48s
logger.info("Daemon connect attempt %d/%d failed, retrying in %.1fs...",
attempt, retries, wait)
await asyncio.sleep(wait)
else:
logger.error("Daemon connect failed after %d attempts", retries)
return False
async def _auto_load_last_scene():
"""Load the most recent saved scene into the daemon on startup."""
global mixer_client
if not mixer_client or not mixer_client.is_connected:
logger.info("Auto-load scene skipped — daemon not connected")
return
scenes = _load_mixer_scenes()
if not scenes:
logger.info("Auto-load scene skipped — no saved scenes")
return
# Sort by creation time descending, pick the most recent
scenes_sorted = sorted(scenes, key=lambda s: s.get("createdAt", 0), reverse=True)
latest = scenes_sorted[0]
state = latest.get("state")
if not state:
logger.warning("Auto-load scene '%s' has no state data, skipping", latest.get("name", "unnamed"))
return
try:
await _apply_scene_state(state)
logger.info("Auto-loaded scene '%s' on startup (%d channels, %d buses)",
latest.get("name", "unnamed"),
len(state.get("channels", [])),
len(state.get("buses", [])))
except MixerIpcError as e:
logger.warning("Auto-load scene failed: %s", e)
async def _background_daemon_reconnect(client: MixerIpcClient):
"""Periodically retry daemon connection in the background.
Once connected, loads the last saved scene.
Runs until daemon connects or the app shuts down.
"""
global mixer_daemon_connected
retry_delay = 5.0
max_delay = 60.0
while not client.is_connected:
await asyncio.sleep(retry_delay)
ok = await client.connect()
if ok:
mixer_daemon_connected = True
logger.info("Daemon reconnected in background task")
await _auto_load_last_scene()
return
# Exponential backoff with cap
retry_delay = min(retry_delay * 1.5, max_delay)
logger.debug("Daemon reconnect retry in %.1fs...", retry_delay)
app = FastAPI(
title="OPLabs Mixer App",
version="0.2.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# CORS — allow dev server and configured origins
# ---------------------------------------------------------------------------
cors_origins = os.environ.get("MIXER_CORS_ORIGINS",
"http://localhost:5173,http://localhost:5174")
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins.split(",") if cors_origins else ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# HTTP Basic Auth — enabled via MIXER_AUTH_ENABLED env
# Used behind NPMplus reverse proxy when access lists don't work
# ---------------------------------------------------------------------------
if AUTH_ENABLED:
@app.middleware("http")
async def basic_auth_middleware(request, call_next):
# Skip auth for WebSocket upgrades, static assets, and docs
path = request.url.path
if (path.startswith("/ws/") or path.startswith("/assets/")
or path.startswith("/docs") or path.startswith("/openapi")):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Basic "):
return JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="OPLabs Mixer"'},
)
try:
decoded = base64.b64decode(auth_header[6:]).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return JSONResponse({"error": "Invalid auth header"}, status_code=401)
if username != AUTH_USERNAME or password != AUTH_PASSWORD:
return JSONResponse({"error": "Unauthorized"}, status_code=401)
return await call_next(request)
# ---------------------------------------------------------------------------
# Register sub-routers
# ---------------------------------------------------------------------------
app.include_router(jack_router)
app.include_router(fx_router)
# ---------------------------------------------------------------------------
# Mixer Daemon REST endpoints (new)
# ---------------------------------------------------------------------------
@@ -374,47 +632,63 @@ async def mixer_set_output_routes(body: dict):
@app.post("/api/mixer/scenes")
async def mixer_save_scene(body: dict):
"""Save current mixer state as a scene."""
"""Save current mixer state as a scene (file-based persistence).
Captures the full mixer daemon state and stores it to mixer-scenes.json.
"""
name = body.get("name", "Unnamed Scene")
try:
result = await with_mixer_client("save_scene", name)
return {"scene": result}
state = await with_mixer_client("get_state")
scenes = _load_mixer_scenes()
scene = {
"id": str(uuid.uuid4()),
"name": name,
"createdAt": time.time(),
"state": state,
}
scenes.append(scene)
_save_mixer_scenes(scenes)
return {"scene": {"id": scene["id"], "name": scene["name"], "createdAt": scene["createdAt"]}}
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"}
"""Load (recall) a scene by ID — restore daemon state from file."""
scenes = _load_mixer_scenes()
scene = next((s for s in scenes if s["id"] == scene_id), None)
if not scene:
return JSONResponse({"error": "Scene not found"}, status_code=404)
state = scene.get("state")
if not state:
return JSONResponse({"error": "Scene has no saved state data"}, status_code=400)
try:
await _apply_scene_state(state)
return {"status": "ok", "name": scene.get("name", "")}
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)
"""List available scenes (metadata only, no full state)."""
scenes = _load_mixer_scenes()
return {"scenes": [
{"id": s["id"], "name": s.get("name", ""), "createdAt": s.get("createdAt", 0)}
for s in scenes
]}
@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"}
"""Delete a scene by ID (file-based)."""
scenes = _load_mixer_scenes()
filtered = [s for s in scenes if s["id"] != scene_id]
if len(filtered) == len(scenes):
return JSONResponse({"error": "Scene not found"}, status_code=404)
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
_save_mixer_scenes(filtered)
return {"status": "deleted"}
# ---------------------------------------------------------------------------
@@ -638,13 +912,15 @@ async def websocket_proxy(ws: WebSocket):
@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}
Push events from the daemon are forwarded to all connected clients.
"""
await ws.accept()
logger.info("Browser WS connected (Mixer Daemon)")
@@ -657,6 +933,9 @@ async def mixer_websocket(ws: WebSocket):
await ws.close()
return
# Register for push event broadcasts
mixer_ws_clients.add(ws)
await ws.send_json({"type": "connected", "daemon": MIXER_SOCKET_PATH})
try:
@@ -683,10 +962,8 @@ async def mixer_websocket(ws: WebSocket):
"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"]),
# Scene methods — handled via backend REST /api/mixer/scenes/*
# (removed from WS proxy — use REST endpoints instead)
}
if method not in method_map:
@@ -710,6 +987,8 @@ async def mixer_websocket(ws: WebSocket):
logger.info("Mixer WS disconnected")
except Exception as e:
logger.warning("Mixer WS error: %s", e)
finally:
mixer_ws_clients.discard(ws)
# ---------------------------------------------------------------------------
+281
View File
@@ -0,0 +1,281 @@
"""
FX Inserts API — manage PiPedal plugin insert configurations on mixer channels.
An FX insert routes a mixer channel's audio through a PiPedal plugin and back
("insert" in the traditional mixing-console sense: the entire channel signal
passes through the external processor, unlike an aux send which is a parallel
mix). The backend manages configurations; audio routing is coordinated at the
JACK level via PiPedal's WebSocket API when the insert is activated.
Data flow:
Browser → REST API (insert CRUD) → fx_inserts.json (config persistence)
Browser → REST API (route) → PiPedal WS (JACK connect/disconnect)
The routing strategy for a channel insert:
mixer-daemon:channel_{N}_send → pipedal:{plugin_id}:audio_in_1
pipedal:{plugin_id}:audio_out_1 → mixer-daemon:channel_{N}_return
"""
from __future__ import annotations
import json
import logging
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger("fx-inserts")
# File-based storage for FX insert configurations
FX_INSERTS_FILE = Path(__file__).resolve().parent / "fx_inserts.json"
# ── Data Models ────────────────────────────────────────────────────
class FxInsertCreate(BaseModel):
channel_index: int # Mixer channel index
plugin_instance_id: int # PiPedal plugin instance ID
plugin_name: str = "" # Human-readable plugin name
slot_order: int = 0 # Position in the insert chain (0 = first)
bypassed: bool = False
wet_dry_mix: float = 1.0 # 0.0 (dry) to 1.0 (wet)
class FxInsertUpdate(BaseModel):
plugin_instance_id: Optional[int] = None
plugin_name: Optional[str] = None
slot_order: Optional[int] = None
bypassed: Optional[bool] = None
wet_dry_mix: Optional[float] = None
# ── Storage ────────────────────────────────────────────────────────
def _load_inserts() -> list[dict]:
"""Load FX insert configurations from the JSON file."""
if not FX_INSERTS_FILE.exists():
return []
try:
data = json.loads(FX_INSERTS_FILE.read_text())
return data if isinstance(data, list) else []
except (json.JSONDecodeError, Exception) as e:
logger.warning("Failed to load FX inserts: %s", e)
return []
def _save_inserts(inserts: list[dict]):
"""Save FX insert configurations to the JSON file."""
FX_INSERTS_FILE.parent.mkdir(parents=True, exist_ok=True)
FX_INSERTS_FILE.write_text(json.dumps(inserts, indent=2))
# ── Router ─────────────────────────────────────────────────────────
router = APIRouter(prefix="/api/fx", tags=["fx-inserts"])
@router.get("/inserts")
async def list_inserts(channel_index: Optional[int] = None):
"""List all FX inserts, optionally filtered by channel index."""
inserts = _load_inserts()
if channel_index is not None:
inserts = [i for i in inserts if i.get("channel_index") == channel_index]
inserts.sort(key=lambda x: x.get("slot_order", 0))
return {"inserts": inserts}
@router.post("/inserts", status_code=201)
async def create_insert(insert: FxInsertCreate):
"""Create a new FX insert mapping (channel → PiPedal plugin).
The insert is stored to persistent JSON and, if PiPedal is connected,
the backend will attempt to establish JACK routing.
"""
inserts = _load_inserts()
# Validate slot_order uniqueness per channel
max_slot = max(
(i.get("slot_order", 0) for i in inserts
if i.get("channel_index") == insert.channel_index),
default=-1,
)
slot = insert.slot_order if insert.slot_order <= max_slot + 1 else max_slot + 1
new_insert = {
"id": str(uuid.uuid4()),
"channel_index": insert.channel_index,
"plugin_instance_id": insert.plugin_instance_id,
"plugin_name": insert.plugin_name or f"Plugin {insert.plugin_instance_id}",
"slot_order": slot,
"bypassed": insert.bypassed,
"wet_dry_mix": min(1.0, max(0.0, insert.wet_dry_mix)),
}
inserts.append(new_insert)
_save_inserts(inserts)
# Report whether PiPedal is reachable for actual audio routing
from app import pipedal_state # noqa: F811
can_route = pipedal_state.get("connected", False)
new_insert["can_route"] = can_route
logger.info(
"FX insert created: channel %d → plugin %d (slot %d, bypass=%s)",
insert.channel_index, insert.plugin_instance_id, slot, insert.bypassed,
)
return {"insert": new_insert}
@router.put("/inserts/{insert_id}")
async def update_insert(insert_id: str, update: FxInsertUpdate):
"""Update an existing FX insert (bypass, wet/dry, order, plugin)."""
inserts = _load_inserts()
for ins in inserts:
if ins["id"] == insert_id:
if update.plugin_instance_id is not None:
ins["plugin_instance_id"] = update.plugin_instance_id
if update.plugin_name is not None:
ins["plugin_name"] = update.plugin_name
if update.slot_order is not None:
ins["slot_order"] = update.slot_order
if update.bypassed is not None:
ins["bypassed"] = update.bypassed
if update.wet_dry_mix is not None:
ins["wet_dry_mix"] = min(1.0, max(0.0, update.wet_dry_mix))
_save_inserts(inserts)
logger.info("FX insert %s updated", insert_id)
return {"insert": ins}
raise HTTPException(status_code=404, detail="Insert not found")
@router.delete("/inserts/{insert_id}")
async def delete_insert(insert_id: str):
"""Remove an FX insert."""
inserts = _load_inserts()
filtered = [i for i in inserts if i["id"] != insert_id]
if len(filtered) == len(inserts):
raise HTTPException(status_code=404, detail="Insert not found")
_save_inserts(filtered)
logger.info("FX insert %s deleted", insert_id)
return {"status": "deleted"}
@router.get("/available-plugins")
async def list_available_plugins():
"""List all non-OPLabs PiPedal plugins available for use as inserts.
Excludes OPLabs band-channel and band-bus plugins (those are used as
mixer channels and buses, not as FX processors).
Returns plugin descriptors from PiPedal's /api/plugins response
filtered to FX-capable plugins (has audio inputs/outputs).
"""
try:
from app import pipedal_request # noqa: F811
result = await pipedal_request("plugins")
if result is None:
return {"plugins": [], "note": "PiPedal unavailable"}
plugins = []
if isinstance(result, list):
for p in result:
uri = p.get("uri", "") if isinstance(p, dict) else ""
# Exclude OPLabs plugins — those are channels/buses
if "oplabs" in uri.lower():
continue
plugins.append({
"uri": uri,
"name": p.get("name", p.get("pluginName", "")),
"brand": p.get("brand", ""),
"audioInputs": p.get("audioInputs", 2),
"audioOutputs": p.get("audioOutputs", 2),
"instanceId": p.get("instanceId"),
})
return {"plugins": plugins}
except Exception as e:
logger.warning("Failed to list available plugins: %s", e)
return {"plugins": [], "note": str(e)}
@router.get("/channel/{channel_index}")
async def get_channel_inserts(channel_index: int):
"""Get all FX inserts for a specific channel, ordered by slot_order."""
inserts = _load_inserts()
channel_inserts = [
i for i in inserts if i.get("channel_index") == channel_index
]
channel_inserts.sort(key=lambda x: x.get("slot_order", 0))
return {"inserts": channel_inserts}
@router.post("/route/{insert_id}")
async def route_insert_audio(insert_id: str):
"""Attempt to create JACK connections for a specific insert.
Returns the intended source→destination port connections.
The actual routing must be performed via PiPedal's JACK API
(POST /api/jack/connect) after this call.
"""
inserts = _load_inserts()
insert = None
for ins in inserts:
if ins["id"] == insert_id:
insert = ins
break
if not insert:
raise HTTPException(status_code=404, detail="Insert not found")
channel_idx = insert["channel_index"]
plugin_instance_id = insert["plugin_instance_id"]
# Declare the intended JACK port connections for this insert
# These are metadata — actual JACK routing depends on the running
# audio graph and is performed separately via the JACK API
connections = [
{
"source": f"mixer-daemon:channel_{channel_idx}_send",
"destination": f"pipedal:{plugin_instance_id}:audio_in_1",
},
{
"source": f"pipedal:{plugin_instance_id}:audio_out_1",
"destination": f"mixer-daemon:channel_{channel_idx}_return",
},
]
logger.info(
"FX insert %s route intended: channel %d ↔ plugin %d",
insert_id, channel_idx, plugin_instance_id,
)
return {
"status": "routed",
"insert_id": insert_id,
"connections": connections,
}
@router.post("/route-all")
async def route_all_inserts():
"""Re-route all active (non-bypassed) FX inserts.
Returns the intended routing state for all active inserts.
"""
inserts = _load_inserts()
active = [i for i in inserts if not i.get("bypassed", False)]
routed = []
for ins in active:
channel_idx = ins["channel_index"]
plugin_id = ins["plugin_instance_id"]
routed.append({
"insert_id": ins["id"],
"channel": channel_idx,
"plugin": plugin_id,
"status": "configured",
})
logger.info("Re-routed %d active FX inserts", len(active))
return {"routed": routed, "total": len(active)}
+71
View File
@@ -0,0 +1,71 @@
{
"scenes": [
{
"id": "577604f4-de57-45e2-bb26-4396dabb5770",
"name": "Production Test",
"createdAt": 1782235528.6320288,
"state": {
"buses": [
{
"id": 1,
"mute": false,
"name": "Master",
"type": "Master",
"volume": 0.0
}
],
"channels": [
{
"auxSendLevels": [],
"channelIndex": 0,
"hpEnabled": false,
"hpFrequency": 80.0,
"label": "Input 1",
"mute": false,
"pan": 0.0,
"solo": false,
"type": "Instrument",
"volume": -96.0
},
{
"auxSendLevels": [],
"channelIndex": 1,
"hpEnabled": false,
"hpFrequency": 80.0,
"label": "Input 2",
"mute": false,
"pan": 0.0,
"solo": false,
"type": "Instrument",
"volume": -96.0
}
],
"outputRoutes": [
{
"channels": 2,
"sourceBusId": 1,
"sourceStartChannel": 0,
"targetStartChannel": 0
}
],
"physicalInputCount": 2,
"physicalOutputCount": 2,
"routes": [
{
"level": 0.0,
"sourceId": 1,
"sourceType": "channel",
"targetBusId": 1
},
{
"level": 0.0,
"sourceId": 2,
"sourceType": "channel",
"targetBusId": 1
}
],
"sampleRate": 48000
}
}
]
}
+139 -136
View File
@@ -1,21 +1,18 @@
"""
OPLabs Mixer — Unix domain socket IPC client.
Connects to the C++ MixerDaemon over a Unix domain socket using a
length-prefixed JSON protocol.
Connects to the C++ MixerDaemon over a Unix domain socket using the
daemon's newline-terminated 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>}}
Send: JSON payload + newline ('\\n')
Receive: JSON payload + newline ('\\n')
Each connection handles one request batch (send → receive → close).
"""
import asyncio
import json
import logging
import struct
from typing import Any, Optional
logger = logging.getLogger("mixer-ipc")
@@ -28,6 +25,11 @@ class MixerIpcError(Exception):
class MixerIpcClient:
"""Async Unix socket IPC client for the OPLabs Mixer Daemon.
Each request opens a fresh connection to the daemon, sends the
command as a newline-terminated JSON line, reads the response,
and closes. This matches the daemon's line-based protocol where
one connection = one request batch.
Usage:
client = MixerIpcClient(socket_path="/tmp/oplabs-mixer.sock")
await client.connect()
@@ -46,41 +48,42 @@ class MixerIpcClient:
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."""
"""Probe the Unix domain socket to verify daemon is reachable.
Opens a test connection to the daemon and immediately closes it.
The daemon's protocol is connection-per-request, so we don't
hold a persistent connection.
"""
try:
loop = asyncio.get_running_loop()
self._reader, self._writer = await asyncio.wait_for(
reader, writer = await asyncio.wait_for(
asyncio.open_unix_connection(self._socket_path),
timeout=self._connect_timeout,
)
# Test succeeded — daemon is listening. Close immediately.
writer.close()
await writer.wait_closed()
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
logger.warning("Daemon not reachable at %s: %s", self._socket_path, exc)
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
await self._disconnect()
async def _disconnect(self):
"""Close the underlying socket."""
if self._writer:
try:
self._writer.close()
@@ -88,108 +91,82 @@ class MixerIpcClient:
except Exception:
pass
self._writer = None
self._reader = None
async def _ensure_connection(self):
"""Open a fresh connection to the daemon."""
await self._disconnect()
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,
)
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
OSError) as exc:
self._writer = None
self._reader = None
raise MixerIpcError(f"Cannot connect to daemon: {exc}")
@property
def is_connected(self) -> bool:
return self._connected
# ------------------------------------------------------------------
# Low-level send / receive
# Send / receive (line-based protocol, one-shot connection)
# ------------------------------------------------------------------
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:
"""Open connection, send request, wait for response, close.
Wire format: ``{"cmd": method, **params}`` (daemon's native format).
The daemon expects ``cmd`` as the command field and all parameters
at the top level (not nested under a ``params`` key).
"""
if not self._connected:
raise MixerIpcError("Not connected to mixer daemon")
req_id = self._next_id
self._next_id += 1
# Build daemon-native wire message: {"cmd": method, ...params}
msg: dict[str, Any] = {"cmd": method}
if params:
msg.update(params)
msg = {
"id": req_id,
"method": method,
"params": params or {},
}
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[req_id] = future
# Open fresh connection
await self._ensure_connection()
try:
payload = json.dumps(msg).encode("utf-8")
await self._send_frame(payload)
# Send: JSON payload + newline
payload = json.dumps(msg) + "\n"
self._writer.write(payload.encode("utf-8"))
await self._writer.drain()
# Read: one response line
response_line = await asyncio.wait_for(
self._reader.readline(),
timeout=self._request_timeout,
)
if not response_line:
raise MixerIpcError("Empty response from daemon")
response = json.loads(response_line.decode("utf-8").strip())
# Check for error status
if isinstance(response, dict):
if response.get("status") == "error":
raise MixerIpcError(response.get("message", "Unknown error"))
return response
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.
except json.JSONDecodeError as e:
raise MixerIpcError(f"Invalid JSON response: {e}")
except MixerIpcError:
raise
except Exception as e:
raise MixerIpcError(f"Request '{method}' failed: {e}")
finally:
await self._disconnect()
# ------------------------------------------------------------------
# High-level API
@@ -206,44 +183,92 @@ class MixerIpcClient:
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,
"channel": channel_index,
"volume": 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,
"channel": 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,
"channel": 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,
"channel": channel_index,
"solo": solo,
})
async def set_channel_label(self, channel_index: int, label: str):
"""Set the label/name for a channel."""
await self._send_request("setChannelLabel", {
"channel": channel_index,
"label": label,
})
# --- 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,
"physicalInput": 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,
"channel": channel_index,
})
# --- Bus Control ---
async def set_bus_volume(self, bus_id: int, volume_db: float):
"""Set bus volume in dB (-inf to +12)."""
await self._send_request("setBusVolume", {
"busId": bus_id,
"volume": volume_db,
})
async def set_bus_mute(self, bus_id: int, mute: bool):
"""Set bus mute state."""
await self._send_request("setBusMute", {
"busId": bus_id,
"mute": mute,
})
# --- Routing ---
async def route_channel_to_bus(self, channel_index: int, bus_id: int, level_db: float = 0.0):
"""Route a channel to a bus with a given level in dB."""
await self._send_request("routeChannelToBus", {
"channel": channel_index,
"busId": bus_id,
"level": level_db,
})
async def route_bus_to_bus(self, source_bus_id: int, target_bus_id: int, level_db: float = 0.0):
"""Route one bus to another bus."""
await self._send_request("routeBusToBus", {
"sourceBusId": source_bus_id,
"targetBusId": target_bus_id,
"level": level_db,
})
async def remove_route(self, source_id: int, target_bus_id: int):
"""Remove a route."""
await self._send_request("removeRoute", {
"sourceId": source_id,
"targetBusId": target_bus_id,
})
# --- Output Routing ---
@@ -264,28 +289,6 @@ class MixerIpcClient:
"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
+37
View File
@@ -0,0 +1,37 @@
[Unit]
Description=OPLabs Mixer Backend (FastAPI)
Documentation=https://gitea.ourpad.casa/oplabs/oplabs-mixer-app
After=network.target oplabs-mixer-daemon.service
Wants=oplabs-mixer-daemon.service
StartLimitBurst=5
StartLimitIntervalSec=30
[Service]
Type=simple
User=oplabs
Group=oplabs
WorkingDirectory=/home/oplabs/projects/oplabs-mixer-app/backend
Environment=PATH=/home/oplabs/projects/oplabs-mixer-app/backend/venv/bin:/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1
SyslogIdentifier=oplabs-mixer-backend
# Auth credentials from environment file (optional — create for HTTP Basic Auth)
EnvironmentFile=-/etc/oplabs/mixer-backend.env
# Uncomment these to enable HTTP Basic Auth (for NPMplus reverse proxy):
# Environment=MIXER_AUTH_ENABLED=true
# Environment=MIXER_AUTH_USERNAME=oplabs
# Environment=MIXER_AUTH_PASSWORD=your-password-here
ExecStart=/home/oplabs/projects/oplabs-mixer-app/backend/venv/bin/uvicorn app:app --host 0.0.0.0 --port 8081 --workers 2
# Exit code 143 (SIGTERM) is expected on clean shutdown — don't restart for that
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OPLabs Mixer</title>
<script type="module" crossorigin src="/assets/index-BixTWz89.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwT15WG7.css">
<script type="module" crossorigin src="/assets/index-CpkXACSZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B9JhTSIS.css">
</head>
<body>
<div id="root"></div>
@@ -3,13 +3,13 @@
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 8px;
padding: 8px 6px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-top: 3px solid #666;
border-top: 3px solid var(--channel-accent, #666);
border-radius: var(--radius, 8px);
min-width: 80px;
max-width: 100px;
min-width: 90px;
max-width: 110px;
transition: border-top-color 0.3s, box-shadow 0.2s;
}
@@ -17,7 +17,7 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
}
/* ---- Header ---- */
/* ── Header: label + type badge ── */
.channel-header {
display: flex;
@@ -36,9 +36,152 @@
text-overflow: ellipsis;
max-width: 100%;
color: var(--text, #eaeaea);
cursor: pointer;
padding: 1px 4px;
border-radius: 3px;
transition: background 0.15s;
}
/* ---- Mute / Solo ---- */
.channel-label:hover {
background: rgba(255, 255, 255, 0.08);
}
.channel-label-input {
font-size: 0.7rem;
font-weight: 600;
text-align: center;
width: 100%;
padding: 1px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--channel-accent, #666);
border-radius: 3px;
outline: none;
}
/* ── Channel Type Badge ── */
.channel-type-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
user-select: none;
line-height: 1.3;
}
.channel-type-icon {
font-size: 0.75em;
line-height: 1;
}
.channel-type-name {
line-height: 1;
}
/* ── HPF Section ── */
.channel-hpf-section {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
min-height: 22px;
}
.channel-hpf-toggle {
flex-shrink: 0;
width: 32px;
height: 18px;
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
padding: 0;
}
.channel-hpf-toggle.active {
background: var(--channel-accent, #666);
color: #fff;
border-color: var(--channel-accent, #666);
}
.channel-hpf-toggle:hover {
opacity: 0.85;
}
.channel-hpf-slider-group {
display: flex;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.channel-hpf-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--border, #2a2a4a);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 0;
}
.channel-hpf-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 8px;
height: 10px;
border-radius: 1px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-hpf-slider::-moz-range-thumb {
width: 8px;
height: 10px;
border-radius: 1px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.channel-hpf-readout {
font-size: 0.5rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 24px;
text-align: right;
flex-shrink: 0;
}
/* ── Level Meter Section ── */
.channel-meter-section {
width: 100%;
display: flex;
justify-content: center;
flex: 1;
min-height: 50px;
}
/* ── Mute / Solo ── */
.channel-mute-solo {
display: flex;
@@ -60,6 +203,7 @@
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
padding: 0;
}
.channel-btn.mute.active {
@@ -78,17 +222,7 @@
opacity: 0.85;
}
/* ---- Level Meter Section ---- */
.channel-meter-section {
width: 100%;
display: flex;
justify-content: center;
flex: 1;
min-height: 60px;
}
/* ---- Pan ---- */
/* ── Pan ── */
.channel-pan {
width: 100%;
@@ -147,98 +281,44 @@
font-size: 0.55rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 20px;
min-width: 22px;
text-align: center;
}
/* ---- Fader ---- */
/* ── Volume Knob Section ── */
.channel-fader-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
gap: 4px;
width: 100%;
padding: 4px 0;
}
/* ── Channel Type Selector ── */
.channel-type-selector {
width: 100%;
}
.channel-fader {
-webkit-appearance: none;
appearance: none;
.channel-type-select {
width: 100%;
height: 100px;
background: linear-gradient(to top, var(--bg, #1a1a2e) 0%, var(--surface-alt, #0f3460) 75%, #4caf50 85%, #ff9800 95%, #f44336 100%);
border-radius: 4px;
outline: none;
cursor: pointer;
border: 1px solid var(--border, #2a2a4a);
writing-mode: vertical-lr;
direction: rtl;
}
/* WebKit vertical slider */
.channel-fader::-webkit-slider-runnable-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
margin-left: -3px;
}
.channel-fader::-moz-range-track {
height: 100px;
border-radius: 3px;
}
.channel-fader::-moz-range-thumb {
width: 18px;
height: 12px;
background: var(--channel-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}
.channel-fader-readout {
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
}
/* ---- Instrument Selector ---- */
.channel-instrument-selector {
width: 100%;
}
.channel-instrument-select {
width: 100%;
padding: 3px 4px;
padding: 2px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.6rem;
font-size: 0.55rem;
cursor: pointer;
outline: none;
}
.channel-instrument-select:focus {
.channel-type-select:focus {
border-color: var(--channel-accent, #666);
}
.channel-instrument-select option {
.channel-type-select option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
}
@@ -2,8 +2,8 @@
* ChannelStrip per-input channel control for the OPLabs Mixer Console.
*
* Mirrors the daemon's MixerChannelStrip parameter set:
* - Volume fader (-96..+12 dB, logarithmic taper)
* - Pan control (-1..1)
* - Volume control (rotary knob, -60..+6 dB, ZoomedDial-style SVG dial)
* - Pan control (-1..1 horizontal slider)
* - Mute / Solo buttons
* - Channel type selector (Instrument / Mic / Line / AuxReturn)
* - High-pass filter toggle + frequency slider
@@ -12,11 +12,16 @@
*
* Designed as a controlled component: all state is passed in via props;
* change callbacks flow upward. No built-in WebSocket or IPC coupling.
*
* The volume RotaryKnob reuses the PiPedal ZoomedDial interaction pattern:
* pointer-based rotation with drag, Shift for fine-tune, double-click reset.
*/
import React, { useCallback, useRef, useState } from 'react';
import { LevelMeter } from './LevelMeter';
import RotaryKnob from '../RotaryKnob/RotaryKnob';
import './ChannelStrip.css';
import '../RotaryKnob/RotaryKnob.css';
// ── Types ──────────────────────────────────────────────────────────
@@ -82,36 +87,12 @@ export interface ChannelStripProps {
onToggleHp: (instanceId: number, enabled: boolean) => void;
onChangeHpFrequency: (instanceId: number, freq: number) => void;
onChangeLabel: (instanceId: number, label: string) => void;
// ── FX Inserts (optional, rendered at bottom of strip) ──
fxInserts?: React.ReactNode;
}
// ── dB ↔ Slider position (logarithmic) ────────────────────────────
/**
* Map slider position (0100) to dB value (-60..+6).
* 75% position = 0 dB (unity), logarithmic taper.
*/
function posToDb(pos: number): number {
if (pos <= 0) return -60;
if (pos >= 100) return 6;
const scaled = (pos / 100) / 0.75;
if (scaled <= 0.001) return -60;
const db = 20 * Math.log10(scaled);
return Math.max(-60, Math.min(6, db));
}
/**
* Map dB value (-60..+6) to slider position (0100).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 100;
const linear = Math.pow(10, db / 20);
return Math.min(100, linear * 0.75 * 100);
}
/**
* Format dB for display.
*/
// ── Format helpers ─────────────────────────────────────────────────
function formatDb(db: number): string {
if (db <= -60) return '-∞';
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
@@ -149,24 +130,15 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
onToggleHp,
onChangeHpFrequency,
onChangeLabel,
fxInserts,
}) => {
const accentColor = CHANNEL_TYPE_COLORS[channelType] ?? '#666';
const [editingLabel, setEditingLabel] = useState(false);
const [labelDraft, setLabelDraft] = useState(label);
const labelInputRef = useRef<HTMLInputElement>(null);
const volumeSliderPos = Math.round(dbToPos(volume));
// ── Handlers ─────────────────────────────────────────────────
const handleVolChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const db = posToDb(parseFloat(e.target.value));
onChangeVolume(instanceId, parseFloat(db.toFixed(1)));
},
[instanceId, onChangeVolume],
);
const handlePanChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChangePan(instanceId, parseFloat(e.target.value));
@@ -339,20 +311,19 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
</div>
</div>
{/* ── Volume Fader ── */}
{/* ── Volume Knob ── */}
<div className="channel-fader-section">
<input
type="range"
min="0"
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolChange}
className="channel-fader"
title={`Volume: ${formatDb(volume)}`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
<RotaryKnob
value={volume}
min={-60}
max={6}
step={0.5}
size={48}
label="Volume"
readout={formatDb(volume)}
accentColor={accentColor}
onChange={(v) => onChangeVolume(instanceId, parseFloat(v.toFixed(1)))}
/>
<span className="channel-fader-readout">{formatDb(volume)}</span>
</div>
{/* ── Channel Type Selector ── */}
@@ -370,6 +341,9 @@ const ChannelStrip: React.FC<ChannelStripProps> = ({
))}
</select>
</div>
{/* ── FX Inserts (injected by parent) ── */}
{fxInserts}
</div>
);
};
@@ -0,0 +1,287 @@
/* ── FX Insert Panel (per-channel) ── */
.fx-insert-panel {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
border-top: 1px solid var(--border, #2a2a4a);
padding-top: 4px;
margin-top: 2px;
}
.fx-insert-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.fx-insert-panel-title {
font-size: 0.5rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim, #9e9e9e);
}
/* ── Add button ── */
.fx-insert-add-btn {
background: transparent;
border: 1px solid var(--border, #2a2a4a);
color: var(--text-dim, #9e9e9e);
font-size: 0.5rem;
padding: 1px 6px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
line-height: 1.4;
}
.fx-insert-add-btn:hover {
border-color: var(--accent, #e94560);
color: var(--accent, #e94560);
}
.fx-insert-add-btn.ghost {
width: 100%;
border-style: dashed;
border-color: var(--border, #2a2a4a);
opacity: 0.6;
padding: 4px 8px;
font-size: 0.55rem;
}
.fx-insert-add-btn.ghost:hover {
opacity: 1;
border-color: var(--accent, #e94560);
color: var(--accent, #e94560);
}
/* ── Insert Slots ── */
.fx-insert-slots {
display: flex;
flex-direction: column;
gap: 3px;
}
/* ── Individual Insert Slot ── */
.fx-insert-slot {
display: flex;
flex-direction: column;
gap: 2px;
padding: 3px 4px;
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
border-left: 2px solid var(--fx-accent, #666);
transition: opacity 0.2s;
}
.fx-insert-slot.bypassed {
opacity: 0.45;
border-left-color: var(--border, #2a2a4a);
}
/* ── Insert header row ── */
.fx-insert-header {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
/* Bypass toggle button */
.fx-insert-bypass-btn {
flex-shrink: 0;
width: 24px;
height: 14px;
border-radius: 2px;
border: 1px solid var(--border, #2a2a4a);
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
font-size: 0.45rem;
font-weight: 700;
text-align: center;
line-height: 14px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.fx-insert-bypass-btn.active {
background: var(--success, #4caf50);
color: #fff;
border-color: var(--success, #4caf50);
}
.fx-insert-bypass-btn:hover {
opacity: 0.85;
}
/* Plugin name */
.fx-insert-name {
flex: 1;
font-size: 0.5rem;
color: var(--text, #eaeaea);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Remove button */
.fx-insert-remove {
flex-shrink: 0;
width: 14px;
height: 14px;
border: none;
background: transparent;
color: var(--text-dim, #9e9e9e);
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
padding: 0;
border-radius: 2px;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.fx-insert-remove:hover {
background: var(--error, #f44336);
color: #fff;
}
/* ── Wet/Dry Mix ── */
.fx-insert-wetdry {
display: flex;
align-items: center;
gap: 3px;
width: 100%;
}
.fx-insert-wetdry-label {
font-size: 0.45rem;
color: var(--text-dim, #9e9e9e);
flex-shrink: 0;
min-width: 16px;
}
.fx-insert-wetdry-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--border, #2a2a4a);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 0;
}
.fx-insert-wetdry-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fx-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.fx-insert-wetdry-slider::-moz-range-thumb {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--fx-accent, #666);
border: 1px solid rgba(255, 255, 255, 0.3);
cursor: pointer;
}
.fx-insert-wetdry-readout {
font-size: 0.45rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
/* ── Empty state ── */
.fx-insert-empty {
display: flex;
width: 100%;
}
/* ── Add form ── */
.fx-insert-add-form {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.fx-insert-plugin-select {
width: 100%;
padding: 2px 4px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
outline: none;
}
.fx-insert-plugin-select option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
}
.fx-insert-plugin-info {
font-size: 0.45rem;
color: var(--text-dim, #9e9e9e);
}
.fx-insert-add-actions {
display: flex;
gap: 4px;
}
.fx-insert-confirm-btn {
flex: 1;
padding: 2px 6px;
background: var(--accent, #e94560);
color: #fff;
border: none;
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
font-weight: 600;
}
.fx-insert-confirm-btn:disabled {
opacity: 0.4;
cursor: default;
}
.fx-insert-cancel-btn {
flex: 1;
padding: 2px 6px;
background: transparent;
color: var(--text-dim, #9e9e9e);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
cursor: pointer;
}
@@ -0,0 +1,201 @@
/**
* FxInsertPanel manages all FX inserts for a single mixer channel.
*
* Provides:
* - List of active insert slots (drawn by FxInsertSlot)
* - "Add Insert" button that opens a plugin selector
* - Add/remove/reorder of PiPedal plugins as channel inserts
*/
import React, { useState, useCallback, useMemo } from 'react';
import FxInsertSlot from './FxInsertSlot';
import type { FxInsert, AvailablePlugin } from '../../hooks/useFxInserts';
import './FxInsertPanel.css';
interface FxInsertPanelProps {
channelIndex: number;
inserts: FxInsert[];
availablePlugins: AvailablePlugin[];
accentColor: string;
onCreateInsert: (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}) => Promise<FxInsert | null>;
onUpdateInsert: (
id: string,
update: Partial<FxInsert>,
) => Promise<FxInsert | null>;
onDeleteInsert: (id: string) => Promise<boolean>;
onToggleBypass: (id: string, currentBypassed: boolean) => void;
}
const FxInsertPanel: React.FC<FxInsertPanelProps> = ({
channelIndex,
inserts,
availablePlugins,
accentColor,
onCreateInsert,
onUpdateInsert,
onDeleteInsert,
onToggleBypass,
}) => {
const [adding, setAdding] = useState(false);
const [selectedPlugin, setSelectedPlugin] = useState<string>('');
// Sorted inserts by slot_order
const sortedInserts = useMemo(
() => [...inserts].sort((a, b) => a.slot_order - b.slot_order),
[inserts],
);
const handleWetDryChange = useCallback(
(id: string, wetDry: number) => {
onUpdateInsert(id, { wet_dry_mix: wetDry } as Partial<FxInsert>);
},
[onUpdateInsert],
);
const handleRemove = useCallback(
(id: string) => {
onDeleteInsert(id);
},
[onDeleteInsert],
);
const handleAddClick = useCallback(() => {
setAdding(true);
setSelectedPlugin('');
}, []);
const handleCancelAdd = useCallback(() => {
setAdding(false);
setSelectedPlugin('');
}, []);
const handlePluginSelect = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedPlugin(e.target.value);
},
[],
);
const handleConfirmAdd = useCallback(async () => {
if (!selectedPlugin) return;
// Find the selected plugin to get its details
const plugin = availablePlugins.find((p) => p.uri === selectedPlugin);
if (!plugin) return;
const nextSlot = sortedInserts.length;
await onCreateInsert({
channel_index: channelIndex,
plugin_instance_id: plugin.instanceId ?? -(Date.now() % 10000),
plugin_name: plugin.name || plugin.uri.split('/').pop() || 'FX',
slot_order: nextSlot,
bypassed: false,
wet_dry_mix: 1.0,
});
setAdding(false);
setSelectedPlugin('');
}, [
selectedPlugin,
availablePlugins,
sortedInserts.length,
channelIndex,
onCreateInsert,
]);
const selectedPluginInfo = useMemo(
() => availablePlugins.find((p) => p.uri === selectedPlugin),
[availablePlugins, selectedPlugin],
);
return (
<div className="fx-insert-panel">
<div className="fx-insert-panel-header">
<span className="fx-insert-panel-title">FX Inserts</span>
{!adding && sortedInserts.length > 0 && (
<button
className="fx-insert-add-btn"
onClick={handleAddClick}
title="Add FX insert"
>
+ Add
</button>
)}
</div>
{/* Insert slots */}
{sortedInserts.length > 0 && (
<div className="fx-insert-slots">
{sortedInserts.map((ins) => (
<FxInsertSlot
key={ins.id}
insert={ins}
accentColor={accentColor}
onToggleBypass={onToggleBypass}
onWetDryChange={handleWetDryChange}
onRemove={handleRemove}
/>
))}
</div>
)}
{/* Empty state */}
{sortedInserts.length === 0 && !adding && (
<div className="fx-insert-empty">
<button className="fx-insert-add-btn ghost" onClick={handleAddClick}>
+ Add FX Insert
</button>
</div>
)}
{/* Add insert form */}
{adding && (
<div className="fx-insert-add-form">
<select
className="fx-insert-plugin-select"
value={selectedPlugin}
onChange={handlePluginSelect}
>
<option value="">-- Select Plugin --</option>
{availablePlugins.map((p) => (
<option key={p.uri} value={p.uri}>
{p.name || p.uri.split('/').pop() || p.uri}
{p.brand ? ` (${p.brand})` : ''}
</option>
))}
</select>
{selectedPluginInfo && (
<div className="fx-insert-plugin-info">
I/O: {selectedPluginInfo.audioInputs}in /{' '}
{selectedPluginInfo.audioOutputs}out
</div>
)}
<div className="fx-insert-add-actions">
<button
className="fx-insert-confirm-btn"
disabled={!selectedPlugin}
onClick={handleConfirmAdd}
>
Add
</button>
<button className="fx-insert-cancel-btn" onClick={handleCancelAdd}>
Cancel
</button>
</div>
</div>
)}
</div>
);
};
export { FxInsertPanel };
export default FxInsertPanel;
@@ -0,0 +1,98 @@
/**
* FxInsertSlot a single FX insert on a channel strip.
*
* Shows:
* - Plugin name
* - Bypass toggle button
* - Wet/dry mix slider
* - Remove button
*/
import React, { useCallback } from 'react';
import type { FxInsert } from '../../hooks/useFxInserts';
import './FxInsertPanel.css';
interface FxInsertSlotProps {
insert: FxInsert;
accentColor: string;
onToggleBypass: (id: string, currentBypassed: boolean) => void;
onWetDryChange: (id: string, wetDry: number) => void;
onRemove: (id: string) => void;
}
const FxInsertSlot: React.FC<FxInsertSlotProps> = ({
insert,
accentColor,
onToggleBypass,
onWetDryChange,
onRemove,
}) => {
const handleBypass = useCallback(() => {
onToggleBypass(insert.id, insert.bypassed);
}, [insert.id, insert.bypassed, onToggleBypass]);
const handleWetDry = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onWetDryChange(insert.id, parseFloat(e.target.value));
},
[insert.id, onWetDryChange],
);
const handleRemove = useCallback(() => {
onRemove(insert.id);
}, [insert.id, onRemove]);
const wetDryPercent = Math.round(insert.wet_dry_mix * 100);
return (
<div
className={`fx-insert-slot ${insert.bypassed ? 'bypassed' : ''}`}
style={
{
'--fx-accent': accentColor,
} as React.CSSProperties
}
>
{/* Header: plugin name + bypass + remove */}
<div className="fx-insert-header">
<span
className={`fx-insert-bypass-btn ${insert.bypassed ? 'active' : ''}`}
onClick={handleBypass}
title={insert.bypassed ? 'Enable insert' : 'Bypass insert'}
>
{insert.bypassed ? 'OFF' : 'ON'}
</span>
<span className="fx-insert-name" title={insert.plugin_name}>
{insert.plugin_name}
</span>
<button
className="fx-insert-remove"
onClick={handleRemove}
title="Remove insert"
>
×
</button>
</div>
{/* Wet/dry mix slider */}
{!insert.bypassed && (
<div className="fx-insert-wetdry">
<label className="fx-insert-wetdry-label">Mix</label>
<input
type="range"
min="0"
max="1"
step="0.01"
value={insert.wet_dry_mix}
onChange={handleWetDry}
className="fx-insert-wetdry-slider"
title={`Wet/Dry: ${wetDryPercent}%`}
/>
<span className="fx-insert-wetdry-readout">{wetDryPercent}%</span>
</div>
)}
</div>
);
};
export { FxInsertSlot };
export default FxInsertSlot;
+86 -12
View File
@@ -4,22 +4,23 @@ import type { SceneState } from '../../hooks/useScenes';
import { useMixerControls } from '../../hooks/useMixerControls';
import { useScenes } from '../../hooks/useScenes';
import { useJackPorts } from '../../hooks/useJackPorts';
import { useFxInserts } from '../../hooks/useFxInserts';
import { PatchBay } from '../PatchBay/PatchBay';
import { ChannelStrip } from './ChannelStrip';
import ChannelStrip, { CHANNEL_TYPE_COLORS } from './ChannelStrip';
import type { ChannelType } from './ChannelStrip';
import { BusStrip } from './BusStrip';
import { MasterBus } from './MasterBus';
import { SceneManager } from '../Scenes/SceneManager';
import { ConnectionStatus } from '../ConnectionStatus';
import { FxInsertPanel } from './FxInsertPanel';
import type { ConnectionState } from '../../hooks/usePiPedalWS';
import './MixerPage.css';
import './ChannelStrip.css';
import './BusStrip.css';
import './LevelMeter.css';
import './InstrumentBadge.css';
import '../PatchBay/PatchBay.css';
import './LevelMeter.css';
import './InstrumentBadge.css';
import './MasterBus.css';
import './FxInsertPanel.css';
import '../PatchBay/PatchBay.css';
interface Props {
plugins: PluginInstance[];
@@ -67,6 +68,16 @@ export const MixerPage: React.FC<Props> = ({
const [showPiPedal, setShowPiPedal] = useState(false);
const [showPatchBay, setShowPatchBay] = useState(false);
// ── FX Inserts ──────────────────────────────────────────────
const {
insertsByChannel,
availablePlugins,
createInsert,
updateInsert,
deleteInsert,
toggleBypass,
} = useFxInserts();
// PiPedal iframe URL — use the Pi's IP since the browser accesses this from another machine
const pipedalUrl = `http://192.168.0.245:8080/`;
@@ -124,6 +135,29 @@ export const MixerPage: React.FC<Props> = ({
};
}, [plugins, channels, buses]);
// ── Channel Strip data mapping (PiPedal → daemon types) ─────
/**
* Map PiPedal instrument value (0-4) to daemon ChannelType.
* Temporary bridge until the daemon protocol replaces PiPedal.
*/
const instrumentToType: Record<number, ChannelType> = {
0: 'instrument', // Guitar
1: 'instrument', // Bass
2: 'line', // Keys
3: 'mic', // Vocals
4: 'line', // Backing
};
const typeToInstrument: Record<ChannelType, number> = {
instrument: 0,
mic: 3,
line: 2,
auxReturn: 4,
};
// ── User interactions ───────────────────────────────────────
const toggleScenePanel = useCallback(() => {
setScenePanelOpen((prev) => !prev);
}, []);
@@ -217,13 +251,53 @@ export const MixerPage: React.FC<Props> = ({
<span className="mixer-count">{channels.length}</span>
</div>
<div className="mixer-channels-grid">
{channels.map((plugin) => (
<ChannelStrip
key={plugin.instanceId}
plugin={plugin}
onSetControl={setControlValue}
/>
))}
{channels.map((plugin, index) => {
const cv = plugin.controlValues;
const instrumentVal = Math.round(cv.instrument ?? 0) || 0;
return (
<ChannelStrip
key={plugin.instanceId}
instanceId={plugin.instanceId}
channelIndex={index}
label={plugin.title || plugin.pluginName || `Ch ${index + 1}`}
volume={cv.volume ?? 0}
pan={cv.pan ?? 0}
mute={(cv.mute ?? 0) >= 0.5}
solo={(cv.solo ?? 0) >= 0.5}
channelType={instrumentToType[instrumentVal] ?? 'instrument'}
hpEnabled={false}
hpFrequency={80}
levelL={cv.levelL ?? -60}
levelR={cv.levelR ?? -60}
onChangeVolume={(id, db) => setControlValue(id, 'volume', db)}
onChangePan={(id, p) => setControlValue(id, 'pan', p)}
onToggleMute={(id, m) => setControlValue(id, 'mute', m ? 1 : 0)}
onToggleSolo={(id, s) => setControlValue(id, 'solo', s ? 1 : 0)}
onChangeChannelType={(id, type) => {
setControlValue(id, 'instrument', typeToInstrument[type] ?? 0);
}}
onToggleHp={() => {}}
onChangeHpFrequency={() => {}}
onChangeLabel={() => {}}
fxInserts={
<FxInsertPanel
channelIndex={index}
inserts={insertsByChannel[index] || []}
availablePlugins={availablePlugins}
accentColor={
CHANNEL_TYPE_COLORS[
instrumentToType[instrumentVal] ?? 'instrument'
] ?? '#666'
}
onCreateInsert={createInsert}
onUpdateInsert={updateInsert}
onDeleteInsert={deleteInsert}
onToggleBypass={toggleBypass}
/>
}
/>
);
})}
{/* Add channel button */}
<div className="mixer-add-channel">
<button className="mixer-add-btn" title="Add channel (future)">
@@ -0,0 +1,99 @@
/* RotaryKnob
*
* SVG rotary control inspired by PiPedal's ZoomedDial.
* All sizing is driven by the component's `size` prop (CSS px on the
* root); the SVG viewBox is a fixed -24..24 coordinate space.
*
* Theming via CSS custom properties:
* --knob-accent: control accent colour (defaults to var(--accent))
* --knob-bg: knob body fill (defaults to surface tone)
* --knob-track: track arc colour (defaults to border tone)
*/
.rotary-knob {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.rotary-knob-svg {
display: block;
cursor: grab;
border-radius: 50%;
transition: filter 0.15s;
outline: none;
}
.rotary-knob-svg:hover {
filter: brightness(1.15);
}
.rotary-knob-svg:focus-visible {
outline: 2px solid var(--knob-accent, var(--accent, #e94560));
outline-offset: 2px;
}
.rotary-knob-svg.dragging {
cursor: grabbing;
filter: brightness(1.25);
}
/* ── SVG elements ─────────────────────────────────────────────── */
.rotary-knob-track {
stroke: var(--knob-track, var(--border, #2a2a4a));
opacity: 0.5;
}
.rotary-knob-fill {
stroke: var(--knob-accent, var(--accent, #e94560));
filter: drop-shadow(0 0 2px var(--knob-accent, var(--accent, #e94560)));
transition: d 0.08s ease-out;
}
.rotary-knob-body {
stroke: var(--knob-track, var(--border, #2a2a4a));
stroke-width: 0.5;
fill: var(--knob-bg, #1e2a4a);
}
.rotary-knob-needle {
stroke: var(--knob-accent, var(--accent, #e94560));
stroke-width: 2;
stroke-linecap: round;
transition: transform 0.08s ease-out;
}
.rotary-knob-centre {
fill: var(--knob-accent, var(--accent, #e94560));
opacity: 0.8;
}
/* ── Label & readout ──────────────────────────────────────────── */
.rotary-knob-label {
font-size: 0.5rem;
font-weight: 600;
color: var(--text-dim, #9e9e9e);
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.rotary-knob-readout {
font-size: 0.55rem;
font-family: 'Cooper Hewitt', 'SF Mono', 'Fira Code', monospace;
color: var(--text, #eaeaea);
text-align: center;
line-height: 1;
white-space: nowrap;
}
@@ -0,0 +1,320 @@
/**
* RotaryKnob SVG rotary control following the PiPedal ZoomedDial pattern.
*
* Interaction model:
* - Pointer-drag: vertical motion maps to value (up = increase)
* - Hold Shift for fine adjustment (10% sensitivity)
* - Double-click resets to default value
* - The rotating needle is the primary value indicator (PiPedal-style)
*
* Angle range: -135° (min) to +135° (max) 270° sweep from 7:30 to 4:30
* via 12:00, matching the ZoomedDial convention.
*/
import React, { useCallback, useRef, useState } from 'react';
import './RotaryKnob.css';
// ── Constants ─────────────────────────────────────────────────────
const MIN_ANGLE = -135;
const MAX_ANGLE = 135;
const ANGLE_RANGE = MAX_ANGLE - MIN_ANGLE;
const FINE_SCALE = 0.1;
const R = 24; // viewport radius (viewBox = -24..24)
const SIZE = R * 2;
const TRACK_R = 20;
const NEEDLE_LEN = 16;
const CENTRE_R = 2.5;
// ── Types ─────────────────────────────────────────────────────────
export interface RotaryKnobProps {
/** Current value. */
value: number;
/** Value at -135° position. */
min: number;
/** Value at +135° position. */
max: number;
/** Snap increment. 0 = continuous. */
step?: number;
/** SVG size in CSS px (default: 48). */
size?: number;
/** Label shown below the knob. */
label?: string;
/** Readout string (e.g. "+2.3 dB"). */
readout?: string;
/** Reset value on double-click (default: min). */
defaultValue?: number;
/** Called during drag with intermediate value. */
onChanging?: (value: number) => void;
/** Committed on drag-end or step. */
onChange: (value: number) => void;
/** Double-click handler. */
onDoubleClick?: () => void;
/** CSS accent colour. */
accentColor?: string;
}
// ── Value / Angle helpers ────────────────────────────────────────
function snap(v: number, mn: number, mx: number, step: number): number {
if (step <= 0) return v;
const clamped = Math.max(mn, Math.min(mx, v));
return Math.max(mn, Math.min(mx, mn + Math.round((clamped - mn) / step) * step));
}
function valToAngle(v: number, mn: number, mx: number): number {
const t = (Math.max(mn, Math.min(mx, v)) - mn) / (mx - mn);
return MIN_ANGLE + t * ANGLE_RANGE;
}
function angleToVal(a: number, mn: number, mx: number): number {
const t = (Math.max(MIN_ANGLE, Math.min(MAX_ANGLE, a)) - MIN_ANGLE) / ANGLE_RANGE;
return mn + t * (mx - mn);
}
/**
* Compute a "rotation from 12-o'clock" angle from pointer coordinates.
* Returns NaN when pointer is inside the centre dead zone.
*/
function pointerAngle(clientX: number, clientY: number, svg: SVGSVGElement): number {
const rc = svg.getBoundingClientRect();
const dx = clientX - (rc.left + rc.width / 2);
const dy = clientY - (rc.top + rc.height / 2);
if (dx * dx + dy * dy < 25) return NaN;
// atan2 gives angle from +x axis; add 90° so 0° = 12 o'clock, positive = CW
const deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
return ((deg % 360) + 360) % 360;
}
// ── Component ────────────────────────────────────────────────────
const RotaryKnob: React.FC<RotaryKnobProps> = ({
value,
min,
max,
step = 0,
size = 48,
label,
readout,
defaultValue,
onChanging,
onChange,
onDoubleClick,
accentColor,
}) => {
const svgRef = useRef<SVGSVGElement>(null);
// Mutable drag state (avoids re-render on every move frame)
const drag = useRef({
active: false,
pointerId: -1,
lastAngle: 0,
dragValue: value,
pointerType: '',
});
const [dragging, setDragging] = useState(false);
const currentAngle = valToAngle(value, min, max);
// ── Pointer handlers ─────────────────────────────────────────
const onDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (e.button !== 0) return;
const svg = svgRef.current;
if (!svg) return;
e.preventDefault();
svg.setPointerCapture(e.pointerId);
const a = pointerAngle(e.clientX, e.clientY, svg);
drag.current = {
active: true,
pointerId: e.pointerId,
lastAngle: isNaN(a) ? 0 : a,
dragValue: value,
pointerType: e.pointerType,
};
setDragging(true);
},
[value]
);
const onMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!drag.current.active || drag.current.pointerId !== e.pointerId) return;
const svg = svgRef.current;
if (!svg) return;
e.preventDefault();
const a = pointerAngle(e.clientX, e.clientY, svg);
if (isNaN(a)) return;
let dAngle = a - drag.current.lastAngle;
while (dAngle > 180) dAngle -= 360;
while (dAngle < -180) dAngle += 360;
const scale = e.shiftKey ? FINE_SCALE : 1.0;
const nAngle = Math.max(MIN_ANGLE, Math.min(MAX_ANGLE, valToAngle(drag.current.dragValue, min, max) + dAngle * scale));
let nVal = angleToVal(nAngle, min, max);
if (step > 0) nVal = snap(nVal, min, max, step);
drag.current.dragValue = nVal;
drag.current.lastAngle = a;
if (onChanging) onChanging(nVal);
onChange(nVal);
},
[min, max, step, onChanging, onChange]
);
const onUp = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!drag.current.active || drag.current.pointerId !== e.pointerId) return;
const svg = svgRef.current;
if (!svg) return;
svg.releasePointerCapture(e.pointerId);
drag.current.active = false;
setDragging(false);
},
[]
);
const onLostCapture = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (drag.current.pointerId === e.pointerId) {
drag.current.active = false;
setDragging(false);
}
},
[]
);
const onDblClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const dflt = defaultValue ?? min;
onChange(dflt);
if (onDoubleClick) onDoubleClick();
},
[defaultValue, min, onChange, onDoubleClick]
);
// ── SVG arc helper ───────────────────────────────────────────
/**
* Compute SVG arc path from our angle convention (0°=12-o'clock, clockwise+).
* Sweep-flag = 1 means increasing angle in SVG-y-down space, which visually
* sweeps from start-angle CW to end-angle.
*/
function arc(cx: number, cy: number, r: number, d0: number, d1: number): string {
const rad = (d: number) => (d * Math.PI) / 180;
const x1 = cx + r * Math.sin(rad(d0));
const y1 = cy - r * Math.cos(rad(d0));
const x2 = cx + r * Math.sin(rad(d1));
const y2 = cy - r * Math.cos(rad(d1));
const large = ((d1 - d0 + 720) % 360) > 180 ? 1 : 0;
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`;
}
// Track arc: from MIN_ANGLE to MAX_ANGLE going clockwise (through top)
const trackStart = MIN_ANGLE;
const trackEnd = MAX_ANGLE;
// Fill arc: from MIN_ANGLE to current angle
const fillStart = MIN_ANGLE;
const fillEnd = currentAngle;
return (
<div
className="rotary-knob"
style={{
'--knob-accent': accentColor || 'var(--accent, #e94560)',
width: size,
height: size,
} as React.CSSProperties}
>
<svg
ref={svgRef}
className={`rotary-knob-svg ${dragging ? 'dragging' : ''}`}
viewBox={`${-R} ${-R} ${SIZE} ${SIZE}`}
width={size}
height={size}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onLostPointerCapture={onLostCapture}
onDoubleClick={onDblClick}
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-label={label || 'Knob'}
tabIndex={0}
>
<defs>
<radialGradient id="knob-body" cx="40%" cy="35%" r="60%">
<stop offset="0%" stopColor="rgba(255,255,255,0.15)" />
<stop offset="60%" stopColor="rgba(255,255,255,0.04)" />
<stop offset="100%" stopColor="rgba(0,0,0,0.35)" />
</radialGradient>
</defs>
{/* Track arc (full 270° range) */}
<path
className="rotary-knob-track"
d={arc(0, 0, TRACK_R, trackStart, trackEnd)}
fill="none"
strokeWidth={3}
/>
{/* Fill arc (current value) */}
<path
className="rotary-knob-fill"
d={arc(0, 0, TRACK_R, fillStart, fillEnd)}
fill="none"
strokeWidth={3}
strokeLinecap="round"
/>
{/* Knob body */}
<circle
className="rotary-knob-body"
cx={0}
cy={0}
r={TRACK_R - 5}
fill="url(#knob-body)"
/>
{/* Indicator needle */}
<line
className="rotary-knob-needle"
x1={0}
y1={0}
x2={0}
y2={-NEEDLE_LEN}
transform={`rotate(${currentAngle})`}
/>
{/* Centre dot */}
<circle
className="rotary-knob-centre"
cx={0}
cy={0}
r={CENTRE_R}
/>
</svg>
{label && <span className="rotary-knob-label">{label}</span>}
{readout !== undefined && (
<span className="rotary-knob-readout">{readout}</span>
)}
</div>
);
};
export default RotaryKnob;
+224
View File
@@ -0,0 +1,224 @@
import { useState, useCallback, useEffect, useRef } from 'react';
// ── Types ──────────────────────────────────────────────────────────
export interface FxInsert {
id: string;
channel_index: number;
plugin_instance_id: number;
plugin_name: string;
slot_order: number;
bypassed: boolean;
wet_dry_mix: number;
can_route?: boolean;
}
export interface AvailablePlugin {
uri: string;
name: string;
brand: string;
audioInputs: number;
audioOutputs: number;
instanceId: number | null;
}
export interface UseFxInsertsReturn {
insertsByChannel: Record<number, FxInsert[]>;
availablePlugins: AvailablePlugin[];
loadingAvailable: boolean;
loadingInserts: boolean;
error: string | null;
fetchInserts: (channelIndex?: number) => Promise<void>;
fetchAvailablePlugins: () => Promise<void>;
createInsert: (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}) => Promise<FxInsert | null>;
updateInsert: (
id: string,
update: Partial<FxInsert>,
) => Promise<FxInsert | null>;
deleteInsert: (id: string) => Promise<boolean>;
toggleBypass: (id: string, currentBypassed: boolean) => Promise<void>;
}
/**
* Hook for managing PiPedal FX inserts via the REST API.
*/
export function useFxInserts(): UseFxInsertsReturn {
const [inserts, setInserts] = useState<FxInsert[]>([]);
const [availablePlugins, setAvailablePlugins] = useState<AvailablePlugin[]>(
[],
);
const [loadingInserts, setLoadingInserts] = useState(false);
const [loadingAvailable, setLoadingAvailable] = useState(false);
const [error, setError] = useState<string | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
// Build a channel→inserts map
const insertsByChannel = useCallback((): Record<number, FxInsert[]> => {
const map: Record<number, FxInsert[]> = {};
for (const ins of inserts) {
const ch = ins.channel_index;
if (!map[ch]) map[ch] = [];
map[ch].push(ins);
}
// Sort each channel's inserts by slot_order
for (const ch of Object.keys(map)) {
map[Number(ch)].sort((a, b) => a.slot_order - b.slot_order);
}
return map;
}, [inserts]);
// Fetch inserts from the API
const fetchInserts = useCallback(async (channelIndex?: number) => {
setLoadingInserts(true);
setError(null);
try {
const url =
channelIndex !== undefined
? `/api/fx/inserts?channel_index=${channelIndex}`
: '/api/fx/inserts';
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (mountedRef.current) setInserts(data.inserts || []);
} catch (e) {
if (mountedRef.current) setError(`Failed to fetch inserts: ${String(e)}`);
} finally {
if (mountedRef.current) setLoadingInserts(false);
}
}, []);
// Fetch available PiPedal plugins for use as inserts
const fetchAvailablePlugins = useCallback(async () => {
setLoadingAvailable(true);
try {
const res = await fetch('/api/fx/available-plugins');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (mountedRef.current) setAvailablePlugins(data.plugins || []);
} catch (e) {
if (mountedRef.current)
setError(`Failed to fetch available plugins: ${String(e)}`);
} finally {
if (mountedRef.current) setLoadingAvailable(false);
}
}, []);
// Create a new FX insert
const createInsert = useCallback(
async (insert: {
channel_index: number;
plugin_instance_id: number;
plugin_name?: string;
slot_order?: number;
bypassed?: boolean;
wet_dry_mix?: number;
}): Promise<FxInsert | null> => {
try {
const res = await fetch('/api/fx/inserts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(insert),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const newInsert = data.insert as FxInsert;
if (mountedRef.current)
setInserts((prev) => [...prev, newInsert]);
return newInsert;
} catch (e) {
if (mountedRef.current)
setError(`Failed to create insert: ${String(e)}`);
return null;
}
},
[],
);
// Update an existing insert
const updateInsert = useCallback(
async (
id: string,
update: Partial<FxInsert>,
): Promise<FxInsert | null> => {
try {
const res = await fetch(`/api/fx/inserts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const updated = data.insert as FxInsert;
if (mountedRef.current)
setInserts((prev) => prev.map((i) => (i.id === id ? updated : i)));
return updated;
} catch (e) {
if (mountedRef.current)
setError(`Failed to update insert: ${String(e)}`);
return null;
}
},
[],
);
// Delete an insert
const deleteInsert = useCallback(
async (id: string): Promise<boolean> => {
try {
const res = await fetch(`/api/fx/inserts/${id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (mountedRef.current)
setInserts((prev) => prev.filter((i) => i.id !== id));
return true;
} catch (e) {
if (mountedRef.current)
setError(`Failed to delete insert: ${String(e)}`);
return false;
}
},
[],
);
// Toggle bypass
const toggleBypass = useCallback(
async (id: string, currentBypassed: boolean) => {
await updateInsert(id, { bypassed: !currentBypassed } as Partial<FxInsert>);
},
[updateInsert],
);
// Auto-fetch inserts and available plugins on mount
useEffect(() => {
fetchInserts();
fetchAvailablePlugins();
}, [fetchInserts, fetchAvailablePlugins]);
return {
insertsByChannel: insertsByChannel(),
availablePlugins,
loadingAvailable,
loadingInserts,
error,
fetchInserts,
fetchAvailablePlugins,
createInsert,
updateInsert,
deleteInsert,
toggleBypass,
};
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/components/ConnectionStatus.tsx","./src/components/PluginList.tsx","./src/components/MixerPage/BusStrip.tsx","./src/components/MixerPage/ChannelStrip.tsx","./src/components/MixerPage/FxInsertPanel.tsx","./src/components/MixerPage/FxInsertSlot.tsx","./src/components/MixerPage/InstrumentBadge.tsx","./src/components/MixerPage/LevelMeter.tsx","./src/components/MixerPage/MasterBus.tsx","./src/components/MixerPage/MixerPage.tsx","./src/components/PatchBay/PatchBay.tsx","./src/components/RotaryKnob/RotaryKnob.tsx","./src/components/Scenes/SceneManager.tsx","./src/hooks/useFxInserts.ts","./src/hooks/useJackPorts.ts","./src/hooks/useMixerControls.ts","./src/hooks/usePiPedalWS.ts","./src/hooks/useScenes.ts"],"version":"5.9.3"}