Skip to content

Commit

Permalink
Moog Voyager adaptation
Browse files Browse the repository at this point in the history
  • Loading branch information
christofmuc committed Jan 28, 2025
1 parent 3b63880 commit 02665d5
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Questions and help with implementing new synths wanted! Or if you have found a b
| Korg | Minilogue XD | works | adaptation | Thanks to @andy2no|
| Korg | MS2000/microKORG | works | adaptation | Thanks to @windo|
| Line 6 | POD Series | works | adaptation | Thanks to @milnak! |
| Moog | Voyager | beta | adaptation | Thanks to @troach242 for the nudge! |
| Novation | AStation/KStation | beta | adaptation | Thanks to @thechildofroth |
| Novation | Summit/Peak | alpha | adaptation | |
| Novation | UltraNova | works | adaptation | Thanks to @nezetic |
Expand Down
251 changes: 251 additions & 0 deletions adaptations/Moog_Voyager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
#
# Copyright (c) 2025 Christof Ruch. All rights reserved.
#
# Dual licensed: Distributed under Affero GPL license by default, an MIT license is available for purchase
#

# https://github.com/eclab/edisyn/blob/master/edisyn/synth/waldorfm/WaldorfM.java#L1952
import hashlib
from copy import copy
from typing import List

import knobkraft.sysex
import testing


MOOG_ID = 0x04
VOYAGER = 0x01
ALL_PRESETS_DUMP = 0x01
PANEL_DUMP = 0x02
SINGLE_PANEL_DUMP = 0x03
ALL_PRESETS_DUMP_REQUEST = 0x04
PANEL_DUMP_REQUEST = 0x05
SINGLE_PANEL_DUMP_REQUEST = 0x06
ROM_DUMP = 0x70 # This is special, the the ROM sector number 0b00-0b11 uses bits 0 and 1, and the ROM sector counter 0b01-0b11 uses bits 2 and 3
BANK_SIZE = 128


def name():
return "Moog Voyager"


def createDeviceDetectMessage(device_id):
# Just request the edit buffer
return createEditBufferRequest(device_id)


def needsChannelSpecificDetection():
return True


def deviceDetectWaitMilliseconds():
return 200


def channelIfValidDeviceResponse(message):
if isEditBufferDump(message):
return message[3] & 0x7f # Hold the horses, the device identifier is not 0..15 but 0..127. This could lead to problems in old code.
return -1


def bankDescriptors():
return [{"bank": 0, "name": f"RAM", "size": 128, "type": "Single Preset Dump"}]


def createEditBufferRequest(device_id):
return [0xf0, MOOG_ID, VOYAGER, device_id & 0x7f, PANEL_DUMP_REQUEST, 0xf7]


def isEditBufferDump(message: List[int]) -> bool:
return (len(message) > 5
and message[0] == 0xf0
and message[1] == MOOG_ID
and message[2] == VOYAGER
and message[4] == PANEL_DUMP)


def convertToEditBuffer(device_id, message):
if isEditBufferDump(message):
if message[3] & 0x7f != device_id & 0x7f:
# Need to change device_id
new_message = copy(message)
new_message[3] = device_id & 0x7f
return new_message
else:
return message
elif isSingleProgramDump(message):
# Drop the program number and change type to PANEL_DUMP
return message[:3] + [device_id & 0x7f, PANEL_DUMP] + message[6:]
raise "Can only convert edit buffers or single programs"


def createProgramDumpRequest(device_id, patchNo):
return [0xf0, MOOG_ID, VOYAGER, device_id & 0x7f, SINGLE_PANEL_DUMP_REQUEST, patchNo & 0x7f, 0xf7]


def isSingleProgramDump(message: List[int]) -> bool:
return (len(message) > 5
and message[0] == 0xf0
and message[1] == MOOG_ID
and message[2] == VOYAGER
and message[4] == SINGLE_PANEL_DUMP)


def numberFromDump(message: List[int]) -> int:
if isSingleProgramDump(message):
return message[5]
return -1


def nameFromDump(message: List[int]) -> str:
if isSingleProgramDump(message):
data = unpack_sysex(message[6:-1])
elif isEditBufferDump(message):
data = unpack_sysex(message[5:-1])
else:
return "invalid"
#line1 = "".join([chr(x) for x in data[84:84+11]])
#line2 = "".join([chr(x) for x in data[84+12:84+12+11]])
#return f"{line1} {line2}"
return "".join([chr(x) for x in data[84:84+24]])


def renamePatch(message: List[int], new_name: str) -> List[int]:
if isSingleProgramDump(message):
data_start = 6
elif isEditBufferDump(message):
data_start = 5
else:
raise "Can only rename edit buffers or program buffers"
data = unpack_sysex(message[data_start:-1])
data[84:84+24] = [ord(c)for c in new_name.ljust(24, " ")]
return message[:data_start] + pack_sysex(data) + [0xf7]


def convertToProgramDump(device_id, message, program_number):
if isSingleProgramDump(message):
# Need to patch device_id and program number
return message[:3] + [device_id & 0x7f, SINGLE_PANEL_DUMP, program_number & 0x7f] + message[6:]
elif isEditBufferDump(message):
# Need to construct a new program dump from an edit buffer dump
return message[:3] + [device_id & 0x7f, SINGLE_PANEL_DUMP, program_number & 0x7f] + message[5:]
raise Exception("Can only convert program dumps and edit buffer dumps")


def calculateFingerprint(message: List[int]):
if isSingleProgramDump(message):
data_start = 6
elif isEditBufferDump(message):
data_start = 5
else:
raise Exception("Can only fingerprint single panel dumps or panel dumps")
# Blank out program name
data_block = unpack_sysex(message[data_start:-1])
data_block[84:84+24] = [0] * 24
return hashlib.md5(bytearray(data_block)).hexdigest() # Calculate the fingerprint from the cleaned payload data


def createBankDumpRequest(device_id, bank):
return [0xf0, MOOG_ID, VOYAGER, device_id & 0x7f, ALL_PRESETS_DUMP_REQUEST, 0xf7]


def isBankDumpFinished(messages):
# We need just a single message
for message in messages:
if isPartOfBankDump(message):
return True
return False


def extractPatchesFromBank(message) -> List[List[int]]:
patches = []
if isPartOfBankDump(message):
patch_size = 128
data_block = unpack_sysex(message[5:-1])
original =message[5:-1]
back = pack_sysex(data_block)
knobkraft.list_compare(original, back)
data_block = data_block[3:]
# Check for hardcoded length of bank dump
if len(data_block) >= BANK_SIZE*patch_size:
for i in range(128):
patch_data = data_block[i * patch_size: (i + 1) * patch_size]
newpatch = [0xf0, MOOG_ID, VOYAGER, message[3], SINGLE_PANEL_DUMP, i] + pack_sysex(patch_data) + [0xf7]
print(f"Found patch {nameFromDump(newpatch)}")
patches += newpatch
return patches
print("Got Moog Voyager bank dump of invalid length - data length is %d but was expected to be %d" % (
len(data_block), (BANK_SIZE*patch_size)))
return []


def isPartOfBankDump(message):
return (len(message) > 5
and message[0] == 0xf0
and message[1] == MOOG_ID
and message[2] == VOYAGER
and message[4] == ALL_PRESETS_DUMP)


def unpack_sysex(midi_data):
result_data = []
register = 0
bit_count = 0

for byte in midi_data:
register |= byte << bit_count
bit_count += 7

if bit_count >= 8:
result_data.append(register & 0xFF)
bit_count -= 8
register >>= 8

return result_data


def pack_sysex(midi_data):
result_data = []
bit_count = 0
next_byte = 0x0

for this_byte in midi_data:
result_data.append(((this_byte << bit_count) | next_byte) & 0x7F)
next_byte = this_byte >> (7 - bit_count)

bit_count += 1
if bit_count == 7:
result_data.append(next_byte & 0x7F)
bit_count = 0
next_byte = 0x0

if bit_count > 0: # Fill the last byte
result_data.append(next_byte & 0x7F)

return result_data


# Test data picked up by test_adaptation.py
def make_test_data():
def programs(data: testing.TestData) -> List[testing.ProgramTestData]:
program_data = [0xF0, 0x04, 0x01, 0x00, 0x03, 0x00, 0x03, 0x4C, 0x1C, 0x5C, 0x11, 0x40, 0x46, 0x02, 0x19, 0x00, 0x00, 0x00, 0x40, 0x08, 0x00, 0x00, 0x00, 0x60, 0x01, 0x00, 0x00, 0x42, 0x08, 0x00, 0x68, 0x00, 0x00, 0x00, 0x41, 0x03, 0x1C, 0x3F, 0x00, 0x08, 0x42, 0x28, 0x06, 0x60, 0x17, 0x00, 0x6B, 0x40, 0x04, 0x04, 0x00, 0x10, 0x5C, 0x7F, 0x40, 0x78, 0x43, 0x62, 0x0F, 0x00, 0x20, 0x00, 0x4D, 0x00, 0x00, 0x00, 0x7D, 0x1F, 0x7C, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, 0x78, 0x7F, 0x41, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x50, 0x7F, 0x03, 0x05, 0x00, 0x5C, 0x7F, 0x3F, 0x7C, 0x68, 0x76, 0x0F, 0x3F, 0x00, 0x40, 0x3F, 0x53, 0x6A, 0x41, 0x2B, 0x26, 0x0E, 0x48, 0x29, 0x61, 0x6E, 0x01, 0x01, 0x02, 0x14, 0x08, 0x10, 0x20, 0x40, 0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x00, 0x05, 0x10, 0x06, 0x00, 0x19, 0x00, 0x66, 0x00, 0x00, 0x70, 0x1F, 0x06, 0x00, 0x00, 0x00, 0x04, 0x04, 0x00, 0x20, 0x20, 0x00, 0x00, 0x00, 0x00, 0xF7]
yield testing.ProgramTestData(message=program_data, number=0, name='Super Saw \xa0 \xa0')

all_programs = extractPatchesFromBank(data.all_messages[0])
messages = knobkraft.splitSysex(all_programs)
yield testing.ProgramTestData(message=messages[0], name="Tasty Moog \xa0Bass \xa0", number=0)
yield testing.ProgramTestData(message=messages[17], name="Clean \xa0Machine \xa0", number=17)
yield testing.ProgramTestData(message=messages[127], name="Stuttering \xa0Evolution \xa0", number=127)

def edit_buffers(data: testing.TestData) -> List[testing.ProgramTestData]:
all_programs = extractPatchesFromBank(data.all_messages[0])
messages = knobkraft.splitSysex(all_programs)
yield testing.ProgramTestData(message=convertToEditBuffer(12, messages[0]), name="Tasty Moog \xa0Bass \xa0")
yield testing.ProgramTestData(message=convertToEditBuffer(11, messages[17]), name="Clean \xa0Machine \xa0")

def banks(data: testing.TestData) -> List[testing.ProgramTestData]:
bank_dump = data.all_messages[0]
assert isPartOfBankDump(bank_dump)
yield bank_dump

return testing.TestData(sysex="testData/Moog_Voyager/Bank_A_Tasty_Moog_Bass.syx", program_generator=programs, bank_generator=banks, edit_buffer_generator=edit_buffers)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 1 addition & 0 deletions adaptations/test_adaptations.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ def test_extract_name_from_program(adaptation, test_data: testing.TestData):
for program in test_data.programs:
#assert adaptation.isSingleProgramDump(program.message.byte_list)
if hasattr(program, "name") and program.name is not None:
knobkraft.list_compare(adaptation.nameFromDump(program.message.byte_list), program.name)
assert adaptation.nameFromDump(program.message.byte_list) == program.name
count += 1
if count == 0:
Expand Down

0 comments on commit 02665d5

Please sign in to comment.