mirror of
https://github.com/zeldaret/oot
synced 2026-06-20 16:21:16 -04:00
970af5600a
Due to the layout of the pitch_frequencies lookup table, note values computed in extraction were reflected around middle C (midi note number 60). This didn't matter for matching, values would successfully roundtrip. However when using samples in external programs or converting soundfonts to standard formats the note values would lead to incorrect playback of sounds. This change corrects the note values so that external programs correctly infer the pitch of the sound when played at a particular MIDI key.
983 lines
37 KiB
Python
983 lines
37 KiB
Python
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
|
# SPDX-License-Identifier: CC0-1.0
|
|
#
|
|
# Implements audiobank file
|
|
#
|
|
|
|
import struct
|
|
from typing import Optional
|
|
|
|
from .audio_tables import AudioCodeTableEntry
|
|
from .audiobank_structs import AdpcmBook, AdpcmLoop, Drum, Instrument, SoundFontSample, SoundFontSound
|
|
from .audiotable import AudioTableFile, AudioTableSample
|
|
from .envelope import Envelope
|
|
from .extraction_xml import SoundFontExtractionDescription
|
|
from .tuning import pitch_names
|
|
from .util import XMLWriter, align, debugm, merge_like_ranges, merge_ranges
|
|
|
|
# Debug settings
|
|
PLOT_DRUM_TUNING = False
|
|
LOG_COVERAGE = False
|
|
|
|
def coverage_log(str):
|
|
if LOG_COVERAGE: debugm(str)
|
|
|
|
if PLOT_DRUM_TUNING:
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
|
|
# dummy types for coverage labeling
|
|
|
|
class Padding:
|
|
pass
|
|
|
|
class SfxListPtr:
|
|
SIZE = 4
|
|
|
|
class DrumsListPtr:
|
|
SIZE = 4
|
|
|
|
class InstrumentPtr:
|
|
SIZE = 4
|
|
|
|
class DrumPtr:
|
|
SIZE = 4
|
|
|
|
|
|
|
|
|
|
|
|
class DrumGroup:
|
|
|
|
def __init__(self):
|
|
self.drums = []
|
|
self.start = None
|
|
self.end = None
|
|
self.sample_header_offset = None
|
|
self.sample = None
|
|
|
|
# Filled in at finalize
|
|
self.envelope_offset = None
|
|
self.envelope = None
|
|
self.release_rate = None
|
|
self.pan = None
|
|
self.sample_header_offset = None
|
|
self.sample_rate = None
|
|
self.base_note = None
|
|
self.needs_rate_override = None
|
|
self.needs_note_override = None
|
|
|
|
def __len__(self):
|
|
return len(self.drums)
|
|
|
|
def __iter__(self):
|
|
for drum in self.drums:
|
|
yield drum
|
|
|
|
def append(self, drum):
|
|
self.drums.append(drum)
|
|
|
|
def set_range(self, start, end):
|
|
self.start, self.end = start, end
|
|
|
|
def finalize(self, envelopes, sample_lookup_fn):
|
|
# A drum group should use the same envelope for all entries
|
|
env_offsets = set(drum.envelope for drum in self.drums)
|
|
assert len(env_offsets) == 1
|
|
self.envelope_offset = env_offsets.pop()
|
|
self.envelope : Envelope = envelopes[self.envelope_offset]
|
|
|
|
# A drum group should use the same release rate
|
|
release_rates = set(drum.release_rate for drum in self.drums)
|
|
assert len(release_rates) == 1
|
|
self.release_rate = release_rates.pop()
|
|
|
|
# The release rate used should belong to the envelope used
|
|
assert self.release_rate in self.envelope.release_rates
|
|
|
|
# A drum group should always contain a single pan value
|
|
pans = set(drum.pan for drum in self.drums)
|
|
assert len(pans) == 1
|
|
self.pan = pans.pop()
|
|
|
|
# A drum group should be the same sample repeated
|
|
sample_header_offsets = set(drum.sample for drum in self.drums)
|
|
assert len(sample_header_offsets) == 1
|
|
sample_header_offset = sample_header_offsets.pop()
|
|
|
|
# Fetch sample header
|
|
self.sample_header_offset = sample_header_offset
|
|
sample = sample_lookup_fn(sample_header_offset)
|
|
sample : AudioTableSample
|
|
|
|
# Collect final samplerate and basenotes for each drum in the group
|
|
final_rate = None
|
|
notes = []
|
|
for drum in self:
|
|
drum : Drum
|
|
|
|
tuning = drum.tuning
|
|
assert tuning in sample.tuning_map
|
|
# Get from sample
|
|
rate, note = sample.tuning_map[tuning]
|
|
|
|
if final_rate is None:
|
|
final_rate = rate
|
|
# This should never occur as drum groups are split when the samplerate changes
|
|
assert final_rate == rate
|
|
|
|
notes.append(note)
|
|
|
|
# Drum frequencies should increase monotonically in a drum group
|
|
# Increasing frequencies correspond to decreasing note values
|
|
assert all(v == (notes[0] - i) % 128 for i,v in enumerate(notes))
|
|
|
|
# Assign final rate and note.
|
|
# Use first note in the group as the basenote for the whole group, the rest will be filled in during build.
|
|
self.sample_rate = final_rate
|
|
self.base_note = notes[0]
|
|
|
|
assert sample.sample_rate is not None
|
|
assert sample.base_note is not None
|
|
|
|
# Needs override if they do not agree with the final values in the sample
|
|
self.needs_rate_override = sample.sample_rate != self.sample_rate
|
|
self.needs_note_override = sample.base_note != self.base_note
|
|
|
|
def to_xml(self, xml : XMLWriter, name : str, sample_name_func, envelope_name_func):
|
|
attributes = {
|
|
"Name" : name,
|
|
"Envelope" : envelope_name_func(self.envelope_offset),
|
|
}
|
|
|
|
if self.release_rate != self.envelope.release_rate():
|
|
attributes["Release"] = self.release_rate
|
|
|
|
attributes["Pan"] = self.pan
|
|
|
|
if self.start == self.end:
|
|
attributes["Note"] = pitch_names[self.start]
|
|
else:
|
|
attributes["NoteStart"] = pitch_names[self.start]
|
|
attributes["NoteEnd"] = pitch_names[self.end]
|
|
|
|
attributes["Sample"] = sample_name_func(self.sample_header_offset)
|
|
|
|
if self.needs_rate_override:
|
|
attributes["SampleRate"] = self.sample_rate
|
|
if self.needs_note_override:
|
|
attributes["BaseNote"] = pitch_names[self.base_note]
|
|
|
|
xml.write_element("Drum", attributes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AudiobankFile:
|
|
"""
|
|
"""
|
|
|
|
def __init__(self, audiobank_seg : memoryview, index : int, table_entry : AudioCodeTableEntry,
|
|
seg_offset : int, bank1 : AudioTableFile, bank2 : AudioTableFile, bank1_num : int, bank2_num : int,
|
|
extraction_desc : Optional[SoundFontExtractionDescription] = None):
|
|
self.bank_num = index
|
|
self.table_entry : AudioCodeTableEntry = table_entry
|
|
self.num_instruments = self.table_entry.num_instruments
|
|
self.data = self.table_entry.data(audiobank_seg, seg_offset)
|
|
self.bank1 : AudioTableFile = bank1
|
|
self.bank2 : AudioTableFile = bank2
|
|
self.bank1_num = bank1_num
|
|
self.bank2_num = bank2_num
|
|
|
|
if extraction_desc is None:
|
|
self.file_name = f"Soundfont_{self.bank_num}"
|
|
self.name = f"Soundfont_{self.bank_num}"
|
|
|
|
self.extraction_envelopes_info = None
|
|
self.extraction_instruments_info = None
|
|
self.extraction_drums_info = None
|
|
self.extraction_effects_info = None
|
|
self.extraction_envelopes_info_versions = []
|
|
self.extraction_instruments_info_versions = {}
|
|
self.extraction_drums_info_versions = []
|
|
self.extraction_effects_info_versions = []
|
|
else:
|
|
self.file_name = extraction_desc.file_name
|
|
self.name = extraction_desc.name
|
|
|
|
self.extraction_envelopes_info = extraction_desc.envelopes_info
|
|
self.extraction_instruments_info = extraction_desc.instruments_info
|
|
self.extraction_drums_info = extraction_desc.drums_info
|
|
self.extraction_effects_info = extraction_desc.effects_info
|
|
self.extraction_envelopes_info_versions = extraction_desc.envelopes_info_versions
|
|
self.extraction_instruments_info_versions = extraction_desc.instruments_info_versions
|
|
self.extraction_drums_info_versions = extraction_desc.drums_info_versions
|
|
self.extraction_effects_info_versions = extraction_desc.effects_info_versions
|
|
|
|
# Coverage consists of a list of itervals of the form [[start,type],[end,type]]
|
|
self.coverage = []
|
|
self.envelopes = {}
|
|
self.sample_headers = {}
|
|
self.books = {}
|
|
self.loops = {}
|
|
self.loops_have_frames = False
|
|
|
|
# Read Drums
|
|
|
|
self.collect_drums()
|
|
self.group_drums()
|
|
|
|
# Read Sfx
|
|
|
|
self.collect_sfx()
|
|
|
|
# Read Instruments
|
|
|
|
self.collect_instruments()
|
|
|
|
|
|
# Check Coverage
|
|
|
|
self.cvg_log()
|
|
self.coverage = merge_ranges(self.coverage)
|
|
|
|
self.resolve_cvg_gaps()
|
|
self.coverage = merge_ranges(self.coverage)
|
|
|
|
coverage_log("Final Coverage:")
|
|
coverage_log([[[interval[0][0], interval[0][1].__name__], [interval[1][0], interval[1][1].__name__]] for interval in self.coverage])
|
|
coverage_log(f"[[{0}, {len(self.data)}]]")
|
|
assert len(self.coverage) == 1
|
|
coverage_log("OK")
|
|
|
|
# Check End of File
|
|
|
|
self.check_end()
|
|
|
|
def collect_drums(self):
|
|
# Read structures
|
|
|
|
self.drums_ptr_list_ptr = self.read_pointer(0, DrumsListPtr)
|
|
assert self.drums_ptr_list_ptr % 16 == 0
|
|
self.drums_ptr_list = self.read_pointer_list(self.drums_ptr_list_ptr, self.table_entry.num_drums, DrumPtr)
|
|
self.drums = self.read_list_from_offset_list(self.drums_ptr_list, Drum)
|
|
|
|
# Process structures
|
|
|
|
for drum in self.drums:
|
|
if drum is None:
|
|
# NULL pointer in drums pointer list
|
|
continue
|
|
|
|
# Read envelope
|
|
self.read_envelope(drum.envelope, drum.release_rate)
|
|
|
|
# Read sample if it exists
|
|
if drum.tuning != 0 and drum.sample != 0:
|
|
self.read_sample_header(drum.sample, drum.tuning, drum)
|
|
|
|
def group_drums(self):
|
|
self.drum_groups = []
|
|
|
|
first = True
|
|
last_drum = None
|
|
for drum in self.drums:
|
|
if drum is None:
|
|
if last_drum is None and not first:
|
|
self.drum_groups[-1].append(None)
|
|
else:
|
|
self.drum_groups.append([None])
|
|
last_drum = None
|
|
else:
|
|
drum : Drum
|
|
|
|
if not drum.group_continuation(last_drum):
|
|
# group changed
|
|
self.drum_groups.append(DrumGroup())
|
|
|
|
self.drum_groups[-1].append(drum)
|
|
last_drum = drum
|
|
|
|
first = False
|
|
|
|
note_start = 0
|
|
for drum_grp in self.drum_groups:
|
|
note_end = note_start + len(drum_grp) - 1
|
|
|
|
if any(d is not None for d in drum_grp):
|
|
drum_grp : DrumGroup
|
|
drum_grp.set_range(note_start, note_end)
|
|
|
|
note_start = note_end + 1
|
|
|
|
def collect_sfx(self):
|
|
# Read structures
|
|
|
|
self.sfx_list_ptr = self.read_pointer(4, SfxListPtr)
|
|
assert self.sfx_list_ptr % 16 == 0
|
|
self.sfx = self.read_list(self.sfx_list_ptr, self.table_entry.num_sfx, SoundFontSound)
|
|
|
|
# Process structures
|
|
|
|
for sfx in self.sfx:
|
|
# Read sample if it exists
|
|
if sfx.tuning != 0 and sfx.sample != 0:
|
|
self.read_sample_header(sfx.sample, sfx.tuning, sfx)
|
|
|
|
def collect_instruments(self):
|
|
# Read structures
|
|
self.instrument_offset_list = self.read_pointer_list(8, self.table_entry.num_instruments, InstrumentPtr)
|
|
self.instruments = self.read_list_from_offset_list(self.instrument_offset_list, Instrument)
|
|
|
|
# Record order information
|
|
for i,instr in enumerate(self.instruments):
|
|
if instr is None:
|
|
# NULL entry in pointer list
|
|
continue
|
|
instr.program_number = i
|
|
instr.offset = self.instrument_offset_list[i]
|
|
|
|
# Get rid of NULL entries, these correspond to program numbers with no assigned instrument.
|
|
self.instruments = [instr for instr in self.instruments if instr is not None]
|
|
|
|
# Build index map for sequence checking
|
|
self.instrument_index_map = { instr.program_number : instr for instr in self.instruments }
|
|
|
|
# The struct index records the order of the instrument structures themselves. This is often different than the
|
|
# order they appear in the pointer table, since the pointer table is indexed by program number. We want to emit
|
|
# xml entries in struct order with a property stating their program number as this seems most user-friendly.
|
|
for i,instr in enumerate(sorted(self.instruments, key=lambda instr : instr.offset)):
|
|
instr : Instrument
|
|
instr.struct_index = i
|
|
|
|
# Read data that this structure references
|
|
|
|
for i,instr in enumerate(self.instruments):
|
|
# Read the envelope
|
|
self.read_envelope(instr.envelope, instr.release_rate)
|
|
|
|
# Read the samples, if they exist
|
|
if instr.low_notes_tuning != 0 and instr.low_notes_sample != 0:
|
|
self.read_sample_header(instr.low_notes_sample, instr.low_notes_tuning, instr)
|
|
|
|
if instr.normal_notes_tuning != 0 and instr.normal_notes_sample != 0:
|
|
self.read_sample_header(instr.normal_notes_sample, instr.normal_notes_tuning, instr)
|
|
|
|
if instr.high_notes_tuning != 0 and instr.high_notes_sample != 0:
|
|
self.read_sample_header(instr.high_notes_sample, instr.high_notes_tuning, instr)
|
|
|
|
def cvg_log(self):
|
|
if not LOG_COVERAGE:
|
|
return
|
|
|
|
types_ranges = merge_like_ranges(self.coverage)
|
|
|
|
for type_range in types_ranges:
|
|
interval_start, interval_start_type = type_range[0]
|
|
interval_end, _ = type_range[1]
|
|
|
|
if interval_start == interval_end:
|
|
continue
|
|
|
|
interval_length = interval_end - interval_start
|
|
|
|
if interval_start_type == int:
|
|
sizeof_type = 4
|
|
elif interval_start_type == Padding:
|
|
sizeof_type = interval_end - interval_start
|
|
elif interval_start_type == AdpcmBook:
|
|
sizeof_type = self.read_book_size(interval_start)
|
|
elif interval_start_type == AdpcmLoop:
|
|
sizeof_type = self.read_loop_size(interval_start)
|
|
elif interval_start_type == Envelope.EnvelopePoint:
|
|
sizeof_type = 4
|
|
else:
|
|
sizeof_type = interval_start_type.SIZE
|
|
|
|
array_size = interval_length // sizeof_type
|
|
|
|
output_str = f"0x{interval_start:04X} - 0x{interval_end:04X} : {interval_start_type.__name__}"
|
|
if array_size != 1 or interval_start_type == Envelope.EnvelopePoint:
|
|
output_str += f"[{array_size}]"
|
|
|
|
coverage_log(output_str)
|
|
|
|
def resolve_cvg_gaps(self):
|
|
if len(self.coverage) < 2:
|
|
# There are already no gaps, nothing to do
|
|
return
|
|
|
|
# Resolve gaps in coverage with heuristics
|
|
|
|
for i in range(len(self.coverage) - 1):
|
|
prev_interval = self.coverage[i]
|
|
next_interval = self.coverage[i + 1]
|
|
|
|
unref_start_offset, unref_start_type = prev_interval[1]
|
|
unref_end_offset, unref_end_type = next_interval[0]
|
|
|
|
unaccounted_data = self.data[unref_start_offset:unref_end_offset]
|
|
|
|
if unref_end_type in [AdpcmBook, AdpcmLoop] and all(b == 0 for b in unaccounted_data) and \
|
|
unref_end_offset - unref_start_offset < 16 and (unref_end_offset % 16) == 0:
|
|
# Book and Loop structures are aligned to 16 byte boundaries, silently mark padding
|
|
self.coverage.append([[unref_start_offset, Padding], [unref_end_offset, Padding]])
|
|
continue
|
|
|
|
coverage_log(f"Unaccounted: 0x{unref_start_offset:04X}({unref_start_type.__name__}) " + \
|
|
f"to 0x{unref_end_offset:04X}({unref_end_type.__name__})")
|
|
coverage_log([f"0x{b:02X}" for b in unaccounted_data])
|
|
|
|
try:
|
|
if unref_start_type == Envelope.EnvelopePoint:
|
|
# Assume it is an envelope if it follows an envelope
|
|
assert unref_start_offset not in self.envelopes
|
|
coverage_log("Unaccounted follows an envelope, assume it is an envelope")
|
|
st = self.read_envelope(unref_start_offset, None, is_zero=all(b == 0 for b in unaccounted_data))
|
|
|
|
elif unref_start_type in [SoundFontSample, AdpcmLoop]:
|
|
# Orphaned loops are unlikely, it's more likely a SoundFontSample
|
|
coverage_log("Unaccounted follows a SoundFontSample or AdpcmLoop, assuming SoundFontSample")
|
|
st = self.read_sample_header(unref_start_offset, None, None)
|
|
|
|
elif unref_start_type == Instrument:
|
|
coverage_log("Unaccounted follows an Instrument, assume it is an Instrument")
|
|
st : Instrument = self.read_structure(unref_start_offset, unref_start_type)
|
|
# Check that we already saw the sample header this instrument wants
|
|
assert st.normal_notes_sample in self.sample_headers
|
|
assert st.normal_range_hi == 127 or st.high_notes_sample in self.sample_headers
|
|
assert st.normal_range_lo == 0 or st.low_notes_sample in self.sample_headers
|
|
# Insert into instrument list in the appropriate location, mark it as unused so that sfc knows not
|
|
# to add it to the instrument pointer list when recompiling
|
|
st.offset = unref_start_offset
|
|
st.unused = True
|
|
|
|
# Assign struct index for this unreferenced instrument
|
|
new_index = -1
|
|
for instr in sorted(self.instruments, key= lambda instr : instr.struct_index):
|
|
instr : Instrument
|
|
|
|
if instr.offset > unref_start_offset:
|
|
if new_index == -1:
|
|
# Record struct index for the unused instrument
|
|
new_index = instr.struct_index
|
|
# Increment struct indices for every structure that occurs after this one
|
|
instr.struct_index += 1
|
|
else:
|
|
# Give it a new index at the end
|
|
if new_index == -1:
|
|
new_index = len(self.instruments)
|
|
|
|
st.struct_index = new_index
|
|
self.instruments.append(st)
|
|
else:
|
|
st = self.read_structure(unref_start_offset, unref_start_type)
|
|
coverage_log(st)
|
|
assert False, "Unhandled coverage case" # handle more structures if they appear
|
|
|
|
coverage_log(st)
|
|
except Exception as e:
|
|
coverage_log("FAILED")
|
|
if all(b == 0 for b in unaccounted_data):
|
|
coverage_log("Probably padding or an empty file?")
|
|
raise e
|
|
|
|
def check_end(self):
|
|
self.pad_to_size = None
|
|
|
|
end = self.coverage[-1][1][0]
|
|
end_aligned = align(end, 16)
|
|
if end_aligned != len(self.data):
|
|
print(f"[Soundfont {self.bank_num:2}] Did not reach end of the file?",
|
|
f"0x{end_aligned:X} vs 0x{len(self.data):X}")
|
|
assert all(b == 0 for b in self.data[end_aligned:])
|
|
self.pad_to_size = len(self.data)
|
|
|
|
self.file_padding = None
|
|
|
|
if not all(b == 0 for b in self.data[end:]):
|
|
print(f"[Soundfont {self.bank_num:2}] Non-zero unaccounted data at the end of the file?",
|
|
f"From 0x{end:X} to 0x{len(self.data):X}")
|
|
self.file_padding = self.data[end:]
|
|
|
|
def dump_bin(self, path):
|
|
with open(path, "wb") as outfile:
|
|
outfile.write(self.data)
|
|
|
|
def read_loop_size(self, offset):
|
|
loop_count, = struct.unpack(">I", self.data[offset+8:offset+0xC])
|
|
return 0x30 if loop_count != 0 else 0x10
|
|
|
|
def read_loop_struct(self, offset):
|
|
return AdpcmLoop(self.logged_read(offset, self.read_loop_size(offset), AdpcmLoop))
|
|
|
|
def read_book_size(self, offset):
|
|
order, npredictors = struct.unpack(">ii", self.data[offset:offset+8])
|
|
return 8 + 2 * 8 * order * npredictors
|
|
|
|
def read_sample_header(self, offset, tuning, ob):
|
|
assert offset % 16 == 0
|
|
|
|
if offset in self.sample_headers:
|
|
# Don't re-read a sample header structure if it was already read
|
|
sample_header = self.sample_headers[offset]
|
|
sample_header : SoundFontSample
|
|
else:
|
|
# Read the new sample header and cache it
|
|
sample_header = self.read_structure(offset, SoundFontSample)
|
|
self.sample_headers[offset] = sample_header
|
|
|
|
# Samples must always have an associated book
|
|
assert sample_header.book != 0
|
|
|
|
if sample_header.book in self.books:
|
|
# Lookup the book, samples may share books if they are identical
|
|
book = self.books[sample_header.book]
|
|
else:
|
|
# Read the new book
|
|
book_size = self.read_book_size(sample_header.book)
|
|
book = AdpcmBook(self.logged_read(sample_header.book, book_size, AdpcmBook))
|
|
|
|
# Books are `8 + 16 * n` bytes large and should start on an 0x10 byte boundary.
|
|
# Check that we get 8 bytes of padding following the book.
|
|
book_end = sample_header.book + book_size
|
|
assert sample_header.book % 16 == 0
|
|
assert book_end % 16 == 8
|
|
assert all(b == 0 for b in self.logged_read(book_end, 8, Padding))
|
|
|
|
# Cache it
|
|
self.books[sample_header.book] = book
|
|
|
|
# Read the loop, if there is one
|
|
if sample_header.loop == 0:
|
|
# No loop
|
|
loop = None
|
|
elif sample_header.loop in self.loops:
|
|
# Already seen, look it up
|
|
loop = self.loops[sample_header.loop]
|
|
else:
|
|
# Read new loop structure
|
|
loop = self.read_loop_struct(sample_header.loop)
|
|
|
|
# If loops were determined to store the sample's total frame count, require that all loops with nonzero
|
|
# count all have the same behavior within the same soundfont
|
|
if self.loops_have_frames and loop.count != 0:
|
|
assert loop.num_frames != 0, loop
|
|
|
|
# If the numFrames field is nonzero anywhere, record this
|
|
# TODO this may miss some checks, fix?
|
|
if loop.num_frames != 0:
|
|
self.loops_have_frames = True
|
|
|
|
# Add the sample to the appropriate samplebank
|
|
bank = self.bank1 if sample_header.medium == 0 else self.bank2
|
|
if tuning is not None:
|
|
bank.add_sample(sample_header, book, loop, tuning, ob)
|
|
else:
|
|
# If we found unreferenced sample data that was not discovered elsewhere there is no tuning value to recover
|
|
# the samplerate from. These need to be handled manually, but this is currently unsupported as this does not
|
|
# occur in zelda64 audio banks.
|
|
assert sample_header.sample_addr in bank.samples , \
|
|
"Unreferenced sample header refers to sample that was not otherwise discovered, cannot " + \
|
|
"automatically recover sample rate"
|
|
|
|
return sample_header
|
|
|
|
def read_envelope_points(self, offset, is_zero=False):
|
|
size = 0
|
|
|
|
if not is_zero:
|
|
points = []
|
|
|
|
while True:
|
|
point = Envelope.EnvelopePoint(*struct.unpack(">hh", self.data[offset + size:][:4]))
|
|
assert point.delay >= -3 # TODO this could be used to determine whether data is really an envelope
|
|
points.append(point)
|
|
size += 4
|
|
if point.delay < 0:
|
|
break
|
|
|
|
# pad to 0x10 byte boundary
|
|
while (size % 16) != 0:
|
|
point = Envelope.EnvelopePoint(*struct.unpack(">hh", self.data[offset + size:][:4]))
|
|
assert point.delay == 0 and point.arg == 0
|
|
points.append(point)
|
|
size += 4
|
|
else:
|
|
size = 16
|
|
points = [Envelope.EnvelopePoint(0, 0), Envelope.EnvelopePoint(0, 0),
|
|
Envelope.EnvelopePoint(0, 0), Envelope.EnvelopePoint(0, 0)]
|
|
|
|
return points, size
|
|
|
|
def read_envelope(self, offset, release_rate, is_zero=False):
|
|
assert offset % 16 == 0
|
|
|
|
if offset in self.envelopes:
|
|
# Look it up if it was already seen
|
|
env = self.envelopes[offset]
|
|
else:
|
|
# Read new
|
|
points, size = self.read_envelope_points(offset, is_zero)
|
|
env = Envelope(points, is_zero=is_zero)
|
|
|
|
# Cache it
|
|
self.envelopes[offset] = env
|
|
# Mark coverage
|
|
self.coverage.append([[offset, Envelope.EnvelopePoint], [offset + size, Envelope.EnvelopePoint]])
|
|
|
|
# Add release rate if there was one
|
|
if release_rate is not None:
|
|
env.release_rates.append(release_rate)
|
|
|
|
return env
|
|
|
|
def logged_read(self, start, length, dtype):
|
|
"""
|
|
Read data while also recording coverage information
|
|
"""
|
|
end = start + length
|
|
self.coverage.append([[start, dtype], [end, dtype]])
|
|
return self.data[start:end]
|
|
|
|
def read_structure(self, offset, dtype):
|
|
return dtype(self.logged_read(offset, dtype.SIZE, dtype))
|
|
|
|
def read_list(self, offset, num, dtype):
|
|
return [dtype(i, self.logged_read(offset + i * dtype.SIZE, dtype.SIZE, dtype)) for i in range(num)]
|
|
|
|
def read_pointer(self, offset, ptr_type):
|
|
return struct.unpack('>I', self.logged_read(offset, 4, ptr_type))[0]
|
|
|
|
def read_list_from_offset_list(self, offset_list, dtype):
|
|
assert all([b % 0x10 == 0 for b in offset_list])
|
|
return [dtype(self.logged_read(offset, dtype.SIZE, dtype)) if offset != 0 else None for offset in offset_list]
|
|
|
|
def read_pointer_list(self, offset, count, ptr_type):
|
|
# May be NULL, but only if the count is 0
|
|
assert (count == 0 and offset == 0) or offset != 0
|
|
|
|
if count == 0:
|
|
# No data
|
|
return []
|
|
|
|
# Read pointer list contents
|
|
ptr_list = [i[0] for i in struct.iter_unpack('>I', self.logged_read(offset, 4 * count, ptr_type))]
|
|
assert len(ptr_list) == count
|
|
|
|
# Pointer lists seem to always pad to the next 0x10 byte boundary
|
|
pointers_end = offset + 4 * count
|
|
possible_pad = self.logged_read(pointers_end, align(pointers_end, 16) - pointers_end, Padding)
|
|
assert all(b == 0 for b in possible_pad)
|
|
|
|
return ptr_list
|
|
|
|
def sorted_envelopes(self):
|
|
# sort by offset
|
|
for i,(offset,env) in enumerate(sorted(self.envelopes.items(), key=lambda x : x[0])):
|
|
yield i,(offset,env)
|
|
|
|
def envelope_name_func(self, offset):
|
|
return self.envelopes[offset].name
|
|
|
|
def sorted_sample_headers(self):
|
|
for i,offset in enumerate(sorted(self.sample_headers)):
|
|
yield i,(offset,self.sample_headers[offset])
|
|
|
|
def lookup_sample(self, header_offset : int) -> Optional[AudioTableSample]:
|
|
if header_offset == 0:
|
|
return None
|
|
header : SoundFontSample = self.sample_headers[header_offset]
|
|
bank = self.bank1 if header.medium == 0 else self.bank2
|
|
return bank.lookup_sample(header.sample_addr)
|
|
|
|
def lookup_sample_name(self, sample_header : SoundFontSample):
|
|
bank = self.bank1 if sample_header.medium == 0 else self.bank2
|
|
name = bank.lookup_sample(sample_header.sample_addr).name
|
|
assert name is not None
|
|
return name
|
|
|
|
def sample_name_func(self, offset):
|
|
return self.lookup_sample_name(self.sample_headers[offset])
|
|
|
|
def finalize(self):
|
|
# Assign envelope names
|
|
for i,(offset,env) in self.sorted_envelopes():
|
|
env : Envelope
|
|
env.name = self.envelope_name(i)
|
|
|
|
# Link Instruments
|
|
for instr in self.instruments:
|
|
instr.finalize(self.lookup_sample)
|
|
|
|
# Final Drum Groups
|
|
|
|
if PLOT_DRUM_TUNING:
|
|
plt.clf()
|
|
plt.cla()
|
|
plt.title(f"Drums in soundfont {self.bank_num}")
|
|
plt.xlabel("Drum index")
|
|
plt.ylabel("Tuning value")
|
|
|
|
for drum_grp in self.drum_groups:
|
|
if all(d is None for d in drum_grp):
|
|
continue
|
|
|
|
if PLOT_DRUM_TUNING:
|
|
plt.plot( range(drum_grp.start,drum_grp.end), [drum.tuning for drum in drum_grp])
|
|
plt.scatter(range(drum_grp.start,drum_grp.end), [drum.tuning for drum in drum_grp])
|
|
|
|
drum_grp : DrumGroup
|
|
drum_grp.finalize(self.envelopes, self.lookup_sample)
|
|
|
|
if PLOT_DRUM_TUNING:
|
|
if len(self.drum_groups) != 0:
|
|
plt.savefig(f"figures/drums_{self.bank_num}.png")
|
|
|
|
# Link SFX
|
|
for sfx in self.sfx:
|
|
sfx.finalize(self.lookup_sample)
|
|
|
|
# TODO resolve decay/release index overrides?
|
|
|
|
def envelope_name(self, index):
|
|
if self.extraction_envelopes_info is not None and index < len(self.extraction_envelopes_info):
|
|
return self.extraction_envelopes_info[index]
|
|
else:
|
|
return f"Env{index}"
|
|
|
|
def instrument_name(self, program_number):
|
|
if self.extraction_instruments_info is not None and program_number in self.extraction_instruments_info:
|
|
return self.extraction_instruments_info[program_number]
|
|
else:
|
|
return f"INST_{program_number}"
|
|
|
|
def drum_grp_name(self, index):
|
|
if self.extraction_drums_info is not None and index < len(self.extraction_drums_info):
|
|
return self.extraction_drums_info[index]
|
|
else:
|
|
return f"DRUM_{index}"
|
|
|
|
def effect_name(self, index):
|
|
if self.extraction_effects_info is not None and index < len(self.extraction_effects_info):
|
|
return self.extraction_effects_info[index]
|
|
else:
|
|
return f"EFFECT_{index}"
|
|
|
|
def envelopes_to_xml(self, xml : XMLWriter):
|
|
if len(self.envelopes) == 0:
|
|
return
|
|
|
|
xml.write_start_tag("Envelopes")
|
|
|
|
for i,(offset,env) in self.sorted_envelopes():
|
|
env : Envelope
|
|
env.to_xml(xml, self.envelope_name(i))
|
|
|
|
xml.write_end_tag()
|
|
|
|
def samples_to_xml(self, xml : XMLWriter):
|
|
if len(self.sample_headers) == 0:
|
|
return
|
|
|
|
xml.write_start_tag("Samples")
|
|
|
|
# Emit these in the order the sample headers appear in the soundfont
|
|
for i,(offset,sample_header) in self.sorted_sample_headers():
|
|
sample_header : SoundFontSample
|
|
sample_header.to_xml(xml, self.lookup_sample_name(sample_header))
|
|
|
|
xml.write_end_tag()
|
|
|
|
def sfx_to_xml(self, xml : XMLWriter):
|
|
if len(self.sfx) == 0:
|
|
return
|
|
|
|
xml.write_start_tag("Effects")
|
|
|
|
for i,sfx in enumerate(self.sfx):
|
|
sfx.to_xml(xml, self.effect_name(i), self.sample_name_func)
|
|
|
|
xml.write_end_tag()
|
|
|
|
def drums_to_xml(self, xml : XMLWriter):
|
|
if len(self.drums) == 0:
|
|
return
|
|
|
|
xml.write_start_tag("Drums")
|
|
|
|
for i,drum_grp in enumerate(self.drum_groups):
|
|
if isinstance(drum_grp, list):
|
|
for _ in range(len(drum_grp)):
|
|
xml.write_element("Drum")
|
|
else:
|
|
drum_grp : DrumGroup
|
|
drum_grp.to_xml(xml, self.drum_grp_name(i), self.sample_name_func, self.envelope_name_func)
|
|
|
|
xml.write_end_tag()
|
|
|
|
def instruments_to_xml(self, xml : XMLWriter):
|
|
if len(self.instruments) == 0:
|
|
return
|
|
|
|
xml.write_start_tag("Instruments")
|
|
|
|
# Write in struct order
|
|
for instr in sorted(self.instruments, key=lambda instr : instr.struct_index):
|
|
instr : Instrument
|
|
name = self.instrument_name(instr.program_number) if not instr.unused else None
|
|
instr.to_xml(xml, name, self.sample_name_func, self.envelope_name_func)
|
|
|
|
xml.write_end_tag()
|
|
|
|
def to_xml(self, name, samplebanks_base):
|
|
xml = XMLWriter()
|
|
|
|
start = {
|
|
"Name" : name,
|
|
"Index" : self.bank_num,
|
|
"Medium" : self.table_entry.medium.name,
|
|
"CachePolicy" : self.table_entry.cache_policy.name,
|
|
"SampleBank" : f"$(BUILD_DIR)/{samplebanks_base}/{self.bank1.file_name}.xml",
|
|
}
|
|
|
|
# If the samplebank1 index is not the true index (that is it's a pointer), write an Indirect
|
|
if self.bank1_num != self.bank1.bank_num:
|
|
start["Indirect"] = self.bank1_num
|
|
|
|
if self.bank2_num != 255: # bank2 is not None if bank2_num != 255
|
|
start["SampleBankDD"] = f"$(BUILD_DIR)/{samplebanks_base}/{self.bank2.file_name}.xml",
|
|
# TODO we should really write an indirect for DD banks too if bank2_num != bank2.bank_num
|
|
|
|
if self.loops_have_frames:
|
|
# Some MM banks have sample frame counts embedded in loop headers, but not all soundfonts do this
|
|
start["LoopsHaveFrames"] = "true"
|
|
|
|
if max(instr.program_number or 0 for instr in self.instruments) + 1 != self.table_entry.num_instruments:
|
|
# Some banks have trailing NULLs in their instrument pointer tables, record the max length for matching
|
|
start["NumInstruments"] = self.table_entry.num_instruments
|
|
|
|
if self.pad_to_size is not None:
|
|
# The final soundfont typically has extra zeros at the end
|
|
start["PadToSize"] = f"0x{self.pad_to_size:X}"
|
|
|
|
xml.write_start_tag("Soundfont", start)
|
|
|
|
self.envelopes_to_xml(xml)
|
|
self.samples_to_xml(xml)
|
|
|
|
self.sfx_to_xml(xml)
|
|
self.drums_to_xml(xml)
|
|
self.instruments_to_xml(xml)
|
|
|
|
if self.file_padding is not None:
|
|
# Some soundfonts may have garbage data in the final 16-byte file padding
|
|
xml.write_start_tag("MatchPadding")
|
|
xml.write_raw(", ".join(f"0x{b:02X}" for b in self.file_padding))
|
|
xml.write_end_tag()
|
|
|
|
xml.write_end_tag()
|
|
return str(xml)
|
|
|
|
def write_extraction_xml(self, path):
|
|
xml = XMLWriter()
|
|
|
|
xml.write_comment("This file is only for extraction of vanilla data. For other purposes see assets/audio/soundfonts/")
|
|
|
|
xml.write_start_tag("SoundFont", {
|
|
"Name" : self.name,
|
|
"Index" : self.bank_num,
|
|
})
|
|
|
|
# add contents for names
|
|
|
|
if len(self.envelopes) != 0 or len(self.extraction_envelopes_info_versions) != 0:
|
|
xml.write_start_tag("Envelopes")
|
|
|
|
# First write envelopes that were defined in the extraction xml, possibly interleaved with envelopes
|
|
# we ignored for this version
|
|
i = 0
|
|
for envelope_entry,in_version in self.extraction_envelopes_info_versions:
|
|
xml.write_element("Envelope", envelope_entry)
|
|
# Count how many envelopes we saw that were defined for this version
|
|
i += in_version
|
|
|
|
# Write any remaining envelopes that weren't defined in the xml
|
|
for j in range(i, len(self.envelopes)):
|
|
xml.write_element("Envelope", {
|
|
"Name" : self.envelope_name(j)
|
|
})
|
|
|
|
xml.write_end_tag()
|
|
|
|
if len(self.instruments) != 0 or len(self.extraction_instruments_info_versions) != 0:
|
|
xml.write_start_tag("Instruments")
|
|
|
|
# Write in struct order
|
|
sorted_instruments = tuple(sorted(self.instruments, key=lambda instr : instr.struct_index))
|
|
|
|
# First write instruments that were defined in the extraction xml, possibly interleaved with instruments
|
|
# we ignored for this version
|
|
i = 0
|
|
for instr_entry,in_version in self.extraction_instruments_info_versions:
|
|
xml.write_element("Instrument", instr_entry)
|
|
# Count how many instruments we saw that were defined for this version
|
|
i += in_version
|
|
|
|
# Write any remaining instruments that weren't defined in the xml
|
|
for instr in sorted_instruments[i:]:
|
|
instr : Instrument
|
|
if not instr.unused:
|
|
xml.write_element("Instrument", {
|
|
"ProgramNumber" : instr.program_number,
|
|
"Name" : self.instrument_name(instr.program_number),
|
|
})
|
|
|
|
xml.write_end_tag()
|
|
|
|
if any(isinstance(dg, DrumGroup) for dg in self.drum_groups) or len(self.extraction_drums_info_versions):
|
|
xml.write_start_tag("Drums")
|
|
|
|
# First write drums that were defined in the extraction xml, possibly interleaved with drums
|
|
# we ignored for this version
|
|
i = 0
|
|
for drum_entry,in_version in self.extraction_drums_info_versions:
|
|
xml.write_element("Drum", drum_entry)
|
|
# Count how many drum groups we saw that were defined for this version
|
|
i += in_version
|
|
|
|
for j,drum_grp in enumerate(self.drum_groups[i:], i):
|
|
if isinstance(drum_grp, DrumGroup):
|
|
xml.write_element("Drum", {
|
|
"Name" : self.drum_grp_name(j)
|
|
})
|
|
|
|
xml.write_end_tag()
|
|
|
|
if len(self.sfx) != 0 or len(self.extraction_effects_info_versions):
|
|
xml.write_start_tag("Effects")
|
|
|
|
# First write effects that were defined in the extraction xml, possibly interleaved with effects
|
|
# we ignored for this version
|
|
i = 0
|
|
for sfx_entry,in_version in self.extraction_effects_info_versions:
|
|
xml.write_element("Effect", sfx_entry)
|
|
# Count how many effects we saw that were defined for this version
|
|
i += in_version
|
|
|
|
for j,sfx in enumerate(self.sfx[i:], i):
|
|
xml.write_element("Effect", {
|
|
"Name" : self.effect_name(j)
|
|
})
|
|
|
|
xml.write_end_tag()
|
|
|
|
xml.write_end_tag()
|
|
|
|
with open(path, "w") as outfile:
|
|
outfile.write(str(xml))
|