mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-15 13:17:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
41e907fa52
commit
a86abadb14
+364
-33
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -21,7 +21,13 @@
|
|||||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
// 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) }));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user