200 lines
5.8 KiB
Python
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)
|