mod2packer and packer2mod

LMF Packer (Generic .MOD to Lyrica Music Format)

The Packer

# mod2packer.py
# by RocketeerEatingEggs

import sys
import math

ModPeriodTable = [
    0, # required by the converter to add no note
    
     3424, 3232, 3048, 2880, 2712, 2560, 2416, 2280, 2152, 2032, 1920, 1812, # octave 2
     1712, 1616, 1524, 1440, 1356, 1280, 1208, 1140, 1076, 1016,  960,  906, # octave 3
      856,  808,  762,  720,  678,  640,  604,  570,  538,  508,  480,  453, # octave 4, amiga
      428,  404,  381,  360,  339,  320,  302,  285,  269,  254,  240,  226, # octave 5, amiga
      214,  202,  190,  180,  170,  160,  151,  143,  135,  127,  120,  113, # octave 6, amiga
      107,  101,   95,   90,   85,   80,   75,   71,   67,   63,   60,   56, # octave 7
       53,   50,   47,   45,   42,   40,   37,   35,   33,   31,   30,   28, # octave 8
    0
    ]

ModChannelsTable = [
    "1CHN","2CHN","3CHN","M.K.","5CHN","6CHN","7CHN","8CHN",
    "9CHN","10CH","11CH","12CH","13CH","14CH","15CH","16CH",
    "17CH","18CH","19CH","20CH","21CH","22CH","23CH","24CH",
    "25CH","26CH","27CH","28CH","29CH","30CH","31CH","32CH",
    "33CH","34CH","35CH","36CH","37CH","38CH","39CH","40CH",
    "41CH","42CH","43CH","44CH","45CH","46CH","47CH","48CH",
    "49CH","50CH","51CH","52CH","53CH","54CH","55CH","56CH",
    "57CH","58CH","59CH","60CH","61CH","62CH","63CH","64CH",
    "65CH","66CH","67CH","68CH","69CH","70CH","71CH","72CH",
    "73CH","74CH","75CH","76CH","77CH","78CH","79CH","80CH",
    "81CH","82CH","83CH","84CH","85CH","86CH","87CH","88CH",
    "89CH","90CH","91CH","92CH","93CH","94CH","95CH","96CH",
    "97CH","98CH","99CH"
    ]

def compareMagic(magic):
    for i in range(32):
        if magic == ModChannelsTable[i]:
            return i + 1
    return 0

def periodToNote(lower, upper):
    newPeriod = (upper << 8) + lower
    for i in range(len(ModPeriodTable)):
        if (ModPeriodTable[i] > newPeriod) and (ModPeriodTable[i+1] < newPeriod):
            return i
        if ModPeriodTable[i] == newPeriod:
            return i
    return 0 # oops, no note found!

with open(sys.argv[2], "wb+") as PTMfile:
    with open(sys.argv[1], "rb") as MODfile:
        MODfile.seek(1080)
        numChannels = compareMagic(str(MODfile.read(4), encoding="utf-8"))
        if numChannels != 0:
            PTMfile.seek(0)
            MODfile.seek(0)
            PTMfile.write(b"LMF1")
            PTMfile.write(MODfile.read(20))
            for i in range(31):
                PTMfile.write(MODfile.read(22))
                smpLenB = MODfile.read(2)
                smpLen = int.from_bytes(smpLenB, byteorder="big")
                smpHead1 = MODfile.read(1)
                defVolB = MODfile.read(1)
                smpHead2 = MODfile.read(4)
                PTMfile.write(smpLenB)
                PTMfile.write(smpHead1)
                if smpLen == 0:
                    PTMfile.write((0).to_bytes(1, byteorder="big"))
                else:
                    PTMfile.write(defVolB)
                PTMfile.write(smpHead2)
            MODfile.seek(950)
            numOrdersBytes = MODfile.read(1)
            numOrders = int.from_bytes(numOrdersBytes, byteorder="big")
            PTMfile.write(numOrdersBytes)
            restartPos = MODfile.read(1)
            PTMfile.write(restartPos)
            numPatterns = 0
            for i in range(128):
                patternNumber = int.from_bytes(MODfile.read(1), byteorder="big")
                PTMfile.write(patternNumber.to_bytes(1, byteorder="big"))
                if patternNumber > numPatterns:
                    numPatterns = patternNumber
            PTMfile.write(numChannels.to_bytes(1, byteorder="big"))
            MODfile.seek(1084)
            for patternNumber in range(numPatterns):
                for rowNumber in range(64):
                    for channelNumber in range(numChannels):
                        eventPart1 = int.from_bytes(MODfile.read(1), byteorder="little")
                        lowerPeriod = int.from_bytes(MODfile.read(1), byteorder="little")
                        eventPart3 = int.from_bytes(MODfile.read(1), byteorder="little")
                        effectParam = MODfile.read(1)
                        upperPeriod = eventPart1 & 15
                        note = periodToNote(lowerPeriod, upperPeriod)
                        instrument = (((eventPart1 & 240) << 4) + (eventPart3 & 240)) >> 4
                        effectNumber = eventPart3 & 15
                        byte1 = (note << 1) + (instrument >> 4)
                        byte2 = ((instrument & 15) << 4) + effectNumber
                        PTMfile.write(byte1.to_bytes(1, byteorder="big"))
                        PTMfile.write(byte2.to_bytes(1, byteorder="big"))
                        PTMfile.write(effectParam)
            PTMfile.write(MODfile.read())
        

First, we initialize everything (the MOD period table, the channel ID table, etc).

Then, we open the second requested file for writing. Then, we open the first file for reading.

We validate the MOD file simply by checking if the ID is valid. (There are more sophisticated methods of checking this, but I'm pretty much a Cirno when it comes to the MOD format.)

Once that is done, we write the LMF1 magic (LMF0 included no text at all) and copy the first (22 + 130 + (30 * 31)) bytes or so of the file.

We then write the number of channels value.

The four pattern event bytes are converted down to three bytes. The highest seven bits are the note, the next five bits the instrument, the next four the effect number, and the final eight are the effect parameter.

Finally, the samples are written.

The Depacker

# packer2mod.py
# by RocketeerEatingEggs

import sys
import math

ModPeriodTable = [
    0, # required by the converter to add no note
    
     3424, 3232, 3048, 2880, 2712, 2560, 2416, 2280, 2152, 2032, 1920, 1812, # octave 2
     1712, 1616, 1524, 1440, 1356, 1280, 1208, 1140, 1076, 1016,  960,  906, # octave 3
      856,  808,  762,  720,  678,  640,  604,  570,  538,  508,  480,  453, # octave 4, amiga
      428,  404,  381,  360,  339,  320,  302,  285,  269,  254,  240,  226, # octave 5, amiga
      214,  202,  190,  180,  170,  160,  151,  143,  135,  127,  120,  113, # octave 6, amiga
      107,  101,   95,   90,   85,   80,   75,   71,   67,   63,   60,   56, # octave 7
       53,   50,   47,   45,   42,   40,   37,   35,   33,   31,   30,   28, # octave 8
    0
    ]

ModChannelsTable = [
    b"1CHN",b"2CHN",b"3CHN",b"M.K.",b"5CHN",b"6CHN",b"7CHN",b"8CHN",
    b"9CHN",b"10CH",b"11CH",b"12CH",b"13CH",b"14CH",b"15CH",b"16CH",
    b"17CH",b"18CH",b"19CH",b"20CH",b"21CH",b"22CH",b"23CH",b"24CH",
    b"25CH",b"26CH",b"27CH",b"28CH",b"29CH",b"30CH",b"31CH",b"32CH",
    b"33CH",b"34CH",b"35CH",b"36CH",b"37CH",b"38CH",b"39CH",b"40CH",
    b"41CH",b"42CH",b"43CH",b"44CH",b"45CH",b"46CH",b"47CH",b"48CH",
    b"49CH",b"50CH",b"51CH",b"52CH",b"53CH",b"54CH",b"55CH",b"56CH",
    b"57CH",b"58CH",b"59CH",b"60CH",b"61CH",b"62CH",b"63CH",b"64CH",
    b"65CH",b"66CH",b"67CH",b"68CH",b"69CH",b"70CH",b"71CH",b"72CH",
    b"73CH",b"74CH",b"75CH",b"76CH",b"77CH",b"78CH",b"79CH",b"80CH",
    b"81CH",b"82CH",b"83CH",b"84CH",b"85CH",b"86CH",b"87CH",b"88CH",
    b"89CH",b"90CH",b"91CH",b"92CH",b"93CH",b"94CH",b"95CH",b"96CH",
    b"97CH",b"98CH",b"99CH"
    ]

def periodToNote(lower, upper):
    newPeriod = (upper << 8) + lower
    for i in range(len(ModPeriodTable)):
        if (ModPeriodTable[i] > newPeriod) and (ModPeriodTable[i+1] < newPeriod):
            return i
        if ModPeriodTable[i] == newPeriod:
            return i
    return 0 # oops, no note found!

with open(sys.argv[2], "wb+") as MODfile:
    with open(sys.argv[1], "rb") as MKfile:
        if str(MKfile.read(4), encoding="utf-8") == "LMF1":
            MKfile.seek(4)
            MODfile.write(MKfile.read(20))
            smpLengths = []
            for i in range(31):
                MODfile.write(MKfile.read(22))
                smpLengthBytes = MKfile.read(2)
                smpLength = int.from_bytes(smpLengthBytes, byteorder="big")
                smpLengths.append(smpLength*2)
                smpHead = MKfile.read(6)
                MODfile.write(smpLengthBytes)
                MODfile.write(smpHead)
            numOrdersBytes = MKfile.read(1)
            numOrders = int.from_bytes(numOrdersBytes, byteorder="big")
            MODfile.write(numOrdersBytes)
            restartPos = MKfile.read(1)
            MODfile.write(restartPos)
            numPatterns = 0
            for i in range(128):
                patternNumber = int.from_bytes(MKfile.read(1), byteorder="big")
                MODfile.write(patternNumber.to_bytes(1, byteorder="big"))
                if patternNumber > numPatterns:
                    numPatterns = patternNumber
            numChannels = int.from_bytes(MKfile.read(1), byteorder="big")
            MODfile.write(ModChannelsTable[numChannels-1])
            for patternNumber in range(numPatterns):
                for rowNumber in range(64):
                    for channelNumber in range(numChannels):
                        evP1 = int.from_bytes(MKfile.read(1), byteorder="big")
                        evP2 = int.from_bytes(MKfile.read(1), byteorder="big")
                        evP3 = MKfile.read(1)
                        note = (evP1 & 254) >> 1
                        inst1 = (evP1 & 1) << 4
                        inst2 = evP2 & 240
                        fxTp = evP2 & 15
                        period = ModPeriodTable[note]
                        byte1 = inst1 + (period >> 8)
                        byte2 = period & 255
                        byte3 = inst2 + fxTp
                        MODfile.write(byte1.to_bytes(1, byteorder="big"))
                        MODfile.write(byte2.to_bytes(1, byteorder="big"))
                        MODfile.write(byte3.to_bytes(1, byteorder="big"))
                        MODfile.write(evP3)
            MODfile.write(MKfile.read())
        

Here, the opposite happens. The file is validated by way of reading the first four bytes.

The song name, the instrument info, and the order list information are copied.

Then the channel count ID is written. For four-channel MODs this is M.K.

The three byte pattern events are converted to the regular four bytes of the MOD format, and the samples are written after the last pattern.

Check through the code yourself if anything seems off.