settings: add host instrument profiles (#753)

* settings: add host instrument profiles

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* settings: add instrument pathway selection

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5

Five regressions from the instrument-profiles rework:

1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
   froze default profiles into config.json (broke
   test_empty_post_preserves_all_existing_keys). Gate on the save touching
   instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
   no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
   plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
   every built-in table while still rejecting a built-in misapplied to the
   wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
   legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
   setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
   4-string tuning). Updated to the valid 'Drop A'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch

Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
  profile with defaults and replacing wholesale, so a one-profile update reset
  the others. Validate each PROVIDED profile individually and merge the partial
  over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
  reset a now-invalid tuning to Standard while the UI kept the old value
  (settings/tuner desync). Clamp + post the valid tuning too, mirroring the
  instrument-switch path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-05 00:16:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 41e907fa52
commit a86abadb14
11 changed files with 786 additions and 53 deletions
+118 -1
View File
@@ -2,7 +2,31 @@
import pytest
from tunings import tuning_name
from tunings import (
DEFAULT_TUNINGS,
TUNING_PRESET_MIDIS,
_valid_tuning_for_key,
apply_flat_instrument_patch_to_profiles,
open_midis_to_freqs,
settings_with_instrument_profiles,
tuning_midis_from_offsets,
tuning_name,
tuning_offsets_from_midis,
tuning_preset_offsets,
)
def test_valid_tuning_for_key_builtin_and_provider_names():
# A built-in valid for the key is accepted; a built-in valid only for a
# DIFFERENT key (misapplied, e.g. "Drop D" on a 5-string bass) is rejected.
assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A"
assert _valid_tuning_for_key("bass-5", "Drop D") is None
assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard"
# A name unknown to every built-in table is a provider/custom tuning (tuner
# plugin, /api/tunings) the pure layer can't resolve — accept it so settings
# round-trip rather than normalizing it away to Standard.
assert _valid_tuning_for_key("bass-5", "My Custom DADGAD") == "My Custom DADGAD"
assert _valid_tuning_for_key("guitar-6", "x" * 65) is None # length cap kept
# ── Standard tunings (all six strings share the same offset) ─────────────────
@@ -132,3 +156,96 @@ def test_drop_pattern_takes_precedence_over_named_dict():
# auto-generator fires first and produces the same string. The named dict entry
# is effectively dead code for this case — this test documents the behavior.
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D"
# ── Host tuning profile catalogue -------------------------------------------
def test_default_tunings_include_extended_host_profiles():
assert "bass-6" in DEFAULT_TUNINGS
assert "C Standard" in DEFAULT_TUNINGS["guitar-6"]
assert "C# Standard" in DEFAULT_TUNINGS["guitar-6"]
assert "Drop Ab" in DEFAULT_TUNINGS["guitar-6"]
assert "BEAD" in DEFAULT_TUNINGS["bass-4"]
assert "High C" in DEFAULT_TUNINGS["bass-5"]
assert "Drop A + Drop E" in DEFAULT_TUNINGS["guitar-8"]
def test_default_tuning_frequencies_are_derived_from_midis():
assert DEFAULT_TUNINGS["guitar-6"]["Standard"] == open_midis_to_freqs([40, 45, 50, 55, 59, 64])
assert DEFAULT_TUNINGS["bass-6"]["Standard"] == open_midis_to_freqs([23, 28, 33, 38, 43, 48])
def test_tuning_offsets_from_named_presets():
assert tuning_preset_offsets("guitar-6", "Drop D") == [-2, 0, 0, 0, 0, 0]
assert tuning_preset_offsets("guitar-6", "C Standard") == [-4, -4, -4, -4, -4, -4]
assert tuning_preset_offsets("bass-4", "BEAD") == [-5, -5, -5, -5]
assert tuning_preset_offsets("bass-5", "High C") == [5, 5, 5, 5, 5]
def test_tuning_midis_round_trip_offsets():
offsets = [-2, 0, 0, 0, 0, 0]
midis = tuning_midis_from_offsets("guitar-6", offsets)
assert midis == TUNING_PRESET_MIDIS["guitar-6"]["Drop D"]
assert tuning_offsets_from_midis("guitar-6", midis) == offsets
def test_tuning_conversion_rejects_wrong_string_count():
assert tuning_offsets_from_midis("guitar-6", [40, 45, 50, 55]) is None
assert tuning_midis_from_offsets("bass-4", [0, 0, 0, 0, 0]) is None
def test_settings_profiles_default_to_lead_rhythm_and_bass():
settings = settings_with_instrument_profiles({})
assert settings["active_instrument_profile"] == "guitar-lead"
assert set(settings["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
assert settings["instrument"] == "guitar"
assert settings["string_count"] == 6
assert settings["tuning"] == "Standard"
assert settings["pathway"] == "songs"
assert settings["instrument_profiles"]["guitar-lead"]["pathway"] == "songs"
def test_settings_profiles_migrate_legacy_flat_bass_selection():
settings = settings_with_instrument_profiles({
"instrument": "bass",
"string_count": 6,
"tuning": "C Standard",
"reference_pitch": 432,
"pathway": "practice",
})
assert settings["active_instrument_profile"] == "bass"
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
assert settings["reference_pitch"] == 432
assert settings["pathway"] == "practice"
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys():
settings = settings_with_instrument_profiles({})
patched = apply_flat_instrument_patch_to_profiles(settings, {"tuning": "Drop D"})
assert patched["tuning"] == "Drop D"
assert patched["instrument_profiles"]["guitar-lead"]["tuning"] == "Drop D"
def test_flat_pathway_patch_updates_active_profile_and_mirrors_legacy_key():
settings = settings_with_instrument_profiles({})
patched = apply_flat_instrument_patch_to_profiles(settings, {"pathway": "studio"})
assert patched["pathway"] == "studio"
assert patched["instrument_profiles"]["guitar-lead"]["pathway"] == "studio"
def test_flat_instrument_patch_defaults_to_target_string_count():
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "Drop D"})
patched = apply_flat_instrument_patch_to_profiles(settings, {"instrument": "bass"})
assert patched["instrument"] == "bass"
assert patched["string_count"] == 4
assert patched["tuning"] == "Standard"
assert patched["active_instrument_profile"] == "bass"
assert patched["instrument_profiles"]["bass"]["string_count"] == 4
def test_flat_string_count_patch_resets_incompatible_named_tuning():
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "DADGAD"})
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
assert patched["string_count"] == 7
assert patched["tuning"] == "Standard"