66 lines
1.8 KiB
Python
66 lines
1.8 KiB
Python
"""
|
|
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"))
|