1
0
Fork 0
mirror of https://github.com/zeldaret/oot.git synced 2024-12-26 14:46:16 +00:00
oot/tools/audio/extraction/tuning.py
Tharo 29acf96db2
[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)
2024-08-08 00:11:39 -04:00

211 lines
8.8 KiB
Python

#!/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)