|
| 1 | +# Korg Triton Classic – Program mode adaption |
| 2 | +from typing import List |
| 3 | + |
| 4 | +# ---------------- Constants ---------------- |
| 5 | + |
| 6 | +KORG = 0x42 |
| 7 | +TRITON_SERIES = 0x50 |
| 8 | +TRITON_CLASSIC = 0x05 # Only used for device identity, NOT in operational messages! |
| 9 | + |
| 10 | +# Request functions |
| 11 | +MODE_REQUEST = 0x12 |
| 12 | +CURRENT_PROGRAM_DUMP_REQUEST = 0x10 |
| 13 | +PROGRAM_BANK_DUMP_REQUEST = 0x1C |
| 14 | + |
| 15 | +# Response functions |
| 16 | +MODE_CHANGE = 0x4E |
| 17 | +CURRENT_PROGRAM_DUMP = 0x40 |
| 18 | +PROGRAM_BANK_DUMP = 0x4C |
| 19 | +PARAMETER_CHANGE = 0x41 |
| 20 | +DATA_DUMP_ERROR = 0x24 |
| 21 | + |
| 22 | +# Per TABLE 1 in MIDI implementation: PCM programs are 540 bytes (0-539) |
| 23 | +PROGRAM_DATA_SIZE = 540 |
| 24 | + |
| 25 | + |
| 26 | +# ---------------- Metadata ---------------- |
| 27 | + |
| 28 | +def name(): |
| 29 | + return "Korg Triton Classic" |
| 30 | + |
| 31 | + |
| 32 | +# ---------------- Device detection ---------------- |
| 33 | + |
| 34 | +def createDeviceDetectMessage(channel): |
| 35 | + return [0xF0, 0x7E, channel & 0x0F, 0x06, 0x01, 0xF7] |
| 36 | + |
| 37 | + |
| 38 | +def deviceDetectWaitMilliseconds(): |
| 39 | + return 300 |
| 40 | + |
| 41 | + |
| 42 | +def generalMessageDelay(): |
| 43 | + # Triton is slow - give it time |
| 44 | + return 1000 |
| 45 | + |
| 46 | + |
| 47 | +def needsChannelSpecificDetection(): |
| 48 | + return True |
| 49 | + |
| 50 | + |
| 51 | +def channelIfValidDeviceResponse(message): |
| 52 | + if ( |
| 53 | + len(message) >= 15 |
| 54 | + and message[0] == 0xF0 |
| 55 | + and message[1] == 0x7E |
| 56 | + and message[3] == 0x06 |
| 57 | + and message[4] == 0x02 |
| 58 | + and message[5] == 0x42 # KORG |
| 59 | + and message[6] == 0x50 # Triton series |
| 60 | + and message[7] == 0x00 |
| 61 | + and message[8] == 0x05 # Triton Classic (model ID only here!) |
| 62 | + and message[9] == 0x00 |
| 63 | + and message[14] == 0xF7 |
| 64 | + ): |
| 65 | + return message[2] # IMPORTANT: unmasked |
| 66 | + return -1 |
| 67 | + |
| 68 | + |
| 69 | +# ---------------- Edit buffer ---------------- |
| 70 | + |
| 71 | +def createEditBufferRequest(channel): |
| 72 | + """ |
| 73 | + Request current program parameter dump (edit buffer) |
| 74 | + |
| 75 | + Format: F0, 42, 3g, 50, 10, 00, F7 |
| 76 | + """ |
| 77 | + return [ |
| 78 | + 0xF0, |
| 79 | + KORG, # 0x42 |
| 80 | + 0x30 | (channel & 0x0F), # 3g where g = channel |
| 81 | + TRITON_SERIES, # 0x50 |
| 82 | + CURRENT_PROGRAM_DUMP_REQUEST, # 0x10 (5th byte) |
| 83 | + 0x00, # Reserved |
| 84 | + 0xF7 |
| 85 | + ] |
| 86 | + |
| 87 | + |
| 88 | +def isEditBufferDump(message): |
| 89 | + """ |
| 90 | + Check if message is a current program dump |
| 91 | + Expected format: F0 42 3g 50 40 0t [data] F7 |
| 92 | + """ |
| 93 | + if not message or message[0] != 0xF0 or len(message) < 7: |
| 94 | + return False |
| 95 | + |
| 96 | + return ( |
| 97 | + message[1] == KORG |
| 98 | + and (message[2] & 0xF0) == 0x30 |
| 99 | + and message[3] == TRITON_SERIES |
| 100 | + and message[4] == CURRENT_PROGRAM_DUMP # 0x40 in 5th byte position |
| 101 | + ) |
| 102 | + |
| 103 | + |
| 104 | +def nameFromDump(message): |
| 105 | + """ |
| 106 | + Extract program name from dump |
| 107 | + Format: F0 42 3g 50 40 0t [escaped_data] F7 |
| 108 | + """ |
| 109 | + if not message or len(message) < 8: |
| 110 | + return "Invalid" |
| 111 | + |
| 112 | + if message[4] == CURRENT_PROGRAM_DUMP: # 0x40 |
| 113 | + try: |
| 114 | + # Skip: F0(1) 42(1) 3g(1) 50(1) 40(1) 0t(1) = 6 bytes |
| 115 | + data = unescapeSysex(message[6:-1]) |
| 116 | + # Program names are 16 chars at the start |
| 117 | + if len(data) >= 16: |
| 118 | + return ''.join(chr(x) if 32 <= x < 127 else ' ' for x in data[0:16]) |
| 119 | + except: |
| 120 | + pass |
| 121 | + |
| 122 | + return "Unknown" |
| 123 | + |
| 124 | + |
| 125 | +def convertToEditBuffer(channel, message): |
| 126 | + if isEditBufferDump(message): |
| 127 | + return ( |
| 128 | + message[0:2] |
| 129 | + + [0x30 | (channel & 0x0F)] |
| 130 | + + message[3:] |
| 131 | + ) |
| 132 | + raise Exception("Not an edit buffer dump") |
| 133 | + |
| 134 | + |
| 135 | +# ---------------- Bank handling ---------------- |
| 136 | + |
| 137 | +def bankDescriptors(): |
| 138 | + return [ |
| 139 | + {"bank": 0x00, "name": "INT-A", "size": 128, "type": "Patch"}, |
| 140 | + {"bank": 0x01, "name": "INT-B", "size": 128, "type": "Patch"}, |
| 141 | + {"bank": 0x02, "name": "INT-C", "size": 128, "type": "Patch"}, |
| 142 | + {"bank": 0x03, "name": "INT-D", "size": 128, "type": "Patch"}, |
| 143 | + ] |
| 144 | + |
| 145 | + |
| 146 | +def createBankDumpRequest(channel, bank): |
| 147 | + """ |
| 148 | + Request program parameter dump for one bank (Program mode) |
| 149 | +
|
| 150 | + Format: F0 42 3g 50 1C 00kk0bbb 0ppppppp 00 F7 |
| 151 | + |
| 152 | + k = 0 : All Programs |
| 153 | + 1 : 1 Bank Programs (Use b) |
| 154 | + 2 : 1 Program (Use b & pp) |
| 155 | + b = 0-5 : Bank A-F |
| 156 | + """ |
| 157 | + kind = 0x01 # 1 Bank Program |
| 158 | + kind_bits = (kind & 0x03) << 4 # bits 5–4 |
| 159 | + bank_bits = bank & 0x07 # bits 2–0 |
| 160 | + |
| 161 | + return [ |
| 162 | + 0xF0, |
| 163 | + KORG, |
| 164 | + 0x30 | (channel & 0x0F), |
| 165 | + TRITON_SERIES, |
| 166 | + PROGRAM_BANK_DUMP_REQUEST, # 0x1C |
| 167 | + kind_bits | bank_bits, # 00kk0bbb |
| 168 | + 0x00, # program number (ignored for bank) |
| 169 | + 0x00, # reserved |
| 170 | + 0xF7 |
| 171 | + ] |
| 172 | + |
| 173 | + |
| 174 | +def isPartOfBankDump(message): |
| 175 | + """ |
| 176 | + Check if message is a bank dump |
| 177 | + The Triton sends ONE large 0x4C message containing all programs |
| 178 | + """ |
| 179 | + if not message or message[0] != 0xF0 or len(message) < 8: |
| 180 | + return False |
| 181 | + |
| 182 | + return ( |
| 183 | + message[1] == KORG and |
| 184 | + (message[2] & 0xF0) == 0x30 and |
| 185 | + message[3] == TRITON_SERIES and |
| 186 | + message[4] == PROGRAM_BANK_DUMP # 0x4C |
| 187 | + ) |
| 188 | + |
| 189 | + |
| 190 | +def isBankDumpFinished(messages): |
| 191 | + """ |
| 192 | + Check if we received a complete bank dump |
| 193 | + |
| 194 | + The Triton sends ONE large 0x4C message with all 128 programs, |
| 195 | + terminated by F7. |
| 196 | + """ |
| 197 | + for msg in messages: |
| 198 | + if (len(msg) > 8 and |
| 199 | + msg[0] == 0xF0 and |
| 200 | + msg[1] == KORG and |
| 201 | + (msg[2] & 0xF0) == 0x30 and |
| 202 | + msg[3] == TRITON_SERIES and |
| 203 | + msg[4] == PROGRAM_BANK_DUMP and |
| 204 | + msg[-1] == 0xF7): |
| 205 | + # Found a complete bank dump message |
| 206 | + return True |
| 207 | + |
| 208 | + return False |
| 209 | + |
| 210 | + |
| 211 | +def extractPatchesFromBank(message): |
| 212 | + """ |
| 213 | + Extract individual patches from bank dump message |
| 214 | + |
| 215 | + The Triton sends one 0x4C message for a bank: |
| 216 | + F0 42 3g 50 4C [000v] [00kk0bbb] [0ppp] [escaped_data_for_all_programs] F7 |
| 217 | + |
| 218 | + According to TABLE 1 in the MIDI implementation, each PCM program is |
| 219 | + exactly 540 bytes (bytes 0-539). The programs are concatenated directly |
| 220 | + in the unescaped data with NO headers between them. |
| 221 | + |
| 222 | + Strategy: |
| 223 | + 1. Unescape the entire data block |
| 224 | + 2. Split into 540-byte chunks |
| 225 | + 3. Re-escape each chunk and create individual 0x40 messages |
| 226 | + """ |
| 227 | + if not isPartOfBankDump(message): |
| 228 | + raise Exception("Not a Triton program bank dump") |
| 229 | + |
| 230 | + channel = message[2] & 0x0F |
| 231 | + |
| 232 | + # The message structure is: |
| 233 | + # F0 42 3g 50 4C [byte5] [byte6] [byte7] [escaped_data...] F7 |
| 234 | + # We need to unescape starting from byte 8 |
| 235 | + escaped_data = message[8:-1] # Skip header and F7 |
| 236 | + unescaped_data = unescapeSysex(escaped_data) |
| 237 | + |
| 238 | + # Calculate how many programs we have |
| 239 | + num_programs = len(unescaped_data) // PROGRAM_DATA_SIZE |
| 240 | + |
| 241 | + programs = [] |
| 242 | + |
| 243 | + for prog_num in range(num_programs): |
| 244 | + # Extract this program's data (540 bytes) |
| 245 | + start = prog_num * PROGRAM_DATA_SIZE |
| 246 | + end = start + PROGRAM_DATA_SIZE |
| 247 | + program_data = unescaped_data[start:end] |
| 248 | + |
| 249 | + # Create an edit buffer dump message for this program |
| 250 | + # F0 42 3g 50 40 0t [escaped_data] F7 |
| 251 | + prog_type = 0x00 # PCM type (0x00), MOSS would be 0x01 |
| 252 | + |
| 253 | + escaped_prog_data = escapeSysex(program_data) |
| 254 | + |
| 255 | + prog_dump = ( |
| 256 | + [0xF0, KORG, 0x30 | channel, TRITON_SERIES, CURRENT_PROGRAM_DUMP, prog_type] |
| 257 | + + escaped_prog_data |
| 258 | + + [0xF7] |
| 259 | + ) |
| 260 | + |
| 261 | + programs.append(prog_dump) |
| 262 | + |
| 263 | + # Return all programs concatenated |
| 264 | + result = [] |
| 265 | + for prog in programs: |
| 266 | + result.extend(prog) |
| 267 | + |
| 268 | + return result |
| 269 | + |
| 270 | + |
| 271 | +# ---------------- Sysex helpers ---------------- |
| 272 | + |
| 273 | +def unescapeSysex(sysex: List[int]) -> List[int]: |
| 274 | + """Unescape Korg's 7-bit MIDI encoding""" |
| 275 | + result = [] |
| 276 | + i = 0 |
| 277 | + while i < len(sysex): |
| 278 | + if i >= len(sysex): |
| 279 | + break |
| 280 | + msb = sysex[i] |
| 281 | + i += 1 |
| 282 | + for bit in range(7): |
| 283 | + if i < len(sysex): |
| 284 | + result.append(sysex[i] | (((msb >> bit) & 1) << 7)) |
| 285 | + i += 1 |
| 286 | + else: |
| 287 | + break |
| 288 | + return result |
| 289 | + |
| 290 | + |
| 291 | +def escapeSysex(data: List[int]) -> List[int]: |
| 292 | + """Escape data to Korg's 7-bit MIDI encoding""" |
| 293 | + result = [] |
| 294 | + i = 0 |
| 295 | + while i < len(data): |
| 296 | + msb = 0 |
| 297 | + for bit in range(7): |
| 298 | + if i + bit < len(data): |
| 299 | + msb |= ((data[i + bit] >> 7) & 1) << bit |
| 300 | + result.append(msb) |
| 301 | + |
| 302 | + for bit in range(7): |
| 303 | + if i < len(data): |
| 304 | + result.append(data[i] & 0x7F) |
| 305 | + i += 1 |
| 306 | + else: |
| 307 | + break |
| 308 | + return result |
0 commit comments