Created
February 21, 2014 05:07
-
-
Save jangler/9129108 to your computer and use it in GitHub Desktop.
IT module-based custom synth renderer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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