# 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) # Note values should increase monotonically in a drum group note_indices = [pitch_names.index(note) + 21 for note in notes] assert all(v == note_indices[0] + i for i,v in enumerate(note_indices)) # 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"] = 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))