feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
15
mcp/protocol/__init__.py
Normal file
15
mcp/protocol/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""FL-MCP Protocol — SysEx encoding/decoding and MIDI transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .sysex import encode_command, decode_command, nibble_encode, nibble_decode, SYSEX_ID
|
||||
from .transport import MidiTransport
|
||||
|
||||
__all__ = [
|
||||
"nibble_encode",
|
||||
"nibble_decode",
|
||||
"encode_command",
|
||||
"decode_command",
|
||||
"SYSEX_ID",
|
||||
"MidiTransport",
|
||||
]
|
||||
65
mcp/protocol/sysex.py
Normal file
65
mcp/protocol/sysex.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
FL-MCP Protocol — SysEx encoding/decoding.
|
||||
|
||||
Protocol: F0 7D [nibble-encoded UTF-8 JSON] F7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
SYSEX_ID = 0x7D
|
||||
|
||||
|
||||
def nibble_encode(data: bytes) -> list[int]:
|
||||
"""Split each byte into two nibbles (high, low) for MIDI data byte compliance (< 0x80).
|
||||
|
||||
Each byte 0x00-0xFF is split into two MIDI-safe bytes:
|
||||
- high nibble: (byte >> 4) & 0x0F
|
||||
- low nibble: byte & 0x0F
|
||||
"""
|
||||
result: list[int] = []
|
||||
for byte in data:
|
||||
result.append((byte >> 4) & 0x0F)
|
||||
result.append(byte & 0x0F)
|
||||
return result
|
||||
|
||||
|
||||
def nibble_decode(nibbles: list[int]) -> bytes:
|
||||
"""Reconstruct bytes from nibble pairs.
|
||||
|
||||
Each pair (high, low) reconstructs one byte:
|
||||
byte = (high << 4) | low
|
||||
"""
|
||||
result = bytearray()
|
||||
for i in range(0, len(nibbles), 2):
|
||||
if i + 1 < len(nibbles):
|
||||
result.append(((nibbles[i] & 0x0F) << 4) | (nibbles[i + 1] & 0x0F))
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def encode_command(cmd: str, params: dict | None = None) -> list[int]:
|
||||
"""Encode a JSON command as a complete SysEx message: F0 7D [nibbles...] F7."""
|
||||
import json
|
||||
|
||||
payload = json.dumps({"cmd": cmd, "params": params or {}})
|
||||
nibbles = nibble_encode(payload.encode("utf-8"))
|
||||
return [0xF0, SYSEX_ID] + nibbles + [0xF7]
|
||||
|
||||
|
||||
def decode_command(data: list[int]) -> dict | None:
|
||||
"""Decode a SysEx message back to JSON dict.
|
||||
|
||||
Returns None if the message is not a valid FL-MCP SysEx message.
|
||||
"""
|
||||
if not data or len(data) < 4 or data[0] != 0xF0 or data[-1] != 0xF7:
|
||||
return None
|
||||
if data[1] != SYSEX_ID:
|
||||
return None
|
||||
import json
|
||||
|
||||
nibbles = data[2:-1]
|
||||
if not nibbles:
|
||||
return None
|
||||
raw = nibble_decode(nibbles)
|
||||
if not raw:
|
||||
return None
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
67
mcp/protocol/transport.py
Normal file
67
mcp/protocol/transport.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""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()}
|
||||
Reference in New Issue
Block a user