"""FL-MCP MIDI Transport using mido.""" from __future__ import annotations import mido from mido import Message from .sysex import encode_command class MidiTransport: """MIDI transport for sending SysEx commands to FL Studio via loopback.""" def __init__(self, port_name: str = "FL_MCP"): self.port_name = port_name self._output: mido.ports.Port | None = None self._input: mido.ports.Port | None = None def connect(self) -> bool: """Find and open the FL_MCP output port.""" ports = mido.get_output_names() # Try exact match first, then partial match: str | None = None for p in ports: if self.port_name in p: match = p break if not match: raise ConnectionError( f"Port '{self.port_name}' not found. Available: {ports}" ) self._output = mido.open_output(match) return True def send_command(self, cmd: str, params: dict | None = None) -> None: """Send a SysEx command to FL Studio.""" if not self._output: self.connect() data = encode_command(cmd, params) # mido.Message('sysex', data=...) automatically wraps with F0/F7 # data[1:-1] skips the F0/SYSEX_ID/F7 wrapper since mido adds them msg = Message("sysex", data=bytes(data[1:-1])) self._output.send(msg) def receive(self, timeout: float = 1.0) -> mido.Message | None: """Receive a MIDI message (blocking with timeout).""" if not self._input: self._input = mido.open_input( next((p for p in mido.get_input_names() if self.port_name in p), None) ) if self._input: return self._input.receive(timeout=timeout) return None def close(self) -> None: """Close all MIDI ports.""" if self._output: self._output.close() self._output = None if self._input: self._input.close() self._input = None @staticmethod def list_ports() -> dict[str, list[str]]: """List all available MIDI input and output ports.""" return {"inputs": mido.get_input_names(), "outputs": mido.get_output_names()}