""" 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": , "method": , "params": {}} Response: {"id": , "result": , "error": } Push: {"type": "push", "event": , "data": {}} """ 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()