1
0
Fork 0
mirror of https://github.com/zeldaret/oot.git synced 2024-12-01 15:26:01 +00:00
oot/tools/audio/extraction/audiobank_file.py
Tharo 2d454933f3
[Audio 9/?] Multiversion samplebank and soundfont extraction xmls, 1.0 and 1.1 audio extraction (#2291)
* [Audio 9/?] Multiversion samplebank and soundfont extraction xmls, 1.0 and 1.1 audio extraction

* Rework multiversion samplebanks to reduce duplicates
2024-11-12 08:47:34 -05:00

982 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)
# 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))