mirror of
https://github.com/zeldaret/oot
synced 2026-06-25 10:02:00 -04:00
[Audio 1/?] Extract Samplebanks and Soundfonts to XML (#2008)
* [Audio 1/?] Extract Samplebanks and Soundfonts to XML * Remove config.py and use the version yamls for addresses, other suggested changes * Adjust setup-audio * Remove some commented out dead code (MM review)
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Extract audio files
|
||||
#
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import auto, Enum
|
||||
from typing import Dict, List, Tuple, Union
|
||||
from xml.etree import ElementTree
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from .audio_tables import AudioCodeTable, AudioCodeTableEntry, AudioStorageMedium
|
||||
from .audiotable import AudioTableFile
|
||||
from .audiobank_file import AudiobankFile
|
||||
from .util import align, debugm, error, incbin
|
||||
|
||||
class MMLVersion(Enum):
|
||||
OOT = auto()
|
||||
MM = auto()
|
||||
|
||||
@dataclass
|
||||
class GameVersionInfo:
|
||||
# Music Macro Language Version
|
||||
mml_version : MMLVersion
|
||||
# Soundfont table code offset
|
||||
soundfont_table : int
|
||||
# Sequence font table code offset
|
||||
seq_font_table : int
|
||||
# Sequence table code offset
|
||||
seq_table : int
|
||||
# Sample bank table code offset
|
||||
sample_bank_table : int
|
||||
# Sequence enum names
|
||||
seq_enum_names : Tuple[str]
|
||||
# List of indices corresponding to handwritten sequences
|
||||
handwritten_sequences : Tuple[int]
|
||||
# Some soundfonts report the wrong samplebank, map them to the correct samplebank for proper sample discovery
|
||||
fake_banks : Dict[int, int]
|
||||
# Contains audiotable indices that suffer from a buffer clearing bug
|
||||
audiotable_buffer_bugs : Tuple[int]
|
||||
|
||||
SAMPLECONV_PATH = f"{os.path.dirname(os.path.realpath(__file__))}/../sampleconv/sampleconv"
|
||||
|
||||
BASEROM_DEBUG = False
|
||||
|
||||
# ======================================================================================================================
|
||||
# Run
|
||||
# ======================================================================================================================
|
||||
|
||||
def collect_sample_banks(audiotable_seg : memoryview, extracted_dir : str, version_info : GameVersionInfo,
|
||||
table : AudioCodeTable, samplebank_xmls : Dict[int, Tuple[str, Element]]):
|
||||
sample_banks : List[Union[AudioTableFile, int]] = []
|
||||
|
||||
for i,entry in enumerate(table):
|
||||
entry : AudioCodeTableEntry
|
||||
|
||||
assert entry.short_data1 == 0 and entry.short_data2 == 0 and entry.short_data3 == 0, \
|
||||
"Bad data for Sample Bank entry, all short data should be 0"
|
||||
assert entry.medium == AudioStorageMedium.MEDIUM_CART , \
|
||||
"Bad data for Sample Bank entry, medium should be CART"
|
||||
|
||||
if entry.size == 0:
|
||||
# Pointer to other entry, in this case the rom address is a table index
|
||||
|
||||
entry_dst = table.entries[entry.rom_addr]
|
||||
sample_banks[entry.rom_addr].register_ptr(i)
|
||||
sample_banks.append(entry_dst.rom_addr)
|
||||
else:
|
||||
# Check whether this samplebank suffers from the buffer bug
|
||||
# TODO it should be possible to detect this automatically by checking padding following sample discovery
|
||||
bug = i in version_info.audiotable_buffer_bugs
|
||||
|
||||
bank = AudioTableFile(i, audiotable_seg, entry, table.rom_addr, buffer_bug=bug,
|
||||
extraction_xml=samplebank_xmls.get(i, None))
|
||||
|
||||
if BASEROM_DEBUG:
|
||||
bank.dump_bin(f"{extracted_dir}/baserom_audiotest/audiotable_files/{bank.file_name}.bin")
|
||||
|
||||
sample_banks.append(bank)
|
||||
|
||||
return sample_banks
|
||||
|
||||
def bank_data_lookup(sample_banks : List[Union[AudioTableFile, int]], e : Union[AudioTableFile, int]) -> AudioTableFile:
|
||||
if isinstance(e, int):
|
||||
if e == 255:
|
||||
return None
|
||||
return bank_data_lookup(sample_banks, sample_banks[e])
|
||||
else:
|
||||
return e
|
||||
|
||||
def collect_soundfonts(audiobank_seg : memoryview, extracted_dir : str, version_info : GameVersionInfo,
|
||||
sound_font_table : AudioCodeTable, soundfont_xmls : Dict[int, Tuple[str, Element]],
|
||||
sample_banks : List[Union[AudioTableFile, int]]):
|
||||
soundfonts = []
|
||||
|
||||
for i,entry in enumerate(sound_font_table):
|
||||
entry : AudioCodeTableEntry
|
||||
|
||||
# Lookup the samplebanks used by this soundfont
|
||||
bank1 = bank_data_lookup(sample_banks, version_info.fake_banks.get(i, entry.sample_bank_id_1))
|
||||
bank2 = bank_data_lookup(sample_banks, entry.sample_bank_id_2)
|
||||
|
||||
# Read the data
|
||||
soundfont = AudiobankFile(audiobank_seg, i, entry, sound_font_table.rom_addr, bank1, bank2,
|
||||
entry.sample_bank_id_1, entry.sample_bank_id_2,
|
||||
extraction_xml=soundfont_xmls.get(i, None))
|
||||
soundfonts.append(soundfont)
|
||||
|
||||
if BASEROM_DEBUG:
|
||||
# Write the individual file for debugging and comparison
|
||||
soundfont.dump_bin(f"{extracted_dir}/baserom_audiotest/audiobank_files/{soundfont.file_name}.bin")
|
||||
|
||||
return soundfonts
|
||||
|
||||
def extract_samplebank(extracted_dir : str, sample_banks : List[Union[AudioTableFile, int]], bank : AudioTableFile,
|
||||
write_xml : bool):
|
||||
# deal with remaining gaps, have to blob them unless we can find an exact match in another bank
|
||||
bank.finalize_coverage(sample_banks)
|
||||
# assign names
|
||||
bank.assign_names()
|
||||
|
||||
# write xml
|
||||
with open(f"{extracted_dir}/assets/audio/samplebanks/{bank.file_name}.xml", "w") as outfile:
|
||||
outfile.write(bank.to_xml(f"assets/audio/samples/{bank.name}"))
|
||||
|
||||
# write the extraction xml if specified
|
||||
if write_xml:
|
||||
bank.write_extraction_xml(f"assets/xml/audio/samplebanks/{bank.file_name}.xml")
|
||||
|
||||
def extract_audio_for_version(version_info : GameVersionInfo, extracted_dir : str, read_xml : bool, write_xml : bool):
|
||||
print("Setting up...")
|
||||
|
||||
# Open baserom segments
|
||||
|
||||
code_seg = None
|
||||
audiotable_seg = None
|
||||
audiobank_seg = None
|
||||
|
||||
with open(f"{extracted_dir}/baserom/code", "rb") as infile:
|
||||
code_seg = memoryview(infile.read())
|
||||
|
||||
with open(f"{extracted_dir}/baserom/Audiotable", "rb") as infile:
|
||||
audiotable_seg = memoryview(infile.read())
|
||||
|
||||
with open(f"{extracted_dir}/baserom/Audiobank", "rb") as infile:
|
||||
audiobank_seg = memoryview(infile.read())
|
||||
|
||||
# ==================================================================================================================
|
||||
# Collect audio tables
|
||||
# ==================================================================================================================
|
||||
|
||||
seq_font_tbl_len = version_info.seq_table - version_info.seq_font_table
|
||||
|
||||
sound_font_table = AudioCodeTable(code_seg, version_info.soundfont_table)
|
||||
sample_bank_table = AudioCodeTable(code_seg, version_info.sample_bank_table)
|
||||
sequence_table = AudioCodeTable(code_seg, version_info.seq_table)
|
||||
sequence_font_table = incbin(code_seg, version_info.seq_font_table, seq_font_tbl_len)
|
||||
|
||||
if BASEROM_DEBUG:
|
||||
# Extract Table Binaries
|
||||
|
||||
os.makedirs(f"{extracted_dir}/baserom_audiotest/audio_code_tables/", exist_ok=True)
|
||||
|
||||
with open(f"{extracted_dir}/baserom_audiotest/audio_code_tables/samplebank_table.bin", "wb") as outfile:
|
||||
outfile.write(sample_bank_table.data)
|
||||
|
||||
with open(f"{extracted_dir}/baserom_audiotest/audio_code_tables/soundfont_table.bin", "wb") as outfile:
|
||||
outfile.write(sound_font_table.data)
|
||||
|
||||
with open(f"{extracted_dir}/baserom_audiotest/audio_code_tables/sequence_table.bin", "wb") as outfile:
|
||||
outfile.write(sequence_table.data)
|
||||
|
||||
with open(f"{extracted_dir}/baserom_audiotest/audio_code_tables/sequence_font_table.bin", "wb") as outfile:
|
||||
outfile.write(sequence_font_table)
|
||||
|
||||
# ==================================================================================================================
|
||||
# Collect extraction xmls
|
||||
# ==================================================================================================================
|
||||
|
||||
samplebank_xmls : Dict[int, Tuple[str, Element]] = {}
|
||||
soundfont_xmls : Dict[int, Tuple[str, Element]] = {}
|
||||
sequence_xmls : Dict[int, Tuple[str, Element]] = {}
|
||||
|
||||
if read_xml:
|
||||
# Read all present xmls
|
||||
|
||||
def walk_xmls(out_dict : Dict[int, Tuple[str, Element]], path : str, typename : str):
|
||||
for root,_,files in os.walk(path):
|
||||
for f in files:
|
||||
fullpath = os.path.join(root, f)
|
||||
xml = ElementTree.parse(fullpath)
|
||||
xml_root = xml.getroot()
|
||||
|
||||
if xml_root.tag != typename or "Name" not in xml_root.attrib or "Index" not in xml_root.attrib:
|
||||
error(f"Malformed {typename} extraction xml: \"{fullpath}\"")
|
||||
out_dict[int(xml_root.attrib["Index"])] = (f.replace(".xml", ""), xml_root)
|
||||
|
||||
walk_xmls(samplebank_xmls, f"assets/xml/audio/samplebanks", "SampleBank")
|
||||
walk_xmls(soundfont_xmls, f"assets/xml/audio/soundfonts", "SoundFont")
|
||||
walk_xmls(sequence_xmls, f"assets/xml/audio/sequences", "Sequence")
|
||||
|
||||
# TODO warn about any missing xmls or xmls with a bad index
|
||||
|
||||
# ==================================================================================================================
|
||||
# Collect samplebanks
|
||||
# ==================================================================================================================
|
||||
|
||||
if BASEROM_DEBUG:
|
||||
os.makedirs(f"{extracted_dir}/baserom_audiotest/audiotable_files", exist_ok=True)
|
||||
sample_banks = collect_sample_banks(audiotable_seg, extracted_dir, version_info, sample_bank_table, samplebank_xmls)
|
||||
|
||||
# ==================================================================================================================
|
||||
# Collect soundfonts
|
||||
# ==================================================================================================================
|
||||
|
||||
if BASEROM_DEBUG:
|
||||
os.makedirs(f"{extracted_dir}/baserom_audiotest/audiobank_files", exist_ok=True)
|
||||
soundfonts = collect_soundfonts(audiobank_seg, extracted_dir, version_info, sound_font_table, soundfont_xmls,
|
||||
sample_banks)
|
||||
|
||||
# ==================================================================================================================
|
||||
# Finalize samplebanks
|
||||
# ==================================================================================================================
|
||||
|
||||
for i,bank in enumerate(sample_banks):
|
||||
if isinstance(bank, AudioTableFile):
|
||||
bank.finalize_samples()
|
||||
|
||||
# ==================================================================================================================
|
||||
# Extract samplebank contents
|
||||
# ==================================================================================================================
|
||||
|
||||
print("Extracting samplebanks...")
|
||||
|
||||
os.makedirs(f"{extracted_dir}/assets/audio/samplebanks", exist_ok=True)
|
||||
if write_xml:
|
||||
os.makedirs(f"assets/xml/audio/samplebanks", exist_ok=True)
|
||||
|
||||
for bank in sample_banks:
|
||||
if isinstance(bank, AudioTableFile):
|
||||
extract_samplebank(extracted_dir, sample_banks, bank, write_xml)
|
||||
|
||||
# ==================================================================================================================
|
||||
# Extract soundfonts
|
||||
# ==================================================================================================================
|
||||
|
||||
print("Extracting soundfonts...")
|
||||
|
||||
os.makedirs(f"{extracted_dir}/assets/audio/soundfonts", exist_ok=True)
|
||||
if write_xml:
|
||||
os.makedirs(f"assets/xml/audio/soundfonts", exist_ok=True)
|
||||
|
||||
for i,sf in enumerate(soundfonts):
|
||||
sf : AudiobankFile
|
||||
|
||||
# Finalize instruments/drums/etc.
|
||||
# This step includes assigning the final samplerate and basenote for the instruments, which may be different
|
||||
# from the samplerate and basenote assigned to their sample prior.
|
||||
sf.finalize()
|
||||
|
||||
# write the soundfont xml itself
|
||||
with open(f"{extracted_dir}/assets/audio/soundfonts/{sf.file_name}.xml", "w") as outfile:
|
||||
outfile.write(sf.to_xml(f"Soundfont_{i}", "assets/audio/samplebanks"))
|
||||
|
||||
# write the extraction xml if specified
|
||||
if write_xml:
|
||||
sf.write_extraction_xml(f"assets/xml/audio/soundfonts/{sf.file_name}.xml")
|
||||
@@ -0,0 +1,107 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Implements code tables structure and related enums
|
||||
#
|
||||
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
|
||||
from .util import incbin
|
||||
|
||||
class AudioStorageMedium(IntEnum):
|
||||
MEDIUM_RAM = 0
|
||||
MEDIUM_UNK = 1
|
||||
MEDIUM_CART = 2
|
||||
MEDIUM_DISK_DRIVE = 3
|
||||
|
||||
class AudioCachePolicy(IntEnum):
|
||||
CACHE_LOAD_PERMANENT = 0
|
||||
CACHE_LOAD_PERSISTENT = 1
|
||||
CACHE_LOAD_TEMPORARY = 2
|
||||
CACHE_LOAD_EITHER = 3
|
||||
CACHE_LOAD_EITHER_NOSYNC = 4
|
||||
|
||||
class AudioCodeTableEntry:
|
||||
"""
|
||||
typedef struct {
|
||||
/* 0x00 */ u32 romAddr;
|
||||
/* 0x04 */ u32 size;
|
||||
/* 0x08 */ s8 medium;
|
||||
/* 0x09 */ s8 cachePolicy;
|
||||
/* 0x0A */ s16 shortData1;
|
||||
/* 0x0C */ s16 shortData2;
|
||||
/* 0x0E */ s16 shortData3;
|
||||
} AudioTableEntry; // size = 0x10
|
||||
"""
|
||||
def __init__(self, data):
|
||||
self.rom_addr, self.size, self.medium, self.cache_policy, self.short_data1, self.short_data2, \
|
||||
self.short_data3 = struct.unpack(">IIbbhhh", data[:0x10])
|
||||
|
||||
self.medium = AudioStorageMedium(self.medium)
|
||||
self.cache_policy = AudioCachePolicy(self.cache_policy)
|
||||
|
||||
self.sample_bank_id_1 = (self.short_data1 >> 8) & 0xFF
|
||||
self.sample_bank_id_2 = (self.short_data1 >> 0) & 0xFF
|
||||
|
||||
self.num_instruments = (self.short_data2 >> 8) & 0xFF
|
||||
self.num_drums = (self.short_data2 >> 0) & 0xFF
|
||||
|
||||
self.num_sfx = self.short_data3
|
||||
|
||||
def __str__(self):
|
||||
out = "{\n"
|
||||
out += f" .romAddr = 0x{self.rom_addr:X}\n"
|
||||
out += f" .size = 0x{self.size:X}\n"
|
||||
out += f" .medium = {self.medium.name}\n"
|
||||
out += f" .cachePolicy = {self.cache_policy.name}\n"
|
||||
out += f" .shortData1 = ({self.sample_bank_id_1} << 8) | {self.sample_bank_id_2}\n"
|
||||
out += f" .shortData2 = ({self.num_instruments} << 8) | {self.num_drums}\n"
|
||||
out += f" .shortData3 = {self.num_sfx}\n"
|
||||
out += "}\n"
|
||||
return out
|
||||
|
||||
def data(self, segment_data : memoryview, segment_offset : int) -> memoryview:
|
||||
return incbin(segment_data, self.rom_addr + segment_offset, self.size)
|
||||
|
||||
class AudioCodeTable:
|
||||
"""
|
||||
typedef struct {
|
||||
/* 0x00 */ s16 numEntries;
|
||||
/* 0x02 */ s16 unkMediumParam;
|
||||
/* 0x04 */ u32 romAddr;
|
||||
/* 0x08 */ char pad[0x8];
|
||||
/* 0x10 */ AudioTableEntry entries[1/* numEntries */];
|
||||
} AudioTable; // size = 0x10 + 0x10 * numEntries
|
||||
"""
|
||||
|
||||
def __init__(self, rom_image : memoryview, rom_start : int):
|
||||
header = incbin(rom_image, rom_start, 0x10)
|
||||
|
||||
self.num_entries, self.unk_medium_param, self.rom_addr = struct.unpack(">hhI", header[:8])
|
||||
assert all([b == 0 for b in header[8:]])
|
||||
|
||||
self.data = incbin(rom_image, rom_start, 0x10 + 0x10 * self.num_entries)
|
||||
|
||||
self.entries = []
|
||||
for i in range(self.num_entries):
|
||||
self.entries.append(AudioCodeTableEntry(self.data[0x10 + 0x10 * i:][:0x10]))
|
||||
|
||||
def __iter__(self) -> AudioCodeTableEntry:
|
||||
for e in self.entries:
|
||||
yield e
|
||||
|
||||
def __len__(self):
|
||||
return len(self.entries)
|
||||
|
||||
def __str__(self):
|
||||
out = "{\n"
|
||||
out += f" .numEntries = {self.num_entries}\n"
|
||||
out += f" .unkMediumParam = {self.unk_medium_param}\n"
|
||||
out += f" .romAddr = 0x{self.rom_addr:X}\n"
|
||||
out += " .entries = {\n"
|
||||
for entry in self.entries:
|
||||
out += str(entry) + "\n"
|
||||
out += " }\n"
|
||||
out += "}\n"
|
||||
return out
|
||||
@@ -0,0 +1,956 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Implements audiobank file
|
||||
#
|
||||
|
||||
import struct
|
||||
from typing import Optional, Tuple
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from .audio_tables import AudioCodeTableEntry
|
||||
from .audiobank_structs import AdpcmBook, AdpcmLoop, Drum, Instrument, SoundFontSample, SoundFontSound
|
||||
from .envelope import Envelope
|
||||
from .audiotable import AudioTableFile, AudioTableSample
|
||||
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_xml : Tuple[str, Element] = 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_xml 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
|
||||
else:
|
||||
self.file_name = extraction_xml[0]
|
||||
self.name = extraction_xml[1].attrib["Name"]
|
||||
|
||||
self.extraction_envelopes_info = []
|
||||
self.extraction_instruments_info = {}
|
||||
self.extraction_drums_info = []
|
||||
self.extraction_effects_info = []
|
||||
|
||||
for item in extraction_xml[1]:
|
||||
if item.tag == "Envelopes":
|
||||
for env in item:
|
||||
assert env.tag == "Envelope"
|
||||
self.extraction_envelopes_info.append(env.attrib["Name"])
|
||||
elif item.tag == "Instruments":
|
||||
for instr in item:
|
||||
assert instr.tag == "Instrument"
|
||||
self.extraction_instruments_info[int(instr.attrib["ProgramNumber"])] = instr.attrib["Name"]
|
||||
elif item.tag == "Drums":
|
||||
for drum in item:
|
||||
self.extraction_drums_info.append(drum.attrib["Name"])
|
||||
elif item.tag == "Effects":
|
||||
for effect in item:
|
||||
self.extraction_effects_info.append(effect.attrib["Name"])
|
||||
else:
|
||||
assert False, item.tag
|
||||
|
||||
# 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:X}({unref_start_type.__name__}) " + \
|
||||
f"to 0x{unref_end_offset:X}({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:
|
||||
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:
|
||||
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:
|
||||
return self.extraction_drums_info[index]
|
||||
else:
|
||||
return f"DRUM_{index}"
|
||||
|
||||
def effect_name(self, index):
|
||||
if self.extraction_effects_info is not None:
|
||||
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:
|
||||
xml.write_start_tag("Envelopes")
|
||||
|
||||
for i in range(len(self.envelopes)):
|
||||
xml.write_element("Envelope", {
|
||||
"Name" : self.envelope_name(i)
|
||||
})
|
||||
|
||||
xml.write_end_tag()
|
||||
|
||||
if len(self.instruments) != 0:
|
||||
xml.write_start_tag("Instruments")
|
||||
|
||||
# Write in struct order
|
||||
for instr in sorted(self.instruments, key=lambda instr : instr.struct_index):
|
||||
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):
|
||||
xml.write_start_tag("Drums")
|
||||
|
||||
for i,drum_grp in enumerate(self.drum_groups):
|
||||
if isinstance(drum_grp, DrumGroup):
|
||||
xml.write_element("Drum", {
|
||||
"Name" : self.drum_grp_name(i)
|
||||
})
|
||||
|
||||
xml.write_end_tag()
|
||||
|
||||
if len(self.sfx) != 0:
|
||||
xml.write_start_tag("Effects")
|
||||
|
||||
for i,sfx in enumerate(self.sfx):
|
||||
xml.write_element("Effect", {
|
||||
"Name" : self.effect_name(i)
|
||||
})
|
||||
|
||||
xml.write_end_tag()
|
||||
|
||||
xml.write_end_tag()
|
||||
|
||||
with open(path, "w") as outfile:
|
||||
outfile.write(str(xml))
|
||||
@@ -0,0 +1,406 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,690 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
#
|
||||
#
|
||||
|
||||
import 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}")
|
||||
return f"Sample{index}{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__"))
|
||||
@@ -0,0 +1,119 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Implements envelopes and envelope point structures
|
||||
#
|
||||
|
||||
import collections
|
||||
|
||||
from .util import XMLWriter
|
||||
|
||||
class EnvDelay(int):
|
||||
def __str__(self):
|
||||
return {
|
||||
0 : "ADSR_DISABLE",
|
||||
-1 : "ADSR_HANG",
|
||||
-2 : "ADSR_GOTO",
|
||||
-3 : "ADSR_RESTART",
|
||||
}.get(self, super().__str__())
|
||||
|
||||
class Envelope:
|
||||
"""
|
||||
Array of envelope points
|
||||
"""
|
||||
|
||||
class EnvelopePoint:
|
||||
"""
|
||||
typedef struct {
|
||||
/* 0x0 */ s16 delay;
|
||||
/* 0x2 */ s16 arg;
|
||||
} EnvelopePoint; // size = 0x4
|
||||
"""
|
||||
|
||||
def __init__(self, delay, arg):
|
||||
self.delay = EnvDelay(delay)
|
||||
self.arg = arg
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
def __str__(self):
|
||||
return f"{{ {self.delay}, {self.arg} }}"
|
||||
|
||||
def is_disable(self):
|
||||
return self.delay == 0 and self.arg == 0
|
||||
|
||||
def is_hang(self):
|
||||
return self.delay == -1 and self.arg == 0
|
||||
|
||||
def to_xml(self, xml : XMLWriter):
|
||||
if self.delay == 0: # Disable
|
||||
assert self.arg == 0
|
||||
xml.write_element("Disable")
|
||||
elif self.delay == -1: # Hang
|
||||
assert self.arg == 0
|
||||
xml.write_element("Hang")
|
||||
elif self.delay == -2: # Goto
|
||||
xml.write_element("Goto",
|
||||
{ "Arg" : self.arg }
|
||||
)
|
||||
elif self.delay == -3: # Restart
|
||||
assert self.arg == 0
|
||||
xml.write_element("Restart")
|
||||
else:
|
||||
assert self.delay >= 0
|
||||
xml.write_element("Point",
|
||||
{
|
||||
"Delay" : self.delay,
|
||||
"Arg" : self.arg,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, points, is_zero=False):
|
||||
self.name = None # Assigned when bank is finalized
|
||||
|
||||
self.is_zero = is_zero
|
||||
self.release_rates = []
|
||||
self._release_rate = None # cached
|
||||
|
||||
assert len(points) != 0
|
||||
assert type(points[0]) == Envelope.EnvelopePoint
|
||||
self.points = points
|
||||
|
||||
if not self.is_zero:
|
||||
while self.points[-1].is_disable():
|
||||
self.points.pop()
|
||||
|
||||
assert self.points[-1].is_hang()
|
||||
|
||||
def __str__(self):
|
||||
out = "{\n"
|
||||
out += " " + ", ".join([str(point) for point in self.points]) + "\n"
|
||||
out += "}\n"
|
||||
return out
|
||||
|
||||
def release_rate(self):
|
||||
if self._release_rate is not None:
|
||||
return self._release_rate
|
||||
|
||||
rates = collections.Counter(self.release_rates).most_common()
|
||||
assert len(rates) in [0, 1], rates # TODO handle ties?
|
||||
|
||||
self._release_rate = 0 if len(rates) == 0 else rates[0][0]
|
||||
return self._release_rate
|
||||
|
||||
def to_xml(self, xml : XMLWriter, name : str):
|
||||
if self.is_zero:
|
||||
return xml.write_element("Envelope")
|
||||
|
||||
xml.write_start_tag("Envelope",
|
||||
{
|
||||
"Name" : name,
|
||||
"Release" : self.release_rate(),
|
||||
}
|
||||
)
|
||||
|
||||
for point in self.points[:-1]: # exclude final hang command, will be added by the soundfont compiler on build
|
||||
point.to_xml(xml)
|
||||
|
||||
xml.write_end_tag()
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Estimate (samplerate, basenote) from tuning
|
||||
#
|
||||
# tuning = samplerate * 2 ** basenote
|
||||
#
|
||||
|
||||
from typing import List, Tuple
|
||||
|
||||
from .util import f32, u32_to_f32, f32_to_u32
|
||||
|
||||
# Mirrors gPitchFrequencies in audio driver source.
|
||||
# Indexed by z64 note numbers, g_pitch_frequencies[C4] = 1.0 (0x3F800000)
|
||||
# Converted to their IEEE-754 binary representation to avoid any string -> float parser trouble as we need exact values.
|
||||
g_pitch_frequencies = (
|
||||
0x3DD744F6, 0x3DE411C3, 0x3DF1A198, 0x3E000000, 0x3E079C84, 0x3E0FACE6, 0x3E1837F8, 0x3E21450F,
|
||||
0x3E2ADC0A, 0x3E350508, 0x3E3FC86D, 0x3E4B2FEC, 0x3E5744F6, 0x3E641206, 0x3E71A1DC, 0x3E800000,
|
||||
0x3E879C84, 0x3E8FACE6, 0x3E9837F8, 0x3EA1450F, 0x3EAADC0A, 0x3EB504E6, 0x3EBFC88E, 0x3ECB2FEC,
|
||||
0x3ED744F6, 0x3EE411E4, 0x3EF1A1BA, 0x3F000000, 0x3F079C84, 0x3F0FACD6, 0x3F1837F8, 0x3F214520,
|
||||
0x3F2ADC0A, 0x3F3504F7, 0x3F3FC88E, 0x3F4B2FFD, 0x3F574507, 0x3F6411F5, 0x3F71A1CB, 0x3F800000,
|
||||
0x3F879C7C, 0x3F8FACD6, 0x3F9837EF, 0x3FA14517, 0x3FAADC0A, 0x3FB504F7, 0x3FBFC886, 0x3FCB2FF5,
|
||||
0x3FD744FE, 0x3FE411F5, 0x3FF1A1C2, 0x40000000, 0x40079C7C, 0x400FACD6, 0x401837EF, 0x40214517,
|
||||
0x402ADC0A, 0x403504F7, 0x403FC88A, 0x404B2FF9, 0x405744FE, 0x406411F5, 0x4071A1C2, 0x40800000,
|
||||
0x40879C7E, 0x408FACD8, 0x409837F1, 0x40A14519, 0x40AADC0A, 0x40B504F5, 0x40BFC888, 0x40CB2FF9,
|
||||
0x40D74500, 0x40E411F5, 0x40F1A1C2, 0x41000000, 0x41079C7D, 0x410FACD7, 0x411837F1, 0x41214519,
|
||||
0x412ADC0A, 0x413504F5, 0x413FC889, 0x414B2FF8, 0x41574500, 0x416411F4, 0x4171A1C3, 0x41800000,
|
||||
0x41879C7D, 0x418FACD7, 0x419837F1, 0x41A14519, 0x41AADC0A, 0x41B504F5, 0x41BFC889, 0x41CB2FF8,
|
||||
0x41D74500, 0x41E411F4, 0x41F1A1C3, 0x42000000, 0x42079C7D, 0x420FACD7, 0x421837F1, 0x42214519,
|
||||
0x422ADC0A, 0x423504F5, 0x423FC889, 0x424B2FF8, 0x42574500, 0x426411F4, 0x4271A1C3, 0x42800000,
|
||||
0x42879C7D, 0x428FACD7, 0x429837F1, 0x42A14519, 0x42AADC0A, 0x3D6411C3, 0x3D71A198, 0x3D800000,
|
||||
0x3D879C41, 0x3D8FACE6, 0x3D9837B5, 0x3DA1450F, 0x3DAADBC6, 0x3DB504C5, 0x3DBFC86D, 0x3DCB302F,
|
||||
)
|
||||
|
||||
# Names for pitch values indexed by z64 note numbers, pitch_names[39] = C4
|
||||
pitch_names = (
|
||||
"A0", "BF0", "B0",
|
||||
"C1", "DF1", "D1", "EF1", "E1", "F1", "GF1", "G1", "AF1", "A1", "BF1", "B1",
|
||||
"C2", "DF2", "D2", "EF2", "E2", "F2", "GF2", "G2", "AF2", "A2", "BF2", "B2",
|
||||
"C3", "DF3", "D3", "EF3", "E3", "F3", "GF3", "G3", "AF3", "A3", "BF3", "B3",
|
||||
"C4", "DF4", "D4", "EF4", "E4", "F4", "GF4", "G4", "AF4", "A4", "BF4", "B4",
|
||||
"C5", "DF5", "D5", "EF5", "E5", "F5", "GF5", "G5", "AF5", "A5", "BF5", "B5",
|
||||
"C6", "DF6", "D6", "EF6", "E6", "F6", "GF6", "G6", "AF6", "A6", "BF6", "B6",
|
||||
"C7", "DF7", "D7", "EF7", "E7", "F7", "GF7", "G7", "AF7", "A7", "BF7", "B7",
|
||||
"C8", "DF8", "D8", "EF8", "E8", "F8", "GF8", "G8", "AF8", "A8", "BF8", "B8",
|
||||
"C9", "DF9", "D9", "EF9", "E9", "F9", "GF9", "G9", "AF9", "A9", "BF9", "B9",
|
||||
"C10", "DF10", "D10", "EF10", "E10", "F10",
|
||||
"BFNEG1", "BNEG1",
|
||||
"C0", "DF0", "D0", "EF0", "E0", "F0", "GF0", "G0", "AF0",
|
||||
)
|
||||
|
||||
# Floats that are encountered in extraction but cannot be resolved to a match.
|
||||
BAD_FLOATS = [0x3E7319E3]
|
||||
|
||||
def note_z64_to_midi(note : int) -> int:
|
||||
"""
|
||||
Convert a z64 note number to MIDI note number.
|
||||
|
||||
Middle C is 39 in z64, while it is 60 in MIDI.
|
||||
We want MIDI note numbers to store in the extracted sample files (aiff or wav)
|
||||
"""
|
||||
return (21 + note) % 128
|
||||
|
||||
def recalc_tuning(rate : int, note : str) -> float:
|
||||
return f32(f32(rate / 32000.0) * u32_to_f32(g_pitch_frequencies[pitch_names.index(note)]))
|
||||
|
||||
def rate_from_tuning(tuning : float) -> Tuple[Tuple[str,int]]:
|
||||
"""
|
||||
Decompose a tuning value into a pair (samplerate, basenote) that round-trips when ran through `recalc_tuning`
|
||||
"""
|
||||
matches : List[Tuple[str,int]] = []
|
||||
diffs : List[Tuple[int, Tuple[str,int]]] = []
|
||||
|
||||
tuning_bits : int = f32_to_u32(tuning)
|
||||
|
||||
def test_value(note_val : int, nominal_rate : int, freq : float):
|
||||
if nominal_rate > 48000:
|
||||
# reject samplerate if too high
|
||||
return
|
||||
|
||||
# recalc tuning and compare to original
|
||||
|
||||
tuning2 : float = f32(f32(nominal_rate / 32000.0) * freq)
|
||||
|
||||
diff : int = abs(f32_to_u32(tuning2) - tuning_bits)
|
||||
|
||||
if diff == 0:
|
||||
matches.append((pitch_names[note_val], nominal_rate))
|
||||
else:
|
||||
diffs.append((diff, (pitch_names[note_val], nominal_rate)))
|
||||
|
||||
# search gPitchFrequencies LUT one by one. We don't exit as soon as a match is found as in general this procedure
|
||||
# only recovers the correct (rate,note) pair up to multiples of 2, to get the final value we want to select the
|
||||
# "best" of these pairs by an essentially arbitrary ranking (cf `rank_rates_notes`)
|
||||
for note_val,freq_bits in enumerate(g_pitch_frequencies):
|
||||
freq : float = u32_to_f32(freq_bits)
|
||||
|
||||
# compute the "nominal" samplerate for a given basenote by R = 32000 * (t / f)
|
||||
nominal_rate : int = int(f32(tuning / freq) * 32000.0)
|
||||
|
||||
# test nominal value and +/-1
|
||||
test_value(note_val, nominal_rate, freq)
|
||||
test_value(note_val, nominal_rate + 1, freq)
|
||||
test_value(note_val, nominal_rate - 1, freq)
|
||||
|
||||
if len(matches) != 0:
|
||||
return tuple(matches)
|
||||
|
||||
# no matches found... check if we expected this, otherwise flag it for special handling
|
||||
assert tuning_bits in BAD_FLOATS , f"0x{tuning_bits:08X}"
|
||||
|
||||
# just take the closest match and hack it in the soundfont compiler
|
||||
hack_rate = sorted(diffs, key=lambda e : e[0])[0]
|
||||
return (hack_rate[1],)
|
||||
|
||||
def rank_rates_notes(layouts):
|
||||
|
||||
def rank_rate_note(rate, notes):
|
||||
"""
|
||||
Arbitrarily rank the input samplerate + note numbers, based on what is most likely.
|
||||
"""
|
||||
rank = 0
|
||||
|
||||
if 'C4' in notes and rate > 10000:
|
||||
rank += 10000
|
||||
elif 'C2' in notes and rate > 10000:
|
||||
rank += 9500
|
||||
elif 'D3' in notes and rate > 10000:
|
||||
rank += 8500
|
||||
elif 'D4' in notes and rate > 10000:
|
||||
rank += 8000
|
||||
elif 'G3' in notes:
|
||||
rank += 2000
|
||||
elif 'F3' in notes:
|
||||
rank += 25
|
||||
elif 'C0' in notes:
|
||||
rank += 50
|
||||
elif 'BF2' in notes:
|
||||
rank += 30
|
||||
elif 'B3' in notes:
|
||||
rank += 25
|
||||
elif 'BF1' in notes:
|
||||
rank += 25
|
||||
elif 'E2' in notes:
|
||||
rank += 20
|
||||
elif 'F6' in notes:
|
||||
rank += 15
|
||||
elif 'GF2' in notes:
|
||||
rank += 10
|
||||
|
||||
rank += {
|
||||
32000 : 200,
|
||||
16000 : 100,
|
||||
24000 : 50,
|
||||
22050 : 30,
|
||||
20000 : 28,
|
||||
44100 : 25,
|
||||
12000 : 15,
|
||||
8000 : 10,
|
||||
15950 : 5,
|
||||
20050 : 5,
|
||||
31800 : 5,
|
||||
}.get(rate, 0)
|
||||
|
||||
return rank
|
||||
|
||||
# Input should not be empty
|
||||
assert len(layouts) != 0
|
||||
|
||||
if len(layouts) == 1:
|
||||
# No ranking needed, there is only one possible option
|
||||
return layouts[0]
|
||||
|
||||
# Ranking is needed, rank each layout
|
||||
ranked = list(sorted(layouts, key=lambda L : rank_rate_note(*L), reverse=True))
|
||||
|
||||
# Ensure the ranking produced a unique best option
|
||||
assert rank_rate_note(*ranked[0]) != rank_rate_note(*ranked[1]) , ranked
|
||||
|
||||
# Output best
|
||||
return ranked[0]
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Given either a (rate,note) or a tuning, compute all matching rates/notes.")
|
||||
parser.add_argument("-t", dest="tuning", required=False, default=None, type=float, help="Tuning value (float)")
|
||||
parser.add_argument("-r", dest="rate", required=False, default=None, type=int, help="Sample rate (integer)")
|
||||
parser.add_argument("-n", dest="note", required=False, default=None, type=str, help="Base note (note name)")
|
||||
parser.add_argument("--show-result", required=False, default=False, action="store_true", help="Show recalculated tuning value")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.tuning is not None:
|
||||
# Take input tuning
|
||||
tuning = args.tuning
|
||||
elif args.rate is not None and args.note is not None:
|
||||
# Calculate target tuning from input rate and note
|
||||
tuning : float = recalc_tuning(args.rate, args.note)
|
||||
else:
|
||||
# Insufficient arguments
|
||||
parser.print_help()
|
||||
raise SystemExit("Must specify either -t or both -r and -n.")
|
||||
|
||||
notes_rates : Tuple[Tuple[str,int]] = rate_from_tuning(tuning)
|
||||
|
||||
for note,rate in notes_rates:
|
||||
if args.show_result:
|
||||
print(rate, note, "->", recalc_tuning(rate, note))
|
||||
else:
|
||||
print(rate, note)
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Misc utilities
|
||||
#
|
||||
|
||||
import struct, subprocess, sys
|
||||
|
||||
def debugm(msg):
|
||||
"""
|
||||
Debug message on stderr
|
||||
"""
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
def error(msg):
|
||||
"""
|
||||
Debug message + exit
|
||||
"""
|
||||
debugm(msg)
|
||||
sys.exit(1)
|
||||
|
||||
def incbin(rom, offset, size):
|
||||
return rom[offset:offset+size]
|
||||
|
||||
def f32(f):
|
||||
"""
|
||||
Reduces precision of f to a 32-bit float for correct intermediate calculations
|
||||
"""
|
||||
return struct.unpack("f", struct.pack("f", f))[0]
|
||||
|
||||
def u32_to_f32(u):
|
||||
"""
|
||||
Convert IEEE-754 binary rep to float
|
||||
"""
|
||||
return struct.unpack(">f", struct.pack(">I", u))[0]
|
||||
|
||||
def f32_to_u32(f):
|
||||
"""
|
||||
Convert float to IEEE-754 binary rep
|
||||
"""
|
||||
return struct.unpack(">I", struct.pack(">f", f))[0]
|
||||
|
||||
def align(x, n):
|
||||
"""
|
||||
Align to next n (power of 2)
|
||||
"""
|
||||
return (x + (n - 1)) & ~(n - 1)
|
||||
|
||||
def merge_ranges(intervals):
|
||||
if len(intervals) == 0:
|
||||
return []
|
||||
|
||||
intervals = sorted(intervals, key=lambda x: x[0][0])
|
||||
|
||||
stack = [intervals[0]]
|
||||
for i in range(1, len(intervals)):
|
||||
last_element = stack[len(stack) - 1]
|
||||
if last_element[1][0] >= intervals[i][0][0]:
|
||||
last_element[1] = max(intervals[i][1], last_element[1], key=lambda x: x[0])
|
||||
stack.pop(len(stack) - 1)
|
||||
stack.append(last_element)
|
||||
else:
|
||||
stack.append(intervals[i])
|
||||
return stack
|
||||
|
||||
def merge_like_ranges(intervals):
|
||||
if len(intervals) == 0:
|
||||
return []
|
||||
|
||||
intervals = sorted(intervals, key=lambda x: x[0][0])
|
||||
|
||||
stack = [intervals[0]]
|
||||
for i in range(1, len(intervals)):
|
||||
last_element = stack[len(stack) - 1]
|
||||
if last_element[1][0] >= intervals[i][0][0] and last_element[1][1] == intervals[i][1][1]:
|
||||
last_element[1] = max(intervals[i][1], last_element[1], key=lambda x: x[0])
|
||||
stack.pop(len(stack) - 1)
|
||||
stack.append(last_element)
|
||||
else:
|
||||
stack.append(intervals[i])
|
||||
return stack
|
||||
|
||||
def list_is_in_order(l):
|
||||
return all(l[i] <= l[i + 1] for i in range(len(l) - 1))
|
||||
|
||||
def program_call(cmd):
|
||||
subprocess.check_call(cmd, shell=True)
|
||||
|
||||
def program_get(cmd):
|
||||
return subprocess.check_output(cmd, shell=True).decode("ascii")
|
||||
|
||||
class XMLWriter:
|
||||
"""
|
||||
Simple XML builder for writing with desired formatting characteristics (no tabs, 4 space indent)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.contents = ""
|
||||
self.tag_stack = []
|
||||
|
||||
def __str__(self):
|
||||
return self.contents
|
||||
|
||||
def write_line(self, name, open, close, attributes):
|
||||
indent = " " * len(self.tag_stack)
|
||||
if attributes is None:
|
||||
self.contents += f"{indent}{open}{name}{close}\n"
|
||||
else:
|
||||
attributes_str = " ".join(f"{k}=\"{v}\"" for k,v in attributes.items())
|
||||
self.contents += f"{indent}{open}{name} {attributes_str}{close}\n"
|
||||
|
||||
def write_comment(self, comment):
|
||||
self.write_line(comment, "<!-- ", " -->", None)
|
||||
|
||||
def write_start_tag(self, name, attributes=None):
|
||||
self.write_line(name, "<", ">", attributes)
|
||||
self.tag_stack.append(name)
|
||||
|
||||
def write_end_tag(self):
|
||||
self.write_line(self.tag_stack.pop(), "</", ">", None)
|
||||
|
||||
def write_element(self, name, attributes=None):
|
||||
self.write_line(name, "<", "/>", attributes)
|
||||
|
||||
def write_raw(self, contents):
|
||||
self.write_line(contents, "", "", None)
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: © 2024 ZeldaRET
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
#
|
||||
# Configures and runs baserom audio extraction
|
||||
#
|
||||
|
||||
import argparse
|
||||
|
||||
import version_config
|
||||
|
||||
from audio.extraction.audio_extract import extract_audio_for_version, GameVersionInfo, MMLVersion
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description="baserom audio asset extractor")
|
||||
parser.add_argument("-o", "--extracted-dir", required=True, help="path to extracted directory")
|
||||
parser.add_argument("-v", "--version", required=True, help="version name")
|
||||
parser.add_argument("--read-xml", required=False, action="store_true", help="Read extraction xml files")
|
||||
parser.add_argument("--write-xml", required=False, action="store_true", help="Write extraction xml files")
|
||||
args = parser.parse_args()
|
||||
|
||||
version = args.version
|
||||
|
||||
config = version_config.load_version_config(version)
|
||||
|
||||
code_vram = config.dmadata_segments["code"].vram
|
||||
soundfont_table_code_offset = config.variables["gSoundFontTable"] - code_vram
|
||||
seq_font_table_code_offset = config.variables["gSequenceFontTable"] - code_vram
|
||||
seq_table_code_offset = config.variables["gSequenceTable"] - code_vram
|
||||
sample_bank_table_code_offset = config.variables["gSampleBankTable"] - code_vram
|
||||
|
||||
# List any sequences that are "handwritten", we don't extract these by
|
||||
# default as we want these checked in for documentation.
|
||||
handwritten_sequences = (0, 1, 2, 109)
|
||||
|
||||
# Sequence enum names for extraction purposes.
|
||||
seq_enum_names = (
|
||||
"NA_BGM_GENERAL_SFX",
|
||||
"NA_BGM_NATURE_AMBIENCE",
|
||||
"NA_BGM_FIELD_LOGIC",
|
||||
"NA_BGM_FIELD_INIT",
|
||||
"NA_BGM_FIELD_DEFAULT_1",
|
||||
"NA_BGM_FIELD_DEFAULT_2",
|
||||
"NA_BGM_FIELD_DEFAULT_3",
|
||||
"NA_BGM_FIELD_DEFAULT_4",
|
||||
"NA_BGM_FIELD_DEFAULT_5",
|
||||
"NA_BGM_FIELD_DEFAULT_6",
|
||||
"NA_BGM_FIELD_DEFAULT_7",
|
||||
"NA_BGM_FIELD_DEFAULT_8",
|
||||
"NA_BGM_FIELD_DEFAULT_9",
|
||||
"NA_BGM_FIELD_DEFAULT_A",
|
||||
"NA_BGM_FIELD_DEFAULT_B",
|
||||
"NA_BGM_FIELD_ENEMY_INIT",
|
||||
"NA_BGM_FIELD_ENEMY_1",
|
||||
"NA_BGM_FIELD_ENEMY_2",
|
||||
"NA_BGM_FIELD_ENEMY_3",
|
||||
"NA_BGM_FIELD_ENEMY_4",
|
||||
"NA_BGM_FIELD_STILL_1",
|
||||
"NA_BGM_FIELD_STILL_2",
|
||||
"NA_BGM_FIELD_STILL_3",
|
||||
"NA_BGM_FIELD_STILL_4",
|
||||
"NA_BGM_DUNGEON",
|
||||
"NA_BGM_KAKARIKO_ADULT",
|
||||
"NA_BGM_ENEMY",
|
||||
"NA_BGM_BOSS",
|
||||
"NA_BGM_INSIDE_DEKU_TREE",
|
||||
"NA_BGM_MARKET",
|
||||
"NA_BGM_TITLE",
|
||||
"NA_BGM_LINK_HOUSE",
|
||||
"NA_BGM_GAME_OVER",
|
||||
"NA_BGM_BOSS_CLEAR",
|
||||
"NA_BGM_ITEM_GET",
|
||||
"NA_BGM_OPENING_GANON",
|
||||
"NA_BGM_HEART_GET",
|
||||
"NA_BGM_OCA_LIGHT",
|
||||
"NA_BGM_JABU_JABU",
|
||||
"NA_BGM_KAKARIKO_KID",
|
||||
"NA_BGM_GREAT_FAIRY",
|
||||
"NA_BGM_ZELDA_THEME",
|
||||
"NA_BGM_FIRE_TEMPLE",
|
||||
"NA_BGM_OPEN_TRE_BOX",
|
||||
"NA_BGM_FOREST_TEMPLE",
|
||||
"NA_BGM_COURTYARD",
|
||||
"NA_BGM_GANON_TOWER",
|
||||
"NA_BGM_LONLON",
|
||||
"NA_BGM_GORON_CITY",
|
||||
"NA_BGM_FIELD_MORNING",
|
||||
"NA_BGM_SPIRITUAL_STONE",
|
||||
"NA_BGM_OCA_BOLERO",
|
||||
"NA_BGM_OCA_MINUET",
|
||||
"NA_BGM_OCA_SERENADE",
|
||||
"NA_BGM_OCA_REQUIEM",
|
||||
"NA_BGM_OCA_NOCTURNE",
|
||||
"NA_BGM_MINI_BOSS",
|
||||
"NA_BGM_SMALL_ITEM_GET",
|
||||
"NA_BGM_TEMPLE_OF_TIME",
|
||||
"NA_BGM_EVENT_CLEAR",
|
||||
"NA_BGM_KOKIRI",
|
||||
"NA_BGM_OCA_FAIRY_GET",
|
||||
"NA_BGM_SARIA_THEME",
|
||||
"NA_BGM_SPIRIT_TEMPLE",
|
||||
"NA_BGM_HORSE",
|
||||
"NA_BGM_HORSE_GOAL",
|
||||
"NA_BGM_INGO",
|
||||
"NA_BGM_MEDALLION_GET",
|
||||
"NA_BGM_OCA_SARIA",
|
||||
"NA_BGM_OCA_EPONA",
|
||||
"NA_BGM_OCA_ZELDA",
|
||||
"NA_BGM_OCA_SUNS",
|
||||
"NA_BGM_OCA_TIME",
|
||||
"NA_BGM_OCA_STORM",
|
||||
"NA_BGM_NAVI_OPENING",
|
||||
"NA_BGM_DEKU_TREE_CS",
|
||||
"NA_BGM_WINDMILL",
|
||||
"NA_BGM_HYRULE_CS",
|
||||
"NA_BGM_MINI_GAME",
|
||||
"NA_BGM_SHEIK",
|
||||
"NA_BGM_ZORA_DOMAIN",
|
||||
"NA_BGM_APPEAR",
|
||||
"NA_BGM_ADULT_LINK",
|
||||
"NA_BGM_MASTER_SWORD",
|
||||
"NA_BGM_INTRO_GANON",
|
||||
"NA_BGM_SHOP",
|
||||
"NA_BGM_CHAMBER_OF_SAGES",
|
||||
"NA_BGM_FILE_SELECT",
|
||||
"NA_BGM_ICE_CAVERN",
|
||||
"NA_BGM_DOOR_OF_TIME",
|
||||
"NA_BGM_OWL",
|
||||
"NA_BGM_SHADOW_TEMPLE",
|
||||
"NA_BGM_WATER_TEMPLE",
|
||||
"NA_BGM_BRIDGE_TO_GANONS",
|
||||
"NA_BGM_OCARINA_OF_TIME",
|
||||
"NA_BGM_GERUDO_VALLEY",
|
||||
"NA_BGM_POTION_SHOP",
|
||||
"NA_BGM_KOTAKE_KOUME",
|
||||
"NA_BGM_ESCAPE",
|
||||
"NA_BGM_UNDERGROUND",
|
||||
"NA_BGM_GANONDORF_BOSS",
|
||||
"NA_BGM_GANON_BOSS",
|
||||
"NA_BGM_END_DEMO",
|
||||
"NA_BGM_STAFF_1",
|
||||
"NA_BGM_STAFF_2",
|
||||
"NA_BGM_STAFF_3",
|
||||
"NA_BGM_STAFF_4",
|
||||
"NA_BGM_FIRE_BOSS",
|
||||
"NA_BGM_TIMED_MINI_GAME",
|
||||
"NA_BGM_CUTSCENE_EFFECTS",
|
||||
)
|
||||
|
||||
# Some bugged soundfonts report the wrong samplebank. Map them to the correct samplebank for proper sample discovery.
|
||||
fake_banks = { 37 : 2 }
|
||||
|
||||
# Some audiotable banks have a buffer clearing bug. Indicate which banks suffer from this.
|
||||
audiotable_buffer_bugs = (0,)
|
||||
|
||||
version_info = GameVersionInfo(MMLVersion.OOT,
|
||||
soundfont_table_code_offset,
|
||||
seq_font_table_code_offset,
|
||||
seq_table_code_offset,
|
||||
sample_bank_table_code_offset,
|
||||
seq_enum_names,
|
||||
handwritten_sequences,
|
||||
fake_banks,
|
||||
audiotable_buffer_bugs)
|
||||
|
||||
extract_audio_for_version(version_info, args.extracted_dir, args.read_xml, args.write_xml)
|
||||
Reference in New Issue
Block a user