Compare commits

...
Author SHA1 Message Date
ChrisBeWithYouandClaude Opus 4.8 71833705b2 fix(tunings): extended-range bass was named as a guitar
Reported by a 6-string bassist: a Sleep Token chart tuned A0 D1 G1 C2 F2
A#2 (standard 6-string bass, whole step down) imported and displayed as
"6 string D Standard". They called it A standard and they were right.

Root cause: `tuning_name()` gated its naming ladder on
`len(offsets) == 6`, treating six offsets as proof of a 6-string GUITAR.
A 6-string BASS also has six offsets, but its lowest string is B, not E
— so the guitar ladder mislabels the whole family: an all-zeros bass
read "E Standard" (it is Standard/B) and a whole-step-down bass read
"D Standard" (it is A Standard). The function's own comment warned about
exactly this error for 7-string guitars; nobody guarded the bass axis.
The stored value feeds the library's Tuning filter, so every 5/6-string
bass song in every library was filed under a guitar name.

- `tuning_name(offsets, *, is_bass=False)`: bass 5/6 use the low-B
  ladder (Standard / Bb / A / G# / G) and bass 4 keeps the E ladder it
  shares with guitar. Drop names come off the resulting low string.
  Default stays guitar, so existing callers are unaffected.
- `sloppak._tuning_for_meta_kind()` reports WHICH kind supplied the
  tuning; `extract_meta` emits `tuning_is_bass` and scan_worker passes
  it through. Guitar-first selection for the library index is unchanged
  — a pack with a guitar part still indexes by the guitar.
- `TUNING_PRESET_MIDIS` bass-5/bass-6 renamed to match, with
  `TUNING_PRESET_ALIASES` so `_valid_tuning_for_key` MIGRATES a saved
  profile carrying an old name instead of rejecting it (it refuses names
  belonging to another key's built-ins, and "D Standard" still exists
  for guitar-6/bass-4 — so a rename alone would have invalidated those
  profiles). Pitches are untouched; only labels change.

Convention confirmed by the bass- and guitar-pedagogy seats: name
extended range off the ACTUAL lowest string, which is what the 7-string
guitar presets already do. The old names came from the band-level habit
of saying "we're in D standard" — true of the guitars, while the bassist
in that band is in A standard. Right answer, wrong scope. The bass table
was also internally inconsistent: its Drop A was already named off the
low string while its standards were not.

Tests: bass ladders for 4/5/6 strings, the reported chart pinned both
ways (is_bass=True -> "A Standard", same offsets as guitar -> "D
Standard"), bass drop naming, a table-wide invariant that every bass
preset name matches the note its low string sounds, and alias migration
(incl. not leaking into guitar-6/bass-4). The existing legacy-flat-bass
migration test now asserts the corrected label — same pitches, right
name. Suite: 1735 passed vs 1720 on main, with the same 99 pre-existing
env failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01929LgKdJMyPGLf8N1WpEVW
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-18 23:30:26 -05:00
ChrisBeWithYou 040bb411df 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>
2026-07-18 16:55:21 -05:00
ChrisBeWithYouandClaude Opus 4.8 81575345f5 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>
2026-07-18 16:36:24 -05:00
K. O. A.andGitHub 1c077c9ab7 fix(highway_3d): stop the lane at the hit line (#994)
ship-ci / ci (push) Has been cancelled
The lane maps chart time to z exactly as notes do, over the window
[now - BEHIND, now + AHEAD]. That puts its near edge at +TS*BEHIND — BEHIND
seconds PAST the hit line, toward the player. Nothing is ever drawn there:
drawNote and the chord frames both clamp to Math.min(0, dZ(dt)), so notes stop
dead at z = 0. The overhang was therefore lane surface with nothing on it.

Clamp the floor geometry's near edge to the hit line. The far edge is
deliberately untouched — it still lands at -AHEAD*TS, aligned with the note
horizon, which is why the span stays AHEAD+BEHIND in the sliced path and the
clamp is applied per slice (a slice entirely past the line collapses to zero
length and is skipped before the arpeggio probe, so it costs nothing).

All four floor sites move together — the sliced lane (which also feeds both
divider loops), the fallback lane, its dividers, and the fret boundary
extension lines. They shared the identical `+ TS * BEHIND` shift; fixing only
some would leave fret lines poking past a lane that now stops.

Closes #991

Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-16 19:33:17 -04:00
3717e4338d fix(plugins): restore window.esc for out-of-tree plugins (#986)
ship-ci / ci (push) Waiting to run
app.js exported `esc` as an implicit global back when it was a classic
script. a9fce29 made it an ES module and 14b4058 carved `esc` into
js/dom.js; the window re-export list was rebuilt without it.

Out-of-tree plugins load screen.js as a classic script and call `esc()`
bare, so nothing in-tree catches the break: no-undef, a call-graph scan
and a grep all pass while the plugin throws in the field. The MIDI
plugin builds its device list with esc() inside the same try block that
catches requestMIDIAccess() failures, so the ReferenceError surfaced to
testers as "MIDI Access denied esc is not defined" — access had actually
been granted.

Pin the whole plugin-facing global surface by name, mirroring
tests/test_plugin_context_contract.py. Verified both ways against a
running app: without the fix the spec fails with "missing or not
functions: esc".

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:06:52 +02:00
0b4b174d33 perf(scan): skip full library re-stat when the tree is unchanged (#979)
ship-ci / ci (push) Waiting to run
* perf(scan): skip full library re-stat when the tree is unchanged

Startup scans globbed the whole DLC tree twice (*.feedpak, *.wem) and
stat()'d every file to detect changes — ~100k filesystem round trips on
a 50k-song library, and painful on a slow NTFS-3G FUSE mount (the "big
drive churns on every launch" report).

Adds/removes/renames of songs all bump the mtime of the containing
directory (verified on the target mount), so after a full pass we persist
{reldir: mtime_ns} for every library dir (scan_dir_signature.json, keyed
by DLC path). The next scan re-stats only those dirs — a handful vs 100k
ops — and skips the entire listing/stat pass when none changed.

Blind spot: a pack rewritten in place under the same name bumps the file
mtime but not its dir's. Rare for a song library, and the manual Refresh
(/api/rescan + /api/rescan/full) now passes force=True to always do the
full pass. force threads through kick_scan -> _scan_runner and coalesces
like the rescan-pending flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scan): track directory-form songs' own dir in the signature

CodeRabbit: _library_dirs recorded only each song's parent. For a
directory-form song (loose-song folder or directory sloppak bundle),
adding/removing/replacing a file INSIDE the folder bumps that folder's
own mtime, not its parent's — so the fast path would skip a rescan it
should run. Record the song's own dir when f.is_dir(). File-form
sloppaks (a single .feedpak zip) aren't dirs, so the flat file library
is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:23:10 +02:00
Byron GamatosandGitHub 2f2a095e4c fix(venue): fly in once per set, not before every song (#978)
ship-ci / ci (push) Waiting to run
Tester, mid-gig: "the second song in the gig started when the first one ended.
But it showed the flyover intro again."

The flyover is arriving at the venue, and you arrive once. #968 stopped it
replaying on an arrangement SWITCH (same filename), but a gig's song 2 is a
genuinely different file, so it took the full-teardown path and played the
arrival flyover again — the camera flew in from the back of the room before
every track of the set.

The play queue now answers isContinuation(): false for the first song of a set
(or a standalone play — an arrival), true for song 2..N. onSongLoaded carries
the room over to the new song's loop on a continuation, and only a real arrival
plays the intro.

Verified on the built AppImage: isContinuation goes false (song 1) -> true
(song 2) across an advance, and song 2 no longer flies in.

Also confirmed NOT a bug, same session: "didn't show the author for the second
song." The credits card shows on a queue advance whenever the song carries
authors — reproduced with a song that has them as the advanced-to track. The
tester's song 2 simply had no `authors:` metadata (most auto-converted feedpaks
don't). No code change.

Tests: isContinuation across start/advance/clear, and that onSongLoaded gates the
flyover on the continuation check. Both fail on pre-fix source. JS 1214/1214.
2026-07-15 12:39:25 +02:00
Byron GamatosandGitHub e14ef64224 fix(playback): the song queue must survive a playSong wrapper that drops options (#977)
ship-ci / ci (push) Waiting to run
Tester: "Passports does not advance in the song queue."

The play queue tells playSong "don't clear the queue I'm driving" by passing
options.fromQueue. But window.playSong is wrapped by a CHAIN of plugins —
nam_tone, midi_amp, fretboard, invert_highway, tabview — and each wrapper
forwards only (filename, arrangement), silently dropping the options object. So
fromQueue never reached playSong: it cleared the queue the instant its first
song started, and a gig/album/playlist never advanced.

Reproduced on the real build via a queue.start + a hooked clear(): the queue
went inactive with 0 remaining immediately after start, and the clear stack ran
through nam_tone -> midi_amp -> invert_highway -> fretboard -> session.js.

Fixing six plugin wrappers is whack-a-mole and the next plugin re-breaks it.
Fix it at the source instead: the queue raises an out-of-band flag
(_consumeInternalPlay, one-shot) beside the wrapper chain, not through it, and
playSong's clear-guard honours it. options.fromQueue stays as the in-band path.
The flag is consumed on read so a later MANUAL play still abandons the queue.

Verified on the real build: the gig queue stays active after start and advances
on song:ended (Iron Maiden -> Blind Guardian), and a manual play still clears.

Tests drive the real clear-guard against the queue for: a dropped-options
wrapper (the bug), the one-shot manual-play-still-clears invariant, and the
in-band fromQueue path on its own. All 3 fail on the pre-fix source. JS 1211/1211.
2026-07-15 10:50:59 +02:00
Byron GamatosandGitHub 365cec1d29 fix(career): gig song selection — full-genre pool, working re-roll, and the venue pack loads (#976)
* fix(career): a gig's song pool is the whole genre, and re-roll varies it

Two tester reports, one root: the gig song pool was built from only two sets —
songs played ON THIS PASSPORT'S INSTRUMENT, and songs never played AT ALL
(`filename NOT IN song_stats`).

A song played on a DIFFERENT instrument's arrangement is in neither: it has a
stats row (so the "unplayed" filler skipped it), and its played bucket is that
other instrument's, not this passport's. It could never be gigged.

- "Metalcore says 137 songs only shows 1 in the gig list" — a library of
  metalcore all played on another instrument. Reproduced: a guitar passport with
  137 bass-played metalcore songs got a 404, zero songs. The "1" the tester saw
  was whatever handful happened to be on-instrument or truly unplayed.

- "Passport re-roll does not change songs" — a set drawn from that filler was the
  library's first N in table ORDER, every call. Re-roll re-proposes, so it
  returned the identical set. Reproduced: 3 proposals, byte-identical.

_unplayed_genre_songs -> _fill_genre_songs: the pool is now every library song of
the genre the set hasn't already picked (a stats row on some other instrument has
no bearing on whether a song can be in THIS gig), and it is shuffled so re-roll
actually re-rolls.

Both reproduced against the real propose logic before the fix and pinned as
regression tests (both fail on the pre-fix routes.py). Full career suite green.

* fix(career): load the gig's venue pack when the gig starts

Tester: "Venue doesn't load when starting song from passport. Loads standard
particles."

crowd.setManifest(venue) — the call that actually loads a venue's crowd/stage
pack — is reached ONLY through pushCrowdManifest, and pushCrowdManifest is
called ONLY from refresh(), the career tab's own reload. A gig navigates AWAY
from the career tab to the player, so refresh() never runs during it. startGig
set the venue override and nulled _appliedManifestVenue but never re-pushed, so
the venue visualization turned on (3D highway) while its pack never loaded — the
song played over the bare highway backdrop, or over whatever venue a previous
refresh() had left applied.

startGig now pushes the crowd manifest for the gig venue right after setting the
override, using the career state the booking screen already fetched.

This is a call-graph fact, not a guess (pushCrowdManifest has exactly one other
caller and startGig is not it), but it is fixed by static analysis — I could not
reproduce the user-visible symptom locally because this instance happened to have
a manifest already applied from a prior refresh. On-device confirmation on a real
passport gig is still owed.

Guard test: startGig must push the manifest after setting the override (fails on
the pre-fix source). Career suite green.
2026-07-15 10:50:55 +02:00
Byron GamatosandGitHub 1702afa379 feat(career): extract the whole setlist before the gig starts (no more waiting between songs) (#971)
ship-ci / ci (push) Waiting to run
* feat(career): extract the whole setlist before the gig starts

A feedpak is a zip, and the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs: the player finished
a number and then sat there waiting for the next one to unpack, mid-gig.

A setlist is a known list up front, so unpack it all while the poster is still on
screen. New POST /gigs/prepare walks the set through resolve_source_dir; the
poster's Play button shows "Preparing set…" while it runs.

Best-effort by design, at every level:
  - a corrupt pak in the set does not sink the prepare (it is reported in
    `failed`; the play itself surfaces the error exactly as it does outside a
    gig — slow beats blocked)
  - a host without the library resolvers degrades to a no-op rather than 500
  - a failed request just falls through to the old lazy extraction

Ordering matters and is pinned: the set is unpacked BEFORE the stage is borrowed
(venue/viz overwritten) and before the queue starts, so a proposal cancelled
while unpacking leaves nothing half-applied to unwind.

Tests unpack REAL zips rather than mocking the extractor: every song of the set
lands on disk before the first note, a re-prepare does not duplicate the unpack,
one bad pak still leaves the good one prepared, and no-library / empty-setlist
degrade cleanly. 18/18.

NB the other half of the gig report — the per-song results popup interrupting
the set (and worse, claimAutoExit'ing so the queue would not advance until it was
dismissed) — is fixed in the note_detect plugin repo, which is not part of this
checkout.

* fix(career): bound the prepare request; validate the setlist (PR #971 review)

Both CodeRabbit findings were right.

1. A HUNG PREPARE COULD BLOCK THE GIG FOREVER.

   `await fetch(...)` only rejects on a network ERROR. A server that accepts the
   connection and then never answers hangs indefinitely — and the gig would never
   start. That makes this optimisation the exact thing the PR promises it can
   never be: the reason you cannot play.

   The request is now bounded by an AbortController (PREPARE_TIMEOUT_MS, generous
   because unpacking a setlist is real work — but a CEILING, not a wait). Past it
   we start the gig and let the first play extract lazily, as it always did. The
   Play button is restored in a `finally`, so a timeout cannot strand the poster
   on "Preparing set…" with Play disabled — which would have been the same bug
   wearing a different hat.

2. THE `songs` BODY WAS UNVALIDATED.

   A str is iterable: "abc" would have prepared three one-character "songs". And
   the endpoint unpacks zips, so an arbitrary caller could ask for unbounded work.
   Now list-only, string entries, blanks dropped, capped at MAX_GIG_SONGS.

Tests: the fetch is abortable and the button is re-enabled on EVERY path
including the abort; non-list bodies, non-string/blank entries, and an
oversized setlist. 50 career tests, JS 5/5, eslint clean.

* fix(career): path-traversal guard on prepare; a cap test that actually tests the cap

CodeRabbit again, and the first one is a real hole I put there.

1. PATH TRAVERSAL. sloppak.resolve_source_dir() does a bare `dlc_root / filename`
   with NO containment guard — so `../../x` walks straight out of the library, and
   my new endpoint handed it attacker-supplied filenames. Every filename now goes
   through _resolve_dlc_path first, the same check every other filename-bound
   handler applies. Pinned: `..`, backslash traversal, an absolute POSIX path and
   a Windows drive path are all refused, and nothing outside the library is
   unpacked.

2. THE CAP TEST WAS VACUOUS. It asserted `prepared == 0` against a fixture with no
   library — where the endpoint exits before extraction — so it passed whether or
   not MAX_GIG_SONGS existed. It now runs against a real library and asserts the
   endpoint CONSIDERED at most MAX_GIG_SONGS of the 82 it was handed. Verified to
   fail when the cap is removed.

   Same class of mistake as the notedetect gigBlock: a test that passes for the
   wrong reason. Worth saying out loud since it is twice in one day.

3. E702 — semicolon-joined statements in the new tests, split.

51 career tests; full suite green.
2026-07-15 00:36:20 +02:00
Byron GamatosandGitHub 917d81c2d2 fix(highway): a SUPERSEDED renderer init is not a FAILED one (#970)
Starting a gig dropped the player onto the fallback 2D highway with no venue.

startGig() calls setViz('venue'), which installs the 3D renderer — whose init is
async — and then immediately starts its play queue. playSong() re-initialises
that same renderer a tick later. A renderer mints a fresh readyPromise per
init() and rejects the previous one with "superseded"; highway.js only checked
that the RENDERER OBJECT was unchanged, which it is. So it treated a healthy,
re-initialising renderer as a failed one, tore it down, and reverted to 2D:

    renderer async init failure: Error: superseded
    viz picker: reverted to default renderer (async-init-failure)

The guard now also checks the PROMISE identity: a rejection from an init cycle
the renderer has already moved on from is ignored. The renderer-identity guard
stays (a rejection for a renderer since REPLACED is also not ours), and a
genuine failure of the CURRENT cycle still reverts — both init() call sites go
through _setRenderer, which re-wires the handler every time, so the new cycle is
always watched.

Reproduced and fixed against the real build:

    before:  vizSelection=default  viz-picker=default  venue=inactive  viz:reverted
    after:   vizSelection=venue    viz-picker=venue    venue=ACTIVE    (no revert)

Also widens the paused-frame throttle's opt-out. The throttle fires whenever the
CHART CLOCK is stalled — not only on a pause, but through a count-in and the
credits/author overlay too. Its opt-out only asked "is a crowd video rolling",
but the venue scene animates on a clock of its own with no pack at all (backdrop
breathe, parallax, haze drift, warmth pulse — Math.sin(t) in the draw loop), so
that motion was still being throttled. It now claims frames for both sources; a
plain 3D highway with no venue reads motion mode 'off' and keeps the #654 GPU
saving.

HONEST CAVEAT on that second part: I could not get the throttle to fire in a
reproduction. A control run on the shipped code showed 100 draws/sec while
paused, not the ~10/sec a firing throttle would give — so the change is
defensible on its own terms (a stalled clock is genuinely not a static picture)
but it does NOT have a demonstrated symptom behind it. The viz fix above does.

Tests: the superseded guard, and that the throttle opt-out covers both motion
sources. All fail against the pre-fix source. eslint 0 errors; JS 1207/1207.
2026-07-15 00:36:16 +02:00
Byron GamatosandGitHub 939c98214b feat(song-info): publish the playable stem list so stems can preload (fixes the 698ms freeze) (#972)
* feat(song-info): publish the playable stem list, so stems can preload

The stems plugin could only learn its stem list from the highway's WS `ready`,
which arrives once the highway is already up. So it fetched, decoded, and then
handed every stem's PCM to its audio worklet — copying the WHOLE SONG — with the
player already on screen.

For a 4-minute 6-stem pack that is over half a GIGABYTE of memcpy, in one frame,
on the main thread. Measured on a real load: a 698 ms frame, right as the
song-credits card appeared, with the venue video visibly stopping. That is the
"the video pauses when the author appears" report.

GET /api/song/{f}?stems=1 now returns the same list — [{id, url, default}] plus
full_mix_url — so the plugin can start the whole load at `song:loading`, before
the highway (and the venue) is drawn, where a stalled frame costs nothing.
Nothing about the work changes; only WHEN.

Opt-in via the query param so the library's own metadata calls — the hot path —
pay nothing. Deliberately NOT stored in the metadata cache: that is a
fixed-column table, and widening it would mean a schema migration plus a stale
row for every song already scanned, to cache something that is a plain manifest
read on an already-unpacked pack.

The safety property: REST and the WS must publish the SAME list. If they
disagreed the plugin would preload a graph and then throw it away and rebuild —
strictly worse than not preloading. So both now resolve `default` through one
shared helper (stem_default_on, extracted from load_song), and a test rebuilds
the WS's payload from load_song and requires the REST helper to produce the
identical list, rather than pinning either against a snapshot.

Also pinned: the mixdown is lifted OUT of the stem list (spec 5.3 — `full` is
not a layer; listing it beside the instruments would play the whole song on top
of the stems) while staying reachable as full_mix_url, a single-`full` pack keeps
it as its only playable stem, and an unreadable pack yields an empty list rather
than failing the request. Full suite 2608 passed.

Consumed by feedBack-plugin-stems (preloadSong).

* fix(song-info): call load_song for the stem payload — do not reimplement it

CodeRabbit caught a real bug, and it would have hit most real libraries.

load_song() falls back to the DEPRECATED `original_audio:` key when a pack has no
reserved `full` stem — which is every pack written before feedpak 1.15.0. My
payload rebuilt the full-mix rule from extract_meta and returned None for those:
REST would say "no full mix" while the WS said there was one.

Worse than a wrong field: the plugin would preload a graph WITHOUT the pristine
mix and — because the stem signature still matched — never rebuild. Unity
playback would silently downgrade to the lossy stem recombination.

That is exactly the drift this PR claims to prevent, and my test had a hole: I
only covered packs that carry a `full` stem.

So stop reimplementing. The payload now calls load_song, whose LoadedSloppak
already carries the partitioned stems and the resolved full mix, and builds the
URLs exactly as ws_highway does. Drift is now impossible by construction rather
than by agreement. extract_meta is reverted to its original shape (it never
needed to change), and the shared stem_default_on helper stays as the one place
`default: off` is resolved.

Tests rewritten to compare against load_song — the WS's own function — for a
reserved-`full` pack, a LEGACY original_audio pack (the case that was broken), and
a single-`full` pack. Also documents the `?stems=1` contract in CHANGELOG.md.
Full suite green.
2026-07-15 00:36:13 +02:00
37 changed files with 3627 additions and 185 deletions
+16
View File
@@ -46,6 +46,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
carry their gig log; instruments their gig count.
### Changed
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
`ready` sends. The stems plugin could only learn it from that WS message, which
arrives once the highway is already on screen — so it decoded and then copied the
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
song-credits card appeared. With the list available at `song:loading` the plugin
does all of it before the highway is drawn. Built by calling `load_song` itself, so
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
nothing.
- **Folder library renders only the songs on screen** (#965) — a song list used to
render *every* song it held. On a flat 50,944-song library that was one `<div>`
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
@@ -202,6 +212,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
The floor geometry now ends at the hit line; its far edge is unchanged, still
`-AHEAD*TS` at the note horizon.
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
+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,
+262 -33
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,13 +36,91 @@ 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 ─────────────────────────────────────────────────────
@@ -381,7 +461,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 +499,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)
@@ -2810,16 +2927,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 +2971,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 +2987,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 +3002,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 +3489,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 +3597,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 +3610,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 +3755,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 +4048,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 +4079,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 +4090,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 +4131,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 +4232,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 +4267,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 +4387,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 +4408,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 +4423,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 +4433,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 +4469,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 +4502,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 +4524,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 +4537,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 +4569,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 +4598,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 +4611,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")
+69 -5
View File
@@ -829,9 +829,60 @@ def post_song_gap_fill(filename: str, data: dict):
return {"ok": True, "written": additions, "skipped": skipped}
def _playable_stems_payload(filename: str, dlc) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Why it exists: the stems plugin could only learn its stem list from the
highway's WS `ready`, which arrives once the highway is already up. So it
decoded, and then copied the whole song's PCM to its worklet, with the player
on screen half a gigabyte of memcpy in one frame, ~700 ms, freezing the
venue video. Given the list at `song:loading` it can do all of that BEFORE the
highway appears, behind the loading overlay where a stall costs nothing.
The list MUST be the same one the WS sends a moment later. If it is not, the
plugin preloads a graph and then throws it away and rebuilds strictly worse
than not preloading. So this does not reimplement the WS's construction, it
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
partitioned stems and the resolved full mix, and then builds the URLs exactly
as ws_highway does. Drift is impossible by construction rather than by
agreement which matters, because `full_mix` in particular is not simply the
`full` stem: load_song falls back to the deprecated `original_audio:` key for
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
first) silently dropped the pristine full mix for most real libraries.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
to preload: load_song raises and we return the empty list.
"""
from urllib.parse import quote
try:
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
def _url(rel: str) -> str:
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"]}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
async def get_song_info(filename: str, stems: int = 0):
"""Return song metadata, from cache or by extracting it from the song source.
`?stems=1` additionally returns the playable stem list with URLs, so the
stems plugin can start fetching/decoding on `song:loading` instead of waiting
for the highway's WS `ready` (see _playable_stems_payload).
"""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
@@ -854,8 +905,21 @@ async def get_song_info(filename: str):
mtime, size = appstate.stat_for_cache(song_path)
cached = appstate.meta_db.get(cache_key, mtime, size)
loop = asyncio.get_event_loop()
# The stem list is NOT stored in the metadata cache: that is a fixed-column
# table, and widening it would mean a migration plus a stale row for every
# song already scanned. It is cheap to read on demand (the pack is unpacked
# by then, so this is a plain manifest read), and only the opt-in caller pays.
async def _with_stems(meta: dict) -> dict:
if not stems:
return meta
extra = await loop.run_in_executor(
None, _playable_stems_payload, filename, dlc)
return {**meta, **extra}
if cached:
return cached
return await _with_stems(cached)
# Extract in thread pool
def _extract():
@@ -863,5 +927,5 @@ async def get_song_info(filename: str):
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
meta = await loop.run_in_executor(None, _extract)
return await _with_stems(meta)
+169 -5
View File
@@ -51,6 +51,120 @@ from scan_worker import _relpath, _scan_one
log = logging.getLogger("feedBack.scan")
import json
# ── Directory-signature fast path ─────────────────────────────────────────────
#
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
# file to detect what changed. On a 50k-song library that lives on a slow mount
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
# the "big drive churns on every startup" report.
#
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
# holds them (verified on the target NTFS-3G mount), and so does the addition of
# a subdirectory (a new entry in its parent). So after a scan we record every
# library directory and its mtime; on the next scan we re-stat ONLY those
# directories (a handful, vs 100k file ops). If none changed, the file set is
# unchanged and the whole listing/stat pass is skipped.
#
# The one thing this cannot see is a file edited IN PLACE under the same name —
# that bumps the file's mtime but not its directory's. That is rare for a song
# library (you add and remove packs, you don't rewrite them under the same name),
# and the manual Refresh forces a full scan (force=True) for exactly that case.
def _dir_signature_file() -> Path:
return appstate.config_dir / "scan_dir_signature.json"
def _load_dir_signature() -> dict | None:
try:
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
return data
except (OSError, ValueError):
pass
return None
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
# Keyed by the DLC path so switching libraries never matches a stale
# signature. Best-effort: a failed write just means the next scan is a full
# one, never a wrong one.
try:
_dir_signature_file().write_text(
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
except OSError as e:
log.debug("scan: could not persist dir signature: %s", e)
def _library_dirs(all_songs, dlc: Path) -> set[str]:
"""Every directory whose mtime reflects an add/remove of a library song:
each song's containing directory and all of its ancestors up to the DLC
root (the root itself always included, as "."). Derived from the already-
listed songs no extra filesystem walk. The builtin carve-outs
(tutorials-builtin / minigames-builtin) are absent because the caller
already excluded them from `all_songs`, so a minigame writing a drill there
never invalidates the fast path.
Directory-form songs (loose-song folders, directory sloppak bundles) also
record their OWN directory: a file added/removed/replaced INSIDE the folder
bumps that folder's mtime but not its parent's, so tracking only the parent
would miss an in-place change to such a song. File-form sloppaks (a single
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
stays at a handful of dir stats."""
rels = {"."}
for f in all_songs:
rel = Path(_relpath(f, dlc))
if f.is_dir():
rels.add(rel.as_posix())
parent = rel.parent
rels.add(parent.as_posix())
for anc in parent.parents:
rels.add(anc.as_posix())
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
_save_dir_signature(dlc, sig)
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
unreadable a vanished recorded dir means the tree changed, so fail to a
full scan rather than a false match."""
out: dict[str, int] = {}
for rel in rels:
try:
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
except OSError:
return None
return out
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
@@ -99,9 +213,12 @@ def _make_scan_executor():
)
def background_scan():
def background_scan(force: bool = False):
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full
listing/stat pass the manual Refresh sets it (see _dir_signature_file).
Never sets `_scan_status["running"] = False` ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner.
@@ -121,6 +238,22 @@ def background_scan():
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
# Fast path: if every library directory recorded by the last scan still has
# the same mtime, nothing was added, removed, or renamed, so the whole
# glob-and-stat pass below can be skipped (see the signature comment above).
# `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 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())
if current is not None and current == stored["dirs"]:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
len(current))
return
# Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently
# appearing to scan nothing.
@@ -209,6 +342,15 @@ def background_scan():
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"]
):
@@ -223,6 +365,9 @@ def background_scan():
to_scan.append((f, mtime, size, dlc))
if not to_scan:
# Full pass completed with the DB already up to date — record the tree
# signature so the next startup can take the fast path.
_record_dir_signature(all_songs, dlc)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
return
@@ -247,6 +392,9 @@ def background_scan():
_scan_status["done"] += 1
_scan_status["current"] = fname
# Record the tree signature after a completed full pass so the next startup
# can skip it when nothing has changed.
_record_dir_signature(all_songs, dlc)
log.info("Scan complete: %d songs cached", len(to_scan))
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
@@ -255,6 +403,9 @@ _scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared
@@ -265,9 +416,15 @@ _scan_rescan_pending = False
_scan_thread: threading.Thread | None = None
def kick_scan() -> bool:
def kick_scan(force: bool = False) -> bool:
"""Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the
manual Refresh uses it so an in-place same-name edit the one thing the
fast path can't see — is always picked up). A forced request that coalesces
onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it.
Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload
@@ -275,8 +432,10 @@ def kick_scan() -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up.
"""
global _scan_rescan_pending, _scan_thread
global _scan_rescan_pending, _scan_thread, _scan_force_next
with _scan_kick_lock:
if force:
_scan_force_next = True
if _scan_status["running"]:
_scan_rescan_pending = True
return False
@@ -290,10 +449,15 @@ def kick_scan() -> bool:
def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending
global _scan_rescan_pending, _scan_force_next
while True:
# Consume the force flag for THIS pass; a forced request queued mid-scan
# sets it again for the follow-up.
with _scan_kick_lock:
forced = _scan_force_next
_scan_force_next = False
try:
background_scan()
background_scan(force=forced)
except Exception:
log.exception("background scan failed unexpectedly")
+65 -3
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,15 +47,69 @@ 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)
offsets = meta.pop("tuning_offsets", None) or [0] * 6
name = tuning_name(offsets)
# Naming needs the instrument: six offsets could be a guitar OR a
# 6-string bass, whose lowest string is B rather than E.
name = tuning_name(offsets, is_bass=bool(meta.pop("tuning_is_bass", False)))
meta["tuning"] = name
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.
@@ -81,11 +139,15 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
# inside DLC_DIR.
meta = loosefolder_mod.extract_meta(path, dlc_root=dlc_root)
offsets = meta.pop("tuning_offsets", None) or [0] * 6
name = tuning_name(offsets)
# Naming needs the instrument: six offsets could be a guitar OR a
# 6-string bass, whose lowest string is B rather than E.
name = tuning_name(offsets, is_bass=bool(meta.pop("tuning_is_bass", False)))
meta["tuning"] = name
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
+67 -10
View File
@@ -80,6 +80,20 @@ def find_full_mix(stems: list[dict]) -> dict | None:
)
def stem_default_on(raw) -> bool:
"""Whether a manifest stem entry plays by default.
Absent means on. A string is honoured so a hand-written manifest can say
`default: off`. Extracted so the WS `ready` payload and the REST song-info
payload cannot drift: the stems plugin now preloads from REST and then has
to agree with what the WS says a moment later, or it would rebuild the whole
graph for nothing.
"""
if isinstance(raw, str):
return raw.lower() not in ("off", "false", "0", "no")
return bool(raw)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
@@ -1100,12 +1114,11 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
stems.append({
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
@@ -1214,17 +1227,54 @@ def load_song(
def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
"""Best-effort guitar-first tuning for the library index."""
offsets, _ = _tuning_for_meta_kind(arrangements_manifest)
return offsets
def _tuning_for_meta_kind(
arrangements_manifest: list[dict],
) -> tuple[list[int], bool]:
"""`_tuning_for_meta` plus whether the tuning came from a BASS part.
The caller names the tuning, and naming needs the instrument: a
6-string bass has six offsets exactly like a 6-string guitar but its
lowest string is B, so the guitar ladder mislabels it (all-zeros reads
"E Standard" when it is Standard/B; a whole step down reads "D
Standard" when it is A Standard). Guitar parts still win the tuning
itself this only reports which kind supplied it.
"""
for entry in arrangements_manifest:
name = str(entry.get("name", "")).lower()
tun = entry.get("tuning")
if tun and isinstance(tun, list) and name in ("lead", "rhythm", "combo"):
return list(tun)
return list(tun), False
# Fallback: first arrangement with a tuning
for entry in arrangements_manifest:
tun = entry.get("tuning")
if tun and isinstance(tun, list):
return list(tun)
return [0] * 6
return list(tun), "bass" in str(entry.get("name", "")).lower()
return [0] * 6, False
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:
@@ -1248,7 +1298,10 @@ def extract_meta(path: Path) -> dict:
a["index"] = i
has_lyrics = bool(manifest.get("lyrics"))
tuning_offsets = _tuning_for_meta(arr_list)
tuning_offsets, tuning_is_bass = _tuning_for_meta_kind(arr_list)
# Per-role tunings alongside the song-level one.
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] = []
@@ -1287,6 +1340,10 @@ 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,
# Song-level naming also needs the instrument for bass-only packs.
"tuning_is_bass": tuning_is_bass,
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
+337 -16
View File
@@ -69,21 +69,49 @@ TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
"Drop C": [24, 31, 36, 41],
"BEAD": [23, 28, 33, 38],
},
# 5- and 6-string basses are named off their ACTUAL lowest string (the low
# B), exactly like the 7-string guitar table above — not off the 4-string
# core. The old names (Eb/D/C#/C Standard) came from the band-level habit
# of saying "we're in D standard" (which describes the guitars); the
# bassist in that band is in A standard. They survive as aliases below so
# saved profiles migrate instead of being rejected.
"bass-5": {
"Standard": [23, 28, 33, 38, 43],
"High C": [28, 33, 38, 43, 48],
"Eb Standard": [22, 27, 32, 37, 42],
"D Standard": [21, 26, 31, 36, 41],
"C# Standard": [20, 25, 30, 35, 40],
"C Standard": [19, 24, 29, 34, 39],
"Bb Standard": [22, 27, 32, 37, 42],
"A Standard": [21, 26, 31, 36, 41],
"G# Standard": [20, 25, 30, 35, 40],
"G Standard": [19, 24, 29, 34, 39],
"Drop A": [21, 28, 33, 38, 43],
},
"bass-6": {
"Standard": [23, 28, 33, 38, 43, 48],
"Eb Standard": [22, 27, 32, 37, 42, 47],
"D Standard": [21, 26, 31, 36, 41, 46],
"C# Standard": [20, 25, 30, 35, 40, 45],
"C Standard": [19, 24, 29, 34, 39, 44],
"Bb Standard": [22, 27, 32, 37, 42, 47],
"A Standard": [21, 26, 31, 36, 41, 46],
"G# Standard": [20, 25, 30, 35, 40, 45],
"G Standard": [19, 24, 29, 34, 39, 44],
},
}
# Superseded preset names, per key → current name. The 5/6-string bass rows
# were originally named off the 4-string core (so a whole-step-down 6-string,
# whose lowest string is A, read "D Standard"). Renaming alone would make
# `_valid_tuning_for_key` REJECT a saved profile carrying the old name — it
# refuses names that belong to a different key's built-ins, and "D Standard"
# still exists for guitar-6/bass-4. These aliases keep those profiles valid
# and migrate them to the corrected name.
TUNING_PRESET_ALIASES: dict[str, dict[str, str]] = {
"bass-5": {
"Eb Standard": "Bb Standard",
"D Standard": "A Standard",
"C# Standard": "G# Standard",
"C Standard": "G Standard",
},
"bass-6": {
"Eb Standard": "Bb Standard",
"D Standard": "A Standard",
"C# Standard": "G# Standard",
"C Standard": "G Standard",
},
}
@@ -229,6 +257,12 @@ def _valid_tuning_for_key(key: str, tuning):
return None
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
return tuning
# A superseded name for THIS key migrates to its current spelling
# (the 5/6-string bass rename). Checked before the cross-key
# rejection below, which would otherwise refuse it.
renamed = TUNING_PRESET_ALIASES.get(key, {}).get(tuning)
if renamed:
return renamed
# A name that IS a built-in preset for a different key is a misapplied
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
# reject it. A name unknown to every built-in table is a provider/custom
@@ -416,21 +450,308 @@ 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.
#
# Five-element arrays are unambiguously extended-range. Six elements remain
# ambiguous because legacy four-string charts are padded to six; preserve that
# legacy interpretation except for a uniform non-zero down-tuning, which cannot
# be padding (the padded tail would be zero) and is the common extended-range
# case that motivated the fix. Ambiguous all-zero and drop-shaped six-element
# arrays stay conservative until the manifest carries an explicit string count.
BASS_DEFAULT_STRING_COUNT = 4
# Standard tunings (all six strings same offset)
standard = {
# 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 can be padded. Five entries are unambiguously extended-range.
# Six are ambiguous: legacy four-string data pads with zeroes, while a
# uniform non-zero down-tuning across all six strings proves the tail is
# authored. Keep every other six-element shape conservative at four.
if persp.truncate:
if len(vals) == 5:
return vals
if len(vals) == 6 and vals[0] != 0 and len(set(vals)) == 1:
return vals
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, is_bass=persp.instrument == "bass")
def _perspective_instrument_key(
offsets: list[int], persp: TuningPerspective,
) -> str:
"""Instrument key matching the normalized tuning's proven string count."""
if persp.instrument == "bass" and f"bass-{len(offsets)}" in STANDARD_OPEN_MIDIS:
return f"bass-{len(offsets)}"
return persp.instrument_key
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(_perspective_instrument_key(offsets, persp), 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(_perspective_instrument_key(offsets, persp), 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], *, is_bass: bool = False) -> str:
"""Display name for a set of per-string offsets.
`is_bass` is load-bearing, not cosmetic: string COUNT alone cannot
identify the instrument. A 6-string BASS has six offsets just like a
6-string guitar, but its lowest string is B, not E so the guitar
ladder labels an all-zeros bass "E Standard" (it is B/Standard) and a
whole-step-down bass "D Standard" (it is A Standard). That is exactly
the error the 7-string comment below warned about, on the axis nobody
guarded. Callers that know the instrument must say so; the default
stays guitar for backward compatibility.
All the pattern checks are gated on the expected string count. The
guitar conventions 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 guitar content falls through to the numeric
fallback. See #43.
"""
# Standard tunings (all strings same offset), named off the LOWEST
# string. Bass 4-string sits on the E ladder like a guitar; bass 5/6
# add a low B, so they sit on the B ladder — the same convention the
# 7-string guitar presets use.
guitar_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",
}
bass_low_b_standard = {
0: "Standard", -1: "Bb Standard", -2: "A Standard",
-3: "G# Standard", -4: "G Standard", -5: "F# Standard",
1: "C Standard", 2: "C# Standard",
}
# A four-offset array is unambiguously a bass tuning; preserve the
# historical one-argument behavior used by the library perspective.
if len(offsets) == 4:
is_bass = True
if is_bass:
# 4-string bass is E-A-D-G — the guitar ladder's low four, so it
# keeps the E-based names. 5/6-string add the low B.
table = guitar_standard if len(offsets) == 4 else bass_low_b_standard
if len(offsets) in (4, 5, 6) and all(o == offsets[0] for o in offsets):
name = table.get(offsets[0])
if name:
return name
# Drop tunings: the lowest string alone goes down 2 semitones.
if (len(offsets) in (4, 5, 6)
and offsets[0] == offsets[1] - 2
and all(o == offsets[1] for o in offsets[1:])):
base = STANDARD_OPEN_MIDIS.get(f"bass-{len(offsets)}")
if base:
low = base[0] + offsets[0]
names = ["C", "C#", "D", "Eb", "E", "F",
"F#", "G", "Ab", "A", "Bb", "B"]
return f"Drop {names[low % 12]}"
if not offsets:
return "Unknown"
return "Custom Tuning"
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
name = standard.get(offsets[0])
name = guitar_standard.get(offsets[0])
if name:
return name
+90 -17
View File
@@ -46,6 +46,8 @@ from pathlib import Path
from fastapi import Body, HTTPException
from fastapi.responses import FileResponse
import sloppak
from dlc_paths import _resolve_dlc_path
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
@@ -53,6 +55,9 @@ VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
@@ -517,27 +522,39 @@ def _current_venue():
return best
def _unplayed_genre_songs(gkey, exclude, limit):
"""Library songs of a genre with no stats yet — a young passport's gig
still gets a full set (playing them is how stubs start).
ponytail: full stat-less scan + python-side genre match (a few ms at 7k
songs, single-user); push the match into SQL if propose ever feels slow."""
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs "
"WHERE filename NOT IN (SELECT filename FROM song_stats)"
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
out = []
for filename, title, artist, genre in rows:
if _genre_key(genre) != gkey or filename in exclude:
continue
out.append({"filename": filename, "title": title or filename,
"artist": artist or ""})
if len(out) >= limit:
break
return out
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
@@ -734,6 +751,62 @@ def setup(app, context):
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
root = Path(dlc_root)
prepared, failed = 0, []
for fn in files:
# CONTAINMENT FIRST. resolve_source_dir() does a bare
# `dlc_root / filename` with no guard, so a crafted `../..` would
# walk straight out of the library. Every other filename-bound
# handler validates through _resolve_dlc_path; so does this one.
safe = _resolve_dlc_path(root, fn)
if safe is None:
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
failed.append(fn)
continue
try:
sloppak.resolve_source_dir(fn, root, Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
@@ -775,7 +848,7 @@ def setup(app, context):
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_unplayed_genre_songs(gkey, exclude, size - len(picks)))
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
+62 -2
View File
@@ -12,6 +12,10 @@
'use strict';
const API = '/api/plugins/career';
// Unpacking a setlist is real work (zips, possibly on a slow/network drive),
// so this is generous — but it is a CEILING, not a wait. Past it we start the
// gig and let the first play extract lazily, as it always did.
const PREPARE_TIMEOUT_MS = 60000;
const VENUE_OVERRIDE_KEY = 'feedBack-career-venue';
const NO_VENUE = '__none__';
const PREV_VIZ_KEY = 'feedBack-career-prev-viz';
@@ -1123,10 +1127,52 @@
sfx('page');
}
function startGig() {
// Unpack the whole set before the first note.
//
// A feedpak is a zip, and the first play of one pays for its extraction. In
// a set that cost landed BETWEEN songs: the player finished a number and
// then sat there waiting for the next one to unpack, mid-gig. The setlist is
// known up front, so warm it all while the poster is still on screen.
//
// Best-effort by design: a library that won't pre-extract must not stop the
// gig from starting — the play itself surfaces the error the same way it
// does outside a gig. Slow is better than blocked.
async function prepareGigSongs(prop, btn) {
const label = btn && btn.textContent;
if (btn) { btn.disabled = true; btn.textContent = 'Preparing set…'; }
// A bare `await fetch(...)` only rejects on a network ERROR — a server
// that accepts the connection and then never answers hangs forever, and
// the gig would never start. That would make this optimisation the very
// thing it promises never to be: the reason you cannot play. Give up
// waiting and let the first play extract lazily, exactly as before.
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), PREPARE_TIMEOUT_MS);
try {
await fetch(`${API}/gigs/prepare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ songs: prop.songs.map((s) => s.filename) }),
signal: ctrl.signal,
});
} catch (_) {
// abort, offline, non-2xx — all the same: start the gig anyway.
} finally {
clearTimeout(timer);
if (btn) { btn.disabled = false; if (label) btn.textContent = label; }
}
}
async function startGig(btn) {
const prop = _ppGigProposal;
const q = window.feedBack && window.feedBack.playQueue;
if (!prop || !q || typeof q.start !== 'function' || typeof window.playSong !== 'function') return;
// Extract the setlist BEFORE the stage is borrowed and the queue starts,
// so a failure here leaves nothing half-applied to unwind.
await prepareGigSongs(prop, btn);
// The poster's Play could have been cancelled while we were unpacking.
if (_ppGigProposal !== prop) return;
// The gig BORROWS the stage: stash whatever venue/viz the user had so
// the set ending gives it back (unlike "Play here", which is an
// explicit persistent choice on the venue card).
@@ -1146,7 +1192,21 @@
if (typeof window.setViz === 'function') window.setViz('venue');
} catch (_) { /* viz optional — restore stays intact */ }
}
// Push the gig's venue pack to the crowd layer NOW.
//
// crowd.setManifest(venue) is reached only through pushCrowdManifest,
// and pushCrowdManifest is called only from refresh() — the career
// tab's own reload. A gig navigates AWAY from the career tab to the
// player, so refresh() never runs during it, and setting the override
// above does nothing on its own. The result the testers saw: the venue
// visualization turns on (3D highway) but its crowd/stage pack never
// loads, so the song plays over the bare highway backdrop ("standard
// particles"), or over whatever venue a previous refresh() happened to
// leave applied. We just changed the override to this gig's venue, so
// re-push for it. _state is the career state the booking screen already
// fetched; guard for the rare null.
_appliedManifestVenue = null;
if (_state) pushCrowdManifest(_state);
_ppGigRun = {
songs: prop.songs,
venue_id: prop.venue_id,
@@ -1424,7 +1484,7 @@
}
const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
if (e.target.closest('[data-pp-gig-reroll]')) {
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
return;
+45 -13
View File
@@ -12614,8 +12614,19 @@
const tC = now + (dt0 + dt1) * 0.5 - BEHIND;
const b = laneBoundsFromAnchor(getChartAnchorAt(anchors, tC));
if (!b) continue;
const z0 = dZ(dt0) + TS * BEHIND;
const z1 = dZ(dt1) + TS * BEHIND;
// The lane STOPS AT THE HIT LINE (z = 0) — issue #991. The
// slice window starts BEHIND seconds in the past, so the
// first slices map to positive z, i.e. past the hit line
// toward the player. Nothing is ever drawn there: notes and
// chord frames clamp to Math.min(0, dZ(dt)), so that strip
// is lane with nothing on it. Clamp the NEAR edge only —
// the far edge stays at dZ(AHEAD+BEHIND)+TS*BEHIND = -AHEAD*TS,
// aligned with the note horizon, exactly as before.
const z0 = Math.min(0, dZ(dt0) + TS * BEHIND);
const z1 = Math.min(0, dZ(dt1) + TS * BEHIND);
// Slice lies entirely past the hit line -> zero length, nothing
// to draw. Skip before the arp probe so it costs nothing.
if (z0 === z1) continue;
const arpSlice = (laneRailArpHsFlags && handShapesRails && handShapesRails.length)
? arpeggioLaneOuterRailLaneSlice(
dt0, dt1, now,
@@ -12764,9 +12775,13 @@
divMin = dMin;
divMax = dMax;
// Same fix: extend to AHEAD+BEHIND so far edge = -AHEAD*TS.
const laneLen = TS * (AHEAD + BEHIND);
const zLane = -laneLen / 2 + TS * BEHIND;
// Far edge at -AHEAD*TS (the note horizon), near edge at the
// hit line (z = 0) — the lane does not run past it toward the
// player, where nothing is ever drawn (#991). Spanning
// AHEAD+BEHIND and shifting by +TS*BEHIND put the near edge at
// +TS*BEHIND; spanning AHEAD alone keeps the same far edge.
const laneLen = TS * AHEAD;
const zLane = -laneLen / 2;
const laneOp = (HWY_LANE_STRIPE_OP_BASE + highwayIntensity * HWY_LANE_STRIPE_OP_INT)
* (_venueSceneOverride ? VENUE_LANE_OP_BOOST : 1);
mLaneOdd.opacity = laneOp;
@@ -12786,7 +12801,8 @@
}
if (highwayIntensity > 0.05) {
const divLen = TS * (AHEAD + BEHIND);
// Matches the lane above: ends at the hit line (#991).
const divLen = TS * AHEAD;
const yPos = boardY + 0.03 * K;
const divOp2 = 0.02 + highwayIntensity * 0.1;
const divOpArp2 = Math.min(0.92, 0.16 + highwayIntensity * 0.42);
@@ -12799,7 +12815,7 @@
for (let f = fDivA; f <= fDivB; f++) {
if (hwyLaneArpOuterDividers && (f === fDivA || f === fDivB)) continue;
const div = pLaneDivider.get();
div.position.set(xFret(f), yPos, dZ(0) - divLen * 0.5 + TS * BEHIND);
div.position.set(xFret(f), yPos, -divLen * 0.5);
div.material = mLaneDivider;
div.scale.set(1, 1, divLen);
div.renderOrder = 2;
@@ -12818,8 +12834,10 @@
// ── Fret boundary extension lines ─────────────────────────
if (mLaneDividerExt && fretDividersVisible) {
const extLaneLen = TS * (AHEAD + BEHIND);
const extZMid = -extLaneLen / 2 + TS * BEHIND;
// Same hit-line stop as the lane (#991) — otherwise these lines
// would be the only floor geometry still running past it.
const extLaneLen = TS * AHEAD;
const extZMid = -extLaneLen / 2;
const extYPos = boardY + 0.03 * K;
mLaneDividerExt.opacity = Math.max(0.3, 0.3 + highwayIntensity * 0.15);
for (let f = 0; f <= NFRETS; f++) {
@@ -15398,15 +15416,29 @@
// highway throttled the whole room. Pausing the song dropped the
// venue, the crowd and the stage to 10 fps.
//
// Only claim continuous frames while a crowd video is actually
// rolling: with no venue pack (the common case) the paused scene IS
// static and the throttle should still save the GPU.
// Two independent sources of motion, and BOTH must keep their frames:
//
// • a crowd video rolling on its own clock (career venue pack), and
// • the venue scene's own fake-depth motion — the backdrop breathes,
// the haze drifts, warmth pulses, the shimmer moves. That is
// Math.sin(t) in the draw loop (see _venueApplyFakeDepthMotion),
// so it only moves while we are actually given frames, and it runs
// with NO pack at all.
//
// The throttle fires whenever the CHART CLOCK is stalled — which is
// not just a pause. A count-in and the credits/author overlay stall it
// exactly the same way, so the venue was stuttering there too.
//
// With no venue at all (plain 3D highway) the paused scene really is a
// still picture: motion mode reads 'off', we claim nothing, and the
// throttle still saves the GPU as #654 intended.
needsContinuousFrames() {
if (!_isReady || _ctxLost) return false;
for (const v of _venueCrowdVideos) {
if (v && !v.paused && !v.ended && v.readyState >= 2) return true;
}
return false;
// 'off' also covers prefers-reduced-motion and "no venue scene".
try { return _venueEffectiveMotionMode() !== 'off'; } catch (_) { return false; }
},
draw(bundle) {
+5 -2
View File
@@ -1115,7 +1115,10 @@ async def startup_status_stream(request: Request):
@app.post("/api/rescan")
def trigger_rescan():
"""Manually trigger a library rescan."""
if not scan.kick_scan():
# force=True: a manual Refresh must skip the directory-signature fast path —
# it is the escape hatch for the one change dir mtimes can't see (a pack
# rewritten in place under the same name).
if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"}
return {"message": "Rescan started"}
@@ -1133,7 +1136,7 @@ def trigger_full_rescan():
# delete_missing() prunes anything genuinely gone at the end.
meta_db.conn.execute("UPDATE songs SET mtime = -1")
meta_db.conn.commit()
if not scan.kick_scan():
if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"}
return {"message": "Full rescan started"}
+27 -2
View File
@@ -1334,12 +1334,25 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// leaving the player still leaves — and abandons the queue.
window.feedBack.playQueue = (function () {
let list = [], idx = -1, source = '', arrangements = null;
// Set true by _play() right before it drives playSong, consumed once by
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
// signal is options.fromQueue, but a chain of plugin playSong wrappers
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
// (filename, arrangement) and silently drop the options object — so the flag
// never arrived and the queue cleared itself the instant its first song
// started (a gig/album/playlist never advanced). This flag rides beside the
// wrapper chain, not through it.
let _internalPlay = false;
const active = () => idx >= 0 && idx < list.length;
const hasNext = () => active() && idx < list.length - 1;
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
function _play(i) {
const fn = list[i];
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
// that survives wrapper chains dropping the options arg. Both set; either
// suffices. playSong runs its clear-guard synchronously at entry, and the
// wrapper chain reaches it synchronously, so the flag is still set then.
_internalPlay = true;
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
}
function start(files, opts) {
@@ -1371,6 +1384,15 @@ window.feedBack.playQueue = (function () {
}
return {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
// True when the current song is a queue ADVANCE (song 2..N of a set),
// false for its first song or a standalone play. The venue uses this to
// fly in once on arrival at the set, then continue the room between
// songs instead of replaying the arrival flyover every track.
isContinuation: function () { return active() && idx > 0; },
// One-shot: true iff _play just kicked off this playSong. Consumed on
// read so a later MANUAL play still clears the queue. playSong calls this
// instead of trusting options.fromQueue to survive the wrapper chain.
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
source: function () { return source; },
remaining: function () { return active() ? list.length - idx - 1 : 0; },
// What's coming, for consumers that RENDER the queue (a results
@@ -2297,11 +2319,14 @@ configureHost({
currentFilename: () => currentFilename,
});
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
// script and called esc() back when app.js was one too and it was an implicit
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
+16
View File
@@ -986,6 +986,22 @@ function createHighway() {
// inline arrow function.
function _handleAsyncInitFailure(e) {
if (hwState._renderer !== _installedRenderer) return;
// ...and ignore a rejection from a SUPERSEDED init cycle.
//
// A renderer mints a fresh readyPromise on every init(), and
// rejects the previous one ("superseded") when a newer init
// starts. The renderer object is unchanged, so the identity
// check above does not catch it — and we would tear down a
// perfectly healthy renderer that is merely re-initialising.
//
// This is exactly what starting a gig did: setViz('venue')
// installed the 3D renderer, then the queue's playSong()
// re-initialised it a tick later; init #1's promise rejected,
// and the gig dropped to the fallback 2D highway with the
// venue gone. A superseded init is not a failed init — the
// NEW cycle owns the outcome, and its own promise is what we
// must judge.
if (_installedRenderer.readyPromise !== rp) return;
console.error('renderer async init failure:', e);
_destroyCurrentIfInited();
hwState._renderer = _defaultRenderer;
+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)
+12 -3
View File
@@ -638,9 +638,18 @@ export let artAbortController = null;
export async function playSong(filename, arrangement, options) {
console.log('playSong called:', filename);
// A manual (non-queue) play abandons any active play-queue, so a stale queue
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.clear();
// can't hijack the next song's end. The queue signals a play it is DRIVING
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
// band). The out-of-band one exists because plugin playSong wrappers forward
// only (filename, arrangement) and drop the options object — with just the
// in-band flag, the queue cleared itself the instant its first song played
// and a gig never advanced. Consume the flag whether or not we go on to clear,
// so it can't leak into a later manual play.
const _pq = window.feedBack && window.feedBack.playQueue;
const _queueDriven = (options && options.fromQueue)
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
if (!_queueDriven && _pq) {
_pq.clear();
}
if (!options || options.bridge !== false) {
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
+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>
+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());
+21 -1
View File
@@ -529,7 +529,27 @@
_loadingLoop = null;
_fadingLoop = null;
if (_venueActive && _manifest) {
if (!playIntro()) showLoop(machine.current, FADE_MS);
// The flyover is ARRIVING at the venue, and you arrive once. Songs
// 2..N of a set (a gig / album / playlist) are a NEW song but the
// SAME arrival — the camera should not fly in from the back of the
// room before every track (tester: "it showed the flyover intro
// again" on a gig's second song). Continue the room to the new song's
// loop; only a first-song / standalone arrival flies in.
if (_isSetContinuation()) showLoop(machine.current, FADE_MS);
else if (!playIntro()) showLoop(machine.current, FADE_MS);
}
}
// Is this song load a continuation of a play queue (a set already in
// progress), rather than an arrival? True for song 2..N of a gig/album/
// playlist. The queue owns the answer; treat any error / absent queue as
// "not a continuation" so a standalone play still flies in.
function _isSetContinuation() {
try {
const q = window.feedBack && window.feedBack.playQueue;
return !!(q && typeof q.isContinuation === 'function' && q.isContinuation());
} catch (_) {
return false;
}
}
@@ -0,0 +1,63 @@
// The window globals are a THIRD-PARTY CONTRACT. Pin them.
//
// Out-of-tree plugins load their screen.js as a CLASSIC script and call these
// as bare globals. Nothing in core reads most of them, so a call-graph scan,
// ESLint's no-undef, and a grep all come back clean while the plugin breaks in
// the field. This is the frontend twin of tests/test_plugin_context_contract.py
// — same reasoning, same literal-list rule.
//
// This guard is retroactive: `esc` was an implicit global back when app.js was
// a classic script, went module-scoped in a9fce29, and got carved into
// js/dom.js in 14b4058. The re-export list at the bottom of app.js was rebuilt
// without it, and the MIDI plugin's device list threw "esc is not defined" for
// testers — reported as "MIDI Access denied", because the ReferenceError landed
// in a try/catch meant for permission failures.
//
// WHY A LITERAL LIST AND NOT A DERIVED ONE. Deriving the expected set from
// app.js would assert the code equals itself. The point is that a human has to
// look at a diff and consciously agree to change the contract.
import { test, expect } from '@playwright/test';
const PLUGIN_GLOBALS = [
'_confirmDialog', '_getArrangementNamingMode', '_libraryLocalFilename', '_librarySongArtUrl',
'_librarySongId', '_onHeaderClick', '_onNamingModeChange', '_trapFocusInModal',
'changeArrangement', 'checkPluginUpdates', 'clearLibFilters', 'clearLoop',
'deleteSelectedLoop', 'esc', 'exportDiagnostics', 'exportSettings', 'filterFavorites',
'filterLibrary', 'fullRescanLibrary', 'goFavPage', 'handleSliderInput',
'hideScanBanner', 'importSettings', 'loadPlugins', 'loadSavedLoop',
'loadSettings', 'onSectionPracticeModeChange', 'openEditModal', 'persistSetting',
'pickDlcFolder', 'pinCurrentArrangementDefault', 'playSong', 'previewDiagnostics',
'previewEditArt', 'renderGridCards', 'renderTreeInto', 'rescanLibrary',
'retuneSong', 'saveCurrentLoop', 'saveSettings', 'seekBy',
'setAvOffsetMs', 'setFavView', 'setInstrumentPathway', 'setLibView',
'setLibraryProvider', 'setLoopEnd', 'setLoopStart', 'setMastery',
'setSpeed', 'setViz', 'showScreen', 'sortFavorites',
'sortLibrary', 'syncLibrarySong', 'toggleAllArtists', 'toggleAllFavoriteArtists',
'toggleLibFilters', 'togglePlay', 'toggleSectionPracticePopover', 'uiPrompt',
'updatePlugin', 'uploadSongs',
'filterFavTreeLetter', 'filterTreeLetter', 'goFavTreePage', 'goTreePage',
];
test('plugin-facing window globals are all callable', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const missing = await page.evaluate(
(names) => names.filter((n) => typeof (window as any)[n] !== 'function'),
PLUGIN_GLOBALS,
);
expect(missing, `window globals plugins depend on are missing or not functions: ${missing.join(', ')}`).toEqual([]);
});
// The plugin call site that actually broke: esc() interpolated into a template
// string. A global that exists but doesn't escape is its own bug.
test('window.esc escapes HTML metacharacters', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
const escaped = await page.evaluate(() => (window as any).esc('<img src=x onerror=alert(1)>'));
expect(escaped).not.toContain('<img');
expect(escaped).toContain('&lt;');
});
+27
View File
@@ -116,3 +116,30 @@ test('career screen pushes the crowd manifest with a base URL', () => {
// Degrades without the crowd layer (PR1 not merged / older desktop).
assert.match(src, /typeof crowd\.setManifest !== 'function'\) return/);
});
// feedBack#… (tester): "Venue doesn't load when starting song from passport.
// Loads standard particles." crowd.setManifest(venue) is reached ONLY through
// pushCrowdManifest, and pushCrowdManifest is called ONLY from refresh() (the
// career tab's own reload). A gig navigates away from that tab, so refresh()
// never runs during it — the venue viz turns on but its crowd/stage pack never
// loads. startGig must push the manifest itself after setting the override.
test('startGig pushes the crowd manifest for the gig venue', () => {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'plugins', 'career', 'screen.js'), 'utf8');
const start = src.indexOf('async function startGig(');
assert.ok(start !== -1, 'startGig not found');
const open = src.indexOf('{', src.indexOf(')', start));
let depth = 1, i = open + 1;
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
const fn = src.slice(start, i);
// The override is set, then the manifest must be (re)pushed for it.
const overrideIdx = fn.search(/VENUE_OVERRIDE_KEY,\s*prop\.venue_id/);
const pushIdx = fn.search(/pushCrowdManifest\s*\(/);
assert.ok(overrideIdx !== -1, 'startGig must set the venue override');
assert.ok(pushIdx !== -1,
'startGig must push the crowd manifest — refresh() (its only other caller) ' +
'never runs during a gig, so the venue pack would never load');
assert.ok(overrideIdx < pushIdx, 'the manifest must be pushed AFTER the override is set to the gig venue');
});
+98
View File
@@ -0,0 +1,98 @@
// A gig is a SET, not a run of unrelated songs.
//
// Reported from a live gig: the player finished the first song and had to sit
// through the per-song results popup before the next one would start, and then
// wait again while that song was extracted from its feedpak zip.
//
// This file covers the CORE half — career pre-extracts the whole setlist before
// the first note. The other half (note_detect must not show its per-song summary
// inside a gig) lives in the note_detect plugin repo, which is not part of this
// checkout: plugins/*/ is gitignored here and note_detect ships from
// feedBack-plugin-notedetect. A test reading it from core would pass on a dev
// box (where the plugin happens to be bundled) and fail in CI, which is worse
// than no test.
//
// The pre-extraction is tested for REAL behaviour — actually unpacking zips — in
// tests/plugins/career/test_routes.py. These are the wiring guards around it.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const CAREER = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8');
const CAREER_ROUTES = fs.readFileSync(path.join(ROOT, 'plugins', 'career', 'routes.py'), 'utf8');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
assert.ok(start !== -1, `signature '${signature}' 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++;
}
assert.ok(depth === 0, `unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
test('startGig extracts the whole setlist before starting the queue', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const startIdx = fn.search(/q\.start\s*\(/);
assert.ok(prepIdx !== -1, 'startGig must pre-extract the set');
assert.ok(startIdx !== -1, 'q.start not found');
assert.ok(prepIdx < startIdx,
'the set must be unpacked BEFORE the queue starts — otherwise the player ' +
'waits between songs, which is the bug');
});
test('the stage is only borrowed once the set is ready', () => {
const fn = extractBlock(CAREER, 'async function startGig(');
const prepIdx = fn.search(/await\s+prepareGigSongs\s*\(/);
const stageIdx = fn.search(/VENUE_OVERRIDE_KEY/);
assert.ok(prepIdx < stageIdx,
'a gig cancelled while unpacking must not leave the venue/viz overwritten');
assert.match(fn, /_ppGigProposal\s*!==\s*prop/,
'a proposal dismissed while unpacking must not then start a gig');
});
test('pre-extraction never blocks the gig from starting', () => {
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
assert.match(fn, /catch\s*\(/,
'a failed prepare must fall through to the old lazy extraction, not abort the gig');
});
test('the prepare route degrades instead of failing', () => {
assert.match(CAREER_ROUTES, /def prepare_gig/, 'prepare route missing');
assert.match(CAREER_ROUTES, /context\.get\(\s*["']get_dlc_dir["']\s*\)/,
'a host without the library resolvers must degrade, not 500 — pre-extraction ' +
'is an optimisation and can never be why a gig will not start');
});
// ── the prepare must never be able to BLOCK the gig (CodeRabbit, #971) ──────
//
// A bare `await fetch(...)` only rejects on a network error. A server that
// accepts the connection and then never answers hangs forever — and the gig
// would never start. That would make this optimisation the exact thing it
// promises never to be: the reason you cannot play.
test('the prepare fetch is bounded — a hung server cannot block the gig', () => {
const fn = extractBlock(CAREER, 'async function prepareGigSongs(');
assert.match(fn, /AbortController/, 'the request must be abortable');
assert.match(fn, /setTimeout\([\s\S]{0,40}abort\s*\(\s*\)/,
'a hung request must be aborted, not awaited forever');
assert.match(fn, /signal:\s*ctrl\.signal/, 'the signal must actually be passed to fetch');
assert.match(fn, /clearTimeout/, 'the timer must be cleared on the happy path');
assert.match(CAREER, /const\s+PREPARE_TIMEOUT_MS\s*=\s*\d+/, 'the ceiling must be named');
// The button must be restored however we leave — otherwise a timeout strands
// the poster on "Preparing set…" with Play disabled: unplayable.
assert.match(fn, /finally\s*\{[\s\S]{0,220}btn\.disabled\s*=\s*false/,
'the Play button must be re-enabled on EVERY path, including the abort');
});
+44 -5
View File
@@ -114,13 +114,52 @@ test('the capability probe fails closed (absent / non-function / throwing)', ()
'only an explicit true opts out — a truthy accident must not disable the throttle');
});
test('3D highway claims continuous frames only while a crowd video is rolling', () => {
test('3D highway claims continuous frames for BOTH sources of venue motion', () => {
const h3d = fs.readFileSync(
path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
const fn = extractBlock(h3d, 'needsContinuousFrames()');
// (1) a crowd video rolling on its own clock (career venue pack)
assert.match(fn, /_venueCrowdVideos/, 'must key off the actual crowd video elements');
assert.match(fn, /\.paused/, 'a paused video is a still frame — throttle should still apply');
// With no venue pack (the common case) the paused scene really is static and
// the GPU saving must survive: the method has to be able to return false.
assert.match(fn, /return false;/, 'must fall through to false with no live video');
assert.match(fn, /\.paused/, 'a paused video is a still frame');
// (2) the venue scene's OWN fake-depth motion — backdrop breathe, haze drift,
// warmth pulse, shimmer. Math.sin(t) in the draw loop, so it only moves while
// we get frames, and it runs with NO pack at all. Missing this meant the venue
// still stuttered on pause / count-in / credits whenever no video was rolling.
assert.match(fn, /_venueEffectiveMotionMode\s*\(\s*\)\s*!==\s*'off'/,
'the venue scene animates without any video — it must claim frames too');
// ...and with no venue at all the paused scene IS static: the #654 GPU saving
// must survive, so the method has to be able to return false.
assert.match(fn, /return false;/, 'must fall through to false on a plain 3D highway');
});
// ── a SUPERSEDED init is not a FAILED init ──────────────────────────────────
//
// Starting a gig dropped the player onto the fallback 2D highway with no venue.
//
// setViz('venue') installs the 3D renderer, whose init is async; the gig then
// immediately starts its play queue, and playSong() re-initialises that same
// renderer a tick later. A renderer mints a fresh readyPromise per init() and
// rejects the previous one with "superseded" — but highway.js only checked that
// the RENDERER object was unchanged, which it is. So it treated a healthy
// re-initialising renderer as a failed one, tore it down, and reverted to 2D:
//
// renderer async init failure: Error: superseded
// viz picker: reverted to default renderer (async-init-failure)
//
// Reproduced and fixed against the real build (venue stays selected, scene
// active, no viz:reverted).
test('a superseded readyPromise must not revert the viz to 2D', () => {
const src = highwaySources();
const fn = extractBlock(src, 'function _handleAsyncInitFailure(e)');
assert.match(fn, /readyPromise\s*!==\s*rp[\s\S]{0,40}return/,
'a rejection from a STALE readyPromise (the renderer has since re-init\'d) must be ' +
'ignored — otherwise a re-initialising renderer is torn down as if it had failed');
// The renderer-identity check must survive too: a rejection belonging to a
// renderer that has since been REPLACED is also not our problem.
assert.match(fn, /hwState\._renderer\s*!==\s*_installedRenderer[\s\S]{0,20}return/,
'the renderer-identity guard must remain');
// ...and a genuine failure of the CURRENT init cycle must still revert.
assert.match(fn, /_emitVizReverted\s*\(\s*'async-init-failure'\s*\)/,
'a real async-init failure must still fall back to the default renderer');
});
+77
View File
@@ -49,3 +49,80 @@ test('peekNext is null after clear', () => {
q.clear();
assert.strictEqual(q.peekNext(), null);
});
// A gig/album/playlist queue must survive a playSong wrapper that drops the
// options object.
//
// The queue tells playSong "don't clear the queue I'm driving" via
// options.fromQueue. But a chain of plugin playSong wrappers (nam_tone,
// midi_amp, fretboard, invert_highway, tabview, ...) forward only
// (filename, arrangement) and silently drop the 3rd arg. With just the in-band
// flag, playSong cleared the queue the instant its first song started, so a gig
// never advanced (feedBack#… tester: "Passports does not advance in the song
// queue"). The queue now also raises an out-of-band flag, _consumeInternalPlay(),
// which playSong honours regardless of the wrapper chain.
// The real clear-guard from session.js, driven against the queue.
function clearGuard(win, options) {
const pq = win.feedBack && win.feedBack.playQueue;
const queueDriven = (options && options.fromQueue)
|| (pq && typeof pq._consumeInternalPlay === 'function' && pq._consumeInternalPlay());
if (!queueDriven && pq) pq.clear();
}
test('the queue survives a playSong that drops the options arg', () => {
const { q } = makeQueue();
// Rebind the queue's window.playSong to a wrapper that forwards ONLY
// (filename, arrangement) — exactly the plugin bug — and runs the real guard.
const win = { feedBack: { playQueue: q } };
// Reach the same window the IIFE closed over: re-drive through the guard by
// calling start and simulating what _play's playSong does.
// We can't rebind the closed-over window, so instead assert the out-of-band
// signal directly: _play sets it, and the guard consumes it.
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
// After start()->_play, the internal flag was set; the guard (which the real
// playSong runs) must see it as queue-driven and NOT clear.
win.feedBack.playQueue = q;
clearGuard(win, undefined /* wrapper dropped options */);
assert.strictEqual(q.active(), true, 'a dropped options arg must not clear the queue');
assert.strictEqual(q.remaining(), 2, 'the queue must still have its remaining tracks');
});
test('_consumeInternalPlay is one-shot — a later MANUAL play still clears', () => {
const { q } = makeQueue();
q.start(['a.sloppak', 'b.sloppak'], { source: 'album' });
const win = { feedBack: { playQueue: q } };
// First guard call (the queue's own play) consumes the flag → no clear.
clearGuard(win, undefined);
assert.strictEqual(q.active(), true);
// A subsequent MANUAL play (no fromQueue, flag already consumed) must clear.
clearGuard(win, undefined);
assert.strictEqual(q.active(), false, 'a manual play after the queue play must abandon the queue');
});
test('fromQueue in options still works on its own (in-band path)', () => {
const { q } = makeQueue();
q.start(['a.sloppak', 'b.sloppak'], { source: 'gig' });
// consume the internal flag first so ONLY options.fromQueue is under test
q._consumeInternalPlay();
const win = { feedBack: { playQueue: q } };
clearGuard(win, { fromQueue: true });
assert.strictEqual(q.active(), true, 'options.fromQueue alone must still keep the queue');
});
// isContinuation(): true for song 2..N of a set, false for the first song / a
// standalone play. The venue uses it to fly in once on arrival, then carry the
// room between songs instead of replaying the arrival flyover every track
// (tester: "it showed the flyover intro again" on a gig's second song).
test('isContinuation is false on the first song, true after advancing', () => {
const { q } = makeQueue();
assert.strictEqual(q.isContinuation(), false, 'idle queue is not a continuation');
q.start(['a.sloppak', 'b.sloppak', 'c.sloppak'], { source: 'gig' });
assert.strictEqual(q.isContinuation(), false, 'the FIRST song of a set is an arrival, not a continuation');
q.advance();
assert.strictEqual(q.isContinuation(), true, 'song 2 is a continuation — no re-flyover');
q.advance();
assert.strictEqual(q.isContinuation(), true, 'song 3 too');
q.clear();
assert.strictEqual(q.isContinuation(), false, 'a cleared queue is not a continuation');
});
+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/);
+19
View File
@@ -117,3 +117,22 @@ test('a throwing document does not take the venue down with it', () => {
global.document = prev;
}
});
// The arrival flyover must NOT replay for songs 2..N of a set. onSongLoaded
// consults the play queue: a continuation (gig/album/playlist song 2+) carries
// the room over with a loop crossfade, only an arrival plays the intro.
test('a set continuation carries the room over instead of re-flying-in', () => {
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'venue-crowd.js'), 'utf8');
const start = src.indexOf('function onSongLoaded(');
const open = src.indexOf('{', src.indexOf(')', start));
let depth = 1, i = open + 1;
while (i < src.length && depth > 0) { const ch = src[i]; if (ch === '{') depth++; else if (ch === '}') depth--; i++; }
const fn = src.slice(start, i);
const contIdx = fn.search(/_isSetContinuation\s*\(\s*\)/);
const introIdx = fn.search(/playIntro\s*\(/);
assert.ok(contIdx !== -1, 'onSongLoaded must consult the set-continuation signal');
assert.ok(introIdx !== -1, 'the intro must still exist for a real arrival');
assert.ok(contIdx < introIdx, 'the continuation check must gate the flyover — a set song 2+ must not fly in');
});
+27
View File
@@ -445,3 +445,30 @@ def test_gold_intake_rejects_junk(client, meta_db):
res = client.post("/api/plugins/career/drill-state",
json={"byNode": {}, "goldImprov": blob})
assert res.status_code == 413
def test_gig_includes_songs_played_on_another_instrument(client, meta_db):
# feedBack#… (tester): "Metalcore says 137 songs, only shows 1 in the gig list".
# A song played on a DIFFERENT instrument's arrangement has a stats row, so it
# was excluded from the unplayed filler — and its played bucket is that other
# instrument's, not this passport's — so it fell into a gap and could never be
# gigged. A guitar passport with a library of bass-played metalcore got a 404.
for i in range(137):
meta_db.add(f"mc{i}.feedpak", 0, 0.80, genre="Metalcore", arrangements=BASS)
res = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
assert res.status_code == 200, "a full library of the genre must never 404"
assert len(res.json()["songs"]) == 4, "the gig must fill from the library, not the gap"
def test_gig_reroll_changes_the_set(client, meta_db):
# feedBack#… (tester): "Passport re-roll does not change songs". A set drawn
# from the filler used to be the library's first N in table order, every time.
for i in range(40):
meta_db.add_song_only(f"un{i}.feedpak", genre="Metalcore")
sets = set()
for _ in range(5):
r = client.post("/api/plugins/career/gigs/propose",
json={"instrument": "guitar", "genre": "Metalcore", "size": 4})
sets.add(tuple(sorted(s["filename"] for s in r.json()["songs"])))
assert len(sets) > 1, "re-roll must be able to produce a different set"
+173
View File
@@ -174,3 +174,176 @@ def test_double_download_409s(client, monkeypatch):
career_routes._state["downloads"]["bar"] = {"status": "running"}
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
assert client.delete("/api/plugins/career/packs/bar").status_code == 409
# ── gig pre-extraction (the wait between songs) ─────────────────────────────
#
# A feedpak is a zip: the first play of one pays for its extraction into
# sloppak_cache. Inside a set that cost landed BETWEEN songs — the player
# finished a number and then sat waiting for the next one to unpack, mid-gig.
# The setlist is known up front, so extract it all while the poster is up.
def _career_client_with_library(tmp_path, meta_db, dlc, cache):
from fastapi import FastAPI
from fastapi.testclient import TestClient
import routes as career_routes
app = FastAPI()
career_routes.setup(app, {
"config_dir": str(tmp_path),
"meta_db": meta_db,
"get_dlc_dir": lambda: dlc,
"get_sloppak_cache_dir": lambda: cache,
})
return TestClient(app)
def _write_feedpak(dlc, name, title="T"):
"""A minimal but REAL feedpak zip, so resolve_source_dir genuinely unpacks."""
import json as _json
import zipfile as _zip
p = dlc / name
with _zip.ZipFile(p, "w") as z:
z.writestr("manifest.json", _json.dumps({"title": title, "artist": "A", "arrangements": []}))
return p
def test_gig_prepare_extracts_every_song_up_front(tmp_path, meta_db):
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
for n in ("one.feedpak", "two.feedpak", "three.feedpak"):
_write_feedpak(dlc, n)
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
before = list(cache.iterdir())
assert before == [], "nothing unpacked yet"
res = client.post("/api/plugins/career/gigs/prepare",
json={"songs": ["one.feedpak", "two.feedpak", "three.feedpak"]})
assert res.status_code == 200
body = res.json()
assert body["ok"] is True
assert body["prepared"] == 3, body
assert body["failed"] == []
# The point of the whole exercise: the set is on disk BEFORE the first note.
assert len(list(cache.iterdir())) == 3, "every song of the set must be unpacked"
def test_gig_prepare_is_idempotent_on_a_warm_cache(tmp_path, meta_db):
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
_write_feedpak(dlc, "one.feedpak")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
first = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
second = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["one.feedpak"]}).json()
assert first["prepared"] == second["prepared"] == 1
assert len(list(cache.iterdir())) == 1, "a re-prepare must not duplicate the unpack"
def test_one_bad_feedpak_does_not_stop_the_set(tmp_path, meta_db):
# A corrupt pak in the setlist must not block the gig: the play itself will
# surface the error exactly as it does outside a gig. Slow beats blocked.
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
_write_feedpak(dlc, "good.feedpak")
(dlc / "bad.feedpak").write_bytes(b"not a zip at all")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
body = client.post("/api/plugins/career/gigs/prepare",
json={"songs": ["good.feedpak", "bad.feedpak"]}).json()
assert body["ok"] is True, "a bad pak must not fail the whole prepare"
assert body["prepared"] == 1
assert body["failed"] == ["bad.feedpak"]
def test_gig_prepare_degrades_without_a_library(tmp_path, meta_db, client):
# The stock fixture's context has no dlc/cache resolvers. That must be a
# graceful no-op, not a 500 — pre-extraction is an optimisation and can
# never be the reason a gig won't start.
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": ["x.feedpak"]})
assert res.status_code == 200
assert res.json()["prepared"] == 0
def test_gig_prepare_empty_setlist(tmp_path, meta_db, client):
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": []})
assert res.status_code == 200
assert res.json() == {"ok": True, "prepared": 0, "failed": []}
def test_prepare_rejects_a_non_list_songs_value(tmp_path, meta_db, client):
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs".
for bad in ("abc", 42, {"a": 1}, None):
res = client.post("/api/plugins/career/gigs/prepare", json={"songs": bad})
assert res.status_code == 200
assert res.json()["prepared"] == 0
def test_prepare_ignores_non_string_and_blank_entries(tmp_path, meta_db):
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
_write_feedpak(dlc, "good.feedpak")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
body = client.post("/api/plugins/career/gigs/prepare",
json={"songs": ["good.feedpak", "", " ", 7, None, {"x": 1}]}).json()
assert body["prepared"] == 1
assert body["failed"] == []
def test_prepare_caps_the_setlist(tmp_path, meta_db):
# This endpoint unpacks zips — an arbitrary caller must not be able to ask for
# unbounded work.
#
# The first version of this test asserted `prepared == 0` against a fixture
# with NO library: the endpoint exits before extraction there, so it passed
# whether or not the cap existed. Give it a real library, ask for far more than
# the cap, and assert the endpoint only ever considered MAX_GIG_SONGS of them.
import routes as career_routes
assert career_routes.MAX_GIG_SONGS <= 64
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
n = career_routes.MAX_GIG_SONGS + 50
# None of these exist, so every song the endpoint LOOKS AT lands in `failed`.
# That makes `failed` an exact count of how many it considered.
body = client.post("/api/plugins/career/gigs/prepare",
json={"songs": [f"missing{i}.feedpak" for i in range(n)]}).json()
assert body["prepared"] == 0
assert len(body["failed"]) == career_routes.MAX_GIG_SONGS, (
f"the endpoint must consider at most MAX_GIG_SONGS "
f"({career_routes.MAX_GIG_SONGS}), not all {n}"
)
def test_prepare_refuses_to_escape_the_library(tmp_path, meta_db):
# resolve_source_dir() does a bare `dlc_root / filename` with no containment
# guard, so a crafted path would walk straight out of the library. Every
# filename must go through _resolve_dlc_path first.
dlc = tmp_path / "dlc"
dlc.mkdir()
cache = tmp_path / "cache"
cache.mkdir()
(tmp_path / "outside.feedpak").write_bytes(b"secret")
client = _career_client_with_library(tmp_path, meta_db, dlc, cache)
for evil in ("../outside.feedpak", "..\\outside.feedpak",
"a/../../outside.feedpak", "/etc/passwd", "C:/Windows/x.feedpak"):
body = client.post("/api/plugins/career/gigs/prepare",
json={"songs": [evil]}).json()
assert body["prepared"] == 0, f"{evil!r} must never be prepared"
assert body["failed"] == [evil]
# Nothing outside the library may have been unpacked.
assert list(cache.iterdir()) == []
+75
View File
@@ -137,6 +137,81 @@ def test_background_scan_discovers_both_suffixes(tmp_path, scan_server):
assert "ignore.zip" not in seen
# ── 2b. directory-signature fast path (skip the full re-stat) ────────────────
def test_dir_signature_fast_path_skips_unchanged_tree(tmp_path, scan_server):
"""After a full scan records the library-dir signature, a second scan with
an unchanged tree takes the fast path and does NOT re-glob/extract but a
forced scan (manual Refresh) always does the full pass, and a new song
(which bumps the dir mtime) reverts to a full pass on its own."""
import unittest.mock as mock
dlc = tmp_path / "dlc"
dlc.mkdir()
(dlc / "a.feedpak").write_bytes(b"")
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
scan = importlib.import_module("scan")
seen: list[str] = []
def mock_extract(f, dlc_dir):
seen.append(f.name)
return {"title": f.name, "artist": "", "album": ""}
with mock.patch("scan_worker._extract_meta_for_file", new=mock_extract):
# 1) first pass: full scan, extracts a.feedpak (+ seeded builtins),
# records the signature
scan.background_scan()
assert "a.feedpak" in seen
assert scan._dir_signature_file().exists()
# 2) unchanged tree: fast path — no glob, no extraction at all
seen.clear()
scan.background_scan()
assert seen == []
assert scan.status()["stage"] == "complete"
# 3) a new song bumps the dlc mtime → signature mismatch → full pass
# picks it up on its own (no manual Refresh needed for adds)
(dlc / "b.feedpak").write_bytes(b"")
seen.clear()
scan.background_scan()
assert "b.feedpak" in seen
# 4) force=True (Refresh) bypasses the fast path even on a settled tree:
# with the signature now current, a plain scan skips, a forced one lists
seen.clear()
scan.background_scan() # fast path
assert seen == []
forced_listed = []
real_delete_missing = scan.appstate.meta_db.delete_missing
def _spy(files):
forced_listed.append(set(files))
return real_delete_missing(files)
with mock.patch.object(scan.appstate.meta_db, "delete_missing", new=_spy):
scan.background_scan(force=True)
assert forced_listed, "force=True must run the full listing pass"
def test_dir_signature_tracks_directory_form_song_own_dir(tmp_path):
"""A directory-form song (loose folder / directory bundle) records its OWN
directory in the signature, so an in-place file change inside it which
bumps that folder's mtime but not its parent's invalidates the fast path.
A file-form sloppak (a plain .feedpak zip) is not a dir and adds nothing."""
scan = importlib.import_module("scan")
dlc = tmp_path / "dlc"
(dlc / "packs").mkdir(parents=True)
loose = dlc / "packs" / "my_loose_song" # directory-form song
loose.mkdir()
zipped = dlc / "packs" / "zipped.feedpak" # file-form song
zipped.write_bytes(b"")
rels = scan._library_dirs([loose, zipped], dlc)
assert "packs/my_loose_song" in rels, "directory-form song must track its own dir"
assert "packs" in rels and "." in rels
assert "packs/zipped.feedpak" not in rels, "a file-form sloppak is not a tracked dir"
# ── 3. POST /api/songs/upload gate (endpoint) ────────────────────────────────
@pytest.fixture()
+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
+140
View File
@@ -0,0 +1,140 @@
"""`/api/song/{f}?stems=1` — the playable stem list, for preloading.
The stems plugin could only learn its stem list from the highway's WS `ready`,
which arrives once the highway is already up. So it decoded the stems and then
copied the whole song's PCM to its audio worklet with the player on screen —
half a gigabyte of memcpy in one frame, ~700 ms, which froze the venue video.
Given the list at `song:loading` it can do all of that BEFORE the highway
appears, behind the loading overlay where a stall costs nothing.
The safety property these tests exist for: the REST payload must be the SAME
list the WS builds. If they disagree, the plugin preloads one graph and then
throws it away and rebuilds another strictly worse than not preloading. So
they are pinned against each other, not just against a snapshot.
"""
import zipfile
import yaml
import sloppak
def _pak(tmp_path, stems, full=None, name="song.feedpak", original_audio=None):
manifest = {
"title": "T", "artist": "A", "duration": 10.0,
"arrangements": [],
"stems": stems + ([full] if full else []),
}
if original_audio:
# The deprecated pre-1.15.0 shape: the mixdown lives outside `stems`.
manifest["original_audio"] = original_audio
p = tmp_path / name
with zipfile.ZipFile(p, "w") as z:
# Real packs carry manifest.yaml — a JSON manifest is not read at all.
z.writestr("manifest.yaml", yaml.safe_dump(manifest))
# _legacy_full_mix only returns a path that actually EXISTS on disk.
if original_audio:
z.writestr(original_audio, b"\0" * 16)
return p
def _payload(tmp_path, pak):
from routers.song import _playable_stems_payload
import appstate
cache = tmp_path / "cache"
cache.mkdir(exist_ok=True)
appstate.sloppak_cache_dir = cache
return _playable_stems_payload(pak.name, tmp_path)
def _ws_payload(tmp_path, pak):
"""Rebuild the WS `ready` stems payload exactly as ws_highway.py does."""
from urllib.parse import quote
cache = tmp_path / "cache"
cache.mkdir(exist_ok=True)
loaded = sloppak.load_song(pak.name, tmp_path, cache)
q = quote(pak.name, safe="")
return {
"stems": [
{"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(s['file'])}",
"default": s["default"]}
for s in loaded.stems
],
"full_mix_url": f"/api/sloppak/{q}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None,
}
def test_default_resolution_is_shared_with_load_song():
assert sloppak.stem_default_on(True) is True
assert sloppak.stem_default_on(False) is False
assert sloppak.stem_default_on("off") is False
assert sloppak.stem_default_on("false") is False
assert sloppak.stem_default_on("0") is False
assert sloppak.stem_default_on("no") is False
assert sloppak.stem_default_on("on") is True
assert sloppak.stem_default_on(1) is True
def test_rest_matches_the_ws_for_a_reserved_full_stem(tmp_path):
pak = _pak(tmp_path,
[{"id": "guitar", "file": "stems/guitar.ogg"},
{"id": "vocals", "file": "stems/vocals.ogg", "default": "off"}],
full={"id": "full", "file": "stems/full.ogg"},
name="Iron Maiden - Phantom.feedpak")
rest = _payload(tmp_path, pak)
assert rest == _ws_payload(tmp_path, pak)
assert [s["id"] for s in rest["stems"]] == ["guitar", "vocals"], "the mixdown is not a layer"
assert rest["full_mix_url"].endswith("stems/full.ogg")
assert rest["stems"][1]["default"] is False
def test_rest_matches_the_ws_for_a_LEGACY_original_audio_pack(tmp_path):
"""The one CodeRabbit caught, and the one that matters most in practice.
load_song falls back to the DEPRECATED `original_audio:` key when a pack has
no reserved `full` stem which is every pack written before feedpak 1.15.0,
i.e. most of a real library. My first version of this payload reimplemented
the full-mix rule from extract_meta and silently returned None for them: REST
would say "no full mix" while the WS said there was one. The plugin would then
preload a graph WITHOUT the pristine mix and, because the signature still
matched, never rebuild unity playback silently downgraded to the lossy
recombination.
The payload now calls load_song itself, so this cannot drift. Pinned anyway.
"""
pak = _pak(tmp_path, [
{"id": "guitar", "file": "stems/guitar.ogg"},
{"id": "bass", "file": "stems/bass.ogg"},
], name="Legacy Pack.feedpak", original_audio="original/full.ogg")
rest = _payload(tmp_path, pak)
assert rest == _ws_payload(tmp_path, pak)
assert rest["full_mix_url"] is not None, (
"a pre-1.15.0 pack's full mix must survive — dropping it downgrades unity "
"playback to the lossy stem recombination, silently"
)
assert rest["full_mix_url"].endswith("original/full.ogg")
def test_rest_matches_the_ws_for_a_single_full_pack(tmp_path):
# Its ONE stem IS the mixdown: nothing to be pristine against, so `full` stays
# the sole playable stem and no separate mixdown is surfaced.
pak = _pak(tmp_path, [{"id": "full", "file": "stems/full.ogg"}], name="Single.feedpak")
rest = _payload(tmp_path, pak)
assert rest == _ws_payload(tmp_path, pak)
assert [s["id"] for s in rest["stems"]] == ["full"]
assert rest["full_mix_url"] is None
def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path):
# Preloading is an optimisation: an unreadable pack must fall back to the
# normal WS-driven path, never break the song-info request.
from routers.song import _playable_stems_payload
import appstate
cache = tmp_path / "cache"
cache.mkdir()
appstate.sloppak_cache_dir = cache
(tmp_path / "bad.feedpak").write_bytes(b"not a zip")
assert _playable_stems_payload("bad.feedpak", tmp_path) == {"stems": [], "full_mix_url": None}
+104 -1
View File
@@ -4,10 +4,15 @@ import pytest
from tunings import (
DEFAULT_TUNINGS,
PERSPECTIVES,
TUNING_PRESET_MIDIS,
_valid_tuning_for_key,
apply_flat_instrument_patch_to_profiles,
normalize_offsets,
open_midis_to_freqs,
perspective_low_pitch,
perspective_tuning_key,
perspective_tuning_name,
settings_with_instrument_profiles,
tuning_midis_from_offsets,
tuning_name,
@@ -214,7 +219,11 @@ def test_settings_profiles_migrate_legacy_flat_bass_selection():
})
assert settings["active_instrument_profile"] == "bass"
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
# The legacy 6-string-bass name migrates to the corrected one: the
# pitches [19,24,29,34,39,44] sound lowest G, and extended-range bass is
# named off its actual lowest string. Same tuning, right label — and the
# alias is what keeps this profile VALID rather than rejected.
assert settings["instrument_profiles"]["bass"]["tuning"] == "G Standard"
assert settings["reference_pitch"] == 432
assert settings["pathway"] == "practice"
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
@@ -279,3 +288,97 @@ def test_freqs_to_midis_rejects_garbage():
assert freqs_to_midis([float("inf")]) is None # non-finite
assert freqs_to_midis([float("-inf")]) is None # non-finite
assert freqs_to_midis([]) == [] # vacuously fine
# ── Extended-range BASS naming (feedBack: 6-string bass read as guitar) ──────
# A 6-string bass has SIX offsets exactly like a 6-string guitar, but its
# lowest string is B, not E. `tuning_name` gated its guitar ladder on
# `len(offsets) == 6` alone, so a bass got guitar names: an all-zeros bass
# read "E Standard" (it is Standard/B) and a whole-step-down bass read
# "D Standard" (it is A Standard). Reported from a real Sleep Token chart
# tuned A0 D1 G1 C2 F2 A#2; the player called it A standard and was right.
# Convention (bass- and guitar-pedagogy seats, 2026-07-18): name extended
# range by the ACTUAL lowest string, matching the 7-string guitar presets.
def test_bass_perspective_keeps_proven_six_string_tuning_but_truncates_padding():
bass = PERSPECTIVES["bass"]
# Legacy four-string Rocksmith data pads its unused tail with zeroes.
assert normalize_offsets([-2, -2, -2, -2, 0, 0], bass) == [-2] * 4
# A uniform non-zero six-string tuning cannot be that padding shape.
extended = normalize_offsets([-2] * 6, bass)
assert extended == [-2] * 6
assert perspective_tuning_name(extended, bass) == "A Standard"
assert perspective_tuning_key(extended, bass) == "bass:21:26:31:36:41:46"
assert perspective_low_pitch(extended, bass) == 21
BASS_STANDARD_CASES = [
# 4-string bass is E-A-D-G — the guitar ladder's low four, names unchanged.
([0, 0, 0, 0], "E Standard"),
([-1, -1, -1, -1], "Eb Standard"),
([-2, -2, -2, -2], "D Standard"),
# 5-string adds a low B → the B ladder.
([0] * 5, "Standard"),
([-1] * 5, "Bb Standard"),
([-2] * 5, "A Standard"),
([-3] * 5, "G# Standard"),
([-4] * 5, "G Standard"),
# 6-string: same names, extra top string.
([0] * 6, "Standard"),
([-1] * 6, "Bb Standard"),
([-2] * 6, "A Standard"),
([-3] * 6, "G# Standard"),
([-4] * 6, "G Standard"),
]
@pytest.mark.parametrize("offsets,expected", BASS_STANDARD_CASES)
def test_bass_standard_tunings(offsets, expected):
assert tuning_name(offsets, is_bass=True) == expected
def test_six_offsets_alone_do_not_imply_a_guitar():
"""The regression the bug report came from."""
sleep_token = [-2] * 6 # A0 D1 G1 C2 F2 A#2
assert tuning_name(sleep_token, is_bass=True) == "A Standard"
# ...and the identical offsets on a guitar keep the guitar name.
assert tuning_name(sleep_token) == "D Standard"
# A STANDARD 6-string bass is not "E Standard" either.
assert tuning_name([0] * 6, is_bass=True) == "Standard"
assert tuning_name([0] * 6) == "E Standard"
def test_bass_drop_tunings_name_the_resulting_low_string():
# 5-string B standard, low string dropped a whole step → A.
assert tuning_name([-2, 0, 0, 0, 0], is_bass=True) == "Drop A"
# 4-string E standard → D.
assert tuning_name([-2, 0, 0, 0], is_bass=True) == "Drop D"
def test_bass_presets_are_named_off_their_lowest_string():
"""Every bass preset's name must match the note its low string sounds."""
names = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
alt = {"Ab": "G#", "G#": "Ab", "Bb": "A#", "A#": "Bb", "Eb": "D#", "D#": "Eb"}
for key in ("bass-4", "bass-5", "bass-6"):
for name, midis in TUNING_PRESET_MIDIS[key].items():
if not name.endswith("Standard") or name == "Standard":
continue
root = name.rsplit(" ", 1)[0]
low = names[midis[0] % 12]
assert root in (low, alt.get(low)), (
f"{key} {name!r} lowest string sounds {low}"
)
def test_superseded_bass_names_migrate_instead_of_being_rejected():
"""Renaming must not invalidate saved profiles (both pedagogy seats)."""
for key in ("bass-5", "bass-6"):
assert _valid_tuning_for_key(key, "D Standard") == "A Standard"
assert _valid_tuning_for_key(key, "C Standard") == "G Standard"
# Current names still pass straight through.
assert _valid_tuning_for_key(key, "A Standard") == "A Standard"
# The rename must not leak into other instruments.
assert _valid_tuning_for_key("guitar-6", "D Standard") == "D Standard"
assert _valid_tuning_for_key("bass-4", "D Standard") == "D Standard"