From 6765954d397c05e658859d43af1bf9ff79310096 Mon Sep 17 00:00:00 2001 From: Christof Date: Wed, 27 May 2026 23:59:12 +0200 Subject: [PATCH 1/2] [synth] Nord Lead adaptation --- README.md | 1 + adaptations/CMakeLists.txt | 1 + adaptations/Clavia_Nord_Lead.py | 363 ++++++++++++++++++ .../testData/Clavia_Nord_Lead/bank0.syx | Bin 0 -> 24391 bytes .../testData/Clavia_Nord_Lead/bank1.syx | Bin 0 -> 24391 bytes .../Clavia_Nord_Lead/nord-lead-2-internal.mid | Bin 0 -> 5734 bytes .../Clavia_Nord_Lead/nord-lead-bank1.mid | Bin 0 -> 24779 bytes 7 files changed, 365 insertions(+) create mode 100644 adaptations/Clavia_Nord_Lead.py create mode 100644 adaptations/testData/Clavia_Nord_Lead/bank0.syx create mode 100644 adaptations/testData/Clavia_Nord_Lead/bank1.syx create mode 100644 adaptations/testData/Clavia_Nord_Lead/nord-lead-2-internal.mid create mode 100644 adaptations/testData/Clavia_Nord_Lead/nord-lead-bank1.mid diff --git a/README.md b/README.md index 7bfcc407..cc67065e 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Questions and help with implementing new synths wanted! Or if you have found a b | Behringer | RD-9 | in progress | adaptation | | | Behringer | Wave | works | adaptation | Thanks to @willxy! | | Black Corporation | Kijimi | beta | adaptation | Thanks to @ffont and @markusschlosser | +| Clavia | Nord Lead / Nord Lead 2 / Nord Lead 2X | alpha | adaptation | official Lead-family fixtures; Nord Lead 3+ is a separate protocol | | DSI | Evolver | beta | adaptation | | | DSI | Mopho | works | adaptation | | | DSI | Mopho X4 | works | adaptation | | diff --git a/adaptations/CMakeLists.txt b/adaptations/CMakeLists.txt index 1075b354..dcf46836 100644 --- a/adaptations/CMakeLists.txt +++ b/adaptations/CMakeLists.txt @@ -12,6 +12,7 @@ set(adaptation_files "Akai_AX80.py" "AlesisAndromedaA6.py" "BC_Kijimi.py" + "Clavia_Nord_Lead.py" "Behringer Deepmind 12.py" "Behringer_Wave.py" "Behringer_Pro_800.py" "DSI Pro 2.py" "DSI Prophet 08.py" "DSI_Evolver.py" "DSI_Mopho.py" "DSI_Mopho_X4.py" "DSI_Tetra.py" "DSI Prophet 12.py" "ElectraOne.py" diff --git a/adaptations/Clavia_Nord_Lead.py b/adaptations/Clavia_Nord_Lead.py new file mode 100644 index 00000000..5faa2ea0 --- /dev/null +++ b/adaptations/Clavia_Nord_Lead.py @@ -0,0 +1,363 @@ +# +# Copyright (c) 2026 Christof Ruch. All rights reserved. +# +# Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase +# + +import hashlib +from pathlib import Path +from typing import List, Tuple + +import knobkraft +import testing + + +MANUFACTURER_ID = 0x33 +MODEL_ID_CLASSIC = 0x04 + +PATCH_DATA_SIZE = 132 +PATCH_MESSAGE_SIZE = 6 + PATCH_DATA_SIZE + 1 + +CLASSIC_BANK_COUNT = 4 +PATCHES_PER_BANK = 99 +MAX_IMPORT_BANKS = 10 +EDIT_BUFFER_SLOTS = 4 + +EDIT_BUFFER_DUMP_TYPE = 0x00 +EDIT_BUFFER_REQUEST_TYPE = 0x0A +FIRST_PROGRAM_DUMP_TYPE = 0x01 +FIRST_PROGRAM_REQUEST_TYPE = 0x0B + + +def name(): + return "Clavia Nord Lead" + + +def setupHelp(): + return ( + "This adaptation targets the classic Nord Lead SysEx family used by the Nord Lead 1, Nord Lead 2, " + "and Nord Lead 2X program dumps. Patch dumps do not contain patch names in the documented 66-parameter " + "payload, so KnobKraft shows location-based names only. Nord Lead 3 and later Nord Lead models use a " + "different SysEx format and are not covered by this adaptation." + ) + + +def createDeviceDetectMessage(channel): + return createEditBufferRequest(channel) + + +def needsChannelSpecificDetection(): + return True + + +def deviceDetectWaitMilliseconds(): + return 200 + + +def generalMessageDelay(): + return 200 + + +def channelIfValidDeviceResponse(message): + if isEditBufferDump(message) or isSingleProgramDump(message): + return message[2] & 0x0F + return -1 + + +def numberOfBanks(): + return CLASSIC_BANK_COUNT + + +def numberOfPatchesPerBank(): + return PATCHES_PER_BANK + + +def friendlyBankName(bank): + if 0 <= bank < MAX_IMPORT_BANKS: + return f"Bank {bank}" + return f"Bank {bank + 1}" + + +def friendlyProgramName(program): + if program < 0: + return "Slot A" + bank = program // PATCHES_PER_BANK + patch = program % PATCHES_PER_BANK + return f"{bank}.{patch + 1:02d}" + + +def createEditBufferRequest(channel): + return [0xF0, MANUFACTURER_ID, channel & 0x0F, MODEL_ID_CLASSIC, EDIT_BUFFER_REQUEST_TYPE, 0x00, 0xF7] + + +def createProgramDumpRequest(channel, patch_no): + bank, slot = divmod(patch_no, PATCHES_PER_BANK) + if not (0 <= bank < MAX_IMPORT_BANKS): + raise Exception(f"Program {patch_no} is out of range for {name()}") + return [0xF0, MANUFACTURER_ID, channel & 0x0F, MODEL_ID_CLASSIC, FIRST_PROGRAM_REQUEST_TYPE + bank, slot, 0xF7] + + +def isEditBufferDump(message): + return ( + len(message) == PATCH_MESSAGE_SIZE + and _has_classic_header(message) + and message[4] == EDIT_BUFFER_DUMP_TYPE + and 0 <= message[5] < EDIT_BUFFER_SLOTS + ) + + +def isSingleProgramDump(message): + return ( + len(message) == PATCH_MESSAGE_SIZE + and _has_classic_header(message) + and FIRST_PROGRAM_DUMP_TYPE <= message[4] < FIRST_PROGRAM_DUMP_TYPE + MAX_IMPORT_BANKS + and 0 <= message[5] < PATCHES_PER_BANK + ) + + +def convertToEditBuffer(channel, message): + if not (isEditBufferDump(message) or isSingleProgramDump(message)): + raise Exception("Can only convert classic Nord Lead program or edit buffer dumps") + return [0xF0, MANUFACTURER_ID, channel & 0x0F, MODEL_ID_CLASSIC, EDIT_BUFFER_DUMP_TYPE, 0x00] + _payload_from_patch_message(message) + [0xF7] + + +def convertToProgramDump(channel, message, program_number): + if not (isEditBufferDump(message) or isSingleProgramDump(message)): + raise Exception("Can only convert classic Nord Lead program or edit buffer dumps") + bank, slot = divmod(program_number, PATCHES_PER_BANK) + if not (0 <= bank < MAX_IMPORT_BANKS): + raise Exception(f"Program {program_number} is out of range for {name()}") + return [0xF0, MANUFACTURER_ID, channel & 0x0F, MODEL_ID_CLASSIC, FIRST_PROGRAM_DUMP_TYPE + bank, slot] + _payload_from_patch_message(message) + [0xF7] + + +def numberFromDump(message): + if isSingleProgramDump(message): + bank = message[4] - FIRST_PROGRAM_DUMP_TYPE + return bank * PATCHES_PER_BANK + message[5] + return -1 + + +def blankedOut(message): + if not (isEditBufferDump(message) or isSingleProgramDump(message)): + raise Exception("Can only blank out classic Nord Lead program or edit buffer dumps") + return [0xF0, MANUFACTURER_ID, 0x00, MODEL_ID_CLASSIC, EDIT_BUFFER_DUMP_TYPE, 0x00] + _payload_from_patch_message(message) + [0xF7] + + +def calculateFingerprint(message): + return hashlib.md5(bytearray(blankedOut(message))).hexdigest() + + +def legacyLoadSupportedExtensions() -> List[str]: + return [".mid"] + + +def loadPatchesFromLegacyData(data: List[int]) -> List[List[int]]: + return [ + message + for message in _extract_sysex_from_midi_bytes(bytes(data)) + if isSingleProgramDump(message) or isEditBufferDump(message) + ] + + +def _has_classic_header(message: List[int]) -> bool: + return ( + len(message) >= 7 + and message[0] == 0xF0 + and message[1] == MANUFACTURER_ID + and message[3] == MODEL_ID_CLASSIC + and message[-1] == 0xF7 + ) + + +def _payload_from_patch_message(message: List[int]) -> List[int]: + return message[6:-1] + + +def _read_vlq(data: bytes, index: int) -> Tuple[int, int]: + value = 0 + while True: + if index >= len(data): + raise ValueError("Unexpected end of MIDI data while decoding VLQ") + byte = data[index] + index += 1 + value = (value << 7) | (byte & 0x7F) + if byte < 0x80: + return value, index + + +def _skip_channel_message(status: int, index: int) -> int: + if 0xC0 <= status <= 0xDF: + return index + 1 + return index + 2 + + +def _extract_sysex_from_midi_bytes(data: bytes) -> List[List[int]]: + if len(data) < 14 or data[:4] != b"MThd": + return [] + + header_length = int.from_bytes(data[4:8], "big") + if header_length < 6: + return [] + + track_count = int.from_bytes(data[10:12], "big") + index = 8 + header_length + messages: List[List[int]] = [] + running_status = None + pending_sysex: List[int] = [] + + for _ in range(track_count): + if index + 8 > len(data) or data[index:index + 4] != b"MTrk": + break + track_length = int.from_bytes(data[index + 4:index + 8], "big") + track = data[index + 8:index + 8 + track_length] + index += 8 + track_length + + pos = 0 + running_status = None + pending_sysex = [] + while pos < len(track): + _, pos = _read_vlq(track, pos) + if pos >= len(track): + break + + status = track[pos] + if status >= 0x80: + pos += 1 + running_status = None if status in (0xF0, 0xF7, 0xFF) else status + elif running_status is not None: + status = running_status + else: + break + + if status == 0xFF: + if pos >= len(track): + break + pos += 1 + meta_length, pos = _read_vlq(track, pos) + pos += meta_length + continue + + if status in (0xF0, 0xF7): + sysex_length, pos = _read_vlq(track, pos) + payload = list(track[pos:pos + sysex_length]) + pos += sysex_length + if status == 0xF0: + pending_sysex = [0xF0] + payload + elif pending_sysex: + pending_sysex.extend(payload) + else: + pending_sysex = [0xF0] + payload + + if pending_sysex and pending_sysex[-1] == 0xF7: + messages.append(pending_sysex) + pending_sysex = [] + continue + + if 0x80 <= status <= 0xEF: + pos = _skip_channel_message(status, pos) + continue + + if status in (0xF1, 0xF3): + pos += 1 + elif status == 0xF2: + pos += 2 + + return messages + + +def _testdata_path(filename: str) -> str: + return str(Path(__file__).resolve().parent / "testData" / "Clavia_Nord_Lead" / filename) + + +def _legacy_file_bytes(filename: str) -> List[int]: + return list(Path(_testdata_path(filename)).read_bytes()) + + +def _assert_first_patch_is_program(adaptation, patches: List[List[int]]): + assert len(patches) > 0 + assert adaptation.isSingleProgramDump(patches[0]) + + +def make_test_data(): + from testing.mock_midi import EditBufferMockDevice, ProgramDumpMockDevice + + bank0_messages = knobkraft.load_sysex(_testdata_path("bank0.syx")) + bank1_messages = knobkraft.load_sysex(_testdata_path("bank1.syx")) + bank0_programs = [message for message in bank0_messages if isSingleProgramDump(message)] + bank1_programs = [message for message in bank1_messages if isSingleProgramDump(message)] + normalized_bank0 = [ + convertToProgramDump(0, message, program_no) + for program_no, message in enumerate(bank0_programs) + ] + normalized_bank1 = [ + convertToProgramDump(0, message, PATCHES_PER_BANK + program_no) + for program_no, message in enumerate(bank1_programs) + ] + + def programs(_test_data: testing.TestData) -> List[testing.ProgramTestData]: + selected = [normalized_bank0[0], normalized_bank0[41], normalized_bank0[-1]] + return [ + testing.ProgramTestData( + message=message, + number=numberFromDump(message), + friendly_number=friendlyProgramName(numberFromDump(message)), + ) + for message in selected + ] + + def edit_buffers(test_data: testing.TestData) -> List[testing.ProgramTestData]: + return [testing.ProgramTestData(message=convertToEditBuffer(0, test_data.programs[0].message.byte_list))] + + def banks(_test_data: testing.TestData) -> List[List[List[int]]]: + return [bank0_messages, bank1_messages] + + def mock_device(_test_data: testing.TestData, _adaptation): + return ProgramDumpMockDevice(_adaptation, normalized_bank0 + normalized_bank1) + + def edit_buffer_mock_device(test_data: testing.TestData, adaptation): + first_buffer = test_data.edit_buffers[0].message.byte_list + return EditBufferMockDevice(adaptation, [first_buffer.copy() for _ in range(PATCHES_PER_BANK)]) + + return testing.TestData( + sysex=_testdata_path("bank0.syx"), + program_generator=programs, + edit_buffer_generator=edit_buffers, + bank_generator=banks, + expected_patch_count=PATCHES_PER_BANK, + program_dump_request=(0, 11, createProgramDumpRequest(0, 11)), + device_detect_call=createDeviceDetectMessage(0), + device_detect_reply=(convertToEditBuffer(0, normalized_bank0[0]), 0), + friendly_bank_name=(1, "Bank 1"), + legacy_loader_cases=[ + testing.LegacyLoaderTestData( + file_extension=".mid", + file_content=_legacy_file_bytes("nord-lead-2-internal.mid"), + expected_patch_count=40, + patch_inspector=_assert_first_patch_is_program, + ), + testing.LegacyLoaderTestData( + file_extension=".mid", + file_content=_legacy_file_bytes("nord-lead-bank1.mid"), + expected_patch_count=PATCHES_PER_BANK, + patch_inspector=_assert_first_patch_is_program, + ), + ], + mock_device_factory=mock_device, + expected_wire_patch_count=PATCHES_PER_BANK, + expected_sent_messages=lambda _test_data, adaptation: [ + adaptation.createProgramDumpRequest(0, program_no) + for program_no in range(PATCHES_PER_BANK) + ], + single_edit_buffer_mock_device_factory=edit_buffer_mock_device, + expected_single_edit_buffer_count=1, + wire_download_banks=[0, 1], + expected_multi_bank_patch_count=PATCHES_PER_BANK * 2, + expected_multi_bank_sent_messages=lambda _test_data, adaptation: [ + adaptation.createProgramDumpRequest(0, program_no) + for program_no in range(PATCHES_PER_BANK * 2) + ], + send_to_synth_patch=lambda test_data: test_data.programs[0].message.byte_list, + expected_send_to_synth_messages=lambda test_data, adaptation: knobkraft.splitSysex( + adaptation.convertToEditBuffer(0, test_data.programs[0].message.byte_list) + ), + ) diff --git a/adaptations/testData/Clavia_Nord_Lead/bank0.syx b/adaptations/testData/Clavia_Nord_Lead/bank0.syx new file mode 100644 index 0000000000000000000000000000000000000000..48c10d941f14272100f4612ff014abd16a20998b GIT binary patch literal 24391 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 literal 0 HcmV?d00001 diff --git a/adaptations/testData/Clavia_Nord_Lead/nord-lead-2-internal.mid b/adaptations/testData/Clavia_Nord_Lead/nord-lead-2-internal.mid new file mode 100644 index 0000000000000000000000000000000000000000..e01f112a2d90585bc51c71786895266c4c63b4ad GIT binary patch literal 5734 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 literal 0 HcmV?d00001 From d2e35a426153f908836f16997806e8734e22b621 Mon Sep 17 00:00:00 2001 From: Christof Date: Fri, 4 Sep 2026 13:11:28 +0200 Subject: [PATCH 2/2] Harden Nord Lead protocol boundaries and MIDI imports with regression tests --- README.md | 2 +- adaptations/CMakeLists.txt | 1 + adaptations/Clavia_Nord_Lead.py | 129 +++++++---- .../testData/Clavia_Nord_Lead/README.md | 33 +++ adaptations/test_Clavia_Nord_Lead.py | 217 ++++++++++++++++++ docs/README.md | 1 + 6 files changed, 341 insertions(+), 42 deletions(-) create mode 100644 adaptations/testData/Clavia_Nord_Lead/README.md create mode 100644 adaptations/test_Clavia_Nord_Lead.py diff --git a/README.md b/README.md index 5b4559b3..98d1d097 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Questions and help with implementing new synths wanted! Or if you have found a b | Behringer | RD-9 | in progress | adaptation | | | Behringer | Wave | works | adaptation | Thanks to @willxy! | | Black Corporation | Kijimi | beta | adaptation | Thanks to @ffont and @markusschlosser | -| Clavia | Nord Lead / Nord Lead 2 / Nord Lead 2X | alpha | adaptation | official Lead-family fixtures; Nord Lead 3+ is a separate protocol | +| Clavia | Nord Lead / Nord Lead 2 / Nord Lead 2X | alpha | adaptation | Four live banks; ten-bank 2X file import/export; official fixtures and mock MIDI tested, hardware verification pending; no Nord Lead 3+ | | DSI | Evolver | beta | adaptation | | | DSI | Mopho | works | adaptation | | | DSI | Mopho X4 | works | adaptation | | diff --git a/adaptations/CMakeLists.txt b/adaptations/CMakeLists.txt index bd3aa516..52fea192 100644 --- a/adaptations/CMakeLists.txt +++ b/adaptations/CMakeLists.txt @@ -43,6 +43,7 @@ set(adaptation_files_test_shipped "test_adaptations.py" "test_AndromedaA6.py" "test_BC_Kijimi.py" + "test_Clavia_Nord_Lead.py" "test_implementation_status.py" "test_jv80.py" "test_JV1080.py" diff --git a/adaptations/Clavia_Nord_Lead.py b/adaptations/Clavia_Nord_Lead.py index 5faa2ea0..8cea7f06 100644 --- a/adaptations/Clavia_Nord_Lead.py +++ b/adaptations/Clavia_Nord_Lead.py @@ -18,6 +18,9 @@ PATCH_DATA_SIZE = 132 PATCH_MESSAGE_SIZE = 6 + PATCH_DATA_SIZE + 1 +# Lead 1/2 and even the 2X request table document only types 0x0B..0x0E. +# The 2X dump table supports ten banks. Do not extrapolate request types: +# 0x14 (which would be bank 10) is the All Controllers Request command. CLASSIC_BANK_COUNT = 4 PATCHES_PER_BANK = 99 MAX_IMPORT_BANKS = 10 @@ -38,7 +41,16 @@ def setupHelp(): "This adaptation targets the classic Nord Lead SysEx family used by the Nord Lead 1, Nord Lead 2, " "and Nord Lead 2X program dumps. Patch dumps do not contain patch names in the documented 66-parameter " "payload, so KnobKraft shows location-based names only. Nord Lead 3 and later Nord Lead models use a " - "different SysEx format and are not covered by this adaptation." + "different SysEx format and are not covered by this adaptation. " + "Set the Global MIDI channel to the channel selected in KnobKraft. Select a normal synth patch " + "in Slot A (not a percussion kit) for detection and audition; audition replaces Slot A only. " + "Live downloads expose four banks of 99 program locations (displayed as banks 0-3); bank availability " + "depends on the instrument and installed memory card. 2X dumps from all ten banks can be imported " + "and exported, but live requests for banks 4-9 are deliberately unsupported because the 2X manual " + "does not document their request codes. Use a front-panel dump/file for those banks. " + "Program-dump playback may overwrite locations in the bank currently selected on the instrument; " + "normal audition uses the temporary edit buffer instead. Percussion kits and performances are " + "not supported. Tested with official files and mock MIDI, not with hardware." ) @@ -59,8 +71,8 @@ def generalMessageDelay(): def channelIfValidDeviceResponse(message): - if isEditBufferDump(message) or isSingleProgramDump(message): - return message[2] & 0x0F + if isEditBufferDump(message) and message[5] == 0: + return message[2] return -1 @@ -73,9 +85,9 @@ def numberOfPatchesPerBank(): def friendlyBankName(bank): - if 0 <= bank < MAX_IMPORT_BANKS: - return f"Bank {bank}" - return f"Bank {bank + 1}" + if not 0 <= bank < MAX_IMPORT_BANKS: + raise ValueError(f"Invalid Nord Lead bank {bank}") + return f"Bank {bank}" def friendlyProgramName(program): @@ -92,8 +104,8 @@ def createEditBufferRequest(channel): def createProgramDumpRequest(channel, patch_no): bank, slot = divmod(patch_no, PATCHES_PER_BANK) - if not (0 <= bank < MAX_IMPORT_BANKS): - raise Exception(f"Program {patch_no} is out of range for {name()}") + if not (0 <= bank < CLASSIC_BANK_COUNT): + raise ValueError(f"Program {patch_no} is outside the four documented live-request banks") return [0xF0, MANUFACTURER_ID, channel & 0x0F, MODEL_ID_CLASSIC, FIRST_PROGRAM_REQUEST_TYPE + bank, slot, 0xF7] @@ -144,7 +156,7 @@ def blankedOut(message): def calculateFingerprint(message): - return hashlib.md5(bytearray(blankedOut(message))).hexdigest() + return hashlib.md5(bytearray(blankedOut(message)), usedforsecurity=False).hexdigest() def legacyLoadSupportedExtensions() -> List[str]: @@ -164,8 +176,10 @@ def _has_classic_header(message: List[int]) -> bool: len(message) >= 7 and message[0] == 0xF0 and message[1] == MANUFACTURER_ID + and 0 <= message[2] <= 0x0F and message[3] == MODEL_ID_CLASSIC and message[-1] == 0xF7 + and all(0 <= byte <= 0x0F for byte in message[6:-1]) ) @@ -175,7 +189,7 @@ def _payload_from_patch_message(message: List[int]) -> List[int]: def _read_vlq(data: bytes, index: int) -> Tuple[int, int]: value = 0 - while True: + for _ in range(4): if index >= len(data): raise ValueError("Unexpected end of MIDI data while decoding VLQ") byte = data[index] @@ -183,85 +197,118 @@ def _read_vlq(data: bytes, index: int) -> Tuple[int, int]: value = (value << 7) | (byte & 0x7F) if byte < 0x80: return value, index + raise ValueError("MIDI VLQ exceeds four bytes") -def _skip_channel_message(status: int, index: int) -> int: - if 0xC0 <= status <= 0xDF: - return index + 1 - return index + 2 +def _read_midi_payload(track: bytes, pos: int) -> Tuple[bytes, int]: + length, pos = _read_vlq(track, pos) + end = pos + length + if end > len(track): + raise ValueError("MIDI event payload extends beyond track") + return track[pos:end], end def _extract_sysex_from_midi_bytes(data: bytes) -> List[List[int]]: - if len(data) < 14 or data[:4] != b"MThd": + if data[:4] != b"MThd": return [] - + if len(data) < 14: + raise ValueError("Truncated MIDI header") header_length = int.from_bytes(data[4:8], "big") - if header_length < 6: - return [] - + if header_length < 6 or 8 + header_length > len(data): + raise ValueError("Invalid MIDI header length") + midi_format = int.from_bytes(data[8:10], "big") track_count = int.from_bytes(data[10:12], "big") + if midi_format not in (0, 1, 2) or track_count == 0 or (midi_format == 0 and track_count != 1): + raise ValueError("Invalid MIDI format or track count") index = 8 + header_length messages: List[List[int]] = [] - running_status = None - pending_sysex: List[int] = [] - for _ in range(track_count): if index + 8 > len(data) or data[index:index + 4] != b"MTrk": - break + raise ValueError("Missing MIDI track") track_length = int.from_bytes(data[index + 4:index + 8], "big") + if index + 8 + track_length > len(data): + raise ValueError("Truncated MIDI track") track = data[index + 8:index + 8 + track_length] index += 8 + track_length pos = 0 running_status = None pending_sysex = [] + ended = False while pos < len(track): _, pos = _read_vlq(track, pos) if pos >= len(track): - break + raise ValueError("Missing MIDI event after delta time") status = track[pos] if status >= 0x80: pos += 1 - running_status = None if status in (0xF0, 0xF7, 0xFF) else status + running_status = status if 0x80 <= status <= 0xEF else None elif running_status is not None: status = running_status else: - break + raise ValueError("MIDI data byte without running status") if status == 0xFF: if pos >= len(track): - break + raise ValueError("Missing MIDI meta-event type") + meta_type = track[pos] pos += 1 - meta_length, pos = _read_vlq(track, pos) - pos += meta_length + if meta_type >= 0x80: + raise ValueError("Invalid MIDI meta-event type") + payload, pos = _read_midi_payload(track, pos) + if meta_type == 0x2F: + if payload or pos != len(track) or pending_sysex: + raise ValueError("Invalid end-of-track or incomplete SysEx") + ended = True continue if status in (0xF0, 0xF7): - sysex_length, pos = _read_vlq(track, pos) - payload = list(track[pos:pos + sysex_length]) - pos += sysex_length + payload_bytes, pos = _read_midi_payload(track, pos) + payload = list(payload_bytes) if status == 0xF0: + if pending_sysex: + raise ValueError("New SysEx before previous message completed") pending_sysex = [0xF0] + payload elif pending_sysex: pending_sysex.extend(payload) else: - pending_sysex = [0xF0] + payload - + # An unpaired F7 event is an SMF escape, not an implicit + # SysEx start. Only extract explicitly framed messages. + escaped = [] + for byte in payload: + if byte == 0xF0: + if escaped: + raise ValueError("Nested SysEx in MIDI escape") + escaped = [byte] + elif escaped: + escaped.append(byte) + if byte == 0xF7: + messages.append(escaped) + escaped = [] + if escaped: + raise ValueError("Incomplete SysEx in MIDI escape") + continue + + sysex_data = pending_sysex[1:-1] if pending_sysex[-1] == 0xF7 else pending_sysex[1:] + if any(byte >= 0x80 for byte in sysex_data): + raise ValueError("Embedded status byte in MIDI SysEx") if pending_sysex and pending_sysex[-1] == 0xF7: messages.append(pending_sysex) pending_sysex = [] continue if 0x80 <= status <= 0xEF: - pos = _skip_channel_message(status, pos) + end = pos + (1 if 0xC0 <= status <= 0xDF else 2) + if end > len(track) or any(byte >= 0x80 for byte in track[pos:end]): + raise ValueError("Truncated or invalid MIDI channel event") + pos = end continue - - if status in (0xF1, 0xF3): - pos += 1 - elif status == 0xF2: - pos += 2 - + raise ValueError(f"Unsupported MIDI file event status {status:#x}") + if not ended: + raise ValueError("MIDI track has no end-of-track event") + if index != len(data): + raise ValueError("Unexpected data after declared MIDI tracks") return messages diff --git a/adaptations/testData/Clavia_Nord_Lead/README.md b/adaptations/testData/Clavia_Nord_Lead/README.md new file mode 100644 index 00000000..3561d0fe --- /dev/null +++ b/adaptations/testData/Clavia_Nord_Lead/README.md @@ -0,0 +1,33 @@ +# Nord Lead regression fixtures + +These existing fixtures come from Nord's official download archives: + +| Files | Source | Verified contents | +| --- | --- | --- | +| `bank0.syx`, `bank1.syx` | [Nord Lead 2X factory bank](https://www.nordkeyboards.com/wt/documents/797/Nord%20Lead%202x%20Factory%20Bank%20v1.00%20revA.zip) | Each contains 99 normal programs and 10 percussion kits; kits are deliberately excluded from import. | +| `nord-lead-2-internal.mid` | [Nord Lead 2 factory bank](https://www.nordkeyboards.com/wt/documents/708/Nord%20Lead%202%20Factory%20Bank.zip) | 40 normal programs. | +| `nord-lead-bank1.mid` | [Nord Lead sound cards 1–4](https://www.nordkeyboards.com/wt/documents/706/Nord%20Lead%20Sound%20Card%201-4.zip) | 99 normal programs. | + +The sound data is unchanged by the release-hardening work. These are manufacturer +files, not newly recorded hardware captures or newly generated public-domain sounds; +the source attribution does not grant a new license to the sound data. + +## Protocol evidence and limits + +The [Nord Lead 2X manual, edition 1.1](https://www.nordkeyboards.com/wt/documents/218/Nord%20Lead%202x%20English%20User%20Manual%20v1.0%20Edition%201.1.pdf) +documents ten program-dump banks on printed page 106, but only four program-request +banks (`0x0B`–`0x0E`) on pages 107–108. Page 109 assigns `0x14` to All Controllers +Request, so extrapolating request types across ten banks is unsafe. The adaptation +therefore exposes four live banks while accepting ten-bank program files. Actual +bank availability depends on the model and installed memory card. + +The regression suite relocates fixture payloads to independently constructed +addresses, including the higher 2X banks. These are simulated responses, not +hardware evidence. Tests cover nonzero channels, all four live banks, repeated +bank downloads, temporary Slot A audition, fingerprints, rejected percussion and +malformed dumps, and complete/fragmented/escaped SysEx in MIDI files. Malformed +MIDI tracks fail with `ValueError`, without returning partially parsed patches. + +Hardware detection, timing, writable-memory behavior, and requests for higher 2X +banks remain unverified. Performances, percussion kits, and Nord Lead 3+ are outside +this adaptation's scope. Keep the adaptation marked alpha until hardware testing. diff --git a/adaptations/test_Clavia_Nord_Lead.py b/adaptations/test_Clavia_Nord_Lead.py new file mode 100644 index 00000000..0df059c2 --- /dev/null +++ b/adaptations/test_Clavia_Nord_Lead.py @@ -0,0 +1,217 @@ +from pathlib import Path + +import pytest + +import knobkraft +import Clavia_Nord_Lead as nord +from testing.librarian import Librarian +from testing.mock_midi import MockMidiController, ScriptedMockDevice + + +DATA = Path(__file__).resolve().parent / "testData" / "Clavia_Nord_Lead" + + +@pytest.fixture +def patch(): + return knobkraft.load_sysex(str(DATA / "bank0.syx"))[0] + + +@pytest.mark.parametrize("filename", ["bank0.syx", "bank1.syx"]) +def test_official_banks_contain_99_programs_and_ten_unsupported_kits(filename): + messages = knobkraft.load_sysex(str(DATA / filename)) + programs = [m for m in messages if nord.isSingleProgramDump(m)] + assert len(messages) == 109 + assert len(programs) == 99 + assert {m[5] for m in programs} == set(range(99)) + kits = [m for m in messages if len(m) == 1063] + assert len(kits) == 10 + assert all(not nord.isSingleProgramDump(m) and not nord.isEditBufferDump(m) for m in kits) + + +@pytest.mark.parametrize("filename,count", [("nord-lead-2-internal.mid", 40), ("nord-lead-bank1.mid", 99)]) +def test_official_midi_files(filename, count): + patches = nord.loadPatchesFromLegacyData(list((DATA / filename).read_bytes())) + assert len(patches) == count + assert all(nord.isSingleProgramDump(m) for m in patches) + assert len({nord.numberFromDump(m) for m in patches}) == count + + +@pytest.mark.parametrize("bank", range(4)) +@pytest.mark.parametrize("slot", [0, 98]) +@pytest.mark.parametrize("channel", [0, 9, 15]) +def test_documented_request_bytes(bank, slot, channel): + assert nord.createProgramDumpRequest(channel, bank * 99 + slot) == [ + 0xF0, 0x33, channel, 0x04, 0x0B + bank, slot, 0xF7 + ] + + +@pytest.mark.parametrize("bank", range(10)) +def test_ten_bank_file_addresses_round_trip_without_changing_sound(patch, bank): + original = patch.copy() + program = bank * 99 + 98 + converted = nord.convertToProgramDump(9, patch, program) + assert converted == [0xF0, 0x33, 9, 4, bank + 1, 98] + patch[6:] + assert nord.numberFromDump(converted) == program + assert nord.friendlyProgramName(program) == f"{bank}.99" + assert nord.friendlyBankName(bank) == f"Bank {bank}" + edit = nord.convertToEditBuffer(15, converted) + assert edit == [0xF0, 0x33, 15, 4, 0, 0] + patch[6:] + assert nord.convertToProgramDump(9, edit, program) == converted + assert nord.calculateFingerprint(edit) == nord.calculateFingerprint(patch) + assert patch == original + if bank >= 4: + with pytest.raises(ValueError): + nord.createProgramDumpRequest(9, program) + + +@pytest.mark.parametrize("program", [-1, 396, 989, 990]) +def test_live_request_rejects_undocumented_banks(program): + with pytest.raises(ValueError): + nord.createProgramDumpRequest(0, program) + + +def test_invalid_export_addresses_and_bank_names(patch): + for number in [-1, 990]: + with pytest.raises(Exception): + nord.convertToProgramDump(0, patch, number) + for bank in [-1, 10]: + with pytest.raises(ValueError): + nord.friendlyBankName(bank) + + +@pytest.mark.parametrize("channel", [0, 9, 15]) +def test_detection_requires_requested_slot_a(patch, channel): + assert nord.createDeviceDetectMessage(channel) == [0xF0, 0x33, channel, 4, 0x0A, 0, 0xF7] + assert nord.needsChannelSpecificDetection() + for slot in range(4): + response = [0xF0, 0x33, channel, 4, 0, slot] + patch[6:] + assert nord.isEditBufferDump(response) + assert nord.channelIfValidDeviceResponse(response) == (channel if slot == 0 else -1) + assert nord.channelIfValidDeviceResponse(patch) == -1 + + +@pytest.mark.parametrize("index,value", [ + (0, 0), (1, 0x41), (2, 16), (2, -1), (3, 5), (4, 11), + (5, 99), (6, 16), (7, 128), (137, -1), (138, 0), +]) +def test_invalid_programs_are_not_recognized(patch, index, value): + patch[index] = value + assert not nord.isSingleProgramDump(patch) + assert not nord.isEditBufferDump(patch) + assert nord.channelIfValidDeviceResponse(patch) == -1 + + +def test_truncated_and_oversized_dumps_are_rejected(patch): + for length in range(len(patch)): + assert not nord.isSingleProgramDump(patch[:length]) + assert not nord.isEditBufferDump(patch[:length]) + assert not nord.isSingleProgramDump(patch + [0]) + invalid_edit = [0xF0, 0x33, 0, 4, 0, 4] + patch[6:] + assert not nord.isEditBufferDump(invalid_edit) + + +def test_fingerprint_detects_sound_changes(patch): + changed = patch.copy() + changed[6] ^= 1 + assert nord.calculateFingerprint(changed) != nord.calculateFingerprint(patch) + + +@pytest.mark.parametrize("banks", [[0], [1], [2], [3], [3, 0, 2, 0]]) +def test_independent_mock_downloads_all_banks_and_repeated_bank(patch, banks): + # Relocated fixture, not recorded responses: construct expected wire bytes + # independently so a matching bug in a request helper cannot hide itself. + requests = [[0xF0, 0x33, 9, 4, 0x0B + bank, slot, 0xF7] for bank in banks for slot in range(99)] + replies = [[0xF0, 0x33, 9, 4, 1 + bank, slot] + patch[6:] for bank in banks for slot in range(99)] + midi = MockMidiController(ScriptedMockDevice({tuple(q): [r] for q, r in zip(requests, replies)})) + downloaded = [] + Librarian().start_downloading_all_patches(midi, 9, nord, banks, downloaded.extend) + midi.drain() + assert midi.finished + assert midi.sent_messages == requests + assert downloaded == replies + + +def test_audition_only_replaces_slot_a_and_exports_reimport(patch): + expected = [0xF0, 0x33, 9, 4, 0, 0] + patch[6:] + midi = MockMidiController(ScriptedMockDevice({tuple(expected): []})) + librarian = Librarian() + librarian.send_patch_to_synth(midi, 9, nord, patch) + assert midi.sent_messages == [expected] # No bank select, PC, or stored-program write. + assert librarian.load_sysex(nord, [expected]) == [expected] + assert librarian.load_sysex(nord, [patch, expected]) == [patch] + + +def vlq(value): + result = [value & 127] + while value > 127: + value >>= 7 + result.insert(0, (value & 127) | 128) + return bytes(result) + + +EOT = b"\x00\xff\x2f\x00" + + +def smf(*tracks, format=0): + return (b"MThd\x00\x00\x00\x06" + format.to_bytes(2, "big") + + len(tracks).to_bytes(2, "big") + b"\x00\x60" + + b"".join(b"MTrk" + len(t).to_bytes(4, "big") + t for t in tracks)) + + +def event(status, payload): + return bytes([0, status]) + vlq(len(payload)) + bytes(payload) + + +def test_midi_complete_split_and_escaped_sysex(patch): + for track in [ + event(0xF0, patch[1:]), + event(0xF0, patch[1:40]) + event(0xF7, patch[40:]), + event(0xF7, [0xF8] + patch), + ]: + assert nord.loadPatchesFromLegacyData(list(smf(track + EOT))) == [patch] + assert nord.loadPatchesFromLegacyData(list(smf(event(0xF7, patch + patch) + EOT))) == [patch, patch] + # A lone escape is not a continuation and must not manufacture an F0. + assert nord.loadPatchesFromLegacyData(list(smf(event(0xF7, patch[1:]) + EOT))) == [] + + +@pytest.mark.parametrize("format", [1, 2]) +def test_midi_multiple_tracks_and_channel_running_status(patch, format): + channel_events = b"\x00\x90\x3c\x40\x00\x3d\x41\x00\xc0\x01\x00\x02\x00\xd0\x30" + meta = b"\x00\xff\x01\x03abc" + data = smf(channel_events + meta + EOT, event(0xF0, patch[1:]) + EOT, format=format) + assert nord.loadPatchesFromLegacyData(list(data)) == [patch] + + +@pytest.mark.parametrize("track", [ + b"", b"\x00", b"\x80", b"\x81\x80\x80\x80\x00" + EOT, + b"\x00\x40\x00" + EOT, b"\x00\x90\x40", b"\x00\xc0\x80" + EOT, + b"\x00\xff", b"\x00\xff\x80\x00" + EOT, b"\x00\xff\x01\x7f" + EOT, + b"\x00\xff\x2f\x01\x00", EOT + b"\x00", b"\x00\xf0\x7f" + EOT, + b"\x00\xf1\x00" + EOT, event(0xF0, [0x33]) + EOT, + event(0xF0, [0x33]) + event(0xF0, [0x33, 0xF7]) + EOT, + event(0xF0, [0x33, 0x80]) + event(0xF7, [0xF7]) + EOT, + event(0xF0, [0x33, 0xF7, 0x01]) + EOT, + event(0xF7, [0xF0, 0x33]) + EOT, + event(0xF7, [0xF0, 0xF0, 0xF7]) + EOT, + b"\x00\x90\x3c\x40\x00\xff\x01\x00\x00\x3d\x40" + EOT, + b"\x00\x90\x3c\x40" + event(0xF0, [0x33, 0xF7]) + b"\x00\x3d\x40" + EOT, +]) +def test_malformed_tracks_raise_value_error(track): + with pytest.raises(ValueError): + nord.loadPatchesFromLegacyData(list(smf(track))) + + +def test_bad_headers_and_truncations_never_return_partial_patches(patch): + good = smf(event(0xF0, patch[1:]) + EOT) + for length in range(4, len(good)): + with pytest.raises(ValueError): + nord.loadPatchesFromLegacyData(list(good[:length])) + for malformed in [ + good + b"extra", good[:4] + b"\x00\x00\x00\x05" + good[8:], + smf(EOT, format=3), smf(), smf(EOT, EOT), + smf(event(0xF0, patch[1:]) + b"\x00\xf0\x7f" + EOT), + smf(EOT, b"\x00\x3c\x40" + EOT, format=1), + ]: + with pytest.raises(ValueError): + nord.loadPatchesFromLegacyData(list(malformed)) + assert nord.loadPatchesFromLegacyData([1, 2, 3]) == [] diff --git a/docs/README.md b/docs/README.md index ef630192..dfb077fe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Questions and help with implementing new synths wanted! Or if you have found a b | Behringer | RD-8 | in progress | adaptation | | | Behringer | RD-9 | in progress | adaptation | | | Black Corporation | Kijimi | in progress | adaptation | Thanks to @ffont| +| Clavia | Nord Lead / Nord Lead 2 / Nord Lead 2X | alpha | adaptation | Four live banks; ten-bank 2X file import/export; official fixtures and mock MIDI tested, hardware verification pending; no Nord Lead 3+ | | DSI | Evolver | beta | adaptation | | | DSI | Mopho | works | adaptation | | | DSI | Mopho X4 | works | adaptation | |