summaryrefslogtreecommitdiff
path: root/pokemontools/audio.py
diff options
context:
space:
mode:
Diffstat (limited to 'pokemontools/audio.py')
-rw-r--r--pokemontools/audio.py385
1 files changed, 385 insertions, 0 deletions
diff --git a/pokemontools/audio.py b/pokemontools/audio.py
new file mode 100644
index 0000000..1cce1fe
--- /dev/null
+++ b/pokemontools/audio.py
@@ -0,0 +1,385 @@
+# coding: utf-8
+
+import os
+
+from math import ceil
+
+from gbz80disasm import get_global_address, get_local_address
+
+import crystal
+from crystal import music_classes as sound_classes
+from crystal import Command
+
+from crystal import load_rom
+rom = load_rom()
+rom = bytearray(rom)
+
+import config
+conf = config.Config()
+
+
+def sort_asms(asms):
+ """sort and remove duplicates from a list of tuples
+ format (address, asm, last_address)"""
+ return sorted(set(asms), key=lambda (x,y,z):(x,z,not y.startswith(';'), ':' not in y))
+
+class NybbleParam:
+ size = 0.5
+ byte_type = 'dn'
+ which = None
+
+ def __init__(self, address, name):
+ if self.which == None:
+ self.which = {0.0: 'lo', 0.5: 'hi'}[address % 1]
+ self.address = int(address)
+ self.name = name
+ self.parse()
+
+ def parse(self):
+ self.nybble = (rom[self.address] >> {'lo': 0, 'hi': 4}[self.which]) & 0xf
+
+class HiNybbleParam(NybbleParam):
+ which = 'hi'
+ def to_asm(self):
+ return '%d' % self.nybble
+
+class LoNybbleParam(NybbleParam):
+ which = 'lo'
+ def to_asm(self):
+ return '%d' % self.nybble
+
+class PitchParam(HiNybbleParam):
+ def to_asm(self):
+ """E and B cant be sharp"""
+ if self.nybble == 0:
+ pitch = '__'
+ else:
+ pitch = 'CCDDEFFGGAAB'[(self.nybble - 1)]
+ if self.nybble in [2, 4, 7, 9, 11]:
+ pitch += '#'
+ else:
+ pitch += '_'
+ return pitch
+
+
+class Note(Command):
+ macro_name = "note"
+ size = 1
+ end = False
+ param_types = {
+ 0: {"name": "pitch", "class": PitchParam},
+ 1: {"name": "duration", "class": LoNybbleParam},
+ }
+ allowed_lengths = [2]
+ override_byte_check = True
+ is_rgbasm_macro = True
+
+ def parse(self):
+ self.params = []
+ byte = rom[self.address]
+ current_address = self.address
+ for (key, param_type) in self.param_types.items():
+ name = param_type["name"]
+ class_ = param_type["class"]
+
+ # by making an instance, obj.parse() is called
+ obj = class_(address=int(current_address), name=name)
+ self.params += [obj]
+
+ current_address += obj.size
+
+ self.params = dict(enumerate(self.params))
+
+ # obj sizes were 0.5, but were working with ints
+ current_address = int(ceil(current_address))
+ self.size = int(ceil(self.size))
+
+ self.last_address = current_address
+ return True
+
+
+class Noise(Note):
+ macro_name = "noise"
+ size = 0
+ end = False
+ param_types = {
+ 0: {"name": "duration", "class": LoNybbleParam},
+ 1: {"name": "intensity", "class": SingleByteParam},
+ 2: {"name": "frequency", "class": MultiByteParam},
+ }
+ allowed_lengths = [2,3]
+ override_byte_check = True
+ is_rgbasm_macro = False
+
+
+
+class Channel:
+ """A sound channel data parser."""
+
+ def __init__(self, address, channel=1, base_label='Sound'):
+ self.start_address = address
+ self.address = address
+ self.channel = channel
+ self.base_label = base_label
+ self.output = []
+ self.labels = []
+ self.parse()
+
+ def parse(self):
+ noise = False
+ done = False
+ while not done:
+ cmd = rom[self.address]
+
+ class_ = self.get_sound_class(cmd)(address=self.address, channel=self.channel)
+
+ # notetype loses the intensity param on channel 4
+ if class_.macro_name == 'notetype':
+ if self.channel in [4, 8]:
+ class_.size -= 1
+ del class_.params[class_.size - 1]
+
+ # togglenoise only has a param when toggled on
+ elif class_.macro_name in ['togglenoise', 'sfxtogglenoise']:
+ if noise:
+ class_.size -= 1
+ del class_.params[class_.size - 1]
+ noise = not noise
+
+ asm = class_.to_asm()
+
+ # label any jumps or calls
+ for key, param in class_.param_types.items():
+ if param['class'] == crystal.PointerLabelParam:
+ label_address = class_.params[key].parsed_address
+ label = '%s_branch_%x' % (
+ self.base_label,
+ label_address
+ )
+ label_output = (
+ label_address,
+ '\n%s: ; %x' % (label, label_address),
+ label_address
+ )
+ self.labels += [label_output]
+ asm = asm.replace(
+ '$%x' % (get_local_address(label_address)),
+ label
+ )
+
+ self.output += [(self.address, '\t' + asm, self.address + class_.size)]
+ self.address += class_.size
+
+ done = class_.end
+ # infinite loops are enders
+ if class_.macro_name == 'loopchannel':
+ if class_.params[0].byte == 0:
+ done = True
+
+ # keep going past enders if theres more to parse
+ if any(self.address <= address for address, asm, last_address in self.output + self.labels):
+ if done:
+ self.output += [(self.address, '; %x' % self.address, self.address)]
+ done = False
+
+ # dumb safety checks
+ if (
+ self.address >= len(rom) or
+ self.address / 0x4000 != self.start_address / 0x4000
+ ) and not done:
+ done = True
+ raise Exception, 'reached the end of the bank without finishing!'
+
+ def to_asm(self):
+ output = sort_asms(self.output + self.labels)
+ text = ''
+ for i, (address, asm, last_address) in enumerate(output):
+ if ':' in asm:
+ # dont print labels for empty chunks
+ for (address_, asm_, last_address_) in output[i:]:
+ if ':' not in asm_:
+ text += '\n' + asm + '\n'
+ break
+ else:
+ text += asm + '\n'
+ text += '; %x' % (last_address) + '\n'
+ return text
+
+ def get_sound_class(self, i):
+ for class_ in sound_classes:
+ if class_.id == i:
+ return class_
+ if self.channel in [4. 8]: return Noise
+ return Note
+
+
+class Sound:
+ """Interprets a sound data header."""
+
+ def __init__(self, address, name=''):
+ self.start_address = address
+ self.bank = address / 0x4000
+ self.address = address
+
+ self.name = name
+ self.base_label = 'Sound_%x' % self.start_address
+ if self.name != '':
+ self.base_label = self.name
+
+ self.output = []
+ self.labels = []
+ self.asms = []
+ self.parse()
+
+ def parse(self):
+ self.num_channels = (rom[self.address] >> 6) + 1
+ self.channels = []
+ for ch in xrange(self.num_channels):
+ current_channel = (rom[self.address] & 0xf) + 1
+ self.address += 1
+ address = rom[self.address] + rom[self.address + 1] * 0x100
+ address = self.bank * 0x4000 + address % 0x4000
+ self.address += 2
+ channel = Channel(address, current_channel, self.base_label)
+ self.channels += [(current_channel, channel)]
+
+ self.labels += channel.labels
+
+ label_text = '\n%s_Ch%d: ; %x' % (
+ self.base_label,
+ current_channel,
+ channel.start_address
+ )
+ label_output = (channel.start_address, label_text, channel.start_address)
+ self.labels += [label_output]
+
+ asms = []
+
+ text = '%s: ; %x' % (self.base_label, self.start_address) + '\n'
+ for i, (num, channel) in enumerate(self.channels):
+ channel_id = num - 1
+ if i == 0:
+ channel_id += (len(self.channels) - 1) << 6
+ text += '\tdbw $%.2x, %s_Ch%d' % (channel_id, self.base_label, num) + '\n'
+ text += '; %x\n' % self.address
+ asms += [(self.start_address, text, self.start_address + len(self.channels) * 3)]
+
+ for num, channel in self.channels:
+ asms += channel.output
+
+ asms = sort_asms(asms)
+ self.last_address = asms[-1][2]
+ asms += [(self.last_address,'; %x' % self.last_address, self.last_address)]
+
+ self.asms += asms
+
+ def to_asm(self, labels=[]):
+ """insert outside labels here"""
+ asms = self.asms
+
+ # incbins dont really count as parsed data
+ incbins = []
+ for i, (address, asm, last_address) in enumerate(asms):
+ if i + 1 < len(asms):
+ next_address = asms[i + 1][0]
+ if last_address != next_address:
+ incbins += [(last_address, 'INCBIN "baserom.gbc", $%x, $%x - $%x' % (last_address, next_address, last_address), next_address)]
+ asms += incbins
+ for label in self.labels + labels:
+ if self.start_address <= label[0] < self.last_address:
+ asms += [label]
+
+ return '\n'.join(asm for address, asm, last_address in sort_asms(asms))
+
+
+def read_bank_address_pointer(addr):
+ bank, address = rom[addr], rom[addr+1] + rom[addr+2] * 0x100
+ return get_global_address(address, bank)
+
+
+def dump_sounds(origin, names, base_label='Sound_'):
+ """Dump sound data from a pointer table."""
+
+ # first pass to grab labels and boundaries
+ labels = []
+ addresses = []
+ for i, name in enumerate(names):
+ sound_at = read_bank_address_pointer(origin + i * 3)
+ sound = Sound(sound_at, base_label + name)
+ labels += sound.labels
+ addresses += [(sound.start_address, sound.last_address)]
+ addresses = sorted(addresses)
+
+ outputs = []
+ for i, name in enumerate(names):
+ sound_at = read_bank_address_pointer(origin + i * 3)
+ sound = Sound(sound_at, base_label + name)
+ output = sound.to_asm(labels) + '\n'
+
+ # incbin trailing commands that didnt get picked up
+ index = addresses.index((sound.start_address, sound.last_address))
+ if index + 1 < len(addresses):
+ next_address = addresses[index + 1][0]
+ if 5 > next_address - sound.last_address > 0:
+ if next_address / 0x4000 == sound.last_address / 0x4000:
+ output += '\nINCBIN "baserom.gbc", $%x, $%x - $%x\n' % (sound.last_address, next_address, sound.last_address)
+
+ filename = name.lower() + '.asm'
+ outputs += [(filename, output)]
+ return outputs
+
+
+def export_sounds(origin, names, path, base_label='Sound_'):
+ for filename, output in dump_sounds(origin, names, base_label):
+ with open(os.path.join(path, filename), 'w') as out:
+ out.write(output)
+
+
+def dump_sound_clump(origin, names, base_label='Sound_'):
+ """some sounds are grouped together and/or share most components.
+ these can't reasonably be split into files for each sound."""
+
+ output = []
+ for i, name in enumerate(names):
+ sound_at = read_bank_address_pointer(origin + i * 3)
+ sound = Sound(sound_at, base_label + name)
+ output += sound.asms + sound.labels
+ output = sort_asms(output)
+ return output
+
+
+def export_sound_clump(origin, names, path, base_label='Sound_'):
+ output = dump_sound_clump(origin, names, base_label)
+ with open(path, 'w') as out:
+ out.write('\n'.join(asm for address, asm, last_address in output))
+
+
+def dump_crystal_music():
+ from song_names import song_names
+ export_sounds(0xe906e, song_names, os.path.join(conf.path, 'audio', 'music'), 'Music_')
+
+def generate_crystal_music_pointers():
+ from song_names import song_names
+ return '\n'.join('\tdbw BANK({0}), {0}'.format('Music_' + label) for label in song_names)
+
+def dump_crystal_sfx():
+ from sfx_names import sfx_names
+ export_sound_clump(0xe927c, sfx_names, os.path.join(conf.path, 'audio', 'sfx.asm'), 'Sfx_')
+
+def generate_crystal_sfx_pointers():
+ from sfx_names import sfx_names
+ return '\n'.join('\tdbw BANK({0}), {0}'.format('Sfx_' + label) for label in sfx_names)
+
+def dump_crystal_cries():
+ from cry_names import cry_names
+ export_sound_clump(0xe91b0, cry_names, os.path.join(conf.path, 'audio', 'cries.asm'), 'Cry_')
+
+def generate_crystal_cry_pointers():
+ from cry_names import cry_names
+ return '\n'.join('\tdbw BANK({0}), {0}'.format('Cry_' + label) for label in cry_names)
+
+
+if __name__ == '__main__':
+ dump_crystal_music()
+ dump_crystal_sfx()
+