Compare commits

...
Author SHA1 Message Date
ChrisBeWithYou 8341d21416 Use instrument tuning in playlist checks 2026-07-19 00:05:15 -05:00
ChrisBeWithYou dbee1b2489 fix(playlists): stay within the shipped Tailwind class set
Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.

Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.

Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.

The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-19 00:05:14 -05:00
ChrisBeWithYou ecc7e61251 build(tailwind): regenerate for the playlist tuning-check classes
CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-19 00:05:14 -05:00
ChrisBeWithYouandClaude Opus 4.8 2f532a6957 feat(playlists): flag songs that are not in your current tuning
Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.

Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.

Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.

Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
  bail-out it could not evaluate. Only a report carrying an actual reason
  counts as a mismatch; the rest render as unknown. This differs from the
  library grid, which paints every not-covered song amber -- acceptable on a
  grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
  defaulting to guitar and reproducing the original bug in a new place.

Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.

Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-19 00:05:14 -05:00
cc75cb876a fix(library): tuning filter answers for your instrument, not always guitar (#1003)
* fix(library): tuning filter answers for your instrument, not always guitar

The library indexed exactly one tuning per song, chosen guitar-first (lead >
rhythm > combo, bass only as a last resort), and nothing consulted the
player's instrument. A bassist filtering by tuning was shown the guitar
chart's tuning, so playlists built by tuning contained songs needing a
retune. Reported by a tester building bass practice sets; Covet "Shibuya" is
the clean case, with a custom guitar tuning over a standard bass chart.

Indexes each arrangement role's own tuning and makes the facet, filter, sort
and labels answer for one perspective. `guitar-lead` reads the original
unprefixed columns and adds no payload keys, so the default response is
unchanged. The same defect existed inside guitar -- lead and rhythm charts
can disagree -- so perspective is three-valued (guitar-lead, guitar-rhythm,
bass) driven by one PERSPECTIVES table rather than parallel column families.

Songs with no chart for the perspective fall back to the song-level tuning
rather than vanishing (18 of 59 packs in the test library have no bass
chart), but the fallback is marked inferred in the facet counts and on the
row instead of being silently coalesced. "Only real charts" reuses the
existing `arrangements_has` filter rather than adding one.

Bass-specific handling, from measured content:
- Bass tuning arrays are padded to six entries; charts never reference
  string index 4 or 5. Truncated to four before naming and grouping.
- Grouping uses a canonical open-pitch key, so [-2,0,0,0] and
  [-2,0,0,0,0,0] are one facet row instead of two.
- Offsets above +1 semitone are refused a name. Bassists tune down, near
  never up; one pack ships [5,5,5,5,4,4] (A-D-G-C, unplayable, and its own
  notes sit in the song's real key under standard tuning). Naming that
  would send a player to retune to a tuning that does not exist.

Rhythm deliberately does not truncate -- padding is a bass finding, and
cutting a seven-string array would invent a tuning the chart lacks.

Adds an opt-in `tuning_match=playable` mode alongside exact match: a chart is
offered when your lowest open pitch is at or below its lowest open pitch, so
a five-string bass covers four-string standard and drop-D with no retune.
Open strings only -- note range is not indexed and the scan stays
manifest-only -- so it fails conservative: unknown low pitch is excluded, and
the upper bound is unchecked and documented rather than guessed.

Existing installs would otherwise never populate: the tree-signature fast
path reports "unchanged" forever on a settled library. Rows with NULL marker
columns re-extract, and the fast path is disabled until that backfill
converges (writes use '' rather than NULL, so it self-clears).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(v3): accept the tuning-perspective indirection in the badge guard

The album-art badge now reads shownTuningName(), so the source-pattern guard
no longer matched the inline `tuning_name || tuning` form and CI went red.
Accept the helper, and pin the helper's own fallback in a companion test so
the guard still fails if a guitar player's tuning label is ever dropped.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:30 -05:00
f0d9c3abc0 feat(playlists): manual drag order + Sort A-Z for the playlist list (#1004)
Playlists could only ever be listed alphabetically (system playlists first).
Users who group playlists by purpose had no way to put the ones they reach
for daily at the front.

Adds a nullable `position` column and orders by
`(system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE`,
so manually-ordered playlists lead, unpositioned ones keep sorting
alphabetically behind them, and system playlists stay pinned first.

Drag-reorder mirrors the existing within-playlist song reorder, adapted for
grid tiles (insert side decided on the horizontal midpoint since tiles flow
left-to-right then wrap). System playlists are neither drag sources nor drop
targets. `POST /api/playlists/reorder` requires an exact permutation of the
current non-system ids, so a duplicate, omission, extra, unknown id, or a
system id is rejected rather than silently producing duplicate positions;
booleans are rejected explicitly because `sorted([True, 2]) == sorted([1, 2])`
would otherwise slip through the permutation check.

`POST /api/playlists/sort-alpha` clears the manual order again.


Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:04:23 -05:00
K. O. A.andGitHub 2413991c5a feat(highway_3d): fret wires flash on a confirmed hit (#969)
ship-ci / ci (push) Waiting to run
* feat(highway_3d): fret wires flash on a confirmed hit

The fret wires were static scenery: gold inside the anchor lane, grey
outside, and nothing tied them to what the player was actually doing.

Give them a job. Widen the lane/neck contrast so the wires around the
active lane read as a focus cue, and flash the wires bracketing a note
when a scorer confirms it. A fretted note lights the wire behind it and
the wire it is pressed against; a chord lights only the outermost wires
of its shape, so it reads as one bracketed block rather than a picket
fence; an open string has no fret of its own and its gem is drawn as a
slab spanning the lane, so it lights the lane's edge wires instead.

Gated on the provider verdict, never the proximity heuristic -- the
latter only means "near the strike line", so it would flash on every
passing note whether or not it was played. With no scorer attached the
neck behaves exactly as before.

Emissive (and emissiveIntensity) carry the flash, not albedo: these are
MeshStandard materials in a scene with no envMap, so raising albedo
alone barely brightens them.

Every value is a named constant -- see FRET_WIRE_* -- because the look
is a taste call that wants tuning by eye, not a derivation.

Signed-off-by: Kris Anderson <topkoa@gmail.com>

* feat(highway_3d): cap the fret-wire flash at one outer pair

Fast passages overlap their decay tails: consecutive notes on nearby
frets left three, four, five wires glowing at once — the picket fence
the chord rule was written to avoid, arriving through time instead of
through a shape.

The apply pass now decays every wire's glow state as before, but flashes
only the outermost pair of the lit span (or the single wire when only
one is above threshold). Interior wires keep decaying invisibly — the
base tier loop re-seeds their materials each frame — so the bracket
tightens naturally as the outer tails expire, and a hit inside the
current span widens nothing.

Net effect: at most two wires are ever lit, and everything currently
glowing reads as one bracket, exactly like a chord.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): chord flash frames the lane, not the shape

The lit lane strip spans the anchor's width (minimum ~4 frets), which
can run a fret past the chord's outermost fret. The chord flash
bracketed the shape (wire behind its lowest fret, wire at its highest),
so on those anchors the bracket sat one wire INSIDE the lit lane —
reading as misaligned rather than as a frame around what's lit.

Chord hits now light the anchor lane's edge wires: the exact wires the
lane strip itself spans, and the same pair open strings already use, so
every hit shape inside a lane produces the same bracket. The shape's
own outer pair survives only as the fallback for charts with no
anchors. Fretted and open intensities merge into one entry (they light
the same two wires now), and an all-open chord on an anchor-less chart
still degrades to no flash rather than a bad index.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): gem rims flash string-coloured, wire-fashion

On a confirmed hit the gem's outline now flashes in the STRING'S OWN
colour with the same intensity treatment as the fret wires — the
FRET_WIRE_HIT_INTENSITY emissive ramp, faded by the provider's alpha —
instead of the fixed spring-green mHitBright rim. Just the rims: the
lateral face fill keeps its existing green, and the sustain trail is
untouched.

Mechanics mirror the wires' pattern. mRimFlash[s] is one material per
string (created with the other per-string materials, palette-retint
aware, fog-exempt, disposed in teardown); drawNote() assigns it as the
outline on a good verdict and records the verdict alpha into a
per-frame per-string max (_rimFlashIn); the flash pass applies the
intensity ramp once per string. Shared-per-string is the same
compromise mGlow already makes — two same-string gems flashing in
different phases share the brighter alpha.

No decay tail of our own, deliberately: the material is only assigned
while the provider confirms the note, and the provider's alpha already
fades. When it goes silent the outline reverts, so idle intensity never
shows.

Signed-off-by: topkoa <topkoa@gmail.com>

* feat(highway_3d): wire flash is a lightning strike, not a lingering glow

The flash was instant-on with a 0.32 s exponential tail, and a held
sustain kept re-feeding it — wires stayed lit for the whole note. The
requested feel is a shock: light hits the frets, they jolt, it's over.

The flash is now a one-shot pulse triggered on the input's rising edge:
a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped
(1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting
into the fall (the electric shudder — the crack itself stays clean),
then hard zero. A held 'active' verdict keeps the input high
continuously, which by construction triggers nothing new: one strike
per hit, and the wires go dark while the note rings on. A re-strike
after the provider goes silent re-triggers cleanly.

Seeking backward or a long stall clears all pulse state, and a pulse
whose strike time lands ahead of the playhead after a seek is
discarded. The outer-pair bracket rule is unchanged — it now selects
across pulses instead of decay tails.

Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH
(replacing FRET_WIRE_HIT_DECAY).

Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): one wire strike per judged hit, not per wire edge

The strike trigger was a rising edge on each WIRE's input, which merged
distinct hits: two consecutive correct notes on the same fret kept that
wire's input continuously high, so the second note produced no strike at
all. The wires must respond to what the player did — one strike per
judged hit-zone event.

The trigger is now per event identity, using the same seen-map pattern
as _sparkSeen: the first frame a note gets a good verdict its key
(string|fret|time — or the chord key for a strum, which strikes once as
a unit) lands in _fwStruck and requests a strike on its wires; the
event never fires again however long its verdict stays live. Because
every producer is gated, any nonzero input in the apply pass IS a fresh
strike, so it restarts a pulse already in flight — a rapid re-hit on
the same wire re-cracks instead of being swallowed.

Seeks clear the map (replayed notes strike again); it is size-bounded
like _sparkSeen. Envelope, flicker, and the outer-pair rule unchanged.

Signed-off-by: topkoa <topkoa@gmail.com>

* Revert the lightning-strike experiment — back to the decaying glow

Reverts d003532 and e05d90e. The wire flash returns to its original behaviour: instant-on at the provider's alpha with a smooth exponential fade (FRET_WIRE_HIT_DECAY 0.32 s), held sustains keep their wires lit while the note rings, and no flicker. The outer-pair bracket, lane-framed chords, and string-coloured gem rims are untouched.

Signed-off-by: topkoa <topkoa@gmail.com>

* test: wire-tier assertions follow the named constants

The two render-order tests pinned the old literal hexes (idle 0x666688). The tiers moved to named constants with a retuned idle (FRET_WIRE_IDLE_HEX 0x4A4A60); the tests now assert the code uses the constants AND pin the constants' values, so a future retune is a deliberate two-line change here rather than a silent one.

Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): clamp provider alpha in the rim-flash path (review)

The wire-flash path clamps the note-state provider's alpha to 0..1; the rim-flash accumulation used it raw, so a provider returning >1 would over-drive emissiveIntensity. Clamped to match.

Signed-off-by: topkoa <topkoa@gmail.com>

* test: add fret inlay dots (renderOrder 3) to the hierarchy header (review)

Signed-off-by: topkoa <topkoa@gmail.com>

* test: accurate depth-flag claims, anchored depth assertions (review)

Two review findings on the render-order test file, both correct:

The header claimed ALL 3D-highway materials use depthTest:false, making
renderOrder "the only" draw-order control — but the accent halo
materials set depthTest:true. Now says "nearly all", names the
exception, and calls renderOrder the primary control. A header someone
trusts mid-debug must not overclaim.

The fret-wire depthTest/depthWrite assertions matched anywhere in
screen.js, which is full of other depthTest:false materials — the test
would keep passing if the wire material dropped the flags. Both are now
anchored to the wire material literal via FRET_WIRE_IDLE_HEX (unique to
it), as two separate anchored matches so property order inside the
literal still isn't pinned.

Signed-off-by: topkoa <topkoa@gmail.com>

---------

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-18 21:11:10 -04:00
24 changed files with 20130 additions and 16986 deletions
+542 -525
View File
File diff suppressed because it is too large Load Diff
+105 -26
View File
@@ -17,7 +17,12 @@ import threading
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 @@ class LocalLibraryProvider:
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 @@ class SmartCollectionProvider:
# 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 @@ class SmartCollectionProvider:
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(
+21 -1
View File
@@ -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,
+344 -36
View File
@@ -25,6 +25,8 @@ import time
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,17 +36,113 @@ log = logging.getLogger("feedBack.server")
# 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 ─────────────────────────────────────────────────────
def _arrangements_all_bass(raw) -> bool:
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
must be scored against bass base pitches, or a 4-string bass tuning read as
guitar can false-match a guitarist. A chart with no arrangements is not bass.
"""
try:
arrs = json.loads(raw) if raw else []
except (ValueError, TypeError):
return False
if not isinstance(arrs, list) or not arrs:
return False
return all(
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
for a in arrs
)
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
@@ -381,7 +479,18 @@ class MetadataDB:
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 +517,32 @@ class MetadataDB:
# 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)
@@ -667,6 +802,16 @@ class MetadataDB:
self.conn.execute(_ddl)
except sqlite3.OperationalError:
pass
# Manual playlist ordering (tester ask): `position` orders the
# PLAYLISTS themselves (playlist_songs.position orders songs within
# one). NULL = unpositioned — those sort alphabetically AFTER the
# manually positioned ones, and system playlists stay pinned first
# regardless (see list_playlists). Additive, idempotent — same
# pattern as `rules`/`kind` above.
try:
self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER")
except sqlite3.OperationalError:
pass
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
# analogue. Unlike playlists (which reference owned local songs by
@@ -2405,10 +2550,14 @@ class MetadataDB:
def list_playlists(self) -> list[dict]:
from urllib.parse import quote
# Order: system playlists pinned first, then manually positioned user
# playlists (position = drag order), then unpositioned ones
# alphabetically — so a manual order wins and a playlist created after
# a reorder still lands somewhere predictable (see reorder_playlists).
rows = self.conn.execute(
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
"WHERE rules IS NULL " # smart collections live in the source picker, not here
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
"ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE"
).fetchall()
out = []
for r in rows:
@@ -2566,7 +2715,9 @@ class MetadataDB:
rows = self.conn.execute(
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
ps.arrangement, ps.work_key, s.arrangements,
(s.filename IS NULL) AS dead
(s.filename IS NULL) AS dead, s.tuning_offsets,
s.bass_tuning_name, s.bass_tuning_offsets,
s.rhythm_tuning_name, s.rhythm_tuning_offsets
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? {dead_filter}
ORDER BY ps.position, ps.filename""",
@@ -2578,6 +2729,17 @@ class MetadataDB:
entry = {
"filename": r[0], "position": r[1],
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
# Offsets + the bass-only flag let the playlist tuning check score a
# row against the player's working tuning the same way the library
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
# rows are different tunings), and coverage needs to know whether to
# measure against bass or guitar base pitches.
"tuning_offsets": r[9] or "",
"bass_tuning_name": r[10] or "",
"bass_tuning_offsets": r[11] or "",
"rhythm_tuning_name": r[12] or "",
"rhythm_tuning_offsets": r[13] or "",
"bass_only": _arrangements_all_bass(r[7]),
"art_url": f"/api/song/{quote(r[0])}/art",
}
if is_album:
@@ -2605,7 +2767,9 @@ class MetadataDB:
if work_key:
self._ensure_work_display()
row = self.conn.execute(
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
(work_key,)).fetchone()
@@ -2615,8 +2779,16 @@ class MetadataDB:
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
except Exception:
arrs = []
# An orphan-resolved slot PLAYS a different chart, so it must report
# that chart's tuning to the check — not the dead pin's.
return {"resolved_filename": row[0], "title": row[1] or row[0],
"artist": row[2] or "", "tuning_name": row[3] or "",
"tuning_offsets": row[5] or "",
"bass_tuning_name": row[6] or "",
"bass_tuning_offsets": row[7] or "",
"rhythm_tuning_name": row[8] or "",
"rhythm_tuning_offsets": row[9] or "",
"bass_only": _arrangements_all_bass(row[4]),
"arrangements": arrs,
"art_url": f"/api/song/{quote(row[0])}/art",
"resolved_from_orphan": True}
@@ -2710,6 +2882,30 @@ class MetadataDB:
self.conn.commit()
return True
def reorder_playlists(self, ordered_ids: list[int]) -> bool:
"""Persist a manual ordering of the playlists THEMSELVES: position =
index in `ordered_ids` (the songs-within sibling is reorder_playlist).
Caller (the route) validates the list is an exact permutation of the
current non-system playlist ids."""
with self._lock:
for pos, pid in enumerate(ordered_ids):
self.conn.execute(
"UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?",
(pos, pid),
)
self.conn.commit()
return True
def clear_playlist_positions(self) -> bool:
"""Drop every manual playlist position → back to alphabetical
(the "Sort AZ" affordance)."""
with self._lock:
self.conn.execute(
"UPDATE playlists SET position = NULL, updated_at = datetime('now') "
"WHERE position IS NOT NULL")
self.conn.commit()
return True
def toggle_saved(self, filename: str) -> bool:
"""Add/remove a song on the Saved-for-Later playlist. Returns new state.
The presence check and the add/remove run under one lock so two
@@ -2810,16 +3006,39 @@ class MetadataDB:
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 +3050,15 @@ class MetadataDB:
"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 +3066,9 @@ class MetadataDB:
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 +3081,14 @@ class MetadataDB:
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 +3568,8 @@ class MetadataDB:
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 +3676,8 @@ class MetadataDB:
"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 +3689,9 @@ class MetadataDB:
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 +3834,32 @@ class MetadataDB:
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 +4127,9 @@ class MetadataDB:
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 +4158,9 @@ class MetadataDB:
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 +4169,14 @@ class MetadataDB:
"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 +4210,15 @@ class MetadataDB:
# 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 +4311,9 @@ class MetadataDB:
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 +4346,30 @@ class MetadataDB:
"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 +4466,7 @@ class MetadataDB:
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 +4487,7 @@ class MetadataDB:
"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 +4502,9 @@ class MetadataDB:
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 +4512,7 @@ class MetadataDB:
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 +4548,7 @@ class MetadataDB:
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 +4581,7 @@ class MetadataDB:
"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 +4603,8 @@ class MetadataDB:
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 +4616,8 @@ class MetadataDB:
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 +4648,9 @@ class MetadataDB:
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 +4677,8 @@ class MetadataDB:
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 +4690,8 @@ class MetadataDB:
"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
+42 -13
View File
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
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")
+31
View File
@@ -70,6 +70,37 @@ def api_create_playlist(data: dict):
return appstate.meta_db.create_playlist(name, kind=kind)
@router.post("/api/playlists/reorder")
def api_reorder_playlists(data: dict):
"""Manual ordering of the playlists themselves (position = index in
`order`); the songs-within sibling is /api/playlists/{pid}/reorder.
System playlists stay pinned first and are not part of the order."""
order = data.get("order")
if not isinstance(order, list) or not all(
isinstance(i, int) and not isinstance(i, bool) for i in order):
return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400)
# Require an exact permutation of the current non-system playlist ids: a
# list with duplicates, omissions, extras, unknown ids, or a system id
# would otherwise produce duplicate positions / a partial reorder while
# still returning 200 (mirrors the songs-within validation).
current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]]
if len(order) != len(current) or sorted(order) != sorted(current):
return JSONResponse(
{"error": "order must be a permutation of your playlists' ids"},
status_code=400,
)
appstate.meta_db.reorder_playlists(order)
return api_list_playlists()
@router.post("/api/playlists/sort-alpha")
def api_sort_playlists_alpha():
"""Clear every manual playlist position → back to the alphabetical
default (system playlists were pinned first either way)."""
appstate.meta_db.clear_playlist_positions()
return api_list_playlists()
@router.get("/api/playlists/{pid}")
def api_get_playlist(pid: int):
pl = appstate.meta_db.get_playlist(pid)
+33 -1
View File
@@ -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 background_scan(force: bool = False):
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"]
):
+59 -1
View File
@@ -27,7 +27,11 @@ import logging
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
+27
View File
@@ -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,
+239 -8
View File
@@ -416,27 +416,258 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
})
return out
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
# 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.
# ── 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
# Standard tunings (all six strings same offset)
# 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:
# 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 (#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}"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.31.5",
"version": "3.32.0",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+16112 -15882
View File
File diff suppressed because it is too large Load Diff
+71 -4
View File
@@ -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 = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
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 = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
@@ -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 =
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
`<span class="flex-1">${esc(label)}</span>` +
`<span class="tuning-count">${t.count}</span>`;
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
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
</div>
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
@@ -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 += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
if (tuningBadge)
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
if (song.has_lyrics)
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
if (song.user_difficulty != null)
+5 -1
View File
@@ -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');
+1 -1
View File
@@ -326,7 +326,7 @@
<section>
<details>
<summary class="cursor-pointer flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-gray-500 mb-2">
<span>Tuning</span>
<span id="filter-tunings-label">Tuning</span>
<span id="filter-tunings-summary" class="text-gray-600 normal-case font-normal text-xs">All tunings</span>
</summary>
<div id="filter-tunings" class="mt-3 space-y-1 max-h-64 overflow-y-auto pr-1"></div>
+236 -4
View File
@@ -53,12 +53,192 @@
return (m && m.index != null) ? m.index : null;
}
// ── Playlist tuning check ────────────────────────────────────────────────
// Playlists are commonly grouped BY TUNING so a practice run needs no
// retune mid-session (retuning a bass is minutes of settling, and detuning
// far on standard gauges goes floppy). A playlist built before the tuning
// filter knew about your instrument can hold songs you can't actually play
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
// playlist — removal is a separate, explicit, itemised action.
// Pick the indexed perspective that matches the player's live instrument.
// #1003 supplies bass-specific columns; when a song has no bass chart we
// deliberately fall back to the historical song-level guitar tuning.
function rowTuningForCheck(s) {
let wantsBass = false;
try {
const wt = window.feedBack && window.feedBack.workingTuning;
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
wantsBass = !!cur && cur.instrument === 'bass';
} catch (_) { /* capability errors degrade to the song-level tuning */ }
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
return {
offsets: hasBassTuning
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
// The selected bass perspective uses bass base pitches. A bass-only
// fallback row does too; every other fallback is the lead chart.
isBass: hasBassTuning || !!s.bass_only,
};
}
// A coverage report says "not covered" BOTH for a real mismatch and for
// "I couldn't work it out" (missing settings/tuner data → an all-empty
// report). Only a report carrying an actual reason — named string changes,
// a reference-pitch gap, or too few strings — is a mismatch. An unexplained
// not-covered is UNKNOWN. A false "wrong tuning" on a hand-curated playlist
// costs more trust than saying nothing.
function tuningStateFromReport(rep) {
if (!rep) return 'unknown';
if (rep.covered) return 'match';
if (rep.cantCover || rep.reference
|| (Array.isArray(rep.retune) && rep.retune.length)) return 'mismatch';
return 'unknown';
}
// Score every row. Returns null when the host exposes no tuning perspective
// at all (no working-tuning capability / no tuner coverage) — the caller
// then renders the playlist exactly as before rather than claiming anything.
async function checkPlaylistTuning(songs) {
const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
const hasWT = window.feedBack && window.feedBack.workingTuning
&& typeof window.feedBack.workingTuning.get === 'function';
if (typeof cov !== 'function' || !hasWT) return null;
const parse = window.parseRawTuningOffsets;
const out = [];
for (const s of songs || []) {
const t = rowTuningForCheck(s);
const offs = (typeof parse === 'function') ? parse(t.offsets) : null;
if (!offs || !offs.length || offs.some((n) => !isFinite(n))) {
out.push({ song: s, state: 'unknown' });
continue;
}
let rep = null;
try {
rep = await cov({
tuning: offs, stringCount: offs.length,
arrangement: t.isBass ? 'Bass' : 'Lead',
});
} catch (_) { rep = null; }
out.push({ song: s, state: tuningStateFromReport(rep) });
}
return out;
}
// Colour + a TEXT marker per state — unknown is deliberately neutral-and-
// dimmed rather than amber, because "I couldn't check this" is a different
// claim from "this is the wrong tuning" and must not read as the latter.
function paintTuningChip(chip, state) {
if (!chip) return;
if (chip.dataset.baseTitle == null) chip.dataset.baseTitle = chip.getAttribute('title') || '';
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400', 'opacity-60');
chip.classList.add(state === 'match' ? 'bg-emerald-500'
: state === 'mismatch' ? 'bg-amber-400' : 'bg-fb-mid');
if (state === 'unknown') chip.classList.add('opacity-60');
chip.setAttribute('title', chip.dataset.baseTitle + (state === 'match'
? ' — matches your tuning'
: state === 'mismatch' ? ' — needs a retune'
: ' — no tuning data, not checked'));
// Never signal by colour alone.
const mark = state === 'mismatch' ? ' ⚠' : state === 'unknown' ? ' ?' : '';
let m = chip.querySelector('[data-tuning-mark]');
if (!m) {
m = document.createElement('span');
m.setAttribute('data-tuning-mark', '');
chip.appendChild(m);
}
m.textContent = mark;
}
function tuningSummaryHtml(results) {
const total = results.length;
if (!total) return '';
const mism = results.filter((r) => r.state === 'mismatch').length;
const unk = results.filter((r) => r.state === 'unknown').length;
// Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
// in the committed tailwind.min.css, and regenerating it is not
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
// bytes), so the summary bar stays within the shipped class set.
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
if (!mism) {
return '<div class="' + box + 'border-fb-good/40 bg-fb-good/30 text-fb-good">' +
'<span>✓ All ' + total + ' songs are in your tuning.</span>' +
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data).</span>' : '') +
'</div>';
}
return '<div class="' + box + 'border-amber-400/40 bg-amber-400/10 text-fb-text">' +
'<span><strong>' + mism + '</strong> of ' + total + ' songs aren\'t in your tuning.</span>' +
(unk ? '<span class="text-fb-textDim text-xs">' + unk + ' couldn\'t be checked (no tuning data) — left alone.</span>' : '') +
'<span class="flex-1"></span>' +
'<button id="v3-pl-tune-only" class="text-xs px-2 py-1 rounded border border-fb-border text-fb-textDim hover:text-fb-text" aria-pressed="false">Show only these</button>' +
'<button id="v3-pl-tune-remove" class="text-xs px-2 py-1 rounded border border-amber-400/40 text-fb-text hover:bg-fb-card">Remove them…</button>' +
'</div>';
}
// Run the check and wire its affordances. Read-only: the only mutation is
// the explicit, itemised, confirmed removal below.
async function applyTuningCheck(root, pl, pid, rerender) {
const host = root.querySelector('#v3-pl-tuning');
const listEl = root.querySelector('#v3-pl-songs');
if (!host || !listEl) return;
const results = await checkPlaylistTuning(pl.songs);
if (!results) return; // no perspective → say nothing
const rows = listEl.querySelectorAll('li[data-fn]');
results.forEach((r, i) => {
const li = rows[i];
if (!li) return;
li.setAttribute('data-tuning-state', r.state);
paintTuningChip(li.querySelector('[data-tuning-chip]'), r.state);
});
host.innerHTML = tuningSummaryHtml(results);
const onlyBtn = host.querySelector('#v3-pl-tune-only');
onlyBtn?.addEventListener('click', () => {
const on = onlyBtn.getAttribute('aria-pressed') !== 'true';
onlyBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
onlyBtn.textContent = on ? 'Show all' : 'Show only these';
rows.forEach((li) => {
li.classList.toggle('hidden', on && li.getAttribute('data-tuning-state') !== 'mismatch');
});
});
host.querySelector('#v3-pl-tune-remove')?.addEventListener('click', async () => {
// Name every song BEFORE removing anything — a curated playlist is
// user data, so the confirm has to be a list, not a count.
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
if (!doomed.length) return;
const names = doomed.map((s) => '<div>• ' + esc(s.title || s.filename) + '</div>').join('');
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
+ ' from "' + esc(pl.name) + '"?'
// Bulleted with a literal •, and sized with max-h-32, so the
// confirm needs no Tailwind class the committed CSS lacks —
// regenerating tailwind.min.css is not reproducible off CI.
+ '<div class="mt-2 text-xs max-h-32 overflow-y-auto">' + names + '</div>'
+ '<p class="text-xs text-fb-textDim mt-2">They stay in your library — only this playlist changes, and you can add them back.</p>';
const ok = (typeof window.uiConfirm === 'function')
? await window.uiConfirm({
title: 'Remove mismatched songs?', html: msg,
confirmText: 'Remove ' + doomed.length, cancelText: 'Cancel', danger: true,
})
: window.confirm('Remove ' + doomed.length + ' song(s) from "' + pl.name + '"?\n\n'
+ doomed.map((s) => '• ' + (s.title || s.filename)).join('\n')
+ '\n\nThey stay in your library.');
if (!ok) return;
for (const s of doomed) {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(s.filename),
{ method: 'DELETE' });
}
rerender();
});
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>' : '';
// The chip carries its own tuning so the post-paint check can colour it
// in place (green = play it now, amber = needs a retune, dimmed ? =
// couldn't tell) without re-rendering the list.
const tuning = s.tuning_name
? '<span class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm">' + esc(s.tuning_name) + '</span>' : '';
? '<span data-tuning-chip class="ml-2 text-[0.625rem] bg-fb-mid text-black font-bold px-1.5 py-0.5 rounded-sm" title="' + esc(s.tuning_name) + '">' + esc(s.tuning_name) + '</span>' : '';
// ── Curated-album slot extras (P6) — mixes/saved emit none of this ──
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
// the work's current keeper when the pinned file is gone) with its
@@ -144,19 +324,29 @@
const root = document.getElementById('v3-playlists');
if (!root) return;
const lists = (await jget('/api/playlists')) || [];
// Drag-to-reorder is for user playlists only — system ones (Saved for
// Later) stay pinned first by the server ordering.
const userCount = lists.filter((p) => !p.system_key).length;
root.innerHTML =
'<div class="max-w-5xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="flex items-center justify-end gap-2 mb-6">' +
// Sort AZ: clears the manual (drag) order server-side. Only worth
// showing once there are two user playlists to order.
(userCount > 1
? '<button id="v3-pl-sort-az" title="Sort playlists alphabetically (clears manual order)" class="text-sm text-fb-textDim hover:text-fb-text px-2">Sort AZ</button>' : '') +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track — same machinery as a playlist, kind='album'.
'<button id="v3-pl-new-album" title="A hand-picked, ordered set of songs — your version of an album, with your chosen chart per track" class="bg-fb-card/80 hover:bg-fb-card border border-fb-border/60 text-fb-text px-4 py-2 rounded-md text-sm font-medium">💿 New album</button>' +
'<button id="v3-pl-new" class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm font-medium shadow-lg shadow-fb-primary/20">New playlist</button>' +
'</div>' +
(lists.length
? '<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '" class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
? '<div id="v3-pl-grid" class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">' + lists.map((p) =>
'<button data-pl="' + p.id + '"' + (p.system_key ? '' : ' draggable="true"') + ' class="text-left bg-fb-card/80 backdrop-blur rounded-xl p-4 border border-fb-border/50 hover:border-fb-primary/40 transition">' +
playlistCoverHtml(p) +
'<div class="text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</div>' +
'<div class="flex items-center gap-1">' +
'<span class="flex-1 min-w-0 text-sm font-medium text-fb-text truncate">' + esc(p.name) + '</span>' +
(p.system_key ? '' : '<span class="cursor-grab text-fb-textDim/60 px-1" title="Drag to reorder">⠿</span>') +
'</div>' +
'<div class="text-xs text-fb-textDim">' + (p.kind === 'album' ? '💿 Album · ' : '') + p.count + ' song' + (p.count === 1 ? '' : 's') + '</div>' +
'</button>').join('') + '</div>'
: '<p class="text-fb-textDim">No playlists yet. Create one to group songs.</p>') +
@@ -173,8 +363,44 @@
await jsend('POST', '/api/playlists', { name, kind: 'album' });
renderPlaylists();
});
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
await jsend('POST', '/api/playlists/sort-alpha');
renderPlaylists();
});
root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
// Only user playlists carry draggable="true"; system cards are neither
// drag sources nor drop targets, so nothing can be inserted ahead of
// them (and the server pins them first regardless).
const grid = root.querySelector('#v3-pl-grid');
if (grid) {
let dragEl = null;
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragEl || dragEl === card) return;
// Grid tiles flow left→right then wrap, so the insert side
// is horizontal (the song rows' vertical-midpoint idiom,
// rotated); moving to another row targets that row's cards.
const rect = card.getBoundingClientRect();
const after = (e.clientX - rect.left) > rect.width / 2;
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
await jsend('POST', '/api/playlists/reorder', { order });
// Re-sync from the server: if /reorder was rejected
// (concurrent change) or the request failed, the optimistic
// DOM order would otherwise diverge from what persisted.
renderPlaylists();
});
});
}
}
async function renderPlaylistDetail(pid) {
@@ -226,6 +452,9 @@
'</div>' +
'</div>' +
meter +
// Filled in after paint by applyTuningCheck (async, feature-detected)
// — stays empty when the host exposes no tuning perspective.
(pl.songs.length ? '<div id="v3-pl-tuning"></div>' : '') +
(pl.songs.length
? '<ul id="v3-pl-songs" class="space-y-1">' + pl.songs.map((s) => songRow(s, { draggable: !isSystem, album: isAlbum, acc: isAlbum ? slotAcc(s) : undefined })).join('') + '</ul>'
: '<p class="text-fb-textDim">Empty — add songs from the library' + (isAlbum ? ' (the ⋮ menu or the batch bar\'s "Add to playlist")' : '') + '.</p>') +
@@ -275,6 +504,9 @@
});
const listEl = root.querySelector('#v3-pl-songs');
if (listEl) wireSongRows(listEl, pid, () => renderPlaylistDetail(pid));
// Post-paint so the list is interactive immediately; a per-song coverage
// call can await the tuner plugin's settings fetch.
if (listEl) applyTuningCheck(root, pl, pid, () => renderPlaylistDetail(pid));
// Album slot editor (▾ per row): pick the slot's chart + arrangement.
if (listEl && isAlbum) {
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
+199 -20
View File
@@ -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 = '<span class="' + pos + ' bg-fb-mid text-black text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight max-w-[5.5rem] text-center"' + matchAttr + ' title="' + esc(badgeTitle) + '">'
+ esc('Custom Tuning') + '<br><span class="font-semibold tracking-wide">' + esc(targetNotes) + '</span></span>';
} else {
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + '</span>';
tuning = '<span class="' + pos + ' bg-fb-mid text-black text-[0.625rem] font-bold px-1.5 py-0.5 rounded-sm"' + matchAttr + ' title="' + esc(badgeTitle) + '">' + esc(tuningLabel) + (inferred ? '<span class="opacity-60"> ~</span>' : '') + '</span>';
}
}
// 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 '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '">' + mark + esc(label) + '</button>';
const tip = title ? ' title="' + esc(title) + '"' : '';
return '<button data-tri="' + group + '" data-val="' + esc(value) + '" class="px-2 py-1 rounded-md text-xs border ' + cls + '"' + tip + '>' + mark + esc(label) + '</button>';
}
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) => '<button data-match="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.match.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
// Genre facet — dynamic list from /api/library/genres (primary genre).
(state.genres && state.genres.length ? section('Genre', state.genres.map((g) => '<button data-genre="' + esc(g) + '" class="px-2 py-1 rounded-md text-xs border ' + (f.genre.includes(g) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + esc(g) + '</button>').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.
'<div class="flex gap-1 mb-2">'
+ [['exact', 'Exact tuning'], ['playable', 'Playable without retuning']].map((m) => {
const on = (f.tuningMatch || 'exact') === m[0];
const dis = m[0] === 'playable' && !playableAvailable();
return '<button data-tuning-match="' + m[0] + '"'
+ (dis ? ' disabled' : '')
+ (dis ? ' title="Needs your current tuning — open the tuner first"' : '')
+ ' class="px-2 py-1 rounded-md text-xs border '
+ (on ? 'bg-fb-primary text-white border-fb-primary'
: 'bg-gray-800/50 text-fb-textDim border-gray-700')
+ (dis ? ' opacity-40 cursor-not-allowed' : '') + '">'
+ esc(m[1]) + '</button>';
}).join('')
+ '</div>'
+ (f.tuningMatch === 'playable'
? '<div class="text-xs text-fb-textDim mb-2">Charts you can play in your current tuning, no retune. Songs whose lowest string sits below yours are excluded.</div>'
: '')
+ ((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('') || '<span class="text-xs text-fb-textDim">No tunings</span>') +
// 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('') || '<span class="text-xs text-fb-textDim">No tunings</span>')) +
// 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]) => '<option value="' + esc(v) + '"' + (v === sel ? ' selected' : '') + '>' + esc(l) + '</option>').join('');
@@ -3512,7 +3668,13 @@
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
// 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.
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(
SORTS.map(([v, l]) => [v, v === 'tuning' ? l + ' (' + libInstrumentLabel() + ')' : l]),
state.sort) + '</select>' +
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
@@ -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());
+480 -461
View File
@@ -1,461 +1,480 @@
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
//
// Three.js renders transparent objects by renderOrder first, then back-to-front
// Z sort within the same renderOrder. All 3D-highway materials use depthTest:false, so
// renderOrder is the *only* draw-order control — getting it wrong silently
// causes one layer to bleed through another (gems clipping through chord frames,
// strings buried under notes, etc.).
//
// Full hierarchy bottom → top:
//
// -1 background stage traversal
// 1 lane quads
// 2 fret dividers
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
// 7 string-line glows (in-lane glow lines)
// 14 board-projection frame
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
// [techniqueMarkerRenderOrder] technique markers
// [after board wire layers] note fret labels, above gem symbols and fret wires
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
// 1000 technique labels, ghost-fret overlay
//
// Tests are source-level regex checks — no need to load Three.js or a DOM.
//
// Any PR that changes a renderOrder value must update the relevant test(s) here
// and provide a visual justification in the PR description.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let _src;
/** Returns the cached 3D highway screen source under test. */
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src;
}
/** Parses the declared render-order layer stack from screen.js. */
function layers() {
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
}
/** Returns the position of a named layer in the render-order stack. */
function layerIndex(name) {
const ordered = layers();
const idx = ordered.indexOf(name);
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
return idx;
}
/** Reads the render-order base used for objects at z = 0. */
function zZeroRenderOrder() {
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
return Number(match[1]);
}
// ---------------------------------------------------------------------------
// Static / fixed renderOrder values
// ---------------------------------------------------------------------------
test('lane quads use renderOrder 1', () => {
assert.match(
src(),
/lane\.renderOrder\s*=\s*1\s*;/,
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
);
});
test('fret dividers use renderOrder 2', () => {
assert.match(
src(),
/div\.renderOrder\s*=\s*2\s*;/,
'fret dividers must use renderOrder = 2, above lane (1)',
);
});
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
// The translucent lane would otherwise paint over and hide the inlay.
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
assert.match(
src(),
/d\.renderOrder\s*=\s*3\s*;/,
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
);
});
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
// min=44) so chord interiors don't disappear behind glow overdraw.
assert.match(
src(),
/line\.renderOrder\s*=\s*7\s*;/,
'string glow lines must use renderOrder = 7',
);
});
test('board-projection frame mesh uses renderOrder 14', () => {
// The fretboard projection plane sits above string glows (7) but below
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
// so the assertion only passes when THAT block seeds renderOrder = 14 —
// not any unrelated renderOrder = 14 elsewhere in the source.
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
assert.match(
src(),
boardProjRO,
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
);
const boardMatch = src().match(boardProjRO);
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
});
test('string mesh in buildBoard uses the named board-string layer', () => {
// The physical string cylinders/planes rendered on the fretboard sit above
// the note-gem layers but below fret wires.
assert.match(
src(),
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
'buildBoard string mesh must use BOARD_STRING',
);
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
});
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, default gray 0x666688', () => {
// Fret wires are a single shared, bowed TubeGeometry (backported from
// highway_babylon): a CatmullRom curve whose middle pushes away from the
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
// across the rounded surface (gold in-anchor → brass). depthTest:false is
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
// depth test at string pixels despite the higher layer; depthWrite:false
// keeps the transparent fret from polluting depth for later overlays.
const s = src();
assert.match(
s,
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
);
assert.match(
s,
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
);
assert.match(
s,
/FRET_BOW_DZ\s*\*\s*zm/,
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
);
assert.match(
s,
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
);
assert.match(
s,
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
);
assert.match(
s,
/new\s+T\.MeshStandardMaterial\(/,
'fret wires must use MeshStandardMaterial so scene light shades the metal',
);
assert.match(
s,
/color\s*:\s*0x666688/,
'fret wire material must have default gray color 0x666688',
);
// Both depth flags asserted independently so the test doesn't pin property
// order in the material literal.
assert.match(
s,
/depthTest\s*:\s*false/,
'fret wire material must set depthTest: false',
);
assert.match(
s,
/depthWrite\s*:\s*false/,
'fret wire material must set depthWrite: false (no z-buffer pollution)',
);
assert.match(
s,
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
'buildBoard must store each wire material in fretWireMats[f]',
);
});
test('update() sets fret wire gold (0xD8A636) for in-anchor frets, gray (0x666688) otherwise', () => {
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
// so fret wire highlight aligns exactly with the lane edges:
// dMin = fret - 1, dMax = fret + width - 1
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
const s = src();
assert.match(
s,
/fretWireMats\.length/,
'update() must guard the per-frame fret wire loop on fretWireMats.length',
);
assert.match(
s,
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
);
assert.match(
s,
/_m\.color\.setHex\(\s*0xD8A636\s*\)/,
'update() must set gold 0xD8A636 for in-anchor fret wires',
);
assert.match(
s,
/_m\.color\.setHex\(\s*0x666688\s*\)/,
'update() must set gray 0x666688 for out-of-anchor fret wires',
);
assert.match(
s,
/_fwBounds\.dMin/,
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
);
assert.match(
s,
/_fwBounds\.dMax/,
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
);
});
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
// pFretColMarker labels use the named stack: one step above chord frame
// and one step below note gems at the same depth.
// This ensures chord frame borders never overdraw the label and the label
// never overdraws gems, at every Z position across the lookahead window.
assert.match(
src(),
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
);
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
});
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
// 1000 is well above the entire Z-proportional range and the
// string/cadence layer — labels must always be readable.
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
assert.ok(
matches.length >= 2,
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
);
});
// ---------------------------------------------------------------------------
// Z-proportional formulas — chord frame / note gem / technique marker
// ---------------------------------------------------------------------------
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
// layer from RENDER_ORDER_LAYER_STACK.
assert.match(
src(),
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
);
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
// Layer is a sub-unit fraction so the integer depth bucket strictly
// dominates (a farther object can't outrank a nearer one via a higher
// layer); the layer only breaks ties within the same depth bucket.
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
// the near render-order base plus its layer index; far notes clamp to the
// far render-order base plus that same layer index.
// The ordered layer list keeps gems above chord frames everywhere.
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
);
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
});
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
// the gem itself.
assert.match(
src(),
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
);
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
});
// ---------------------------------------------------------------------------
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
// ---------------------------------------------------------------------------
test('chord fill interior uses the named layer below chord frame', () => {
// The translucent chord-box fill sits below the frame edge so the edge
// always wins when both cover the same pixel.
assert.match(
src(),
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
'chord fill must use CHORD_FILL',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
});
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
// The black background fill of the muted-note X symbol is above chord fill
// but below the X lines — same chord, so same chord-frame renderOrder base.
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
});
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
// The coloured X stroke lines are above the black fill but below
// the chord frame border edge, so they don't escape the box.
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
});
test('chord frame glow uses the layer after chord frame', () => {
// Accent glow draws after the frame while still remaining below connectors
// and note symbols in the ordered layer list.
assert.match(
src(),
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
'chord frame edge slabs must use CHORD_EDGE_GLOW',
);
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
});
// ---------------------------------------------------------------------------
// Sustain-trail strip & ribbon — always below chord frame of same depth
// ---------------------------------------------------------------------------
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
// Sustain trails use the ordered layer immediately below chord frames at
// the same depth.
assert.match(
src(),
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
);
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
});
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
// same Z scale as dZ() on the sustain-trail layer.
assert.match(
src(),
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
);
});
// ---------------------------------------------------------------------------
// Note gem ordering (outline < core, both driven by named depth layers)
// ---------------------------------------------------------------------------
test('note gem outline uses the named outline layer', () => {
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note gem outline must use NOTE_OUTLINE',
);
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
});
test('note gem core uses the named layer above outline', () => {
assert.match(
src(),
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
'note gem core must use NOTE_CORE',
);
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
});
// ---------------------------------------------------------------------------
// Key relative-ordering invariants (derived constants)
// ---------------------------------------------------------------------------
test('chord frame layer is below note outline layer', () => {
// Chord frames must always render below note gems, even at maximum depth
// (far end of the lookahead).
//
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('fret labels are above note symbols in the named stack', () => {
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
});
test('string mesh layer is above note symbols and below labels', () => {
// Board strings are never occluded by flying gems, but labels still appear above strings.
const s = src();
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
// Confirm 1000 also exists (labels above strings)
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
});
test('fret-column marker layer is above chord frame and below gem outline', () => {
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
});
test('static fret wire layer is above string mesh and note symbols', () => {
// Structural invariant: fret wires must always draw after (on top of) strings.
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
});
// Pins the renderOrder hierarchy in plugins/highway_3d/screen.js.
//
// Three.js renders transparent objects by renderOrder first, then back-to-front
// Z sort within the same renderOrder. Nearly all 3D-highway materials use
// depthTest:false (exceptions exist — e.g. the accent halo mats set
// depthTest:true), so renderOrder is the primary draw-order control — getting it wrong silently
// causes one layer to bleed through another (gems clipping through chord frames,
// strings buried under notes, etc.).
//
// Full hierarchy bottom → top:
//
// -1 background stage traversal
// 1 lane quads
// 2 fret dividers
// 3 fret inlay dots (above the lane so it no longer hides them)
// 4 sus-rail bloom (pSusRailBloom seed) ← highway_3d_sustain_bloom.test.js
// 5 sus-rail core (pSusRail seed) ← highway_3d_sustain_rail.test.js
// 7 string-line glows (in-lane glow lines)
// 14 board-projection frame
// [renderOrderForLayerAtZ(z, FRET_COLUMN)] fret-column markers (pFretColMarker) — between chord frame and gem
// [layered below chordFrameRenderOrder] chord fill / PM-FH fill / PM-FH lines
// [chordFrameRenderOrder] chord frame edges = renderOrderForLayerAtZ(z, CHORD_FRAME)
// [layered above chordFrameRenderOrder] chord-frame glow, connector/drop lines
// [below chordFrameRenderOrder] sustain-trail strip segments (Z-proportional, always < frame)
// [renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)] note gem outline
// [renderOrderForLayerAtZ(noteZ, NOTE_CORE)] note gem core
// [techniqueMarkerRenderOrder] technique markers
// [after board wire layers] note fret labels, above gem symbols and fret wires
// [renderOrderForLayerAtZ(0, BOARD_STRING)] string mesh (drawn over gems but under fret wires)
// [renderOrderForLayerAtZ(0, BOARD_FRET_WIRE)] static fret wires (above strings, as on a real guitar)
// 1000 technique labels, ghost-fret overlay
//
// Tests are source-level regex checks — no need to load Three.js or a DOM.
//
// Any PR that changes a renderOrder value must update the relevant test(s) here
// and provide a visual justification in the PR description.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
let _src;
/** Returns the cached 3D highway screen source under test. */
function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src;
}
/** Parses the declared render-order layer stack from screen.js. */
function layers() {
const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
}
/** Returns the position of a named layer in the render-order stack. */
function layerIndex(name) {
const ordered = layers();
const idx = ordered.indexOf(name);
assert.ok(idx !== -1, `${name} must be present in RENDER_ORDER_LAYER_STACK`);
return idx;
}
/** Reads the render-order base used for objects at z = 0. */
function zZeroRenderOrder() {
const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
return Number(match[1]);
}
// ---------------------------------------------------------------------------
// Static / fixed renderOrder values
// ---------------------------------------------------------------------------
test('lane quads use renderOrder 1', () => {
assert.match(
src(),
/lane\.renderOrder\s*=\s*1\s*;/,
'lane quads must use renderOrder = 1 (bottom-most visible layer)',
);
});
test('fret dividers use renderOrder 2', () => {
assert.match(
src(),
/div\.renderOrder\s*=\s*2\s*;/,
'fret dividers must use renderOrder = 2, above lane (1)',
);
});
test('fret inlay dots use renderOrder 3, above lane (1) and dividers (2)', () => {
// The translucent lane would otherwise paint over and hide the inlay.
// The dots must draw after the lane/dividers but stay below the depth-layer stack.
assert.match(
src(),
/d\.renderOrder\s*=\s*3\s*;/,
'fret inlay dots must use renderOrder = 3 so the lane no longer hides them',
);
});
test('string-line glows use renderOrder 7, above sus-rails (4/5)', () => {
// The in-lane string glow lines sit at 7 — above sus-rail bloom (4) and
// core (5) so the glow is visible, but below chord fill (chordFrameRenderOrder-4,
// min=44) so chord interiors don't disappear behind glow overdraw.
assert.match(
src(),
/line\.renderOrder\s*=\s*7\s*;/,
'string glow lines must use renderOrder = 7',
);
});
test('board-projection frame mesh uses renderOrder 14', () => {
// The fretboard projection plane sits above string glows (7) but below
// chord fill (min 44). Value 14 keeps it sandwiched cleanly.
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
// so the assertion only passes when THAT block seeds renderOrder = 14 —
// not any unrelated renderOrder = 14 elsewhere in the source.
const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
assert.match(
src(),
boardProjRO,
'board-projection pool (projMeshArr) must seed meshes with renderOrder = 14',
);
const boardMatch = src().match(boardProjRO);
assert.ok(boardMatch, 'board projection mesh must be assigned renderOrder = 14');
});
test('string mesh in buildBoard uses the named board-string layer', () => {
// The physical string cylinders/planes rendered on the fretboard sit above
// the note-gem layers but below fret wires.
assert.match(
src(),
/mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/,
'buildBoard string mesh must use BOARD_STRING',
);
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'));
assert.ok(layerIndex('BOARD_STRING') < layerIndex('BOARD_FRET_WIRE'));
});
test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named board-fret-wire layer, depthTest+depthWrite false, idle tier FRET_WIRE_IDLE_HEX', () => {
// Fret wires are a single shared, bowed TubeGeometry (backported from
// highway_babylon): a CatmullRom curve whose middle pushes away from the
// camera by FRET_BOW_DZ so the row of frets reads as wrapping a cylindrical
// neck. T.Line is avoided — WebGL ignores linewidth > 1px so a Line always
// renders as a hairline. The lit MeshStandardMaterial lets scene light glint
// across the rounded surface (gold in-anchor → brass). depthTest:false is
// required: the string BoxGeometry (MeshStandardMaterial, depthWrite:true)
// writes depth at Z = +STR_THICK/2, so fret wires near Z=0 would fail the
// depth test at string pixels despite the higher layer; depthWrite:false
// keeps the transparent fret from polluting depth for later overlays.
const s = src();
assert.match(
s,
/new\s+T\.TubeGeometry\(\s*tubeCurve\s*,\s*FRET_TUBE_SEG\s*,\s*FRET_TUBE_RADIUS\s*,\s*FRET_TUBE_RADIAL\s*,\s*false\s*,?\s*\)/,
'buildBoard fret wires must use a TubeGeometry built from tubeCurve + FRET_TUBE_* params',
);
assert.match(
s,
/new\s+T\.CatmullRomCurve3\(\s*tubePath\s*\)/,
'buildBoard fret tube must follow a CatmullRomCurve3 through the bowed path',
);
assert.match(
s,
/FRET_BOW_DZ\s*\*\s*zm/,
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
);
assert.match(
s,
/new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
);
assert.match(
s,
/fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/,
'buildBoard fret wire mesh must use BOARD_FRET_WIRE',
);
assert.match(
s,
/new\s+T\.MeshStandardMaterial\(/,
'fret wires must use MeshStandardMaterial so scene light shades the metal',
);
// The wire tiers moved to named constants (feedBack#969): idle is the
// dimmed 0x4A4A60 so the neck recedes and the anchor lane reads as the
// focus cue. Assert the material uses the constant AND pin the constant's
// value, so a retune is a deliberate two-line change here.
assert.match(
s,
/color\s*:\s*FRET_WIRE_IDLE_HEX/,
'fret wire material must take its default color from FRET_WIRE_IDLE_HEX',
);
assert.match(
s,
/FRET_WIRE_IDLE_HEX\s*=\s*0x4A4A60/,
'FRET_WIRE_IDLE_HEX must be the dimmed idle gray-violet 0x4A4A60',
);
// Both depth flags anchored to the fret-wire material literal (via its
// FRET_WIRE_IDLE_HEX color, unique to it) — an unscoped match would pass
// off any other depthTest:false material in the file. Asserted as two
// separate anchored matches so property order inside the literal still
// isn't pinned.
assert.match(
s,
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthTest\s*:\s*false/,
'the fret wire material itself must set depthTest: false',
);
assert.match(
s,
/color\s*:\s*FRET_WIRE_IDLE_HEX[\s\S]{0,400}?depthWrite\s*:\s*false/,
'the fret wire material itself must set depthWrite: false (no z-buffer pollution)',
);
assert.match(
s,
/fretWireMats\s*\[\s*f\s*\]\s*=\s*mat\s*;/,
'buildBoard must store each wire material in fretWireMats[f]',
);
});
test('update() sets fret wire FRET_WIRE_ACTIVE_HEX (gold) for in-anchor frets, FRET_WIRE_IDLE_HEX otherwise', () => {
// Uses anchorLaneBoundsAt() — the same helper the dynamic lane uses —
// so fret wire highlight aligns exactly with the lane edges:
// dMin = fret - 1, dMax = fret + width - 1
// Example: { fret: 3, width: 4 } → dMin=2, dMax=6 → wires 2..6 gold.
const s = src();
assert.match(
s,
/fretWireMats\.length/,
'update() must guard the per-frame fret wire loop on fretWireMats.length',
);
assert.match(
s,
/anchorLaneBoundsAt\(\s*anchors\s*,\s*now\s*\)/,
'update() must use anchorLaneBoundsAt(anchors, now) to get fret wire range',
);
assert.match(
s,
/_m\.color\.setHex\(\s*FRET_WIRE_ACTIVE_HEX\s*\)/,
'update() must set FRET_WIRE_ACTIVE_HEX for in-anchor fret wires',
);
assert.match(
s,
/FRET_WIRE_ACTIVE_HEX\s*=\s*0xD8A636/,
'FRET_WIRE_ACTIVE_HEX must stay the anchor-lane gold 0xD8A636',
);
assert.match(
s,
/_m\.color\.setHex\(\s*FRET_WIRE_IDLE_HEX\s*\)/,
'update() must set FRET_WIRE_IDLE_HEX for out-of-anchor fret wires',
);
assert.match(
s,
/_fwBounds\.dMin/,
'update() must use dMin from anchorLaneBoundsAt (= fret - 1)',
);
assert.match(
s,
/_fwBounds\.dMax/,
'update() must use dMax from anchorLaneBoundsAt (= fret + width - 1)',
);
});
test('fret-column markers use Z-proportional renderOrder between chord frame and gem', () => {
// pFretColMarker labels use the named stack: one step above chord frame
// and one step below note gems at the same depth.
// This ensures chord frame borders never overdraw the label and the label
// never overdraws gems, at every Z position across the lookahead window.
assert.match(
src(),
/sp\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)\s*;/,
'pFretColMarker renderOrder must use renderOrderForLayerAtZ(z, FRET_COLUMN)',
);
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'));
});
test('technique labels and ghost-fret overlay use renderOrder 1000', () => {
// 1000 is well above the entire Z-proportional range and the
// string/cadence layer — labels must always be readable.
const matches = src().match(/m\.renderOrder\s*=\s*1000\s*;/g) || [];
assert.ok(
matches.length >= 2,
'at least two renderOrder = 1000 assignments must exist (technique labels + ghost fret)',
);
});
// ---------------------------------------------------------------------------
// Z-proportional formulas — chord frame / note gem / technique marker
// ---------------------------------------------------------------------------
test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () => {
// Per-chord frame renderOrder mirrors the note-gem scale with an earlier
// layer from RENDER_ORDER_LAYER_STACK.
assert.match(
src(),
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
);
assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
// Layer is a sub-unit fraction so the integer depth bucket strictly
// dominates (a farther object can't outrank a nearer one via a higher
// layer); the layer only breaks ties within the same depth bucket.
assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('note outline uses renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)', () => {
// Per-note gem renderOrder. noteZ is negative (ahead of hit line → negative
// Z in world space). At noteZ=0 (on the hit line), the note outline uses
// the near render-order base plus its layer index; far notes clamp to the
// far render-order base plus that same layer index.
// The ordered layer list keeps gems above chord frames everywhere.
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note outline must use renderOrderForLayerAtZ(noteZ, NOTE_OUTLINE)',
);
assert.strictEqual(layerIndex('CHORD_FILL'), 0);
});
test('techniqueMarkerRenderOrder uses the named technique marker layer above gem core', () => {
// Technique markers (PM cross, bend arrow, H/P chevron, etc.) must overlay
// the gem itself.
assert.match(
src(),
/const\s+techniqueMarkerRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'TECHNIQUE_MARKER'\s*\)/,
'techniqueMarkerRenderOrder must use TECHNIQUE_MARKER',
);
assert.ok(layerIndex('TECHNIQUE_MARKER') > layerIndex('NOTE_CORE'));
});
// ---------------------------------------------------------------------------
// Intra-chord layering (chord fill < PM/FH fill < PM/FH lines < frame edge)
// ---------------------------------------------------------------------------
test('chord fill interior uses the named layer below chord frame', () => {
// The translucent chord-box fill sits below the frame edge so the edge
// always wins when both cover the same pixel.
assert.match(
src(),
/fill\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FILL'\s*\)\s*;/,
'chord fill must use CHORD_FILL',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_FRAME'));
});
test('PM/FH X fill (pPMXFill / pFHXFill) uses its ordered layer', () => {
// The black background fill of the muted-note X symbol is above chord fill
// but below the X lines — same chord, so same chord-frame renderOrder base.
const matches = src().match(/xf\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_FILL'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-fill meshes must use CHORD_STRUM_FILL (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_FILL') < layerIndex('CHORD_STRUM_FILL'));
assert.ok(layerIndex('CHORD_STRUM_FILL') < layerIndex('CHORD_STRUM_LINE'));
});
test('PM/FH X lines (pMuteXLines / pFHXLines) use their ordered layer', () => {
// The coloured X stroke lines are above the black fill but below
// the chord frame border edge, so they don't escape the box.
const matches = src().match(/xl\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_STRUM_LINE'\s*\)\s*;/g) || [];
assert.ok(
matches.length >= 2,
'both PM and FH X-line meshes must use CHORD_STRUM_LINE (found ' + matches.length + ')',
);
assert.ok(layerIndex('CHORD_STRUM_LINE') < layerIndex('CHORD_FRAME'));
});
test('chord frame glow uses the layer after chord frame', () => {
// Accent glow draws after the frame while still remaining below connectors
// and note symbols in the ordered layer list.
assert.match(
src(),
/b\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_EDGE_GLOW'\s*\)\s*;/,
'chord frame edge slabs must use CHORD_EDGE_GLOW',
);
assert.ok(layerIndex('CHORD_EDGE_GLOW') > layerIndex('CHORD_FRAME'));
assert.ok(layerIndex('CHORD_EDGE_GLOW') < layerIndex('CONNECTOR_LINE'));
});
// ---------------------------------------------------------------------------
// Sustain-trail strip & ribbon — always below chord frame of same depth
// ---------------------------------------------------------------------------
test('sus-trail strip renderOrder formula keeps trails strictly below chord frames at same Z', () => {
// Sustain trails use the ordered layer immediately below chord frames at
// the same depth.
assert.match(
src(),
/const\s+trailRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*Math\.min\(\s*0\s*,\s*zCenter\s*\)\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail strip renderOrder must use renderOrderForLayerAtZ(min zCenter, SUSTAIN_TRAIL)',
);
assert.ok(layerIndex('SUSTAIN_TRAIL') < layerIndex('CHORD_FRAME'));
});
test('sus-trail ribbon renderOrder formula mirrors strip formula using time-based depth', () => {
// Ribbons use _ribDt (time from now to ribbon midpoint) converted to the
// same Z scale as dZ() on the sustain-trail layer.
assert.match(
src(),
/const\s+ribbonRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*-\s*_ribDt\s*\*\s*TS\s*,\s*'SUSTAIN_TRAIL'\s*\)\s*;/,
'sus-trail ribbon renderOrder must use renderOrderForLayerAtZ on the sustain-trail layer',
);
});
// ---------------------------------------------------------------------------
// Note gem ordering (outline < core, both driven by named depth layers)
// ---------------------------------------------------------------------------
test('note gem outline uses the named outline layer', () => {
assert.match(
src(),
/outline\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_OUTLINE'\s*\)\s*;/,
'note gem outline must use NOTE_OUTLINE',
);
assert.ok(layerIndex('NOTE_OUTLINE') > layerIndex('FRET_COLUMN'));
});
test('note gem core uses the named layer above outline', () => {
assert.match(
src(),
/core\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*noteZ\s*,\s*'NOTE_CORE'\s*\)\s*;/,
'note gem core must use NOTE_CORE',
);
assert.ok(layerIndex('NOTE_CORE') > layerIndex('NOTE_OUTLINE'));
});
// ---------------------------------------------------------------------------
// Key relative-ordering invariants (derived constants)
// ---------------------------------------------------------------------------
test('chord frame layer is below note outline layer', () => {
// Chord frames must always render below note gems, even at maximum depth
// (far end of the lookahead).
//
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
});
test('fret labels are above note symbols in the named stack', () => {
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('NOTE_CORE'), 'note fret labels must draw above gem core');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('TECHNIQUE_MARKER'), 'note fret labels must draw above technique markers');
assert.ok(layerIndex('ARP_NOTE_FRET_LABEL') > layerIndex('NOTE_FRET_LABEL'), 'arp labels retain a one-layer tie-breaker');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('NOTE_CORE'), 'chord-loop fret labels must draw above gem core at the same depth');
assert.ok(layerIndex('NOTE_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'note fret labels must clear static fret wires');
assert.ok(layerIndex('CHORD_FRET_LABEL') > layerIndex('BOARD_FRET_WIRE'), 'chord fret labels must clear static fret wires');
});
test('string mesh layer is above note symbols and below labels', () => {
// Board strings are never occluded by flying gems, but labels still appear above strings.
const s = src();
assert.match(s, /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
// Confirm 1000 also exists (labels above strings)
assert.match(s, /m\.renderOrder\s*=\s*1000\s*;/, 'technique label renderOrder 1000 must exist');
assert.ok(layerIndex('BOARD_STRING') > layerIndex('TECHNIQUE_MARKER'), 'string mesh layer must be above note symbols');
assert.ok(layerIndex('BOARD_STRING') < layerIndex('NOTE_FRET_LABEL'), 'string mesh layer must be below fret labels');
});
test('fret-column marker layer is above chord frame and below gem outline', () => {
assert.ok(layerIndex('FRET_COLUMN') > layerIndex('CHORD_FRAME'), 'fret-column marker layer must be above chord frame');
assert.ok(layerIndex('FRET_COLUMN') < layerIndex('NOTE_OUTLINE'), 'fret-column marker layer must be below gem outline');
assert.match(src(), /renderOrderForLayerAtZ\(\s*z\s*,\s*'FRET_COLUMN'\s*\)/);
});
test('static fret wire layer is above string mesh and note symbols', () => {
// Structural invariant: fret wires must always draw after (on top of) strings.
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('BOARD_STRING'), 'fret wire must be above string mesh');
assert.ok(layerIndex('BOARD_FRET_WIRE') > layerIndex('TECHNIQUE_MARKER'), 'fret wire must be above note symbols');
assert.ok(zZeroRenderOrder() + layerIndex('BOARD_FRET_WIRE') < 1000, 'fret wire must be below technique labels (1000)');
assert.match(src(), /fw\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_FRET_WIRE'\s*\)\s*;/, 'buildBoard fret wire must use BOARD_FRET_WIRE');
assert.match(src(), /mesh\.renderOrder\s*=\s*renderOrderForLayerAtZ\(\s*0\s*,\s*'BOARD_STRING'\s*\)\s*;/, 'string mesh must use BOARD_STRING');
});
+327
View File
@@ -0,0 +1,327 @@
// The playlist tuning check (static/v3/playlists.js).
//
// A bass-playing tester built playlists grouped BY TUNING so a practice run
// needs no retune, using a library filter that only ever looked at the guitar
// tuning. Those playlists still hold songs he can't play without stopping. The
// check flags them; it must never quietly edit the playlist, and — the part
// that decides whether he trusts it — it must not call a song "wrong tuning"
// when it simply couldn't work the song out.
//
// The real functions are lifted out of playlists.js and run in a vm (the module
// is a browser IIFE with no export surface, and there is no jsdom here). No
// re-implementation: if the source changes, these tests run the changed code.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const PL_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'playlists.js');
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const TUNER_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const PL_SRC = fs.readFileSync(PL_JS, 'utf8');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
if (start === -1) throw new Error(`extractBlock: '${startMarker}' not found`);
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractBlock: unbalanced braces after '${startMarker}'`);
return src.slice(start, i);
}
// The REAL offset parser the checker calls through window.parseRawTuningOffsets.
function loadParseRawTuningOffsets() {
const body = fs.readFileSync(TUNING_JS, 'utf8').replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(body + '\nexports.parseRawTuningOffsets = parseRawTuningOffsets;', sandbox);
return sandbox.exports.parseRawTuningOffsets;
}
// Build a sandbox holding the real checker functions, over a caller-supplied
// window (so each test controls the host capabilities and the coverage stub
// boundary). `coverage` stands in for the tuner plugin's coverageReport — a
// genuinely external collaborator, not the subject under test; the contract
// test at the bottom pins its report shape so these fixtures can't drift.
function loadChecker(opts) {
opts = opts || {};
const calls = [];
const window = {
parseRawTuningOffsets: loadParseRawTuningOffsets(),
feedBack: opts.noWorkingTuning ? {} : { workingTuning: { get: () => ({ instrument: opts.instrument || 'bass' }) } },
_tunerAutoOpen: opts.noCoverage ? undefined : {
coverageReport: async (info) => {
calls.push(info);
if (opts.coverage) return opts.coverage(info);
throw new Error('no coverage fixture supplied');
},
},
};
const sandbox = { window, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
extractBlock(PL_SRC, 'function rowTuningForCheck(') + '\n'
+ extractBlock(PL_SRC, 'function tuningStateFromReport(') + '\n'
+ extractBlock(PL_SRC, 'async function checkPlaylistTuning(') + '\n'
+ extractBlock(PL_SRC, 'function tuningSummaryHtml(') + '\n'
+ 'exports.rowTuningForCheck = rowTuningForCheck;\n'
+ 'exports.tuningStateFromReport = tuningStateFromReport;\n'
+ 'exports.checkPlaylistTuning = checkPlaylistTuning;\n'
+ 'exports.tuningSummaryHtml = tuningSummaryHtml;\n',
sandbox
);
return { ...sandbox.exports, calls };
}
// Report shapes exactly as plugins/tuner/screen.js documents and returns them.
const REPORT_COVERED = { covered: true, retune: [], reference: false, cantCover: false };
const REPORT_RETUNE = { covered: false, retune: [{ from: 'E', to: 'D' }], reference: false, cantCover: false };
const REPORT_REFERENCE = { covered: false, retune: [], reference: true, cantCover: false };
const REPORT_CANT_COVER = { covered: false, retune: [], reference: false, cantCover: true };
// The "I couldn't work it out" report — the tuner's `none` bail-out. Byte-for-byte
// a not-covered report with no reason attached.
const REPORT_UNKNOWN = { covered: false, retune: [], reference: false, cantCover: false };
// The checker runs inside the vm, so the arrays it returns belong to another
// realm and would fail deepStrictEqual's prototype check. Copy into host arrays.
const plain = (a) => Array.from(a);
const song = (over) => Object.assign(
{ filename: 'a.sloppak', title: 'A', tuning_name: 'E Standard', tuning_offsets: '0 0 0 0 0 0', bass_only: false },
over
);
// ── The unknown-vs-mismatch distinction ─────────────────────────────────────
test('a covered report is a match', () => {
const { tuningStateFromReport } = loadChecker();
assert.equal(tuningStateFromReport(REPORT_COVERED), 'match');
});
test('a not-covered report WITH a reason is a mismatch', () => {
const { tuningStateFromReport } = loadChecker();
assert.equal(tuningStateFromReport(REPORT_RETUNE), 'mismatch');
assert.equal(tuningStateFromReport(REPORT_REFERENCE), 'mismatch');
assert.equal(tuningStateFromReport(REPORT_CANT_COVER), 'mismatch');
});
test('a not-covered report with NO reason is unknown, not a mismatch', () => {
// This is the whole trust argument. The tuner returns this identical shape
// when settings/tuner data are missing. Treating it as "wrong tuning" (which
// the library grid's chip decorator does) would put a false ⚠ on songs that
// are perfectly playable, on a playlist the user curated by hand.
const { tuningStateFromReport } = loadChecker();
assert.equal(tuningStateFromReport(REPORT_UNKNOWN), 'unknown');
});
test('a null/absent report is unknown', () => {
const { tuningStateFromReport } = loadChecker();
assert.equal(tuningStateFromReport(null), 'unknown');
assert.equal(tuningStateFromReport(undefined), 'unknown');
});
// ── Round-tripping a whole playlist ─────────────────────────────────────────
test('each song is scored and reported in playlist order', async () => {
const byFile = {
'match.sloppak': REPORT_COVERED,
'bad.sloppak': REPORT_RETUNE,
'huh.sloppak': REPORT_UNKNOWN,
};
const songs = [
song({ filename: 'match.sloppak', title: 'Match' }),
song({ filename: 'bad.sloppak', title: 'Bad', tuning_offsets: '-2 -2 -2 -2 -2 -2' }),
song({ filename: 'huh.sloppak', title: 'Huh', tuning_offsets: '-1 0 0 0 0 0' }),
];
// Resolve the fixture from the offsets the checker actually passed, so the
// mapping can't silently drift out of playlist order.
const byOffsets = new Map(songs.map((s) => [s.tuning_offsets.replace(/\s+/g, ','), byFile[s.filename]]));
const checker = loadChecker({ coverage: async (info) => byOffsets.get(info.tuning.join(',')) });
const out = await checker.checkPlaylistTuning(songs);
assert.deepEqual(plain(out.map((r) => r.state)), ['match', 'mismatch', 'unknown']);
assert.deepEqual(plain(out.map((r) => r.song.filename)), songs.map((s) => s.filename));
});
test('a song with no usable tuning data is unknown WITHOUT consulting coverage', async () => {
// Adversarial payloads: empty, whitespace, a non-numeric name with no
// offsets, and a garbage offsets string. None of these can be scored, and
// asking coverage about them would invite a bogus not-covered → false ⚠.
const checker = loadChecker({ coverage: async () => REPORT_RETUNE });
const out = await checker.checkPlaylistTuning([
song({ filename: 'a', tuning_offsets: '', tuning_name: '' }),
song({ filename: 'b', tuning_offsets: ' ', tuning_name: ' ' }),
song({ filename: 'c', tuning_offsets: '', tuning_name: 'E Standard' }),
song({ filename: 'd', tuning_offsets: 'not offsets', tuning_name: 'x' }),
song({ filename: 'e', tuning_offsets: null, tuning_name: null }),
]);
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown', 'unknown', 'unknown', 'unknown', 'unknown']);
assert.equal(checker.calls.length, 0, 'coverage must not be asked about unscoreable rows');
});
test('a coverage call that throws degrades to unknown, not mismatch', async () => {
const checker = loadChecker({ coverage: async () => { throw new Error('tuner exploded'); } });
const out = await checker.checkPlaylistTuning([song({})]);
assert.deepEqual(plain(out.map((r) => r.state)), ['unknown']);
});
test('the bass perspective uses #1003 bass offsets instead of guitar offsets', async () => {
const checker = loadChecker({ instrument: 'bass', coverage: async () => REPORT_COVERED });
await checker.checkPlaylistTuning([song({
tuning_offsets: '0 0 0 0 0 0',
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
})]);
assert.deepEqual(plain(checker.calls[0].tuning), [-2, -2, -2, -2, -2, -2]);
assert.equal(checker.calls[0].arrangement, 'Bass');
});
test("the guitar perspective ignores a song's bass offsets", async () => {
const checker = loadChecker({ instrument: 'guitar', coverage: async () => REPORT_COVERED });
await checker.checkPlaylistTuning([song({
tuning_offsets: '0 0 0 0 0 0',
bass_tuning_offsets: '-2 -2 -2 -2 -2 -2',
})]);
assert.deepEqual(plain(checker.calls[0].tuning), [0, 0, 0, 0, 0, 0]);
assert.equal(checker.calls[0].arrangement, 'Lead');
});
test('a bass-only chart is scored against bass base pitches', async () => {
// Otherwise a 4-string bass tuning read as guitar can false-match — the
// cross-instrument confusion this whole feature exists to undo.
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
await checker.checkPlaylistTuning([
song({ filename: 'bass', tuning_offsets: '0 0 0 0', bass_only: true }),
song({ filename: 'gtr', tuning_offsets: '0 0 0 0 0 0', bass_only: false }),
]);
assert.deepEqual(checker.calls.map((c) => c.arrangement), ['Bass', 'Lead']);
assert.deepEqual(checker.calls.map((c) => c.stringCount), [4, 6]);
});
test('the check stays silent when the host exposes no tuning perspective', async () => {
// No working-tuning capability, or no tuner coverage → null, and the caller
// renders the playlist exactly as before. Guessing "guitar" here would
// reproduce the original bug in a new place.
for (const opts of [{ noWorkingTuning: true }, { noCoverage: true }]) {
const checker = loadChecker(Object.assign({ coverage: async () => REPORT_COVERED }, opts));
assert.equal(await checker.checkPlaylistTuning([song({})]), null);
}
});
test('an empty playlist yields an empty result, not a crash', async () => {
const checker = loadChecker({ coverage: async () => REPORT_COVERED });
assert.deepEqual(plain(await checker.checkPlaylistTuning([])), []);
assert.deepEqual(plain(await checker.checkPlaylistTuning(null)), []);
});
// ── The summary ─────────────────────────────────────────────────────────────
test('the summary counts mismatches against the playlist total', () => {
const { tuningSummaryHtml } = loadChecker();
const results = [
{ state: 'mismatch' }, { state: 'mismatch' }, { state: 'mismatch' },
...Array(21).fill({ state: 'match' }),
];
const html = tuningSummaryHtml(results);
assert.match(html, /<strong>3<\/strong> of 24 songs aren't in your tuning/);
});
test('unknowns are reported separately from mismatches and never counted as them', () => {
const { tuningSummaryHtml } = loadChecker();
const html = tuningSummaryHtml([{ state: 'mismatch' }, { state: 'unknown' }, { state: 'match' }]);
assert.match(html, /<strong>1<\/strong> of 3 songs aren't in your tuning/);
assert.match(html, /1 couldn't be checked/);
assert.match(html, /left alone/);
});
test('an all-unknown playlist makes no mismatch claim and offers no removal', () => {
const { tuningSummaryHtml } = loadChecker();
const html = tuningSummaryHtml([{ state: 'unknown' }, { state: 'unknown' }]);
assert.doesNotMatch(html, /aren't in your tuning/);
assert.doesNotMatch(html, /v3-pl-tune-remove/);
assert.match(html, /2 couldn't be checked/);
});
test('a clean playlist offers no filter and no removal button', () => {
const { tuningSummaryHtml } = loadChecker();
const html = tuningSummaryHtml([{ state: 'match' }, { state: 'match' }]);
assert.match(html, /All 2 songs are in your tuning/);
assert.doesNotMatch(html, /v3-pl-tune-only/);
assert.doesNotMatch(html, /v3-pl-tune-remove/);
});
test('an empty playlist renders no summary at all', () => {
const { tuningSummaryHtml } = loadChecker();
assert.equal(tuningSummaryHtml([]), '');
});
// ── Read-only / explicit-action guarantees (source-level) ───────────────────
test('the check itself never mutates the playlist', () => {
// checkPlaylistTuning and its helpers must contain no write verbs. The only
// DELETE in the module's tuning path is inside the confirmed removal.
const fns = ['function rowTuningForCheck(', 'function tuningStateFromReport(',
'async function checkPlaylistTuning(', 'function tuningSummaryHtml('];
for (const marker of fns) {
const body = extractBlock(PL_SRC, marker);
assert.doesNotMatch(body, /DELETE|jsend\(|method:/,
marker + ' must not mutate the playlist');
}
});
test('bulk removal names every song and is confirmed before any DELETE', () => {
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
// The confirm is built from the doomed titles, each escaped. The row markup
// moved from <li> to a bulleted <div> so the confirm needs no Tailwind class
// the committed CSS lacks — what matters is that every song is named and
// escaped, not which element wraps it.
assert.match(body, /doomed\.map\(\(s\) => '<(?:li|div)>[^']*' \+ esc\(s\.title \|\| s\.filename\)/);
// … it is awaited, and an early return happens before the delete loop.
const confirmAt = body.indexOf('uiConfirm');
const bailAt = body.indexOf('if (!ok) return;');
const deleteAt = body.indexOf("method: 'DELETE'");
assert.ok(confirmAt > -1 && bailAt > confirmAt && deleteAt > bailAt,
'DELETE must come after an awaited confirm and its bail-out');
// And it says the songs survive in the library — the "reversible-feeling" ask.
assert.match(body, /stay in your library/);
});
test('removal targets only mismatches — never unknowns', () => {
const body = extractBlock(PL_SRC, 'async function applyTuningCheck(');
assert.match(body, /results\.filter\(\(r\) => r\.state === 'mismatch'\)\.map\(\(r\) => r\.song\)/);
assert.doesNotMatch(body, /doomed[\s\S]{0,200}'unknown'/);
});
test('unknown is styled distinctly from mismatch', () => {
const body = extractBlock(PL_SRC, 'function paintTuningChip(');
// Mismatch is amber; unknown is the neutral chip, dimmed — not amber.
assert.match(body, /state === 'mismatch' \? 'bg-amber-400'/);
assert.match(body, /state === 'unknown'\) chip\.classList\.add\('opacity-60'\)/);
// …and both carry a text marker, so the states never rest on colour alone.
assert.match(body, /state === 'mismatch' \? ' ⚠' : state === 'unknown' \? ' \?'/);
});
// ── Collaborator contract ───────────────────────────────────────────────────
test('the tuner coverage report still carries the fields the states are read from', () => {
// If the tuner plugin drops `retune`/`reference`/`cantCover`, every mismatch
// silently degrades to "unknown" and the feature goes quiet. Pin the shape
// the fixtures above rely on.
const tuner = fs.readFileSync(TUNER_JS, 'utf8');
const body = extractBlock(tuner, 'async function _computeCoverageReport(');
for (const field of ['covered', 'retune', 'reference', 'cantCover']) {
assert.match(body, new RegExp(field), `coverage report must still carry ${field}`);
}
assert.match(body, /const none = \{ covered: false, retune: \[\], reference: false, cantCover: false \}/,
'the no-data bail-out must stay a reasonless not-covered report — that is what "unknown" detects');
});
+17 -1
View File
@@ -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/);
+721
View File
@@ -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 AZ 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"] == ""
+294
View File
@@ -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
+41
View File
@@ -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
+182
View File
@@ -175,3 +175,185 @@ def test_deleting_playlist_removes_custom_cover(client, server):
assert _playlist_cover_path(pid).exists()
client.delete(f"/api/playlists/{pid}")
assert not _playlist_cover_path(pid).exists()
# ── Reordering the playlists THEMSELVES (not songs-within) ───────────────────
def _mk(client, name):
return client.post("/api/playlists", json={"name": name}).json()["id"]
def _ids(client):
return [p["id"] for p in client.get("/api/playlists").json()]
def test_playlists_default_order_is_alphabetical(client):
b = _mk(client, "Bravo")
a = _mk(client, "alpha") # NOCASE: lowercase still sorts by letter
z = _mk(client, "Zulu")
assert _ids(client) == [a, b, z]
def test_playlist_manual_reorder_persists(client):
a = _mk(client, "Alpha")
b = _mk(client, "Bravo")
c = _mk(client, "Charlie")
r = client.post("/api/playlists/reorder", json={"order": [c, a, b]})
assert r.status_code == 200
assert [p["id"] for p in r.json()] == [c, a, b]
# persists across independent list calls
assert _ids(client) == [c, a, b]
assert _ids(client) == [c, a, b]
def test_playlist_reorder_excludes_system_and_keeps_it_pinned(client):
# First toggle creates the "Saved for Later" system playlist.
client.post("/api/saved/toggle", json={"filename": "x.archive"})
a = _mk(client, "Alpha")
b = _mk(client, "Bravo")
saved = next(p["id"] for p in client.get("/api/playlists").json() if p["system_key"])
# A system id in the order is rejected — it isn't reorderable.
assert client.post("/api/playlists/reorder", json={"order": [saved, b, a]}).status_code == 400
# User playlists reorder; the system playlist stays pinned first.
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 200
listing = client.get("/api/playlists").json()
assert listing[0]["system_key"] == "saved_for_later"
assert [p["id"] for p in listing[1:]] == [b, a]
def test_playlist_reorder_rejects_bad_orders(client):
a = _mk(client, "Alpha")
b = _mk(client, "Bravo")
for bad in (
[a], # missing an id (partial order)
[a, b, 999999], # extra unknown id
[a, a], # duplicate (drops b)
[a, 999999], # unknown id in place of b
"nope", # not a list
[a, str(b)], # non-int entry
[True, False], # bools are ints to Python — must still be rejected
None, # {"order": null}
):
assert client.post("/api/playlists/reorder", json={"order": bad}).status_code == 400, bad
assert client.post("/api/playlists/reorder", json={}).status_code == 400
# Nothing was persisted by any rejected request.
assert _ids(client) == [a, b]
def test_sort_alpha_clears_manual_order(client):
a = _mk(client, "Alpha")
b = _mk(client, "Bravo")
z = _mk(client, "Zulu")
client.post("/api/playlists/reorder", json={"order": [z, b, a]})
assert _ids(client) == [z, b, a]
r = client.post("/api/playlists/sort-alpha")
assert r.status_code == 200
assert [p["id"] for p in r.json()] == [a, b, z]
assert _ids(client) == [a, b, z]
def test_new_playlist_after_manual_reorder_sorts_alphabetically_after_positioned(client):
a = _mk(client, "Alpha")
b = _mk(client, "Bravo")
client.post("/api/playlists/reorder", json={"order": [b, a]})
# New playlists are unpositioned → they follow the manually positioned
# ones, alphabetically among themselves, and never disturb the manual
# order ("Aardvark" would be first alphabetically).
z = _mk(client, "Zebra")
aa = _mk(client, "Aardvark")
assert _ids(client) == [b, a, aa, z]
# A subsequent full reorder must include the newcomers (exact permutation).
assert client.post("/api/playlists/reorder", json={"order": [b, a]}).status_code == 400
assert client.post("/api/playlists/reorder", json={"order": [z, aa, b, a]}).status_code == 200
assert _ids(client) == [z, aa, b, a]
# ── Tuning-check payload (per-song data the playlist tuning check scores) ────
# A playlist grouped BY TUNING is a run you can practise without retuning, so
# the detail view flags rows your instrument can't reach. Scoring needs more
# than the tuning NAME: two "Custom Tuning" rows are different tunings, and a
# bass-only chart has to be measured against bass base pitches.
def test_playlist_songs_carry_tuning_offsets_for_the_check(client, server):
db = server.meta_db
db.put("drop.archive", 0, 0, {"title": "Drop", "tuning_name": "Drop D",
"tuning_offsets": "-2 0 0 0 0 0"})
pid = client.post("/api/playlists", json={"name": "T"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "drop.archive"})
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert song["tuning_offsets"] == "-2 0 0 0 0 0"
assert song["tuning_name"] == "Drop D"
def test_playlist_songs_carry_role_specific_tunings(client, server):
db = server.meta_db
db.put("roles.archive", 0, 0, {
"title": "Roles",
"tuning_name": "E Standard",
"tuning_offsets": "0 0 0 0 0 0",
"bass_tuning_name": "A Standard",
"bass_tuning_offsets": "-2 -2 -2 -2 -2 -2",
"rhythm_tuning_name": "Drop D",
"rhythm_tuning_offsets": "-2 0 0 0 0 0",
})
pid = client.post("/api/playlists", json={"name": "Roles"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "roles.archive"})
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert song["bass_tuning_name"] == "A Standard"
assert song["bass_tuning_offsets"] == "-2 -2 -2 -2 -2 -2"
assert song["rhythm_tuning_name"] == "Drop D"
assert song["rhythm_tuning_offsets"] == "-2 0 0 0 0 0"
def test_playlist_songs_flag_bass_only_charts(client, server):
# Every arrangement a bass part → bass_only, so coverage scores the row
# against bass strings. A chart that ALSO has a guitar part must not be
# flagged, or a guitarist's row gets measured on the wrong instrument.
db = server.meta_db
db.put("bassonly.archive", 0, 0, {"title": "Bass Only", "arrangements": [
{"name": "Bass"}, {"name": "Alt. Bass"}]})
db.put("mixed.archive", 0, 0, {"title": "Mixed", "arrangements": [
{"name": "Lead"}, {"name": "Bass"}]})
db.put("noarr.archive", 0, 0, {"title": "No Arrangements"})
pid = client.post("/api/playlists", json={"name": "B"}).json()["id"]
for fn in ("bassonly.archive", "mixed.archive", "noarr.archive"):
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
got = {s["filename"]: s["bass_only"] for s in client.get(f"/api/playlists/{pid}").json()["songs"]}
assert got == {"bassonly.archive": True, "mixed.archive": False, "noarr.archive": False}
def test_bass_only_flag_survives_adversarial_arrangement_data(client, server):
# Corrupt/odd `arrangements` must not 500 the playlist, and must not claim
# bass — an unscoreable row is left for the client to report as "unknown".
db = server.meta_db
cases = {
"empty.archive": [],
"unnamed.archive": [{"name": ""}],
"nullname.archive": [{"name": None}],
"substring.archive": [{"name": "Bassoon"}], # not a bass part
"cased.archive": [{"name": "BASS"}], # is one
}
for fn, arrs in cases.items():
db.put(fn, 0, 0, {"title": fn, "arrangements": arrs})
pid = client.post("/api/playlists", json={"name": "Adv"}).json()["id"]
for fn in cases:
client.post(f"/api/playlists/{pid}/songs", json={"filename": fn})
r = client.get(f"/api/playlists/{pid}")
assert r.status_code == 200
got = {s["filename"]: s["bass_only"] for s in r.json()["songs"]}
assert got == {"empty.archive": False, "unnamed.archive": False,
"nullname.archive": False, "substring.archive": False,
"cased.archive": True}
def test_playlist_song_with_no_tuning_data_reports_empty_not_missing(client, server):
# The key must always be present: the client distinguishes "no tuning data"
# (unknown — say nothing) from "wrong tuning" (flag it), and a missing key
# would make every row unscoreable by accident rather than by fact.
db = server.meta_db
db.put("bare.archive", 0, 0, {"title": "Bare"})
pid = client.post("/api/playlists", json={"name": "Bare"}).json()["id"]
client.post(f"/api/playlists/{pid}/songs", json={"filename": "bare.archive"})
song = client.get(f"/api/playlists/{pid}").json()["songs"][0]
assert song["tuning_offsets"] == ""
assert song["bass_only"] is False