diff --git a/lib/tunings.py b/lib/tunings.py index 6abfb5a..9de3ab8 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -149,6 +149,7 @@ 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": { @@ -159,6 +160,7 @@ PROFILE_DEFAULTS: dict[str, dict] = { "string_count": 6, "tuning": "Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", }, "guitar-rhythm": { "id": "guitar-rhythm", @@ -168,6 +170,7 @@ PROFILE_DEFAULTS: dict[str, dict] = { "string_count": 6, "tuning": "Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", }, "bass": { "id": "bass", @@ -177,6 +180,7 @@ PROFILE_DEFAULTS: dict[str, dict] = { "string_count": 4, "tuning": "Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, + "pathway": "songs", }, } @@ -250,6 +254,9 @@ def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str 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({ @@ -260,6 +267,7 @@ def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str "string_count": string_count, "tuning": tuning, "reference_pitch": ref, + "pathway": pathway, }) return out, None @@ -297,6 +305,7 @@ def profile_from_legacy_settings(cfg: dict) -> dict: 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({ @@ -304,6 +313,7 @@ def profile_from_legacy_settings(cfg: dict) -> dict: "string_count": sc, "tuning": tuning, "reference_pitch": ref, + "pathway": pathway, }) return profile @@ -326,13 +336,14 @@ def settings_with_instrument_profiles(cfg: dict) -> dict: 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")): + 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: @@ -348,6 +359,8 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: 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: @@ -364,6 +377,7 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: "string_count": profile["string_count"], "tuning": profile["tuning"], "reference_pitch": profile["reference_pitch"], + "pathway": profile["pathway"], }) return out @@ -408,4 +422,4 @@ def tuning_name(offsets: list[int]) -> str: if not offsets: return "Unknown" - return "Custom Tuning" \ No newline at end of file + return "Custom Tuning" diff --git a/server.py b/server.py index ceb72b7..1321840 100644 --- a/server.py +++ b/server.py @@ -43,7 +43,7 @@ from song import ( ) from audio import find_wem_files, convert_wem from tunings import ( - DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, + DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, PROFILE_IDS, PROFILE_PATHWAYS, apply_flat_instrument_patch_to_profiles, apply_reference_pitch, normalize_instrument_profiles, settings_with_instrument_profiles, tuning_name, ) @@ -9324,6 +9324,13 @@ def save_settings(data: dict): else: return {"error": "tuning must be a name (string) or a list of semitone offsets"} + if "pathway" in data: + raw = data["pathway"] + if raw is not None: + if not isinstance(raw, str) or raw not in PROFILE_PATHWAYS: + return {"error": "pathway must be one of songs, practice, learn, studio"} + updates["pathway"] = raw + if "instrument_profiles" in data: raw = data["instrument_profiles"] if raw is not None: @@ -9369,7 +9376,7 @@ def save_settings(data: dict): _RESETTABLE_SETTINGS_KEYS = frozenset({ "default_arrangement", "demucs_server_url", "master_difficulty", "av_offset_ms", "countdown_before_song", "miss_penalty", "fail_behavior", - "reference_pitch", "instrument", "string_count", "tuning", + "reference_pitch", "instrument", "string_count", "tuning", "pathway", "instrument_profiles", "active_instrument_profile", "achievements_enabled", "use_amp_sims", }) @@ -9486,6 +9493,10 @@ def _validate_server_config_types(cfg: dict) -> str | None: return "server_config.tuning offsets must be ≤8 integers between -12 and 12" else: return "server_config.tuning must be a name (string) or a list of semitone offsets" + if "pathway" in cfg: + v = cfg["pathway"] + if v is not None and (not isinstance(v, str) or v not in PROFILE_PATHWAYS): + return "server_config.pathway must be one of songs, practice, learn, studio" if "instrument_profiles" in cfg: profiles, error = normalize_instrument_profiles(cfg["instrument_profiles"]) if error: @@ -12076,4 +12087,3 @@ def index_v2(): # Always serve the classic v2 UI, independent of the env var, so the # fallback is reachable without flipping FEEDBACK_UI. return FileResponse(str(STATIC_DIR / "index.html")) - diff --git a/static/app.js b/static/app.js index c9c5ed7..5f0f1ae 100644 --- a/static/app.js +++ b/static/app.js @@ -2758,6 +2758,12 @@ function goFavTreePage(p) { // ── Settings ───────────────────────────────────────────────────────────── let _defaultArrangement = ''; +const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio']; + +function _normalizeInstrumentPathway(value) { + return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs'; +} + function _syncDefaultArrangementSelect(value) { const sel = document.getElementById('default-arrangement'); if (!sel) return; @@ -3410,6 +3416,8 @@ async function loadSettings() { if (dlcEl) dlcEl.value = data.dlc_dir || ''; _defaultArrangement = data.default_arrangement || ''; _syncDefaultArrangementSelect(_defaultArrangement); + const pathwayEl = document.getElementById('setting-instrument-pathway'); + if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway); const demucsEl = document.getElementById('demucs-server-url'); if (demucsEl) demucsEl.value = data.demucs_server_url || ''; const leftyEl = document.getElementById('setting-lefty'); @@ -3901,6 +3909,18 @@ function persistSetting(key, value) { _settingSaveChain = next.catch(() => {}); return next; } +function setInstrumentPathway(value) { + const pathway = _normalizeInstrumentPathway(value); + const el = document.getElementById('setting-instrument-pathway'); + if (el) el.value = pathway; + persistSetting('pathway', pathway).then(() => { + if (window.v3Badges && typeof window.v3Badges.reload === 'function') { + try { window.v3Badges.reload(); } catch (_) { /* noop */ } + } + }); +} + + async function _postSetting(key, value) { const status = document.getElementById('settings-status'); try { diff --git a/static/v3/badges.js b/static/v3/badges.js index 21d2ce5..20e94a1 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -22,6 +22,12 @@ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] }; + const PATHWAY_OPTIONS = [ + { id: 'songs', label: 'Songs' }, + { id: 'practice', label: 'Practice' }, + { id: 'learn', label: 'Learn' }, + { id: 'studio', label: 'Studio' }, + ]; // Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from // GET /api/tunings. Falls back to empty arrays until the fetch resolves. let _tuningsByKey = {}; @@ -106,7 +112,7 @@ } } - let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 }; + let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; async function loadTunings() { try { @@ -126,6 +132,15 @@ } catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ } } + function pathwayForProfile(profiles, profileId, fallback) { + const p = profiles && profiles[profileId]; + return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs'); + } + + function profileIdForInstrument(inst) { + return inst === 'bass' ? 'bass' : 'guitar-lead'; + } + async function loadSettings() { try { const r = await fetch('/api/settings'); @@ -150,16 +165,34 @@ if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard'); else if (Array.isArray(s.tuning)) tuning = s.tuning; else tuning = tunings[0] || 'Standard'; + const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {}; + const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs'; settings = { instrument: instrument, string_count: scValid, tuning: tuning, reference_pitch: Math.min(450, Math.max(430, ref)), + pathway: pathway, + instrument_profiles: profiles, + active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument), }; } } catch (e) { /* settings endpoint always present */ } } + function syncLocalProfilePatch(patch) { + const profileId = profileIdForInstrument(patch.instrument || settings.instrument); + if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {}; + if (patch.instrument) settings.active_instrument_profile = profileId; + const profile = Object.assign({}, settings.instrument_profiles[profileId] || {}); + let changed = false; + if (patch.instrument) { profile.instrument = patch.instrument; changed = true; } + if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; } + if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; } + if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; } + if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; } + if (changed) settings.instrument_profiles[profileId] = profile; + } async function saveSettings(patch) { // Only adopt the patch once the server accepts it. /api/settings returns // {error: ...} with HTTP 200 on a validation failure, so a rejected @@ -177,8 +210,9 @@ } catch (e) { /* non-fatal — leave settings unchanged */ } if (!accepted) return false; Object.assign(settings, patch); + syncLocalProfilePatch(patch); if (sm && sm.emit) sm.emit('instrument:changed', { - instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, + instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway, }); pushToTuner(); renderTuner(); // reflect new tuning on the tuner card @@ -424,6 +458,9 @@ // (picking a named tuning still works and replaces the custom one). (typeof settings.tuning === 'string' ? '' : '') + _tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '' + esc(t) + '').join('') + '' + + '
Pathway
' + + '
' + '
Reference pitch' + settings.reference_pitch + ' Hz
' + '
' + ''; @@ -454,6 +491,7 @@ instrument: v, string_count: newSc, tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning), + pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway), }); // Only move the working-tuning context once the switch was actually persisted — // otherwise the selector stays on the old instrument while the card shows the @@ -467,6 +505,7 @@ renderInstrument(); keepOpen(); })); menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value })); + menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value })); const ref = menu.querySelector('[data-inst-ref]'); ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; }); ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) })); diff --git a/static/v3/index.html b/static/v3/index.html index 4b25ed2..2e6736a 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -429,6 +429,23 @@ + +
+ +
+
Instrument pathway
+
Preferred path for the selected instrument. This is remembered per instrument profile.
+
+
+ +
+
diff --git a/static/v3/settings.js b/static/v3/settings.js index 31e83fb..b961b7c 100644 --- a/static/v3/settings.js +++ b/static/v3/settings.js @@ -28,7 +28,7 @@ var RESET_MAP = { gameplay: { server: ['master_difficulty', 'av_offset_ms', 'miss_penalty', - 'fail_behavior', 'countdown_before_song', 'default_arrangement'], + 'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'], local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'], after: function () { // Left-handed is held on the highway object, not re-derived diff --git a/tests/test_settings_api.py b/tests/test_settings_api.py index 8a54563..487466a 100644 --- a/tests/test_settings_api.py +++ b/tests/test_settings_api.py @@ -752,17 +752,20 @@ def test_get_settings_exposes_default_instrument_profiles(client, tmp_path): 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"}) + 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): @@ -773,6 +776,7 @@ def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path): "string_count": 7, "tuning": "Drop A", "reference_pitch": 432, + "pathway": "studio", }, "bass": { "string_count": 6, @@ -787,6 +791,14 @@ def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path): 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): @@ -803,18 +815,20 @@ def test_reset_clears_requested_keys(client, tmp_path): (tmp_path / "config.json").write_text(json.dumps({ "master_difficulty": 40, "countdown_before_song": True, + "pathway": "studio", "default_arrangement": "Lead", "demucs_server_url": "http://demucs.example:9000", })) r = client.post("/api/settings/reset", - json={"keys": ["master_difficulty", "countdown_before_song"]}) + json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]}) assert r.status_code == 200 body = r.json() - assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"} + assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"} cfg = _read_cfg(tmp_path) # Reset removes the key so GET falls back to the default. assert "master_difficulty" not in cfg assert "countdown_before_song" not in cfg + assert "pathway" not in cfg # Unlisted keys are untouched. assert cfg["default_arrangement"] == "Lead" assert cfg["demucs_server_url"] == "http://demucs.example:9000" @@ -841,4 +855,3 @@ def test_reset_with_no_config_is_noop(client, tmp_path): r = client.post("/api/settings/reset", json={"keys": ["master_difficulty"]}) assert r.status_code == 200 assert r.json()["reset"] == [] - diff --git a/tests/test_tunings.py b/tests/test_tunings.py index 661e896..d788ea2 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -186,6 +186,8 @@ def test_settings_profiles_default_to_lead_rhythm_and_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(): @@ -194,11 +196,14 @@ def test_settings_profiles_migrate_legacy_flat_bass_selection(): "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(): @@ -208,6 +213,13 @@ def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys(): 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"}) @@ -223,5 +235,3 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning(): patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7}) assert patched["string_count"] == 7 assert patched["tuning"] == "Standard" - -