diff --git a/lib/library_registry.py b/lib/library_registry.py
index 4588d0f3..65613233 100644
--- a/lib/library_registry.py
+++ b/lib/library_registry.py
@@ -17,7 +17,12 @@
from typing import ClassVar
import appstate
-from metadata_db import MetadataDB, _tuning_group_key_sql
+from metadata_db import (
+ MetadataDB, _effective_tuning_cols_sql, _perspective_is_inferred_sql,
+ _tuning_group_key_sql,
+)
+import tunings as tunings_mod
+from tunings import DEFAULT_PERSPECTIVE, PERSPECTIVES
from routers import art as art_router
import logging
@@ -39,9 +44,6 @@ def _safe_art_redirect_url(url: str) -> str | None:
return None
-_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
-
-
class LocalLibraryProvider:
id = "local"
label = "My Library"
@@ -69,28 +71,43 @@ def query_albums(self, **kwargs) -> tuple[list[dict], int]:
def query_stats(self, **kwargs) -> dict:
return self._db.query_stats(**kwargs)
- def tuning_names(self) -> dict:
+ def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
# Group custom tunings on their raw offsets so distinct ones stay
# distinct (tuning_name collapses them all to "Custom Tuning"); named
# tunings keep grouping by name (stable across the rescan boundary, no
# offsets/name split). `key` is the value the client sends back as the
# filter selector — equal to the name for named tunings, the offsets
# string for customs; offsets also feed the client's custom-pill label.
+ #
+ # `instrument=bass` swaps every column for its effective bass-facing
+ # expression (bass arrangement's tuning, guitar fallback) — the SAME
+ # expressions _build_intrinsic_where filters on, so a facet entry
+ # always selects exactly the songs it counted.
+ name_sql, offsets_sql, sort_sql = _effective_tuning_cols_sql("songs", instrument)
+ gkey_sql = _tuning_group_key_sql("songs", instrument)
+ # How many of a row's songs are showing an INFERRED tuning — i.e. have
+ # no bass chart of their own and are falling back to the guitar-derived
+ # one. Reported per entry so the UI can be honest about it instead of
+ # presenting a borrowed tuning as a measured one. Always 0 for guitar.
+ inferred_sql = f"SUM({_perspective_is_inferred_sql('songs', instrument)})"
with self._db._lock:
rows = self._db.conn.execute(
- f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
- "MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
- "FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
+ f"SELECT {name_sql}, {gkey_sql} AS gkey, "
+ f"MIN({sort_sql}), COUNT(*), MIN({offsets_sql}), {inferred_sql} "
+ f"FROM songs WHERE title != '' AND COALESCE({name_sql}, '') != '' "
"GROUP BY gkey COLLATE NOCASE "
- "ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
- "COALESCE(MIN(tuning_sort_key), 0) ASC, "
- "tuning_name COLLATE NOCASE"
+ f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
+ f"COALESCE(MIN({sort_sql}), 0) ASC, "
+ f"{name_sql} COLLATE NOCASE"
).fetchall()
return {
+ "instrument": instrument,
"tunings": [
{"name": name, "key": gkey, "offsets": offs or "",
- "sort_key": int(sk or 0), "count": count}
- for name, gkey, sk, count, offs in rows
+ "sort_key": int(sk or 0), "count": count,
+ # Portion of `count` borrowed from the guitar chart.
+ "inferred_count": int(inferred or 0)}
+ for name, gkey, sk, count, offs, inferred in rows
],
}
@@ -330,9 +347,16 @@ def update(self, collection: dict) -> None:
# have been hand-edited; never let a bad value reach a query.
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
- def _filter_kwargs(self) -> dict:
- return _library_filter_args(**{k: v for k, v in self._rules.items()
+ def _filter_kwargs(self, instrument: str = "", playable_from_pitch=None) -> dict:
+ # `instrument` is the CALLER's play perspective (rides every request),
+ # never part of the saved rules — a collection saved by a guitarist
+ # must still read in bass tunings for a bass player, and vice versa.
+ args = _library_filter_args(**{k: v for k, v in self._rules.items()
if k in _LIBRARY_FILTER_PARAM_KEYS})
+ args["instrument"] = _normalize_instrument(instrument)
+ # The caller's CURRENT tuning is likewise per-request, never a saved rule.
+ args["playable_from_pitch"] = playable_from_pitch
+ return args
def _sort(self, fallback: str) -> str:
# A collection may pin its own sort (e.g. "recently added"); query_page
@@ -340,28 +364,31 @@ def _sort(self, fallback: str) -> str:
return self._rules.get("sort") or fallback
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
- naming_mode="legacy", **_ignore):
+ naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_page(
page=page, size=size, sort=self._sort(sort), direction=direction,
- naming_mode=naming_mode, **self._filter_kwargs())
+ naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
- def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
+ def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
+ instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_artists(
letter=letter, page=page, size=size, naming_mode=naming_mode,
- **self._filter_kwargs())
+ **self._filter_kwargs(instrument, playable_from_pitch))
- def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
+ def query_albums(self, *, page=0, size=120, naming_mode="legacy",
+ instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_albums(
- page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
+ page=page, size=size, naming_mode=naming_mode,
+ **self._filter_kwargs(instrument, playable_from_pitch))
def query_stats(self, *, sort="artist", want_sort_letters=False,
- naming_mode="legacy", **_ignore):
+ naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
return self._local._db.query_stats(
sort=self._sort(sort), want_sort_letters=want_sort_letters,
- naming_mode=naming_mode, **self._filter_kwargs())
+ naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
- def tuning_names(self):
- return self._local.tuning_names()
+ def tuning_names(self, instrument: str = "guitar"):
+ return self._local.tuning_names(instrument=_normalize_instrument(instrument))
async def get_art(self, song_id: str):
return await self._local.get_art(song_id)
@@ -390,7 +417,10 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
- has_lyrics: str = "", tunings: str = "") -> dict:
+ has_lyrics: str = "", tunings: str = "",
+ instrument: str = "", tuning_match: str = "",
+ playable_offsets: str = "", playable_instrument: str = "",
+ playable_string_count: str = "") -> dict:
fmt = format if format in ("archive", "sloppak", "loose") else ""
return {
"q": q,
@@ -404,9 +434,58 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
"stems_lacks": _split_csv(stems_lacks),
"has_lyrics": _parse_has_lyrics(has_lyrics),
"tunings": _split_csv(tunings),
+ # Which perspective the tuning facet/filter/sort speaks for (the
+ # caller's play role, NOT a saved rule — see _sanitize_collection_rules).
+ "instrument": _normalize_instrument(instrument),
+ # "Playable without retuning" mode: the caller's CURRENT tuning,
+ # resolved to the one number the comparison needs. None = exact-match
+ # mode (the default), so the tuning pills behave exactly as before.
+ "playable_from_pitch": (
+ _playable_from_pitch(playable_offsets, playable_instrument,
+ playable_string_count)
+ if tuning_match == "playable" else None),
}
+def _playable_from_pitch(offsets_csv: str, instrument: str, string_count: str):
+ """Lowest open-string MIDI pitch of the CALLER's current tuning.
+
+ The client sends its live working tuning (offsets + instrument + string
+ count) rather than a precomputed pitch, so the pitch tables stay in one
+ place (lib/tunings.py) instead of being duplicated in JS.
+
+ Returns None for anything unusable — the caller then applies NO playable
+ filter at all. That is the neutral state, not a claim: a malformed tuning
+ must not silently assert that everything is playable OR that nothing is.
+ """
+ try:
+ offsets = [int(x) for x in _split_csv(offsets_csv)]
+ except (TypeError, ValueError):
+ return None
+ if not offsets:
+ return None
+ inst = "bass" if instrument == "bass" else "guitar"
+ try:
+ sc = int(string_count)
+ except (TypeError, ValueError):
+ sc = len(offsets)
+ key = tunings_mod.instrument_key(inst, sc)
+ if key not in tunings_mod.STANDARD_OPEN_MIDIS or len(offsets) != sc:
+ return None
+ midis = tunings_mod.tuning_midis_from_offsets(key, offsets)
+ return min(midis) if midis else None
+
+
+def _normalize_instrument(raw: str) -> str:
+ """Resolve a tuning PERSPECTIVE id (guitar-lead | guitar-rhythm | bass).
+
+ Tolerates the legacy two-valued vocabulary ("guitar" -> guitar-lead) and
+ falls back to the default for anything unknown — an unrecognised value
+ must never silently change filter semantics."""
+ return raw if raw in PERSPECTIVES else (
+ DEFAULT_PERSPECTIVE if raw != "bass" else "bass")
+
+
def _sync_collection_provider(collection: dict) -> None:
"""Register (or replace) the provider for one collection."""
appstate.library_providers.register(
diff --git a/lib/loosefolder.py b/lib/loosefolder.py
index 0c456674..35fe6ba7 100644
--- a/lib/loosefolder.py
+++ b/lib/loosefolder.py
@@ -225,13 +225,18 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
Returns (arrangements_list, shared_meta).
shared_meta contains title/artist/album/year/duration/tuning_offsets
sourced from the highest-priority arrangement (lead > combo > rhythm >
- bass) — picking the guitar tuning when both bass and lead are present.
+ bass) — picking the guitar tuning when both bass and lead are present —
+ plus `bass_tuning_offsets` from the first bass arrangement (None when the
+ folder has none), so the index can carry both tunings.
"""
arrangements = []
# Track which arrangement priority sourced shared_meta so a later,
# higher-priority arrangement (lead < bass in sort order) overrides.
shared_meta = {}
shared_priority = None
+ # First tuning seen per arrangement ROLE, kept alongside the guitar-first
+ # song tuning so the library can answer for the part a player plays.
+ role_tunings: dict[str, list[int] | None] = {"bass": None, "rhythm": None}
for xml in sorted(_iter_local_xmls(path)):
# Trust the XML root over the filename — a custom named
@@ -269,6 +274,10 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
"duration", "tuning_offsets")}
shared_priority = priority
+ if (arr_type in role_tunings and role_tunings[arr_type] is None
+ and meta.get("tuning_offsets")):
+ role_tunings[arr_type] = list(meta["tuning_offsets"])
+
arrangements.append({
"type": arr_type,
"name": arr_name,
@@ -281,6 +290,8 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
a["index"] = i
del a["priority"]
+ for role, offs in role_tunings.items():
+ shared_meta[f"{role}_tuning_offsets"] = offs
return arrangements, shared_meta
@@ -412,6 +423,14 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
xml_meta.get("duration", 0))
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
xml_meta.get("tuning_offsets"))
+ # Per-role tunings: XML-derived only. A manifest `tuning_offsets` overrides
+ # the SONG tuning (above) but says nothing about WHICH chart it describes,
+ # so it must never be mistaken for a specific part's tuning.
+ role_tunings = {}
+ for role in ("bass", "rhythm"):
+ offs = xml_meta.get(f"{role}_tuning_offsets")
+ role_tunings[f"{role}_tuning_offsets"] = (
+ offs if isinstance(offs, list) and offs else None)
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
if manifest_arr is not None:
@@ -427,6 +446,7 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
"year": year,
"duration": duration,
"tuning_offsets": tuning_offsets,
+ **role_tunings, # None = no arrangement in that role
"arrangements": arrangements,
"audio_path": str(audio) if audio else None,
"art_path": str(art) if art else None,
diff --git a/lib/metadata_db.py b/lib/metadata_db.py
index ccdbbb9e..6bb021a8 100644
--- a/lib/metadata_db.py
+++ b/lib/metadata_db.py
@@ -25,6 +25,8 @@
from pathlib import Path
from song import compute_smart_names
+from tunings import DEFAULT_PERSPECTIVE, ROLE_PERSPECTIVES
+from tunings import perspective as _perspective
log = logging.getLogger("feedBack.server")
@@ -34,13 +36,91 @@
# raw offsets so distinct customs stay distinct, while named tunings keep
# grouping by name (stable across the offsets-column migration). Used by both
# the tuning-names listing and the filter WHERE so the contract matches.
-def _tuning_group_key_sql(alias: str) -> str:
- """The tuning grouping key (name for named tunings, raw offsets for
- customs) against an explicit table alias — the grouped filter law (§7.1)
- evaluates chart-intrinsic predicates inside a member subquery, where bare
- column names would resolve against the wrong scope."""
- return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
- f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
+#
+# A non-default PERSPECTIVE (guitar-rhythm / bass) swaps every tuning column
+# for its EFFECTIVE expression: that role's indexed tuning when the song has
+# such an arrangement, falling back to the guitar-derived song tuning
+# otherwise — so a song with no rhythm/bass chart (or a row that predates the
+# columns, NULL there) still groups/filters/sorts instead of disappearing.
+# guitar-lead reads the original unprefixed columns, so it is byte-identical
+# to the historical behaviour.
+def _effective_tuning_cols_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> tuple[str, str, str]:
+ """(name_sql, offsets_sql, sort_key_sql) for the given perspective."""
+ persp = _perspective(perspective)
+ if not persp.column_prefix:
+ return (f"{alias}.tuning_name", f"{alias}.tuning_offsets", f"{alias}.tuning_sort_key")
+ has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
+ return (
+ f"COALESCE(NULLIF({alias}.{persp.column('name')}, ''), {alias}.tuning_name)",
+ f"CASE WHEN {has_own} THEN {alias}.{persp.column('offsets')} ELSE {alias}.tuning_offsets END",
+ f"CASE WHEN {has_own} THEN {alias}.{persp.column('sort_key')} ELSE {alias}.tuning_sort_key END",
+ )
+
+
+def _effective_low_pitch_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
+ """Lowest open-string MIDI pitch under this perspective, with the same
+ fallback as the tuning columns — the "playable without retuning"
+ comparison reads it (see tunings.chart_is_playable_in)."""
+ persp = _perspective(perspective)
+ if not persp.column_prefix:
+ return f"{alias}.tuning_low_pitch"
+ has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
+ return (f"CASE WHEN {has_own} THEN {alias}.{persp.column('low_pitch')} "
+ f"ELSE {alias}.tuning_low_pitch END")
+
+
+def _perspective_is_inferred_sql(alias: str, perspective: str) -> str:
+ """1 when this row is BORROWING the guitar-derived song tuning because it
+ has no chart in the perspective's role. Always 0 for guitar-lead, which is
+ never a fallback."""
+ persp = _perspective(perspective)
+ if not persp.column_prefix:
+ return "0"
+ return f"(CASE WHEN COALESCE({alias}.{persp.column('name')}, '') = '' THEN 1 ELSE 0 END)"
+
+
+# ── The custom-tuning group key ──────────────────────────────────────────────
+#
+# Named tunings group by NAME, which is already serialization-agnostic. Custom
+# tunings group on a raw offsets STRING, which is not: the same physical bass
+# tuning stored as "-2 0 0 0" and "-2 0 0 0 0 0" would fragment into two facet
+# rows with split counts.
+#
+# For BASS we therefore group customs on `bass_tuning_key` — the tuning's
+# absolute open-string PITCHES, computed once at scan time
+# (tunings.bass_tuning_key) after the padded tail is truncated away. Pitch is
+# the identity that matters musically and it is serialization-independent, so
+# one physical tuning is one entry however it was authored. Guitar keeps the
+# offsets string (unchanged; six-element guitar arrays are not padded).
+#
+# The key is built HERE, once, and read by the facet listing, the filter WHERE
+# and the grouped member-match alike — a facet row that selected a different
+# set than it counted is exactly the bug this shared expression prevents.
+def _tuning_group_key_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
+ """The tuning grouping key (name for named tunings, canonical pitches or
+ raw offsets for customs) against an explicit table alias — the grouped
+ filter law (§7.1) evaluates chart-intrinsic predicates inside a member
+ subquery, where bare column names would resolve against the wrong scope."""
+ persp = _perspective(perspective)
+ name_sql, offsets_sql, _ = _effective_tuning_cols_sql(alias, perspective)
+ if persp.column_prefix:
+ # Fall back to the offsets string when the canonical key is absent
+ # (a fallback row borrowing the guitar tuning, or a row scanned before
+ # the key column existed) so a custom never groups under an empty key.
+ offsets_sql = (f"COALESCE(NULLIF({alias}.{persp.column('key')}, ''), "
+ f"{offsets_sql})")
+ return (f"CASE WHEN {name_sql} = 'Custom Tuning' AND COALESCE({offsets_sql}, '') != '' "
+ f"THEN {offsets_sql} ELSE {name_sql} END")
+
+
+def _put_perspective_value(meta: dict, col: str):
+ """Value to store for one per-perspective column on a freshly-scanned row."""
+ if col.endswith("_low_pitch"):
+ val = meta.get(col)
+ return int(val) if isinstance(val, int) else None
+ if col.endswith("_sort_key"):
+ return int(meta.get(col, 0) or 0)
+ return meta.get(col, "") or ""
# ── SQLite metadata cache ─────────────────────────────────────────────────────
@@ -381,7 +461,18 @@ def __init__(self, config_dir: Path):
tuning_offsets TEXT DEFAULT '',
genre TEXT DEFAULT '',
track_number INTEGER,
- disc INTEGER
+ disc INTEGER,
+ bass_tuning_name TEXT,
+ bass_tuning_sort_key INTEGER,
+ bass_tuning_offsets TEXT,
+ bass_tuning_key TEXT,
+ bass_tuning_low_pitch INTEGER,
+ rhythm_tuning_name TEXT,
+ rhythm_tuning_sort_key INTEGER,
+ rhythm_tuning_offsets TEXT,
+ rhythm_tuning_key TEXT,
+ rhythm_tuning_low_pitch INTEGER,
+ tuning_low_pitch INTEGER
)
""")
# Idempotent migrations for installs that predate each column.
@@ -408,6 +499,32 @@ def __init__(self, config_dir: Path):
# falls back to title order. Cache; repopulated on rescan.
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
"ALTER TABLE songs ADD COLUMN disc INTEGER",
+ # Bass-arrangement tuning (the KwasimodoZAZA report): the song-level
+ # tuning columns above are guitar-first, so the library filter lied
+ # to bass players when the bass chart is tuned differently. Caches;
+ # repopulated on rescan. NULL (no literal default) is deliberate —
+ # it marks a pre-migration row the scanner must re-extract, while
+ # '' means "extracted, song has no bass arrangement" (see scan.py).
+ "ALTER TABLE songs ADD COLUMN bass_tuning_name TEXT",
+ "ALTER TABLE songs ADD COLUMN bass_tuning_sort_key INTEGER",
+ "ALTER TABLE songs ADD COLUMN bass_tuning_offsets TEXT",
+ # Canonical grouping key: the bass tuning's absolute open-string
+ # pitches. Keyed on PITCH, not the serialization-dependent offsets
+ # string, so one physical tuning is one facet entry however it was
+ # stored. See tunings.bass_tuning_key.
+ "ALTER TABLE songs ADD COLUMN bass_tuning_key TEXT",
+ # Lowest open-string MIDI pitch per perspective — the "playable
+ # without retuning" comparison (tunings.chart_is_playable_in).
+ "ALTER TABLE songs ADD COLUMN bass_tuning_low_pitch INTEGER",
+ "ALTER TABLE songs ADD COLUMN tuning_low_pitch INTEGER",
+ # The RHYTHM chart's own tuning: lead and rhythm arrangements can
+ # be tuned differently, which is the same bug a bassist hit,
+ # inside guitar. Same NULL-vs-'' contract as the bass family.
+ "ALTER TABLE songs ADD COLUMN rhythm_tuning_name TEXT",
+ "ALTER TABLE songs ADD COLUMN rhythm_tuning_sort_key INTEGER",
+ "ALTER TABLE songs ADD COLUMN rhythm_tuning_offsets TEXT",
+ "ALTER TABLE songs ADD COLUMN rhythm_tuning_key TEXT",
+ "ALTER TABLE songs ADD COLUMN rhythm_tuning_low_pitch INTEGER",
):
try:
self.conn.execute(ddl)
@@ -2810,16 +2927,39 @@ def continue_session(self) -> dict | None:
def favorite_set(self) -> set[str]:
return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}
+ # Every per-perspective column, in one place, so the SELECT, the INSERT and
+ # the scanner's "was this ever extracted?" check can never drift apart.
+ # NULL is meaningful on `name`/`key`/`low_pitch`: it marks a row written
+ # before the column existed, which the scanner re-extracts (see
+ # scan._has_unextracted_columns). '' / 0 means "extracted, no such chart".
+ _PERSPECTIVE_COLS = tuple(
+ p.column(suffix)
+ for p in ROLE_PERSPECTIVES
+ for suffix in ("name", "sort_key", "offsets", "key", "low_pitch")
+ ) + ("tuning_low_pitch",)
+ # Columns whose NULL means "never extracted" rather than "no such chart".
+ #
+ # low_pitch is deliberately NOT a marker: a song with no chart in that role
+ # legitimately has NULL there (nothing to compute a pitch from), so keying
+ # re-extraction on it would re-scan those rows on every single pass and
+ # never converge. `name` and `key` carry the signal instead — they are ''
+ # when extracted-but-absent, NULL only when the column predates the row.
+ _EXTRACTION_MARKER_COLS = tuple(
+ p.column(suffix) for p in ROLE_PERSPECTIVES for suffix in ("name", "key")
+ )
+
def get(self, filename: str, mtime: float, size: int) -> dict | None:
cache_key = str(filename)
+ pcols = ", ".join(self._PERSPECTIVE_COLS)
with self._lock:
row = self.conn.execute(
"SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
- "format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
+ "format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, "
+ f"{pcols} "
"FROM songs WHERE filename = ?", (cache_key,)
).fetchone()
if row and row[0] == mtime and row[1] == size and row[2]:
- return {
+ out = {
"title": row[2], "artist": row[3], "album": row[4],
"year": row[5], "duration": row[6], "tuning": row[7],
"arrangements": json.loads(row[8]) if row[8] else [],
@@ -2831,6 +2971,15 @@ def get(self, filename: str, mtime: float, size: int) -> dict | None:
"tuning_sort_key": int(row[14] or 0),
"tuning_offsets": row[15] or "",
}
+ for i, col in enumerate(self._PERSPECTIVE_COLS, start=16):
+ val = row[i]
+ if col in self._EXTRACTION_MARKER_COLS:
+ out[col] = val # NULL preserved — drives re-extraction
+ elif col.endswith("_sort_key"):
+ out[col] = int(val or 0)
+ else:
+ out[col] = val or ""
+ return out
return None
def put(self, filename: str, mtime: float, size: int, meta: dict):
@@ -2838,8 +2987,9 @@ def put(self, filename: str, mtime: float, size: int, meta: dict):
self.conn.execute(
"INSERT OR REPLACE INTO songs "
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
- "has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc, "
+ + ", ".join(self._PERSPECTIVE_COLS) + ") "
+ "VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
@@ -2852,7 +3002,14 @@ def put(self, filename: str, mtime: float, size: int, meta: dict):
meta.get("tuning_offsets", "") or "",
meta.get("genre", "") or "",
meta.get("track_number"),
- meta.get("disc")),
+ meta.get("disc"),
+ # A put() row is by definition freshly extracted, so the
+ # marker columns must never be written NULL — that state is
+ # reserved for rows predating the column, which re-extract.
+ # low_pitch is the exception: NULL there means "this tuning
+ # has no computable pitch" (unusable offsets), and the
+ # playable filter treats unknown as not-playable.
+ *[_put_perspective_value(meta, col) for col in self._PERSPECTIVE_COLS]),
)
self.conn.commit()
# A song's identity may have changed → the grouping read-model is stale.
@@ -3332,6 +3489,8 @@ def _build_where(self, q: str = "", favorites_only: bool = False,
match_states: list[str] | None = None,
genre: list[str] | None = None,
naming_mode: str = "legacy",
+ instrument: str = DEFAULT_PERSPECTIVE,
+ playable_from_pitch: int | None = None,
include_intrinsic: bool = True) -> tuple[str, list]:
"""Shared WHERE-clause builder for query_page / query_artists /
query_stats. Returns (where_sql, params). Leading 'WHERE' is
@@ -3438,7 +3597,8 @@ def _build_where(self, q: str = "", favorites_only: bool = False,
"songs", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
+ has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
+ instrument=instrument, playable_from_pitch=playable_from_pitch)
where += ifrag
params += iparams
return where, params
@@ -3450,7 +3610,9 @@ def _build_intrinsic_where(self, alias: str, format_filter: str = "",
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
- naming_mode: str = "legacy") -> tuple[str, list]:
+ naming_mode: str = "legacy",
+ instrument: str = DEFAULT_PERSPECTIVE,
+ playable_from_pitch: int | None = None) -> tuple[str, list]:
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
tuning) as ' AND …' fragments against an explicit table alias. Flat
queries apply them to `songs` directly; grouped queries evaluate them
@@ -3593,10 +3755,32 @@ def _build_intrinsic_where(self, alias: str, format_filter: str = "",
placeholders = ",".join(["?"] * len(tn))
# Match the same grouping key tuning_names() returns so a single
# "Custom Tuning" pill selects exactly its offset set while named
- # tunings still match by name.
- where += (f" AND {_tuning_group_key_sql(alias)} "
+ # tunings still match by name. `instrument` swaps in the
+ # effective bass tuning key (guitar fallback) — the facet and
+ # this WHERE must use the same expression or they disagree.
+ where += (f" AND {_tuning_group_key_sql(alias, instrument)} "
f"COLLATE NOCASE IN ({placeholders})")
params += tn
+ if playable_from_pitch is not None:
+ # "Playable without retuning" — the mode the tester actually wants
+ # ("don't make me retune"), offered ALONGSIDE exact match, not
+ # instead of it. A chart needs no retune when its lowest required
+ # pitch is reachable, and every pitch above your lowest open string
+ # is reachable by fretting, so the comparison is:
+ #
+ # your lowest open pitch <= the chart's lowest open pitch
+ #
+ # That is why a 5-string bass (low B) covers every 4-string
+ # standard AND every drop-D chart untouched.
+ #
+ # CONSERVATIVE BY CONSTRUCTION: a chart whose low pitch we could
+ # not compute (NULL) is EXCLUDED rather than assumed playable —
+ # wrongly claiming playability costs a mid-practice retune, which
+ # is the failure this whole feature exists to prevent. See
+ # tunings.chart_is_playable_in for the full reasoning + limits.
+ low_sql = _effective_low_pitch_sql(alias, instrument)
+ where += f" AND {low_sql} IS NOT NULL AND {low_sql} >= ?"
+ params.append(int(playable_from_pitch))
return where, params
# Under group=1, chart-intrinsic filters match if ANY member of the work
@@ -3864,7 +4048,9 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
genre: list[str] | None = None,
after: str | None = None,
group: bool = False,
- naming_mode: str = "legacy") -> tuple[list[dict], int]:
+ naming_mode: str = "legacy",
+ instrument: str = DEFAULT_PERSPECTIVE,
+ playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
"""Server-side paginated search. Returns (songs, total_count).
`after` is an opaque keyset cursor (the last row of the previous page).
@@ -3893,7 +4079,9 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
match_states=match_states, genre=genre,
- naming_mode=naming_mode, include_intrinsic=not group,
+ naming_mode=naming_mode, instrument=instrument,
+ playable_from_pitch=playable_from_pitch,
+ include_intrinsic=not group,
)
ifrag, iparams = "", []
if group:
@@ -3902,12 +4090,14 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
+ has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
+ instrument=instrument, playable_from_pitch=playable_from_pitch)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
where += self._GROUP_REP_PREDICATE
+ _eff_tuning_name, _, _eff_tuning_sort = _effective_tuning_cols_sql("songs", instrument)
sort_map = {
# Artist sorts order WITHIN an artist by title (the tree view's
# artist -> album -> title feel) instead of raw filename — the
@@ -3941,11 +4131,15 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
# behind, and a NULL `tuning_name` in `(tuning_name = '')`
# evaluates to NULL itself (which sorts ahead of 0 in
# ASC), defeating the push-to-bottom intent.
+ #
+ # Under `instrument=bass` the effective expressions swap in
+ # the bass arrangement's tuning (guitar fallback) so a bass
+ # player's tuning sort orders by the tuning they'd play.
"tuning": (
- "(COALESCE(tuning_name, '') = '') ASC, "
- "ABS(COALESCE(tuning_sort_key, 0)), "
- "COALESCE(tuning_sort_key, 0) ASC, "
- "COALESCE(tuning_name, '') COLLATE NOCASE"
+ f"(COALESCE({_eff_tuning_name}, '') = '') ASC, "
+ f"ABS(COALESCE({_eff_tuning_sort}, 0)), "
+ f"COALESCE({_eff_tuning_sort}, 0) ASC, "
+ f"COALESCE({_eff_tuning_name}, '') COLLATE NOCASE"
),
# Year sort (feedBack#128). Empty-year rows pushed to the
# bottom for both directions; otherwise CAST so '2010' >
@@ -4038,7 +4232,9 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
- "tuning_name, tuning_offsets FROM songs ")
+ "tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
+ "rhythm_tuning_name, rhythm_tuning_offsets "
+ "FROM songs ")
cursor = _decode_cursor(after) if after else None
eff_sort = _effective_keyset_sort(sort, direction)
if cursor and eff_sort in _KEYSET_SORTS:
@@ -4071,8 +4267,30 @@ def query_page(self, q: str = "", page: int = 0, size: int = 24,
"stem_ids": json.loads(r[12]) if r[12] else [],
"tuning_name": r[13] or "",
"tuning_offsets": r[14] or "",
+ # '' when the song has no bass arrangement (or the row predates
+ # '' when the song has no such chart (or the row predates the
+ # columns) — clients fall back to tuning_name.
+ "bass_tuning_name": r[15] or "",
+ "bass_tuning_offsets": r[16] or "",
+ "rhythm_tuning_name": r[17] or "",
+ "rhythm_tuning_offsets": r[18] or "",
"has_estd": r[0] in estd, "favorite": r[0] in favs,
})
+ # PROVENANCE (non-default perspectives): a row shown to a bass or
+ # rhythm player either carries that chart's own tuning (native) or is
+ # borrowing the guitar-derived song tuning (inferred). The fallback is
+ # deliberate — a third of a real library has no bass chart and
+ # excluding it would be worse — but it must never be SILENT, or we
+ # reproduce the original bug in a new place. The client marks inferred
+ # rows; it can't infer this itself without duplicating the COALESCE.
+ #
+ # guitar-lead adds NOTHING here, so the default payload is unchanged.
+ _persp = _perspective(instrument)
+ if _persp.column_prefix:
+ _name_key = _persp.column("name")
+ for s in songs:
+ s["tuning_perspective"] = _persp.id
+ s["tuning_inferred"] = not s.get(_name_key)
# Personal layer (difficulty + tags) rides along like `favorite`, so a
# card can badge it without a second request. Notes stay OUT of the list
# payload (they can be long) — fetch per-song via /user-meta. Batched to
@@ -4169,7 +4387,7 @@ def _attach_display_charts(self, songs: list[dict], eff_by_fn: dict,
rows = self.conn.execute(
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
- "m.tuning_name, m.tuning_offsets "
+ "m.tuning_name, m.tuning_offsets, m.bass_tuning_name, m.bass_tuning_offsets "
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
@@ -4190,6 +4408,7 @@ def _attach_display_charts(self, songs: list[dict], eff_by_fn: dict,
"stem_count": int(m[9] or 0),
"stem_ids": json.loads(m[10]) if m[10] else [],
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
+ "bass_tuning_name": m[13] or "", "bass_tuning_offsets": m[14] or "",
}
def query_artists(self, letter: str = "", q: str = "",
@@ -4204,7 +4423,9 @@ def query_artists(self, letter: str = "", q: str = "",
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
- naming_mode: str = "legacy") -> tuple[list[dict], int]:
+ naming_mode: str = "legacy",
+ instrument: str = DEFAULT_PERSPECTIVE,
+ playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
"""Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
where, params = self._build_where(
q=q, favorites_only=favorites_only, format_filter=format_filter,
@@ -4212,6 +4433,7 @@ def query_artists(self, letter: str = "", q: str = "",
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
+ instrument=instrument, playable_from_pitch=playable_from_pitch,
)
# Canonicalize artists at display when aliases exist (P4): dedupe / group /
# letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
@@ -4247,7 +4469,7 @@ def query_artists(self, letter: str = "", q: str = "",
rows = self.conn.execute(
f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
- f"format, stem_count, stem_ids, tuning_name "
+ f"format, stem_count, stem_ids, tuning_name, bass_tuning_name "
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
song_params
).fetchall()
@@ -4280,6 +4502,7 @@ def query_artists(self, letter: str = "", q: str = "",
"stem_count": int(r[10] or 0),
"stem_ids": json.loads(r[11]) if r[11] else [],
"tuning_name": r[12] or "",
+ "bass_tuning_name": r[13] or "",
"has_estd": r[0] in estd,
"favorite": r[0] in favs,
"user_difficulty": udm.get(r[0]),
@@ -4301,7 +4524,8 @@ def query_albums(self, q="", favorites_only=False, format_filter="",
stems_has=None, stems_lacks=None,
has_lyrics=None, tunings=None, mastery=None,
match_states=None, genre=None,
- naming_mode="legacy", page=0, size=120):
+ naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
+ playable_from_pitch=None, page=0, size=120):
"""Distinct (artist, album) groups with a track count + a representative
cover song, for the album-condensed browse (paged by album). Rows with no
album name are excluded -- they can't form an album card. Same filters as
@@ -4313,7 +4537,8 @@ def query_albums(self, q="", favorites_only=False, format_filter="",
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
match_states=match_states, genre=genre,
- naming_mode=naming_mode,
+ naming_mode=naming_mode, instrument=instrument,
+ playable_from_pitch=playable_from_pitch,
)
awhere = where + " AND album IS NOT NULL AND album != ''"
total = self.conn.execute(
@@ -4344,7 +4569,9 @@ def query_stats(self, favorites_only: bool = False,
sort: str = "artist",
want_sort_letters: bool = False,
group: bool = False,
- naming_mode: str = "legacy") -> dict:
+ naming_mode: str = "legacy",
+ instrument: str = DEFAULT_PERSPECTIVE,
+ playable_from_pitch: int | None = None) -> dict:
"""Aggregate stats for the letter bar. Accepts the same filter
params as query_page so the letter counts stay synchronized
with the grid when filters are active.
@@ -4371,7 +4598,8 @@ def query_stats(self, favorites_only: bool = False,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
- naming_mode=naming_mode,
+ naming_mode=naming_mode, instrument=instrument,
+ playable_from_pitch=playable_from_pitch,
include_intrinsic=not group,
)
if group:
@@ -4383,7 +4611,8 @@ def query_stats(self, favorites_only: bool = False,
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
+ has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
+ instrument=instrument, playable_from_pitch=playable_from_pitch)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
diff --git a/lib/routers/library.py b/lib/routers/library.py
index 2b2a4964..c25e0251 100644
--- a/lib/routers/library.py
+++ b/lib/routers/library.py
@@ -19,7 +19,7 @@
import appstate
from library_registry import (
- _library_filter_args, _sanitize_collection_rules,
+ _library_filter_args, _normalize_instrument, _sanitize_collection_rules,
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
_unregister_collection_provider,
)
@@ -52,7 +52,8 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
- "mastery", "match_states")
+ "mastery", "match_states", "instrument",
+ "playable_from_pitch")
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
@@ -235,9 +236,20 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
has_lyrics: str = "", tunings: str = "", provider: str = "local",
mastery: str = "", tags: str = "", user_difficulty: str = "",
match: str = "", genre: str = "", after: str = "", group: int = 0,
- naming_mode: str = "legacy"):
+ naming_mode: str = "legacy", instrument: str = "",
+ tuning_match: str = "", playable_offsets: str = "",
+ playable_instrument: str = "", playable_string_count: str = ""):
"""Paginated library search through the selected library provider.
+ `instrument` is the tuning PERSPECTIVE ("guitar-lead" default |
+ "guitar-rhythm" | "bass"): which arrangement's tuning the tuning
+ filter/sort speaks for, with a guitar fallback when a song has no chart in
+ that role.
+
+ `tuning_match=playable` switches the tuning filter from exact-match to
+ "playable without retuning" against the caller's current tuning
+ (`playable_offsets` + `playable_instrument` + `playable_string_count`).
+
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
`next_cursor` from the previous response to fetch the next page with a
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
@@ -270,7 +282,10 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings,
+ has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
+ tuning_match=tuning_match, playable_offsets=playable_offsets,
+ playable_instrument=playable_instrument,
+ playable_string_count=playable_string_count,
),
)
# The cursor to resume after this page (effective sort folds in dir=desc).
@@ -292,7 +307,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
- provider: str = "local"):
+ provider: str = "local", instrument: str = ""):
"""Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library."""
size = min(size, 500)
@@ -306,7 +321,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings,
+ has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
),
)
return {"albums": albums, "total": total, "page": page, "size": size}
@@ -319,7 +334,9 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
- naming_mode: str = "legacy"):
+ naming_mode: str = "legacy", instrument: str = "",
+ tuning_match: str = "", playable_offsets: str = "",
+ playable_instrument: str = "", playable_string_count: str = ""):
"""Get artists grouped by letter with albums and songs (for tree view)."""
size = min(size, 100)
library_provider = _get_library_provider(provider)
@@ -336,7 +353,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings,
+ has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
),
)
return {"artists": artists, "total_artists": total, "page": page, "size": size}
@@ -350,7 +367,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
match: str = "",
sort: str = "artist", sort_letters: int = 0,
- group: int = 0, naming_mode: str = "legacy"):
+ group: int = 0, naming_mode: str = "legacy",
+ instrument: str = "", tuning_match: str = "",
+ playable_offsets: str = "", playable_instrument: str = "",
+ playable_string_count: str = ""):
"""Aggregate stats for the UI. Accepts the same filter params as
/api/library so the letter bar mirrors the active grid filter set.
`sort` selects the column the jump rail's `sort_letters` keys on;
@@ -375,7 +395,10 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
- has_lyrics=has_lyrics, tunings=tunings,
+ has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
+ tuning_match=tuning_match, playable_offsets=playable_offsets,
+ playable_instrument=playable_instrument,
+ playable_string_count=playable_string_count,
),
)
@@ -407,14 +430,20 @@ def library_genres(provider: str = "local"):
@router.get("/api/library/tuning-names")
-async def list_tuning_names(provider: str = "local"):
+async def list_tuning_names(provider: str = "local", instrument: str = ""):
"""Distinct tuning names present in the library, with per-tuning
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
so names appear in the same musical order the sort uses
- (feedBack#22) — E Standard first, then nearest neighbors."""
+ (feedBack#22) — E Standard first, then nearest neighbors.
+
+ `instrument=bass` groups by each song's bass-arrangement tuning
+ (guitar-derived fallback for songs without a bass chart) so bass
+ players see the tunings they'd actually play. Providers that predate
+ the kwarg simply don't receive it (signature-filtered)."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
- return await _call_library_provider_async(library_provider, "tuning_names")
+ return await _call_library_provider_async(
+ library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
@router.get("/api/library/practice-suggestions")
diff --git a/lib/scan.py b/lib/scan.py
index 6780b04f..6e50046b 100644
--- a/lib/scan.py
+++ b/lib/scan.py
@@ -124,6 +124,29 @@ def _library_dirs(all_songs, dlc: Path) -> set[str]:
return rels
+def _has_unextracted_columns() -> bool:
+ """True while any `songs` row still carries NULL in a column added by an
+ additive migration — i.e. metadata the current extractor would fill but
+ that no existing row has yet (currently `bass_tuning_name`).
+
+ The tree-signature fast path only asks "did the file set change"; on a
+ settled library the answer is no forever, so a schema addition would never
+ reach extraction. This one-row probe forces the full pass exactly until the
+ backfill completes — `put()` writes '' rather than NULL, so it self-clears
+ after the rescan instead of disabling the fast path permanently."""
+ try:
+ from metadata_db import MetadataDB
+ cond = " OR ".join(f"{c} IS NULL" for c in MetadataDB._EXTRACTION_MARKER_COLS)
+ row = appstate.meta_db.conn.execute(
+ f"SELECT 1 FROM songs WHERE {cond} LIMIT 1").fetchone()
+ except Exception as e:
+ # A probe failure must not take the scan down; falling back to the fast
+ # path costs at most a delayed backfill.
+ log.debug("scan: unextracted-column probe failed: %s", e)
+ return False
+ return row is not None
+
+
def _record_dir_signature(all_songs, dlc: Path) -> None:
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
@@ -221,7 +244,7 @@ def background_scan(force: bool = False):
# `force` (manual Refresh) always does the full pass. Seeding above is
# idempotent — it only writes when a builtin is missing — so it does not
# perturb the mtimes on a settled library.
- if not force:
+ if not force and not _has_unextracted_columns():
stored = _load_dir_signature()
if stored is not None and stored.get("dlc") == str(dlc):
current = _stat_dirs(dlc, stored["dirs"].keys())
@@ -319,6 +342,15 @@ def _is_excluded_from_library(p: Path) -> bool:
cached = None
if not cached:
to_scan.append((f, mtime, size, dlc))
+ elif any(cached.get(c) is None for c in appstate.meta_db._EXTRACTION_MARKER_COLS):
+ # Row predates one of the per-perspective tuning columns (NULL
+ # from the additive migration), so that perspective's tuning was
+ # never extracted for it. Without this
+ # re-queue an existing library would keep every bass column empty
+ # forever — mtime/size still match, so nothing else would ever
+ # bring the row back through extraction. Converges: put() always
+ # writes '' (never NULL), so a rescanned row is never re-queued.
+ to_scan.append((f, mtime, size, dlc))
elif cached.get("arrangements") and any(
"smart_name" not in a for a in cached["arrangements"]
):
diff --git a/lib/scan_worker.py b/lib/scan_worker.py
index c535c2d8..c388a2c6 100644
--- a/lib/scan_worker.py
+++ b/lib/scan_worker.py
@@ -27,7 +27,11 @@
from pathlib import Path
from song import compute_smart_names
-from tunings import tuning_name
+from tunings import (
+ DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
+ perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
+ tuning_name,
+)
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
@@ -43,6 +47,56 @@ def _relpath(f: Path, dlc: Path) -> str:
return f.name
+def _apply_role_tunings(meta: dict) -> None:
+ """Derive each ROLE perspective's tuning columns from the raw offsets the
+ extractor emitted (currently bass + rhythm; guitar-lead reads the
+ song-level columns the scanner has always written).
+
+ The domain rules live in `tunings` (see the PERSPECTIVES table and the
+ block above it for the evidence behind each):
+
+ 1. NORMALIZE FIRST. Stored bass arrays are commonly six elements whose
+ last two slots are padding, so bass truncates to four strings before
+ anything looks at them — padding must never reach the namer or the
+ grouping key. Guitar does NOT truncate (a 7-string array is real).
+ 2. Refuse to name data the perspective distrusts (bass up-tuning), so the
+ library can't send a player off to a tuning nobody plays.
+ 3. Group on CANONICAL PITCHES, not the raw offsets string — the same
+ physical tuning serialized two ways must be ONE facet entry.
+
+ A song with no arrangement in that role gets EMPTY strings / 0, not NULL:
+ '' is the indexed "we looked, there is no such chart" state the library's
+ fallback keys on, while NULL means "never extracted" and re-scans.
+ """
+ for persp in ROLE_PERSPECTIVES:
+ raw = meta.pop(f"{persp.role}_tuning_offsets", None)
+ offsets = normalize_offsets(raw, persp)
+ if offsets is None:
+ meta[persp.column("name")] = ""
+ meta[persp.column("sort_key")] = 0
+ meta[persp.column("offsets")] = ""
+ meta[persp.column("key")] = ""
+ meta[persp.column("low_pitch")] = None
+ continue
+ meta[persp.column("name")] = perspective_tuning_name(offsets, persp)
+ meta[persp.column("sort_key")] = sum(offsets)
+ # The NORMALIZED offsets are what we store: padding is not data, and a
+ # client rendering target notes must not print phantom strings.
+ meta[persp.column("offsets")] = " ".join(str(o) for o in offsets)
+ meta[persp.column("key")] = perspective_tuning_key(offsets, persp)
+ meta[persp.column("low_pitch")] = perspective_low_pitch(offsets, persp)
+
+
+def _apply_song_low_pitch(meta: dict, offsets: list[int]) -> None:
+ """Lowest open-string pitch of the SONG-level (guitar-lead) tuning, for
+ the "playable without retuning" comparison. Indexed here, on the existing
+ manifest-only pass — never by reopening chart JSON."""
+ persp = PERSPECTIVES[DEFAULT_PERSPECTIVE]
+ norm = normalize_offsets(offsets, persp)
+ meta["tuning_low_pitch"] = (
+ perspective_low_pitch(norm, persp) if norm is not None else None)
+
+
def _extract_meta_sloppak(path: Path) -> dict:
"""Extract metadata for a sloppak (file or directory)."""
meta = sloppak_mod.extract_meta(path)
@@ -52,6 +106,8 @@ def _extract_meta_sloppak(path: Path) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
+ _apply_song_low_pitch(meta, offsets)
+ _apply_role_tunings(meta)
meta["format"] = "sloppak"
# `extract_meta` already populates `stem_ids` (feedBack#129);
# default to empty for older callers / mocks.
@@ -86,6 +142,8 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
+ _apply_song_low_pitch(meta, offsets)
+ _apply_role_tunings(meta)
meta["format"] = "loose"
meta.setdefault("stem_ids", [])
# The library helper exposes absolute filesystem paths for audio/art
diff --git a/lib/sloppak.py b/lib/sloppak.py
index 8f943e9a..838882c8 100644
--- a/lib/sloppak.py
+++ b/lib/sloppak.py
@@ -1240,6 +1240,27 @@ def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
return [0] * 6
+def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
+ """Per-ROLE companion to _tuning_for_meta: the tuning of the arrangement
+ playing `role` ("bass" / "rhythm"), or None when the pack has no such
+ arrangement with a tuning — the index then leaves that perspective's
+ columns empty and the library falls back to the song (guitar-first)
+ tuning, marking the row inferred.
+
+ Exact name first, then a looser containment pass so an alt/bonus chart
+ ("Bass 2", "Alt Rhythm") still beats pretending the part is in the lead
+ guitar's tuning."""
+ for match_exact in (True, False):
+ for entry in arrangements_manifest:
+ name = str(entry.get("name", "")).lower()
+ tun = entry.get("tuning")
+ if not (tun and isinstance(tun, list)):
+ continue
+ if name == role if match_exact else role in name:
+ return list(tun)
+ return None
+
+
def extract_meta(path: Path) -> dict:
"""Fast metadata for the library scanner. Reads only the manifest."""
manifest = load_manifest(path)
@@ -1262,6 +1283,10 @@ def extract_meta(path: Path) -> dict:
has_lyrics = bool(manifest.get("lyrics"))
tuning_offsets = _tuning_for_meta(arr_list)
+ # Per-role tunings alongside the song-level one, so the library can answer
+ # for whichever arrangement the player actually plays.
+ role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
+ for role in ("bass", "rhythm")}
stems_list = manifest.get("stems", []) or []
valid_stems: list[dict] = []
@@ -1300,6 +1325,8 @@ def extract_meta(path: Path) -> dict:
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
"duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
+ # None = the pack has no arrangement in that role.
+ **role_tunings,
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
diff --git a/lib/tunings.py b/lib/tunings.py
index c5d34ed6..19460d26 100644
--- a/lib/tunings.py
+++ b/lib/tunings.py
@@ -416,27 +416,258 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
})
return out
+# ── Bass tuning normalization (library indexing) ─────────────────────────────
+#
+# Bass charts in the wild store SIX-element tuning arrays even when the chart is
+# a 4-string part: slots 4-5 are PADDING. Confirmed by inspecting the charts
+# themselves — across every pack whose bass and guitar tunings diverge, no bass
+# note ever references string index 4 or 5 (the deepest reach is index 3).
+#
+# The feedpak spec carries NO string-count field (manifest `arrangement.tuning`
+# is an untyped integer array, `minItems: 1`), and counting strings for real
+# would mean parsing the 600KB-1.2MB arrangement JSON of every song on the
+# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
+# TO 4 STRINGS and truncate.
+#
+# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
+# truncated to its low four. That is harmless for the overwhelmingly common
+# case — a 5-string in standard truncates to [0,0,0,0] and still names
+# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
+# Revisit if the spec ever gains a string count.
+BASS_DEFAULT_STRING_COUNT = 4
+
+# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
+# string tension. Anything above +1 semitone across the board is data we do not
+# trust, not a tuning a human plays (the real-world example that motivated this
+# is a bass array of [5,5,5,5,4,4] — "all four strings up a perfect fourth" —
+# on a song whose guitar chart is dead standard and whose own note content is
+# consistent with standard tuning; the offsets were almost certainly computed
+# against a 6-string-bass reference with an uninitialised tail).
+#
+# Such a tuning MUST NOT be named: printing "A Standard" would send a player
+# off to retune to something nobody plays. It degrades to the custom path,
+# where it stays visible and distinct but makes no pitch claim.
+BASS_MAX_PLAUSIBLE_OFFSET = 1
+
+
+# ── Tuning PERSPECTIVES ──────────────────────────────────────────────────────
+#
+# The library's tuning facet/filter/sort always answers for ONE arrangement
+# role. There are three, matching `active_instrument_profile`:
+#
+# guitar-lead the song-level (guitar-first) tuning — the historical
+# default. Its columns are the original unprefixed
+# `tuning_*` family, so today's behaviour is byte-identical.
+# guitar-rhythm the RHYTHM chart's own tuning. Lead and rhythm charts can
+# disagree (the same bug a bassist hit, inside guitar).
+# bass the BASS chart's own tuning.
+#
+# One table drives extraction, the derived columns, the SQL, and the labels —
+# rather than three near-identical column families maintained in parallel.
+class TuningPerspective:
+ __slots__ = ("id", "role", "instrument", "string_count", "column_prefix",
+ "truncate", "guard_up_tuning", "label")
+
+ def __init__(self, id, role, instrument, string_count, column_prefix,
+ truncate, guard_up_tuning, label):
+ self.id = id
+ self.role = role # arrangement name to look for ('' = song-level)
+ self.instrument = instrument
+ self.string_count = string_count
+ self.column_prefix = column_prefix # '' | 'rhythm_' | 'bass_'
+ self.truncate = truncate
+ self.guard_up_tuning = guard_up_tuning
+ self.label = label
+
+ @property
+ def instrument_key(self) -> str:
+ return instrument_key(self.instrument, self.string_count)
+
+ def column(self, suffix: str) -> str:
+ return f"{self.column_prefix}tuning_{suffix}"
+
+
+PERSPECTIVES: dict[str, TuningPerspective] = {
+ "guitar-lead": TuningPerspective(
+ "guitar-lead", "", "guitar", 6, "", False, False, "lead"),
+ "guitar-rhythm": TuningPerspective(
+ "guitar-rhythm", "rhythm", "guitar", 6, "rhythm_", False, False, "rhythm"),
+ # Bass alone truncates (padded arrays) and guards against up-tuned data —
+ # both are bass-specific findings, see the block above.
+ "bass": TuningPerspective(
+ "bass", "bass", "bass", BASS_DEFAULT_STRING_COUNT, "bass_", True, True, "bass"),
+}
+
+DEFAULT_PERSPECTIVE = "guitar-lead"
+
+# Perspectives that carry their OWN indexed columns (guitar-lead reads the
+# song-level ones, which the scanner has always written).
+ROLE_PERSPECTIVES = tuple(p for p in PERSPECTIVES.values() if p.column_prefix)
+
+
+def perspective(perspective_id) -> TuningPerspective:
+ """Resolve a perspective id, tolerating the legacy two-valued vocabulary
+ ('guitar' -> guitar-lead) and anything unknown (-> the default). An
+ unrecognised value must never change filter semantics."""
+ if perspective_id in PERSPECTIVES:
+ return PERSPECTIVES[perspective_id]
+ if perspective_id == "guitar":
+ return PERSPECTIVES[DEFAULT_PERSPECTIVE]
+ return PERSPECTIVES[DEFAULT_PERSPECTIVE]
+
+
+def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None:
+ """Coerce a stored tuning array to the strings the perspective's
+ instrument actually has. Returns None for anything unusable (empty /
+ non-integer / too short), so callers leave the index empty rather than
+ record a guess."""
+ if not isinstance(offsets, list) or not offsets:
+ return None
+ if any(isinstance(o, bool) for o in offsets):
+ return None
+ try:
+ vals = [int(o) for o in offsets]
+ except (TypeError, ValueError):
+ return None
+ if len(vals) < persp.string_count:
+ return None
+ # Only bass truncates: its arrays are padded (see above). A guitar array
+ # longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
+ # invent a tuning the chart does not have.
+ if persp.truncate:
+ return vals[:persp.string_count]
+ return vals
+
+
+def offsets_are_plausible(offsets: list[int], persp: TuningPerspective) -> bool:
+ """False for data the perspective refuses to trust — currently only the
+ bass up-tuning guard (see BASS_MAX_PLAUSIBLE_OFFSET)."""
+ if not persp.guard_up_tuning:
+ return True
+ return all(o <= BASS_MAX_PLAUSIBLE_OFFSET for o in offsets)
+
+
+def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str:
+ """Name a NORMALIZED tuning for this perspective, refusing to name data the
+ perspective distrusts — that becomes "Custom Tuning", which stays distinct
+ by its canonical pitches without asserting a tuning anyone plays."""
+ if not offsets_are_plausible(offsets, persp):
+ return "Custom Tuning"
+ return tuning_name(offsets)
+
+
+def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str:
+ """CANONICAL grouping key: the tuning's absolute open-string pitches, so
+ the same physical tuning groups as ONE facet entry no matter how it was
+ serialized. Keyed on pitch rather than the raw offsets string, which is
+ serialization-dependent and fragments.
+
+ Joined with ':' and NOT ',' — this key travels back as a `tunings` filter
+ selector, and that query param is a COMMA-separated list, so a comma here
+ would be split into meaningless fragments and match nothing.
+ """
+ midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
+ if not midis:
+ return ""
+ return persp.id + ":" + ":".join(str(m) for m in midis)
+
+
+def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | None:
+ """Absolute MIDI pitch of the tuning's LOWEST open string — the value the
+ "playable without retuning" comparison is built on (see
+ `chart_is_playable_in`)."""
+ midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
+ if not midis:
+ return None
+ return min(midis)
+
+
+# ── "Playable without retuning" ──────────────────────────────────────────────
+#
+# What the player actually wants is "don't make me retune", not "match this
+# label". A chart is playable as-is when every pitch it needs is reachable on
+# the instrument as currently tuned.
+#
+# WHAT WE CAN HONESTLY COMPUTE. We index open-string TUNINGS, not the notes a
+# chart plays — note data lives in the 600KB-1.2MB arrangement JSON, and the
+# library scan is deliberately manifest-only, so we do not read it (indexing a
+# per-song lowest note would mean opening every chart on every scan).
+#
+# So the comparison is on OPEN-STRING PITCH, with a conservative assumption:
+# a chart may require its own lowest open string. That gives
+#
+# playable <=> your lowest open pitch <= the chart's lowest open pitch
+#
+# On a fretted instrument every pitch ABOVE your lowest open string is
+# reachable by fretting (strings sit within an octave of each other and the
+# neck gives ~2 octaves), so the low end is the binding constraint. This is
+# exactly the dominant real case: a 5-string bass (low B) plays every 4-string
+# standard chart AND every drop-D chart untouched, because the low D is just
+# fretted on the B string.
+#
+# DELIBERATE LIMITATIONS, both erring toward NOT claiming playability:
+# * A chart that never actually touches its lowest open string is excluded
+# anyway. Conservative: excluding a playable chart costs a scroll;
+# including an unplayable one costs a mid-practice retune, which is the
+# failure this feature exists to prevent.
+# * The UPPER bound is not checked — a chart tuned far above you could in
+# principle exceed your neck. Checking it needs the note range we do not
+# have. It is the rare direction (and the guard above already refuses
+# up-tuned bass data), but it is a real gap, not an oversight.
+def chart_is_playable_in(chart_low_pitch, your_low_pitch) -> bool:
+ """True when a chart whose lowest open string is `chart_low_pitch` needs no
+ retune for a player tuned to `your_low_pitch`. Unknown chart pitch => False
+ (never claim playability we cannot support)."""
+ if chart_low_pitch is None or your_low_pitch is None:
+ return False
+ return int(your_low_pitch) <= int(chart_low_pitch)
+
+
+# Back-compat wrappers over the generic helpers — bass was the first
+# perspective and reads better spelled out at bass-specific call sites.
+def normalize_bass_offsets(offsets) -> list[int] | None:
+ return normalize_offsets(offsets, PERSPECTIVES["bass"])
+
+
+def bass_offsets_are_plausible(offsets: list[int]) -> bool:
+ return offsets_are_plausible(offsets, PERSPECTIVES["bass"])
+
+
+def bass_tuning_name(offsets: list[int]) -> str:
+ return perspective_tuning_name(offsets, PERSPECTIVES["bass"])
+
+
+def bass_tuning_key(offsets: list[int]) -> str:
+ return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
+
+
def tuning_name(offsets: list[int]) -> str:
- # 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
+ # The pattern checks below are gated on `len(offsets)` being 6 or 4. The
+ # naming conventions are E-standard-rooted — e.g. a 7-string all-zeros
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
- # 7+-string community content falls through to the numeric fallback. See #43.
-
- # Standard tunings (all six strings same offset)
+ # 7+-string community content falls through to the numeric fallback (#43).
+ #
+ # Length 4 is accepted because a bass's open strings (EADG) are the low
+ # four of the guitar, so the same standard/drop names apply at the same
+ # offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`):
+ # stored bass arrays are commonly six elements with a padded tail, and the
+ # padding must never reach this namer. See the block above.
+
+ # Standard tunings (all strings same offset)
standard = {
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
-6: "Bb Standard", -7: "A Standard",
1: "F Standard", 2: "F# Standard",
}
- if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
+ if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
name = standard.get(offsets[0])
if name:
return name
# Drop tunings (low string 2 semitones below the rest)
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
- if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
+ if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
low_note = note_names[offsets[0] % 12]
return f"Drop {low_note}"
diff --git a/static/js/library.js b/static/js/library.js
index 1d358e51..d96cda94 100644
--- a/static/js/library.js
+++ b/static/js/library.js
@@ -410,6 +410,51 @@ function _applyLibraryProviderToParams(params) {
return params;
}
+// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
+// A song's bass chart is often tuned differently from its guitar chart, so the
+// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
+// badge must all speak for the instrument the player actually plays. Read the
+// host's working-tuning capability (the live selection, seeded from
+// /api/settings at boot) rather than adding another settings fetch; hosts
+// without the capability keep the guitar behaviour.
+const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
+let _libSettingsProfile = '';
+
+export function _setLibraryProfile(profileId) {
+ _libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
+}
+
+export function _libraryInstrument() {
+ // The PROFILE is the only three-valued source (lead / rhythm / bass); the
+ // working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
+ // so it is only the fallback.
+ if (_libSettingsProfile) return _libSettingsProfile;
+ try {
+ const wt = window.feedBack?.workingTuning;
+ if (wt && typeof wt.get === 'function') {
+ const cur = wt.get();
+ if (cur?.instrument === 'bass') return 'bass';
+ }
+ } catch { /* capability absent/erroring — lead guitar is the safe default */ }
+ return 'guitar-lead';
+}
+
+export function _libraryInstrumentLabel() {
+ const p = _libraryInstrument();
+ return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
+}
+
+// The tuning a row should SHOW: the bass chart's for a bass player, falling
+// back to the song (guitar-derived) tuning when the song has no bass
+// arrangement — the common case, not an edge path.
+function _rowTuningRaw(song) {
+ const p = _libraryInstrument();
+ const field = p === 'bass' ? 'bass_tuning_name'
+ : p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
+ if (field && song[field]) return song[field];
+ return song.tuning || song.tuning_name || '';
+}
+
export function _resetLibraryProviderViewState() {
L.libEpoch++;
L.currentPage = 0;
@@ -768,6 +813,8 @@ export function _applyLibFiltersToParams(params) {
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
+ // Which instrument's tuning the `tunings` filter + the tuning sort read.
+ if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
return params;
}
@@ -851,6 +898,7 @@ async function _renderTuningList() {
c.innerHTML = '
Loading...
';
try {
const params = _applyLibraryProviderToParams(new URLSearchParams());
+ params.set('instrument', _libraryInstrument());
const resp = await fetch(`/api/library/tuning-names?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
@@ -869,6 +917,11 @@ async function _renderTuningList() {
fetchError = e.message || 'request failed';
}
}
+ // NAME the perspective: silent instrument-following is the original bug in
+ // a new place — the user must be able to see which instrument these
+ // tunings describe.
+ const labelEl = document.getElementById('filter-tunings-label');
+ if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
c.innerHTML = '';
if (fetchError) {
c.innerHTML = `
Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.
`;
@@ -894,10 +947,17 @@ async function _renderTuningList() {
const checked = _libFilters.tunings.includes(val);
const row = document.createElement('label');
row.className = 'tuning-row';
+ // Be honest about the fallback: songs with no bass arrangement borrow
+ // the guitar chart's tuning, and that must be visible rather than
+ // presented as a measured bass tuning.
+ const inferred = t.inferred_count || 0;
+ if (inferred) {
+ row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
+ }
row.innerHTML =
`` +
`${esc(label)}` +
- `${t.count}`;
+ `${t.count}${inferred ? ` (${inferred}~)` : ''}`;
const cb = row.querySelector('input');
cb.onchange = () => {
const i = _libFilters.tunings.indexOf(val);
@@ -1244,6 +1304,10 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
+ // The BADGE follows the player's instrument; `tuning` above stays the
+ // song's guitar-derived tuning because the retune action below rewrites
+ // the chart to E Standard and must not key on the bass part.
+ const tuningBadge = displayTuningName(_rowTuningRaw(song));
const artUrl = _librarySongArtUrl(song, providerId);
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
@@ -1299,7 +1363,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
- ${tuning ? `${esc(tuning)}` : ''}
+ ${tuningBadge ? `${esc(tuningBadge)}` : ''}
${song.has_lyrics ? `Lyrics` : ''}
${song.user_difficulty != null ? `◆${esc(song.user_difficulty)}` : ''}
${duration ? `${duration}` : ''}
@@ -1470,6 +1534,9 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
+ // Badge follows the player's instrument; the retune action below
+ // keeps operating on the song's guitar-derived tuning.
+ const tuningBadge = displayTuningName(_rowTuningRaw(song));
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
@@ -1496,8 +1563,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
{ const _nm = _getArrangementNamingMode();
for (const arrangement of (song.arrangements || []))
html += _arrangementBadgeHtml(arrangement, _nm); }
- if (tuning)
- html += `${esc(tuning)}`;
+ if (tuningBadge)
+ html += `${esc(tuningBadge)}`;
if (song.has_lyrics)
html += `Lyrics`;
if (song.user_difficulty != null)
diff --git a/static/js/settings.js b/static/js/settings.js
index 7591524b..6fbaaac1 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -18,7 +18,7 @@
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
import { hwcInitSettingsUI } from './highway-colors.js';
-import { _getArrangementNamingMode } from './library.js';
+import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
import {
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
} from './player-controls.js';
@@ -111,6 +111,10 @@ export async function loadSettings() {
if (dlcEl) dlcEl.value = data.dlc_dir || '';
_defaultArrangement = data.default_arrangement || '';
_syncDefaultArrangementSelect(_defaultArrangement);
+ // Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
+ // tuning facet, filter, sort and badges all answer for the profile the
+ // player actually plays.
+ _setLibraryProfile(data.active_instrument_profile);
const pathwayEl = document.getElementById('setting-instrument-pathway');
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
const demucsEl = document.getElementById('demucs-server-url');
diff --git a/static/v3/index.html b/static/v3/index.html
index 3dafcb17..6056f363 100644
--- a/static/v3/index.html
+++ b/static/v3/index.html
@@ -326,7 +326,7 @@
Filters
- Tuning
+ TuningAll tunings
diff --git a/static/v3/songs.js b/static/v3/songs.js
index 255e8145..a12c6639 100644
--- a/static/v3/songs.js
+++ b/static/v3/songs.js
@@ -62,7 +62,7 @@
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: [] },
+ 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: {}, tuningNames: [], genres: [],
artistCatalog: [], renderedHash: '',
scrollBound: false,
@@ -120,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.tunings.length + (f.mastery ? f.mastery.length : 0) +
+ (f.lyrics ? 1 : 0) + (f.tuningMatch === 'playable' ? 1 : 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);
}
@@ -280,7 +280,15 @@
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);
- if (f.tunings.length) p.set('tunings', f.tunings.join(','));
+ // 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(','));
+ // Which perspective the `tunings` filter + the tuning sort read.
+ 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.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(','));
@@ -786,6 +794,10 @@
// so without them the chips render exactly as before. Decoration runs AFTER the
// (sync) window paint so scrolling stays snappy; a token cancels a superseded pass.
let _tuningDecorToken = 0;
+ // The instrument the current grid was queried/painted for, so a
+ // working-tuning change can tell a guitar<->bass SWITCH (re-query) from a
+ // retune within the same instrument (re-colour only).
+ let _lastRenderInstrument = null;
function _applyChipMatch(chip, stateName) {
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400');
chip.classList.add(stateName === 'match' ? 'bg-emerald-500'
@@ -825,22 +837,31 @@
const shown = song.display_chart ? Object.assign({}, song, song.display_chart) : song;
// In select mode the checkbox occupies top-2 left-2, so shift the
// tuning chip right (left-9) to avoid overlapping it.
+ // Bass players see the bass chart's tuning (guitar fallback) — the card
+ // must agree with the facet/filter or the grid contradicts the pills.
+ const shownTuning = shownTuningName(shown);
const tuningLabel = (typeof window.displayTuningName === 'function')
- ? window.displayTuningName(shown.tuning_name || shown.tuning)
- : (shown.tuning_name || '');
+ ? window.displayTuningName(shownTuning)
+ : (shownTuning || '');
let tuning = '';
if (tuningLabel) {
const rawOffsets = (typeof window.parseRawTuningOffsets === 'function')
- ? (window.parseRawTuningOffsets(shown.tuning_offsets)
- || window.parseRawTuningOffsets(shown.tuning_name || shown.tuning))
+ ? (window.parseRawTuningOffsets(shownTuningOffsets(shown))
+ || window.parseRawTuningOffsets(shownTuning))
: null;
const targetNotes = (tuningLabel === 'Custom Tuning' && rawOffsets
&& typeof window.displayTuningTargets === 'function')
? window.displayTuningTargets(rawOffsets, { tuningName: tuningLabel })
: '';
- const badgeTitle = targetNotes
+ // Mark a tuning we INFERRED from the guitar chart (this song has no
+ // bass arrangement) so a bass player isn't shown a borrowed tuning
+ // as if it were their part's. `~` keeps the chip compact; the title
+ // spells it out.
+ const inferred = shown.tuning_inferred === true;
+ const badgeTitle = (targetNotes
? ('Custom Tuning: ' + targetNotes)
- : tuningLabel;
+ : tuningLabel)
+ + (inferred ? ' — from the guitar chart (no bass arrangement)' : '');
const pos = 'absolute top-2 ' + (state.selectMode ? 'left-9' : 'left-2');
// Tag the chip with its offsets so decorateTuningChips() can colour it
// green (matches your current tuning) / amber (needs a retune) after paint.
@@ -848,8 +869,14 @@
// scores its bass tuning against the bass base pitches, not guitar — otherwise
// a 4-string bass tuning read as guitar can false-match a guitar player.
const chipArrs = shown.arrangements || [];
- const chipIsBass = chipArrs.length > 0
- && chipArrs.every((a) => /\bbass\b/i.test((a && a.name) || ''));
+ // 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' && !!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(',')) + '"'
+ (chipIsBass ? ' data-tuning-bass="1"' : '') : '';
@@ -857,7 +884,7 @@
tuning = ''
+ esc('Custom Tuning') + ' ' + esc(targetNotes) + '';
} else {
- tuning = '' + esc(tuningLabel) + '';
+ tuning = '' + esc(tuningLabel) + (inferred ? ' ~' : '') + '';
}
}
// Display-only (pointer-events-none) so a click falls through to the
@@ -2489,6 +2516,89 @@
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 && song[f]) {
+ return song[f.replace('_name', '_offsets')] || 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 +2606,10 @@
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 || '';
});
}
@@ -2809,12 +2923,13 @@
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 renderDrawer() {
const d = document.getElementById('v3-songs-drawer');
@@ -2834,7 +2949,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.
+ '
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 +2987,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 +3027,11 @@
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.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 +3045,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 +3632,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 +3668,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.
+ '' +
'' +
'' +
'' +
@@ -4130,6 +4292,23 @@
// visible tuning chips against the new tuning. Cheap: re-decorates in place,
// no re-fetch or re-paint. No-op off the Songs grid or without the capability.
sm.on('working-tuning-changed', () => {
+ // A guitar<->bass SWITCH changes which tuning the facet, the filter,
+ // the sort and the card chip speak for, so the grid must re-query —
+ // re-colouring chips would leave the guitar tuning on screen and a
+ // guitar-keyed filter applied. A retune within one instrument still
+ // takes the cheap in-place path below.
+ const inst = libInstrument();
+ if (inst !== _lastRenderInstrument) {
+ _lastRenderInstrument = inst;
+ // A tuning selection keyed to the old instrument means nothing
+ // for the new one; clearing avoids an empty grid the user can't
+ // explain (the pills are re-rendered from the new facet).
+ state.filters.tunings = [];
+ const active = document.querySelector('.screen.active');
+ if (active && active.id === 'v3-songs') reload();
+ else _libraryDirty = true;
+ return;
+ }
if (typeof songsActive === 'function' && !songsActive()) return;
if (state.view !== 'grid') return;
decorateTuningChips(_gridEl());
diff --git a/tests/js/v3_songs_tuning.test.js b/tests/js/v3_songs_tuning.test.js
index 913f472f..604c4636 100644
--- a/tests/js/v3_songs_tuning.test.js
+++ b/tests/js/v3_songs_tuning.test.js
@@ -67,11 +67,27 @@ test('v3 songs.js uses display helpers for album-art tuning badge', () => {
const src = fs.readFileSync(SONGS_JS, 'utf8');
// The card renderer's row variable was renamed song → shown when grouped
// cards landed (the badge reads the representative chart); accept either.
- assert.match(src, /displayTuningName\((?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning\)/);
+ // The raw read then moved behind shownTuningName() so the badge can answer
+ // for the active tuning perspective — accept that indirection too, and pin
+ // the fallback inside the helper below so this stays a real guard.
+ assert.match(
+ src,
+ /displayTuningName\((?:(?:song|shown)\.tuning_name \|\| (?:song|shown)\.tuning|shownTuning)\)/,
+ );
assert.match(src, /displayTuningTargets/);
assert.match(src, /parseRawTuningOffsets/);
});
+test('the tuning-perspective helper still falls back to tuning_name || tuning', () => {
+ // shownTuningName() is what the badge now reads. With no perspective field
+ // set (guitar-lead, the default) it must resolve exactly what the badge
+ // used to read inline, or guitar players silently lose their tuning label.
+ const src = fs.readFileSync(SONGS_JS, 'utf8');
+ const body = src.match(/function shownTuningName\(song\)\s*\{[\s\S]*?\n {4}\}/);
+ assert.ok(body, 'shownTuningName() not found — the badge read moved again');
+ assert.match(body[0], /return song\.tuning_name \|\| song\.tuning;/);
+});
+
test('raw offset tuning_name does not appear in rendered card HTML', () => {
const html = renderSongCardBadge({ tuning_name: '-2 0 0 0 -2' }, helpers);
assert.doesNotMatch(html, /-2 0 0 0 -2/);
diff --git a/tests/test_library_tuning_instrument.py b/tests/test_library_tuning_instrument.py
new file mode 100644
index 00000000..a0c331c6
--- /dev/null
+++ b/tests/test_library_tuning_instrument.py
@@ -0,0 +1,721 @@
+"""Instrument-aware tuning in the library (the KwasimodoZAZA bass report).
+
+A song's BASS chart is often tuned differently from its guitar chart, but the
+library indexed exactly one guitar-first tuning per song — so a bass player
+filtering "Drop D" got songs whose GUITAR is in Drop D, and playlists built
+that way were wrong.
+
+These tests round-trip through the real extractors, the real scanner
+derivation, the real SQLite schema/migration, and the real HTTP surface. The
+only thing stubbed is metadata EXTRACTION in the scan tests (the production
+process pool can't reach an in-process mock) — never the code under test.
+
+Real-library notes, all confirmed against actual pack contents:
+
+* Bass arrangements usually store SIX-element offset arrays even when the
+ chart is a 4-string part — slots 4-5 are PADDING (no bass chart in the
+ corpus references string index 4 or 5). So bass offsets are truncated to 4
+ before naming or grouping. The feedpak spec has no string-count field, so 4
+ is a documented default, not a read value.
+* AC/DC "Girls Got Rhythm" stores [5,5,5,5,4,4] — every string up a fourth,
+ which no bassist plays. That is BAD DATA, and it must never be NAMED, or the
+ library sends a player to retune to a tuning that does not exist.
+* Covet "Shibuya" (custom guitar tuning, dead-standard bass) is the headline
+ regression: the tester's bug in a single song.
+"""
+
+import importlib
+import json
+import sys
+
+import pytest
+import yaml
+from fastapi.testclient import TestClient
+
+import sloppak as sloppak_mod
+from scan_worker import _extract_meta_for_file
+from tunings import (
+ PERSPECTIVES, bass_offsets_are_plausible, bass_tuning_key, bass_tuning_name,
+ chart_is_playable_in, normalize_bass_offsets, perspective_tuning_key,
+ tuning_name,
+)
+
+
+# ── Fixtures ─────────────────────────────────────────────────────────────────
+
+@pytest.fixture()
+def server_mod(tmp_path, monkeypatch):
+ monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
+ sys.modules.pop("server", None)
+ mod = importlib.import_module("server")
+ yield mod
+ conn = getattr(getattr(mod, "meta_db", None), "conn", None)
+ if conn is not None:
+ getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
+ conn.close()
+
+
+@pytest.fixture()
+def client(server_mod):
+ c = TestClient(server_mod.app)
+ try:
+ yield c
+ finally:
+ c.close()
+
+
+def _pack(root, name, arrangements):
+ """A directory-form pack whose manifest carries per-arrangement tunings."""
+ d = root / name
+ d.mkdir(parents=True)
+ (d / "manifest.yaml").write_text(yaml.safe_dump({
+ "title": name, "artist": "A", "duration": 100,
+ "arrangements": arrangements, "stems": [],
+ }), encoding="utf-8")
+ return d
+
+
+def _put(server_mod, *, filename, title, tuning_name_="E Standard",
+ tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
+ bass_tuning_name="", bass_tuning_sort_key=0, bass_tuning_offsets="",
+ bass_tuning_key=""):
+ server_mod.meta_db.put(filename, 1.0, 1, {
+ "title": title, "artist": "A", "album": "A - LP", "year": "2010",
+ "duration": 200.0, "tuning": tuning_name_, "arrangements": [],
+ "has_lyrics": False, "format": "sloppak", "stem_ids": [],
+ "tuning_name": tuning_name_,
+ "tuning_sort_key": tuning_sort_key,
+ "tuning_offsets": tuning_offsets,
+ "bass_tuning_name": bass_tuning_name,
+ "bass_tuning_sort_key": bass_tuning_sort_key,
+ "bass_tuning_offsets": bass_tuning_offsets,
+ "bass_tuning_key": bass_tuning_key,
+ })
+
+
+# ── 1. Extraction: sloppak ───────────────────────────────────────────────────
+
+def test_sloppak_extract_indexes_both_tunings_when_they_differ(tmp_path):
+ """The reported case: guitar down a step, bass in standard. BOTH must be
+ indexed — previously only the guitar tuning survived."""
+ d = _pack(tmp_path, "differ.sloppak", [
+ {"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
+ {"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
+ ])
+ meta = sloppak_mod.extract_meta(d)
+ assert meta["tuning_offsets"] == [-2, 0, 0, -1, -2, 0]
+ assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
+
+
+def test_sloppak_extract_leaves_bass_absent_without_bass_arrangement(tmp_path):
+ """No bass chart → None, NOT a copy of the guitar tuning. The library
+ falls back explicitly, so 'no bass part' stays distinguishable."""
+ d = _pack(tmp_path, "nobass.sloppak", [
+ {"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
+ {"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2]},
+ ])
+ meta = sloppak_mod.extract_meta(d)
+ assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
+ assert meta["bass_tuning_offsets"] is None
+
+
+def test_sloppak_extract_bass_wins_over_guitar_first_ordering(tmp_path):
+ """The bass entry is listed FIRST in the manifest; the song tuning must
+ still be the guitar's while the bass column takes the bass entry — the two
+ selections are independent, not 'first wins'."""
+ d = _pack(tmp_path, "order.sloppak", [
+ {"name": "Bass", "tuning": [-4, -4, -4, -4, -4, -4]},
+ {"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
+ ])
+ meta = sloppak_mod.extract_meta(d)
+ assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
+ assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, -4, -4]
+
+
+def test_sloppak_extract_ignores_bass_arrangement_without_a_tuning(tmp_path):
+ """A bass chart that authors no tuning gives us nothing to index; the
+ column stays empty rather than defaulting to a wrong all-zeros."""
+ d = _pack(tmp_path, "untuned.sloppak", [
+ {"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
+ {"name": "Bass"},
+ ])
+ assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] is None
+
+
+def test_sloppak_extract_falls_back_to_an_alt_bass_chart(tmp_path):
+ """Only a "Bass 2" chart exists. Using it beats reporting the guitar
+ tuning as the player's bass tuning."""
+ d = _pack(tmp_path, "altbass.sloppak", [
+ {"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
+ {"name": "Bass 2", "tuning": [-2, 0, 0, 0, 0, 0]},
+ ])
+ assert sloppak_mod.extract_meta(d)["bass_tuning_offsets"] == [-2, 0, 0, 0, 0, 0]
+
+
+# ── 2. Scanner derivation (name / sort key / offsets string) ─────────────────
+
+def test_scan_worker_derives_bass_columns_like_the_guitar_ones(tmp_path):
+ """Guitar columns keep all six strings; bass columns are TRUNCATED to the
+ bass's four (the stored tail is padding — see tunings.normalize_bass_offsets)."""
+ d = _pack(tmp_path, "derive.sloppak", [
+ {"name": "Lead", "tuning": [-2, -2, -2, -2, -2, -2]},
+ {"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
+ ])
+ meta = _extract_meta_for_file(d)
+ assert meta["tuning_name"] == "D Standard"
+ assert meta["tuning_sort_key"] == -12
+ assert meta["tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
+ assert meta["bass_tuning_name"] == "E Standard"
+ assert meta["bass_tuning_sort_key"] == 0
+ assert meta["bass_tuning_offsets"] == "0 0 0 0"
+ # Canonical key = absolute open pitches of a 4-string bass in standard.
+ assert meta["bass_tuning_key"] == "bass:28:33:38:43"
+
+
+def test_bass_padding_is_truncated_before_naming_and_grouping(tmp_path):
+ """The padded tail must never reach the namer or the group key: a bass
+ stored six-wide and the same tuning stored four-wide must produce
+ IDENTICAL indexed columns."""
+ six = _extract_meta_for_file(_pack(tmp_path, "six.sloppak", [
+ {"name": "Bass", "tuning": [-2, 0, 0, 0, 0, 0]}]))
+ four = _extract_meta_for_file(_pack(tmp_path, "four.sloppak", [
+ {"name": "Bass", "tuning": [-2, 0, 0, 0]}]))
+ for col in ("bass_tuning_name", "bass_tuning_offsets",
+ "bass_tuning_sort_key", "bass_tuning_key"):
+ assert six[col] == four[col], col
+ assert six["bass_tuning_name"] == "Drop D"
+
+
+def test_scan_worker_bass_columns_empty_without_a_bass_arrangement(tmp_path):
+ """Empty string, never None: '' is the indexed 'we looked, no bass chart'
+ state, while NULL means 'never extracted' and triggers a re-scan."""
+ d = _pack(tmp_path, "nobass2.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
+ meta = _extract_meta_for_file(d)
+ assert meta["bass_tuning_name"] == ""
+ assert meta["bass_tuning_sort_key"] == 0
+ assert meta["bass_tuning_offsets"] == ""
+
+
+def test_implausible_bass_tuning_is_never_named(tmp_path):
+ """Real library data, and it is BAD DATA: AC/DC "Girls Got Rhythm" stores
+ a bass tuning of [5,5,5,5,4,4] — every string up a perfect fourth, which
+ no bassist plays (roughly double string tension), on a song whose guitar
+ chart is dead standard.
+
+ Truncation alone would leave [5,5,5,5] = "all strings up a 4th", which the
+ namer WOULD happily name. Naming it would send a player off to retune to a
+ tuning that does not exist, so the plausibility guard must refuse: bassists
+ tune down, essentially never up."""
+ d = _pack(tmp_path, "weird.sloppak", [
+ {"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
+ {"name": "Bass", "tuning": [5, 5, 5, 5, 4, 4]},
+ ])
+ meta = _extract_meta_for_file(d)
+ assert meta["bass_tuning_name"] == "Custom Tuning"
+ assert meta["bass_tuning_offsets"] == "5 5 5 5"
+ assert meta["bass_tuning_sort_key"] == 20
+
+
+@pytest.mark.parametrize("offsets", [
+ [5, 5, 5, 5], [5, 5, 5, 5, 4, 4], [2, 2, 2, 2], [12, 12, 12, 12],
+])
+def test_up_tuned_bass_offsets_are_refused_by_the_guard(offsets):
+ """Anything above +1 semitone is data we do not trust. Note the namer
+ ALONE would name several of these ([2,2,2,2] -> "F# Standard"), which is
+ exactly the retune-to-nowhere the guard exists to prevent."""
+ norm = normalize_bass_offsets(offsets)
+ assert bass_offsets_are_plausible(norm) is False
+ assert bass_tuning_name(norm) == "Custom Tuning"
+
+
+@pytest.mark.parametrize("offsets,expected", [
+ ([0, 0, 0, 0], "E Standard"), # standard
+ ([-1, -1, -1, -1], "Eb Standard"), # down a semitone
+ ([-2, 0, 0, 0], "Drop D"), # drop
+ ([1, 1, 1, 1], "F Standard"), # +1 is the plausible ceiling, still named
+])
+def test_plausible_bass_tunings_are_still_named(offsets, expected):
+ """The guard must not over-fire: real down-tunings, standard, and the +1
+ ceiling all keep their names."""
+ assert bass_tuning_name(offsets) == expected
+
+
+# ── 3. Storage round-trip + the pre-migration re-extract marker ──────────────
+
+def test_put_get_round_trips_the_bass_columns(server_mod):
+ _put(server_mod, filename="rt.sloppak", title="RT",
+ tuning_name_="D Standard", tuning_sort_key=-12,
+ tuning_offsets="-2 -2 -2 -2 -2 -2",
+ bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
+ got = server_mod.meta_db.get("rt.sloppak", 1.0, 1)
+ assert got["tuning_name"] == "D Standard"
+ assert got["bass_tuning_name"] == "E Standard"
+ assert got["bass_tuning_offsets"] == "0 0 0 0 0 0"
+
+
+def test_put_never_writes_null_bass_columns(server_mod):
+ """A freshly-scanned row is by definition extracted, so even a song with
+ no bass chart stores '' — otherwise it would look pre-migration forever
+ and the scanner would re-extract it on every single pass."""
+ _put(server_mod, filename="fresh.sloppak", title="Fresh")
+ row = server_mod.meta_db.conn.execute(
+ "SELECT bass_tuning_name FROM songs WHERE filename = 'fresh.sloppak'").fetchone()
+ assert row[0] == ""
+ assert server_mod.meta_db.get("fresh.sloppak", 1.0, 1)["bass_tuning_name"] == ""
+
+
+def test_pre_migration_row_reads_back_as_null(server_mod):
+ """A row written before the columns existed (simulated with raw SQL that
+ omits them) reads back None — the marker the scanner keys its re-extract
+ on. If this ever became '' the backfill would silently never run."""
+ server_mod.meta_db.conn.execute(
+ "INSERT INTO songs (filename, mtime, size, title, artist, album, year, "
+ "duration, tuning, arrangements, has_lyrics, format, stem_count, "
+ "stem_ids, tuning_name, tuning_sort_key, tuning_offsets) "
+ "VALUES ('old.sloppak', 1.0, 1, 'Old', 'A', 'A - LP', '2010', 200.0, "
+ "'E Standard', '[]', 0, 'sloppak', 0, '[]', 'E Standard', 0, '0 0 0 0 0 0')")
+ server_mod.meta_db.conn.commit()
+ got = server_mod.meta_db.get("old.sloppak", 1.0, 1)
+ assert got["bass_tuning_name"] is None
+ # Same for the canonical key: coalescing this to '' would make the
+ # scanner's re-extract check unfireable and strand the backfill.
+ assert got["bass_tuning_key"] is None
+
+
+def test_a_row_missing_only_the_canonical_key_still_re_extracts(server_mod):
+ """A row scanned by an EARLIER build of this feature has bass_tuning_name
+ but no bass_tuning_key. It must still be re-queued, or its custom tunings
+ would group on the old serialization-dependent key forever."""
+ _put(server_mod, filename="halfway.sloppak", title="Halfway",
+ bass_tuning_name="Drop D", bass_tuning_offsets="-2 0 0 0")
+ server_mod.meta_db.conn.execute(
+ "UPDATE songs SET bass_tuning_key = NULL WHERE filename = 'halfway.sloppak'")
+ server_mod.meta_db.conn.commit()
+ cached = server_mod.meta_db.get("halfway.sloppak", 1.0, 1)
+ assert cached["bass_tuning_name"] == "Drop D"
+ assert cached["bass_tuning_key"] is None # → the scanner re-queues it
+
+
+# ── 4. The migration actually backfills (the highest-risk gap) ───────────────
+
+@pytest.fixture()
+def scan_server(tmp_path, monkeypatch, isolate_logging, reset_scan_state):
+ """Server with the background scan forced in-process (see
+ test_feedpak_extension.py::scan_server — the production spawn pool can't
+ reach an in-process mock)."""
+ import concurrent.futures
+ monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
+ monkeypatch.delenv("DLC_DIR", raising=False)
+ sys.modules.pop("server", None)
+ mod = importlib.import_module("server")
+ import scan as scan_mod
+ monkeypatch.setattr(
+ scan_mod, "_make_scan_executor",
+ lambda: concurrent.futures.ThreadPoolExecutor(max_workers=4),
+ )
+ yield mod
+ conn = getattr(getattr(mod, "meta_db", None), "conn", None)
+ if conn is not None:
+ getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
+ conn.close()
+
+
+def test_existing_library_backfills_bass_tuning_on_next_scan(tmp_path, scan_server):
+ """END TO END for every CURRENT user: a settled library whose rows predate
+ the bass columns must re-extract on the next scan.
+
+ Both guards are exercised together — the row-level "bass column is NULL →
+ re-queue" AND the tree-signature fast path, which on an unchanged library
+ would otherwise skip the listing pass entirely and strand the backfill.
+ Then a second scan must NOT re-extract (the backfill converges, it doesn't
+ re-scan the whole library every launch).
+ """
+ import unittest.mock as mock
+
+ dlc = tmp_path / "dlc"
+ dlc.mkdir()
+ (dlc / "song.feedpak").write_bytes(b"")
+ # json.dumps, not %s: a Windows path interpolated raw produces invalid JSON
+ # escapes (\U, \d), the config silently fails to parse, and the scan then
+ # reports "no DLC folder configured" and extracts nothing.
+ (tmp_path / "config.json").write_text(
+ json.dumps({"dlc_dir": str(dlc)}), encoding="utf-8")
+
+ scan = importlib.import_module("scan")
+ seen: list[str] = []
+
+ def mock_extract(f, dlc_dir):
+ seen.append(f.name)
+ return {"title": f.name, "artist": "A", "album": "",
+ "bass_tuning_name": "Drop D", "bass_tuning_sort_key": -2,
+ "bass_tuning_offsets": "-2 0 0 0 0 0"}
+
+ with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
+ scan.background_scan()
+ assert "song.feedpak" in seen
+
+ # Simulate the pre-migration state: the row exists and is otherwise
+ # fresh (mtime/size match), but its bass columns were never extracted.
+ scan.appstate.meta_db.conn.execute(
+ "UPDATE songs SET bass_tuning_name = NULL, bass_tuning_sort_key = NULL, "
+ "bass_tuning_offsets = NULL")
+ scan.appstate.meta_db.conn.commit()
+
+ seen.clear()
+ scan.background_scan()
+ assert "song.feedpak" in seen, (
+ "a row with NULL bass columns must re-extract — otherwise no "
+ "existing library ever gets the bass tuning")
+
+ row = scan.appstate.meta_db.conn.execute(
+ "SELECT bass_tuning_name, bass_tuning_offsets FROM songs "
+ "WHERE filename = 'song.feedpak'").fetchone()
+ assert row == ("Drop D", "-2 0 0 0 0 0")
+
+ # Converged: the fast path is back and nothing re-extracts.
+ seen.clear()
+ scan.background_scan()
+ assert seen == []
+
+
+# ── 5. The facet endpoint ────────────────────────────────────────────────────
+
+@pytest.fixture()
+def facet_seeded(server_mod):
+ """Three shapes, matching the real library's distribution:
+ differ — guitar D Standard, bass E Standard (the bug)
+ match — both Drop D (common)
+ nobass — guitar Drop D, no bass chart (fallback, common)
+ """
+ _put(server_mod, filename="differ.sloppak", title="Differ",
+ tuning_name_="D Standard", tuning_sort_key=-12,
+ tuning_offsets="-2 -2 -2 -2 -2 -2",
+ bass_tuning_name="E Standard", bass_tuning_sort_key=0,
+ bass_tuning_offsets="0 0 0 0 0 0")
+ _put(server_mod, filename="match.sloppak", title="Match",
+ tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0",
+ bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
+ bass_tuning_offsets="-2 0 0 0 0 0")
+ _put(server_mod, filename="nobass.sloppak", title="NoBass",
+ tuning_name_="Drop D", tuning_sort_key=-2, tuning_offsets="-2 0 0 0 0 0")
+
+
+def _facet(client, **kw):
+ return {t["name"]: t["count"]
+ for t in client.get("/api/library/tuning-names", params=kw).json()["tunings"]}
+
+
+def test_facet_defaults_to_the_guitar_tuning(client, facet_seeded):
+ assert _facet(client) == {"D Standard": 1, "Drop D": 2}
+
+
+def test_facet_bass_groups_by_bass_tuning_with_guitar_fallback(client, facet_seeded):
+ """differ counts under its BASS tuning (E Standard), match under Drop D,
+ and nobass — having no bass chart — falls back to its guitar Drop D rather
+ than vanishing from the facet."""
+ assert _facet(client, instrument="bass") == {"E Standard": 1, "Drop D": 2}
+
+
+def test_facet_ignores_an_unknown_instrument(client, facet_seeded):
+ """An unknown value must not silently change filter semantics."""
+ assert _facet(client, instrument="theremin") == _facet(client)
+
+
+# ── 6. The filter: the actual reported bug ───────────────────────────────────
+
+def _files(client, **kw):
+ return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
+
+
+def test_bass_filter_excludes_a_song_whose_only_match_is_its_guitar_tuning(
+ client, facet_seeded):
+ """THE BUG. Filtering bass "D Standard" must NOT return `differ` — its
+ D Standard is the GUITAR chart; its bass is in E Standard."""
+ assert _files(client, tunings="D Standard") == {"differ.sloppak"}
+ assert _files(client, tunings="D Standard", instrument="bass") == set()
+
+
+def test_bass_filter_returns_songs_by_their_bass_tuning(client, facet_seeded):
+ """…and the converse: bass "E Standard" finds `differ`, which the guitar
+ filter would never return."""
+ assert _files(client, tunings="E Standard") == set()
+ assert _files(client, tunings="E Standard", instrument="bass") == {"differ.sloppak"}
+
+
+def test_bass_filter_keeps_songs_without_a_bass_arrangement_via_fallback(
+ client, facet_seeded):
+ """The most common shape. `nobass` has no bass chart, so it must still be
+ reachable under its guitar tuning instead of disappearing for bass users —
+ and the facet's count for that pill must equal what the filter returns."""
+ got = _files(client, tunings="Drop D", instrument="bass")
+ assert got == {"match.sloppak", "nobass.sloppak"}
+ assert _facet(client, instrument="bass")["Drop D"] == len(got)
+
+
+def test_custom_bass_tunings_stay_distinct_under_their_offsets(client, server_mod):
+ """Two unnameable bass tunings both label "Custom Tuning"; the facet keys
+ them on raw offsets so selecting one doesn't drag in the other. Uses the
+ real [5,5,5,5,4,4] shape from the library."""
+ _put(server_mod, filename="c1.sloppak", title="C1",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
+ bass_tuning_offsets="5 5 5 5 4 4")
+ _put(server_mod, filename="c2.sloppak", title="C2",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-7,
+ bass_tuning_offsets="-3 -1 -1 -1 -1 0")
+ keys = [t["key"] for t in client.get(
+ "/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
+ if t["name"] == "Custom Tuning"]
+ assert sorted(keys) == sorted(["5 5 5 5 4 4", "-3 -1 -1 -1 -1 0"])
+ assert _files(client, tunings="5 5 5 5 4 4", instrument="bass") == {"c1.sloppak"}
+
+
+def test_stats_facet_counts_agree_with_the_bass_filter(client, facet_seeded):
+ """The A–Z rail / count surface must apply the same instrument-aware
+ predicate as the grid, or the header count contradicts the results."""
+ body = client.get("/api/library/stats", params={
+ "tunings": "Drop D", "instrument": "bass"}).json()
+ assert body["total_songs"] == 2
+
+
+# ── 7. Sort ──────────────────────────────────────────────────────────────────
+
+def test_tuning_sort_respects_the_instrument(client, facet_seeded):
+ """Tuning sort is musical distance from E Standard. For a bass player that
+ distance must be measured on the BASS tuning: `differ` is the furthest
+ song by guitar (D Standard, |−12|) but the nearest by bass (E Standard, 0),
+ so it moves from last to first."""
+ def order(**kw):
+ return [s["filename"] for s in client.get(
+ "/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
+
+ guitar = order()
+ assert guitar[-1] == "differ.sloppak"
+ bass = order(instrument="bass")
+ assert bass[0] == "differ.sloppak"
+
+
+# ── 8. Song payload ──────────────────────────────────────────────────────────
+
+# ── 9. Real-library offset SHAPES ────────────────────────────────────────────
+# Measured across the 59-pack test library: bass offset lists are NOT reliably
+# 4 or reliably 6 — 41 store six elements, 1 stores four. Two six-element ones
+# diverge in the tail (AC/DC "Girls Got Rhythm" [5,5,5,5,4,4]; Intervals
+# "Libra" [-2,0,0,0,0,0]). Nothing may crash or mislabel on any of them.
+
+@pytest.mark.parametrize("offsets,expected", [
+ ([0, 0, 0, 0], "E Standard"), # four-element (the 1 outlier)
+ ([0, 0, 0, 0, 0, 0], "E Standard"), # six-element all-equal (39 of them)
+ ([-1, -1, -1, -1], "Eb Standard"), # four-element, down a semitone
+ ([5, 5, 5, 5, 4, 4], "Custom Tuning"), # AC/DC — divergent tail
+ ([-2, 0, 0, 0, 0, 0], "Drop D"), # Intervals — drop + trailing zeros
+ ([0, 0, 0, 0, 0], "Custom Tuning"), # five: no naming convention → custom
+])
+def test_real_library_bass_offset_shapes_name_without_crashing(offsets, expected):
+ assert tuning_name(offsets) == expected
+
+
+@pytest.mark.parametrize("offsets", [
+ [0, 0, 0, 0], [0, 0, 0, 0, 0, 0], [5, 5, 5, 5, 4, 4], [-2, 0, 0, 0, 0, 0],
+])
+def test_real_library_bass_offset_shapes_survive_extraction(tmp_path, offsets):
+ """Each shape must round-trip the real extractor + scanner derivation,
+ landing on the NORMALIZED (truncated, plausibility-checked) columns."""
+ norm = normalize_bass_offsets(offsets)
+ d = _pack(tmp_path, "shape.sloppak", [
+ {"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
+ {"name": "Bass", "tuning": offsets},
+ ])
+ meta = _extract_meta_for_file(d)
+ assert meta["bass_tuning_name"] == bass_tuning_name(norm)
+ assert meta["bass_tuning_offsets"] == " ".join(str(o) for o in norm)
+ assert meta["bass_tuning_sort_key"] == sum(norm)
+ assert meta["bass_tuning_key"] == bass_tuning_key(norm)
+
+
+def test_named_bass_tunings_group_across_serialization_lengths(client, server_mod):
+ """The length question does NOT fragment NAMED tunings: a bass stored as
+ four elements and one stored as six both name "E Standard", and the facet
+ groups by name — so they land in ONE row with a combined count. This is the
+ common case (40 of the 42 bass arrangements in the real library)."""
+ _put(server_mod, filename="four.sloppak", title="Four",
+ bass_tuning_name=tuning_name([0, 0, 0, 0]), bass_tuning_offsets="0 0 0 0")
+ _put(server_mod, filename="six.sloppak", title="Six",
+ bass_tuning_name=tuning_name([0, 0, 0, 0, 0, 0]),
+ bass_tuning_offsets="0 0 0 0 0 0")
+ assert _facet(client, instrument="bass") == {"E Standard": 2}
+ assert _files(client, tunings="E Standard", instrument="bass") == {
+ "four.sloppak", "six.sloppak"}
+
+
+def test_drop_d_bass_groups_across_serialization_lengths(client, server_mod):
+ """Same for the Intervals shape: [-2,0,0,0,0,0] and [-2,0,0,0] both name
+ "Drop D", so trailing zeros can't split a named tuning into two rows."""
+ _put(server_mod, filename="d6.sloppak", title="D6",
+ bass_tuning_name=tuning_name([-2, 0, 0, 0, 0, 0]),
+ bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0 0 0")
+ _put(server_mod, filename="d4.sloppak", title="D4",
+ bass_tuning_name=tuning_name([-2, 0, 0, 0]),
+ bass_tuning_sort_key=-2, bass_tuning_offsets="-2 0 0 0")
+ assert _facet(client, instrument="bass") == {"Drop D": 2}
+
+
+def test_equivalent_custom_bass_tunings_group_into_one_facet_row(client, server_mod):
+ """Two CUSTOM bass tunings that are the same physical tuning must be ONE
+ facet row, however they were serialized. They group on canonical PITCHES
+ (bass_tuning_key), so the offsets string no longer fragments them —
+ previously this produced two rows with split counts."""
+ key = bass_tuning_key([-3, -1, -1, -1])
+ _put(server_mod, filename="c6.sloppak", title="C6",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
+ bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
+ _put(server_mod, filename="c4.sloppak", title="C4",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=-6,
+ bass_tuning_offsets="-3 -1 -1 -1", bass_tuning_key=key)
+ rows = client.get("/api/library/tuning-names",
+ params={"instrument": "bass"}).json()["tunings"]
+ customs = [t for t in rows if t["name"] == "Custom Tuning"]
+ assert len(customs) == 1 and customs[0]["count"] == 2
+ assert _files(client, tunings=customs[0]["key"], instrument="bass") == {
+ "c6.sloppak", "c4.sloppak"}
+
+
+def test_canonical_key_is_pitch_not_serialization(tmp_path):
+ """The property that makes the grouping robust: two serializations of one
+ tuning yield the same key, and two genuinely different tunings do not."""
+ assert bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0, 0, 0])) == \
+ bass_tuning_key(normalize_bass_offsets([-2, 0, 0, 0]))
+ assert bass_tuning_key([-2, 0, 0, 0]) != bass_tuning_key([-3, 0, 0, 0])
+ # Absolute open pitches of a standard 4-string bass (E1 A1 D2 G2).
+ assert bass_tuning_key([0, 0, 0, 0]) == "bass:28:33:38:43"
+
+
+def test_custom_bass_facet_row_selects_exactly_what_it_counted(client, server_mod):
+ """Whatever the grouping rule, the invariant that must NEVER break: every
+ facet row's count equals the number of songs its own key returns. This is
+ what makes the seam safe to change — a normalization that merged rows but
+ not the filter would fail here."""
+ _put(server_mod, filename="x6.sloppak", title="X6",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=28,
+ bass_tuning_offsets="5 5 5 5 4 4")
+ _put(server_mod, filename="x4.sloppak", title="X4",
+ bass_tuning_name="Custom Tuning", bass_tuning_sort_key=20,
+ bass_tuning_offsets="5 5 5 5")
+ _put(server_mod, filename="plain.sloppak", title="Plain",
+ bass_tuning_name="E Standard", bass_tuning_offsets="0 0 0 0 0 0")
+ for row in client.get("/api/library/tuning-names",
+ params={"instrument": "bass"}).json()["tunings"]:
+ got = _files(client, tunings=row["key"], instrument="bass")
+ assert len(got) == row["count"], (
+ f"facet row {row['key']!r} counted {row['count']} but selects {len(got)}")
+
+
+# ── 10. THE HEADLINE REGRESSION ──────────────────────────────────────────────
+
+def test_covet_shibuya_is_findable_by_a_bassist(tmp_path, server_mod, client):
+ """Covet - "Shibuya" (Effloresce): the guitar is in a custom tuning
+ [-2,0,0,-1,-2,0] while the bass is dead standard. This is the tester's bug
+ in one song — a bassist filtering "E Standard" never saw it, because the
+ library only knew the guitar's custom tuning.
+
+ Round-tripped through the REAL extractor and scanner derivation, not
+ hand-written columns, so it covers the whole chain."""
+ d = _pack(tmp_path, "shibuya.sloppak", [
+ {"name": "Lead", "tuning": [-2, 0, 0, -1, -2, 0]},
+ {"name": "Bass", "tuning": [0, 0, 0, 0, 0, 0]},
+ ])
+ meta = _extract_meta_for_file(d)
+ server_mod.meta_db.put("shibuya.sloppak", 1.0, 1, {
+ **meta, "title": "Shibuya", "artist": "Covet", "album": "Effloresce"})
+
+ # The guitar chart really is a custom tuning…
+ assert meta["tuning_name"] == "Custom Tuning"
+ # …and the bass chart really is standard.
+ assert meta["bass_tuning_name"] == "E Standard"
+
+ # Before the fix a bassist filtering E Standard got nothing.
+ assert _files(client, tunings="E Standard") == set()
+ assert _files(client, tunings="E Standard", instrument="bass") == {"shibuya.sloppak"}
+
+ # And it appears in the bass facet under E Standard, as a REAL bass chart
+ # (not an inferred fallback).
+ row = next(t for t in client.get(
+ "/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]
+ if t["name"] == "E Standard")
+ assert row["count"] == 1 and row["inferred_count"] == 0
+
+
+# ── 11. Provenance: the fallback must be honest, never silent ────────────────
+
+def test_facet_reports_how_many_rows_are_inferred_from_the_guitar_chart(
+ client, facet_seeded):
+ """The fallback keeps no-bass-chart songs visible (a third of a real
+ library), but the UI must be able to say so. `nobass` has no bass chart and
+ rides under the guitar's Drop D; `match` has a real one."""
+ rows = {t["name"]: t for t in client.get(
+ "/api/library/tuning-names", params={"instrument": "bass"}).json()["tunings"]}
+ assert rows["Drop D"]["count"] == 2
+ assert rows["Drop D"]["inferred_count"] == 1 # nobass only
+ assert rows["E Standard"]["inferred_count"] == 0 # differ has a real bass chart
+
+
+def test_guitar_facet_reports_no_inferred_rows(client, facet_seeded):
+ """Guitar is never a fallback perspective, so nothing is ever inferred."""
+ rows = client.get("/api/library/tuning-names").json()["tunings"]
+ assert all(t["inferred_count"] == 0 for t in rows)
+
+
+def test_song_rows_mark_an_inferred_tuning(client, facet_seeded):
+ """A bass player's row must be distinguishable: native bass chart vs
+ borrowed from the guitar. Without this the card silently presents a guitar
+ tuning as the bass tuning — the original bug in a new place."""
+ rows = {s["filename"]: s for s in client.get(
+ "/api/library", params={"instrument": "bass"}).json()["songs"]}
+ assert rows["differ.sloppak"]["tuning_inferred"] is False
+ assert rows["nobass.sloppak"]["tuning_inferred"] is True
+ assert rows["differ.sloppak"]["tuning_perspective"] == "bass"
+
+
+def test_guitar_rows_carry_no_bass_perspective_fields(client, facet_seeded):
+ """The guitar payload is untouched — no perspective/inferred keys at all."""
+ row = client.get("/api/library").json()["songs"][0]
+ assert "tuning_inferred" not in row and "tuning_perspective" not in row
+
+
+def test_arrangements_has_bass_is_the_real_bass_chart_lever(server_mod, client):
+ """'Only songs with a real bass chart' is the EXISTING `arrangements_has`
+ filter — no new filter, no "confirmed tunings" checkbox. It composes with
+ the tuning filter, so a bassist who wants to exclude inferred rows already
+ can, and it is already expressible in a saved collection rule."""
+ def put_with_arrs(fn, arrs, **kw):
+ server_mod.meta_db.put(fn, 1.0, 1, {
+ "title": fn, "artist": "A", "album": "A - LP", "year": "2010",
+ "duration": 200.0, "tuning": "Drop D", "arrangements": arrs,
+ "has_lyrics": False, "format": "sloppak", "stem_ids": [],
+ "tuning_name": "Drop D", "tuning_sort_key": -2,
+ "tuning_offsets": "-2 0 0 0 0 0", **kw})
+
+ put_with_arrs("withbass.sloppak",
+ [{"index": 0, "name": "Lead"}, {"index": 1, "name": "Bass"}],
+ bass_tuning_name="Drop D", bass_tuning_sort_key=-2,
+ bass_tuning_offsets="-2 0 0 0",
+ bass_tuning_key=bass_tuning_key([-2, 0, 0, 0]))
+ put_with_arrs("nobass.sloppak", [{"index": 0, "name": "Lead"}])
+
+ # Both are reachable under the bass Drop D pill (the fallback keeps the
+ # no-bass-chart song visible)…
+ assert _files(client, tunings="Drop D", instrument="bass") == {
+ "withbass.sloppak", "nobass.sloppak"}
+ # …and the existing arrangements_has lever narrows to real bass charts.
+ assert _files(client, tunings="Drop D", instrument="bass",
+ arrangements_has="Bass") == {"withbass.sloppak"}
+
+
+def test_song_rows_carry_the_bass_tuning_for_the_client(client, facet_seeded):
+ """The card renders the bass tuning client-side, so the row must ship it —
+ and ship '' (not the guitar value) when there is no bass chart, so the
+ client's fallback stays the client's decision."""
+ rows = {s["filename"]: s for s in client.get("/api/library").json()["songs"]}
+ assert rows["differ.sloppak"]["tuning_name"] == "D Standard"
+ assert rows["differ.sloppak"]["bass_tuning_name"] == "E Standard"
+ assert rows["differ.sloppak"]["bass_tuning_offsets"] == "0 0 0 0 0 0"
+ assert rows["nobass.sloppak"]["bass_tuning_name"] == ""
diff --git a/tests/test_library_tuning_perspectives.py b/tests/test_library_tuning_perspectives.py
new file mode 100644
index 00000000..67ed6c23
--- /dev/null
+++ b/tests/test_library_tuning_perspectives.py
@@ -0,0 +1,294 @@
+"""The three-valued tuning PERSPECTIVE, and "playable without retuning".
+
+Two behaviours that extend the bass tuning fix (see
+test_library_tuning_instrument.py):
+
+1. `active_instrument_profile` has three values (guitar-lead / guitar-rhythm /
+ bass), so the tuning perspective must too. Lead and rhythm charts can be
+ tuned differently, which is the identical bug a bassist hit, inside guitar.
+
+2. Exact tuning match answers "which tuning is this labelled". A player
+ actually wants "will this cost me a retune". Both are offered; exact stays
+ the default.
+
+Everything round-trips through the real extractor, the real scanner
+derivation, the real schema and the real HTTP surface.
+"""
+
+import importlib
+import sys
+
+import pytest
+import yaml
+from fastapi.testclient import TestClient
+
+from scan_worker import _extract_meta_for_file
+from tunings import (
+ PERSPECTIVES, bass_tuning_key, chart_is_playable_in, perspective_tuning_key,
+)
+
+
+@pytest.fixture()
+def server_mod(tmp_path, monkeypatch):
+ monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
+ sys.modules.pop("server", None)
+ mod = importlib.import_module("server")
+ yield mod
+ conn = getattr(getattr(mod, "meta_db", None), "conn", None)
+ if conn is not None:
+ getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
+ conn.close()
+
+
+@pytest.fixture()
+def client(server_mod):
+ c = TestClient(server_mod.app)
+ try:
+ yield c
+ finally:
+ c.close()
+
+
+def _pack(root, name, arrangements):
+ d = root / name
+ d.mkdir(parents=True)
+ (d / "manifest.yaml").write_text(yaml.safe_dump({
+ "title": name, "artist": "A", "duration": 100,
+ "arrangements": arrangements, "stems": [],
+ }), encoding="utf-8")
+ return d
+
+
+def _files(client, **kw):
+ return {s["filename"] for s in client.get("/api/library", params=kw).json()["songs"]}
+
+
+# ── 1. The same bug WITHIN guitar: lead vs rhythm ────────────────────────────
+
+def test_rhythm_chart_tuning_is_indexed_separately(tmp_path):
+ """A song whose LEAD is in E standard but whose RHYTHM is in Drop D must
+ index both — through the real extractor + scanner derivation."""
+ d = _pack(tmp_path, "split.sloppak", [
+ {"name": "Lead", "tuning": [0, 0, 0, 0, 0, 0]},
+ {"name": "Rhythm", "tuning": [-2, 0, 0, 0, 0, 0]},
+ ])
+ meta = _extract_meta_for_file(d)
+ assert meta["tuning_name"] == "E Standard" # song-level = guitar-first
+ assert meta["rhythm_tuning_name"] == "Drop D" # the rhythm chart's own
+ assert meta["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
+ assert meta["rhythm_tuning_low_pitch"] == 38 # low D
+
+
+def test_rhythm_offsets_are_not_truncated(tmp_path):
+ """Only BASS truncates (its arrays are padded). A 7-string guitar array is
+ real data — cutting it to 6 would invent a tuning the chart doesn't have."""
+ d = _pack(tmp_path, "seven.sloppak", [
+ {"name": "Rhythm", "tuning": [-2, -2, -2, -2, -2, -2, -2]},
+ ])
+ meta = _extract_meta_for_file(d)
+ assert meta["rhythm_tuning_offsets"] == "-2 -2 -2 -2 -2 -2 -2"
+
+
+def test_no_rhythm_arrangement_leaves_the_columns_empty(tmp_path):
+ d = _pack(tmp_path, "leadonly.sloppak", [{"name": "Lead", "tuning": [0] * 6}])
+ meta = _extract_meta_for_file(d)
+ assert meta["rhythm_tuning_name"] == ""
+ assert meta["rhythm_tuning_key"] == ""
+
+
+def _put(server_mod, fn, **kw):
+ base = dict(title=fn, artist="A", album="LP", year="2010", duration=200.0,
+ tuning="E Standard", arrangements=[], has_lyrics=False,
+ format="sloppak", stem_ids=[], tuning_name="E Standard",
+ tuning_sort_key=0, tuning_offsets="0 0 0 0 0 0",
+ tuning_low_pitch=40)
+ base.update(kw)
+ server_mod.meta_db.put(fn, 1.0, 1, base)
+
+
+@pytest.fixture()
+def rhythm_seeded(server_mod):
+ """Both songs are E Standard by LEAD. One has a Drop D rhythm chart; the
+ other has no rhythm chart at all (so it falls back + is marked inferred)."""
+ _put(server_mod, "rdiffer.sloppak",
+ rhythm_tuning_name="Drop D", rhythm_tuning_sort_key=-2,
+ rhythm_tuning_offsets="-2 0 0 0 0 0",
+ rhythm_tuning_key=perspective_tuning_key(
+ [-2, 0, 0, 0, 0, 0], PERSPECTIVES["guitar-rhythm"]),
+ rhythm_tuning_low_pitch=38)
+ _put(server_mod, "rnone.sloppak")
+
+
+def test_rhythm_filter_excludes_a_lead_only_tuning_match(client, rhythm_seeded):
+ """THE WITHIN-GUITAR BUG. Filtering rhythm "E Standard" must not return
+ rdiffer — that is its LEAD tuning; its rhythm chart is in Drop D."""
+ assert _files(client, tunings="E Standard") == {"rdiffer.sloppak", "rnone.sloppak"}
+ # rnone has no rhythm chart, so it falls back to its lead tuning and stays.
+ assert _files(client, tunings="E Standard", instrument="guitar-rhythm") == {
+ "rnone.sloppak"}
+ assert _files(client, tunings="Drop D", instrument="guitar-rhythm") == {
+ "rdiffer.sloppak"}
+ # …and Drop D finds nothing from the lead perspective.
+ assert _files(client, tunings="Drop D") == set()
+
+
+def test_rhythm_perspective_marks_inferred_rows(client, rhythm_seeded):
+ rows = {s["filename"]: s for s in client.get(
+ "/api/library", params={"instrument": "guitar-rhythm"}).json()["songs"]}
+ assert rows["rdiffer.sloppak"]["tuning_inferred"] is False
+ assert rows["rnone.sloppak"]["tuning_inferred"] is True
+ assert rows["rdiffer.sloppak"]["tuning_perspective"] == "guitar-rhythm"
+
+
+def test_rhythm_facet_reports_inferred_portion(client, rhythm_seeded):
+ rows = {t["name"]: t for t in client.get(
+ "/api/library/tuning-names",
+ params={"instrument": "guitar-rhythm"}).json()["tunings"]}
+ assert rows["Drop D"]["count"] == 1 and rows["Drop D"]["inferred_count"] == 0
+ assert rows["E Standard"]["count"] == 1 and rows["E Standard"]["inferred_count"] == 1
+
+
+def test_facet_row_selects_exactly_what_it_counted_for_rhythm(client, rhythm_seeded):
+ """The invariant that must hold for EVERY perspective."""
+ for row in client.get("/api/library/tuning-names",
+ params={"instrument": "guitar-rhythm"}).json()["tunings"]:
+ got = _files(client, tunings=row["key"], instrument="guitar-rhythm")
+ assert len(got) == row["count"], row["key"]
+
+
+def test_guitar_lead_is_byte_identical_to_the_legacy_default(client, rhythm_seeded):
+ """The majority path must not regress: the default payload gains no keys,
+ and the legacy two-valued vocabulary still resolves to it."""
+ default = client.get("/api/library").json()
+ explicit = client.get("/api/library", params={"instrument": "guitar-lead"}).json()
+ legacy = client.get("/api/library", params={"instrument": "guitar"}).json()
+ assert default == explicit == legacy
+ row = default["songs"][0]
+ assert "tuning_inferred" not in row and "tuning_perspective" not in row
+
+
+def test_unknown_perspective_falls_back_to_lead(client, rhythm_seeded):
+ """An unrecognised value must never silently change filter semantics."""
+ assert client.get("/api/library", params={"instrument": "kazoo"}).json() == \
+ client.get("/api/library").json()
+
+
+def test_tuning_sort_respects_the_rhythm_perspective(client, rhythm_seeded):
+ """Sort is musical distance from standard. rdiffer is 0 away by lead but
+ -2 by rhythm, so the perspective changes its position."""
+ def order(**kw):
+ return [s["filename"] for s in client.get(
+ "/api/library", params={"sort": "tuning", **kw}).json()["songs"]]
+ assert order()[0] == "rdiffer.sloppak" # tie → filename
+ assert order(instrument="guitar-rhythm")[0] == "rnone.sloppak" # 0 beats -2
+
+
+# ── 2. "Playable without retuning" ───────────────────────────────────────────
+
+@pytest.mark.parametrize("your_low,chart_low,expected", [
+ (23, 28, True), # 5-string bass (low B) plays a 4-string standard chart
+ (23, 26, True), # …and a drop-D chart: the low D is fretted on the B string
+ (28, 26, False), # 4-string standard CANNOT reach a drop-D chart's low D
+ (28, 28, True), # identical tuning
+ (40, 38, False), # guitar standard vs a drop-D chart
+ (38, 40, True), # a drop-D guitar covers a standard chart
+ (None, 28, False), # unknown chart pitch is never claimed playable
+ (28, None, False),
+])
+def test_playability_rule(your_low, chart_low, expected):
+ """The core comparison as a property: your lowest open string vs the
+ chart's lowest required pitch. Unknown => not playable (conservative)."""
+ assert chart_is_playable_in(chart_low, your_low) is expected
+
+
+@pytest.fixture()
+def pitched(server_mod):
+ _put(server_mod, "std.sloppak", tuning_low_pitch=40)
+ _put(server_mod, "dropd.sloppak", tuning="Drop D", tuning_name="Drop D",
+ tuning_offsets="-2 0 0 0 0 0", tuning_sort_key=-2, tuning_low_pitch=38)
+ _put(server_mod, "dropc.sloppak", tuning="Drop C", tuning_name="Drop C",
+ tuning_offsets="-4 -2 -2 -2 -2 -2", tuning_sort_key=-14, tuning_low_pitch=36)
+
+
+def _playable(client, offsets, instrument="guitar", sc=6, **kw):
+ return {s["filename"] for s in client.get("/api/library", params={
+ "tuning_match": "playable", "playable_offsets": offsets,
+ "playable_instrument": instrument, "playable_string_count": str(sc), **kw,
+ }).json()["songs"]}
+
+
+def test_playable_from_standard_excludes_lower_tuned_charts(client, pitched):
+ """In E standard you can play the standard chart, but the drop-D and
+ drop-C charts need a retune — exactly what the tester wants surfaced."""
+ assert _playable(client, "0,0,0,0,0,0") == {"std.sloppak"}
+
+
+def test_playable_from_drop_c_covers_everything_above_it(client, pitched):
+ """Tuned DOWN to drop C, every higher-tuned chart is reachable by fretting
+ — the dominant real case this feature exists for."""
+ assert _playable(client, "-4,-2,-2,-2,-2,-2") == {
+ "std.sloppak", "dropd.sloppak", "dropc.sloppak"}
+
+
+def test_playable_is_a_mode_not_a_replacement_for_exact(client, pitched):
+ """Exact match still works untouched, and returns something DIFFERENT from
+ playable — they answer different questions."""
+ exact = {s["filename"] for s in client.get(
+ "/api/library", params={"tunings": "Drop D"}).json()["songs"]}
+ assert exact == {"dropd.sloppak"}
+ assert _playable(client, "-2,0,0,0,0,0") == {"std.sloppak", "dropd.sloppak"}
+
+
+def test_playable_excludes_rows_with_no_indexed_pitch(client, server_mod, pitched):
+ """Conservative by construction: a chart whose low pitch we could not
+ compute is EXCLUDED, never assumed playable. Wrongly claiming playability
+ costs a mid-practice retune — the failure this feature prevents."""
+ _put(server_mod, "unknown.sloppak", tuning_low_pitch=None)
+ assert "unknown.sloppak" not in _playable(client, "-4,-2,-2,-2,-2,-2")
+ # …but it is still reachable normally, so it isn't lost from the library.
+ assert any(s["filename"] == "unknown.sloppak"
+ for s in client.get("/api/library").json()["songs"])
+
+
+def test_malformed_playable_tuning_applies_no_filter(client, pitched):
+ """A tuning we cannot resolve must not silently claim everything is
+ playable OR that nothing is — it applies no filter at all."""
+ everything = {s["filename"] for s in client.get("/api/library").json()["songs"]}
+ assert _playable(client, "not,a,tuning") == everything
+ assert _playable(client, "") == everything
+ # A string count that disagrees with the offsets is equally unusable.
+ assert _playable(client, "0,0,0,0", instrument="guitar", sc=6) == everything
+
+
+def test_playable_respects_the_bass_perspective(client, server_mod):
+ """A 5-string bass (low B) can play a 4-string standard bass chart. The
+ comparison must run on the BASS tuning — this song's GUITAR chart is tuned
+ far lower, so reading the wrong column would flip the answer."""
+ _put(server_mod, "bassy.sloppak",
+ tuning="Custom Tuning", tuning_name="Custom Tuning",
+ tuning_offsets="-4 -2 -2 -1 -2 0", tuning_sort_key=-11,
+ tuning_low_pitch=36,
+ bass_tuning_name="E Standard", bass_tuning_sort_key=0,
+ bass_tuning_offsets="0 0 0 0",
+ bass_tuning_key=bass_tuning_key([0, 0, 0, 0]),
+ bass_tuning_low_pitch=28)
+ # 5-string bass low B (23) <= the chart low E (28) → playable.
+ got = {s["filename"] for s in client.get("/api/library", params={
+ "tuning_match": "playable", "playable_offsets": "0,0,0,0,0",
+ "playable_instrument": "bass", "playable_string_count": "5",
+ "instrument": "bass"}).json()["songs"]}
+ assert got == {"bassy.sloppak"}
+ # A 4-string bass tuned UP a semitone (low F, 29) cannot reach the low E.
+ got_up = {s["filename"] for s in client.get("/api/library", params={
+ "tuning_match": "playable", "playable_offsets": "1,1,1,1",
+ "playable_instrument": "bass", "playable_string_count": "4",
+ "instrument": "bass"}).json()["songs"]}
+ assert got_up == set()
+
+
+def test_playable_and_stats_agree(client, pitched):
+ """The count surface must apply the same predicate as the grid."""
+ body = client.get("/api/library/stats", params={
+ "tuning_match": "playable", "playable_offsets": "0,0,0,0,0,0",
+ "playable_instrument": "guitar", "playable_string_count": "6"}).json()
+ assert body["total_songs"] == 1
diff --git a/tests/test_loosefolder.py b/tests/test_loosefolder.py
index 3ed514dd..4a0ca0f4 100644
--- a/tests/test_loosefolder.py
+++ b/tests/test_loosefolder.py
@@ -302,3 +302,44 @@ def test_extract_meta_uses_lead_tuning_when_bass_sorts_first(tmp_path):
meta = loosefolder.extract_meta(tmp_path)
assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
+ # …and the bass chart's OWN tuning is indexed alongside it, so a bass
+ # player's library filter isn't answered with the guitar tuning.
+ assert meta["bass_tuning_offsets"] == [-4, -4, -4, -4, 0, 0]
+
+
+def test_extract_meta_bass_tuning_absent_without_bass_arrangement(tmp_path):
+ """A folder with no bass chart leaves the bass tuning EMPTY (None) rather
+ than echoing the guitar tuning — the library then falls back explicitly,
+ and 'no bass part' stays distinguishable from 'bass part in E Standard'."""
+ (tmp_path / "audio.wem").write_bytes(b"\0")
+ (tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
+
+ meta = loosefolder.extract_meta(tmp_path)
+ assert meta["tuning_offsets"] == [0, 0, 0, 0, 0, 0]
+ assert meta["bass_tuning_offsets"] is None
+
+
+def test_extract_meta_bass_tuning_matches_guitar_is_still_indexed(tmp_path):
+ """The COMMON case: bass and guitar in the same tuning. The bass column
+ must still be populated — an empty one would be read as 'no bass chart'."""
+ (tmp_path / "audio.wem").write_bytes(b"\0")
+ _write_min_xml(tmp_path / "lead.xml", arrangement="Lead")
+ _write_min_xml(tmp_path / "bass.xml", arrangement="Bass")
+
+ meta = loosefolder.extract_meta(tmp_path)
+ assert meta["bass_tuning_offsets"] == [0, 0, 0, 0, 0, 0]
+
+
+def test_extract_meta_manifest_tuning_does_not_become_the_bass_tuning(tmp_path):
+ """A manifest `tuning_offsets` overrides the SONG tuning but says nothing
+ about which chart it describes, so it must never be mistaken for the bass
+ part's tuning — with no bass chart the bass column stays empty."""
+ (tmp_path / "audio.wem").write_bytes(b"\0")
+ (tmp_path / "lead.xml").write_text(_LEAD_STD_XML, encoding="utf-8")
+ (tmp_path / "manifest.json").write_text(json.dumps({
+ "tuning_offsets": [-2, -2, -2, -2, -2, -2],
+ }), encoding="utf-8")
+
+ meta = loosefolder.extract_meta(tmp_path)
+ assert meta["tuning_offsets"] == [-2, -2, -2, -2, -2, -2]
+ assert meta["bass_tuning_offsets"] is None