Skip to content

Instantly share code, notes, and snippets.

@jangler
Created February 21, 2014 05:07
Show Gist options
  • Save jangler/9129108 to your computer and use it in GitHub Desktop.
Save jangler/9129108 to your computer and use it in GitHub Desktop.
IT module-based custom synth renderer
#!/usr/bin/env python
"""
Program that renders a WAV file by converting IT pattern data into a
playback routine for an imaginary sound chip.
"""
import operator
import wave
from argparse import ArgumentParser
from collections import namedtuple
from math import pi, sin
from struct import unpack_from, pack_into
from sys import stderr
FRAMERATE = 11025
NCHANNELS = 2
SAMPWIDTH = 2
SIN_FACTOR = pi / FRAMERATE
PITCHES = ('C-', 'C#', 'D-', 'D#', 'E-', 'F-',
'F#', 'G-', 'G#', 'A-', 'A#', 'B-')
"""
# quarter-comma meantone
INTERVALS = (1.0000, 1.0700, 1.1180, 1.1963, 1.2500, 1.3375,
1.3975, 1.4953, 1.6000, 1.6719, 1.7889, 1.8692)
"""
# equal temperament
INTERVALS = (1.000000, 1.056463, 1.112462, 1.189207,
1.259921, 1.334840, 1.414214, 1.498307,
1.587401, 1.681793, 1.781797, 1.887749)
C0_FREQ = 32.7032 / 2
op_map = {
0: operator.add,
1: operator.sub,
2: operator.and_,
3: operator.or_,
4: operator.xor,
}
PatternItem = namedtuple('PatternItem', ('note', 'ins', 'vol', 'cmd',
'cmdval'))
Module = namedtuple('Module', ('speed', 'tempo', 'orders', 'patterns'))
class ChannelState:
def __init__(self):
self.pitch = 0
self.target_pitch = 0
self.vol = 0x40
self.channel_vol = 0x40
self.arp = [0]
self.blend_func = op_map[0]
self.prev_val = 0
self.volcmd = None
self.volcmdval = None
def row_update(self, item):
if item is not None:
if item.note is not None:
if item.note <= 119:
self.target_pitch = freq(item.note)
self.vol = 0x40
if self.volcmd != 'G':
self.pitch = self.target_pitch
else:
self.pitch = 0
if item.vol is not None:
if item.vol <= 64:
self.vol = item.vol
self.volcmd = None
self.volcmdval = None
elif item.vol <= 124:
self.volcmd = chr(((item.vol - 65) // 10) + 65)
volcmdval = (item.vol - 5) % 10
if volcmdval != 0:
self.volcmdval = volcmdval
elif 193 <= item.vol <= 212:
self.volcmd = chr(((item.vol - 193) // 10) + 71)
volcmdval = (item.vol - 3) % 10
if volcmdval != 0:
self.volcmdval = volcmdval
if self.volcmd == 'H':
self.blend_func = op_map[volcmdval]
else:
self.volcmd = None
self.volcmdval = None
else:
self.volcmd = None
self.volcmdval = None
if item.cmd is not None:
if item.cmd == 13:
self.channel_vol = item.cmdval
elif item.cmd == 10:
if item.cmdval != 0:
self.arp = [0, item.cmdval & 0xf0, item.cmdval & 0x0f]
else:
self.arp = [0]
def tick_update(self):
if self.volcmd is not None:
if self.volcmd == 'C':
self.vol = min(64, self.volcmdval + self.vol)
elif self.volcmd == 'D':
self.vol = max(0, -self.volcmdval + self.vol)
elif self.volcmd == 'E':
self.pitch = max(0, self.pitch *
(1.00 - self.volcmdval * 0.008))
elif self.volcmd == 'F':
self.pitch = max(0, self.pitch *
(1.00 + self.volcmdval * 0.008))
elif self.volcmd == 'G':
if self.target_pitch > self.pitch:
self.pitch = min(self.target_pitch, self.pitch *
(1.00 + self.volcmdval * 0.008))
elif self.target_pitch < self.pitch:
self.pitch = max(self.target_pitch, self.pitch *
(1.00 - self.volcmdval * 0.008))
def format_item(item):
b = bytearray(b'... .. .. .00')
if item is not None:
if item.note is not None:
if item.note == 255:
b[0:3] = '==='.encode()
elif item.note == 254:
b[0:3] = '^^^'.encode()
elif item.note <= 119:
b[0:2] = PITCHES[item.note % 12].encode()
b[2:3] = str(item.note // 12).encode()
else:
b[0:3] = '~~~'.encode()
if item.ins is not None:
b[4:6] = str(item.ins).zfill(2).encode()
if item.vol is not None:
if item.vol <= 64:
b[7:9] = str(item.vol).zfill(2).encode()
elif item.vol <= 124:
b[7:8] = chr(((item.vol - 65) // 10) + 65).encode()
b[8:9] = str((item.vol - 5) % 10).encode()
elif 128 <= item.vol <= 192:
b[7:9] = str(item.vol - 128).zfill(2).encode()
elif 193 <= item.vol <= 212:
b[7:8] = chr(((item.vol - 193) // 10) + 71).encode()
b[8:9] = str((item.vol - 3) % 10).encode()
if item.cmd is not None:
b[10:11] = (chr(item.cmd + 64)).encode()
if item.cmdval is not None:
b[11:13] = hex(item.cmdval)[2:].upper().zfill(2).encode()
return b.decode('ascii')
def pretty_print_pattern(pattern):
for row in range(len(pattern[0])):
for channel in range(4):
print(format_item(pattern[channel][row]), end=' ')
print()
def die(msg):
if isinstance(msg, BaseException):
msg = str(msg)
stderr.write(str(msg) +'\n')
exit(1)
def parse_args():
description = 'render a WAV file using a virtual sound chip'
parser = ArgumentParser(description=description)
parser.add_argument('MODULE', type=str, help='source IT module')
parser.add_argument('WAV', type=str, help='output filename')
parser.add_argument('-p', '--pattern', type=int,
help='render only one pattern')
parser.add_argument('-f', '--framerate', type=int, default=11025,
help='audio frames per second')
return parser.parse_args()
def read_orders(data):
ordnum = unpack_from('H', data, 0x20)[0]
return unpack_from('B' * ordnum, data, 0xC0)
def pattern_offsets(data):
ordnum, insnum, smpnum, patnum = unpack_from('HHHH', data, 0x20)
offset = 0xC0 + ordnum + insnum * 4 + smpnum * 4
return unpack_from('I' * patnum, data, offset)
def read_pattern(data, offset):
_, rows = unpack_from('HH', data, offset)
offset += 8
prev_maskvar, prev_note, prev_ins = ([0] * 64 for i in range(3))
prev_vol, prev_cmd, prev_cmdval = ([0] * 64 for i in range(3))
items = [[None for y in range(rows)] for x in range(4)]
for row in range(rows):
while True:
channelvariable = unpack_from('B', data, offset)[0]
offset += 1
if channelvariable == 0:
break # end of row
channel = (channelvariable - 1) & 63
if channelvariable & 128:
maskvar = unpack_from('B', data, offset)[0]
offset += 1
else:
maskvar = prev_maskvar[channel]
prev_maskvar[channel] = maskvar
if maskvar & 1:
note = unpack_from('B', data, offset)[0]
prev_note[channel] = note
offset += 1
else:
note = None
if maskvar & 2:
ins = unpack_from('B', data, offset)[0]
prev_ins[channel] = ins
offset += 1
else:
ins = None
if maskvar & 4:
vol = unpack_from('B', data, offset)[0]
prev_vol[channel] = vol
offset += 1
else:
vol = None
if maskvar & 8:
cmd, cmdval = unpack_from('BB', data, offset)
prev_cmd[channel], prev_cmdval[channel] = cmd, cmdval
offset += 2
else:
cmd, cmdval = None, None
if maskvar & 16:
note = prev_note[channel]
if maskvar & 32:
ins = prev_ins[channel]
if maskvar & 64:
vol = prev_vol[channel]
if maskvar & 128:
cmd = prev_cmd[channel]
cmdval = prev_cmdval[channel]
if channel < 4:
items[channel][row] = PatternItem(note, ins, vol, cmd, cmdval)
return items
def read_patterns(data):
offsets = pattern_offsets(data)
patterns = []
for offset in offsets:
pattern = read_pattern(data, offset)
patterns.append(pattern)
return tuple(patterns)
def read_module(filename):
try:
with open(filename, 'rb') as f:
data = f.read()
except BaseException as ex:
die(ex)
if data[:4].decode('ascii') != 'IMPM':
die("Invalid IT module: '{}'".format(filename))
speed, tempo = unpack_from('BB', data, 0x32)
orders = read_orders(data)
patterns = read_patterns(data)
return Module(speed, tempo, orders, patterns)
def freq(note):
div, mod = divmod(note, 12)
return C0_FREQ * 2**div * INTERVALS[mod]
def freq_offset(pitch, offset):
if offset == 0:
return pitch
div, mod = divmod(offset, 12)
return pitch * 2**div * INTERVALS[mod]
def render_tick(channels, wav, frame_counter, frames_per_tick):
length = frames_per_tick * NCHANNELS * SAMPWIDTH
data = bytearray(length)
for offset in range(0, length, NCHANNELS * SAMPWIDTH):
frame_counter += 1
left = right = 0
for i, channel in enumerate(channels):
if channel.pitch != 0:
for j, interval in enumerate(channel.arp):
pitch = freq_offset(channel.pitch, interval)
sin_phase = pitch * SIN_FACTOR * frame_counter
vol = channel.vol * channel.channel_vol * 2
if j == 0:
val = int(sin(sin_phase) * vol)
else:
val = channel.blend_func(val,
int(sin(sin_phase) * vol))
val = int((channel.prev_val * 7 + val) / 8)
channel.prev_val = val
if i == 0:
left += val
right += val // 2
elif i == 3:
left += val // 2
right += val
else:
left += val
right += val
pack_into('hh', data, offset, left, right)
wav.writeframes(data)
return frame_counter
def render(module, filename, only_order=None):
try:
wav = wave.open(filename, 'wb')
except BaseException as ex:
die(ex)
bpm = module.tempo * 6 / module.speed
frames_per_tick = int(FRAMERATE / ((module.tempo * 6) * 4 / 60))
frames_per_row = frames_per_tick * module.speed
wav.setnchannels(NCHANNELS)
wav.setsampwidth(SAMPWIDTH)
wav.setframerate(FRAMERATE)
frame_counter = 0
channels = tuple(ChannelState() for i in range(4))
if only_order is not None:
orders = [only_order]
else:
orders = (x for x in module.orders if x != 255)
for order in orders:
pattern = module.patterns[order]
for row in range(len(pattern[0])):
for i, channel in enumerate(channels):
channel.row_update(pattern[i][row])
for j in range(module.speed):
for channel in channels:
channel.tick_update()
frame_counter = render_tick(channels, wav, frame_counter,
frames_per_tick)
wav.close()
def run():
args = parse_args()
global FRAMERATE, SIN_FACTOR
FRAMERATE = args.framerate
SIN_FACTOR = pi / FRAMERATE
module = read_module(args.MODULE)
#pretty_print_pattern(module.patterns[0])
if args.pattern is not None:
render(module, args.WAV, args.pattern)
else:
render(module, args.WAV)
if __name__ == '__main__':
run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment