From 29acf96db2fe30f0d194cc4298a3ceef367bf0e5 Mon Sep 17 00:00:00 2001 From: Tharo <17233964+Thar0@users.noreply.github.com> Date: Thu, 8 Aug 2024 05:11:39 +0100 Subject: [PATCH] [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) --- Makefile | 8 + assets/xml/audio/samplebanks/SampleBank_0.xml | 433 ++++++++ assets/xml/audio/samplebanks/SampleBank_2.xml | 4 + assets/xml/audio/samplebanks/SampleBank_3.xml | 8 + assets/xml/audio/samplebanks/SampleBank_4.xml | 8 + assets/xml/audio/samplebanks/SampleBank_5.xml | 9 + assets/xml/audio/samplebanks/SampleBank_6.xml | 10 + assets/xml/audio/soundfonts/Soundfont_0.xml | 250 +++++ assets/xml/audio/soundfonts/Soundfont_1.xml | 102 ++ assets/xml/audio/soundfonts/Soundfont_10.xml | 23 + assets/xml/audio/soundfonts/Soundfont_11.xml | 15 + assets/xml/audio/soundfonts/Soundfont_12.xml | 13 + assets/xml/audio/soundfonts/Soundfont_13.xml | 24 + assets/xml/audio/soundfonts/Soundfont_14.xml | 15 + assets/xml/audio/soundfonts/Soundfont_15.xml | 26 + assets/xml/audio/soundfonts/Soundfont_16.xml | 23 + assets/xml/audio/soundfonts/Soundfont_17.xml | 18 + assets/xml/audio/soundfonts/Soundfont_18.xml | 29 + assets/xml/audio/soundfonts/Soundfont_19.xml | 14 + assets/xml/audio/soundfonts/Soundfont_2.xml | 28 + assets/xml/audio/soundfonts/Soundfont_20.xml | 23 + assets/xml/audio/soundfonts/Soundfont_21.xml | 20 + assets/xml/audio/soundfonts/Soundfont_22.xml | 25 + assets/xml/audio/soundfonts/Soundfont_23.xml | 14 + assets/xml/audio/soundfonts/Soundfont_24.xml | 23 + assets/xml/audio/soundfonts/Soundfont_25.xml | 19 + assets/xml/audio/soundfonts/Soundfont_26.xml | 10 + assets/xml/audio/soundfonts/Soundfont_27.xml | 21 + assets/xml/audio/soundfonts/Soundfont_28.xml | 15 + assets/xml/audio/soundfonts/Soundfont_29.xml | 22 + assets/xml/audio/soundfonts/Soundfont_3.xml | 37 + assets/xml/audio/soundfonts/Soundfont_30.xml | 13 + assets/xml/audio/soundfonts/Soundfont_31.xml | 19 + assets/xml/audio/soundfonts/Soundfont_32.xml | 32 + assets/xml/audio/soundfonts/Soundfont_33.xml | 37 + assets/xml/audio/soundfonts/Soundfont_34.xml | 42 + assets/xml/audio/soundfonts/Soundfont_35.xml | 26 + assets/xml/audio/soundfonts/Soundfont_36.xml | 17 + assets/xml/audio/soundfonts/Soundfont_37.xml | 11 + assets/xml/audio/soundfonts/Soundfont_4.xml | 11 + assets/xml/audio/soundfonts/Soundfont_5.xml | 22 + assets/xml/audio/soundfonts/Soundfont_6.xml | 16 + assets/xml/audio/soundfonts/Soundfont_7.xml | 17 + assets/xml/audio/soundfonts/Soundfont_8.xml | 26 + assets/xml/audio/soundfonts/Soundfont_9.xml | 34 + baseroms/gc-eu-mq-dbg/config.yml | 4 + baseroms/gc-eu-mq/config.yml | 4 + baseroms/gc-eu/config.yml | 4 + baseroms/gc-us/config.yml | 4 + tools/audio/extraction/audio_extract.py | 269 +++++ tools/audio/extraction/audio_tables.py | 107 ++ tools/audio/extraction/audiobank_file.py | 956 ++++++++++++++++++ tools/audio/extraction/audiobank_structs.py | 406 ++++++++ tools/audio/extraction/audiotable.py | 690 +++++++++++++ tools/audio/extraction/envelope.py | 119 +++ tools/audio/extraction/tuning.py | 211 ++++ tools/audio/extraction/util.py | 126 +++ tools/audio_extraction.py | 166 +++ 58 files changed, 4678 insertions(+) create mode 100644 assets/xml/audio/samplebanks/SampleBank_0.xml create mode 100644 assets/xml/audio/samplebanks/SampleBank_2.xml create mode 100644 assets/xml/audio/samplebanks/SampleBank_3.xml create mode 100644 assets/xml/audio/samplebanks/SampleBank_4.xml create mode 100644 assets/xml/audio/samplebanks/SampleBank_5.xml create mode 100644 assets/xml/audio/samplebanks/SampleBank_6.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_0.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_1.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_10.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_11.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_12.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_13.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_14.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_15.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_16.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_17.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_18.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_19.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_2.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_20.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_21.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_22.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_23.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_24.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_25.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_26.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_27.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_28.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_29.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_3.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_30.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_31.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_32.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_33.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_34.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_35.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_36.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_37.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_4.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_5.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_6.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_7.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_8.xml create mode 100644 assets/xml/audio/soundfonts/Soundfont_9.xml create mode 100644 tools/audio/extraction/audio_extract.py create mode 100644 tools/audio/extraction/audio_tables.py create mode 100644 tools/audio/extraction/audiobank_file.py create mode 100644 tools/audio/extraction/audiobank_structs.py create mode 100644 tools/audio/extraction/audiotable.py create mode 100644 tools/audio/extraction/envelope.py create mode 100644 tools/audio/extraction/tuning.py create mode 100644 tools/audio/extraction/util.py create mode 100644 tools/audio_extraction.py diff --git a/Makefile b/Makefile index adf6907054..fa0ede70ab 100644 --- a/Makefile +++ b/Makefile @@ -183,6 +183,9 @@ PYTHON ?= $(VENV)/bin/python3 # preprocessor for this because it won't substitute inside string literals. SPEC_REPLACE_VARS := sed -e 's|$$(BUILD_DIR)|$(BUILD_DIR)|g' +# Audio tools +AUDIO_EXTRACT := $(PYTHON) tools/audio_extraction.py + CFLAGS += $(CPP_DEFINES) CPPFLAGS += $(CPP_DEFINES) @@ -427,6 +430,10 @@ venv: $(PYTHON) -m pip install -U pip $(PYTHON) -m pip install -U -r requirements.txt +# TODO this is a temporary rule for testing audio, to be removed +setup-audio: + $(AUDIO_EXTRACT) -o $(EXTRACTED_DIR) -v $(VERSION) --read-xml + setup: venv $(MAKE) -C tools $(PYTHON) tools/decompress_baserom.py $(VERSION) @@ -434,6 +441,7 @@ setup: venv $(PYTHON) tools/extract_incbins.py $(EXTRACTED_DIR)/baserom --oot-version $(VERSION) -o $(EXTRACTED_DIR)/incbin $(PYTHON) tools/msgdis.py $(VERSION) $(PYTHON) extract_assets.py -v $(VERSION) -j$(N_THREADS) + $(AUDIO_EXTRACT) -o $(EXTRACTED_DIR) -v $(VERSION) --read-xml disasm: $(RM) -r $(EXPECTED_DIR) diff --git a/assets/xml/audio/samplebanks/SampleBank_0.xml b/assets/xml/audio/samplebanks/SampleBank_0.xml new file mode 100644 index 0000000000..336d170287 --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_0.xml @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/samplebanks/SampleBank_2.xml b/assets/xml/audio/samplebanks/SampleBank_2.xml new file mode 100644 index 0000000000..21e76424e2 --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_2.xml @@ -0,0 +1,4 @@ + + + + diff --git a/assets/xml/audio/samplebanks/SampleBank_3.xml b/assets/xml/audio/samplebanks/SampleBank_3.xml new file mode 100644 index 0000000000..e6738f8b39 --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_3.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/xml/audio/samplebanks/SampleBank_4.xml b/assets/xml/audio/samplebanks/SampleBank_4.xml new file mode 100644 index 0000000000..8d68e285ff --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_4.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/xml/audio/samplebanks/SampleBank_5.xml b/assets/xml/audio/samplebanks/SampleBank_5.xml new file mode 100644 index 0000000000..6eb7356935 --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_5.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/xml/audio/samplebanks/SampleBank_6.xml b/assets/xml/audio/samplebanks/SampleBank_6.xml new file mode 100644 index 0000000000..e6971659b0 --- /dev/null +++ b/assets/xml/audio/samplebanks/SampleBank_6.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_0.xml b/assets/xml/audio/soundfonts/Soundfont_0.xml new file mode 100644 index 0000000000..02f75704f0 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_0.xml @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_1.xml b/assets/xml/audio/soundfonts/Soundfont_1.xml new file mode 100644 index 0000000000..6376f6d115 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_1.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_10.xml b/assets/xml/audio/soundfonts/Soundfont_10.xml new file mode 100644 index 0000000000..578101fd4d --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_10.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_11.xml b/assets/xml/audio/soundfonts/Soundfont_11.xml new file mode 100644 index 0000000000..72b4f77fd4 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_11.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_12.xml b/assets/xml/audio/soundfonts/Soundfont_12.xml new file mode 100644 index 0000000000..4800fecedf --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_12.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_13.xml b/assets/xml/audio/soundfonts/Soundfont_13.xml new file mode 100644 index 0000000000..b399905de8 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_13.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_14.xml b/assets/xml/audio/soundfonts/Soundfont_14.xml new file mode 100644 index 0000000000..2345061c9b --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_14.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_15.xml b/assets/xml/audio/soundfonts/Soundfont_15.xml new file mode 100644 index 0000000000..bf32ecd437 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_15.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_16.xml b/assets/xml/audio/soundfonts/Soundfont_16.xml new file mode 100644 index 0000000000..c1c6d1d2b0 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_16.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_17.xml b/assets/xml/audio/soundfonts/Soundfont_17.xml new file mode 100644 index 0000000000..a16e9752f8 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_17.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_18.xml b/assets/xml/audio/soundfonts/Soundfont_18.xml new file mode 100644 index 0000000000..76807539ad --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_18.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_19.xml b/assets/xml/audio/soundfonts/Soundfont_19.xml new file mode 100644 index 0000000000..9f2ba9286e --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_19.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_2.xml b/assets/xml/audio/soundfonts/Soundfont_2.xml new file mode 100644 index 0000000000..a297ce8505 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_2.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_20.xml b/assets/xml/audio/soundfonts/Soundfont_20.xml new file mode 100644 index 0000000000..5ce632d47c --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_20.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_21.xml b/assets/xml/audio/soundfonts/Soundfont_21.xml new file mode 100644 index 0000000000..a1bc07be8f --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_21.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_22.xml b/assets/xml/audio/soundfonts/Soundfont_22.xml new file mode 100644 index 0000000000..fe0099beb0 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_22.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_23.xml b/assets/xml/audio/soundfonts/Soundfont_23.xml new file mode 100644 index 0000000000..582c638a2d --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_23.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_24.xml b/assets/xml/audio/soundfonts/Soundfont_24.xml new file mode 100644 index 0000000000..aee3907f99 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_24.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_25.xml b/assets/xml/audio/soundfonts/Soundfont_25.xml new file mode 100644 index 0000000000..5ad7d22f22 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_25.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_26.xml b/assets/xml/audio/soundfonts/Soundfont_26.xml new file mode 100644 index 0000000000..ec20f4f56f --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_26.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_27.xml b/assets/xml/audio/soundfonts/Soundfont_27.xml new file mode 100644 index 0000000000..1bc1c9d886 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_27.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_28.xml b/assets/xml/audio/soundfonts/Soundfont_28.xml new file mode 100644 index 0000000000..c0292b0d7b --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_28.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_29.xml b/assets/xml/audio/soundfonts/Soundfont_29.xml new file mode 100644 index 0000000000..39e03a9a23 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_29.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_3.xml b/assets/xml/audio/soundfonts/Soundfont_3.xml new file mode 100644 index 0000000000..bb510aaef1 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_3.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_30.xml b/assets/xml/audio/soundfonts/Soundfont_30.xml new file mode 100644 index 0000000000..e9a1d093e7 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_30.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_31.xml b/assets/xml/audio/soundfonts/Soundfont_31.xml new file mode 100644 index 0000000000..d246fff00f --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_31.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_32.xml b/assets/xml/audio/soundfonts/Soundfont_32.xml new file mode 100644 index 0000000000..7508ee8b5e --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_32.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_33.xml b/assets/xml/audio/soundfonts/Soundfont_33.xml new file mode 100644 index 0000000000..93116ad1cd --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_33.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_34.xml b/assets/xml/audio/soundfonts/Soundfont_34.xml new file mode 100644 index 0000000000..20437b048a --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_34.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_35.xml b/assets/xml/audio/soundfonts/Soundfont_35.xml new file mode 100644 index 0000000000..fa9209a72f --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_35.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_36.xml b/assets/xml/audio/soundfonts/Soundfont_36.xml new file mode 100644 index 0000000000..fe49eda57b --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_36.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_37.xml b/assets/xml/audio/soundfonts/Soundfont_37.xml new file mode 100644 index 0000000000..67fa255b2c --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_37.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_4.xml b/assets/xml/audio/soundfonts/Soundfont_4.xml new file mode 100644 index 0000000000..4e023974e1 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_4.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_5.xml b/assets/xml/audio/soundfonts/Soundfont_5.xml new file mode 100644 index 0000000000..465b554eeb --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_5.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_6.xml b/assets/xml/audio/soundfonts/Soundfont_6.xml new file mode 100644 index 0000000000..f4c239da8e --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_6.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_7.xml b/assets/xml/audio/soundfonts/Soundfont_7.xml new file mode 100644 index 0000000000..aa61f7d253 --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_7.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_8.xml b/assets/xml/audio/soundfonts/Soundfont_8.xml new file mode 100644 index 0000000000..06cc14a1ca --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_8.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/xml/audio/soundfonts/Soundfont_9.xml b/assets/xml/audio/soundfonts/Soundfont_9.xml new file mode 100644 index 0000000000..97384ba3cd --- /dev/null +++ b/assets/xml/audio/soundfonts/Soundfont_9.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/baseroms/gc-eu-mq-dbg/config.yml b/baseroms/gc-eu-mq-dbg/config.yml index c5e301afad..115ee5c051 100644 --- a/baseroms/gc-eu-mq-dbg/config.yml +++ b/baseroms/gc-eu-mq-dbg/config.yml @@ -72,6 +72,10 @@ variables: sFraMessageEntryTable: 0x80151658 sStaffMessageEntryTable: 0x80153768 sShadowTex: 0x80A8E610 + gSoundFontTable: 0x801550D0 + gSequenceFontTable: 0x80155340 + gSequenceTable: 0x80155500 + gSampleBankTable: 0x80155BF0 assets: - name: code/fbdemo_circle xml_path: assets/xml/code/fbdemo_circle.xml diff --git a/baseroms/gc-eu-mq/config.yml b/baseroms/gc-eu-mq/config.yml index 34c5cbe9c0..59b1802e49 100644 --- a/baseroms/gc-eu-mq/config.yml +++ b/baseroms/gc-eu-mq/config.yml @@ -64,6 +64,10 @@ variables: sFraMessageEntryTable: 0x8010DB28 sStaffMessageEntryTable: 0x8010FC38 sShadowTex: 0x80A72FA0 + gSoundFontTable: 0x80110470 + gSequenceFontTable: 0x801106E0 + gSequenceTable: 0x801108A0 + gSampleBankTable: 0x80110F90 assets: - name: code/fbdemo_circle xml_path: assets/xml/code/fbdemo_circle.xml diff --git a/baseroms/gc-eu/config.yml b/baseroms/gc-eu/config.yml index 90712dc3f1..7d54a02590 100644 --- a/baseroms/gc-eu/config.yml +++ b/baseroms/gc-eu/config.yml @@ -64,6 +64,10 @@ variables: sFraMessageEntryTable: 0x8010DB48 sStaffMessageEntryTable: 0x8010FC58 sShadowTex: 0x80A73020 + gSoundFontTable: 0x80110490 + gSequenceFontTable: 0x80110700 + gSequenceTable: 0x801108C0 + gSampleBankTable: 0x80110FB0 assets: - name: code/fbdemo_circle xml_path: assets/xml/code/fbdemo_circle.xml diff --git a/baseroms/gc-us/config.yml b/baseroms/gc-us/config.yml index 54498f9a20..c66c4322ea 100644 --- a/baseroms/gc-us/config.yml +++ b/baseroms/gc-us/config.yml @@ -63,6 +63,10 @@ variables: sNesMessageEntryTable: 0x8010DFCC sStaffMessageEntryTable: 0x801121EC sShadowTex: 0x80A74130 + gSoundFontTable: 0x80112C80 + gSequenceFontTable: 0x80112EF0 + gSequenceTable: 0x801130B0 + gSampleBankTable: 0x801137A0 assets: - name: code/fbdemo_circle xml_path: assets/xml/code/fbdemo_circle.xml diff --git a/tools/audio/extraction/audio_extract.py b/tools/audio/extraction/audio_extract.py new file mode 100644 index 0000000000..ac51d2e0e7 --- /dev/null +++ b/tools/audio/extraction/audio_extract.py @@ -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") diff --git a/tools/audio/extraction/audio_tables.py b/tools/audio/extraction/audio_tables.py new file mode 100644 index 0000000000..d288b92629 --- /dev/null +++ b/tools/audio/extraction/audio_tables.py @@ -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 diff --git a/tools/audio/extraction/audiobank_file.py b/tools/audio/extraction/audiobank_file.py new file mode 100644 index 0000000000..8c6bd58b99 --- /dev/null +++ b/tools/audio/extraction/audiobank_file.py @@ -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)) diff --git a/tools/audio/extraction/audiobank_structs.py b/tools/audio/extraction/audiobank_structs.py new file mode 100644 index 0000000000..fa6afa37d0 --- /dev/null +++ b/tools/audio/extraction/audiobank_structs.py @@ -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: + # + + 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) diff --git a/tools/audio/extraction/audiotable.py b/tools/audio/extraction/audiotable.py new file mode 100644 index 0000000000..e02a6f285c --- /dev/null +++ b/tools/audio/extraction/audiotable.py @@ -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__")) diff --git a/tools/audio/extraction/envelope.py b/tools/audio/extraction/envelope.py new file mode 100644 index 0000000000..0dab8c8d99 --- /dev/null +++ b/tools/audio/extraction/envelope.py @@ -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() diff --git a/tools/audio/extraction/tuning.py b/tools/audio/extraction/tuning.py new file mode 100644 index 0000000000..b538c68e8d --- /dev/null +++ b/tools/audio/extraction/tuning.py @@ -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) diff --git a/tools/audio/extraction/util.py b/tools/audio/extraction/util.py new file mode 100644 index 0000000000..20891810cc --- /dev/null +++ b/tools/audio/extraction/util.py @@ -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) diff --git a/tools/audio_extraction.py b/tools/audio_extraction.py new file mode 100644 index 0000000000..da30273b75 --- /dev/null +++ b/tools/audio_extraction.py @@ -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)