Files
oplabs-mixer-app/backend/mixer_ipc.py
T
shawn d19e0ea7a8 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.
2026-06-23 12:15:09 -04:00

306 lines
11 KiB
Python

"""
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()