mirror of
https://github.com/zeldaret/oot.git
synced 2024-12-01 15:26:01 +00:00
ef329e633a
* [Audio 2/?] Extract audio samples to wav Co-authored-by: zelda2774 <69368340+zelda2774@users.noreply.github.com> * How * Hopefully fix warning I don't get locally * Pad default sample filenames, comment on the vadpcm frame encoder functions, other suggested changes * Small tweaks to above * Remove some obsolete code --------- Co-authored-by: zelda2774 <69368340+zelda2774@users.noreply.github.com>
692 lines
26 KiB
Python
692 lines
26 KiB
Python
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
|
# SPDX-License-Identifier: CC0-1.0
|
|
#
|
|
#
|
|
#
|
|
|
|
import math, struct
|
|
from typing import Dict, Tuple
|
|
from xml.etree.ElementTree import Element
|
|
|
|
from .audio_tables import AudioCodeTableEntry
|
|
from .audiobank_structs import AudioSampleCodec, SoundFontSample, AdpcmBook, AdpcmLoop
|
|
from .tuning import pitch_names, note_z64_to_midi, recalc_tuning, rate_from_tuning, rank_rates_notes, BAD_FLOATS
|
|
from .util import align, error, XMLWriter, f32_to_u32
|
|
|
|
class AIFCFile:
|
|
|
|
def __init__(self):
|
|
self.sections = []
|
|
self.total_size = 0
|
|
|
|
@staticmethod
|
|
def pstring(data):
|
|
return bytes([len(data)]) + data + (b"" if len(data) % 2 else b"\0")
|
|
|
|
@staticmethod
|
|
def serialize_f80(num):
|
|
"""
|
|
Convert num to 80-bit float. Does not accept denormal/infinity/nan but these should never appear anyway.
|
|
"""
|
|
num = float(num)
|
|
if num == 0.0:
|
|
return b"\0" * 10
|
|
elif num == -0.0:
|
|
return b"\x80" + b"\0" * 9
|
|
|
|
f64_bits, = struct.unpack(">Q", struct.pack(">d", num))
|
|
|
|
f64_sign_bit = f64_bits & (2 ** 63)
|
|
|
|
f64_exponent = (f64_bits ^ f64_sign_bit) >> 52
|
|
assert f64_exponent != 0, "can't handle denormals"
|
|
assert f64_exponent != 0x7FF, "can't handle infinity/nan"
|
|
f64_exponent -= 1023
|
|
|
|
f64_mantissa = f64_bits & (2 ** 52 - 1)
|
|
|
|
f80_sign_bit = f64_sign_bit << (80 - 64)
|
|
f80_exponent = (f64_exponent + 0x3FFF) << 64
|
|
f80_mantissa = (2 ** 63) | (f64_mantissa << (63 - 52))
|
|
|
|
f80 = f80_sign_bit | f80_exponent | f80_mantissa
|
|
|
|
return struct.pack(">HQ", f80 >> 64, f80 & (2 ** 64 - 1))
|
|
|
|
def add_section(self, tp, data):
|
|
assert isinstance(tp, bytes)
|
|
assert isinstance(data, bytes)
|
|
|
|
self.sections.append((tp, data))
|
|
self.total_size += align(len(data),2) + 8
|
|
|
|
def add_custom_section(self, tp, data):
|
|
self.add_section(b"APPL", b"stoc" + self.pstring(tp) + data)
|
|
|
|
def remove_section(self, tp):
|
|
assert isinstance(tp, bytes)
|
|
|
|
for s_tp, s_data in self.sections:
|
|
if s_tp == tp:
|
|
self.sections.remove((s_tp, s_data))
|
|
self.total_size -= align(len(s_data),2) + 8
|
|
return
|
|
|
|
def commit(self, outpath):
|
|
self.total_size += 4
|
|
|
|
with open(outpath, "wb") as outfile:
|
|
outfile.write(b"FORM" + struct.pack(">I", self.total_size) + b"AIFC")
|
|
|
|
for tp, data in self.sections:
|
|
outfile.write(tp + struct.pack(">I", len(data)))
|
|
outfile.write(data)
|
|
|
|
if len(data) % 2:
|
|
outfile.write(b"\0")
|
|
|
|
class AudioTableData:
|
|
"""
|
|
Unaccounted data in the Audiotable
|
|
"""
|
|
|
|
def __init__(self, start, end, data):
|
|
self.start : int = start
|
|
self.end : int = end
|
|
self.data = data
|
|
assert len(self.data) % 2 == 0
|
|
|
|
self.name : str = None
|
|
self.filename : str = None
|
|
|
|
def __len__(self):
|
|
return len(self.data)
|
|
|
|
def to_asm(self, name):
|
|
out = f"# {name} [0x{self.start:X}:0x{self.end:X}](0x{self.end-self.start:X})\n\n"
|
|
out += " .byte "
|
|
for i,b in enumerate(self.data):
|
|
if i != 0 and i % 32 == 0:
|
|
out = out[:-2] + "\n .byte "
|
|
out += f"0x{b:02X}, "
|
|
out = out[:-2] + "\n\n"
|
|
return out
|
|
|
|
def to_file(self, outpath : str):
|
|
# Output as binary blob
|
|
|
|
with open(outpath, "wb") as outfile:
|
|
outfile.write(self.data)
|
|
|
|
|
|
|
|
PCM16_SAMPLE_SIZE = 16
|
|
|
|
class AudioTableSample(AudioTableData):
|
|
"""
|
|
Sample in the Audiotable
|
|
"""
|
|
|
|
def __init__(self, start : int, end : int, header : SoundFontSample, data, book : AdpcmBook, loop : AdpcmLoop, padding=None):
|
|
super().__init__(start, end, data)
|
|
|
|
self.header : SoundFontSample = header
|
|
self.book : AdpcmBook = book
|
|
self.loop : AdpcmLoop = loop
|
|
self.padding = padding
|
|
|
|
self.notes_rates = set()
|
|
self.sample_rate = None
|
|
self.base_note = None
|
|
self.tuning_map = None
|
|
|
|
if self.loop.count == 0:
|
|
# If a count is 0 the loop end must be the (bugged, vadpcm_enc computed it wrong originally) frame count
|
|
num_frames_bugged = (len(self.data) * PCM16_SAMPLE_SIZE) // self.frame_size()
|
|
assert self.loop.end == num_frames_bugged, f"{self.loop.end}, {num_frames_bugged}"
|
|
|
|
def clone(self, start, end, padding):
|
|
new_sample = AudioTableSample(start, end, self.header, self.data, self.book, self.loop, padding)
|
|
new_sample.notes_rates = self.notes_rates
|
|
return new_sample
|
|
|
|
def frame_size(self):
|
|
return {
|
|
AudioSampleCodec.CODEC_ADPCM : 9,
|
|
AudioSampleCodec.CODEC_S8 : 16,
|
|
AudioSampleCodec.CODEC_S16_INMEMORY : 32,
|
|
AudioSampleCodec.CODEC_SMALL_ADPCM : 5,
|
|
AudioSampleCodec.CODEC_REVERB : 0,
|
|
AudioSampleCodec.CODEC_S16 : 32
|
|
}[self.header.codec]
|
|
|
|
def codec_id(self):
|
|
return {
|
|
AudioSampleCodec.CODEC_ADPCM : b'ADP9',
|
|
AudioSampleCodec.CODEC_S8 : b'HPCM',
|
|
AudioSampleCodec.CODEC_S16_INMEMORY : b'NONE',
|
|
AudioSampleCodec.CODEC_SMALL_ADPCM : b'ADP5',
|
|
AudioSampleCodec.CODEC_REVERB : b'RVRB',
|
|
AudioSampleCodec.CODEC_S16 : b'NONE',
|
|
}[self.header.codec]
|
|
|
|
def codec_name(self):
|
|
return {
|
|
AudioSampleCodec.CODEC_ADPCM : b"Nintendo/SGI VADPCM 9-bytes/frame",
|
|
AudioSampleCodec.CODEC_S8 : b"Half-frame PCM",
|
|
AudioSampleCodec.CODEC_S16_INMEMORY : b"Uncompressed",
|
|
AudioSampleCodec.CODEC_SMALL_ADPCM : b"Nintendo/SGI VADPCM 5-bytes/frame",
|
|
AudioSampleCodec.CODEC_REVERB : b"Nintendo Reverb format",
|
|
AudioSampleCodec.CODEC_S16 : b"Uncompressed"
|
|
}[self.header.codec]
|
|
|
|
def codec_file_extension_compressed(self):
|
|
ext = {
|
|
AudioSampleCodec.CODEC_ADPCM : ".aifc",
|
|
AudioSampleCodec.CODEC_S8 : None,
|
|
AudioSampleCodec.CODEC_S16_INMEMORY : None,
|
|
AudioSampleCodec.CODEC_SMALL_ADPCM : ".half.aifc",
|
|
AudioSampleCodec.CODEC_REVERB : None,
|
|
AudioSampleCodec.CODEC_S16 : ".aiff",
|
|
}[self.header.codec]
|
|
assert ext is not None
|
|
return ext
|
|
|
|
def codec_file_extension_decompressed(self):
|
|
ext = {
|
|
AudioSampleCodec.CODEC_ADPCM : ".wav",
|
|
AudioSampleCodec.CODEC_S8 : None,
|
|
AudioSampleCodec.CODEC_S16_INMEMORY : None,
|
|
AudioSampleCodec.CODEC_SMALL_ADPCM : ".half.wav",
|
|
AudioSampleCodec.CODEC_REVERB : None,
|
|
AudioSampleCodec.CODEC_S16 : ".wav",
|
|
}[self.header.codec]
|
|
assert ext is not None
|
|
return ext
|
|
|
|
def base_note_number(self):
|
|
return note_z64_to_midi(pitch_names.index(self.base_note))
|
|
|
|
def resolve_basenote_rate(self, extraction_sample_info : Dict[int, Dict[str,str]]):
|
|
assert len(self.notes_rates) != 0
|
|
|
|
# rate_3ds = None
|
|
# if SAMPLERATES_3DS is not None:
|
|
# rate_3ds = SAMPLERATES_3DS[self.bank_num].get(i, None)
|
|
|
|
tuning_map = {}
|
|
def update_tuning_map(tuning, rate, note):
|
|
tuning_map.update({ tuning : (rate, note) })
|
|
|
|
# check
|
|
tuning_bits = f32_to_u32(tuning)
|
|
ntuning = recalc_tuning(rate, note)
|
|
assert ntuning == tuning or tuning_bits in BAD_FLOATS, \
|
|
f"Got: {ntuning}(0x{f32_to_u32(ntuning):X}), Expected: {tuning}(0x{f32_to_u32(tuning):X})"
|
|
|
|
if len(self.notes_rates) == 1:
|
|
# only need to match one tuning value
|
|
|
|
notes_rates,tuning = self.notes_rates.pop()
|
|
|
|
# if rate_3ds is not None and rate_3ds not in [rate for _,rate in notes_rates]:
|
|
# print(f"NONMATCHING: 3DS={rate_3ds} N64={[rate for _,rate in notes_rates]}")
|
|
|
|
if len(notes_rates) == 1:
|
|
# only one possible combination of samplerate and basenote
|
|
final_note,final_rate = notes_rates[0]
|
|
else:
|
|
# Several possible combinations of samplerate and basenote that result in the same tuning value,
|
|
# choose just one by arbitrary ranking
|
|
final_rate,(final_note,) = rank_rates_notes(tuple((rate, (note,)) for note,rate in notes_rates))
|
|
|
|
update_tuning_map(tuning, final_rate, final_note)
|
|
else:
|
|
# need to match for multiple tuning values
|
|
|
|
# produce a list of samplerates that are common to all entries, the correct samplerate is most likely in
|
|
# this intersection
|
|
rate_cands = set.intersection(*(set(rate for note,rate in nrs) for nrs,t in self.notes_rates))
|
|
|
|
# if rate_3ds is not None and rate_3ds not in rate_cands:
|
|
# print(f"NONMATCHING: 3DS={rate_3ds} N64={rate_cands}")
|
|
|
|
if len(rate_cands) == 0:
|
|
# no common samplerates, arbitrarily rank each separately to get best candidate for each tuning, then
|
|
# rank those again to find the one we should associate with the sample itself
|
|
|
|
finalists = []
|
|
for all_layout,tuning in self.notes_rates:
|
|
best_rate,(best_note,) = rank_rates_notes([(rate, (note,)) for note, rate in all_layout])
|
|
|
|
update_tuning_map(tuning, best_rate, best_note)
|
|
|
|
finalists.append((best_rate,(best_note,)))
|
|
|
|
final_rate,(final_note,) = rank_rates_notes(finalists)
|
|
else:
|
|
tunings = [t for nrs,t in self.notes_rates]
|
|
# Found one or more common samplerate, select just one by arbitrary ranking
|
|
|
|
# build a map from samplerate -> note value for each entry
|
|
dicts = tuple(dict((rate,note) for note,rate in nrs) for nrs,t in self.notes_rates)
|
|
|
|
# list of tuples (rate, (notes for each entry)) for each candidate samplerate
|
|
final_rate,final_notes = rank_rates_notes([(rate, tuple(D[rate] for D in dicts)) for rate in rate_cands])
|
|
|
|
finalists = []
|
|
|
|
# map the result of this stage to the tunings
|
|
for tuning,note in zip(tunings,final_notes):
|
|
update_tuning_map(tuning, final_rate, note)
|
|
finalists.append((final_rate,(note,)))
|
|
|
|
# select best note to go in the sample
|
|
final_rate,(final_note,) = rank_rates_notes(finalists)
|
|
|
|
if extraction_sample_info is not None:
|
|
if self.start in extraction_sample_info:
|
|
entry = extraction_sample_info[self.start]
|
|
if "SampleRate" in entry and "BaseNote" in entry:
|
|
final_rate = int(entry["SampleRate"])
|
|
final_note = entry["BaseNote"]
|
|
else:
|
|
print(f"WARNING: Missing extraction xml entry for sample at offset=0x{self.start:X}")
|
|
|
|
# print(" ",len(FINAL_NOTES_RATES), FINAL_NOTES_RATES)
|
|
# if rate_3ds is not None and len(FINAL_NOTES_RATES) == 1:
|
|
# print(f"3DS : {rate_3ds} N64 : {FINAL_NOTES_RATES[0][0]}")
|
|
# if rate_3ds != FINAL_NOTES_RATES[0][0]:
|
|
# print("NONMATCHING AFTER RANKING")
|
|
# else:
|
|
# print("No 3DS comparison")
|
|
|
|
self.notes_rates = None
|
|
self.sample_rate = final_rate
|
|
self.base_note = final_note
|
|
self.tuning_map = tuning_map
|
|
|
|
def to_file(self, outpath : str):
|
|
assert self.sample_rate is not None and self.base_note is not None,\
|
|
f"The sample must have been assigned a samplerate and basenote to be extracted to AIFC: [0x{self.start:X}:0x{self.end:X}]\n{self.header}"
|
|
|
|
NUM_CHANNELS = 1
|
|
|
|
# Note this computes the correct number of frames, The original sdk tool vadpcm_enc contained a bug where aifc
|
|
# files would sometimes be 1-off in the reported number of frames. We do not reproduce this.
|
|
num_frames = (len(self.data) // self.frame_size()) * PCM16_SAMPLE_SIZE
|
|
|
|
aifc = AIFCFile()
|
|
|
|
aifc.add_section(b"COMM",
|
|
struct.pack(">hIh", NUM_CHANNELS, num_frames, PCM16_SAMPLE_SIZE)
|
|
+ AIFCFile.serialize_f80(self.sample_rate)
|
|
+ self.codec_id()
|
|
+ AIFCFile.pstring(self.codec_name())
|
|
)
|
|
|
|
aifc.add_section(b"INST",
|
|
struct.pack(">bbbbbbhhhhhhh",
|
|
self.base_note_number(),
|
|
0, # detune
|
|
# TODO fill in the rest? with what?
|
|
0, # lownote
|
|
0, # highnote
|
|
0, # lowvel
|
|
0, # highvel
|
|
0, # gain
|
|
0,0,0, # sustain(mode,start,end)
|
|
0,0,0, # release(mode,start,end)
|
|
)
|
|
)
|
|
|
|
aifc.add_custom_section(b"VADPCMCODES", self.book.serialize())
|
|
if self.loop.count != 0:
|
|
# We don't need to write a VADPCMLOOPS chunk if the count is 0 as we can represent these by the absence of
|
|
# a VADPCMLOOPS chunk; a count of 0 indicates the sample has no loop, the start and end of a loop with
|
|
# count=0 are always 0 and the end of the sample respectively.
|
|
aifc.add_custom_section(b"VADPCMLOOPS", self.loop.serialize())
|
|
|
|
aifc.add_section(b"SSND", struct.pack(">II", 0, 0) + bytes(self.data))
|
|
|
|
aifc.commit(outpath)
|
|
|
|
def to_asm(self, name):
|
|
out = f"# {name} [0x{self.start:X}:0x{self.end:X}](0x{self.end-self.start:X})\n"
|
|
out += "\n"
|
|
out += f".global {name}\n"
|
|
out += f"{name}:\n"
|
|
out += f".global {name}_OFF\n"
|
|
out += f".set {name}_OFF, . - $start\n"
|
|
out += "\n"
|
|
out += " .byte "
|
|
for i,b in enumerate(self.data):
|
|
if i != 0 and i % 32 == 0:
|
|
out = out[:-2] + "\n .byte "
|
|
out += f"0x{b:02X}, "
|
|
out = out[:-2] + "\n"
|
|
if len(self.padding) == 0 or all(b == 0 for b in self.padding):
|
|
out += " .balign 16\n"
|
|
else:
|
|
out += f"# PADDING\n"
|
|
out += " .byte " + ", ".join(f"0x{b:02X}" for b in self.padding) + "\n"
|
|
out += "\n"
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class AudioTableFile:
|
|
"""
|
|
Single sample bank in the Audiotable
|
|
"""
|
|
|
|
def __init__(self, bank_num : int, audiotable_seg : memoryview, table_entry : AudioCodeTableEntry,
|
|
seg_offset : int, buffer_bug : bool = False, extraction_xml : Tuple[str, Element] = None):
|
|
self.bank_num = bank_num
|
|
self.table_entry : AudioCodeTableEntry = table_entry
|
|
self.data = self.table_entry.data(audiotable_seg, seg_offset)
|
|
self.buffer_bug = buffer_bug
|
|
|
|
self.samples_final = None
|
|
|
|
if extraction_xml is None:
|
|
self.file_name = f"SampleBank_{self.bank_num}"
|
|
self.name = f"SampleBank_{self.bank_num}"
|
|
self.extraction_sample_info = None
|
|
self.extraction_blob_info = None
|
|
else:
|
|
self.file_name = extraction_xml[0]
|
|
self.name = extraction_xml[1].attrib["Name"]
|
|
|
|
self.extraction_sample_info = {}
|
|
self.extraction_blob_info = {}
|
|
for item in extraction_xml[1]:
|
|
if item.tag == "Sample":
|
|
self.extraction_sample_info[int(item.attrib["Offset"], 16)] = item.attrib
|
|
elif item.tag == "Blob":
|
|
self.extraction_blob_info[int(item.attrib["Offset"], 16)] = item.attrib
|
|
else:
|
|
assert False
|
|
|
|
self.pointer_indices = []
|
|
|
|
self.samples = {}
|
|
self.coverage = set()
|
|
|
|
def register_ptr(self, index):
|
|
self.pointer_indices.append(index)
|
|
|
|
def dump_bin(self, path):
|
|
with open(path, "wb") as outfile:
|
|
outfile.write(self.data)
|
|
|
|
def __len__(self):
|
|
return len(self.data)
|
|
|
|
def add_sample(self, sample_header : SoundFontSample, book : AdpcmBook, loop : AdpcmLoop, tuning : float, ob):
|
|
# collect sample data
|
|
sample_start = sample_header.sample_addr
|
|
sample_end = sample_header.sample_addr + sample_header.size
|
|
sample_end_aligned = align(sample_end, 16)
|
|
sample_data = self.data[sample_start:sample_end]
|
|
sample_padding = self.data[sample_end:sample_end_aligned]
|
|
notes_rates = rate_from_tuning(tuning)
|
|
|
|
# update coverage
|
|
self.coverage.add((sample_start, sample_end_aligned, sample_end))
|
|
|
|
if sample_start in self.samples:
|
|
# if this sample start was already recorded, compare with previous
|
|
prev_sample : AudioTableSample = self.samples[sample_start]
|
|
|
|
# check data integrity, these should not change if the same is the same
|
|
assert prev_sample.end == sample_end
|
|
assert prev_sample.header.codec == sample_header.codec
|
|
assert prev_sample.book == book
|
|
assert prev_sample.loop == loop
|
|
|
|
# add notes/rates candidates
|
|
prev_sample.notes_rates.add((notes_rates, tuning))
|
|
else:
|
|
# if this sample start was not recorded, add it
|
|
new_sample = AudioTableSample(sample_start, sample_end, sample_header, sample_data, book, loop, sample_padding)
|
|
new_sample.notes_rates.add((notes_rates, tuning))
|
|
self.samples[sample_start] = new_sample
|
|
|
|
def lookup_sample(self, offset : int) -> AudioTableSample:
|
|
return self.samples[offset]
|
|
|
|
def sample_name(self, sample : AudioTableSample, index : int):
|
|
if self.extraction_sample_info is not None:
|
|
if sample.start in self.extraction_sample_info:
|
|
return self.extraction_sample_info[sample.start]["Name"]
|
|
print(f"WARNING: Missing extraction xml entry for sample at offset=0x{sample.start:X}")
|
|
return f"SAMPLE_{self.bank_num}_{index}"
|
|
|
|
def sample_filename(self, sample : AudioTableSample, index : int):
|
|
ext = sample.codec_file_extension_compressed()
|
|
|
|
if self.extraction_sample_info is not None:
|
|
if sample.start in self.extraction_sample_info:
|
|
return self.extraction_sample_info[sample.start]["FileName"] + ext
|
|
print(f"WARNING: Missing extraction xml entry for sample at offset=0x{sample.start:X}")
|
|
|
|
npad = int(math.floor(1 + math.log10(len(self.samples)))) if len(self.samples) != 0 else 0
|
|
return f"Sample{index:0{npad}}{ext}"
|
|
|
|
def blob_filename(self, start, end):
|
|
if self.extraction_blob_info is not None:
|
|
if start in self.extraction_blob_info:
|
|
return self.extraction_blob_info[start]["Name"]
|
|
print(f"WARNING: Missing extraction xml entry for blob at offset=0x{start:X}")
|
|
return f"UNACCOUNTED_{start:X}_{end:X}"
|
|
|
|
def finalize_samples(self):
|
|
self.samples_final = list(sorted(self.samples.values(), key = lambda sample : sample.start))
|
|
|
|
for i,sample in enumerate(self.samples_final):
|
|
sample : AudioTableSample
|
|
sample.resolve_basenote_rate(self.extraction_sample_info)
|
|
|
|
def finalize_coverage(self, all_sample_banks):
|
|
if len(self.coverage) != 0:
|
|
# merge ranges if there are any
|
|
self.coverage = list(sorted(self.coverage))
|
|
|
|
merged = [list(self.coverage.pop(0))]
|
|
|
|
while len(self.coverage) != 0:
|
|
next = self.coverage.pop(0)
|
|
if merged[-1][1] == next[0]:
|
|
merged[-1][1] = next[1]
|
|
merged[-1][2] = next[2]
|
|
else:
|
|
merged.append(list(next))
|
|
|
|
self.coverage = merged
|
|
|
|
# check fully covered
|
|
if len(self.coverage) == 1 and self.coverage[0][0] == 0 and self.coverage[0][1] == len(self.data):
|
|
return # all accounted
|
|
|
|
# not fully covered, determine ranges of unaccounted data
|
|
if len(self.coverage) == 0:
|
|
# absolutely nothing is accounted for
|
|
unaccounted_ranges = [(0, len(self))]
|
|
else:
|
|
unaccounted_ranges = []
|
|
# deal with gap at the start
|
|
if self.coverage[0][0] != 0:
|
|
unaccounted_ranges.append((0, self.coverage[0][0]))
|
|
# deal with gaps in the middle
|
|
for j,cvg in enumerate(self.coverage[:-1]):
|
|
start = cvg[1]
|
|
end = self.coverage[j + 1][0]
|
|
if start != end:
|
|
unaccounted_ranges.append((start, end))
|
|
# deal with gap at the end
|
|
if self.coverage[-1][1] != len(self):
|
|
unaccounted_ranges.append((self.coverage[-1][1], len(self)))
|
|
|
|
# TODO if an unaccounted range is in the extraction xml, trust it before searching other banks
|
|
|
|
unaccounted_str = "[" + ", ".join(f"(0x{start:06X}, 0x{end:06X})" for start,end in unaccounted_ranges) + "]"
|
|
print(f"Sample Bank {self.bank_num} has incomplete coverage. Unaccounted: {unaccounted_str}")
|
|
|
|
# search other banks for matches
|
|
for start,end in unaccounted_ranges:
|
|
while start != end:
|
|
found = False
|
|
|
|
for j,bank in enumerate(all_sample_banks):
|
|
if not isinstance(bank, AudioTableFile):
|
|
# Ignore pointer entries
|
|
continue
|
|
|
|
for sample in bank.samples_final:
|
|
sample : AudioTableSample
|
|
|
|
sample_end = start + len(sample)
|
|
sample_end_aligned = align(sample_end, 16)
|
|
|
|
if self.data[start:sample_end] == sample.data:
|
|
print(f" Located match for range [0x{start:X}:0x{sample_end:X}] in bank {j} at 0x{sample.start:X}")
|
|
new_sample = sample.clone(start, sample_end, self.data[sample_end:sample_end_aligned])
|
|
new_sample.start = start
|
|
new_sample.end = sample_end
|
|
new_sample.sample_rate = sample.sample_rate
|
|
new_sample.base_note = sample.base_note
|
|
self.samples_final.append(new_sample)
|
|
found = True
|
|
start = sample_end_aligned
|
|
break
|
|
if found:
|
|
break
|
|
else:
|
|
# found no matches, blob it
|
|
print(f" No match found in other banks for range [0x{start:X}:0x{end:X}], leaving as binary blob")
|
|
self.samples_final.append(AudioTableData(start, end, self.data[start:end]))
|
|
break
|
|
|
|
# Final sort
|
|
self.samples_final.sort(key = lambda sample : sample.start)
|
|
|
|
def assign_names(self):
|
|
i = 0
|
|
for sample in self.samples_final:
|
|
if isinstance(sample, AudioTableSample):
|
|
sample : AudioTableSample
|
|
|
|
sample.name = self.sample_name(sample, i)
|
|
sample.filename = self.sample_filename(sample, i)
|
|
i += 1
|
|
else:
|
|
sample : AudioTableData
|
|
|
|
name = self.blob_filename(sample.start, sample.end)
|
|
sample.name = name
|
|
sample.filename = f"{name}.bin"
|
|
|
|
def to_xml(self, base_path):
|
|
xml = XMLWriter()
|
|
|
|
start = {
|
|
"Name" : self.name,
|
|
"Index" : self.bank_num,
|
|
"Medium" : self.table_entry.medium.name,
|
|
"CachePolicy" : self.table_entry.cache_policy.name,
|
|
}
|
|
if self.buffer_bug:
|
|
start["BufferBug"] = "true"
|
|
|
|
xml.write_start_tag("SampleBank", start)
|
|
|
|
# write pointers
|
|
for index in self.pointer_indices:
|
|
xml.write_element("Pointer", { "Index" : index })
|
|
|
|
# write samples/blobs
|
|
for sample in self.samples_final:
|
|
if isinstance(sample, AudioTableSample):
|
|
sample : AudioTableSample
|
|
|
|
xml.write_element("Sample", {
|
|
"Name" : sample.name,
|
|
"Path" : f"$(BUILD_DIR)/{base_path}/{sample.filename}",
|
|
})
|
|
else:
|
|
sample : AudioTableData
|
|
|
|
xml.write_element("Blob", {
|
|
"Name" : sample.name,
|
|
"Path" : f"$(BUILD_DIR)/{base_path}/{sample.filename}",
|
|
})
|
|
|
|
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/samplebanks/")
|
|
|
|
start = {
|
|
"Name" : self.name,
|
|
"Index" : self.bank_num,
|
|
}
|
|
xml.write_start_tag("SampleBank", start)
|
|
|
|
i = 0
|
|
for sample in self.samples_final:
|
|
if isinstance(sample, AudioTableSample):
|
|
sample : AudioTableSample
|
|
|
|
xml.write_element("Sample", {
|
|
"Name" : sample.name,
|
|
"FileName" : sample.filename.replace(sample.codec_file_extension_compressed(), ""),
|
|
"Offset" : f"0x{sample.start:06X}",
|
|
"SampleRate" : sample.sample_rate,
|
|
"BaseNote" : sample.base_note,
|
|
})
|
|
i += 1
|
|
else:
|
|
sample : AudioTableData
|
|
|
|
xml.write_element("Blob", {
|
|
"Name" : sample.name,
|
|
"Offset" : f"0x{sample.start:06X}",
|
|
"Size" : f"0x{sample.end - sample.start:X}",
|
|
})
|
|
|
|
xml.write_end_tag()
|
|
|
|
with open(path, "w") as outfile:
|
|
outfile.write(str(xml))
|
|
|
|
def write_s_file(self, name, path):
|
|
with open(path, "w") as outfile:
|
|
out = ".rdata\n"
|
|
out += "\n"
|
|
out += ".balign 16\n"
|
|
out += "\n"
|
|
out += f".global {name}\n"
|
|
out += f"{name}_Start:\n"
|
|
out += "$start:\n"
|
|
out += "\n"
|
|
|
|
outfile.write(out)
|
|
|
|
i = 0
|
|
for sample in self.samples:
|
|
if isinstance(sample, AudioTableSample):
|
|
sample : AudioTableSample
|
|
outfile.write(sample.to_asm(self.sample_name(i)))
|
|
i += 1
|
|
else:
|
|
sample : AudioTableData
|
|
outfile.write(sample.to_asm("__UNACCOUNTED__"))
|