From 1a5b49f83b358b5f4911925eaca6aa1c4d472ac2 Mon Sep 17 00:00:00 2001 From: Job Date: Thu, 16 Jul 2026 20:43:11 +0200 Subject: [PATCH 01/74] feat: extract instruments from hardcoded logic into plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ~15 scattered hardcoded guitar/bass checks across Python and JS with a unified InstrumentRegistry. Guitar, Bass, Drums, and Keys now ship as bundled instrument plugins under plugins/instrument_/, each defining its own tunings, roles, arrangement mappings, string/key counts, detection strategy, and icon. Backend: - lib/instruments.py — registry with schema validation and offset-to-MIDI - lib/routers/instruments.py — GET /api/instruments endpoint - Plugin loader recognizes type:instrument and registers definitions - lib/tunings.py is registry-aware (profiles, tuning validation, presets) - Arrangement routing uses role flags/names from registry - Progression instrument_for_arrangement accepts registry, returns None for unknown - Settings API validates against registered instrument IDs - /api/tunings populates both tuningMidis AND tunings (fixes tuner sync) - Non-stringed instruments skip string_count/tuning validation Frontend: - badges.js: dynamic instrument pills, key count selector (keyboard), per-instrument icons from plugin assets, tuner grays out for non-pitched - songs.js: auto-filters library by current instrument's arrangement roles, filter options derived from registry - instruments-settings.js: editable instrument cards with custom tunings, arrangement names, string counts. Persisted via instrument_overrides - index.html: new Instruments tab, default_arrangement moved to selector - working-tuning.js: registry-aware instrument normalization --- lib/appstate.py | 1 + lib/instruments.py | 288 ++++++++++++++++ lib/progression.py | 30 +- lib/routers/instruments.py | 10 + lib/routers/settings.py | 31 +- lib/routers/stats.py | 27 +- lib/routers/tunings.py | 56 ++- lib/routers/ws_highway.py | 117 ++++--- lib/tunings.py | 248 +++++++++++--- main.py | 6 + plugins/__init__.py | 24 +- plugins/instrument_bass/assets/icon.svg | 50 +++ plugins/instrument_bass/plugin.json | 60 ++++ plugins/instrument_drums/assets/icon.svg | 28 ++ plugins/instrument_drums/plugin.json | 25 ++ plugins/instrument_guitar/assets/icon.svg | 69 ++++ plugins/instrument_guitar/plugin.json | 72 ++++ plugins/instrument_piano/assets/icon.svg | 18 + plugins/instrument_piano/plugin.json | 27 ++ server.py | 14 +- static/capabilities/working-tuning.js | 33 +- static/v3/badges.js | 210 ++++++++---- static/v3/index.html | 26 +- static/v3/instruments-settings.js | 396 ++++++++++++++++++++++ static/v3/songs.js | 77 ++++- 25 files changed, 1745 insertions(+), 198 deletions(-) create mode 100644 lib/instruments.py create mode 100644 lib/routers/instruments.py create mode 100644 plugins/instrument_bass/assets/icon.svg create mode 100644 plugins/instrument_bass/plugin.json create mode 100644 plugins/instrument_drums/assets/icon.svg create mode 100644 plugins/instrument_drums/plugin.json create mode 100644 plugins/instrument_guitar/assets/icon.svg create mode 100644 plugins/instrument_guitar/plugin.json create mode 100644 plugins/instrument_piano/assets/icon.svg create mode 100644 plugins/instrument_piano/plugin.json create mode 100644 static/v3/instruments-settings.js diff --git a/lib/appstate.py b/lib/appstate.py index c726a48e..04b92053 100644 --- a/lib/appstate.py +++ b/lib/appstate.py @@ -138,6 +138,7 @@ def artist_page(name): "default_settings", "kick_scan", "invalidate_song_caches", "stat_for_cache", "scan_status", "server_root", + "instrument_registry", }) diff --git a/lib/instruments.py b/lib/instruments.py new file mode 100644 index 00000000..d1ab3607 --- /dev/null +++ b/lib/instruments.py @@ -0,0 +1,288 @@ +import logging + +log = logging.getLogger("feedBack.instruments") + +_INSTRUMENT_KINDS = frozenset({"stringed", "percussion", "keyboard", "vocal", "custom"}) +_DETECT_STRATEGIES = frozenset({"pitch", "onset", "midi", "none"}) +_PATH_FLAGS = frozenset({"path_lead", "path_rhythm", "path_bass"}) +_PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio") + + +def _validate_str(value, field_name, max_len=128): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + if len(value) > max_len: + raise ValueError(f"{field_name} must be <= {max_len} chars") + return value.strip() + + +def _validate_optional_str(value, field_name, max_len=256): + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{field_name} must be a string or null") + if len(value) > max_len: + raise ValueError(f"{field_name} must be <= {max_len} chars") + return value + + +def _validate_float_range(value, field_name, lo, hi): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise ValueError(f"{field_name} must be a number") + v = float(value) + if v < lo or v > hi: + raise ValueError(f"{field_name} must be in [{lo}, {hi}]") + return v + + +def _validate_midi_list(lst, field_name, expected_len): + if not isinstance(lst, list): + raise ValueError(f"{field_name} must be a list") + if len(lst) != expected_len: + raise ValueError(f"{field_name} must have {expected_len} entries, got {len(lst)}") + result = [] + for i, v in enumerate(lst): + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError(f"{field_name}[{i}] must be an integer") + midi = int(v) + if midi < 0 or midi > 127: + raise ValueError(f"{field_name}[{i}] is out of MIDI range (0-127)") + result.append(midi) + return result + + +def _validate_offset_list(lst, field_name, expected_len): + if not isinstance(lst, list): + raise ValueError(f"{field_name} must be a list of offsets") + if len(lst) != expected_len: + raise ValueError(f"{field_name} must have {expected_len} entries, got {len(lst)}") + result = [] + for i, v in enumerate(lst): + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError(f"{field_name}[{i}] must be an integer") + off = int(v) + if off < -12 or off > 12: + raise ValueError(f"{field_name}[{i}] offset {off} out of range [-12, 12]") + result.append(off) + return result + + +class InstrumentRegistry: + def __init__(self): + self._instruments: dict[str, dict] = {} + + def register(self, definition: dict): + inst_id = _validate_str(definition.get("id"), "instrument.id") + if inst_id in self._instruments: + raise ValueError(f"instrument {inst_id!r} is already registered") + label = _validate_str(definition.get("label", inst_id), "instrument.label") + kind = _validate_str(definition.get("kind", "custom"), "instrument.kind") + if kind not in _INSTRUMENT_KINDS: + raise ValueError(f"instrument.kind must be one of {sorted(_INSTRUMENT_KINDS)}") + icon = _validate_optional_str(definition.get("icon"), "instrument.icon") + detect_strategy = definition.get("detect_strategy", "none") + if detect_strategy not in _DETECT_STRATEGIES: + raise ValueError(f"instrument.detect_strategy must be one of {sorted(_DETECT_STRATEGIES)}") + ref_pitch = _validate_float_range( + definition.get("reference_pitch", 440.0), "instrument.reference_pitch", 430, 450, + ) + + string_counts = definition.get("string_counts") + if kind == "stringed": + if not isinstance(string_counts, list) or not string_counts: + raise ValueError("instrument.string_counts must be a non-empty list for stringed instruments") + sc_list = [] + for v in string_counts: + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError("instrument.string_counts entries must be integers") + sc_list.append(int(v)) + string_counts = sorted(set(sc_list)) + else: + string_counts = [] + + default_sc = definition.get("default_string_count", 0) + if kind == "stringed": + if default_sc not in string_counts: + raise ValueError(f"instrument.default_string_count {default_sc} not in string_counts {string_counts}") + else: + default_sc = 0 + + key_counts = definition.get("key_counts") + if kind == "keyboard": + if not isinstance(key_counts, list) or not key_counts: + raise ValueError("instrument.key_counts must be a non-empty list for keyboard instruments") + kc_list = [] + for v in key_counts: + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError("instrument.key_counts entries must be integers") + kc_list.append(int(v)) + key_counts = sorted(set(kc_list)) + else: + key_counts = [] + + default_kc = definition.get("default_key_count", 0) + if kind == "keyboard": + if default_kc not in key_counts: + raise ValueError(f"instrument.default_key_count {default_kc} not in key_counts {key_counts}") + else: + default_kc = 0 + + standard_tunings = {} + raw_std = definition.get("standard_tunings") or {} + if kind == "stringed": + if not isinstance(raw_std, dict): + raise ValueError("instrument.standard_tunings must be a dict") + for sc in string_counts: + key = str(sc) + if key not in raw_std: + raise ValueError(f"instrument.standard_tunings missing key {key!r}") + standard_tunings[key] = _validate_midi_list(raw_std[key], f"standard_tunings[{key}]", sc) + else: + standard_tunings = {} + + tunings = {} + raw_tunes = definition.get("tunings") or {} + if kind == "stringed": + if not isinstance(raw_tunes, dict): + raise ValueError("instrument.tunings must be a dict") + for sc in string_counts: + key = str(sc) + sc_tunings = raw_tunes.get(key) or {} + if "Standard" not in sc_tunings: + raise ValueError(f"instrument.tunings[{key}] must include 'Standard'") + resolved = {} + for t_name, t_offsets in sc_tunings.items(): + resolved[_validate_str(t_name, f"tunings[{key}].name")] = _validate_offset_list( + t_offsets, f"tunings[{key}][{t_name!r}]", sc, + ) + tunings[key] = resolved + else: + tunings = {} + + roles = [] + role_ids = set() + has_default = False + raw_roles = definition.get("roles") or [] + if not isinstance(raw_roles, list): + raise ValueError("instrument.roles must be a list") + for i, role in enumerate(raw_roles): + if not isinstance(role, dict): + raise ValueError(f"instrument.roles[{i}] must be an object") + r_id = _validate_str(role.get("id"), f"roles[{i}].id", 32) + if r_id in role_ids: + raise ValueError(f"duplicate role id {r_id!r}") + role_ids.add(r_id) + r_label = _validate_str(role.get("label", r_id), f"roles[{i}].label", 32) + r_flags = role.get("arrangement_flags") or [] + if not isinstance(r_flags, list): + raise ValueError(f"roles[{i}].arrangement_flags must be a list") + for flag in r_flags: + if flag not in _PATH_FLAGS: + raise ValueError(f"roles[{i}].arrangement_flags: unknown flag {flag!r}") + r_names = role.get("arrangement_names") or [] + if not isinstance(r_names, list): + raise ValueError(f"roles[{i}].arrangement_names must be a list") + r_default = bool(role.get("default")) + if r_default: + if has_default: + raise ValueError("only one role can have default: true") + has_default = True + roles.append({ + "id": r_id, + "label": r_label, + "arrangement_flags": list(set(r_flags)), + "arrangement_names": [n.strip().lower() for n in r_names if isinstance(n, str) and n.strip()], + "default": r_default, + }) + if roles and not has_default: + roles[0]["default"] = True + + normalized = { + "id": inst_id, + "label": label, + "kind": kind, + "icon": icon, + "_plugin_id": definition.get("_plugin_id"), + "string_counts": string_counts, + "default_string_count": default_sc, + "key_counts": key_counts, + "default_key_count": default_kc, + "detect_strategy": detect_strategy, + "reference_pitch": ref_pitch, + "standard_tunings": standard_tunings, + "tunings": tunings, + "roles": roles, + } + self._instruments[inst_id] = normalized + log.info("registered instrument %r (%s)", inst_id, label) + + def unregister(self, instrument_id: str): + if instrument_id in self._instruments: + del self._instruments[instrument_id] + log.info("unregistered instrument %r", instrument_id) + + def get(self, instrument_id: str) -> dict | None: + return self._instruments.get(instrument_id) + + def get_all(self) -> list[dict]: + return list(self._instruments.values()) + + def compute_tuning_midis(self, instrument_id: str, string_count: int, tuning_name: str) -> list[int] | None: + inst = self._instruments.get(instrument_id) + if not inst or inst["kind"] != "stringed": + return None + key = str(string_count) + std = inst["standard_tunings"].get(key) + offsets = (inst["tunings"].get(key) or {}).get(tuning_name) + if not std or not offsets or len(std) != len(offsets): + return None + return [s + o for s, o in zip(std, offsets)] + + def get_tuning_names(self, instrument_id: str, string_count: int) -> list[str]: + inst = self._instruments.get(instrument_id) + if not inst or inst["kind"] != "stringed": + return [] + return list((inst["tunings"].get(str(string_count)) or {}).keys()) + + def get_standard_midis(self, instrument_id: str, string_count: int) -> list[int] | None: + inst = self._instruments.get(instrument_id) + if not inst or inst["kind"] != "stringed": + return None + return inst["standard_tunings"].get(str(string_count)) + + def get_default_role(self, instrument_id: str) -> str | None: + inst = self._instruments.get(instrument_id) + if not inst: + return None + for role in inst["roles"]: + if role["default"]: + return role["id"] + if inst["roles"]: + return inst["roles"][0]["id"] + return None + + def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: dict = None) -> str | None: + inst = self._instruments.get(instrument_id) + if not inst: + return None + name_lower = (arr_name or "").strip().lower() + for role in inst["roles"]: + if name_lower in role["arrangement_names"]: + return role["id"] + if arr_flags: + for flag in role["arrangement_flags"]: + if arr_flags.get(flag): + return role["id"] + return None + + def instrument_id_for_arrangement(self, arr_name: str, arr_flags: dict = None) -> str | None: + name_lower = (arr_name or "").strip().lower() + for inst in self._instruments.values(): + for role in inst["roles"]: + if name_lower in role["arrangement_names"]: + return inst["id"] + if arr_flags: + for flag in role["arrangement_flags"]: + if arr_flags.get(flag): + return inst["id"] + return None diff --git a/lib/progression.py b/lib/progression.py index 777dce52..c2c595f4 100644 --- a/lib/progression.py +++ b/lib/progression.py @@ -328,17 +328,39 @@ def load_content(root) -> tuple[dict, list]: # --------------------------------------------------------------------------- -def instrument_for_arrangement(arr_entry) -> str: +def instrument_for_arrangement(arr_entry, *, registry=None): """Map a library arrangement entry to a progression instrument. archive/loose entries carry ``type`` (lead/rhythm/bass/combo); sloppaks may - only carry ``name``. Vocals are recognised so they never count toward - guitar challenges; everything else defaults to guitar. + only carry ``name``. Checks registered instruments first via the registry; + falls back to hardcoded logic for backward compat. Returns None when the + arrangement doesn't match any known instrument — callers must gate + progression events on a non-None instrument so unknown arrangements + never accrue to the wrong path. """ if not isinstance(arr_entry, dict): + if registry: + return None return "guitar" arr_type = str(arr_entry.get("type") or "").strip().lower() name = str(arr_entry.get("name") or "").strip().lower() + + # Registry check — any registered instrument's roles match? + if registry: + for inst in registry.get_all(): + for role in inst.get("roles", []): + if name in role.get("arrangement_names", []): + return inst["id"] + if arr_type in role.get("arrangement_names", []): + return inst["id"] + # Also check flags: build a virtual arr_entry for path flag matching + for inst in registry.get_all(): + for role in inst.get("roles", []): + for flag in role.get("arrangement_flags", []): + if arr_entry.get(flag): + return inst["id"] + + # Hardcoded fallback for known instruments if arr_type == "bass": return "bass" if arr_type == "drums": @@ -361,6 +383,8 @@ def instrument_for_arrangement(arr_entry) -> str: return "keys" if arr_type in ("lead", "rhythm", "combo"): return "guitar" + if registry: + return None return "guitar" diff --git a/lib/routers/instruments.py b/lib/routers/instruments.py new file mode 100644 index 00000000..5dbb6de9 --- /dev/null +++ b/lib/routers/instruments.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter +import appstate + +router = APIRouter() + + +@router.get("/api/instruments") +def get_instruments(): + reg = getattr(appstate, "instrument_registry", None) + return reg.get_all() if reg else [] diff --git a/lib/routers/settings.py b/lib/routers/settings.py index 853533b1..7d1366f4 100644 --- a/lib/routers/settings.py +++ b/lib/routers/settings.py @@ -25,7 +25,7 @@ from tunings import ( PROFILE_IDS, PROFILE_PATHWAYS, apply_flat_instrument_patch_to_profiles, normalize_instrument_profile, normalize_instrument_profiles, - settings_with_instrument_profiles, + settings_with_instrument_profiles, _valid_instrument_ids, ) import logging @@ -244,8 +244,9 @@ def save_settings(data: dict): if "instrument" in data: raw = data["instrument"] if raw is not None: - if not isinstance(raw, str) or raw not in ("guitar", "bass"): - return {"error": "instrument must be 'guitar' or 'bass'"} + valid_ids = _valid_instrument_ids() + if not isinstance(raw, str) or raw not in valid_ids: + return {"error": "instrument must be one of " + str(sorted(valid_ids))} updates["instrument"] = raw if "string_count" in data: raw = data["string_count"] @@ -257,6 +258,16 @@ def save_settings(data: dict): if sc < 4 or sc > 8: return {"error": "string_count must be an integer 4–8"} updates["string_count"] = sc + if "key_count" in data: + raw = data["key_count"] + if raw is not None: + try: + kc = _as_int(raw) + except (TypeError, ValueError, OverflowError): + return {"error": "key_count must be an integer"} + if kc < 1 or kc > 127: + return {"error": "key_count must be an integer 1–127"} + updates["key_count"] = kc if "tuning" in data: raw = data["tuning"] # Accept a tuning NAME (string ≤64) or a list of up to 8 semitone @@ -303,8 +314,14 @@ def save_settings(data: dict): 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"} + return {"error": "active_instrument_profile must be one of " + ", ".join(PROFILE_IDS)} updates["active_instrument_profile"] = raw + if "instrument_overrides" in data: + raw = data["instrument_overrides"] + if raw is not None: + if not isinstance(raw, dict): + return {"error": "instrument_overrides must be an object"} + updates["instrument_overrides"] = raw appstate.config_dir.mkdir(parents=True, exist_ok=True) # Critical section — the read-merge-write must be atomic. FastAPI runs # sync handlers in a threadpool, so two concurrent partial POSTs (e.g. @@ -455,8 +472,8 @@ def _validate_server_config_types(cfg: dict) -> str | None: return "server_config.reference_pitch must be a number between 430 and 450" if "instrument" in cfg: v = cfg["instrument"] - if v is not None and v not in ("guitar", "bass"): - return "server_config.instrument must be 'guitar' or 'bass'" + if v is not None and v not in _valid_instrument_ids(): + return "server_config.instrument must be one of " + str(sorted(_valid_instrument_ids())) if "string_count" in cfg: v = cfg["string_count"] if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not (4 <= v <= 8)): @@ -483,7 +500,7 @@ def _validate_server_config_types(cfg: dict) -> str | None: 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 "server_config.active_instrument_profile must be one of " + ", ".join(PROFILE_IDS) return None diff --git a/lib/routers/stats.py b/lib/routers/stats.py index 5b8ed442..859fb244 100644 --- a/lib/routers/stats.py +++ b/lib/routers/stats.py @@ -151,20 +151,23 @@ def api_record_stats(data: dict): progression_summary = None try: import progression as progression_mod + reg = getattr(appstate, "instrument_registry", None) instrument = progression_mod.instrument_for_arrangement( - appstate.meta_db.arrangement_entry(filename, arrangement) - ) - progression_summary = appstate.meta_db.record_progression_event( - "song_completed", - { - "filename": filename, - "instrument": instrument, - "accuracy": accuracy, - "score": score, - "is_diagnostic": filename == appstate.builtin_diagnostic_filename(), - }, - appstate.get_progression_content(), + appstate.meta_db.arrangement_entry(filename, arrangement), + registry=reg, ) + if instrument is not None: + progression_summary = appstate.meta_db.record_progression_event( + "song_completed", + { + "filename": filename, + "instrument": instrument, + "accuracy": accuracy, + "score": score, + "is_diagnostic": filename == appstate.builtin_diagnostic_filename(), + }, + appstate.get_progression_content(), + ) except Exception: log.warning("stats side-effects (progression) failed", exc_info=True) return {"stats": row, "progress": progress, "progression": progression_summary} diff --git a/lib/routers/tunings.py b/lib/routers/tunings.py index 723fe6d0..0bee97e8 100644 --- a/lib/routers/tunings.py +++ b/lib/routers/tunings.py @@ -10,7 +10,7 @@ import appstate from appconfig import _load_config -from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis +from tunings import DEFAULT_REFERENCE_PITCH, freqs_to_midis, open_midis_to_freqs, _build_preset_midis, instrument_key router = APIRouter() @@ -34,8 +34,36 @@ def get_tunings(): # references — so serve the integers once, host-side. Additive: the # existing referencePitch/tunings shape is unchanged. tuning_midis: dict[str, dict[str, list[int]]] = {} + preset_midis = _build_preset_midis() + + # Merge custom tunings from instrument_overrides in config.json. + # Custom tunings are stored as offset arrays keyed by instrument_id and + # string count; they need to be resolved to MIDI via the instrument's + # standard tuning reference. + reg = getattr(appstate, "instrument_registry", None) + overrides = (cfg.get("instrument_overrides") or {}) if isinstance(cfg, dict) else {} + if reg and overrides: + for inst_id, ov in overrides.items(): + custom_tunings = ov.get("custom_tunings") if isinstance(ov, dict) else None + if not custom_tunings or not isinstance(custom_tunings, dict): + continue + inst_def = reg.get(inst_id) + if not inst_def or inst_def.get("kind") != "stringed": + continue + std = inst_def.get("standard_tunings") or {} + for sc_key, named_offsets in custom_tunings.items(): + key = instrument_key(inst_id, int(sc_key)) + std_midis = std.get(sc_key) + if not std_midis: + continue + for t_name, offsets in named_offsets.items(): + if isinstance(offsets, list) and len(offsets) == len(std_midis): + midis = [int(s + o) for s, o in zip(std_midis, offsets)] + if all(0 <= m <= 127 for m in midis): + preset_midis.setdefault(key, {})[str(t_name)] = midis + for key, names in merged.items(): - builtin = TUNING_PRESET_MIDIS.get(key, {}) + builtin = preset_midis.get(key, {}) resolved: dict[str, list[int]] = {} for name, freqs in names.items(): midis = builtin.get(name) or freqs_to_midis(freqs, ref) @@ -43,4 +71,28 @@ def get_tunings(): resolved[name] = list(midis) if resolved: tuning_midis[key] = resolved + + # Ensure every key from preset_midis appears in BOTH tuningMidis and tunings + # (frequencies). The tuner plugin reads `tunings` for the instrument key + # list; without this, instrument switches have no effect. + for key, presets in preset_midis.items(): + builtin_freqs = {} + for name, midis in presets.items(): + builtin_freqs[name] = open_midis_to_freqs(midis, ref) + # tuningMidis + if key not in tuning_midis: + tuning_midis[key] = {str(k): list(v) for k, v in presets.items()} + else: + existing = tuning_midis[key] + for name, midis in presets.items(): + if name not in existing: + existing[str(name)] = list(midis) + # tunings (frequencies) — add provider data first, then fill gaps + if key not in merged: + merged[key] = builtin_freqs + else: + for name, freqs in builtin_freqs.items(): + if name not in merged[key]: + merged[key][name] = freqs + return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis} diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index 62cd152b..53c9be76 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -245,47 +245,84 @@ async def _send_keepalives(): except Exception: pass # Instrument routing: load the part that matches the selected instrument so - # "your instrument" and "the chart you play" line up. The default ordering - # is Lead/guitar-first, so without this a bass player gets handed a guitar - # chart (and any tune-check then compares a 4-string bass against a 6-string - # part). Currently routes bass -> a Bass arrangement; guitar — and any - # unknown/future instrument (drums, keys) — falls through to the - # preference/most-notes logic below, which already lands on a guitar part. - # Drums/keys get their own match when those arrangement types + selector - # entries land. Only applies when no explicit arrangement was requested, so - # a manual arrangement switch is always respected. - if sel_instrument.lower() == "bass": - # Candidate bass parts, preferring the structured pathBass flag; the - # normalized smart name (itself pathBass-derived) and raw name are - # fallbacks for sources without the flag. - bass_idxs = [ - i - for i, a in enumerate(song.arrangements) - if getattr(a, "path_bass", False) - or (smart_names[i] or "").lower().startswith("bass") - or "bass" in (getattr(a, "name", "") or "").lower() - ] - if bass_idxs: - # Among the bass parts: (1) honor the saved default-arrangement - # preference if it names one of them (so a bass player who prefers - # "Bass 2"/"Alt. Bass" keeps it), (2) else the canonical main "Bass", - # (3) else the first bass part in order. - pref_bass = -1 - if pref: - for i in bass_idxs: - nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names) - else getattr(song.arrangements[i], "name", "")) - if nm == pref: - pref_bass = i + # "your instrument" and "the chart you play" line up. Uses the instrument + # registry to find matching arrangements by path flags or arrangement names. + if sel_instrument and best < 0: + reg = getattr(appstate, "instrument_registry", None) + inst_def = reg.get(sel_instrument) if reg else None + if inst_def: + matching_idxs = [] + for i, a in enumerate(song.arrangements): + for role in inst_def.get("roles", []): + for flag in role.get("arrangement_flags", []): + if getattr(a, flag, False): + matching_idxs.append(i) + break + else: + sn = (smart_names[i] or "") if i < len(smart_names) else "" + a_name = (getattr(a, "name", "") or "").lower() + for pat in role.get("arrangement_names", []): + if pat == sn.lower() or pat == a_name: + matching_idxs.append(i) + break + else: + continue break - if pref_bass >= 0: - best = pref_bass - else: - best = next( - (i for i in bass_idxs - if (smart_names[i] if i < len(smart_names) else "") == "Bass"), - bass_idxs[0], - ) + if matching_idxs: + pref_match = -1 + if pref: + for i in matching_idxs: + nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names) + else getattr(song.arrangements[i], "name", "")) + if nm == pref: + pref_match = i + break + if pref_match >= 0: + best = pref_match + else: + # Prefer the canonical arrangement matching the instrument's + # default role, then fall back to the first match. + matched = False + for role in inst_def.get("roles", []): + if role.get("default"): + canonical_label = role["label"] + for i in matching_idxs: + sn = (smart_names[i] if i < len(smart_names) else "") + if sn == canonical_label: + best = i + matched = True + break + if matched: + break + if not matched: + best = matching_idxs[0] + else: + # Legacy fallback for when registry is unavailable + if sel_instrument.lower() == "bass": + bass_idxs = [ + i + for i, a in enumerate(song.arrangements) + if getattr(a, "path_bass", False) + or (smart_names[i] or "").lower().startswith("bass") + or "bass" in (getattr(a, "name", "") or "").lower() + ] + if bass_idxs: + pref_bass = -1 + if pref: + for i in bass_idxs: + nm = (smart_names[i] if naming_mode == "smart" and i < len(smart_names) + else getattr(song.arrangements[i], "name", "")) + if nm == pref: + pref_bass = i + break + if pref_bass >= 0: + best = pref_bass + else: + best = next( + (i for i in bass_idxs + if (smart_names[i] if i < len(smart_names) else "") == "Bass"), + bass_idxs[0], + ) # User's default arrangement preference (only when instrument routing did not # already resolve a part — i.e. guitar, or a bass player with no bass part). if best < 0 and pref: diff --git a/lib/tunings.py b/lib/tunings.py index c5d34ed6..cffd5147 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -10,6 +10,107 @@ DEFAULT_REFERENCE_PITCH = 440.0 +_instrument_registry = None + + +def set_instrument_registry(reg): + global _instrument_registry + _instrument_registry = reg + + +def _build_standard_midis(registry=None): + reg = registry or _instrument_registry + result = {} + if reg: + for inst in reg.get_all(): + if inst["kind"] == "stringed": + for sc_key, midis in inst.get("standard_tunings", {}).items(): + key = f"{inst['id']}-{sc_key}" + if key not in result: + result[key] = midis + if not result: + return dict(STANDARD_OPEN_MIDIS) + return result + + +def _build_preset_midis(registry=None): + reg = registry or _instrument_registry + result = {} + if reg: + for inst in reg.get_all(): + if inst["kind"] == "stringed": + std = inst.get("standard_tunings", {}) + for sc_key, named_offsets in inst.get("tunings", {}).items(): + key = f"{inst['id']}-{sc_key}" + std_midis = std.get(sc_key) + if not std_midis: + continue + presets = {} + for t_name, offsets in named_offsets.items(): + if len(std_midis) == len(offsets): + presets[t_name] = [s + o for s, o in zip(std_midis, offsets)] + if presets: + result[key] = presets + if not result: + return {k: dict(v) for k, v in TUNING_PRESET_MIDIS.items()} + return result + + +def _build_profile_defaults(registry=None): + reg = registry or _instrument_registry + result = {} + if reg: + for inst in reg.get_all(): + default_role = inst["roles"][0]["id"] if inst["roles"] else inst["id"] + for role in inst["roles"]: + r_default = role.get("default", False) + if r_default: + default_role = role["id"] + profile_id = f"{inst['id']}-{role['id']}" + result[profile_id] = { + "id": profile_id, + "label": f"{role['label']} {inst['label']}", + "instrument": inst["id"], + "role": role["id"], + "string_count": inst.get("default_string_count", 0), + "tuning": "Standard", + "reference_pitch": inst.get("reference_pitch", DEFAULT_REFERENCE_PITCH), + "pathway": "songs", + "default_role": r_default, + } + if default_role and f"{inst['id']}-{default_role}" in result: + for pid, profile in result.items(): + if pid == f"{inst['id']}-{default_role}": + profile["default_role"] = True + if not result: + return {pid: dict(p) for pid, p in PROFILE_DEFAULTS.items()} + return result + + +def _build_profile_ids(registry=None): + profiles = _build_profile_defaults(registry) + return tuple(profiles.keys()) + + +def _valid_instrument_ids(registry=None): + reg = registry or _instrument_registry + if reg: + ids = {inst["id"] for inst in reg.get_all()} + if ids: + return ids + return {"guitar", "bass"} + + +def _default_profile_id_for_instrument(instrument_id, registry=None): + profiles = _build_profile_defaults(registry) + for pid, profile in profiles.items(): + if profile["instrument"] == instrument_id and profile.get("default_role"): + return pid + for pid, profile in profiles.items(): + if profile["instrument"] == instrument_id: + return pid + return "guitar-lead" + # Canonical open strings, low to high, as MIDI notes. This is the host-level # source of truth for guitar/bass tuning profiles; UI surfaces derive names, # frequencies, and semitone offsets from these absolute pitches. @@ -207,8 +308,8 @@ 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 default_instrument_profiles(registry=None) -> dict[str, dict]: + return _build_profile_defaults(registry) def _valid_reference_pitch(value) -> float | None: @@ -223,11 +324,13 @@ def _valid_reference_pitch(value) -> float | None: return ref -def _valid_tuning_for_key(key: str, tuning): +def _valid_tuning_for_key(key: str, tuning, *, registry=None): + preset_midis = _build_preset_midis(registry) + standard_midis = _build_standard_midis(registry) if isinstance(tuning, str): if len(tuning) > 64: return None - if tuning in TUNING_PRESET_MIDIS.get(key, {}): + if tuning in 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) — @@ -235,11 +338,11 @@ def _valid_tuning_for_key(key: str, tuning): # 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()): + if any(tuning in names for names in preset_midis.values()): return None return tuning if isinstance(tuning, list): - expected = len(STANDARD_OPEN_MIDIS.get(key, [])) + expected = len(standard_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): @@ -248,9 +351,10 @@ def _valid_tuning_for_key(key: str, tuning): return None -def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]: +def normalize_instrument_profile(profile_id: str, raw, *, registry=None) -> tuple[dict | None, str | None]: """Validate one persisted host instrument profile.""" - base = dict(PROFILE_DEFAULTS.get(profile_id, {})) + profile_defaults = _build_profile_defaults(registry) + base = dict(profile_defaults.get(profile_id, {})) if not base: return None, f"unknown instrument profile: {profile_id}" if raw is None: @@ -259,20 +363,31 @@ def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str 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'" + valid_ids = _valid_instrument_ids(registry) + if instrument not in valid_ids: + return None, f"instrument_profiles.{profile_id}.instrument must be one of {sorted(valid_ids)}" - 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" + inst_def = None + reg = registry or _instrument_registry + if reg: + inst_def = reg.get(instrument) + is_stringed = (inst_def is not None and inst_def.get("kind") == "stringed") or instrument in ("guitar", "bass") - 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}" + if is_stringed: + 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) + standard_midis = _build_standard_midis(registry) + if key not in standard_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"]), registry=registry) + if tuning is None: + return None, f"instrument_profiles.{profile_id}.tuning must match {key}" + else: + string_count = 0 + tuning = "" ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"])) if ref is None: @@ -302,42 +417,58 @@ def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str return out, None -def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]: +def normalize_instrument_profiles(raw_profiles=None, *, registry=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 + return _build_profile_defaults(registry), None if not isinstance(raw_profiles, dict): return None, "instrument_profiles must be an object" + profile_ids = _build_profile_ids(registry) profiles = {} - for profile_id in PROFILE_IDS: - profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id)) + for profile_id in profile_ids: + profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id), registry=registry) 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 active_profile_id(raw, *, registry=None) -> str: + defaults = _build_profile_defaults(registry) + return raw if raw in defaults else "guitar-lead" -def profile_from_legacy_settings(cfg: dict) -> dict: +def profile_from_legacy_settings(cfg: dict, *, registry=None) -> 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 + valid_ids = _valid_instrument_ids(registry) + instrument = cfg.get("instrument") if cfg.get("instrument") in valid_ids else "guitar" + inst_def = None + if registry: + inst_def = registry.get(instrument) + is_stringed = (inst_def is not None and inst_def.get("kind") == "stringed") or instrument in ("guitar", "bass") + + if is_stringed: + if inst_def: + fallback_sc = inst_def.get("default_string_count", 6) + else: + 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) - tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard" + standard_midis = _build_standard_midis(registry) + if key not in standard_midis: + sc = fallback_sc + key = instrument_key(instrument, sc) + tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard"), registry=registry) or "Standard" + else: + sc = 0 + tuning = "" 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_id = _default_profile_id_for_instrument(instrument, registry) + profile = dict(_build_profile_defaults(registry)[profile_id]) profile.update({ "instrument": instrument, "string_count": sc, @@ -348,14 +479,15 @@ def profile_from_legacy_settings(cfg: dict) -> dict: return profile -def settings_with_instrument_profiles(cfg: dict) -> dict: +def settings_with_instrument_profiles(cfg: dict, *, registry=None) -> dict: """Return settings with canonical host profiles and mirrored flat keys.""" + reg = registry or _instrument_registry out = dict(cfg) - profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles")) + profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"), registry=reg) if profiles is None: - profiles = default_instrument_profiles() + profiles = _build_profile_defaults(reg) if "instrument_profiles" not in out: - legacy = profile_from_legacy_settings(out) + legacy = profile_from_legacy_settings(out, registry=reg) 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 @@ -363,7 +495,7 @@ def settings_with_instrument_profiles(cfg: dict) -> dict: # 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")) + active = active_profile_id(out.get("active_instrument_profile"), registry=reg) selected = profiles[active] out["instrument_profiles"] = profiles out["active_instrument_profile"] = active @@ -375,21 +507,26 @@ def settings_with_instrument_profiles(cfg: dict) -> dict: return out -def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: +def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict, *, registry=None) -> dict: """Mirror legacy flat instrument updates into the active host profile.""" - out = settings_with_instrument_profiles(cfg) + reg = registry or _instrument_registry + out = settings_with_instrument_profiles(cfg, registry=reg) 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")) + active = active_profile_id(out.get("active_instrument_profile"), registry=reg) if "instrument" in updates: - active = "bass" if updates["instrument"] == "bass" else "guitar-lead" + active = _default_profile_id_for_instrument(updates["instrument"], reg) 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 + inst_def = reg.get(updates["instrument"]) if reg else None + if inst_def: + current["string_count"] = inst_def.get("default_string_count", 0) + else: + 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: @@ -399,11 +536,14 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict: 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) + inst_def = reg.get(current["instrument"]) if reg else None + is_str = (inst_def is not None and inst_def.get("kind") == "stringed") or current["instrument"] in ("guitar", "bass") + if is_str: + key = instrument_key(current["instrument"], current["string_count"]) + if _valid_tuning_for_key(key, current.get("tuning"), registry=reg) is None: + current["tuning"] = "Standard" + + profile, error = normalize_instrument_profile(active, current, registry=reg) if error: raise ValueError(error) out["instrument_profiles"][active] = profile diff --git a/main.py b/main.py index 0673b537..8151fc4a 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,12 @@ """ import os +import sys + +# Ensure lib/ is on the path for local development (Docker sets PYTHONPATH). +_lib = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lib") +if _lib not in sys.path: + sys.path.insert(0, _lib) def run() -> None: diff --git a/plugins/__init__.py b/plugins/__init__.py index 6624e3ca..42a2f5e3 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -1038,7 +1038,8 @@ def _install_requirements(plugin_dir: Path, plugin_id: str): return False -def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=None): +def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=None, + instrument_registry=None): """Discover and load all plugins from built-in and user directories. progress_cb, when provided, receives structured progress events: @@ -1347,6 +1348,27 @@ def _is_bundled(pdir: Path, mf: dict) -> bool: [(plugin_id, plugin_dir) for plugin_id, plugin_dir, _ in plugin_load_specs] ) + # ── Instrument plugin registration ──────────────────────────────────── + if instrument_registry is not None: + for plugin_id, plugin_dir, manifest in plugin_load_specs: + if manifest.get("type") == "instrument": + inst_def = manifest.get("instrument") + if isinstance(inst_def, dict): + # Stamp the plugin_id so consumers can build asset URLs + # like /api/plugins//assets/icon.svg + inst_def = dict(inst_def) + inst_def["_plugin_id"] = plugin_id + # The icon lives at the manifest root, not inside "instrument" + if manifest.get("icon") and not inst_def.get("icon"): + inst_def["icon"] = manifest["icon"] + try: + instrument_registry.register(inst_def) + except ValueError as e: + log.warning( + "Invalid instrument definition in plugin %s: %s", + plugin_id, e, + ) + _emit_progress( "plugins-discovered", f"Discovered {len(plugin_load_specs)} plugin(s)", diff --git a/plugins/instrument_bass/assets/icon.svg b/plugins/instrument_bass/assets/icon.svg new file mode 100644 index 00000000..57a22fc8 --- /dev/null +++ b/plugins/instrument_bass/assets/icon.svg @@ -0,0 +1,50 @@ + + + + + + + + \ No newline at end of file diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json new file mode 100644 index 00000000..9fb2e446 --- /dev/null +++ b/plugins/instrument_bass/plugin.json @@ -0,0 +1,60 @@ +{ + "id": "instrument_bass", + "name": "Bass", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "icon": "assets/icon.svg", + "instrument": { + "id": "bass", + "label": "Bass", + "kind": "stringed", + "string_counts": [4, 5, 6], + "default_string_count": 4, + "reference_pitch": 440.0, + "detect_strategy": "pitch", + "standard_tunings": { + "4": [28, 33, 38, 43], + "5": [23, 28, 33, 38, 43], + "6": [23, 28, 33, 38, 43, 48] + }, + "tunings": { + "4": { + "Standard": [ 0, 0, 0, 0], + "Eb Standard": [-1, -1, -1, -1], + "D Standard": [-2, -2, -2, -2], + "C# Standard": [-3, -3, -3, -3], + "C Standard": [-4, -4, -4, -4], + "Drop D": [-2, 0, 0, 0], + "Drop C": [-4, -2, -2, -2], + "BEAD": [-5, -5, -5, -5] + }, + "5": { + "Standard": [ 0, 0, 0, 0, 0], + "High C": [ 5, 5, 5, 5, 5], + "Eb Standard": [-1, -1, -1, -1, -1], + "D Standard": [-2, -2, -2, -2, -2], + "C# Standard": [-3, -3, -3, -3, -3], + "C Standard": [-4, -4, -4, -4, -4], + "Drop A": [-2, 0, 0, 0, 0] + }, + "6": { + "Standard": [ 0, 0, 0, 0, 0, 0], + "Eb Standard": [-1, -1, -1, -1, -1, -1], + "D Standard": [-2, -2, -2, -2, -2, -2], + "C# Standard": [-3, -3, -3, -3, -3, -3], + "C Standard": [-4, -4, -4, -4, -4, -4] + } + }, + "roles": [ + { + "id": "bass", + "label": "Bass", + "arrangement_flags": ["path_bass"], + "arrangement_names": ["Bass"], + "default": true + } + ] + } +} diff --git a/plugins/instrument_drums/assets/icon.svg b/plugins/instrument_drums/assets/icon.svg new file mode 100644 index 00000000..aff812ec --- /dev/null +++ b/plugins/instrument_drums/assets/icon.svg @@ -0,0 +1,28 @@ + + + + + + + + + \ No newline at end of file diff --git a/plugins/instrument_drums/plugin.json b/plugins/instrument_drums/plugin.json new file mode 100644 index 00000000..6d34924e --- /dev/null +++ b/plugins/instrument_drums/plugin.json @@ -0,0 +1,25 @@ +{ + "id": "instrument_drums", + "name": "Drums", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "drums", + "label": "Drums", + "kind": "percussion", + "string_counts": [], + "default_string_count": 0, + "reference_pitch": 440.0, + "detect_strategy": "onset", + "roles": [ + { + "id": "drums", + "label": "Drums", + "arrangement_names": ["Drums", "Drum Kit", "Percussion"], + "default": true + } + ] + } +} diff --git a/plugins/instrument_guitar/assets/icon.svg b/plugins/instrument_guitar/assets/icon.svg new file mode 100644 index 00000000..b8d04b1c --- /dev/null +++ b/plugins/instrument_guitar/assets/icon.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/plugins/instrument_guitar/plugin.json b/plugins/instrument_guitar/plugin.json new file mode 100644 index 00000000..a725dbc2 --- /dev/null +++ b/plugins/instrument_guitar/plugin.json @@ -0,0 +1,72 @@ +{ + "id": "instrument_guitar", + "name": "Guitar", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "guitar", + "label": "Guitar", + "kind": "stringed", + "string_counts": [6, 7, 8], + "default_string_count": 6, + "reference_pitch": 440.0, + "detect_strategy": "pitch", + "standard_tunings": { + "6": [40, 45, 50, 55, 59, 64], + "7": [35, 40, 45, 50, 55, 59, 64], + "8": [30, 35, 40, 45, 50, 55, 59, 64] + }, + "tunings": { + "6": { + "Standard": [ 0, 0, 0, 0, 0, 0], + "Eb Standard": [-1, -1, -1, -1, -1, -1], + "D Standard": [-2, -2, -2, -2, -2, -2], + "C# Standard": [-3, -3, -3, -3, -3, -3], + "C Standard": [-4, -4, -4, -4, -4, -4], + "Drop D": [-2, 0, 0, 0, 0, 0], + "Drop C": [-4, -2, -2, -2, -2, -2], + "Drop B": [-5, -3, -3, -3, -3, -3], + "Drop A": [-7, -5, -5, -5, -5, -5], + "Drop Ab": [-8, -6, -6, -6, -6, -6], + "Open G": [-2, 0, 0, 0, 0, -2], + "Open D": [-2, 0, 0, -1, -2, -2], + "DADGAD": [-2, 0, 0, 0, -2, -2], + "Open E": [ 0, 2, 2, 1, 0, 0] + }, + "7": { + "Standard": [ 0, 0, 0, 0, 0, 0, 0], + "Bb Standard": [-1, -1, -1, -1, -1, -1, -1], + "A Standard": [-2, -2, -2, -2, -2, -2, -2], + "G Standard": [-4, -4, -4, -4, -4, -4, -4], + "Drop A": [-2, 0, 0, 0, 0, 0, 0], + "Drop G": [-4, -2, -2, -2, -2, -2, -2], + "Drop F#": [-5, -3, -3, -3, -3, -3, -3] + }, + "8": { + "Standard": [ 0, 0, 0, 0, 0, 0, 0, 0], + "Drop E": [-2, 0, 0, 0, 0, 0, 0, 0], + "Drop A + Drop E":[-2, -2, 0, 0, 0, 0, 0, 0], + "E Standard": [-2, -2, -2, -2, -2, -2, -2, -2], + "Eb Standard": [-3, -3, -3, -3, -3, -3, -3, -3], + "Drop D": [-4, -2, -2, -2, -2, -2, -2, -2] + } + }, + "roles": [ + { + "id": "lead", + "label": "Lead", + "arrangement_flags": ["path_lead"], + "arrangement_names": ["Lead", "Lead Guitar"], + "default": true + }, + { + "id": "rhythm", + "label": "Rhythm", + "arrangement_flags": ["path_rhythm"], + "arrangement_names": ["Rhythm", "Rhythm Guitar"] + } + ] + } +} diff --git a/plugins/instrument_piano/assets/icon.svg b/plugins/instrument_piano/assets/icon.svg new file mode 100644 index 00000000..9d01df28 --- /dev/null +++ b/plugins/instrument_piano/assets/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/plugins/instrument_piano/plugin.json b/plugins/instrument_piano/plugin.json new file mode 100644 index 00000000..eeb741ed --- /dev/null +++ b/plugins/instrument_piano/plugin.json @@ -0,0 +1,27 @@ +{ + "id": "instrument_piano", + "name": "Piano/Keys", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "keys", + "label": "Keys", + "kind": "keyboard", + "string_counts": [], + "default_string_count": 0, + "key_counts": [25, 49, 61, 88], + "default_key_count": 88, + "reference_pitch": 440.0, + "detect_strategy": "midi", + "roles": [ + { + "id": "keys", + "label": "Keys", + "arrangement_names": ["Keys", "Piano", "Keyboard", "Synth", "Synth Lead"], + "default": true + } + ] + } +} diff --git a/server.py b/server.py index ecb68637..4e4211c7 100644 --- a/server.py +++ b/server.py @@ -26,8 +26,9 @@ from appconfig import _load_config from tunings import ( DEFAULT_REFERENCE_PITCH, DEFAULT_TUNINGS, - apply_reference_pitch, tuning_name, + apply_reference_pitch, tuning_name, set_instrument_registry, ) +from instruments import InstrumentRegistry # The library metadata cache. `MetadataDB` and the query helpers it owns live in # their own module; the `meta_db` singleton below stays here. The private names # are re-imported because callers outside the DB layer still use them. @@ -55,6 +56,7 @@ from routers import art as art_router from routers import settings as settings_router from routers import song as song_router +from routers import instruments as instruments_router from routers import library as library_router from routers import enrichment as enrichment_routes from routers import media as media_router @@ -164,6 +166,9 @@ def _env_flag(name: str) -> bool: local_library_provider=_local_library_provider, ) +instrument_registry = InstrumentRegistry() +appstate.configure(instrument_registry=instrument_registry) + @@ -923,7 +928,9 @@ def _log_deferred(f: concurrent.futures.Future): error=None, ) load_plugins(app, plugin_context, progress_cb=_on_progress, - route_setup_fn=_route_setup_on_main) + route_setup_fn=_route_setup_on_main, + instrument_registry=instrument_registry) + set_instrument_registry(instrument_registry) # Self-heal a freshly recreated container: its filesystem reset to # the image-baked sheet (in-tree plugins only), but a mounted # FEEDBACK_PLUGINS_DIR may carry user-installed plugins whose @@ -1372,6 +1379,9 @@ def _invalidate_song_caches(cache_key: str) -> None: # ── Settings routes → routers/settings.py (R3) ────────────────────────────── app.include_router(settings_router.router) +# ── Instruments routes → routers/instruments.py ─────────────────────────── +app.include_router(instruments_router.router) + def _default_settings(): """Fallback settings returned when config.json is missing or diff --git a/static/capabilities/working-tuning.js b/static/capabilities/working-tuning.js index 133447c5..dcfd3990 100644 --- a/static/capabilities/working-tuning.js +++ b/static/capabilities/working-tuning.js @@ -40,11 +40,32 @@ let _touched = false; // set once anything explicitly writes/selects; gates the async seed function _normInstrument(instrument) { - return instrument === 'bass' ? 'bass' : 'guitar'; + if (!instrument || typeof instrument !== 'string') return 'guitar'; + var inst = _getInstDef(instrument); + if (inst) return inst.id; + if (instrument === 'bass' || instrument === 'guitar') return instrument; + return 'guitar'; + } + + function _getInstDef(instId) { + var insts = window.feedBack && window.feedBack._instruments; + if (Array.isArray(insts)) { + for (var i = 0; i < insts.length; i++) { + if (insts[i].id === instId) return insts[i]; + } + } + return null; + } + + function _defaultStringCount(instrument) { + var inst = _getInstDef(instrument); + if (inst && inst.kind === 'stringed') return inst.default_string_count; + if (instrument === 'bass') return 4; + return 6; } function _keyOf(instrument, stringCount) { const inst = _normInstrument(instrument); - const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6); + const sc = Number(stringCount) || _defaultStringCount(inst); return inst + '-' + sc; } // Like _keyOf, but when the caller omits a string count we resolve it against the @@ -59,14 +80,14 @@ const cur = _splitKey(_currentKey); if (cur.instrument === inst) sc = cur.stringCount; } - if (!sc) sc = (inst === 'bass' ? 4 : 6); + if (!sc) sc = _defaultStringCount(inst); } return inst + '-' + sc; } function _splitKey(key) { const parts = (typeof key === 'string' ? key : '').split('-'); - const inst = parts[0] === 'bass' ? 'bass' : 'guitar'; - return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) }; + const inst = _normInstrument(parts[0]); + return { instrument: inst, stringCount: Number(parts[1]) || _defaultStringCount(inst) }; } // The shape every consumer reads. `offsets` are per-string semitone offsets from @@ -273,7 +294,7 @@ .then(function (s) { if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber const inst = _normInstrument(s.instrument); - const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6); + const sc = Number(s.string_count) || _defaultStringCount(inst); const key = _keyOf(inst, sc); function commit(offsets) { diff --git a/static/v3/badges.js b/static/v3/badges.js index f36cf849..8a2c9087 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -28,6 +28,50 @@ { id: 'learn', label: 'Learn' }, { id: 'studio', label: 'Studio' }, ]; + let _instruments = []; + let _instrumentsById = {}; + + async function loadInstruments() { + try { + const r = await fetch('/api/instruments'); + if (r.ok) _instruments = await r.json(); + } catch (_) { + _instruments = []; + } + _instrumentsById = {}; + _instruments.forEach(function (inst) { _instrumentsById[inst.id] = inst; }); + // Expose to other capabilities (working-tuning) that read from registry + if (window.feedBack) window.feedBack._instruments = _instruments; + } + + function getInstruments() { + if (_instruments.length) return _instruments; + return [ + { id: 'guitar', label: 'Guitar', kind: 'stringed', string_counts: [6, 7, 8], default_string_count: 6, roles: [{ id: 'lead', label: 'Lead', default: true }, { id: 'rhythm', label: 'Rhythm' }] }, + { id: 'bass', label: 'Bass', kind: 'stringed', string_counts: [4, 5, 6], default_string_count: 4, roles: [{ id: 'bass', label: 'Bass', default: true }] }, + ]; + } + + function getInstrument(id) { + if (_instrumentsById[id]) return _instrumentsById[id]; + var insts = getInstruments(); + for (var i = 0; i < insts.length; i++) { + if (insts[i].id === id) return insts[i]; + } + return null; + } + + function isStringedInstrument(instId) { + var inst = getInstrument(instId); + return inst ? inst.kind === 'stringed' : (instId === 'guitar' || instId === 'bass'); + } + + function stringCountsFor(instId) { + var inst = getInstrument(instId); + if (inst && inst.kind === 'stringed' && Array.isArray(inst.string_counts)) return inst.string_counts; + if (instId === 'bass') return STRING_COUNTS.bass; + return STRING_COUNTS.guitar; + } // 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 = {}; @@ -112,7 +156,7 @@ } } - let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; + let settings = { instrument: 'guitar', string_count: 6, key_count: 88, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; async function loadTunings() { try { @@ -147,6 +191,15 @@ } function profileIdForInstrument(inst) { + var instDef = getInstrument(inst); + if (instDef && instDef.roles && instDef.roles.length) { + var defaultRole = null; + for (var i = 0; i < instDef.roles.length; i++) { + if (instDef.roles[i].default) { defaultRole = instDef.roles[i]; break; } + } + if (!defaultRole) defaultRole = instDef.roles[0]; + return inst + '-' + defaultRole.id; + } return inst === 'bass' ? 'bass' : 'guitar-lead'; } @@ -158,10 +211,10 @@ // Clamp persisted values to valid ranges — config.json could // hold out-of-range data (hand-edited or from an import), and the // badge/tuner must render consistent state, not a bad number. - const instrument = s.instrument === 'bass' ? 'bass' : 'guitar'; - const counts = STRING_COUNTS[instrument]; + const instrument = getInstrument(s.instrument) ? s.instrument : 'guitar'; + const counts = stringCountsFor(instrument); const sc = Number(s.string_count); - const scValid = counts.includes(sc) ? sc : counts[0]; + const scValid = counts.length && counts.includes(sc) ? sc : (counts[0] || (instrument === 'bass' ? 4 : 6)); const tunings = _tuningsForInstrument(instrument, scValid); let ref = Number(s.reference_pitch); if (!Number.isFinite(ref)) ref = 440; @@ -175,13 +228,14 @@ 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'; + const keyCount = Number.isFinite(Number(s.key_count)) ? Number(s.key_count) : 88; settings = { instrument: instrument, string_count: scValid, + key_count: keyCount, tuning: tuning, reference_pitch: Math.min(450, Math.max(430, ref)), - pathway: pathway, + pathway: 'songs', instrument_profiles: profiles, active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument), }; @@ -327,6 +381,16 @@ function renderTuner() { const host = document.getElementById('v3-badge-tuner'); if (!host) return; + const currentInst = getInstrument(settings.instrument); + const isPitched = currentInst && currentInst.detect_strategy === 'pitch'; + if (!isPitched) { + host.innerHTML = + '
' + + '
' + + 'No tuner' + + '
'; + return; + } const hz = Math.round(settings.reference_pitch || 440); const initNote = typeof settings.tuning === 'string' ? (TUNING_NOTE[settings.tuning] || 'E') : lowStringNote(settings.tuning); @@ -403,9 +467,19 @@ } // ── Instrument selector card (Stitch RightInstrumentSelector) ──────────-- - const guitarIcon = - ''; + function iconUrl(instDef) { + if (!instDef) return ''; + var pluginId = instDef._plugin_id; + var iconPath = instDef.icon || 'assets/icon.svg'; + if (pluginId && iconPath) return '/api/plugins/' + pluginId + '/' + iconPath; + return ''; + } + function iconForInstrument(instDef) { + var url = iconUrl(instDef); + if (url) return ''; + // Fallback inline guitar SVG + return ''; + } // Instrument-menu open/close helpers keyed by id (NOT a captured element), // so they survive renderInstrument() replacing the menu node. closeInstMenu @@ -434,21 +508,38 @@ const host = document.getElementById('v3-badge-instrument'); if (!host) return; const wt = workingTuningInfo(); + const instrumentList = getInstruments(); + const currentInst = getInstrument(settings.instrument); + const isStringed = currentInst && currentInst.kind === 'stringed'; + + const isKeyboard = currentInst && currentInst.kind === 'keyboard'; + + var instPills = ''; + if (instrumentList.length) { + instPills = instrumentList.map(function (inst) { + return pill('inst', inst.id, inst.label, settings.instrument === inst.id); + }).join(''); + } else { + instPills = 'No instruments installed. Install one.'; + } + var toggleTitle; + if (isStringed) { + toggleTitle = 'Instrument: ' + settings.string_count + '-str ' + (wt ? wt.label : tuningLabel()); + } else if (isKeyboard) { + toggleTitle = 'Instrument: ' + (settings.key_count || 88) + '-key ' + currentInst.label; + } else { + toggleTitle = 'Instrument: ' + currentInst.label; + } host.innerHTML = '
' + - '' + ''; const toggle = host.querySelector('[data-inst-toggle]'); @@ -496,22 +579,25 @@ const keepOpen = openInstMenu; menu.querySelectorAll('[data-pill="inst"]').forEach((b) => b.addEventListener('click', async () => { const v = b.getAttribute('data-val'); - const counts = STRING_COUNTS[v]; - // Clamp string_count AND tuning to ones valid for the new - // instrument, so switching can't persist (and push to the tuner) an - // unsupported instrument+tuning combo. - const newSc = counts.includes(settings.string_count) ? settings.string_count : counts[0]; - const tunings = _tuningsForInstrument(v, newSc); - const ok = await saveSettings({ - 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 - // new one's tuning (settings and workingTuning desync on a rejected save). - if (ok) setWorkingInstrument(v, newSc); // surface THIS instrument's own working tuning + const instDef = getInstrument(v); + const isStr = instDef && instDef.kind === 'stringed'; + const isKeys = instDef && instDef.kind === 'keyboard'; + const counts = stringCountsFor(v); + // Use the instrument's DEFAULT count on switch — always. + const newSc = isStr ? (instDef && instDef.default_string_count ? instDef.default_string_count : (counts[0] || 0)) : 0; + const newKc = isKeys ? (instDef && instDef.default_key_count ? instDef.default_key_count : 88) : 0; + const tunings = isStr ? _tuningsForInstrument(v, newSc) : []; + var patch = { instrument: v }; + if (isStr) { + patch.string_count = newSc; + patch.tuning = tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning); + } + if (isKeys) { + patch.key_count = newKc; + } + const ok = await saveSettings(patch); + // Only move the working-tuning context once the switch was actually persisted. + if (ok && isStr) setWorkingInstrument(v, newSc); renderInstrument(); keepOpen(); })); menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => { @@ -528,15 +614,22 @@ setWorkingInstrument(settings.instrument, newSc); renderInstrument(); keepOpen(); })); + menu.querySelectorAll('[data-pill="key_count"]').forEach((b) => b.addEventListener('click', async () => { + const newKc = Number(b.getAttribute('data-val')); + await saveSettings({ key_count: newKc }); + renderInstrument(); keepOpen(); + })); menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => { _setLeftyPref(b.getAttribute('data-val') === 'left'); renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open })); - 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) })); + var tuningSelect = menu.querySelector('[data-inst-tuning]'); + if (tuningSelect) tuningSelect.addEventListener('change', (e) => saveSettings({ tuning: e.target.value })); + var refSlider = menu.querySelector('[data-inst-ref]'); + if (refSlider) { + refSlider.addEventListener('input', (e) => { var label = menu.querySelector('[data-ref-val]'); if (label) label.textContent = e.target.value + ' Hz'; }); + refSlider.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) })); + } const resetBtn = menu.querySelector('[data-inst-reset]'); if (resetBtn) resetBtn.addEventListener('click', () => { const wtCap = window.feedBack && window.feedBack.workingTuning; @@ -568,9 +661,10 @@ (active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + ''; } - window.v3Badges = { reload: async () => { await Promise.all([loadTunings(), loadSettings()]); renderInstrument(); renderTuner(); } }; + window.v3Badges = { reload: async () => { await loadInstruments(); await Promise.all([loadTunings(), loadSettings()]); renderInstrument(); renderTuner(); } }; async function boot() { + await loadInstruments(); await Promise.all([loadTunings(), loadSettings()]); // NB: we deliberately do NOT call setWorkingInstrument() here. The host seeds its // current instrument from the same /api/settings on boot and emits diff --git a/static/v3/index.html b/static/v3/index.html index 3dafcb17..3e849937 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -407,6 +407,7 @@

Settings

+
@@ -435,21 +436,17 @@

Gameplay Settings

+ +
Default arrangement
-
Which arrangement loads first when you open a song.
-
-
- +
Set per instrument in the topbar instrument selector.
+
@@ -912,6 +909,14 @@

Gameplay Settings

+ + +
+

Instruments

+
+
Loading instruments…
+
+
@@ -1324,6 +1329,7 @@

Gameplay Settings

+ +
+ +
+
Auto-filter by instrument
+
When enabled, the song library automatically filters to show only songs with arrangements for your selected instrument. Disable to browse freely.
+
+
+ +
+ +
diff --git a/static/v3/songs.js b/static/v3/songs.js index 3f2a563d..9e2f890f 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -3657,7 +3657,10 @@ if (sr.ok) { var sd = await sr.json(); var instId = sd && sd.instrument; - if (instId) _applyArrangementAutoFilter(instId); + // Respect the auto-filter toggle in Gameplay settings. + var autoFilter = sd.auto_filter_instrument !== false; + _autoFilterEnabled = autoFilter; + if (instId && autoFilter) _applyArrangementAutoFilter(instId); } } catch (_) {} } @@ -4274,6 +4277,7 @@ // Clears on manual arrangement filter change; re-applies on next // instrument switch. var _arrAutoInstrument = null; + var _autoFilterEnabled = true; // mirror of auto_filter_instrument setting function _instrumentArrangementNames(instrumentId) { var insts = sm && sm._instruments; @@ -4307,7 +4311,7 @@ // on the next instrument switch. sm.on('instrument:changed', function (e) { var instId = e && e.detail && e.detail.instrument; - if (!instId) return; + if (!instId || !_autoFilterEnabled) return; if (_applyArrangementAutoFilter(instId)) { reload(); renderDrawerIfOpen(); From 4249b08354fb7651ed259a9cf47916e6075019be Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:21:56 +0200 Subject: [PATCH 52/74] fix: remove unused Default arrangement setting, fix orphaned HTML closers - Removed the muted 'Default arrangement' row from Gameplay tab entirely. It was already non-functional (just a note saying it moved to the selector). - Auto-filter by instrument toggle now sits in its place. - Fixed orphaned
closers left from the old structure. --- static/v3/index.html | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/static/v3/index.html b/static/v3/index.html index 51e57972..3c0fc63f 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -434,23 +434,12 @@

Gameplay Settings

- -
- -
- -
-
Default arrangement
-
Set per instrument in the topbar instrument selector.
-
Auto-filter by instrument
-
When enabled, the song library automatically filters to show only songs with arrangements for your selected instrument. Disable to browse freely.
+
When enabled, the song library automatically filters to show only songs with arrangements for your selected instrument. Disable to browse all songs freely.
-
-
From 2f6cd8216e3c4604e95c1421132e9767f5fbe40c Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:26:13 +0200 Subject: [PATCH 53/74] debug: add console.log to instrument:changed handler --- static/v3/songs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/static/v3/songs.js b/static/v3/songs.js index 9e2f890f..970c803e 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -4311,6 +4311,7 @@ // on the next instrument switch. sm.on('instrument:changed', function (e) { var instId = e && e.detail && e.detail.instrument; + console.log('auto-filter: instrument=' + instId + ' enabled=' + _autoFilterEnabled); if (!instId || !_autoFilterEnabled) return; if (_applyArrangementAutoFilter(instId)) { reload(); From 40b3f010e0f558e044082c4fef9bff0c6748fd68 Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:26:38 +0200 Subject: [PATCH 54/74] debug: add console.log to initial render auto-filter --- static/v3/songs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/static/v3/songs.js b/static/v3/songs.js index 970c803e..ecf3478d 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -3660,6 +3660,7 @@ // Respect the auto-filter toggle in Gameplay settings. var autoFilter = sd.auto_filter_instrument !== false; _autoFilterEnabled = autoFilter; + console.log('auto-filter: initial render instrument=' + instId + ' enabled=' + autoFilter + ' raw=' + sd.auto_filter_instrument); if (instId && autoFilter) _applyArrangementAutoFilter(instId); } } catch (_) {} From 180633b033d4fdffee26a670540080cb34e0a044 Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:30:00 +0200 Subject: [PATCH 55/74] fix: always read auto-filter setting, not just on first render _autoFilterEnabled was only read inside the _arrAutoInstrument===null block, so it never updated on subsequent library visits after the first one. Now the settings fetch runs on every render() call. When the toggle is OFF, any previously-applied auto-filter is cleared. --- static/v3/songs.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index ecf3478d..c3cdfd91 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -3651,19 +3651,25 @@ // toolbar so its selects reflect the saved choice (default: Artist A–Z). if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; } // ── Auto-apply instrument arrangement filter on first render ───── - if (_arrAutoInstrument === null && !state.filters.arr_has.length && !state.filters.arr_lacks.length) { + // Always read the auto-filter toggle from settings so the user can + // enable/disable it in Settings → Gameplay without a full page reload. + try { + var sr = await fetch('/api/settings'); + if (sr.ok) { + var sd = await sr.json(); + _autoFilterEnabled = sd.auto_filter_instrument !== false; + } + } catch (_) {} + if (_autoFilterEnabled && _arrAutoInstrument === null && !state.filters.arr_has.length && !state.filters.arr_lacks.length) { try { - var sr = await fetch('/api/settings'); - if (sr.ok) { - var sd = await sr.json(); - var instId = sd && sd.instrument; - // Respect the auto-filter toggle in Gameplay settings. - var autoFilter = sd.auto_filter_instrument !== false; - _autoFilterEnabled = autoFilter; - console.log('auto-filter: initial render instrument=' + instId + ' enabled=' + autoFilter + ' raw=' + sd.auto_filter_instrument); - if (instId && autoFilter) _applyArrangementAutoFilter(instId); - } + var instId = sd && sd.instrument; + if (instId) _applyArrangementAutoFilter(instId); } catch (_) {} + } else if (!_autoFilterEnabled && state.filters.arr_has.length) { + // Toggle was turned off — clear any previously-applied auto-filter. + state.filters.arr_has = []; + state.filters.arr_lacks = []; + _arrAutoInstrument = null; } const providers = await loadProviders(); const [, tn] = await Promise.all([ @@ -4312,7 +4318,6 @@ // on the next instrument switch. sm.on('instrument:changed', function (e) { var instId = e && e.detail && e.detail.instrument; - console.log('auto-filter: instrument=' + instId + ' enabled=' + _autoFilterEnabled); if (!instId || !_autoFilterEnabled) return; if (_applyArrangementAutoFilter(instId)) { reload(); From 3fb69d8595140ea6066800290dbbd566df1376d5 Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:33:47 +0200 Subject: [PATCH 56/74] fix: read auto-filter setting in onV3SongsScreenEnter, not render() render() is cached/skipped on return visits to the library when the DOM hash matches. Moved the settings read to onV3SongsScreenEnter which always runs on screen entry, so the toggle change is picked up immediately even without a full re-render. --- static/v3/songs.js | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index c3cdfd91..811ed2d2 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -3651,25 +3651,17 @@ // toolbar so its selects reflect the saved choice (default: Artist A–Z). if (!_prefsRestored) { applySavedPrefs(); _prefsRestored = true; } // ── Auto-apply instrument arrangement filter on first render ───── - // Always read the auto-filter toggle from settings so the user can - // enable/disable it in Settings → Gameplay without a full page reload. - try { - var sr = await fetch('/api/settings'); - if (sr.ok) { - var sd = await sr.json(); - _autoFilterEnabled = sd.auto_filter_instrument !== false; - } - } catch (_) {} + // auto_filter_instrument setting is already refreshed in + // onV3SongsScreenEnter before render() is called. if (_autoFilterEnabled && _arrAutoInstrument === null && !state.filters.arr_has.length && !state.filters.arr_lacks.length) { try { - var instId = sd && sd.instrument; - if (instId) _applyArrangementAutoFilter(instId); + var sr2 = await fetch('/api/settings'); + if (sr2.ok) { + var sd2 = await sr2.json(); + var instId = sd2 && sd2.instrument; + if (instId) _applyArrangementAutoFilter(instId); + } } catch (_) {} - } else if (!_autoFilterEnabled && state.filters.arr_has.length) { - // Toggle was turned off — clear any previously-applied auto-filter. - state.filters.arr_has = []; - state.filters.arr_lacks = []; - _arrAutoInstrument = null; } const providers = await loadProviders(); const [, tn] = await Promise.all([ @@ -3843,6 +3835,26 @@ } async function onV3SongsScreenEnter() { + // Always refresh the auto-filter toggle state so the user can + // enable/disable it in Settings without a full page reload. + try { + var sr = await fetch('/api/settings'); + if (sr.ok) { + var sd = await sr.json(); + _autoFilterEnabled = sd.auto_filter_instrument !== false; + // If the toggle was turned off, clear any previously-applied filter. + if (!_autoFilterEnabled && state.filters.arr_has.length) { + state.filters.arr_has = []; + state.filters.arr_lacks = []; + _arrAutoInstrument = null; + } + // If the toggle was turned on and no filter is active, apply it. + if (_autoFilterEnabled && _arrAutoInstrument === null && !state.filters.arr_has.length && !state.filters.arr_lacks.length) { + var instId = sd && sd.instrument; + if (instId) _applyArrangementAutoFilter(instId); + } + } + } catch (_) {} // A library scan / DLC-folder change marked the grid stale — re-fetch // from scratch instead of restoring a cached (possibly empty, pre-DLC) // snapshot. Must win over every fast-path below. From edeaf4db46d594d996902ce66986d4effb8d0836 Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 20:44:29 +0200 Subject: [PATCH 57/74] docs: add docstrings to all new/modified functions to meet 80% coverage - lib/instruments.py: module docstring, class docstring, method docstrings for all public methods and private validators - lib/tunings.py: docstrings for _build_*, _valid_instrument_ids, _default_profile_id_for_instrument helper functions - lib/routers/instruments.py and lib/routers/stats.py: endpoint docstrings --- lib/instruments.py | 18 ++++++++++++++++++ lib/routers/instruments.py | 1 + lib/routers/stats.py | 3 +-- lib/tunings.py | 6 ++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/instruments.py b/lib/instruments.py index 95896aff..6e6b0d23 100644 --- a/lib/instruments.py +++ b/lib/instruments.py @@ -20,6 +20,7 @@ def _validate_str(value, field_name, max_len=128): + """Validate a required non-empty string field, returning stripped value.""" if not isinstance(value, str) or not value.strip(): raise ValueError(f"{field_name} must be a non-empty string") if len(value) > max_len: @@ -28,6 +29,7 @@ def _validate_str(value, field_name, max_len=128): def _validate_optional_str(value, field_name, max_len=256): + """Validate an optional string field; None passes through, non-empty string required otherwise.""" if value is None: return None if not isinstance(value, str): @@ -38,6 +40,7 @@ def _validate_optional_str(value, field_name, max_len=256): def _validate_float_range(value, field_name, lo, hi): + """Validate a numeric value falls within [lo, hi], rejecting bools.""" if not isinstance(value, (int, float)) or isinstance(value, bool): raise ValueError(f"{field_name} must be a number") v = float(value) @@ -47,6 +50,7 @@ def _validate_float_range(value, field_name, lo, hi): def _validate_midi_list(lst, field_name, expected_len): + """Validate a list of MIDI note numbers (ints in 0-127) of the given length.""" if not isinstance(lst, list): raise ValueError(f"{field_name} must be a list") if len(lst) != expected_len: @@ -63,6 +67,7 @@ def _validate_midi_list(lst, field_name, expected_len): def _validate_offset_list(lst, field_name, expected_len): + """Validate a list of semitone offsets (ints in -12..12) of the given length.""" if not isinstance(lst, list): raise ValueError(f"{field_name} must be a list of offsets") if len(lst) != expected_len: @@ -242,17 +247,25 @@ def register(self, definition: dict): log.info("registered instrument %r (%s)", inst_id, label) def unregister(self, instrument_id: str): + """Remove an instrument definition from the registry.""" if instrument_id in self._instruments: del self._instruments[instrument_id] log.info("unregistered instrument %r", instrument_id) def get(self, instrument_id: str) -> dict | None: + """Return the definition for a single instrument, or None.""" return self._instruments.get(instrument_id) def get_all(self) -> list[dict]: + """Return all registered instrument definitions.""" return list(self._instruments.values()) def compute_tuning_midis(self, instrument_id: str, string_count: int, tuning_name: str) -> list[int] | None: + """Return absolute MIDI notes for a named tuning on a stringed instrument. + + Computed by adding the tuning's semitone offsets to the instrument's + standard open-string MIDI notes for the given string count. + """ inst = self._instruments.get(instrument_id) if not inst or inst["kind"] != "stringed": return None @@ -264,18 +277,21 @@ def compute_tuning_midis(self, instrument_id: str, string_count: int, tuning_nam return [s + o for s, o in zip(std, offsets)] def get_tuning_names(self, instrument_id: str, string_count: int) -> list[str]: + """Return all tuning preset names for a stringed instrument + string count.""" inst = self._instruments.get(instrument_id) if not inst or inst["kind"] != "stringed": return [] return list((inst["tunings"].get(str(string_count)) or {}).keys()) def get_standard_midis(self, instrument_id: str, string_count: int) -> list[int] | None: + """Return standard open-string MIDI notes for a stringed instrument + string count.""" inst = self._instruments.get(instrument_id) if not inst or inst["kind"] != "stringed": return None return inst["standard_tunings"].get(str(string_count)) def get_default_role(self, instrument_id: str) -> str | None: + """Return the id of the default role for an instrument, or None.""" inst = self._instruments.get(instrument_id) if not inst: return None @@ -287,6 +303,7 @@ def get_default_role(self, instrument_id: str) -> str | None: return None def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: dict = None) -> str | None: + """Find the role id matching an arrangement name or path flags for a specific instrument.""" inst = self._instruments.get(instrument_id) if not inst: return None @@ -301,6 +318,7 @@ def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: return None def instrument_id_for_arrangement(self, arr_name: str, arr_flags: dict = None) -> str | None: + """Return the instrument id whose roles match a given arrangement name or flags.""" name_lower = (arr_name or "").strip().lower() for inst in self._instruments.values(): for role in inst["roles"]: diff --git a/lib/routers/instruments.py b/lib/routers/instruments.py index e4e59465..2f7074ea 100644 --- a/lib/routers/instruments.py +++ b/lib/routers/instruments.py @@ -12,5 +12,6 @@ @router.get("/api/instruments") def get_instruments(): + """Return all instrument definitions registered via instrument plugins.""" reg = getattr(appstate, "instrument_registry", None) return reg.get_all() if reg else [] diff --git a/lib/routers/stats.py b/lib/routers/stats.py index 2882f713..557ca72e 100644 --- a/lib/routers/stats.py +++ b/lib/routers/stats.py @@ -243,8 +243,7 @@ def api_stats_best(): @router.get("/api/stats/best-by-arrangement") def api_stats_best_by_arrangement(): - """{filename: {arrangement_index: best_accuracy}} per-arrangement accuracy, - for per-role badging and hover overlays on the library grid.""" + """Return {filename: {arrangement_index: best_accuracy}} for per-role badging.""" return appstate.meta_db.arrangement_accuracy_map() diff --git a/lib/tunings.py b/lib/tunings.py index fd489aab..e6806fb8 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -28,6 +28,7 @@ def set_instrument_registry(reg): def _build_standard_midis(registry=None): + """Build {instrument_key: [midis]} from the registry, falling back to hardcoded STANDARD_OPEN_MIDIS.""" reg = registry or _instrument_registry result = {} if reg: @@ -43,6 +44,7 @@ def _build_standard_midis(registry=None): def _build_preset_midis(registry=None): + """Build {instrument_key: {tuning_name: [midis]}} from the registry, falling back to TUNING_PRESET_MIDIS.""" reg = registry or _instrument_registry result = {} if reg: @@ -66,6 +68,7 @@ def _build_preset_midis(registry=None): def _build_profile_defaults(registry=None): + """Build {profile_id: profile_dict} from registered instruments, falling back to PROFILE_DEFAULTS.""" reg = registry or _instrument_registry result = {} if reg: @@ -97,11 +100,13 @@ def _build_profile_defaults(registry=None): def _build_profile_ids(registry=None): + """Return a tuple of valid instrument profile ids, derived from the registry.""" profiles = _build_profile_defaults(registry) return tuple(profiles.keys()) def _valid_instrument_ids(registry=None): + """Return the set of valid instrument IDs from the registry, or a guitar/bass fallback.""" reg = registry or _instrument_registry if reg: ids = {inst["id"] for inst in reg.get_all()} @@ -111,6 +116,7 @@ def _valid_instrument_ids(registry=None): def _default_profile_id_for_instrument(instrument_id, registry=None): + """Return the profile id for an instrument's default role, e.g. 'guitar-lead'.""" profiles = _build_profile_defaults(registry) for pid, profile in profiles.items(): if profile["instrument"] == instrument_id and profile.get("default_role"): From 38c46249c9c5ed8d59e44639b5416aba04bde25c Mon Sep 17 00:00:00 2001 From: Job Date: Fri, 17 Jul 2026 22:35:24 +0200 Subject: [PATCH 58/74] docs: add docstrings to our new/modified functions across all changed files - lib/instruments.py: __init__ - lib/tunings.py: instrument_key, default_instrument_profiles, _valid_reference_pitch, _valid_tuning_for_key, active_profile_id, tuning_name - lib/routers/settings.py: get_settings, save_settings - lib/routers/stats.py: api_song_stats - lib/routers/tunings.py: get_tunings - lib/routers/ws_highway.py: _send_keepalives, _evict_audio_cache - Re-applied cross-key tuning validation fix (reverted earlier) --- lib/instruments.py | 1 + lib/routers/settings.py | 2 ++ lib/routers/stats.py | 1 + lib/routers/tunings.py | 1 + lib/routers/ws_highway.py | 2 ++ lib/tunings.py | 61 ++++++++++++++++++++++++++++++++++----- tests/test_tunings.py | 14 +++++---- 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/lib/instruments.py b/lib/instruments.py index 6e6b0d23..c9ff5f1a 100644 --- a/lib/instruments.py +++ b/lib/instruments.py @@ -93,6 +93,7 @@ class InstrumentRegistry: """ def __init__(self): + """Initialise an empty registry.""" self._instruments: dict[str, dict] = {} def register(self, definition: dict): diff --git a/lib/routers/settings.py b/lib/routers/settings.py index 6053a5fa..2620b296 100644 --- a/lib/routers/settings.py +++ b/lib/routers/settings.py @@ -38,12 +38,14 @@ @router.get("/api/settings") def get_settings(): + """Return the merged settings dict with instrument profiles virtualized from config.json.""" cfg = _load_config(appstate.config_dir / "config.json") return settings_with_instrument_profiles(cfg if cfg is not None else appstate.default_settings()) @router.post("/api/settings") def save_settings(data: dict): + """Validate and persist partial settings updates to config.json atomically.""" # Partial-update: merge only keys present in the request body so # single-key POSTs (like the difficulty slider's oninput) don't # clobber unrelated settings on disk. diff --git a/lib/routers/stats.py b/lib/routers/stats.py index 557ca72e..f45c196a 100644 --- a/lib/routers/stats.py +++ b/lib/routers/stats.py @@ -271,4 +271,5 @@ def api_top_stats(limit: int = 5): @router.get("/api/stats/{filename:path}") def api_song_stats(filename: str): + """Return per-arrangement stats for a single song.""" return appstate.meta_db.get_song_stats(filename) diff --git a/lib/routers/tunings.py b/lib/routers/tunings.py index 0bee97e8..e305421f 100644 --- a/lib/routers/tunings.py +++ b/lib/routers/tunings.py @@ -17,6 +17,7 @@ @router.get("/api/tunings") def get_tunings(): + """Return the merged tuning catalog (registry built-ins + provider contributions).""" cfg = _load_config(appstate.config_dir / "config.json") or {} ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH) try: diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index de7ff3b2..2f1d0065 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -178,6 +178,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, _keepalive_active = True async def _send_keepalives(): + """Send periodic WebSocket pings to keep the connection alive.""" while _keepalive_active: try: await asyncio.sleep(3) @@ -458,6 +459,7 @@ async def _send_keepalives(): break def _evict_audio_cache(): + """Evict old entries from the audio cache to bound disk usage.""" # Keep appstate.audio_cache_dir bounded so a library full of loose # folders / many archives doesn't fill disk. LRU on st_atime # so songs the user keeps replaying stay warm. Best-effort: diff --git a/lib/tunings.py b/lib/tunings.py index e6806fb8..6abcbabc 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -322,14 +322,17 @@ def apply_reference_pitch( def instrument_key(instrument: str, string_count: int) -> str: + """Build the canonical instrument key, e.g. 'guitar-6'.""" return f"{instrument}-{string_count}" def default_instrument_profiles(registry=None) -> dict[str, dict]: + """Return the default host instrument profiles dict.""" return _build_profile_defaults(registry) def _valid_reference_pitch(value) -> float | None: + """Validate reference pitch is a float in [430, 450]; return None on failure.""" if isinstance(value, bool): return None try: @@ -342,6 +345,12 @@ def _valid_reference_pitch(value) -> float | None: def _valid_tuning_for_key(key: str, tuning, *, registry=None): + """Validate a tuning name or offset list against the registry for a given instrument key. + + Accepts built-in names for the key, maps legacy 'Standard' to the all-zero + tuning, rejects misapplied built-ins from other keys, and passes through + unknown names as provider/custom tunings. + """ preset_midis = _build_preset_midis(registry) standard_midis = _build_standard_midis(registry) if isinstance(tuning, str): @@ -349,12 +358,23 @@ def _valid_tuning_for_key(key: str, tuning, *, registry=None): return None if tuning in preset_midis.get(key, {}): return tuning - # Accept any named tuning not in our presets as a provider/custom - # tuning — the registry (and tuning providers) own validity now. - # Previously we rejected names that existed in a different key as - # misapplied built-ins, but with instruments-as-plugins the same - # name (e.g. "E Standard") can legitimately exist for different - # instrument keys (guitar-6 vs guitar-8). + # Legacy alias: "Standard" → all-zero-offset tuning for this key. + # Users who had "Standard" saved before the rename to "E Standard" / + # "B Standard" / "F# Standard" get auto-migrated. + if tuning == "Standard": + std_presets = preset_midis.get(key, {}) + if std_presets: + # Find the tuning with all-zero offsets as the standard for this key. + target = next((n for n, m in std_presets.items() + if m == standard_midis.get(key)), None) + if target: + return target + return None + # Reject names that exist as built-ins for a DIFFERENT key — they're + # misapplied (e.g. "Drop D" on a 5-string bass). Names unknown to + # every built-in table are provider/custom tunings and are accepted. + if any(tuning in names for names in preset_midis.values()): + return None return tuning if isinstance(tuning, list): expected = len(standard_midis.get(key, [])) @@ -454,6 +474,7 @@ def normalize_instrument_profiles(raw_profiles=None, *, registry=None) -> tuple[ def active_profile_id(raw, *, registry=None) -> str: + """Return the normalized active profile id, falling back to guitar-lead.""" defaults = _build_profile_defaults(registry) return raw if raw in defaults else "guitar-lead" @@ -481,7 +502,18 @@ def profile_from_legacy_settings(cfg: dict, *, registry=None) -> dict: if key not in standard_midis: sc = fallback_sc key = instrument_key(instrument, sc) - tuning = _valid_tuning_for_key(key, cfg.get("tuning", "E Standard"), registry=registry) or "E Standard" + # Use the instrument key's actual standard tuning as the default, + # not a hardcoded "E Standard" which doesn't work for 7/8-string. + preset_midis = _build_preset_midis(registry) + std_midis = _build_standard_midis(registry) + key_std = std_midis.get(key) + default_tuning = "E Standard" + if key_std: + for name, midis in preset_midis.get(key, {}).items(): + if midis == key_std: + default_tuning = name + break + tuning = _valid_tuning_for_key(key, cfg.get("tuning", default_tuning), registry=registry) or default_tuning else: sc = 0 tuning = "" @@ -561,7 +593,19 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict, *, registr if is_str: key = instrument_key(current["instrument"], current["string_count"]) if _valid_tuning_for_key(key, current.get("tuning"), registry=reg) is None: - current["tuning"] = "E Standard" + # Use the key's actual standard tuning (E Standard, B Standard, + # F# Standard) rather than a hardcoded "E Standard". + preset_midis = _build_preset_midis(reg) + std_midis = _build_standard_midis(reg) + key_presets = preset_midis.get(key, {}) + key_std = std_midis.get(key) + fallback = "E Standard" + if key_presets and key_std: + for name, midis in key_presets.items(): + if midis == key_std: + fallback = name + break + current["tuning"] = fallback profile, error = normalize_instrument_profile(active, current, registry=reg) if error: @@ -577,6 +621,7 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict, *, registr return out def tuning_name(offsets: list[int]) -> str: + """Return a human-readable name for semitone offsets, e.g. 'E Standard' or 'Drop D'.""" # 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 # tuning has a low B, not an E, so labeling it "E Standard" would be wrong. diff --git a/tests/test_tunings.py b/tests/test_tunings.py index d71d25d3..67beaa9c 100644 --- a/tests/test_tunings.py +++ b/tests/test_tunings.py @@ -17,12 +17,12 @@ def test_valid_tuning_for_key_builtin_and_provider_names(): - # Built-in valid for the key is accepted; names that would have been - # misapplied built-ins under the old cross-key check are now accepted - # as provider/custom tunings (the registry owns validity per-instrument). + # Built-in valid for the key is accepted. assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A" - assert _valid_tuning_for_key("bass-5", "Drop D") == "Drop D" - assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard" + # Misapplied built-in (Drop D on a 5-string bass — the 5-string drop is Drop A) is rejected. + assert _valid_tuning_for_key("bass-5", "Drop D") is None + # Legacy "Standard" is mapped to the all-zero tuning for the key. + assert _valid_tuning_for_key("guitar-6", "Standard") == "E 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. @@ -249,7 +249,9 @@ 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"] == "DADGAD" + # DADGAD is a 6-string tuning — patching to 7 strings resets to B Standard + # (the all-zero tuning for guitar-7) because the cross-key check rejects it. + assert patched["tuning"] == "B Standard" # ── freqs_to_midis (the /api/tunings tuningMidis inverse) ──────────────────── From 3f98715075c7ea5456e6522768cadf72596a9f06 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 19:25:26 +0200 Subject: [PATCH 59/74] fix: tuning chip flush top-left, hover fade, and reliable offset detection --- static/v3/songs.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index a7594eec..12762ed4 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1029,8 +1029,8 @@ let tuning = ''; if (tuningLabel) { const rawOffsets = (typeof window.parseRawTuningOffsets === 'function') - ? (window.parseRawTuningOffsets(shownTuningOffsets(shown)) - || window.parseRawTuningOffsets(shownTuning)) + ? (window.parseRawTuningOffsets(shown.tuning_offsets) + || window.parseRawTuningOffsets(shownTuningOffsets(shown))) : null; const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets && typeof window.displayTuningTargets === 'function') @@ -1045,7 +1045,7 @@ ? ('Custom Tuning: ' + targetNotes) : tuningLabel) + (inferred ? ' — from the guitar chart (no bass arrangement)' : ''); - const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2'); + const pos = 'absolute top-0 rounded-br-md ' + (state.selectMode ? 'left-9' : 'left-0'); // Tag the chip with its offsets so decorateTuningChips() can colour it // green (matches your current tuning) / amber (needs a retune) after paint. // Also flag a bass-only song (every arrangement is a bass part) so coverage @@ -1064,10 +1064,10 @@ ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"' + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; if (targetNotes) { - tuning = '' + tuning = '' + esc('Custom Tuning') + '
' + esc(targetNotes) + '
'; } else { - tuning = '' + esc(tuningLabel) + (inferred ? ' ~' : '') + ''; + tuning = '' + esc(tuningLabel) + (inferred ? ' ~' : '') + ''; } } // Display-only (pointer-events-none) so a click falls through to the From f68f7dac3880072855fe74a48d9d74fd1dff244c Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 19:32:41 +0200 Subject: [PATCH 60/74] fix: missing renderDrawerIfOpen, bass chip inferred offset truncation --- static/v3/songs.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 12762ed4..59397f8a 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1060,8 +1060,13 @@ const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name) || (chipArrs.length > 0 && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''))); - const matchAttr = (rawOffsets && rawOffsets.length) - ? ' data-tuning-chip data-tuning-offsets="' + esc(rawOffsets.join(',')) + '"' + // Truncate inferred bass offsets: 6-element guitar offsets copied + // to a bass chip must be 4 elements or the coverage checker + // compares 6-string guitar against 4-string bass → mismatch. + let chipOffsets = rawOffsets; + if (chipIsBass && chipOffsets && chipOffsets.length === 6) chipOffsets = chipOffsets.slice(0, 4); + const matchAttr = (chipOffsets && chipOffsets.length) + ? ' data-tuning-chip data-tuning-offsets="' + esc(chipOffsets.join(',')) + '"' + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; if (targetNotes) { tuning = '' @@ -3117,6 +3122,10 @@ const tip = title ? ' title="' + esc(title) + '"' : ''; return ''; } + function renderDrawerIfOpen() { + const d = document.getElementById('v3-songs-drawer'); + if (d && !d.classList.contains('hidden') && d.innerHTML.trim()) renderDrawer(); + } function renderDrawer() { const d = document.getElementById('v3-songs-drawer'); if (!d) return; From 131498ffc745c5c71200c8b69831d1e40ca6982c Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 19:37:24 +0200 Subject: [PATCH 61/74] fix: restore name-based tuning offset fallbacks for older songs --- static/v3/songs.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 59397f8a..ab95b808 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1030,7 +1030,9 @@ if (tuningLabel) { const rawOffsets = (typeof window.parseRawTuningOffsets === 'function') ? (window.parseRawTuningOffsets(shown.tuning_offsets) - || window.parseRawTuningOffsets(shownTuningOffsets(shown))) + || window.parseRawTuningOffsets(shownTuningOffsets(shown)) + || window.parseRawTuningOffsets(shownTuning) + || window.parseRawTuningOffsets(shown.tuning)) : null; const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets && typeof window.displayTuningTargets === 'function') From c5428f247acd799c591825ac3ea84f789d84daba Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 19:41:07 +0200 Subject: [PATCH 62/74] fix: shownTuningOffsets gate on offsets not name; chipIsBass always true for bass players --- static/v3/songs.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index ab95b808..964376f2 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -1059,7 +1059,7 @@ // arrangement is a bass part. Checked via libInstrument() rather // than comparing the two names — they are EQUAL for most songs, so // a value comparison would flag a guitarist's chip as bass. - const chipIsBass = (libInstrument() === 'bass' && !!shown.bass_tuning_name) + const chipIsBass = libInstrument() === 'bass' || (chipArrs.length > 0 && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''))); // Truncate inferred bass offsets: 6-element guitar offsets copied @@ -2783,8 +2783,10 @@ function shownTuningOffsets(song) { const f = perspectiveTuningField(); - if (f && song[f]) { - return song[f.replace('_name', '_offsets')] || song.tuning_offsets; + if (f) { + const offKey = f.replace('_name', '_offsets'); + if (song[offKey]) return song[offKey]; + return song.tuning_offsets; } return song.tuning_offsets; } From f59b1e5a7502f9f68bac765ba0a3a5300b352452 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 19:46:45 +0200 Subject: [PATCH 63/74] fix: registry-driven instrument chip logic, prevent double-reload race --- static/v3/songs.js | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 964376f2..18603714 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -135,6 +135,17 @@ return true; } + function _activeInstrument() { + var id = sm && sm._activeInstrumentProfile; + if (!id) return null; + var insts = window.feedBack && window.feedBack._instruments; + if (!Array.isArray(insts)) return null; + for (var i = 0; i < insts.length; i++) { + if (id.indexOf(insts[i].id) === 0) return insts[i]; + } + return null; + } + const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'piano', 'other']; const PAGE_SIZE = 24; // Extra rows rendered above/below the viewport so a fast scroll doesn't flash @@ -1000,10 +1011,11 @@ for (const chip of chips) { const offs = chip.getAttribute('data-tuning-offsets').split(',').map(Number); if (!offs.length || offs.some((n) => !isFinite(n))) continue; - // Pass the instrument so coverage uses the right base pitches (bass vs guitar). + // Pass the instrument so coverage uses the right base pitches. const arrangement = chip.dataset.tuningBass === '1' ? 'Bass' : 'Lead'; + const instId = chip.dataset.tuningInstrument || (chip.dataset.tuningBass === '1' ? 'bass' : 'guitar'); let rep = null; - try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement }); } catch (_) { rep = null; } + try { rep = await cov({ tuning: offs, stringCount: offs.length, arrangement: arrangement, instrument: instId }); } catch (_) { rep = null; } if (token !== _tuningDecorToken) return; // superseded by a re-paint / tuning change if (rep) _applyChipMatch(chip, rep.covered ? 'match' : 'retune'); } @@ -1055,21 +1067,24 @@ // a 4-string bass tuning read as guitar can false-match a guitar player. const chipArrs = shown.arrangements || []; // Bass either because the chip is SHOWING the bass chart's tuning - // (a bass player on a song that has one), or because every - // arrangement is a bass part. Checked via libInstrument() rather - // than comparing the two names — they are EQUAL for most songs, so - // a value comparison would flag a guitarist's chip as bass. - const chipIsBass = libInstrument() === 'bass' + // Which instrument this chip represents — read from the active + // instrument profile in the registry, falling back to the + // 3-perspective bass/lead detection for the tuner plugin's coverage + // checker (which only knows 'Bass' and 'Lead' arrangement labels). + const inst = _activeInstrument(); + const instId = inst ? inst.id : (libInstrument() === 'bass' ? 'bass' : 'guitar'); + const isBass = instId === 'bass' || (chipArrs.length > 0 && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''))); - // Truncate inferred bass offsets: 6-element guitar offsets copied - // to a bass chip must be 4 elements or the coverage checker - // compares 6-string guitar against 4-string bass → mismatch. + const stringCount = inst && inst.string_count ? inst.string_count : (isBass ? 4 : 6); + // Truncate offsets to the instrument's string count so the + // coverage checker compares the right number of strings. let chipOffsets = rawOffsets; - if (chipIsBass && chipOffsets && chipOffsets.length === 6) chipOffsets = chipOffsets.slice(0, 4); + if (chipOffsets && chipOffsets.length > stringCount) chipOffsets = chipOffsets.slice(0, stringCount); const matchAttr = (chipOffsets && chipOffsets.length) ? ' data-tuning-chip data-tuning-offsets="' + esc(chipOffsets.join(',')) + '"' - + (chipIsBass ? ' data-tuning-bass="1"' : '') : ''; + + ' data-tuning-instrument="' + esc(instId) + '"' + + (isBass ? ' data-tuning-bass="1"' : '') : ''; if (targetNotes) { tuning = '' + esc('Custom Tuning') + '
' + esc(targetNotes) + '
'; @@ -4583,6 +4598,10 @@ sm.on('instrument:changed', function (e) { var instId = e && e.detail && e.detail.instrument; if (!instId || !_autoFilterEnabled) return; + // Record the new instrument so working-tuning-changed skips its + // reload path — this handler already handles the reload. + _lastRenderInstrument = libInstrument(); + state.filters.tunings = []; if (_applyArrangementAutoFilter(instId)) { reload(); renderDrawerIfOpen(); From 5f954378210b08a9a3be96f532f66a9e2ccc9043 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:24:13 +0200 Subject: [PATCH 64/74] fix: encoding corruption in instruments-settings.js, add pick/fingered bass arrangement names --- plugins/instrument_bass/plugin.json | 2 +- static/v3/instruments-settings.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index 220a4d5f..e566f0d4 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -52,7 +52,7 @@ "id": "bass", "label": "Bass", "arrangement_flags": ["path_bass"], - "arrangement_names": ["Bass"], + "arrangement_names": ["Bass", "Pick Bass", "Fingered Bass", "Finger Bass", "Picked Bass"], "default": true } ] diff --git a/static/v3/instruments-settings.js b/static/v3/instruments-settings.js index 124e353c..55735c9f 100644 --- a/static/v3/instruments-settings.js +++ b/static/v3/instruments-settings.js @@ -81,7 +81,7 @@ html += '
'; - // ΓöÇΓöÇ Roles ΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇΓöÇ + // ── Roles ────────────────────────────────── if (inst.roles && inst.roles.length) { html += '
Roles
' + '
'; @@ -94,15 +94,15 @@ html += '
'; } - // ΓöÇΓöÇ Arrangement name patterns (editable) ΓöÇΓöÇΓöÇΓöÇ + // ── Arrangement name patterns (editable) ──── html += _renderEditableNames(inst, over); - // ΓöÇΓöÇ String counts (stringed, editable) ΓöÇΓöÇΓöÇΓöÇΓöÇ + // ── String counts (stringed, editable) ───── if (isStringed) { html += _renderEditableStringCounts(inst, over); } - // ΓöÇΓöÇ Custom tunings (stringed, editable) ΓöÇΓöÇΓöÇΓöÇ + // ── Custom tunings (stringed, editable) ──── if (isStringed) { html += _renderTunings(inst, over); } @@ -160,7 +160,7 @@ ? '' : ''; html += '' + - esc(item.name) + ' ΓåÆ ' + esc(item.role) + '' + removeBtn + + esc(item.name) + ' \u2192 ' + esc(item.role) + '' + removeBtn + ''; } html += '
'; From f2d37ca8bd9ba9904e3fb24f1126bd5f30cfe884 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:37:28 +0200 Subject: [PATCH 65/74] feat: add Vocals instrument plugin and fret_count for stringed instruments --- lib/instruments.py | 22 ++++++++ lib/routers/settings.py | 18 ++++++- lib/tunings.py | 25 ++++++++- plugins/instrument_bass/plugin.json | 2 + plugins/instrument_guitar/plugin.json | 2 + plugins/instrument_vocals/assets/icon.svg | 6 +++ plugins/instrument_vocals/plugin.json | 27 ++++++++++ static/v3/badges.js | 21 +++++++- static/v3/instruments-settings.js | 64 +++++++++++++++++++++++ 9 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 plugins/instrument_vocals/assets/icon.svg create mode 100644 plugins/instrument_vocals/plugin.json diff --git a/lib/instruments.py b/lib/instruments.py index c9ff5f1a..442077d3 100644 --- a/lib/instruments.py +++ b/lib/instruments.py @@ -158,6 +158,26 @@ def register(self, definition: dict): else: default_kc = 0 + fret_counts = definition.get("fret_counts") + if kind == "stringed": + if not isinstance(fret_counts, list) or not fret_counts: + raise ValueError("instrument.fret_counts must be a non-empty list for stringed instruments") + fc_list = [] + for v in fret_counts: + if isinstance(v, bool) or not isinstance(v, (int, float)): + raise ValueError("instrument.fret_counts entries must be integers") + fc_list.append(int(v)) + fret_counts = sorted(set(fc_list)) + else: + fret_counts = [] + + default_fc = definition.get("default_fret_count", 0) + if kind == "stringed": + if default_fc not in fret_counts: + raise ValueError(f"instrument.default_fret_count {default_fc} not in fret_counts {fret_counts}") + else: + default_fc = 0 + standard_tunings = {} raw_std = definition.get("standard_tunings") or {} if kind == "stringed": @@ -238,6 +258,8 @@ def register(self, definition: dict): "default_string_count": default_sc, "key_counts": key_counts, "default_key_count": default_kc, + "fret_counts": fret_counts, + "default_fret_count": default_fc, "detect_strategy": detect_strategy, "reference_pitch": ref_pitch, "standard_tunings": standard_tunings, diff --git a/lib/routers/settings.py b/lib/routers/settings.py index 2620b296..3e97ce3a 100644 --- a/lib/routers/settings.py +++ b/lib/routers/settings.py @@ -276,6 +276,16 @@ def save_settings(data: dict): if kc < 1 or kc > 127: return {"error": "key_count must be an integer 1–127"} updates["key_count"] = kc + if "fret_count" in data: + raw = data["fret_count"] + if raw is not None: + try: + fc = _as_int(raw) + except (TypeError, ValueError, OverflowError): + return {"error": "fret_count must be an integer"} + if fc < 12 or fc > 30: + return {"error": "fret_count must be an integer 12–30"} + updates["fret_count"] = fc if "tuning" in data: raw = data["tuning"] # Accept a tuning NAME (string ≤64) or a list of up to 8 semitone @@ -361,7 +371,7 @@ def save_settings(data: dict): # 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", + _profile_keys = ("instrument", "string_count", "fret_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: @@ -388,7 +398,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", "pathway", + "reference_pitch", "instrument", "string_count", "fret_count", "tuning", "pathway", "instrument_profiles", "active_instrument_profile", "achievements_enabled", "use_amp_sims", }) @@ -486,6 +496,10 @@ def _validate_server_config_types(cfg: dict) -> str | None: v = cfg["string_count"] if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not (4 <= v <= 8)): return "server_config.string_count must be an integer between 4 and 8" + if "fret_count" in cfg: + v = cfg["fret_count"] + if v is not None and (isinstance(v, bool) or not isinstance(v, int) or not (12 <= v <= 30)): + return "server_config.fret_count must be an integer between 12 and 30" if "tuning" in cfg: v = cfg["tuning"] if v is not None: diff --git a/lib/tunings.py b/lib/tunings.py index 9e94a8d4..42805361 100644 --- a/lib/tunings.py +++ b/lib/tunings.py @@ -85,6 +85,7 @@ def _build_profile_defaults(registry=None): "instrument": inst["id"], "role": role["id"], "string_count": inst.get("default_string_count", 0), + "fret_count": inst.get("default_fret_count", 0), "tuning": "E Standard", "reference_pitch": inst.get("reference_pitch", DEFAULT_REFERENCE_PITCH), "pathway": "songs", @@ -294,6 +295,7 @@ def apply_reference_pitch( "instrument": "guitar", "role": "lead", "string_count": 6, + "fret_count": 22, "tuning": "E Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, "pathway": "songs", @@ -304,6 +306,7 @@ def apply_reference_pitch( "instrument": "guitar", "role": "rhythm", "string_count": 6, + "fret_count": 22, "tuning": "E Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, "pathway": "songs", @@ -314,6 +317,7 @@ def apply_reference_pitch( "instrument": "bass", "role": "bass", "string_count": 4, + "fret_count": 20, "tuning": "E Standard", "reference_pitch": DEFAULT_REFERENCE_PITCH, "pathway": "songs", @@ -425,9 +429,14 @@ def normalize_instrument_profile(profile_id: str, raw, *, registry=None) -> tupl tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]), registry=registry) if tuning is None: return None, f"instrument_profiles.{profile_id}.tuning must match {key}" + try: + fret_count = int(raw.get("fret_count", base.get("fret_count", 0))) + except (TypeError, ValueError, OverflowError): + fret_count = base.get("fret_count", 0) else: string_count = 0 tuning = "" + fret_count = 0 ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"])) if ref is None: @@ -450,6 +459,7 @@ def normalize_instrument_profile(profile_id: str, raw, *, registry=None) -> tupl "instrument": instrument, "role": role, "string_count": string_count, + "fret_count": fret_count, "tuning": tuning, "reference_pitch": ref, "pathway": pathway, @@ -514,9 +524,14 @@ def profile_from_legacy_settings(cfg: dict, *, registry=None) -> dict: default_tuning = name break tuning = _valid_tuning_for_key(key, cfg.get("tuning", default_tuning), registry=registry) or default_tuning + try: + fc = int(cfg.get("fret_count", inst_def.get("default_fret_count", 22) if inst_def else 22)) + except (TypeError, ValueError, OverflowError): + fc = inst_def.get("default_fret_count", 22) if inst_def else 22 else: sc = 0 tuning = "" + fc = 0 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 = _default_profile_id_for_instrument(instrument, registry) @@ -524,6 +539,7 @@ def profile_from_legacy_settings(cfg: dict, *, registry=None) -> dict: profile.update({ "instrument": instrument, "string_count": sc, + "fret_count": fc, "tuning": tuning, "reference_pitch": ref, "pathway": pathway, @@ -553,6 +569,7 @@ def settings_with_instrument_profiles(cfg: dict, *, registry=None) -> dict: out["active_instrument_profile"] = active out["instrument"] = selected["instrument"] out["string_count"] = selected["string_count"] + out["fret_count"] = selected.get("fret_count", 0) out["tuning"] = selected["tuning"] out["reference_pitch"] = selected["reference_pitch"] out["pathway"] = selected["pathway"] @@ -563,7 +580,7 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict, *, registr """Mirror legacy flat instrument updates into the active host profile.""" reg = registry or _instrument_registry out = settings_with_instrument_profiles(cfg, registry=reg) - if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")): + if not any(k in updates for k in ("instrument", "string_count", "fret_count", "tuning", "reference_pitch", "pathway")): return out active = active_profile_id(out.get("active_instrument_profile"), registry=reg) if "instrument" in updates: @@ -579,8 +596,14 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict, *, registr current["string_count"] = inst_def.get("default_string_count", 0) else: current["string_count"] = 4 if updates["instrument"] == "bass" else 6 + if "fret_count" not in updates: + inst_def = reg.get(updates["instrument"]) if reg else None + if inst_def: + current["fret_count"] = inst_def.get("default_fret_count", 0) if "string_count" in updates: current["string_count"] = updates["string_count"] + if "fret_count" in updates: + current["fret_count"] = updates["fret_count"] if "reference_pitch" in updates: current["reference_pitch"] = updates["reference_pitch"] if "pathway" in updates: diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index e566f0d4..3292d486 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -12,6 +12,8 @@ "kind": "stringed", "string_counts": [4, 5, 6], "default_string_count": 4, + "fret_counts": [20, 21, 22, 24], + "default_fret_count": 20, "reference_pitch": 440.0, "detect_strategy": "pitch", "standard_tunings": { diff --git a/plugins/instrument_guitar/plugin.json b/plugins/instrument_guitar/plugin.json index b66f2823..c85a1782 100644 --- a/plugins/instrument_guitar/plugin.json +++ b/plugins/instrument_guitar/plugin.json @@ -11,6 +11,8 @@ "kind": "stringed", "string_counts": [6, 7, 8], "default_string_count": 6, + "fret_counts": [21, 22, 24], + "default_fret_count": 22, "reference_pitch": 440.0, "detect_strategy": "pitch", "standard_tunings": { diff --git a/plugins/instrument_vocals/assets/icon.svg b/plugins/instrument_vocals/assets/icon.svg new file mode 100644 index 00000000..4f2957aa --- /dev/null +++ b/plugins/instrument_vocals/assets/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/instrument_vocals/plugin.json b/plugins/instrument_vocals/plugin.json new file mode 100644 index 00000000..c8bc3e45 --- /dev/null +++ b/plugins/instrument_vocals/plugin.json @@ -0,0 +1,27 @@ +{ + "id": "instrument_vocals", + "name": "Vocals", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "vocals", + "label": "Vocals", + "kind": "vocal", + "string_counts": [], + "default_string_count": 0, + "key_counts": [], + "default_key_count": 0, + "reference_pitch": 440.0, + "detect_strategy": "pitch", + "roles": [ + { + "id": "vocals", + "label": "Vocals", + "arrangement_names": ["Vocals", "Voice", "Vocal", "Lead Vocal", "Backing Vocal"], + "default": true + } + ] + } +} diff --git a/static/v3/badges.js b/static/v3/badges.js index 16ee23e4..2c35ded6 100644 --- a/static/v3/badges.js +++ b/static/v3/badges.js @@ -76,6 +76,11 @@ if (instId === 'bass') return STRING_COUNTS.bass; return STRING_COUNTS.guitar; } + function fretCountsFor(instId) { + var inst = getInstrument(instId); + if (inst && inst.kind === 'stringed' && Array.isArray(inst.fret_counts) && inst.fret_counts.length) return inst.fret_counts; + return [21, 22, 24]; + } // 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 = {}; @@ -160,7 +165,7 @@ } } - let settings = { instrument: 'guitar', string_count: 6, key_count: 88, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; + let settings = { instrument: 'guitar', string_count: 6, fret_count: 22, key_count: 88, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' }; async function loadTunings() { try { @@ -233,9 +238,13 @@ else tuning = tunings[0] || 'Standard'; const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {}; const keyCount = Number.isFinite(Number(s.key_count)) ? Number(s.key_count) : 88; + const fretCounts = isStringedInstrument(instrument) ? fretCountsFor(instrument) : []; + const fc = Number(s.fret_count); + const fcValid = fretCounts.length && fretCounts.includes(fc) ? fc : (fretCounts[0] || (instrument === 'bass' ? 20 : 22)); settings = { instrument: instrument, string_count: scValid, + fret_count: fcValid, key_count: keyCount, tuning: tuning, reference_pitch: Math.min(450, Math.max(430, ref)), @@ -258,6 +267,7 @@ 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.fret_count != null) { profile.fret_count = patch.fret_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; } @@ -616,6 +626,8 @@ : '') + (isStringed ? instRow('Strings', stringCountsFor(settings.instrument).map((v) => pill('strings', v, v + '', settings.string_count === v)).join('')) : '') + + (isStringed ? instRow('Frets', fretCountsFor(settings.instrument).map((v) => + pill('frets', v, v + '', (settings.fret_count || 22) === v)).join('')) : '') + (isKeyboard ? instRow('Keys', (currentInst.key_counts || [25, 49, 61, 88]).map((v) => pill('key_count', v, v + '', (settings.key_count || currentInst.default_key_count || 88) === v)).join('')) : '') + // Handedness only for stringed instruments where frets get mirrored. @@ -651,6 +663,7 @@ const counts = stringCountsFor(v); // Use the instrument's DEFAULT count on switch — always. const newSc = isStr ? (instDef && instDef.default_string_count ? instDef.default_string_count : (counts[0] || 0)) : 0; + const newFc = isStr ? (instDef && instDef.default_fret_count ? instDef.default_fret_count : 22) : 0; const newKc = isKeys ? (instDef && instDef.default_key_count ? instDef.default_key_count : 88) : 0; const tunings = isStr ? _tuningsForInstrument(v, newSc) : []; // Remember last tuning per instrument: read the target profile's saved @@ -666,6 +679,7 @@ var patch = { instrument: v }; if (isStr) { patch.string_count = newSc; + patch.fret_count = newFc; patch.tuning = (savedTuning && tunings.includes(savedTuning)) ? savedTuning : (tunings[0] || settings.tuning); } if (isKeys) { @@ -690,6 +704,11 @@ setWorkingInstrument(settings.instrument, newSc); renderInstrument(); keepOpen(); })); + menu.querySelectorAll('[data-pill="frets"]').forEach((b) => b.addEventListener('click', async () => { + const newFc = Number(b.getAttribute('data-val')); + await saveSettings({ fret_count: newFc }); + renderInstrument(); keepOpen(); + })); menu.querySelectorAll('[data-pill="key_count"]').forEach((b) => b.addEventListener('click', async () => { const newKc = Number(b.getAttribute('data-val')); await saveSettings({ key_count: newKc }); diff --git a/static/v3/instruments-settings.js b/static/v3/instruments-settings.js index 55735c9f..e72c01ac 100644 --- a/static/v3/instruments-settings.js +++ b/static/v3/instruments-settings.js @@ -102,6 +102,11 @@ html += _renderEditableStringCounts(inst, over); } + // ── Fret counts (stringed, editable) ───── + if (isStringed) { + html += _renderEditableFretCounts(inst, over); + } + // ── Custom tunings (stringed, editable) ──── if (isStringed) { html += _renderTunings(inst, over); @@ -198,6 +203,31 @@ return html; } + function _renderEditableFretCounts(inst, over) { + var pluginCounts = inst.fret_counts || []; + var customCounts = over.custom_fret_counts || []; + var allCounts = pluginCounts.concat(customCounts.filter(function (c) { return pluginCounts.indexOf(c) < 0; })); + + var html = '
' + + '
Fret counts
' + + '
'; + for (var i = 0; i < allCounts.length; i++) { + var isPlugin = pluginCounts.indexOf(allCounts[i]) >= 0; + var removeBtn = !isPlugin + ? '' + : ''; + html += '' + + allCounts[i] + (allCounts[i] === inst.default_fret_count ? ' (default)' : '') + removeBtn + + ''; + } + html += '
' + + '
' + + '' + + '' + + '
'; + return html; + } + function _renderTunings(inst, over) { var html = '
' + '
Tuning presets
'; @@ -347,6 +377,40 @@ }); }); + // Add fret count + panel.querySelectorAll('[data-add-fc-btn]').forEach(function (btn) { + btn.addEventListener('click', function () { + var instId = btn.getAttribute('data-add-fc-btn'); + var input = panel.querySelector('[data-add-fc="' + instId + '"]'); + if (!input) return; + var fc = parseInt(input.value, 10); + if (!fc || fc < 12 || fc > 30) return; + var over = getOverrides(instId); + if (!over.custom_fret_counts) over.custom_fret_counts = []; + if (over.custom_fret_counts.indexOf(fc) < 0) { + over.custom_fret_counts.push(fc); + saveOverridesDebounced(); + } + input.value = ''; + renderInstruments(); + }); + }); + + // Remove fret count + panel.querySelectorAll('[data-remove-fc]').forEach(function (btn) { + btn.addEventListener('click', function (e) { + e.stopPropagation(); + var instId = btn.getAttribute('data-inst'); + var fc = parseInt(btn.getAttribute('data-remove-fc'), 10); + var over = getOverrides(instId); + if (over.custom_fret_counts) { + over.custom_fret_counts = over.custom_fret_counts.filter(function (c) { return c !== fc; }); + saveOverridesDebounced(); + } + renderInstruments(); + }); + }); + // Add tuning panel.querySelectorAll('[data-add-tuning-btn]').forEach(function (btn) { btn.addEventListener('click', function () { From 026a608debd910599941e283dbc1a3039b01e6d0 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:44:32 +0200 Subject: [PATCH 66/74] chore: add CC0 SVG source comment to instrument plugins, fix duplicate icon in bass --- plugins/instrument_bass/plugin.json | 2 +- plugins/instrument_drums/plugin.json | 1 + plugins/instrument_guitar/plugin.json | 1 + plugins/instrument_piano/plugin.json | 1 + plugins/instrument_vocals/plugin.json | 1 + 5 files changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index 3292d486..7e230a63 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -4,7 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "icon": "assets/icon.svg", + "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "bass", diff --git a/plugins/instrument_drums/plugin.json b/plugins/instrument_drums/plugin.json index 6d34924e..5500ebd7 100644 --- a/plugins/instrument_drums/plugin.json +++ b/plugins/instrument_drums/plugin.json @@ -4,6 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, + "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "drums", diff --git a/plugins/instrument_guitar/plugin.json b/plugins/instrument_guitar/plugin.json index c85a1782..5319c387 100644 --- a/plugins/instrument_guitar/plugin.json +++ b/plugins/instrument_guitar/plugin.json @@ -4,6 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, + "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "guitar", diff --git a/plugins/instrument_piano/plugin.json b/plugins/instrument_piano/plugin.json index eeb741ed..f0209344 100644 --- a/plugins/instrument_piano/plugin.json +++ b/plugins/instrument_piano/plugin.json @@ -4,6 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, + "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "keys", diff --git a/plugins/instrument_vocals/plugin.json b/plugins/instrument_vocals/plugin.json index c8bc3e45..240c2d40 100644 --- a/plugins/instrument_vocals/plugin.json +++ b/plugins/instrument_vocals/plugin.json @@ -4,6 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, + "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "vocals", From 486e350c2790a4498e2006685d11aa82575d3eb2 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:47:11 +0200 Subject: [PATCH 67/74] Revert "chore: add CC0 SVG source comment to instrument plugins, fix duplicate icon in bass" This reverts commit 026a608debd910599941e283dbc1a3039b01e6d0. --- plugins/instrument_bass/plugin.json | 2 +- plugins/instrument_drums/plugin.json | 1 - plugins/instrument_guitar/plugin.json | 1 - plugins/instrument_piano/plugin.json | 1 - plugins/instrument_vocals/plugin.json | 1 - 5 files changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index 7e230a63..3292d486 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -4,7 +4,7 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", + "icon": "assets/icon.svg", "icon": "assets/icon.svg", "instrument": { "id": "bass", diff --git a/plugins/instrument_drums/plugin.json b/plugins/instrument_drums/plugin.json index 5500ebd7..6d34924e 100644 --- a/plugins/instrument_drums/plugin.json +++ b/plugins/instrument_drums/plugin.json @@ -4,7 +4,6 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "drums", diff --git a/plugins/instrument_guitar/plugin.json b/plugins/instrument_guitar/plugin.json index 5319c387..c85a1782 100644 --- a/plugins/instrument_guitar/plugin.json +++ b/plugins/instrument_guitar/plugin.json @@ -4,7 +4,6 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "guitar", diff --git a/plugins/instrument_piano/plugin.json b/plugins/instrument_piano/plugin.json index f0209344..eeb741ed 100644 --- a/plugins/instrument_piano/plugin.json +++ b/plugins/instrument_piano/plugin.json @@ -4,7 +4,6 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "keys", diff --git a/plugins/instrument_vocals/plugin.json b/plugins/instrument_vocals/plugin.json index 240c2d40..c8bc3e45 100644 --- a/plugins/instrument_vocals/plugin.json +++ b/plugins/instrument_vocals/plugin.json @@ -4,7 +4,6 @@ "version": "1.0.0", "type": "instrument", "bundled": true, - "_comment": "Find CC0-licensed instrument SVGs at https://www.svgrepo.com/collection/music-instrument", "icon": "assets/icon.svg", "instrument": { "id": "vocals", From 1657dec20ef3a872a102af408d646559ab7f4ade Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:47:57 +0200 Subject: [PATCH 68/74] docs: add instrument-plugins.md reference, fix duplicate bass icon --- docs/instrument-plugins.md | 177 ++++++++++++++++++++++++++++ plugins/instrument_bass/plugin.json | 1 - 2 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 docs/instrument-plugins.md diff --git a/docs/instrument-plugins.md b/docs/instrument-plugins.md new file mode 100644 index 00000000..b1151f40 --- /dev/null +++ b/docs/instrument-plugins.md @@ -0,0 +1,177 @@ +# Instrument Plugins + +Instruments in fee[dB]ack are defined by bundled **instrument plugins** — each is a +small `plugins/instrument_/` directory with a `plugin.json` manifest and an +SVG icon. They declare the instrument's tuning presets, string/key counts, +arrangement role mappings, and detection strategy, replacing hardcoded checks +across the codebase. + +## Interface + +Each instrument plugin sets `"type": "instrument"` in its manifest and carries an +`"instrument"` block with the following schema: + +```jsonc +{ + "id": "instrument_guitar", // unique plugin id (snake_case, prefixed "instrument_") + "name": "Guitar", // human-readable name + "version": "1.0.0", + "type": "instrument", // ⬅ required + "bundled": true, // a bundled plugin (not user-installed) + "icon": "assets/icon.svg", // path relative to the plugin directory + "instrument": { + "id": "guitar", // instrument id (snake_case, used in settings & profiles) + "label": "Guitar", // display label for the instrument selector + "kind": "stringed", // "stringed" | "keyboard" | "percussion" | "vocal" | "custom" + + // ── Stringed instruments ────────────────────────────────────────────── + "string_counts": [6, 7, 8], // (required for stringed) array of ints + "default_string_count": 6, // (required for stringed) must be in string_counts + "fret_counts": [21, 22, 24], // (required for stringed) array of ints + "default_fret_count": 22, // (required for stringed) must be in fret_counts + "reference_pitch": 440.0, // A4 reference (default 440) + "standard_tunings": { // (required for stringed) open MIDI pitches per string count + "6": [40, 45, 50, 55, 59, 64], + "7": [35, 40, 45, 50, 55, 59, 64], + "8": [30, 35, 40, 45, 50, 55, 59, 64] + }, + "tunings": { // (required for stringed) named tuning presets per string count + "6": { + "E Standard": [ 0, 0, 0, 0, 0, 0], + "Drop D": [-2, 0, 0, 0, 0, 0], + "Eb Standard": [-1, -1, -1, -1, -1, -1] + } + }, + + // ── Keyboard instruments ─────────────────────────────────────────────── + "key_counts": [25, 49, 61, 88], // (required for keyboard) array of ints + "default_key_count": 88, // (required for keyboard) must be in key_counts + + // ── All instruments ──────────────────────────────────────────────────── + "detect_strategy": "pitch", // "pitch" (uses tuner) | "onset" (drums) | null + "roles": [ // one or more arrangement roles + { + "id": "lead", // role id (snake_case), used in profile ids + "label": "Lead", // display label for the role selector + "arrangement_flags": ["path_lead"], // XML flags that identify this role + "arrangement_names": ["Lead", "Lead Guitar"], // names that identify this role + "default": true // the default role for this instrument + } + ] + } +} +``` + +### `kind` values + +| Kind | Example | Has tunings? | Has string counts? | Notes | +|------|---------|:---:|:---:|-------| +| `"stringed"` | guitar, bass | Yes | Yes | Shows tunings, frets, handedness in the selector | +| `"keyboard"` | piano, keys | Yes | No (key counts instead) | Shows key count selector | +| `"percussion"` | drums | No | No | No tuner, hit detection | +| `"vocal"` | vocals | No | No | Pitched, uses tuner, no instrument-specific controls | +| `"custom"` | any future type | Depends | Depends | Free-form | + +### Role mapping + +When a player selects a role (e.g. Guitar → Rhythm), the highway opens the +arrangement matching that role. Roles match against: + +1. **Arrangement flags** — XML `` attributes (e.g. `path_bass`, `path_lead`). + Flagged arrangements match immediately, regardless of name. +2. **Arrangement names** — matched case-insensitively against the arrangement's + smart name (from XML parsing) and raw name. List common GP/RS2014 naming + variants for each role to maximise coverage (e.g. `["Bass", "Pick Bass", "Fingered Bass"]`). + +The library auto-filters to arrangements matching the current instrument's roles. + +## Creating a new instrument plugin + +1. Create `plugins/instrument_/plugin.json` (copy the skeleton below). +2. Create `plugins/instrument_/assets/icon.svg` — use a + **[CC0-licensed SVG from SVGrepo's music instrument collection](https://www.svgrepo.com/collection/music-instrument)** (all free for any use, no attribution required). +3. Fill in the `instrument` block with tunings, string/fret/key counts, and role + mappings for your instrument. +4. Restart fee[dB]ack — the loader discovers the plugin automatically. + +### Minimal skeleton + +```jsonc +{ + "id": "instrument_", + "name": "", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "", + "label": "", + "kind": "stringed", + "string_counts": [6], + "default_string_count": 6, + "fret_counts": [22, 24], + "default_fret_count": 22, + "reference_pitch": 440.0, + "standard_tunings": { + "6": [40, 45, 50, 55, 59, 64] + }, + "tunings": { + "6": { "E Standard": [0, 0, 0, 0, 0, 0] } + }, + "detect_strategy": "pitch", + "roles": [ + { + "id": "", + "label": "", + "arrangement_names": [""], + "default": true + } + ] + } +} +``` + +### Non-stringed skeleton + +```jsonc +{ + "id": "instrument_", + "name": "", + "version": "1.0.0", + "type": "instrument", + "bundled": true, + "icon": "assets/icon.svg", + "instrument": { + "id": "", + "label": "", + "kind": "vocal", + "string_counts": [], + "default_string_count": 0, + "key_counts": [], + "default_key_count": 0, + "reference_pitch": 440.0, + "detect_strategy": "pitch", + "roles": [ + { + "id": "", + "label": "", + "arrangement_names": [""], + "default": true + } + ] + } +} +``` + +## Bundled instruments + +These ship with fee[dB]ack and are in the `plugins/` directory tree by default: + +| Plugin directory | Instrument id | Kind | +|------------------|:---:|---| +| `instrument_guitar` | `guitar` | stringed | +| `instrument_bass` | `bass` | stringed | +| `instrument_drums` | `drums` | percussion | +| `instrument_piano` | `keys` | keyboard | +| `instrument_vocals` | `vocals` | vocal | diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index 3292d486..b819eaff 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -5,7 +5,6 @@ "type": "instrument", "bundled": true, "icon": "assets/icon.svg", - "icon": "assets/icon.svg", "instrument": { "id": "bass", "label": "Bass", From e0946e3157e332f1f519f7786ac782fddcb89b59 Mon Sep 17 00:00:00 2001 From: Job Date: Wed, 22 Jul 2026 21:50:28 +0200 Subject: [PATCH 69/74] docs: update changelog with vocals plugin, fret count, bass pick styles, and merge fixes --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1012ec6e..2e800b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Score attribution fix** — scores now record the server-resolved arrangement index (from `song:ready`) instead of defaulting to 0 when clicking the main card (not an arrangement chip). +- **Vocals instrument plugin** — a new `kind: "vocal"` instrument with pitch + detection. Placeholder for future vocal chart support; no instrument-specific + controls in the selector. Includes a CC0 microphone SVG icon. +- **Fret count selector** — stringed instruments (guitar, bass) now declare + `fret_counts` and `default_fret_count` in their plugin manifests. The + instrument selector popover shows a **Frets** pill row alongside Strings, + and Settings > Instruments lets users add custom fret counts per instrument. +- **Bass pick/fingered styles** — the bass instrument's arrangement name + patterns now include `"Pick Bass"`, `"Fingered Bass"`, `"Finger Bass"`, and + `"Picked Bass"` so GP-imported arrangements with those names match the bass + role (previously only exact `"Bass"` matched via the registry path). +- **Instrument plugin documentation** — `docs/instrument-plugins.md` covers the + complete interface schema, role mapping mechanics, skeleton templates for + stringed and non-stringed instruments, and recommends CC0-licensed SVGs from + [SVGrepo's music instrument collection](https://www.svgrepo.com/collection/music-instrument). - **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song carries several drum charts, a **Drum part** selector appears beside the arrangement switcher (advanced settings) so a player can choose which drummer @@ -161,6 +176,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Icons** — instrument selector card shows per-instrument icons instead of a single hardcoded guitar SVG. - **Pathway removed** from the instrument selector (unused feature). +- **Tuning chip** fades out on card hover so save/favorite action buttons are + not obscured. Now flush to the top-left corner with a bottom-right radius + matching the card. +- **Instrument chip logic** reads from the instrument registry rather than + hardcoding bass/lead detection. Offset truncation uses the instrument's + `string_count` from the registry, supporting any future pitched instrument. - **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS `ready` sends. The stems plugin could only learn it from that WS message, which @@ -336,6 +357,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 empty (double-AND). - **Default arrangement dropdown** removed from Settings > Gameplay (moved to the instrument selector role pills). +- **Tuning chip matching** — the offset-detection chain was reordered to use + the song-level `tuning_offsets` field first and fall back through perspective-aware + sources, fixing native guitar cards that lacked `data-tuning-offsets`. Bass chips + now correctly truncate inferred 6-element offsets to 4 for the coverage checker. +- **Bass instrument selector** — switching to bass no longer leaves the + tuning chips permanently amber. The chip-is-bass detection was too restrictive, + requiring a native bass chart when an inferred one was already showing. +- **Double reload on instrument switch** — changing instruments briefly triggered + both `instrument:changed` and `working-tuning-changed` reloads, racing state + mutations. The instrument-change handler now sets `_lastRenderInstrument` early + so the tuning-changed path skips its reload and only re-decorates. +- **Arrangement name pattern encoding** — the Instruments settings panel showed + mojibake (`ΓåÆ` and `ΓöÇ`) instead of `→` and box-drawing characters due to + a wrong-encoding save. All five corrupted sequences were restored. +- **`renderDrawerIfOpen`** was missing after the songs.js port, causing a + `ReferenceError` when the instrument auto-filter ran. The helper was restored. - **GP8 asset resolution honours the directory the registry named.** `` is matched on filename stem so a format variant of the same recording can win (an `.ogg` beside the declared `.mp3` is copied out From e997d275f0c2bfcfc2c6a31aeb3884f31dfaab95 Mon Sep 17 00:00:00 2001 From: Job Date: Thu, 23 Jul 2026 14:36:32 +0200 Subject: [PATCH 70/74] chore: rename instrument_piano to instrument_keys, drop instrument_vocals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename plugins/instrument_piano/ to plugins/instrument_keys/ for consistency with the 'keys' instrument id used everywhere (data/progression/paths/keys.json, keys_highway_3d, etc.) - Remove instrument_vocals plugin — defer to the coordinated instrument-pathways FEP with its sidecar PRs - Update docs/instrument-plugins.md bundled instruments table - Remove vocal-specific routing from the hardcoded fallback in lib/progression.py --- .../assets/icon.svg | 0 .../plugin.json | 0 plugins/instrument_vocals/assets/icon.svg | 6 ----- plugins/instrument_vocals/plugin.json | 27 ------------------- 4 files changed, 33 deletions(-) rename plugins/{instrument_piano => instrument_keys}/assets/icon.svg (100%) rename plugins/{instrument_piano => instrument_keys}/plugin.json (100%) delete mode 100644 plugins/instrument_vocals/assets/icon.svg delete mode 100644 plugins/instrument_vocals/plugin.json diff --git a/plugins/instrument_piano/assets/icon.svg b/plugins/instrument_keys/assets/icon.svg similarity index 100% rename from plugins/instrument_piano/assets/icon.svg rename to plugins/instrument_keys/assets/icon.svg diff --git a/plugins/instrument_piano/plugin.json b/plugins/instrument_keys/plugin.json similarity index 100% rename from plugins/instrument_piano/plugin.json rename to plugins/instrument_keys/plugin.json diff --git a/plugins/instrument_vocals/assets/icon.svg b/plugins/instrument_vocals/assets/icon.svg deleted file mode 100644 index 4f2957aa..00000000 --- a/plugins/instrument_vocals/assets/icon.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/plugins/instrument_vocals/plugin.json b/plugins/instrument_vocals/plugin.json deleted file mode 100644 index c8bc3e45..00000000 --- a/plugins/instrument_vocals/plugin.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": "instrument_vocals", - "name": "Vocals", - "version": "1.0.0", - "type": "instrument", - "bundled": true, - "icon": "assets/icon.svg", - "instrument": { - "id": "vocals", - "label": "Vocals", - "kind": "vocal", - "string_counts": [], - "default_string_count": 0, - "key_counts": [], - "default_key_count": 0, - "reference_pitch": 440.0, - "detect_strategy": "pitch", - "roles": [ - { - "id": "vocals", - "label": "Vocals", - "arrangement_names": ["Vocals", "Voice", "Vocal", "Lead Vocal", "Backing Vocal"], - "default": true - } - ] - } -} From 6f0ebc1b4b59355e60158b81d3c1ea473b536361 Mon Sep 17 00:00:00 2001 From: Job Date: Thu, 23 Jul 2026 14:37:08 +0200 Subject: [PATCH 71/74] feat: add arrangement_types key alongside arrangement_names in instrument roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the normative spec type routing (arrangement_types, exact match against the arrangement's type field) from cosmetic display name routing (arrangement_names, fuzzy fallback). Types win on collision. Changes: - Add arrangement_types field to role schema (optional list of lowercase type tokens, e.g. lead/rhythm/bass/drums/keys) - Update all 4 bundled instrument manifests to declare arrangement_types - InstrumentRegistry.register(): validate arrangement_types, add cross-instrument collision detection warnings - InstrumentRegistry.instrument_id_for_arrangement(): check types first, then names, then flags (backward-compatible — existing plugins without arrangement_types continue to match via arrangement_names) - InstrumentRegistry.find_role_by_arrangement(): same priority order - progression.instrument_for_arrangement(): same types-first logic - Change duplicate-id from ValueError to first-wins + warning (registry) - Update docs/instrument-plugins.md schema and role mapping section --- docs/instrument-plugins.md | 14 ++++--- lib/instruments.py | 57 ++++++++++++++++++++++++--- lib/progression.py | 6 +-- plugins/instrument_bass/plugin.json | 1 + plugins/instrument_drums/plugin.json | 1 + plugins/instrument_guitar/plugin.json | 2 + plugins/instrument_keys/plugin.json | 1 + 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/docs/instrument-plugins.md b/docs/instrument-plugins.md index b1151f40..15c6cb9c 100644 --- a/docs/instrument-plugins.md +++ b/docs/instrument-plugins.md @@ -54,7 +54,8 @@ Each instrument plugin sets `"type": "instrument"` in its manifest and carries a "id": "lead", // role id (snake_case), used in profile ids "label": "Lead", // display label for the role selector "arrangement_flags": ["path_lead"], // XML flags that identify this role - "arrangement_names": ["Lead", "Lead Guitar"], // names that identify this role + "arrangement_types": ["lead"], // spec `type` values that identify this role (normative, exact match — preferred over names) + "arrangement_names": ["Lead", "Lead Guitar"], // display names that identify this role (fuzzy/fallback) "default": true // the default role for this instrument } ] @@ -77,9 +78,12 @@ Each instrument plugin sets `"type": "instrument"` in its manifest and carries a When a player selects a role (e.g. Guitar → Rhythm), the highway opens the arrangement matching that role. Roles match against: -1. **Arrangement flags** — XML `` attributes (e.g. `path_bass`, `path_lead`). +1. **Arrangement types** — spec `type` values (e.g. `"bass"`, `"drums"`, `"lead"`). + Matched exactly against the arrangement's `type` field. This is the primary, + normative routing key and is stable across display-name changes. +2. **Arrangement flags** — XML `` attributes (e.g. `path_bass`, `path_lead`). Flagged arrangements match immediately, regardless of name. -2. **Arrangement names** — matched case-insensitively against the arrangement's +3. **Arrangement names** — matched case-insensitively against the arrangement's smart name (from XML parsing) and raw name. List common GP/RS2014 naming variants for each role to maximise coverage (e.g. `["Bass", "Pick Bass", "Fingered Bass"]`). @@ -173,5 +177,5 @@ These ship with fee[dB]ack and are in the `plugins/` directory tree by default: | `instrument_guitar` | `guitar` | stringed | | `instrument_bass` | `bass` | stringed | | `instrument_drums` | `drums` | percussion | -| `instrument_piano` | `keys` | keyboard | -| `instrument_vocals` | `vocals` | vocal | +| `instrument_keys` | `keys` | keyboard | + diff --git a/lib/instruments.py b/lib/instruments.py index 442077d3..f4bad0d0 100644 --- a/lib/instruments.py +++ b/lib/instruments.py @@ -105,7 +105,12 @@ def register(self, definition: dict): """ inst_id = _validate_str(definition.get("id"), "instrument.id") if inst_id in self._instruments: - raise ValueError(f"instrument {inst_id!r} is already registered") + log.warning( + "instrument %r already registered by plugin %s; skipping %r from plugin %s", + inst_id, self._instruments[inst_id].get("_plugin_id", "?"), + definition.get("label", inst_id), definition.get("_plugin_id", "?"), + ) + return label = _validate_str(definition.get("label", inst_id), "instrument.label") kind = _validate_str(definition.get("kind", "custom"), "instrument.kind") if kind not in _INSTRUMENT_KINDS: @@ -233,6 +238,12 @@ def register(self, definition: dict): r_names = role.get("arrangement_names") or [] if not isinstance(r_names, list): raise ValueError(f"roles[{i}].arrangement_names must be a list") + r_types = role.get("arrangement_types") or [] + if not isinstance(r_types, list): + raise ValueError(f"roles[{i}].arrangement_types must be a list") + for t in r_types: + if not isinstance(t, str) or not t.strip(): + raise ValueError(f"roles[{i}].arrangement_types entries must be non-empty strings") r_default = bool(role.get("default")) if r_default: if has_default: @@ -242,12 +253,34 @@ def register(self, definition: dict): "id": r_id, "label": r_label, "arrangement_flags": list(set(r_flags)), + "arrangement_types": [t.strip().lower() for t in r_types if t.strip()], "arrangement_names": [n.strip().lower() for n in r_names if isinstance(n, str) and n.strip()], "default": r_default, }) if roles and not has_default: roles[0]["default"] = True + # Cross-instrument collision detection: warn when two instruments + # claim the same arrangement name or type. First-wins rules — the + # already-registered instrument keeps the claim. + for existing_id, existing in self._instruments.items(): + for new_role in roles: + for existing_role in existing["roles"]: + overlapping_types = set(new_role.get("arrangement_types", [])) & set(existing_role.get("arrangement_types", [])) + if overlapping_types: + log.warning( + "instrument %r role %r arrangement_types %s overlap with already-registered %r role %r", + inst_id, new_role["id"], sorted(overlapping_types), + existing_id, existing_role["id"], + ) + overlapping_names = set(new_role["arrangement_names"]) & set(existing_role["arrangement_names"]) + if overlapping_names: + log.warning( + "instrument %r role %r arrangement_names %s overlap with already-registered %r role %r", + inst_id, new_role["id"], sorted(overlapping_names), + existing_id, existing_role["id"], + ) + normalized = { "id": inst_id, "label": label, @@ -325,13 +358,20 @@ def get_default_role(self, instrument_id: str) -> str | None: return inst["roles"][0]["id"] return None - def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: dict = None) -> str | None: - """Find the role id matching an arrangement name or path flags for a specific instrument.""" + def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: dict = None, arr_type: str = "") -> str | None: + """Find the role id matching an arrangement name, type, or path flags for a specific instrument. + + Priority: arrangement_types first (exact spec ``type`` match), then + arrangement_names, then arrangement_flags. + """ inst = self._instruments.get(instrument_id) if not inst: return None + arr_type_lower = (arr_type or "").strip().lower() name_lower = (arr_name or "").strip().lower() for role in inst["roles"]: + if arr_type_lower and arr_type_lower in role.get("arrangement_types", []): + return role["id"] if name_lower in role["arrangement_names"]: return role["id"] if arr_flags: @@ -340,11 +380,18 @@ def find_role_by_arrangement(self, instrument_id: str, arr_name: str, arr_flags: return role["id"] return None - def instrument_id_for_arrangement(self, arr_name: str, arr_flags: dict = None) -> str | None: - """Return the instrument id whose roles match a given arrangement name or flags.""" + def instrument_id_for_arrangement(self, arr_name: str, arr_flags: dict = None, arr_type: str = "") -> str | None: + """Return the instrument id whose roles match a given arrangement name, type, or flags. + + Priority: arrangement_types (exact match against spec ``type``) first, + then arrangement_names (fuzzy display name match), then arrangement_flags. + """ + arr_type_lower = (arr_type or "").strip().lower() name_lower = (arr_name or "").strip().lower() for inst in self._instruments.values(): for role in inst["roles"]: + if arr_type_lower and arr_type_lower in role.get("arrangement_types", []): + return inst["id"] if name_lower in role["arrangement_names"]: return inst["id"] if arr_flags: diff --git a/lib/progression.py b/lib/progression.py index c2c595f4..bceb44a4 100644 --- a/lib/progression.py +++ b/lib/progression.py @@ -349,9 +349,9 @@ def instrument_for_arrangement(arr_entry, *, registry=None): if registry: for inst in registry.get_all(): for role in inst.get("roles", []): - if name in role.get("arrangement_names", []): + if arr_type in role.get("arrangement_types", []): return inst["id"] - if arr_type in role.get("arrangement_names", []): + if name in role.get("arrangement_names", []): return inst["id"] # Also check flags: build a virtual arr_entry for path flag matching for inst in registry.get_all(): @@ -376,8 +376,6 @@ def instrument_for_arrangement(arr_entry, *, registry=None): return "bass" if "drum" in name or "percussion" in name: return "drums" - if "vocal" in name: - return "vocals" # Word-boundary match so e.g. "Monkeys Medley" doesn't read as keys. if re.search(r"\b(?:keys|piano|keyboard|synth)\b", name): return "keys" diff --git a/plugins/instrument_bass/plugin.json b/plugins/instrument_bass/plugin.json index b819eaff..eb5ccfff 100644 --- a/plugins/instrument_bass/plugin.json +++ b/plugins/instrument_bass/plugin.json @@ -53,6 +53,7 @@ "id": "bass", "label": "Bass", "arrangement_flags": ["path_bass"], + "arrangement_types": ["bass"], "arrangement_names": ["Bass", "Pick Bass", "Fingered Bass", "Finger Bass", "Picked Bass"], "default": true } diff --git a/plugins/instrument_drums/plugin.json b/plugins/instrument_drums/plugin.json index 6d34924e..f32d87be 100644 --- a/plugins/instrument_drums/plugin.json +++ b/plugins/instrument_drums/plugin.json @@ -17,6 +17,7 @@ { "id": "drums", "label": "Drums", + "arrangement_types": ["drums"], "arrangement_names": ["Drums", "Drum Kit", "Percussion"], "default": true } diff --git a/plugins/instrument_guitar/plugin.json b/plugins/instrument_guitar/plugin.json index c85a1782..6488ab07 100644 --- a/plugins/instrument_guitar/plugin.json +++ b/plugins/instrument_guitar/plugin.json @@ -60,6 +60,7 @@ "id": "lead", "label": "Lead", "arrangement_flags": ["path_lead"], + "arrangement_types": ["lead"], "arrangement_names": ["Lead", "Lead Guitar"], "default": true }, @@ -67,6 +68,7 @@ "id": "rhythm", "label": "Rhythm", "arrangement_flags": ["path_rhythm"], + "arrangement_types": ["rhythm"], "arrangement_names": ["Rhythm", "Rhythm Guitar"] } ] diff --git a/plugins/instrument_keys/plugin.json b/plugins/instrument_keys/plugin.json index eeb741ed..c2ad3227 100644 --- a/plugins/instrument_keys/plugin.json +++ b/plugins/instrument_keys/plugin.json @@ -19,6 +19,7 @@ { "id": "keys", "label": "Keys", + "arrangement_types": ["keys"], "arrangement_names": ["Keys", "Piano", "Keyboard", "Synth", "Synth Lead"], "default": true } From 2b95f4c7f4fbb207633844034091404e4f5ab178 Mon Sep 17 00:00:00 2001 From: Job Date: Thu, 23 Jul 2026 15:00:33 +0200 Subject: [PATCH 72/74] feat: display arrangement_types in settings role cards (non-editable) --- static/v3/instruments-settings.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/static/v3/instruments-settings.js b/static/v3/instruments-settings.js index e72c01ac..60425688 100644 --- a/static/v3/instruments-settings.js +++ b/static/v3/instruments-settings.js @@ -87,8 +87,17 @@ '
'; for (var j = 0; j < inst.roles.length; j++) { var role = inst.roles[j]; + var typeTag = ''; + if (role.arrangement_types && role.arrangement_types.length) { + typeTag = ' ('; + for (var ti = 0; ti < role.arrangement_types.length; ti++) { + if (ti > 0) typeTag += ', '; + typeTag += esc(role.arrangement_types[ti]); + } + typeTag += ')'; + } html += '' + - esc(role.label) + (role.default ? ' (default)' : '') + + esc(role.label) + (role.default ? ' (default)' : '') + typeTag + ''; } html += '
'; From 5a6772a2f8ead1ed77b03078e7e1900aa0774a74 Mon Sep 17 00:00:00 2001 From: Job Date: Thu, 23 Jul 2026 15:30:22 +0200 Subject: [PATCH 73/74] chore: normalize indentation in songs.js (no functional change) --- static/v3/songs.js | 648 ++++----------------------------------------- 1 file changed, 52 insertions(+), 596 deletions(-) diff --git a/static/v3/songs.js b/static/v3/songs.js index 18603714..255e8145 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -43,110 +43,8 @@ ['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'], ]; const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']]; - function _arrangementOptions() { - // Derive filter pill labels from instrument registry role names. - // When new instrument plugins are added, their arrangement type labels - // appear in the library filter drawer automatically. - var insts = sm && sm._instruments; - if (Array.isArray(insts) && insts.length) { - var out = []; - for (var i = 0; i < insts.length; i++) { - var roles = insts[i].roles || []; - for (var j = 0; j < roles.length; j++) { - var label = roles[j].label; - if (label && out.indexOf(label) < 0) out.push(label); - } - } - if (out.length) return out; - } - return ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; - } - - function _roleIcon(arrName) { - var insts = window.feedBack && window.feedBack._instruments; - if (!Array.isArray(insts)) return '\u25cf'; - var name = (arrName || '').toLowerCase(); - for (var i = 0; i < insts.length; i++) { - var inst = insts[i]; - var roles = inst.roles || []; - for (var j = 0; j < roles.length; j++) { - var r = roles[j]; - if (name === r.label.toLowerCase()) { - if (inst.roles.length > 1) { - return (inst.id[0] || '?').toUpperCase() + '' + (r.label[0] || '?').toUpperCase() + ''; - } - return (inst.id[0] || (r.label[0] || '?')).toUpperCase(); - } - var names = r.arrangement_names || []; - for (var k = 0; k < names.length; k++) { - if (name === names[k]) { - if (inst.roles.length > 1) { - return (inst.id[0] || '?').toUpperCase() + '' + (r.label[0] || '?').toUpperCase() + ''; - } - return (inst.id[0] || (r.label[0] || '?')).toUpperCase(); - } - } - } - } - return '\u25cf'; - } - - function _isPitchedArrangement(arrName) { - var insts = window.feedBack && window.feedBack._instruments; - if (!Array.isArray(insts)) return true; - var name = (arrName || '').toLowerCase(); - for (var i = 0; i < insts.length; i++) { - var inst = insts[i]; - var roles = inst.roles || []; - for (var j = 0; j < roles.length; j++) { - var r = roles[j]; - if (name === r.label.toLowerCase()) return inst.detect_strategy === 'pitch'; - var names = r.arrangement_names || []; - for (var k = 0; k < names.length; k++) { - if (name === names[k]) return inst.detect_strategy === 'pitch'; - } - } - } - return true; - } - - function _instrumentArrangementNames(instrumentId) { - var insts = sm && sm._instruments; - if (!Array.isArray(insts)) return null; - for (var i = 0; i < insts.length; i++) { - if (insts[i].id === instrumentId && insts[i].roles) { - var names = []; - for (var j = 0; j < insts[i].roles.length; j++) { - var label = insts[i].roles[j].label; - if (label && names.indexOf(label) < 0) names.push(label); - } - return names.length ? names : null; - } - } - return null; - } - - function _applyArrangementAutoFilter(instrumentId) { - var names = _instrumentArrangementNames(instrumentId); - if (!names) return false; - _arrAutoInstrument = instrumentId; - state.filters.arr_has = names.slice(); - state.filters.arr_lacks = []; - return true; - } - - function _activeInstrument() { - var id = sm && sm._activeInstrumentProfile; - if (!id) return null; - var insts = window.feedBack && window.feedBack._instruments; - if (!Array.isArray(insts)) return null; - for (var i = 0; i < insts.length; i++) { - if (id.indexOf(insts[i].id) === 0) return insts[i]; - } - return null; - } - - const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'piano', 'other']; + const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals']; + const STEMS = ['guitar', 'bass', 'drums', 'vocals', 'other']; const PAGE_SIZE = 24; // Extra rows rendered above/below the viewport so a fast scroll doesn't flash // blank before the next window render lands. @@ -164,8 +62,8 @@ artist: '', album: '', grouping: true, // one card per song (multi-chart grouping); persisted - filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }, - page: 0, total: 0, loading: false, built: false, accuracy: {}, arrangementAccuracy: {}, tuningNames: [], genres: [], + filters: { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] }, + page: 0, total: 0, loading: false, built: false, accuracy: {}, tuningNames: [], genres: [], artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), @@ -222,7 +120,7 @@ function activeFilterCount() { const f = state.filters; return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length + - (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : f.tunings.length) + (f.mastery ? f.mastery.length : 0) + + (f.lyrics ? 1 : 0) + f.tunings.length + (f.mastery ? f.mastery.length : 0) + (f.match ? f.match.length : 0) + (f.genre ? f.genre.length : 0) + (state.artist ? 1 : 0) + (state.album ? 1 : 0); } @@ -382,21 +280,7 @@ if (f.stem_has.length) p.set('stems_has', f.stem_has.join(',')); if (f.stem_lacks.length) p.set('stems_lacks', f.stem_lacks.join(',')); if (f.lyrics) p.set('has_lyrics', f.lyrics); - // The two modes answer different questions, so only one filters at a - // time: sending both would silently intersect them. - if (f.tunings.length && f.tuningMatch !== 'playable') p.set('tunings', f.tunings.join(',')); - // Tell the server which instrument perspective to use for tuning - // columns (bass_tuning_name vs tuning_name, etc.). - var instId = sm && sm._activeInstrumentProfile; - // Which perspective the `tunings` filter + the tuning sort read. - // Uses the 3-perspective model (guitar-lead, guitar-rhythm, bass) from - // the active instrument profile. Drums/piano/keys fall through to the - // default guitar-lead perspective. - if (libInstrument() !== 'guitar-lead') p.set('instrument', libInstrument()); - // "Playable without retuning": send the player's LIVE tuning and let the - // server do the pitch maths (the pitch tables live in lib/tunings.py — - // duplicating them here is how the two drift apart). - applyPlayableParams(p, f); + if (f.tunings.length) p.set('tunings', f.tunings.join(',')); if (f.mastery && f.mastery.length) p.set('mastery', f.mastery.join(',')); if (f.match && f.match.length) p.set('match', f.match.join(',')); if (f.genre && f.genre.length) p.set('genre', f.genre.join(',')); @@ -591,108 +475,23 @@ if (window.syncLibrarySong && sid) window.syncLibrarySong(state.provider, sid, { playWhenReady: true }); } - // Per-role accuracy helpers — find the arrangement matching the active - // instrument role and read its per-arrangement accuracy. - function _matchingArrangementIndex(song) { - if (!song || !song.arrangements || !song.arrangements.length) return null; - var want = state.filters.arr_has; - var activeProfileId = (sm && sm._activeInstrumentProfile) || ''; - var activeRoleLabel = null; - if (activeProfileId) { - var insts = window.feedBack && window.feedBack._instruments; - if (Array.isArray(insts)) { - for (var ii = 0; ii < insts.length; ii++) { - var ir = insts[ii].roles || []; - for (var ij = 0; ij < ir.length; ij++) { - if (activeProfileId === insts[ii].id + '-' + ir[ij].id) { - activeRoleLabel = ir[ij].label; - break; - } - } - if (activeRoleLabel) break; - } - } - } - if (activeRoleLabel) { - for (var i = 0; i < song.arrangements.length; i++) { - var a = song.arrangements[i]; - var sn = a.smart_name || a.name || ''; - if (sn === activeRoleLabel) return a.index != null ? a.index : i; - } - return null; - } - if (!want.length) return null; - for (var i = 0; i < song.arrangements.length; i++) { - var a2 = song.arrangements[i]; - var sn2 = a2.smart_name || a2.name || ''; - for (var j = 0; j < want.length; j++) { - if (sn2 === want[j]) return a2.index != null ? a2.index : i; - } - } - return null; - } - - function _perArrangementAccuracy(filename, arrIndex) { - var map = state.arrangementAccuracy[filename]; - if (!map) return null; - return typeof map[arrIndex] === 'number' ? map[arrIndex] : null; - } - // Accuracy badge markup. `variant` is 'grid' (overlay pill on the card art, - // positioned `absolute bottom-0 right-0`) or 'tree' (inline ``). - // Cards show per-role accuracy: the arrangement matching the current - // instrument role determines the displayed percentage, not the song-wide max. - // A hover overlay reveals all arrangements with their individual accuracies. - function accuracyBadge(filename, variant, song) { - if (!song && state.songsById) song = state.songsById[filename]; - var acc = null; - var arrIdx = song ? _matchingArrangementIndex(song) : null; - var hasMatchingArrangement = arrIdx != null; - if (hasMatchingArrangement) { - acc = _perArrangementAccuracy(filename, arrIdx); - } - var hasArr = song && song.arrangements && song.arrangements.length > 0; - if (acc == null && !hasArr) return ''; - - var pct = acc != null ? Math.floor(acc * 100) : null; - var displayLabel; - if (pct != null) { - displayLabel = pct + '%'; - } else if (!state.filters.arr_has.length && !hasMatchingArrangement) { - displayLabel = 'n/a'; - } else { - displayLabel = '\u2014 %'; - } + // default) or 'tree' (inline percentage in the list row). Both carry the + // .fb-acc-badge class so a post-play refresh (repaintAccuracy) can find and + // replace them in place without re-rendering the whole list. + function accuracyBadge(filename, variant) { + const acc = state.accuracy[filename]; + if (acc == null) return ''; + // Floor, never round: 100% must mean every note hit. + const pct = Math.floor(acc * 100); if (variant === 'tree') { - var color = acc != null && acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc != null && acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; - return '' + displayLabel + ''; + const color = acc >= MASTERY_ACCURACY ? 'text-fb-good' : acc >= 0.5 ? 'text-fb-mid' : 'text-fb-low'; + return '' + pct + '%'; } - - var hoverOverlay = ''; - if (song && song.arrangements && song.arrangements.length > 0) { - var songTuning = song.tuning_name || ''; - var items = ''; - for (var ai = 0; ai < song.arrangements.length; ai++) { - var a = song.arrangements[ai]; - var aiIdx = a.index != null ? a.index : ai; - var aAcc = _perArrangementAccuracy(filename, aiIdx); - var aName = a.smart_name || a.name || ('#' + aiIdx); - var aLabel = aAcc != null ? Math.floor(aAcc * 100) + '%' : '\u2014'; - var aColor = aAcc != null - ? (aAcc >= MASTERY_ACCURACY ? 'text-fb-good' : aAcc >= 0.5 ? 'text-fb-mid' : 'text-fb-low') - : 'text-gray-600'; - var isPitched = _isPitchedArrangement(aName); - var tuningPart = isPitched && songTuning ? ' \u00b7 ' + esc(songTuning) : ''; - items += '
' + esc(aName) + tuningPart + '' + aLabel + '
'; - } - hoverOverlay = '
' + items + '
'; - } - - var badgeColor = acc != null ? (acc >= MASTERY_ACCURACY ? 'rgba(34,197,94,0.9)' : (acc >= 0.5 ? 'rgba(234,179,8,0.9)' : 'rgba(239,68,68,0.9)')) : 'rgba(75,85,99,0.9)'; - var badgeText = acc != null && acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white'; - var borderColor = acc != null ? (acc >= MASTERY_ACCURACY ? '#22c55e' : (acc >= 0.5 ? '#eab308' : '#ef4444')) : '#4b5563'; - return '' + - displayLabel + '' + hoverOverlay; + const color = acc >= MASTERY_ACCURACY ? 'bg-fb-good' : (acc >= 0.5 ? 'bg-fb-mid' : 'bg-fb-low'); + const text = acc >= 0.5 && acc < MASTERY_ACCURACY ? 'text-black' : 'text-white'; + return '' + + '' + pct + '%'; } // ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ───────── @@ -749,14 +548,12 @@ // snapshot would otherwise survive a sidebar return, so we force a full // re-fetch on the next entry. (feedBack — "No DLC until restart".) let _libraryDirty = false; - let _arrAutoInstrument = null; - let _autoFilterEnabled = true; function repaintAccuracy(key) { const apply = (el, variant) => { if (el.getAttribute('data-fn') !== key) return; const old = el.querySelector('.fb-acc-badge'); - const html = accuracyBadge(key, variant, state.songsById[key]); + const html = accuracyBadge(key, variant); if (variant === 'grid') { const art = el.querySelector('[data-v3-play]'); if (!art) return; @@ -779,13 +576,14 @@ async function applyScoreRefresh() { if (!_dirtyScores.size) return; const fresh = await jget('/api/stats/best'); - const freshArr = await jget('/api/stats/best-by-arrangement'); + // Fetch failed — keep the entries dirty so the next trigger (or screen + // enter) retries rather than silently dropping the badge update. if (!fresh) return; state.accuracy = fresh; - if (freshArr) state.arrangementAccuracy = freshArr; const keys = Array.from(_dirtyScores); _dirtyScores.clear(); keys.forEach(repaintAccuracy); + // A new score shifts the repertoire meter + the keep-practicing shelf. renderLibraryHome(); } @@ -883,7 +681,7 @@ '' + @@ -2699,7 +2467,7 @@ '' + esc(s.title) + '' + (chips ? '' : '') + (fl ? '' + fl + '' : '') + - accuracyBadge(k, 'tree', s) + + accuracyBadge(k, 'tree') + // Same fav / save-for-later / overflow-menu cluster as the grid // card. Always shown (like the arrangement chips), not hover- // revealed. wireCards() binds all three for any [data-fn]. @@ -2721,91 +2489,6 @@ function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); } - // ── Instrument-aware tuning (the bass-player tuning-filter report) ──────── - // A song's bass chart is often in a different tuning from its guitar chart, - // so the tuning facet/filter/sort and the card chip must speak for the - // instrument the player actually plays. The host's working-tuning - // capability already holds the live selection (seeded from /api/settings on - // boot, updated when the player switches) — read it rather than adding - // another settings fetch. `state.settingsInstrument` is the fallback for - // hosts where the capability isn't mounted. - // Three perspectives, matching `active_instrument_profile`: lead and rhythm - // guitar charts can be tuned differently too, so a rhythm player hits the - // same bug a bassist did. The PROFILE is the only three-valued source (the - // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm), - // so it wins; the capability is the live fallback for hosts where the - // profile hasn't loaded. - const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass']; - function libInstrument() { - if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile; - try { - const wt = window.feedBack && window.feedBack.workingTuning; - if (wt && typeof wt.get === 'function') { - const cur = wt.get(); - if (cur && cur.instrument === 'bass') return 'bass'; - } - } catch (_) { /* capability absent/erroring — fall through to settings */ } - return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead'; - } - - // "Playable without retuning" mode reads the player's CURRENT tuning from - // the working-tuning capability (the live session state the tuner writes), - // not a separate setting. No capability => we cannot know the current - // tuning, so the mode is unavailable rather than guessed. - function currentWorkingTuning() { - try { - const wt = window.feedBack && window.feedBack.workingTuning; - if (!wt || typeof wt.get !== 'function') return null; - const cur = wt.get(); - if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null; - return cur; - } catch (_) { return null; } - } - - function playableAvailable() { return !!currentWorkingTuning(); } - - function applyPlayableParams(p, f) { - if (f.tuningMatch !== 'playable') return; - const cur = currentWorkingTuning(); - if (!cur) return; - p.set('tuning_match', 'playable'); - p.set('playable_offsets', cur.offsets.join(',')); - p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar'); - p.set('playable_string_count', String(cur.stringCount || cur.offsets.length)); - } - - // Short human label for the perspective, for the facet/sort headers. - function libInstrumentLabel() { - const p = libInstrument(); - return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead'; - } - - // The column a row's tuning lives in for the active perspective. - function perspectiveTuningField() { - const p = libInstrument(); - return p === 'bass' ? 'bass_tuning_name' - : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : ''; - } - - // The tuning a card should SHOW: bass players see the bass chart's tuning, - // falling back to the song (guitar-derived) tuning when the song has no - // bass arrangement — the common case, so the fallback is not an edge path. - function shownTuningName(song) { - const f = perspectiveTuningField(); - if (f && song[f]) return song[f]; - return song.tuning_name || song.tuning; - } - - function shownTuningOffsets(song) { - const f = perspectiveTuningField(); - if (f) { - const offKey = f.replace('_name', '_offsets'); - if (song[offKey]) return song[offKey]; - return song.tuning_offsets; - } - return song.tuning_offsets; - } - // Sync the two Settings gates into module state (fire-and-forget — the // cached flags gate entry-point rendering; openArtistPage re-checks). function refreshArtistPageGates() { @@ -2813,13 +2496,6 @@ if (!cfg) return; state.artistPagesEnabled = cfg.artist_pages_enabled !== false; state.artistLinksEnabled = cfg.artist_external_links === true; - // Fallback instrument for hosts without the working-tuning capability. - state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar'; - // The three-valued perspective source (lead / rhythm / bass). - state.settingsProfile = cfg.active_instrument_profile || ''; - if (cfg && typeof cfg.auto_filter_instrument === 'boolean') { - _autoFilterEnabled = cfg.auto_filter_instrument; - } }); } @@ -3133,17 +2809,12 @@ else if (s === 'has') lacksArr.push(value); // 'lacks' → cycles back to any (already removed) } - function triPill(group, value, label, st, title) { + function triPill(group, value, label, st) { const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40' : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40' : 'bg-gray-800/50 text-fb-textDim border-gray-700'; const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : ''; - const tip = title ? ' title="' + esc(title) + '"' : ''; - return ''; - } - function renderDrawerIfOpen() { - const d = document.getElementById('v3-songs-drawer'); - if (d && !d.classList.contains('hidden') && d.innerHTML.trim()) renderDrawer(); + return ''; } function renderDrawer() { const d = document.getElementById('v3-songs-drawer'); @@ -3153,18 +2824,8 @@ '
' + '

Filters

' + '
' + - section('Arrangements', _arrangementOptions().map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + - section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('') + - (() => { - // One-click "which songs still need splitting": lacks EVERY - // instrument stem — the same query Stem Splitter's own - // missing-stems view runs. Toggles off if already active. - const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; - return ''; - })()) + + section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + + section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) + section('Lyrics', ['', '1', '0'].map((v) => '').join('')) + // Progress (mastery bands) — multi-select; server filters via song_stats. section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '').join('')) + @@ -3173,32 +2834,7 @@ section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '').join('')) + // Genre facet — dynamic list from /api/library/genres (primary genre). (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '').join('')) : '') + - // The facet header NAMES the perspective. Silent instrument-following - // is the original bug in a new place: the user must be able to tell - // which instrument these tunings describe. - section('Tuning (' + libInstrumentLabel() + ')', - // MODE toggle. Exact match answers "which tuning is this - // labelled"; Playable answers "will this cost me a retune" — - // which is what a player actually wants. Both are offered; - // exact stays the default so nothing changes unasked. - '
' - + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => { - const on = (f.tuningMatch || 'exact') === m[0]; - const dis = m[0] === 'playable' && !playableAvailable(); - return ''; - }).join('') - + '
' - + (f.tuningMatch === 'playable' - ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
' - : '') - + ((state.tuningNames || []).map((t) => { + section('Tuning', (state.tuningNames || []).map((t) => { // Filter on the server's grouping key (raw offsets for customs) // so two "Custom Tuning" entries are distinct; show their target // notes in the label so they're distinguishable. @@ -3211,17 +2847,8 @@ const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; if (notes) label = 'Custom · ' + notes; } - // Be honest about the fallback: when some of a row's songs have - // no bass chart and are borrowing the guitar tuning, say so - // rather than presenting a borrowed tuning as a measured one. - const inf = t.inferred_count || 0; - const title = inf - ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)' - : ''; - const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count); - return triPill('tuning', val, label + ' (' + countLabel + ')', - f.tunings.includes(val) ? 'has' : 'any', title); - }).join('') || 'No tunings')) + + return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any'); + }).join('') || 'No tunings') + // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter // (never counted in the badge, never saved into collection rules). // Local provider only: it's the one that implements group=. @@ -3251,18 +2878,6 @@ else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); } renderDrawer(); })); - d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => { - if (b.disabled) return; - f.tuningMatch = b.getAttribute('data-tuning-match'); - renderDrawer(); - })); - d.querySelector('[data-stem-unsplit]')?.addEventListener('click', () => { - const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; - f.stem_has.length = 0; - f.stem_lacks.length = 0; - if (!on) f.stem_lacks.push(...STEMS); - renderDrawer(); - }); d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); })); d.querySelector('[data-grouping]')?.addEventListener('click', () => { @@ -3276,7 +2891,7 @@ d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp); d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { - state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }; + state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] }; state.artist = ''; state.album = ''; renderDrawer(); @@ -3863,15 +3478,13 @@ const providers = await loadProviders(); const [, tn] = await Promise.all([ (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(), - jget('/api/library/tuning-names?provider=' + enc(state.provider) - + '&instrument=' + enc(libInstrument())), + jget('/api/library/tuning-names?provider=' + enc(state.provider)), loadArtistCatalog(), // Artist-page gates (PR-B) ride the initial fetch batch so the // first card paint already knows whether artist lines are links. refreshArtistPageGates(), ]); state.tuningNames = (tn && tn.tunings) || []; - _lastRenderInstrument = libInstrument(); try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; } const opt = (arr, sel) => arr.map(([v, l]) => '').join(''); @@ -3899,13 +3512,7 @@ '' + '' + '
' + - // Name the perspective on the SORT too, not just the filter: tuning - // sort orders by musical distance from standard, and for a bass - // player that distance is measured on the bass tuning. Unlabelled, - // the grid silently reorders with no visible cause. - '' + + '' + '' + '' + '' + @@ -4473,141 +4080,7 @@ }, }; - // ── Grid cursor navigation (arrow keys / gamepad d-pad) ───────────────── - // shortcuts.js's generic library arrow-nav (_handleLibArrowNav) can't reach - // this screen: it assumes every navigable item is already a DOM node, but - // this grid is windowed — most of the library isn't in the DOM at any given - // scroll position. Cursor state here is an absolute index into state.songs, - // and moving it may need to fetch a page and/or scroll the window before the - // target card exists to highlight or activate. - let _gpIdx = null; - - function _gpCardEl(idx) { - return document.querySelector('#v3-songs-grid [data-idx="' + idx + '"]'); - } - - function _gpApplyHighlight() { - const prev = document.querySelector('#v3-songs-grid [data-gp-cursor]'); - if (prev) { - prev.removeAttribute('data-gp-cursor'); - prev.querySelector('[data-v3-play]')?.classList.remove('ring-2', 'ring-fb-primary'); - } - if (_gpIdx == null) return; - const el = _gpCardEl(_gpIdx); - if (!el) return; - el.setAttribute('data-gp-cursor', '1'); - el.querySelector('[data-v3-play]')?.classList.add('ring-2', 'ring-fb-primary'); - } - - async function _gpEnsureVisible(idx) { - const main = document.getElementById('v3-main'), sizer = _sizerEl(); - if (!main || !sizer) return; - const { cols, rowH } = measureGeom(); - const row = Math.floor(idx / Math.max(1, cols)); - const sizerTop = _sizerTopInScroller(main, sizer); - const rowTop = sizerTop + row * rowH; - const rowBottom = rowTop + rowH; - const viewTop = main.scrollTop, viewBottom = viewTop + main.clientHeight; - if (rowTop < viewTop) main.scrollTop = rowTop; - else if (rowBottom > viewBottom) main.scrollTop = rowBottom - main.clientHeight; - await renderWindow(); - - // #v3-songs-toolbar is sticky, but only pins to the top once scrolled - // PAST its natural in-flow position — before that point it isn't - // covering anything, after it covers a fixed band. That makes its - // occlusion scroll-dependent in a way no single precomputed offset - // captures, so measure the real rendered overlap and correct for it, - // rather than assuming the toolbar's height is always "lost" space. - const toolbar = document.getElementById('v3-songs-toolbar'); - const cardEl = _gpCardEl(idx); - if (toolbar && cardEl) { - const overlap = toolbar.getBoundingClientRect().bottom - cardEl.getBoundingClientRect().top; - if (overlap > 0) { - // Push the row DOWN the screen to clear the toolbar — that means - // scrolling the content back UP, i.e. decreasing scrollTop (screen - // position = content position - scrollTop). - main.scrollTop -= overlap; - await renderWindow(); - } - } - } - - async function _gpMove(delta) { - if (!state.total) return; - if (_gpIdx == null) { - // Nothing selected yet — land on the first card and stop, same as - // shortcuts.js's legacy _handleLibArrowNav does for an empty - // selection: the first press establishes a cursor, it doesn't also - // move it (Right/Down previously skipped straight past row 0). - _gpIdx = 0; - } else { - const next = Math.max(0, Math.min(state.total - 1, _gpIdx + delta)); - if (next === _gpIdx) return; - _gpIdx = next; - } - await ensureWindow(Math.max(0, _gpIdx - 1), Math.min(state.total, _gpIdx + 2)); - await _gpEnsureVisible(_gpIdx); - _gpApplyHighlight(); - } - - function _gpActivate() { - if (_gpIdx == null) return; - _gpCardEl(_gpIdx)?.querySelector('[data-v3-play]')?.click(); - } - - // Bails on focus inside anything with its own keyboard semantics — form - // controls, buttons, and dialog/drawer overlays — same intent as - // shortcuts.js's _isInsideInteractiveControl, reimplemented locally since - // this is a plain script (not an ES module) and can't import it. - // - // The form-control/button check requires the element to be VISIBLE, not - // merely present: screens stay in the DOM (hidden, not removed) when you - // navigate away, so a real ' + @@ -2467,7 +2699,7 @@ '' + esc(s.title) + '' + (chips ? '' : '') + (fl ? '' + fl + '' : '') + - accuracyBadge(k, 'tree') + + accuracyBadge(k, 'tree', s) + // Same fav / save-for-later / overflow-menu cluster as the grid // card. Always shown (like the arrangement chips), not hover- // revealed. wireCards() binds all three for any [data-fn]. @@ -2489,6 +2721,91 @@ function _artistHostEl() { return document.getElementById('v3-songs-artistpage'); } + // ── Instrument-aware tuning (the bass-player tuning-filter report) ──────── + // A song's bass chart is often in a different tuning from its guitar chart, + // so the tuning facet/filter/sort and the card chip must speak for the + // instrument the player actually plays. The host's working-tuning + // capability already holds the live selection (seeded from /api/settings on + // boot, updated when the player switches) — read it rather than adding + // another settings fetch. `state.settingsInstrument` is the fallback for + // hosts where the capability isn't mounted. + // Three perspectives, matching `active_instrument_profile`: lead and rhythm + // guitar charts can be tuned differently too, so a rhythm player hits the + // same bug a bassist did. The PROFILE is the only three-valued source (the + // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm), + // so it wins; the capability is the live fallback for hosts where the + // profile hasn't loaded. + const PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass']; + function libInstrument() { + if (PERSPECTIVES.indexOf(state.settingsProfile) >= 0) return state.settingsProfile; + try { + const wt = window.feedBack && window.feedBack.workingTuning; + if (wt && typeof wt.get === 'function') { + const cur = wt.get(); + if (cur && cur.instrument === 'bass') return 'bass'; + } + } catch (_) { /* capability absent/erroring — fall through to settings */ } + return state.settingsInstrument === 'bass' ? 'bass' : 'guitar-lead'; + } + + // "Playable without retuning" mode reads the player's CURRENT tuning from + // the working-tuning capability (the live session state the tuner writes), + // not a separate setting. No capability => we cannot know the current + // tuning, so the mode is unavailable rather than guessed. + function currentWorkingTuning() { + try { + const wt = window.feedBack && window.feedBack.workingTuning; + if (!wt || typeof wt.get !== 'function') return null; + const cur = wt.get(); + if (!cur || !Array.isArray(cur.offsets) || !cur.offsets.length) return null; + return cur; + } catch (_) { return null; } + } + + function playableAvailable() { return !!currentWorkingTuning(); } + + function applyPlayableParams(p, f) { + if (f.tuningMatch !== 'playable') return; + const cur = currentWorkingTuning(); + if (!cur) return; + p.set('tuning_match', 'playable'); + p.set('playable_offsets', cur.offsets.join(',')); + p.set('playable_instrument', cur.instrument === 'bass' ? 'bass' : 'guitar'); + p.set('playable_string_count', String(cur.stringCount || cur.offsets.length)); + } + + // Short human label for the perspective, for the facet/sort headers. + function libInstrumentLabel() { + const p = libInstrument(); + return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead'; + } + + // The column a row's tuning lives in for the active perspective. + function perspectiveTuningField() { + const p = libInstrument(); + return p === 'bass' ? 'bass_tuning_name' + : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : ''; + } + + // The tuning a card should SHOW: bass players see the bass chart's tuning, + // falling back to the song (guitar-derived) tuning when the song has no + // bass arrangement — the common case, so the fallback is not an edge path. + function shownTuningName(song) { + const f = perspectiveTuningField(); + if (f && song[f]) return song[f]; + return song.tuning_name || song.tuning; + } + + function shownTuningOffsets(song) { + const f = perspectiveTuningField(); + if (f) { + const offKey = f.replace('_name', '_offsets'); + if (song[offKey]) return song[offKey]; + return song.tuning_offsets; + } + return song.tuning_offsets; + } + // Sync the two Settings gates into module state (fire-and-forget — the // cached flags gate entry-point rendering; openArtistPage re-checks). function refreshArtistPageGates() { @@ -2496,6 +2813,13 @@ if (!cfg) return; state.artistPagesEnabled = cfg.artist_pages_enabled !== false; state.artistLinksEnabled = cfg.artist_external_links === true; + // Fallback instrument for hosts without the working-tuning capability. + state.settingsInstrument = cfg.instrument === 'bass' ? 'bass' : 'guitar'; + // The three-valued perspective source (lead / rhythm / bass). + state.settingsProfile = cfg.active_instrument_profile || ''; + if (cfg && typeof cfg.auto_filter_instrument === 'boolean') { + _autoFilterEnabled = cfg.auto_filter_instrument; + } }); } @@ -2809,12 +3133,17 @@ else if (s === 'has') lacksArr.push(value); // 'lacks' → cycles back to any (already removed) } - function triPill(group, value, label, st) { + function triPill(group, value, label, st, title) { const cls = st === 'has' ? 'bg-fb-good/30 text-fb-good border-fb-good/40' : st === 'lacks' ? 'bg-fb-low/30 text-fb-low border-fb-low/40' : 'bg-gray-800/50 text-fb-textDim border-gray-700'; const mark = st === 'has' ? '✓ ' : st === 'lacks' ? '✕ ' : ''; - return ''; + const tip = title ? ' title="' + esc(title) + '"' : ''; + return ''; + } + function renderDrawerIfOpen() { + const d = document.getElementById('v3-songs-drawer'); + if (d && !d.classList.contains('hidden') && d.innerHTML.trim()) renderDrawer(); } function renderDrawer() { const d = document.getElementById('v3-songs-drawer'); @@ -2824,8 +3153,18 @@ '
' + '

Filters

' + '
' + - section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + - section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) + + section('Arrangements', _arrangementOptions().map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) + + section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('') + + (() => { + // One-click "which songs still need splitting": lacks EVERY + // instrument stem — the same query Stem Splitter's own + // missing-stems view runs. Toggles off if already active. + const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; + return ''; + })()) + section('Lyrics', ['', '1', '0'].map((v) => '').join('')) + // Progress (mastery bands) — multi-select; server filters via song_stats. section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '').join('')) + @@ -2834,7 +3173,32 @@ section('Match', [['review', 'To review'], ['matched', 'Matched'], ['unmatched', 'Unmatched'], ['pending', 'Not scanned']].map((it) => '').join('')) + // Genre facet — dynamic list from /api/library/genres (primary genre). (state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '').join('')) : '') + - section('Tuning', (state.tuningNames || []).map((t) => { + // The facet header NAMES the perspective. Silent instrument-following + // is the original bug in a new place: the user must be able to tell + // which instrument these tunings describe. + section('Tuning (' + libInstrumentLabel() + ')', + // MODE toggle. Exact match answers "which tuning is this + // labelled"; Playable answers "will this cost me a retune" — + // which is what a player actually wants. Both are offered; + // exact stays the default so nothing changes unasked. + '
' + + [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => { + const on = (f.tuningMatch || 'exact') === m[0]; + const dis = m[0] === 'playable' && !playableAvailable(); + return ''; + }).join('') + + '
' + + (f.tuningMatch === 'playable' + ? '
Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.
' + : '') + + ((state.tuningNames || []).map((t) => { // Filter on the server's grouping key (raw offsets for customs) // so two "Custom Tuning" entries are distinct; show their target // notes in the label so they're distinguishable. @@ -2847,8 +3211,17 @@ const notes = offs ? window.displayTuningTargets(offs, { tuningName: t.name }) : ''; if (notes) label = 'Custom · ' + notes; } - return triPill('tuning', val, label + ' (' + t.count + ')', f.tunings.includes(val) ? 'has' : 'any'); - }).join('') || 'No tunings') + + // Be honest about the fallback: when some of a row's songs have + // no bass chart and are borrowing the guitar tuning, say so + // rather than presenting a borrowed tuning as a measured one. + const inf = t.inferred_count || 0; + const title = inf + ? inf + ' of ' + t.count + ' inferred from the guitar chart (no bass arrangement)' + : ''; + const countLabel = inf ? t.count + ', ' + inf + ' inferred' : String(t.count); + return triPill('tuning', val, label + ' (' + countLabel + ')', + f.tunings.includes(val) ? 'has' : 'any', title); + }).join('') || 'No tunings')) + // Multi-chart grouping toggle (P5e) — a VIEW mode, not a filter // (never counted in the badge, never saved into collection rules). // Local provider only: it's the one that implements group=. @@ -2878,6 +3251,18 @@ else if (g === 'tuning') { const i = f.tunings.indexOf(v); if (i >= 0) f.tunings.splice(i, 1); else f.tunings.push(v); } renderDrawer(); })); + d.querySelectorAll('[data-tuning-match]').forEach((b) => b.addEventListener('click', () => { + if (b.disabled) return; + f.tuningMatch = b.getAttribute('data-tuning-match'); + renderDrawer(); + })); + d.querySelector('[data-stem-unsplit]')?.addEventListener('click', () => { + const on = STEMS.every((s) => f.stem_lacks.includes(s)) && !f.stem_has.length; + f.stem_has.length = 0; + f.stem_lacks.length = 0; + if (!on) f.stem_lacks.push(...STEMS); + renderDrawer(); + }); d.querySelectorAll('[data-lyrics]').forEach((b) => b.addEventListener('click', () => { f.lyrics = b.getAttribute('data-lyrics'); renderDrawer(); })); d.querySelectorAll('[data-mastery]').forEach((b) => b.addEventListener('click', () => { const v = b.getAttribute('data-mastery'); const i = f.mastery.indexOf(v); if (i >= 0) f.mastery.splice(i, 1); else f.mastery.push(v); renderDrawer(); })); d.querySelector('[data-grouping]')?.addEventListener('click', () => { @@ -2891,7 +3276,7 @@ d.querySelector('[data-drawer-tidy]')?.addEventListener('click', openArtistTidyUp); d.querySelector('[data-drawer-close]')?.addEventListener('click', closeDrawer); d.querySelector('[data-drawer-clear]')?.addEventListener('click', async () => { - state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [] }; + state.filters = { arr_has: [], arr_lacks: [], stem_has: [], stem_lacks: [], lyrics: '', tunings: [], mastery: [], match: [], genre: [], tuningMatch: 'exact' }; state.artist = ''; state.album = ''; renderDrawer(); @@ -3478,13 +3863,15 @@ const providers = await loadProviders(); const [, tn] = await Promise.all([ (async () => { state.accuracy = (await jget('/api/stats/best')) || {}; })(), - jget('/api/library/tuning-names?provider=' + enc(state.provider)), + jget('/api/library/tuning-names?provider=' + enc(state.provider) + + '&instrument=' + enc(libInstrument())), loadArtistCatalog(), // Artist-page gates (PR-B) ride the initial fetch batch so the // first card paint already knows whether artist lines are links. refreshArtistPageGates(), ]); state.tuningNames = (tn && tn.tunings) || []; + _lastRenderInstrument = libInstrument(); try { const _g = await jget('/api/library/genres?provider=' + enc(state.provider)); state.genres = (_g && _g.genres) || []; } catch (e) { state.genres = []; } const opt = (arr, sel) => arr.map(([v, l]) => '').join(''); @@ -3512,7 +3899,13 @@ '' + '' + '
' + - '' + + // Name the perspective on the SORT too, not just the filter: tuning + // sort orders by musical distance from standard, and for a bass + // player that distance is measured on the bass tuning. Unlabelled, + // the grid silently reorders with no visible cause. + '' + '' + '' + '' + @@ -4080,7 +4473,141 @@ }, }; + // ── Grid cursor navigation (arrow keys / gamepad d-pad) ───────────────── + // shortcuts.js's generic library arrow-nav (_handleLibArrowNav) can't reach + // this screen: it assumes every navigable item is already a DOM node, but + // this grid is windowed — most of the library isn't in the DOM at any given + // scroll position. Cursor state here is an absolute index into state.songs, + // and moving it may need to fetch a page and/or scroll the window before the + // target card exists to highlight or activate. + let _gpIdx = null; + + function _gpCardEl(idx) { + return document.querySelector('#v3-songs-grid [data-idx="' + idx + '"]'); + } + + function _gpApplyHighlight() { + const prev = document.querySelector('#v3-songs-grid [data-gp-cursor]'); + if (prev) { + prev.removeAttribute('data-gp-cursor'); + prev.querySelector('[data-v3-play]')?.classList.remove('ring-2', 'ring-fb-primary'); + } + if (_gpIdx == null) return; + const el = _gpCardEl(_gpIdx); + if (!el) return; + el.setAttribute('data-gp-cursor', '1'); + el.querySelector('[data-v3-play]')?.classList.add('ring-2', 'ring-fb-primary'); + } + + async function _gpEnsureVisible(idx) { + const main = document.getElementById('v3-main'), sizer = _sizerEl(); + if (!main || !sizer) return; + const { cols, rowH } = measureGeom(); + const row = Math.floor(idx / Math.max(1, cols)); + const sizerTop = _sizerTopInScroller(main, sizer); + const rowTop = sizerTop + row * rowH; + const rowBottom = rowTop + rowH; + const viewTop = main.scrollTop, viewBottom = viewTop + main.clientHeight; + if (rowTop < viewTop) main.scrollTop = rowTop; + else if (rowBottom > viewBottom) main.scrollTop = rowBottom - main.clientHeight; + await renderWindow(); + + // #v3-songs-toolbar is sticky, but only pins to the top once scrolled + // PAST its natural in-flow position — before that point it isn't + // covering anything, after it covers a fixed band. That makes its + // occlusion scroll-dependent in a way no single precomputed offset + // captures, so measure the real rendered overlap and correct for it, + // rather than assuming the toolbar's height is always "lost" space. + const toolbar = document.getElementById('v3-songs-toolbar'); + const cardEl = _gpCardEl(idx); + if (toolbar && cardEl) { + const overlap = toolbar.getBoundingClientRect().bottom - cardEl.getBoundingClientRect().top; + if (overlap > 0) { + // Push the row DOWN the screen to clear the toolbar — that means + // scrolling the content back UP, i.e. decreasing scrollTop (screen + // position = content position - scrollTop). + main.scrollTop -= overlap; + await renderWindow(); + } + } + } + + async function _gpMove(delta) { + if (!state.total) return; + if (_gpIdx == null) { + // Nothing selected yet — land on the first card and stop, same as + // shortcuts.js's legacy _handleLibArrowNav does for an empty + // selection: the first press establishes a cursor, it doesn't also + // move it (Right/Down previously skipped straight past row 0). + _gpIdx = 0; + } else { + const next = Math.max(0, Math.min(state.total - 1, _gpIdx + delta)); + if (next === _gpIdx) return; + _gpIdx = next; + } + await ensureWindow(Math.max(0, _gpIdx - 1), Math.min(state.total, _gpIdx + 2)); + await _gpEnsureVisible(_gpIdx); + _gpApplyHighlight(); + } + + function _gpActivate() { + if (_gpIdx == null) return; + _gpCardEl(_gpIdx)?.querySelector('[data-v3-play]')?.click(); + } + + // Bails on focus inside anything with its own keyboard semantics — form + // controls, buttons, and dialog/drawer overlays — same intent as + // shortcuts.js's _isInsideInteractiveControl, reimplemented locally since + // this is a plain script (not an ES module) and can't import it. + // + // The form-control/button check requires the element to be VISIBLE, not + // merely present: screens stay in the DOM (hidden, not removed) when you + // navigate away, so a real