d19e0ea7a8
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.
306 lines
11 KiB
Python
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()
|