feat: reggaeton production system with intelligent sample selection and FLP generation
This commit is contained in:
5
mcp/__init__.py
Normal file
5
mcp/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""FL Studio MCP Server — nibble-encoded SysEx over MIDI loopback."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "0.1.0"
|
||||
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()}
|
||||
5
mcp/run.py
Normal file
5
mcp/run.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""FL Studio MCP Server entry point."""
|
||||
from server import mcp
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
356
mcp/server.py
Normal file
356
mcp/server.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
FL Studio MCP Server — FastMCP server with MIDI SysEx transport.
|
||||
|
||||
Sends nibble-encoded JSON commands to FL Studio via Windows MIDI Services loopback.
|
||||
FL Studio controller script receives via OnSysEx() and calls FL Studio API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from protocol.transport import MidiTransport
|
||||
|
||||
mcp = FastMCP("fl-studio-mcp")
|
||||
transport = MidiTransport()
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
"""Print a log message to stderr."""
|
||||
print(f"[fl-studio-mcp] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
# ─── Transport Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def play() -> str:
|
||||
"""Start FL Studio playback."""
|
||||
try:
|
||||
transport.send_command("start_playback")
|
||||
return "Playback started"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def stop() -> str:
|
||||
"""Stop FL Studio playback."""
|
||||
try:
|
||||
transport.send_command("stop_playback")
|
||||
return "Playback stopped"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def record() -> str:
|
||||
"""Start recording in FL Studio."""
|
||||
try:
|
||||
transport.send_command("start_recording")
|
||||
return "Recording started"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_tempo(bpm: float) -> str:
|
||||
"""Set FL Studio tempo (40-999 BPM)."""
|
||||
try:
|
||||
transport.send_command("set_tempo", {"tempo": float(bpm)})
|
||||
return f"Tempo set to {bpm} BPM"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_time_signature(numerator: int = 4, denominator: int = 4) -> str:
|
||||
"""Set time signature."""
|
||||
try:
|
||||
transport.send_command("set_time_signature", {
|
||||
"numerator": int(numerator),
|
||||
"denominator": int(denominator),
|
||||
})
|
||||
return f"Time signature set to {numerator}/{denominator}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─── Channel Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def select_channel(channel_index: int) -> str:
|
||||
"""Select a channel in the channel rack (0-based index)."""
|
||||
try:
|
||||
transport.send_command("select_channel", {"channel_index": int(channel_index)})
|
||||
return f"Channel {channel_index} selected"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mute_channel(channel_index: int, muted: bool = True) -> str:
|
||||
"""Mute or unmute a channel."""
|
||||
try:
|
||||
transport.send_command("mute_channel", {
|
||||
"channel_index": int(channel_index),
|
||||
"muted": bool(muted),
|
||||
})
|
||||
return f"Channel {channel_index} muted={muted}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def solo_channel(channel_index: int) -> str:
|
||||
"""Solo a channel."""
|
||||
try:
|
||||
transport.send_command("solo_channel", {"channel_index": int(channel_index)})
|
||||
return f"Channel {channel_index} soloed"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_channel_volume(channel_index: int, volume: float) -> str:
|
||||
"""Set channel volume (0.0-1.0)."""
|
||||
try:
|
||||
transport.send_command("set_channel_volume", {
|
||||
"channel_index": int(channel_index),
|
||||
"volume": float(volume),
|
||||
})
|
||||
return f"Channel {channel_index} volume={volume}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_channel_pan(channel_index: int, pan: float) -> str:
|
||||
"""Set channel pan (-1.0 to 1.0)."""
|
||||
try:
|
||||
transport.send_command("set_channel_pan", {
|
||||
"channel_index": int(channel_index),
|
||||
"pan": float(pan),
|
||||
})
|
||||
return f"Channel {channel_index} pan={pan}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def note_on(channel_index: int, note: int, velocity: int = 100) -> str:
|
||||
"""Send a note on event."""
|
||||
try:
|
||||
transport.send_command("note_on", {
|
||||
"channel_index": int(channel_index),
|
||||
"note": int(note),
|
||||
"velocity": int(velocity),
|
||||
})
|
||||
return f"Note on: ch={channel_index} note={note} vel={velocity}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def note_off(channel_index: int, note: int) -> str:
|
||||
"""Send a note off event."""
|
||||
try:
|
||||
transport.send_command("note_off", {
|
||||
"channel_index": int(channel_index),
|
||||
"note": int(note),
|
||||
})
|
||||
return f"Note off: ch={channel_index} note={note}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def stop_all_notes() -> str:
|
||||
"""Stop all playing notes (panic)."""
|
||||
try:
|
||||
transport.send_command("stop_all_notes")
|
||||
return "All notes stopped"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─── Mixer Tools ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_mixer_volume(track_index: int, volume: float) -> str:
|
||||
"""Set mixer track volume (0.0-1.25)."""
|
||||
try:
|
||||
transport.send_command("set_mixer_volume", {
|
||||
"track_index": int(track_index),
|
||||
"volume": float(volume),
|
||||
})
|
||||
return f"Mixer track {track_index} volume={volume}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def set_mixer_pan(track_index: int, pan: float) -> str:
|
||||
"""Set mixer track pan (-1.0 to 1.0)."""
|
||||
try:
|
||||
transport.send_command("set_mixer_pan", {
|
||||
"track_index": int(track_index),
|
||||
"pan": float(pan),
|
||||
})
|
||||
return f"Mixer track {track_index} pan={pan}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def mute_mixer_track(track_index: int, muted: bool = True) -> str:
|
||||
"""Mute or unmute a mixer track."""
|
||||
try:
|
||||
transport.send_command("mute_mixer_track", {
|
||||
"track_index": int(track_index),
|
||||
"muted": bool(muted),
|
||||
})
|
||||
return f"Mixer track {track_index} muted={muted}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def solo_mixer_track(track_index: int) -> str:
|
||||
"""Solo a mixer track."""
|
||||
try:
|
||||
transport.send_command("solo_mixer_track", {"track_index": int(track_index)})
|
||||
return f"Mixer track {track_index} soloed"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def rename_mixer_track(track_index: int, name: str) -> str:
|
||||
"""Rename a mixer track."""
|
||||
try:
|
||||
transport.send_command("set_mixer_track_name", {
|
||||
"track_index": int(track_index),
|
||||
"name": str(name),
|
||||
})
|
||||
return f"Mixer track {track_index} renamed to '{name}'"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─── Pattern Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def select_pattern(pattern_index: int) -> str:
|
||||
"""Select a pattern by index."""
|
||||
try:
|
||||
transport.send_command("select_pattern", {"pattern_index": int(pattern_index)})
|
||||
return f"Pattern {pattern_index} selected"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_pattern(name: str) -> str:
|
||||
"""Create a new pattern with the given name."""
|
||||
try:
|
||||
transport.send_command("set_pattern_name", {
|
||||
"pattern_index": 0, # Will create new
|
||||
"name": str(name),
|
||||
})
|
||||
return f"Pattern '{name}' created"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def rename_pattern(pattern_index: int, name: str) -> str:
|
||||
"""Rename a pattern."""
|
||||
try:
|
||||
transport.send_command("set_pattern_name", {
|
||||
"pattern_index": int(pattern_index),
|
||||
"name": str(name),
|
||||
})
|
||||
return f"Pattern {pattern_index} renamed to '{name}'"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─── UI Tools ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def show_channel_rack() -> str:
|
||||
"""Show the FL Studio channel rack window."""
|
||||
try:
|
||||
transport.send_command("show_channel_rack")
|
||||
return "Channel rack shown"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def show_mixer() -> str:
|
||||
"""Show the FL Studio mixer window."""
|
||||
try:
|
||||
transport.send_command("show_mixer")
|
||||
return "Mixer shown"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def show_piano_roll() -> str:
|
||||
"""Show the FL Studio piano roll window."""
|
||||
try:
|
||||
transport.send_command("show_piano_roll")
|
||||
return "Piano roll shown"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def show_playlist() -> str:
|
||||
"""Show the FL Studio playlist window."""
|
||||
try:
|
||||
transport.send_command("show_playlist")
|
||||
return "Playlist shown"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
# ─── Meta Tools ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ping() -> str:
|
||||
"""Ping the FL Studio MCP server to verify connectivity."""
|
||||
try:
|
||||
transport.send_command("ping", {"ts": 1})
|
||||
return "pong"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_ports() -> dict[str, list[str]]:
|
||||
"""List all available MIDI input and output ports."""
|
||||
return MidiTransport.list_ports()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_session_info() -> dict:
|
||||
"""Get FL Studio session information (transport state)."""
|
||||
return {
|
||||
"server": "fl-studio-mcp",
|
||||
"transport": "midi_sysex",
|
||||
"loopback": "FL_MCP",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
26
mcp/tests/quick_test.py
Normal file
26
mcp/tests/quick_test.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Quick validation of the protocol module."""
|
||||
import sys
|
||||
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
|
||||
|
||||
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
|
||||
|
||||
# Test 1: nibble roundtrip
|
||||
for original in [b"Hello", b"", b"\x00\x7f", b'{"cmd":"ping"}']:
|
||||
encoded = nibble_encode(original)
|
||||
decoded = nibble_decode(encoded)
|
||||
assert decoded == original, f"Roundtrip failed for {original}"
|
||||
print("PASS: nibble roundtrip")
|
||||
|
||||
# Test 2: encode/decode command
|
||||
result = encode_command("ping", {"ts": 1})
|
||||
assert result[0] == 0xF0 and result[1] == 0x7D and result[-1] == 0xF7
|
||||
decoded = decode_command(result)
|
||||
assert decoded["cmd"] == "ping"
|
||||
assert decoded["params"] == {"ts": 1}
|
||||
print("PASS: encode/decode command")
|
||||
|
||||
# Test 3: SYSEX_ID
|
||||
assert SYSEX_ID == 0x7D
|
||||
print("PASS: SYSEX_ID == 0x7D")
|
||||
|
||||
print("All unit tests passed!")
|
||||
46
mcp/tests/send_ping.py
Normal file
46
mcp/tests/send_ping.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Send a SysEx ping to FL Studio and check if it arrives."""
|
||||
import mido
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
print("=== FL Studio MCP — Send Ping Test ===")
|
||||
|
||||
# List ports
|
||||
print(f"Inputs: {mido.get_input_names()}")
|
||||
print(f"Outputs: {mido.get_output_names()}")
|
||||
|
||||
# Open FL_MCP 1 output
|
||||
out = mido.open_output("FL_MCP 1")
|
||||
print("Opened FL_MCP 1 output")
|
||||
|
||||
# Encode ping command
|
||||
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
|
||||
nibbles = []
|
||||
for b in payload:
|
||||
nibbles.append((b >> 4) & 0x0F)
|
||||
nibbles.append(b & 0x0F)
|
||||
|
||||
# mido adds F0/F7 automatically, we provide [SYSEX_ID + nibble data]
|
||||
msg = mido.Message("sysex", data=bytes([0x7D] + nibbles))
|
||||
hex_str = " ".join(f"{b:02X}" for b in msg.bytes())
|
||||
print(f"Sending SysEx ({len(msg.bytes())} bytes): {hex_str}")
|
||||
out.send(msg)
|
||||
print("Sent! Check FL Studio script console for: [FL-MCP] Ping received")
|
||||
|
||||
# Also try a play command
|
||||
time.sleep(0.5)
|
||||
payload2 = json.dumps({"cmd": "start_playback", "params": {}}).encode("utf-8")
|
||||
nibbles2 = []
|
||||
for b in payload2:
|
||||
nibbles2.append((b >> 4) & 0x0F)
|
||||
nibbles2.append(b & 0x0F)
|
||||
msg2 = mido.Message("sysex", data=bytes([0x7D] + nibbles2))
|
||||
hex_str2 = " ".join(f"{b:02X}" for b in msg2.bytes())
|
||||
print(f"\nSending PLAY SysEx ({len(msg2.bytes())} bytes): {hex_str2}")
|
||||
out.send(msg2)
|
||||
print("Sent! FL Studio should start playing if controller is loaded.")
|
||||
|
||||
time.sleep(0.5)
|
||||
out.close()
|
||||
print("\nDone. Check FL Studio console and transport state.")
|
||||
125
mcp/tests/test_integration.py
Normal file
125
mcp/tests/test_integration.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Integration tests for FL Studio MCP server.
|
||||
|
||||
Tests nibble encode/decode roundtrip, command encoding, and tool registration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "C:\\Users\\Administrator\\Documents\\fl_control\\mcp")
|
||||
|
||||
from protocol.sysex import nibble_encode, nibble_decode, encode_command, decode_command, SYSEX_ID
|
||||
from protocol.transport import MidiTransport
|
||||
|
||||
|
||||
def test_nibble_encode_decode_roundtrip():
|
||||
"""Test that nibble_encode and nibble_decode are perfect inverses."""
|
||||
test_cases = [
|
||||
b"Hello",
|
||||
b"",
|
||||
b"\x00\x7f\x80\xff",
|
||||
b'{"cmd":"ping","params":{"ts":1}}',
|
||||
b"\xff\xfe\xfd\xfc\xfb",
|
||||
b"A" * 256, # stress test
|
||||
]
|
||||
for original in test_cases:
|
||||
encoded = nibble_encode(original)
|
||||
# Each byte becomes 2 nibbles
|
||||
assert len(encoded) == len(original) * 2, f"Length mismatch for {original!r}"
|
||||
decoded = nibble_decode(encoded)
|
||||
assert decoded == original, f"Roundtrip failed for {original!r}: got {decoded!r}"
|
||||
print("PASS: nibble_encode/decode roundtrip")
|
||||
|
||||
|
||||
def test_encode_command_format():
|
||||
"""Test that encode_command produces valid SysEx format."""
|
||||
# Test 1: Basic command
|
||||
result = encode_command("ping", {"ts": 1})
|
||||
assert result[0] == 0xF0, "Must start with F0"
|
||||
assert result[1] == SYSEX_ID, "Must have SYSEX_ID=0x7D"
|
||||
assert result[-1] == 0xF7, "Must end with F7"
|
||||
print("PASS: encode_command format (basic)")
|
||||
|
||||
# Test 2: Empty params
|
||||
result2 = encode_command("play")
|
||||
assert result2[0] == 0xF0
|
||||
assert result2[1] == SYSEX_ID
|
||||
assert result2[-1] == 0xF7
|
||||
print("PASS: encode_command format (no params)")
|
||||
|
||||
# Test 3: Command decodes back to original JSON
|
||||
original_cmd = "set_tempo"
|
||||
original_params = {"tempo": 140.0}
|
||||
encoded = encode_command(original_cmd, original_params)
|
||||
decoded = decode_command(encoded)
|
||||
assert decoded is not None
|
||||
assert decoded["cmd"] == original_cmd
|
||||
assert decoded["params"] == original_params
|
||||
print("PASS: encode_command → decode_command roundtrip")
|
||||
|
||||
|
||||
def test_decode_command_invalid():
|
||||
"""Test that decode_command returns None for invalid input."""
|
||||
assert decode_command([]) is None
|
||||
assert decode_command([0xF0]) is None # too short
|
||||
assert decode_command([0xF0, 0x7D]) is None # too short
|
||||
assert decode_command([0xF0, 0x00, 0xF7]) is None # wrong ID
|
||||
assert decode_command([0xF0, 0x7D, 0xF7]) is None # empty payload
|
||||
print("PASS: decode_command rejects invalid input")
|
||||
|
||||
|
||||
def test_sysex_id():
|
||||
"""Verify SYSEX_ID is the correct non-commercial experimental ID."""
|
||||
assert SYSEX_ID == 0x7D
|
||||
print("PASS: SYSEX_ID == 0x7D")
|
||||
|
||||
|
||||
def test_miditransport_list_ports():
|
||||
"""Test that MidiTransport.list_ports() works without crashing."""
|
||||
ports = MidiTransport.list_ports()
|
||||
assert "inputs" in ports
|
||||
assert "outputs" in ports
|
||||
assert isinstance(ports["inputs"], list)
|
||||
assert isinstance(ports["outputs"], list)
|
||||
print(f"PASS: list_ports — inputs={ports['inputs']}, outputs={ports['outputs']}")
|
||||
|
||||
|
||||
def test_sysex_protocol_complete_roundtrip():
|
||||
"""Full end-to-end: encode → send模拟 → receive → decode."""
|
||||
# Simulate a complete conversation
|
||||
commands = [
|
||||
("ping", {"ts": 1}),
|
||||
("set_tempo", {"tempo": 92.0}),
|
||||
("select_channel", {"channel_index": 3}),
|
||||
("note_on", {"channel_index": 0, "note": 60, "velocity": 100}),
|
||||
("stop_all_notes", {}),
|
||||
]
|
||||
for cmd, params in commands:
|
||||
encoded = encode_command(cmd, params)
|
||||
# Verify format
|
||||
assert encoded[0] == 0xF0
|
||||
assert encoded[1] == SYSEX_ID
|
||||
assert encoded[-1] == 0xF7
|
||||
# Verify decode
|
||||
decoded = decode_command(encoded)
|
||||
assert decoded is not None
|
||||
assert decoded["cmd"] == cmd
|
||||
assert decoded["params"] == params
|
||||
print("PASS: complete command roundtrip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("FL Studio MCP — Integration Tests")
|
||||
print("==================================\n")
|
||||
|
||||
test_sysex_id()
|
||||
test_nibble_encode_decode_roundtrip()
|
||||
test_encode_command_format()
|
||||
test_decode_command_invalid()
|
||||
test_miditransport_list_ports()
|
||||
test_sysex_protocol_complete_roundtrip()
|
||||
|
||||
print("\nAll tests passed!")
|
||||
199
mcp/tests/test_sysex_loopback.py
Normal file
199
mcp/tests/test_sysex_loopback.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Phase 0: SysEx Loopback Validation Test.
|
||||
|
||||
Tests whether MIDI SysEx messages can travel through the Windows MIDI Services
|
||||
loopback port "FL_MCP" from the MCP server to the FL Studio controller script.
|
||||
|
||||
Usage:
|
||||
python test_sysex_loopback.py
|
||||
|
||||
Requires:
|
||||
- FL Studio running with FL_MCP controller script loaded
|
||||
- Windows MIDI Services loopback ports "FL_MCP 0" (input) and "FL_MCP 1" (output)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
try:
|
||||
import mido
|
||||
except ImportError:
|
||||
print("ERROR: mido not installed. Run: pip install mido")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def send_sysex_raw(output, data):
|
||||
"""Send raw SysEx bytes via mido."""
|
||||
msg = mido.Message("sysex", data=bytes(data))
|
||||
output.send(msg)
|
||||
|
||||
|
||||
def test_simple_sysex():
|
||||
"""Test 1: Send F0 7D 48 69 F7 ("Hi") and expect it back."""
|
||||
print("\n=== Test 1: Simple SysEx Echo ===")
|
||||
output_name = None
|
||||
input_name = None
|
||||
|
||||
for name in mido.get_output_names():
|
||||
if "FL_MCP" in name and "1" in name:
|
||||
output_name = name
|
||||
break
|
||||
for name in mido.get_input_names():
|
||||
if "FL_MCP" in name and "0" in name:
|
||||
input_name = name
|
||||
break
|
||||
|
||||
if not output_name:
|
||||
print("FAIL: FL_MCP output port (FL_MCP 1) not found")
|
||||
print("Available outputs:", mido.get_output_names())
|
||||
return False
|
||||
if not input_name:
|
||||
print("FAIL: FL_MCP input port (FL_MCP 0) not found")
|
||||
print("Available inputs:", mido.get_input_names())
|
||||
return False
|
||||
|
||||
print(f"Output: {output_name}")
|
||||
print(f"Input: {input_name}")
|
||||
|
||||
try:
|
||||
output = mido.open_output(output_name)
|
||||
input_port = mido.open_input(input_name)
|
||||
except Exception as e:
|
||||
print(f"FAIL: Cannot open ports: {e}")
|
||||
return False
|
||||
|
||||
# Send "Hi" ping: F0 7D 48 69 F7
|
||||
# (0x48='H', 0x69='i')
|
||||
ping_data = [0xF0, 0x7D, 0x48, 0x69, 0xF7]
|
||||
print(f"Sending: {' '.join(f'{b:02X}' for b in ping_data)}")
|
||||
send_sysex_raw(output, ping_data[1:-1]) # mido adds F0/F7
|
||||
|
||||
print("Waiting 5s for echo...")
|
||||
timeout = 5.0
|
||||
start = time.time()
|
||||
received = None
|
||||
while time.time() - start < timeout:
|
||||
msg = input_port.receive(timeout=0.1)
|
||||
if msg and msg.type == "sysex":
|
||||
received = list(msg.bytes())
|
||||
break
|
||||
|
||||
output.close()
|
||||
input_port.close()
|
||||
|
||||
if received:
|
||||
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
|
||||
if received[0] == 0xF0 and received[-1] == 0xF7 and received[1] == 0x7D:
|
||||
print("PASS: Simple SysEx loopback works!")
|
||||
return True
|
||||
print(f"UNKNOWN: Received but unexpected bytes: {received}")
|
||||
return False
|
||||
else:
|
||||
print("FAIL: No message received within timeout")
|
||||
return False
|
||||
|
||||
|
||||
def test_json_command():
|
||||
"""Test 2: Send a proper JSON command via nibble encoding."""
|
||||
print("\n=== Test 2: JSON Command SysEx ===")
|
||||
import json
|
||||
|
||||
output_name = None
|
||||
input_name = None
|
||||
for name in mido.get_output_names():
|
||||
if "FL_MCP" in name and "1" in name:
|
||||
output_name = name
|
||||
break
|
||||
for name in mido.get_input_names():
|
||||
if "FL_MCP" in name and "0" in name:
|
||||
input_name = name
|
||||
break
|
||||
|
||||
if not output_name or not input_name:
|
||||
print("SKIP: FL_MCP ports not available for JSON test")
|
||||
return False
|
||||
|
||||
try:
|
||||
output = mido.open_output(output_name)
|
||||
input_port = mido.open_input(input_name)
|
||||
except Exception as e:
|
||||
print(f"SKIP: Cannot open ports: {e}")
|
||||
return False
|
||||
|
||||
# Encode ping command: {"cmd":"ping","params":{"ts":1}}
|
||||
payload = json.dumps({"cmd": "ping", "params": {"ts": 1}}).encode("utf-8")
|
||||
print(f"JSON payload: {payload}")
|
||||
|
||||
# Nibble encode
|
||||
nibbles = []
|
||||
for byte in payload:
|
||||
nibbles.append((byte >> 4) & 0x0F)
|
||||
nibbles.append(byte & 0x0F)
|
||||
|
||||
sysex_data = [0xF0, 0x7D] + nibbles + [0xF7]
|
||||
print(f"SysEx: {' '.join(f'{b:02X}' for b in sysex_data)}")
|
||||
|
||||
send_sysex_raw(output, sysex_data[1:-1])
|
||||
print("Waiting 5s for response...")
|
||||
|
||||
timeout = 5.0
|
||||
start = time.time()
|
||||
received = None
|
||||
while time.time() - start < timeout:
|
||||
msg = input_port.receive(timeout=0.1)
|
||||
if msg and msg.type == "sysex":
|
||||
received = list(msg.bytes())
|
||||
break
|
||||
|
||||
output.close()
|
||||
input_port.close()
|
||||
|
||||
if received:
|
||||
print(f"Received: {' '.join(f'{b:02X}' for b in received)}")
|
||||
# Decode nibbles
|
||||
if received[1] == 0x7D:
|
||||
nibble_data = received[2:-1]
|
||||
result = bytearray()
|
||||
for i in range(0, len(nibble_data), 2):
|
||||
if i + 1 < len(nibble_data):
|
||||
result.append(((nibble_data[i] & 0x0F) << 4) | (nibble_data[i + 1] & 0x0F))
|
||||
decoded = result.decode("utf-8")
|
||||
print(f"Decoded JSON: {decoded}")
|
||||
print("PASS: JSON command roundtrip works!")
|
||||
return True
|
||||
else:
|
||||
print("FAIL: No response within timeout")
|
||||
return False
|
||||
|
||||
|
||||
def list_ports():
|
||||
"""List all available MIDI ports."""
|
||||
print("\n=== Available MIDI Ports ===")
|
||||
print("Inputs:")
|
||||
for p in mido.get_input_names():
|
||||
print(f" {p}")
|
||||
print("Outputs:")
|
||||
for p in mido.get_output_names():
|
||||
print(f" {p}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("FL Studio MCP — SysEx Loopback Validation")
|
||||
print("=========================================")
|
||||
list_ports()
|
||||
|
||||
test1 = test_simple_sysex()
|
||||
test2 = test_json_command()
|
||||
|
||||
print("\n=== Summary ===")
|
||||
print(f"Simple SysEx: {'PASS' if test1 else 'FAIL'}")
|
||||
print(f"JSON Command: {'PASS' if test2 else 'FAIL'}")
|
||||
|
||||
if test1 and test2:
|
||||
print("\nSysEx loopback is functional. Phase 1-4 can proceed.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nSysEx loopback FAILED. Phase 1-4 ABORTED — pivot required.")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user