"""
Roland JD-Xi System Exclusive (SysEx) Message Module
====================================================
This module provides functionality for constructing, parsing, and handling
Roland JD-Xi SysEx messages. It includes support for both writing (DT1) and
reading (RQ1) parameter data, ensuring compliance with Roland's SysEx format.
Features:
---------
- Constructs valid SysEx messages for Roland JD-Xi.
- Supports both parameter write (DT1) and read (RQ1) operations.
- Computes and verifies Roland SysEx checksums.
- Allows dynamic configuration of MIDI parameters.
- Provides utilities to convert between byte sequences and structured data.
Classes:
--------
- `RolandSysEx`: Base class for handling Roland SysEx messages.
- `SysExParameter`: Enum for predefined SysEx parameters and command mappings.
- `SysExMessage`: Helper class for constructing and sending SysEx messages.
Usage Example:
--------------
```python
message = SysExMessage(area=0x19, synth_type=0x01, part=0x00, group=0x00, parameter=0x10, value=0x7F)
sysex_bytes = message.construct_sysex()
print(sysex_bytes) # Outputs a valid SysEx message as a byte sequence
[0xF0, 0x41, 0x10, 0x00, 0x00, 0x00, 0x0E, 0x12, 0x19, 0x01, 0x00, 0x00, 0x10, 0x7F, 0x57, 0xF7]
"""
from dataclasses import dataclass
from enum import Enum
from typing import List
from picomidi.constant import Midi
from jdxi_editor.jdxi.midi.message.sysex.offset import JDXiSysExMessageLayout
from jdxi_editor.midi.data.address.address import (
AddressOffsetProgramLMB,
CommandID,
RolandID,
)
from jdxi_editor.midi.message.jdxi import JDXiSysexHeader
from jdxi_editor.midi.message.midi import MidiMessage
# MIDI Constants
# Deprecated: Use JDXiSysexHeader.to_bytes() instead
[docs]
class SysexParameter(Enum):
"""SysEx Parameters for Roland JD-Xi"""
[docs]
DT1_COMMAND_12 = ("Data Set 1", CommandID.DT1)
[docs]
RQ1_COMMAND_11 = ("Data Request 1", CommandID.RQ1)
[docs]
PROGRAM_COMMON = ("PROGRAM_COMMON", AddressOffsetProgramLMB.COMMON)
def __new__(cls, *args: int | tuple[str, int]) -> "SysexParameter":
if len(args) == 1:
obj = object.__new__(cls)
obj._value_ = args[0]
obj.param_name = None
elif len(args) == 2:
param_name, value = args
obj = object.__new__(cls)
obj._value_ = value
obj.param_name = param_name
else:
raise ValueError("Invalid number of arguments for SysexParameter Enum")
return obj
@classmethod
[docs]
def get_command_name(cls, command_type: int) -> str | None:
"""Retrieve the command name given a command type."""
for item in cls:
if hasattr(item, "param_name") and item.value == command_type:
return item.param_name
return None
@dataclass
[docs]
class SysExMessage(MidiMessage):
"""Base class for MIDI System Exclusive (SysEx) messages."""
[docs]
start_of_sysex: int = Midi.SYSEX.START # Start of SysEx
[docs]
manufacturer_id: int = (
RolandID.ROLAND_ID
) # Manufacturer ID (e.g., [0x41] for Roland)
[docs]
device_id: int = RolandID.DEVICE_ID # Default device ID
[docs]
model_id: list[int] | None = None # Model ID (4 bytes)
[docs]
command: int = CommandID.DT1 # SysEx command (DT1, RQ1, etc.)
[docs]
address: list[int] | None = None # Address (4 bytes)
[docs]
data: list[int] | None = None # Data payload
[docs]
end_of_sysex: int = Midi.SYSEX.END # End of SysEx
[docs]
def __post_init__(self) -> None:
"""Ensure proper initialization of address, model_id, and data fields."""
if self.manufacturer_id is None:
raise ValueError("manufacturer_id must be provided.")
if self.model_id is None or len(self.model_id) != 4:
raise ValueError("model_id must be a list of exactly 4 bytes.")
if self.address is None:
self.address = [0x00] * 4 # Default to an empty address
if self.data is None:
self.data = []
[docs]
def calculate_checksum(self) -> int:
"""Calculate Roland checksum: (128 - sum(bytes) & 0x7F)."""
checksum_data = self.address + self.data
return (128 - (sum(checksum_data) & 0x7F)) & 0x7F if checksum_data else 0
[docs]
def to_message_list(self) -> List[int]:
"""Convert the SysEx message to a list of integers."""
msg = (
[self.start_of_sysex]
+ [self.manufacturer_id]
+ [self.device_id]
+ self.model_id
+ [self.command]
+ self.address
+ self.data
)
if self.manufacturer_id == 0x41: # Roland messages require checksum
msg.append(self.calculate_checksum())
msg.append(self.end_of_sysex)
return msg
@classmethod
[docs]
def from_bytes(cls, data: bytes) -> "SysExMessage":
"""Parse a received SysEx message into an instance."""
if len(data) < 12:
raise ValueError(f"Invalid SysEx message: too short ({len(data)} bytes)")
if (
data[JDXiSysExMessageLayout.START] != Midi.SYSEX.START
or data[JDXiSysExMessageLayout.END] != Midi.SYSEX.END
):
raise ValueError("Invalid SysEx message: missing start or end bytes")
[data[1]]
device_id = data[2]
model_id = list(data[3:7]) # Extract model_id (4 bytes)
command = data[7]
address = list(data[8:12]) # Extract address (4 bytes)
message_data = list(data[12:-2]) # Extract data before checksum and EOX
return cls(
manufacturer_id=data[1],
device_id=device_id,
model_id=model_id,
command=command,
address=address,
data=message_data,
)