Files
Tharo 970af5600a Audio: Fix note values (#2716)
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.
2026-03-10 06:22:28 +09:00

256 lines
10 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 : 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]]:
"""
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((note_val, nominal_rate))
else:
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 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)
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
notes_named = [pitch_names[note] for note in notes]
if 'C4' in notes_named and rate > 10000:
rank += 10000
elif 'C2' in notes_named and rate > 10000:
rank += 9500
elif 'D3' in notes_named and rate > 10000:
rank += 8500
elif 'D4' in notes_named and rate > 10000:
rank += 8000
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 '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 'F3' in notes_named:
rank += 25
elif 'C0' in notes_named:
rank += 50
elif 'BF2' in notes_named:
rank += 30
elif 'B3' in notes_named:
rank += 25
elif 'BF1' in notes_named:
rank += 25
elif 'E2' in notes_named:
rank += 20
elif 'F6' in notes_named:
rank += 15
elif 'GF2' in notes_named:
rank += 10
elif 'BF3' in notes_named:
rank += 1
elif 'AF2' in notes_named:
rank += 1
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]) , [(rate,tuple(pitch_names[note] for note in notes)) for rate,notes in 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, 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")
args = parser.parse_args()
if args.tuning is not None:
# Take input 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, pitch_names.index(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, pitch_names[note], "->", recalc_tuning(rate, note))
else:
print(rate, pitch_names[note])