Files
reaper-control/mcp/tests/test_sysex_loopback.py

200 lines
5.8 KiB
Python

"""
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)