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
+364 -33
View File
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
FastAPI / SQLite module-level side effects. FastAPI / SQLite module-level side effects.
""" """
from __future__ import annotations
import math
DEFAULT_REFERENCE_PITCH = 440.0 DEFAULT_REFERENCE_PITCH = 440.0
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then # Canonical open strings, low to high, as MIDI notes. This is the host-level
# tuning name. This is the authoritative source; tuner/routes.py previously # source of truth for guitar/bass tuning profiles; UI surfaces derive names,
# held a copy — it was removed in favour of this one. # frequencies, and semitone offsets from these absolute pitches.
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = { 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": { "guitar-6": {
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63], "Standard": [40, 45, 50, 55, 59, 64],
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13], "Eb Standard": [39, 44, 49, 54, 58, 63],
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63], "D Standard": [38, 43, 48, 53, 57, 62],
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66], "C# Standard": [37, 42, 47, 52, 56, 61],
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66], "C Standard": [36, 41, 46, 51, 55, 60],
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66], "Drop D": [38, 45, 50, 55, 59, 64],
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66], "Drop C": [36, 43, 48, 53, 57, 62],
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66], "Drop B": [35, 42, 47, 52, 56, 61],
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63], "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": { "guitar-7": {
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], "Standard": [35, 40, 45, 50, 55, 59, 64],
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], "Bb Standard": [34, 39, 44, 49, 54, 58, 63],
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], "A Standard": [33, 38, 43, 48, 53, 57, 62],
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63], "G Standard": [31, 36, 41, 46, 51, 55, 60],
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13], "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": { "guitar-8": {
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], "Standard": [30, 35, 40, 45, 50, 55, 59, 64],
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63], "Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], "Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66], "E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18], "Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
}, },
"bass-4": { "bass-4": {
"Standard": [41.20, 55.00, 73.42, 98.00], "Standard": [28, 33, 38, 43],
"Eb Standard": [38.89, 51.91, 69.30, 92.50], "Eb Standard": [27, 32, 37, 42],
"Drop D": [36.71, 55.00, 73.42, 98.00], "D Standard": [26, 31, 36, 41],
"D Standard": [36.71, 48.99, 65.41, 87.31], "C# Standard": [25, 30, 35, 40],
"Drop C": [32.70, 48.99, 65.41, 87.31], "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": { "bass-5": {
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00], "Standard": [23, 28, 33, 38, 43],
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50], "High C": [28, 33, 38, 43, 48],
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00], "Eb Standard": [22, 27, 32, 37, 42],
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31], "D Standard": [21, 26, 31, 36, 41],
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31], "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: def tuning_name(offsets: list[int]) -> str:
# All three pattern checks below are gated on `len(offsets) == 6`. The # 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 # naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
+88 -4
View File
@@ -43,7 +43,12 @@ from song import (
scale_degree_for_pitch, scale_degree_for_pitch,
) )
from audio import find_wem_files, convert_wem 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 sloppak as sloppak_mod
import drums as drums_mod import drums as drums_mod
import notation as notation_mod import notation as notation_mod
@@ -9862,7 +9867,7 @@ def get_tunings():
@app.get("/api/settings") @app.get("/api/settings")
def get_settings(): def get_settings():
cfg = _load_config(CONFIG_DIR / "config.json") 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") @app.post("/api/settings")
@@ -10073,6 +10078,38 @@ def save_settings(data: dict):
else: else:
return {"error": "tuning must be a name (string) or a list of semitone offsets"} 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) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# Critical section — the read-merge-write must be atomic. FastAPI runs # Critical section — the read-merge-write must be atomic. FastAPI runs
# sync handlers in a threadpool, so two concurrent partial POSTs (e.g. # 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: if cfg is None:
cfg = _default_settings() cfg = _default_settings()
cfg.update(updates) 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")) _atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
return {"message": ". ".join(messages) if messages else "Settings saved"} return {"message": ". ".join(messages) if messages else "Settings saved"}
@@ -10100,7 +10160,8 @@ def save_settings(data: dict):
_RESETTABLE_SETTINGS_KEYS = frozenset({ _RESETTABLE_SETTINGS_KEYS = frozenset({
"default_arrangement", "demucs_server_url", "master_difficulty", "default_arrangement", "demucs_server_url", "master_difficulty",
"av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", "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", "achievements_enabled", "use_amp_sims",
}) })
@@ -10125,6 +10186,16 @@ def reset_settings(data: dict):
removed = [k for k in keys if k in cfg] removed = [k for k in keys if k in cfg]
for k in removed: for k in removed:
del cfg[k] 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")) _atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
return {"message": "Settings reset", "reset": removed} 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" return "server_config.tuning offsets must be ≤8 integers between -12 and 12"
else: else:
return "server_config.tuning must be a name (string) or a list of semitone offsets" 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 return None
@@ -10585,6 +10668,7 @@ def export_settings():
server_config = _load_config(config_file) server_config = _load_config(config_file)
if server_config is None: if server_config is None:
server_config = _default_settings() server_config = _default_settings()
server_config = settings_with_instrument_profiles(server_config)
# Snapshot the library DB + custom art FIRST: if the irreplaceable state # 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 # 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: with _settings_lock:
_atomic_write_file( _atomic_write_file(
CONFIG_DIR / "config.json", 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: except OSError as e:
# Phase-1 validation should have caught all foreseeable # Phase-1 validation should have caught all foreseeable
+20
View File
@@ -2758,6 +2758,12 @@ function goFavTreePage(p) {
// ── Settings ───────────────────────────────────────────────────────────── // ── Settings ─────────────────────────────────────────────────────────────
let _defaultArrangement = ''; let _defaultArrangement = '';
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
function _normalizeInstrumentPathway(value) {
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
}
function _syncDefaultArrangementSelect(value) { function _syncDefaultArrangementSelect(value) {
const sel = document.getElementById('default-arrangement'); const sel = document.getElementById('default-arrangement');
if (!sel) return; if (!sel) return;
@@ -3410,6 +3416,8 @@ async function loadSettings() {
if (dlcEl) dlcEl.value = data.dlc_dir || ''; if (dlcEl) dlcEl.value = data.dlc_dir || '';
_defaultArrangement = data.default_arrangement || ''; _defaultArrangement = data.default_arrangement || '';
_syncDefaultArrangementSelect(_defaultArrangement); _syncDefaultArrangementSelect(_defaultArrangement);
const pathwayEl = document.getElementById('setting-instrument-pathway');
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
const demucsEl = document.getElementById('demucs-server-url'); const demucsEl = document.getElementById('demucs-server-url');
if (demucsEl) demucsEl.value = data.demucs_server_url || ''; if (demucsEl) demucsEl.value = data.demucs_server_url || '';
const leftyEl = document.getElementById('setting-lefty'); const leftyEl = document.getElementById('setting-lefty');
@@ -3901,6 +3909,18 @@ function persistSetting(key, value) {
_settingSaveChain = next.catch(() => {}); _settingSaveChain = next.catch(() => {});
return next; 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) { async function _postSetting(key, value) {
const status = document.getElementById('settings-status'); const status = document.getElementById('settings-status');
try { try {
+1 -1
View File
@@ -305,7 +305,7 @@
return fetch('/api/tunings') return fetch('/api/tunings')
.then(function (r) { return r && r.ok ? r.json() : null; }) .then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (t) { .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); commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
}) })
.catch(function () { commit(null); }); .catch(function () { commit(null); });
+53 -5
View File
@@ -21,7 +21,13 @@
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 // 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. // GET /api/tunings. Falls back to empty arrays until the fetch resolves.
let _tuningsByKey = {}; 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() { async function loadTunings() {
try { try {
@@ -126,6 +132,15 @@
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ } } 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() { async function loadSettings() {
try { try {
const r = await fetch('/api/settings'); 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'); 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 if (Array.isArray(s.tuning)) tuning = s.tuning;
else tuning = tunings[0] || 'Standard'; 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 = { settings = {
instrument: instrument, instrument: instrument,
string_count: scValid, string_count: scValid,
tuning: tuning, tuning: tuning,
reference_pitch: Math.min(450, Math.max(430, ref)), 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 */ } } 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) { async function saveSettings(patch) {
// Only adopt the patch once the server accepts it. /api/settings returns // Only adopt the patch once the server accepts it. /api/settings returns
// {error: ...} with HTTP 200 on a validation failure, so a rejected // {error: ...} with HTTP 200 on a validation failure, so a rejected
@@ -177,8 +210,9 @@
} catch (e) { /* non-fatal — leave settings unchanged */ } } catch (e) { /* non-fatal — leave settings unchanged */ }
if (!accepted) return false; if (!accepted) return false;
Object.assign(settings, patch); Object.assign(settings, patch);
syncLocalProfilePatch(patch);
if (sm && sm.emit) sm.emit('instrument:changed', { 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(); pushToTuner();
renderTuner(); // reflect new tuning on the tuner card renderTuner(); // reflect new tuning on the tuner card
@@ -424,6 +458,9 @@
// (picking a named tuning still works and replaces the custom one). // (picking a named tuning still works and replaces the custom one).
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') + (typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' + _tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
'<select data-inst-pathway class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' + '<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' + '<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
'</div></div>'; '</div></div>';
@@ -454,6 +491,7 @@
instrument: v, instrument: v,
string_count: newSc, string_count: newSc,
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning), 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 — // 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 // otherwise the selector stays on the old instrument while the card shows the
@@ -462,11 +500,21 @@
renderInstrument(); keepOpen(); renderInstrument(); keepOpen();
})); }));
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => { menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
await saveSettings({ string_count: Number(b.getAttribute('data-val')) }); const newSc = Number(b.getAttribute('data-val'));
setWorkingInstrument(settings.instrument, settings.string_count); // 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(); renderInstrument(); keepOpen();
})); }));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value })); 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]'); const ref = menu.querySelector('[data-inst-ref]');
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; }); 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) })); ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
+17
View File
@@ -429,6 +429,23 @@
</select> </select>
</div> </div>
</div> </div>
<!-- Instrument pathway -->
<div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
<div class="fb-srow-main">
<div class="fb-srow-title">Instrument pathway</div>
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
</div>
<div class="fb-srow-control">
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="songs">Songs</option>
<option value="practice">Practice</option>
<option value="learn">Learn</option>
<option value="studio">Studio</option>
</select>
</div>
</div>
<!-- Arrangement routes (naming mode) --> <!-- Arrangement routes (naming mode) -->
<div class="fb-srow"> <div class="fb-srow">
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span> <span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
+1 -1
View File
@@ -28,7 +28,7 @@
var RESET_MAP = { var RESET_MAP = {
gameplay: { gameplay: {
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty', 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'], local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
after: function () { after: function () {
// Left-handed is held on the highway object, not re-derived // Left-handed is held on the highway object, not re-derived
+5 -4
View File
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js'); const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.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. // Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
const TUNINGS = { const TUNING_TABLE = {
'guitar-6': { 'guitar-6': {
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63], 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], '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], Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
}, },
}; };
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
function deferred() { function deferred() {
let resolve; 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 () => { test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
const { wt, changes } = loadWorkingTuning({ const { wt, changes } = loadWorkingTuning({
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 }, '/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
'/api/tunings': TUNINGS, '/api/tunings': API_TUNINGS,
}); });
await flush(); await flush();
const s = wt.get('guitar-6'); 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 settings = deferred();
const { wt } = loadWorkingTuning({ const { wt } = loadWorkingTuning({
'/api/settings': settings.promise, // held open '/api/settings': settings.promise, // held open
'/api/tunings': TUNINGS, '/api/tunings': API_TUNINGS,
}); });
// A consumer writes before the seed lands. // A consumer writes before the seed lands.
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' }); wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
+115 -2
View File
@@ -753,29 +753,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path):
assert data["fail_behavior"] == "continue" 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 ───────────────────────────────────────────────────── # ── /api/settings/reset ─────────────────────────────────────────────────────
def test_reset_clears_requested_keys(client, tmp_path): def test_reset_clears_requested_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({ (tmp_path / "config.json").write_text(json.dumps({
"master_difficulty": 40, "master_difficulty": 40,
"countdown_before_song": True, "countdown_before_song": True,
"pathway": "studio",
"default_arrangement": "Lead", "default_arrangement": "Lead",
"demucs_server_url": "http://demucs.example:9000", "demucs_server_url": "http://demucs.example:9000",
})) }))
r = client.post("/api/settings/reset", 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 assert r.status_code == 200
body = r.json() 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) cfg = _read_cfg(tmp_path)
# Reset removes the key so GET falls back to the default. # Reset removes the key so GET falls back to the default.
assert "master_difficulty" not in cfg assert "master_difficulty" not in cfg
assert "countdown_before_song" not in cfg assert "countdown_before_song" not in cfg
assert "pathway" not in cfg
# Unlisted keys are untouched. # Unlisted keys are untouched.
assert cfg["default_arrangement"] == "Lead" assert cfg["default_arrangement"] == "Lead"
assert cfg["demucs_server_url"] == "http://demucs.example:9000" 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): def test_reset_ignores_unknown_keys(client, tmp_path):
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40})) (tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
# Unknown / non-resettable keys are silently ignored, not an error, and # Unknown / non-resettable keys are silently ignored, not an error, and
+4 -2
View File
@@ -31,12 +31,14 @@ def _cfg(tmp_path):
def test_instrument_fields_persist(env): def test_instrument_fields_persist(env):
srv, tmp = env srv, tmp = env
c = TestClient(srv.app) 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, 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 assert r.status_code == 200
cfg = _cfg(tmp) cfg = _cfg(tmp)
assert cfg["instrument"] == "bass" and cfg["string_count"] == 5 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. # Reflected back through GET.
got = c.get("/api/settings").json() got = c.get("/api/settings").json()
assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0 assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0
+118 -1
View File
@@ -2,7 +2,31 @@
import pytest 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) ───────────────── # ── 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 # 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. # is effectively dead code for this case — this test documents the behavior.
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D" 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"