From 2121d62a6f99a2a93be73c6364f4daa70bec3850 Mon Sep 17 00:00:00 2001 From: Tharo Date: Sat, 28 Mar 2026 21:01:53 +0000 Subject: [PATCH] Audio: Fix note values (#1864) Due to the layout of the pitch_frequencies lookup table, note values computed in extraction were reflected around middle C (midi note number 60). This didn't matter for matching, values would successfully roundtrip. However when using samples in external programs or converting soundfonts to standard formats the note values would lead to incorrect playback of sounds. This change corrects the note values so that external programs correctly infer the pitch of the sound when played at a particular MIDI key. --- assets/xml/audio/samplebanks/SampleBank_0.xml | 310 +++++++++--------- assets/xml/audio/samplebanks/SampleBank_2.xml | 8 +- tools/audio/extraction/audiobank_file.py | 8 +- tools/audio/extraction/audiobank_structs.py | 10 +- tools/audio/extraction/audiotable.py | 8 +- tools/audio/extraction/tuning.py | 94 ++++-- tools/audio/soundfont_compiler.c | 11 +- 7 files changed, 247 insertions(+), 202 deletions(-) diff --git a/assets/xml/audio/samplebanks/SampleBank_0.xml b/assets/xml/audio/samplebanks/SampleBank_0.xml index bc1c5bb8d8..9526f41ae1 100644 --- a/assets/xml/audio/samplebanks/SampleBank_0.xml +++ b/assets/xml/audio/samplebanks/SampleBank_0.xml @@ -13,8 +13,8 @@ - - + + @@ -22,11 +22,11 @@ - + - + @@ -41,7 +41,7 @@ - + @@ -67,36 +67,36 @@ - + - - - - - - + + + + + + - + - + - + - + - + @@ -106,48 +106,48 @@ - + - - + + - - - - + + + + - - + + - - + + - - - - + + + + - + - + - + - + - + - - + + @@ -238,13 +238,13 @@ - + - - - - + + + + @@ -300,13 +300,13 @@ - - - - - - - + + + + + + + @@ -373,26 +373,26 @@ - + - - - + + + - - - + + + - - + + - + @@ -401,13 +401,13 @@ - - + + - - - - + + + + @@ -417,7 +417,7 @@ - + @@ -428,31 +428,31 @@ - + - - + + - + - - + + - - - - - + + + + + @@ -461,7 +461,7 @@ - + @@ -469,7 +469,7 @@ - + @@ -525,7 +525,7 @@ - + @@ -534,12 +534,12 @@ - + - + @@ -553,124 +553,124 @@ - + - - - + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - + + + - + - + - - - + + + - - + + - + - - - - + + + + - - - - - - - - - - + + + + + + + + + + - + - - + + - - + + - + - + - + - - - - - + + + + + - - - + + + - - - - + + + + - + - - + + - - - - - + + + + + - + diff --git a/assets/xml/audio/samplebanks/SampleBank_2.xml b/assets/xml/audio/samplebanks/SampleBank_2.xml index 0512a81d82..c07c05f8b8 100644 --- a/assets/xml/audio/samplebanks/SampleBank_2.xml +++ b/assets/xml/audio/samplebanks/SampleBank_2.xml @@ -2,8 +2,8 @@ - - - - + + + + diff --git a/tools/audio/extraction/audiobank_file.py b/tools/audio/extraction/audiobank_file.py index cd4e48740b..0f1caf96ee 100644 --- a/tools/audio/extraction/audiobank_file.py +++ b/tools/audio/extraction/audiobank_file.py @@ -129,9 +129,9 @@ class DrumGroup: 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)) + # Drum frequencies should increase monotonically in a drum group + # Increasing frequencies correspond to decreasing note values + assert all(v == (notes[0] - i) % 128 for i,v in enumerate(notes)) # 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. @@ -167,7 +167,7 @@ class DrumGroup: if self.needs_rate_override: attributes["SampleRate"] = self.sample_rate if self.needs_note_override: - attributes["BaseNote"] = self.base_note + attributes["BaseNote"] = pitch_names[self.base_note] xml.write_element("Drum", attributes) diff --git a/tools/audio/extraction/audiobank_structs.py b/tools/audio/extraction/audiobank_structs.py index fa6afa37d0..beb2e1a57d 100644 --- a/tools/audio/extraction/audiobank_structs.py +++ b/tools/audio/extraction/audiobank_structs.py @@ -64,7 +64,7 @@ class SoundFontSample: # SampleHeader ? if rate_override is not None: attrs["SampleRate"] = rate_override if note_override is not None: - attrs["BaseNote"] = note_override + attrs["BaseNote"] = pitch_names[note_override] if self.medium != 0: attrs["IsDD"] = "true" if self.cached: @@ -235,7 +235,7 @@ class SoundFontSound: if self.needs_rate_override: attrs["SampleRate"] = self.sample_rate if self.needs_note_override: - attrs["BaseNote"] = self.base_note + attrs["BaseNote"] = pitch_names[self.base_note] xml.write_element("Effect", attrs) @@ -383,7 +383,7 @@ class Instrument: if self.needs_rate_override[1]: attributes["SampleRate"] = self.sample_rate[1] if self.needs_note_override[1]: - attributes["BaseNote"] = self.base_note[1] + attributes["BaseNote"] = pitch_names[self.base_note[1]] if self.normal_range_lo != 0: attributes["RangeLo"] = pitch_names[self.normal_range_lo] @@ -392,7 +392,7 @@ class Instrument: if self.needs_rate_override[0]: attributes["SampleRateLo"] = self.sample_rate[0] if self.needs_note_override[0]: - attributes["BaseNoteLo"] = self.base_note[0] + attributes["BaseNoteLo"] = pitch_names[self.base_note[0]] if self.normal_range_hi != 127: attributes["RangeHi"] = pitch_names[self.normal_range_hi] @@ -401,6 +401,6 @@ class Instrument: if self.needs_rate_override[2]: attributes["SampleRateHi"] = self.sample_rate[2] if self.needs_note_override[2]: - attributes["BaseNoteHi"] = self.base_note[2] + attributes["BaseNoteHi"] = pitch_names[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 index 29a27695ae..3a76396c6c 100644 --- a/tools/audio/extraction/audiotable.py +++ b/tools/audio/extraction/audiotable.py @@ -11,7 +11,7 @@ from .audio_tables import AudioCodeTableEntry from .audiobank_structs import AudioSampleCodec, SoundFontSample, AdpcmBook, AdpcmLoop from .extraction_xml import SampleBankExtractionDescription 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 +from .util import align, XMLWriter, f32_to_u32 class AIFCFile: @@ -205,7 +205,7 @@ class AudioTableSample(AudioTableData): return ext def base_note_number(self): - return note_z64_to_midi(pitch_names.index(self.base_note)) + return note_z64_to_midi(self.base_note) def resolve_basenote_rate(self, extraction_sample_info : Optional[Dict[str,str]]): assert len(self.notes_rates) != 0 @@ -287,7 +287,7 @@ class AudioTableSample(AudioTableData): if extraction_sample_info is not None: assert "SampleRate" in extraction_sample_info and "BaseNote" in extraction_sample_info final_rate = int(extraction_sample_info["SampleRate"]) - final_note = extraction_sample_info["BaseNote"] + final_note = pitch_names.index(extraction_sample_info["BaseNote"]) # print(" ",len(FINAL_NOTES_RATES), FINAL_NOTES_RATES) # if rate_3ds is not None and len(FINAL_NOTES_RATES) == 1: @@ -644,7 +644,7 @@ class AudioTableFile: "Name" : sample.name, "FileName" : sample.filename.replace(sample.codec_file_extension_compressed(), ""), "SampleRate" : sample.sample_rate, - "BaseNote" : sample.base_note, + "BaseNote" : pitch_names[sample.base_note], } xml.write_element("Sample", attrs) else: diff --git a/tools/audio/extraction/tuning.py b/tools/audio/extraction/tuning.py index b538c68e8d..065cd714ff 100644 --- a/tools/audio/extraction/tuning.py +++ b/tools/audio/extraction/tuning.py @@ -62,8 +62,14 @@ def note_z64_to_midi(note : int) -> int: """ 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 recalc_tuning(rate : int, note : int) -> float: + # The tuning formula t(r,n) for n a midi note number is + # t = (r / 32000) * 2^{60 - n} + # We use a lookup table for the power of 2 calculation for z a z64 note number + # t = (r / 32000) * frequencies[78 - z] + # The offset by 78 comes from 2*(60 - 21) where 21 relates the z64 and midi note numbers + # n = 21 + z + return f32(f32(rate / 32000.0) * u32_to_f32(g_pitch_frequencies[(78 - note) % 128])) def rate_from_tuning(tuning : float) -> Tuple[Tuple[str,int]]: """ @@ -86,14 +92,16 @@ def rate_from_tuning(tuning : float) -> Tuple[Tuple[str,int]]: diff : int = abs(f32_to_u32(tuning2) - tuning_bits) if diff == 0: - matches.append((pitch_names[note_val], nominal_rate)) + matches.append((note_val, nominal_rate)) else: - diffs.append((diff, (pitch_names[note_val], nominal_rate))) + diffs.append((diff, (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): + for i,freq_bits in enumerate(g_pitch_frequencies): + # Reflect the note value as in recalc_tuning + note_val = (78 - i) % 128 freq : float = u32_to_f32(freq_bits) # compute the "nominal" samplerate for a given basenote by R = 32000 * (t / f) @@ -122,32 +130,68 @@ def rank_rates_notes(layouts): """ rank = 0 - if 'C4' in notes and rate > 10000: + notes_named = [pitch_names[note] for note in notes] + + if 'C4' in notes_named and rate > 10000: rank += 10000 - elif 'C2' in notes and rate > 10000: + elif 'C2' in notes_named and rate > 10000: rank += 9500 - elif 'D3' in notes and rate > 10000: + elif 'D3' in notes_named and rate > 10000: rank += 8500 - elif 'D4' in notes and rate > 10000: + elif 'D4' in notes_named and rate > 10000: rank += 8000 - elif 'G3' in notes: + elif 'C3' in notes_named and rate > 10000: + rank += 4000 + elif 'C5' in notes_named and rate > 10000: + rank += 4000 + elif 'D0' in notes_named: + rank += 3500 + elif 'A9' in notes_named: + rank += 3000 + elif 'A3' in notes_named: + rank += 2750 + elif 'C6' in notes_named: + rank += 2750 + elif 'C8' in notes_named: + rank += 2500 + elif 'C1' in notes_named: + rank += 2500 + elif 'A4' in notes_named: + rank += 2500 + elif 'A0' in notes_named: + rank += 2250 + elif 'B0' in notes_named: + rank += 2250 + elif 'AF9' in notes_named: rank += 2000 - elif 'F3' in notes: - rank += 25 - elif 'C0' in notes: + elif 'AF0' in notes_named: + rank += 2000 + elif 'G3' in notes_named: + rank += 2000 + elif 'GF4' in notes_named: + rank += 100 + elif 'F9' in notes_named: rank += 50 - elif 'BF2' in notes: + elif 'F3' in notes_named: + rank += 25 + elif 'C0' in notes_named: + rank += 50 + elif 'BF2' in notes_named: rank += 30 - elif 'B3' in notes: + elif 'B3' in notes_named: rank += 25 - elif 'BF1' in notes: + elif 'BF1' in notes_named: rank += 25 - elif 'E2' in notes: + elif 'E2' in notes_named: rank += 20 - elif 'F6' in notes: + elif 'F6' in notes_named: rank += 15 - elif 'GF2' in notes: + elif 'GF2' in notes_named: rank += 10 + elif 'BF3' in notes_named: + rank += 1 + elif 'AF2' in notes_named: + rank += 1 rank += { 32000 : 200, @@ -176,7 +220,7 @@ def rank_rates_notes(layouts): 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 + assert rank_rate_note(*ranked[0]) != rank_rate_note(*ranked[1]) , [(rate,tuple(pitch_names[note] for note in notes)) for rate,notes in ranked] # Output best return ranked[0] @@ -185,7 +229,7 @@ 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("-t", dest="tuning", required=False, default=None, help="Tuning value (float or hex)") 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") @@ -193,10 +237,10 @@ if __name__ == '__main__': if args.tuning is not None: # Take input tuning - tuning = args.tuning + tuning : float = u32_to_f32(int(args.tuning,16)) if args.tuning.startswith("0x") else float(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) + tuning : float = recalc_tuning(args.rate, pitch_names.index(args.note)) else: # Insufficient arguments parser.print_help() @@ -206,6 +250,6 @@ if __name__ == '__main__': for note,rate in notes_rates: if args.show_result: - print(rate, note, "->", recalc_tuning(rate, note)) + print(rate, pitch_names[note], "->", recalc_tuning(rate, note)) else: - print(rate, note) + print(rate, pitch_names[note]) diff --git a/tools/audio/soundfont_compiler.c b/tools/audio/soundfont_compiler.c index 6a47d643b8..4f37baa557 100644 --- a/tools/audio/soundfont_compiler.c +++ b/tools/audio/soundfont_compiler.c @@ -199,7 +199,8 @@ calc_tuning(float sample_rate, int basenote, int8_t finetune) /* 0x7F */ 0.099213f, // PITCH_AF0 }; - float tuning = (sample_rate / playback_sample_rate) * pitch_frequencies[basenote]; + // Due to the way the lookup table is arranged, the note needs to be reflected about middle C (z64 note value 39) + float tuning = (sample_rate / playback_sample_rate) * pitch_frequencies[(78u - (unsigned)basenote) % 128u]; if (finetune == 0) return tuning; @@ -1446,10 +1447,10 @@ emit_c_drums(FILE *out, soundfont *sf) ptr_table[ptr_offset].n = note_offset; // wrap note on overflow - int note = drum->base_note + note_offset; - if (note > 127) - note -= 128; - + // drum frequencies increase with drum offset, corresponding to a decrease in note number + int note = drum->base_note - note_offset; + if (note < 0) + note += 128; float tuning = calc_tuning(drum->sample_rate, note, drum->fine_tune); fprintf(out, " SF%d_%s_ENTRY(" F32_FMT "f),\n", sf->info.index, drum->name, tuning);