mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 04:34:30 +00:00
settings: add host instrument profiles
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
This commit is contained in:
+333
-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,225 @@ def apply_reference_pitch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
"guitar-rhythm": {
|
||||||
|
"id": "guitar-rhythm",
|
||||||
|
"label": "Rhythm Guitar",
|
||||||
|
"instrument": "guitar",
|
||||||
|
"role": "rhythm",
|
||||||
|
"string_count": 6,
|
||||||
|
"tuning": "Standard",
|
||||||
|
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||||
|
},
|
||||||
|
"bass": {
|
||||||
|
"id": "bass",
|
||||||
|
"label": "Bass",
|
||||||
|
"instrument": "bass",
|
||||||
|
"role": "bass",
|
||||||
|
"string_count": 4,
|
||||||
|
"tuning": "Standard",
|
||||||
|
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
return tuning if tuning in TUNING_PRESET_MIDIS.get(key, {}) and len(tuning) <= 64 else None
|
||||||
|
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"
|
||||||
|
|
||||||
|
out = dict(base)
|
||||||
|
out.update({
|
||||||
|
"id": profile_id,
|
||||||
|
"label": label,
|
||||||
|
"instrument": instrument,
|
||||||
|
"role": role,
|
||||||
|
"string_count": string_count,
|
||||||
|
"tuning": tuning,
|
||||||
|
"reference_pitch": ref,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
out["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"]
|
||||||
|
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")):
|
||||||
|
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 "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"],
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ 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,
|
||||||
|
apply_flat_instrument_patch_to_profiles, apply_reference_pitch,
|
||||||
|
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
|
||||||
@@ -9111,7 +9115,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")
|
||||||
@@ -9320,6 +9324,19 @@ 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 "instrument_profiles" in data:
|
||||||
|
raw = data["instrument_profiles"]
|
||||||
|
if raw is not None:
|
||||||
|
profiles, error = normalize_instrument_profiles(raw)
|
||||||
|
if error:
|
||||||
|
return {"error": error}
|
||||||
|
updates["instrument_profiles"] = profiles
|
||||||
|
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.
|
||||||
@@ -9336,6 +9353,11 @@ 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)
|
||||||
|
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"}
|
||||||
|
|
||||||
@@ -9348,6 +9370,7 @@ _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",
|
||||||
|
"instrument_profiles", "active_instrument_profile",
|
||||||
"achievements_enabled", "use_amp_sims",
|
"achievements_enabled", "use_amp_sims",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -9463,6 +9486,14 @@ 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 "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
|
||||||
|
|
||||||
|
|
||||||
@@ -9832,6 +9863,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
|
||||||
@@ -10074,7 +10106,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
|
||||||
@@ -12044,3 +12076,4 @@ def index_v2():
|
|||||||
# Always serve the classic v2 UI, independent of the env var, so the
|
# Always serve the classic v2 UI, independent of the env var, so the
|
||||||
# fallback is reachable without flipping FEEDBACK_UI.
|
# fallback is reachable without flipping FEEDBACK_UI.
|
||||||
return FileResponse(str(STATIC_DIR / "index.html"))
|
return FileResponse(str(STATIC_DIR / "index.html"))
|
||||||
|
|
||||||
|
|||||||
@@ -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); });
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
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] };
|
||||||
// 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 = {};
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -744,6 +744,59 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_flat_instrument_updates_active_profile(client, tmp_path):
|
||||||
|
r = client.post("/api/settings", json={"instrument": "bass"})
|
||||||
|
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["instrument_profiles"]["bass"]["string_count"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
@@ -788,3 +841,4 @@ def test_reset_with_no_config_is_noop(client, tmp_path):
|
|||||||
r = client.post("/api/settings/reset", json={"keys": ["master_difficulty"]})
|
r = client.post("/api/settings/reset", json={"keys": ["master_difficulty"]})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["reset"] == []
|
assert r.json()["reset"] == []
|
||||||
|
|
||||||
|
|||||||
+94
-1
@@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tunings import tuning_name
|
from tunings import (
|
||||||
|
DEFAULT_TUNINGS,
|
||||||
|
TUNING_PRESET_MIDIS,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Standard tunings (all six strings share the same offset) ─────────────────
|
# ── Standard tunings (all six strings share the same offset) ─────────────────
|
||||||
@@ -132,3 +142,86 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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_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