sm64pc/tools/disassemble_sound.py

731 lines
23 KiB
Python
Raw Permalink Normal View History

2020-05-05 12:15:53 +00:00
#!/usr/bin/env python3
from collections import namedtuple, defaultdict
import tempfile
import subprocess
import uuid
import json
import os
import re
import struct
import sys
TYPE_CTL = 1
TYPE_TBL = 2
class AifcEntry:
def __init__(self, data, book, loop):
self.name = None
self.data = data
self.book = book
self.loop = loop
self.tunings = []
class SampleBank:
def __init__(self, name, data, offset):
self.offset = offset
self.name = name
self.data = data
self.entries = {}
def add_sample(self, offset, sample_size, book, loop):
assert sample_size % 2 == 0
if sample_size % 9 != 0:
assert sample_size % 9 == 1
sample_size -= 1
if offset in self.entries:
entry = self.entries[offset]
assert entry.book == book
assert entry.loop == loop
assert len(entry.data) == sample_size
else:
entry = AifcEntry(self.data[offset : offset + sample_size], book, loop)
self.entries[offset] = entry
return entry
Sound = namedtuple("Sound", ["sample_addr", "tuning"])
Drum = namedtuple("Drum", ["name", "addr", "release_rate", "pan", "envelope", "sound"])
Inst = namedtuple(
"Inst",
[
"name",
"addr",
"release_rate",
"normal_range_lo",
"normal_range_hi",
"envelope",
"sound_lo",
"sound_med",
"sound_hi",
],
)
Book = namedtuple("Book", ["order", "npredictors", "table"])
Loop = namedtuple("Loop", ["start", "end", "count", "state"])
Envelope = namedtuple("Envelope", ["name", "entries"])
Bank = namedtuple(
"Bank",
[
"name",
"iso_date",
"sample_bank",
"insts",
"drums",
"all_insts",
"inst_list",
"envelopes",
"samples",
],
)
def align(val, al):
return (val + (al - 1)) & -al
name_tbl = {}
def gen_name(prefix, name_table=[]):
if prefix not in name_tbl:
name_tbl[prefix] = 0
ind = name_tbl[prefix]
name_tbl[prefix] += 1
if ind < len(name_table):
return name_table[ind]
return prefix + str(ind)
def parse_bcd(data):
ret = 0
for c in data:
ret *= 10
ret += c >> 4
ret *= 10
ret += c & 15
return ret
def serialize_f80(num):
num = float(num)
f64, = struct.unpack(">Q", struct.pack(">d", num))
f64_sign_bit = f64 & 2 ** 63
if num == 0.0:
if f64_sign_bit:
return b"\x80" + b"\0" * 9
else:
return b"\0" * 10
exponent = (f64 ^ f64_sign_bit) >> 52
assert exponent != 0, "can't handle denormals"
assert exponent != 0x7FF, "can't handle infinity/nan"
exponent -= 1023
f64_mantissa_bits = f64 & (2 ** 52 - 1)
f80_sign_bit = f64_sign_bit << (80 - 64)
f80_exponent = (exponent + 0x3FFF) << 64
f80_mantissa_bits = 2 ** 63 | (f64_mantissa_bits << (63 - 52))
f80 = f80_sign_bit | f80_exponent | f80_mantissa_bits
return struct.pack(">HQ", f80 >> 64, f80 & (2 ** 64 - 1))
def round_f32(num):
enc = struct.pack(">f", num)
for decimals in range(5, 20):
num2 = round(num, decimals)
if struct.pack(">f", num2) == enc:
return num2
return num
def parse_sound(data):
sample_addr, tuning = struct.unpack(">If", data)
if sample_addr == 0:
assert tuning == 0
return None
return Sound(sample_addr, tuning)
def parse_drum(data, addr):
name = gen_name("drum")
release_rate, pan, loaded, pad = struct.unpack(">BBBB", data[:4])
assert loaded == 0
assert pad == 0
sound = parse_sound(data[4:12])
env_addr, = struct.unpack(">I", data[12:])
assert env_addr != 0
return Drum(name, addr, release_rate, pan, env_addr, sound)
def parse_inst(data, addr):
name = gen_name("inst")
loaded, normal_range_lo, normal_range_hi, release_rate, env_addr = struct.unpack(
">BBBBI", data[:8]
)
assert env_addr != 0
sound_lo = parse_sound(data[8:16])
sound_med = parse_sound(data[16:24])
sound_hi = parse_sound(data[24:])
if sound_lo is None:
assert normal_range_lo == 0
if sound_hi is None:
assert normal_range_hi == 127
return Inst(
name,
addr,
release_rate,
normal_range_lo,
normal_range_hi,
env_addr,
sound_lo,
sound_med,
sound_hi,
)
def parse_loop(addr, bank_data):
start, end, count, pad = struct.unpack(">IIiI", bank_data[addr : addr + 16])
assert pad == 0
if count != 0:
state = struct.unpack(">16h", bank_data[addr + 16 : addr + 48])
else:
state = None
return Loop(start, end, count, state)
def parse_book(addr, bank_data):
order, npredictors = struct.unpack(">ii", bank_data[addr : addr + 8])
assert order == 2
assert npredictors == 2
table_data = bank_data[addr + 8 : addr + 8 + 16 * order * npredictors]
table = []
for i in range(0, 16 * order * npredictors, 2):
table.append(struct.unpack(">h", table_data[i : i + 2])[0])
return Book(order, npredictors, table)
def parse_sample(data, bank_data, sample_bank):
zero, addr, loop, book, sample_size = struct.unpack(">IIIII", data)
assert zero == 0
assert loop != 0
assert book != 0
loop = parse_loop(loop, bank_data)
book = parse_book(book, bank_data)
return sample_bank.add_sample(addr, sample_size, book, loop)
def parse_envelope(addr, data_bank):
entries = []
while True:
delay, arg = struct.unpack(">HH", data_bank[addr : addr + 4])
entries.append((delay, arg))
addr += 4
if 1 <= (-delay) % 2 ** 16 <= 3:
break
return entries
def parse_ctl(header, data, sample_bank, index):
name_tbl.clear()
name = "{:02X}".format(index)
num_instruments, num_drums, shared = struct.unpack(">III", header[:12])
date = parse_bcd(header[12:])
y = date // 10000
m = date // 100 % 100
d = date % 100
iso_date = "{:02}-{:02}-{:02}".format(y, m, d)
assert shared in [0, 1]
# print("{}: {}, {} + {}".format(name, iso_date, num_instruments, num_drums))
drum_base_addr, = struct.unpack(">I", data[:4])
drum_addrs = []
if num_drums != 0:
assert drum_base_addr != 0
for i in range(num_drums):
drum_addr, = struct.unpack(
">I", data[drum_base_addr + i * 4 : drum_base_addr + i * 4 + 4]
)
assert drum_addr != 0
drum_addrs.append(drum_addr)
else:
assert drum_base_addr == 0
inst_base_addr = 4
inst_addrs = []
inst_list = []
for i in range(num_instruments):
inst_addr, = struct.unpack(
">I", data[inst_base_addr + i * 4 : inst_base_addr + i * 4 + 4]
)
if inst_addr == 0:
inst_list.append(None)
else:
inst_list.append(inst_addr)
inst_addrs.append(inst_addr)
inst_addrs.sort()
assert drum_addrs == sorted(drum_addrs)
if drum_addrs and inst_addrs:
assert max(inst_addrs) < min(drum_addrs)
assert len(set(inst_addrs)) == len(inst_addrs)
assert len(set(drum_addrs)) == len(drum_addrs)
insts = []
for inst_addr in inst_addrs:
insts.append(parse_inst(data[inst_addr : inst_addr + 32], inst_addr))
drums = []
for drum_addr in drum_addrs:
drums.append(parse_drum(data[drum_addr : drum_addr + 16], drum_addr))
env_addrs = set()
sample_addrs = set()
tunings = defaultdict(lambda: [])
for inst in insts:
for sound in [inst.sound_lo, inst.sound_med, inst.sound_hi]:
if sound is not None:
sample_addrs.add(sound.sample_addr)
tunings[sound.sample_addr].append(sound.tuning)
env_addrs.add(inst.envelope)
for drum in drums:
sample_addrs.add(drum.sound.sample_addr)
tunings[drum.sound.sample_addr].append(drum.sound.tuning)
env_addrs.add(drum.envelope)
# Put drums somewhere in the middle of the instruments to make sample
# addresses come in increasing order. (This logic isn't totally right,
# but it works for our purposes.)
all_insts = []
need_drums = len(drums) > 0
for inst in insts:
if need_drums and any(
s.sample_addr > drums[0].sound.sample_addr
for s in [inst.sound_lo, inst.sound_med, inst.sound_hi]
if s is not None
):
all_insts.append(drums)
need_drums = False
all_insts.append(inst)
if need_drums:
all_insts.append(drums)
samples = {}
for addr in sorted(sample_addrs):
samples[addr] = parse_sample(data[addr : addr + 20], data, sample_bank)
samples[addr].tunings.extend(tunings[addr])
env_data = {}
used_env_addrs = set()
for addr in sorted(env_addrs):
env = parse_envelope(addr, data)
env_data[addr] = env
for i in range(align(len(env), 4)):
used_env_addrs.add(addr + i * 4)
# Unused envelopes
unused_envs = set()
if used_env_addrs:
for addr in range(min(used_env_addrs) + 4, max(used_env_addrs), 4):
if addr not in used_env_addrs:
unused_envs.add(addr)
stub_marker, = struct.unpack(">I", data[addr : addr + 4])
assert stub_marker == 0
env = parse_envelope(addr, data)
env_data[addr] = env
for i in range(align(len(env), 4)):
used_env_addrs.add(addr + i * 4)
envelopes = {}
for addr in sorted(env_data.keys()):
env_name = gen_name("envelope")
if addr in unused_envs:
env_name += "_unused"
envelopes[addr] = Envelope(env_name, env_data[addr])
return Bank(
name,
iso_date,
sample_bank,
insts,
drums,
all_insts,
inst_list,
envelopes,
samples,
)
def parse_seqfile(data, filetype):
magic, num_entries = struct.unpack(">HH", data[:4])
assert magic == filetype
prev = align(4 + num_entries * 8, 16)
entries = []
for i in range(num_entries):
offset, length = struct.unpack(">II", data[4 + i * 8 : 4 + i * 8 + 8])
if filetype == TYPE_CTL:
assert offset == prev
else:
assert offset <= prev
prev = max(prev, offset + length)
entries.append((offset, length))
assert all(x == 0 for x in data[prev:])
return entries
def parse_tbl(data, entries):
seen = {}
tbls = []
sample_banks = []
sample_bank_map = {}
for (offset, length) in entries:
if offset not in seen:
name = gen_name("sample_bank")
seen[offset] = name
sample_bank = SampleBank(name, data[offset : offset + length], offset)
sample_banks.append(sample_bank)
sample_bank_map[name] = sample_bank
tbls.append(seen[offset])
return tbls, sample_banks, sample_bank_map
class AifcWriter:
def __init__(self, out):
self.out = out
self.sections = []
self.total_size = 0
def add_section(self, tp, data):
assert isinstance(tp, bytes)
assert isinstance(data, bytes)
self.sections.append((tp, data))
self.total_size += align(len(data), 2) + 8
def add_custom_section(self, tp, data):
self.add_section(b"APPL", b"stoc" + self.pstring(tp) + data)
def pstring(self, data):
return bytes([len(data)]) + data + (b"" if len(data) % 2 else b"\0")
def finish(self):
# total_size isn't used, and is regularly wrong. In particular, vadpcm_enc
# preserves the size of the input file...
self.total_size += 4
self.out.write(b"FORM" + struct.pack(">I", self.total_size) + b"AIFC")
for (tp, data) in self.sections:
self.out.write(tp + struct.pack(">I", len(data)))
self.out.write(data)
if len(data) % 2:
self.out.write(b"\0")
def write_aifc(entry, out):
writer = AifcWriter(out)
num_channels = 1
data = entry.data
assert len(data) % 9 == 0
if len(data) % 2 == 1:
data += b"\0"
# (Computing num_frames this way makes it off by one when the data length
# is odd. It matches vadpcm_enc, though.)
num_frames = len(data) * 16 // 9
sample_size = 16 # bits per sample
if len(set(entry.tunings)) == 1:
sample_rate = 32000 * entry.tunings[0]
else:
# Some drum sounds in sample bank B don't have unique sample rates, so
# we have to guess. This doesn't matter for matching, it's just to make
# the sounds easy to listen to.
if min(entry.tunings) <= 0.5 <= max(entry.tunings):
sample_rate = 16000
elif min(entry.tunings) <= 1.0 <= max(entry.tunings):
sample_rate = 32000
elif min(entry.tunings) <= 1.5 <= max(entry.tunings):
sample_rate = 48000
elif min(entry.tunings) <= 2.5 <= max(entry.tunings):
sample_rate = 80000
else:
sample_rate = 16000 * (min(entry.tunings) + max(entry.tunings))
writer.add_section(
b"COMM",
struct.pack(">hIh", num_channels, num_frames, sample_size)
+ serialize_f80(sample_rate)
+ b"VAPC"
+ writer.pstring(b"VADPCM ~4-1"),
)
writer.add_section(b"INST", b"\0" * 20)
table_data = b"".join(struct.pack(">h", x) for x in entry.book.table)
writer.add_custom_section(
b"VADPCMCODES",
struct.pack(">hhh", 1, entry.book.order, entry.book.npredictors) + table_data,
)
writer.add_section(b"SSND", struct.pack(">II", 0, 0) + data)
if entry.loop.count != 0:
writer.add_custom_section(
b"VADPCMLOOPS",
struct.pack(
">HHIIi16h",
1,
1,
entry.loop.start,
entry.loop.end,
entry.loop.count,
*entry.loop.state
),
)
writer.finish()
def write_aiff(entry, filename):
with tempfile.NamedTemporaryFile(suffix=".aifc", delete=False) as temp:
write_aifc(entry, temp)
temp.flush()
aifc_decode = os.path.join(os.path.dirname(__file__), "aifc_decode")
subprocess.run([aifc_decode, temp.name, filename], check=True)
# Modified from https://stackoverflow.com/a/25935321/1359139, cc by-sa 3.0
class NoIndent(object):
def __init__(self, value):
self.value = value
class NoIndentEncoder(json.JSONEncoder):
def __init__(self, *args, **kwargs):
super(NoIndentEncoder, self).__init__(*args, **kwargs)
self._replacement_map = {}
def default(self, o):
def ignore_noindent(o):
if isinstance(o, NoIndent):
return o.value
return self.default(o)
if isinstance(o, NoIndent):
key = uuid.uuid4().hex
self._replacement_map[key] = json.dumps(o.value, default=ignore_noindent)
return "@@%s@@" % (key,)
else:
return super(NoIndentEncoder, self).default(o)
def encode(self, o):
result = super(NoIndentEncoder, self).encode(o)
repl_map = self._replacement_map
def repl(m):
key = m.group()[3:-3]
return repl_map[key]
return re.sub(r"\"@@[0-9a-f]*?@@\"", repl, result)
def inst_ifdef_json(bank_index, inst_index):
if bank_index == 7 and inst_index >= 13:
return NoIndent(["VERSION_US", "VERSION_EU"])
if bank_index == 8 and inst_index >= 16:
return NoIndent(["VERSION_US", "VERSION_EU"])
if bank_index == 10 and inst_index >= 14:
return NoIndent(["VERSION_US", "VERSION_EU"])
return None
def main():
args = []
need_help = False
only_samples = False
only_samples_list = []
for a in sys.argv[1:]:
if a == "--help" or a == "-h":
need_help = True
elif a == "--only-samples":
only_samples = True
elif a.startswith("-"):
print("Unrecognized option " + a)
sys.exit(1)
elif only_samples:
only_samples_list.append(a)
else:
args.append(a)
expected_num_args = 2 if only_samples else 4
if need_help or len(args) != expected_num_args:
print(
"Usage: {}"
" <.ctl file> <.tbl file>"
" (<samples outdir> <sound bank outdir> |"
" --only-samples file:index ...)".format(sys.argv[0])
)
sys.exit(0 if need_help else 1)
ctl_data = open(args[0], "rb").read()
tbl_data = open(args[1], "rb").read()
if not only_samples:
samples_out_dir = args[2]
banks_out_dir = args[3]
ctl_entries = parse_seqfile(ctl_data, TYPE_CTL)
tbl_entries = parse_seqfile(tbl_data, TYPE_TBL)
assert len(ctl_entries) == len(tbl_entries)
tbls, sample_banks, sample_bank_map = parse_tbl(tbl_data, tbl_entries)
banks = []
for ((offset, length), sample_bank_name, index) in zip(
ctl_entries, tbls, range(len(ctl_entries))
):
sample_bank = sample_bank_map[sample_bank_name]
entry = ctl_data[offset : offset + length]
banks.append(parse_ctl(entry[:16], entry[16:], sample_bank, index))
# Special mode used for asset extraction: generate aifc files, with paths
# given by command line arguments
if only_samples:
index_to_filename = {}
created_dirs = set()
for arg in only_samples_list:
filename, index = arg.rsplit(":", 1)
index_to_filename[int(index)] = filename
index = -1
for sample_bank in sample_banks:
offsets = sorted(set(sample_bank.entries.keys()))
for offset in offsets:
entry = sample_bank.entries[offset]
index += 1
if index in index_to_filename:
filename = index_to_filename[index]
dir = os.path.dirname(filename)
if dir not in created_dirs:
os.makedirs(dir, exist_ok=True)
created_dirs.add(dir)
write_aiff(entry, filename)
return
# Generate aiff files
for sample_bank in sample_banks:
dir = os.path.join(samples_out_dir, sample_bank.name)
os.makedirs(dir, exist_ok=True)
offsets = sorted(set(sample_bank.entries.keys()))
# print(sample_bank.name, len(offsets), 'entries')
offsets.append(len(sample_bank.data))
assert 0 in offsets
for offset, next_offset, index in zip(
offsets, offsets[1:], range(len(offsets))
):
entry = sample_bank.entries[offset]
entry.name = "{:02X}".format(index)
size = next_offset - offset
assert size % 16 == 0
assert size - 15 <= len(entry.data) <= size
garbage = sample_bank.data[offset + len(entry.data) : offset + size]
if len(entry.data) % 2 == 1:
assert garbage[0] == 0
if next_offset != offsets[-1]:
# (The last chunk follows a more complex garbage pattern)
assert all(x == 0 for x in garbage)
filename = os.path.join(dir, entry.name + ".aiff")
write_aiff(entry, filename)
# Generate sound bank .json files
os.makedirs(banks_out_dir, exist_ok=True)
for bank_index, bank in enumerate(banks):
filename = os.path.join(banks_out_dir, bank.name + ".json")
with open(filename, "w") as out:
def sound_to_json(sound):
entry = bank.samples[sound.sample_addr]
if len(set(entry.tunings)) == 1:
return entry.name
return {"sample": entry.name, "tuning": round_f32(sound.tuning)}
bank_json = {
"date": bank.iso_date,
"sample_bank": bank.sample_bank.name,
"envelopes": {},
"instruments": {},
"instrument_list": [],
}
addr_to_name = {}
# Envelopes
for env in bank.envelopes.values():
env_json = []
for (delay, arg) in env.entries:
if delay == 0:
ins = "stop"
assert arg == 0
elif delay == 2 ** 16 - 1:
ins = "hang"
assert arg == 0
elif delay == 2 ** 16 - 2:
ins = ["goto", arg]
elif delay == 2 ** 16 - 3:
ins = "restart"
assert arg == 0
else:
ins = [delay, arg]
env_json.append(NoIndent(ins))
bank_json["envelopes"][env.name] = env_json
# Instruments/drums
for inst_index, inst in enumerate(bank.all_insts):
if isinstance(inst, Inst):
inst_json = {
"ifdef": inst_ifdef_json(bank_index, inst_index),
"release_rate": inst.release_rate,
"normal_range_lo": inst.normal_range_lo,
"normal_range_hi": inst.normal_range_hi,
"envelope": bank.envelopes[inst.envelope].name,
}
if inst_json["ifdef"] is None:
del inst_json["ifdef"]
if inst.sound_lo is not None:
inst_json["sound_lo"] = NoIndent(sound_to_json(inst.sound_lo))
else:
del inst_json["normal_range_lo"]
inst_json["sound"] = NoIndent(sound_to_json(inst.sound_med))
if inst.sound_hi is not None:
inst_json["sound_hi"] = NoIndent(sound_to_json(inst.sound_hi))
else:
del inst_json["normal_range_hi"]
bank_json["instruments"][inst.name] = inst_json
addr_to_name[inst.addr] = inst.name
else:
assert isinstance(inst, list)
drums_list_json = []
for drum in inst:
drum_json = {
"release_rate": drum.release_rate,
"pan": drum.pan,
"envelope": bank.envelopes[drum.envelope].name,
"sound": sound_to_json(drum.sound),
}
drums_list_json.append(NoIndent(drum_json))
bank_json["instruments"]["percussion"] = drums_list_json
# Instrument lists
for addr in bank.inst_list:
if addr is None:
bank_json["instrument_list"].append(None)
else:
bank_json["instrument_list"].append(addr_to_name[addr])
out.write(json.dumps(bank_json, indent=4, cls=NoIndentEncoder))
out.write("\n")
if __name__ == "__main__":
main()