From 14a8da93056f5046dba542fc3ff25b1e2372ec61 Mon Sep 17 00:00:00 2001 From: Christof Date: Fri, 4 Sep 2026 13:25:23 +0200 Subject: [PATCH 1/2] Fix Roland block identity, device routing and fingerprints --- adaptations/Roland_JV1080.py | 3 + adaptations/Roland_JV80.py | 6 +- adaptations/roland/GenericRoland.py | 319 ++++++++++------- .../testData/Roland_XV3080/Pianomonics.syx | Bin 0 -> 1056 bytes adaptations/test_GenericRoland.py | 328 ++++++++++++++++++ docs/roland-fingerprints.md | 97 ++++++ release_notes/2.9.0.md | 3 + 7 files changed, 624 insertions(+), 132 deletions(-) create mode 100644 adaptations/testData/Roland_XV3080/Pianomonics.syx create mode 100644 adaptations/test_GenericRoland.py create mode 100644 docs/roland-fingerprints.md diff --git a/adaptations/Roland_JV1080.py b/adaptations/Roland_JV1080.py index f2c9134a..59a61988 100644 --- a/adaptations/Roland_JV1080.py +++ b/adaptations/Roland_JV1080.py @@ -28,6 +28,9 @@ # But we need to do this for all valid device IDs, as the sysex ID could be set to a non-standard ID (standard is 0x10) _jv1080_system_common = RolandData("JV-1080 System Common", 1, 4, 4, (0x00, 0x00, 0x00, 0x00), [DataBlock((0x00, 0x00, 0x00, 0x00), 0x28, "System common")]) +for _layout in (_jv1080_edit_buffer_addresses, _jv1080_program_buffer_addresses): + _layout.supported_layouts.update([(0x4a, 0x81, 0x81, 0x81, 0x81)]) + jv_1080 = GenericRoland("Roland JV-1080", model_id=[0x6a], address_size=4, edit_buffer=_jv1080_edit_buffer_addresses, program_dump=_jv1080_program_buffer_addresses, device_detect_message = _jv1080_system_common) diff --git a/adaptations/Roland_JV80.py b/adaptations/Roland_JV80.py index 585243f1..a34558f5 100644 --- a/adaptations/Roland_JV80.py +++ b/adaptations/Roland_JV80.py @@ -12,7 +12,7 @@ this_module = sys.modules[__name__] -# JV-80. The JV-880/JV-90/JV-1000 share the model ID of the JV-80, but have one respectively two bytes more size than the JV-80. How do I handle that? +# JV-880/JV-90/JV-1000 share the model ID but have longer tone blocks. _jv80_patch_data = [DataBlock((0x00, 0x00, 0x00, 0x00), 0x22, "Patch common"), DataBlock((0x00, 0x00, 0x08, 0x00), 0x73, "Patch tone 1"), # 0x74 size for the JV-880, 0x75 size for the JV-90/JV-1000 DataBlock((0x00, 0x00, 0x09, 0x00), 0x73, "Patch tone 2"), # 0x74 size for the JV-880, 0x75 size for the JV-90/JV-1000 @@ -32,6 +32,10 @@ , blocks=_jv80_patch_data) _jv80_system_common = RolandData("JV-80 System Common", 1, 4, 4, (0x00, 0x00, 0x00, 0x00), [DataBlock((0x00, 0x00, 0x00, 0x00), 0x21, "System common")]) +# Accept complete model variants, not a mixture of tone lengths in one patch. +for _layout in (_jv80_edit_buffer_addresses, _jv80_program_buffer_addresses): + _layout.supported_layouts.update([(0x22, size, size, size, size) for size in (0x74, 0x75)]) + jv_80 = GenericRoland("Roland JV-80", model_id=[0x46], address_size=4, diff --git a/adaptations/roland/GenericRoland.py b/adaptations/roland/GenericRoland.py index effdbceb..81cdaa3a 100644 --- a/adaptations/roland/GenericRoland.py +++ b/adaptations/roland/GenericRoland.py @@ -4,6 +4,7 @@ # Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase # import hashlib +import copy from typing import List, Tuple, Optional, Dict, Union import knobkraft @@ -84,7 +85,8 @@ def size_to_number(size) -> int: class RolandData: - def __init__(self, data_name: str, num_items: int, num_address_bytes: int, num_size_bytes: int, base_address: Tuple, blocks: List[DataBlock], uses_consecutive_addresses: Optional[bool] = False): + def __init__(self, data_name: str, num_items: int, num_address_bytes: int, num_size_bytes: int, base_address: Tuple, blocks: List[DataBlock], uses_consecutive_addresses: Optional[bool] = False, + supported_layouts: Optional[List[Tuple[int, ...]]] = None): self.data_name = data_name self.num_items = num_items # This is the "bank size" of that data type self.num_address_bytes = num_address_bytes @@ -95,6 +97,9 @@ def __init__(self, data_name: str, num_items: int, num_address_bytes: int, num_s self.allowed_addresses = set([self.absolute_address(x.address) for x in self.data_blocks]) self.blank_out_zones = None self.uses_consecutive_addresses = uses_consecutive_addresses + # Each tuple describes a complete supported model variant, in block order. + self.supported_layouts = {tuple(block.size for block in blocks)} + self.supported_layouts.update(supported_layouts or []) def make_black_out_zones(self, model_id_length: int, program_position: Union[int, Tuple[int, int]] = None, device_id_position: int = None, name_blankout: Tuple[int, int, int] = None): # Calculate the additional bytes each data block takes. This is sysex header, checksum and sysex end, plus model ID and device ID @@ -186,7 +191,8 @@ def __init__(self, name: str, model_id: List[int], address_size: int, edit_buffe patch_name_message_number: Optional[int] = 0, patch_name_length: Optional[int] = 12, use_roland_character_set: Optional[bool] = False, - uses_consecutive_addresses: Optional[bool] = False): + uses_consecutive_addresses: Optional[bool] = False, + patch_name_offset: int = 0): self._name = name self.model_id = model_id self.device_family = device_family # This is only used in the Identity Reply Message. @@ -200,12 +206,10 @@ def __init__(self, name: str, model_id: List[int], address_size: int, edit_buffe self.category_index = category_index self.patch_name_message_number = patch_name_message_number self.patch_name_length = patch_name_length + self.patch_name_offset = patch_name_offset self.use_roland_character_set = use_roland_character_set self.uses_consecutive_addresses = uses_consecutive_addresses - # Calculate the fingerprint blank out zones for edit buffer (just the name) and program dump (program position and name) - edit_buffer.make_black_out_zones(self._model_id_len, program_position=5 + self._model_id_len) - program_dump.make_black_out_zones(self._model_id_len, program_position=5 + self._model_id_len, - name_blankout=(0, 0, 12)) # name always is in block 0 with index 0 and length 12 + self._address_maps = {} @knobkraft_api def name(self): @@ -229,20 +233,26 @@ def createDeviceDetectMessage(self, channel: int) -> List[int]: def channelIfValidDeviceResponse(self, message: List[int]) -> int: if self.device_family is not None: # The Roland usually will reply on a Universal Device Identity Reply message - if (len(message) > 6 + self._model_id_len + if (len(message) >= 15 and message[0] == 0xf0 # Sysex and message[1] == 0x7e # Non-realtime and message[3] == 0x06 # Device request and message[4] == 0x02 # Device request reply and message[5] == 0x41 # Roland - and message[6:6 + self._model_id_len] == self.device_family): # Family code expected, this is *not* the model ID + and message[6:6 + len(self.device_family)] == self.device_family + and message[-1] == 0xf7 + and all(0 <= x < 0x80 for x in message[1:-1]) + and message[2] <= 0x1f): # and message[8:10] == [0x00, 0x00]): # Family code self.device_id = message[2] # Store the device ID for later, we'll need it return message[2] & 0x0f # Simulate MIDI channel, but of course this is stupid elif self.device_detect_message is not None: # Check if the message is our own, and at the address we were expecting if self.isOwnSysex(message): - command, address, reply = self.parseRolandMessage(message) + try: + command, address, reply = self.parseRolandMessage(message) + except ValueError: + return -1 if command == command_dt1 and address == list(self.device_detect_message.absolute_address(self.device_detect_message.data_blocks[0].address)): self.device_id = message[2] return message[2] & 0x0f @@ -272,13 +282,18 @@ def buildRolandMessage(self, device, command_id, address, data) -> List[int]: return message def parseRolandMessage(self, message: list) -> Tuple[int, List[int], List[int]]: + if (len(message) < self._checksum_start() + self.address_size + 2 + or not self.isOwnSysex(message) or message[-1] != 0xf7 + or not all(isinstance(x, int) and 0 <= x < 0x80 for x in message[1:-1]) + or message[2] > 0x1f): + raise ValueError("Invalid Roland message framing, model or device ID") checksum_start = self._checksum_start() checksum = self.roland_checksum(message[checksum_start:-2]) if checksum == message[-2]: command = message[3 + self._model_id_len] address = message[checksum_start:checksum_start + self.address_size] return command, address, message[checksum_start + self.address_size:-2] - raise Exception("Checksum error in Roland message parsing, expected", message[-2], "but got", checksum) + raise ValueError("Checksum error in Roland message parsing", message[-2], checksum) def getCommandAndAddressFromRolandMessage(self, message: list) -> Tuple[int, List[int]]: checksum_start = self._checksum_start() @@ -291,103 +306,145 @@ def roland_checksum(data_block) -> int: @knobkraft_api def createEditBufferRequest(self, channel) -> List[int]: # The edit buffer is called Patch mode temporary patch - address, size = self.edit_buffer.address_and_size_for_sub_request(0, 0) + address, size = self._address_for_sub_request(self.edit_buffer, 0, 0) return self.buildRolandMessage(self.device_id, command_rq1, address, size) def _createFollowUpEditBufferDumpRequest(self, previousRequestNo): # Check if there is a follow up data block if previousRequestNo + 1 < len(self.edit_buffer.data_blocks): - address, size = self.edit_buffer.address_and_size_for_sub_request(previousRequestNo + 1, 0) + address, size = self._address_for_sub_request(self.edit_buffer, previousRequestNo + 1, 0) return self.buildRolandMessage(self.device_id, command_rq1, address, size) else: return [] + def _address_for_sub_request(self, layout, block_no, item): + # Models with a different part stride can override this one address hook. + return layout.address_and_size_for_sub_request(block_no, item) + + def _block_address_map(self, layout): + # Use concrete addresses, including program/part context, rather than + # dropping an address byte. Cache per instance, never on shared layouts. + if layout not in self._address_maps: + addresses = {} + for item in range(layout.num_items): + for block_no in range(len(layout.data_blocks)): + address, _ = self._address_for_sub_request(layout, block_no, item) + key = tuple(address) + if key in addresses: + raise ValueError("Ambiguous Roland block layout") + addresses[key] = (block_no, item) + self._address_maps[layout] = addresses + return self._address_maps[layout] + + def _parse_block(self, message, layout): + command, address, data = self.parseRolandMessage(message) + if command != command_dt1: + raise ValueError("Expected a Roland DT1 block") + match = self._block_address_map(layout).get(tuple(address)) + if match is None: + raise ValueError("Unknown Roland block address or program") + block_no, item = match + if not any(len(data) == sizes[block_no] for sizes in layout.supported_layouts): + raise ValueError("Unsupported Roland block size") + return block_no, item, address, data + + def _parse_dump(self, message, layout): + blocks = {} + context = None + end_of_previous = 0 + for start, end in knobkraft.sysex.findSysexDelimiters(message): + if start != end_of_previous: + raise ValueError("Unexpected data between Roland blocks") + sub = message[start:end] + block_no, item, address, data = self._parse_block(sub, layout) + if block_no in blocks: + raise ValueError("Duplicate Roland block") + if context is not None and context != (sub[2], item): + raise ValueError("Mixed Roland devices or programs") + context = (sub[2], item) + blocks[block_no] = (address, data) + end_of_previous = end + if end_of_previous != len(message) or len(blocks) != len(layout.data_blocks): + raise ValueError("Incomplete Roland dump") + if tuple(len(blocks[i][1]) for i in range(len(blocks))) not in layout.supported_layouts: + raise ValueError("Mixed or unsupported Roland layout variants") + return blocks, context + + def _validated_dump(self, message): + for layout in (self.program_dump, self.edit_buffer): + try: + blocks, context = self._parse_dump(message, layout) + return layout, blocks, context + except ValueError: + pass + raise ValueError("Expected a complete, valid Roland edit buffer or program dump") + + def _is_dump(self, messages, layout): + try: + self._parse_dump(messages, layout) + return True + except ValueError: + return False + @knobkraft_api def isPartOfEditBufferDump(self, message): - # Accept a certain set of addresses. This does not verify the checksum, for speed reasons, or check the size - if self.isOwnSysex(message): - command, address = self.getCommandAndAddressFromRolandMessage(message) - if command == command_dt1: - normalized_address = tuple(self.edit_buffer.reset_to_base_address(address)) - # Find out which data block we got - for sub_request in range(len(self.edit_buffer.data_blocks)): - if normalized_address == self.edit_buffer.absolute_address(self.edit_buffer.data_blocks[sub_request].address): - return True, self._createFollowUpEditBufferDumpRequest(sub_request) - return False + try: + block_no, _, _, _ = self._parse_block(message, self.edit_buffer) + return True, self._createFollowUpEditBufferDumpRequest(block_no) + except ValueError: + return False @knobkraft_api def isEditBufferDump(self, messages): - addresses = set() - for message in knobkraft.sysex.findSysexDelimiters(messages): - if self.isOwnSysex(messages[message[0]:message[1]]): - _, address = self.getCommandAndAddressFromRolandMessage(messages[message[0]:message[1]]) - addresses.add(tuple(self.edit_buffer.reset_to_base_address(address))) - return all(a in addresses for a in self.edit_buffer.allowed_addresses) + return self._is_dump(messages, self.edit_buffer) + + def _convert_dump(self, message, destination, item, device_id=None): + source, blocks, _ = self._validated_dump(message) + by_identity = {source.data_blocks[i].address: data for i, (_, data) in blocks.items()} + if set(by_identity) != {block.address for block in destination.data_blocks}: + raise ValueError("Incompatible Roland source and destination layouts") + payloads = [by_identity[block.address] for block in destination.data_blocks] + if tuple(map(len, payloads)) not in destination.supported_layouts: + raise ValueError("Unsupported Roland destination layout variant") + result = [] + for block_no, data in enumerate(payloads): + address, _ = self._address_for_sub_request(destination, block_no, item) + result += self.buildRolandMessage(self.device_id if device_id is None else device_id, + command_dt1, address, data) + return result @knobkraft_api def convertToEditBuffer(self, channel, message): - editBuffer = [] - if self.isEditBufferDump(message) or self.isSingleProgramDump(message): - # We need to poke the device ID and the edit buffer address into the messages - msg_no = 0 - for message in knobkraft.sysex.splitSysexMessage(message): - command, address, data = self.parseRolandMessage(message) - edit_buffer_address, _ = self.edit_buffer.address_and_size_for_sub_request(msg_no, 0x00) - editBuffer = editBuffer + self.buildRolandMessage(self.device_id, command_dt1, edit_buffer_address, data) - msg_no += 1 - return editBuffer - raise Exception("Invalid argument given, can only convert edit buffers and program dumps to edit buffers") + return self._convert_dump(message, self.edit_buffer, 0) @knobkraft_api def createProgramDumpRequest(self, channel, patchNo): - address, size = self.program_dump.address_and_size_for_sub_request(0, patchNo % self.program_dump.num_items) + address, size = self._address_for_sub_request(self.program_dump, 0, patchNo % self.program_dump.num_items) return self.buildRolandMessage(self.device_id, command_rq1, address, size) def _createFollowUpProgramDumpRequest(self, patchNo, previousRequestNo): # Check if there is a follow up data block if previousRequestNo + 1 < len(self.program_dump.data_blocks): - address, size = self.program_dump.address_and_size_for_sub_request(previousRequestNo + 1, patchNo % self.program_dump.num_items) + address, size = self._address_for_sub_request(self.program_dump, previousRequestNo + 1, patchNo % self.program_dump.num_items) return self.buildRolandMessage(self.device_id, command_rq1, address, size) else: return [] @knobkraft_api def isPartOfSingleProgramDump(self, message): - # Accept a certain set of addresses - if self.isOwnSysex(message): - command, address = self.getCommandAndAddressFromRolandMessage(message) - if command == command_dt1: - patchNo = self._patch_number_from_address(address) - normalized_address = tuple(self.program_dump.reset_to_base_address(address)) - # Find out which data block we got - for sub_request in range(len(self.program_dump.data_blocks)): - if normalized_address == self.program_dump.absolute_address(self.program_dump.data_blocks[sub_request].address): - return True, self._createFollowUpProgramDumpRequest(patchNo, sub_request) - return False + try: + block_no, item, _, _ = self._parse_block(message, self.program_dump) + return True, self._createFollowUpProgramDumpRequest(item, block_no) + except ValueError: + return False @knobkraft_api def isSingleProgramDump(self, messages): - addresses = set() - programs = set() - for message in knobkraft.sysex.findSysexDelimiters(messages): - _, address = self.getCommandAndAddressFromRolandMessage(messages[message[0]:message[1]]) - addresses.add(self.program_dump.reset_to_base_address(address)) - programs.add(self._patch_number_from_address(address)) - return len(programs) == 1 and all(a in addresses for a in self.program_dump.allowed_addresses) + return self._is_dump(messages, self.program_dump) @knobkraft_api def convertToProgramDump(self, channel, message, program_number): - programDump = [] - if self.isSingleProgramDump(message) or self.isEditBufferDump(message): - # We need to poke the device ID and the program number into the messages - msg_no = 0 - for message in knobkraft.sysex.splitSysexMessage(message): - _, _, data = self.parseRolandMessage(message) - program_buffer_address, _ = self.program_dump.address_and_size_for_sub_request(msg_no, program_number % self.program_dump.num_items) - programDump = programDump + self.buildRolandMessage(self.device_id, command_dt1, program_buffer_address, data) - msg_no += 1 - return programDump - raise Exception("Can only convert single program dumps to program dumps!") + return self._convert_dump(message, self.program_dump, program_number % self.program_dump.num_items) @staticmethod def _apply_blankout(data: List[int], blankout: List[Tuple[int, int]]): @@ -404,12 +461,20 @@ def _apply_blankout(data: List[int], blankout: List[Tuple[int, int]]): return result def blankedOut(self, message): - # Use the prepared blank out zones to clear out a) program place and b) patch name - if self.isEditBufferDump(message): - return self._apply_blankout(message.copy(), self.edit_buffer.blank_out_zones) - elif self.isSingleProgramDump(message): - return self._apply_blankout(message.copy(), self.program_dump.blank_out_zones) - raise Exception("Only works with edit buffers and program dumps") + # Canonical program-slot-zero DT1 serialization preserves the legacy hash + # of ordered, nominal-size program dumps at the default device ID. + # Construct each block separately so variant lengths cannot shift masks + # into sound data. See docs/roland-fingerprints.md for database migration. + canonical = self._convert_dump(message, self.program_dump, 0, device_id=0x10) + result = [] + for block_no, sub in enumerate(knobkraft.splitSysex(canonical)): + sub[self._checksum_start() + 1] = 0 # legacy program-position mask + sub[-2] = 0 + if block_no == self.patch_name_message_number: + name_start = self._checksum_start() + self.address_size + self.patch_name_offset + sub[name_start:name_start + self.patch_name_length] = [0] * self.patch_name_length + result.extend(sub) + return result @knobkraft_api def calculateFingerprint(self, message): @@ -425,24 +490,23 @@ def _patch_number_from_address(self, address): @knobkraft_api def numberFromDump(self, message) -> int: - if not self.isSingleProgramDump(message): + try: + _, context = self._parse_dump(message, self.program_dump) + return context[1] + except ValueError: return 0 - messages = knobkraft.sysex.findSysexDelimiters(message, 1) - _, address = self.getCommandAndAddressFromRolandMessage(message[messages[0][0]:messages[0][1]]) - return self._patch_number_from_address(address) @knobkraft_api def nameFromDump(self, message) -> str: - if self.isSingleProgramDump(message) or self.isEditBufferDump(message): - msg_no = self.patch_name_message_number - messages = knobkraft.sysex.findSysexDelimiters(message, msg_no + 1) - _, _, data = self.parseRolandMessage(message[messages[msg_no][0]:messages[msg_no][1]]) - if self.use_roland_character_set: - patch_name = ''.join([character_set[x] for x in data[0:self.patch_name_length]]) - else: - patch_name = ''.join([chr(x) for x in data[0:self.patch_name_length]]) - return patch_name - return 'Invalid' + try: + _, blocks, _ = self._validated_dump(message) + except ValueError: + return 'Invalid' + data = blocks[self.patch_name_message_number][1] + name = data[self.patch_name_offset:self.patch_name_offset + self.patch_name_length] + if self.use_roland_character_set: + return ''.join(character_set[x] if x < len(character_set) else ' ' for x in name) + return ''.join(chr(x) for x in name) @knobkraft_api def renamePatch(self, message: List[int], new_name: str) -> List[int]: @@ -450,8 +514,7 @@ def renamePatch(self, message: List[int], new_name: str) -> List[int]: Return a new dump with the patch name changed to `new_name`. Works for both single program dumps and edit buffer dumps. """ - if not (self.isSingleProgramDump(message) or self.isEditBufferDump(message)): - raise Exception("renamePatch: only supports single program dumps or edit buffer dumps") + layout, blocks, context = self._validated_dump(message) # Prepare name bytes name = (new_name or "").strip() @@ -466,37 +529,25 @@ def renamePatch(self, message: List[int], new_name: str) -> List[int]: # Standard 7-bit ASCII (Roland SysEx is 7-bit clean) name_bytes = [ord(ch) & 0x7F for ch in name] - # Rebuild the entire multi-part SysEx with the new name in the correct sub-message rebuilt: List[int] = [] - msg_no = 0 - for start, end in knobkraft.sysex.findSysexDelimiters(message): - sub = message[start:end] - # Preserve the original device id from this submessage - device_id_in_msg = sub[2] - command, address, data = self.parseRolandMessage(sub) - - if msg_no == self.patch_name_message_number: - # Overwrite the name region at the beginning of this data block - data = data.copy() - data[0:self.patch_name_length] = name_bytes - - # Always send DT1 (data set) when rebuilding - rebuilt += self.buildRolandMessage(device_id_in_msg, command_dt1, address, data) - msg_no += 1 - + for block_no in range(len(layout.data_blocks)): + address, data = blocks[block_no] + data = data.copy() + if block_no == self.patch_name_message_number: + data[self.patch_name_offset:self.patch_name_offset + self.patch_name_length] = name_bytes + rebuilt += self.buildRolandMessage(context[0], command_dt1, address, data) return rebuilt - @knobkraft_api def storedTags(self, message) -> List[str]: if self.category_index is not None: - if self.isSingleProgramDump(message) or self.isEditBufferDump(message): - messages = knobkraft.sysex.findSysexDelimiters(message, 1) - _, _, data = self.parseRolandMessage(message[messages[0][0]:messages[0][1]]) - category = data[self.category_index] - if 0 <= category < len(categories): - return [categories[category][1]] - print(f"Warning - encountered invalid category number {category} for which no text is defined, ignoring") + try: + _, blocks, _ = self._validated_dump(message) + except ValueError: + return [] + data = blocks[0][1] + if self.category_index < len(data) and data[self.category_index] in categories: + return [categories[data[self.category_index]][1]] return [] def install(self, module): @@ -510,13 +561,19 @@ def install(self, module): class GenericRolandWithBackwardCompatibility: def __init__(self, main_model: GenericRoland, compatible_models: List[GenericRoland]): - self.main_model = GenericRoland(main_model.name(), main_model.model_id, main_model.address_size, main_model.edit_buffer, - main_model.program_dump, - category_index=main_model.category_index, - device_family=main_model.device_family, - device_detect_message=main_model.device_detect_message, - device_detect_ids=main_model.device_detect_ids) - self.models_supported = [main_model] + compatible_models + # Own the destination and protocol instances. Detection must not mutate + # imported JV adaptations or another wrapper constructed from these models. + self.models_supported = copy.deepcopy([main_model] + compatible_models) + self.main_model = self.models_supported[0] + + def _destination_model(self, message): + model = self.model_from_message(message) + if model is None: + raise ValueError("Unsupported Roland source model") + # Keep protocol/layout selection independent of the destination device ID. + destination = copy.copy(model) + destination.device_id = self.main_model.device_id + return destination def model_from_message(self, message) -> Optional[GenericRoland]: for synth in self.models_supported: @@ -535,7 +592,11 @@ def createDeviceDetectMessage(self, channel: int) -> List[int]: @knobkraft_api def channelIfValidDeviceResponse(self, message: List[int]) -> int: # The Roland usually will reply on a Universal Device Identity Reply message - return self.main_model.channelIfValidDeviceResponse(message) + channel = self.main_model.channelIfValidDeviceResponse(message) + if channel >= 0: + for model in self.models_supported: + model.device_id = self.main_model.device_id + return channel @knobkraft_api def needsChannelSpecificDetection(self) -> bool: @@ -566,8 +627,7 @@ def isEditBufferDump(self, data) -> bool: @knobkraft_api def convertToEditBuffer(self, _channel, message): - model = self.model_from_message(message) - return model.convertToEditBuffer(model.device_id, message) + return self._destination_model(message).convertToEditBuffer(_channel, message) @knobkraft_api def createProgramDumpRequest(self, _channel, patchNo): @@ -590,10 +650,7 @@ def isSingleProgramDump(self, data): @knobkraft_api def convertToProgramDump(self, _channel, message, program_number): - model = self.model_from_message(message) - if model is not None: - return model.convertToProgramDump(self.main_model.device_id, message, program_number) - raise Exception("Can only convert edit buffers and program dumps of one of the compatible synths!") + return self._destination_model(message).convertToProgramDump(_channel, message, program_number) @knobkraft_api def numberFromDump(self, message) -> int: diff --git a/adaptations/testData/Roland_XV3080/Pianomonics.syx b/adaptations/testData/Roland_XV3080/Pianomonics.syx new file mode 100644 index 0000000000000000000000000000000000000000..4c1ff1b95232a362a29b59460aaa87443425e26f GIT binary patch literal 1056 zcmb`GJx{|x42EA{(zFGYRxKL?RRt_;hzXD|&Ob6_q zhw@3R48v}$AQ8Mcog}FZ$Xe?ik`N`G6p5q~C=gjpY;K+UMHZ{EJ&pMvx$WUXyf1+ugRv$W{rVFveDNNoiya=S$Q6DqB8%%~i`GO8v;qoZ?;Q zKBx)H(j0kR_q6sTLsRJ|qs|x!UG1?mszOys0)7dG%x&Mv059%>_jDy8j{pGVu5Rw$JM^Eu?Uxk}NF PLfJgjMHcG&_BEy-qNa4K literal 0 HcmV?d00001 diff --git a/adaptations/test_GenericRoland.py b/adaptations/test_GenericRoland.py new file mode 100644 index 00000000..a625e880 --- /dev/null +++ b/adaptations/test_GenericRoland.py @@ -0,0 +1,328 @@ +"""Regressions for #560–562, using wire addresses as the independent oracle.""" +import copy +import itertools +from pathlib import Path + +import pytest + +import knobkraft +import Roland_JV80 +import Roland_JV1080 +import Roland_JD_Xi +import Roland_XV3080 +from roland import DataBlock, GenericRoland, GenericRolandWithBackwardCompatibility, RolandData +from testing.librarian import Librarian + + +def flatten(blocks): + return list(itertools.chain.from_iterable(blocks)) + + +@pytest.fixture(params=['jv80', 'jv1080', 'jdxi', 'xv3080']) +def sound(request): + if request.param == 'jv80': + model = copy.deepcopy(Roland_JV80.jv_80) + patch = next(iter(Roland_JV80.make_test_data().programs)).message.byte_list + elif request.param == 'jv1080': + model = copy.deepcopy(Roland_JV1080.jv_1080) + patch = next(iter(Roland_JV1080.make_test_data().programs)).message.byte_list + elif request.param == 'jdxi': + model = copy.deepcopy(Roland_JD_Xi._jdxi_sn_tone) + patch = next(iter(Roland_JD_Xi.make_test_data().edit_buffers)).message.byte_list + else: + model = copy.deepcopy(Roland_XV3080.xv_3080_main) + # Extracted unchanged from the existing test_Roland_XV3080 fixture. + patch = list(Path('testData/Roland_XV3080/Pianomonics.syx').read_bytes()) + model.device_id = 0x10 + return model, patch + + +def payloads(model, patch): + start = 4 + len(model.model_id) + return {tuple(block[start:start + model.address_size]): block[start + model.address_size:-2] + for block in knobkraft.splitSysex(patch)} + + +def rewrite(model, block, *, address=None, data=None, device=None): + start = 4 + len(model.model_id) + address = block[start:start + model.address_size] if address is None else address + data = block[start + model.address_size:-2] if data is None else data + # Deliberately independent of buildRolandMessage / parseRolandMessage. + header = block[:start] + if device is not None: + header[2] = device + return header + address + data + [(-sum(address + data)) & 127, 247] + + +def test_permutations_preserve_addresses_and_metadata(sound): + model, patch = sound + blocks = knobkraft.splitSysex(patch) + expected_edit = payloads(model, model.convertToEditBuffer(0, patch)) + expected_program = payloads(model, model.convertToProgramDump(0, patch, 7)) + expected_name = model.nameFromDump(patch) + expected_tags = model.storedTags(patch) + expected_hash = model.calculateFingerprint(patch) + # All permutations for five-block models; all equal-sized XV tone permutations + # plus reversal (moves common/name/category to the end). + if len(blocks) <= 5: + permutations = itertools.permutations(blocks) + else: + permutations = [list(reversed(blocks))] + [blocks[:5] + list(p) for p in itertools.permutations(blocks[5:])] + for permutation in permutations: + reordered = flatten(permutation) + assert model.isSingleProgramDump(reordered) or model.isEditBufferDump(reordered) + assert payloads(model, model.convertToEditBuffer(11, reordered)) == expected_edit + assert payloads(model, model.convertToProgramDump(11, reordered, 7)) == expected_program + assert model.nameFromDump(reordered) == expected_name + assert model.storedTags(reordered) == expected_tags + assert model.calculateFingerprint(reordered) == expected_hash + renamed = model.renamePatch(reordered, 'RENAMED') + assert model.nameFromDump(renamed) == 'RENAMED'.ljust(model.patch_name_length) + before, after = payloads(model, reordered), payloads(model, renamed) + name_address = tuple(blocks[model.patch_name_message_number][4 + len(model.model_id): + 4 + len(model.model_id) + model.address_size]) + assert {address for address in before if before[address] != after[address]} == {name_address} + assert before[name_address][:model.patch_name_offset] == after[name_address][:model.patch_name_offset] + name_end = model.patch_name_offset + model.patch_name_length + assert before[name_address][name_end:] == after[name_address][name_end:] + + +@pytest.mark.parametrize('edit', [False, True]) +def test_fingerprint_invariants_and_parameter_sensitivity(sound, edit): + model, patch = sound + expected = model.calculateFingerprint(patch) + patch = model.convertToEditBuffer(0, patch) if edit else model.convertToProgramDump(0, patch, 0) + original = patch.copy() + for device in (0, 0x10, 0x15, 0x1f): + routed = flatten(rewrite(model, b, device=device) for b in knobkraft.splitSysex(patch)) + assert model.calculateFingerprint(routed) == expected + assert model.calculateFingerprint(model.renamePatch(routed, 'NEW NAME')) == expected + for place in (0, model.program_dump.num_items - 1): + assert model.calculateFingerprint(model.convertToProgramDump(3, patch, place)) == expected + # Exercise every payload byte, including the byte before each checksum. + blocks = knobkraft.splitSysex(patch) + for block_no, block in enumerate(blocks): + data = block[4 + len(model.model_id) + model.address_size:-2] + for offset in range(len(data)): + if block_no == model.patch_name_message_number and model.patch_name_offset <= offset < model.patch_name_offset + model.patch_name_length: + continue + changed = data.copy() + changed[offset] ^= 1 + mutated = blocks.copy() + mutated[block_no] = rewrite(model, block, data=changed) + assert model.calculateFingerprint(flatten(mutated)) != expected, (block_no, offset) + assert patch == original + + +@pytest.mark.parametrize('edit', [False, True]) +@pytest.mark.parametrize('damage', ['model', 'command', 'checksum', 'short', 'long', 'duplicate', + 'conflicting_duplicate', 'unknown', 'missing', 'device', 'program', + 'prefix', 'suffix', 'between', 'truncated', 'nested', 'high_bit', 'empty']) +def test_malformed_dumps_are_rejected(sound, edit, damage): + model, patch = sound + patch = model.convertToEditBuffer(0, patch) if edit else model.convertToProgramDump(0, patch, 0) + blocks = knobkraft.splitSysex(patch) + address_start = 4 + len(model.model_id) + if damage == 'model': + blocks[-1][3] ^= 1 + elif damage == 'command': + blocks[-1][address_start - 1] = 0x11 + elif damage == 'checksum': + blocks[-1][-2] ^= 1 + elif damage in ('short', 'long'): + data = blocks[-1][address_start + model.address_size:-2] + blocks[-1] = rewrite(model, blocks[-1], data=data[:-1] if damage == 'short' else data + [0]) + elif damage in ('duplicate', 'conflicting_duplicate'): + duplicate = blocks[0].copy() + if damage == 'conflicting_duplicate': + data = duplicate[address_start + model.address_size:-2] + data[-1] ^= 1 + duplicate = rewrite(model, duplicate, data=data) + blocks.append(duplicate) + elif damage == 'unknown': + address = blocks[-1][address_start:address_start + model.address_size] + address[-1] = 1 + blocks[-1] = rewrite(model, blocks[-1], address=address) + elif damage == 'missing': + blocks.pop() + elif damage == 'device': + blocks[-1][2] = 0x15 + elif damage == 'program': + address = blocks[-1][address_start:address_start + model.address_size] + address[1] += 1 + blocks[-1] = rewrite(model, blocks[-1], address=address) + elif damage == 'prefix': + blocks.insert(0, [0]) + elif damage == 'suffix': + blocks.append([0]) + elif damage == 'between': + blocks.insert(1, [0]) + elif damage == 'truncated': + blocks[-1].pop() + elif damage == 'nested': + blocks[0].insert(address_start, 0xf0) + elif damage == 'high_bit': + blocks[-1][-3] = 0x80 + blocks[-1][-2] = (-sum(blocks[-1][address_start:-2])) & 127 + else: + blocks = [] + malformed = flatten(blocks) + assert not model.isSingleProgramDump(malformed) + assert not model.isEditBufferDump(malformed) + assert model.nameFromDump(malformed) == 'Invalid' + assert model.storedTags(malformed) == [] + for operation in (lambda: model.convertToEditBuffer(0, malformed), + lambda: model.convertToProgramDump(0, malformed, 0), + lambda: model.renamePatch(malformed, 'NO'), + lambda: model.calculateFingerprint(malformed)): + with pytest.raises(ValueError): + operation() + + +def test_partial_recognition_validates_each_block(sound): + model, patch = sound + for edit in (True, False): + converted = model.convertToEditBuffer(0, patch) if edit else model.convertToProgramDump(0, patch, 0) + predicate = model.isPartOfEditBufferDump if edit else model.isPartOfSingleProgramDump + for block in knobkraft.splitSysex(converted): + assert predicate(block) + for end in range(len(block)): + assert not predicate(block[:end]) + bad = block.copy() + bad[-2] ^= 1 + assert not predicate(bad) + bad = block.copy() + bad[4 + len(model.model_id) - 1] = 0x11 + assert not predicate(bad) + + +@pytest.mark.parametrize('module', [Roland_JV1080, Roland_XV3080]) +def test_librarian_deduplicates_program_and_edit(module): + patch = (next(iter(module.make_test_data().programs)).message.byte_list if module is Roland_JV1080 + else list(Path('testData/Roland_XV3080/Pianomonics.syx').read_bytes())) + program = module.convertToProgramDump(0, patch, 0) + edit = module.renamePatch(module.convertToEditBuffer(0, patch), 'EDIT NAME') + assert len(Librarian().load_sysex(module, knobkraft.splitSysex(program) + knobkraft.splitSysex(edit))) == 1 + + +def test_legacy_program_hashes(sound): + model, patch = sound + # Captured from the unchanged pre-fix module, not computed by the new code. + expected = {'Roland JV-80': 'b112b99735e8dae3b0ec0d8e28574d50', + 'Roland JV-1080': '3abd33edc2d6f03628583fdf86fb03aa', + 'Roland JD-Xi': '5a8ed265efbc6e018eeea59bba89bf87', + 'Roland XV-3080': '2a82aa6220f30d91e944bebb2666b99e'} + assert model.calculateFingerprint(patch) == expected[model.name()] + + +@pytest.mark.parametrize('module,sizes', [(Roland_JV80, (0x22, 0x73, 0x73, 0x73, 0x73)), + (Roland_JV80, (0x22, 0x74, 0x74, 0x74, 0x74)), + (Roland_JV80, (0x22, 0x75, 0x75, 0x75, 0x75)), + (Roland_JV1080, (0x48, 0x81, 0x81, 0x81, 0x81)), + (Roland_JV1080, (0x4a, 0x81, 0x81, 0x81, 0x81))]) +def test_documented_jv_variants(module, sizes): + model = module.jv_80 if module is Roland_JV80 else module.jv_1080 + original = next(iter(module.make_test_data().programs)).message.byte_list + blocks = knobkraft.splitSysex(original) + resized = [] + for block, size in zip(blocks, sizes): + data = block[9:-2] + resized.append(rewrite(model, block, data=(data + [17, 23])[:size])) + patch = flatten(resized) + assert model.isSingleProgramDump(patch) + edit = model.convertToEditBuffer(0, flatten(reversed(resized))) + assert model.isEditBufferDump(edit) + assert model.convertToProgramDump(0, edit, model.numberFromDump(patch)) == patch + assert model.calculateFingerprint(edit) == model.calculateFingerprint(patch) + assert model.calculateFingerprint(model.renamePatch(edit, 'VARIANT')) == model.calculateFingerprint(patch) + # A byte appended by the variant is sound data too. + resized[0][-3] ^= 1 + resized[0] = rewrite(model, resized[0]) + assert model.calculateFingerprint(flatten(resized)) != model.calculateFingerprint(patch) + + +def test_mixed_jv_tone_lengths_rejected(): + patch = next(iter(Roland_JV80.make_test_data().programs)).message.byte_list + blocks = knobkraft.splitSysex(patch) + blocks[1] = rewrite(Roland_JV80.jv_80, blocks[1], data=blocks[1][9:-2] + [0]) + assert Roland_JV80.isPartOfSingleProgramDump(blocks[1]) + assert not Roland_JV80.isSingleProgramDump(flatten(blocks)) + + +def test_xv_destination_state_is_local_and_used_by_all_paths(): + sources = [Roland_XV3080.xv_3080_main, Roland_JV80.jv_80, Roland_JV1080.jv_1080] + initial_ids = [model.device_id for model in sources] + wrappers = [GenericRolandWithBackwardCompatibility(sources[0], sources[1:]) for _ in range(2)] + patches = [list(Path('testData/Roland_XV3080/Pianomonics.syx').read_bytes()), + next(iter(Roland_JV80.make_test_data().programs)).message.byte_list, + next(iter(Roland_JV1080.make_test_data().programs)).message.byte_list] + for index, device in [(0, 0x15), (1, 0x1f), (0, 0x10), (0, 0x1f), (1, 0x15)]: + wrapper = wrappers[index] + other_device = wrappers[1 - index].main_model.device_id + reply = [0xf0, 0x7e, device, 6, 2, 0x41, 0x10, 1, 0, 0, 0, 0, 0, 0, 0xf7] + assert wrapper.channelIfValidDeviceResponse(reply) == device & 15 + assert wrapper.createEditBufferRequest(7)[2] == device + assert wrapper.createProgramDumpRequest(7, 5)[2] == device + for patch in patches: + for converted, predicate in [(wrapper.convertToEditBuffer(7, patch), wrapper.isPartOfEditBufferDump), + (wrapper.convertToProgramDump(7, patch, 5), wrapper.isPartOfSingleProgramDump)]: + for block in knobkraft.splitSysex(converted): + assert block[2] == device + # Compatible JV imports retain their source protocol. + assert block[3] == patch[3] + accepted, followup = predicate(block) + assert accepted + if followup: + assert followup[2] == device + assert [model.device_id for model in sources] == initial_ids + assert wrappers[1 - index].main_model.device_id == other_device + + +def test_public_xv_detection_then_send(monkeypatch): + wrapper = Roland_XV3080.xv_3080 + for model in wrapper.models_supported: + monkeypatch.setattr(model, 'device_id', 0x10) + assert Roland_XV3080.channelIfValidDeviceResponse([240, 126, 21, 6, 2, 65, 16, 1, 0, 0, 0, 0, 0, 0, 247]) == 5 + patch = list(Path('testData/Roland_XV3080/Pianomonics.syx').read_bytes()) + assert all(block[2] == 21 for block in knobkraft.splitSysex(Roland_XV3080.convertToEditBuffer(5, patch))) + assert all(block[2] == 21 for block in knobkraft.splitSysex(Roland_XV3080.convertToProgramDump(5, patch, 0))) + + +def test_name_offset_length_and_block_identity(): + blocks = [DataBlock((0, 0, 0), 32, 'Parameters'), DataBlock((0, 0, 32), 32, 'Name')] + edit = RolandData('Edit', 1, 3, 3, (0, 0, 0), blocks) + program = RolandData('Programs', 8, 3, 3, (1, 0, 0), blocks) + model = GenericRoland('Offset name', [0x42], 3, edit, program, + patch_name_message_number=1, patch_name_offset=8, patch_name_length=16) + patch = flatten(model.buildRolandMessage(16, 18, [1, 0, i * 32], [i + 1] * 32) for i in range(2)) + renamed = model.renamePatch(flatten(reversed(knobkraft.splitSysex(patch))), 'SIXTEEN CHARS!!!') + assert model.nameFromDump(renamed) == 'SIXTEEN CHARS!!!' + assert payloads(model, renamed)[(1, 0, 0)] == [1] * 32 + assert payloads(model, renamed)[(1, 0, 32)][:8] == [2] * 8 + assert payloads(model, renamed)[(1, 0, 32)][24:] == [2] * 8 + assert model.calculateFingerprint(renamed) == model.calculateFingerprint(patch) + assert model.calculateFingerprint(model.convertToEditBuffer(0, renamed)) == model.calculateFingerprint(patch) + + +def test_consecutive_addresses_do_not_alias_other_programs_into_edit_buffer(): + blocks = [DataBlock((0, 0, 0), 256, 'A'), DataBlock((0, 2, 0), 128, 'B')] + edit = RolandData('Edit', 1, 3, 3, (0, 0, 0), blocks, uses_consecutive_addresses=True) + program = RolandData('Programs', 64, 3, 3, (5, 0, 0), blocks, uses_consecutive_addresses=True) + model = GenericRoland('Consecutive', [0x3d], 3, edit, program, patch_name_length=16) + patch = model.buildRolandMessage(16, 18, [5, 3, 0], [1] * 256) + model.buildRolandMessage(16, 18, [5, 5, 0], [2] * 128) + assert model.numberFromDump(patch) == 1 + reordered = flatten(reversed(knobkraft.splitSysex(patch))) + assert model.convertToProgramDump(0, reordered, 1) == patch + assert model.calculateFingerprint(model.convertToEditBuffer(0, reordered)) == model.calculateFingerprint(patch) + aliased_edit = model.buildRolandMessage(16, 18, [0, 3, 0], [1] * 256) + model.buildRolandMessage(16, 18, [0, 5, 0], [2] * 128) + assert not model.isEditBufferDump(aliased_edit) + + +def test_ambiguous_layout_is_rejected(): + blocks = [DataBlock((0, 0, 0), 32, 'A'), DataBlock((0, 1, 0), 32, 'B')] + layout = RolandData('Unsupported layout', 8, 3, 3, (1, 0, 0), blocks) + model = GenericRoland('Ambiguous', [0x42], 3, layout, layout) + patch = model.buildRolandMessage(16, 18, [1, 0, 0], [0] * 32) + assert not model.isSingleProgramDump(patch) + with pytest.raises(ValueError): + model.convertToEditBuffer(0, patch) diff --git a/docs/roland-fingerprints.md b/docs/roland-fingerprints.md new file mode 100644 index 00000000..c0e4300b --- /dev/null +++ b/docs/roland-fingerprints.md @@ -0,0 +1,97 @@ +# Roland dump validation and fingerprint compatibility + +The fixes for #560–562 affect the shared `roland/GenericRoland.py` implementation +(JV-80 family, JV-1080 family, native/compatible XV-3080 imports, and JD-Xi). +The separate D-50 and Juno-DS implementations are unchanged. + +## Existing databases + +Ordinary, correctly ordered program dumps with the nominal block lengths and +device ID `0x10` keep their previous fingerprints. Regression tests pin the old +JV-80, JV-1080, XV-3080 and JD-Xi fixture hashes as literal values. Program +locations, edit-buffer addresses, device IDs, message order and patch names now +produce the same identity for the same sound. + +Some old hashes must change: edit-buffer hashes for models with different +program/edit addresses, reordered dumps, non-default device IDs, and JV variant +lengths whose old positional masks landed in payload data. It is impossible to +retain every old hash while making those representations share one identity. + +Before importing more patches with the updated adaptation: + +1. Keep a backup of the database and any recordings. Restart Orm after updating + adaptations, to discard the in-memory fingerprint cache. +2. Select each affected synth separately and use **Edit > Reindex patches...**. + This includes hidden patches and creates a `-before-reindexing` database backup. +3. Check patch counts and user/bank lists. Equivalent sounds may merge and the + retained name may differ when duplicates had different names. Keep the backup + if that choice needs to be reviewed. + +This uses the existing explicit migration workflow; installing the adaptation +does not automatically rewrite database keys. Until reindexing, old affected +rows can coexist with newly imported canonical rows. Do not manually replace +hashes in the database: list references must be remapped as well. + +### Audit of the existing migration path + +At the MidiKraft revision pinned by this change's base, +`30777d50b29ec8abbea6d86269f3a39ebca25488`, `PatchDatabase::reindexPatches` +retrieves old and recalculated hashes, merges patches through the existing +metadata merge logic, updates `patch_in_list` references, and deletes superseded +rows within a SQLite transaction. `PatchView::reindexPatches` makes the backup +and asks the user to confirm. No database schema or submodule changes are needed. + +The migration does **not** rename files outside the database: thumbnail `.kkc` +and prehear `.wav` files are named by fingerprint. If an affected patch has a +recording, retain the original and copy it under the new hash after reindexing; +do not overwrite an existing recording when several old hashes merge. Thumbnails +can be regenerated. Keep the database backup together with these files for +rollback. This audit is of the code; the Python regression suite does not run the +C++ database migration or exercise hardware. + +Malformed dumps previously admitted by the loose validator are now rejected. +Reindexing cannot repair a missing block, bad checksum or payload already moved +to the wrong address by an earlier conversion. Recover such a patch from its +original valid dump or from the synth before migration. + +## Canonical representation + +1. Validate every DT1 frame: manufacturer/model, command, 7-bit framing, device + ID, checksum, concrete address, and supported payload size. +2. Require one block of each identity in a single device/program context. +3. Match source and destination blocks by their relative address identities. +4. Serialize in program-layout order at program zero with device ID `0x10`. +5. Zero each checksum and the legacy program-position byte; zero only the + configured name block, offset and length. Hash the resulting bytes with MD5. + +The transport headers are retained to preserve the ordinary legacy program +hashes and distinguish protocols. Masks are applied inside each actual frame; +variant block lengths cannot shift a mask into the next block. Every sound byte +outside the name region participates, including variant extensions and bytes +immediately before checksums. + +## Input policy and model extensions + +Reordered blocks are accepted and emitted in declared layout order. Identical +or conflicting duplicates, unknown addresses, missing blocks, mixed devices or +programs, interleaved garbage, truncated frames and unsupported layouts are +rejected. Recognition returns `False`; conversion, rename and fingerprinting +raise `ValueError`. Name/tag lookup returns `Invalid` / an empty list. + +JV-80/880/90/1000 tone lengths `0x73`, `0x74`, `0x75` and JV-1080/2080 common +lengths `0x48`, `0x4a` are explicit supported layouts. All four JV-80-family tones +must have the same supported size; mixing variants is rejected. Payloads are +preserved at their original lengths, without padding or truncation. + +Adaptations with other addressing schemes should override +`_address_for_sub_request(layout, block_no, item)`, which is shared by recognition, +requests and conversion. The address map must identify every block and item +uniquely and the layout must remain fixed after construction. Use +`patch_name_message_number`, `patch_name_offset` and `patch_name_length` for +names instead of overriding positional stream operations. Declare additional +complete length tuples with `RolandData(..., supported_layouts=[...])`. + +The open JD-800, SC-88ST Pro and SD-90 PRs (#538–540) need companion changes to +remove their positional validators/fingerprinters; the SD-90 retains its custom +part-stride address hook. Merge/rebase the shared fix before applying those +companion patches. Those new adaptations are deliberately not introduced here. diff --git a/release_notes/2.9.0.md b/release_notes/2.9.0.md index 110b101a..10e3f2b2 100644 --- a/release_notes/2.9.0.md +++ b/release_notes/2.9.0.md @@ -17,6 +17,9 @@ ## Bug fixes: +* **\#560** Shared Roland adaptations now validate complete DT1 dumps and preserve block addresses when converting or renaming reordered messages, including supported JV family length variants. +* **\#561** XV-3080 requests and patch sends consistently use the detected device ID, including compatible JV imports. +* **\#562** Equivalent Roland program/edit dumps now share a fingerprint regardless of name, device ID or block order. Ordinary program hashes are preserved; existing affected databases should use **Edit > Reindex patches...** before further imports. See [compatibility and migration details](../docs/roland-fingerprints.md). * **\#520** Improved CMake Python detection with a fallback path and explicit Python 3.12+ version checks. Thanks for @ilantz for this! * **\#525** Updated bundled third-party libraries, including JUCE 8.0.12, pybind11 3.0.3, spdlog 1.17.0, fmt 12.1.0, doctest 2.5.2, json 3.12.0, json-schema-validator 2.4.0, ICU, and WinSparkle. * **\#524** Updated pytest to 9.0.3. From f95050543a73523691949736ce5198139b7f4c15 Mon Sep 17 00:00:00 2001 From: Christof Date: Fri, 4 Sep 2026 13:29:06 +0200 Subject: [PATCH 2/2] Move Roland fixes to 2.10.0 release notes --- release_notes/2.10.0.md | 5 +++++ release_notes/2.9.0.md | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 release_notes/2.10.0.md diff --git a/release_notes/2.10.0.md b/release_notes/2.10.0.md new file mode 100644 index 00000000..d06f1727 --- /dev/null +++ b/release_notes/2.10.0.md @@ -0,0 +1,5 @@ +## Bug fixes: + +* **\#560** Shared Roland adaptations now validate complete DT1 dumps and preserve block addresses when converting or renaming reordered messages, including supported JV family length variants. +* **\#561** XV-3080 requests and patch sends consistently use the detected device ID, including compatible JV imports. +* **\#562** Equivalent Roland program/edit dumps now share a fingerprint regardless of name, device ID or block order. Ordinary program hashes are preserved; existing affected databases should use **Edit > Reindex patches...** before further imports. See [compatibility and migration details](../docs/roland-fingerprints.md). diff --git a/release_notes/2.9.0.md b/release_notes/2.9.0.md index 10e3f2b2..110b101a 100644 --- a/release_notes/2.9.0.md +++ b/release_notes/2.9.0.md @@ -17,9 +17,6 @@ ## Bug fixes: -* **\#560** Shared Roland adaptations now validate complete DT1 dumps and preserve block addresses when converting or renaming reordered messages, including supported JV family length variants. -* **\#561** XV-3080 requests and patch sends consistently use the detected device ID, including compatible JV imports. -* **\#562** Equivalent Roland program/edit dumps now share a fingerprint regardless of name, device ID or block order. Ordinary program hashes are preserved; existing affected databases should use **Edit > Reindex patches...** before further imports. See [compatibility and migration details](../docs/roland-fingerprints.md). * **\#520** Improved CMake Python detection with a fallback path and explicit Python 3.12+ version checks. Thanks for @ilantz for this! * **\#525** Updated bundled third-party libraries, including JUCE 8.0.12, pybind11 3.0.3, spdlog 1.17.0, fmt 12.1.0, doctest 2.5.2, json 3.12.0, json-schema-validator 2.4.0, ICU, and WinSparkle. * **\#524** Updated pytest to 9.0.3.