Add MasterBus component + enhance BusStrip with routing selector

MasterBus: stereo master fader, mute, level meters in PiPedal theme
(purple accent per mixer-engine branch reference).

BusStrip: added per-channel routing selector (ch1Route..ch8Route)
for selecting output bus target (Main, Bus 2-8).

MixerPage: integrated MasterBus with levels derived from bus
master data. Local state for volume/mute until backend exposes
master output.
This commit is contained in:
2026-06-23 12:15:09 -04:00
parent 17d423b21f
commit d19e0ea7a8
25 changed files with 4419 additions and 288 deletions
+227 -53
View File
@@ -1,24 +1,41 @@
# OPLabs Mixer App
A standalone mixer application that connects to PiPedal (at `192.168.0.245:8080`) via WebSocket to discover and display OPLabsBandChannel and OPLabsBandBus LV2 plugin instances loaded on the pedalboard.
A standalone digital mixer application with a C++ real-time audio engine,
Python/FastAPI backend, and React frontend.
## Architecture
```
Browser (React SPA) ←→ FastAPI Backend (localhost:8081) ←→ PiPedal (192.168.0.245:8080)
REST / WS
Browser (React SPA) ───────── FastAPI Backend (:8081)
│ │
└── WS /ws/pipedal └── WS /pipedal (upstream)
└── REST /api/discover └── REST proxied via WS
WS /ws/pipedal WS /pipedal (upstream, legacy)
└── PiPedal (192.168.0.245) └── Legacy PiPedal scenes
│ │
│ WS /ws/mixer │ Unix Socket IPC
└── Mixer Daemon Relay ──────┴── /tmp/oplabs-mixer.sock
C++ Mixer Daemon
(MixerEngine + JACK)
```
- **Backend:** FastAPI on port 8081 — serves the SPA, proxies WebSocket connections to PiPedal, and provides a REST API for plugin discovery.
- **Frontend:** Vite + React + TypeScript — connects to the local WS proxy and shows discovered OPLabs plugin instances with their parameter values.
Two parallel backends:
1. **Mixer Daemon (primary):** C++ daemon running the real-time mixer engine
(MixerEngine, MixerChannelStrip, MixerBus). Communicates with FastAPI
via Unix domain socket IPC.
2. **PiPedal (legacy):** WebSocket proxy to PiPedal pedalboard for backward
compatibility with existing OPLabsBandChannel/Bus LV2 plugins.
## Prerequisites
- Python 3.10+
- Node.js 18+
- PiPedal running at `192.168.0.245:8080` with OPLabs plugin instances loaded on the pedalboard
- C++20 compiler (gcc 11+ or clang 14+)
- CMake 3.16+
- JACK Audio Connection Kit (libjack-jackd2-dev)
- PiPedal at `192.168.0.245:8080` (optional, for legacy mode)
## Setup
@@ -36,92 +53,249 @@ pip install -r requirements.txt
```bash
cd frontend
npm install
npm run build
```
### Mixer Daemon (C++)
```bash
cd daemon
mkdir build && cd build
cmake ..
make
```
The daemon requires the MixerEngine sources from the `op-pedal` mixer-engine branch.
Either:
- Build inside the op-pedal CMake tree, or
- Set `-DMIXER_ENGINE_SOURCE_DIR=/path/to/op-pedal/src/`
## Running
### Development Mode
Start both backend and frontend:
### 1. Start JACK (if using the daemon with audio I/O)
```bash
jackd -d alsa -r 48000 -p 512 -n 2 &
```
### 2. Start the Mixer Daemon
```bash
./daemon/build/oplabs-mixer-daemon \
--socket-path /tmp/oplabs-mixer.sock \
--sample-rate 48000 \
--buffer-size 512
```
### 3. Start the Backend
```bash
# Terminal 1: Backend
cd backend
source venv/bin/activate
python app.py # runs on port 8081
# Terminal 2: Frontend (with hot-reload)
cd frontend
npm run dev # runs on port 5173, proxies API/WS to 8081
```
Then open http://localhost:5173 in your browser.
### Production Mode
Build the frontend and serve from the backend:
```bash
cd frontend
npm run build
# Then start the backend (it will serve the built SPA):
cd ../backend
source venv/bin/activate
python app.py
# Open http://localhost:8081
```
Open http://localhost:8081 in your browser.
Or use the convenience script:
```bash
./start.sh
```
## WebSocket Protocol
## IPC Protocol (C++ Mixer Daemon <-> Python FastAPI)
The app uses PiPedal's native WebSocket protocol:
### Transport
- Messages are JSON arrays: `[{"message": "msgName", "replyTo": N}, optionalBody]`
- Replies: `[{"reply": N, "message": "msgName"}, optionalBody]`
- The browser connects to `ws://localhost:8081/ws/pipedal`, which proxies to PiPedal's `ws://192.168.0.245:8080/pipedal`
- **Type:** Unix domain socket (`AF_UNIX`, `SOCK_STREAM`)
- **Path:** `/tmp/oplabs-mixer.sock`
- **Permissions:** 0666 (read/write for owner and group)
### Framing
Every message is prefixed with a 4-byte big-endian unsigned integer
(uint32_t) specifying the length of the JSON payload in bytes.
```
[4-byte length BE] [UTF-8 JSON payload]
```
### Request Format
```json
{
"id": 1,
"method": "getState",
"params": {}
}
```
### Response Format
```json
{
"id": 1,
"result": { ... },
"error": null
}
```
On error, `result` is null and `error` contains a string description.
### Push Messages (Daemon -> Client)
Unsolicited messages from the daemon are tagged with `"type": "push"`:
```json
{
"type": "push",
"event": "vuMeter",
"data": {
"channels": [{"index": 0, "left": -12.5, "right": -10.2}, ...],
"buses": [{"id": 1, "left": -6.0, "right": -5.8}]
}
}
```
### Message Reference
| Method | Params | Result |
|---------------------|------------------------------------------------------|------------------------------------------------------|
| `getState` | `{}` | Full mixer state object (channels, buses, routes, output routes) |
| `setChannelVolume` | `{"channelIndex": int, "volumeDb": float}` | `{}` |
| `setChannelPan` | `{"channelIndex": int, "pan": float}` | `{}` |
| `setChannelMute` | `{"channelIndex": int, "mute": bool}` | `{}` |
| `setChannelSolo` | `{"channelIndex": int, "solo": bool}` | `{}` |
| `addChannel` | `{"physicalInputIndex": int}` | `{"channelIndex": int}` |
| `removeChannel` | `{"channelIndex": int}` | `{}` |
| `getOutputRoutes` | `{}` | `{"routes": [sourceBusId, sourceStartChannel, targetStartChannel, channels]}` |
| `setOutputRoutes` | `{"routes": [sourceBusId, sourceStartChannel, targetStartChannel, channels]}` | `{}` |
| `saveScene` | `{"name": string}` | `{"id": int, "name": string}` |
| `loadScene` | `{"sceneId": string}` | `{"ok": bool}` |
| `listScenes` | `{}` | `{"scenes": [{"id": int, "name": string}, ...]}` |
| `deleteScene` | `{"sceneId": string}` | `{"ok": bool}` |
### getState Result Schema
```json
{
"channels": [
{
"channelIndex": 0,
"volume": -6.0,
"pan": 0.0,
"mute": false,
"solo": false,
"type": "Instrument",
"label": "Channel 1",
"hpEnabled": false,
"hpFrequency": 80.0,
"vuLeft": -12.5,
"vuRight": -10.2
}
],
"buses": [
{
"id": 1,
"name": "Master",
"type": "Master",
"volume": 0.0,
"mute": false
}
],
"routes": [
{
"sourceId": 1001,
"targetBusId": 1,
"level": 0.0,
"sourceType": "channel"
}
],
"outputRoutes": [
{
"sourceBusId": 1,
"sourceStartChannel": 0,
"targetStartChannel": 0,
"channels": 2
}
]
}
```
## REST API
### Mixer Daemon Endpoints (new)
| Endpoint | Method | Description |
|----------|--------|-------------|
|-------------------------------------------------------|--------|--------------------------------------|
| `/api/mixer/state` | GET | Get full mixer state |
| `/api/mixer/channel/{index}/volume` | POST | Set channel volume |
| `/api/mixer/channel/{index}/pan` | POST | Set channel pan |
| `/api/mixer/channel/{index}/mute` | POST | Set channel mute |
| `/api/mixer/channel/{index}/solo` | POST | Set channel solo |
| `/api/mixer/channel/add` | POST | Add a new channel |
| `/api/mixer/channel/{index}` | DELETE | Remove a channel |
| `/api/mixer/output-routes` | GET | Get output routing table |
| `/api/mixer/output-routes` | POST | Set output routing table |
| `/api/mixer/scenes` | GET | List all scenes |
| `/api/mixer/scenes` | POST | Save current state as scene |
| `/api/mixer/scenes/{id}/load` | POST | Load (recall) a scene |
| `/api/mixer/scenes/{id}` | DELETE | Delete a scene |
### PiPedal Endpoints (legacy)
| Endpoint | Method | Description |
|-------------------|--------|------------------------------------------------|
| `/api/ping` | GET | Health check + connection status |
| `/api/discover` | GET | Discover OPLabs plugin instances from current pedalboard |
| `/api/discover` | GET | Discover OPLabs plugin instances from PiPedal |
| `/api/plugins` | GET | List all available plugins from PiPedal |
| `/api/scenes` | GET | List saved scenes (legacy file-based) |
| `/api/scenes` | POST | Create new scene |
| `/api/scenes/{id}`| PUT | Update scene |
| `/api/scenes/{id}`| DELETE | Delete scene |
| `/api/scenes/{id}/recall` | POST | Recall scene via PiPedal |
## OPLabs Plugin URIs
## WebSocket Endpoints
- OPLabsBandChannel: `http://ourpad.casa/plugins/oplabs-band-channel`
- OPLabsBandBus: `http://ourpad.casa/plugins/oplabs-band-bus#`
| Endpoint | Description |
|-----------------|-----------------------------------------------------|
| `/ws/pipedal` | Proxy WebSocket: browser <-> FastAPI <-> PiPedal |
| `/ws/mixer` | Direct mixer daemon control (JSON method calls) |
### `/ws/mixer` Protocol
The mixer WebSocket accepts JSON messages in the same format as the IPC protocol:
```json
// Request
{"method": "getState", "id": 1}
{"method": "setChannelVolume", "params": {"channelIndex": 0, "volumeDb": -6.0}, "id": 2}
// Response
{"id": 1, "result": {...}, "error": null}
{"id": 2, "result": {}, "error": null}
```
## Project Structure
```
~/projects/oplabs-mixer-app/
├── backend/
│ ├── app.py # FastAPI app (routes, SPA serving, WS proxy)
│ ├── ws_proxy.py # PiPedal WebSocket proxy client
│ ├── app.py # FastAPI app (mixer + legacy PiPedal routes)
│ ├── mixer_ipc.py # Unix socket IPC client for Mixer Daemon
│ ├── ws_proxy.py # PiPedal WebSocket proxy
│ └── requirements.txt
├── daemon/
│ ├── CMakeLists.txt # Build configuration
│ ├── MixerIpcServer.hpp # C++ Unix socket IPC server
│ ├── MixerIpcServer.cpp # IPC server implementation
│ ├── MixerDaemon.cpp # Main entry point
│ └── oplabs-mixer-daemon.service.in # Systemd service template
├── frontend/
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── App.css
│ ├── hooks/
│ │ └── usePiPedalWS.ts
│ └── components/
│ ├── ConnectionStatus.tsx
│ └── PluginList.tsx
├── README.md
└── start.sh
```
+300 -36
View File
@@ -1,8 +1,12 @@
"""
OPLabs Mixer App FastAPI Backend
OPLabs Mixer App - FastAPI Backend
Serves the React SPA and proxies WebSocket connections to PiPedal.
Provides REST API for plugin discovery and scene management.
Serves the React SPA and provides REST/WebSocket access to the
C++ Mixer Daemon via Unix domain socket IPC.
Architecture: Browser <-> FastAPI (:8081) <-> Unix Socket <-> MixerDaemon
Also retains backward compatibility with PiPedal via WebSocket proxy.
"""
import asyncio
@@ -21,6 +25,7 @@ from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from mixer_ipc import MixerIpcClient, MixerIpcError
from ws_proxy import PiPedalWSProxy
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -34,6 +39,8 @@ PIPEDAL_HOST = "192.168.0.245"
PIPEDAL_PORT = 8080
PIPEDAL_WS_PATH = "/pipedal"
MIXER_SOCKET_PATH = "/tmp/oplabs-mixer.sock"
# OPLabs plugin URIs we care about
OPLABS_PLUGIN_URIS = {
"http://ourpad.casa/plugins/oplabs-band-channel",
@@ -45,7 +52,7 @@ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist"
SCENES_FILE = Path(__file__).resolve().parent / "scenes.json"
# ---------------------------------------------------------------------------
# Global proxy state
# Global state
# ---------------------------------------------------------------------------
pipedal_proxy: Optional[PiPedalWSProxy] = None
@@ -56,6 +63,10 @@ pipedal_state: dict = {
"oplabs_instances": [], # discovered OPLabs plugin instances
}
# Mixer daemon IPC client (set in lifespan)
mixer_client: Optional[MixerIpcClient] = None
mixer_daemon_connected: bool = False
# ---------------------------------------------------------------------------
# Scene storage helpers
# ---------------------------------------------------------------------------
@@ -81,8 +92,7 @@ def _save_scenes(scenes: list[dict]):
# ---------------------------------------------------------------------------
async def pipedal_request(message_name: str, body=None, timeout: float = 10.0):
"""Connect to PiPedal WS, send a request, wait for reply, then disconnect.
Handles interleaved push messages by discarding non-matching messages."""
"""Connect to PiPedal WS, send a request, wait for reply, then disconnect."""
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
reply_to = hash(message_name + str(time.time())) & 0x7FFFFFFF
@@ -92,6 +102,8 @@ async def pipedal_request(message_name: str, body=None, timeout: float = 10.0):
try:
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
ws.settimeout(timeout)
# Send hello first to register as subscriber
hello_reply_to = 0x10000001
hello_msg = json.dumps([{"message": "hello", "replyTo": hello_reply_to}])
@@ -151,8 +163,7 @@ def is_oplabs_plugin(uri: str) -> bool:
def discover_oplabs_from_pedalboard(pedalboard: dict) -> list[dict]:
"""Extract OPLabs plugin instances from a pedalboard state dict.
Handles split items (which contain nested sub-items)."""
"""Extract OPLabs plugin instances from a pedalboard state dict."""
instances = []
if not pedalboard:
return instances
@@ -200,6 +211,28 @@ def fetch_pedalboard_state():
return False
# ---------------------------------------------------------------------------
# Mixer Daemon IPC helpers
# ---------------------------------------------------------------------------
async def with_mixer_client(method_name: str, *args, **kwargs):
"""Call a method on the MixerIpcClient, handling connection errors."""
global mixer_client, mixer_daemon_connected
if not mixer_client or not mixer_client.is_connected:
mixer_daemon_connected = False
raise MixerIpcError("Mixer daemon not connected")
try:
result = await getattr(mixer_client, method_name)(*args, **kwargs)
mixer_daemon_connected = True
return result
except MixerIpcError:
raise
except Exception as e:
mixer_daemon_connected = False
raise MixerIpcError(f"Mixer daemon communication error: {e}")
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
@@ -207,10 +240,26 @@ def fetch_pedalboard_state():
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown."""
global pipedal_proxy
global pipedal_proxy, mixer_client
# Start PiPedal proxy
pipedal_proxy = PiPedalWSProxy(host=PIPEDAL_HOST, port=PIPEDAL_PORT, path=PIPEDAL_WS_PATH)
logger.info("Mixer app starting PiPedal at %s:%d", PIPEDAL_HOST, PIPEDAL_PORT)
logger.info("Mixer app starting - PiPedal at %s:%d", PIPEDAL_HOST, PIPEDAL_PORT)
# Try connecting to Mixer Daemon
mixer_client = MixerIpcClient(socket_path=MIXER_SOCKET_PATH)
connected = await mixer_client.connect()
if connected:
logger.info("Connected to Mixer Daemon at %s", MIXER_SOCKET_PATH)
else:
logger.warning("Mixer Daemon not available at %s - mixer endpoints disabled",
MIXER_SOCKET_PATH)
yield
# Shutdown
if mixer_client:
await mixer_client.close()
if pipedal_proxy:
await pipedal_proxy.close_upstream()
logger.info("Mixer app shutting down")
@@ -218,13 +267,158 @@ async def lifespan(app: FastAPI):
app = FastAPI(
title="OPLabs Mixer App",
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# Mixer Daemon REST endpoints (new)
# ---------------------------------------------------------------------------
@app.get("/api/mixer/state")
async def mixer_get_state():
"""Get full mixer state from the mixer daemon."""
try:
state = await with_mixer_client("get_state")
return state
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/volume")
async def mixer_set_channel_volume(channel_index: int, body: dict):
"""Set channel volume in dB."""
volume_db = body.get("volumeDb", body.get("volume", -96.0))
try:
await with_mixer_client("set_channel_volume", channel_index, volume_db)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/pan")
async def mixer_set_channel_pan(channel_index: int, body: dict):
"""Set channel pan (-1.0 to +1.0)."""
pan = body.get("pan", 0.0)
try:
await with_mixer_client("set_channel_pan", channel_index, pan)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/mute")
async def mixer_set_channel_mute(channel_index: int, body: dict):
"""Set channel mute state."""
mute = body.get("mute", True)
try:
await with_mixer_client("set_channel_mute", channel_index, mute)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/{channel_index}/solo")
async def mixer_set_channel_solo(channel_index: int, body: dict):
"""Set channel solo state."""
solo = body.get("solo", True)
try:
await with_mixer_client("set_channel_solo", channel_index, solo)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/channel/add")
async def mixer_add_channel(body: dict):
"""Add a new channel. Returns {channelIndex: N}."""
physical_input = body.get("physicalInputIndex", 0)
try:
idx = await with_mixer_client("add_channel", physical_input)
return {"channelIndex": idx}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.delete("/api/mixer/channel/{channel_index}")
async def mixer_remove_channel(channel_index: int):
"""Remove a channel by index."""
try:
await with_mixer_client("remove_channel", channel_index)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.get("/api/mixer/output-routes")
async def mixer_get_output_routes():
"""Get output routing table."""
try:
routes = await with_mixer_client("get_output_routes")
return {"routes": routes}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/output-routes")
async def mixer_set_output_routes(body: dict):
"""Set output routing table."""
routes = body.get("routes", [])
try:
await with_mixer_client("set_output_routes", routes)
return {"status": "ok"}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/scenes")
async def mixer_save_scene(body: dict):
"""Save current mixer state as a scene."""
name = body.get("name", "Unnamed Scene")
try:
result = await with_mixer_client("save_scene", name)
return {"scene": result}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.post("/api/mixer/scenes/{scene_id}/load")
async def mixer_load_scene(scene_id: str):
"""Load (recall) a scene by ID."""
try:
ok = await with_mixer_client("load_scene", scene_id)
if ok:
return {"status": "ok"}
return JSONResponse({"error": "Scene not found"}, status_code=404)
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.get("/api/mixer/scenes")
async def mixer_list_scenes():
"""List available scenes."""
try:
scenes = await with_mixer_client("list_scenes")
return {"scenes": scenes}
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
@app.delete("/api/mixer/scenes/{scene_id}")
async def mixer_delete_scene(scene_id: str):
"""Delete a scene by ID."""
try:
ok = await with_mixer_client("delete_scene", scene_id)
if ok:
return {"status": "deleted"}
return JSONResponse({"error": "Scene not found"}, status_code=404)
except MixerIpcError as e:
return JSONResponse({"error": str(e)}, status_code=503)
# ---------------------------------------------------------------------------
# Scene REST endpoints
# Scene REST endpoints (legacy - file-based, via PiPedal)
# ---------------------------------------------------------------------------
class SceneStateChannel(BaseModel):
@@ -248,14 +442,14 @@ class SceneUpdate(BaseModel):
@app.get("/api/scenes")
async def list_scenes():
"""List all saved scenes."""
"""List all saved scenes (legacy file-based)."""
scenes = _load_scenes()
return {"scenes": scenes}
@app.post("/api/scenes")
async def create_scene(scene: SceneCreate):
"""Save a new scene."""
"""Save a new scene (legacy file-based)."""
scenes = _load_scenes()
new_scene = {
"id": str(uuid.uuid4()),
@@ -298,9 +492,7 @@ async def delete_scene(scene_id: str):
@app.post("/api/scenes/{scene_id}/recall")
async def recall_scene(scene_id: str):
"""Recall a scene by sending setControlValue messages to PiPedal for each
channel and bus parameter. Connects fresh to PiPedal and sends sequenced
messages."""
"""Recall a scene via PiPedal."""
scenes = _load_scenes()
scene = None
for sc in scenes:
@@ -314,7 +506,6 @@ async def recall_scene(scene_id: str):
channels = state.get("channels", [])
buses = state.get("buses", [])
# Build all setControlValue messages
messages: list[dict] = []
for ch in channels:
instance_id = ch["instanceId"]
@@ -334,14 +525,11 @@ async def recall_scene(scene_id: str):
if not messages:
return {"status": "recalled", "message_count": 0}
# Connect to PiPedal and send sequenced messages
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
try:
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
# Send hello
hello_reply_to = 0x10000001
await ws.send(json.dumps([{"message": "hello", "replyTo": hello_reply_to}]))
# Wait for hello reply (discard push messages)
deadline = time.time() + 10.0
got_hello = False
while time.time() < deadline:
@@ -357,7 +545,6 @@ async def recall_scene(scene_id: str):
logger.warning("Recall: hello reply not received")
return JSONResponse({"error": "PiPedal not responding"}, status_code=502)
# Send each message with a small delay to let PiPedal process in order
sent = 0
for msg in messages:
reply_to = (hash(str(msg)) & 0x7FFFFFFF)
@@ -367,11 +554,10 @@ async def recall_scene(scene_id: str):
])
await ws.send(wire_msg)
sent += 1
# Small delay between messages to ensure ordering
if sent % 5 == 0:
await asyncio.sleep(0.01)
logger.info("Recalled scene '%s' sent %d control messages", scene["name"], sent)
logger.info("Recalled scene '%s' - sent %d control messages", scene["name"], sent)
return {"status": "recalled", "message_count": sent}
except Exception as e:
logger.warning("Recall failed: %s", e)
@@ -379,13 +565,13 @@ async def recall_scene(scene_id: str):
# ---------------------------------------------------------------------------
# Existing REST endpoints
# PiPedal REST endpoints (legacy)
# ---------------------------------------------------------------------------
@app.get("/api/discover")
async def discover_plugins():
"""Discover OPLabs plugin instances from PiPedal's current pedalboard."""
"""Discover OPLabs plugin instances from PiPedal."""
ok = fetch_pedalboard_state()
return {
"connected": pipedal_state["connected"],
@@ -403,37 +589,39 @@ async def list_pipedal_plugins():
@app.get("/api/ping")
async def ping():
"""Health check + connection status."""
"""Health check."""
return {
"status": "ok",
"pipedal_host": PIPEDAL_HOST,
"pipedal_port": PIPEDAL_PORT,
"connected": pipedal_state["connected"],
"pipedal_connected": pipedal_state["connected"],
"mixer_daemon_connected": mixer_daemon_connected if mixer_client else False,
}
# ---------------------------------------------------------------------------
# WebSocket proxy endpoint
# WebSocket endpoints
# ---------------------------------------------------------------------------
@app.websocket("/ws/pipedal")
async def websocket_proxy(ws: WebSocket):
"""Proxy WebSocket: browser local FastAPI PiPedal."""
"""Proxy WebSocket: browser <-> local FastAPI <-> PiPedal."""
await ws.accept()
logger.info("Browser WS connected")
logger.info("Browser WS connected (PiPedal proxy)")
proxy = PiPedalWSProxy(host=PIPEDAL_HOST, port=PIPEDAL_PORT, path=PIPEDAL_WS_PATH)
connected = await proxy.connect_upstream()
if not connected:
await ws.send_json({"type": "error", "message": f"Cannot connect to PiPedal at {proxy.upstream_url}"})
await ws.send_json({
"type": "error",
"message": f"Cannot connect to PiPedal at {proxy.upstream_url}"
})
await ws.close()
return
pipedal_state["connected"] = True
# Notify browser that upstream is connected
await ws.send_json({"type": "connected", "pipedal": proxy.upstream_url})
try:
@@ -447,6 +635,83 @@ async def websocket_proxy(ws: WebSocket):
await proxy.close_upstream()
@app.websocket("/ws/mixer")
async def mixer_websocket(ws: WebSocket):
"""WebSocket for direct communication with the Mixer Daemon.
Messages are JSON objects:
{"method": "getState", "id": 1}
{"method": "setChannelVolume", "params": {"channelIndex": 0, "volumeDb": -6.0}, "id": 2}
Responses mirror the IPC protocol:
{"id": 1, "result": {...}, "error": null}
"""
await ws.accept()
logger.info("Browser WS connected (Mixer Daemon)")
if not mixer_client or not mixer_client.is_connected:
await ws.send_json({
"type": "error",
"message": "Mixer daemon not available"
})
await ws.close()
return
await ws.send_json({"type": "connected", "daemon": MIXER_SOCKET_PATH})
try:
while True:
raw = await ws.receive_text()
try:
msg = json.loads(raw)
except json.JSONDecodeError:
await ws.send_json({"id": -1, "error": "Invalid JSON"})
continue
method = msg.get("method", "")
params = msg.get("params", {})
req_id = msg.get("id", -1)
# Map WebSocket methods to MixerIpcClient methods
method_map = {
"getState": ("get_state", None),
"setChannelVolume": ("set_channel_volume", ["channelIndex", "volumeDb"]),
"setChannelPan": ("set_channel_pan", ["channelIndex", "pan"]),
"setChannelMute": ("set_channel_mute", ["channelIndex", "mute"]),
"setChannelSolo": ("set_channel_solo", ["channelIndex", "solo"]),
"addChannel": ("add_channel", ["physicalInputIndex"]),
"removeChannel": ("remove_channel", ["channelIndex"]),
"getOutputRoutes": ("get_output_routes", None),
"setOutputRoutes": ("set_output_routes", ["routes"]),
"saveScene": ("save_scene", ["name"]),
"loadScene": ("load_scene", ["sceneId"]),
"listScenes": ("list_scenes", None),
"deleteScene": ("delete_scene", ["sceneId"]),
}
if method not in method_map:
await ws.send_json({"id": req_id, "error": f"Unknown method: {method}"})
continue
client_method, param_keys = method_map[method]
try:
if param_keys is None:
result = await getattr(mixer_client, client_method)()
else:
args = [params.get(k) for k in param_keys]
result = await getattr(mixer_client, client_method)(*args)
await ws.send_json({"id": req_id, "result": result, "error": None})
except MixerIpcError as e:
await ws.send_json({"id": req_id, "error": str(e)})
except WebSocketDisconnect:
logger.info("Mixer WS disconnected")
except Exception as e:
logger.warning("Mixer WS error: %s", e)
# ---------------------------------------------------------------------------
# Serve SPA (must be last, after all API routes)
# ---------------------------------------------------------------------------
@@ -458,9 +723,8 @@ if FRONTEND_DIR.exists():
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
"""Serve the SPA return index.html for all non-API routes."""
"""Serve the SPA - return index.html for all non-API routes."""
if full_path.startswith("api/") or full_path.startswith("ws/"):
from fastapi.responses import JSONResponse
return JSONResponse({"error": "not found"}, status_code=404)
if not SPA_INDEX.exists():
return JSONResponse(
+420
View File
@@ -0,0 +1,420 @@
"""
JACK port management endpoints.
Provides REST API for querying and managing JACK audio port connections
via the PiPedal WebSocket API. PiPedal runs on a separate machine (the Pi)
and manages JACK internally, so all JACK operations go through PiPedal's WS.
PiPedal JACK message name notes:
The message names below are based on common PiPedal / mod-host conventions.
Adjust MSG_* constants if PiPedal uses different names in your version.
"""
import asyncio
import json
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger("jack-endpoints")
# ---------------------------------------------------------------------------
# PiPedal JACK message names — adjust these if PiPedal uses different names
# ---------------------------------------------------------------------------
# Message to list all JACK ports (returns port list with types/directions)
MSG_LIST_PORTS = "getAudioPorts"
# Message to list current JACK connections (returns port→connected mapping)
MSG_LIST_CONNECTIONS = "getAudioConnections"
# Message to connect two JACK ports: body = {"source": "...", "destination": "..."}
MSG_CONNECT = "connectPorts"
# Message to disconnect two JACK ports: body = {"source": "...", "destination": "..."}
MSG_DISCONNECT = "disconnectPorts"
# Alternative message names to try if the primary ones don't work
ALT_MSG_LIST_PORTS = ["getPorts", "jack_get_ports", "listAudioPorts"]
ALT_MSG_LIST_CONNECTIONS = ["getConnections", "jack_get_connections", "listAudioConnections"]
ALT_MSG_CONNECT = ["jack_connect", "connectAudioPorts"]
ALT_MSG_DISCONNECT = ["jack_disconnect", "disconnectAudioPorts"]
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
class JackPortModel(BaseModel):
"""A single JACK port."""
name: str # full JACK port name, e.g. "system:capture_1"
client: str # client name, e.g. "system"
port: str # port short name, e.g. "capture_1"
port_type: str # "audio" | "midi"
direction: str # "output" = audio source (produces audio), "input" = destination (receives audio)
is_physical: bool = False
connections: list[str] = [] # names of connected ports
class JackConnectRequest(BaseModel):
source: str # source port name (output)
destination: str # destination port name (input)
# ---------------------------------------------------------------------------
# Helper: try calling PiPedal with a message name, falling back to alternatives
# ---------------------------------------------------------------------------
# Import pipedal_request lazily — it's defined in app.py which imports us
_pipedal_request = None
async def _get_pipedal_request():
"""Get the pipedal_request function, importing from app on first call."""
global _pipedal_request
if _pipedal_request is None:
from app import pipedal_request
_pipedal_request = pipedal_request
return _pipedal_request
async def _call_pipedal(message: str, body=None, alt_messages: list[str] | None = None) -> Optional[dict]:
"""
Call PiPedal with *message*, falling back to *alt_messages* if None is returned.
Returns the response body dict, or None if all variants failed.
"""
req = await _get_pipedal_request()
result = await req(message, body)
if result is not None:
return result
# Try alternates
if alt_messages:
for alt in alt_messages:
result = await req(alt, body)
if result is not None:
logger.info("Fell back to alternative message: %s (primary was %s)", alt, message)
return result
return None
# ---------------------------------------------------------------------------
# Port / connection parsing (handles various PiPedal response shapes)
# ---------------------------------------------------------------------------
def _parse_ports_from_pedalboard() -> list[dict]:
"""
Derive port information from the cached pedalboard state as a fallback
when PiPedal JACK port listing is unavailable.
"""
from app import pipedal_state, discover_oplabs_from_pedalboard
ports = []
# System physical ports (generic — actual ports depend on audio interface)
ports.append({
"name": "system:capture_1",
"client": "system",
"port": "capture_1",
"port_type": "audio",
"direction": "output",
"is_physical": True,
"connections": [],
})
ports.append({
"name": "system:capture_2",
"client": "system",
"port": "capture_2",
"port_type": "audio",
"direction": "output",
"is_physical": True,
"connections": [],
})
ports.append({
"name": "system:playback_1",
"client": "system",
"port": "playback_1",
"port_type": "audio",
"direction": "input",
"is_physical": True,
"connections": [],
})
ports.append({
"name": "system:playback_2",
"client": "system",
"port": "playback_2",
"port_type": "audio",
"direction": "input",
"is_physical": True,
"connections": [],
})
# PiPedal client ports (derived from pedalboard state)
pb = pipedal_state.get("pedalboard") or {}
items = pb.get("items") or []
seen_clients = set()
for item in items:
uri = item.get("uri", "")
instance_id = item.get("instanceId")
title = item.get("title", "") or item.get("pluginName", "") or f"plugin_{instance_id}"
client_name = f"pipedal:{instance_id}" if instance_id else f"pipedal:{title}"
if client_name in seen_clients:
continue
seen_clients.add(client_name)
audio_ins = item.get("audioInputs", 2) or 2
audio_outs = item.get("audioOutputs", 2) or 2
# Input ports (the plugin's audio inputs = JACK destinations)
for j in range(audio_ins):
pname = f"{client_name}:audio_in_{j + 1}"
ports.append({
"name": pname,
"client": client_name,
"port": f"audio_in_{j + 1}",
"port_type": "audio",
"direction": "input",
"is_physical": False,
"connections": [],
})
# Output ports (the plugin's audio outputs = JACK sources)
for j in range(audio_outs):
pname = f"{client_name}:audio_out_{j + 1}"
ports.append({
"name": pname,
"client": client_name,
"port": f"audio_out_{j + 1}",
"port_type": "audio",
"direction": "output",
"is_physical": False,
"connections": [],
})
# Generic PiPedal engine output ports
engine_client = "pipedal"
for j in range(2):
pname = f"{engine_client}:out_{j + 1}"
ports.append({
"name": pname,
"client": engine_client,
"port": f"out_{j + 1}",
"port_type": "audio",
"direction": "output",
"is_physical": False,
"connections": [],
})
return ports
def _normalize_port_entry(raw: dict) -> dict:
"""
Normalize a port entry from PiPedal's response to our standard format.
Handles various key naming conventions.
"""
name = raw.get("name") or raw.get("portName") or raw.get("fullName") or ""
client = raw.get("client") or raw.get("clientName") or name.split(":")[0] if ":" in name else ""
port = raw.get("port") or raw.get("shortName") or (name.split(":", 1)[1] if ":" in name else name)
port_type = raw.get("type") or raw.get("portType") or "audio"
direction = raw.get("direction") or raw.get("mode") or "output"
is_physical = raw.get("isPhysical") or raw.get("physical") or raw.get("is_physical") or False
connections = raw.get("connections") or raw.get("connectedTo") or []
return {
"name": name,
"client": client,
"port": port,
"port_type": port_type,
"direction": direction,
"is_physical": bool(is_physical),
"connections": connections if isinstance(connections, list) else [],
}
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
router = APIRouter(prefix="/api/jack", tags=["jack"])
@router.get("/ports")
async def list_jack_ports():
"""
List all JACK audio ports and their current connections.
Returns:
{
"ports": [...], # list of port dicts
"connections": {...}, # port_name → [connected_port_names]
"source": "pipedal" | "fallback"
}
"""
# Try PiPedal for real port data
ports_data = await _call_pipedal(
MSG_LIST_PORTS,
alt_messages=ALT_MSG_LIST_PORTS,
)
connections_data = await _call_pipedal(
MSG_LIST_CONNECTIONS,
alt_messages=ALT_MSG_LIST_CONNECTIONS,
)
if ports_data is not None:
# Normalize port entries
raw_ports = ports_data if isinstance(ports_data, list) else (ports_data.get("ports") or ports_data.get("items") or [])
ports = [_normalize_port_entry(p) for p in raw_ports]
# Merge connection info
if connections_data:
conn_map = connections_data
if isinstance(connections_data, dict):
conn_map = connections_data.get("connections") or connections_data.get("items") or connections_data
for port in ports:
pname = port["name"]
port["connections"] = conn_map.get(pname) or conn_map.get(pname, [])
return {
"ports": ports,
"connections": _build_connection_map(ports),
"source": "pipedal",
}
# Fallback: derive from cached pedalboard state
from app import pipedal_state
if pipedal_state.get("pedalboard"):
ports = _parse_ports_from_pedalboard()
return {
"ports": ports,
"connections": _build_connection_map(ports),
"source": "fallback",
"note": "PiPedal JACK API unavailable — ports derived from pedalboard state, connections unknown",
}
# No data available at all
return {
"ports": [],
"connections": {},
"source": "none",
"note": "PiPedal is unreachable. No JACK port information available.",
}
@router.get("/ports/refresh")
async def refresh_jack_ports():
"""
Force-refresh the pedalboard state and re-query JACK ports.
"""
from app import fetch_pedalboard_state
fetch_pedalboard_state()
return await list_jack_ports()
@router.post("/connect")
async def connect_ports(req: JackConnectRequest):
"""
Connect two JACK ports (source → destination).
Body: {"source": "...", "destination": "..."}
"""
result = await _call_pipedal(
MSG_CONNECT,
body={"source": req.source, "destination": req.destination},
alt_messages=ALT_MSG_CONNECT,
)
if result is None:
# If connect message failed, try as positional args
result = await _call_pipedal(
MSG_CONNECT,
body=[req.source, req.destination],
alt_messages=ALT_MSG_CONNECT,
)
if result is not None:
logger.info("JACK connect: %s%s", req.source, req.destination)
return {"status": "connected", "source": req.source, "destination": req.destination}
raise HTTPException(
status_code=502,
detail=f"Failed to connect {req.source}{req.destination}: PiPedal did not respond",
)
@router.post("/disconnect")
async def disconnect_ports(req: JackConnectRequest):
"""
Disconnect two JACK ports.
Body: {"source": "...", "destination": "..."}
"""
result = await _call_pipedal(
MSG_DISCONNECT,
body={"source": req.source, "destination": req.destination},
alt_messages=ALT_MSG_DISCONNECT,
)
if result is None:
result = await _call_pipedal(
MSG_DISCONNECT,
body=[req.source, req.destination],
alt_messages=ALT_MSG_DISCONNECT,
)
if result is not None:
logger.info("JACK disconnect: %s%s", req.source, req.destination)
return {"status": "disconnected", "source": req.source, "destination": req.destination}
raise HTTPException(
status_code=502,
detail=f"Failed to disconnect {req.source}{req.destination}: PiPedal did not respond",
)
@router.post("/disconnect-all")
async def disconnect_all():
"""
Disconnect all JACK port connections.
"""
ports_data = await list_jack_ports()
conns = ports_data.get("connections", {})
disconnected = 0
for src, dests in conns.items():
for dst in dests:
try:
await _call_pipedal(
MSG_DISCONNECT,
body={"source": src, "destination": dst},
alt_messages=ALT_MSG_DISCONNECT,
)
disconnected += 1
except Exception:
pass
return {"status": "disconnected_all", "count": disconnected}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _build_connection_map(ports: list[dict]) -> dict[str, list[str]]:
"""Build a port_name → [connected_port_names] map from port list."""
conn_map = {}
for p in ports:
pname = p["name"]
if p["connections"]:
conn_map[pname] = p["connections"]
# Also add reverse entries
for c in p.get("connections", []):
if c not in conn_map:
conn_map[c] = []
if pname not in conn_map[c]:
conn_map[c].append(pname)
return conn_map
+305
View File
@@ -0,0 +1,305 @@
"""
OPLabs Mixer — Unix domain socket IPC client.
Connects to the C++ MixerDaemon over a Unix domain socket using a
length-prefixed JSON protocol.
Protocol:
Frame: 4-byte big-endian uint32 length prefix + UTF-8 JSON payload
Request: {"id": <int>, "method": <string>, "params": {<object>}}
Response: {"id": <int>, "result": <any>, "error": <string|null>}
Push: {"type": "push", "event": <string>, "data": {<object>}}
"""
import asyncio
import json
import logging
import struct
from typing import Any, Optional
logger = logging.getLogger("mixer-ipc")
class MixerIpcError(Exception):
"""Raised when the mixer daemon returns an error response."""
class MixerIpcClient:
"""Async Unix socket IPC client for the OPLabs Mixer Daemon.
Usage:
client = MixerIpcClient(socket_path="/tmp/oplabs-mixer.sock")
await client.connect()
state = await client.get_state()
await client.set_channel_volume(0, -6.0)
await client.close()
"""
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock",
connect_timeout: float = 5.0,
request_timeout: float = 10.0):
self._socket_path = socket_path
self._connect_timeout = connect_timeout
self._request_timeout = request_timeout
self._reader: Optional[asyncio.StreamReader] = None
self._writer: Optional[asyncio.StreamWriter] = None
self._connected = False
self._next_id = 1
self._pending: dict[int, asyncio.Future] = {}
self._read_task: Optional[asyncio.Task] = None
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
async def connect(self) -> bool:
"""Connect to the Unix domain socket. Returns True on success."""
try:
loop = asyncio.get_running_loop()
self._reader, self._writer = await asyncio.wait_for(
asyncio.open_unix_connection(self._socket_path),
timeout=self._connect_timeout,
)
self._connected = True
self._read_task = asyncio.create_task(self._read_loop())
logger.info("Connected to mixer daemon at %s", self._socket_path)
return True
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
OSError) as exc:
logger.warning("Failed to connect to mixer daemon: %s", exc)
self._connected = False
return False
async def close(self):
"""Close the connection."""
self._connected = False
if self._read_task:
self._read_task.cancel()
try:
await self._read_task
except asyncio.CancelledError:
pass
self._read_task = None
if self._writer:
try:
self._writer.close()
await self._writer.wait_closed()
except Exception:
pass
self._writer = None
@property
def is_connected(self) -> bool:
return self._connected
# ------------------------------------------------------------------
# Low-level send / receive
# ------------------------------------------------------------------
async def _send_frame(self, payload: bytes):
"""Send a length-prefixed frame."""
length = struct.pack("!I", len(payload))
self._writer.write(length + payload)
await self._writer.drain()
async def _read_loop(self):
"""Background task: read frames and dispatch responses."""
try:
while self._connected and self._reader:
# Read 4-byte length prefix
header = await self._reader.readexactly(4)
length = struct.unpack("!I", header)[0]
if length == 0:
continue
# Read JSON payload
data = await self._reader.readexactly(length)
payload = data.decode("utf-8")
try:
msg = json.loads(payload)
except json.JSONDecodeError:
logger.warning("Invalid JSON from daemon: %s", payload[:200])
continue
# Dispatch
if isinstance(msg, dict):
if "type" in msg and msg["type"] == "push":
# Push message (VU meter updates, state changes)
await self._handle_push(msg)
elif "id" in msg:
# Response to a request
req_id = msg["id"]
future = self._pending.pop(req_id, None)
if future and not future.done():
if msg.get("error"):
future.set_exception(
MixerIpcError(msg["error"])
)
else:
future.set_result(msg.get("result"))
except asyncio.IncompleteReadError:
logger.info("Mixer daemon connection closed")
except asyncio.CancelledError:
pass
except Exception as exc:
logger.warning("Read loop error: %s", exc)
finally:
self._connected = False
# Fail all pending requests
for future in self._pending.values():
if not future.done():
future.set_exception(MixerIpcError("Connection closed"))
self._pending.clear()
async def _send_request(self, method: str, params: dict = None) -> Any:
"""Send a request and wait for the response."""
if not self._connected or not self._writer:
raise MixerIpcError("Not connected to mixer daemon")
req_id = self._next_id
self._next_id += 1
msg = {
"id": req_id,
"method": method,
"params": params or {},
}
future: asyncio.Future = asyncio.get_running_loop().create_future()
self._pending[req_id] = future
try:
payload = json.dumps(msg).encode("utf-8")
await self._send_frame(payload)
result = await asyncio.wait_for(future, timeout=self._request_timeout)
return result
except asyncio.TimeoutError:
self._pending.pop(req_id, None)
raise MixerIpcError(f"Request '{method}' timed out")
async def _handle_push(self, msg: dict):
"""Handle an unsolicited push message from the daemon.
Override in subclass or attach a callback.
"""
event = msg.get("event", "unknown")
data = msg.get("data")
logger.debug("Push event: %s", event)
# Subclasses can override to handle VU meter updates, etc.
# ------------------------------------------------------------------
# High-level API
# ------------------------------------------------------------------
# --- State ---
async def get_state(self) -> dict:
"""Get the full mixer state (channels, buses, routes, output routes)."""
return await self._send_request("getState")
# --- Channel Control ---
async def set_channel_volume(self, channel_index: int, volume_db: float):
"""Set channel volume in dB (-inf to +12)."""
await self._send_request("setChannelVolume", {
"channelIndex": channel_index,
"volumeDb": volume_db,
})
async def set_channel_pan(self, channel_index: int, pan: float):
"""Set channel pan (-1.0 left to +1.0 right)."""
await self._send_request("setChannelPan", {
"channelIndex": channel_index,
"pan": pan,
})
async def set_channel_mute(self, channel_index: int, mute: bool):
"""Set channel mute state."""
await self._send_request("setChannelMute", {
"channelIndex": channel_index,
"mute": mute,
})
async def set_channel_solo(self, channel_index: int, solo: bool):
"""Set channel solo state."""
await self._send_request("setChannelSolo", {
"channelIndex": channel_index,
"solo": solo,
})
# --- Channel Lifecycle ---
async def add_channel(self, physical_input_index: int = 0) -> int:
"""Add a new channel. Returns the channel index."""
result = await self._send_request("addChannel", {
"physicalInputIndex": physical_input_index,
})
return result.get("channelIndex", -1)
async def remove_channel(self, channel_index: int):
"""Remove a channel by index."""
await self._send_request("removeChannel", {
"channelIndex": channel_index,
})
# --- Output Routing ---
async def get_output_routes(self) -> list[dict]:
"""Get the output routing table."""
result = await self._send_request("getOutputRoutes")
return result.get("routes", [])
async def set_output_routes(self, routes: list[dict]):
"""Set the output routing table.
Each route dict:
{"sourceBusId": int, "sourceStartChannel": int,
"targetStartChannel": int, "channels": int}
"""
await self._send_request("setOutputRoutes", {
"routes": routes,
})
# --- Scenes ---
async def save_scene(self, name: str) -> dict:
"""Save the current mixer state as a named scene.
Returns {"id": int, "name": str}."""
return await self._send_request("saveScene", {"name": name})
async def load_scene(self, scene_id: str) -> bool:
"""Load (recall) a scene by its ID. Returns True on success."""
result = await self._send_request("loadScene", {"sceneId": scene_id})
return result.get("ok", False)
async def list_scenes(self) -> list[dict]:
"""List all available scenes."""
result = await self._send_request("listScenes")
return result.get("scenes", [])
async def delete_scene(self, scene_id: str) -> bool:
"""Delete a scene by ID. Returns True on success."""
result = await self._send_request("deleteScene", {"sceneId": scene_id})
return result.get("ok", False)
# ------------------------------------------------------------------
# Convenience context manager
# ------------------------------------------------------------------
class MixerIpcContext:
"""Async context manager for MixerIpcClient."""
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock"):
self.client = MixerIpcClient(socket_path=socket_path)
async def __aenter__(self) -> MixerIpcClient:
await self.client.connect()
return self.client
async def __aexit__(self, *args):
await self.client.close()
+80
View File
@@ -0,0 +1,80 @@
cmake_minimum_required(VERSION 3.16)
project(oplabs-mixer-daemon
VERSION 1.0.0
DESCRIPTION "OPLabs Mixer Engine Daemon"
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Find dependencies
find_package(PkgConfig QUIET)
# --- JACK Audio Connection Kit ---
find_library(JACK_LIBRARY jack)
find_path(JACK_INCLUDE_DIR jack/jack.h)
# --- Source files for the daemon (standalone) ---
set(DAEMON_SOURCES
MixerDaemon.cpp
MixerIpcServer.cpp
)
# Header-only convenience target for linking to op-pedal sources
# The daemon needs MixerEngine, MixerApi, MixerChannelStrip, MixerBus, etc.
# These are compiled as part of the op-pedal CMake project and linked here.
add_executable(oplabs-mixer-daemon ${DAEMON_SOURCES})
target_include_directories(oplabs-mixer-daemon PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
if(JACK_LIBRARY AND JACK_INCLUDE_DIR)
target_include_directories(oplabs-mixer-daemon PRIVATE ${JACK_INCLUDE_DIR})
target_link_libraries(oplabs-mixer-daemon PRIVATE ${JACK_LIBRARY})
message(STATUS "JACK found: ${JACK_LIBRARY}")
target_compile_definitions(oplabs-mixer-daemon PRIVATE HAS_JACK=1)
else()
message(WARNING "JACK not found — daemon will be built without audio I/O. "
"Install libjack-jackd2-dev or jack-audio-connection-kit.")
endif()
# When built inside the op-pedal CMake tree, link against op-pedal's libraries.
# When built standalone, the user must point CMAKE_PREFIX_PATH to an op-pedal build.
# For standalone development, provide a build wrapper:
if(TARGET op-pedal-lib)
# We're inside the op-pedal CMake tree
target_link_libraries(oplabs-mixer-daemon PRIVATE op-pedal-lib)
message(STATUS "Building inside op-pedal tree — linking op-pedal-lib")
else()
message(STATUS "Building standalone — user must provide MixerEngine source")
# In standalone mode, assume the user has checked out the mixer-engine sources
# and set the MIXER_ENGINE_SOURCE_DIR variable.
if(MIXER_ENGINE_SOURCE_DIR)
message(STATUS "Using MixerEngine sources from: ${MIXER_ENGINE_SOURCE_DIR}")
target_include_directories(oplabs-mixer-daemon PRIVATE
${MIXER_ENGINE_SOURCE_DIR}
${MIXER_ENGINE_SOURCE_DIR}/../PiPedalCommon
${MIXER_ENGINE_SOURCE_DIR}/../modules/SQLiteCpp/include
)
endif()
endif()
# Install
install(TARGETS oplabs-mixer-daemon RUNTIME DESTINATION bin)
# Systemd service file
configure_file(
oplabs-mixer-daemon.service.in
oplabs-mixer-daemon.service
@ONLY
)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/oplabs-mixer-daemon.service
DESTINATION /etc/systemd/system
)
+134
View File
@@ -0,0 +1,134 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerDaemon — Standalone OPLabs Mixer Engine daemon.
//
// This daemon:
// 1. Creates a MixerEngine with JACK audio I/O
// 2. Creates a MixerApi for control
// 3. Starts the MixerIpcServer on a Unix domain socket
// 4. Runs the audio loop until SIGINT/SIGTERM
#include <cstdio>
#include <csignal>
#include <atomic>
#include <chrono>
#include <thread>
#include <memory>
#include "MixerIpcServer.hpp"
#include "MixerApi.hpp"
#include "MixerEngine.hpp"
using namespace pipedal;
// Global pointers for signal handler
static MixerIpcServer* g_server = nullptr;
static std::atomic<bool> g_running{true};
void signalHandler(int sig) {
(void)sig;
fprintf(stderr, "\n[MixerDaemon] Signal received, shutting down...\n");
g_running.store(false);
if (g_server) {
g_server->stop();
}
}
void printUsage(const char* prog) {
fprintf(stderr, "Usage: %s [options]\n", prog);
fprintf(stderr, "Options:\n");
fprintf(stderr, " --socket-path PATH Unix socket path (default: /tmp/oplabs-mixer.sock)\n");
fprintf(stderr, " --sample-rate RATE Sample rate in Hz (default: 48000)\n");
fprintf(stderr, " --buffer-size SIZE Buffer size in frames (default: 512)\n");
fprintf(stderr, " --input-channels N Number of input channels (default: 8)\n");
fprintf(stderr, " --output-channels N Number of output channels (default: 8)\n");
fprintf(stderr, " --help Print this help\n");
}
int main(int argc, char* argv[]) {
// Default parameters
std::string socketPath = "/tmp/oplabs-mixer.sock";
uint32_t sampleRate = 48000;
size_t bufferSize = 512;
uint32_t inputChannels = 8;
uint32_t outputChannels = 8;
// Parse command line
for (int i = 1; i < argc; i++) {
std::string arg = argv[i];
if (arg == "--socket-path" && i + 1 < argc) {
socketPath = argv[++i];
} else if (arg == "--sample-rate" && i + 1 < argc) {
sampleRate = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--buffer-size" && i + 1 < argc) {
bufferSize = static_cast<size_t>(std::stoul(argv[++i]));
} else if (arg == "--input-channels" && i + 1 < argc) {
inputChannels = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--output-channels" && i + 1 < argc) {
outputChannels = static_cast<uint32_t>(std::stoul(argv[++i]));
} else if (arg == "--help") {
printUsage(argv[0]);
return 0;
} else {
fprintf(stderr, "Unknown option: %s\n", arg.c_str());
printUsage(argv[0]);
return 1;
}
}
fprintf(stderr, "[MixerDaemon] Starting OPLabs Mixer Daemon\n");
fprintf(stderr, "[MixerDaemon] Socket: %s\n", socketPath.c_str());
fprintf(stderr, "[MixerDaemon] SampleRate: %u Hz\n", sampleRate);
fprintf(stderr, "[MixerDaemon] BufferSize: %zu frames\n", bufferSize);
fprintf(stderr, "[MixerDaemon] Inputs: %u\n", inputChannels);
fprintf(stderr, "[MixerDaemon] Outputs: %u\n", outputChannels);
// Set up signal handling
signal(SIGINT, signalHandler);
signal(SIGTERM, signalHandler);
// Create MixerEngine
auto engine = std::make_unique<MixerEngine>();
engine->setSampleRate(sampleRate);
engine->setMaxBufferSize(bufferSize);
// Auto-create channels
engine->autoCreateChannels(inputChannels);
fprintf(stderr, "[MixerDaemon] MixerEngine created with %zu channels\n",
engine->channelCount());
// Create MixerApi and attach engine
auto api = std::make_unique<MixerApi>();
api->setMixerEngine(engine.get());
// Create IPC server
auto server = std::make_unique<MixerIpcServer>();
server->setMixerApi(api.get());
server->setMixerEngine(engine.get());
g_server = server.get();
if (!server->start(socketPath)) {
fprintf(stderr, "[MixerDaemon] Failed to start IPC server on %s\n",
socketPath.c_str());
return 1;
}
fprintf(stderr, "[MixerDaemon] IPC server listening on %s\n", socketPath.c_str());
fprintf(stderr, "[MixerDaemon] Ready. Waiting for connections...\n");
// Main loop — keep running until signal
// In a full implementation, this would run the JACK audio processing loop.
// For now, the daemon provides IPC access to the MixerEngine.
while (g_running.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// Cleanup
server->stop();
api->setMixerEngine(nullptr);
fprintf(stderr, "[MixerDaemon] Shutdown complete.\n");
return 0;
}
+753
View File
@@ -0,0 +1,753 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerIpcServer — Unix domain socket IPC server implementation.
#include "MixerIpcServer.hpp"
#include "MixerApi.hpp"
#include "MixerEngine.hpp"
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <fcntl.h>
#include <cstring>
#include <cstdio>
#include <cerrno>
#include <sstream>
#include <algorithm>
#include <arpa/inet.h> // for htonl/ntohl
// JSON writer/reader (inline minimal implementation to avoid heavy dependency)
// In production, use a proper JSON library like nlohmann/json or boost.json.
// Here we use the PiPedal project's own json_writer/json_reader if available,
// but since this is a standalone daemon, we provide a minimal inline version.
namespace {
// Write a JSON string with proper escaping
std::string jsonEscape(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buf[8];
snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
} else {
out += c;
}
}
}
return out;
}
// Simple JSON object builder
class JsonBuilder {
public:
JsonBuilder() : ss_() { ss_ << "{"; first_ = true; }
void add(const std::string& key, const std::string& value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":\"" << jsonEscape(value) << "\"";
}
void add(const std::string& key, const char* value) {
add(key, std::string(value));
}
void add(const std::string& key, int64_t value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << value;
}
void add(const std::string& key, double value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << value;
}
void add(const std::string& key, bool value) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << (value ? "true" : "false");
}
void addRaw(const std::string& key, const std::string& rawJson) {
if (!first_) ss_ << ",";
first_ = false;
ss_ << "\"" << jsonEscape(key) << "\":" << rawJson;
}
std::string build() { ss_ << "}"; return ss_.str(); }
private:
std::stringstream ss_;
bool first_ = true;
};
// Simple JSON key-value extractor (does not handle nested objects)
// Just enough for our params extraction
class SimpleJsonParser {
public:
SimpleJsonParser(const std::string& json) : data_(json), pos_(0) {
skipWhitespace();
}
bool enterObject() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '{') {
pos_++;
skipWhitespace();
return true;
}
return false;
}
bool enterArray() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '[') {
pos_++;
skipWhitespace();
return true;
}
return false;
}
void leaveObject() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == '}') pos_++;
}
void leaveArray() {
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == ']') pos_++;
}
std::string readKey() {
skipWhitespace();
std::string key = readString();
skipWhitespace();
if (pos_ < data_.size() && data_[pos_] == ':') pos_++;
return key;
}
std::string readString() {
skipWhitespace();
if (pos_ >= data_.size() || data_[pos_] != '"') return "";
pos_++; // skip opening quote
std::string result;
while (pos_ < data_.size() && data_[pos_] != '"') {
if (data_[pos_] == '\\' && pos_ + 1 < data_.size()) {
pos_++;
switch (data_[pos_]) {
case '"': result += '"'; break;
case '\\': result += '\\'; break;
case '/': result += '/'; break;
case 'n': result += '\n'; break;
case 'r': result += '\r'; break;
case 't': result += '\t'; break;
default: result += data_[pos_]; break;
}
} else {
result += data_[pos_];
}
pos_++;
}
if (pos_ < data_.size() && data_[pos_] == '"') pos_++; // skip closing quote
return result;
}
int64_t readInt() {
skipWhitespace();
char* end = nullptr;
int64_t val = strtoll(data_.c_str() + pos_, &end, 10);
if (end) pos_ = end - data_.c_str();
return val;
}
double readDouble() {
skipWhitespace();
char* end = nullptr;
double val = strtod(data_.c_str() + pos_, &end);
if (end) pos_ = end - data_.c_str();
return val;
}
bool readBool() {
skipWhitespace();
if (data_.substr(pos_, 4) == "true") { pos_ += 4; return true; }
if (data_.substr(pos_, 5) == "false") { pos_ += 5; return false; }
return false;
}
std::string readRaw() {
// Read a raw JSON value (for nested objects/arrays we want to preserve)
skipWhitespace();
if (pos_ >= data_.size()) return "";
char start = data_[pos_];
if (start == '{' || start == '[') {
// Find matching close brace/bracket
char close = (start == '{') ? '}' : ']';
int depth = 0;
size_t startPos = pos_;
bool inString = false;
while (pos_ < data_.size()) {
char c = data_[pos_];
if (c == '"' && (pos_ == 0 || data_[pos_-1] != '\\')) inString = !inString;
if (!inString) {
if (c == start) depth++;
if (c == close) {
depth--;
if (depth == 0) {
pos_++;
return data_.substr(startPos, pos_ - startPos);
}
}
}
pos_++;
}
pos_ = startPos; // reset on failure
}
return "";
}
// Skip a single value (for keys we don't care about)
void skipValue() {
skipWhitespace();
if (pos_ >= data_.size()) return;
char c = data_[pos_];
if (c == '"') { readString(); }
else if (c == '{' || c == '[') { readRaw(); }
else if (c == 't' || c == 'f') { readBool(); }
else { readDouble(); } // number (also handles int)
skipWhitespace();
if (pos_ < data_.size() && (data_[pos_] == ',' || data_[pos_] == '}')) {
if (data_[pos_] == ',') pos_++;
}
}
// Check if we've reached the end of the current object
bool atEnd() {
skipWhitespace();
return pos_ >= data_.size() || data_[pos_] == '}';
}
// Parse a string value for a given key from a flat JSON object
std::string getString(const std::string& key) {
if (!enterObject()) return "";
while (!atEnd()) {
std::string k = readKey();
if (k == key) { std::string v = readString(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return "";
}
int64_t getInt(const std::string& key, int64_t def = 0) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { int64_t v = readInt(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
double getDouble(const std::string& key, double def = 0.0) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { double v = readDouble(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
bool getBool(const std::string& key, bool def = false) {
if (!enterObject()) return def;
while (!atEnd()) {
std::string k = readKey();
if (k == key) { bool v = readBool(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return def;
}
std::string getRaw(const std::string& key) {
if (!enterObject()) return "";
while (!atEnd()) {
std::string k = readKey();
if (k == key) { std::string v = readRaw(); leaveObject(); return v; }
else { skipValue(); }
}
leaveObject();
return "";
}
private:
const std::string& data_;
size_t pos_;
void skipWhitespace() {
while (pos_ < data_.size() && (data_[pos_] == ' ' || data_[pos_] == '\t' ||
data_[pos_] == '\n' || data_[pos_] == '\r'))
pos_++;
}
};
} // anonymous namespace
using namespace pipedal;
// ---------------------------------------------------------------------------
// Construction / Destruction
// ---------------------------------------------------------------------------
MixerIpcServer::MixerIpcServer() {}
MixerIpcServer::~MixerIpcServer() { stop(); }
// ---------------------------------------------------------------------------
// Start / Stop
// ---------------------------------------------------------------------------
bool MixerIpcServer::start(const std::string& socketPath) {
if (running_.load()) {
return true; // already running
}
socketPath_ = socketPath;
// Remove existing socket file if present
unlink(socketPath_.c_str());
// Create socket
serverFd_ = socket(AF_UNIX, SOCK_STREAM, 0);
if (serverFd_ < 0) {
perror("socket");
return false;
}
// Bind
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, socketPath_.c_str(), sizeof(addr.sun_path) - 1);
if (bind(serverFd_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
close(serverFd_);
serverFd_ = -1;
return false;
}
// Set permissions so the FastAPI process (running as same user) can connect
chmod(socketPath_.c_str(), 0666);
// Listen
if (listen(serverFd_, 5) < 0) {
perror("listen");
close(serverFd_);
serverFd_ = -1;
unlink(socketPath_.c_str());
return false;
}
running_.store(true);
// Start accept thread
acceptThread_ = std::thread(&MixerIpcServer::acceptLoop, this);
return true;
}
void MixerIpcServer::stop() {
running_.store(false);
// Close all client connections
for (auto& [id, conn] : clients_) {
if (conn->fd >= 0) {
close(conn->fd);
conn->fd = -1;
}
}
clients_.clear();
// Close server socket
if (serverFd_ >= 0) {
close(serverFd_);
serverFd_ = -1;
}
// Remove socket file
if (!socketPath_.empty()) {
unlink(socketPath_.c_str());
}
// Join accept thread
if (acceptThread_.joinable()) {
acceptThread_.join();
}
}
// ---------------------------------------------------------------------------
// Communication
// ---------------------------------------------------------------------------
bool MixerIpcServer::sendFrame(int fd, const std::string& jsonPayload) {
if (fd < 0) return false;
uint32_t len = static_cast<uint32_t>(jsonPayload.size());
uint32_t lenBe = htonl(len);
// Write length prefix
ssize_t written = write(fd, &lenBe, sizeof(lenBe));
if (written != sizeof(lenBe)) return false;
// Write payload
written = write(fd, jsonPayload.data(), jsonPayload.size());
if (written != static_cast<ssize_t>(jsonPayload.size())) return false;
return true;
}
bool MixerIpcServer::sendResponse(int clientFd, const std::string& jsonResponse) {
return sendFrame(clientFd, jsonResponse);
}
void MixerIpcServer::broadcast(const std::string& jsonMessage) {
auto fds = connectedClientFds();
for (int fd : fds) {
if (fd >= 0) {
sendFrame(fd, jsonMessage);
}
}
}
std::vector<int> MixerIpcServer::connectedClientFds() const {
std::vector<int> fds;
for (const auto& [id, conn] : clients_) {
if (conn->active && conn->fd >= 0) {
fds.push_back(conn->fd);
}
}
return fds;
}
// ---------------------------------------------------------------------------
// Accept loop
// ---------------------------------------------------------------------------
void MixerIpcServer::acceptLoop() {
while (running_.load()) {
struct sockaddr_un clientAddr;
socklen_t clientLen = sizeof(clientAddr);
int clientFd = accept(serverFd_, (struct sockaddr*)&clientAddr, &clientLen);
if (clientFd < 0) {
if (errno == EINTR) continue;
if (!running_.load()) break;
perror("accept");
continue;
}
// Create client connection
auto conn = std::make_unique<ClientConnection>();
conn->fd = clientFd;
conn->active = true;
int clientId = nextClientId_++;
clients_[clientId] = std::move(conn);
// Handle this client (blocking per-client)
// In a real implementation, this would be in a separate thread or use event loop.
// For this embedded version, we handle one client at a time per thread.
handleClient(clientId);
}
}
// ---------------------------------------------------------------------------
// Client handler
// ---------------------------------------------------------------------------
void MixerIpcServer::handleClient(int clientId) {
auto it = clients_.find(clientId);
if (it == clients_.end()) return;
auto& conn = it->second;
while (running_.load() && conn->active) {
if (!readFromClient(*conn)) {
break;
}
}
// Cleanup
if (conn->fd >= 0) {
close(conn->fd);
conn->fd = -1;
}
conn->active = false;
clients_.erase(clientId);
}
bool MixerIpcServer::readFromClient(ClientConnection& conn) {
uint8_t buffer[4096];
ssize_t n = read(conn.fd, buffer, sizeof(buffer));
if (n <= 0) {
if (n == 0) {
// Connection closed
return false;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// No data available yet — yield and retry on next loop iteration
// In a proper impl, use poll()/epoll()
return true;
}
return false;
}
// Append to read buffer
conn.readBuffer.insert(conn.readBuffer.end(), buffer, buffer + n);
// Process complete frames
while (true) {
if (conn.readingHeader) {
if (conn.readBuffer.size() < 4) break;
// Read length prefix (big-endian uint32)
uint32_t lenBe;
memcpy(&lenBe, conn.readBuffer.data(), 4);
conn.expectedLength = ntohl(lenBe);
// Remove header from buffer
conn.readBuffer.erase(conn.readBuffer.begin(), conn.readBuffer.begin() + 4);
conn.readingHeader = false;
}
if (conn.readBuffer.size() < conn.expectedLength) break;
// We have a complete message
std::string jsonMessage(conn.readBuffer.begin(),
conn.readBuffer.begin() + conn.expectedLength);
conn.readBuffer.erase(conn.readBuffer.begin(),
conn.readBuffer.begin() + conn.expectedLength);
conn.readingHeader = true;
conn.expectedLength = 0;
// Process the message
processMessage(conn, jsonMessage);
}
return true;
}
// ---------------------------------------------------------------------------
// Message processing
// ---------------------------------------------------------------------------
void MixerIpcServer::processMessage(ClientConnection& conn, const std::string& jsonMessage) {
if (messageCallback_) {
messageCallback_(jsonMessage);
}
// Parse the JSON message
SimpleJsonParser parser(jsonMessage);
// Extract id, method, params
int64_t msgId = 0;
std::string method;
std::string params;
if (!parser.enterObject()) {
// Invalid message
JsonBuilder err;
err.add("id", 0);
err.add("error", std::string("Invalid JSON: expected object"));
sendResponse(conn.fd, err.build());
return;
}
while (!parser.atEnd()) {
std::string key = parser.readKey();
if (key == "id") {
msgId = parser.readInt();
} else if (key == "method") {
method = parser.readString();
} else if (key == "params") {
// Read raw params value (could be object, array, etc.)
params = parser.readRaw();
} else {
parser.skipValue();
}
}
parser.leaveObject();
if (method.empty()) {
JsonBuilder err;
err.add("id", msgId);
err.add("error", std::string("Missing 'method' field"));
sendResponse(conn.fd, err.build());
return;
}
// Dispatch and get response
std::string resultJson = dispatchMethod(method, params);
// Wrap with response id
std::stringstream ss;
ss << "{\"id\":" << msgId << ",";
// The resultJson starts with {"result": ...} or {"error": ...}
// Strip the outer { and add our id
if (!resultJson.empty() && resultJson[0] == '{') {
ss << resultJson.substr(1); // skip leading {
} else {
ss << "\"result\":" << resultJson << ",\"error\":null";
}
sendResponse(conn.fd, ss.str());
}
// ---------------------------------------------------------------------------
// Method dispatch
// ---------------------------------------------------------------------------
std::string MixerIpcServer::dispatchMethod(const std::string& method, const std::string& paramsJson) {
if (!mixerApi_) {
return "{\"error\":\"no mixer api configured\"}";
}
try {
if (method == "getState") {
std::string state = mixerApi_->getStateJson();
return "{\"result\":" + state + ",\"error\":null}";
}
else if (method == "setChannelVolume") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
double vol = p.getDouble("volumeDb", -96.0);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelVolume(ci, (float)vol);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelPan") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
double pan = p.getDouble("pan", 0.0);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelPan(ci, (float)pan);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelMute") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
bool mute = p.getBool("mute", false);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelMute(ci, mute);
return "{\"result\":{},\"error\":null}";
}
else if (method == "setChannelSolo") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
bool solo = p.getBool("solo", false);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->setChannelSolo(ci, solo);
return "{\"result\":{},\"error\":null}";
}
else if (method == "addChannel") {
SimpleJsonParser p(paramsJson);
int pi = (int)p.getInt("physicalInputIndex", 0);
int idx = mixerApi_->addChannel(pi);
JsonBuilder resp;
resp.add("channelIndex", (int64_t)idx);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else if (method == "removeChannel") {
SimpleJsonParser p(paramsJson);
int ci = (int)p.getInt("channelIndex", -1);
if (ci < 0) return "{\"error\":\"missing channelIndex\"}";
mixerApi_->removeChannel(ci);
return "{\"result\":{},\"error\":null}";
}
else if (method == "getOutputRoutes") {
std::string routes = mixerApi_->getOutputRoutesJson();
return "{\"result\":{\"routes\":" + routes + "},\"error\":null}";
}
else if (method == "setOutputRoutes") {
// params is the raw JSON for output routes array
// We need the routes array from params
SimpleJsonParser p(paramsJson);
std::string routesArray = p.getRaw("routes");
if (!routesArray.empty()) {
mixerApi_->setOutputRoutesFromJson(routesArray);
}
return "{\"result\":{},\"error\":null}";
}
else if (method == "saveScene") {
SimpleJsonParser p(paramsJson);
std::string name = p.getString("name");
if (name.empty()) return "{\"error\":\"missing name\"}";
std::string result = mixerApi_->saveScene(name);
// result from MixerApi is already JSON: {"id": N, "name": "..."}
return "{\"result\":" + result + ",\"error\":null}";
}
else if (method == "loadScene") {
SimpleJsonParser p(paramsJson);
std::string sceneId = p.getString("sceneId");
if (sceneId.empty()) {
// Try as integer
int64_t id = p.getInt("sceneId", -1);
if (id < 0) return "{\"error\":\"missing sceneId\"}";
sceneId = std::to_string(id);
}
bool ok = mixerApi_->loadScene(sceneId);
JsonBuilder resp;
resp.add("ok", ok);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else if (method == "listScenes") {
std::string scenes = mixerApi_->listScenes();
if (scenes.empty()) scenes = "[]";
return "{\"result\":{\"scenes\":" + scenes + "},\"error\":null}";
}
else if (method == "deleteScene") {
SimpleJsonParser p(paramsJson);
std::string sceneId = p.getString("sceneId");
if (sceneId.empty()) {
int64_t id = p.getInt("sceneId", -1);
if (id < 0) return "{\"error\":\"missing sceneId\"}";
sceneId = std::to_string(id);
}
bool ok = mixerApi_->deleteScene(sceneId);
JsonBuilder resp;
resp.add("ok", ok);
return "{\"result\":" + resp.build() + ",\"error\":null}";
}
else {
return "{\"error\":\"unknown method: " + jsonEscape(method) + "\"}";
}
}
catch (const std::exception& e) {
return "{\"error\":\"" + jsonEscape(e.what()) + "\"}";
}
}
+128
View File
@@ -0,0 +1,128 @@
// Copyright (c) 2026 Ourpad Network
// See LICENSE file in the project root for full license text.
//
// MixerIpcServer — Unix domain socket IPC server for the OPLabs Mixer Daemon.
//
// Protocol:
// - Transport: Unix domain socket (AF_UNIX, SOCK_STREAM)
// - Socket path: /tmp/oplabs-mixer.sock
// - Framing: 4-byte big-endian length prefix (uint32_t) + JSON payload (UTF-8)
//
// Request: {"id": <int>, "method": <string>, "params": {<object>}}
// Response: {"id": <int>, "result": <any>, "error": <string|null>}
// Push: {"type": "push", "event": <string>, "data": {<object>}}
//
// Methods:
// getState params: {} result: {full mixer state}
// setChannelVolume params: {channelIndex, volumeDb} result: {}
// setChannelPan params: {channelIndex, pan} result: {}
// setChannelMute params: {channelIndex, mute} result: {}
// setChannelSolo params: {channelIndex, solo} result: {}
// addChannel params: {physicalInputIndex} result: {channelIndex}
// removeChannel params: {channelIndex} result: {}
// getOutputRoutes params: {} result: {routes: [...]}
// setOutputRoutes params: {routes: [...]} result: {}
// saveScene params: {name} result: {id, name}
// loadScene params: {sceneId} result: {ok: bool}
// listScenes params: {} result: {scenes: [...]}
// deleteScene params: {sceneId} result: {ok: bool}
#pragma once
#include <string>
#include <functional>
#include <atomic>
#include <thread>
#include <vector>
#include <map>
namespace pipedal {
class MixerApi;
class MixerEngine;
/// Unix domain socket IPC server that dispatches JSON messages to MixerApi.
class MixerIpcServer {
public:
/// Callback signature: clientConnected, clientDisconnected
using ConnectionCallback = std::function<void()>;
using MessageCallback = std::function<void(const std::string&)>;
MixerIpcServer();
~MixerIpcServer();
// Disable copy
MixerIpcServer(const MixerIpcServer&) = delete;
MixerIpcServer& operator=(const MixerIpcServer&) = delete;
/// Set the MixerApi instance to dispatch to.
void setMixerApi(MixerApi* api) { mixerApi_ = api; }
/// Set the MixerEngine instance directly (for push messages / VU).
void setMixerEngine(MixerEngine* engine) { mixerEngine_ = engine; }
/// Start the IPC server on the given socket path.
/// Returns true on success.
bool start(const std::string& socketPath = "/tmp/oplabs-mixer.sock");
/// Stop the IPC server and close all connections.
void stop();
/// Check if the server is running.
bool isRunning() const { return running_.load(); }
/// Broadcast a push message to all connected clients.
void broadcast(const std::string& jsonMessage);
/// Set callback for incoming messages (for logging/monitoring).
void setMessageCallback(MessageCallback cb) { messageCallback_ = std::move(cb); }
private:
MixerApi* mixerApi_ = nullptr;
MixerEngine* mixerEngine_ = nullptr;
std::atomic<bool> running_{false};
int serverFd_ = -1;
std::string socketPath_;
std::thread acceptThread_;
// Per-client state
struct ClientConnection {
int fd = -1;
bool active = false;
std::vector<uint8_t> readBuffer;
uint32_t expectedLength = 0;
bool readingHeader = true;
};
std::map<int, std::unique_ptr<ClientConnection>> clients_;
int nextClientId_ = 1;
MessageCallback messageCallback_;
// Accept loop (runs in its own thread)
void acceptLoop();
// Handle a single client connection
void handleClient(int clientFd);
// Read and accumulate data from a client
bool readFromClient(ClientConnection& conn);
// Process a complete JSON message
void processMessage(ClientConnection& conn, const std::string& jsonMessage);
// Send a JSON response to a specific client
bool sendResponse(int clientFd, const std::string& jsonResponse);
// Convenience: send a length-prefixed frame
bool sendFrame(int fd, const std::string& jsonPayload);
// Dispatch a method call to MixerApi and return JSON result
std::string dispatchMethod(const std::string& method, const std::string& paramsJson);
// All connected client file descriptors
std::vector<int> connectedClientFds() const;
};
} // namespace pipedal
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=OPLabs Mixer Daemon
Documentation=https://git.ourpad.casa/oplabs/oplabs-mixer-app
After=network.target sound.target
Wants=jack.service
[Service]
Type=simple
User=oplabs
ExecStart=/usr/bin/oplabs-mixer-daemon \
--socket-path /tmp/oplabs-mixer.sock \
--sample-rate 48000 \
--buffer-size 512 \
--input-channels 8 \
--output-channels 8
Restart=on-failure
RestartSec=5
LimitNOFILE=8192
Nice=-10
# JACK-specific: wait for JACK to be ready
ExecStartPre=/bin/sh -c 'while ! jack_wait -c 2>/dev/null; do sleep 1; done'
[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-DDgWDHTC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-58I2yFEM.css">
<script type="module" crossorigin src="/assets/index-BixTWz89.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BwT15WG7.css">
</head>
<body>
<div id="root"></div>
@@ -180,3 +180,33 @@
width: 8px;
height: 10px;
}
/* ---- Routing Selector ---- */
.bus-channel-route {
width: 100%;
padding: 1px 2px;
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.5rem;
font-weight: 600;
cursor: pointer;
outline: none;
text-align: center;
text-align-last: center;
-moz-text-align-last: center;
min-height: 16px;
max-height: 18px;
}
.bus-channel-route:focus {
border-color: var(--accent, #e94560);
}
.bus-channel-route option {
background: var(--bg, #1a1a2e);
color: var(--text, #eaeaea);
font-size: 0.55rem;
}
+40 -3
View File
@@ -10,6 +10,18 @@ interface Props {
onSetControl: (instanceId: number, key: string, value: number) => void;
}
/** Available routing targets for bus channels */
const ROUTE_TARGETS = [
{ value: 1, label: 'Main' },
{ value: 2, label: 'Bus 2' },
{ value: 3, label: 'Bus 3' },
{ value: 4, label: 'Bus 4' },
{ value: 5, label: 'Bus 5' },
{ value: 6, label: 'Bus 6' },
{ value: 7, label: 'Bus 7' },
{ value: 8, label: 'Bus 8' },
];
/**
* Converts a dB value to a mini-fader position (0-100).
*/
@@ -23,10 +35,12 @@ function sliderPosToDb(pos: number): number {
}
/**
* Bus meter bridge: per-channel mini faders and mute buttons + master section.
* Bus meter bridge: per-channel mini faders, mute buttons, routing selector,
* and master section.
*
* The OPLabsBandBus plugin has 62 ports:
* The OPLabsBandBus plugin has 70 ports:
* - ch1Vol..ch8Vol, ch1Pan..ch8Pan, ch1Mute..ch8Mute
* - ch1Route..ch8Route (routing: 1=Main, 2-8=sub-buses)
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
* - masterVol, masterMute, masterLevelL, masterLevelR
*/
@@ -44,10 +58,12 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
index: idx,
vol: cv[`ch${idx}Vol`] ?? 0,
mute: (cv[`ch${idx}Mute`] ?? 0) >= 0.5,
route: Math.round(cv[`ch${idx}Route`] ?? 1) as 1|2|3|4|5|6|7|8,
levelL: cv[`ch${idx}LevelL`] ?? -60,
levelR: cv[`ch${idx}LevelR`] ?? -60,
volKey: `ch${idx}Vol` as const,
muteKey: `ch${idx}Mute` as const,
routeKey: `ch${idx}Route` as const,
};
});
@@ -78,6 +94,14 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
[plugin.instanceId, onSetControl]
);
const handleChannelRoute = useCallback(
(chIdx: number, routeKey: string, e: React.ChangeEvent<HTMLSelectElement>) => {
const val = parseInt(e.target.value, 10);
onSetControl(plugin.instanceId, routeKey, val);
},
[plugin.instanceId, onSetControl]
);
return (
<div className="bus-strip">
{/* Master section */}
@@ -122,7 +146,7 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
{channels.map((ch) => (
<div key={ch.index} className="bus-channel">
<span className="bus-channel-label">
{ch.index}
Ch {ch.index}
</span>
<button
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
@@ -131,6 +155,19 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
>
M
</button>
{/* Routing selector */}
<select
className="bus-channel-route"
value={ch.route}
onChange={(e) => handleChannelRoute(ch.index, ch.routeKey, e)}
title={`Ch ${ch.index} route`}
>
{ROUTE_TARGETS.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<div className="bus-channel-meter">
<LevelMeter
levelL={ch.levelL}
@@ -1,134 +1,327 @@
import React, { useCallback } from 'react';
import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { Instrument } from './InstrumentBadge';
import { InstrumentBadge, INSTRUMENT_NAMES, INSTRUMENT_COLORS } from './InstrumentBadge';
/**
* 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)
* - Mute / Solo buttons
* - Channel type selector (Instrument / Mic / Line / AuxReturn)
* - High-pass filter toggle + frequency slider
* - Stereo level meter (VU)
* - Editable label
*
* Designed as a controlled component: all state is passed in via props;
* change callbacks flow upward. No built-in WebSocket or IPC coupling.
*/
import React, { useCallback, useRef, useState } from 'react';
import { LevelMeter } from './LevelMeter';
import './ChannelStrip.css';
interface Props {
plugin: PluginInstance;
/** Callback to set a control value on the plugin */
onSetControl: (instanceId: number, key: string, value: number) => void;
// ── Types ──────────────────────────────────────────────────────────
/** Channel type matching the daemon's MixerChannelType enum. */
export type ChannelType = 'instrument' | 'mic' | 'line' | 'auxReturn';
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
instrument: 'Instrument',
mic: 'Mic',
line: 'Line',
auxReturn: 'Aux Return',
};
export const CHANNEL_TYPE_COLORS: Record<ChannelType, string> = {
instrument: '#ff8c00', // orange
mic: '#e91e63', // pink
line: '#2196f3', // blue
auxReturn: '#9c27b0', // purple
};
export const CHANNEL_TYPE_ICONS: Record<ChannelType, string> = {
instrument: '🎸',
mic: '🎤',
line: '🔌',
auxReturn: '📥',
};
export const CHANNEL_TYPES: ChannelType[] = ['instrument', 'mic', 'line', 'auxReturn'];
export interface ChannelStripProps {
/** Unique mixer-engine instance id. */
instanceId: number;
/** 0-based channel index (displayed as N+1). */
channelIndex: number;
/** User-assignable label shown at the top of the strip. */
label: string;
/** Volume in dB (-96 to +12). */
volume: number;
/** Pan: -1.0 (L) to +1.0 (R). 0.0 = centre. */
pan: number;
/** Mute state. */
mute: boolean;
/** Solo state. */
solo: boolean;
/** Channel type classification (controls accent colour). */
channelType: ChannelType;
/** High-pass filter enabled. */
hpEnabled: boolean;
/** High-pass filter corner frequency in Hz (20400). */
hpFrequency: number;
/** Left-channel VU level in dB (-96 to 0). */
levelL: number;
/** Right-channel VU level in dB (-96 to 0). */
levelR: number;
// ── Callbacks ─────────────────────────────────────────────────
onChangeVolume: (instanceId: number, db: number) => void;
onChangePan: (instanceId: number, pan: number) => void;
onToggleMute: (instanceId: number, mute: boolean) => void;
onToggleSolo: (instanceId: number, solo: boolean) => void;
onChangeChannelType: (instanceId: number, type: ChannelType) => void;
onToggleHp: (instanceId: number, enabled: boolean) => void;
onChangeHpFrequency: (instanceId: number, freq: number) => void;
onChangeLabel: (instanceId: number, label: string) => void;
}
const INSTRUMENT_VALUES: Instrument[] = [0, 1, 2, 3, 4];
// ── dB ↔ Slider position (logarithmic) ────────────────────────────
/**
* Map a dB slider position (0-1) to a dB value (-60..+6).
* 75% position = 0 dB (unity).
* 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 >= 1) return 6;
// Logarithmic taper: 0.75 = 0 dB
const scaled = pos / 0.75;
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 a dB value (-60..+6) to a slider position (0-1).
* Map dB value (-60..+6) to slider position (0100).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 1;
// Inverse of posToDb
if (db >= 6) return 100;
const linear = Math.pow(10, db / 20);
return Math.min(1, linear * 0.75);
return Math.min(100, linear * 0.75 * 100);
}
/**
* Full channel strip with fader, pan, mute/solo, instrument selector, level meter.
* Format dB for display.
*/
export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
const cv = plugin.controlValues;
function formatDb(db: number): string {
if (db <= -60) return '-∞';
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
}
const volume = cv.volume ?? 0;
const pan = cv.pan ?? 0;
const mute = (cv.mute ?? 0) >= 0.5;
const solo = (cv.solo ?? 0) >= 0.5;
const instrument = (Math.round(cv.instrument ?? 0) || 0) as Instrument;
const levelL = cv.levelL ?? -60;
const levelR = cv.levelR ?? -60;
/**
* Format pan for display.
*/
function formatPan(pan: number): string {
if (pan === 0) return 'C';
if (pan < 0) return `L${Math.abs(Math.round(pan * 100))}`;
return `R${Math.round(pan * 100)}`;
}
const instrumentColor = INSTRUMENT_COLORS[instrument] ?? '#666';
// ── Component ──────────────────────────────────────────────────────
const handleVolumeChange = useCallback(
const ChannelStrip: React.FC<ChannelStripProps> = ({
instanceId,
channelIndex,
label,
volume,
pan,
mute,
solo,
channelType,
hpEnabled,
hpFrequency,
levelL,
levelR,
onChangeVolume,
onChangePan,
onToggleMute,
onToggleSolo,
onChangeChannelType,
onToggleHp,
onChangeHpFrequency,
onChangeLabel,
}) => {
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 pos = parseFloat(e.target.value) / 100;
const db = posToDb(pos);
onSetControl(plugin.instanceId, 'volume', parseFloat(db.toFixed(1)));
const db = posToDb(parseFloat(e.target.value));
onChangeVolume(instanceId, parseFloat(db.toFixed(1)));
},
[plugin.instanceId, onSetControl]
[instanceId, onChangeVolume],
);
const handlePanChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseFloat(e.target.value);
onSetControl(plugin.instanceId, 'pan', val);
onChangePan(instanceId, parseFloat(e.target.value));
},
[plugin.instanceId, onSetControl]
[instanceId, onChangePan],
);
const toggleMute = useCallback(() => {
onSetControl(plugin.instanceId, 'mute', mute ? 0 : 1);
}, [plugin.instanceId, mute, onSetControl]);
const handleMuteClick = useCallback(() => {
onToggleMute(instanceId, !mute);
}, [instanceId, mute, onToggleMute]);
const toggleSolo = useCallback(() => {
onSetControl(plugin.instanceId, 'solo', solo ? 0 : 1);
}, [plugin.instanceId, solo, onSetControl]);
const handleSoloClick = useCallback(() => {
onToggleSolo(instanceId, !solo);
}, [instanceId, solo, onToggleSolo]);
const handleInstrumentChange = useCallback(
const handleTypeChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const val = parseInt(e.target.value, 10);
onSetControl(plugin.instanceId, 'instrument', val);
onChangeChannelType(instanceId, e.target.value as ChannelType);
},
[plugin.instanceId, onSetControl]
[instanceId, onChangeChannelType],
);
const volumeSliderPos = dbToPos(volume) * 100;
const handleHpToggle = useCallback(() => {
onToggleHp(instanceId, !hpEnabled);
}, [instanceId, hpEnabled, onToggleHp]);
const handleHpFreqChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChangeHpFrequency(instanceId, parseFloat(e.target.value));
},
[instanceId, onChangeHpFrequency],
);
const handleLabelClick = useCallback(() => {
setLabelDraft(label);
setEditingLabel(true);
// Focus input on next tick
requestAnimationFrame(() => labelInputRef.current?.focus());
}, [label]);
const handleLabelSubmit = useCallback(() => {
const trimmed = labelDraft.trim();
if (trimmed && trimmed !== label) {
onChangeLabel(instanceId, trimmed);
}
setEditingLabel(false);
}, [labelDraft, label, instanceId, onChangeLabel]);
const handleLabelKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleLabelSubmit();
} else if (e.key === 'Escape') {
setEditingLabel(false);
}
},
[handleLabelSubmit],
);
// Format HPF frequency for display
const hzLabel = hpFrequency >= 100
? `${Math.round(hpFrequency)} Hz`
: `${hpFrequency.toFixed(0)} Hz`;
return (
<div
className="channel-strip"
style={{
borderTopColor: instrumentColor,
'--channel-accent': instrumentColor,
} as React.CSSProperties}
style={{ '--channel-accent': accentColor } as React.CSSProperties}
>
{/* Header: channel label + instrument */}
{/* ── Header: editable label + type badge ── */}
<div className="channel-header">
<span className="channel-label" title={plugin.title || plugin.pluginName}>
{plugin.title || plugin.pluginName || `Ch ${plugin.instanceId}`}
{editingLabel ? (
<input
ref={labelInputRef}
className="channel-label-input"
type="text"
value={labelDraft}
onChange={(e) => setLabelDraft(e.target.value)}
onBlur={handleLabelSubmit}
onKeyDown={handleLabelKeyDown}
maxLength={32}
/>
) : (
<span
className="channel-label"
onClick={handleLabelClick}
title="Click to rename"
>
{label || `Ch ${channelIndex + 1}`}
</span>
)}
<span
className="channel-type-badge"
style={{
backgroundColor: `${accentColor}22`,
color: accentColor,
borderColor: accentColor,
}}
title={CHANNEL_TYPE_LABELS[channelType]}
>
<span className="channel-type-icon">{CHANNEL_TYPE_ICONS[channelType]}</span>
<span className="channel-type-name">{CHANNEL_TYPE_LABELS[channelType]}</span>
</span>
<InstrumentBadge instrument={instrument} size="small" />
</div>
{/* Mute & Solo buttons */}
{/* ── HPF Section ── */}
<div className="channel-hpf-section">
<button
className={`channel-hpf-toggle ${hpEnabled ? 'active' : ''}`}
onClick={handleHpToggle}
title={`High-pass filter: ${hpEnabled ? 'ON' : 'OFF'}`}
>
HPF
</button>
{hpEnabled && (
<div className="channel-hpf-slider-group">
<input
type="range"
min="20"
max="400"
step="1"
value={hpFrequency}
onChange={handleHpFreqChange}
className="channel-hpf-slider"
title={`HPF frequency: ${hzLabel}`}
/>
<span className="channel-hpf-readout">{hzLabel}</span>
</div>
)}
</div>
{/* ── Level Meter ── */}
<div className="channel-meter-section">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* ── Mute & Solo ── */}
<div className="channel-mute-solo">
<button
className={`channel-btn mute ${mute ? 'active' : ''}`}
onClick={toggleMute}
title="Mute"
onClick={handleMuteClick}
title={mute ? 'Unmute' : 'Mute'}
>
M
</button>
<button
className={`channel-btn solo ${solo ? 'active' : ''}`}
onClick={toggleSolo}
title="Solo"
onClick={handleSoloClick}
title={solo ? 'Un-solo' : 'Solo'}
>
S
</button>
</div>
{/* Level Meter */}
<div className="channel-meter-section">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* Pan control */}
{/* ── Pan Control ── */}
<div className="channel-pan">
<label className="channel-pan-label">Pan</label>
<div className="channel-pan-control">
@@ -140,15 +333,13 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
value={pan}
onChange={handlePanChange}
className="channel-pan-slider"
title={`Pan: ${pan.toFixed(2)}`}
title={`Pan: ${formatPan(pan)}`}
/>
<span className="channel-pan-value">
{pan === 0 ? 'C' : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
</span>
<span className="channel-pan-value">{formatPan(pan)}</span>
</div>
</div>
{/* Volume fader */}
{/* ── Volume Fader ── */}
<div className="channel-fader-section">
<input
type="range"
@@ -156,27 +347,25 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolumeChange}
onChange={handleVolChange}
className="channel-fader"
title={`Volume: ${volume.toFixed(1)} dB`}
title={`Volume: ${formatDb(volume)}`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<span className="channel-fader-readout">
{volume > -60 ? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}` : '-∞'}
</span>
<span className="channel-fader-readout">{formatDb(volume)}</span>
</div>
{/* Instrument selector */}
<div className="channel-instrument-selector">
{/* ── Channel Type Selector ── */}
<div className="channel-type-selector">
<select
value={instrument}
onChange={handleInstrumentChange}
className="channel-instrument-select"
style={{ borderColor: instrumentColor }}
value={channelType}
onChange={handleTypeChange}
className="channel-type-select"
style={{ borderColor: accentColor }}
>
{INSTRUMENT_VALUES.map((val) => (
<option key={val} value={val}>
{INSTRUMENT_NAMES[val]}
{CHANNEL_TYPES.map((ct) => (
<option key={ct} value={ct}>
{CHANNEL_TYPE_LABELS[ct]}
</option>
))}
</select>
@@ -184,3 +373,5 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
</div>
);
};
export default ChannelStrip;
@@ -0,0 +1,183 @@
/* ---- Master Bus ---- */
/* Styled in PiPedal theme with purple accent to differentiate from channels */
.master-bus {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 10px;
background: var(--surface, #16213e);
border: 1px solid var(--border, #2a2a4a);
border-top: 3px solid #a770e4;
border-radius: var(--radius, 8px);
min-width: 90px;
max-width: 110px;
position: relative;
transition: border-top-color 0.3s, opacity 0.2s;
}
.master-bus:hover {
box-shadow: 0 2px 16px rgba(167, 112, 228, 0.15);
}
.master-bus.muted {
opacity: 0.6;
}
/* ---- Header ---- */
.master-bus-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
width: 100%;
}
.master-bus-label {
font-size: 0.75rem;
font-weight: 700;
text-align: center;
color: var(--text, #eaeaea);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.master-bus-type {
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #a770e4;
font-weight: 600;
}
/* ---- Level Meters ---- */
.master-bus-meters {
width: 100%;
display: flex;
justify-content: center;
min-height: 70px;
}
/* ---- Controls ---- */
.master-bus-controls {
display: flex;
gap: 3px;
width: 100%;
justify-content: center;
}
.master-bus-btn {
width: 32px;
height: 24px;
border: 1px solid var(--border, #2a2a4a);
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
cursor: pointer;
transition: all 0.15s;
background: var(--bg, #1a1a2e);
color: var(--text-dim, #9e9e9e);
user-select: none;
line-height: 1;
}
.master-bus-btn.mute.active {
background: var(--error, #f44336);
color: #fff;
border-color: var(--error, #f44336);
}
.master-bus-btn:hover {
opacity: 0.85;
}
/* ---- Fader ---- */
.master-bus-fader-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 100%;
}
.master-bus-fader {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 110px;
background: linear-gradient(
to top,
var(--bg, #1a1a2e) 0%,
#a770e4 75%,
#c084fc 85%,
#e94560 95%,
#f44336 100%
);
border-radius: 4px;
outline: none;
cursor: pointer;
border: 1px solid var(--border, #2a2a4a);
writing-mode: vertical-lr;
direction: rtl;
}
.master-bus-fader::-webkit-slider-runnable-track {
height: 110px;
border-radius: 3px;
}
.master-bus-fader::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 14px;
background: #a770e4;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
margin-left: -4px;
}
.master-bus-fader::-moz-range-track {
height: 110px;
border-radius: 3px;
}
.master-bus-fader::-moz-range-thumb {
width: 20px;
height: 14px;
background: #a770e4;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 2px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.master-bus-fader-readout {
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
text-align: center;
min-height: 1em;
}
/* ---- dB values ---- */
.master-bus-db-values {
display: flex;
gap: 4px;
font-size: 0.6rem;
font-family: monospace;
color: var(--text-dim, #9e9e9e);
}
.master-bus-db-values .hot {
color: var(--meter-red, #f44336);
font-weight: 700;
}
@@ -0,0 +1,126 @@
import React, { useCallback } from 'react';
import { LevelMeter } from './LevelMeter';
import './MasterBus.css';
interface Props {
/** Current volume in dB (-60 .. +6) */
volume: number;
/** Whether master is muted */
mute: boolean;
/** Left channel level in dB */
levelL: number;
/** Right channel level in dB */
levelR: number;
/** Callback when volume changes */
onVolumeChange: (db: number) => void;
/** Callback when mute toggles */
onMuteToggle: (muted: boolean) => void;
/** Optional label (defaults to "Master") */
label?: string;
}
/**
* Map a dB slider position (0-1) to a dB value (-60..+6).
* 75% position = 0 dB (unity).
*/
function posToDb(pos: number): number {
if (pos <= 0) return -60;
if (pos >= 1) return 6;
const scaled = pos / 0.75;
if (scaled <= 0.001) return -60;
const db = 20 * Math.log10(scaled);
return Math.max(-60, Math.min(6, db));
}
/**
* Map a dB value (-60..+6) to a slider position (0-1).
*/
function dbToPos(db: number): number {
if (db <= -60) return 0;
if (db >= 6) return 1;
const linear = Math.pow(10, db / 20);
return Math.min(1, linear * 0.75);
}
/**
* Master bus final stereo mix output with fader, mute, and level meters.
* Styled in PiPedal theme, visually distinct from channel strips.
*/
export const MasterBus: React.FC<Props> = ({
volume,
mute,
levelL,
levelR,
onVolumeChange,
onMuteToggle,
label = 'Master',
}) => {
const volumeSliderPos = dbToPos(volume) * 100;
const handleVolumeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const pos = parseFloat(e.target.value) / 100;
const db = posToDb(pos);
onVolumeChange(parseFloat(db.toFixed(1)));
},
[onVolumeChange]
);
const handleMuteClick = useCallback(() => {
onMuteToggle(!mute);
}, [mute, onMuteToggle]);
return (
<div className={`master-bus ${mute ? 'muted' : ''}`}>
{/* Header */}
<div className="master-bus-header">
<span className="master-bus-label">{label}</span>
<span className="master-bus-type">Main Out</span>
</div>
{/* Level meters */}
<div className="master-bus-meters">
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
</div>
{/* Mute button */}
<div className="master-bus-controls">
<button
className={`master-bus-btn mute ${mute ? 'active' : ''}`}
onClick={handleMuteClick}
title="Master Mute"
>
M
</button>
</div>
{/* Volume fader */}
<div className="master-bus-fader-section">
<input
type="range"
min="0"
max="100"
step="0.5"
value={volumeSliderPos}
onChange={handleVolumeChange}
className="master-bus-fader"
title={`Master Volume: ${volume.toFixed(1)} dB`}
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
/>
<span className="master-bus-fader-readout">
{mute
? 'MUTED'
: volume > -60
? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}`
: '-∞'}
</span>
</div>
{/* dB values under meter */}
<div className="master-bus-db-values">
<span className={levelL > -10 ? 'hot' : ''}>{levelL.toFixed(1)}</span>
<span className={levelR > -10 ? 'hot' : ''}>{levelR.toFixed(1)}</span>
</div>
</div>
);
};
@@ -252,3 +252,79 @@
opacity: 0.7;
margin-top: 8px !important;
}
/* ---- Split Layout (when PiPedal iframe is shown) ---- */
.mixer-content-split {
display: flex;
flex-direction: row;
gap: 0;
padding: 0;
height: calc(100vh - 60px);
overflow: hidden;
}
.mixer-content-split .mixer-content-main {
flex: 1;
overflow-y: auto;
padding: 20px;
border-right: 1px solid var(--border, #2a2a4a);
}
/* ---- PiPedal Iframe Pane ---- */
.mixer-pipedal-pane {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg, #1a1a2e);
min-width: 400px;
}
.mixer-pipedal-iframe {
width: 100%;
height: 100%;
border: none;
background: #fff;
}
/* PiPedal toggle button */
.mixer-pipedal-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface-alt, #0f3460);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
padding: 6px 12px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.mixer-pipedal-btn:hover {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.mixer-pipedal-btn.active {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
/* ---- Master Section ---- */
.mixer-master-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border, #2a2a4a);
}
.mixer-master-layout {
display: flex;
gap: 10px;
justify-content: flex-start;
}
+107 -6
View File
@@ -3,8 +3,11 @@ import type { PluginInstance } from '../../hooks/usePiPedalWS';
import type { SceneState } from '../../hooks/useScenes';
import { useMixerControls } from '../../hooks/useMixerControls';
import { useScenes } from '../../hooks/useScenes';
import { useJackPorts } from '../../hooks/useJackPorts';
import { PatchBay } from '../PatchBay/PatchBay';
import { ChannelStrip } from './ChannelStrip';
import { BusStrip } from './BusStrip';
import { MasterBus } from './MasterBus';
import { SceneManager } from '../Scenes/SceneManager';
import { ConnectionStatus } from '../ConnectionStatus';
import type { ConnectionState } from '../../hooks/usePiPedalWS';
@@ -13,6 +16,10 @@ 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';
interface Props {
plugins: PluginInstance[];
@@ -24,9 +31,9 @@ interface Props {
/**
* Main mixer page layout.
* - Header with connection status and scene selector
* - Sidebar of channel strips (left)
* - Bus meter bridge (right)
* - Header with connection status, PiPedal toggle, scene selector
* - Left pane: channel strips + bus strips
* - Right pane: PiPedal iframe (when toggled)
* - Scene management sidebar
*/
export const MixerPage: React.FC<Props> = ({
@@ -46,7 +53,22 @@ export const MixerPage: React.FC<Props> = ({
recallScene,
} = useScenes();
const {
portsState,
refresh: refreshJack,
connect,
disconnect,
disconnectAll,
actionState,
actionError,
} = useJackPorts();
const [scenePanelOpen, setScenePanelOpen] = useState(false);
const [showPiPedal, setShowPiPedal] = useState(false);
const [showPatchBay, setShowPatchBay] = useState(false);
// PiPedal iframe URL — use the Pi's IP since the browser accesses this from another machine
const pipedalUrl = `http://192.168.0.245:8080/`;
// Separate plugins by type
const channels = useMemo(
@@ -67,6 +89,22 @@ export const MixerPage: React.FC<Props> = ({
const hasBuses = buses.length > 0;
const hasAny = plugins.length > 0;
// Derive master bus state from bus plugin data
// For now, use average of bus master levels as master levels;
// volume/mute are local-state placeholders until backend exposes master output.
const masterLevelL = useMemo(() => {
if (!hasBuses) return -60;
return Math.max(...buses.map((b) => b.controlValues.masterLevelL ?? -60));
}, [buses, hasBuses]);
const masterLevelR = useMemo(() => {
if (!hasBuses) return -60;
return Math.max(...buses.map((b) => b.controlValues.masterLevelR ?? -60));
}, [buses, hasBuses]);
const [masterVolume, setMasterVolume] = useState(0);
const [masterMute, setMasterMute] = useState(false);
/**
* Capture current plugin state for saving as a scene.
* Returns channels and buses with their instanceIds and current controlValues.
@@ -126,14 +164,30 @@ export const MixerPage: React.FC<Props> = ({
<span className="mixer-scenes-count">{scenes.length}</span>
)}
</button>
<button className="mixer-refresh-btn" onClick={onRefresh}>
<button className="mixer-scenes-btn" onClick={onRefresh}>
Refresh
</button>
<button
className={`mixer-pipedal-btn ${showPiPedal ? 'active' : ''}`}
onClick={() => setShowPiPedal((v) => !v)}
title="Toggle PiPedal view"
>
🎛 PiPedal
</button>
<button
className={`mixer-patch-btn ${showPatchBay ? 'active' : ''}`}
onClick={() => setShowPatchBay((v) => !v)}
title="Toggle Patch Bay"
>
🔌 Patch
</button>
</div>
</header>
{/* Main content */}
<div className="mixer-content">
{/* Main content — splits into two panes when PiPedal is shown */}
<div className={`mixer-content ${showPiPedal ? 'mixer-content-split' : ''}`}>
{/* Left pane: mixer controls */}
<div className="mixer-content-main">
{/* Empty state */}
{!hasAny && (
<div className="mixer-empty">
@@ -198,7 +252,54 @@ export const MixerPage: React.FC<Props> = ({
</div>
</div>
)}
{/* Master bus — final mix output */}
{hasAny && (
<div className="mixer-master-section">
<div className="mixer-section-header">
<h2>Master</h2>
</div>
<div className="mixer-master-layout">
<MasterBus
volume={masterVolume}
mute={masterMute}
levelL={masterLevelL}
levelR={masterLevelR}
onVolumeChange={(db: number) => setMasterVolume(db)}
onMuteToggle={(muted: boolean) => setMasterMute(muted)}
/>
</div>
</div>
)}
</div> {/* end mixer-content-main */}
{/* Right pane: PiPedal iframe (when toggled) */}
{showPiPedal && (
<div className="mixer-pipedal-pane">
<iframe
src={pipedalUrl}
title="PiPedal"
className="mixer-pipedal-iframe"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
/>
</div>
)}
</div> {/* end mixer-content */}
{/* Patch Bay pane (full-width, below mixer content) */}
{showPatchBay && (
<div className="mixer-patch-pane">
<PatchBay
portsState={portsState}
onConnect={connect}
onDisconnect={disconnect}
onDisconnectAll={disconnectAll}
onRefresh={refreshJack}
actionState={actionState}
actionError={actionError}
/>
</div>
)}
{/* Scene Manager sidebar */}
{scenePanelOpen && (
@@ -0,0 +1,408 @@
/* ---- Patch Bay Container ---- */
.patch-bay {
background: var(--bg, #1a1a2e);
border: 1px solid var(--border, #2a2a4a);
border-radius: var(--radius, 8px);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* ---- Header ---- */
.patch-bay-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--surface, #16213e);
border-bottom: 1px solid var(--border, #2a2a4a);
gap: 12px;
flex-wrap: wrap;
}
.patch-bay-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
font-weight: 600;
color: var(--text, #eaeaea);
}
.patch-icon {
font-size: 1rem;
}
.patch-conn-count {
font-size: 0.65rem;
background: var(--accent, #e94560);
color: #fff;
padding: 1px 6px;
border-radius: 8px;
font-weight: 600;
min-width: 18px;
text-align: center;
}
.patch-bay-actions {
display: flex;
align-items: center;
gap: 8px;
}
.patch-armed-info {
font-size: 0.72rem;
color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
padding: 3px 8px;
border-radius: 4px;
white-space: nowrap;
}
.patch-armed-info code {
font-weight: 600;
}
.patch-action-state {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 4px;
}
.patch-action-state.connecting,
.patch-action-state.disconnecting {
color: var(--warning, #ff9800);
background: rgba(255, 152, 0, 0.12);
}
.patch-action-state.error {
color: var(--error, #f44336);
background: rgba(244, 67, 54, 0.12);
}
.patch-action-btn {
display: flex;
align-items: center;
gap: 4px;
background: transparent;
color: var(--text-dim, #9e9e9e);
border: 1px solid var(--border, #2a2a4a);
padding: 4px 10px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.72rem;
transition: all 0.15s;
}
.patch-action-btn:hover:not(:disabled) {
border-color: var(--error, #f44336);
color: var(--error, #f44336);
background: rgba(244, 67, 54, 0.08);
}
.patch-action-btn:disabled {
opacity: 0.4;
cursor: default;
}
.patch-refresh-btn {
background: var(--surface-alt, #0f3460);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
padding: 4px 10px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.15s;
}
.patch-refresh-btn:hover:not(:disabled) {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.patch-refresh-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ---- Body (port columns) ---- */
.patch-bay-body {
position: relative;
display: flex;
gap: 0;
padding: 0;
overflow-x: auto;
}
/* ---- Column ---- */
.patch-column {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 4px;
z-index: 2;
min-width: 240px;
max-width: 300px;
}
.patch-column-left {
padding-right: 8px;
border-right: 1px solid var(--border, #2a2a4a);
}
.patch-column-right {
padding-left: 8px;
}
/* ---- Port Group ---- */
.patch-group {
margin-bottom: 1px;
}
.patch-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim, #9e9e9e);
background: rgba(255, 255, 255, 0.02);
border-radius: 4px;
height: 28px;
}
.patch-group-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim, #9e9e9e);
}
.patch-group-dot.physical {
background: #4fc3f7;
}
.patch-group-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---- Port Row ---- */
.patch-port {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
height: 32px;
border-radius: 4px;
cursor: pointer;
transition: all 0.12s;
border-left: 2px solid transparent;
position: relative;
}
.patch-port:hover {
background: rgba(255, 255, 255, 0.05);
}
.patch-port.connected {
border-left-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.06);
}
.patch-port.armed {
border-left-color: #4fc3f7;
background: rgba(79, 195, 247, 0.1);
box-shadow: inset 0 0 0 1px rgba(79, 195, 247, 0.3);
}
.patch-port.available {
border-left-color: transparent;
}
.patch-port.available:hover {
border-left-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.08);
}
.patch-port-indicator {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--accent, #e94560);
flex-shrink: 0;
}
.patch-port-name {
font-size: 0.78rem;
color: var(--text, #eaeaea);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.patch-port-name.physical {
color: #4fc3f7;
}
.patch-port-conn-count {
font-size: 0.6rem;
background: var(--accent, #e94560);
color: #fff;
padding: 0 5px;
border-radius: 6px;
font-weight: 600;
min-width: 14px;
text-align: center;
flex-shrink: 0;
}
/* ---- SVG Cable Layer ---- */
.patch-cables-svg {
z-index: 1;
pointer-events: none;
}
/* The invisible hit area paths have pointer-events: stroke set inline */
/* ---- Source note ---- */
.patch-source-note {
position: absolute;
bottom: 4px;
right: 8px;
font-size: 0.6rem;
color: var(--text-dim, #9e9e9e);
opacity: 0.5;
z-index: 3;
}
/* ---- Error banner ---- */
.patch-bay-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: rgba(244, 67, 54, 0.08);
border-top: 1px solid rgba(244, 67, 54, 0.2);
font-size: 0.72rem;
color: var(--error, #f44336);
}
.patch-bay-error.action {
background: rgba(244, 67, 54, 0.12);
}
.patch-dismiss-btn {
background: transparent;
border: none;
color: var(--error, #f44336);
cursor: pointer;
font-size: 0.85rem;
padding: 2px 6px;
border-radius: 4px;
}
.patch-dismiss-btn:hover {
background: rgba(244, 67, 54, 0.15);
}
/* ---- Loading ---- */
.patch-bay-loading,
.patch-bay-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 24px;
text-align: center;
color: var(--text-dim, #9e9e9e);
gap: 12px;
}
.patch-empty-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.patch-bay-empty h3 {
font-size: 1rem;
color: var(--text, #eaeaea);
}
.patch-bay-empty p {
font-size: 0.82rem;
max-width: 400px;
line-height: 1.5;
}
.patch-empty-note {
font-size: 0.72rem;
opacity: 0.6;
}
.patch-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border, #2a2a4a);
border-top-color: var(--accent, #e94560);
border-radius: 50%;
animation: patch-spin 0.8s linear infinite;
}
@keyframes patch-spin {
to {
transform: rotate(360deg);
}
}
/* ---- MixerPage integration ---- */
.mixer-patch-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--surface-alt, #0f3460);
color: var(--text, #eaeaea);
border: 1px solid var(--border, #2a2a4a);
padding: 6px 12px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
}
.mixer-patch-btn:hover {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.1);
}
.mixer-patch-btn.active {
border-color: var(--accent, #e94560);
background: rgba(233, 69, 96, 0.15);
color: var(--accent, #e94560);
}
/* Patch bay pane inside mixer content split */
.mixer-patch-pane {
width: 100%;
padding: 12px;
overflow-y: auto;
}
@@ -0,0 +1,513 @@
/**
* Patch Bay Visual JACK connection matrix.
*
* Layout:
* Left column: output ports (audio sources) grouped by JACK client.
* Right column: input ports (audio destinations) grouped by JACK client.
* SVG overlay: bezier curves showing active connections.
*
* Interaction:
* Click an output port it becomes "armed" for connection.
* Click an input port connects/disconnects the armed output.
* Click a connected port disconnects it.
*/
import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import type { JackPort, JackPortsState } from '../../hooks/useJackPorts';
import './PatchBay.css';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const PORT_ROW_HEIGHT = 32;
const GROUP_HEADER_HEIGHT = 28;
const GROUP_GAP = 4;
const CABLE_COLOR = '#e94560';
const CABLE_WIDTH = 2.5;
const HOVER_CABLE_WIDTH = 4;
// ---------------------------------------------------------------------------
// Props + Types
// ---------------------------------------------------------------------------
interface Props {
portsState: JackPortsState;
onConnect: (source: string, destination: string) => Promise<boolean>;
onDisconnect: (source: string, destination: string) => Promise<boolean>;
onDisconnectAll: () => Promise<boolean>;
onRefresh: () => void;
actionState: string;
actionError: string | null;
}
interface PortGroup {
client: string;
ports: JackPort[];
totalHeight: number;
}
interface PortPosition {
name: string;
y: number;
side: 'left' | 'right';
}
interface Cable {
source: string;
dest: string;
y1: number;
y2: number;
}
// ---------------------------------------------------------------------------
// Helper: group ports by client
// ---------------------------------------------------------------------------
function groupPorts(ports: JackPort[], direction: 'output' | 'input'): PortGroup[] {
const filtered = ports
.filter((p) => p.direction === direction && p.port_type === 'audio')
.sort((a, b) => {
// Physical ports first, then alphabetical by client
if (a.is_physical !== b.is_physical) return a.is_physical ? -1 : 1;
const clientCmp = a.client.localeCompare(b.client);
if (clientCmp !== 0) return clientCmp;
return a.port.localeCompare(b.port);
});
const groups: PortGroup[] = [];
let currentGroup: PortGroup | null = null;
for (const port of filtered) {
if (!currentGroup || currentGroup.client !== port.client) {
currentGroup = {
client: port.client,
ports: [],
totalHeight: GROUP_HEADER_HEIGHT + GROUP_GAP,
};
groups.push(currentGroup);
}
currentGroup.ports.push(port);
currentGroup.totalHeight += PORT_ROW_HEIGHT;
}
return groups;
}
// ---------------------------------------------------------------------------
// Compute positions and cables
// ---------------------------------------------------------------------------
function computeCables(
leftGroups: PortGroup[],
rightGroups: PortGroup[],
connections: Record<string, string[]>,
leftOffset: number
): Cable[] {
const outputPositions: Record<string, number> = {};
const inputPositions: Record<string, number> = {};
let ly = leftOffset;
for (const group of leftGroups) {
ly += GROUP_HEADER_HEIGHT + GROUP_GAP / 2;
for (const port of group.ports) {
outputPositions[port.name] = ly + PORT_ROW_HEIGHT / 2;
ly += PORT_ROW_HEIGHT;
}
}
let ry = leftOffset;
for (const group of rightGroups) {
ry += GROUP_HEADER_HEIGHT + GROUP_GAP / 2;
for (const port of group.ports) {
inputPositions[port.name] = ry + PORT_ROW_HEIGHT / 2;
ry += PORT_ROW_HEIGHT;
}
}
const cables: Cable[] = [];
const seen = new Set<string>();
for (const [srcName, srcY] of Object.entries(outputPositions)) {
const conns = connections[srcName] || [];
for (const dstName of conns) {
const key = `${srcName}${dstName}`;
if (seen.has(key)) continue;
seen.add(key);
const dstY = inputPositions[dstName];
if (dstY !== undefined) {
cables.push({ source: srcName, dest: dstName, y1: srcY, y2: dstY });
}
}
}
return cables;
}
// ---------------------------------------------------------------------------
// SVG Cable Path
// ---------------------------------------------------------------------------
function cablePath(
y1: number,
y2: number,
containerWidth: number,
margin: number
): string {
const x1 = margin;
const x2 = containerWidth - margin;
const midX = (x1 + x2) / 2;
const cpOffset = Math.max(60, (x2 - x1) * 0.4);
return `M ${x1} ${y1} C ${x1 + cpOffset} ${y1}, ${x2 - cpOffset} ${y2}, ${x2} ${y2}`;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const PatchBay: React.FC<Props> = ({
portsState,
onConnect,
onDisconnect,
onDisconnectAll,
onRefresh,
actionState,
actionError,
}) => {
const { ports, connections, loading, error, source } = portsState;
const [armedPort, setArmedPort] = useState<string | null>(null);
const [hoveredCable, setHoveredCable] = useState<{
source: string;
dest: string;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(800);
// Group ports
const leftGroups = useMemo(() => groupPorts(ports, 'output'), [ports]);
const rightGroups = useMemo(() => groupPorts(ports, 'input'), [ports]);
// Calculate layout dimensions
const layout = useMemo(() => {
const headerH = 40;
const leftH = leftGroups.reduce((sum, g) => sum + g.totalHeight, GROUP_GAP);
const rightH = rightGroups.reduce((sum, g) => sum + g.totalHeight, GROUP_GAP);
const totalH = Math.max(leftH, rightH) + headerH + 20;
return { headerH, leftH, rightH, totalH };
}, [leftGroups, rightGroups]);
// Cable positions
const cableMargin = 280;
const cables = useMemo(
() => computeCables(leftGroups, rightGroups, connections, layout.headerH + 12),
[leftGroups, rightGroups, connections, layout.headerH]
);
// Track container width
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// Clear armed port after successful action
useEffect(() => {
if (actionState === 'idle') {
setArmedPort(null);
}
}, [actionState]);
const handlePortClick = useCallback(
async (port: JackPort) => {
if (port.direction === 'output') {
// Clicking an output port arms it for connection
if (port.connections.length > 0) {
// If already connected, disconnect all
for (const dst of port.connections) {
await onDisconnect(port.name, dst);
}
} else {
setArmedPort(armedPort === port.name ? null : port.name);
}
} else {
// Clicking an input port
if (armedPort) {
// Connect armed output to this input
await onConnect(armedPort, port.name);
setArmedPort(null);
} else if (port.connections.length > 0) {
// Disconnect if already connected
for (const src of port.connections) {
await onDisconnect(src, port.name);
}
}
}
},
[armedPort, onConnect, onDisconnect]
);
const getPortClass = useCallback(
(port: JackPort): string => {
const classes = ['patch-port'];
if (port.connections.length > 0) classes.push('connected');
if (armedPort === port.name) classes.push('armed');
if (port.direction === 'output' && armedPort && armedPort !== port.name) classes.push('available');
return classes.join(' ');
},
[armedPort]
);
// Count connections
const connectionCount = useMemo(() => {
let count = 0;
for (const [, conns] of Object.entries(connections)) {
count += conns.length;
}
return count;
}, [connections]);
// Render a column of port groups
const renderColumn = (groups: PortGroup[], side: 'left' | 'right') => (
<div className={`patch-column patch-column-${side}`}>
{groups.map((group) => (
<div key={group.client} className="patch-group">
<div className="patch-group-header">
<span
className={`patch-group-dot ${
group.client === 'system' ? 'physical' : ''
}`}
/>
<span className="patch-group-name">
{group.client === 'system'
? group.client
: group.client.replace(/^pipedal:/, '').replace(/^pipedal/, 'PiPedal')}
</span>
</div>
{group.ports.map((port) => (
<div
key={port.name}
className={getPortClass(port)}
onClick={() => handlePortClick(port)}
title={`${port.name}${
port.connections.length > 0
? `\nConnected to: ${port.connections.join(', ')}`
: '\nClick to connect'
}`}
>
{side === 'left' && (
<span className="patch-port-indicator" />
)}
<span className={`patch-port-name ${port.is_physical ? 'physical' : ''}`}>
{port.port}
</span>
{port.connections.length > 0 && (
<span className="patch-port-conn-count">
{port.connections.length}
</span>
)}
</div>
))}
</div>
))}
</div>
);
// --- Empty / loading states ---
if (loading) {
return (
<div className="patch-bay">
<div className="patch-bay-loading">
<div className="patch-spinner" />
<span>Loading JACK ports...</span>
</div>
</div>
);
}
if (ports.length === 0) {
return (
<div className="patch-bay">
<div className="patch-bay-empty">
<div className="patch-empty-icon">🔌</div>
<h3>No JACK Ports Available</h3>
<p>
{source === 'none'
? 'PiPedal is unreachable. Ensure the PiPedal server is running on 192.168.0.245:8080.'
: 'No ports found in the current pedalboard.'}
</p>
{error && <p className="patch-empty-note">{error}</p>}
<button className="patch-refresh-btn" onClick={onRefresh}>
Retry
</button>
</div>
</div>
);
}
return (
<div className="patch-bay" ref={containerRef}>
{/* Header */}
<div className="patch-bay-header">
<div className="patch-bay-title">
<span className="patch-icon">🔌</span>
<span>Patch Bay</span>
<span className="patch-conn-count">{connectionCount}</span>
</div>
<div className="patch-bay-actions">
{armedPort && (
<span className="patch-armed-info">
Armed: <code>{armedPort.split(':').pop()}</code>
</span>
)}
{actionState !== 'idle' && (
<span className={`patch-action-state ${actionState}`}>
{actionState === 'connecting'
? 'Connecting...'
: actionState === 'disconnecting'
? 'Disconnecting...'
: actionError || 'Error'}
</span>
)}
<button
className="patch-action-btn"
onClick={onDisconnectAll}
disabled={connectionCount === 0 || actionState !== 'idle'}
title="Disconnect all"
>
Disconnect All
</button>
<button
className="patch-refresh-btn"
onClick={onRefresh}
disabled={actionState !== 'idle'}
title="Refresh ports"
>
</button>
</div>
</div>
{/* Content area */}
<div
className="patch-bay-body"
style={{ minHeight: layout.totalH }}
>
{/* SVG Cable Layer */}
<svg
className="patch-cables-svg"
width={containerWidth}
height={layout.totalH}
style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
>
<defs>
<linearGradient id="cableGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={CABLE_COLOR} stopOpacity={0.15} />
<stop offset="50%" stopColor={CABLE_COLOR} stopOpacity={0.6} />
<stop offset="100%" stopColor={CABLE_COLOR} stopOpacity={0.15} />
</linearGradient>
<filter id="cableGlow">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background cable paths (for hover hit area) */}
{cables.map((c) => (
<path
key={`bg-${c.source}${c.dest}`}
d={cablePath(c.y1, c.y2, containerWidth, cableMargin)}
fill="none"
stroke="transparent"
strokeWidth={14}
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onMouseEnter={() => setHoveredCable({ source: c.source, dest: c.dest })}
onMouseLeave={() => setHoveredCable(null)}
onClick={() => {
onDisconnect(c.source, c.dest);
}}
/>
))}
{/* Visible cable paths */}
{cables.map((c) => {
const isHovered =
hoveredCable?.source === c.source && hoveredCable?.dest === c.dest;
return (
<path
key={`cable-${c.source}${c.dest}`}
d={cablePath(c.y1, c.y2, containerWidth, cableMargin)}
fill="none"
stroke={isHovered ? '#ff6b81' : CABLE_COLOR}
strokeWidth={isHovered ? HOVER_CABLE_WIDTH : CABLE_WIDTH}
strokeLinecap="round"
opacity={isHovered ? 0.9 : 0.5}
filter={isHovered ? 'url(#cableGlow)' : undefined}
style={{ pointerEvents: 'none' }}
/>
);
})}
{/* Source dots on cables */}
{cables.map((c) => (
<circle
key={`dot-${c.source}${c.dest}`}
cx={cableMargin}
cy={c.y1}
r={3}
fill={CABLE_COLOR}
opacity={0.8}
/>
))}
{/* Destination dots */}
{cables.map((c) => (
<circle
key={`dot2-${c.source}${c.dest}`}
cx={containerWidth - cableMargin}
cy={c.y2}
r={3}
fill={CABLE_COLOR}
opacity={0.8}
/>
))}
</svg>
{/* Columns */}
{renderColumn(leftGroups, 'left')}
{renderColumn(rightGroups, 'right')}
{/* Source indicator */}
{source !== 'pipedal' && source !== 'none' && (
<div className="patch-source-note">
Port data from pedalboard state (source: {source})
</div>
)}
</div>
{/* Error banner */}
{error && (
<div className="patch-bay-error">
<span> {error}</span>
<button className="patch-dismiss-btn" onClick={onRefresh}>
</button>
</div>
)}
{actionError && (
<div className="patch-bay-error action">
<span> {actionError}</span>
</div>
)}
</div>
);
};
+182
View File
@@ -0,0 +1,182 @@
/**
* Hook for fetching and managing JACK audio port connections via the backend API.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface JackPort {
name: string;
client: string;
port: string;
port_type: string; // "audio" | "midi"
direction: string; // "output" = audio source, "input" = audio destination
is_physical: boolean;
connections: string[];
}
export interface JackPortsResponse {
ports: JackPort[];
connections: Record<string, string[]>;
source: 'pipedal' | 'fallback' | 'none';
note?: string;
}
export type ConnectionAction = 'idle' | 'connecting' | 'disconnecting' | 'error';
export interface JackPortsState {
ports: JackPort[];
connections: Record<string, string[]>;
loading: boolean;
error: string | null;
source: string;
}
export interface UseJackPortsReturn {
portsState: JackPortsState;
refresh: () => Promise<void>;
connect: (source: string, destination: string) => Promise<boolean>;
disconnect: (source: string, destination: string) => Promise<boolean>;
disconnectAll: () => Promise<boolean>;
actionState: ConnectionAction;
actionError: string | null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useJackPorts(): UseJackPortsReturn {
const [portsState, setPortsState] = useState<JackPortsState>({
ports: [],
connections: {},
loading: true,
error: null,
source: 'none',
});
const [actionState, setActionState] = useState<ConnectionAction>('idle');
const [actionError, setActionError] = useState<string | null>(null);
const isMounted = useRef(true);
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
const refresh = useCallback(async () => {
setPortsState((prev) => ({ ...prev, loading: true, error: null }));
try {
const res = await fetch('/api/jack/ports');
if (!res.ok) {
throw new Error(`Failed to load JACK ports: ${res.statusText}`);
}
const data: JackPortsResponse = await res.json();
if (isMounted.current) {
setPortsState({
ports: data.ports || [],
connections: data.connections || {},
loading: false,
error: data.note || null,
source: data.source,
});
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (isMounted.current) {
setPortsState((prev) => ({ ...prev, loading: false, error: msg }));
}
}
}, []);
const connect = useCallback(
async (source: string, destination: string): Promise<boolean> => {
setActionState('connecting');
setActionError(null);
try {
const res = await fetch('/api/jack/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Connect failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
},
[refresh]
);
const disconnect = useCallback(
async (source: string, destination: string): Promise<boolean> => {
setActionState('disconnecting');
setActionError(null);
try {
const res = await fetch('/api/jack/disconnect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, destination }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Disconnect failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
},
[refresh]
);
const disconnectAll = useCallback(async (): Promise<boolean> => {
setActionState('disconnecting');
setActionError(null);
try {
const res = await fetch('/api/jack/disconnect-all', { method: 'POST' });
if (!res.ok) {
throw new Error(`Disconnect all failed: ${res.statusText}`);
}
await refresh();
setActionState('idle');
return true;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setActionError(msg);
setActionState('error');
return false;
}
}, [refresh]);
// Initial fetch
useEffect(() => {
refresh();
}, [refresh]);
return {
portsState,
refresh,
connect,
disconnect,
disconnectAll,
actionState,
actionError,
};
}
-1
View File
@@ -1 +0,0 @@
{"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/InstrumentBadge.tsx","./src/components/MixerPage/LevelMeter.tsx","./src/components/MixerPage/MixerPage.tsx","./src/components/Scenes/SceneManager.tsx","./src/hooks/useMixerControls.ts","./src/hooks/usePiPedalWS.ts","./src/hooks/useScenes.ts"],"version":"5.9.3"}