From ffc52f13cef0b2106e073b2e7c16defdb372cc80 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sun, 12 Jul 2026 15:24:39 -0500 Subject: [PATCH] Harden freqs_to_midis against NaN/Inf; badges read exact tuningMidis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to #829 (CodeRabbit's review nit + the consumer adoption the PR body promised): - freqs_to_midis: reject non-finite frequencies (NaN/Infinity) — a provider handing one through would otherwise raise inside int(round(...)) and 500 GET /api/tunings. Tests cover nan/inf/-inf alongside the existing garbage cases. - v3 instrument badge: TUNING_NOTE now prefers the exact integer midis the server serves (tuningMidis) over reconstructing the note from the lowest string's frequency via log2 against a hardcoded 440 — which can land a semitone off at non-440 reference pitches. Frequency path kept as the fallback for older cached responses. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MS2YFb6UUSwJVV6CmEa25i Signed-off-by: ChrisBeWithYou --- lib/tunings.py | 6 ++++-- static/v3/badges.js | 13 +++++++++++-- tests/test_tunings.py | 3 +++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/tunings.py b/lib/tunings.py index 2099cdb..c5d34ed 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -101,14 +101,16 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER 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).""" + non-numeric, non-finite, or non-positive (a provider could hand us + anything; NaN/Infinity would otherwise raise inside int(round(...)) and + 500 the /api/tunings endpoint).""" out: list[int] = [] for f in freqs: try: f = float(f) except (TypeError, ValueError): return None - if f <= 0: + if not math.isfinite(f) or f <= 0: return None out.append(int(round(69 + 12 * math.log2(f / reference_pitch)))) return out diff --git a/static/v3/badges.js b/static/v3/badges.js index 7e49f1b..f36cf84 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -120,11 +120,20 @@ if (!r.ok) return; const data = await r.json(); _tuningsByKey = data.tunings || {}; - // Build TUNING_NOTE from the first (lowest) string frequency of each tuning. + // Build TUNING_NOTE from the lowest string of each tuning. Prefer the + // exact integer midis the server now sends (tuningMidis, #829) — the + // frequency path reconstructs the note via log2 against a hardcoded + // 440 and can land a semitone off at non-440 reference pitches. + // Frequencies remain the fallback for older cached responses. + const midisByKey = data.tuningMidis || {}; TUNING_NOTE = {}; for (const key of Object.keys(_tuningsByKey)) { for (const [name, freqs] of Object.entries(_tuningsByKey[key])) { - if (!(name in TUNING_NOTE) && Array.isArray(freqs) && freqs.length > 0) { + if (name in TUNING_NOTE) continue; + const midis = midisByKey[key] && midisByKey[key][name]; + if (Array.isArray(midis) && midis.length > 0 && Number.isFinite(midis[0])) { + TUNING_NOTE[name] = NOTE_NAMES[((midis[0] % 12) + 12) % 12]; + } else if (Array.isArray(freqs) && freqs.length > 0) { TUNING_NOTE[name] = _freqToNote(freqs[0]); } } diff --git a/tests/test_tunings.py b/tests/test_tunings.py index ae08d6a..70b310c 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -275,4 +275,7 @@ 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([float("nan")]) is None # non-finite (would raise in int(round(...))) + assert freqs_to_midis([float("inf")]) is None # non-finite + assert freqs_to_midis([float("-inf")]) is None # non-finite assert freqs_to_midis([]) == [] # vacuously fine