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:
@@ -1,24 +1,41 @@
|
|||||||
# OPLabs Mixer App
|
# 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
|
## 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)
|
│ WS /ws/pipedal │ WS /pipedal (upstream, legacy)
|
||||||
└── REST /api/discover └── REST proxied via WS
|
└── 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.
|
Two parallel backends:
|
||||||
- **Frontend:** Vite + React + TypeScript — connects to the local WS proxy and shows discovered OPLabs plugin instances with their parameter values.
|
|
||||||
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- Node.js 18+
|
- 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
|
## Setup
|
||||||
|
|
||||||
@@ -36,92 +53,249 @@ pip install -r requirements.txt
|
|||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
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
|
## Running
|
||||||
|
|
||||||
### Development Mode
|
### 1. Start JACK (if using the daemon with audio I/O)
|
||||||
|
|
||||||
Start both backend and frontend:
|
```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
|
```bash
|
||||||
# Terminal 1: Backend
|
|
||||||
cd backend
|
cd backend
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
python app.py # runs on port 8081
|
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.
|
Open http://localhost:8081 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
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the convenience script:
|
Or use the convenience script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./start.sh
|
./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]`
|
- **Type:** Unix domain socket (`AF_UNIX`, `SOCK_STREAM`)
|
||||||
- Replies: `[{"reply": N, "message": "msgName"}, optionalBody]`
|
- **Path:** `/tmp/oplabs-mixer.sock`
|
||||||
- The browser connects to `ws://localhost:8081/ws/pipedal`, which proxies to PiPedal's `ws://192.168.0.245:8080/pipedal`
|
- **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
|
## REST API
|
||||||
|
|
||||||
|
### Mixer Daemon Endpoints (new)
|
||||||
|
|
||||||
| Endpoint | Method | Description |
|
| 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/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/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`
|
| Endpoint | Description |
|
||||||
- OPLabsBandBus: `http://ourpad.casa/plugins/oplabs-band-bus#`
|
|-----------------|-----------------------------------------------------|
|
||||||
|
| `/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
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
~/projects/oplabs-mixer-app/
|
~/projects/oplabs-mixer-app/
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── app.py # FastAPI app (routes, SPA serving, WS proxy)
|
│ ├── app.py # FastAPI app (mixer + legacy PiPedal routes)
|
||||||
│ ├── ws_proxy.py # PiPedal WebSocket proxy client
|
│ ├── mixer_ipc.py # Unix socket IPC client for Mixer Daemon
|
||||||
|
│ ├── ws_proxy.py # PiPedal WebSocket proxy
|
||||||
│ └── requirements.txt
|
│ └── 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/
|
├── frontend/
|
||||||
│ ├── package.json
|
│ ├── package.json
|
||||||
│ ├── vite.config.ts
|
│ ├── vite.config.ts
|
||||||
│ ├── tsconfig.json
|
│ ├── tsconfig.json
|
||||||
│ ├── index.html
|
│ ├── index.html
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── main.tsx
|
|
||||||
│ ├── App.tsx
|
|
||||||
│ ├── App.css
|
|
||||||
│ ├── hooks/
|
|
||||||
│ │ └── usePiPedalWS.ts
|
|
||||||
│ └── components/
|
|
||||||
│ ├── ConnectionStatus.tsx
|
|
||||||
│ └── PluginList.tsx
|
|
||||||
├── README.md
|
├── README.md
|
||||||
└── start.sh
|
└── start.sh
|
||||||
```
|
```
|
||||||
|
|||||||
+300
-36
@@ -1,8 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
OPLabs Mixer App — FastAPI Backend
|
OPLabs Mixer App - FastAPI Backend
|
||||||
|
|
||||||
Serves the React SPA and proxies WebSocket connections to PiPedal.
|
Serves the React SPA and provides REST/WebSocket access to the
|
||||||
Provides REST API for plugin discovery and scene management.
|
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
|
import asyncio
|
||||||
@@ -21,6 +25,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from mixer_ipc import MixerIpcClient, MixerIpcError
|
||||||
from ws_proxy import PiPedalWSProxy
|
from ws_proxy import PiPedalWSProxy
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
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_PORT = 8080
|
||||||
PIPEDAL_WS_PATH = "/pipedal"
|
PIPEDAL_WS_PATH = "/pipedal"
|
||||||
|
|
||||||
|
MIXER_SOCKET_PATH = "/tmp/oplabs-mixer.sock"
|
||||||
|
|
||||||
# OPLabs plugin URIs we care about
|
# OPLabs plugin URIs we care about
|
||||||
OPLABS_PLUGIN_URIS = {
|
OPLABS_PLUGIN_URIS = {
|
||||||
"http://ourpad.casa/plugins/oplabs-band-channel",
|
"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"
|
SCENES_FILE = Path(__file__).resolve().parent / "scenes.json"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Global proxy state
|
# Global state
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pipedal_proxy: Optional[PiPedalWSProxy] = None
|
pipedal_proxy: Optional[PiPedalWSProxy] = None
|
||||||
@@ -56,6 +63,10 @@ pipedal_state: dict = {
|
|||||||
"oplabs_instances": [], # discovered OPLabs plugin instances
|
"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
|
# 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):
|
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.
|
"""Connect to PiPedal WS, send a request, wait for reply, then disconnect."""
|
||||||
Handles interleaved push messages by discarding non-matching messages."""
|
|
||||||
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
|
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
|
||||||
reply_to = hash(message_name + str(time.time())) & 0x7FFFFFFF
|
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:
|
try:
|
||||||
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
|
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
|
# Send hello first to register as subscriber
|
||||||
hello_reply_to = 0x10000001
|
hello_reply_to = 0x10000001
|
||||||
hello_msg = json.dumps([{"message": "hello", "replyTo": hello_reply_to}])
|
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]:
|
def discover_oplabs_from_pedalboard(pedalboard: dict) -> list[dict]:
|
||||||
"""Extract OPLabs plugin instances from a pedalboard state dict.
|
"""Extract OPLabs plugin instances from a pedalboard state dict."""
|
||||||
Handles split items (which contain nested sub-items)."""
|
|
||||||
instances = []
|
instances = []
|
||||||
if not pedalboard:
|
if not pedalboard:
|
||||||
return instances
|
return instances
|
||||||
@@ -200,6 +211,28 @@ def fetch_pedalboard_state():
|
|||||||
return False
|
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
|
# FastAPI app
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -207,10 +240,26 @@ def fetch_pedalboard_state():
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Startup / shutdown."""
|
"""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)
|
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
|
yield
|
||||||
|
|
||||||
|
# Shutdown
|
||||||
|
if mixer_client:
|
||||||
|
await mixer_client.close()
|
||||||
if pipedal_proxy:
|
if pipedal_proxy:
|
||||||
await pipedal_proxy.close_upstream()
|
await pipedal_proxy.close_upstream()
|
||||||
logger.info("Mixer app shutting down")
|
logger.info("Mixer app shutting down")
|
||||||
@@ -218,13 +267,158 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="OPLabs Mixer App",
|
title="OPLabs Mixer App",
|
||||||
version="0.1.0",
|
version="0.2.0",
|
||||||
lifespan=lifespan,
|
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):
|
class SceneStateChannel(BaseModel):
|
||||||
@@ -248,14 +442,14 @@ class SceneUpdate(BaseModel):
|
|||||||
|
|
||||||
@app.get("/api/scenes")
|
@app.get("/api/scenes")
|
||||||
async def list_scenes():
|
async def list_scenes():
|
||||||
"""List all saved scenes."""
|
"""List all saved scenes (legacy file-based)."""
|
||||||
scenes = _load_scenes()
|
scenes = _load_scenes()
|
||||||
return {"scenes": scenes}
|
return {"scenes": scenes}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/scenes")
|
@app.post("/api/scenes")
|
||||||
async def create_scene(scene: SceneCreate):
|
async def create_scene(scene: SceneCreate):
|
||||||
"""Save a new scene."""
|
"""Save a new scene (legacy file-based)."""
|
||||||
scenes = _load_scenes()
|
scenes = _load_scenes()
|
||||||
new_scene = {
|
new_scene = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
@@ -298,9 +492,7 @@ async def delete_scene(scene_id: str):
|
|||||||
|
|
||||||
@app.post("/api/scenes/{scene_id}/recall")
|
@app.post("/api/scenes/{scene_id}/recall")
|
||||||
async def recall_scene(scene_id: str):
|
async def recall_scene(scene_id: str):
|
||||||
"""Recall a scene by sending setControlValue messages to PiPedal for each
|
"""Recall a scene via PiPedal."""
|
||||||
channel and bus parameter. Connects fresh to PiPedal and sends sequenced
|
|
||||||
messages."""
|
|
||||||
scenes = _load_scenes()
|
scenes = _load_scenes()
|
||||||
scene = None
|
scene = None
|
||||||
for sc in scenes:
|
for sc in scenes:
|
||||||
@@ -314,7 +506,6 @@ async def recall_scene(scene_id: str):
|
|||||||
channels = state.get("channels", [])
|
channels = state.get("channels", [])
|
||||||
buses = state.get("buses", [])
|
buses = state.get("buses", [])
|
||||||
|
|
||||||
# Build all setControlValue messages
|
|
||||||
messages: list[dict] = []
|
messages: list[dict] = []
|
||||||
for ch in channels:
|
for ch in channels:
|
||||||
instance_id = ch["instanceId"]
|
instance_id = ch["instanceId"]
|
||||||
@@ -334,14 +525,11 @@ async def recall_scene(scene_id: str):
|
|||||||
if not messages:
|
if not messages:
|
||||||
return {"status": "recalled", "message_count": 0}
|
return {"status": "recalled", "message_count": 0}
|
||||||
|
|
||||||
# Connect to PiPedal and send sequenced messages
|
|
||||||
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
|
url = f"ws://{PIPEDAL_HOST}:{PIPEDAL_PORT}{PIPEDAL_WS_PATH}"
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
|
async with websockets.connect(url, ping_interval=30, ping_timeout=10, close_timeout=5) as ws:
|
||||||
# Send hello
|
|
||||||
hello_reply_to = 0x10000001
|
hello_reply_to = 0x10000001
|
||||||
await ws.send(json.dumps([{"message": "hello", "replyTo": hello_reply_to}]))
|
await ws.send(json.dumps([{"message": "hello", "replyTo": hello_reply_to}]))
|
||||||
# Wait for hello reply (discard push messages)
|
|
||||||
deadline = time.time() + 10.0
|
deadline = time.time() + 10.0
|
||||||
got_hello = False
|
got_hello = False
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
@@ -357,7 +545,6 @@ async def recall_scene(scene_id: str):
|
|||||||
logger.warning("Recall: hello reply not received")
|
logger.warning("Recall: hello reply not received")
|
||||||
return JSONResponse({"error": "PiPedal not responding"}, status_code=502)
|
return JSONResponse({"error": "PiPedal not responding"}, status_code=502)
|
||||||
|
|
||||||
# Send each message with a small delay to let PiPedal process in order
|
|
||||||
sent = 0
|
sent = 0
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
reply_to = (hash(str(msg)) & 0x7FFFFFFF)
|
reply_to = (hash(str(msg)) & 0x7FFFFFFF)
|
||||||
@@ -367,11 +554,10 @@ async def recall_scene(scene_id: str):
|
|||||||
])
|
])
|
||||||
await ws.send(wire_msg)
|
await ws.send(wire_msg)
|
||||||
sent += 1
|
sent += 1
|
||||||
# Small delay between messages to ensure ordering
|
|
||||||
if sent % 5 == 0:
|
if sent % 5 == 0:
|
||||||
await asyncio.sleep(0.01)
|
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}
|
return {"status": "recalled", "message_count": sent}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Recall failed: %s", 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")
|
@app.get("/api/discover")
|
||||||
async def discover_plugins():
|
async def discover_plugins():
|
||||||
"""Discover OPLabs plugin instances from PiPedal's current pedalboard."""
|
"""Discover OPLabs plugin instances from PiPedal."""
|
||||||
ok = fetch_pedalboard_state()
|
ok = fetch_pedalboard_state()
|
||||||
return {
|
return {
|
||||||
"connected": pipedal_state["connected"],
|
"connected": pipedal_state["connected"],
|
||||||
@@ -403,37 +589,39 @@ async def list_pipedal_plugins():
|
|||||||
|
|
||||||
@app.get("/api/ping")
|
@app.get("/api/ping")
|
||||||
async def ping():
|
async def ping():
|
||||||
"""Health check + connection status."""
|
"""Health check."""
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"pipedal_host": PIPEDAL_HOST,
|
"pipedal_host": PIPEDAL_HOST,
|
||||||
"pipedal_port": PIPEDAL_PORT,
|
"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")
|
@app.websocket("/ws/pipedal")
|
||||||
async def websocket_proxy(ws: WebSocket):
|
async def websocket_proxy(ws: WebSocket):
|
||||||
"""Proxy WebSocket: browser ↔ local FastAPI ↔ PiPedal."""
|
"""Proxy WebSocket: browser <-> local FastAPI <-> PiPedal."""
|
||||||
await ws.accept()
|
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)
|
proxy = PiPedalWSProxy(host=PIPEDAL_HOST, port=PIPEDAL_PORT, path=PIPEDAL_WS_PATH)
|
||||||
|
|
||||||
connected = await proxy.connect_upstream()
|
connected = await proxy.connect_upstream()
|
||||||
if not connected:
|
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()
|
await ws.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
pipedal_state["connected"] = True
|
pipedal_state["connected"] = True
|
||||||
|
|
||||||
# Notify browser that upstream is connected
|
|
||||||
await ws.send_json({"type": "connected", "pipedal": proxy.upstream_url})
|
await ws.send_json({"type": "connected", "pipedal": proxy.upstream_url})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -447,6 +635,83 @@ async def websocket_proxy(ws: WebSocket):
|
|||||||
await proxy.close_upstream()
|
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)
|
# Serve SPA (must be last, after all API routes)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -458,9 +723,8 @@ if FRONTEND_DIR.exists():
|
|||||||
|
|
||||||
@app.get("/{full_path:path}")
|
@app.get("/{full_path:path}")
|
||||||
async def serve_spa(full_path: str):
|
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/"):
|
if full_path.startswith("api/") or full_path.startswith("ws/"):
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
return JSONResponse({"error": "not found"}, status_code=404)
|
return JSONResponse({"error": "not found"}, status_code=404)
|
||||||
if not SPA_INDEX.exists():
|
if not SPA_INDEX.exists():
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""
|
||||||
|
JACK port management endpoints.
|
||||||
|
|
||||||
|
Provides REST API for querying and managing JACK audio port connections
|
||||||
|
via the PiPedal WebSocket API. PiPedal runs on a separate machine (the Pi)
|
||||||
|
and manages JACK internally, so all JACK operations go through PiPedal's WS.
|
||||||
|
|
||||||
|
PiPedal JACK message name notes:
|
||||||
|
The message names below are based on common PiPedal / mod-host conventions.
|
||||||
|
Adjust MSG_* constants if PiPedal uses different names in your version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger("jack-endpoints")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PiPedal JACK message names — adjust these if PiPedal uses different names
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Message to list all JACK ports (returns port list with types/directions)
|
||||||
|
MSG_LIST_PORTS = "getAudioPorts"
|
||||||
|
# Message to list current JACK connections (returns port→connected mapping)
|
||||||
|
MSG_LIST_CONNECTIONS = "getAudioConnections"
|
||||||
|
# Message to connect two JACK ports: body = {"source": "...", "destination": "..."}
|
||||||
|
MSG_CONNECT = "connectPorts"
|
||||||
|
# Message to disconnect two JACK ports: body = {"source": "...", "destination": "..."}
|
||||||
|
MSG_DISCONNECT = "disconnectPorts"
|
||||||
|
# Alternative message names to try if the primary ones don't work
|
||||||
|
ALT_MSG_LIST_PORTS = ["getPorts", "jack_get_ports", "listAudioPorts"]
|
||||||
|
ALT_MSG_LIST_CONNECTIONS = ["getConnections", "jack_get_connections", "listAudioConnections"]
|
||||||
|
ALT_MSG_CONNECT = ["jack_connect", "connectAudioPorts"]
|
||||||
|
ALT_MSG_DISCONNECT = ["jack_disconnect", "disconnectAudioPorts"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class JackPortModel(BaseModel):
|
||||||
|
"""A single JACK port."""
|
||||||
|
name: str # full JACK port name, e.g. "system:capture_1"
|
||||||
|
client: str # client name, e.g. "system"
|
||||||
|
port: str # port short name, e.g. "capture_1"
|
||||||
|
port_type: str # "audio" | "midi"
|
||||||
|
direction: str # "output" = audio source (produces audio), "input" = destination (receives audio)
|
||||||
|
is_physical: bool = False
|
||||||
|
connections: list[str] = [] # names of connected ports
|
||||||
|
|
||||||
|
|
||||||
|
class JackConnectRequest(BaseModel):
|
||||||
|
source: str # source port name (output)
|
||||||
|
destination: str # destination port name (input)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: try calling PiPedal with a message name, falling back to alternatives
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Import pipedal_request lazily — it's defined in app.py which imports us
|
||||||
|
_pipedal_request = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_pipedal_request():
|
||||||
|
"""Get the pipedal_request function, importing from app on first call."""
|
||||||
|
global _pipedal_request
|
||||||
|
if _pipedal_request is None:
|
||||||
|
from app import pipedal_request
|
||||||
|
_pipedal_request = pipedal_request
|
||||||
|
return _pipedal_request
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_pipedal(message: str, body=None, alt_messages: list[str] | None = None) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Call PiPedal with *message*, falling back to *alt_messages* if None is returned.
|
||||||
|
|
||||||
|
Returns the response body dict, or None if all variants failed.
|
||||||
|
"""
|
||||||
|
req = await _get_pipedal_request()
|
||||||
|
result = await req(message, body)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Try alternates
|
||||||
|
if alt_messages:
|
||||||
|
for alt in alt_messages:
|
||||||
|
result = await req(alt, body)
|
||||||
|
if result is not None:
|
||||||
|
logger.info("Fell back to alternative message: %s (primary was %s)", alt, message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Port / connection parsing (handles various PiPedal response shapes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ports_from_pedalboard() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Derive port information from the cached pedalboard state as a fallback
|
||||||
|
when PiPedal JACK port listing is unavailable.
|
||||||
|
"""
|
||||||
|
from app import pipedal_state, discover_oplabs_from_pedalboard
|
||||||
|
|
||||||
|
ports = []
|
||||||
|
|
||||||
|
# System physical ports (generic — actual ports depend on audio interface)
|
||||||
|
ports.append({
|
||||||
|
"name": "system:capture_1",
|
||||||
|
"client": "system",
|
||||||
|
"port": "capture_1",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "output",
|
||||||
|
"is_physical": True,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
ports.append({
|
||||||
|
"name": "system:capture_2",
|
||||||
|
"client": "system",
|
||||||
|
"port": "capture_2",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "output",
|
||||||
|
"is_physical": True,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
ports.append({
|
||||||
|
"name": "system:playback_1",
|
||||||
|
"client": "system",
|
||||||
|
"port": "playback_1",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "input",
|
||||||
|
"is_physical": True,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
ports.append({
|
||||||
|
"name": "system:playback_2",
|
||||||
|
"client": "system",
|
||||||
|
"port": "playback_2",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "input",
|
||||||
|
"is_physical": True,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# PiPedal client ports (derived from pedalboard state)
|
||||||
|
pb = pipedal_state.get("pedalboard") or {}
|
||||||
|
items = pb.get("items") or []
|
||||||
|
seen_clients = set()
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
uri = item.get("uri", "")
|
||||||
|
instance_id = item.get("instanceId")
|
||||||
|
title = item.get("title", "") or item.get("pluginName", "") or f"plugin_{instance_id}"
|
||||||
|
client_name = f"pipedal:{instance_id}" if instance_id else f"pipedal:{title}"
|
||||||
|
|
||||||
|
if client_name in seen_clients:
|
||||||
|
continue
|
||||||
|
seen_clients.add(client_name)
|
||||||
|
|
||||||
|
audio_ins = item.get("audioInputs", 2) or 2
|
||||||
|
audio_outs = item.get("audioOutputs", 2) or 2
|
||||||
|
|
||||||
|
# Input ports (the plugin's audio inputs = JACK destinations)
|
||||||
|
for j in range(audio_ins):
|
||||||
|
pname = f"{client_name}:audio_in_{j + 1}"
|
||||||
|
ports.append({
|
||||||
|
"name": pname,
|
||||||
|
"client": client_name,
|
||||||
|
"port": f"audio_in_{j + 1}",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "input",
|
||||||
|
"is_physical": False,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Output ports (the plugin's audio outputs = JACK sources)
|
||||||
|
for j in range(audio_outs):
|
||||||
|
pname = f"{client_name}:audio_out_{j + 1}"
|
||||||
|
ports.append({
|
||||||
|
"name": pname,
|
||||||
|
"client": client_name,
|
||||||
|
"port": f"audio_out_{j + 1}",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "output",
|
||||||
|
"is_physical": False,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Generic PiPedal engine output ports
|
||||||
|
engine_client = "pipedal"
|
||||||
|
for j in range(2):
|
||||||
|
pname = f"{engine_client}:out_{j + 1}"
|
||||||
|
ports.append({
|
||||||
|
"name": pname,
|
||||||
|
"client": engine_client,
|
||||||
|
"port": f"out_{j + 1}",
|
||||||
|
"port_type": "audio",
|
||||||
|
"direction": "output",
|
||||||
|
"is_physical": False,
|
||||||
|
"connections": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_port_entry(raw: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Normalize a port entry from PiPedal's response to our standard format.
|
||||||
|
Handles various key naming conventions.
|
||||||
|
"""
|
||||||
|
name = raw.get("name") or raw.get("portName") or raw.get("fullName") or ""
|
||||||
|
client = raw.get("client") or raw.get("clientName") or name.split(":")[0] if ":" in name else ""
|
||||||
|
port = raw.get("port") or raw.get("shortName") or (name.split(":", 1)[1] if ":" in name else name)
|
||||||
|
port_type = raw.get("type") or raw.get("portType") or "audio"
|
||||||
|
direction = raw.get("direction") or raw.get("mode") or "output"
|
||||||
|
is_physical = raw.get("isPhysical") or raw.get("physical") or raw.get("is_physical") or False
|
||||||
|
connections = raw.get("connections") or raw.get("connectedTo") or []
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"client": client,
|
||||||
|
"port": port,
|
||||||
|
"port_type": port_type,
|
||||||
|
"direction": direction,
|
||||||
|
"is_physical": bool(is_physical),
|
||||||
|
"connections": connections if isinstance(connections, list) else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/jack", tags=["jack"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ports")
|
||||||
|
async def list_jack_ports():
|
||||||
|
"""
|
||||||
|
List all JACK audio ports and their current connections.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"ports": [...], # list of port dicts
|
||||||
|
"connections": {...}, # port_name → [connected_port_names]
|
||||||
|
"source": "pipedal" | "fallback"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Try PiPedal for real port data
|
||||||
|
ports_data = await _call_pipedal(
|
||||||
|
MSG_LIST_PORTS,
|
||||||
|
alt_messages=ALT_MSG_LIST_PORTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
connections_data = await _call_pipedal(
|
||||||
|
MSG_LIST_CONNECTIONS,
|
||||||
|
alt_messages=ALT_MSG_LIST_CONNECTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ports_data is not None:
|
||||||
|
# Normalize port entries
|
||||||
|
raw_ports = ports_data if isinstance(ports_data, list) else (ports_data.get("ports") or ports_data.get("items") or [])
|
||||||
|
ports = [_normalize_port_entry(p) for p in raw_ports]
|
||||||
|
|
||||||
|
# Merge connection info
|
||||||
|
if connections_data:
|
||||||
|
conn_map = connections_data
|
||||||
|
if isinstance(connections_data, dict):
|
||||||
|
conn_map = connections_data.get("connections") or connections_data.get("items") or connections_data
|
||||||
|
for port in ports:
|
||||||
|
pname = port["name"]
|
||||||
|
port["connections"] = conn_map.get(pname) or conn_map.get(pname, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ports": ports,
|
||||||
|
"connections": _build_connection_map(ports),
|
||||||
|
"source": "pipedal",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback: derive from cached pedalboard state
|
||||||
|
from app import pipedal_state
|
||||||
|
if pipedal_state.get("pedalboard"):
|
||||||
|
ports = _parse_ports_from_pedalboard()
|
||||||
|
return {
|
||||||
|
"ports": ports,
|
||||||
|
"connections": _build_connection_map(ports),
|
||||||
|
"source": "fallback",
|
||||||
|
"note": "PiPedal JACK API unavailable — ports derived from pedalboard state, connections unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
# No data available at all
|
||||||
|
return {
|
||||||
|
"ports": [],
|
||||||
|
"connections": {},
|
||||||
|
"source": "none",
|
||||||
|
"note": "PiPedal is unreachable. No JACK port information available.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ports/refresh")
|
||||||
|
async def refresh_jack_ports():
|
||||||
|
"""
|
||||||
|
Force-refresh the pedalboard state and re-query JACK ports.
|
||||||
|
"""
|
||||||
|
from app import fetch_pedalboard_state
|
||||||
|
fetch_pedalboard_state()
|
||||||
|
return await list_jack_ports()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect")
|
||||||
|
async def connect_ports(req: JackConnectRequest):
|
||||||
|
"""
|
||||||
|
Connect two JACK ports (source → destination).
|
||||||
|
Body: {"source": "...", "destination": "..."}
|
||||||
|
"""
|
||||||
|
result = await _call_pipedal(
|
||||||
|
MSG_CONNECT,
|
||||||
|
body={"source": req.source, "destination": req.destination},
|
||||||
|
alt_messages=ALT_MSG_CONNECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
# If connect message failed, try as positional args
|
||||||
|
result = await _call_pipedal(
|
||||||
|
MSG_CONNECT,
|
||||||
|
body=[req.source, req.destination],
|
||||||
|
alt_messages=ALT_MSG_CONNECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
logger.info("JACK connect: %s → %s", req.source, req.destination)
|
||||||
|
return {"status": "connected", "source": req.source, "destination": req.destination}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Failed to connect {req.source} → {req.destination}: PiPedal did not respond",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disconnect")
|
||||||
|
async def disconnect_ports(req: JackConnectRequest):
|
||||||
|
"""
|
||||||
|
Disconnect two JACK ports.
|
||||||
|
Body: {"source": "...", "destination": "..."}
|
||||||
|
"""
|
||||||
|
result = await _call_pipedal(
|
||||||
|
MSG_DISCONNECT,
|
||||||
|
body={"source": req.source, "destination": req.destination},
|
||||||
|
alt_messages=ALT_MSG_DISCONNECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
result = await _call_pipedal(
|
||||||
|
MSG_DISCONNECT,
|
||||||
|
body=[req.source, req.destination],
|
||||||
|
alt_messages=ALT_MSG_DISCONNECT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
logger.info("JACK disconnect: %s → %s", req.source, req.destination)
|
||||||
|
return {"status": "disconnected", "source": req.source, "destination": req.destination}
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Failed to disconnect {req.source} → {req.destination}: PiPedal did not respond",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disconnect-all")
|
||||||
|
async def disconnect_all():
|
||||||
|
"""
|
||||||
|
Disconnect all JACK port connections.
|
||||||
|
"""
|
||||||
|
ports_data = await list_jack_ports()
|
||||||
|
conns = ports_data.get("connections", {})
|
||||||
|
disconnected = 0
|
||||||
|
|
||||||
|
for src, dests in conns.items():
|
||||||
|
for dst in dests:
|
||||||
|
try:
|
||||||
|
await _call_pipedal(
|
||||||
|
MSG_DISCONNECT,
|
||||||
|
body={"source": src, "destination": dst},
|
||||||
|
alt_messages=ALT_MSG_DISCONNECT,
|
||||||
|
)
|
||||||
|
disconnected += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "disconnected_all", "count": disconnected}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _build_connection_map(ports: list[dict]) -> dict[str, list[str]]:
|
||||||
|
"""Build a port_name → [connected_port_names] map from port list."""
|
||||||
|
conn_map = {}
|
||||||
|
for p in ports:
|
||||||
|
pname = p["name"]
|
||||||
|
if p["connections"]:
|
||||||
|
conn_map[pname] = p["connections"]
|
||||||
|
# Also add reverse entries
|
||||||
|
for c in p.get("connections", []):
|
||||||
|
if c not in conn_map:
|
||||||
|
conn_map[c] = []
|
||||||
|
if pname not in conn_map[c]:
|
||||||
|
conn_map[c].append(pname)
|
||||||
|
return conn_map
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
OPLabs Mixer — Unix domain socket IPC client.
|
||||||
|
|
||||||
|
Connects to the C++ MixerDaemon over a Unix domain socket using a
|
||||||
|
length-prefixed JSON protocol.
|
||||||
|
|
||||||
|
Protocol:
|
||||||
|
Frame: 4-byte big-endian uint32 length prefix + UTF-8 JSON payload
|
||||||
|
|
||||||
|
Request: {"id": <int>, "method": <string>, "params": {<object>}}
|
||||||
|
Response: {"id": <int>, "result": <any>, "error": <string|null>}
|
||||||
|
Push: {"type": "push", "event": <string>, "data": {<object>}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import struct
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("mixer-ipc")
|
||||||
|
|
||||||
|
|
||||||
|
class MixerIpcError(Exception):
|
||||||
|
"""Raised when the mixer daemon returns an error response."""
|
||||||
|
|
||||||
|
|
||||||
|
class MixerIpcClient:
|
||||||
|
"""Async Unix socket IPC client for the OPLabs Mixer Daemon.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
client = MixerIpcClient(socket_path="/tmp/oplabs-mixer.sock")
|
||||||
|
await client.connect()
|
||||||
|
state = await client.get_state()
|
||||||
|
await client.set_channel_volume(0, -6.0)
|
||||||
|
await client.close()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock",
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
request_timeout: float = 10.0):
|
||||||
|
self._socket_path = socket_path
|
||||||
|
self._connect_timeout = connect_timeout
|
||||||
|
self._request_timeout = request_timeout
|
||||||
|
self._reader: Optional[asyncio.StreamReader] = None
|
||||||
|
self._writer: Optional[asyncio.StreamWriter] = None
|
||||||
|
self._connected = False
|
||||||
|
self._next_id = 1
|
||||||
|
self._pending: dict[int, asyncio.Future] = {}
|
||||||
|
self._read_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Connection lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def connect(self) -> bool:
|
||||||
|
"""Connect to the Unix domain socket. Returns True on success."""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._reader, self._writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_unix_connection(self._socket_path),
|
||||||
|
timeout=self._connect_timeout,
|
||||||
|
)
|
||||||
|
self._connected = True
|
||||||
|
self._read_task = asyncio.create_task(self._read_loop())
|
||||||
|
logger.info("Connected to mixer daemon at %s", self._socket_path)
|
||||||
|
return True
|
||||||
|
except (FileNotFoundError, ConnectionRefusedError, asyncio.TimeoutError,
|
||||||
|
OSError) as exc:
|
||||||
|
logger.warning("Failed to connect to mixer daemon: %s", exc)
|
||||||
|
self._connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the connection."""
|
||||||
|
self._connected = False
|
||||||
|
if self._read_task:
|
||||||
|
self._read_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._read_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._read_task = None
|
||||||
|
if self._writer:
|
||||||
|
try:
|
||||||
|
self._writer.close()
|
||||||
|
await self._writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._writer = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Low-level send / receive
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_frame(self, payload: bytes):
|
||||||
|
"""Send a length-prefixed frame."""
|
||||||
|
length = struct.pack("!I", len(payload))
|
||||||
|
self._writer.write(length + payload)
|
||||||
|
await self._writer.drain()
|
||||||
|
|
||||||
|
async def _read_loop(self):
|
||||||
|
"""Background task: read frames and dispatch responses."""
|
||||||
|
try:
|
||||||
|
while self._connected and self._reader:
|
||||||
|
# Read 4-byte length prefix
|
||||||
|
header = await self._reader.readexactly(4)
|
||||||
|
length = struct.unpack("!I", header)[0]
|
||||||
|
|
||||||
|
if length == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Read JSON payload
|
||||||
|
data = await self._reader.readexactly(length)
|
||||||
|
payload = data.decode("utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Invalid JSON from daemon: %s", payload[:200])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Dispatch
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
if "type" in msg and msg["type"] == "push":
|
||||||
|
# Push message (VU meter updates, state changes)
|
||||||
|
await self._handle_push(msg)
|
||||||
|
elif "id" in msg:
|
||||||
|
# Response to a request
|
||||||
|
req_id = msg["id"]
|
||||||
|
future = self._pending.pop(req_id, None)
|
||||||
|
if future and not future.done():
|
||||||
|
if msg.get("error"):
|
||||||
|
future.set_exception(
|
||||||
|
MixerIpcError(msg["error"])
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
future.set_result(msg.get("result"))
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
logger.info("Mixer daemon connection closed")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Read loop error: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._connected = False
|
||||||
|
# Fail all pending requests
|
||||||
|
for future in self._pending.values():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(MixerIpcError("Connection closed"))
|
||||||
|
self._pending.clear()
|
||||||
|
|
||||||
|
async def _send_request(self, method: str, params: dict = None) -> Any:
|
||||||
|
"""Send a request and wait for the response."""
|
||||||
|
if not self._connected or not self._writer:
|
||||||
|
raise MixerIpcError("Not connected to mixer daemon")
|
||||||
|
|
||||||
|
req_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
"id": req_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params or {},
|
||||||
|
}
|
||||||
|
|
||||||
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
||||||
|
self._pending[req_id] = future
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.dumps(msg).encode("utf-8")
|
||||||
|
await self._send_frame(payload)
|
||||||
|
|
||||||
|
result = await asyncio.wait_for(future, timeout=self._request_timeout)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending.pop(req_id, None)
|
||||||
|
raise MixerIpcError(f"Request '{method}' timed out")
|
||||||
|
|
||||||
|
async def _handle_push(self, msg: dict):
|
||||||
|
"""Handle an unsolicited push message from the daemon.
|
||||||
|
|
||||||
|
Override in subclass or attach a callback.
|
||||||
|
"""
|
||||||
|
event = msg.get("event", "unknown")
|
||||||
|
data = msg.get("data")
|
||||||
|
logger.debug("Push event: %s", event)
|
||||||
|
# Subclasses can override to handle VU meter updates, etc.
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# High-level API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- State ---
|
||||||
|
|
||||||
|
async def get_state(self) -> dict:
|
||||||
|
"""Get the full mixer state (channels, buses, routes, output routes)."""
|
||||||
|
return await self._send_request("getState")
|
||||||
|
|
||||||
|
# --- Channel Control ---
|
||||||
|
|
||||||
|
async def set_channel_volume(self, channel_index: int, volume_db: float):
|
||||||
|
"""Set channel volume in dB (-inf to +12)."""
|
||||||
|
await self._send_request("setChannelVolume", {
|
||||||
|
"channelIndex": channel_index,
|
||||||
|
"volumeDb": volume_db,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def set_channel_pan(self, channel_index: int, pan: float):
|
||||||
|
"""Set channel pan (-1.0 left to +1.0 right)."""
|
||||||
|
await self._send_request("setChannelPan", {
|
||||||
|
"channelIndex": channel_index,
|
||||||
|
"pan": pan,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def set_channel_mute(self, channel_index: int, mute: bool):
|
||||||
|
"""Set channel mute state."""
|
||||||
|
await self._send_request("setChannelMute", {
|
||||||
|
"channelIndex": channel_index,
|
||||||
|
"mute": mute,
|
||||||
|
})
|
||||||
|
|
||||||
|
async def set_channel_solo(self, channel_index: int, solo: bool):
|
||||||
|
"""Set channel solo state."""
|
||||||
|
await self._send_request("setChannelSolo", {
|
||||||
|
"channelIndex": channel_index,
|
||||||
|
"solo": solo,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Channel Lifecycle ---
|
||||||
|
|
||||||
|
async def add_channel(self, physical_input_index: int = 0) -> int:
|
||||||
|
"""Add a new channel. Returns the channel index."""
|
||||||
|
result = await self._send_request("addChannel", {
|
||||||
|
"physicalInputIndex": physical_input_index,
|
||||||
|
})
|
||||||
|
return result.get("channelIndex", -1)
|
||||||
|
|
||||||
|
async def remove_channel(self, channel_index: int):
|
||||||
|
"""Remove a channel by index."""
|
||||||
|
await self._send_request("removeChannel", {
|
||||||
|
"channelIndex": channel_index,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Output Routing ---
|
||||||
|
|
||||||
|
async def get_output_routes(self) -> list[dict]:
|
||||||
|
"""Get the output routing table."""
|
||||||
|
result = await self._send_request("getOutputRoutes")
|
||||||
|
return result.get("routes", [])
|
||||||
|
|
||||||
|
async def set_output_routes(self, routes: list[dict]):
|
||||||
|
"""Set the output routing table.
|
||||||
|
|
||||||
|
Each route dict:
|
||||||
|
{"sourceBusId": int, "sourceStartChannel": int,
|
||||||
|
"targetStartChannel": int, "channels": int}
|
||||||
|
"""
|
||||||
|
await self._send_request("setOutputRoutes", {
|
||||||
|
"routes": routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Scenes ---
|
||||||
|
|
||||||
|
async def save_scene(self, name: str) -> dict:
|
||||||
|
"""Save the current mixer state as a named scene.
|
||||||
|
Returns {"id": int, "name": str}."""
|
||||||
|
return await self._send_request("saveScene", {"name": name})
|
||||||
|
|
||||||
|
async def load_scene(self, scene_id: str) -> bool:
|
||||||
|
"""Load (recall) a scene by its ID. Returns True on success."""
|
||||||
|
result = await self._send_request("loadScene", {"sceneId": scene_id})
|
||||||
|
return result.get("ok", False)
|
||||||
|
|
||||||
|
async def list_scenes(self) -> list[dict]:
|
||||||
|
"""List all available scenes."""
|
||||||
|
result = await self._send_request("listScenes")
|
||||||
|
return result.get("scenes", [])
|
||||||
|
|
||||||
|
async def delete_scene(self, scene_id: str) -> bool:
|
||||||
|
"""Delete a scene by ID. Returns True on success."""
|
||||||
|
result = await self._send_request("deleteScene", {"sceneId": scene_id})
|
||||||
|
return result.get("ok", False)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Convenience context manager
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MixerIpcContext:
|
||||||
|
"""Async context manager for MixerIpcClient."""
|
||||||
|
|
||||||
|
def __init__(self, socket_path: str = "/tmp/oplabs-mixer.sock"):
|
||||||
|
self.client = MixerIpcClient(socket_path=socket_path)
|
||||||
|
|
||||||
|
async def __aenter__(self) -> MixerIpcClient:
|
||||||
|
await self.client.connect()
|
||||||
|
return self.client
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
await self.client.close()
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()) + "\"}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
-1
File diff suppressed because one or more lines are too long
+42
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>OPLabs Mixer</title>
|
<title>OPLabs Mixer</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DDgWDHTC.js"></script>
|
<script type="module" crossorigin src="/assets/index-BixTWz89.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-58I2yFEM.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwT15WG7.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -180,3 +180,33 @@
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 10px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ interface Props {
|
|||||||
onSetControl: (instanceId: number, key: string, value: number) => void;
|
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).
|
* 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
|
* - ch1Vol..ch8Vol, ch1Pan..ch8Pan, ch1Mute..ch8Mute
|
||||||
|
* - ch1Route..ch8Route (routing: 1=Main, 2-8=sub-buses)
|
||||||
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
|
* - ch1LevelL..ch8LevelL, ch1LevelR..ch8LevelR
|
||||||
* - masterVol, masterMute, masterLevelL, masterLevelR
|
* - masterVol, masterMute, masterLevelL, masterLevelR
|
||||||
*/
|
*/
|
||||||
@@ -44,10 +58,12 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
index: idx,
|
index: idx,
|
||||||
vol: cv[`ch${idx}Vol`] ?? 0,
|
vol: cv[`ch${idx}Vol`] ?? 0,
|
||||||
mute: (cv[`ch${idx}Mute`] ?? 0) >= 0.5,
|
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,
|
levelL: cv[`ch${idx}LevelL`] ?? -60,
|
||||||
levelR: cv[`ch${idx}LevelR`] ?? -60,
|
levelR: cv[`ch${idx}LevelR`] ?? -60,
|
||||||
volKey: `ch${idx}Vol` as const,
|
volKey: `ch${idx}Vol` as const,
|
||||||
muteKey: `ch${idx}Mute` 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]
|
[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 (
|
return (
|
||||||
<div className="bus-strip">
|
<div className="bus-strip">
|
||||||
{/* Master section */}
|
{/* Master section */}
|
||||||
@@ -122,7 +146,7 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
<div key={ch.index} className="bus-channel">
|
<div key={ch.index} className="bus-channel">
|
||||||
<span className="bus-channel-label">
|
<span className="bus-channel-label">
|
||||||
{ch.index}
|
Ch {ch.index}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
|
className={`bus-btn mute mini ${ch.mute ? 'active' : ''}`}
|
||||||
@@ -131,6 +155,19 @@ export const BusStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
>
|
>
|
||||||
M
|
M
|
||||||
</button>
|
</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">
|
<div className="bus-channel-meter">
|
||||||
<LevelMeter
|
<LevelMeter
|
||||||
levelL={ch.levelL}
|
levelL={ch.levelL}
|
||||||
|
|||||||
@@ -1,134 +1,327 @@
|
|||||||
import React, { useCallback } from 'react';
|
/**
|
||||||
import type { PluginInstance } from '../../hooks/usePiPedalWS';
|
* ChannelStrip — per-input channel control for the OPLabs Mixer Console.
|
||||||
import type { Instrument } from './InstrumentBadge';
|
*
|
||||||
import { InstrumentBadge, INSTRUMENT_NAMES, INSTRUMENT_COLORS } from './InstrumentBadge';
|
* 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 { LevelMeter } from './LevelMeter';
|
||||||
import './ChannelStrip.css';
|
import './ChannelStrip.css';
|
||||||
|
|
||||||
interface Props {
|
// ── Types ──────────────────────────────────────────────────────────
|
||||||
plugin: PluginInstance;
|
|
||||||
/** Callback to set a control value on the plugin */
|
/** Channel type matching the daemon's MixerChannelType enum. */
|
||||||
onSetControl: (instanceId: number, key: string, value: number) => void;
|
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 (20–400). */
|
||||||
|
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).
|
* Map slider position (0–100) to dB value (-60..+6).
|
||||||
* 75% position = 0 dB (unity).
|
* 75% position = 0 dB (unity), logarithmic taper.
|
||||||
*/
|
*/
|
||||||
function posToDb(pos: number): number {
|
function posToDb(pos: number): number {
|
||||||
if (pos <= 0) return -60;
|
if (pos <= 0) return -60;
|
||||||
if (pos >= 1) return 6;
|
if (pos >= 100) return 6;
|
||||||
// Logarithmic taper: 0.75 = 0 dB
|
const scaled = (pos / 100) / 0.75;
|
||||||
const scaled = pos / 0.75;
|
|
||||||
if (scaled <= 0.001) return -60;
|
if (scaled <= 0.001) return -60;
|
||||||
const db = 20 * Math.log10(scaled);
|
const db = 20 * Math.log10(scaled);
|
||||||
return Math.max(-60, Math.min(6, db));
|
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 (0–100).
|
||||||
*/
|
*/
|
||||||
function dbToPos(db: number): number {
|
function dbToPos(db: number): number {
|
||||||
if (db <= -60) return 0;
|
if (db <= -60) return 0;
|
||||||
if (db >= 6) return 1;
|
if (db >= 6) return 100;
|
||||||
// Inverse of posToDb
|
|
||||||
const linear = Math.pow(10, db / 20);
|
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 }) => {
|
function formatDb(db: number): string {
|
||||||
const cv = plugin.controlValues;
|
if (db <= -60) return '-∞';
|
||||||
|
return `${db >= 0 ? '+' : ''}${db.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const volume = cv.volume ?? 0;
|
/**
|
||||||
const pan = cv.pan ?? 0;
|
* Format pan for display.
|
||||||
const mute = (cv.mute ?? 0) >= 0.5;
|
*/
|
||||||
const solo = (cv.solo ?? 0) >= 0.5;
|
function formatPan(pan: number): string {
|
||||||
const instrument = (Math.round(cv.instrument ?? 0) || 0) as Instrument;
|
if (pan === 0) return 'C';
|
||||||
const levelL = cv.levelL ?? -60;
|
if (pan < 0) return `L${Math.abs(Math.round(pan * 100))}`;
|
||||||
const levelR = cv.levelR ?? -60;
|
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>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const pos = parseFloat(e.target.value) / 100;
|
const db = posToDb(parseFloat(e.target.value));
|
||||||
const db = posToDb(pos);
|
onChangeVolume(instanceId, parseFloat(db.toFixed(1)));
|
||||||
onSetControl(plugin.instanceId, 'volume', parseFloat(db.toFixed(1)));
|
|
||||||
},
|
},
|
||||||
[plugin.instanceId, onSetControl]
|
[instanceId, onChangeVolume],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePanChange = useCallback(
|
const handlePanChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const val = parseFloat(e.target.value);
|
onChangePan(instanceId, parseFloat(e.target.value));
|
||||||
onSetControl(plugin.instanceId, 'pan', val);
|
|
||||||
},
|
},
|
||||||
[plugin.instanceId, onSetControl]
|
[instanceId, onChangePan],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
const handleMuteClick = useCallback(() => {
|
||||||
onSetControl(plugin.instanceId, 'mute', mute ? 0 : 1);
|
onToggleMute(instanceId, !mute);
|
||||||
}, [plugin.instanceId, mute, onSetControl]);
|
}, [instanceId, mute, onToggleMute]);
|
||||||
|
|
||||||
const toggleSolo = useCallback(() => {
|
const handleSoloClick = useCallback(() => {
|
||||||
onSetControl(plugin.instanceId, 'solo', solo ? 0 : 1);
|
onToggleSolo(instanceId, !solo);
|
||||||
}, [plugin.instanceId, solo, onSetControl]);
|
}, [instanceId, solo, onToggleSolo]);
|
||||||
|
|
||||||
const handleInstrumentChange = useCallback(
|
const handleTypeChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
const val = parseInt(e.target.value, 10);
|
onChangeChannelType(instanceId, e.target.value as ChannelType);
|
||||||
onSetControl(plugin.instanceId, 'instrument', val);
|
|
||||||
},
|
},
|
||||||
[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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="channel-strip"
|
className="channel-strip"
|
||||||
style={{
|
style={{ '--channel-accent': accentColor } as React.CSSProperties}
|
||||||
borderTopColor: instrumentColor,
|
|
||||||
'--channel-accent': instrumentColor,
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
>
|
||||||
{/* Header: channel label + instrument */}
|
{/* ── Header: editable label + type badge ── */}
|
||||||
<div className="channel-header">
|
<div className="channel-header">
|
||||||
<span className="channel-label" title={plugin.title || plugin.pluginName}>
|
{editingLabel ? (
|
||||||
{plugin.title || plugin.pluginName || `Ch ${plugin.instanceId}`}
|
<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>
|
</span>
|
||||||
<InstrumentBadge instrument={instrument} size="small" />
|
|
||||||
</div>
|
</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">
|
<div className="channel-mute-solo">
|
||||||
<button
|
<button
|
||||||
className={`channel-btn mute ${mute ? 'active' : ''}`}
|
className={`channel-btn mute ${mute ? 'active' : ''}`}
|
||||||
onClick={toggleMute}
|
onClick={handleMuteClick}
|
||||||
title="Mute"
|
title={mute ? 'Unmute' : 'Mute'}
|
||||||
>
|
>
|
||||||
M
|
M
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`channel-btn solo ${solo ? 'active' : ''}`}
|
className={`channel-btn solo ${solo ? 'active' : ''}`}
|
||||||
onClick={toggleSolo}
|
onClick={handleSoloClick}
|
||||||
title="Solo"
|
title={solo ? 'Un-solo' : 'Solo'}
|
||||||
>
|
>
|
||||||
S
|
S
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Level Meter */}
|
{/* ── Pan Control ── */}
|
||||||
<div className="channel-meter-section">
|
|
||||||
<LevelMeter levelL={levelL} levelR={levelR} mini={false} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pan control */}
|
|
||||||
<div className="channel-pan">
|
<div className="channel-pan">
|
||||||
<label className="channel-pan-label">Pan</label>
|
<label className="channel-pan-label">Pan</label>
|
||||||
<div className="channel-pan-control">
|
<div className="channel-pan-control">
|
||||||
@@ -140,15 +333,13 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
value={pan}
|
value={pan}
|
||||||
onChange={handlePanChange}
|
onChange={handlePanChange}
|
||||||
className="channel-pan-slider"
|
className="channel-pan-slider"
|
||||||
title={`Pan: ${pan.toFixed(2)}`}
|
title={`Pan: ${formatPan(pan)}`}
|
||||||
/>
|
/>
|
||||||
<span className="channel-pan-value">
|
<span className="channel-pan-value">{formatPan(pan)}</span>
|
||||||
{pan === 0 ? 'C' : pan < 0 ? `L${Math.abs(Math.round(pan * 100))}` : `R${Math.round(pan * 100)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Volume fader */}
|
{/* ── Volume Fader ── */}
|
||||||
<div className="channel-fader-section">
|
<div className="channel-fader-section">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -156,27 +347,25 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
max="100"
|
max="100"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={volumeSliderPos}
|
value={volumeSliderPos}
|
||||||
onChange={handleVolumeChange}
|
onChange={handleVolChange}
|
||||||
className="channel-fader"
|
className="channel-fader"
|
||||||
title={`Volume: ${volume.toFixed(1)} dB`}
|
title={`Volume: ${formatDb(volume)}`}
|
||||||
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
|
{...({ orient: 'vertical' } as React.InputHTMLAttributes<HTMLInputElement>)}
|
||||||
/>
|
/>
|
||||||
<span className="channel-fader-readout">
|
<span className="channel-fader-readout">{formatDb(volume)}</span>
|
||||||
{volume > -60 ? `${volume >= 0 ? '+' : ''}${volume.toFixed(1)}` : '-∞'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instrument selector */}
|
{/* ── Channel Type Selector ── */}
|
||||||
<div className="channel-instrument-selector">
|
<div className="channel-type-selector">
|
||||||
<select
|
<select
|
||||||
value={instrument}
|
value={channelType}
|
||||||
onChange={handleInstrumentChange}
|
onChange={handleTypeChange}
|
||||||
className="channel-instrument-select"
|
className="channel-type-select"
|
||||||
style={{ borderColor: instrumentColor }}
|
style={{ borderColor: accentColor }}
|
||||||
>
|
>
|
||||||
{INSTRUMENT_VALUES.map((val) => (
|
{CHANNEL_TYPES.map((ct) => (
|
||||||
<option key={val} value={val}>
|
<option key={ct} value={ct}>
|
||||||
{INSTRUMENT_NAMES[val]}
|
{CHANNEL_TYPE_LABELS[ct]}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -184,3 +373,5 @@ export const ChannelStrip: React.FC<Props> = ({ plugin, onSetControl }) => {
|
|||||||
</div>
|
</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;
|
opacity: 0.7;
|
||||||
margin-top: 8px !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import type { PluginInstance } from '../../hooks/usePiPedalWS';
|
|||||||
import type { SceneState } from '../../hooks/useScenes';
|
import type { SceneState } from '../../hooks/useScenes';
|
||||||
import { useMixerControls } from '../../hooks/useMixerControls';
|
import { useMixerControls } from '../../hooks/useMixerControls';
|
||||||
import { useScenes } from '../../hooks/useScenes';
|
import { useScenes } from '../../hooks/useScenes';
|
||||||
|
import { useJackPorts } from '../../hooks/useJackPorts';
|
||||||
|
import { PatchBay } from '../PatchBay/PatchBay';
|
||||||
import { ChannelStrip } from './ChannelStrip';
|
import { ChannelStrip } from './ChannelStrip';
|
||||||
import { BusStrip } from './BusStrip';
|
import { BusStrip } from './BusStrip';
|
||||||
|
import { MasterBus } from './MasterBus';
|
||||||
import { SceneManager } from '../Scenes/SceneManager';
|
import { SceneManager } from '../Scenes/SceneManager';
|
||||||
import { ConnectionStatus } from '../ConnectionStatus';
|
import { ConnectionStatus } from '../ConnectionStatus';
|
||||||
import type { ConnectionState } from '../../hooks/usePiPedalWS';
|
import type { ConnectionState } from '../../hooks/usePiPedalWS';
|
||||||
@@ -13,6 +16,10 @@ import './ChannelStrip.css';
|
|||||||
import './BusStrip.css';
|
import './BusStrip.css';
|
||||||
import './LevelMeter.css';
|
import './LevelMeter.css';
|
||||||
import './InstrumentBadge.css';
|
import './InstrumentBadge.css';
|
||||||
|
import '../PatchBay/PatchBay.css';
|
||||||
|
import './LevelMeter.css';
|
||||||
|
import './InstrumentBadge.css';
|
||||||
|
import './MasterBus.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plugins: PluginInstance[];
|
plugins: PluginInstance[];
|
||||||
@@ -24,9 +31,9 @@ interface Props {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Main mixer page layout.
|
* Main mixer page layout.
|
||||||
* - Header with connection status and scene selector
|
* - Header with connection status, PiPedal toggle, scene selector
|
||||||
* - Sidebar of channel strips (left)
|
* - Left pane: channel strips + bus strips
|
||||||
* - Bus meter bridge (right)
|
* - Right pane: PiPedal iframe (when toggled)
|
||||||
* - Scene management sidebar
|
* - Scene management sidebar
|
||||||
*/
|
*/
|
||||||
export const MixerPage: React.FC<Props> = ({
|
export const MixerPage: React.FC<Props> = ({
|
||||||
@@ -46,7 +53,22 @@ export const MixerPage: React.FC<Props> = ({
|
|||||||
recallScene,
|
recallScene,
|
||||||
} = useScenes();
|
} = useScenes();
|
||||||
|
|
||||||
|
const {
|
||||||
|
portsState,
|
||||||
|
refresh: refreshJack,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
disconnectAll,
|
||||||
|
actionState,
|
||||||
|
actionError,
|
||||||
|
} = useJackPorts();
|
||||||
|
|
||||||
const [scenePanelOpen, setScenePanelOpen] = useState(false);
|
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
|
// Separate plugins by type
|
||||||
const channels = useMemo(
|
const channels = useMemo(
|
||||||
@@ -67,6 +89,22 @@ export const MixerPage: React.FC<Props> = ({
|
|||||||
const hasBuses = buses.length > 0;
|
const hasBuses = buses.length > 0;
|
||||||
const hasAny = plugins.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.
|
* Capture current plugin state for saving as a scene.
|
||||||
* Returns channels and buses with their instanceIds and current controlValues.
|
* 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>
|
<span className="mixer-scenes-count">{scenes.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button className="mixer-refresh-btn" onClick={onRefresh}>
|
<button className="mixer-scenes-btn" onClick={onRefresh}>
|
||||||
↻ Refresh
|
↻ Refresh
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content — splits into two panes when PiPedal is shown */}
|
||||||
<div className="mixer-content">
|
<div className={`mixer-content ${showPiPedal ? 'mixer-content-split' : ''}`}>
|
||||||
|
{/* Left pane: mixer controls */}
|
||||||
|
<div className="mixer-content-main">
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{!hasAny && (
|
{!hasAny && (
|
||||||
<div className="mixer-empty">
|
<div className="mixer-empty">
|
||||||
@@ -198,7 +252,54 @@ export const MixerPage: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Master bus — final mix output */}
|
||||||
|
{hasAny && (
|
||||||
|
<div className="mixer-master-section">
|
||||||
|
<div className="mixer-section-header">
|
||||||
|
<h2>Master</h2>
|
||||||
</div>
|
</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 */}
|
{/* Scene Manager sidebar */}
|
||||||
{scenePanelOpen && (
|
{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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 +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"}
|
|
||||||
Reference in New Issue
Block a user