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
+88 -4
View File
@@ -43,7 +43,12 @@ from song import (
scale_degree_for_pitch,
)
from audio import find_wem_files, convert_wem
from tunings import tuning_name, DEFAULT_TUNINGS, DEFAULT_REFERENCE_PITCH, apply_reference_pitch
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,
)
import sloppak as sloppak_mod
import drums as drums_mod
import notation as notation_mod
@@ -9862,7 +9867,7 @@ def get_tunings():
@app.get("/api/settings")
def get_settings():
cfg = _load_config(CONFIG_DIR / "config.json")
return cfg if cfg is not None else _default_settings()
return settings_with_instrument_profiles(cfg if cfg is not None else _default_settings())
@app.post("/api/settings")
@@ -10073,6 +10078,38 @@ def save_settings(data: dict):
else:
return {"error": "tuning must be a name (string) or a list of semitone offsets"}
if "pathway" in data:
raw = data["pathway"]
if raw is not None:
if not isinstance(raw, str) or raw not in PROFILE_PATHWAYS:
return {"error": "pathway must be one of songs, practice, learn, studio"}
updates["pathway"] = raw
_profile_patch = None
if "instrument_profiles" in data:
raw = data["instrument_profiles"]
if raw is not None:
if not isinstance(raw, dict):
return {"error": "instrument_profiles must be an object"}
# Validate each PROVIDED profile individually and keep the patch
# PARTIAL — /api/settings is a partial-merge endpoint, so updating one
# profile must NOT reset the others to defaults. Merged over the
# persisted profiles inside the lock below (not via the wholesale
# `updates` merge, which would clobber the unspecified ones).
_profile_patch = {}
for _pid, _praw in raw.items():
if _pid not in PROFILE_IDS:
return {"error": f"unknown instrument profile: {_pid}"}
_prof, _perr = normalize_instrument_profile(_pid, _praw)
if _perr:
return {"error": _perr}
_profile_patch[_pid] = _prof
if "active_instrument_profile" in data:
raw = data["active_instrument_profile"]
if raw is not None:
if not isinstance(raw, str) or raw not in PROFILE_IDS:
return {"error": "active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"}
updates["active_instrument_profile"] = raw
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Critical section — the read-merge-write must be atomic. FastAPI runs
# sync handlers in a threadpool, so two concurrent partial POSTs (e.g.
@@ -10089,6 +10126,29 @@ def save_settings(data: dict):
if cfg is None:
cfg = _default_settings()
cfg.update(updates)
if _profile_patch is not None:
# Merge the validated partial over the persisted profiles so a
# single-profile update leaves the others intact (a fresh config
# falls back to the built-in defaults for the unspecified ones).
_existing, _ = normalize_instrument_profiles(cfg.get("instrument_profiles"))
if _existing is None:
_existing = {}
_existing.update(_profile_patch)
cfg["instrument_profiles"] = _existing
# Only canonicalize/persist the instrument profiles when this save
# actually touches them (or the config already carries them). GET always
# virtualizes profiles via settings_with_instrument_profiles, so a save
# that doesn't touch instrument settings must stay a plain partial merge
# — otherwise an empty (or unrelated) POST would freeze the default
# profiles into the on-disk config.
_profile_keys = ("instrument", "string_count", "tuning", "reference_pitch",
"pathway", "instrument_profiles", "active_instrument_profile")
if "instrument_profiles" in cfg or any(k in updates for k in _profile_keys):
try:
cfg = apply_flat_instrument_patch_to_profiles(cfg, updates)
except ValueError as exc:
return {"error": str(exc)}
cfg = settings_with_instrument_profiles(cfg)
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
return {"message": ". ".join(messages) if messages else "Settings saved"}
@@ -10100,7 +10160,8 @@ def save_settings(data: dict):
_RESETTABLE_SETTINGS_KEYS = frozenset({
"default_arrangement", "demucs_server_url", "master_difficulty",
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior",
"reference_pitch", "instrument", "string_count", "tuning",
"reference_pitch", "instrument", "string_count", "tuning", "pathway",
"instrument_profiles", "active_instrument_profile",
"achievements_enabled", "use_amp_sims",
})
@@ -10125,6 +10186,16 @@ def reset_settings(data: dict):
removed = [k for k in keys if k in cfg]
for k in removed:
del cfg[k]
# `pathway` is mirrored into every instrument profile, so deleting the
# flat key alone doesn't reset it — GET re-derives the value from the
# active profile. Reset it inside the persisted profiles too (back to the
# "songs" default), without disturbing the rest of the instrument config.
if "pathway" in keys and isinstance(cfg.get("instrument_profiles"), dict):
for prof in cfg["instrument_profiles"].values():
if isinstance(prof, dict):
prof["pathway"] = "songs"
if "pathway" not in removed:
removed.append("pathway")
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
return {"message": "Settings reset", "reset": removed}
@@ -10216,6 +10287,18 @@ def _validate_server_config_types(cfg: dict) -> str | None:
return "server_config.tuning offsets must be ≤8 integers between -12 and 12"
else:
return "server_config.tuning must be a name (string) or a list of semitone offsets"
if "pathway" in cfg:
v = cfg["pathway"]
if v is not None and (not isinstance(v, str) or v not in PROFILE_PATHWAYS):
return "server_config.pathway must be one of songs, practice, learn, studio"
if "instrument_profiles" in cfg:
profiles, error = normalize_instrument_profiles(cfg["instrument_profiles"])
if error:
return f"server_config.{error}"
if "active_instrument_profile" in cfg:
v = cfg["active_instrument_profile"]
if v is not None and (not isinstance(v, str) or v not in PROFILE_IDS):
return "server_config.active_instrument_profile must be one of guitar-lead, guitar-rhythm, bass"
return None
@@ -10585,6 +10668,7 @@ def export_settings():
server_config = _load_config(config_file)
if server_config is None:
server_config = _default_settings()
server_config = settings_with_instrument_profiles(server_config)
# Snapshot the library DB + custom art FIRST: if the irreplaceable state
# can't be captured, abort with an error rather than hand back a bundle
@@ -10827,7 +10911,7 @@ def import_settings(bundle: dict):
with _settings_lock:
_atomic_write_file(
CONFIG_DIR / "config.json",
json.dumps(server_config, indent=2).encode("utf-8"),
json.dumps(settings_with_instrument_profiles(server_config), indent=2).encode("utf-8"),
)
except OSError as e:
# Phase-1 validation should have caught all foreseeable