diff --git a/lib/tunings.py b/lib/tunings.py index 2b9bede..2099cdb 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -98,6 +98,22 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER return [round(midi_to_freq(m, reference_pitch), 2) for m in midis] +def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None: + """Return absolute open-string MIDI notes for frequencies at the supplied + A4 reference — the inverse of open_midis_to_freqs. None if any entry is + non-numeric or non-positive (a provider could hand us anything).""" + out: list[int] = [] + for f in freqs: + try: + f = float(f) + except (TypeError, ValueError): + return None + if f <= 0: + return None + out.append(int(round(69 + 12 * math.log2(f / reference_pitch)))) + return out + + def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None: """Return semitone offsets from the instrument's standard open strings.""" standard = STANDARD_OPEN_MIDIS.get(instrument_key) diff --git a/server.py b/server.py index c80118a..8c49b51 100644 --- a/server.py +++ b/server.py @@ -45,9 +45,10 @@ from song import ( from audio import find_wem_files, convert_wem from tunings import ( DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS, - apply_flat_instrument_patch_to_profiles, apply_reference_pitch, - normalize_instrument_profile, normalize_instrument_profiles, - settings_with_instrument_profiles, tuning_name, + TUNING_PRESET_MIDIS, apply_flat_instrument_patch_to_profiles, + apply_reference_pitch, freqs_to_midis, normalize_instrument_profile, + normalize_instrument_profiles, settings_with_instrument_profiles, + tuning_name, ) import sloppak as sloppak_mod import drums as drums_mod @@ -10766,7 +10767,25 @@ def get_tunings(): ref = DEFAULT_REFERENCE_PITCH except (TypeError, ValueError): ref = DEFAULT_REFERENCE_PITCH - return {"referencePitch": ref, "tunings": tuning_providers.get_merged(ref)} + merged = tuning_providers.get_merged(ref) + # tuningMidis: the same catalog as exact integer MIDI notes (low → high). + # Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip); + # provider-contributed entries are recovered from their frequencies at the + # served reference pitch. Every consumer today (the v3 badges, plugins) + # reconstructs midis client-side via log2 — a rounding footgun at non-440 + # references — so serve the integers once, host-side. Additive: the + # existing referencePitch/tunings shape is unchanged. + tuning_midis: dict[str, dict[str, list[int]]] = {} + for key, names in merged.items(): + builtin = TUNING_PRESET_MIDIS.get(key, {}) + resolved: dict[str, list[int]] = {} + for name, freqs in names.items(): + midis = builtin.get(name) or freqs_to_midis(freqs, ref) + if midis: + resolved[name] = list(midis) + if resolved: + tuning_midis[key] = resolved + return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis} @app.get("/api/settings") diff --git a/tests/test_tunings.py b/tests/test_tunings.py index b2f67e2..ae08d6a 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -249,3 +249,30 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning(): patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7}) assert patched["string_count"] == 7 assert patched["tuning"] == "Standard" + + +# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ──────────────────── + +def test_freqs_to_midis_round_trips_every_builtin_at_440(): + from tunings import freqs_to_midis + for key, presets in TUNING_PRESET_MIDIS.items(): + for name, midis in presets.items(): + assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}" + + +def test_freqs_to_midis_round_trips_at_nonstandard_reference(): + # The consumer footgun this exists to kill: frequencies served at a 432/450 + # reference must recover the SAME integer midis when inverted at that + # reference (client-side log2-at-440 reconstruction drifts here). + from tunings import freqs_to_midis + for ref in (430.0, 432.0, 444.0, 450.0): + for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]): + freqs = open_midis_to_freqs(midis, ref) + assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}" + + +def test_freqs_to_midis_rejects_garbage(): + from tunings import freqs_to_midis + assert freqs_to_midis([82.41, 0]) is None # non-positive + assert freqs_to_midis([82.41, "x"]) is None # non-numeric + assert freqs_to_midis([]) == [] # vacuously fine