"""
Parameter Address Map
Example Usage:
--------------
>>> class AddressMemoryAreaMSB(Address):
... PROGRAM = 0x18
... TEMPORARY_TONE = 0x19
>>> # Add an offset to a base address
>>> addr_bytes = AddressMemoryAreaMSB.PROGRAM.add_offset((0x00, 0x20, 0x00))
>>> print(addr_bytes) # (0x18, 0x00, 0x20, 0x00)
(24, 0, 32, 0)
>>> # Get SysEx-ready address
>>> sysex_address = AddressMemoryAreaMSB.PROGRAM.to_sysex_address((0x00, 0x20, 0x00))
>>> print(sysex_address.hex()) # '18002000'
18002000
>>> # Lookup
>>> found = AddressMemoryAreaMSB.get_parameter_by_address(0x19)
>>> print(found) # ProgramAddress.TEMPORARY_TONE
AddressMemoryAreaMSB.TEMPORARY_TONE: 0x19
SysExByte
Example usage:
--------------
>>> command = CommandID.DT1
>>> print(f"Command: {command}, Value: {command.DT1}, Message Position: {command.message_position}")
Command: 18, Value: 18, Message Position: <bound method CommandID.message_position of <enum 'CommandID'>>
"""
from __future__ import annotations
from enum import IntEnum, unique
from typing import Any, List, Optional, Tuple, Type, TypeVar, Union
from picomidi.core.bitmask import BitMask
from jdxi_editor.midi.data.address.sysex import ZERO_BYTE
from jdxi_editor.midi.data.address.sysex_byte import SysExByte
[docs]
T = TypeVar("T", bound="Address")
[docs]
DIGITAL_PARTIAL_MAP = {i: 0x1F + i for i in range(1, 4)} # 1: 0x20, 2: 0x21, 3: 0x22
@unique
[docs]
class RolandID(IntEnum):
"""Roland IDs"""
@classmethod
[docs]
def to_list(cls) -> list[int]:
"""
Convert the header to a list of integers
"""
return [cls.ROLAND_ID, cls.DEVICE_ID]
@unique
[docs]
class ResponseID(IntEnum):
"""Midi responses"""
[docs]
ACK = 0x4F # Acknowledge
[docs]
class Address(SysExByte):
"""
Base class for Roland-style hierarchical memory address enums (e.g., 0x18, 0x19, etc.)
Includes lookup, offset arithmetic, and SysEx-ready address formatting.
"""
[docs]
def add_offset(
self, address_offset: Union[int, Tuple[int, ...]]
) -> tuple[int, Any]:
"""
Returns the full 4-byte address by adding a 3-byte offset to the base address.
The base address is assumed to be a single byte (e.g., 0x18).
:param address_offset: Union[int, Tuple[int, int, int]] The address offset
:return: tuple[int, Any] The full 4-byte address
"""
base = self.value
if isinstance(address_offset, int):
offset_bytes = [
(address_offset >> 16) & BitMask.FULL_BYTE,
(address_offset >> 8) & BitMask.FULL_BYTE,
address_offset & BitMask.FULL_BYTE,
]
elif isinstance(address_offset, tuple) and len(address_offset) == 3:
offset_bytes = list(address_offset)
else:
raise ValueError("Offset must be an int or a 3-byte tuple")
if any(b > 0x7F for b in offset_bytes):
raise ValueError("SysEx address bytes must be 7-bit (0x00 to 0x7F)")
return base, *offset_bytes
[docs]
def to_sysex_address(
self, address_offset: Union[int, Tuple[int, int, int]] = (0, 0, 0)
) -> bytes:
"""
Returns the full 4-byte address as a `bytes` object, suitable for SysEx messages.
:param address_offset: Union[int, Tuple[int, int, int]] The address offset
:return: bytes The full 4-byte address
"""
return bytes(self.add_offset(address_offset))
@classmethod
[docs]
def get_parameter_by_address(cls: Type[T], address: int) -> Optional[T]:
"""
Get parameter by address value.
Overrides the base class method to use 'value' instead of 'STATUS'.
:param address: int The address value
:return: Optional[T] The parameter
"""
return next(
(parameter for parameter in cls if parameter.value == address), None
)
@classmethod
[docs]
def from_sysex_bytes(cls: Type[T], address: bytes) -> Optional[T]:
"""
Create an Address object from a 4-byte SysEx address.
:param address: bytes The 4-byte SysEx address
:return: Optional[T] The Address object
"""
if len(address) != 4:
return None
return cls.get_parameter_by_address(address[0])
[docs]
def __repr__(self) -> str:
return f"<{self.__class__.__name__}.{self.name}: 0x{self.value:02X}>"
[docs]
def __str__(self) -> str:
return f"{self.__class__.__name__}.{self.name}: 0x{self.value:02X}"
[docs]
class RolandSysExAddress:
"""
Represents a full 4-byte SysEx address (MSB, UMB, LMB, LSB), with support for
address arithmetic, formatting, and conversion to/from SysEx message bytes.
:param msb: int The MSB
:param umb: int The UMB
:param lmb: int The LMB
:param lsb: int The LSB
"""
def __init__(self, msb: int, umb: int, lmb: int, lsb: int):
@classmethod
[docs]
def from_bytes(cls, b: bytes) -> Optional[RolandSysExAddress]:
"""
Create a RolandSysExAddress object from a 4-byte bytes object.
:param b: bytes The 4-byte bytes object
:return: Optional[RolandSysExAddress] The RolandSysExAddress object
"""
if len(b) != 4:
return None
return cls(*b)
[docs]
def to_list(self) -> List[int]:
"""
Convert the RolandSysExAddress object to a list of integers.
:return: List[int] The list of integers
"""
return [self.msb, self.umb, self.lmb, self.lsb]
[docs]
def to_bytes(self) -> bytes:
"""
Convert the RolandSysExAddress object to a 4-byte bytes object.
:return: bytes The 4-byte bytes object
"""
return bytes([self.msb, self.umb, self.lmb, self.lsb])
[docs]
def add_offset(
self, offset: Union[int, tuple[int, int, int]]
) -> RolandSysExAddress:
"""
Adds a 3-byte offset to the lower three bytes (UMB, LMB, LSB).
MSB remains unchanged.
:param offset: Union[int, tuple[int, int, int]] The offset
:return: RolandSysExAddress The RolandSysExAddress object
"""
if isinstance(offset, int):
offset_bytes = [
(offset >> 16) & 0x7F,
(offset >> 8) & BitMask.LOW_7_BITS,
offset & BitMask.LOW_7_BITS,
]
elif isinstance(offset, tuple) and len(offset) == 3:
offset_bytes = list(offset)
else:
raise ValueError("Offset must be an int or a 3-byte tuple")
new_umb = (self.umb + offset_bytes[0]) & BitMask.LOW_7_BITS
new_lmb = (self.lmb + offset_bytes[1]) & BitMask.LOW_7_BITS
new_lsb = (self.lsb + offset_bytes[2]) & BitMask.LOW_7_BITS
return RolandSysExAddress(self.msb, new_umb, new_lmb, new_lsb)
[docs]
def __repr__(self) -> str:
"""
Return a string representation of the RolandSysExAddress object.
:return: str The string representation
"""
return (
f"<{self.__class__.__name__}(msb=0x{int(self.msb):02X}, umb=0x{int(self.umb):02X}, "
f"lmb=0x{int(self.lmb):02X}, lsb=0x{int(self.lsb):02X})>"
)
[docs]
def __str__(self) -> str:
"""
Return a string representation of the RolandSysExAddress object.
:return: str The string representation
"""
return f"0x{int(self.msb):02X} 0x{int(self.umb):02X} 0x{int(self.lmb):02X} 0x{int(self.lsb):02X}"
[docs]
def __eq__(self, other: object) -> bool:
"""
Check if the RolandSysExAddress object is equal to another object.
:param other: object The other object
:return: bool True if the objects are equal, False otherwise
"""
if not isinstance(other, RolandSysExAddress):
return NotImplemented
return self.to_bytes() == other.to_bytes()
[docs]
def __hash__(self) -> int:
"""
Return the hash of the RolandSysExAddress object.
:return: int The hash of the RolandSysExAddress object
"""
return hash(self.to_bytes())
[docs]
def copy(self) -> RolandSysExAddress:
return RolandSysExAddress(self.msb, self.umb, self.lsb, self.lsb)
# ==========================
# JD-Xi SysEx Header
# ==========================
[docs]
class ModelID(Address):
"""
Model ID
"""
# Model ID bytes
[docs]
MODEL_ID_1 = ZERO_BYTE # Manufacturer ID extension
[docs]
MODEL_ID_2 = ZERO_BYTE # Device family code MSB
[docs]
MODEL_ID_3 = ZERO_BYTE # Device family code LSB
[docs]
MODEL_ID_4 = 0x0E # JD-XI Product code
@classmethod
[docs]
def to_list(cls) -> list[int]:
"""
Convert the header to a list of integers
"""
return [cls.MODEL_ID_1, cls.MODEL_ID_2, cls.MODEL_ID_3, cls.MODEL_ID_4]
[docs]
JD_XI_MODEL_ID = [
ModelID.MODEL_ID_1,
ModelID.MODEL_ID_2,
ModelID.MODEL_ID_3,
ModelID.MODEL_ID_4,
]
# Deprecated: Use JDXiSysexHeader from jdxi_editor.midi.message.jdxi instead
# This is kept here for backward compatibility only
@unique
[docs]
class CommandID(SysExByte):
"""Roland Commands"""
[docs]
DT1 = 0x12 # Data Set 1
[docs]
RQ1 = 0x11 # Data Request 1
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 7
# ==========================
# Memory and Program Areas
# ==========================
@unique
[docs]
class AddressStartMSB(Address):
"""
Memory and Program Areas
"""
[docs]
TEMPORARY_PROGRAM = 0x18
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 8
@unique
[docs]
class AddressOffsetTemporaryToneUMB(Address):
"""
Address Offset Temporary Tone UMB
"""
[docs]
DIGITAL_SYNTH_1 = 0x01 # Avoiding "Part" because of Partials
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 9
[docs]
class AddressOffsetSystemUMB(Address):
"""
Address Offset System UMB
"""
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 9
@unique
[docs]
class AddressOffsetSystemLMB(Address):
"""
Address Offset System LMB
"""
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 10
@unique
[docs]
class AddressOffsetSuperNATURALLMB(Address):
"""
Address Offset SuperNATURAL LMB
"""
@classmethod
[docs]
def message_position(cls) -> int:
"""Return the fixed message position for command bytes."""
return 10
@classmethod
[docs]
def digital_partial_offset(cls, partial_number: int) -> int:
"""Return the LMB offset for the given drum partial (0–37)."""
base_address = DIGITAL_PARTIAL_MAP.get(partial_number, 0x00)
return base_address
[docs]
class AddressOffsetAnalogLMB(Address):
"""
Analog Synth Tone
"""
[docs]
class AddressOffsetProgramLMB(Address):
"""
Address Offset Program LMB
"""
[docs]
PART_DIGITAL_SYNTH_1 = 0x20
[docs]
PART_DIGITAL_SYNTH_2 = 0x21
[docs]
ZONE_DIGITAL_SYNTH_1 = 0x30
[docs]
ZONE_DIGITAL_SYNTH_2 = 0x31
[docs]
DRUM_DEFAULT_PARTIAL = (
0x2E # BD1 from DRUM_ADDRESS_MAP (lazy import to avoid circular dependency)
)
[docs]
DIGITAL_DEFAULT_PARTIAL = DIGITAL_PARTIAL_MAP[1]
[docs]
DRUM_KIT_PART_10 = 0x40
[docs]
DRUM_KIT_PART_11 = 0x42
[docs]
DRUM_KIT_PART_12 = 0x44
[docs]
DRUM_KIT_PART_13 = 0x46
[docs]
DRUM_KIT_PART_14 = 0x48
[docs]
DRUM_KIT_PART_15 = 0x4A
[docs]
DRUM_KIT_PART_16 = 0x4C
[docs]
DRUM_KIT_PART_17 = 0x4E
[docs]
DRUM_KIT_PART_18 = 0x50
[docs]
DRUM_KIT_PART_19 = 0x52
[docs]
DRUM_KIT_PART_20 = 0x54
[docs]
DRUM_KIT_PART_21 = 0x56
[docs]
DRUM_KIT_PART_22 = 0x58
[docs]
DRUM_KIT_PART_23 = 0x5A
[docs]
DRUM_KIT_PART_24 = 0x5C
[docs]
DRUM_KIT_PART_25 = 0x5E
[docs]
DRUM_KIT_PART_26 = 0x60
[docs]
DRUM_KIT_PART_27 = 0x62
[docs]
DRUM_KIT_PART_28 = 0x64
[docs]
DRUM_KIT_PART_29 = 0x66
[docs]
DRUM_KIT_PART_30 = 0x68
[docs]
DRUM_KIT_PART_31 = 0x6A
[docs]
DRUM_KIT_PART_32 = 0x6C
[docs]
DRUM_KIT_PART_33 = 0x6E
[docs]
DRUM_KIT_PART_34 = 0x70
[docs]
DRUM_KIT_PART_35 = 0x72
[docs]
DRUM_KIT_PART_36 = 0x74
[docs]
DRUM_KIT_PART_37 = 0x76
@classmethod
[docs]
def message_position(cls) -> int:
"""
Return the fixed message position for command bytes.
:return: int The fixed message position
"""
return 10
@classmethod
[docs]
def drum_partial_offset(cls, partial_number: int) -> int:
"""
Return the LMB offset for the given drum partial (0–37).
:param partial_number: int The partial number
:return: int The LMB offset
"""
base_address = 0x00
step = 0x2E
return base_address + (step * partial_number)
[docs]
class AddressOffsetDrumKitLMB(Address):
"""
Address Offset Program LMB
"""
[docs]
DRUM_DEFAULT_PARTIAL = (
0x2E # BD1 from DRUM_ADDRESS_MAP (lazy import to avoid circular dependency)
)
[docs]
DIGITAL_DEFAULT_PARTIAL = DIGITAL_PARTIAL_MAP[1]
[docs]
DRUM_KIT_PART_10 = 0x40
[docs]
DRUM_KIT_PART_11 = 0x42
[docs]
DRUM_KIT_PART_12 = 0x44
[docs]
DRUM_KIT_PART_13 = 0x46
[docs]
DRUM_KIT_PART_14 = 0x48
[docs]
DRUM_KIT_PART_15 = 0x4A
[docs]
DRUM_KIT_PART_16 = 0x4C
[docs]
DRUM_KIT_PART_17 = 0x4E
[docs]
DRUM_KIT_PART_18 = 0x50
[docs]
DRUM_KIT_PART_19 = 0x52
[docs]
DRUM_KIT_PART_20 = 0x54
[docs]
DRUM_KIT_PART_21 = 0x56
[docs]
DRUM_KIT_PART_22 = 0x58
[docs]
DRUM_KIT_PART_23 = 0x5A
[docs]
DRUM_KIT_PART_24 = 0x5C
[docs]
DRUM_KIT_PART_25 = 0x5E
[docs]
DRUM_KIT_PART_26 = 0x60
[docs]
DRUM_KIT_PART_27 = 0x62
[docs]
DRUM_KIT_PART_28 = 0x64
[docs]
DRUM_KIT_PART_29 = 0x66
[docs]
DRUM_KIT_PART_30 = 0x68
[docs]
DRUM_KIT_PART_31 = 0x6A
[docs]
DRUM_KIT_PART_32 = 0x6C
[docs]
DRUM_KIT_PART_33 = 0x6E
[docs]
DRUM_KIT_PART_34 = 0x70
[docs]
DRUM_KIT_PART_35 = 0x72
[docs]
DRUM_KIT_PART_36 = 0x74
[docs]
DRUM_KIT_PART_37 = 0x76
@classmethod
[docs]
def message_position(cls) -> int:
"""
Return the fixed message position for command bytes.
:return: int The fixed message position
"""
return 10
@classmethod
[docs]
def drum_partial_offset(cls, partial_number: int) -> int:
"""
Return the LMB offset for the given drum partial (0–37).
:param partial_number: int The partial number
:return: int The LMB offset
"""
base_address = 0x00
step = 0x2E
return base_address + (step * partial_number)