1
0
Fork 0
mirror of https://github.com/zeldaret/oot.git synced 2024-11-15 14:09:35 +00:00
oot/tools/audio/extraction/audiobank_structs.py

407 lines
15 KiB
Python
Raw Normal View History

# SPDX-FileCopyrightText: © 2024 ZeldaRET
# SPDX-License-Identifier: CC0-1.0
#
# This file implements reading various structures resident to the Audiobank files.
# Additionally handles:
# - Linking with finalized samples
# - Writing xml elements representing these structures in soundfont xmls
#
import struct
from enum import IntEnum
from .audio_tables import AudioStorageMedium
from .tuning import rate_from_tuning, pitch_names
from .util import XMLWriter
VADPCM_VERSTAMP = 1
class AudioSampleCodec(IntEnum):
CODEC_ADPCM = 0
CODEC_S8 = 1
CODEC_S16_INMEMORY = 2
CODEC_SMALL_ADPCM = 3
CODEC_REVERB = 4
CODEC_S16 = 5
class SoundFontSample: # SampleHeader ?
"""
typedef struct {
/* 0x00 */ u32 codec : 4;
/* 0x00 */ u32 medium : 2; // storage medium determines which of the two sample bank ids to use when relocating sampleAddr
/* 0x00 */ u32 cached : 1;
/* 0x00 */ u32 isRelocated : 1;
/* 0x01 */ u32 size : 24;
/* 0x04 */ u8* sampleAddr; // offset into the sample bank associated with this soundfont
/* 0x08 */ AdpcmLoop* loop;
/* 0x0C */ AdpcmBook* book;
} SoundFontSample; // size = 0x10
"""
SIZE = 0x10
def __init__(self, data):
bits, self.sample_addr, self.loop, self.book = struct.unpack(">IIII", data[:0x10])
self.codec = AudioSampleCodec((bits >> 28) & 0b1111)
self.medium = AudioStorageMedium((bits >> 26) & 0b11)
self.cached = bool((bits >> 25) & 1)
self.is_relocated = bool((bits >> 24) & 1)
self.size = (bits >> 0) & 0b111111111111111111111111
assert self.book != 0
assert self.loop != 0
assert self.codec in [AudioSampleCodec.CODEC_ADPCM, AudioSampleCodec.CODEC_SMALL_ADPCM]
assert self.medium == 0
assert not self.is_relocated # Not relocated in ROM
def to_xml(self, xml : XMLWriter, name : str, rate_override = None, note_override = None):
# Example xml output:
# <Sample Name="SAMPLE_NAME" SampleRate="32000" BaseNote="C4" IsDD="false" Cached="false">
attrs = { "Name" : name }
if rate_override is not None:
attrs["SampleRate"] = rate_override
if note_override is not None:
attrs["BaseNote"] = note_override
if self.medium != 0:
attrs["IsDD"] = "true"
if self.cached:
attrs["Cached"] = str(self.cached).lower()
xml.write_element("Sample", attrs)
def __str__(self):
out = "(SoundFontSample){\n"
out += f" .codec = {self.codec.name}\n"
out += f" .medium = {self.medium.name}\n"
out += f" .cached = {self.cached}\n"
out += f" .is_relocated = {self.is_relocated}\n"
out += f" .size = 0x{self.size:X}\n"
out += f" .sampleAddr = 0x{self.sample_addr:X}\n"
out += f" .loop = 0x{self.loop:X}\n"
out += f" .book = 0x{self.book:X}\n"
out += "}\n"
return out
class AdpcmLoop:
"""
typedef struct {
/* 0x00 */ u32 start;
/* 0x04 */ u32 end;
/* 0x08 */ u32 count;
/* 0x0C */ u32 numFrames;
/* 0x10 */ s16 state[16]; // only exists if count != 0. 8-byte aligned
} AdpcmLoop; // size = 0x30 (or 0x10)
"""
def __init__(self, data):
self.start, self.end, self.count, self.num_frames = struct.unpack(">IIII", data[:0x10])
# We expect loops to be either "no loop" or "infinite", as these are all that vadpcm_enc could handle.
assert self.count in (0,0xFFFFFFFF)
if self.count != 0:
self.state = tuple(s[0] for s in struct.iter_unpack(">h", data[0x10:0x30]))
else:
# A count of 0 indicates "no loop", but a loop structure is mandatory for all samples so something had to
# be emitted. Ensure the start is at 0, later we will ensure that the end is at the last frame of the sample
# once we have the sample data.
assert self.start == 0
self.state = tuple([0] * 16)
assert len(self.state) == 16
def serialize(self):
"""
Creates VADPCMLOOPS section data for aifc files
"""
NUM_LOOPS = 1
return struct.pack(">HHIII16h",
VADPCM_VERSTAMP, NUM_LOOPS,
self.start, self.end, self.count,
*self.state)
def __eq__(self, other):
if not isinstance(other, AdpcmLoop):
return False
other : AdpcmLoop
start_matches = self.start == other.start
end_matches = self.end == other.end
count_matches = self.count == other.count
# We don't check num_frames in loop equality since loops in different soundfonts referring to the same
# sample data may not have this field filled out
return start_matches and end_matches and count_matches and self.state == other.state
def __str__(self):
out = "(AdpcmLoop){\n"
out += f" .start = {self.start},\n"
out += f" .end = {self.end},\n"
out += f" .count = {self.count},\n"
out += f" .numFrames = {self.num_frames},\n"
out += f" .state = {self.state},\n"
out += "}\n"
return out
class AdpcmBook:
"""
typedef struct {
/* 0x00 */ s32 order;
/* 0x04 */ s32 npredictors;
/* 0x08 */ s16 book[1]; // size 8 * order * npredictors. 8-byte aligned
} AdpcmBook; // size >= 0x8
"""
def __init__(self, data):
self.order, self.n_predictors = struct.unpack(">ii", data[:8])
self.book = tuple(s[0] for s in struct.iter_unpack(">h", data[8:][:2 * 8 * self.order * self.n_predictors]))
assert len(self.book) == 8 * self.order * self.n_predictors , (len(self.book), 8 * self.order * self.n_predictors)
def serialize(self):
header = struct.pack(">hhh", VADPCM_VERSTAMP, self.order, self.n_predictors)
data = b"".join(struct.pack(">h", x) for x in self.book)
return header + data
def __eq__(self, other):
if not isinstance(other, AdpcmBook):
return False
other : AdpcmBook
order_matches = self.order == other.order
npredictors_matches = self.n_predictors == other.n_predictors
return order_matches and npredictors_matches and self.book == other.book
def __str__(self):
out = "(AdpcmBook){\n"
out += f" .order = {self.order},\n"
out += f" .npredictors = {self.n_predictors},\n"
out += f" .book = {self.book},\n"
out += "}\n"
return out
class SoundFontSound:
"""
typedef struct {
/* 0x00 */ SoundFontSample* sample;
/* 0x04 */ f32 tuning; // frequency scale factor
} SoundFontSound; // size = 0x8
"""
SIZE = 8
def __init__(self, index, data):
self.index = index
self.sample, self.tuning = struct.unpack(">If", data[:8])
def finalize(self, sample_lookup_fn):
from .audiotable import AudioTableSample
sample = sample_lookup_fn(self.sample)
if sample is None:
return
assert isinstance(sample, AudioTableSample)
sample : AudioTableSample
assert self.tuning in sample.tuning_map
rate,note = sample.tuning_map[self.tuning]
self.sample_rate = rate
self.needs_rate_override = self.sample_rate != sample.sample_rate
self.base_note = note
self.needs_note_override = self.base_note != sample.base_note
def __str__(self) -> str:
out = "(SoundFontSound}{\n"
out += f" .sample = 0x{self.sample:X}\n"
out += f" .tuning = {self.tuning:.7f}f\n"
out += "}\n"
return out
def to_xml(self, xml : XMLWriter, name : str, sample_name_func):
if self.sample == 0 and self.tuning == 0:
xml.write_element("Effect")
else:
attrs = {
"Name" : name,
"Sample" : sample_name_func(self.sample),
}
if self.needs_rate_override:
attrs["SampleRate"] = self.sample_rate
if self.needs_note_override:
attrs["BaseNote"] = self.base_note
xml.write_element("Effect", attrs)
class Drum:
"""
typedef struct {
/* 0x00 */ u8 releaseRate;
/* 0x01 */ u8 pan;
/* 0x02 */ u8 isRelocated;
/* 0x04 */ SoundFontSound sound;
/* 0x0C */ AdsrEnvelope* envelope;
} Drum; // size = 0x10
"""
SIZE = 0x10
def __init__(self, data):
self.release_rate, self.pan, self.is_relocated, self.sample, self.tuning, self.envelope = \
struct.unpack(">BBBxIfI", data[:0x10])
assert self.is_relocated == 0
def group_continuation(self, other):
"""
Determine if self is a continuation of the drum group containing other, the last drum added.
"""
# If there is no previous drum or the previous drum was an empty entry, always begin a new group
if other is None:
return False
assert isinstance(other, Drum)
# Check general agreement, if these attributes do not match it is certainly not part of the same group
if self.sample == other.sample and self.pan == other.pan and self.envelope == other.envelope and \
self.release_rate == other.release_rate:
# If there is any intersection in the samplerates, assume these are in the same drum group
samplerates1 = set(rate for _,rate in rate_from_tuning(self.tuning))
samplerates2 = set(rate for _,rate in rate_from_tuning(other.tuning))
return len(samplerates1.intersection(samplerates2)) != 0
return False
def __str__(self):
out = "(Drum){\n"
out += f" .releaseRate = {self.release_rate},\n"
out += f" .pan = {self.pan},\n"
out += f" .isRelocated = {self.is_relocated},\n"
out += f" .sound.sample = 0x{self.sample:X},\n"
out += f" .sound.tuning = {self.tuning:.7f}f,\n"
out += f" .envelope = 0x{self.envelope:X},\n"
out += "}\n"
return out
class Instrument:
"""
typedef struct {
/* 0x00 */ u8 isRelocated;
/* 0x01 */ u8 normalRangeLo;
/* 0x02 */ u8 normalRangeHi;
/* 0x03 */ u8 releaseRate;
/* 0x04 */ AdsrEnvelope* envelope;
/* 0x08 */ SoundFontSound lowNotesSound;
/* 0x10 */ SoundFontSound normalNotesSound;
/* 0x18 */ SoundFontSound highNotesSound;
} Instrument; // size = 0x20
"""
SIZE = 0x20
def __init__(self, data):
self.is_relocated, self.normal_range_lo, self.normal_range_hi, self.release_rate, self.envelope, \
self.low_notes_sample, self.low_notes_tuning, \
self.normal_notes_sample, self.normal_notes_tuning, \
self.high_notes_sample, self.high_notes_tuning = struct.unpack(">BBBBIIfIfIf", data[:0x20])
self.program_number = None
self.offset = None
self.struct_index = None
self.unused = False
assert self.is_relocated == 0
# Sample is either present or the split point is at the start/end
assert not (self.low_notes_sample == 0 and self.low_notes_tuning == 0.0) or self.normal_range_lo == 0
assert not (self.high_notes_sample == 0 and self.high_notes_tuning == 0.0) or self.normal_range_hi == 127
def __str__(self):
out = "(Instrument){\n"
out += f" .isRelocated = {self.is_relocated},\n"
out += f" .normalRangeLo = {self.normal_range_lo},\n"
out += f" .normalRangeHi = {self.normal_range_hi},\n"
out += f" .releaseRate = {self.release_rate},\n"
out += f" .envelope = 0x{self.envelope:X},\n"
out += f" .lowNotesSound.sample = {self.low_notes_sample},\n"
out += f" .lowNotesSound.tuning = {self.low_notes_tuning},\n"
out += f" .normalNotesSound.sample = {self.normal_notes_sample},\n"
out += f" .normalNotesSound.tuning = {self.normal_notes_tuning},\n"
out += f" .highNotesSound.sample = {self.high_notes_sample},\n"
out += f" .highNotesSound.tuning = {self.high_notes_tuning},\n"
out += "}\n"
return out
def finalize(self, sample_lookup_fn):
from .audiotable import AudioTableSample
self.sample_rate = [None] * 3
self.base_note = [None] * 3
self.needs_rate_override = [False] * 3
self.needs_note_override = [False] * 3
sample_offsets = (self.low_notes_sample, self.normal_notes_sample, self.high_notes_sample)
tunings = (self.low_notes_tuning, self.normal_notes_tuning, self.high_notes_tuning)
for i,(sample_offset,tuning) in enumerate(zip(sample_offsets, tunings)):
sample = sample_lookup_fn(sample_offset)
if sample is None:
continue
assert isinstance(sample, AudioTableSample)
sample : AudioTableSample
assert tuning in sample.tuning_map
rate,note = sample.tuning_map[tuning]
self.sample_rate[i] = rate
self.needs_rate_override[i] = self.sample_rate[i] != sample.sample_rate
self.base_note[i] = note
self.needs_note_override[i] = self.base_note[i] != sample.base_note
def to_xml(self, xml : XMLWriter, name : str, sample_names_func, envelope_name_func):
attributes = {}
if not self.unused:
attributes["ProgramNumber"] = self.program_number
attributes["Name"] = name
# TODO release rate overrides?
attributes.update({
"Envelope" : envelope_name_func(self.envelope),
#"Release" : self.release_rate,
"Sample" : sample_names_func(self.normal_notes_sample),
})
if self.needs_rate_override[1]:
attributes["SampleRate"] = self.sample_rate[1]
if self.needs_note_override[1]:
attributes["BaseNote"] = self.base_note[1]
if self.normal_range_lo != 0:
attributes["RangeLo"] = pitch_names[self.normal_range_lo]
attributes["SampleLo"] = sample_names_func(self.low_notes_sample)
if self.needs_rate_override[0]:
attributes["SampleRateLo"] = self.sample_rate[0]
if self.needs_note_override[0]:
attributes["BaseNoteLo"] = self.base_note[0]
if self.normal_range_hi != 127:
attributes["RangeHi"] = pitch_names[self.normal_range_hi]
attributes["SampleHi"] = sample_names_func(self.high_notes_sample)
if self.needs_rate_override[2]:
attributes["SampleRateHi"] = self.sample_rate[2]
if self.needs_note_override[2]:
attributes["BaseNoteHi"] = self.base_note[2]
xml.write_element("Instrument" if not self.unused else "InstrumentUnused", attributes)