From a86abadb144e6cc510bf8aca4f95bd6ba751b1e0 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Sat, 4 Jul 2026 17:16:42 -0500 Subject: [PATCH] settings: add host instrument profiles (#753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * settings: add host instrument profiles Signed-off-by: ChrisBeWithYou * settings: add instrument pathway selection Signed-off-by: ChrisBeWithYou * 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) * 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) --------- Signed-off-by: ChrisBeWithYou Co-authored-by: Claude Opus 4.8 (1M context) --- lib/tunings.py | 397 +++++++++++++++++++++++--- server.py | 92 +++++- static/app.js | 20 ++ static/capabilities/working-tuning.js | 2 +- static/v3/badges.js | 58 +++- static/v3/index.html | 17 ++ static/v3/settings.js | 2 +- tests/js/working_tuning.test.js | 9 +- tests/test_settings_api.py | 117 +++++++- tests/test_settings_instrument.py | 6 +- tests/test_tunings.py | 119 +++++++- 11 files changed, 786 insertions(+), 53 deletions(-) diff --git a/lib/tunings.py b/lib/tunings.py index ff248fa..2b9bede 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering FastAPI / SQLite module-level side effects. """ +from __future__ import annotations + +import math + DEFAULT_REFERENCE_PITCH = 440.0 -# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then -# tuning name. This is the authoritative source; tuner/routes.py previously -# held a copy — it was removed in favour of this one. -DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = { +# Canonical open strings, low to high, as MIDI notes. This is the host-level +# source of truth for guitar/bass tuning profiles; UI surfaces derive names, +# frequencies, and semitone offsets from these absolute pitches. +STANDARD_OPEN_MIDIS: dict[str, list[int]] = { + "guitar-6": [40, 45, 50, 55, 59, 64], + "guitar-7": [35, 40, 45, 50, 55, 59, 64], + "guitar-8": [30, 35, 40, 45, 50, 55, 59, 64], + "bass-4": [28, 33, 38, 43], + "bass-5": [23, 28, 33, 38, 43], + "bass-6": [23, 28, 33, 38, 43, 48], +} + +# Curated built-in profiles. This intentionally starts by absorbing the useful +# Virtuoso guitar/bass coverage into host-owned data so the host selector, +# tuner, practice tools, and plugins can converge on one profile model. +TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = { "guitar-6": { - "Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63], - "Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13], - "Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63], - "D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66], - "Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66], - "Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66], - "Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66], - "DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66], - "Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63], + "Standard": [40, 45, 50, 55, 59, 64], + "Eb Standard": [39, 44, 49, 54, 58, 63], + "D Standard": [38, 43, 48, 53, 57, 62], + "C# Standard": [37, 42, 47, 52, 56, 61], + "C Standard": [36, 41, 46, 51, 55, 60], + "Drop D": [38, 45, 50, 55, 59, 64], + "Drop C": [36, 43, 48, 53, 57, 62], + "Drop B": [35, 42, 47, 52, 56, 61], + "Drop A": [33, 40, 45, 50, 54, 59], + "Drop Ab": [32, 39, 44, 49, 53, 58], + "Open G": [38, 43, 50, 55, 59, 62], + "Open D": [38, 45, 50, 54, 57, 62], + "DADGAD": [38, 45, 50, 55, 57, 62], + "Open E": [40, 47, 52, 56, 59, 64], }, "guitar-7": { - "Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], - "Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], - "A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], - "Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63], - "Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13], + "Standard": [35, 40, 45, 50, 55, 59, 64], + "Bb Standard": [34, 39, 44, 49, 54, 58, 63], + "A Standard": [33, 38, 43, 48, 53, 57, 62], + "G Standard": [31, 36, 41, 46, 51, 55, 60], + "Drop A": [33, 40, 45, 50, 55, 59, 64], + "Drop G": [31, 38, 43, 48, 53, 57, 62], + "Drop F#": [30, 37, 42, 47, 52, 56, 61], }, "guitar-8": { - "Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], - "Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], - "E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], - "Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], - "Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18], + "Standard": [30, 35, 40, 45, 50, 55, 59, 64], + "Drop E": [28, 35, 40, 45, 50, 55, 59, 64], + "Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64], + "E Standard": [28, 33, 38, 43, 48, 53, 57, 62], + "Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61], + "Drop D": [26, 33, 38, 43, 48, 53, 57, 62], }, "bass-4": { - "Standard": [41.20, 55.00, 73.42, 98.00], - "Eb Standard": [38.89, 51.91, 69.30, 92.50], - "Drop D": [36.71, 55.00, 73.42, 98.00], - "D Standard": [36.71, 48.99, 65.41, 87.31], - "Drop C": [32.70, 48.99, 65.41, 87.31], + "Standard": [28, 33, 38, 43], + "Eb Standard": [27, 32, 37, 42], + "D Standard": [26, 31, 36, 41], + "C# Standard": [25, 30, 35, 40], + "C Standard": [24, 29, 34, 39], + "Drop D": [26, 33, 38, 43], + "Drop C": [24, 31, 36, 41], + "BEAD": [23, 28, 33, 38], }, "bass-5": { - "Standard": [30.87, 41.20, 55.00, 73.42, 98.00], - "Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50], - "Drop D": [30.87, 36.71, 55.00, 73.42, 98.00], - "D Standard": [27.50, 36.71, 48.99, 65.41, 87.31], - "Drop C": [27.50, 32.70, 48.99, 65.41, 87.31], + "Standard": [23, 28, 33, 38, 43], + "High C": [28, 33, 38, 43, 48], + "Eb Standard": [22, 27, 32, 37, 42], + "D Standard": [21, 26, 31, 36, 41], + "C# Standard": [20, 25, 30, 35, 40], + "C Standard": [19, 24, 29, 34, 39], + "Drop A": [21, 28, 33, 38, 43], }, + "bass-6": { + "Standard": [23, 28, 33, 38, 43, 48], + "Eb Standard": [22, 27, 32, 37, 42, 47], + "D Standard": [21, 26, 31, 36, 41, 46], + "C# Standard": [20, 25, 30, 35, 40, 45], + "C Standard": [19, 24, 29, 34, 39, 44], + }, +} + + +def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float: + """Return the frequency for a MIDI note at the supplied A4 reference.""" + return reference_pitch * math.pow(2, (midi - 69) / 12) + + +def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]: + """Return rounded frequencies for low-to-high MIDI open strings.""" + return [round(midi_to_freq(m, reference_pitch), 2) for m in midis] + + +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) + if not standard or len(standard) != len(midis): + return None + return [int(m - s) for m, s in zip(midis, standard)] + + +def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None: + """Return absolute open-string MIDI notes for host semitone offsets.""" + standard = STANDARD_OPEN_MIDIS.get(instrument_key) + if not standard or len(standard) != len(offsets): + return None + return [int(s + o) for s, o in zip(standard, offsets)] + + +def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None: + """Return host semitone offsets for a named preset.""" + midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name) + if not midis: + return None + return tuning_offsets_from_midis(instrument_key, midis) + + +# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then +# tuning name. Kept for the existing /api/tunings contract. +DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = { + instrument: { + name: open_midis_to_freqs(midis) + for name, midis in presets.items() + } + for instrument, presets in TUNING_PRESET_MIDIS.items() } @@ -67,6 +148,256 @@ def apply_reference_pitch( } +PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass") +PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio") +DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead" +PROFILE_DEFAULTS: dict[str, dict] = { + "guitar-lead": { + "id": "guitar-lead", + "label": "Lead Guitar", + "instrument": "guitar", + "role": "lead", + "string_count": 6, + "tuning": "Standard", + "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", + }, + "guitar-rhythm": { + "id": "guitar-rhythm", + "label": "Rhythm Guitar", + "instrument": "guitar", + "role": "rhythm", + "string_count": 6, + "tuning": "Standard", + "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", + }, + "bass": { + "id": "bass", + "label": "Bass", + "instrument": "bass", + "role": "bass", + "string_count": 4, + "tuning": "Standard", + "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", + }, +} + + +def instrument_key(instrument: str, string_count: int) -> str: + return f"{instrument}-{string_count}" + + +def default_instrument_profiles() -> dict[str, dict]: + return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()} + + +def _valid_reference_pitch(value) -> float | None: + if isinstance(value, bool): + return None + try: + ref = float(value) + except (TypeError, ValueError, OverflowError): + return None + if not math.isfinite(ref) or ref < 430.0 or ref > 450.0: + return None + return ref + + +def _valid_tuning_for_key(key: str, tuning): + if isinstance(tuning, str): + if len(tuning) > 64: + return None + if tuning in TUNING_PRESET_MIDIS.get(key, {}): + return tuning + # A name that IS a built-in preset for a different key is a misapplied + # built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) — + # reject it. A name unknown to every built-in table is a provider/custom + # tuning (the tuner plugin's, exposed via /api/tunings) that this pure + # layer can't resolve — accept it so settings round-trip; the provider + # owns its validity. + if any(tuning in names for names in TUNING_PRESET_MIDIS.values()): + return None + return tuning + if isinstance(tuning, list): + expected = len(STANDARD_OPEN_MIDIS.get(key, [])) + if len(tuning) != expected: + return None + if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning): + return None + return list(tuning) + return None + + +def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]: + """Validate one persisted host instrument profile.""" + base = dict(PROFILE_DEFAULTS.get(profile_id, {})) + if not base: + return None, f"unknown instrument profile: {profile_id}" + if raw is None: + return base, None + if not isinstance(raw, dict): + return None, f"instrument_profiles.{profile_id} must be an object" + + instrument = raw.get("instrument", base["instrument"]) + if instrument not in ("guitar", "bass"): + return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'" + + try: + string_count = int(raw.get("string_count", base["string_count"])) + except (TypeError, ValueError, OverflowError): + return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument" + key = instrument_key(instrument, string_count) + if key not in STANDARD_OPEN_MIDIS: + return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument" + + tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"])) + if tuning is None: + return None, f"instrument_profiles.{profile_id}.tuning must match {key}" + + ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"])) + if ref is None: + return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450" + + label = raw.get("label", base["label"]) + if not isinstance(label, str) or len(label) > 64: + return None, f"instrument_profiles.{profile_id}.label must be a short string" + role = raw.get("role", base["role"]) + if not isinstance(role, str) or len(role) > 32: + return None, f"instrument_profiles.{profile_id}.role must be a short string" + pathway = raw.get("pathway", base["pathway"]) + if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS: + return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio" + + out = dict(base) + out.update({ + "id": profile_id, + "label": label, + "instrument": instrument, + "role": role, + "string_count": string_count, + "tuning": tuning, + "reference_pitch": ref, + "pathway": pathway, + }) + return out, None + + +def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]: + """Validate persisted host profiles, filling omitted built-ins with defaults.""" + if raw_profiles is None: + return default_instrument_profiles(), None + if not isinstance(raw_profiles, dict): + return None, "instrument_profiles must be an object" + profiles = {} + for profile_id in PROFILE_IDS: + profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id)) + if error: + return None, error + profiles[profile_id] = profile + return profiles, None + + +def active_profile_id(raw) -> str: + return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE + + +def profile_from_legacy_settings(cfg: dict) -> dict: + """Build an active profile from the old flat settings keys.""" + instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar" + fallback_sc = 4 if instrument == "bass" else 6 + try: + sc = int(cfg.get("string_count", fallback_sc)) + except (TypeError, ValueError, OverflowError): + sc = fallback_sc + key = instrument_key(instrument, sc) + if key not in STANDARD_OPEN_MIDIS: + sc = fallback_sc + key = instrument_key(instrument, sc) + tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard" + ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH + pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs" + profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE + profile = dict(PROFILE_DEFAULTS[profile_id]) + profile.update({ + "instrument": instrument, + "string_count": sc, + "tuning": tuning, + "reference_pitch": ref, + "pathway": pathway, + }) + return profile + + +def settings_with_instrument_profiles(cfg: dict) -> dict: + """Return settings with canonical host profiles and mirrored flat keys.""" + out = dict(cfg) + profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles")) + if profiles is None: + profiles = default_instrument_profiles() + if "instrument_profiles" not in out: + legacy = profile_from_legacy_settings(out) + profiles[legacy["id"]] = legacy + # Default the active profile to the one migrated from the legacy flat + # fields, but DON'T clobber an explicit request — a fresh-config + # `POST {"active_instrument_profile": "bass"}` must switch, not be + # overwritten by the guitar-lead inferred from defaults. active_profile_id + # below normalizes an invalid value. + out.setdefault("active_instrument_profile", legacy["id"]) + active = active_profile_id(out.get("active_instrument_profile")) + selected = profiles[active] + out["instrument_profiles"] = profiles + out["active_instrument_profile"] = active + out["instrument"] = selected["instrument"] + out["string_count"] = selected["string_count"] + out["tuning"] = selected["tuning"] + out["reference_pitch"] = selected["reference_pitch"] + out["pathway"] = selected["pathway"] + return out + + +def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: + """Mirror legacy flat instrument updates into the active host profile.""" + out = settings_with_instrument_profiles(cfg) + if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")): + return out + active = active_profile_id(out.get("active_instrument_profile")) + if "instrument" in updates: + active = "bass" if updates["instrument"] == "bass" else "guitar-lead" + out["active_instrument_profile"] = active + current = dict(out["instrument_profiles"][active]) + + if "instrument" in updates: + current["instrument"] = updates["instrument"] + if "string_count" not in updates: + current["string_count"] = 4 if updates["instrument"] == "bass" else 6 + if "string_count" in updates: + current["string_count"] = updates["string_count"] + if "reference_pitch" in updates: + current["reference_pitch"] = updates["reference_pitch"] + if "pathway" in updates: + current["pathway"] = updates["pathway"] + if "tuning" in updates: + current["tuning"] = updates["tuning"] + else: + key = instrument_key(current["instrument"], current["string_count"]) + if _valid_tuning_for_key(key, current.get("tuning")) is None: + current["tuning"] = "Standard" + + profile, error = normalize_instrument_profile(active, current) + if error: + raise ValueError(error) + out["instrument_profiles"][active] = profile + out.update({ + "instrument": profile["instrument"], + "string_count": profile["string_count"], + "tuning": profile["tuning"], + "reference_pitch": profile["reference_pitch"], + "pathway": profile["pathway"], + }) + return out + def tuning_name(offsets: list[int]) -> str: # All three pattern checks below are gated on `len(offsets) == 6`. The # naming conventions here are 6-string-specific — e.g. a 7-string all-zeros diff --git a/server.py b/server.py index c78355c..9ed3b7e 100644 --- a/server.py +++ b/server.py @@ -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 diff --git a/static/app.js b/static/app.js index 50a6181..d04826f 100644 --- a/static/app.js +++ b/static/app.js @@ -2758,6 +2758,12 @@ function goFavTreePage(p) { // ── Settings ───────────────────────────────────────────────────────────── let _defaultArrangement = ''; +const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio']; + +function _normalizeInstrumentPathway(value) { + return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs'; +} + function _syncDefaultArrangementSelect(value) { const sel = document.getElementById('default-arrangement'); if (!sel) return; @@ -3410,6 +3416,8 @@ async function loadSettings() { if (dlcEl) dlcEl.value = data.dlc_dir || ''; _defaultArrangement = data.default_arrangement || ''; _syncDefaultArrangementSelect(_defaultArrangement); + const pathwayEl = document.getElementById('setting-instrument-pathway'); + if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway); const demucsEl = document.getElementById('demucs-server-url'); if (demucsEl) demucsEl.value = data.demucs_server_url || ''; const leftyEl = document.getElementById('setting-lefty'); @@ -3901,6 +3909,18 @@ function persistSetting(key, value) { _settingSaveChain = next.catch(() => {}); return next; } +function setInstrumentPathway(value) { + const pathway = _normalizeInstrumentPathway(value); + const el = document.getElementById('setting-instrument-pathway'); + if (el) el.value = pathway; + persistSetting('pathway', pathway).then(() => { + if (window.v3Badges && typeof window.v3Badges.reload === 'function') { + try { window.v3Badges.reload(); } catch (_) { /* noop */ } + } + }); +} + + async function _postSetting(key, value) { const status = document.getElementById('settings-status'); try { diff --git a/static/capabilities/working-tuning.js b/static/capabilities/working-tuning.js index e000692..133447c 100644 --- a/static/capabilities/working-tuning.js +++ b/static/capabilities/working-tuning.js @@ -305,7 +305,7 @@ return fetch('/api/tunings') .then(function (r) { return r && r.ok ? r.json() : null; }) .then(function (t) { - const byName = t && t[key]; + const byName = t && ((t.tunings && t.tunings[key]) || t[key]); commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null); }) .catch(function () { commit(null); }); diff --git a/static/v3/badges.js b/static/v3/badges.js index 0157cd7..c67b3e1 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -21,7 +21,13 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); - const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] }; + const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] }; + const PATHWAY_OPTIONS = [ + { id: 'songs', label: 'Songs' }, + { id: 'practice', label: 'Practice' }, + { id: 'learn', label: 'Learn' }, + { id: 'studio', label: 'Studio' }, + ]; // Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from // GET /api/tunings. Falls back to empty arrays until the fetch resolves. let _tuningsByKey = {}; @@ -106,7 +112,7 @@ } } - let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 }; + let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; async function loadTunings() { try { @@ -126,6 +132,15 @@ } catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ } } + function pathwayForProfile(profiles, profileId, fallback) { + const p = profiles && profiles[profileId]; + return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs'); + } + + function profileIdForInstrument(inst) { + return inst === 'bass' ? 'bass' : 'guitar-lead'; + } + async function loadSettings() { try { const r = await fetch('/api/settings'); @@ -150,16 +165,34 @@ if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard'); else if (Array.isArray(s.tuning)) tuning = s.tuning; else tuning = tunings[0] || 'Standard'; + const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {}; + const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs'; settings = { instrument: instrument, string_count: scValid, tuning: tuning, reference_pitch: Math.min(450, Math.max(430, ref)), + pathway: pathway, + instrument_profiles: profiles, + active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument), }; } } catch (e) { /* settings endpoint always present */ } } + function syncLocalProfilePatch(patch) { + const profileId = profileIdForInstrument(patch.instrument || settings.instrument); + if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {}; + if (patch.instrument) settings.active_instrument_profile = profileId; + const profile = Object.assign({}, settings.instrument_profiles[profileId] || {}); + let changed = false; + if (patch.instrument) { profile.instrument = patch.instrument; changed = true; } + if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; } + if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; } + if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; } + if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; } + if (changed) settings.instrument_profiles[profileId] = profile; + } async function saveSettings(patch) { // Only adopt the patch once the server accepts it. /api/settings returns // {error: ...} with HTTP 200 on a validation failure, so a rejected @@ -177,8 +210,9 @@ } catch (e) { /* non-fatal — leave settings unchanged */ } if (!accepted) return false; Object.assign(settings, patch); + syncLocalProfilePatch(patch); if (sm && sm.emit) sm.emit('instrument:changed', { - instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, + instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway, }); pushToTuner(); renderTuner(); // reflect new tuning on the tuner card @@ -424,6 +458,9 @@ // (picking a named tuning still works and replaces the custom one). (typeof settings.tuning === 'string' ? '' : '') + _tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '' + esc(t) + '').join('') + '' + + '
Pathway
' + + '
' + '
Reference pitch' + settings.reference_pitch + ' Hz
' + '
' + ''; @@ -454,6 +491,7 @@ instrument: v, string_count: newSc, tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning), + pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway), }); // Only move the working-tuning context once the switch was actually persisted — // otherwise the selector stays on the old instrument while the card shows the @@ -462,11 +500,21 @@ renderInstrument(); keepOpen(); })); menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => { - await saveSettings({ string_count: Number(b.getAttribute('data-val')) }); - setWorkingInstrument(settings.instrument, settings.string_count); + const newSc = Number(b.getAttribute('data-val')); + // Clamp the tuning to one valid for the new string count and post it + // alongside string_count — otherwise the backend silently resets a + // now-invalid tuning to Standard while this UI keeps showing the old + // one (settings/tuner desync). Mirrors the instrument-switch clamp. + const tunings = _tuningsForInstrument(settings.instrument, newSc); + await saveSettings({ + string_count: newSc, + tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning), + }); + setWorkingInstrument(settings.instrument, newSc); renderInstrument(); keepOpen(); })); menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value })); + menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value })); const ref = menu.querySelector('[data-inst-ref]'); ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; }); ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) })); diff --git a/static/v3/index.html b/static/v3/index.html index 41f9e76..2196535 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -429,6 +429,23 @@ + +
+ +
+
Instrument pathway
+
Preferred path for the selected instrument. This is remembered per instrument profile.
+
+
+ +
+
diff --git a/static/v3/settings.js b/static/v3/settings.js index 31e83fb..b961b7c 100644 --- a/static/v3/settings.js +++ b/static/v3/settings.js @@ -28,7 +28,7 @@ var RESET_MAP = { gameplay: { server: ['master_difficulty', 'av_offset_ms', 'miss_penalty', - 'fail_behavior', 'countdown_before_song', 'default_arrangement'], + 'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'], local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'], after: function () { // Left-handed is held on the highway object, not re-derived diff --git a/tests/js/working_tuning.test.js b/tests/js/working_tuning.test.js index c31bcc9..3a94b3a 100644 --- a/tests/js/working_tuning.test.js +++ b/tests/js/working_tuning.test.js @@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness'); const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js'); const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js'); -// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets. -const TUNINGS = { +// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets. +const TUNING_TABLE = { 'guitar-6': { Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63], 'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63], @@ -26,6 +26,7 @@ const TUNINGS = { Standard: [30.87, 41.20, 55.00, 73.42, 98.00], }, }; +const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE }; function deferred() { let resolve; @@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => { const { wt, changes } = loadWorkingTuning({ '/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 }, - '/api/tunings': TUNINGS, + '/api/tunings': API_TUNINGS, }); await flush(); const s = wt.get('guitar-6'); @@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t const settings = deferred(); const { wt } = loadWorkingTuning({ '/api/settings': settings.promise, // held open - '/api/tunings': TUNINGS, + '/api/tunings': API_TUNINGS, }); // A consumer writes before the seed lands. wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' }); diff --git a/tests/test_settings_api.py b/tests/test_settings_api.py index e79b230..aae4a11 100644 --- a/tests/test_settings_api.py +++ b/tests/test_settings_api.py @@ -753,29 +753,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path): assert data["fail_behavior"] == "continue" + +def test_get_settings_exposes_default_instrument_profiles(client, tmp_path): + data = client.get("/api/settings").json() + assert data["active_instrument_profile"] == "guitar-lead" + assert set(data["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"} + assert data["instrument"] == "guitar" + assert data["string_count"] == 6 + assert data["tuning"] == "Standard" + assert data["pathway"] == "songs" + + +def test_post_flat_instrument_updates_active_profile(client, tmp_path): + r = client.post("/api/settings", json={"instrument": "bass", "pathway": "practice"}) + assert r.status_code == 200 + cfg = _read_cfg(tmp_path) + assert cfg["active_instrument_profile"] == "bass" + assert cfg["instrument"] == "bass" + assert cfg["string_count"] == 4 + assert cfg["tuning"] == "Standard" + assert cfg["pathway"] == "practice" + assert cfg["instrument_profiles"]["bass"]["string_count"] == 4 + assert cfg["instrument_profiles"]["bass"]["pathway"] == "practice" + + +def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path): + r = client.post("/api/settings", json={ + "active_instrument_profile": "guitar-rhythm", + "instrument_profiles": { + "guitar-rhythm": { + "string_count": 7, + "tuning": "Drop A", + "reference_pitch": 432, + "pathway": "studio", + }, + "bass": { + "string_count": 6, + "tuning": "C Standard", + }, + }, + }) + assert r.status_code == 200 + cfg = _read_cfg(tmp_path) + assert cfg["active_instrument_profile"] == "guitar-rhythm" + assert cfg["instrument"] == "guitar" + assert cfg["string_count"] == 7 + assert cfg["tuning"] == "Drop A" + assert cfg["reference_pitch"] == 432 + assert cfg["pathway"] == "studio" + + +def test_post_pathway_rejects_bad_value(client, tmp_path): + (tmp_path / "config.json").write_text(json.dumps({"pathway": "songs"})) + r = client.post("/api/settings", json={"pathway": "invalid"}) + assert "error" in r.json() + assert _read_cfg(tmp_path)["pathway"] == "songs" + + +def test_post_instrument_profiles_rejects_bad_custom_string_count(client, tmp_path): + r = client.post("/api/settings", json={ + "instrument_profiles": { + "bass": {"string_count": 6, "tuning": [0, 0, 0, 0]}, + }, + }) + assert "error" in r.json() + # ── /api/settings/reset ───────────────────────────────────────────────────── def test_reset_clears_requested_keys(client, tmp_path): (tmp_path / "config.json").write_text(json.dumps({ "master_difficulty": 40, "countdown_before_song": True, + "pathway": "studio", "default_arrangement": "Lead", "demucs_server_url": "http://demucs.example:9000", })) r = client.post("/api/settings/reset", - json={"keys": ["master_difficulty", "countdown_before_song"]}) + json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]}) assert r.status_code == 200 body = r.json() - assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"} + assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"} cfg = _read_cfg(tmp_path) # Reset removes the key so GET falls back to the default. assert "master_difficulty" not in cfg assert "countdown_before_song" not in cfg + assert "pathway" not in cfg # Unlisted keys are untouched. assert cfg["default_arrangement"] == "Lead" assert cfg["demucs_server_url"] == "http://demucs.example:9000" +def test_partial_instrument_profiles_update_preserves_others(client, tmp_path): + # /api/settings is a partial-merge endpoint, so a POST that carries only ONE + # instrument profile must not reset the others to defaults. + gl = client.get("/api/settings").json()["instrument_profiles"]["guitar-lead"] + gl = dict(gl); gl["tuning"] = "Drop D" + client.post("/api/settings", json={"instrument_profiles": {"guitar-lead": gl}}) + assert (client.get("/api/settings").json()["instrument_profiles"] + ["guitar-lead"]["tuning"] == "Drop D") + # Now update ONLY bass (Drop D is valid for a 4-string bass). + bass = client.get("/api/settings").json()["instrument_profiles"]["bass"] + bass = dict(bass); bass["tuning"] = "Drop D" + client.post("/api/settings", json={"instrument_profiles": {"bass": bass}}) + out = client.get("/api/settings").json()["instrument_profiles"] + assert out["guitar-lead"]["tuning"] == "Drop D", "the untouched profile survived" + assert out["bass"]["tuning"] == "Drop D" + + +def test_active_profile_switch_on_fresh_config(client, tmp_path): + # A fresh config has no instrument_profiles; an explicit active-profile + # switch must be honored, not overwritten by the profile inferred from the + # legacy flat defaults (guitar-lead). + r = client.post("/api/settings", json={"active_instrument_profile": "bass"}) + assert r.status_code == 200 and "error" not in r.json() + got = client.get("/api/settings").json() + assert got["active_instrument_profile"] == "bass" + assert got["instrument"] == "bass" + + +def test_reset_pathway_reaches_into_instrument_profiles(client, tmp_path): + # pathway is mirrored into every instrument profile, so a Gameplay reset + # that only deleted the flat key would leave GET re-deriving the old value + # from the profile. The reset must reach into the persisted profiles too. + client.post("/api/settings", json={"pathway": "studio"}) + assert client.get("/api/settings").json()["pathway"] == "studio" + profiles = _read_cfg(tmp_path)["instrument_profiles"] + assert any(p["pathway"] == "studio" for p in profiles.values()) + + r = client.post("/api/settings/reset", json={"keys": ["pathway"]}) + assert r.status_code == 200 + assert "pathway" in r.json()["reset"] + # GET re-derives from the profile — which must now be back to the default. + assert client.get("/api/settings").json()["pathway"] == "songs" + for prof in _read_cfg(tmp_path)["instrument_profiles"].values(): + assert prof["pathway"] == "songs" + + def test_reset_ignores_unknown_keys(client, tmp_path): (tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40})) # Unknown / non-resettable keys are silently ignored, not an error, and diff --git a/tests/test_settings_instrument.py b/tests/test_settings_instrument.py index ecbf707..d45724f 100644 --- a/tests/test_settings_instrument.py +++ b/tests/test_settings_instrument.py @@ -31,12 +31,14 @@ def _cfg(tmp_path): def test_instrument_fields_persist(env): srv, tmp = env c = TestClient(srv.app) + # "Drop A" is the 5-string bass drop tuning (its low string is B, not E, so + # "Drop D" is a 4-string tuning — now correctly rejected per-profile). r = c.post("/api/settings", json={"instrument": "bass", "string_count": 5, - "tuning": "Drop D", "reference_pitch": 442}) + "tuning": "Drop A", "reference_pitch": 442}) assert r.status_code == 200 cfg = _cfg(tmp) assert cfg["instrument"] == "bass" and cfg["string_count"] == 5 - assert cfg["tuning"] == "Drop D" and cfg["reference_pitch"] == 442.0 + assert cfg["tuning"] == "Drop A" and cfg["reference_pitch"] == 442.0 # Reflected back through GET. got = c.get("/api/settings").json() assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0 diff --git a/tests/test_tunings.py b/tests/test_tunings.py index 8f67037..b2f67e2 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -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"