Compare commits

..
Author SHA1 Message Date
byrongamatos dfca07a15d Merge remote-tracking branch 'origin/main' into fix/gpx-bcfs-final-partial-sector
# Conflicts:
#	CHANGELOG.md
2026-07-05 00:20:13 +02:00
c7aa5a10b0 fix(v3): recycle library grid cards on scroll instead of rebuilding the window (#742)
The virtualized v3 Songs grid rebuilt its entire visible window
(grid.innerHTML = _renderCardsRange(...) + a full wireCards pass) every
time it slid by one row. Each row-boundary crossing was therefore a heavy
synchronous frame — reparse ~60 cards, re-attach hundreds of listeners,
reflow — that stalled the main thread and buffered held-arrow key-repeats,
flushing them in a burst. Testers saw the library "go super fast for a
second then slow down," skipping "every so many scrolls," up or down, at
the same spots each time. It hitched scrolling back up over already-loaded
songs too, because the cost was DOM teardown, not fetching.

renderWindow() now reconciles the window in place: it reuses the card
nodes that stay on-screen and builds only the row that enters/leaves
(~6 nodes per slide instead of ~60). Nodes are keyed by absolute index
(data-idx) with a real-vs-skeleton + select-mode signature (data-sig) so
hole-fills after a page fetch and select-mode toggles still rebuild
exactly the nodes that changed. wireCards()'s existing data-wired guard
then wires only the freshly-built nodes, so per-slide listener churn drops
with it. Everything keyed off data-fn (favorites, ⋮ menu, right-click,
selection, accuracy badges, A–Z rail) is unaffected.

Follow-up to the stage-2 virtualized grid (#636 item 3). Frontend-only.

Tests: tests/js/v3_songs_window_recycle.test.js — window stays [start,end)
contiguous and in-window node identity is reused across a down-then-up
scroll; select-mode toggle and a rail-seek jump rebuild correctly.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 00:19:46 +02:00
byrongamatos 54e48c2e77 Merge remote-tracking branch 'origin/main' into fix/gpx-bcfs-final-partial-sector
# Conflicts:
#	CHANGELOG.md
2026-07-05 00:19:27 +02:00
fa2d12222a feat(v3 library): "Refresh Metadata" button + per-view re-match + filename-artist seed (#764)
Adds a media-server-style "Refresh Metadata" control to the Songs toolbar
(beside "⟳ Refresh") — the metadata counterpart to a file scan.

- Re-matches the songs currently SHOWN (the visible grid window) against
  MusicBrainz: a per-view refresh that's visible even on an already-matched
  library. The button doubles as Stop while a pass runs; a batch progress bar
  + per-tile queued→working→done badges show what's happening. User-pinned
  `manual` matches are never re-matched.
- Backend: POST /api/enrichment/{cancel,states,rematch}; /status gains
  total/matched/current/cancelling; a cooperative cancel Event is checked
  between songs in the match + art phases so Stop halts without waiting for
  the whole queue. `states` is read-only (open); `cancel`/`rematch` are
  demo-blocked.
- Matcher: when a pack's `artist` field is blank (common in community
  charts), derive artist/title from the CDLC `Artist_Song-Title` filename
  convention as a SEARCH SEED so text matching can identify it — the displayed
  values still come only from the confirmed MusicBrainz match, nothing
  estimated is shown as author-set. Rescues blank-artist packs that otherwise
  always failed.

Tests: enrichment_states_for, the three new routes, cancel-halts-a-pass,
kick-clears-stale-cancel, filename parse, blank-artist seeding, and
present-artist-not-overridden.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:18:18 +02:00
73c5ab149e feat(enrichment): AcoustID audio-fingerprint identification (opt-in) (#759)
* feat(enrichment): AcoustID audio-fingerprint identification (opt-in)

Text search can only guess the version; the definitive fix is content-based —
fingerprint the actual audio with Chromaprint (fpcalc) and look it up on
AcoustID, which maps the fingerprint to the EXACT MusicBrainz recording (the
approach Lidarr uses). Sidesteps the studio-vs-live ambiguity entirely.

- lib/acoustid_match.py: pure response parsing + config gating (unit-tested);
  normalizes AcoustID hits into the same candidate shape as mb_match so the
  review UI + editor Match popup render fingerprint and text hits identically.
- server.py: _fpcalc (Chromaprint subprocess), _acoustid_lookup (throttled,
  offline-guarded HTTP), _identify_by_fingerprint (also available to the
  library-enrichment pipeline), and POST /api/enrichment/identify (upload the
  master audio → candidates).
- Fully OPT-IN and graceful: absent the fpcalc binary or an ACOUSTID_API_KEY
  the whole path is a no-op / 503 and the text matcher runs unchanged.

Requires (both optional): the `fpcalc` (Chromaprint) binary on PATH/$FPCALC,
and a free AcoustID application key in $ACOUSTID_API_KEY. Pure parsing/gating
is unit-tested; the fpcalc + live-lookup path needs those two to exercise.

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

* feat(enrichment): make AcoustID self-serve — opt-in toggle + API key in settings

Fingerprinting was env-var only (ACOUSTID_API_KEY), so only an operator could
enable it. Add two core settings so a user can turn it on themselves:
  - acoustid_enabled (bool, default OFF — opt-in)
  - acoustid_api_key (string, ≤128 chars, trimmed; env var stays a fallback)

_acoustid_available()/_acoustid_lookup() now resolve (enabled, key) from
settings via _acoustid_settings(). /api/enrichment/identify distinguishes
"not set up" (412 needs_setup — the UI nudges the user to enable it) from
"set up but fpcalc/network missing" (503) so the client never fakes a match.

Verified: default off; POST round-trips + trims; 412 vs 503 gating; bad
types/over-length rejected. acoustid_match unit tests green (8/8).

* fix(enrichment): POST the AcoustID lookup instead of GET

A Chromaprint fingerprint is multi-KB (a 3.5-min track ≈ 3.5k chars), so
sending it as a GET query param overflows the request URL for longer songs and
fails spuriously. AcoustID accepts the same params form-encoded — POST them.

* fix(acoustid): space-separate the lookup `meta` (was silently dropping metadata)

The meta value was `+`-joined ("recordings+releasegroups+compress"). Sent over
the wire the literal `+` percent-encodes to %2B, which AcoustID does NOT split
into flags — so every hit came back with an empty `recordings` array and the
parser produced zero candidates (a fingerprint match that resolved to nothing).
AcoustID wants the flags space-separated. Verified against real fingerprints:
`+`-joined → 0 recordings; space-joined → 28, resolving Highway to Hell and
Living After Midnight to their canonical studio albums as the top hit.

* feat(acoustid): resolve the canonical original album + year from the fingerprint

AcoustID hits resolved the right recording but a weak album/blank year: the
album picker took the first studio-typed group (a later comp/soundtrack typed
"Album" could win) and the year took an arbitrary release (often a reissue).
Request the `releases` meta (which carries per-release dates) and use them to
(1) pick the EARLIEST original studio album among the groups and (2) fill the
year from that album's earliest release. Verified against real fingerprints:
Smoke on the Water → Machine Head (1972) not a later comp; Highway to Hell →
1979; Living After Midnight → British Steel (1980). +2 unit tests.

* feat(acoustid): per-song "Identify by audio" for the library metadata tooling

Add POST /api/enrichment/identify/{filename} — fingerprints an EXISTING library
song's own master audio (resolves the sloppak's original_audio or a loose
folder's audio), the library counterpart to the upload-based /identify used by
the editor. Wire an "Identify by audio" action into the match-review / Fix-match
modal: it renders fingerprint hits in the same candidate list and pins the pick
via the existing /review/{f}/pick. Shared _acoustid_gate() (412 needs_setup /
503) for both endpoints; 404 when a pack has no full mix. Both identify routes
added to the demo-mode block list (they spend fpcalc + the AcoustID budget) —
fixes a pre-existing miss on the upload route.

* fix(acoustid): regenerate stale tailwind CSS + cap identify upload

- static/tailwind.min.css was stale vs a fresh rebuild (ci/tailwind-fresh red);
  regenerated with the pinned tailwindcss@3.4.19 (byte-stable).
- /api/enrichment/identify read the whole multipart upload into memory before
  writing it; stream it to the temp file with a 256 MB cap (413 over) so an
  oversized upload can't balloon RAM. fpcalc reads from the temp file anyway.

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

* feat(acoustid): pre-parse upload guard + settings UI to enable it

- /api/enrichment/identify is now async: a pre-parse Content-Length check +
  request.form(max_part_size=…) reject an oversized body BEFORE Starlette spools
  the multipart to temp disk (mirrors the song-upload endpoint), and the blocking
  fpcalc subprocess + AcoustID HTTP run off the event loop via run_in_executor.
- The v3 Metadata-matching settings card gains an 'Identify by audio' opt-in
  toggle (acoustid_enabled, default OFF) + an AcoustID key input
  (acoustid_api_key), wired in match-review.js — so the advertised feature is
  reachable from the UI instead of only via a manual settings POST. Reuses
  existing classes only; committed tailwind.min.css stays fresh.

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-05 00:17:43 +02:00
a65d8cfa13 fix(enrichment): rank the canonical studio take over live/comp versions (#758)
* fix(enrichment): rank the canonical studio take over live/comp versions

A flat MusicBrainz /recording text search ties every take of a song at the
same score, so "AC/DC — Highway to Hell" returns a wall of live bootlegs and
compilations with the 1979 studio version buried (or below the fetch limit).

- build_recording_query: drop live-ONLY recordings (`-secondarytype:Live`).
  Compilations are deliberately kept — they REUSE the studio recording, so
  filtering them cuts the very recording we want (verified against MB).
- _best_release / parse_recording_doc: pick the canonical studio album
  (primary Album, no Live/Compilation/Remix/... secondary type) for the
  displayed album/year, and expose a `studio` flag.
- rank_candidates: since the combined score caps at 1.0 (perfect text match
  ties), break ties on the studio flag and — when the caller knows the audio
  length — on duration proximity, so the studio take wins over live/extended
  cuts. The studio distinction is intentionally NOT scored (a live take is
  still the right SONG), only re-ordered.
- /api/enrichment/search: accept an optional `duration` param so a caller that
  has the audio but no library row (the editor's create modal) can pass the
  master-track length for the duration tiebreak.

Verified end-to-end against live MusicBrainz: AC/DC "Highway to Hell" now
returns the 1979 studio recording at #1 with the correct album + year.

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

* fix(enrichment): official releases outrank unofficial studio albums

_best_release sorted (clean, status_ok, date), so an UNofficial bootleg Album
outranked an official Single/EP/comp — regressing canonical album/year and
seeding cover-art from a bootleg for single-only songs. Order status_ok before
clean: official first, then prefer a clean studio album among the official
releases (still surfaces the studio album over an official live/comp album).

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

* fix(enrichment): keep live recordings for genuinely-live charts

build_recording_query unconditionally added -secondarytype:Live, but denoise()
strips a '(Live at …)' qualifier from the query — so a chart that IS a live take
had its only correct recording filtered out (both background enrichment and
manual search). Skip the live filter when the source title carries a
parenthetical live marker; a bare title word ('Live and Let Die') still filters.

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

* fix(enrichment): drop the studio tiebreak when the chart is a live take

Follow-through on keeping live recordings for live charts: rank_candidates still
ranked the studio take ahead of a tied live one, so a live chart would auto-match
the studio recording. Skip the studio tiebreak when the source title has a live
marker — duration proximity + score then pick the right live version.

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-05 00:17:05 +02:00
a86abadb14 settings: add host instrument profiles (#753)
* settings: add host instrument profiles

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

* settings: add instrument pathway selection

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

* fix(settings): profile-aware saves/resets/switch, provider tunings, bass-5

Five regressions from the instrument-profiles rework:

1. save_settings canonicalized profiles on EVERY save -> empty/unrelated POST
   froze default profiles into config.json (broke
   test_empty_post_preserves_all_existing_keys). Gate on the save touching
   instrument settings; GET already virtualizes profiles.
2. pathway is profile-mirrored, so the Gameplay reset (flat-key delete) was a
   no-op. reset_settings now resets pathway inside the persisted profiles too.
3. Per-profile tuning validation rejected provider/custom tunings (tuner
   plugin, /api/tunings). _valid_tuning_for_key now accepts a name unknown to
   every built-in table while still rejecting a built-in misapplied to the
   wrong key.
4. First-migration overwrote an explicit active_instrument_profile with the
   legacy-inferred one, so a fresh-config switch to 'bass' was lost. Use
   setdefault so an explicit request wins.
5. Pre-existing test_instrument_fields_persist used bass-5 + 'Drop D' (a
   4-string tuning). Updated to the valid 'Drop A'.

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

* fix(settings): partial-merge instrument_profiles; clamp tuning on string-count switch

Two partial-update follow-ups:
- save_settings normalized a POSTed instrument_profiles by FILLING every omitted
  profile with defaults and replacing wholesale, so a one-profile update reset
  the others. Validate each PROVIDED profile individually and merge the partial
  over the persisted set inside the lock — /api/settings is partial-merge.
- the string-count picker posted only string_count, so the backend silently
  reset a now-invalid tuning to Standard while the UI kept the old value
  (settings/tuner desync). Clamp + post the valid tuning too, mirroring the
  instrument-switch path.

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

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:16:42 +02:00
41e907fa52 fix(library): serve art/load for songs mounted through a library junction (#766)
* fix(library): serve art/load songs mounted through a library junction

A song library mounted through a directory JUNCTION/symlink subfolder (a
library shared across app installs; the desktop app's own mounts) had broken
album art and couldn't load: the scanner's rglob follows the junction and
indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed
the junction to its real target, saw it outside DLC_DIR, and rejected every
song reached through it → 403 on /art, 404 on /art/candidates, broken covers.

- _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink
  following) so an in-library junction is allowed, while `..` traversal and
  absolute paths are still rejected (the traversal tests pin this).
- safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin-
  asset / avatar guard, where following a symlink out IS the defense — but
  gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer
  raises on an embedded NUL, so the byte was leaking through; strictly-more-
  rejection, no effect on the zip-slip contract).

Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the
safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates
suites stay green.

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

* fix(library): reject Windows drive-letter paths in _resolve_dlc_path

The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on
POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the
contained relative dir '<dlc>/C:/…' and slipped through the lexical containment
check (red on the Linux CI). Not an escape, but the traversal contract should
hold cross-platform (a shared library is reached from either OS). Reject a path
that is absolute or drive-qualified in either POSIX or Windows semantics before
the containment check. Legitimate relative/junction paths are 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-05 00:15:50 +02:00
2c1c6f7eac fix(starter): sync _BUILTIN_STARTER_SOURCES with content/starter on disk (#775)
Two commits (delete beethoven-ode_to_joy, re-add The Adicts' Ode to Joy) never
updated _BUILTIN_STARTER_SOURCES: it still listed the deleted pack and omitted
the added one. The listed-but-missing file made the all-present gate never fire,
so NO starter content seeded on first run — and the on-disk-but-unlisted pack
would bundle as dead weight. Both starter-seed guard tests were red on main,
reddening ci/test on every core PR. Sync the manifest to disk.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:15:29 +02:00
ChrisBeWithYouandClaude Opus 4.8 4106f25ee2 fix(gpx): clamp partial final BCFS sector so GP6 .gpx import works
Every real Guitar Pro 6 (.gpx) file failed to import with
"GPX BCFS sector pointer out of range (malformed file)".

A real .gpx's BCFZ-declared decompressed size isn't 0x1000-aligned, so
its last (small) container file lands in a partial trailing sector.
_parse_bcfs raised whenever a sector read would run past the buffer
end, rejecting the whole container before score.gpif could be extracted
-- so no GP6 file could be charted in the song editor. (GP7/GP8 .gp
files take the ZIP path, not BCFS, which is why this wasn't caught
earlier.)

Clamp the final sector read to the buffer end (the per-file size field
trims the padding anyway), matching canonical GPX readers (alphaTab /
PyGuitarPro). A sector whose start is past the end still raises, so the
malformed-file guard is preserved.

Verified against two real GP6 files -- both now unpack to valid GPIF
with all tracks. Adds the previously-missing positive BCFS round-trip
coverage: partial-final-sector, multi-file, sector-aligned baseline,
and the preserved out-of-range guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:28:46 -05:00
OmikronApexandGitHub b6169af6aa Delete content/starter/beethoven-ode_to_joy.feedpak 2026-07-03 23:42:45 +02:00
OmikronApexandGitHub a3f1bceb15 Readded Ode to Joy by The adicts 2026-07-03 23:42:22 +02:00
OmikronApexandGitHub c2153b277b Merge pull request #745 from got-feedBack/fix/ffmpeg-autobuild-repin
fix(docker): repin FFmpeg to autobuild-2026-07-03-13-21
2026-07-03 23:20:31 +02:00
OmikronApexandClaude Fable 5 2ffeeaca0b fix(docker): repin FFmpeg to autobuild-2026-07-03-13-21
The previously pinned BtbN autobuild release (2026-06-19) was pruned
upstream, so the release build's curl download 404'd (exit 22).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:16:50 +02:00
14eaad09e9 feat(library): add Star-Spangled Banner + Ode to Joy starter content (#744)
Add two more public-domain starter songs alongside Für Elise, wired into
_BUILTIN_STARTER_SOURCES so they seed into DLC_DIR/starter/ on first run:

- The Star-Spangled Banner (lead) — John Stafford Smith; cleaned the "Unknown"
  artist / placeholder album, author "Fee[dB]ack".
- Ode to Joy (lead/rhythm/bass + drums) — Beethoven. Replaces the raw
  "Ode to Joy (VST Cover)_The Adicts.feedpak" that was committed to main but
  never added to the seed list (so it bundled 23 MB of dead weight and never
  appeared). Fixed metadata (artist Beethoven, year 1824, author "Fee[dB]ack"),
  and repointed the stem from the 22 MB editor WAV to the byte-identical-render
  full.ogg (both exactly 85.324 s), shrinking the pack 23.8 MB -> 1.7 MB.

Add guard tests asserting every _BUILTIN_STARTER_SOURCES entry has its file
committed and that a seed run lands them all — this catches exactly the
listed-but-missing (or committed-but-unlisted) mismatch that left Ode to Joy
un-seeded.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 22:58:39 +02:00
OmikronApexandGitHub 6ab1ed95c9 Add Ode to Joy (VST Cover) as new starter content 2026-07-03 22:00:51 +02:00
7c873f5cc2 feat(library): seed bundled starter content into the library on first run (#743)
Ship a public-domain Für Elise (keys) feedpak as starter content so a fresh
install isn't an empty library. server._seed_builtin_starter_content() copies
bundled packs into DLC_DIR/starter/ exactly once, guarded by a marker in
CONFIG_DIR — unlike the always-reseeding diagnostic seed, a user who deletes
the starter song does not get it back. `starter/` is deliberately outside the
diagnostics/tutorials library carve-out so the song surfaces as a normal
library entry.

Extract the shared symlink-safe, mtime-aware copy loop into
_copy_builtin_packs() and route both the diagnostic and starter seeds through
it (diagnostic behavior unchanged; existing tests green).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 21:35:22 +02:00
68e29a8b6e fix(plugins): don't treat transient absence from /api/plugins as uninstall (#741)
* fix(plugins): don't treat transient absence from /api/plugins as uninstall

The backend clears its plugin registry at the start of load_plugins()
and repopulates it incrementally while HTTP stays up, so every backend
restart (desktop: Audio Quality soundfont switch, LAN toggle, update
restart) serves a window of partial — even empty — /api/plugins
responses. loadPlugins() treated absence from the current response as
an uninstall, with three destructive consequences for still-loaded
plugins:

1. Their settings-panel and screen DOM were wiped while their
   _loadedPluginScripts entry survived, so the NEXT refetch failed the
   DOM-existence check and re-evaluated the plugin's screen.js
   mid-session. For the desktop audio_engine plugin that re-ran init()
   against the surviving native audio chain and exactly duplicated
   every VST/NAM/IR stage (the alpha testers' "chain duplicates after
   leaving the Audio menu" / blown-out gain reports).
2. _reconcilePluginStyles dropped their stylesheet, leaving them
   visible but unstyled until they reappeared.
3. The stale-contribution sweep unmounted their UI contributions and
   unregistered their capability participant with no re-registration
   path (plugin scripts don't re-run thanks to the loadedScripts
   guard).

Absence is now a non-signal everywhere in loadPlugins: the DOM wipe and
style reconcile are scoped to plugins the response actually names, and
the absence sweep is removed. Present plugins still fully re-sync via
_registerLegacyPluginUiContributions each round; failed plugins are
present in the response and still cleaned up; nav is rebuilt from the
response so genuinely uninstalled plugins drop out of it, and their
(un-unloadable) already-evaluated scripts keep their DOM until reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: update idempotence contract to the absence-is-not-uninstall invariant

The removed-plugin sweep contract pinned the old behavior this branch
deletes; pin the new invariant instead (no absence sweep + respondedIds
scoping on the DOM/style reconcilers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:45:30 +02:00
d2b2a7e9f7 fix(tests): re-green the JS suite — 18 stale source-shape tests + 1 real seek-reason violation (#740)
main's JS suite has been red since the recent v3-library and player
refactors landed. 17 of 18 failures were test harnesses/regexes that
went stale behind real, intentional code changes; one was a genuine
contract violation in the code.

Code fix:
- session-resume seek passed 'resume' as its _audioSeek reason; the
  documented contract (enforced by song_seek.test.js) requires
  multi-word kebab-case. Renamed to 'session-resume' — no consumer
  string-matches specific reasons, so this is rename-safe.

Test updates (each pins the CURRENT contract):
- highway_colors_facade: inject HWC_PRESETS + applyHighwayStringPreset
  (new preset feature); lock presets/applyPreset into the surface test
- loop_api: stub _updateEditRegionBtn (new edit-region UI hook)
- song_close: sandbox gets window.feedBack.playQueue; assert a real
  close abandons the queue (the new queue-aware behavior)
- v3_keep_practicing: the shelf moved from client-side /api/stats/recent
  dedupe+gating to the server-side practice-suggestions recommender —
  tests now pin that (fetch, arrangement-aware card click, Promise.all)
- v3_songs_tuning: card row variable renamed song → shown (grouped cards)
- live_guitar_tone_source: accept literal ’ where &rsquo; drifted in copy
- legacy_shim_hits: normalize CRLF before fixed-width region() slicing
  (Windows-only failure; char windows shrank by one char per line)

Suite: 987/987 locally (Windows), previously 968/987 (and 18 red on CI).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:35:07 +02:00
OmikronApexandGitHub b6442dda75 Merge pull request #737 from got-feedback/feat/playlist-shuffle
feat(v3): playlist shuffle toggle
2026-07-03 14:21:40 +02:00
OmikronApexandClaude Fable 5 336132e049 fix(v3): keep shuffle toggle size stable across states
Off state had a 1px border, on state none — toggling grew/shrank the
button 2px and shifted the row. On state now carries a same-color
border (border-fb-primary, already in the prebuilt CSS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:10:16 +02:00
OmikronApexandClaude Fable 5 a2f43009f7 fix(v3): match shuffle icon height to Play all button
w-4 icon (16px) vs text-sm line-height (20px) made the shuffle button
4px shorter than its neighbor at equal py-2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 14:08:54 +02:00
d27cbe78ba chore: remove stale root README (#739)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:49:03 +02:00
Byron GamatosandGitHub 803bd0cdf3 Bump version to 0.3.0-alpha.1 2026-07-03 13:41:50 +02:00
9456790083 fix(release): lowercase the ghcr repo name in image tags (#738)
The repo is 'got-feedback/feedBack' (capital B) after the rename, so ${GITHUB_REPOSITORY} produced an invalid Docker tag ('repository name must be lowercase'). Use ${GITHUB_REPOSITORY,,}. nightly/rc already hardcode lowercase 'feedback'.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:16:49 +02:00
OmikronApexandClaude Fable 5 425f72b33f feat(v3): playlist shuffle toggle
Crossing-arrows toggle next to Play all / Play album on the playlist
detail page. When on, playQueue.start Fisher-Yates-shuffles the queue
once at start — on a copy, so the stored playlist order is untouched —
swapping per-slot album arrangements in lockstep so each slot keeps its
pinned arrangement (#685 contract preserved). Prev-less queue semantics
are unchanged: auto-advance simply walks the shuffled order.

Preference is global, persisted as localStorage v3PlaylistShuffle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:07:47 +02:00
286c59707b fix(tests): isolate plugin routes modules + redact .feedpak filenames (#736)
Two pre-existing failures the segfault had been masking (the run aborted at ~25%, so they never ran until #735 let the suite complete):

1) Tuner group (~24): plugins ship a bare-named routes.py, so sys.modules['routes'] leaked between plugin test dirs (achievements ran first, tuner got its module). Each plugin conftest now pops the stale 'routes' and an autouse fixture binds sys.modules['routes'] to that plugin's module for the duration of its tests (covers runtime 'import routes' in test bodies).

2) Diagnostics group (5): _SONG_FILENAME_RE never matched the tests' .feedpak/.archive filenames — it also lacked 'feedpak' (the current primary format), a real redaction gap. Added feedpak to the regex and switched the tests off the fake .archive to the real .feedpak. Verified: full suite 2183 passed, 0 failed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:01:07 +02:00
97a941c45d fix(tests): join background scan/enrich workers before closing the DB (#735)
Root cause of the flaky pytest segfault (exit 139): the background scan and enrichment daemon threads (_scan_runner/_enrich_runner) use the shared MetadataDB connection, but test fixtures closed that connection in teardown without stopping them. A daemon thread mid-query on a freed SQLite conn is a native use-after-free → SIGSEGV. The app's startup kicks a scan, so almost any app-booting fixture was vulnerable. It only surfaced now because got-feedback/feedBack#728 added a push trigger, so ci/test runs on every push to main.

Fix: server.py retains the scan/enrich thread handles and adds _join_background_db_threads(); every test fixture now joins the workers before conn.close(). Verified: the full suite runs to completion (no segfault) where it previously crashed at ~25%.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:15:43 +02:00
9d6fdfe232 feat(v3): use PNG logo in sidebar nav instead of the text wordmark (#734)
Replaces the fee[dB]ack text wordmark in the #v3-brand sidebar header with the exported PNG logo (static/v3/brand/feedback-logo-light.png, 664x165). Sized width:100% + height:auto so it fits the 256px sidebar's content width (~208px inside the p-6). Updated both the no-JS fallback (index.html) and the shell.js boot render. Inline style avoids introducing a new Tailwind utility (constitution P-II).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:39:27 +02:00
005270608b ci: adapt workflows to trunk-based development (#728)
Nightly builds main directly (old release/v* discovery pinned nightlies
to shipped branches forever). ship-ci adds push triggers on main and
release/** for post-merge signal. New rc.yml builds :rc images from
release branches during stabilization.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:25:44 +02:00
be9e965001 v3 library: artist pages — in-your-library view, similar-in-library, links-only web links (#731)
* v3 library: artist pages — in-your-library view, similar-in-library, links-only web

The greenlit artist-pages feature. Every page renders from LOCAL data;
an optional, opt-in external-links strip is the only network surface.

Server:
- artist_enrichment table (mb_artist_id PK, url_rels JSON, genres JSON,
  fetched_at) — never purged; one row per matched MusicBrainz artist.
- GET /api/artist/{name}/page — all-local: canonical name (+ raw alias
  variants), song/album/mastered counts, album list, similar-in-library
  (top artists by shared genre, self excluded, empty is fine), and the
  artist's MB id when any matched/manual song carries one. THE DENOMINATOR
  LAW: "N mastered" counts songs you OWN (best_accuracy >= 0.9 across the
  artist's library songs), never a global discography — a stored score for
  a song no longer in the library does not count.
- GET /api/artist/{name}/links — lazy, cached-forever: returns the cached
  row, else (external links enabled + network + a known MB artist id) ONE
  throttled artist lookup (inc=url-rels+genres+tags), whitelisted into
  {official, tour, video, social[], wikipedia}. Every URL passes the same
  http(s) scheme gate as art redirects, so a hostile javascript:/data:/file:
  can never reach an href. POST .../links/refresh re-fetches. Offline /
  no-mbid / links-disabled → empty, no error. Both routes demo-blocked.
- Settings keys artist_pages_enabled (default ON — local-only) and
  artist_external_links (default OFF — opt-in per the dev-chat thread).

Frontend (static/v3/songs.js): an in-place sub-render mirroring openAlbum()
with a "← Song Library" back + scroll restore. 2x2 album-art mosaic header
(borrows the playlist-cover renderer), canonical name + "also shown as"
variants + a Matched·MusicBrainz pill when known; stats strip that omits
the mastered segment at zero (invitational, never "0 mastered"); Play all /
Shuffle (playQueue) + Save as smart playlist (collections rule {artist});
album rail → openAlbum; song list via the artist filter + wireCards;
"Similar in your library" chips → open that artist; and the external-links
row under an "On the web · opens your browser" divider, each link
target=_blank rel=noopener noreferrer with its domain shown — rendered only
when external links are on AND links exist. Empty modules hide.

Entry points: card ⋮ "Go to artist", the grid card artist line, and a
"View artist page" link in the Details drawer — all via
window.__fbOpenArtistPage.

Tests: tests/test_artist_page.py — page counts/albums/alias folding, the
denominator law (owned-only, best-across-arrangements), similar ranking +
empty, mb-id only from matched rows, links whitelist + scheme gate (a
javascript: and an ftp:// URL both rejected), disabled-by-default no
network, cache-no-second-fetch, refresh, demo block. 21 pass (35 with
artist_alias). node --check clean; tailwind rebuilt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 artist pages): unfiltered album view + select-mode row guard on artist page

Artist-page album click no longer applies the global library filters: openAlbum
gains an ignoreFilters option that builds the /api/library request scoped only to
artist+album (no drawer/genre/tuning/search params), so the album view and
Play-album match the artist page's full-shelf counts. The normal albums-view
click path is unchanged (ignoreFilters defaults off).

Select-mode row clicks on the artist page now toggle selection instead of playing.
Extracted the grid/tree capture-phase select guard into a shared bindSelectGuard()
and attach it to the persistent artist-page host too. Each host is bound once at
shell build; innerHTML re-renders reuse the same element, so there is no
double-binding.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:52:20 +02:00
64a499975e v3 library: multi-candidate cover picker — select from a populated list (#732)
* v3 library: multi-candidate cover picker — select from list, media-server style

Auto-best already ships; this adds the "pick from a populated list" surface
Christian asked for, as ONE reusable component (song covers now; album/artist
art reuse the same picker later).

Server:
- GET /api/song/{filename}/art/candidates — assembles WITHOUT hoarding: the
  Current image + its provenance, Pack art when present, and Cover Art Archive
  candidates for the matched release (and any release ids stored on a review
  row's candidates). New _caa_release_index() fetches the CAA release INDEX
  json (image list + types + thumb sizes) through the existing throttle +
  offline gate, cached as caa_index_{id}.json beside the covers (indexes are
  stable). Capped at 12; fetched on demand. Demo-blocked (it spends the rate
  budget) and offline → instant tiles only, no error.

Frontend: new static/v3/image-picker.js — window.__fbOpenImagePicker({filename,
title}), a body-appended singleton modal (match-review anatomy: overlay, focus
trap, Esc). Current image + provenance badge on the left; a tile grid on the
right whose instant tiles — Current, Pack original, Upload, Paste URL — work
immediately even offline, while CAA candidates load behind ONE /art/candidates
fetch with skeleton tiles + a "the source is rate-limited" caption. The fetch
is tied to an AbortController and cancelled when the modal closes.

Applying a pick reuses EXISTING routes so there's no new write path and the
design's key trick holds: a chosen cover POSTs to …/art/url (the override
lane — never evicted by the art-cache LRU, survives a re-match); "Pack
original" DELETEs the override; Upload POSTs …/art/upload (GIF stays
upload-only + local-only). Silent-on-success; the drawer/card art refreshes
via the existing cache-buster.

Entry points: the Details drawer art click (the old direct file dialog is now
the Upload tile) and a card ⋮ "Change cover…" action.

Tests: tests/test_art_candidates.py (matched row lists index images; review
row pulls in candidate releases; unmatched/offline → instant tiles only;
index cached, no second fetch; demo blocked) over a fake index seam.
30 pass with test_art_layer green (same seams). node --check clean; tailwind
rebuilt for the new file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 cover picker): uiPrompt over window.prompt, visible-only focus trap, gate CAA to matched rows, index-cache lock, abort-on-reopen; +traversal tests

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:44 +02:00
8c7cde5d5c v3 library: first-hour polish — zero-states, match progress, provenance, alias search (#730)
* v3 library: first-hour polish — zero-states, match progress, provenance, alias search

Six launch-eve fixes for a brand-new user's first hour with a fresh,
being-matched library. Each is small and reuses shipped idioms.

- Invitational repertoire meter: with no practice data yet, the home meter
  no longer reads "0 of N mastered" (debt framing) — it shows an empty bar
  with "grows as you master songs". A count of 0 read as failure on day one.
- "Start here" starter shelf: growth_edge_suggestions() distinguishes two
  empties — attempts exist but all mastered (honest empty shelf) vs nothing
  attempted yet (day one) → new starter_suggestions() returns up to 8
  approachable songs (90–480s, shortest first) flagged starter:true, and the
  client renders a "Start here" shelf instead of a blank home.
- Library-visible match progress: while the background pass runs, a quiet
  "Matching your library — X of Y" line sits by the review chip (5s poll,
  single guarded interval, cleared the moment the pass stops — no leak,
  no toast, silent completion).
- One-time transparency toast: the first time an install is seen matching a
  real library, one fbNotify names what's contacted (MusicBrainz / Cover Art
  Archive), that results are stored locally, that files aren't changed
  without you, and where the switch is. localStorage-gated, wrapped so a
  blocked notifier can't break the chip.
- Empty-library dead-end card: a genuinely empty local library (no songs, no
  query/filter) shows "Your library is empty" + drop-files hint + Open
  Settings, instead of a bare grid under dead dropdowns.
- Alias-aware search: searching a canonical name ("AC/DC") now also finds
  songs whose raw tag is a merged variant ("ACDC"), via the artist_alias
  table. Probe-guarded so a no-aliases library keeps the exact original
  3-term query; pure predicate, keyset-safe.
- Details-drawer provenance line: matched/manual rows show "Matched:
  <artist — title> (source) · Fix match" under the Identity fields — the
  wrong-match escape hatch at the point of the data, wired to the same
  fix-match flow the card menu uses. New read-only GET
  /api/enrichment/song/{filename} backs it.

Tests: tests/test_starter_suggestions.py (starter vs normal-shelf behaviour,
length window, attempts-exist path unchanged) + alias-search cases added to
tests/test_artist_alias.py. 34 targeted pass; node --check clean; no new
Tailwind classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3 library): stop match-progress poll when leaving the library screen

The 5s enrichment poll (_pollTimer) was cleared on pass completion and on
fetch error, but not when the user navigated away from the library. Leaving
v3-songs mid-pass left the interval pinging /api/enrichment/status in the
background until the pass ended. Subscribe to the existing feedBack
'screen:changed' event: clear the poll when any non-v3-songs screen shows,
and refresh (re-arming if a pass is still running) on returning to v3-songs.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-03 08:49:04 +02:00
df2d660d1e Re-enable the v3 "Support Us!" donate button (#727)
Funding was cleared to come back online 2026-06-30 (offending
functionality fully removed). Restore the v3 topbar donate button
(hand-re-applied revert of cad7885 — the topbar was refactored since,
so this re-adds the Support Us! anchor alongside the new v3-search-wrap)
pointing at the feedBack-branded Patreon page
https://patreon.com/got_feedback.

Rebuild static/tailwind.min.css: the button's utilities
(bg-fb-accent, hover:bg-red-600, shadow-fb-accent/20, sm:inline-flex)
were purged when the button was removed and are needed again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:21:10 +02:00
f5c9c34291 library: scraper options — per-source + per-field auto-apply, review-queue order (R1) (#726)
* library: scraper options — per-source + per-field auto-apply toggles, review-queue order (R1)

Grows the Settings→Library "Metadata matching" card into the full
scraper-options panel — not everybody needs the same things out of a
scraper:

- Sources: enrich_src_musicbrainz gates the background matcher (phase 2;
  identity hashes still stamp, and manual Fix-match/search stays
  available — same contract as the master toggle). enrich_src_caa gates
  the Cover Art Archive fetch (phase 3). Rows skipped by an off toggle
  stay unevaluated, so re-enabling picks them up on the next pass —
  nothing is permanently forfeited.
- Auto-apply fields: enrich_apply_names/year/genres filter what an
  AUTOMATIC match may canonicalize (_enrich_field_filter, applied on all
  three automatic paths: cache copy, mbid/isrc exact keys, text auto).
  MusicBrainz ids always stamp — they're identity, not display; the art
  fetch and future re-matching need them. A match the user confirms in
  the review modal applies in full. enrich_apply_art gates the art fetch
  alongside the CAA source toggle (two axes, one behaviour today —
  future art sources slot in without re-teaching the panel).
- Review queue order: enrich_review_order = missing_first (default,
  today's behaviour) | artist | recent, read by GET /api/enrichment/review;
  unknown stored values degrade to the default.
- Settings card: Sources / Auto-apply / Review-queue-order groups wired
  in match-review.js; the master toggle is relabelled "Match songs
  automatically" so it doesn't read the same as the new MusicBrainz
  source toggle. No tailwind rebuild needed — every class was already
  scanned from core source.

Tests: tests/test_scraper_options.py (9) — settings validation,
MB-source-off stamps-without-matching + re-enable, per-field stripping
on auto matches with ids preserved, review-accept full-apply despite
toggles, CAA gating on both axes, review-order modes incl. the
unknown-value fallback. Full-suite failure set A/B-identical to the
base (39 env/pre-existing).

Stacked on feat/enrichment-art (#715) — merge that first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* library: per-field auto-apply honours "nothing forfeited" (backfill + no partial seeding)

The R1 per-FIELD auto-apply toggles settled a `matched` row with the
disabled fields stripped, but enrichment_pending() never revisits an
unchanged-hash matched row — so re-enabling a field never backfilled it,
and enrichment_cache_lookup() (which gates only on mb_recording_id) could
seed sibling charts with the stripped blanks. This broke the same
"nothing is permanently forfeited" contract the source (mb_on) and art
(art_on) toggles already keep.

Fix: persist an `apply_mask` marker (sorted blocked apply-keys) on every
AUTOMATIC match:
- migration: additive `apply_mask TEXT` column (idempotent ALTER).
- enrichment_pending(allowed_keys=...): re-queues a `matched` row whose
  apply_mask names a field that is now re-enabled → backfill on re-enable,
  converges (a fully-applied row is never re-queued).
- enrichment_cache_lookup: only fully-applied donors (apply_mask empty/NULL)
  may seed siblings; a partial row is skipped and the sibling falls through
  to its own re-filtered match.
- _enrich_apply_mask()/_enrich_blocked_apply_keys() helpers; threaded through
  _enrich_one → apply_enrichment_match. Review/manual writers leave it NULL
  (a confirmed pick applies in full).

Tests: re-enable-backfills-and-converges; partial row is not a cache donor
(fully-applied one is). 13 scraper-options tests pass; 170 enrichment/
settings tests green.

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

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:57:42 +02:00
7ca736d525 library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (P9) (#715)
* library: cover-art layer — CAA auto-fetch + user overrides, GIF local-only (R3/P9)

Third slice of the enrichment series (stacked on the matcher): covers.

- Serve chain for GET /api/song/{fn}/art: USER OVERRIDE -> PACK ART ->
  COVER ART ARCHIVE cache -> 404. Behaviour change, deliberate: a
  user-uploaded cover now OVERRIDES pack art (previously the upload only
  filled the no-art gap, which made custom art look broken on any song
  that already shipped a cover).
- GIF is allowed as an override and kept VERBATIM (animation intact) —
  a local-only bonus. Everything else normalizes to RGB PNG as before.
  One override per song (saving either kind removes the other), and
  nothing ever writes art INTO a pack file — test-pinned: the pack's
  cover.jpg is byte-identical after a GIF upload.
- Art by URL: POST /api/song/{fn}/art/url fetches server-side (http(s)
  only, 10 MB cap enforced while streaming) into the same override slot.
  DELETE /api/art/{fn}/override drops it — under /api/art because the
  greedy DELETE /api/song/{path} catch-all shadows anything beneath it
  (the same dodge the chart split/unsplit routes use).
- Cover Art Archive fetch as phase 3 of the enrichment pass: matched
  songs that LACK pack art get their release's front cover, throttled +
  identified + offline-guarded exactly like the MusicBrainz client
  (pytest can never reach the network; a transport error pauses the
  pass without burning the row). The cache is keyed by RELEASE MBID —
  ten charts of one album cost one fetch — and every outcome writes an
  art_state (pack/user/caa/none/error) so a row is evaluated once.
- LRU cap (200 MB) on the CAA side of the cache only; user overrides
  are never evicted, and evicted rows reset so a later pass may
  re-fetch. Deleting a song removes its override files (CAA files stay
  — they may be shared by other charts of the release).

No frontend changes: the grid, the review modal, and the player pick
the new art up through the same route they already use. The
upload/paste-a-link surfaces in the Details drawer land with the
context-menu slice once the drawer PR merges.

13 new tests (tests/test_art_layer.py) + demo-mode routes; full-suite
failure set byte-identical with the change stashed vs applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* library: harden cover-art layer — SSRF guard, demo/size caps, override-delete state reset

Follow-up hardening on the R3 cover-art layer:

- remove_song_art_override: reset the enrichment row (set_enrichment_art(fn,
  None, None)) when an override is deleted, so a row previously settled as
  'user' re-queues and the CAA fallback resumes. Previously a removed override
  stranded the row (enrichment_art_pending only re-queues art_state IS NULL),
  leaving the song with no art at all.
- Base64 art upload: block it in demo mode (was open — a write/disk-fill vector,
  worse now that GIFs are stored verbatim), validate the filename resolves to a
  real song (mirrors the url route), and cap the decoded payload at 10 MB.
- Art-by-URL: reject hosts that resolve to loopback/private/link-local/reserved/
  multicast/unspecified addresses (SSRF, e.g. cloud metadata) and stop following
  redirects (allow_redirects=False) so a redirect can't smuggle the request to an
  internal target. Fails closed on unresolvable/unparseable hosts.
- _caa_http_get: stream with a per-file 10 MB cap (bounds any one response
  independently of the aggregate LRU); guard release_id against a conservative
  token before interpolating it into a cache-file path (no separators/dots).

Tests: delete-override→CAA-fallback, upload unknown-song/oversize rejection,
SSRF internal-host guard, and a demo-mode block assertion for art/upload.

Note: art_state='error' rows are intentionally not auto-retried — there is no
per-row attempt counter on the art side, so an unbounded retry could storm CAA
for permanently-bad rows; a bounded retry would need extra state, left out here.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 20:55:43 +02:00
8e953e8bc4 library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a) (#724)
* library: opt-in gap-fill — write confirmed missing metadata into the pack (R4a)

The write-back contract agreed with the spec chair (alignment doc §7),
made executable, now that feedpak-spec 1.14.0 (mbid/isrc) is merged:
opt-in + user-initiated, adds ABSENT keys only, spec'd-keys allowlist,
values only from a CONFIRMED identity, atomic write + .bak. Single-song
only — batch write-back stays an open question with the chair.

- songmeta.gap_fill_sloppak: append-only manifest writer. Every added
  key is absent by definition, so the new lines are APPENDED — the
  author's existing bytes (key order, comments, formatting) survive
  verbatim, unlike the metadata editor's full re-serialize. Directory
  form gets a one-time manifest.yaml.bak + temp + atomic replace; zip
  form reuses the editor's backup/temp/replace rewriter. Raises on any
  already-present key: the never-clobber rule lives in the writer, not
  just the callers.
- GET /api/song/{fn}/gap-fill: read-only preview — which of
  album/year/genres/mbid/isrc are missing from the file (absent or
  empty; year 0 = empty), with the values the enrichment match
  supplies. Only a CONFIRMED identity is eligible (matched or a user
  pin); review-tier rows are refused until a human confirms —
  wrong-match > fast, same as everywhere else in the enrichment layer.
- POST /api/song/{fn}/gap-fill {keys}: writes the user-confirmed
  subset. Proposals are RECOMPUTED under _song_io_lock, so a key that
  gained an author value between preview and confirm is skipped, never
  replaced. mbid/isrc written in canonical form only (validated).
  DB stays scanner-consistent (album/year/genre columns + mtime/size
  re-stat, cache invalidation + scan kick — the metadata editor's
  contract). Demo mode blocks the write.
- Details drawer (Identity section): "Write missing info to file…" →
  per-key checkbox confirm ("Only adds what's missing — nothing already
  in the file is changed. A backup (.bak) is kept.") → written
  confirmation; not-eligible states explain themselves. v3 only; no
  new tailwind classes.
- Rides along: _manifest_exact_ids now strips ISRC display separators
  (spec 1.14.0's strip rule) — a hand-authored "AU-AP0-90-00045" hits
  the exact-match tier instead of silently falling back to text.

Tests: tests/test_gap_fill.py (10) — preview eligibility incl.
review-refusal + empty-as-gap, author-bytes-preserved-verbatim on dir
AND zip (with .bak content pinned), skip-not-replace on the mixed
request, the writer's ValueError guard, key validation, demo block,
DB sync; +1 hyphenated-ISRC test in test_mb_enrichment.py. 46 targeted
green; full-suite failure set A/B-identical to the main base (39
env/pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* gap-fill: align preview with append-only writer (no cleared-value 500)

The R4a preview offered present-but-empty manifest values (album: '',
genres: [], year: 0) as gaps, but the append-only writer's never-clobber
guard raises on ANY key already present — so a user-confirmed POST for
those keys turned into a 500 "write failed" instead of filling the gap.
Appending can't fill an empty-but-present key anyway (it would duplicate
the YAML key).

Fix: _gap_fill_manifest_absent now treats only genuinely-MISSING keys as
gaps; a present-but-empty value is left to the metadata editor (which
re-serializes and can replace in place). This closes the preview→POST
mismatch — the preview never offers what the writer would refuse.

Tests: test_preview_treats_empty_values_as_gaps replaced by
test_preview_excludes_present_but_empty_keys (present-but-empty not
offered; genuinely-absent still offered) + test_write_present_but_empty_
key_is_refused_not_500 (POST → clean 409, file untouched, no .bak; a
genuinely-absent key alongside still writes). Closes the write-path blind
spot in the original empty-value test.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 20:52:11 +02:00
7ef52cdd66 fix: Edit Metadata persists into .feedpak files (suffix gate predated the rename) (#725)
write_song_metadata dispatched zip-form packages on `suffix == ".sloppak"`
only, while core reads both suffixes everywhere else (sloppak.SONG_EXTS,
.feedpak being the current write extension). Editing a zip-form .feedpak's
title/artist/album/year therefore silently fell back to a DB-only update,
which looked fine until the next full library rescan re-derived metadata
from the file and reverted the edit — the exact failure this module exists
to prevent. Directory-form packages were unaffected (manifest-presence
dispatch, not suffix).

Gate on SONG_EXTS, add TestWriteSongMetadata regression coverage (both zip
suffixes, mixed-case suffix, directory form, unknown-suffix fallback), and
correct the stale scan_worker comment claiming .sloppak-suffix-only
detection (the code already accepts both via is_sloppak).


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:51:57 +02:00
55060c4f67 v3 library: artist sort orders titles within an artist (tree-view feel) (#720)
* v3 library: artist sort orders titles within an artist (tree-view feel)

Tester report: "the list is set up by artist, but the cards are
alphabetical(-ish random)". Real: the tree orders artist -> album -> title,
while the grid's artist sort ordered within an artist by RAW FILENAME —
community-pack filename noise, so an artist's cards looked shuffled.

- artist / artist-desc gain a title secondary (direction baked per entry so
  the legacy `dir=desc` append can't land on the title term; titles stay
  A->Z under Z->A artists).
- The two-term (value, filename) keyset cursor can't seek a three-term
  order, so artist sorts leave _KEYSET_SORTS and page by OFFSET — measured
  trivial at real library sizes; title/recent keep their keyset. Restore
  via a composite sort-key column if 50k-song libraries ever hurt.
- The tree view says "List view groups by artist — the selected sort
  applies to the card grid" when a non-artist sort is active, instead of
  silently ignoring the picker.
- Keyset proof-tests repinned to the title sort (same property, a sort
  that still keysets); 2 new tests pin the title-within-artist order and
  the OFFSET pagination's no-skip/no-dupe across pages.

Full-suite failure set identical to the same-main baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 library: honor legacy sort=artist&dir=desc (fold dir into effective sort)

Codex/review follow-up to the title-within-artist change: the new artist
ORDER BY bakes in `ASC` (for the title secondary), so the global `dir=desc`
append is suppressed and `sort=artist&dir=desc` silently returned A->Z
instead of Z->A — a regression on the legacy /api/library dir contract.

Fold `dir=desc` into the canonical sort key BEFORE the sort_map lookup via
the existing _effective_keyset_sort helper (same fold the cursor side already
does), so the ORDER BY is built from the effective sort. Only artist/title
fold (they have `-desc` twins); title/recent/tuning/year/mastery are
unaffected — verified by the keyset/filter suites.

New test pins that legacy `sort=artist&dir=desc` matches the explicit
`artist-desc` ordering (Z->A artists, A->Z titles within each).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 20:51:33 +02:00
7564934d06 v3: rename sidebar "Songs" entry to "Song Library" (#721)
The LIBRARY group's main entry now reads "Song Library" in the sidebar
(and in the topbar page title, which mirrors nav labels). The nav key
and screen id are unchanged, so routing, saved hashes and promoted-
plugin anchors are untouched.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:50:59 +02:00
28b0319e27 play-queue: peekNext() — expose the following track for queue-aware UIs (#719)
A results screen that offers "Up next: <song> — starting in 10s" needs to
know WHAT follows without reaching into queue internals. peekNext() returns
{filename, index, total} for the next track (null when nothing follows),
pure — peeking never plays or mutates.

First consumer: the note_detect results card's queue-advance strip (the
"Playlist Play All has no way to progress" tester issue).


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 20:50:56 +02:00
4b6cbe8b11 Fix 3D drum/keys highways not resizing on fullscreen under splitscreen (#723)
The guitar/bass highway_3d renderer self-detects panel-canvas size changes
in its draw() loop and re-runs applySize() every frame, because the
splitscreen host overrides hw.resize and never calls renderer.resize().
The drum and keys highways lacked that fallback — they only re-framed when
the host explicitly called resize(w, h) — so their panels stayed framed for
the pre-fullscreen size while the guitar/bass panels adapted. Symptom: a
too-small, off-center highway in the drum/keys panels after maximizing a
split-screen session.

Port highway_3d's per-frame drift check into both draw() loops: re-apply on
backing-store change (canvas.width/height) AND on CSS-box drift
(clientWidth/clientHeight vs the last applied logical size, throttled to
every 10th frame). Track _lastHwW/_lastHwH + _appliedW/_appliedH per
instance and reset them in destroy() so a reused instance re-frames on the
next song.

plugins/drum_highway_3d -> 0.3.1, plugins/keys_highway_3d -> 0.1.1.
Tests: tests/js/drum_keys_highway_3d_resize_reframe.test.js.

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:42:26 +02:00
c7497c758d v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (#718)
* v3 library: context-menu unification — Fix match, Refresh metadata, Get info, multi-version remove (R2)

The ⋮ overflow and the native right-click menu already render from one
builder (openCardMenu), so every entry here lands in BOTH surfaces on
grid cards and tree rows alike — parity is structural, not maintained.

New entries (local library):
- Fix match… — opens the match modal in a single-song mode: no queue,
  no Skip, the search panel open and pre-filled; a pick pins the match
  exactly like the review flow (window.__fbFixMatch).
- Refresh metadata — POST /api/enrichment/refresh/{fn}: resets the
  song's match to unscanned (canonical values + candidates cleared,
  backoff zeroed) and kicks a pass. An EXPLICIT user action, so it may
  discard a manual pin — the automation never does, but the user asking
  for a re-match is the one party who owns that pin. Silent on success.
- Get info… — GET /api/chart/{fn}/fileinfo: file location + folder
  (selectable/copyable under the v3 no-select default), format, size,
  modified; for feedpaks the manifest summary (arrangements, stems,
  cover/lyrics presence, authors, and whichever identity keys are
  actually authored — mbid/isrc/genres/track/disc); plus the match
  verdict ("Matched (text, 96%)" / "Pinned by you" / "Not scanned").
  Under /api/chart because the GET /api/song/{path} catch-all would
  swallow the suffix.
- Remove from library — with the multi-version interstitial: on a
  multi-chart work, "remove the song" is ambiguous (a grouped card
  stands for several files), so a modal lists EVERY version with
  checkboxes (the card's own chart pre-ticked) and deletes exactly
  what was picked — one file or the batch. Single-chart songs keep the
  plain confirm.

Refresh + Get info are demo-mode blocked (cache mutation / path
exposure). apply_enrichment_match now zeroes `attempts` on an explicit
reset to unscanned, matching the stub upsert's identity-change rule.

5 new tests (refresh resets even a manual pin then re-matches via the
fake transport; 404s; fileinfo manifest/identity/match shapes;
traversal guard) + the demo-mode route list. Full-suite failure set
identical to the same-main baseline. tailwind.min.css regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 context menu: review fixes — target displayed chart, harden Get-info

Three fixes from the PR review round:

- songs.js: Fix match / Refresh metadata / Get info now act on the DISPLAYED
  chart (playTarget) rather than the group representative, matching Play. On a
  grouped card where an intrinsic (tuning/arrangement) filter attached a
  display_chart, these three previously fixed/refreshed/showed-info for the
  wrong file. (__remove stays on `song`: it needs the group's work_key/
  chart_count and already pre-ticks the shown chart.)

- server.py fileinfo: 404 ("not a chart") unless the path is a sloppak or a
  loose song. The route previously stat'd ANY file under DLC_DIR, leaking its
  path/size/mtime for e.g. a notes.txt the user keeps there. `format` can no
  longer be "other".

- server.py fileinfo: the directory size sum skips symlinked entries so a link
  inside a song folder can't pull in (or leak the size of) a file outside it.
  Verified on the runtime that rglob does not descend symlinked subdirs.

+1 regression test (non-chart file -> 404).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 18:36:00 +02:00
0a8c8945ea v3 library: Albums view — the client half of the album-condense work (#716)
* v3 library: Albums view — the client half of the album-condense work

Follow-up to the query_albums endpoint: the UI that consumes it, plus the
track-order plumbing the endpoint's track list needs.

- Albums view (a fourth view toggle next to grid/tree/folder): album cards
  (cover / title / artist / track count) from /api/library/albums,
  respecting the active filter drawer; clicking one opens the track list
  with per-track play and a Play-album button that feeds the play queue
  (falls back to plain playSong when the queue plugin is absent).
- Track order: the scanner now reads the feedpak `track`/`disc` fields
  (spec 1.12.0) into new nullable songs columns (idempotent ALTERs), and
  the album track list orders by the new `track` sort — disc, then track
  number, unauthored charts to the bottom by title. Charts without
  authored numbers keep working; they just sort alphabetically.
- The albums view persists like the other view choices.

3 new tests: manifest track/disc extraction (and unauthored -> None),
the disc->track->title sort order over /api/library, and the put()
round-trip. Full-suite failure set matches the known env baseline (one
tuner-config name swapped inside the suite-ordering flake family — the
file passes 25/25 in isolation on clean main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 albums: honour Genre/Match filters in album grid + detail (review fixes)

The Albums view only partially respected the filter drawer:

- /api/library/albums silently dropped the `genre` and `match` params the
  client sends via queryParams(), so with a Genre or Match filter active the
  album grid surfaced albums with zero matching tracks. Thread match_states/
  genre through the endpoint -> query_albums -> _build_where, mirroring the
  /api/library grid route. (SmartCollection/pass-through providers keep their
  existing kwarg handling.)

- The album-detail track list built its own params (provider/artist/album/
  sort only), so it ignored ALL active filters — the track list and the
  Play-album queue could include songs the user had filtered out. Reuse
  queryParams({...}, {catalog: true}) so detail honours the same filters as
  the grid while pinning this album's artist/album and track order.

+1 regression test (albums endpoint honours the Genre filter).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 15:57:26 +02:00
2e4383524f fix(v3): drawer fav-sync honours data-fav-idle (no dim-heart on List View) (#717)
_patchCardFav (the Song Details drawer's like -> card heart sync) hardcoded
`classList.toggle('text-white', !fav)`, so toggling the like from the drawer
left List-View rows' `text-fb-textDim` idle class in place — the exact
dim-heart bug #654 fixed for the on-card click handler, reintroduced on the
drawer path. Read the per-heart `data-fav-idle` and swap that class instead,
mirroring wireCards. +regression assertion in v3_favorites_toggle.test.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:52:40 +02:00
13db718bda test(v3): sync A–Z rail assertion to the railParams refactor (#702) (#714)
refreshRail was refactored (PR #702 work-grouping) to build a
`railParams = { sort_letters: 1 }` object (adding group when grouping is
active) before calling queryParams(), instead of the inline
queryParams({ sort_letters: 1 }). Behaviour is unchanged — it still opts
into the active-sort breakdown — but the source-assertion test lagged and
went red on main. Point the assertion at the new railParams shape.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:03:05 +02:00
727b8c8f24 feat(a11y): app-wide "Interface size" setting (Accessibility) (#664)
* feat(a11y): app-wide "Interface size" setting (Accessibility)

Adds a dedicated Accessibility -> Interface size control so users on large,
low-DPI displays can enlarge the app's menus, buttons and text (reported: eye
strain on a 32" 1440p panel with no OS scaling).

Mechanism: a host-owned scale capability (window.feedBack.scale) applies a
RELATIVE root font-size (a % of the user-agent base, never a px literal, so a
raised browser/OS base font is respected) and publishes an always-present
--fb-scale token. The rem-based v3 chrome scales together; the gameplay highway
canvas (device-pixel sized) is deliberately untouched, so playback resolution
and FPS are unchanged. Medium (100%) clears the override, so default rendering
is byte-identical to before -- zero blast radius.

- Settings -> new Accessibility tab: Small/Medium/Large/Extra-Large presets
  (0.90/1.00/1.15/1.30) + a fine-tune slider (to 150%).
- Applied pre-paint from an inline <head> script (mirrors the ss-follower
  pattern) so there is no flash-of-reflow on load.
- window.feedBack.scale read-API (get/set + scale:changed, fires once on load)
  so canvas/WebGL surfaces that cannot inherit rem can follow the size. Shape
  mirrors the working-tuning read-API; persists as a durable preference.
- Cosmetic px->rem sweep so text scales cleanly at the larger stops (2 v3.css
  font-sizes + 20 text-[Npx] utilities across 8 v3 files; tailwind.min.css
  rebuilt byte-stable via the pinned toolchain).
- One-time first-run nudge for the large/low-DPI display profile that deep-links
  to the control (never fires once the setting is touched, or on other displays).

v3-only. Verified headless: core apply/persist/reset/reload/UI-sync + nudge
gating, with no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): regenerate tailwind.min.css to satisfy tailwind-fresh CI (PR #664 review)

Regenerate static/tailwind.min.css via scripts/build-tailwind.sh
(tailwindcss@3.4.19) so a fresh build matches the committed artifact and
the tailwind-fresh CI job's `git diff --quiet` passes. Two consecutive
regenerations are byte-identical.

Also (Fix 2) switch the fine-tune interface-size slider to the documented
transient-preview path: oninput now calls scale.set(v, { persist:false })
so dragging previews without writing localStorage/emitting a commit each
tick, and a new onchange commits with persistence on release.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 14:00:30 +02:00
15fabb62aa Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size (#653)
* Fix v3 Songs A–Z rail: reliable taps, precise drag, hittable size

Follow-up to #634. Three rail bugs reported on macOS + Windows (0.3.0,
2026-06-29 — =Scr4tch=, MajorMokoto):

- Taps often did nothing ("clicked O, nothing happened"). pointerdown
  calls setPointerCapture, after which the browser retargets the
  follow-up click to the rail container, so the click handler's
  closest('.v3-azrail-letter') resolved null and a plain tap (no
  pointermove) had no other path. Drive the jump from pointerdown
  itself; reduce the click handler to keyboard activation only
  (e.detail === 0, Enter/Space).

- A drag landed short of the release ("where you release isn't where
  you get sent"). Every letter crossed fired jumpToLetter with
  behavior:'smooth'; stacked smooth-scroll animations over the
  virtualized grid lagged and settled imprecisely. jumpToLetter now
  takes a smooth flag and scrolls instantly ('auto') while scrubbing,
  animating only discrete taps/keyboard jumps, so the grid tracks the
  finger and the release lands on the let-go letter.

- The rail was too small at 1440p and didn't scale. Letters were a
  fixed .62rem glued at right:2px (~13px-tall target). They now scale
  with the viewport (clamp(.72rem, 1.4vh, 1.05rem)), sit off the edge
  with taller/wider equal-width hit targets and a hover/active
  highlight so the scrub target is visible.

Keyboard arrow-nav and present-letter gating are unchanged. Tests:
tests/js/v3_az_rail.test.js gains pointerdown-seek, keyboard-only click
guard, and instant-vs-smooth assertions (809 JS tests; the 13
pre-existing unrelated failures are unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): ignore non-primary buttons on A–Z rail pointerdown (PR #653 review)

Right- or middle-clicking the A–Z rail (or a secondary multi-touch
pointer) no longer triggers a seek; only the primary tap/drag scrubs.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:59:10 +02:00
0d28886d46 Fix v3 Songs List View favorite heart staying dim until re-search (#654)
Favoriting from the tree / "List View" flipped the glyph ♡→♥ but the
heart stayed grey until a re-search — reported macOS+Windows, open since
0.3.0 / 2026-06-25.

One shared wireCards() [data-fav] handler serves both the grid card and
the List-View row, but they render with different idle colours (grid
text-white, List View text-fb-textDim) and the handler only ever removed
the grid's text-white. So in List View text-fb-textDim lingered next to
the freshly-added text-fb-accent and won by CSS source order — the glyph
changed but the colour didn't, until a re-search re-rendered the row.

Each heart now declares its idle colour via a data-fav-idle attribute;
the handler swaps exactly that class (so only one colour class is ever
present) and writes the new state back onto the in-memory song model so a
re-render / virtualized-grid recycle agrees instead of reverting.

Tests: tests/js/v3_favorites_toggle.test.js. Full JS suite 810 tests; the
13 pre-existing unrelated failures are unchanged.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:58:21 +02:00
fee85a14e7 v3 library: native right-click context menu on cards (#688)
* feat(v3): native right-click context menu on library cards

Right-clicking a song card now opens the same overflow (more) menu at the
pointer, so the actions (Play, Add to playlist, Save for later, and any plugin
card actions) are reachable without aiming for the small button. openCardMenu
gains an optional pointer position (fixed + viewport-clamped when opened via
right-click, the existing card-anchored absolute otherwise). Adds a "Save for
later" row to the menu too, closing the gap where Save was only the inline
button. Shares one menu definition + the libraryCardActions registry, so plugin
actions appear in both paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): valid JS emoji escape in card-menu toast + dismiss orphaned menu on re-render (PR #688 review)

Replace the Python-style `\U0001f3b5` escape (invalid in JS, rendered
literal text) with the 🎵 emoji character in the playlists-updated toast.
Also tear down any open card context menu at the start of render(), the
full filter/scan-refresh path: the right-click menu is appended to
document.body, so replacing root.innerHTML would otherwise leave it
floating orphaned until the next document click. Reuses _closeCardMenu.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:58:13 +02:00
80caf78306 v3 library: genre filter facet (reads feedpak genres) (#690)
* feat(v3): genre filter facet (reads the feedpak genres field)

Adds a Genre facet to the library Filters drawer, populated from each song's
primary genre. Server: a genre column (idempotent ALTER, indexed) written from
the sloppak manifest's genres list on scan (primary = genres[0]); a genre band in
_build_where (OR within the selected set); GET /api/library/genres for the facet's
distinct list. Client: a multi-select Genre section mirroring the tuning/mastery
facets. Follows the merged spec 1.12.0 genres field (#40).

v1 stores only the PRIMARY genre (genres[0]); secondary genres aren't filterable
yet. Threaded like the mastery filter (separate query_page kwarg, so query_artists
/query_stats are unaffected) -- genre filters the grid view. Needs a rescan to
backfill genre on existing packs (only packs whose manifest carries genres).

Verified live: a sloppak tagged genres:[Metal, Rock] -> /api/library/genres
returns [Metal]; ?genre=Metal returns it; ?genre=Rock (secondary) returns none; a
plain song stays ungenred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): scope genre facet to local provider (PR #690 review)

The /api/library/genres facet always read the local meta DB, so a remote
provider showed local genres while the `genre` filter was a no-op on that
provider's grid. Make the endpoint provider-aware: return an empty facet
for remote providers (kind != "local") and keep serving genres for the
local library and its smart collections, which share the local DB. The
v3 client now passes the active provider, mirroring the tuning-names facet.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:58:05 +02:00
d20b33348b feat(v3): host theme read surface — window.feedBack.theme + always-present --fbv-* tokens (#646)
First slice of the host theme contract (#644): give plugins a host-owned way to
read the active theme + its device affordances, so a feature renders correctly
under any theme instead of binding to whichever one the dev saw.

theme-core.js previously only APPLIED themes and emitted --fbv-* vars only while
a theme was equipped (nothing to read in the default state), with no read API.
Now, all additive + feature-detected:

- Always-present default `fb` palette as `--fbv-*` on :root (the un-themed look
  is unchanged — fb-* utilities still use their compiled defaults; this only
  hands plugins a stable host token to read + derive surfaces from). Adds two
  keystone ROLES the palette lacked: `on-accent` (legible fg on the accent fill)
  and `focus-ring`.
- window.feedBack.theme.get() -> {id, isThemed, tokens}; .capabilities() ->
  {glow, gradients, motion} (the device-affordance signal; recolor-only themes
  report defaults, a theme may opt out via `capabilities` in its payload, motion
  is reduced-motion-gated); .prefersReducedMotion().
- Normalized `theme:changed` event from the single apply() chokepoint.

The apply side stays on window.v3Theme; the read surface is attached defensively
so it survives the feedBack bus being (re)built by capabilities.js regardless of
load order. Verified via a headless render (apply/unequip intact, defaults
present + restored, capability opt-out honored, event payload correct) +
tests/js/v3_theme_read_api.test.js. See docs/host-theme-contract.md.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:57:58 +02:00
7ff9261000 feat(v3): show "N added / M removed" after a library scan (#686)
Completes the Refresh feature: the scanner now reports a delta so the toast can
say what changed instead of a generic confirmation.

Server: delete_missing returns both deltas from its one query -- rows pruned
(removed) and current files not yet in the DB (added) -- and the scan retains
added/removed on the terminal scan-status (previously wiped to 0). Client: the
completion toast shows "N songs added / M removed" (or "up to date"), and a scan
we merely attached to (background / Settings) toasts only when it actually
changed something, so a periodic no-op pass stays silent. library:changed now
carries the delta too.

Verified end to end: empty rescan -> added 0; add a song -> added 1; remove it
-> removed 1.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:57:50 +02:00
11c0f0483f feat(server): query_albums endpoint for album-condensed browse (#689)
Adds GET /api/library/albums + MetadataDB.query_albums (plus the local and
smart-collection provider delegations): distinct (artist, album) groups with a
track count and a representative cover song, paged by album, honoring the same
filters as /api/library (including the new mastery bands). Rows with no album
name are excluded. Album detail needs no new endpoint -- it reuses the existing
/api/library?artist=&album=.

Backend foundation for the album-condense "Albums" view + play-album; the client
view consumes it next.

Verified: 4 tracks sharing one album -> /api/library/albums returns one card
(artist, album, count=4, cover); /api/library?album=... lists its 4 tracks.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:56:57 +02:00
74cd08f765 library: MusicBrainz text matching + Match-Review UI (P8) (#710)
* library: MusicBrainz text matching + Match-Review UI — P8

Replaces the enrichment plumbing's no-op matcher (P7) with the real
pipeline, per the library-metadata design: a wrong match is worse than a
slow one, so medium confidence goes to a human review queue and never
straight to canonical values.

- lib/mb_match.py (new, pure — no network/DB/server imports): denoise
  (author credits, (440Hz)/(Live)/(No Lead)/(v2) parentheticals,
  diacritics/punctuation, ACDC / AC DC / AC/DC folding via compacted
  token equality), token-set similarity, scoring with year/duration
  corroboration bonuses, tier classification (auto needs combined
  >= 0.95 AND per-field floors — a perfect-title cover by the wrong
  artist, or a chart with no artist, can never auto-match), Lucene
  query building, MusicBrainz response normalization.
- Matcher precedence in _enrich_one: content-hash cache copy (another
  chart of the same recording matches with no network) -> manifest
  mbid (tier 0) / isrc (tier 1) exact keys, feature-detected and
  strictly shape-validated, read-only -> text search tiers
  (auto / review / failed).
- Lifecycle: review rows store their ranked candidate list (JSON) and
  write NO canonical fields until a human accepts; failed rows retry on
  an exponential backoff (1 h doubling, 7 d cap) via the attempts
  column; user-rejected rows never auto-retry; an identity edit
  re-queues anything and resets the backoff; never-overwrite-manual is
  enforced inside the single writer (apply_enrichment_match) so no call
  path can forget it.
- Network: _mb_http_get is the one transport seam — throttled to
  <= 1 req/s through P7's _enrich_throttle, identified with a real
  User-Agent from VERSION, and a 503 pauses the whole pass without
  burning attempts. Offline guard: no sockets under
  FEEDBACK_ENRICH_OFFLINE or FEEDBACK_SKIP_STARTUP_TASKS, so pytest can
  never reach MusicBrainz; the pass still stamps identity hashes
  (two-phase), which is why every P7 test passes unchanged.
- Routes: GET /api/enrichment/review, POST
  /api/enrichment/review/{filename}/accept|reject|pick, GET
  /api/enrichment/search (throttled manual-search proxy). All four are
  demo-mode blocked.
- Match facet: match= CSV accepted by /api/library AND
  /api/library/stats (the A-Z rail's letter counts stay lockstep with
  the grid) — review / matched (incl. manual) / unmatched / pending,
  the same EXISTS idiom as the mastery facet.
- UI: static/v3/match-review.js (new, self-contained) — an ambient
  "N to review" chip beside the song count (rendered only when
  non-zero; silent on success, no toasts), and a review drawer on the
  filter-drawer slide idiom (Escape + focus trap; row click accepts,
  "Not a match" rejects, "Search instead" is the fix-match escape
  hatch). songs.js gets the chip mount, a Match filter section, and
  session-only match state; also fixes the latent applySavedPrefs bug
  where restored filters dropped the mastery key, which made the
  filter drawer throw for anyone with saved prefs.
- static/tailwind.min.css regenerated (scripts/build-tailwind.sh) for
  the new utility classes; conflicts with sibling PRs resolve by
  re-running the script.

Nothing is ever written to pack files — canonical values live only in
the song_enrichment display cache. Cover art caching and acoustic
fingerprinting are follow-up slices.

22 pure unit tests + 19 server tests (fake transport injected over the
_mb_http_get seam) + demo-mode route cases; full-suite failure set
A/B-identical with the change stashed vs applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* library: match-review modal + configurable auto-apply confidence (P8 R0)

Follow-up to the initial P8 commit, folding in the first round of tester
feedback on the review surface and the matcher's knobs:

- Review GUI is a centred MODAL now, not a sidebar — one chart at a
  time (the scraper-review model from media-server / emulation-frontend
  apps): the chart's current metadata with explicit amber
  "Missing: album / year / cover art" chips (art detected via the art
  request failing), candidates each carrying "Adds: year - genres -
  ISRC" / "Shows as: ACDC -> AC/DC" per-field chips, and Skip /
  Not a match / Search instead / Use selected with prev-next + arrow-key
  navigation. Chip + window API surface unchanged, so songs.js needed no
  edits for the rework.
- Auto-apply confidence is a SETTING: default drops 0.95 -> 0.90
  (mb_match.AUTO_MIN; classify() takes an auto_min override). The
  per-field floors are untouched and threshold-independent — a
  perfect-title cover by the wrong artist still can't auto-match at any
  setting. New validated settings keys: enrich_enabled (bool) +
  enrich_auto_threshold (0.5–1.01; >1.0 = "Always review", since a
  capped score can equal exactly 1.0). Read once per pass; disabling
  gates only the BACKGROUND matcher — manual search/fix stays available.
- Settings -> Library -> "Metadata matching" card: enable toggle,
  confidence select (85 / 90 / 95 / Always review), a Match Now button
  (new POST /api/enrichment/kick, single-flight like every other kick,
  demo-mode blocked), and a live status line fed by the same fetch as
  the review chip. Markup in index.html per the v3 settings pattern,
  wired by match-review.js, null-guarded so v2 no-ops.
- Review queue orders missing-data charts first — confirming those has
  the most to gain; complete charts only stand to be re-labelled.

Tests: threshold moves the auto/review boundary via settings; the
enable toggle gates matching but not the manual proxy; settings
validation; kick route; queue ordering; classify(auto_min=...) floors.
Full-suite failure set byte-identical to the pre-change baseline.
tailwind.min.css regenerated for the modal's utility classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(library): lock MusicBrainz throttle across sleep + de-dup enrich queue (PR #710 review)

Hold a module-level lock across _enrich_throttle's read/sleep/write so the
background daemon and threadpooled sync search route serialize outbound MB
requests instead of bursting past the 1 req/s limit. De-dup the enrich queue
by filename so a changed-hash failed row isn't processed twice per pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:47:55 +02:00
7c15cdda66 library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7 (#707)
* library: metadata-enrichment plumbing (cache table + worker lifecycle) — P7

The pipeline around a deliberately NO-OP matcher, so the real
MusicBrainz text matcher (next slice) replaces exactly one function and
inherits the queue, throttle, lifecycle, and safety contracts:

- song_enrichment cache table: one row per song holding the match
  lifecycle (unscanned -> matched(source,score) | manual | failed) plus
  the canonical values a confident match supplies. A DISPLAY cache -
  canonical values are never auto-written into pack files. Never purged
  on rescan (only by the explicit per-song delete); dead rows filtered
  at read time; re-derivable, so a lost row just re-enriches.
- Identity hashing: sha1 of normalized artist|title|album|duration.
  Filename-free, so a renamed pack keeps its enrichment; unchanged hash
  makes re-enrichment a no-op (idempotent).
- Queue rules (test-pinned): no row / unscanned / identity-changed ->
  re-match; matched + current hash = settled; a MANUAL row is the
  user's pinned pick and is never auto-reset (state and hash both
  survive metadata edits); failed waits for the matcher's backoff
  policy (attempts column ready).
- Worker: _kick_enrich/_enrich_runner mirror the scan's single-flight +
  coalescing pattern, kicked when a scan pass fully completes (the scan
  pool is a no-network process pool by design; the 5-minute periodic
  rescan is the natural retry hook). One bounded pass per kick - no
  drain-loop, since the no-op matcher legitimately leaves rows
  unscanned. _enrich_throttle() is the <=1 req/s seam every matcher
  must call before a network request, and the never-hold-meta_db._lock-
  across-a-fetch rule is documented at the seam.
- CONFIG_DIR/art_cache dir helper (the cover-art slice adds the LRU
  cap) + GET /api/enrichment/status (worker flags + counts by state).

8 new tests; full-suite failure set identical to unmodified main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(library): lock enrichment reads on shared conn + skip redundant stub writes (PR #707 review)

Issue 1: wrap the SELECT+fetch in enrichment_pending, get_enrichment and
enrichment_state_counts in self._lock so request-thread reads no longer
interleave with the worker's execute+commit on the shared connection.

Issue 2: guard upsert_enrichment_stub so an already-settled row (manual
pick, or a non-manual row whose content_hash already matches) skips the
UPDATE/commit — stops the no-op matcher re-writing every song each pass.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:39:10 +02:00
f6d8e241eb v3 library: curated album (kind='album' + per-slot chart/arrangement pins + play-album) — P6 (#706)
* v3 library: curated album — your version of an album, one chart per slot — P6

A curated album is a hand-picked, ORDERED practice set of works with a
chosen chart per track (metadata-design 7.2) - the repeatable gameplay
loop. No new tables: a playlists row with kind='album' plus two per-slot
columns.

- Schema (additive, idempotent): playlists.kind ('album' | NULL=mix),
  playlist_songs.arrangement (the pinned arrangement NAME - names
  survive rescans; the index is resolved at play), playlist_songs.
  work_key (stamped at ADD time = "resolved to preferred once at add,
  pinned thereafter").
- Orphan-at-read self-heal: an album keeps every slot. A slot whose
  pinned chart was deleted resolves to the work's CURRENT keeper at
  read (marked "(auto)"; membership is never rewritten - if the file
  returns, the slot resolves back to itself), and reports missing when
  the whole work is gone so the set's denominator stays honest. Mixes
  keep hiding dead songs byte-identically.
- Slot editor: PATCH /api/playlists/{pid}/songs/{fn} pins/clears the
  arrangement and/or swaps the slot's chart - validated to the SAME
  work via the stored stamp, position + pin kept, duplicate members
  rejected. The per-slot pick is independent of the work's global
  preferred: a rehearsed set stays the same notes even if the global
  keeper is re-picked later.
- UI: "New album" on the Playlists screen (album chip + disc cover);
  the album detail adds a set-scoped "Album repertoire" meter (N of M
  mastered - per-track mastery, never one album score), per-track
  accuracy, and a per-row slot editor listing only the work's charts.
  "Play album" runs the play-queue front-to-back honoring pins (the
  queue already supported per-index arrangements); per-row play uses
  the resolved chart + pinned arrangement.

12 new tests; playlists/collections regressions green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): count all album slots (list vs detail parity) + surface slot-edit PATCH failures (PR #706 review)

_playlist_count applied the mix "dead-filter" to every playlist, so an album's
list-card count dropped orphaned/missing slots that its detail view still
renders and plays (5-track album, 2 pins deleted → card "3" vs detail 5). Count
ALL slots for kind='album' (mirroring get_playlist's is_album discriminator);
mixes/other kinds keep the dead-filter. openSlotPicker's Apply now checks the
jsend return and, on a rejected PATCH (swap-to-other-work / duplicate pin),
shows an inline error and keeps the picker open instead of closing as success.
Adds album count-parity + mix dead-filter regression tests.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:38:06 +02:00
e9d95ad190 v3 library: multi-chart work grouping, complete (engine + API + card + drawer + toggle/split/filter-law) — P5a–P5e (#702)
* v3 library: multi-chart work grouping engine + work-charts API — P5a/P5b

Charts of the same song (same normalized artist+title) now GROUP under a
computed work_key, with a materialized representative filter so the grid
can collapse them without breaking keyset paging:

- work_key = normalize(artist+title) (diacritics/punct/case folded,
  leading "The" folded on artist); resolves the effective artist via the
  artist_alias table when present (feature-detected, no hard dep).
- Sparse, never-purged-on-rescan tables: chart_group_pref(work_key,
  preferred_filename) + chart_group_split(filename, split_key); purged
  only by the explicit per-song delete.
- Materialized work_display(filename, work_key, effective_work_key,
  is_group_representative, group_size) read-model: lazy rebuild via a
  dirty flag set on put/delete; set_chart_preferred does an incremental
  re-flip (no full rebuild). Auto-pick representative = most
  arrangements -> most plays -> newest -> filename; a user pref wins and
  degrades to auto-pick if its file disappears.
- group=1 on query_page/query_stats = one extra representative
  predicate applied identically to page + total + sort_letters, so the
  keyset cursor (sort_value, filename) stays a valid total order and
  counts works, not charts. Grouped rows carry chart_count + work_key.
- Charts API: GET /api/work/{work_key}/charts (members + which is the
  keeper, your pick vs auto), PUT/DELETE .../preferred, and
  POST /api/chart/{filename}/split + /unsplit (under /api/chart so the
  DELETE /api/song catch-all can't shadow them).

Tests: 15 grouping-engine + 7 charts-API tests, including grouped
keyset pagination (no skip/dupe across pages).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 library: grouped grid card + persistent "N charts" chip — P5c

Flip the v3 grid to the grouped library (group=1 on /api/library and the
rail's /api/library/stats fetch): one card per song, showing the
representative (preferred/auto-pick) chart. group rides page, total and
sort_letters identically so the A-Z rail's cumulative-seek math and the
virtualized sizer stay consistent, counting works not charts; the keyset
cursor chains with group on every page.

- New groupingActive() helper, default ON per the design; the persisted
  per-view toggle (P5e) lands there. Only the local provider implements
  group=; smart collections and remote providers ignore it and stay
  flat, so it is safe to send unconditionally.
- chartsChipHtml(): a "flag N charts" chip rendered ONLY when
  chart_count >= 2 - single-chart cards emit byte-identical markup.
  First in the fixed-height chip row + shrink-0 so it never clips and
  card height is unchanged.
- Chip click = feature-detected window.__fbOpenChartsDrawer (the Charts
  drawer arrives in P5d); until then a no-op. Plain-click / play / the
  arrangement chips are untouched and play the representative.
- The library-home repertoire meter's stats fetch deliberately stays
  ungrouped: its mastered numerator counts chart filenames, so a works
  denominator could exceed 100% - reconciling that is P5e's
  mastery-anchor work. The tree view stays flat (query_artists has no
  grouping; its opener is wired in P5d).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 library: Charts drawer + openers — P5d

The single deep-management surface for a work's charts (design UX-2/3):
a body-appended slide-in drawer (filter-drawer idiom) listing every
chart of the work as a radiogroup — the checked row is the keeper the
grid card plays.

- Rows show format / tuning / arrangements / year / your accuracy (or
  "not played") plus the pack filename, usually the only human-readable
  distinguisher between duplicate charts. Keeper is labeled
  "Preferred (auto)" vs "Preferred - your pick".
- Row click (or Enter/Space) = one-tap Set-preferred; "Reset to auto
  pick" appears when the keeper is an explicit pick. Writes go through
  the work-charts API and the drawer re-renders from the response; the
  grid re-fetches in place since the representative may have flipped.
- Per-row Play (plays that exact chart) and Add-to-playlist (the picker
  is z-[200], layering over the z-50 drawer).
- a11y: Tab focus-trap, Escape closes, ArrowUp/Down move focus between
  rows (focus only - arrow-select would fire a preferred write per
  keystroke), focus restored to the opener on close.
- Openers: the "N charts" chip opens the drawer directly; the card's
  overflow menu gains "Charts (N)..." and "Play version >" (expands
  inline; picking one plays it as a one-off - the keeper/headline does
  not move). Tree rows ride the ungrouped artists endpoint, so the menu
  resolves their work lazily via the new GET /api/chart/{fn}/work
  ({work_key, chart_count}) and slots a "Charts (N)..." entry in when
  versions exist. A window.__fbOpenChartsDrawer global lets other views
  open the drawer. Right-click is deferred: the open native card
  context-menu PR should host that entry once both merge.
- tailwind.min.css rebuilt: carries the new utility classes from this
  and the previous commit (the grouped-card chip tint was missing).

Split keys contain '#', so clients MUST URL-encode work_key in paths
(the v3 client does; a test documents the round-trip). 4 new endpoint
tests; 26/26 grouping+charts tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* v3 library: group toggle, split UI, the filter law + mastery-anchor rules — P5e

Completes the multi-chart grouping slice (design 7.1):

- Filter law under group=1: work-identity (artist/album/search) and
  practice-state (favorites/mastery/tags/difficulty) predicates stay on
  the representative row, while CHART-INTRINSIC predicates (format/
  arrangements/stems/lyrics/tuning) now match if ANY member of the work
  does - a song you own in Drop D is no longer hidden because your
  preferred chart is E Standard. Intrinsic clauses moved to an
  alias-aware builder and re-applied as a member EXISTS; identical in
  query_page and query_stats so counts and the A-Z rail stay in
  lockstep. A pure predicate - keyset paging is untouched (tested).
- Display-chart switch: when the representative itself doesn't match,
  the row carries a display_chart override (the matching member). The
  row stays the representative's - swapping rows wholesale would break
  the (sort_value, filename) cursor - and the card renders/plays the
  member while the accuracy badge and heart stay anchored on the
  preferred chart.
- Mastery sort aggregates MAX across the group ("a song surfaces on any
  chart you've touched"); OFFSET-paged, so cursor-safe. The
  Recently-Added aggregate is deliberately deferred: mtime IS a keyset
  sort, so its aggregate would need materializing into work_display.
- History-sticky auto-pick: most-played -> most-complete -> newest.
  A newer/"more complete" import can't silently take the pick from the
  chart your reps accrued on, and a one-off try of an alternate can't
  out-rank a practiced incumbent; all-unplayed groups still pick by
  completeness.
- Persisted "One card per song" toggle in the filter drawer (default
  ON; OFF = one card per chart). A view mode: never counted in the
  filter badge, never saved into collection rules, local provider only.
- Split escape hatch: "Split out" per drawer row gives a chart its own
  card; the split card's overflow menu offers "Rejoin other versions"
  (rows and the chart-work lookup now carry is_split).
- Mastery-anchor heads-up: after set-preferred the drawer shows a
  one-line ambient note that practice history stays with each chart
  (no toast - hearing-safe).

10 new filter-law tests; 38/38 grouping tests green. tailwind.min.css
rebuilt for the new utility classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): work-grouping — escape Charts-drawer meta (XSS), keep non-Latin titles distinct, guard mid-rebuild reads (PR #702 review)

- XSS: esc() the composed `meta` string in _chartRowHtml (arrangement/tuning
  names come from untrusted feedpak metadata) before innerHTML; acc stays HTML.
- Non-Latin titles: _norm_token falls back to raw lowercased whitespace-collapsed
  text when the NFKD+strip fold yields "" (CJK/Cyrillic/Greek/Arabic), so distinct
  non-Latin titles keep distinct _work_key values instead of collapsing into one
  bogus work. Latin names still hit the folded branch — behavior unchanged.
- Mid-rebuild reads: wrap the grouped representative SELECT in query_page and
  query_stats under self._lock (nullcontext when ungrouped, so lazy reads stay
  lock-free) so a reader can't observe work_display between rebuild_work_display's
  DELETE and INSERT/commit. _ensure_work_display stays OUTSIDE the lock — it
  self-locks the rebuild and self._lock is non-reentrant — so only the SELECT is
  guarded (rebuild fully completes before the guarded SELECT runs).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:37:15 +02:00
a47accd894 v3 library: artist aliases + Tidy-up merge UI — P4 (#705)
* v3 library: artist aliases + Tidy-up merge UI — P4

Fixes the "ACDC vs AC/DC" split without touching a single file or row:
a never-purged artist_alias table (raw_name -> canonical_name) applied
at DISPLAY time. The scanner keeps writing whatever the pack says; one
alias row fixes every matching song.

- query_artists dedupes/groups/orders on the effective artist, with a
  zero-cost fast path when no aliases exist; the artist filter expands
  a canonical name to its raw variants (index-friendly, keyset-safe);
  query_page re-labels row artists through the alias map.
- CRUD + merge API: list aliases, list raw artists (variants + counts
  for the picker), set/merge/remove; a self-alias clears (= un-merge).
- "Tidy up artists..." in the filter drawer (local library only): a
  searchable raw-variant checklist, merge-into-canonical, and a
  current-merges list with per-row un-merge. The artist dropdown + tree
  pick up canonical names with no dropdown code changes.
- Sort + A-Z rail stay on the RAW artist (keyset-safe): a cross-letter
  alias shows its canonical label but buckets under the raw letter
  until effective columns are materialized (the grouping engine's
  work_key already resolves aliases when this table exists, so merged
  artists group correctly there).

11 tests. tailwind.min.css regenerated (generated file - on a merge
conflict, re-run scripts/build-tailwind.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): flatten transitive artist-alias chains + cycle guard so sequential merges unify (PR #705 review)

merge_artists looped set_artist_alias which stored one hop, so sequential
merges (ACDC->AC/DC then AC/DC->AC-DC) left a two-hop chain that the
single-hop effective_artist/grouping/filtering split into two groups. Add
_single_hop_canonical + _terminal_canonical (visited-set cycle break),
resolve the canonical to its terminal before storing, forward-flatten
existing rows that pointed at the raw name, and reject cycles (409). Batch
merge now runs under one lock + one commit for atomicity.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:32:51 +02:00
77e5a4982b v3 library: growth-edge "practice next" recommender — P3 (#704)
* v3 library: growth-edge "practice next" recommender — P3

The "Keep practicing" shelf stops being recency-only: a new GET
/api/library/practice-suggestions ranks started-but-unmastered songs by
difficulty-appropriateness x mastery-proximity (the growth edge - the
mid-difficulty, closest-to-mastery material where practice pays off
fastest), and the shelf sources it instead of filtering /api/stats/recent.

- Score = difficulty band fit (your 1-5 rating; unrated degrades to the
  middle band so the shelf works before any ratings exist) x proximity
  to the 0.9 mastery threshold. Read-only - never writes difficulty.
- A shelf click opens the closest-to-mastery arrangement.
- Per-arrangement difficulty and seed-from-authored intentionally NOT
  faked: there is no authored/derived difficulty on songs yet (the
  feedpak difficulty spec is unmerged) and the personal rating is
  per-song - both revisit when that field lands.

9 endpoint tests. tailwind.min.css regenerated (generated file - on a
merge conflict, re-run scripts/build-tailwind.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): deterministic tiebreak (filename) in practice-next ordering (PR #704 review)

Add r["filename"] as the final sort component so suggestions with equal
growth_score and equal/None last_played_at order deterministically instead
of by SQLite's unordered agg.items() scan.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:31:19 +02:00
feaaa5cd81 v3 library: song Details drawer + bulk edit — P2 (#703)
* v3 library: song Details drawer + bulk edit — P2

Evolves the per-song editing surface from the legacy modal into a v3
slide-in Details drawer (the filter-drawer idiom, body-appended): catalog
fields (title/artist/album/year, written through the existing atomic
manifest writer) plus the P1 personal layer - your difficulty (1-5),
tags, and notes - with the heart staying the existing favorite system.

- Cards badge the personal layer at rest (difficulty pip + tag count,
  top-right, fading on hover so the action buttons keep that corner);
  un-annotated cards render byte-identical to before.
- Bulk edit from the select-mode batch bar: POST
  /api/songs/user-meta/batch applies additive tag add/remove and a
  leave/set/clear difficulty across the selection (mixed-state aware).
- The core card action relabels to "Details" and opens the drawer via a
  feature-detected global, falling back to the legacy modal when the
  drawer isn't mounted.

16 batch tests new; the P1 user-meta suite stays green.
tailwind.min.css rebuilt for the drawer's utility classes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN

* fix(v3): surface bulk-edit / save-details request failures instead of reporting success (PR #703 review)

Check the batch/write responses and show an fbNotify error (keeping selection and drawer) instead of unconditionally closing as success.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:29:05 +02:00
58e7407c38 v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1 (#691)
* v3 library: personal per-song metadata (user-difficulty / notes / tags) — P1

The local "your relationship to the song" layer: a per-song user-difficulty
(1–5, planning-only, distinct from the authored 1–10 difficulty bands),
free-form notes, and free-form practice tags. All kept OUT of the shared
feedpak file and OUT of the `songs` table, in two new never-clobbered tables
so a rescan's `INSERT OR REPLACE INTO songs` can't wipe them. Likes stay the
existing favorites heart — no rating column.

Backend only (the Details drawer + tag/difficulty filter UI come next).

Schema (additive, idempotent):
- song_user_meta(filename PK, user_difficulty INTEGER, notes TEXT, updated_at)
- song_tags(filename, tag, created_at, PK(filename, tag)) + idx on tag

API (DB-only — distinct from POST /api/song/{f}/meta, which writes catalog
fields back into the file):
- GET  /api/song/{f}/user-meta  → {user_difficulty, notes, tags}
- PUT  /api/song/{f}/user-meta  → partial update; user_difficulty (1–5 or
  null), notes (string or null), tags (full-replace array). Tag removal is a
  full-replace array rather than a DELETE sub-route because the greedy
  DELETE /api/song/{filename:path} already owns every DELETE under /api/song
  and would shadow it.
- GET  /api/tags → tags in use with counts (for the filter UI)

Read path:
- query_page rows embed user_difficulty + tags (like `favorite`); notes stay
  out of the list payload (per-song GET — they can be long).
- Read-time filters ?user_difficulty= and ?tags= threaded through _build_where
  exactly like the mastery filter — EXISTS-style predicates, so keyset paging,
  counts, and the A–Z rail are unaffected.
- delete_song purges both personal tables inside the existing lock.

Tags are normalized (trim + lowercase + collapse whitespace) so "Rock"/"rock"
don't split. New tests cover defaults, difficulty validation (rejects out-of-
range / non-integral / bool), notes trim, tag normalize/dedupe, grid embed,
both filters, never-clobber-on-rescan, and purge (21 tests). Neighboring
library tests (filters/keyset/providers/playlists/collections/stats) stay
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): cap per-song tags at 50 to bound writes (PR #691 review)

set_song_tags capped each tag at 60 chars but not the number of tags,
so one PUT could write unbounded rows. Cap the normalized-unique tag
list to the first 50 after dedup. Adds a test asserting >50 stores 50.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 13:25:18 +02:00
22d299959f fix: restore .feedpak in the upload file-picker accept filter (#698)
PR #530 set accept=".feedpak,.sloppak" on the shared upload input, but a
later index.html edit reverted the attribute to ".sloppak" only. The
client-side extension filter (app.js) and the server upload endpoint both
accept .feedpak, so the only effect was the OS file-picker hiding .feedpak
files from the dialog. Restore the dual filter.

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:44:10 +02:00
5e78f2f7f7 fix(tuner): remove unused settings + fix sidebar panel position (#661)
Removes the Floating Button and Tuning Visibility settings sections and finishes retiring their still-live config: drops the disabledTunings menu filter and showFloatingButton gate from screen.js/ui.js and their persistence in routes.py (retired keys are stripped on write). Repositions the tuner panel opened from the v3 sidebar Plugins popover to anchor beside it via the host's stable plugin-control slot API (falling back to the popover id), clamped to the viewport so it can't open off-screen, and re-anchored on resize. Updates tuner config tests to the retired-key behavior; plugins/tuner 1.3.2 -> 1.3.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 09:35:18 +02:00
9b4bef3fd1 fix(drum_highway_3d): enlarge note gems ~20% for readability (#713)
Disc radius 3.6->4.3, cymbal 3.0->3.6 (heights proportional) — hands-on
feedback said the gems were hard to read at the default camera. All
variant shapes key off these radii; accents still fit the lane gap.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:22:02 +02:00
3e2703d8e1 feat(keys_highway_3d): audio-reactive ambience + score FX overlay (K4) (#709)
- BG_STYLES port (off/particles/lights/geometric; lights use the
  pitch-class palette) mounted behind the scene; _bgGetAnalyser/
  _bgReadBands (stems-first, one-shot #audio fallback, permanent-failure
  latch); Ambience intensity + Audio-reactive settings; remounts on
  style/intensity change
- Score-FX overlay canvas (drum_highway_3d pattern): +1 pops at the
  scored key, ring pulse every 10-combo, milestone bursts at 25/50/100,
  red wash on 3+ streak break (wrong notes AND swept misses); cleared
  when idle, removed in teardown
- Tests: style id validation + FX defaults (30 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:06:12 +02:00
d409d55615 feat(drum_highway_3d): audio-reactive ambience + score FX overlay (D4) (#708)
- BG_STYLES port (off/particles/lights/geometric) into a renderOrder -1
  group; _bgGetAnalyser/_bgReadBands (stems-first, one-shot #audio
  fallback, permanent-failure latch, 5ms bands cache); Ambience
  intensity slider + Audio-reactive toggle; remounts on style/intensity/
  palette change and across kit-change scene rebuilds
- Score-FX overlay canvas (guitar drawScoreFx adapted to internal
  scoring): +1 pops at the struck lane, ring pulse every 10-combo,
  milestone bursts at 25/50/100, red wash on 3+ streak break; pooled,
  cleared when idle, removed in teardown
- butterchurn/image/video deliberately out of scope
- Tests: style id validation + FX defaults (15 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 09:01:56 +02:00
4214ef365e feat(drum_highway_3d): materials & themes — studio env map, metal cymbals, BG_THEMES, cinematic, glow/vibrancy (D3) (#712)
- _makeStudioEnv: procedural PMREM environment (no RoomEnvironment addon
  vendored) -> scene.environment; rebuilt on kit-change, disposed both
  teardown paths
- Cymbals roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 (metal
  finally reads); discs satin 0.45 + envInt 0.5; floor 0.7 + envInt 0.35;
  hit bar envInt 1.0
- BG_THEMES port (guitar ids/values; drum 'default' = original palette
  byte-for-byte; single pick drives clear/fog + board + lane stripes);
  drumH3dSetTheme + drum_h3d_bg_theme; live _applyTheme
- Cinematic lighting toggle (0.3/1.2 on, 0.4/1.0 off = stock)
- Glow slider (base * glow*2; 0.5 default = stock) across notes/hit
  bar/snare stripe; Lane vibrancy slider (stripe base 0.12+0.24v +
  halo/ghost opacities), stacking under the D2 approach highlight
- Fixed pre-existing floor/hit-bar geometry+material leak on kit change
- Tests: theme table parity + default preservation + fallbacks (13)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:54:17 +02:00
80fc371f11 feat(keys_highway_3d): anti-plastic materials, studio env, themes, gradient sky, cinematic + glow (K3) (#711)
- Note gems -> MeshPhysicalMaterial: clearcoat 1.0/0.18, roughness 0.32,
  envMapIntensity 0.9 — lacquered glass, not plastic (the explicit ask)
- _makeStudioEnv (PORTED drum_highway_3d): PMREM studio -> scene.environment;
  black keys 0.22 roughness / 1.3 envInt (glossy piano black), whites
  0.42/0.55 ivory, floor 0.55/0.15/0.4 stage sheen
- Vertical-gradient background (light horizon -> theme clear -> dark deck),
  sRGB-tagged for the composer path
- BG_THEMES port (guitar ids/values; keys 'default' = original palette;
  themes drive gradient/fog/floor/rails, never the pitch-class colors);
  keys3dSetTheme + keys3d_bg_theme, live _applyTheme
- Cinematic lighting toggle (0.55/1.3 on; stock 0.75/1.1 off); Glow slider
  across NOTE_EMISSIVE_BASE / consume-flash / key approach-glow
- Env RT + gradient texture disposed in teardown
- Tests: theme table parity + default preservation (28 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:51:48 +02:00
95b0786725 feat(keys_highway_3d): hit FX — vibrancy, timing-colored sparks, hit-line kick (K2) (#699)
- Note vibrancy: gem opacity 0.8 -> slider-driven (default 0.92),
  NOTE_EMISSIVE_BASE 0.08 -> 0.22, lane guides scale with the slider,
  _applyVibrancy retints the live scene without a chart rebuild
- Sparks (PORTED highway_3d, pool 96) at the struck key, colored by
  _timingHex/_classifyTiming (±100ms window, inner 40% = on-time; delta
  recovered from judgeHit's noteKey prefix — contract untouched);
  streak-scaled counts
- Hit-line brightness kick on scored presses (exp(-t*6) decay, hitFx
  slider), folded into the existing pulse incl. the bloom damp gate
- Settings: Hit sparks / Timing colours / Streak feedback + Hit feedback
  intensity + Note vibrancy sliders (keys3d_bg_*, live-applying)
- Tests: classifier boundaries, noteKey time round-trip, FX defaults
  (26 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 08:45:09 +02:00
749af31cc3 fix: correctly import and notate multi-staff (piano/keys) tracks from GP8 (#692)
* fix: correctly import and notate multi-staff (piano/keys) tracks from GP8

Fixes bass stave being dropped on import (bar-column enumeration bug)
and wrong hand-split heuristic in notation_lift for chords straddling
middle C.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

* fix(gp-import): fold all grand-staff staves, per-stave tuning, playable hand-splits

Addresses review on #692 (topkoa):

- split_hands: only use the middle-C boundary when both resulting hands are
  within HAND_SPLIT_SPAN_SEMITONES, else fall back to the largest-gap
  heuristic — a hard middle-C split otherwise put a 19-semitone (unplayable)
  span in one hand for bass-under-treble voicings (e.g. E2+B3 under an Em7
  shape).
- Treat any multi-stave (grand-staff) track as keys end-to-end, so the
  stave-0 and folded stave-1+ notes share one encoding and note_count (which
  sums every stave column) matches what actually imports — closing the
  phantom-count case for grand-staff instruments the name/program heuristics
  miss (harp, celesta, marimba).
- Fold *every* extra stave (stave_columns[1:]), not just stave 1.
- Per-staff tuning fall-back to the track-level Tuning property so an untuned
  staff never yields an empty pitch list (silent note loss); via a shared
  _parse_tuning helper.
- Extract _collect_column_notes / _merge_lh_notes so the GPX LH/RH pair merge
  and the GP8 grand-staff fold share one implementation and can't drift in
  tie/timing/dedup handling.
- Rebuild filtered_to_raw from the already-computed stave_columns (one source
  of truth for the counting rule) and drop the dead num_raw_tracks/raw_tracks.

Tests: grand-staff fold + bar-column offset (test_gp2notation.py); both
middle-C split cases (test_notation_lift.py). CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-02 08:40:20 +02:00
OmikronApexandGitHub 991eadeff6 Merge pull request #694 from got-feedback/perf/highway-frame-hotspots
perf: eliminate per-frame layout thrash, shader-compile spikes, and per-frame allocations in the player
2026-07-02 01:17:17 +02:00
56419dc789 feat(drum_highway_3d): hit FX — sparks, timing colors, lane flashes, kick pulse, open hi-hat (D2) (#697)
- Pooled spark bursts (PORTED highway_3d, pool 160) at the struck lane,
  timing-colored via _timingHex/_classifyTiming (±50ms window, inner 40%
  = on-time); streak-scaled counts (streakFx)
- Lane flashes resurrected as pooled additive gauss-tex quads at the hit
  line (timing-colored; red for wrong hits); kick = full-width quad
- Kick pulse: camera dip + amber floor wash, exp decay, hitFx-scaled
- Approach highlight: lane stripes brighten toward their next note
- Open hi-hat: hh_open renders a warm ring around the gem (closes TODO);
  orthogonal to accent/ghost/flam variants
- Settings: Hit sparks / Timing colours / Streak feedback toggles +
  Hit feedback intensity slider (drum_h3d_bg_*, live-applying)
- All FX resources pooled/shared, disposed in BOTH teardown paths
- Tests: _classifyTiming boundaries + FX defaults (10 total)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:07:50 +02:00
beefd7bc09 feat(keys_highway_3d): foundation for guitar-highway visual parity (K1) (#696)
- setPixelRatio at last: DPR (cap 2; 1.25 when >1 instance) x host
  adaptive bundle.renderScale — HiDPI displays were rendering at CSS
  resolution and upscaling (soft/aliased)
- Bloom: port _bloomEnsure/_bloomDispose (UnrealBloomPass 0.65/0.5/0.82,
  MSAA HalfFloat target, ACES<->None switch); hit-line, flames and
  consume-glow benefit immediately; direct render is the degrade path
- First settings panel: settings.html (graphics category) with a live
  Glow (bloom) toggle; FX scaffold (FX_DEFAULTS/readFxSettings/
  window.keys3dSetFx, keys3d_bg_* keys, keys3d:settings event)
- Combo/accuracy/best-streak DOM HUD (drum_highway_3d pattern), gated on
  a live MIDI session
- tests/fx_settings.test.js (3 tests; vm harness)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:53:12 +02:00
a7ea719652 feat(drum_highway_3d): foundation for guitar-highway visual parity (D1) (#695)
- Consume host adaptive-quality bundle.renderScale; fold into DPR like
  highway_3d (splitscreen proxy: >1 instance caps baseDPR at 1.25)
- Bloom: port _bloomEnsure/_bloomDispose (UnrealBloomPass 0.65/0.5/0.82,
  MSAA HalfFloat target, ACES<->None tone-mapping switch); rebuilt across
  the kit-change renderer recreation; direct render is the degrade path
- FX settings scaffold: FX_DEFAULTS + readFxSettings + drumH3dSetFx
  (drum_h3d_bg_* keys), Graphics section in settings.html (bloom toggle,
  default ON, live-applies)
- _applyLaneFlashes dead-code comment updated (visual consumer lands in
  the hit-FX PR)
- __test export + tests/data_layer.test.js (vm harness, 8 tests) —
  picked up by the CI glob from the bundling PR; README refreshed

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:52:48 +02:00
OmikronApexandClaude Fable 5 95cb51b2ad perf(highway_3d): forceSinglePass on transparent DoubleSide quads
The retrace after the label-swap fix showed getParameters unchanged
(~2.5s / ~4% throttled main thread) — the real driver is Three r158+'s
transparent-DoubleSide two-pass path: renderBufferDirect renders such
objects back side then front side, setting material.needsUpdate BOTH
times, i.e. a full getParameters/program-cache lookup twice per object
per frame, plus double draw calls. (Found by reading the two-pass
branch in the vendored three.module.min.js right next to the
getParameters call site.)

All 18 transparent DoubleSide materials in this renderer are flat
unlit quads — technique markers, sustain rails, chord frames, lane
planes, halo bars — where the two-pass self-occlusion ordering buys
nothing. Declare forceSinglePass: true on all of them.

Also corrects the _setLabelMap comment's churn attribution (that fix
removes the label-swap contribution; this one removes the dominant
source). Plugin 3.31.1 -> 3.31.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:48:52 +02:00
OmikronApexandClaude Fable 5 59aa70ce5a perf: remove throttled-trace residuals — program churn, per-frame rect, HUD clock
A 4x-CPU-throttled retrace (the honest weak-hardware proxy) surfaced
three residual per-frame costs; stack attribution pinned each:

- getParameters/getProgramCacheKey (~4% of main thread): every pooled
  label sprite map swap set material.needsUpdate, bumping
  material.version and forcing full program re-resolution next render.
  Swapping between two non-null cached textures never changes the
  compiled program (USE_MAP define unchanged) — new _setLabelMap()
  helper only flags needsUpdate on a null<->texture transition, used at
  all 7 swap sites.
- getBoundingClientRect (~1.2%): the 3D highway's per-frame canvas-size
  self-check forced a layout read every frame. The CSS-box drift read
  now runs every 10th frame (or when the wrap isn't pinned); the
  backing-store comparison stays per-frame with cheap property reads
  and forces an immediate box read + applySize when it fires.
- set textContent: the core 60 Hz HUD clock rewrote hud-time (and
  getElementById'd it) every tick for a display that changes 1/s — now
  write-on-change with a cached element ref.

(The remaining textContent writer in the trace is notedetect's
badges.js — external repo, to be filed there.)

tests/js: resize-reframe shape test updated for the hoisted _bsChanged
gate, incl. an assertion that the throttle can never delay the
backing-store path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:40:52 +02:00
OmikronApexandClaude Fable 5 1e9741043b review: address PR #694 findings
- isVisible() forces a fresh DOM sample (was serving the rAF loop's
  throttled cache, contradicting its 'live DOM check' docstring).
- v3 chrome: reconcile the edge-driven overControls hover flag against
  matches(':hover') on the throttled ~6 Hz tick — covers a missed
  mouseleave (flag stuck true, transport never hides) and a re-created
  #player-controls node with lost listeners.
- highway_3d pre-warm now also covers teachFg/teachSd label textures
  and the technique sprite factories (mute X, hammer/pull triangles,
  bend chevrons, slide arrows) per active-palette string colour, plus
  a maintenance note tying new label styles to the warm list.
- Document that the visibility throttle's manual invalidations are
  latency-only (periodic resample self-heals within ~10 frames), and
  why highway_3d keeps its local lowerBoundT (downlevel hosts).

External-repo audit (finding 1): staffview, tabview, piano, drums,
keys_highway_3d, drum_highway_3d grepped — no cross-frame bundle
retention or bundle-identity checks found.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:35:24 +02:00
1e2d5a6162 Bundle drum_highway_3d + keys_highway_3d as in-tree core plugins (#693)
* Initial commit — clean relaunch

* Initial commit — clean relaunch

* Remove external game/format terminology from docs and code

Reword references to the external game and its proprietary file formats
in comments, docstrings, UI text and identifiers; no behaviour change.

* Repoint dead slopsmith URLs -> got-feedback

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

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/onboarding; retires this plugin's private Web-MIDI. The
note-detection 'midi' exact-verdict provider role and the audio-input
source export are preserved (now backed by midi-input source data). Saved
pick ({id,name}) stays compatible — domain sourceId == the old MIDIInput.id.
Live listener is the domain handle's addListener/removeListener; the async
init/resume/pause gate and device-vanish handling are kept.

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

* fix(midi): stop exporting MIDI into the audio-input domain

Keys MIDI now lives in the dedicated midi-input domain (#881). Continuing to
register pseudonymized 'midi-input-N' sources into audio-input polluted audio
device pickers — notably the onboarding guitar audio-input dropdown showed MIDI
devices under cryptic names. Unregister any leftovers and register none.

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

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

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

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- plugin.json: replace the stale audio-input provider role (the removed
  MIDI-into-audio-input export bridge) with the midi-input requester it now uses.

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

* fix(midi): make _midiResume idempotent under multiple live instances

Codex re-review (round 3): with the domain addListener API, a second live
renderer instance (splitscreen/overlapping lifetimes) calling _midiResume() while
already active could register the same listener again and double-deliver a MIDI
note to the focused instance (hit + duplicate misses). Return early when already
active rather than relying on the provider's Set-backed de-dup.

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

* fix(midi): release domain session on teardown; counter-free legacy source cleanup

Codex re-review (round 4):
- [P2] Last-instance teardown only detached the listener and never released the
  midi-input domain session, leaking the requester ref. Add _midiReleaseSession()
  (delegates to _midiDetach: close + null + generation bump) at the destroy site;
  re-mount's _midiInit auto-connects from the saved pick.
- [P3] The legacy audio-input MIDI-source cleanup looped over the module-local
  _aiRegisteredCount, which resets to 0 on an in-page upgrade so the prior build's
  'keys-midi:input-N' entries were never unregistered. Iterate a fixed bound over
  the known sourceId pattern instead (unregister of an absent source is a no-op).

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

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- Miss-sweeping was gated on _midiInput, set as soon as a device is picked, but
  the async mi.open() may still be pending (slow / permission prompt) — notes
  passing during that window banked false misses. Gate on _midiHandle, truthy
  only after a handle is opened and wired.
- A settings-only ensure-init (or a discover resolving after the last instance
  was torn down) could open a midi-input session with no renderer to release it.
  Gate mi.open() on _instances.size > 0; the pick is saved and a later mount
  re-runs auto-connect.

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

* fix(midi): guard open-failure clobber + don't clobber saved device on unplug

Bringing keys-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle (and leak the
  handle). Only clear when myGen === _midiConnectSeq.
- The sources-changed recovery called _midiAutoConnect(), whose fallback persists
  a substitute device — overwriting the user's saved pick on a transient
  multi-device unplug. Parameterize _midiAutoConnect(allowFallback); recovery
  passes false (reconnect the saved device only, never a persisted fallback).

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

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Codex round-11:
- The unplug-recovery _midiAutoConnect(false) skipped ALL fallbacks, which broke
  first-hotplug (open visualizer with no keyboard, plug one in → never picked →
  no connect). Skip the substitute only when a saved pick exists but is absent
  (preserve it); allow the fallback when nothing was ever picked.
- _midiInit re-ran _midiAutoConnect on every ready re-init (settings/splitscreen),
  tearing down the live handle + releasing held keys. Only re-connect when there's
  no live handle.

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

---------

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

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/keys/onboarding. Saved pick ({id,name}, with legacy id-only
fallback) stays compatible — domain sourceId == the old MIDIInput.id. The
async in-flight init guard, _midiActive teardown gate, device-vanish handling,
and settings device APIs are preserved; the live listener is the domain
handle's addListener/removeListener. Degrades to no-MIDI when the domain
is absent.

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

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

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

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- Declare the midi-input requester capability (degrade-noop) in plugin.json.

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

* fix(midi): make _midiResume idempotent under multiple live instances

Proactively mirror the keys-highway-3d round-3 fix (same multi-instance lifecycle
and identical code): a second live renderer instance calling _midiResume() while
already active could double-register the listener and double-deliver a MIDI hit.
Return early when already active.

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

* fix(midi): release the domain session on final teardown

Mirror the keys-highway-3d round-4 fix: last-instance teardown only detached the
listener and never released the midi-input domain session, leaking the requester
ref. Add _midiReleaseSession() (delegates to _midiDetach: close + null +
generation bump) at the destroy site; re-mount's _midiInit auto-connects.

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

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- [P2] drumH3dEnsureMidiInit (settings-only, no renderer) auto-connected and
  opened a midi-input session that destroy()/_midiReleaseSession would never run
  to release — held until reload. Gate mi.open() on _instances.size > 0; the pick
  is saved and a later renderer mount re-runs auto-connect.
- Mirror the keys-highway-3d miss-sweep fix: gate accumulation on _midiHandle
  (live wired session), not _midiInput (set before the async open resolves), so
  notes during a pending open don't bank false misses.

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

* fix(midi): guard open-failure clobber + reconnect saved device on replug

Bringing drum-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle. Only clear when
  myGen === _midiConnectSeq.
- The sources-changed handler only refreshed the list (never reconnected), so a
  replug of the saved device didn't reattach. Reconnect on sources-changed via
  _midiAutoConnect(false) — saved device only, no fallback, so a transient unplug
  can't switch to / persist another input.

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

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Same two round-11 fixes as keys-highway-3d (shared structure): the recovery
_midiAutoConnect(false) now allows a fallback when nothing was ever picked
(first-hotplug) while still preserving a saved-but-absent pick; and _midiInit
only re-connects when there's no live handle.

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

---------

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

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#4)

Codex review of the midi-input migration: resolving a selection with
find(s => s.id === id) treats the provider-local sourceId as globally unique,
so when the core midi-input domain aggregates multiple providers two devices
sharing a sourceId can't be distinguished — the wrong device opens and unplug
detection can miss that the selected key vanished.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
auto-connect reconnect, the public keysH3dSetMidiInput entry, and the
capability _aiOpen path, preferring the key and keeping the bare sourceId /
name only as a legacy fallback. Mirrors the already-merged drums/piano
migration (_midiResolveSaved). Self-reviewed against that reference.

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

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#3)

Codex review of the midi-input migration (same issue as keys-highway-3d):
resolving a selection with find(s => s.id === id) treats the provider-local
sourceId as globally unique, so when the core midi-input domain aggregates
multiple providers two devices sharing a sourceId can't be distinguished —
the wrong device opens and unplug detection can miss the vanished key.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
the auto-connect reconnect, and the public drumH3dSetMidiInput entry,
preferring the key with the bare sourceId / name only as a legacy fallback.
Mirrors the already-merged drums/piano migration. Self-reviewed.

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

* chore: add plugin metadata (category, description, thumbnail) (#5)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* chore: add plugin metadata (category, description, thumbnail) (#4)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#5)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

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

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#6)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

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

* feat(plugins): bundle drum_highway_3d + keys_highway_3d as in-tree core plugins

Import both 3D highway plugins from their standalone repos via git subtree
(history preserved), ahead of the visual-parity epic that ports the guitar
highway's polish to them.

- .gitignore: !plugins/drum_highway_3d/ + !plugins/keys_highway_3d/ exceptions
- keys plugin.json: "bundled": true (drum already had it)
- CI: JS test step gains 'plugins/*/tests/*.test.js' (+20 keys tests)
- static/tailwind.min.css regenerated (core build scans plugins/**)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): narrow Auto-mode predicate so bundling can't steal guitar arrangements

has_drum_tab is pack-level; first-match-wins Auto order sorts this plugin
before highway_3d. Claim only drum arrangements, or packs nothing more
specific can render (Codex preflight P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: CHANGELOG — note the deliberate Auto-predicate narrowing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): MIDI lifecycle — no false misses on mid-song connect, promote survivor on destroy

- _missSweepFloor exempts notes that passed before the device wired up
  from the miss sweep (lowered on seek-back; cleared with scoring resets)
- destroy() promotes a surviving instance to _activeInstance so
  splitscreen panel teardown doesn't drop all MIDI routing
  (Codex preflight round 2, both pre-existing in the imported code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sin <deathlysin@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:25:31 +02:00
OmikronApexandClaude Fable 5 77547af110 perf: allocation/scan hardening for weaker hardware
Static-analysis follow-ups to the trace-backed fixes; each is cheap
insurance on machines where the profiled headroom doesn't exist.

- highway.js: _makeBundle now mutates one persistent per-instance
  object instead of allocating a fresh ~35-field bundle every rAF
  frame (xN under splitscreen). Object identity is stable and
  meaningless; array fields still swap reference on chart changes,
  which field-identity caches rely on. Contract documented in both
  CLAUDE.mds.
- highway.js: new bsearchTime (lower-bound on .time) windows the
  default 2D renderer's beat-line scan (was O(all beats) per frame);
  bundle.lowerBoundT / bundle.lowerBoundTime expose the searches to
  custom viz so they stop reimplementing visible-window culling.
- highway_3d: localStorage 'h3d_full_sus' polled at ~1 Hz instead of
  every frame (synchronous storage read on the hot path).
- highway_3d: drawLyrics caches the measureText row layout keyed on
  (lyrics ref, line index, shown count, font size, width) — per-frame
  work is now just drawing over cached widths.
- tests/js: bundle source-shape assertions widened to accept the
  assignment form ([:=]) alongside the old object-literal form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:02:54 +02:00
OmikronApexandClaude Fable 5 5239665e2b perf(highway_3d): pre-warm shaders and label textures at init
Trace showed frame spikes from Three.js first-use costs mid-song:
shader program compilation (getParameters/getProgramCacheKey) and lazy
texture uploads (texSubImage2D) whenever a chord name, section banner,
or fret label first appeared.

- ren.compile(scene, cam) after initScene (pools already warmed by
  feedBack#226, board built, background mounted) so programs compile
  during the load spinner.
- Pre-rasterise + GPU-upload (ren.initTexture) the deterministic txtMat
  entries: fret numbers 0-24 in the noteFret/fretRow/ghostFret combos
  the per-frame paths request.
- Chart-dependent labels (chord template names, section names) prewarm
  once on the first draw() after each init, when bundle arrays are
  guaranteed populated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:53:36 +02:00
OmikronApexandClaude Fable 5 fd840011d2 perf(core): stop per-frame layout thrash in visibility check + v3 chrome loop
Chrome trace showed ~0.5s self-time in _isHighwayVisible (offsetParent
read every rAF frame forces style/layout recalc) and ~1.5s in the v3
player-chrome loop (matches(':hover') per frame, unconditional
textContent/width writes at 6 Hz -> ~1800 layout passes in 63s).

- highway.js: sample offsetParent every 10th frame, cached in between;
  fresh sample forced on init/canvas-replace/resize/override-clear.
- player-chrome.js: hover tracked via mouseenter/mouseleave; Up-Next
  refs cached, text written only on change (eta coarsened to 1s steps
  beyond 10s), progress bar moved from width to scaleX (compositor-only).
- v3.css: bar fill uses transform-origin:left + scaleX transition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:51:39 +02:00
744c848636 v3 library: mastery filter + sort (needs-practice / most-mastered) (#687)
* feat(v3): sort the library by mastery (needs-practice / most-mastered)

Adds two sort options to the Songs library: "Needs practice first" (weakest
measured accuracy first) and "Most mastered first". Mastery = MAX(best_accuracy)
across a song's arrangements, from song_stats; because that's a separate table
it's a correlated subquery in the ORDER BY, so these sorts use OFFSET paging like
tuning/year. Unscored ("not started") songs always sort to the bottom in both
directions, so a large unpracticed library doesn't bury the songs you're
actually working on. Never the default.

Verified against a running server: scored songs at 0.90 / 0.30 plus unscored ->
ascending orders 0.30, 0.90, unscored; descending 0.90, 0.30, unscored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* feat(v3): add the 3-state mastery filter (Mastered / In progress / Not started)

Complements the mastery sort with the gentler filter the charrette preferred: a
"Progress" facet in the Filters drawer with Mastered (>= 0.9), In progress
(attempted but < 0.9), and Not started (no score) -- multi-select, OR within the
set. Server-side via a correlated subquery on song_stats threaded through
_build_where / query_page (passed as a separate kwarg so query_artists /
query_stats are unaffected). Smart-collection providers ignore it (they define
their own filters).

Verified live: mastered -> the 0.90 song, in_progress -> the 0.30 song,
not_started -> the unscored songs, combined -> both scored (with correct totals).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:57 -05:00
41cdfd576a feat(v3): play a playlist as a queue with auto-advance (#685)
Playing a playlist previously played one song and returned to the menu -- a
play-queue was never implemented. Add window.feedBack.playQueue (start / advance
/ hasNext / clear) and a "Play all" button on the playlist detail.

Advancing rides the same exit choke point as auto-exit and a results-card close:
song-end paths call window.closeCurrentSong() (the auto-exit grace timer and a
results screen's release()), so wrapping it plays the next track instead of
returning to the menu -- advancing AFTER the user dismisses a score card, not
through it. A user-initiated exit (Escape / the close button) uses the bareword
closeCurrentSong(), left untouched, so leaving the player still leaves and
abandons the queue. playSong gains a fromQueue guard (a manual play abandons a
stale queue) and closeCurrentSong clears the queue on a real close. Binds via
song:ended / the choke point, not the <audio> element, so it advances on the
desktop (JUCE) route too. The no-queue path is unchanged.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:55 -05:00
23df6767a7 feat(v3): Refresh button + live scan progress on the Songs library (#673)
Add a media-server-style "I dropped files in my folder, hit refresh" control to
the Songs toolbar. Refresh triggers an incremental /api/rescan and reuses the
existing /api/scan-status poll for live progress: a 3-state button (idle /
"Scanning..." while listing / "Scanning N/M" once counting) with a title tooltip
showing the current file + percent. A scan already running (the Settings buttons
or a background pass) is reflected on the button too. On completion it emits
library:changed so the grid reloads, and shows an honest, never-punishing
fbNotify toast (bottom-right, suppressed while in a song). No backend change --
same machinery the Settings rescan already drives. The precise "N added" count
is a follow-up.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:52 -05:00
f4b59e67d6 feat(v3): modern "Add to playlist" picker (replaces run-on prompt) (#672)
The old add-to-playlist flow crammed every playlist into one uiPrompt label
("1. Foo  2. Bar  3. Baz ...") and asked the user to type a number -- unreadable
past a couple of playlists, and reported as a bug.

Replace it with openPlaylistPicker: a checkbox modal with membership pre-check (a
song already in a playlist shows checked; a multi-song selection shows an
indeterminate box when only some are in), an inline "+ New playlist" row, and a
search box once the list is long. Toggling adds/removes via the existing REST
(POST + DELETE .../songs/{filename}); only playlists the user actually touched
change. One shared function still feeds the per-card menu, the batch bar, and the
batch button, so the fix lands in all three. Escape / backdrop close; a
bottom-right fbNotify toast confirms.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:49 -05:00
110b47f2ae feat(v3): persist last-used library sort / filter / view (#671)
The Songs library reset sort + filters to defaults on every visit; testers asked
it to remember their choice ("most players pick a preferred sort and leave it").

Persist sort, format, view, and the drawer filters to localStorage
('v3:songs-prefs'), restored once at first build. Cold start stays the neutral
Artist A-Z default. The search query and the artist/album drill-down are
deliberately NOT persisted (navigational). Single write point in reload() (where
every change already funnels); every restored value is validated against its
option list, so a removed/stale setting can never wedge the toolbar. Global (not
per-provider) for now.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 06:08:46 -05:00
96a84996a2 fix(tuner): mic-verify stamps the tuning it actually checked + on-device test plan (#684)
Working-tuning follow-ups:

- Mic-verify used _state.currentSongOffsets for the 'verified' stamp, but for a
  MANUALLY-selected tuning (tuner opened off a song) that's a stale/different
  song's tuning — so verify could mark the WRONG tuning verified. It now derives
  the verified offsets from the tuning actually being checked (its target freqs;
  the player's reference pitch cancels in the ratio), so 'verified' always
  attaches to the tuning the player confirmed. Explicit offsets still win.

- Adds docs/working-tuning-on-device-tests.md: the checklist for the parts that
  can't be covered headlessly — the auto-open/gate flow, both-directions prompts,
  mic-verify detection, and the tuner-mic-vs-note_detect ASIO/exclusive-mode
  contention flagged in the design charrette.

Test: mic-verify with no explicit offsets / no song context derives the correct
offsets (Drop-D). 55 tuner tests green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:45:26 +02:00
2910a3c8cb feat(tuner): mic-verify — promote working tuning assumed→verified via a per-string check (working-tuning PR 9b) (#670)
* feat(tuner): mic-verify — promote the working tuning assumed→verified via a per-string check (working-tuning PR 9b)

Adds the choreographed per-string mic verification the design reserved for
'verified' provenance (audio-engine's honesty rule — nothing else may claim it):
the player plays each string, and once every one reads in tune (±6 cents) and
holds stable for 8 frames, the tuner stamps the working tuning
provenance:'verified' + verifiedStrings via workingTuning.set.

- screen.js: a pure verify state machine (verifyStart/verifyFeed/verifyCancel/
  verifyState, exposed on the tuner API) + the set-verified writer; cancels on close.
- ui.js: updateUI feeds each processed frame (matched string + cents) into the
  session; a "Verify tuning" button + per-string progress + status, shown for a
  selected (non-free) tuning.

Pairs with the 9a lifecycle: a 'verified' decays back to 'assumed' on the next
song load, so mic-verify is a per-session confidence boost, never a sticky claim.

Tests: tests/js/tuner_auto_open.test.js +4 (all-strings->verified, out-of-tune
never completes, streak resets on drift, API exposed / only it claims verified) —
33/33. The state machine is headless-verified with synthetic frames; the real
per-string mic detection + the button flow need an on-device pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(tuner): mic-verify writes the confirmed tuning + no clobber + stricter streak (PR #670 review)

Review fixes for mic-verify (working-tuning PR 9b):

- 'verified' could attach to STALE offsets: _publishVerified stamped provenance
  without writing offsets, so the slot's pre-tuning offsets got marked verified.
  verifyStart(targets, offsets) now captures the confirmed tuning's offsets, and
  _publishVerified writes offsets + stringCount + instrument + referencePitch +
  verifiedStrings ATOMICALLY with provenance:'verified' into the selected slot
  (and refuses to stamp verified with no concrete offsets).
- The assumed publish-on-clear immediately clobbered a just-earned 'verified':
  disable() now skips it when a mic-verify wrote verified this session
  (_verifiedPublished).
- The per-string streak could accumulate across silence / wrong-string frames.
  verifyFeed now requires CONSECUTIVE in-tune frames: the one confirmed string
  advances, every other unfinished string resets each frame.
- A mid-verify tuning change (song switch) left stale captured offsets; verify is
  now cancelled in _syncCurrentTuning when the song tuning changes.

Tests: verify writes the confirmed offsets (not stale); source-guard for the
no-clobber path. 47 tuner + 77 tuner/capability tests green. Codex-reviewed.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 11:33:52 +02:00
115c96a3f0 feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4) (#666)
* feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4)

When the opt-in auto-open fires because a song needs a different tuning, playback
now WAITS behind the tuner instead of starting underneath it — the "tune before
you play" model. Built on a new generic core hook window.feedBack.holdAutoplay()
(mirrors holdAutoExit): the tuner claims the hold synchronously on song:loading
(beating the song:ready autostart) and releases it — or a 12s fail-open backstop
does — so a wedged plugin can never strand a song. Generation-guarded; manual
Play always wins.

No one-way trap:
- Skip = "I've tuned" -> plays and records the song's tuning as the instrument's
  current working tuning (the explicit write-point PR 3 left as 'assumed').
- Back to library / Esc -> leave the song, record nothing (reuses requestExitSong;
  Esc is the existing player shortcut).
- The in-panel x is dropped for an auto-open — Skip/Back/Esc are the dismiss
  surface. This also keeps the write honest: Skip is the only on-player dismiss
  that records, so leaving never falsely records a tuning.

Stacked on #660 (working-tuning PR 3). Core app.js gains only the generic hook
(a test asserts it never references the tuner's internals); shell-agnostic.

Needs a desktop smoke-test that the tuner mic doesn't contend with note_detect's
scoring input under ASIO/exclusive mode (per the design charrette).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(tuner): backstop can't cut off tuning + gate race/token hardening (PR #666 review)

Review fixes for the autoplay gate:

- The 12s fail-open backstop could start playback UNDER a legitimately-open tuner
  (a slow / mic-verify retune > 12s). holdAutoplay()'s release now carries a
  .settle() that cancels the backstop; the tuner calls it once the tuner is
  confirmed open (_gateClaimed), so the hold becomes deliberate and only a
  dismiss / song switch releases it. (Fail-open still covers "claimed but wedged
  before deciding".)
- The async song:ready handler could release a NEWER song's gate after its await
  (global _gateClaimed, no guard). It now snapshots _autoOpenGeneration and bails
  if a newer song took over.
- holdAutoplay guarded by song generation, not per-hold — a stale release from an
  earlier hold could clear a later one. Each hold now mints a unique token that
  release()/settle() must match.

Tests: source-level assertions for the token, settle(), the settle-on-open call,
and the song:ready gen-guard. 45 tuner+speed tests green. Codex-reviewed.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 11:18:29 +02:00
48f435408f feat(core): working-tuning lifecycle — launch default, verified decay, idempotent re-injection + tests (working-tuning PR 9a) (#669)
Hardens the host workingTuning capability (PR 1) with the "polish & safety"
lifecycle:

- Idempotent re-injection: a second load of the module no longer replaces the live
  state with a fresh (empty) one — it early-returns once registered.
- Opt-in "launch tuning" default (setLaunchDefault/getLaunchDefault/clearLaunchDefault):
  a per-instrument, localStorage-backed seed the player can opt into ("start me in
  THIS tuning on app open"). Boot seeds from it when set, else /api/settings as before.
  Off by default — a SEED only; the live tuning still resets on restart.
- Verified decay: on song:loading the current instrument's 'verified' provenance
  decays to 'assumed' (offsets kept) — a per-string mic check is only trustworthy for
  the context it was done in, so a stale 'verified' can never suppress a needed prompt.

Adds a state-machine smoke suite (tests/js/working_tuning_capability.test.js, 12/12):
defaults, per-instrument isolation, both-directions, verified-invalidation-on-retune,
decay-on-song-load, resetToDefault, launch-default set/seed/clear, idempotent
re-injection, the change event.

The opt-in UI + the mic-verify writer land with the tuner (PR 9b).


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:07:58 +02:00
7173007412 feat(v3): show the live working tuning on the instrument card (working-tuning PR 5) (#667)
* feat(v3): show the live working tuning on the instrument card (working-tuning PR 5)

The topbar instrument selector now surfaces the selected instrument's live
working tuning from the host workingTuning capability (PR 1): a compact label on
the card — dim while you're in your home tuning, amber once you've retuned — and,
in the dropdown, a "Now in: <tuning> <assumed/verified glyph>" banner with a
one-tap "Back to default" (resetToDefault). Switching instrument/strings calls
setCurrentInstrument so the card follows the right instrument (guitar's tuning vs
bass's, tracked separately); it re-renders on working-tuning-changed.

Names offsets via the shared window.displayTuningName resolver. Fully
feature-detected — without the workingTuning capability the card renders exactly
as before. v3-only, single file (static/v3/badges.js).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(badges): consistent home-tuning check + gate working-context on save (PR #667 review)

Review fixes for the instrument-card working-tuning label:

- isHome used display-label equality where the home label was the raw settings
  string ('Custom') but the working label came from displayTuningName(offsets) —
  a real home tuning could show amber. Both now resolve through the same namer.
- The instrument pill moved the working-tuning context (setCurrentInstrument) even
  when saveSettings() rejected the patch, desyncing the selector from the card.
  saveSettings() now returns whether it was accepted; the pill only switches the
  working context on success.

(No boot-time setCurrentInstrument: the host already seeds its current instrument
from /api/settings and emits working-tuning-changed on hydration — which this card
re-renders on — so pre-touching would only race/suppress that seed.)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 11:01:42 +02:00
df4e17bc99 feat(v3): flag library songs by working-tuning match (working-tuning PR 6) (#668)
* feat(v3): flag library songs by working-tuning match (working-tuning PR 6)

Each song's tuning chip in the v3 library grid is now coloured by whether your
CURRENT working tuning covers it: green = play it now, amber = needs a retune
(with a matching tooltip). Uses the tuner plugin's coverageReport (async), so it
runs as a post-paint decoration pass — chips render instantly, then colour a tick
later; a token cancels a superseded pass so scrolling stays snappy. Re-flags on
working-tuning-changed (retune / instrument swap / reset), no re-fetch.

Fully feature-detected: without the tuner coverage API + the host workingTuning
state, the chips render exactly as before. v3-only, single file (static/v3/songs.js).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(tuner/library): correct bass matching + memoize player tuning (PR #668 review)

Review fixes for working-tuning PR 6 (library tuning-match chips):

- Bass songs were scored against the guitar tuning. The chip passed no arrangement
  to coverageReport, so isBassArrangement fell back to guitar — a 4-string bass
  drop-D read as guitar could FALSE-MATCH a drop-D guitar player (green). songCard
  now flags a bass-only song (every arrangement name matches /\bbass\b/) with
  data-tuning-bass, and decorateTuningChips passes arrangement 'Bass'/'Lead' so
  coverage uses the right base pitches. Mixed guitar+bass songs → guitar (the
  song-level tuning is the guitar one); least-wrong given one tuning per song.

- Per-chip /api/settings fetch storm. coverageReport()→_playerTuning() fetched
  /api/settings once per visible chip per grid paint (~60). _playerTuning is now
  memoized (the player's tuning is song-independent) so all callers share one read;
  invalidated on instrument:changed / working-tuning-changed, with a 3s TTL so a
  settings write that doesn't emit an event still heals. A transient fetch failure
  is NOT cached (next read retries) — else one hiccup would freeze coverage.

Tests: player tuning shared across songs (one fetch); transient-failure retry
(fails without the fix). The prior #680 dedup test updated for the memoized behavior.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 10:51:33 +02:00
6aed8510d7 Tuner: passive "different tuning" badge cue naming the retune (issue E, stage 2.5/3) (#657)
* Tuner: passive "different tuning" badge cue that names the retune

Building on the coverage check: when you enter a song your current
instrument doesn't cover, the topbar tuner badge gets an amber ring + a
tooltip naming the change (e.g. "retune B->A", or "the reference pitch"
for an A440 vs A432 mismatch). Advisory only -- it never auto-opens the
panel; recomputed on song:ready, cleared on song-load / leaving the
player.

Refactors the coverage check into a structured report
(window._tunerAutoOpen.coverageReport -> { covered, retune:[{from,to}],
reference, cantCover }); the boolean gate now wraps it. The cue is
CSS-free (inline ring + native tooltip, no Tailwind rebuild) and no-ops
when the tuner plugin is absent.

Touches static/v3/badges.js (cue) + plugins/tuner/screen.js (report).
v3-only. Stacked on #656 (issue E stage 2.5/3). The splitscreen-suppress
and no-usable-input guards move to E2 (the playback gate).

Tests: tests/js/tuner_auto_open.test.js (report names the strings,
reference mismatch, badge wiring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* feat(tuner): read/write the live per-instrument working tuning — both-directions retune prompt (working-tuning PR 3) (#660)

The §4 coverage check compared each song against the player's fixed
instrument-profile tuning, so the tuner only ever prompted *away* from a "home"
tuning (E -> Drop C#) and stayed silent coming back (Drop C# -> E), even though
the player had physically retuned.

_playerTuning() now reads the host's live per-instrument working tuning
(window.feedBack.workingTuning, keyed by the selected instrument from
/api/settings) instead of re-deriving from the static settings tuning, so
coverage is measured against what the instrument is ACTUALLY in and prompts both
directions. On clearing an auto-opened tuner, _publishWorkingTuning() writes that
song's tuning as the instrument's live working tuning ('assumed' — PR 4's
explicit "I tuned / Skip" refines the write-point), so the next song is judged
against where the player now is.

Per-instrument (guitar vs bass tracked separately). Feature-detected: falls back
to the static /api/settings tuning when the working-tuning capability is absent,
so the 27 existing coverage tests are unchanged. Builds on PR 1 (host
workingTuning) + PR 2 (instrument->chart routing).

Tests: tests/js/tuner_auto_open.test.js — +2 (both-directions coverage via a live
Drop-D working tuning; publish-on-clear targets the right instrument slot); 29
pass total.

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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tuner): transactional open + fail-closed auto-open config (tuner-E #655 review) (#681)

Two review fixes for the auto-open opt-in+persist stage:

- enable() wasn't transactional. The panel (with the ×/Skip buttons) is shown
  before `await _tunerAudio.start()`, and `_state.enabled` was only set after it.
  A ×/Skip dismiss during that await hit disable() with wasEnabled=false, then
  enable() completed and flipped enabled on — an enabled-but-hidden zombie. Guard
  the open with an `_openGen` token bumped on every enable()/disable(); after the
  audio-start await, bail if superseded instead of enabling. Closes #675.

- Config wasn't fail-closed. routes.py normalized the opt-in with
  bool(data.get("autoOpenOnTuningChange", False)), so "false"/"0"/junk coerced to
  True. Accept only a real JSON boolean. Closes #676.

Tests: tuner_auto_open.test.js (dismiss-mid-open stays disabled — fails without
the token guard), test_config.py (auto-open default-false + fail-closed on
non-bool). 34 JS + 24 config tests green.

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

* fix(tuner): coverage stays conservative when the instrument is unknown (tuner-E #656 review) (#682)

_playerTuning() is documented as conservative ("missing data → not covered → still
prompt"), but when /api/settings carried no instrument identity (a fresh profile:
_default_settings() omits instrument/string_count/tuning) it invented guitar/6/440/
standard, so an unconfigured player was treated as 6-string E-standard and coverage
suppressed the auto-open (and badge cue) for matching songs. The post-#660 rewrite
only returned null when the whole fetch failed (!s), not when settings existed but
lacked an instrument.

Now return null unless there's a confident identity — any of instrument/string_count/
tuning in settings, or live working-tuning offsets. A configured standard guitar still
covers a standard song (no regression). Closes #677.

Tests: tuner_auto_open.test.js — empty-settings → not covered (fails without the fix);
configured standard guitar → still covered.

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

* fix(tuner): badge coverage cue staleness + unknown-as-warning + dedupe (tuner-E #657 review) (#683)

Three review fixes for the passive "different tuning" badge cue stage:

- Stale async cue (#678): _refreshCoverageCue awaited coverageReport then wrote the
  DOM unconditionally, so a slow /api/settings fetch could restore the previous
  song's amber ring after song:loading / leaving the player. Add a monotonic token
  bumped on every refresh and both clear paths; apply the awaited report only if the
  token still matches.

- "Unknown" rendered as "needs retune" (#679): the plugin returns a conservative
  all-false report on a fetch hiccup; the cue painted that as an amber "retune the
  reference pitch" ring. Collapse a no-signal report (not covered, no reference /
  retune / cantCover) to null (no cue) via _meaningfulReport(). A genuine not-covered
  report always carries reference / retune / cantCover, so real cues are preserved.

- Duplicate /api/settings fetch (#680): the auto-open gate and the badge cue both
  call coverageReport() per song:ready. Cache the coverage promise per song (keyed by
  session + tuning + centOffset) so they share one fetch; invalidate on song:loading,
  instrument:changed, and working-tuning-changed so it can't go stale within a song.

Tests: tuner_auto_open.test.js — concurrent reports share one fetch, a new song
refetches (fails without the cache). 34 JS tests green. Codex-reviewed.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-01 10:28:04 +02:00
6b0e37aa35 Tuner auto-open: instrument-coverage check (issue E, stage 2/3) (#656)
With the opt-in auto-open on, only prompt when the player's current
physical tuning doesn't already cover the song. FeedBack is tune-to-song
(the highway draws tab in the song's tuning), so the check aligns the
song's open-string tuning string-for-string against the player's
instrument:

- An 8-string F# player gets no prompt for a 6-/7-string standard song
  (its top strings already match those tunings).
- A song needing an open string the player lacks (e.g. a Drop-A
  7-string's low A on an F# 8-string) still prompts.
- A whole-instrument reference difference (A440 vs A432, or an
  octave-down centOffset, previously ignored) also prompts.

Reads the player's instrument from core /api/settings (the v3 instrument
selector, a stable physical reference); conservative fallback (prompt)
when undeclared or unavailable, so a real retune is never silently
skipped. v3-only. All in plugins/tuner/screen.js; no core changes.

Stacked on #655. Follow-up E1.6: a passive badge cue that names the
strings to retune, plus splitscreen / no-usable-input guards.

Tests: tests/js/tuner_auto_open.test.js (covered/uncovered, the Drop-A
case, reference mismatch, direct contiguous alignment).


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:25:57 +02:00
6bfd92aa06 Fix tuner auto-open flash: opt-in + persist (issue E, stage 1/3) (#655)
The tuner self-closes on song:play; autoplay fires it right after a song
switch, so an auto-opened tuner flashed shut ~1s later. An arrangement
switch (which never arms autoplay) instead persisted — the opposite
tester reports, and not the mic.

- New opt-in setting autoOpenOnTuningChange (tuner Settings, default OFF)
- An auto-opened tuner persists: it ignores the autoplay song:play, stray
  outside-clicks, and same-screen re-emits, closing only via the new
  in-panel x / Skip buttons or leaving the song. A manual open keeps the
  classic click-away / play-to-close behaviour.
- Adds the panel's first in-box close (x + contextual Skip).
- All in the tuner plugin; no core app.js changes.

Default (opt-in vs opt-out) is teed up for Byron to flip one boolean.
Staged follow-ups: E1.5 = instrument-coverage smart prompting + badge
cue; E2 = holdAutoplay gate.

Tests: tests/js/tuner_auto_open.test.js (opt-in gate, persist mode,
play/click-proofing).


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:24:39 +02:00
K. O. A.andGitHub bc4d2a3592 Merge pull request #533 from got-feedback/feat/feedpak-jsonc
feat(core): read .jsonc data files (strip C-style comments) (feedpak-spec §8)
2026-07-01 03:35:09 -04:00
topkoa 7713ca92f4 Merge remote-tracking branch 'origin/main' into feat/feedpak-jsonc
Signed-off-by: topkoa <topkoa@gmail.com>

# Conflicts:
#	CHANGELOG.md
2026-07-01 03:27:38 -04:00
K. O. A.andGitHub 7e977b9572 Merge pull request #674 from got-feedback/feat/3d-wide-pane-tuner-per-panel
3D highway wide-pane tuner: dismiss + per-pane targeting
2026-07-01 03:02:54 -04:00
topkoaandClaude Opus 4.8 095d718b85 Address review: Reset on All restores defaults verbatim
The Reset handler forced base.enabled = true after copying _ASPECT_DEFAULTS
(where enabled is false) — a leftover from when enabled controlled panel
visibility. Visibility is now independent (Shift+A / ×), so drop the override
and let Reset restore the defaults exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:59:51 -04:00
c4bb58233d feat(core): route the highway chart to the selected instrument's part (working-tuning PR 2) (#659)
When a song loads without an explicit arrangement, highway_ws now reads the
player's selected `instrument` from config.json (the same file it already reads
for the default-arrangement preference) and picks the arrangement that matches:
bass -> the Bass part. Guitar — and any unknown/future instrument (drums, keys)
— falls through to the existing preference/most-notes default, which already
lands on a guitar part.

Previously the instrument selector only fed the tuner, so a bass player was
handed the default Lead/guitar chart, and the working-tuning coverage check then
compared a 4-string bass against a 6-string part (always "can't cover"). This is
the instrument->chart routing the working-tuning series leans on.

Server-only (every launch path flows through the WS, so no client change). An
explicit arrangement request always wins, so only the default part chosen on
load changes. Tests: tests/test_highway_ws_instrument_routing.py (bass->Bass,
guitar->default, explicit-wins) — 3 new, existing highway WS tests still green.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 08:58:50 +02:00
topkoaandClaude Opus 4.8 1434eb6342 Address review: only register panes while the tuner is open
camUpdate registered every pane each frame regardless of whether the tuner
had ever been opened, so window.__h3dAspectPanes could grow unbounded (prune
runs only while the panel is open) and it ran even for users who never opt
in. Gate _aspectRegisterPane behind __h3dAspectPanelOpen (same gate as the
readout). The pane key is still resolved every frame so saved overrides keep
applying; only the picker bookkeeping is deferred until the panel is open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:54:15 -04:00
topkoaandClaude Opus 4.8 24d24ef2cf Address review: resolve cache, Date.now fallback, prune-on-open, rename
- Memoize _resolveTuneFor per pane, invalidated by a revision bumped on every
  tune mutation (all writes funnel through _aspectPersist). Panes with an
  override no longer rebuild the merged object every frame; panes without one
  still return the base directly.
- _aspectNowMs falls back to Date.now() when the Performance API is absent, so
  pane/readout pruning still works in older/borrowed contexts.
- _setAspectPanelVisible prunes stale panes before the first dropdown build, so
  panes from a prior song/split don't flash until the first RAF tick.
- Rename _abShortcutRegistered/_registerAspectAbShortcut to
  _tunerShortcutRegistered/_registerTunerShortcut — the shortcut opens/closes
  the tuner now, it isn't an A/B toggle.
- Fix a stale 'pane1' example in a comment (keys are 'arr:<name>'/'pane:<uid>').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:47:08 -04:00
topkoaandClaude Opus 4.8 58047e6ad6 Address review: force target to All when the pane picker is hidden
When only one pane is live the Target row is hidden, but _aspectEditTarget
could remain a specific pane key — silently routing edits into a hidden
(and persistent arr:*) override in single-player. Reset the edit target to
"" in _aspectBuildTargets whenever the row is hidden (or the selected pane
is gone), so single-pane edits always go to the shared base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:38:58 -04:00
topkoaandClaude Opus 4.8 817db6382b Address review: explicit button types + Target select label
- Set type="button" on the × close control and the Reset/Copy buttons so
  they can never act as submit if the panel is ever nested in a <form>.
- Add aria-label="Target pane" to the Target <select> so screen readers can
  identify the control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:32:16 -04:00
topkoaandClaude Opus 4.8 9f914770c6 Address review: sparse overrides, hfov clear, readout prune
Three fixes from PR review of the per-pane tuner:

- Sync no longer writes back. _syncAspectPanel dispatches synthetic input
  events to refresh slider labels; guard those with _aspectSyncing so the
  slider handler skips the write. Previously opening/switching a target
  populated a full override for every field (defeating sparse inherit) and
  spammed localStorage.

- Unchecking "Override held hFOV" on a pane target now clears the override
  key (via _aspectClearVal) so the pane re-inherits the base value, instead
  of pinning hfovDeg:null in the override. On the base target it still sets
  the explicit auto (null).

- _aspectPrunePanes now prunes the matching __h3dAspectReadout slot and drops
  a dangling __last, so the readout cache can't grow unbounded as songs and
  arrangements churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:27:01 -04:00
topkoaandClaude Opus 4.8 5ef163e9f9 Key wide-pane overrides by arrangement, not the split panel index
The Target picker disappeared in split because it keyed panes off the
external splitscreen panel index (panelIndexFor), which isn't always
available — both panes then collapsed to a single 'main' key and the
one-pane row-hide kicked in.

Key panes by arrangement name instead ('arr:Bass'): distinct between split
panes AND stable across songs, with no dependency on the split plugin. A
per-instance id ('pane:N') is the fallback when a pane has no arrangement.
Only arr:* overrides persist to localStorage (instance-id fallback keys are
session-only, so they can't leak a new key each reload). This also gives
nicer semantics — a pane's framing follows its arrangement into the next
song.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:15:17 -04:00
topkoaandClaude Opus 4.8 64b95d6f34 Persist per-pane framing across songs via durable slot keys
Per-pane overrides were keyed by an ephemeral per-instance id, so leaving a
song and opening another rebuilt the renderer with a new id and the pane's
framing was lost.

Key overrides by the durable split slot again ('main' | 'panel<idx>', via
_bgPanelKey) so the same slot means the same pane across songs, and persist
__panels to localStorage. Keep the anti-flicker fixes that were the actual
cause of the earlier dropdown churn (prune stale panes, rebuild only on a
pane-set change, never rebuild while the select is focused). The slot key is
latched to the last real slot so a transient null from panelIndexFor during
a song/layout transition can't flip it to 'main' and drop the override for a
frame; it resets in destroy() for instance reuse in another slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:08:27 -04:00
491039a12d feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1) (#658)
* feat(core): host per-instrument workingTuning capability + read-API/event (working-tuning PR 1)

Introduce window.feedBack.workingTuning — the live, host-authoritative current
instrument tuning (offsets + string-count + reference pitch + assumed/verified
provenance), distinct from any one song's tuning and from a soft opt-in default.
It's the single source of truth the highway, library, and plugins (tuner,
Virtuoso, minigames) will read so a retune or instrument swap is reflected
app-wide instead of being re-derived per surface.

PER-INSTRUMENT: state is a map keyed by `${instrument}-${stringCount}` (e.g.
guitar-6 / bass-4, the selector's key) — your guitar's tuning and your bass's are
kept separately; get() returns the selected instrument's, and switching the
selector surfaces that instrument's own remembered tuning. You only ever deal
with the one you've picked.

Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API:
synchronous get(instrument?), set(state,{provenance,instrument}) mutator,
setCurrentInstrument(), resetToDefault(), and a `working-tuning-changed` event
that fires on change and once on hydration (carrying which instrument changed).
In-memory, seeded from /api/settings, reset-on-restart. Registered as a separate
`working-tuning` exclusive-owner capability (tuner = sole writer, others read).

Foundation only — pure plumbing, nothing writes to it yet and no behavior
changes. The tuner becomes the writer (and the gate's E->C# asymmetry is fixed)
in a later PR.

Frontend-only: new static/capabilities/working-tuning.js, loaded from
static/index.html + static/v3/index.html. Per-instrument state machine verified
by a stubbed node harness (separate guitar/bass slots, selector switch, isolated
writes, verified stamp, reset, defensive copies, capability registration).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(working-tuning): resolve review P1/P2s + add behavioral test harness

Addresses the manual + Codex review of PR 1 (working-tuning foundation).

P1 — named tunings were dropped by the boot seed: /api/settings.tuning may be a
name ("Drop D") OR an offsets list, but the seed only handled the list and stored
offsets:null for names. The seed now resolves a name to per-string semitone offsets
via /api/tunings (ratio vs Standard; reference pitch cancels).

P1 — async seed could clobber state a consumer had already written: _seedFromSettings
resolves after boot and used to overwrite _currentKey/_byInstrument unconditionally.
It now bails when state was already _touched (and re-checks after the /api/tunings
leg), so an explicit set()/setCurrentInstrument()/resetToDefault() before hydration
wins. Hydration still fires.

P1/P2 — shallow copy leaked live nested arrays: get() and set() now clone offsets and
verifiedStrings on both ingress and egress, honouring the "readers can't mutate live
state" contract.

P2 — provenance/verification state machine made coherent by construction:
verified <=> verifiedStrings is an array AND verifiedAt is a finite number. A tuning
change invalidates prior verification unless a fresh bundle is supplied; a "verified"
claim with no strings or a null/absent timestamp is repaired (assumed / stamped now).

P2 — bare-instrument writes targeted a hard-coded default string count: _keyOfResolved()
resolves an omitted string count against the current selection (same instrument), so
set({instrument:'bass'}) / set({stringCount:5}) hit the selected bass-5, not bass-4.

Test — adds tests/js/working_tuning.test.js (the harness the PR described but did not
commit): 11 behavioral cases over a stubbed window — registration, per-instrument
isolation + selector switch, defensive copies, the verification invariant, bare-key
routing, named + offsets-list seeding, and the boot-race guard. Full tests/js suite:
no new failures (the 12 pre-existing branch failures are unrelated).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-01 08:05:56 +02:00
topkoaandClaude Opus 4.8 6c42d83c31 Fix flickering / wrong panes in the wide-pane Target picker
The Target dropdown keyed panes off feedBackSplitscreen.panelIndexFor,
which can return the focused index for any canvas — so both split panes'
keys ping-ponged, rebuilding the <select> every frame (flicker) and
listing wrong/duplicate entries. The registry also never dropped panes
from a prior song or a closed split.

- Key each pane by a stable per-renderer-instance id (_paneUid, assigned
  once in init) instead of the split panel index.
- Prune panes not reported within ~1.5s (song change / split teardown).
- Mark the dropdown dirty only when the pane SET changes, not on every
  per-frame re-report, and skip rebuilding while the <select> is focused.
- Hide the Target row entirely when there's a single pane.
- Label panes by arrangement name, falling back to "Pane N".

Per-pane overrides are now session-only (keyed by ephemeral instance ids),
so they're no longer persisted to localStorage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 02:01:41 -04:00
topkoaandClaude Opus 4.8 9f0d48cb1f 3D highway wide-pane tuner: dismiss + per-pane targeting
Two usability gaps in the wide-pane framing tuner:

- No way to dismiss the panel. Add a × close button to the header and make
  the Shift+A shortcut open/close the panel (reveal/dismiss). The A/B
  enabled toggle now lives as a checkbox in the panel, so closing the panel
  no longer changes the framing state.

- Edits hit every split pane at once. Add a Target selector (All panes, or a
  specific pane labelled by its arrangement, e.g. "Panel 1 — Rhythm"). Per-
  pane edits write a sparse override map (__panels[key]); each renderer
  resolves the shared base with its own pane's overrides laid on top via
  _resolveTuneFor(paneKey), so one pane can be framed independently. Reset on
  a pane clears its override (re-inherits the base); Copy exports the resolved
  values for the selected target. The live readout is keyed per pane.

Panes are discovered from the existing per-panel key (_bgPanelKey /
feedBackSplitscreen.panelIndexFor) and self-register each frame for the
picker. Overrides persist to localStorage alongside the base.

Tests extended in tests/js/highway_3d_wide_fov.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-01 01:53:45 -04:00
f9607c5c94 Port the Min res (minimum auto-resolution) selector into the v3 player (#663)
The v2 control bar exposes a "Min res" selector next to Quality that sets
highway.setMinRenderScale — capping how far the load-adaptive resolution
scaler (feedBack#654) may downscale, or disabling it entirely (Full). The
v3 UI only ported the Quality selector, so v3 users had no way to stop the
highway auto-downscaling to as low as quarter-res on heavy scenes / weak-
GPU launches — pixelated even at Quality = HD, with no workaround (worse
than v2).

Add the Min res row to the v3 viz/quality rail popover, under Quality,
mirroring the v2 control (same options, handler, title, aria). The handler,
the setMinRenderScale/getMinRenderScale API, and the shared app.js init
that syncs the selector's value (guarded by element id) all already exist —
only the v3 markup was missing.

Fixes #662

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 20:19:08 +02:00
3e3a98a0d0 Aspect-aware framing for ultra-wide 3D highway panes (#652)
* Add aspect-aware framing for ultra-wide highway panes

On a top/bottom 2-player split each 3D highway pane is full-width /
half-height (~32:9). The camera's vertical FOV was locked at a single
value, so at that aspect the horizontal cone ballooned past 130deg and
squeezed the fixed-width neck into a thin central sliver with large dead
margins on either side.

Add a "horizontal-FOV-hold" path: past a configurable start aspect the
effective vertical FOV is lowered so the horizontal cone stays roughly
constant, letting the neck fill a wide pane. At/under the start aspect it
is an exact no-op, so normal ~16:9 single-player and most 2x2 panes are
unchanged. Optional pose nudges (height / dolly / pitch / look-depth)
further flatten the view toward a low, immersive angle.

Everything is driven by a runtime bridge (window.__h3dAspectTune) with a
live tuner panel (Shift+A in the player) exposing every knob plus a live
aspect/FOV readout, localStorage persistence, and a Copy button. Toggling
the feature off restores the exact prior framing, so it doubles as an A/B
control. Shipped on by default for wide panes for testing feedback.

Source-pinned by tests/js/highway_3d_wide_fov.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(highway_3d): ship wide-pane framing default-OFF with a coherent config

Review fixes for the aspect-aware framing. The first cut shipped
_ASPECT_DEFAULTS = { enabled:true, baseVfov:30, blend:0, minVfovDeg:36 },
which contradicted the PR's own "default off → byte-for-byte prior behaviour"
claim:

- enabled:true made the tune active for everyone, and baseVfov:30 forced every
  pane's vertical fov from 70° to 30° (normal single-player/2x2 panes included —
  a drastic global zoom, not the advertised no-op).
- blend:0 collapsed the Hor+ math back to base, so the actual horizontal-FOV-
  hold did nothing even on wide panes — the only net effect was the zoom.
- minVfovDeg:36 > baseVfov:30 was an inverted floor (clamped wide panes UP to
  36° rather than flooring a real reduction).

New defaults: { enabled:false, baseVfov:BASE_VFOV(70), blend:1,
minVfovDeg:HORPLUS_MIN_VFOV(28) }. Now:

- OFF by default → camUpdate passes a null tune → effectiveVfov returns
  BASE_VFOV → exact no-op on every pane (verified: 70° at 16:9 and 32:9).
- When a tester enables it (Shift+A), baseVfov==BASE_VFOV keeps normal/≤start
  panes at 70° (still a no-op there) and blend:1 makes the hold actually engage
  on genuinely wide panes (47.7° at 32:9, flooring toward 28° as aspect grows).
- minVfovDeg < baseVfov is a real floor.

Also bumps the localStorage key (h3d_aspect_tune → h3d_aspect_tune2) so a
machine that persisted the old broken default gets the corrected one, and adds
source-pin tests guarding default-off + the coherent base/blend/floor so this
can't silently regress to default-on again. The pose-nudge values are left as
the author's in-progress wide-pane look (dormant until enabled). 110/110 tests
pass; node --check clean.

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

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-30 17:36:32 +02:00
db81d7dafb Add progress bar to v3 "Up Next" pill (#649)
The persistent top-right "Up Next" pill showed the upcoming section name
and a countdown ("in 12.3s") but no at-a-glance sense of how far through
the current section the song is. Add a thin progress bar directly under
the existing text that fills as the current section elapses toward the
next, reaching full when the section flips.

The text row is wrapped unchanged in a flex row and the pill stacks the
bar beneath it; nothing else about the pill's content or styling changes.
Progress is computed in updateUpNext() as the fraction elapsed between the
previous section boundary (last section at/before now, else song start)
and the next section. The fill uses the same gradient as the section name
for visual cohesion.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 23:44:09 +02:00
a732e1f9d2 perf(tuner): idle the always-on tuner viz rAF when there's no signal (#647)
The v3 home tuner card runs continuously, and every tuner visualization
drove a self-rescheduling 60fps requestAnimationFrame loop that never
stopped — pinning a renderer core even on a silent home screen with the
needle/strobe at rest.

Make each viz idle its loop once there's nothing left to animate, and
re-kick it from update() on the next reading that actually moves it:

- analogue-gauge: stop when the needle + drum strip have settled on their
  targets (|target-current| below a sub-visible epsilon); restart when a
  new reading moves the target.
- strobe / mace-fx-iii / chef-mt3: stop when there's no live signal and the
  strobe drift (and glow fade) have fully decayed; restart on the next note.
- toilet-tuner: stop when silent and the plunger has eased back to centre;
  restart on the next reading (guarded so repeated no-signal updates don't
  re-kick a parked loop).

Active tuning is unchanged — the loop runs whenever a note is sounding or
the indicator is still moving. Bumps tuner 1.3.1 -> 1.3.2.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 23:12:42 +02:00
dfa825b4ab docs: host theme contract proposal (#645)
* docs: host theme contract proposal (prevent features carving into one theme)

Charrette output after a plugin UI feature (note_detect results card) was built
against only the default skin and broke on the others — the colours adapted via
tokens but the visual *devices* (glow ring, gradient) did not, because themes are
design languages, not palettes, and nothing governs whether a theme does glow.

Proposes a host theme contract: always-present semantic role tokens (incl. the
missing on-accent + focus-ring), intent-named capability recipe slots where "off"
is legal (an EMPHASIS recipe + an ACCENT-TEXT recipe), a window.feedBack.theme
read/capability API + theme:changed event, a derive-surfaces-from-host
reconciliation rule, accessibility baked in, and a skin-matrix verification gate.
All additive + feature-detected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* docs(host-theme-contract): make the proposal normative (review fixes)

Address the review of #645 (manual + Codex) — the doc was a sound design
sketch but not yet the precise *contract* it claims to be:

- P1 Token contract pinned: one public namespace `--fb-*` written on :root by a
  host-owned contract stylesheet (present themed-or-not); `--fbv-*` explicitly
  demoted to internal Tailwind-override plumbing (plugins must not read it).
  Added a normative role table + value grammar (colour roles = `r g b` triplets
  consumed via `rgb(var(--fb-x))`; recipe slots = full CSS device values). The
  §5 example now uses `--fb-*` throughout (was bare `--accent`/`--emph-*`).
- P1 Invisible-text bug removed from the spec: `--fb-acc-text-fill` is the one
  slot where `none` is ILLEGAL (always a real paint, defaulting to the solid
  accent); the example feature-detects `background-clip: text` and keeps a solid
  `color` base, so the accuracy number can never render transparent — honouring
  the DoD "a device stays legible when its slot resolves to none".
- P1 capabilities() booleans removed: they contradicted "never branch on
  glowy?" and were too lossy for canvas. The JS API is now CSS/DOM-forbidden and
  exposes RESOLVED token values (`get().tokens`) for canvas/WebGL renderers only.
- P1 Physical home decided: a static `theme-contract.css` (outside the prebuilt
  Tailwind artifact, so no tailwind-fresh CI churn) holds the :root `--fb-*`
  defaults + the single focus-visible + reduced-motion rules; existing v3.css
  focus/motion rules are a tracked reconciliation, not day-one magic.
- P2 Full on-fill family (`--fb-on-accent/-good/-warn/-bad`) + good/warn/bad ↔
  existing good/mid/low mapping; `theme:changed` lifecycle pinned (get() sync +
  valid pre-apply, event after commit + once on hydration, plugins read on
  mount); same-document-light-DOM scope + shadow/iframe bridge stated;
  prefersReducedMotion() named the single JS motion gate.

Resolved open questions folded into the body; the two genuine ones (skins-as-
host-themes, component-recipe bundles) remain.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 15:48:35 +02:00
3120ae3e71 fix(highway_3d): dolly back so the fret-number row can't clip off the bottom (#633)
The heat-coloured fret-number row is drawn as a band BELOW the board
(sY(lowest) - S_GAP*1.4), but camUpdate's self-correcting framing only
anchors the board CENTRE to the lower third of the screen and reserves no
headroom for that row. So a tight zoom on a centred active span (worst
mid-neck; fine at either end of the neck) pushes the numbers past the
bottom edge -- which is why testers saw it "only when centered" and "not
every song." Tilt can't fix it (it would only trade a bottom clip for a
top clip); the vertical-extent problem at tight zoom needs camera distance.

Add a fret-row fit guard: project the row band with the final camera and,
when it falls below FRET_ROW_FIT_NDC_MIN, raise a capped, hysteretic
_fretRowFitBoost applied to the curDist lerp target (the span-driven
tgtDist still owns zooming IN). The boost rises promptly (proportional to
the deficit), relaxes lazily past a deadband, and is capped at
FRET_ROW_FIT_BOOST_MAX (+60%) so the zoom can't pop or hunt. It cooperates
with the tilt loop (pull-back shrinks the scene, tilt keeps the centre
anchored) and yields entirely to the Camera Director free-cam. Surgical:
passages where the row is already visible never trigger it, so framing is
unchanged everywhere it already worked.

plugin.json 3.30.0 -> 3.30.2 (screen.js cache-buster; 3.30.1 is taken by the
FPS-counter PR). Tests: tests/js/highway_3d_camera_framing.test.js
(guard constants, the boosted curDist lerp, the projected-row hysteresis,
free-cam yield).

Fixes #632


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:31:24 +02:00
199550e5fb fix(v3): dismiss Section Practice popover when another player popover opens (#638)
* fix(v3): dismiss Section Practice popover when another player popover opens

The Section Practice popover (Songs > Song > Practice pill) stayed open
when the user then clicked a v3 player-rail icon (Plugins, Audio, …),
leaving two popovers stacked on top of each other. Reported on 0.3.0
(macOS) and still reproducing in the 2026-06-28 build.

Root cause: the popover's outside-click dismiss was bound in the
bubbling phase, but the v3 rail's icon buttons call e.stopPropagation()
in their click handler (player-chrome.js wireRail), which kills bubbling
before the click reaches document. So the dismiss listener never fired
for a rail-icon click and the popover was orphaned open.

Fix: bind the outside-click dismiss in the capture phase, which runs
before the target's handler so stopPropagation() can't swallow it. This
mirrors the audio mixer popover (audio-mixer.js), which already
dismisses outside-clicks via capture-phase listeners for exactly this
reason. Esc handling stays in the bubble phase (no rail handler stops
keydown propagation, and capturing it would reorder it ahead of the
player's Escape-to-exit handling).

Shared app.js code, so v2 is covered too; v2 has no stopPropagation rail,
so its outside-click dismiss behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* chore(#638): add CHANGELOG entry + capture-phase regression test

Review follow-ups for the Section Practice popover dismiss fix:
- CHANGELOG [Unreleased] → Fixed entry (repo workflow requires one).
- tests/js/section_practice_dismiss.test.js pins the fix: the outside-click
  dismiss binds in the CAPTURE phase (so a rail icon's stopPropagation can't
  swallow it), exactly one capture binding (Escape keydown stays bubble-phase),
  and the #section-practice-control containment guard (no self-close). A revert
  to bubble-phase fails the test.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 15:00:28 +02:00
8fbbc761fc fix(v3): reject accidental text-selection of UI chrome (user-select policy) (#637)
* fix(v3): reject accidental text-selection of UI chrome (user-select policy)

Dragging/double-clicking across the v3 UI marquee-highlighted buttons, labels,
the sidebar, transport, and the note-highway HUD — looks broken (reported Mac +
Windows). Default the v3 shell to user-select:none on html, then opt CONTENT
back in. Decided by a 4-lens panel (UX / a11y / dev-ops / plugin-ecosystem);
their guardrails are baked in:

- Form fields ALWAYS re-enabled (input/textarea/select/[contenteditable]) so the
  caret + IME composition never break. No `* { user-select:none }` (WebKit input
  bug 82692).
- Plugin screens (.screen[id^="plugin-"]) stay selectable BY INHERITANCE (no `*`,
  so a plugin's own non-select chrome still wins) — a plugin's copyable text
  (lyrics, chords, results), including community/out-of-tree plugins that never
  adopt the class, isn't silently locked.
- Core read-only content opts back in by CONTAINER via a hand-authored
  `.fb-selectable` (not a Tailwind utility — so runtime-installed plugins get it
  too): the whole Settings panel (paths, device names, version, diagnostics,
  About) and the now-playing song metadata. Answers the open "keep settings
  copyable?" question: yes, at the container.

Cosmetic only — never used to lock copy-worthy text (errors/IDs/paths/versions/
metadata stay selectable; WCAG 2.2 allows copy-paste as a mechanism). v3-only
(v2 unchanged; v3.css loads only on /v3); plain CSS, no Tailwind rebuild; no
desktop/Electron changes (standard OS-framed window). `.fb-selectable` is
documented in CLAUDE.md for plugin authors.

Tests: tests/js/v3_user_select_policy.test.js (html default, form-field
re-enable, plugin-screen carve without `*`, .fb-selectable, container opt-ins,
and the no-`*`-rule guardrail).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): address review of the user-select policy (#637)

Review (manual + Codex) of the v3 text-selection policy:

- P1 (real bug): the now-playing HUD metadata opted into `.fb-selectable` but
  its `#player-hud` parent is `pointer-events: none`, so the mouse could never
  reach the text to select it — the opt-in was inert. Add `pointer-events-auto`
  to the metadata block (verified in-browser: user-select:text + pointer-
  events:auto, while the HUD parent stays pointer-events:none).
- Coverage: the PR's a11y guardrail promised copyable text stays selectable
  "incl. in modals/toasts", but only Settings + the HUD were opted in. Blanket-
  opt the focused copyable surfaces back in by selector — `.feedBack-modal`,
  `[role="dialog"]`, `#fb-notify-stack`, `#v3-fb-toast`, `#scan-banner` — so
  errors / IDs / paths / file names in dialogs, toasts, and the scan banner stay
  copyable. These are focused panels, not dense card lists, so re-enabling
  selection there can't recreate the across-cards marquee mess.
  (Deliberately NOT opting in the library grid / dashboard / profile card lists:
  making dense card text selectable would reintroduce exactly that marquee mess
  on a drag — copy song metadata from the now-playing HUD / Settings instead.)
- Test (P3): assert the selectable rule's selectors order-independently, cover
  the new modal/toast/banner surfaces, and check the HUD block carries BOTH
  fb-selectable and pointer-events-auto (class-order independent).

Verified in a real browser (chromium): html=none, sidebar chrome=none, input=
text, Settings=text, HUD meta=text+pointer-events:auto, dialog/modal=text.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 14:44:56 +02:00
a791a0d8fe feat(v3): DOM-virtualize the Songs grid (#636 item 3 stage 2) (#643)
The v3 Songs grid appended every scrolled page and never released nodes,
so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a
2000-song library). Replace it with a windowed/recycled render: only the
visible window (± overscan) is in the DOM while a #v3-songs-gridsizer
element sized to ceil(total/cols)*rowH gives the scrollbar full-library
geometry; #v3-songs-grid is absolutely positioned to the first visible row.

- state.songs is a sparse, absolutely-indexed store filled a page at a time
  by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll
  (O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage
  shares an in-flight promise per page and an epoch guard discards a stale
  fetch that lands after a reset.
- A–Z rail seeks directly via sort_letters cumulative counts (O(1), no
  page-through); bounded scan fallback for legacy providers without it.
- Snapshot/restore is now scrollTop-based (geometry is stable). Select mode,
  accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence
  survive cards recycling; renderWindow re-renders when select mode toggles.
- Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered
  event instead of assuming all cards are present (highway-stutter lesson).

Verified in a browser against a seeded 2001-song library: DOM bounded to
~60 nodes while the count reads "2001 songs", rail jump lands on the target
row, selection survives recycling, scroll-restore exact. Codex-reviewed
(3 findings fixed: stale-fetch epoch guard, await-in-flight page promise,
select-mode resync on cached re-entry).

Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the
bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and
v3_songs_scroll.test.js updated to the new wiring.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:26:29 +02:00
5ed6f454e7 feat(library): smart collections as a library provider (#641)
Implements feedBack#636 item 2 (P1) — saved library filters that stay live,
the homelab primitive FeedBack was missing (Plex smart collections / Navidrome
.nsp / *arr custom filters).

A collection is a saved /api/library query surfaced as a registered library
provider, so it appears in the v3 source picker and inherits the whole Songs
UI (paging, stats, A–Z rail, art) with no new screen.

- Storage reuses the playlist subsystem: a `playlists.rules` JSON column
  (additive, idempotent migration). A row with rules != NULL is a smart
  collection; list_playlists + get_playlist filter `rules IS NULL`, so
  collections are excluded from the manual-playlist list and read-only to
  every playlist mutation that gates on get_playlist.
- SmartCollectionProvider (kind="local" — matched songs are local rows, so the
  client's play/art paths stay on the local branch) delegates query_page/
  query_stats/query_artists to the local DB with the stored rules applied;
  tuning_names/get_art delegate straight through. Registered via a boot scan +
  on create/update (replace=True) / delete.
- Rules mirror the raw /api/library query params; `_sanitize_collection_rules`
  drops unknown keys and is applied at API ingress AND on provider load, so a
  hand-edited / imported bad value can't crash a query.
- API: GET/POST/PUT/DELETE /api/collections. Frontend: a "+ Save as
  collection" action in the v3 filter drawer (local provider + active filters
  only) that names the current filter set and switches to it.

Reviewed by Codex; 3 findings fixed (local-kind playback path, save gated to
local provider, re-sanitize persisted rules).

Tests: tests/test_collections_api.py (CRUD, provider filtering, restart
re-registration, kind=local, corrupt-rule tolerance, playlist isolation),
tests/js/v3_collections.test.js.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:42:41 +02:00
331857ff2a feat(library): keyset cursor pagination + stable sort tiebreak (#636 item 3, stage 1) (#642)
Stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3):
the data layer the DOM-recycling render window will build on, plus a latent
paging bug fixed on the way.

- Every grid sort now appends a unique `filename` tiebreak → a TOTAL order.
  Without it, rows with an equal sort key (e.g. two songs by the same artist)
  could be skipped or duplicated across OFFSET pages.
- query_page gains an opaque `after` keyset cursor: when supplied and the sort
  can keyset (artist[-desc], title[-desc], recent), the page is fetched with a
  WHERE-seek instead of OFFSET — O(page), independent of depth. The seek is
  NULL-aware (NULLs first in ASC / last in DESC) so it's EXACTLY OFFSET-
  equivalent; the legacy `dir=desc` shape is canonicalized so its cursor seeks
  the right direction. Unknown/compound sorts + bad cursors fall back to OFFSET.
- /api/library exposes `after` + `next_cursor`. Only the true local provider is
  handed a cursor (a collection may pin a different sort; remote don't keyset),
  so both page by OFFSET safely.
- Composite (artist NOCASE, filename) / (title NOCASE, filename) /
  (mtime, filename) indexes cover the order; `after` added to the optional
  provider kwargs so legacy providers drop it.

Codex-reviewed; 3 findings fixed (dir=desc canonicalization, NULL-key seek,
cursor only for the local provider).

Tests: tests/test_library_keyset.py (keyset==OFFSET parity for 5 sorts, stable
tiebreak on equal keys, dir=desc, NULL sort keys, bad-cursor + compound-sort
fallback).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 10:40:47 +02:00
8ca7ea4002 feat(library): persisted wishlist / "wanted" list (#640)
Closes feedBack#636 item 4 — the *arr "Wanted/Monitored" analogue FeedBack
was missing. A wishlist entry is a song the user does NOT own yet, so unlike
a playlist (which references owned local songs by filename) it can't reuse the
playlist subsystem; it lives in a new `wanted` table keyed by descriptive
identity (artist, title, source, source_ref, note, created_at).

- New table + a UNIQUE index on (artist NOCASE, title NOCASE, source,
  source_ref); additive + idempotent (CREATE … IF NOT EXISTS).
- MetadataDB.add_wanted (INSERT OR IGNORE + re-select under the write lock,
  so a re-run of an ownership-diff returns the existing row, never a dup),
  list_wanted (newest first), remove_wanted, count_wanted.
- Routes GET/POST/DELETE /api/wanted. POST requires artist or title and
  defaults source to "manual"; idempotent on identity so producers (the
  find_more ownership-diff, or a manual add) can re-post freely.

This is the core persistence primitive the charrette flagged as the missing
piece; the consuming UI lives in the producing plugin (find_more / the_daily).

Tests: tests/test_wanted_api.py (round-trip, identity idempotency incl.
case-insensitive, distinct source_ref, ordering, validation, additive schema).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:36:02 +02:00
07ab902604 feat(settings): back up the library DB + custom art in the export bundle (#639)
Closes the dev-ops lens's #1 finding from the library charrette
(got-feedback/feedBack#636 item 1): scores, favorites, playlists, and play
history — the only library state a rescan can't rebuild — were absent from
the settings backup. Now GET /api/settings/export carries an additive
`core_server_files` section:

- a CONSISTENT snapshot of web_library.db via the SQLite online-backup API
  (a complete single file even while the server runs; taken under the
  MetadataDB write lock), base64-encoded;
- custom playlist covers + avatar (CONFIG_DIR/playlist_covers, /avatars),
  walked with the existing _walk_export_paths machinery.

Restore is DB-safe:
- POST /api/settings/import STAGES the DB to web_library.db.restore (never
  over the live, open file); _apply_pending_db_restore swaps it in at the
  next startup BEFORE the connection opens, clearing stale -wal/-shm so a
  stale WAL can't be replayed onto the restored file. Response sets
  `restart_required` + a warning; custom art applies immediately.
- The staged DB is integrity-checked (open + PRAGMA quick_check) at import
  AND again at startup before the live DB is touched — a corrupt/truncated
  restore is refused/discarded and the live DB is left intact, so a bad
  bundle can never brick startup or lose data.
- Export hard-fails (500) if the snapshot can't be produced (no silent
  DB-less backup); a partial import disarms its own staged restore.

Backward-compatible: older servers ignore the new section; a bundle without
it imports as before. Known gap: custom uploaded *song* art is still
commingled with the rebuildable thumbnail cache in art_cache/, so it isn't
bundled yet (tracked follow-up on #636).

Tests: tests/test_settings_export_library_db.py (snapshot consistency,
staged-not-live restore, sidecar clearing, corrupt-DB refusal at import +
startard, traversal rejection, export hard-fail, disarm-on-failure, full
round-trip).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 09:34:47 +02:00
6a6efc793a feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf (#635)
* feat(v3 library): practice-aware home — Repertoire meter + "Keep practicing" shelf

The Songs page opened cold into a flat sorted grid. This adds a practice-aware
front door on the unfiltered grid, built entirely from data already on hand
(no new endpoints, no new stored state):

- Repertoire meter — "Repertoire: N of M songs · K in progress" + a bar,
  counting songs at/above the same mastery threshold the green accuracy badge
  uses (>= 0.9 best accuracy) over the unfiltered library total. Reads
  state.accuracy (/api/stats/best, already loaded for the card badges) and the
  unfiltered /api/library/stats total.
- "Keep practicing" shelf — a horizontal row of recently-played, not-yet-
  mastered songs (newest first, click to play). Reads /api/stats/recent.

Both show ONLY on the grid view when not searching/filtering/selecting (the
front-door context), refresh after a song is scored (applyScoreRefresh), and
collapse on an empty library. Soft-gamification only: descriptive encouragement
(goal-gradient / endowed-progress), never content-gating, decay, or nagging —
the practice-accuracy "continue" rail a media server can't do.

Frontend-only: static/v3/songs.js (renderLibraryHome / _repertoireCounts /
libHomeVisible, wired through reload() + applyScoreRefresh), static/v3/v3.css.
Came out of the library design charrette (UX + gamification lenses' top pick).

Stacked on the A–Z rail branch (feat/v3-library-az-rail) since both touch
static/v3/songs.js; merge that PR first (or retarget).

Tests: tests/js/v3_keep_practicing.test.js (threshold, front-door gating,
shelf filter, denominator, render/reload/score-refresh wiring, click-to-play).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3 library): correct practice-aware home for review P1/P2/P3

Addresses the PR #635 review findings (manual + Codex):

P1 correctness
- Gate the Repertoire meter + "Keep practicing" shelf to the LOCAL
  provider (libHomeVisible). They read local practice stats
  (state.accuracy / /api/stats/recent); on a remote provider they mixed a
  local mastered count with a remote song total (e.g. "85 of 80") and the
  shelf played local files while browsing a remote library.
- Shelf now gates on the per-SONG best (state.accuracy[filename] = MAX
  across arrangements, what the green badge shows) and dedupes by filename,
  instead of the per-arrangement recents row — so a "keep practicing" card
  can no longer show a green "mastered" badge, and a song can't appear twice.

P2 robustness
- renderLibraryHome fetches /api/library/stats + /api/stats/recent together
  (Promise.all) and a _homeToken generation guard discards a stale render
  so a slow response can't repaint a home the grid already moved past.

P3 polish
- accuracyBadge references MASTERY_ACCURACY instead of a bare 0.9, so the
  badge and the meter/shelf can't drift from "the same mastery threshold".

Tests updated (v3_keep_practicing.test.js): provider gating, per-song
deduped shelf, Promise.all + token.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 08:56:31 +02:00
6a71577e05 feat(v3 library): A–Z fast-scroll jump rail on the Songs grid (#634)
* feat(v3 library): A–Z fast-scroll jump rail on the Songs grid

Adds a vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the
right edge next to the scrollbar so you can jump the library to a starting
letter — tap, drag-to-scrub with a live letter bubble, or arrow-key between
letters. The classic (v2) tree already had letter selection; this brings the
new v3 grid to parity (it was the gap behind the "alphabetical scroll
selection next to the scrollbar" idea).

It shows ONLY for the grid view + alphabetical (artist/title) sorts, and only
offers letters present in the current sort AND filter set, so a tap always
lands on a real card (absent letters are dimmed + non-interactive). The grid
is forward-only, server-paged infinite scroll with no virtualization, so a
jump pages through to the target card then scrolls to it; a token guards
overlapping jumps (drag) so the newest wins. A keyset-seek + virtualized
window is the scaling follow-up for very large libraries.

Backend: /api/library/stats gains an optional `sort` param and an additive
`sort_letters` map — songs-per-first-letter of the ACTIVE sort column (artist
or title), filter-synced — so the rail's present-letters match the grid's real
order. The legacy `letters` (distinct-artist) field is unchanged, so the
dashboard + classic tree are unaffected. `sort` is dropped for providers whose
query_stats predates it (existing kwarg-filter), so third-party library
providers keep working (rail simply falls back / hides).

Frontend: static/v3/songs.js (refreshRail / jumpToLetter / pointer-drag +
keyboard, cards tagged data-letter), static/v3/v3.css (.v3-azrail + bubble).

Tests: tests/test_library_filters.py (sort_letters artist/title, song-vs-
distinct-artist counting), tests/test_library_providers.py (sort forwarded),
tests/js/v3_az_rail.test.js (gating, data-letter, load-through, drag/keys).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3 library): harden A–Z jump rail (review P2/P3)

Addresses the PR #634 review findings (manual + Codex):

P2 correctness
- refreshRail prefers the active-sort `sort_letters`; falls back to the
  artist-based `letters` only on an artist sort, and hides the rail on a
  title sort when a legacy provider returns none (was mislabeling letters).
- reload() bumps `_jumpToken` so an in-flight letter jump can't scroll a
  grid that's being rebuilt from page 0.
- songBucket no longer trims, matching the server SQL + grid ORDER BY raw
  first-char bucketing (a leading-space title now buckets under '#' on both
  sides).

P3 polish
- Paging guard is total-derived (ceil(total/PAGE_SIZE)+2) instead of a
  magic 4000, keeping large libraries reachable while still bounded.
- Roving tabindex: only the first present letter is tabbable; arrow keys
  move it. Removes up to 27 page tab stops.
- `sort_letters` is computed only when the caller opts in
  (want_sort_letters / route `sort_letters=1`); the dashboard + v2 tree
  skip the extra GROUP BY. Added sort + want_sort_letters to the optional
  provider-kwargs so non-introspectable legacy providers drop them.
- _railToken supersedes stale refreshRail responses; hide the rail when no
  letters are present instead of rendering disabled buttons.

Tests updated accordingly (v3_az_rail.test.js, test_library_filters.py).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-29 08:49:01 +02:00
b29bab1884 fix(highway_3d): keep the FPS counter from hiding behind the v3 "Up Next" pill (#630)
The on-highway FPS readout (Settings -> Graphics -> 3D Highway -> Show FPS
counter) is pinned to the top-right of the highway overlay -- the same
corner the v3 player chrome stacks its persistent Up Next pill and
live-performance HUD into, on a higher layer that paints over the canvas.
So the readout sat behind that chrome and couldn't be read, exactly when a
tester turned it on to judge performance (and because the pill is default-on
it covered the counter regardless of the separate "Up Next won't turn off"
report).

Keep it top-right (where testers look) but drop it just below whichever of
that chrome is showing: measure the lowest visible top-right v3 HUD element
(#v3-upnext / #v3-live-performance-hud / #hud-time) and floor the FPS box's
Y beneath it. Element refs are resolved once and cached (no per-frame
querySelector, per the plugin perf rules) and only read while the counter is
actually drawn; gated on window.feedBack.uiVersion === 'v3' so classic v2 is
byte-for-byte unaffected. Bump plugin version 3.30.0 -> 3.30.1 (the screen.js
cache-buster).


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:50:36 +02:00
8a2175aa1c feat(onboarding): amp-sim opt-in choice + use_amp_sims setting (#631)
Second half of feedBack-desktop#46. The desktop app monitors through an
in-app amp-sim/tone chain that, once loaded, auto-restores every launch —
an idle high-gain amp on the input is a constant distorted buzz, and the
dry-only monitor mute can't silence it. This adds the "own-rig first"
opt-in so players using their own external amp/rig never get a processed
monitor in the first place.

Core changes:
- New `use_amp_sims` setting (default OFF / own-rig first): GET default,
  POST boolean validation, and resettable key — mirroring achievements_enabled.
- Onboarding wizard: a DESKTOP-ONLY step ("How do you want to hear
  yourself?") between instrument paths and the calibration challenge. The
  web build has no native amp sims, so the step is skipped there (5 steps
  on web, 6 on desktop) — gated on window.feedBackDesktop, dot count and
  setStep bounds are derived from it. Ticking "Use in-app amp simulations"
  POSTs use_amp_sims; default unticked.

The desktop renderer consumes this setting to gate its saved-tone-chain
restore (feedback-desktop PR, paired).

Verified by booting core locally and walking the wizard with Playwright:
web shows 5 dots/no amp step, desktop shows 6 dots, the amp step is
reachable, calibration stays the final "Play it now" step, ticking the box
persists use_amp_sims=true, and there are no page errors. Server-side
GET default / POST validation / reset confirmed via curl.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:13:28 +02:00
Byron GamatosandGitHub 5a0b62599d feat(highway): show feedpak author/editor credits on song load (#629)
Surface the feedpak manifest `authors` list (spec §5.4) on the highway: a credits card ("Charted by Azure") shown over the highway when a song loads, riding the count-in / a ~3s hold and dismissed when playback starts. Gated to fresh feedpak plays only (minigames, loose/archive, arrangement switches, seeks, replays excluded). Includes a 12s backstop so the overlay never lingers if playback fails to start.

Closes #628. Reviewed by Codex (3 passes, converged). Verified locally: pytest 9/9, node --test 23/23, headless-browser end-to-end.
2026-06-28 22:08:44 +02:00
271fedda55 fix(input_setup): stop collapsing audio driver-type variants in the wizard (#627)
The onboarding audio picker de-duped the device list by display LABEL. On
Windows the engine enumerates one interface once per host API (ASIO /
Windows Audio / DirectSound) with the same name, so the variants collapsed
to a single choice — silently keeping whichever sorted first, often not the
low-latency ASIO one the player wants. It could also drop the variant that
was actually `selected`.

The audio-input capability already collapses true duplicates by
logicalSourceKey (_visibleInputSources), and these variants each have a
DISTINCT key, so the wizard's extra label-collapse was redundant for real
dupes and destructive for the variants. Removed it; the picker now lists
every selectable input.

Pairs with feedBack-desktop's change to tag each source label with its
driver type ("Focusrite (ASIO)" vs "(Windows Audio)") so the now-distinct
entries are legible.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:25:49 +02:00
90fb2ee3bc feat(v3): content-dependent playlist covers + custom art (#626)
* feat(v3): content-dependent playlist covers + custom art upload

Playlist cards were a tiny 🎵 emoji on an empty square. Now the cover reflects
the playlist's contents, and you can override it with a custom image.

Cover (in priority order):
- custom uploaded cover, else
- empty playlist  -> the icon
- a few songs     -> the first song's album art
- 4+ songs        -> a 2x2 album-art mosaic

Backend (server.py):
- MetadataDB.list_playlists() returns each playlist's first few still-present
  songs' art URLs (`art_urls`) for the content cover.
- GET /api/playlists and GET /api/playlists/{id} add `cover_url` when a custom
  cover exists.
- POST/GET/DELETE /api/playlists/{id}/cover — store a small PNG thumbnail under
  CONFIG_DIR/playlist_covers/ (PIL-converted, mirroring song-art upload); the
  cover is deleted with the playlist. Cover mutators added to _MUTATING_ROUTES.

Frontend (static/v3/playlists.js): playlistCoverHtml(p) renders the rules above;
the playlist detail view gets "Cover" (pick an image) + "Remove cover".

Tests: tests/test_playlists_api.py (art_urls + cover roundtrip / reject-non-image
/ delete-removes-cover — 11 pass) and tests/js/v3_playlist_cover.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(playlists): 400 (not 500) on non-string cover image + bust same-second cover cache

Two review follow-ups on the playlist-cover endpoints:

- POST /cover did `if "," in b64` before any type check, so a non-string
  image (e.g. {"image": 123} / null) raised TypeError -> 500. Guard with
  isinstance (mirrors the avatar/song-art upload) for a clean 400. +regression
  test covering number/null/object/list.

- The cover URL busted only on int(st_mtime) (1s granularity) and GET /cover
  sent no cache headers, so a same-second replace/remove/re-upload could serve
  a stale image. Use st_mtime_ns in the cache-bust token and add the shared
  no-cache header (_ART_CACHE_HEADERS), matching song art.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 14:10:15 +02:00
3d97c07b2b feat(v3): add "Add to playlist" to a song's ⋮ More menu (#625)
* feat(v3): add "Add to playlist" to a song's ⋮ More menu

You could only add a song to a playlist via select-mode (checkbox → batch bar).
Add an "Add to playlist" row to each song card's ⋮ overflow menu that targets
that one song, reusing the same picker (pick a listed number or type a new name
to create the playlist).

The select-mode batch flow and the single-song menu now share one extracted
`addFilenamesToPlaylist(filenames)` helper; the menu is `openCardMenu`, shared by
grid cards and tree rows, so both views get it. Tests:
tests/js/v3_add_to_playlist_menu.test.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(v3): don't clear the batch selection when the playlist picker is cancelled

The extract-helper refactor made batchAddToPlaylist() call finishBatch()
unconditionally, so cancelling (or a failed create) cleared the multi-select
and reloaded the grid — a regression from the original early-return-on-cancel
behaviour. addFilenamesToPlaylist() already returns null on cancel/failure;
gate finishBatch() on a truthy playlist id so the selection is preserved for
a retry. Adds a regression assertion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:58:14 +02:00
b103a722ce fix(v3): refresh Songs grid after a Settings rescan / DLC-folder change (#624)
Reported on macOS: on a fresh install, pointing at a DLC folder in Settings and
running a scan showed NO songs until an app restart. The scan itself was fine —
_background_scan re-reads config.json fresh, so it scans the new folder and
populates the library — but the v3 Songs grid never reloaded.

The Settings Rescan / Full Rescan handlers only refreshed the classic (v2)
library via loadLibrary(); the v3 grid (static/v3/songs.js) had no listener for
a scan it didn't initiate (only its own upload path self-refreshes via
watchUploadScan). So its cached, pre-DLC (empty) DOM/snapshot survived a sidebar
return until a full reload (restart).

Fix: the rescan handlers now emit `library:changed` (static/app.js). The v3 grid
listens and reloads if it's the active screen, else sets `_libraryDirty` so the
next onV3SongsScreenEnter does a full re-fetch — a short-circuit placed ahead of
every cached-DOM fast-path so it can't restore the stale grid.

Tests: tests/js/v3_library_refresh.test.js guards the emit + the reload/dirty
wiring (DOM/event glue isn't headlessly unit-testable; end-to-end wants an
in-app run of the reporter's flow: set DLC in Settings → scan → Songs populate
without restart).


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:52:48 +02:00
d841813e0b fix(library): Edit Metadata modal — editable Year + don't close on drag-release outside (#623)
* fix(library): Edit Metadata modal — editable Year + no close on drag-release

Two fixes to the Songs -> Edit Metadata modal (openEditModal/saveEditModal in
static/app.js), both reported on macOS for 0.3.0.

1) Year is now editable. A year can be set when authoring a pak but the modal
   had no Year field, so it could never be changed. The backend
   (POST /api/song/<f>/meta) already accepts + normalizes `year` and writes it
   into the file via songmeta (survives a rescan) -- only the UI omitted it.
   Add a Year input (populated from the song's current year) and include
   `year` in the save POST body. Both the v3 card menu and the legacy edit
   button already pass the year through, so both surfaces get the field.

2) The modal no longer closes when a click-drag is released on the backdrop.
   Selecting text inside a field and releasing the mouse past the modal edge
   dismissed the form without warning (the `click` event's target resolves to
   the backdrop, the common ancestor) -- discarding the edit. Backdrop
   dismissal now also requires the mousedown to have STARTED on the backdrop,
   tracked per-modal and decided by a new pure helper
   _editModalShouldClose(clickTarget, modalEl, downOnBackdrop). Cancel / X
   still close on a normal click.

Tests: tests/js/edit_metadata_modal.test.js extracts the real functions from
app.js and asserts (a) openEditModal renders #edit-year, (b) saveEditModal's
meta POST body carries `year`, and (c) the backdrop-close decision table
(Cancel always closes; backdrop needs down+up on the backdrop; a drag from a
field released on the backdrop does NOT close).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(library): wire Edit Metadata Save via listener, not an inline onclick

encodeURIComponent does not escape "'", so embedding the filename in the
single-quoted inline onclick="saveEditModal('…')" handler produced a
malformed handler for any song whose filename contains an apostrophe
(e.g. Bob's Song.sloppak) — clicking Save threw a syntax error and the
edit silently failed. Replace the inline onclick with a data-edit-save
hook wired in JS from the closure filename (mirrors the existing Delete
button pattern), so the filename never has to survive attribute-string
embedding. Pre-existing bug surfaced during review of this modal.

Adds a regression assertion (no inline saveEditModal onclick; Save wired
via data-edit-save).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 13:42:02 +02:00
a0f5435854 perf(highway): stop the adaptive renderScale from visibly hunting up/down (#622)
Alpha testers reported the 3D-highway "quality going up and down to try to
compensate" as passages got busier (#618 charrette). That's core's
load-adaptive render scale (_adaptRenderScale, #654) ping-ponging across the
7-12ms deadband: it downscales when a busy frame blows the budget, then the
now-cheaper frame dips under the low watermark so it upscales, which blows the
budget again — a visible resolution pop on a loop.

Fix: keep downscaling prompt (protect the frame rate), but make UPSCALING lazy
and predictive:
- smaller up-step (x1.06 vs x1.1) on a longer, separate cooldown
  (_AUTO_UPSCALE_COOLDOWN_MS = 2500ms vs the 600ms general adjust cooldown),
  reset on any downscale so we never bounce straight back up;
- a predictive guard: only upscale when the projected cost AFTER the step
  (~cost * step^2, since draw cost tracks pixel count) still clears the high
  budget. The scale settles just inside the deadband instead of oscillating.

No public API change; the user-facing "Min res" floor (_autoScaleMin) is
untouched. Pairs with the in-plugin AA-under-bloom fix in feedBack#618.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:05:34 +02:00
2a43d5b494 fix(diagnostics): rebuild diagnostic sloppak so song name reads "FeedBack", not "Slopsmith" (#621)
PR #586 renamed the bundled diagnostic to
docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak but only git-mv'd
the file -- it never regenerated the zip. So the manifest INSIDE still
carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith`
(and the same heading in DIAGNOSTIC.md), which is the name testers saw in
the library/player and the onboarding calibration step. Meanwhile the build
script, server `_BUILTIN_DIAGNOSTIC_SOURCES`, README, and docs all already
say "FeedBack Diagnostic — Basic Guitar" -- only the committed binary was
stale.

Regenerate the artifact from its own generator
(docs/diagnostics/build_diagnostic_basic_guitar.py) so the committed sloppak
matches the source of truth: title/artist/heading now "FeedBack"; the chart
(5 notes / 7 chords / 5 sections), the click-track stem, and the
`diagnostic:` metadata block are unchanged. Verified the rebuilt manifest
parses, carries a real U+2014 em-dash, and contains no "Slopsmith".

No code change -- the #586 rename just needed the rebuild it skipped.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:47:12 +02:00
ee7bafbb47 fix(v3): decode stats:recorded filename so post-play score badge refreshes (#620)
PR #574 added a `stats:recorded` -> in-place accuracy-badge repaint so a
just-earned score shows without restarting the app. But the repaint never
matched a card, so the badge stayed stale until a full render() (app
restart / search / re-enter the screen) -- exactly the "only updates after
a restart" report.

Root cause is a filename key-space mismatch. The event (like song:loading)
carries the filename `encodeURIComponent`'d, because that is what playCard
hands to playSong (the highway WS decodeURIComponent's it). Library cards,
though, key on the DECODED localFilename (data-fn), and /api/stats/best is
server-canonicalized to that same decoded key (server.py
_canonical_song_filename). So repaintAccuracy's `data-fn !== key` check
rejected every card and `state.accuracy[encoded]` was undefined.

Decode the event filename back into the card / state.accuracy key space via
a small `decFn` helper before marking dirty and repainting, fixing both the
immediate repaint and the onV3SongsScreenEnter deferred path. decFn is
idempotent for already-decoded names and falls back to the original on
malformed input, so a real filename containing a literal '%' is never
corrupted.

Tests: tests/js/v3_songs_score_badge_refresh.test.js extracts the real
decFn from the shipped source and proves the encoded event filename
round-trips to the raw card key (incl. spaces and subfolder '/').


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:39:13 +02:00
fef870047b fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)
* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.

Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.

Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.

Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.

Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.

Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* test(browser): suppress first-run onboarding in keyboard/resume/exit specs

The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).

Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.

Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay

Refinements from tester feedback on the exit-confirm (default stays OFF):

- Escape on the open prompt now = Stay (dismiss + return to the song), matching
  every other modal and the generic _confirmDialog (Esc=cancel). A second
  Escape therefore returns to the song instead of leaving it. Leaving stays the
  explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
  out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
  + _juceMode) so it isn't running/being scored behind the modal; Stay resumes
  exactly what we paused. Guards: cancel any count-in on open; resume only if we
  paused (wasPlaying), only if still the same live song on the player
  (_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.

Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.

NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load

Two review follow-ups on the Escape/resume/confirm work:

- Settings copy said "a second Escape (or Space/Enter) still leaves",
  but Escape dismisses the confirm (Stay) like every other modal — only
  Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
  matches the implementation (and the committed exit-confirm specs).

- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
  a transient load/connect failure permanently lost the Resume pill with
  no retry. Clear only after the load resolves; on failure keep the
  snapshot (and drop the pending in-memory resume) so the pill re-offers
  it on the next non-player screen.

All 16 Escape/resume/exit-confirm Playwright specs still pass.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 12:32:33 +02:00
290783b80b feat(highway_3d): hit-feedback juice + Hit-sparks toggle (#618)
* feat(highway_3d): hit-feedback juice — cinematic lighting, strike line, sparks, intensity dial

Charrette wave 1 (additive, default-tasteful, all behind settings):
- #8 Hit-feedback settings: hitFx (0..1), cinematic, verdictMarks, timingFx,
  streakFx in BG_DEFAULTS + h3dBgSet* setters + settings.html (intensity slider +
  cinematic toggle). hitFx=0 → colour verdict only.
- #2 Cinematic lighting: ambient 0.85→0.35 + stronger key light when cinematic on,
  so emissive gems have a dark surround to pop against. Live-toggleable.
- #1 Strike line: a glowing bar at the hit line (Z=0) that flashes green on a
  verified hit / red on a miss, eased from the per-frame verdict alpha.
- #3 Hit sparks: a pooled additive Points burst at the gem on a verified hit
  (deduped one burst per note), scaled by hitFx; disposed on teardown.

Staged for wave 2 (after dogfooding): bloom+ACES (#4), colorblind verdict glyphs
(#6), early/late timing tint (#5), streak heat + clean-bar (#7), gem scale-punch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): wave 2 — gem scale-punch, streak heat, colorblind verdict marks

- #3 (completion) gem scale-punch: the hit gem briefly grows (1 + 0.22·hitFx·alpha),
  biggest at the strike and easing with the verdict — the per-gem impulse.
- #7 streak heat: a renderer-side consecutive-hit counter eases a 0..1 "heat"
  (plateau at 16) that grows the spark burst + warms the strike-line idle glow;
  a miss eases it back down. Behind the Streak-feedback toggle.
- #6 colorblind verdict marks: a redundant ✓ (hit) / ✗ (miss) glyph on the verdict
  via the existing 2D label overlay, so the green/red pair isn't the only signal —
  notably also covers the provider path (where the timing labels don't show).
- settings.html: Streak-feedback + Accessible-marks toggles.

Deferred: #4 bloom+ACES (needs the Three.js postprocessing addons vendored into
core static/vendor/three/ — not present; warrants its own infra change), and #5's
timing tint (the early/late ±ms labels already render on the event path; surfacing
them on the provider path needs a notedetect verdict field — a cross-plugin item).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): #4 bloom + ACES — vendored Three.js postprocessing, perf-gated

The single biggest fidelity lever from the charrette. Core had only
three.module.min.js (no postprocessing addons), so this vendors the r170
EffectComposer/RenderPass/UnrealBloomPass/OutputPass + their shader deps into
static/vendor/three/addons/, with every `from 'three'` rewritten to the SAME
vendored three (../../three.module.min.js) so the addons share the plugin's
three instance (a CDN copy would be a second, non-interoperable module).

highway_3d wiring:
- Lazy-loads the addons only when the new `bloom` setting is on (dynamic import),
  builds EffectComposer(RenderPass → UnrealBloomPass(strength .65/radius .5/
  threshold .82 — high so only emissive gems + the hit flash bloom) → OutputPass).
- Render loop uses composer.render() with ACES tone-mapping when bloom is active,
  else the unchanged direct ren.render() with NoToneMapping (bloom-off = today's look).
- Perf-gated: OFF in splitscreen; graceful fallback to direct render if the modules
  or composer fail; composer.setSize on canvas resize; disposed on teardown.
- settings.html: "Glow bloom" toggle (default on).

Verified the import chain resolves + renders via a same-origin module-load test
(EffectComposer built + a bloom frame rendered, three r170).

Charrette status: 7/8 (only #5's early/late timing tint remains — a notedetect
verdict-field change, outside the highway).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): #5 early/late timing — colour the hit feedback by timing

Surfaces the detector's timing on every hit (the charrette's last item), fully
highway-side: notedetect already dispatches the judgment (timingState/timingError)
on notedetect:hit/miss, so we carry timingState onto the event mark and tint the
hit's spark burst + the ✓ verdict glyph by it — on-time green, early cyan, late
amber. Gracefully falls back to green when no timing is known (pure-provider path),
so it never invents data. Behind the new "Timing feedback" toggle (default on).

Charrette: 8/8 complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* feat(highway_3d): add a "Hit sparks" on/off toggle (note-hit particles)

The on-hit spark burst (the particle effect that fires the instant
note_detect confirms a hit) could previously only be removed by dragging
Hit-feedback intensity to 0 — which also kills the strike-line flash and
the scale-punch. Add a dedicated "Hit sparks" toggle (default on) under
3D Highway settings, in the hit-feedback group beside the intensity
slider, that gates ONLY the spark particles; the strike flash and colour
verdict are unaffected.

Wired the same way as the sibling juice toggles: a `sparks` boolean in
BG_DEFAULTS, in _BG_BOOL_KEYS, a window.h3dBgSetSparks setter, the
per-instance _sparks state + settings re-read, and a guard on the
_sparkBurst spawn. Reuses existing Tailwind utility classes, so
assets/plugin.css is unchanged; plugin.json version bumped to 3.28.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): act on tester charrette — strike line, fog readability, AA

Addresses the alpha-tester 3D-highway feedback thread via the design panel's
recommendations:

- Strike line (panel rec 1a): now a HIT-ONLY faint "now" line — flashes green
  on a confirmed hit, no red miss branch (misses already show at the gem: red
  wash + ✗). Moved off the bottom edge to the vertical CENTRE of the string
  field, which was the "incorrectly placed" complaint (it read as the board's
  lower border and fused with open-string gems on a miss). Added a "Strike
  line" on/off toggle (`strikeLine`, default on).

- Horizon readability (#2): the note gems + their outlines are now fog-exempt
  (`material.fog = false` on mStr/mGlow/mStrHitOutline/mHitBright/mWhiteOutline/
  mMissOutline), so upcoming notes punch through the distance fog and stay
  legible as they render in — the board, lane, sustains and scenery keep their
  atmospheric fog, so depth is preserved.

- Cinematic lighting softened: cinematic ambient 0.35 -> 0.45 so the dark stage
  doesn't crush note/fret legibility.

- Anti-aliasing under bloom (perf rec): give the bloom EffectComposer a
  multisampled (WebGL2 MSAA x4) HalfFloat render target. The default target had
  no `samples`, so bloom-on bypassed MSAA — the "too HD / jagged on Windows,
  fine on Mac" report (Mac only won via Retina supersampling). This is the
  highest-value, smallest fix for the jaggies.

plugin.json -> 3.29.0. The renderScale quality-oscillation is core
(static/highway.js) and will be a separate feedBack PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(highway_3d): remove the strike line; sparks-only hit feedback, subtler

Second tester-charrette pass. The strike line (even hit-only/centred from the
last pass) was still too distracting/confusing on a hit, so it's removed
entirely — strings + fret markers already orient the player, and the hit is
fully carried at the gem (bright outline + scale-punch + spark burst) with the
timing-coloured ✓/✗ verdict as the knowledge-of-results channel.

- Deleted the strike-line mesh, its per-frame update, the `strikeLine` setting
  (BG_DEFAULTS / _BG_BOOL_KEYS / setter / settings-load), the settings.html
  toggle, and the now-dead `_strikeLine`/`_ndHitFlash`/`_ndMissFlash` state +
  their verdict-block feeds.
- Made the spark burst subtler now that it's the sole celebration: point size
  1.7→1.0·K, opacity 0.95→0.8, burst count (7+13·hitFx)→(4+7·hitFx), radial
  speed (7+r·20)→(5+r·12)·K, life (0.40+r·0.28)→(0.30+r·0.16)s.
- Toggles for Hit sparks and the ✓/✗ verdict marks already exist in settings
  (kept).

Minimal hit-feedback set now: gem bright + subtle spark (celebration) +
timing-coloured ✓/✗ (the KR) + ambient streak heat. plugin.json -> 3.30.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(highway_3d): hydrate hit-feedback settings controls from saved state

The 7 new juice controls (Hit sparks, Cinematic, Streak, Verdict marks,
Bloom, Timing, Hit-feedback intensity) were hard-coded to their default
markup and never read back from localStorage when the settings panel
reopened — so a saved non-default (e.g. Hit sparks off) showed as the
default (checked) even though the renderer correctly honored it. The
sibling controls in the same panel were already hydrated; this restores
that pattern for the new ones.

Reads h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' coercion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-28 11:53:48 +02:00
b206633131 fix(v3): keep Section Map's leftmost section clickable under the rail catcher (#617)
The section_map plugin pins a ~20px clickable bar (#section-map, z-index:5)
to the top of #player. The v3 left-rail hover-catcher (.v3-railzone::before)
is full-height at z-index:30 with pointer-events:auto, so its top-left
corner swallowed every click on the section map's first section — the
left-most section was never clickable on the v3 desktop (macOS/Windows) UI.

Drop the catcher below the 20px bar when the section map is present,
mirroring the existing #section-map ~ #player-hud special-case in
static/style.css. The rail still reveals from anywhere below the bar.

Adds a Playwright regression test (hit-test of the top-left corner) with a
negative control that re-raises the catcher to reproduce the bug.

Fixes #616

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:07:44 +02:00
a57d0e3f85 fix(v3): replace broken window.prompt() in Playlists with in-app uiPrompt modal (#614)
window.prompt() is a silent no-op in the Electron desktop shell, so the
Playlists "New Playlist" and "Rename" buttons and the library's bulk
"add selected songs to a playlist" action did nothing. Route all three
through the existing window.uiPrompt() modal (resolves to the string, or
null on cancel; the handlers were already async). window.confirm() works
in Electron and is left as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 20:35:30 +02:00
4480ac2732 feat(v3): promote Audio Engine to a first-class sidebar entry (after Settings) (#613)
The desktop Audio Engine plugin (input device selection, VST hosting, pitch
detection, and the new config Reset/repair UI) was reachable only via the
generic Plugins gallery — per-plugin manifest nav entries aren't surfaced in
the v3 sidebar unless the plugin is promoted. Add it to PROMOTED_PLUGINS
anchored after Settings, plus the matching NAV registry entry so the slot
resolves its label/screen. Desktop-only by construction: the slot is filled
only when /api/plugins reports audio_engine installed, so the web app shows
no dead entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:16:36 +02:00
3b2d83d406 feat(folder_library): Folder Library core plugin (#610)
Adds the bundled Folder Library plugin (browse the DLC library by its on-disk
folder tree, in-app folder CRUD, drag-and-drop + dialog song moves, sort/filter,
live search), wired into the classic v2 toolbar and the v3 Songs page.

Includes the screen.js IIFE dedup (unified surface factory) and review fixes:
path-traversal guard on /song/move, folder-delete data-loss fix, plural
/api/plugins/<id> namespace, loose-folder song recognition, error-text escaping,
v3 setLibView null-guard, and tests.

Co-authored-by: Kyle <kyle.j.t@live.co.uk>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:03:54 +02:00
d1f7f12293 fix(v3): add "Show 'Up Next'" toggle so the player pill can be turned off (#612)
The v0.3.0 player chrome's persistent upcoming-section pill (#v3-upnext,
drawn by static/v3/player-chrome.js's updateUpNext) shipped with no off
switch: it always showed during playback whenever a section was upcoming,
overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up
Next' section card" checkbox (a different, in-canvas widget demoted to
default-off precisely because this pill is the canonical readout). Users
reading the pill as that same setting saw "disabled in settings but still
there."

Add a real core toggle, following the autoplayExit idiom:
- static/app.js: client-only `showUpNext` localStorage pref (absence =
  enabled), _showUpNextEnabled()/setShowUpNext(), loadSettings()
  hydration, and a read-only window.feedBack.showUpNext getter. Disabling
  mid-playback hides the pill immediately.
- static/v3/index.html: a "Show 'Up Next'" switch in the Gameplay tab.
- static/v3/player-chrome.js: gate updateUpNext() on the pref.
- static/v3/settings.js: add showUpNext to RESET_MAP.gameplay.local.

Default ON, so behaviour is unchanged for existing users. v3-only (the
pill is v3 core chrome); no Tailwind rebuild.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:31 +02:00
6dbcc5861b fix(player): keep play/pause button in sync when a JUCE reroute aborts autoplay's play() (#611)
On the first song after a fresh load on desktop, the audio engine is often
still starting when the song loads, so the song begins on the HTML5 <audio>
element and the engine-reroute watcher then migrates it to the JUCE backing
transport. The reroute's first step is a deliberate audio.pause(), which
rejects autoplay's in-flight togglePlay() audio.play() with an AbortError —
even though playback continues on JUCE.

togglePlay()'s catch then reset isPlaying=false and the button to "Play"
while the song kept playing: the button showed Play during playback, so it
took two clicks to actually pause (one to resync the flag, one to pause).
The reroute already guards the <audio> 'play'/'pause' DOM listeners with
window._juceRerouteInProgress; this extends the same guard to togglePlay()'s
catch and the count-in catch, so a play() rejection caused by the reroute's
own pause doesn't clobber the button. A genuine failure (outside a reroute)
still resets correctly.

Adds a regression test that drives togglePlay() through a reroute-aborted
play() and asserts the button stays Pause; it fails without the guard.


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

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:16:28 +02:00
8f0625e1f7 fix(v3): reset live performance HUD on backward seek / restart (#607)
The v3 live performance HUD (the visible top-right score tracker) keeps
its own hits/misses/streak counters from note:hit / note:miss events and
only reset them on song load / stop / ended — not on a seek. So pressing
Restart (or scrubbing back), which only repositions the playhead and
emits song:seek, left the tracker showing the stale cumulative score
(tester report).

Mirror the notedetect HUD fix: keep a per-note {t,hit} ledger (note:hit/
note:miss carry the judgment incl. noteTime) and, on a BACKWARD song:seek,
rebuild the tally to reflect only the notes up to the new playhead
(Restart -> "Waiting for notes" / 0). Forward seeks keep earlier notes;
loop-wrap (drill mode) is skipped so a practiced A-B loop still
accumulates, matching the notedetect HUD.

Tests: +3 in tests/js/live_performance_hud.test.js (backward rebuild,
restart-to-0, forward no-op, loop-wrap ignored). Existing 10 still pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:47:43 +02:00
c8e0ad3f75 fix(gp-import): correct bass string count, lead/rhythm roles, preview note count (#601)
Four tester-reported GP-import issues, all in the converter/parse layer:

* String count (bugs 2 & 4): <tuning> is padded to 6 slots, so a 4-string
  bass, 5-string bass and 6-string guitar were byte-identical and the real
  count was lost — a 5-string bass played on 4 strings and a 4-string bass
  showed a phantom B in the editor. Record the authoritative count in a new
  <tuning stringCount=N> attribute (gp2rs._build_xml) and trim the padded
  tail back to it on read (song.parse_arrangement). All consumers already
  trust a non-6 tuning length (arrangement_string_count, the editor's
  _stringCountFor and build-time _normalize_tuning_to_count), so this fixes
  the create-mode preview AND the built sloppak with no consumer changes.

* Lead/Rhythm reversed (bug 3): guitar arrangements were named by appearance
  order (first guitar -> Lead), swapping roles for files that list Rhythm
  before Lead. Honor 'lead'/'rhythm' in the GP track name; unhinted tracks
  keep positional fallback. Applied to both convert_file's fallback (the
  editor's track_indices-without-names path) and _auto_select_gpx, with
  cross-role dedup so name-based and positional labels can't collide.

* Preview note count (bug 1): the importer's per-track count included
  tie-continuation notes, which are folded into the previous note's sustain
  and never become separate RS notes (260 shown vs 241 imported). Exclude
  tie destinations so the preview matches the imported result.

Adds regression tests for all three. Bug 5 (no stems from synced audio) is
environment-dependent (best-effort demucs backend) and not addressed here.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:58:35 +02:00
13bbfc0b3d refactor(highway_3d): move Butterchurn controls into settings.html (#600)
Addresses the altitude finding from the Butterchurn review: the visualizer's
on/off + slider options shipped as a parallel UI (a ~140-line floating
in-canvas control panel) separate from the plugin's standard settings panel.

Move the standard controls (Background on, opacity, dim-behind-lane + strength,
chart accents + strength, color tint + strength, guitar gain, song gain) into
settings.html, using the plugin's normal settings UI. They persist into the
same 'viz3d_settings' blob the controller already reads; a new module-scope
window.h3dBcApplySettings() hook lets settings.html push changes to a mounted
highway live (it invalidates the controller's settings cache and re-applies).

The in-canvas panel is now ONLY the live preset browser (pick / favorite /
ban / cycle / hold / meters) — things that are inherently live tools and don't
belong in a static settings form. cyclePool/hold and the favorites/bans lists
stay there; reads were made cache-safe (read fresh via _bcLoadSettings) so a
settings.html write can't be clobbered by a stale captured reference.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:09:16 +02:00
6fa5aabba8 fix(highway_3d): re-home Butterchurn panel to a surviving highway (splitscreen) (#599)
The Butterchurn control panel is a singleton, created only when a controller
is created and parented to that controller's wrap. In splitscreen the panel
followed the last-created controller; when that controller was torn down,
destroy() only removed the panel DOM if it was the LAST controller, so with
another highway still alive the panel stayed orphaned on the destroyed wrap
and the surviving highway was left with no visualizer controls.

Track each controller's wrap (ctrl.wrap) and, on destroy with another
controller still alive, re-home the panel+pane onto the surviving primary's
wrap via _bcEnsurePanel (which moves them when connected, or rebuilds them on
the survivor if the old wrap was already detached).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:08:13 +02:00
3bb55ab854 feat(highway_3d): Butterchurn visualizer background style (#598)
* feat(highway_3d): add Butterchurn visualizer background style

Adds an opt-in "Butterchurn (visualizer)" option to the 3D Highway plugin's
Background-style dropdown. When selected, the highway renders over a WebGL
MilkDrop (Butterchurn) canvas that reacts to your playing (guitar input on
desktop, the song <audio> spectrum in the browser) and the chart (beat/note/
chord accents + instrument-color tint). The default stays 'particles', so
existing users see no change until they pick it.

Integrates the standalone "3D Highway + Butterchurn" mod into the bundled
renderer as the 'butterchurn' bg-style (not a fork):
- a self-contained _bc* controller that lazy-loads the vendored butterchurn
  libs only when the style is selected; mount/unmount is driven idempotently
  by the existing bg-style lifecycle (_bcSyncMode in _bgMountStyle) plus an
  explicit teardown in destroy()
- the renderer uses alpha:true with the transparent clear gated on the mode,
  so every other bg style stays byte-identical (opaque clear)
- the fog-scenery <audio> tap is disabled while active to avoid a double
  createMediaElementSource on #audio
- the mod's slopsmith* globals are adapted to the current feedBack* names and
  the vendored asset URLs repointed to /api/plugins/highway_3d/assets/

Vendors butterchurn.min.js + butterchurnPresets.min.js (MIT) + viz-worklet.js
under assets/vendor/; see plugins/highway_3d/NOTICE for attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): anchor Butterchurn panel to the highway, centered

Addresses three issues found testing the visualizer control panel:
- Attach the panel + preset pane to the 3D highway's `wrap` (position:absolute,
  pointer-events:auto) instead of position:fixed on document.body, so they sit
  on the highway's right edge and only exist while the highway is on-screen
  (no longer linger on the main menu / float at the app edge).
- Re-home the singleton panel to the active highway wrap on mount, so it follows
  whichever highway is showing (e.g. moves off Virtuoso's embedded highway onto a
  normal song's highway) instead of sticking to the first one created.
- Center it vertically (top:50% + translateY(-50%), folded into the slide
  transform) so a top overlay element no longer covers it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): harden Butterchurn audio + lifecycle (review #597)

Browser audio reactivity now REUSES the highway's existing shared analyser
(the fog scenery's #audio / stems side-chain tap) instead of opening a
second createMediaElementSource on #audio. The old _bcBrowserSource path:
  - threw InvalidStateError when the fog tap already owned #audio (default
    config), leaving the visualizer non-reactive in the browser, and could
    permanently disable fog reactivity if it tapped first (one-shot/element);
  - rerouted the song through a fresh, possibly-suspended AudioContext, which
    could MUTE playback when butterchurn was selected mid-song;
  - ignored the stems analyser, so it saw only silence on sloppak songs.
_bcCreateController now takes an audioProvider (wired to _bgGetAnalyser) and
connectAudio()s the shared AnalyserNode (a passthrough, so the fog's own
reads are undisturbed).

Also:
- destroy() now closes the AudioContext when we own it (desktop / browser
  fallback), fixing a per-mount leak that hit the browser ~6-context cap
  after a few style toggles. The shared (fog-owned) context is never closed.
- _bgApplyVenueSceneFog keeps the clear transparent while butterchurn is
  active, so the venue scene no longer occludes the visualizer.
- _bcLoadLib no longer caches a rejected promise, so a transient vendor-load
  failure can be retried instead of disabling the feature for the session.

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

* fix(highway_3d): Butterchurn lifecycle + audio re-bind (Codex preflight)

Local Codex preflight on the Butterchurn feature flagged four issues; all fixed:

- WebGL context leak on teardown: destroy() (and the async-init failure path)
  now call _bcReleaseCanvasGL() to force WEBGL_lose_context before dropping the
  canvas, so repeated mount/toggle cycles can't exhaust the browser's WebGL
  context cap.
- Stale shared analyser across songs: the browser path captured the analyser
  once at mount, so a sloppak stems swap (new analyser, often new context) left
  the visualizer reacting to a dead node. update() now compares the live
  _bgGetAnalyser() against what the controller actually bound (boundAnalyser(),
  guarded by ready()) and either reconnects (same context) or rebuilds the
  controller (context changed) via the proven destroy()+_bcSyncMode paths.
- Half-mounted controller on createVisualizer failure: the async .catch now
  cleans up (closes an owned AudioContext, removes layers, marks dead) and
  _bcSyncMode retries when bcCtrl.dead(), instead of leaking and never recovering.
- _bcFfIdx off-by-one dropped accents landing exactly on a seek/loop target
  time; it now uses strict < so the update walkers fire the boundary event.

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

---------

Co-authored-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:07:29 +02:00
97dae88860 feat(highway_3d): colour theming — string presets + Background/Highway scene themes (#596)
* feat(highway_3d): add one-click string-color presets

Adds 12 named string-color presets (Warm→Cool, Vivid, Colorblind-friendly,
Neon, Accessible, Warm Ember, Tape Deck, CRT Green/Amber, Pitch Ramp, Sunrise)
selectable from the 3D Highway settings panel.

Extends the existing core HWC (highway-color) subsystem in static/app.js with
HWC_PRESETS + applyHighwayStringPreset(), exposed on the existing facade as
window.feedBack.highwayColors.{presets, applyPreset}. The plugin settings page
renders the preset buttons from that core list and refreshes the per-string
pickers on apply. Purely additive — stock behavior is unchanged.

Scope: core static/app.js (the shared HWC facade both highways consume) plus the
highway_3d plugin's settings.html / screen.js / CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Q9BpGYqUaga9ZJyS3dDPq

* fix(highway_3d): address review of colour-theming PR

- Rebuild assets/plugin.css so the new `flex-wrap` (preset row) and
  `text-[10px]` (theme-dropdown helper) Tailwind classes are actually
  compiled, and bump plugin.json 3.26.0 -> 3.27.0 so the <link>'s ?v=
  cache-buster fetches the fresh CSS (per the plugin's build rule).
- Replace the mirror-at-every-read hwTheme migration with a one-time
  backfill (persist hwTheme := bgTheme on first load, no emit). The two
  scene-color axes are now genuinely independent: changing the Background
  dropdown no longer silently retints the Highway surface/lane, and the
  rendered highway can't disagree with the Highway dropdown value.
- Collapse the duplicated theme id-set in settings.html (two identical
  <option> lists + VALID_BG_THEMES) into a single SCENE_THEMES source the
  dropdowns and validator are generated from; sync points 4 -> 2.
- Update CLAUDE.md to document the backfill + reduced sync contract.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-26 11:05:31 +02:00
b70fde9b02 fix(player): new song no longer seeks to previous song's stop position (#595)
audio.currentTime does not reset synchronously when audio.src is cleared
— it only resets when audio.load() is called (later, in highway.js).
The jump-fix guard (setInterval ~line 8979) held lastAudioTime at the
old position and, once the new song started playing from t=0, saw a 30s+
jump and sought the new song to the previous position. If the new song
was shorter, song:ended fired immediately, showing the score screen.

Reset lastAudioTime = 0 in playSong() so the guard has no stale anchor.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 10:05:52 +02:00
4c3ec2ff66 feat(plugins): full-screen (immersive) plugin screens via manifest opt-in (#590)
DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a
scrolling content page below the v3 topbar — embedded in the shell they get
cut off at the bottom with excess padding up top.

Add an opt-in top-level `"fullscreen": true` plugin.json field, surfaced as the
`fullscreen` boolean on /api/plugins (mirrors the settings_category plumbing in
plugins/__init__.py). When a fullscreen plugin's screen is active, static/v3/
shell.js toggles `html.fb-immersive` from syncActive() so it tracks every
navigation incl. deep-link; static/v3/v3.css then hides the topbar, collapses
the sidebar to a functional icon rail (kept reachable — Escape is bound only on
player/settings scopes, so a fully hidden sidebar would trap the user), and
lets the active plugin screen fill #v3-main. Mirrors the existing
ss-follower-pre chrome-hide pattern. Additive + opt-in: plugins without the
flag are unaffected.

Test: tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest


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

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-25 00:02:16 +02:00
8b4c9b0050 Fix list/tree view: select mode, parts visibility, song actions (#585)
* Fix list/tree view: select mode, parts visibility, song actions

Bring the v3 list/tree view to parity with the grid card:
- Select mode now renders a per-row checkbox + selected-ring, preserves
  expanded artist groups across re-render, and a capture-phase guard
  makes a row/chip click select the song instead of starting playback.
- Always-on favourite / save-for-later / overflow-menu cluster on each
  row, same actions as the grid card.

Rebuild static/tailwind.min.css so the new utilities are compiled in -
notably .sm:flex behind the arrangement chips' "hidden sm:flex" wrapper.
Without it the chips (and #582's badges) render display:none on the
Docker build, which serves the committed CSS; the desktop build looked
fine only because it rebuilds Tailwind from source at bundle time.

Signed-off-by: Sin <deathlysin@outlook.com>

* fix(v3): regenerate tailwind.min.css from source + add tree select tests + CHANGELOG

The committed tailwind.min.css was over-built: 135,578 bytes / 1,428
selectors, with 294 selectors (accent-amber-400, bg-cyan-500,
animate-spin, after:bg-gray-400, …) used in zero core source files —
bloat from a local build scanning outside the repo's content globs. It
would fail CI's rebuild-and-diff and violates the byte-stable rule in
scripts/build-tailwind.sh.

Regenerate via `scripts/build-tailwind.sh` (pinned tailwindcss@3.4.19):
111,491 bytes / 1,134 selectors, byte-identical to a clean rebuild,
still containing the .sm\:flex fix plus every new tree class
(ring-fb-primary, accent-fb-primary, pointer-events-none, …). Docker
chips now render and CI stays green.

Add tests/browser/v3-tree-select.spec.ts:
- select mode keeps expanded artist groups open across the tree
  re-render (fails without loadTree's openArtists capture/restore)
- clicking a row in select mode selects instead of playing

Record the fix under CHANGELOG [Unreleased] -> Fixed.

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

---------

Signed-off-by: Sin <deathlysin@outlook.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:49:02 +02:00
82db8e56b1 chore(hotkeys): remove sloppak-convert library hotkey (#594)
* chore(hotkeys): remove sloppak-convert library hotkey

Removes the 'c' keyboard shortcut for converting library entries to
.sloppak. The shortcut was defined in two places:

- The no-op registerShortcut() entry that only existed to show in the
  ? help panel (the Sloppak Converter plugin handles conversion and
  can register its own shortcut via window.registerShortcut).
- The c dispatch in the library-entry keydown handler
  ({ c: 'button.sloppak-convert-btn', ... }) that triggered the
  plugin button.

* test+docs: update tests & CHANGELOG for removed `c` convert hotkey

The previous commit removed the `c` library hotkey but left three
assertions in tests/browser/keyboard-shortcuts.spec.ts that require it,
which fail deterministically (the two registry tests read window._panels
directly, independent of environment):
- should list all registered shortcuts (required {key:'c',scope:'library'})
- should have correct shortcut scopes (expected library::c)
- should show library shortcuts in help modal (Convert library entry / c)

Drop those assertions and record the removal under CHANGELOG
[Unreleased] -> Removed.

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

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:24:31 +02:00
64801d5735 fix(player): Space bar play/pause when focus is on sidebar or rail buttons (#593)
* fix(player): Space bar play/pause when focus is on sidebar or rail buttons

When any <button> in the player rail (viz, audio, mixer, etc.), a sidebar
nav link, or a popover control has keyboard focus, pressing Space was
blocked by _shortcutDispatchBlocked → _isInsideInteractiveControl, which
returns true for BUTTON elements. The Space shortcut never reached the
shortcut dispatcher and togglePlay() was never called.

The fix extends the same carve-out pattern already used for the section
practice bar: when the player screen is active, Space is always dispatched
through the shortcut system. The shortcut handler's preventDefault() stops
the focused element from also activating, so this is not a double-trigger.

* test(player): cover Space play/pause carve-out + add CHANGELOG entry

Adds two Playwright regression tests for #593 in
tests/browser/keyboard-shortcuts.spec.ts:
- Space toggles play/pause when a player rail <button> has focus, and
  the focused button does NOT also activate (dispatcher preventDefault).
  Fails on base (Space blocked, played=0), passes with the carve-out.
- Space in a player-screen text input still types a space and never
  reaches play/pause (locks the _isTextInput exemption ordering).

Also records the fix under CHANGELOG [Unreleased] -> Fixed, per the
project workflow that every PR updates the changelog.

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

* fix(player): don't override Space inside modal dialogs over the player

The player-screen Space carve-out keyed off the active *screen*, so it
also hijacked Space inside a true modal dialog layered over the player
(e.g. the keyboard-shortcuts help modal, edit modal): Space toggled
playback behind the modal and preventDefault blocked the modal's focused
control (Close) from activating — contradicting aria-modal semantics.

Narrow the carve-out to skip focus inside a modal
(role="dialog" aria-modal="true" or .feedBack-modal). Non-modal player
popovers/toasts (loop A/B, arrangement pin, role=dialog aria-modal=false)
are not dialogs and stay covered, so the original fix is unchanged for
the cases it targeted. Adds a Playwright regression test (Space inside a
modal reaches the modal's button, not play/pause) and updates the
CHANGELOG entry.

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

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:12:55 +02:00
d2569cc2a8 feat(achievements): wall sync drain worker + review fixes (epic PR3) (#592)
* feat(achievements): wall sync drain worker (epic PR3, client side)

Background dead-letter worker that POSTs queued Feat unlocks/removals to the
hosted feedback-achievements wall. Idle unless FEEDBACK_ACHIEVEMENTS_WALL_URL
is set; uses requests + the client-token header (mirrors lyrics_transcribe).

Dead-letter, never drop (pure engine.drain_decision):
  network err / 429 / 5xx -> keep pending (retry)
  other 4xx               -> dead_letter (diagnosable, replayable)
  2xx                     -> delete on server ack
remove-me enqueues a wall removal keyed by the reused player_hash.

Verified by an end-to-end staging round-trip (earn a Feat -> drains onto the
wall with name + short hash -> remove-me -> wall empties) with no IP in tables
or access logs. 42 plugin tests pass (test_sync.py adds the decision table +
ack/retry/dead-letter retention + four-field on-the-wire payload).

The hosted service lives in the new feedback-achievements repo.

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

* fix(achievements): address local review findings (epic)

Bugs caught in the pre-merge review loop:

- secret_witching Feat was DEAD: post_activity wrote witching_nights_run to the
  DB before snapshotting prev_tiers, so diff_unlocks never saw the fresh unlock.
  Fold the run into the activity delta instead (same asymmetry chart_encore
  uses) so the 7th-night unlock is detected. +regression tests.
- chart_encore broke across restarts: per-chart counter keyed on abs(hash(str)),
  which Python salts per-process (PYTHONHASHSEED). Use a stable sha1 digest so
  the same chart accumulates across sessions. +regression test.
- Bounded the per-activity counter read: _read_counters no longer pulls the
  unbounded chart_plays:* rows (they're bumped/read individually).
- screen.js: gate note:hit/miss on an active-song flag so tuner/calibration note
  events can't inflate Feats or flush a phantom chart:null session.
- screen.js: P-III — prefix the plugin localStorage key (achievements:profile-cat).
- screen.js: extract the duplicated local-ISO-date helper.

45 plugin tests pass (3 new). Wall-side review fixes are in the
feedback-achievements repo.

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

* feat(achievements): default the drain worker to the hosted wall

Point FEEDBACK_ACHIEVEMENTS_WALL_URL's default at the live got-feedback wall
(https://feedback-achievements.onrender.com) so the drain worker targets it out
of the box; still env-overridable for self-hosting/staging. Nothing publishes
unless the user opted in AND has a profile identity, so a default URL alone
sends nothing.

Tests disable the default (autouse fixture) so no test ever POSTs to production;
drain logic is covered via _drain_once() with an injected poster. 45 pass.

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-06-24 17:01:48 +02:00
287c23a532 feat(achievements): opt-in, privacy controls & data-min gate (epic PR2) (#591)
Sharing earned Feats on the (forthcoming) public wall is strictly opt-in,
default OFF, with a binding data-minimization contract.

- Onboarding (static/v3/profile.js): a new opt-in step (now a 5-step wizard)
  after song-directory / before paths — publishes only display name + earned
  Feats, never songs/skills/scores; off by default.
- Settings (plugins/achievements/settings.html, System tab via
  settings.category): the same toggle + a "Remove me from the wall" button
  (POST remove-me — wipes local synced state offline + enqueues removal).
- Core (server.py): achievements_enabled (bool, default false) in
  _default_settings + /api/settings validation + _RESETTABLE_SETTINGS_KEYS;
  mirrored to localStorage in app.js loadSettings().
- Data-minimization gate: engine.build_wall_payload is the single explicit-dict
  serializer; key-set is EXACTLY {display_name, player_hash, achievement_id,
  unlocked_at}, achievement_id always a Feat id. Enqueue is gated on
  opted-in AND profile identity (reused player_hash); competency never
  enqueues (integration law).

Verified natively: settings round-trip + validation + remove-me; opted-in
activity enqueues exactly one 4-field Feat payload; Playwright confirms the
5-step wizard + opt-in card (default unchecked), zero console errors.
29 plugin tests + new settings tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:00:30 +02:00
05dd3d227a feat(achievements): local engine + tabbed Profile shell (epic PR1) (#587)
Adds the Achievements & Feats of Power local engine, fully offline.

Core (static/v3/profile.js): the Profile screen becomes tabbed exactly
like v3 Settings (.fb-tabbar/.fb-tab/.fb-tabpanel, active tab persisted in
localStorage 'v3-profile-tab'). A Profile (main) tab carries the existing
cards + a Feats trophy-shelf mount (#v3-profile-feats-slot, earned-only),
and an Achievements tab carries a plugin mount
(#v3-profile-achievements-mount) + empty-state note. A new
`v3:profile-rendered` event fires after every render so the plugin
re-injects (mirrors v3:settings-rendered).

New bundled plugin (plugins/achievements/): SQLite engine
(unlocks/counters/comp_ledger/sync_queue) with pure threshold/criterion
math in the testable sibling engine.py (P-V); routes activity/
report-unlock/report-criterion/catalog/earned/feats/remove-me. Feats read
activity counters only (batched song:ended POST; notes only when notedetect
present — graceful degradation); competency Achievements evaluate from
progression events only — the integration law, never crossed. Catalogue is
always shown (locked=greyed), grouped by the real progression paths
(Global/Guitar/Bass/Drums/Keys, auto-extending) with per-category earned
badges. Versioned window.feedBack.achievements registration API with the
__feedBackAchievementsPending load-order queue + achievements:ready event.

Verified natively (uvicorn) end-to-end + Playwright (tabbar, earned-only
Feats shelf, greyed catalogue, registration API, zero console errors);
24 plugin tests pass incl. the integration-law assertion.

Opt-in/privacy/data-min gate (PR2) and the hosted wall (PR3) follow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 16:57:37 +02:00
873ee3d5f2 fix(onboarding): rename diagnostic sloppak to feedBack- so "Play it now" finds it (#586)
The slopsmith→feedBack rename updated the diagnostic constant in server.py
(_BUILTIN_DIAGNOSTIC_SOURCES), the build script, README, and the calibration
test to `feedBack-diagnostic-basic-guitar.sloppak`, but the committed data
file was never regenerated/renamed — it stayed `slopsmith-diagnostic-...`.

Result: _seed_builtin_diagnostic_sloppaks() finds no matching source, silently
skips seeding, and the onboarding "Play it now" button (profile.js step 4 →
window.playSong) loads a file that isn't in the library. The server replies
{"error":"File not found"} and highway.js surfaces it as a native
`Error: File not found` popup. Affects all platforms.

Pure file rename to match the (already-renamed) code; no logic change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 23:38:22 +02:00
Sin d91995fec3 Revert "Fix list/tree view: select mode, parts visibility, song actions"
This reverts commit ac3c89493d.
2026-06-23 21:12:55 +01:00
Sin ac3c89493d Fix list/tree view: select mode, parts visibility, song actions
- Preserve expanded artist groups across re-renders (was collapsing
  all groups whenever select mode toggled)
- Add select checkbox + ring highlight to tree rows, matching grid
- Add capture-phase select guard on tree clicks so rows/chips toggle
  selection instead of falling through to play
- Always show favorite/save-for-later/overflow-menu buttons on tree
  rows instead of hover-only (matches grid card behaviour)
- Always show arrangement chips on tree rows (no longer hidden below
  the sm breakpoint)

Signed-off-by: Sin <deathlysin@outlook.com>
2026-06-23 21:02:27 +01:00
3b485fe62b feat(v3): tabbed, card-row settings page + per-plugin settings category (#584)
Replace the single long scrolling v3 settings screen with a horizontal tab
bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins /
System) over card rows (icon + title + description, control on the right) with
a per-category Reset.

- static/v3/index.html: tab bar + card-row markup (ids keep hydrating through
  the unchanged app.js loadSettings()/persistSetting() path).
- static/v3/settings.js (new): tab switching + active-tab persistence
  (localStorage 'v3-settings-tab'), per-category reset, read-only Keybinds
  reference from window.getAllShortcuts().
- static/v3/v3.css: plain CSS, no Tailwind rebuild.
- Per-plugin settings tab: new optional settings.category in plugin.json →
  plugins/__init__.py surfaces settings_category; app.js mounts each plugin
  <details> into #plugin-settings-<category> (fallback: Plugins tab).
  highway_3d ships category: "graphics".
- New gameplay settings: countdown_before_song (wired end-to-end, default off);
  miss_penalty + fail_behavior (persist-only stubs); "Note highway speed"
  surfaces existing master_difficulty.
- New POST /api/settings/reset clears whitelisted keys back to defaults.

Tests: test_settings_api.py, test_plugins.py::test_settings_category_parsed_from_manifest,
tests/browser/settings-tabbed.spec.ts. 179 passed locally.

Ported from the pre-rename feat/v3-settings-tabbed WIP onto current main
(slopsmith→feedBack rename applied; settings-screen markup conflict resolved
in favour of the new tabbed layout — all prior setting ids preserved).

Closes #579

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:06:46 +02:00
f3a5cb9ed3 feat(sloppak): expose full-mix original_audio alongside stems (#583)
Lets a .sloppak ship the single pre-separation full mixdown next to its
per-instrument stems, so the player can use the pristine original when nothing
is isolated (demucs recombination is lossy) and switch to separated stems only
when a slider drops below unity.

- lib/sloppak.py::load_song parses the optional manifest `original_audio:` key
  into a new LoadedSloppak.original_audio field, with the same path-traversal
  guard + permissive "missing → disabled" posture as the drum_tab loader.
- The highway WS song_info frame additively carries original_audio_url (served
  by the existing /api/sloppak/{filename}/file/{rel_path} endpoint, None for
  stems-only packs), has_original_audio, and has_stems.
- A stem-less, full-mix-only sloppak now sets audio_url to the full mix (plays
  natively) instead of emitting audio_error.

Message shape stays a stable contract — all additions are purely additive.
Tests: tests/test_sloppak_original_audio_load.py (6 passing).

Closes #580

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:05:45 +02:00
db4a30085b feat(v3 library): clickable arrangement badges in tree view (#582)
The v3 library tree rows showed no arrangement badges, unlike the grid/card
view. Render the same clickable chips in tree rows so both views match, and
clicking a specific arrangement opens THAT arrangement in the highway.

Extract the grid's chip markup into a shared arrChipsHtml(song) (one
<button data-arr="<index>"> per arrangement, capped at 4) and use it in both
songCard and the tree row. No new wiring needed: wireCards() already binds
[data-arr] → playCard(song, index) → playSong(filename, index) for any
[data-fn] scope, and the arrangement index is preserved through
/api/library/artists. Chips are hidden on the narrowest viewports
(hidden sm:flex) so they don't crowd the dense single-line tree row.

Closes #581

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 18:05:41 +02:00
b123ab3258 fix(minigames): drain legacy slopsmith pending queue + alias SDK (#578)
After the slopsmith→feedBack rename (#537) the minigames SDK publishes
window.feedBackMinigames and only drains window.__feedBackMinigamesPending.
Minigame plugins that still use the pre-rename shim register against
window.slopsmithMinigames and queue to window.__slopsmithMinigamesPending
when the SDK isn't up yet, so their specs are stranded in the legacy queue
and never register. In v3, FeedBarcade renders those games as non-launchable
"Loading…" tiles that do nothing on click (the tile itself comes from the
server registry, so it appears even though the JS spec never registered).

Publish window.slopsmithMinigames as an alias and drain the legacy pending
queue too (register() is keyed on spec.id, so double-queued specs register
once). Also fire the legacy slopsmith-minigames-ready event. Bump the plugin
version so the desktop renderer cache-busts the updated screen.js.

This rescues every not-yet-migrated minigame plugin, including community
ones we don't control.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 16:30:00 +02:00
a43e7b13be fix(onboarding): calibration Tuner step no longer exposes the input-select overlay (#577)
Tester: "at the tune step, pressing the Tuner button starts a second wizard at
the input-select step."

Root cause is stacked full-screen overlays. During onboarding the input-setup
flow runs as #input-setup-overlay (z-210) on top of the onboarding modal
#v3-onboarding (z-200), and note_detect's Calibration Wizard (z-300) launches on
top of that. When the player opens the Tuner, that wizard minimizes itself to
transparent + pointer-events:none so the Tuner (z-1000) is usable — but the
input-setup overlay underneath, still showing its "select your input" card, then
shows through behind the floating tuner and reads as a second wizard.

Two targeted hides so only the active surface is visible:
- input_setup: hide #input-setup-overlay while launchCalibration runs; restore on
  its onDone/onCancel (one always fires on close), so the calibration wizard /
  tuner own the screen.
- onboarding runInputSetup: hide #v3-onboarding for the whole input-setup phase
  (its own overlay replaces it visually); restore in finally before advancing to
  the calibration-challenge step.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:51:59 +02:00
7399a2ac63 Edit region: Loop-in-3D round-trip between player and Song Editor (#575)
* Add "Edit region" + Loop-in-3D handoff between player and Song Editor

Wires the player half of the Editor ⇄ 3D Highway region round-trip
(editor half is in feedback-plugin-editor).

Highway → Editor:
- New "✎ Edit region" button in the loop controls (v2 and v3) opens the
  Song Editor scrolled to the active A–B loop — or, when none is set, the
  section under the playhead (or a short window around it).
- A "↩ Editor" button appears after a Loop-in-3D handoff to return to the
  exact edit position you came from.
- Both are hidden unless the editor plugin is loaded (typeof
  window.editSong) and gated by _updateEditRegionBtn.

Editor → Highway:
- A one-shot song:ready listener consumes window._pendingHighwayLoop set
  by the editor's "Loop in 3D" button — after playSong()'s own clearLoop()
  has run — arming setLoop(a,b) over the region and auto-starting playback.
  Filename-guarded so a cancelled handoff can't arm a stale loop on an
  unrelated song.

Reuses the existing A/B loop API; no new looping engine. Buttons added to
both static/index.html (v2) and static/v3/index.html (separate file —
v2 markup doesn't carry over), using already-scanned Tailwind classes.

New globals editRegionInEditor / returnToEditorFromHighway; helpers
_resolveEditRegion / _updateEditRegionBtn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(loop-in-3d): use canonical window.feedBack namespace (post-#537)

The new song:ready loop-applier landed on the legacy window.slopsmith
alias because the branch predated the slopsmith->feedBack rename (#537).
Normalize it to window.feedBack like the rest of core; the alias would
have worked but leaves the lone slopsmith reference in the file.

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

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-06-23 11:38:51 +02:00
7fb568c727 docs: correct plugin URL casing after the feedBack rename (#576)
Cosmetic follow-up to #537 (doc-only).

- Virtuoso: README + CHANGELOG used lowercase
  `got-feedback/feedback-plugin-virtuoso`; the canonical repo (like every
  other got-feedback repo) is capital-B `feedBack-plugin-virtuoso`. Brought
  it in line with the sibling rows.
- Community plugin references in CLAUDE.md, TODO.md, docs/, and the bundled
  tuner README were over-renamed to `feedBack-*` by the rename, but those
  repos are owned by community members who never renamed them
  (topkoa/stems+notedetect, OmikronApex/tuner, masc0t/update-manager).
  Restored their real `slopsmith-*` names. got-feedback's own `feedBack-*`
  references in the same files are left untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:20:23 +02:00
af2949677a rename: slopsmith → feedBack, byron → got-feedBack (#537)
* Update GitHub repo references from feedback* to feedBack*

* rename: slopsmith -> feedBack, byron -> got-feedBack

Renames across the entire codebase:
- slopsmith/Slopsmith/SLOPSMITH/SlopSmith -> feedBack/FeedBack/FEEDBACK/FeedBack
- byron/Byron/Byrongamatos -> got-feedBack/got-feedBack/got-feedBack
- /home/byron/ -> /opt/got-feedBack/
- byron@ougsoft.com -> hi@got-feedBack.org
- github.com/byrongamatos/ -> github.com/got-feedback/
- com.byron. -> com.got-feedback.
- SLOPSMITH_ env vars -> FEEDBACK_ with backward-compat fallback
- Protocol/storage strings migrated with read-old/write-new pattern
- window.slopsmith JS API -> window.feedBack (canonical) + backward-compat alias

Refs: #rename-slopsmith

* rename: complete regen against current main + fix backward-compat alias

Regenerated the slopsmith->feedBack / byron->got-feedBack rename on top of
current main (3 commits had landed since the branch: #572/#554/#574),
resolving the four content conflicts in favour of main's newer content
(autoplay/auto-exit, accuracy-badge, Virtuoso re-home, feedpak badge).

Completion fixes on top of the mechanical rename:
- Re-apply rename to post-branch content the original rename never saw:
  window.slopsmith(.Tour) consumers in lessons.js / notifications.js /
  onboarding-tour.js, and the matching JS + python tests (autoplay_exit,
  progression_*, test_feedpak_extension FEEDBACK_* env vars). The test env
  vars now match server.py (which reads FEEDBACK_SYNC_STARTUP /
  FEEDBACK_SKIP_STARTUP_TASKS), so the sync-startup test exercises the real
  path again.
- Restore the window.slopsmith backward-compat alias dropped during conflict
  resolution, and move the bus aliases to AFTER the _feedBackExisting merge
  block so they reference the fully-assembled object (also fixes the
  loop_api.test.js API-surface regex, which the original PR latently broke).
- Drop the stray empty data/web_library.db (runtime DB lives in CONFIG_DIR)
  and gitignore it.
- Fix stale tone-source test: feed[dB]ack -> fee[dB]ack to match shipped
  source labels.

Verified locally (org CI billing-blocked): JS 819/819 pass; pytest 1669
passed / 1683 collected with 0 import errors; zero residual slopsmith/byron
except the two intentional window.slopsmith aliases.

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

* rename: implement advertised backward-compat + prune dead community plugins

Address gaps where PR #537's "Backward compatibility" section was advertised
but not implemented, and clean up the community plugin list.

Env vars (FEEDBACK_* canonical, legacy SLOPSMITH_* honoured):
- New lib/env_compat.py (getenv_compat / env_flag_compat) + tests. server.py
  (_env_flag + all FEEDBACK_* reads), diagnostics_hardware, gp2midi and
  tailwind_rebuild now resolve the legacy alias, so existing SLOPSMITH_UI /
  SLOPSMITH_PLUGINS_DIR / etc. deployments keep working.
- Fix the rename collapsing plugins/__init__.py and minigames/routes.py from
  `FEEDBACK_PLUGINS_DIR or SLOPSMITH_PLUGINS_DIR` into a redundant
  `FEEDBACK_ or FEEDBACK_` (the fallback was silently lost).

Storage (app.js update-channel):
- Read feedBack-update-channel, fall back to legacy slopsmith-update-channel,
  and clear the legacy key on write — so a user's update-channel preference
  survives the rename instead of resetting to "stable".

Community plugin list (README): the rename rewrote third-party repo URLs we
don't own. Probed every one; their owners never renamed, so:
- Restore the 13 live community plugins to their real slopsmith-* names.
- Prune 6 that are 404 to the public (topkoa splitscreen/stems, OmikronApex
  tuner, Jafz2001 nam-rig-builder, DeathlySin song-preview, Erikcb91 shuffle).
- Fix a pre-existing Guitar Theory clone-command typo (nam-tone -> guitar-theory).

Verified: env_compat 7/7, JS 819/819, pytest 1690 collected / 0 import errors,
rename-sensitive + startup suites green.

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

---------

Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:01 +02:00
Bret Mogilefsky b0338849f8 feat(core): read .jsonc data files (strip C-style comments) (feedpak-spec §8)
feedpak-spec §8 (FEP #3 / PR #13) allows .jsonc files (JSON with C-style
// line and /* */ block comments) anywhere .json is specified. A Reader MUST
strip comments before parsing. Core's sloppak/feedpak readers parsed every
side file with bare json.loads, so a .jsonc arrangement / notation / drum_tab
/ song_timeline / lyrics / keys would fail to load.

- lib/jsonc.py (new): shared parse_jsonc(text) + load_json(path). String-aware
  regex (mirrors the spec reference validator in feedpak-spec/tools/validate.py)
  — keeps comment-like text inside JSON string literals. load_json auto-detects
  .jsonc by suffix; plain .json goes straight through json.loads.
- lib/sloppak.py: import load_json; replace the 6 json.loads(...read_text...)
  side-file read sites (arrangement, notation, drum_tab, song_timeline,
  lyrics, keys) with load_json(<path>). Removed the now-unused `import json`.
- scripts/lift_keys_notation.py: import load_json; replace the 3 read sites
  (song_timeline, arrangement beats fallback, arrangement lift). `import json`
  stays (json.dumps write at the notation sidecar emit).

Additive (MINOR) change: older readers parse .jsonc as plain JSON and ignore
comments via the spec's forward-compatibility rules, so no existing pack needs
regeneration.

Tests: tests/test_sloppak_jsonc_load.py (16 tests) — parse_jsonc unit cases
(line/block/multiline/string-boundary/malformed/plain), and end-to-end loads
for all 6 side-file types via .jsonc with comments, plus the lift helper
reading .jsonc song_timeline + .jsonc arrangement beats, plus the
string-boundary preservation rule through the full loader. 122 sloppak/lift
tests pass.
2026-06-20 14:10:04 -07:00
247 changed files with 47705 additions and 1545 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
run: pytest
- name: Run JS plugin-API tests
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'
run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' 'plugins/*/tests/*.test.js'
tailwind-fresh:
# Guard that the committed static/tailwind.min.css is in sync with source.
+9 -28
View File
@@ -1,5 +1,9 @@
name: Nightly
# Trunk-based: nightly always builds main — the release-branch discovery
# from the old release-centric flow is gone (it pinned nightlies to the
# highest release/v* branch forever, even after it shipped). Stabilization
# builds from release/** come from rc.yml instead.
on:
schedule:
- cron: '0 2 * * *'
@@ -9,33 +13,7 @@ permissions:
contents: read
jobs:
setup:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.branch.outputs.branch }}
date: ${{ steps.date.outputs.date }}
steps:
- name: Find active release branch
id: branch
env:
GH_TOKEN: ${{ github.token }}
run: |
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
--jq '[.[].ref | ltrimstr("refs/heads/")] | map(ltrimstr("refs/heads/")) | .[]' \
| sort -V | tail -1 || true)
if [[ -z "$branch" ]]; then
branch="main"
fi
echo "branch=$branch" >> "$GITHUB_OUTPUT"
echo "Active branch: $branch"
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
build-docker:
needs: setup
runs-on: ubuntu-latest
permissions:
contents: read
@@ -44,9 +22,12 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.setup.outputs.branch }}
persist-credentials: false
- name: Get date
id: date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -65,6 +46,6 @@ jobs:
push: true
tags: |
ghcr.io/got-feedback/feedback:nightly
ghcr.io/got-feedback/feedback:nightly-${{ needs.setup.outputs.date }}
ghcr.io/got-feedback/feedback:nightly-${{ steps.date.outputs.date }}
cache-from: type=gha
cache-to: type=gha,mode=max
+63
View File
@@ -0,0 +1,63 @@
name: rc
# Release-candidate images for stabilization: every push to a release/**
# branch builds and pushes ghcr.io tags :rc (moving) and
# :rc-<version>-<date> (pinned). Final versioned images still come from
# release.yml on tag push.
on:
push:
branches: ['release/**']
permissions:
contents: read
# One build per branch at a time; a newer push supersedes an in-flight one.
concurrency:
group: rc-${{ github.ref }}
cancel-in-progress: true
jobs:
build-docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Derive RC tags
id: meta
run: |
# release/v0.3.0 -> 0.3.0 (tolerate a missing v prefix too)
version="${GITHUB_REF_NAME#release/}"
version="${version#v}"
date="$(date -u +%Y%m%d)"
{
echo "tags<<TAGS_EOF"
echo "ghcr.io/got-feedback/feedback:rc"
echo "ghcr.io/got-feedback/feedback:rc-${version}-${date}"
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -2
View File
@@ -35,9 +35,9 @@ jobs:
# stable releases (no pre-release suffix).
{
echo "tags<<TAGS_EOF"
echo "ghcr.io/${GITHUB_REPOSITORY}:${version}"
echo "ghcr.io/${GITHUB_REPOSITORY,,}:${version}"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "ghcr.io/${GITHUB_REPOSITORY}:latest"
echo "ghcr.io/${GITHUB_REPOSITORY,,}:latest"
fi
echo "TAGS_EOF"
} >> "$GITHUB_OUTPUT"
+5
View File
@@ -8,6 +8,11 @@ name: ship-ci
on:
pull_request:
branches: [main, 'release/**']
# Trunk-based: post-merge CI on main catches semantic conflicts between
# independently-green PRs; push on release/** covers stabilization
# cherry-picks that land without a PR.
push:
branches: [main, 'release/**']
permissions:
contents: read
+12
View File
@@ -21,9 +21,15 @@ plugins/*/
# treats them identically to user-installed ones) but are bundled with
# the default container image and marked `"bundled": true` in their
# manifest. Add new core plugins as `!plugins/<id>/` exceptions.
!plugins/achievements/
!plugins/achievements/**
plugins/achievements/__pycache__/
!plugins/highway_3d/
!plugins/highway_3d/**
plugins/highway_3d/__pycache__/
!plugins/folder_library/
!plugins/folder_library/**
plugins/folder_library/__pycache__/
!plugins/app_tour_library/
!plugins/app_tour_library/**
!plugins/app_tour_settings/
@@ -38,6 +44,12 @@ plugins/minigames/__pycache__/
plugins/tuner/__pycache__/
!plugins/input_setup/
!plugins/input_setup/**
!plugins/drum_highway_3d/
!plugins/drum_highway_3d/**
plugins/drum_highway_3d/__pycache__/
!plugins/keys_highway_3d/
!plugins/keys_highway_3d/**
plugins/keys_highway_3d/__pycache__/
node_modules/
test-results/
playwright-report/
+72 -1
View File
@@ -8,6 +8,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
### Fixed
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
### Added
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()``{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (01, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly**`hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 01 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 01 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
- **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass.
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx``dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
@@ -29,6 +75,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where``query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport``{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
- **v3 Songs AZ rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps**`pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked``_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
@@ -36,11 +103,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Screensaver no longer kicks in during windowed-mode playback** (#686). While a song is playing, `static/app.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
### Changed
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedback-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale``virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale``virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 712 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
### Removed
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
### Added
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
+14 -1
View File
@@ -231,6 +231,18 @@ window.feedBackViz_my_viz = function () {
// toneChanges, toneBase, mastery, hasPhraseData, inverted,
// lefty, renderScale, lyricsVisible, the 2D coordinate
// helpers project and fretX, and getNoteState (see below).
// The bundle OBJECT is reused across frames (mutated in
// place — no per-frame allocation): never cache it or
// compare its identity between frames; field values are
// only valid for the current draw call. Array FIELDS still
// swap reference when chart data changes, so field-identity
// caches (`myRef !== bundle.chords`) remain valid.
// Windowed-iteration helpers (stable fn refs): bundle
// .lowerBoundT(arr, time) is a lower-bound binary search on
// `.t` (notes/chords); bundle.lowerBoundTime(arr, time) on
// `.time` (beats/anchors/sections). Use these to cull to
// the visible window instead of full-scanning chart arrays
// per frame.
// `stringCount` is the active arrangement's string count (4
// for bass, 6 for guitar, 7+ for extended-range GP imports —
// size string-indexed geometry against this, not a hardcoded
@@ -530,7 +542,7 @@ cover.jpg Album art (optional)
lyrics.json Syllable-level lyrics (optional)
```
Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) provides live stem mixing for sloppak songs.
Sloppak is the preferred format for new features. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) provides live stem mixing for sloppak songs.
**Full developer reference:** the authoritative format spec now lives in its own repo —
[got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)
@@ -552,6 +564,7 @@ a local pointer + code map.
- **Storage** — `localStorage` for all user preferences
- **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II.
- **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs
- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-<id>` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.)
- **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it.
## Backend Conventions
+8 -8
View File
@@ -47,11 +47,11 @@ RUN cmake -S /tmp/vgmstream -B /tmp/vgmstream/build \
# and update FFMPEG_RELEASE + both SHA256 ARGs below.
FROM alpine:3.20 AS ffmpeg-fetcher
ARG TARGETARCH
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=03c0431e0d1aa75cc343d83bda9d2d4cd8eaa37f35b7b93465e9ff6864f5d7f8
ARG FFMPEG_SHA256_ARM64=74629b88342fd94eea12b7481c8b8560ca6d497744123c0a27b98f39d767fd93
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_SHA256_AMD64=1390e1c320a1e38dae106d6d0b05a6f08eb8b30f732bc1aa0d45a4aa17f13795
ARG FFMPEG_SHA256_ARM64=53b2e30df04d56932b7782234c9bc97abfe0bb242192ca50346474a41b100ab0
RUN apk add --no-cache curl xz \
&& arch="${TARGETARCH:-$(apk --print-arch)}" \
&& case "$arch" in \
@@ -94,9 +94,9 @@ FROM python:3.12-slim
# Re-declare the ffmpeg ARGs so their values are available to LABEL below.
# ARG values don't cross stage boundaries in multi-stage builds; defaults
# must be repeated here to take effect when no --build-arg is supplied.
ARG FFMPEG_RELEASE=autobuild-2026-06-19-23-17
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.4-145-g4cbf7a4b3d-linuxarm64-gpl-7.1.tar.xz
ARG FFMPEG_RELEASE=autobuild-2026-07-03-13-21
ARG FFMPEG_BUILD_AMD64=ffmpeg-n7.1.5-1-g7d0e842004-linux64-gpl-7.1.tar.xz
ARG FFMPEG_BUILD_ARM64=ffmpeg-n7.1.5-1-g7d0e842004-linuxarm64-gpl-7.1.tar.xz
# Apply latest security updates to base packages (clears glibc deb13u3 and
# similar). Done first so any subsequent installs resolve against the
-47
View File
@@ -1,47 +0,0 @@
# fee[dB]ack
## Plugins
| Plugin | Description | Install |
|------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
| [Create from Tab](https://github.com/got-feedback/feedBack-plugin-ug) | Search Ultimate Guitar for GP tabs and convert to playable songs | `git clone ...feedBack-plugin-ug.git ultimate_guitar` |
| [Import Tab](https://github.com/got-feedback/feedBack-plugin-tabimport) | Drag and drop Guitar Pro files to create songs | `git clone ...feedBack-plugin-tabimport.git tab_import` |
| [Practice Journal](https://github.com/got-feedback/feedBack-plugin-practice) | Auto-track practice time, speed, loops. Dashboard with charts | `git clone ...feedBack-plugin-practice.git practice_journal` |
| [Setlist Builder](https://github.com/got-feedback/feedBack-plugin-setlist) | Create ordered playlists with sequential playback | `git clone ...feedBack-plugin-setlist.git setlist` |
| [Metronome](https://github.com/got-feedback/feedBack-plugin-metronome) | Audible click and visual beat flash synced to song tempo | `git clone ...feedBack-plugin-metronome.git metronome` |
| [Tone Player](https://github.com/got-feedback/feedBack-plugin-tones) | View amp/pedal/cab signal chains with gear artwork | `git clone ...feedBack-plugin-tones.git tones` |
| [Fretboard View](https://github.com/got-feedback/feedBack-plugin-fretboard) | Live fretboard overlay showing active notes in real-time | `git clone ...feedBack-plugin-fretboard.git fretboard` |
| [Tab View](https://github.com/got-feedback/feedBack-plugin-tabview) | Scrolling guitar tablature notation via alphaTab | `git clone ...feedBack-plugin-tabview.git tab_view` |
| [MIDI Amp Control](https://github.com/got-feedback/feedBack-plugin-midi) | Auto-switch amp/modeler presets via MIDI on tone changes | `git clone ...feedBack-plugin-midi.git midi_amp` |
| [Section Map](https://github.com/got-feedback/feedBack-plugin-sectionmap) | Color-coded song structure minimap with clickable navigation | `git clone ...feedBack-plugin-sectionmap.git section_map` |
| [Arrangement Editor](https://github.com/got-feedback/feedBack-plugin-editor) | DAW-like visual editor for creating and editing song note charts | `git clone ...feedBack-plugin-editor.git editor` |
| [MIDI Capo](https://github.com/masc0t/slopsmith-plugin-midi-capo) | MIDI capo control for real-time transposition | `git clone ...slopsmith-plugin-midi-capo.git midi_capo` |
| [Note Detection](https://github.com/got-feedback/feedBack-plugin-notedetect) | Real-time pitch detection and scoring against highway notes | `git clone ...feedBack-plugin-notedetect.git note_detect` |
| [Find More](https://github.com/masc0t/slopsmith-plugin-find-more) | Search for more songs by the same artist | `git clone ...slopsmith-plugin-find-more.git find_more` |
| [Piano Highway](https://github.com/got-feedback/feedBack-plugin-piano) | Scrolling piano/keyboard view for Keys arrangements with MIDI input | `git clone ...feedBack-plugin-piano.git piano` |
| [Studio](https://github.com/got-feedback/feedBack-plugin-studio) | Collaborative band recording and multi-track mixing | `git clone ...feedBack-plugin-studio.git studio` |
| [Drum Highway](https://github.com/got-feedback/feedBack-plugin-drums) | Lane-based drum highway with MIDI drum pad input and built-in sounds | `git clone ...feedBack-plugin-drums.git drums` |
| [Invert Highway](https://github.com/masc0t/slopsmith-plugin-invert-highway) | Flip the highway note direction | `git clone ...slopsmith-plugin-invert-highway.git invert_highway` |
| [Jumping Tab](https://github.com/renanboni/slopsmith-plugin-jumpingtab) | Yousician-style 2D horizontal tab with trajectory arcs and hopping ball | `git clone ...slopsmith-plugin-jumpingtab.git jumpingtab` |
| [Step Mode](https://github.com/got-feedback/feedBack-plugin-stepmode) | Step-by-step practice mode — highway freezes at each note until played (via Note Detection) or Space | `git clone ...feedBack-plugin-stepmode.git step_mode` |
| [Lyrics Sync](https://github.com/got-feedback/feedBack-plugin-lyrics-sync) | Generate synced LRC lyrics from text + vocals stem via Whisper alignment | `git clone ...feedBack-plugin-lyrics-sync.git lyrics_sync` |
| [Lyrics Karaoke](https://github.com/got-feedback/feedBack-plugin-lyrics-karaoke) | Per-syllable karaoke pitch ribbon for sloppak songs (Whisper alignment + librosa pYIN) | `git clone ...feedBack-plugin-lyrics-karaoke.git lyrics_karaoke` |
| [NAM Tone Engine](https://github.com/got-feedback/feedBack-plugin-nam-tone) | In-browser amp modeling with NAM WASM, cabinet IRs, tone auto-switching | `git clone ...feedBack-plugin-nam-tone.git nam_tone` |
| [Guitar Theory Lab](https://github.com/topkoa/slopsmith-plugin-guitar-theory) | Explore scales, chords, intervals, tunings, and voicings on a fully interactive fretboard | `git clone ...slopsmith-plugin-guitar-theory.git guitar-theory-lab` |
| [Themes](https://github.com/masc0t/slopsmith-plugin-themes) | Offers several basic recolorings of the interface | `git clone ...slopsmith-plugin-themes.git themes` |
| [Update Manager](https://github.com/masc0t/slopsmith-update-manager) | Installs, updates, and uninstalls other plugins and the feedBack core itself | `git clone ...slopsmith-update-manager.git update_manager` |
| [Simplify Chords](https://github.com/bkranendonk/slopsmith-plugin-simplify-chords) | Changes complex chords on the note highway to simpler ones. Inspired by Ultimate Guitar's Simplify button. | `git clone ...slopsmith-plugin-simplify-chords.git simplify-chords` |
| [Key Bindings](https://github.com/jackipicco/slopsmith-plugin-key-bindings) | Highway key bindings for keyboard and TV remote | `git clone ...slopsmith-plugin-key-bindings.git key_bindings` |
| [Folder Organizer](https://github.com/Elit3d/slopsmith-plugin-folder-organizer) | Organize your sloppak DLC songs into a folder tree view, grouped by subfolder name | `git clone ...slopsmith-plugin-folder-organizer.git folder-organizer` |
| [Virtuoso](https://github.com/got-feedback/feedback-plugin-virtuoso) | Practice studio for guitar & bass — scale, technique, and rhythm drills, timed workouts, and jam backing that teach skills you take off the screen. | `git clone ...feedback-plugin-virtuoso.git virtuoso` |
| [Audio Preview](https://github.com/saleemk/slopsmith-plugin-audio-preview) | Quick audio previews from library cards with configurable start time, volume, and duration | `git clone ...slopsmith-plugin-audio-preview.git audio_preview` |
| [Song Mastery](https://github.com/jamesgaiser/slopsmith-plugin-song-mastery) | Auto-adjusts difficulty based on your rolling note accuracy and saves the slider position per song | `git clone ...slopsmith-plugin-song-mastery.git song_mastery` |
| [Mobile Note Highway](https://github.com/saleemk/slopsmith-plugin-mobile-note-highway) | Touch-optimized player with collapsible controls, highway gestures, and device-adaptive layouts for phones and tablets | `git clone ...slopsmith-plugin-mobile-note-highway.git mobile_note_highway` |
Install any plugin by cloning it into your `plugins/` directory and restarting:
```bash
cd plugins
git clone https://github.com/got-feedback/feedBack-plugin-ug.git ultimate_guitar
docker compose restart
```
+1 -1
View File
@@ -22,5 +22,5 @@ too sharp / too flat / not played).
- **[Implementation Plan](docs/NOTE_FAILURE_PLAN.md)** — 7 phases from
detection foundation through section grading and polish
- **Note Detection Plugin Plan** — see the
[feedBack-plugin-notedetect](https://github.com/topkoa/feedBack-plugin-notedetect)
[slopsmith-plugin-notedetect](https://github.com/topkoa/slopsmith-plugin-notedetect)
repository (Phase 0 foundation)
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.3.0-alpha.1
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -177,7 +177,7 @@ entry explaining why.
"capability_validation_warnings": [],
"capability_unsupported_versions": [],
"compatibility_shims": [],
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/feedBack-plugin-stems.git" }
"git": { "sha": "abc123d", "remote": "https://github.com/topkoa/slopsmith-plugin-stems.git" }
}
],
"orphans": [
+246
View File
@@ -0,0 +1,246 @@
# Host Theme Contract — design proposal
**Status:** proposal (charrette output, 2026-06-29) · **Owner area:** core v3 + plugin UI
**Trigger:** a plugin UI feature accidentally "carved itself into a single theme."
## 1. Problem
A results-card feature in the `note_detect` plugin (a glow-ring hero button + a
gradient-filled accuracy number) was built and visually verified against **only the
default skin** ("neon"). On the other skins it broke: on "esports" — a deliberately
glow-less, near-monochrome design language — the glow ring and the colour gradient
simply **vanished**. The colours adapted (everything used CSS custom-property tokens),
but the **visual devices themselves did not port**, because nothing in the system says
"this theme does / doesn't do glow rings."
### Root cause (three findings)
1. **Themes are design *languages*, not palettes.** neon = glow + animation + gradients;
esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels +
drop-shadows. Tokens made *colour* portable; they never made a *device* portable.
2. **Tokens are named by *device*, not *intent*.** e.g. `--nd-glow-*` holds a glow in neon
but a **hard drop-shadow** in metal — the metal skin is already repurposing a
device-named slot to express a different language. The cure is to finish that move:
name slots by intent, with "off" (`none`) a legal value.
3. **No "text-legible-on-accent" role.** White-on-accent was hardcoded in several places;
on esports' amber accent that's a contrast failure. And `--nd-accent2` was
**double-booked** (gradient-end *and* S-grade colour), so the hero gradient resolved
amber→near-white and washed out.
A process gap compounds it: **verification covered one skin**, so the regression was
invisible until a user switched themes. And this recurs ecosystem-wide — other plugins
ship their own independent skin systems too.
## 2. Current state (two disconnected systems)
| System | What it is | Limits |
| --- | --- | --- |
| **Host themes** (`static/v3/theme-core.js`, `html[data-fb-theme]`) | Cosmetic "shop" themes that recolour `fb-*` Tailwind tokens (surfaces/text/borders). | Apply-only & recolour-only. `--fbv-*` vars exist **only while a theme is equipped** (nothing to read in the default state). No read API, no capability signal, no normalized `theme:changed` event. Comment explicitly says it *leaves decorative accents (rings/shadows) at defaults***devices are an ownerless gap.** |
| **Plugin skins** (e.g. `note_detect` `data-nd-skin`) | Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
## 3. Goals / non-goals
- **Goal:** a feature, authored once, renders correctly in **any** theme — including ones not
yet invented — and degrades **intentionally** (neon ring → esports border), never accidentally.
- **Goal:** the host owns a canonical contract so plugins consume instead of reinventing.
- **Non-goal:** forcing every plugin skin to become a host theme. Skins stay plugin-local but
**implement** the contract.
- **Non-goal:** backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
## 4. The contract — three layers
### Layer 1 — Semantic colour **roles** (always present)
The host writes default `--fb-*` role tokens on `:root` **unconditionally** (not only under
`[data-fb-theme]`), seeded from the canonical `fb` palette, so `var(--fb-accent, …)` always
resolves — themed or not. Roles:
**Namespace (normative).** The public contract lives under one prefix, **`--fb-*`**, written on
`:root` by a host-owned *contract stylesheet* (see §6 / §8) so it is present **themed or not**.
The existing `--fbv-*` vars stay **internal plumbing**`theme-core.js` uses them only to
recolour the Tailwind `.bg-fb-*/.text-fb-*/.border-fb-*` utilities under `html[data-fb-theme]`;
they are **not** part of this contract and plugins must not read them. (Implementation may seed
`--fb-*` from the same source the `--fbv-*` overrides use, so an equipped theme moves both.)
**Value grammar (normative).** Colour roles are a **space-separated `r g b` triplet** (matching
today's `--fbv-*` and the Tailwind utilities), consumed as `rgb(var(--fb-accent))` with optional
alpha `rgb(var(--fb-accent) / .5)`. Recipe slots (Layer 2) hold **full CSS values** for their
device (a `box-shadow`, a `border` shorthand, a length, a paint), with `none` legal **except**
where noted.
**Normative role tokens** (all `--fb-*`, all always present):
| Role | Token | Notes |
| --- | --- | --- |
| surface / card / border | `--fb-surface` `--fb-card` `--fb-border` | structural |
| text / dim | `--fb-text` `--fb-text-dim` | |
| accent / second hue | `--fb-accent` `--fb-accent-2` | `accent-2` is **just a second hue** — never an assumed gradient end |
| status | `--fb-good` `--fb-warn` `--fb-bad` | maps onto today's palette `good / mid / low` (mid→warn, low→bad) — implementation aliases both |
| **on-fill (new)** | `--fb-on-accent` `--fb-on-good` `--fb-on-warn` `--fb-on-bad` | **Rule: every role used as a fill behind text gets a paired `--fb-on-*`** (fixes white-on-amber). Required + contrast-linted (§6). |
| **focus (new)** | `--fb-focus-ring` | focus indicator independent of `accent`, so focus stays visible when `accent ≈ surface` |
### Layer 2 — Capability **recipes** (intent-named slots; "off" is legal)
A theme declares its design *language* by filling intent-named slots (all `--fb-*`-prefixed,
same namespace as the roles). A feature applies the slot bundle **unconditionally**; it never
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
`--fb-corner-radius`, `--fb-corner-clip`, `--fb-panel-shadow`, `--fb-text-emph-shadow`,
`--fb-panel-texture`, `--fb-motion-decorative` (reduced-motion-gated). For these, `none` is legal.
Two **composite recipes** carry the load:
- **EMPHASIS** — how this theme makes a primary action special:
`--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on`.
neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow.
Any individual slot may be `none` — but a theme **must** emphasise *somehow* (at least one of
fill/border/halo non-`none`), so a primary action is never visually flat.
- **ACCENT-TEXT** — how this theme fills a big accent number: `--fb-acc-text-fill`
(decoupled from `accent-2`). neon/metal → a gradient; esports → a solid accent.
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
`background-clip: text` and keeps a solid `color` base (see §5), so the digits are legible
even where clip-text is unsupported.
> These generalize the interim per-skin tokens already shipped in `note_detect`
> (`--nd-hero-ring-idle/on`, `--nd-hero-border`, `--nd-acc-fill`).
### Layer 3 — JS read API + reconciliation
**The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
consumers** — those use the tokens and slots directly (§5). Critically, it exposes *resolved
token values*, **not** theme-style booleans: a `glow:false` flag can't tell a canvas whether to
draw a border, a bevel, a drop-shadow, or flat text, so there is **no** `capabilities()` of
booleans. On the existing `window.feedBack` bus:
- `feedBack.theme.get()``{ id, isThemed, tokens }` where `tokens` is the **resolved** map of
every `--fb-*` role + recipe slot (the computed values, so a canvas reads the actual device,
e.g. the gradient stops for `--fb-acc-text-fill`, not a boolean).
- `feedBack.theme.prefersReducedMotion()` → boolean (host wraps `matchMedia` once). **This is the
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
CSS-authored decorative motion.
- `theme:changed` event → `{ id, tokens }`.
**Lifecycle (normative).** `get()` always returns the **current effective theme synchronously**
and is valid at any time — before any theme is applied it returns the default/unthemed roles
(which always exist on `:root`). Theme application is async (it follows a `/api/profile` refresh);
`theme:changed` fires **only after** the DOM vars/classes are committed, and **once on initial
hydration** so a late-mounting plugin isn't stuck on stale state. **Plugin rule:** read `get()`
on mount, then subscribe to `theme:changed` — never assume an order between your mount and the
first theme apply.
**Reconciliation rule (ends the two-disconnected-systems problem):** a plugin skin
**derives surface/text/border from host tokens** (`--nd-bg: rgb(var(--fb-card))`, etc.) and
**owns only its accent + its devices**, selecting the device via the recipe. A host theme then
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
never imposes a device the active theme neutralizes.
**Propagation scope (normative).** The contract is **same-document light-DOM**: `:root` `--fb-*`
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
renders into a **shadow root or iframe** is responsible for bridging — copy the resolved
`get().tokens` into its sub-root and re-subscribe to `theme:changed` (host `:root` vars don't
cross those boundaries).
## 5. Consumption pattern (the rule for feature authors)
> **A feature may reference a colour *role* or a recipe *slot*. It may never write a raw
> device — no literal glow `box-shadow`, no literal `linear-gradient`, no hex.** Devices live
> in slots; the theme owns the slots.
```css
.hero-cta {
background: var(--fb-emph-fill);
border: var(--fb-emph-border);
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
color: var(--fb-emph-on); /* never hardcoded #fff again */
border-radius: var(--fb-corner-radius);
}
.accuracy-number {
/* Always-legible solid base; survives no-clip-text support too. */
color: rgb(var(--fb-accent));
}
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
invisible. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
.accuracy-number {
background: var(--fb-acc-text-fill);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
}
```
**Where the contract physically lives.** A **host-owned static contract stylesheet** (e.g.
`static/v3/theme-contract.css`, hand-authored, linked from `static/v3/index.html`) holds the
always-present `:root --fb-*` defaults **plus** the two central a11y rules below. It is **not** a
Tailwind file, so it never touches the prebuilt `static/tailwind.min.css` artifact the
`tailwind-fresh` CI check diffs (and it's independent of `theme-core.js`, which keeps
runtime-injecting only the `--fbv-*` utility overrides under `[data-fb-theme]`).
- **Reduced motion:** `--fb-motion-decorative` is the *only* place CSS decorative animation is
named; one central rule in the contract sheet sets it to `none` under
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
uses `feedBack.theme.prefersReducedMotion()` — §4.3.)
- **Focus parity:** one contract-level `:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }`
for contract consumers; themes recolour `--fb-focus-ring` but may not author their own focus
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
`v3.css`; those are reconciled onto the contract token (not magically replaced) as a tracked
cleanup — "one rule" describes the end state, not day one.
- **On-fill contrast:** every `--fb-on-*` is required and **lintable**
(`contrast(on-X, X) ≥ 4.5:1`, 3:1 large) for each fill role (`accent / good / warn / bad`).
Contrast is the theme's job, computed once — not re-judged per feature.
## 7. Verification gate (prevent recurrence)
- A committed **render-matrix** tool, driven off the runtime skin list, that renders the key
surfaces (hero CTA, accent number, **and the canvas share-image card**) across **every skin ×
key states** (rest / hover / focus / reduced-motion).
- The gate is **computed-style invariant assertions** (deterministic, CI-safe) — e.g. "emphasis
present and text legible in each theme" — **not** pixel-snapshot diffing (the animated ring +
fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime +
a guard test so the matrix can't silently go stale.
**Definition-of-done for any theme-touching UI change** (the few items that would have caught this):
expressed via tokens not hardcoded values · rendered across all skins · **a new visual *device*
stays legible when its slot resolves to `none`** · reduced-motion + focus parity · on-accent contrast.
## 8. Back-compat & rollout
All additive: the new always-present `--fb-*` tokens (in the contract sheet, §6) + a new
`feedBack.theme` namespace + a new event with no current listeners. Existing plugins (those
reading `fb-*` Tailwind utility classes, or shipping their own skins) are untouched unless they
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
two-arg fallback `rgb(var(--fb-accent, 224 128 32))` resolves to the literal, and
`window.feedBack?.theme?.get?.()` is feature-detected — so older hosts behave exactly as today.
**Workstream (sub-tasks):**
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
## 9. Cross-apply status (already done)
- `note_detect` results-card hero + accuracy number — fixed via per-skin device tokens
(the Layer-2 prototype) and verified across neon/esports/metal.
- The **canvas share-image card** — re-checked across all three skins: **theme-robust**
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
fidelity gap only: it uses flat `--nd-bg` and skips metal's brushed-steel *texture*.
## 10. Open questions
- Should plugin skins eventually become *selectable host themes* (one picker), or stay
plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
- Component-recipe **bundles** (per named component) are the richer end-state; intent-named
slots are the right seed. When/whether to graduate.
*(Resolved during review and folded into the sections above: the token namespace + value grammar
and normative role table (§4.1); the `none`-is-illegal carve-out for `--fb-acc-text-fill` (§4.2);
JS exposes resolved tokens, not booleans (§4.3); `theme:changed` lifecycle + shadow/iframe
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
host-owned contract stylesheet outside Tailwind (§6).)*
+2 -2
View File
@@ -73,7 +73,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
2. Open `manifest.yaml` in any text editor (Notepad++, VS Code, BBEdit, gedit — all fine; just **don't use Word**).
3. Find the `stems:` block. Two things matter here:
- **Order:** FeedBack's base `<audio>` element always plays the **first** stem listed in `stems[]`, regardless of `default:` flags. So if you want your custom stem to be what the player plays out-of-the-box (and what users without the Stems plugin will hear), put it **first**.
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
- **`default:` flags:** consulted by the [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) to decide which faders start un-muted. They do **not** affect what the base `<audio>` element plays — that's purely the first-stem rule above.
Example for a Demucs-split sloppak where you re-recorded the rhythm guitar:
@@ -110,7 +110,7 @@ The use case: the converted rhythm guitar sounds muddy (Demucs has a tough time
### Step 5 — Reload and verify
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/feedBack-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
Reload the song in FeedBack. The [Stems plugin](https://github.com/topkoa/slopsmith-plugin-stems) will show a fader for `rhythm_custom` next to the others. If you don't see it, check the cache notes in §1.
### Common gotchas
+53
View File
@@ -0,0 +1,53 @@
# Working-tuning — on-device test checklist
The working-tuning series (PRs 19) ships with headless unit tests for every state
machine (`tests/js/working_tuning*.test.js`, `tests/js/tuner_auto_open.test.js`). The
items below are the parts that **cannot** be covered headlessly — they need a real mic,
a real instrument, and (for the ASIO item) a specific audio backend. Run these on a
build before shipping the feature to users.
Prereq: enable the opt-in in **Tuner → settings → "Auto-open on tuning change"** (it's
off by default). Have a guitar (and a bass, for the per-instrument checks) on hand.
## 1. Auto-open + gate ("tune before you play")
- [ ] Load a song whose tuning differs from your instrument's current tuning → the tuner
**auto-opens** and playback **waits** (does not start underneath it).
- [ ] Load a song already covered by your tuning → **no** auto-open, playback starts.
- [ ] **Skip** ("I've tuned") → playback starts, and the tuner badge stops flagging this
song's tuning (a working tuning was recorded).
- [ ] **Back to library** / **Esc** → leaves the song, records **nothing** (re-enter the
same song → it still prompts).
- [ ] Take **longer than 12 s** to tune with the panel open → playback does **not** start
underneath you (the fail-open backstop was settled once the panel opened).
- [ ] Hit **Play** manually while the panel is open → Play wins; no double-start.
## 2. Both-directions retune prompt
- [ ] From standard, load a Drop-C# song → prompted **down** (E→C#). Tune down, Skip.
- [ ] Now load a standard song → prompted **back up** (C#→E). (Pre-series, this direction
was silent.)
- [ ] Switch guitar↔bass in the instrument card → each instrument remembers its **own**
working tuning; the card label follows the selection (dim = home, amber = retuned).
## 3. Mic-verify (assumed → verified)
- [ ] With a selected (non-free) tuning, tap **Verify tuning** and play each string in tune.
Each string needs ~8 stable in-tune frames (±6 ¢); the per-string progress advances.
- [ ] Play a string **out of tune** → it never completes; drifting out mid-streak resets it.
- [ ] Complete all strings → the instrument card's provenance glyph flips to the **filled**
(verified) diamond, and the recorded working tuning carries the tuning you verified
(not a stale one).
- [ ] Load the **next** song → the verified state **decays to assumed** (per-session only).
- [ ] Verify against a **manually-selected** tuning (tuner opened off a song) → the stamped
offsets match that tuning, not the last song's.
## 4. Mic contention with note-detection (the ASIO / exclusive-mode risk)
This is the item flagged in the design charrette: the tuner's mic capture must not starve
note_detect's scoring input.
- [ ] Desktop, **ASIO / WASAPI-exclusive** device: auto-open the tuner mid-song, tune, Skip
→ scoring resumes cleanly; no dropped input, no device-in-use error, no crash.
- [ ] Shared/`auto` device: same flow → both the tuner and scoring read the mic without a
stall.
- [ ] Leave the tuner's background badge audio running + start a scored song → note_detect
still scores (the badge auto-start doesn't hold the device exclusively).
Log the build hash and OS/audio backend with results; file any failure against the
working-tuning series.
+152
View File
@@ -0,0 +1,152 @@
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
A flat MusicBrainz *text* search ties every take of a song at the same score —
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
it). The definitive fix is content-based: fingerprint the actual audio with
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
This module is the PURE half (no network, no subprocess): response parsing +
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
subprocess and the throttled HTTP GET to api.acoustid.org.
Operational requirements (both optional — absent ⇒ this path is a graceful
no-op and the text matcher still runs):
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
"""
import os
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
# AcoustID does NOT split into flags — it then attaches no recording metadata
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
# `releases` is what carries the per-release DATE (nested under each
# releasegroup), which we need to pick the earliest original album + fill year.
LOOKUP_META = "recordings releasegroups releases compress"
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
# non-canonical (live/comp/remix) release, so we can flag the studio take.
_SECONDARY_SKIP = {
"live", "compilation", "remix", "dj-mix", "mixtape/street",
"demo", "interview", "audiobook", "spokenword",
}
def api_key(explicit: str | None = None) -> str:
"""The AcoustID application API key: an explicit value (e.g. a host setting)
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
def is_configured(explicit_key: str | None = None) -> bool:
"""True when an API key is available. `fpcalc` presence is checked by
server.py (it owns the binary lookup); both are required to actually run."""
return bool(api_key(explicit_key))
def _rg_is_studio(rg: dict) -> bool:
if str(rg.get("type", "")).lower() != "album":
return False
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
return not (secs & _SECONDARY_SKIP)
def _rg_earliest_year(rg: dict) -> "int | None":
"""Earliest release YEAR in a release-group (min over its nested releases'
dates). None when no release carries a date. This is what separates the
original pressing from later reissues/comps sharing the same group."""
years = []
for rel in (rg.get("releases") or []):
d = (rel or {}).get("date")
if isinstance(d, dict) and d.get("year"):
try:
years.append(int(d["year"]))
except (TypeError, ValueError):
pass
return min(years) if years else None
def _best_group(recording: dict) -> dict:
"""Pick the display album: a clean studio Album first, and among those the
EARLIEST-released one — the original, not a later reissue or a compilation
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
the first group when nothing is a studio album or nothing carries a date."""
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
if not groups:
return {}
def sort_key(g):
yr = _rg_earliest_year(g)
# studio (0) before non-studio (1); then earliest year (undated last).
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
return sorted(groups, key=sort_key)[0]
def _first_artist(recording: dict) -> str:
for a in (recording.get("artists") or []):
if isinstance(a, dict) and a.get("name"):
return str(a["name"])
return ""
def parse_lookup_response(body: dict) -> list[dict]:
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
shape as mb_match (recording_id / title / artist / album / year / duration /
studio / mb_score / score), so the review UI and the editor's Match popup
render fingerprint hits and text hits identically. `mb_score` carries the
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
if not isinstance(body, dict) or body.get("status") != "ok":
return []
out: list[dict] = []
seen: set[str] = set()
for result in (body.get("results") or []):
if not isinstance(result, dict):
continue
try:
score = float(result.get("score") or 0.0)
except (TypeError, ValueError):
score = 0.0
for rec in (result.get("recordings") or []):
if not isinstance(rec, dict) or not rec.get("id"):
continue
rid = str(rec["id"])
if rid in seen:
continue
seen.add(rid)
rg = _best_group(rec)
_yr = _rg_earliest_year(rg)
year = str(_yr) if _yr else ""
dur = rec.get("duration")
try:
duration = int(round(float(dur))) if dur else None
except (TypeError, ValueError):
duration = None
out.append({
"recording_id": rid,
"title": str(rec.get("title", "") or ""),
"artist": _first_artist(rec),
"album": str(rg.get("title", "") or ""),
"year": year,
"duration": duration,
"isrc": "",
"genres": [],
"studio": _rg_is_studio(rg),
"acoustid_score": round(score, 4),
# Fingerprint hits are content-verified, not text-guessed — carry
# the AcoustID confidence as the display score band.
"mb_score": int(round(score * 100)),
"score": round(score, 4),
"source": "acoustid",
})
# Best AcoustID confidence first; studio take breaks ties.
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
return out
+1 -1
View File
@@ -34,7 +34,7 @@ _QSTRING_SECRET_RE = re.compile(
r"(?i)\b(api[_-]?key|key|token|secret|password|pwd|auth)=([^\s&\"']+)"
)
_SONG_FILENAME_RE = re.compile(
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|wem|ogg|mp3|wav)\b",
r"\b[\w()'\-+&,.!?\[\]]+\.(?:psarc|sloppak|feedpak|wem|ogg|mp3|wav)\b",
re.IGNORECASE,
)
+10
View File
@@ -1124,7 +1124,17 @@ def _build_xml(
# for compatibility, and emit additional string6+ attributes (up to
# `len(tuning)-1`) for 7+ string arrangements. FeedBack parses
# them; the format ignores them.
#
# `stringCount` records the AUTHORITATIVE string count (== len(tuning)),
# because the 6-slot padding above erases the 4-vs-5-vs-6-string
# distinction for standard tunings (a 4-string bass, 5-string bass and
# 6-string guitar are otherwise byte-identical, all string0..5 = 0).
# parse_arrangement trims `tuning` back to this on read so downstream
# string-count derivation (song.arrangement_string_count, the editor's
# _stringCountFor) sees the real width instead of guessing. RS2014 and
# any other consumer simply ignore the unknown attribute.
tuning_el = ET.SubElement(root, "tuning")
tuning_el.set("stringCount", str(len(tuning)))
for i in range(max(6, len(tuning))):
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
ET.SubElement(root, "capo").text = "0"
+365 -173
View File
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
while sc <= max_sectors:
s = _gi(po + 4 * sc); sc += 1
if s == 0: break
so = s * SECTOR
if HDR + so + SECTOR > len(data):
start = HDR + s * SECTOR
# Real .gpx files' final sector is a few bytes short of a full
# 0x1000 block: the BCFZ-declared decompressed size isn't
# sector-aligned, so the last (small) container file lands in a
# partial trailing sector. Clamp the read to the buffer end —
# the per-file size field (`fs`, applied below) trims any
# padding — matching canonical GPX readers (alphaTab /
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
# past the end is genuinely malformed.
if start < 0 or start >= len(data):
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
fb.extend(data[HDR + so: HDR + so + SECTOR])
fb.extend(data[start: min(start + SECTOR, len(data))])
else:
raise ValueError("GPX BCFS sector chain too long (malformed file)")
files[fn] = bytes(fb[:fs])
@@ -229,22 +237,47 @@ def _build_tempo_map(root: ET.Element) -> list[tuple[int, float]]:
return events
def _parse_tuning(el: ET.Element) -> list[int]:
"""Return the string-tuning MIDI pitches from the first ``Tuning`` Property
at or below ``el`` (a Track or a single Staff), high string first. ``[]`` if
there is no Tuning property or its Pitches text is unparseable."""
for prop in el.findall('.//Property'):
if prop.get('name') == 'Tuning':
pe = prop.find('Pitches')
if pe is not None and pe.text:
try:
return [int(p) for p in pe.text.split()]
except ValueError:
return []
break
return []
def _gpif_tracks(root: ET.Element) -> list[dict]:
"""Return a list of raw track dicts from the GPIF Tracks element."""
# Lookups for per-track note counting. MasterBar/Bars lists one bar id per
# track in raw Tracks order, so the enumerate index below (which counts
# skipped pseudo-tracks) is the correct bar-lookup index — same mapping
# convert_file uses via filtered_to_raw.
# *stave* (not per Track element) in document order. A multi-stave track
# (e.g. GP8 piano with treble + bass) occupies N consecutive columns; the
# bar_column counter below advances by num_staves per track so every track
# gets the correct column regardless of neighbour stave counts.
_masterbars = list(root.find('MasterBars') or [])
_bars_by_id = {b.get('id'): b for b in (root.find('Bars') or [])}
_voices_by_id = {v.get('id'): v for v in (root.find('Voices') or [])}
_beats_by_id = {b.get('id'): b for b in (root.find('Beats') or [])}
_notes_by_id = {n.get('id'): n for n in (root.find('Notes') or [])}
def _note_count_for_raw(raw_idx: int) -> int:
# Total note count for the track (sum of notes across all its beats).
# This is the single source of truth: list_tracks surfaces it as the
# 'notes' field, and _auto_select_gpx uses (count == 0) to skip empty
# tracks — so the graph is walked once here, not again in list_tracks.
# Count of notes that ACTUALLY become RS notes for the track. This is
# the single source of truth: list_tracks surfaces it as the 'notes'
# field (the importer's per-track preview count) and _auto_select_gpx
# uses (count == 0) to skip empty tracks — so the graph is walked once
# here, not again in list_tracks.
#
# Tie-DESTINATION notes are excluded: a tied note is folded into the
# previous note as extended sustain (see the `_note_is_tie` skips in
# convert_file), so it never becomes a separate RS note. Counting them
# made the preview overstate the result (e.g. 260 shown, 241 imported);
# excluding them makes the preview match what the user actually gets.
n = 0
for mb in _masterbars:
bar_ids = mb.findtext('Bars', '').split()
@@ -263,16 +296,25 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
beat = _beats_by_id.get(bid)
if beat is None:
continue
notes_text = beat.findtext('Notes', '').strip()
if notes_text:
n += len(notes_text.split())
for nid in beat.findtext('Notes', '').split():
note_el = _notes_by_id.get(nid)
if note_el is not None and _note_is_tie(note_el):
continue
n += 1
return n
result = []
for raw_idx, t in enumerate(root.find('Tracks') or []):
bar_column = 0
for t in (root.find('Tracks') or []):
# Count staves: each Staff occupies one column in MasterBar/Bars.
# Default to 1 for tracks with no explicit <Staves> (GP3/4/5, old GPX).
num_staves = max(1, len(list(t.findall('Staves/Staff'))))
stave_columns = list(range(bar_column, bar_column + num_staves))
name = (t.findtext('Name') or '').strip()
if name.startswith('@$') and name.endswith('$@'):
continue # GP internal pseudo-tracks (raw_idx still advances)
bar_column += num_staves
continue # GP internal pseudo-tracks (bar_column still advances)
gm = t.find('GeneralMidi')
midi_program = 0
@@ -309,27 +351,37 @@ def _gpif_tracks(root: ET.Element) -> list[dict]:
except (ValueError, TypeError):
pass
# String tuning
string_pitches: list[int] = []
for prop in t.findall('.//Property'):
if prop.get('name') == 'Tuning':
pe = prop.find('Pitches')
if pe is not None and pe.text:
try:
string_pitches = [int(p) for p in pe.text.split()]
except ValueError:
pass
# String tuning — one list per stave, in stave order. Reading all
# `.//Property` descendants across every stave meant the last stave's
# tuning overwrote the first; for a GP8 piano (treble 6-string +
# bass 5-string) that caused stave-0 notes with String=5 to be
# out-of-range against the 5-entry bass tuning and silently dropped.
# A staff with no Tuning of its own falls back to the track-level
# property (never to []) — an empty list silently drops every fretted
# note on that stave in `_note_midi`. The list stays parallel to
# `stave_columns` so a per-stave column always has a matching tuning.
_track_tuning = _parse_tuning(t)
_staff_els = list(t.findall('Staves/Staff'))
if _staff_els:
stave_pitches = [(_parse_tuning(s) or _track_tuning) for s in _staff_els]
else:
# No <Staves> (GP3/4/5 or old GPX): single track-level tuning.
stave_pitches = [_track_tuning]
result.append({
'_el': t,
'id': t.get('id', ''),
'name': name,
'string_pitches': string_pitches,
'string_pitches': stave_pitches[0], # primary stave (existing key)
'num_staves': num_staves,
'stave_columns': stave_columns,
'stave_pitches': stave_pitches,
'is_drums': is_drums,
'midi_program': midi_program,
'midi_channel': midi_channel,
'note_count': _note_count_for_raw(raw_idx),
'note_count': sum(_note_count_for_raw(c) for c in stave_columns),
})
bar_column += num_staves
return result
@@ -357,6 +409,121 @@ def _beat_dur_secs(beat_el: ET.Element, rhythms_dict: dict, tempo_bpm: float) ->
return dur_qn * (60.0 / tempo_bpm)
def _collect_column_notes(
col: int,
string_pitches: list[int],
*,
masterbars: list,
bars_by_id: dict,
voices_dict: dict,
beats_dict: dict,
notes_dict: dict,
rhythms_dict: dict,
tempo_map: list,
tempo_bpm: float,
audio_offset: float,
) -> list['RsNote']:
"""Walk one ``MasterBar/Bars`` column (a single stave / hand) and return its
notes as keys-encoded ``RsNote`` (``string = midi // 24``, ``fret = midi %
24``). Tie destinations extend the matching prior note's sustain (keyed by
pitch, so polyphonic parts are handled) rather than emitting a new note
mirroring the main ``convert_file`` builder, including its full-precision
timing and the 0.2s sustain threshold.
Shared by the GPX LH/RH pair merge and the GP8 multi-stave (grand-staff)
fold so the two code paths can never drift in tie / timing / dedup handling.
"""
from gp2rs import RsNote # lazy: gp2rs<->gpx circular import (see convert_file)
notes: list[RsNote] = []
last_per_key: dict[int, RsNote] = {}
tempo_iter = iter(tempo_map)
next_bar, next_bpm = next(tempo_iter, (999999, tempo_bpm))
cur_tempo = tempo_bpm
t_cursor = 0.0
for mb_idx, mb in enumerate(masterbars):
while mb_idx >= next_bar:
cur_tempo = next_bpm
next_bar, next_bpm = next(tempo_iter, (999999, cur_tempo))
ts = mb.findtext('Time', '4/4')
try:
nb, db = [int(x) for x in ts.split('/')]
except ValueError:
nb, db = 4, 4
bar_dur = nb * (4.0 / db) * (60.0 / cur_tempo)
bar_ids = mb.findtext('Bars', '').split()
bid = bar_ids[col] if col < len(bar_ids) else '-1'
if bid != '-1' and bid:
bar = bars_by_id.get(bid)
if bar is not None:
for vid in bar.findtext('Voices', '').split():
if vid == '-1':
continue
voice = voices_dict.get(vid)
if voice is None:
continue
vt = t_cursor
for beat_id in voice.findtext('Beats', '').split():
beat = beats_dict.get(beat_id)
if beat is None:
continue
dur = _beat_dur_secs(beat, rhythms_dict, cur_tempo)
for nid in beat.findtext('Notes', '').strip().split():
note_el = notes_dict.get(nid)
if note_el is None:
continue
if _note_is_tie(note_el):
tie_midi = _note_midi(note_el, string_pitches)
if tie_midi is not None:
prev = last_per_key.get(tie_midi)
tie_t = vt + audio_offset
if prev is not None and prev.time < tie_t:
prev.sustain = max(
prev.sustain, (tie_t + dur) - prev.time)
continue
midi = _note_midi(note_el, string_pitches)
if midi is None:
continue
rn = RsNote(
time=vt + audio_offset,
string=midi // 24,
fret=midi % 24,
sustain=dur if dur > 0.2 else 0.0,
)
notes.append(rn)
last_per_key[midi] = rn
vt += dur
t_cursor += bar_dur
return notes
def _merge_lh_notes(rs_notes: list, rs_chords: list, lh_notes: list) -> None:
"""Fold ``lh_notes`` (a second stave / left hand) into ``rs_notes`` in
place, de-duplicating simultaneous same-pitch notes and keeping the LONGER
sustain when both hands strike the same key at the same instant. Seeds the
dedup set from chord notes too (polyphonic RH beats live in
``rs_chords[*].notes``). No-op for an empty ``lh_notes``."""
if not lh_notes:
return
seen: dict[tuple, RsNote] = {}
for n in rs_notes:
seen.setdefault((round(n.time, 3), n.string, n.fret), n)
for c in rs_chords:
for cn in c.notes:
seen.setdefault((round(cn.time, 3), cn.string, cn.fret), cn)
for rn in lh_notes:
k = (round(rn.time, 3), rn.string, rn.fret)
existing = seen.get(k)
if existing is None:
rs_notes.append(rn)
seen[k] = rn
elif rn.sustain > existing.sustain:
# Mutating the RsNote also updates it in place inside any RH chord.
existing.sustain = rn.sustain
rs_notes.sort(key=lambda n: (n.time, n.string))
# ---------------------------------------------------------------------------
# Drum encoding tables — ported from alphaTab PercussionMapper (MIT licensed)
# ---------------------------------------------------------------------------
@@ -1356,25 +1523,84 @@ def convert_file(
rhythms_dict = {r.get('id'): r for r in (root.find('Rhythms') or [])}
_bend_divisor = _gpx_bend_scale(root) # GPIF bend value -> semitones
# Map filtered track index -> raw track index (needed for bar lookup)
raw_tracks = list(root.find('Tracks') or [])
filtered_to_raw: dict[int, int] = {}
filtered_pos = 0
for raw_idx, t_el in enumerate(raw_tracks):
name = (t_el.findtext('Name') or '').strip()
if name.startswith('@$') and name.endswith('$@'):
continue
filtered_to_raw[filtered_pos] = raw_idx
filtered_pos += 1
# Map filtered track index -> bar column (MasterBar/Bars position for
# stave 0 of that track). `_gpif_tracks` already computed the per-stave
# column layout (advancing by num_staves per track, pseudo-tracks skipped),
# so reuse its `stave_columns[0]` rather than re-deriving the counting rule
# here — divergence in stave counting *is* the bug class this fix closes.
# NB: despite the historical name, the value is a bar *column*, not a raw
# Track index — do not index `root.find('Tracks')` with it.
filtered_to_raw: dict[int, int] = {
i: t['stave_columns'][0] for i, t in enumerate(tracks)
}
# Detect and merge Piano LH+RH pairs into single full-keyboard arrangements
track_indices, _piano_merge_map = _find_piano_pairs(track_indices, tracks, names)
output_files = []
# Counts of auto-named guitar/bass arrangements so far, so multiple guitars
# get distinct RS roles (Lead, Rhythm, Combo, …) instead of all "Lead".
_role_counts: dict[str, int] = {}
# All auto-assigned arrangement names handed out so far, so multiple
# arrangements get distinct labels (Lead, Rhythm, Combo, Bass, Bass 2, …)
# and the name-aware and positional guitar paths never collide.
_used_arr_names: set[str] = set()
def _unique_arr_name(base: str) -> str:
"""Return `base`, or `base 2`/`base 3`/… if it's already been used."""
if base not in _used_arr_names:
_used_arr_names.add(base)
return base
k = 2
while f"{base} {k}" in _used_arr_names:
k += 1
name = f"{base} {k}"
_used_arr_names.add(name)
return name
_KEYS_PROGS = set(range(0, 8)) | set(range(16, 24)) | {80, 81, 82, 83}
def _auto_guitar_hint(track_idx: int):
"""For a track that auto-resolves to a guitar arrangement, return its
role hint: 'lead', 'rhythm', or '' (unhinted). None when the track is
NOT an auto-named guitar (explicitly named, bass, drum, vocal, keys).
Mirrors the per-track classification in the conversion loop below."""
if track_idx >= len(tracks) or names.get(track_idx):
return None
t = tracks[track_idx]
if t['is_drums'] or _is_vocal_track(t):
return None
low = t['name'].lower()
sp = t['string_pitches']
prog = t['midi_program']
if (isinstance(prog, int) and 32 <= prog <= 39) or (bool(sp) and max(sp) <= 48) or 'bass' in low:
return None # bass
if (not sp and prog in _KEYS_PROGS) or any(kw in low for kw in ('piano', 'keys', 'keyboard', 'organ')):
return None # keys
if 'lead' in low and 'rhythm' not in low:
return 'lead'
if 'rhythm' in low and 'lead' not in low:
return 'rhythm'
return '' # guitar, no role hint
# Two-pass guitar role naming, resolved up front so the per-track loop just
# looks names up. Reserve every name-hinted Lead/Rhythm first, THEN fill
# unhinted guitars into the remaining canonical roles. A single pass would
# let an unhinted guitar that appears BEFORE a hinted one steal its role,
# pushing the real Lead/Rhythm to a non-canonical "Rhythm 2" that the
# downstream name-based path classification doesn't recognise.
_guitar_name_by_idx: dict[int, str] = {}
_unhinted_guitars: list[int] = []
for _ti in track_indices:
hint = _auto_guitar_hint(_ti)
if hint is None:
continue
if hint == 'lead':
_guitar_name_by_idx[_ti] = _unique_arr_name('Lead')
elif hint == 'rhythm':
_guitar_name_by_idx[_ti] = _unique_arr_name('Rhythm')
else:
_unhinted_guitars.append(_ti)
for _ti in _unhinted_guitars:
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in _used_arr_names), 'Combo')
_guitar_name_by_idx[_ti] = _unique_arr_name(base)
for track_idx in track_indices:
if track_idx >= len(tracks):
@@ -1398,7 +1624,16 @@ def convert_file(
is_keys = (
not is_drum and not is_vocal
and (
any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
# A multi-stave track is a grand staff (treble + bass) — i.e. a
# keyboard-family part. Treating it as keys end-to-end keeps the
# stave-0 encoding and the folded stave-1+ encoding consistent
# (both midi//24, midi%24) and makes the `note_count` preview
# (which sums every stave column) match what actually imports,
# even for instruments the name/program heuristics miss (harp,
# celesta, marimba). GPIF writes guitars as a single Staff, so
# this does not sweep in ordinary fretted tracks.
track.get('num_staves', 1) > 1
or any(kw in track['name'].lower() for kw in ('piano', 'keys', 'keyboard', 'organ'))
or arr_name.lower().startswith('keys')
or (
not track['string_pitches']
@@ -1422,16 +1657,12 @@ def convert_file(
)
low = track['name'].lower()
if is_bass or 'bass' in low:
_bc = _role_counts.get('bass', 0)
_role_counts['bass'] = _bc + 1
arr_name = 'Bass' if _bc == 0 else f'Bass {_bc + 1}'
arr_name = _unique_arr_name('Bass')
else:
# Distinct guitar roles by appearance order so two guitars
# don't both become "Lead": Lead, Rhythm, Combo, then Combo N.
_gc = _role_counts.get('guitar', 0)
_role_counts['guitar'] = _gc + 1
_roles = ('Lead', 'Rhythm', 'Combo')
arr_name = _roles[_gc] if _gc < len(_roles) else f'Combo {_gc - 1}'
# Guitar role was resolved up front (two-pass, honoring
# "lead"/"rhythm" in the GP track name so a Rhythm-before-Lead
# file isn't swapped by positional assignment).
arr_name = _guitar_name_by_idx.get(track_idx) or _unique_arr_name('Lead')
# Vocal tracks get their own converter — outputs vocals XML, not notes XML
if is_vocal:
@@ -1468,7 +1699,6 @@ def convert_file(
pending_slides: list = [] # (RsNote, rs_string, gp_slide_flags) — resolved post-loop
current_time = 0.0
num_raw_tracks = len(raw_tracks)
# Resolve current tempo per bar from the tempo map
_tempo_iter = iter(tempo_map)
@@ -1800,112 +2030,20 @@ def convert_file(
tuning = _gpx_tuning(track)
# Merge Piano LH notes into this (RH) arrangement if a pair was detected
_walk_kwargs = dict(
masterbars=masterbars, bars_by_id=bars_by_id,
voices_dict=voices_dict, beats_dict=beats_dict,
notes_dict=notes_dict, rhythms_dict=rhythms_dict,
tempo_map=tempo_map, tempo_bpm=tempo_bpm, audio_offset=audio_offset,
)
if is_keys and track_idx in _piano_merge_map:
# GPX LH/RH pair: the left hand is a *separate* Track element. Walk
# its column and fold it into this (right-hand) arrangement.
lh_idx = _piano_merge_map[track_idx]
lh_track = tracks[lh_idx]
lh_raw_idx = filtered_to_raw.get(lh_idx, lh_idx)
_lh_notes: list[RsNote] = []
_lh_last_per_key: dict[int, RsNote] = {}
_lh_tempo_iter = iter(tempo_map)
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, tempo_bpm))
_lh_cur_tempo = tempo_bpm
_lh_time = 0.0
for _lh_mb_idx, _lh_mb in enumerate(masterbars):
while _lh_mb_idx >= _lh_next_bar:
_lh_cur_tempo = _lh_next_bpm
_lh_next_bar, _lh_next_bpm = next(_lh_tempo_iter, (999999, _lh_cur_tempo))
_lh_ts = _lh_mb.findtext('Time', '4/4')
try:
_lh_nb, _lh_db = [int(x) for x in _lh_ts.split('/')]
except ValueError:
_lh_nb, _lh_db = 4, 4
_lh_bar_dur = _lh_nb * (4.0 / _lh_db) * (60.0 / _lh_cur_tempo)
_lh_bar_ids = _lh_mb.findtext('Bars', '').split()
_lh_bid = _lh_bar_ids[lh_raw_idx] if lh_raw_idx < len(_lh_bar_ids) else '-1'
if _lh_bid != '-1' and _lh_bid:
_lh_bar = bars_by_id.get(_lh_bid)
if _lh_bar is not None:
for _lh_vid in _lh_bar.findtext('Voices', '').split():
if _lh_vid == '-1':
continue
_lh_voice = voices_dict.get(_lh_vid)
if _lh_voice is None:
continue
_lh_vt = _lh_time
for _lh_beat_id in _lh_voice.findtext('Beats', '').split():
_lh_beat = beats_dict.get(_lh_beat_id)
if _lh_beat is None:
continue
_lh_dur = _beat_dur_secs(_lh_beat, rhythms_dict, _lh_cur_tempo)
for _lh_nid in _lh_beat.findtext('Notes', '').strip().split():
_lh_note_el = notes_dict.get(_lh_nid)
if _lh_note_el is None:
continue
if _note_is_tie(_lh_note_el):
# Extend the matching prior note (same
# pitch), mirroring the main builder's
# last_note_per_key handling — blindly
# extending the last-emitted note
# mishandles polyphonic (chord) LH parts.
_tie_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
if _tie_midi is not None:
_prev = _lh_last_per_key.get(_tie_midi)
# Full-precision comparison (matching
# the main builder); rounding only
# happens at XML serialization. Rounding
# here could make a short note appear to
# start at the tie time and skip the
# sustain extension.
_tie_t = _lh_vt + audio_offset
if _prev is not None and _prev.time < _tie_t:
_prev.sustain = max(
_prev.sustain,
(_tie_t + _lh_dur) - _prev.time,
)
continue
_lh_midi = _note_midi(_lh_note_el, lh_track['string_pitches'])
if _lh_midi is None:
continue
# Keep full-precision time (like the main
# convert_file() builder — rounding happens at
# serialization); same 0.2s sustain threshold.
_lh_rn = RsNote(
time=_lh_vt + audio_offset,
string=_lh_midi // 24,
fret=_lh_midi % 24,
sustain=_lh_dur if _lh_dur > 0.2 else 0.0,
)
_lh_notes.append(_lh_rn)
_lh_last_per_key[_lh_midi] = _lh_rn
_lh_vt += _lh_dur
_lh_time += _lh_bar_dur
# Merge: combine and deduplicate simultaneous same-pitch notes, then
# sort by time. Map each (time, string, fret) to its existing RsNote
# so that when both hands hit the same key at the same instant we
# keep the LONGER sustain instead of arbitrarily discarding the LH
# one. Seed from both single notes and chord notes — polyphonic RH
# beats live in rs_chords[*].notes, so seeding from rs_notes alone
# would let an identical LH note slip in as a duplicate.
_seen: dict[tuple, RsNote] = {}
for _n in rs_notes:
_seen.setdefault((round(_n.time, 3), _n.string, _n.fret), _n)
for _c in rs_chords:
for _cn in _c.notes:
_seen.setdefault((round(_cn.time, 3), _cn.string, _cn.fret), _cn)
for _lh_rn in _lh_notes:
_k = (round(_lh_rn.time, 3), _lh_rn.string, _lh_rn.fret)
_existing = _seen.get(_k)
if _existing is None:
rs_notes.append(_lh_rn)
_seen[_k] = _lh_rn
elif _lh_rn.sustain > _existing.sustain:
# Same key both hands — preserve the longer sustain (mutating
# the RsNote also updates it in place inside any RH chord).
_existing.sustain = _lh_rn.sustain
rs_notes.sort(key=lambda n: (n.time, n.string))
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
lh_raw_idx, lh_track['string_pitches'], **_walk_kwargs))
# Collapse "Keys 2" -> "Keys": the merged LH+RH is a single
# keyboard arrangement. Keep the standard "Keys" name (not "Piano")
@@ -1913,6 +2051,18 @@ def convert_file(
# auto-select (which keys on arr_name.startswith("keys")) still work.
arr_name = re.sub(r'\s*\d+$', '', arr_name).strip() or 'Keys'
elif track.get('num_staves', 1) > 1:
# GP8 grand-staff keyboard: staves 1+ (bass clef, and any further
# staves) are extra MasterBar/Bars columns for the SAME Track
# element. Fold each one in, exactly like the GPX LH merge above.
# (num_staves > 1 implies is_keys, set above.) Iterating every
# extra column — not just stave_columns[1] — keeps the arrangement
# consistent with note_count, which sums all columns.
for _col, _sp in zip(track['stave_columns'][1:],
track['stave_pitches'][1:]):
_merge_lh_notes(rs_notes, rs_chords, _collect_column_notes(
_col, _sp, **_walk_kwargs))
# Resolve pending slides now that every note on each string is known.
# GPIF slide flags: 1=shift, 2=legato (both slide to the NEXT note on the
# string); 4=slide out downwards, 8=slide out upwards (unpitched).
@@ -1966,19 +2116,24 @@ def convert_file(
try:
import gp2notation as _gp2notation
_lh_idx = _piano_merge_map.get(track_idx)
if _lh_idx is not None:
# GPX LH/RH pair (two separate Track elements)
_nt_lh_raw = filtered_to_raw.get(_lh_idx, _lh_idx)
_nt_lh_sp = tracks[_lh_idx]['string_pitches']
elif track.get('num_staves', 1) > 1:
# GP8 two-stave piano (one Track with multiple <Staves>)
_nt_lh_raw = track['stave_columns'][1]
_nt_lh_sp = (track['stave_pitches'][1]
if len(track.get('stave_pitches', [])) > 1 else [])
else:
_nt_lh_raw, _nt_lh_sp = None, []
_payload = _gp2notation.convert_track_to_notation(
root, raw_idx, track['string_pitches'],
instrument='piano',
audio_offset=audio_offset,
track_name=track['name'],
lh_raw_idx=(
filtered_to_raw.get(_lh_idx, _lh_idx)
if _lh_idx is not None else None
),
lh_string_pitches=(
tracks[_lh_idx]['string_pitches']
if _lh_idx is not None else None
),
lh_raw_idx=_nt_lh_raw,
lh_string_pitches=_nt_lh_sp or None,
)
_gp2notation.write_notation_sidecar(filepath, _payload)
except Exception:
@@ -2317,7 +2472,15 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
if is_bass:
selected.append((i, 'bass'))
elif is_guitar:
selected.append((i, 'guitar'))
# Honor "lead"/"rhythm" in the GP track name so two guitars keep
# the author's roles instead of being labelled by appearance order
# (which swaps a Rhythm-before-Lead file). Unhinted → positional.
if 'lead' in name_l and 'rhythm' not in name_l:
selected.append((i, 'guitar_lead'))
elif 'rhythm' in name_l and 'lead' not in name_l:
selected.append((i, 'guitar_rhythm'))
else:
selected.append((i, 'guitar'))
elif is_keys:
selected.append((i, 'keys'))
@@ -2326,19 +2489,48 @@ def _auto_select_gpx(tracks: list[dict]) -> tuple[list[int], dict[int, str]]:
if not t['is_drums'] and t.get('note_count', 1) > 0:
selected.append((i, 'guitar'))
indices = []
name_map = {}
counts: dict[str, int] = {}
RS_NAMES = {'guitar': ('Lead', 'Rhythm', 'Combo'), 'bass': ('Bass',), 'keys': ('Keys',), 'drums': ('Drums',), 'vocal': ('Vocals',)}
RS_NAMES = {'bass': ('Bass',), 'keys': ('Keys',),
'drums': ('Drums',), 'vocal': ('Vocals',)}
used: set[str] = set()
def _unique(base: str) -> str:
if base not in used:
used.add(base)
return base
k = 2
while f"{base} {k}" in used:
k += 1
used.add(f"{base} {k}")
return f"{base} {k}"
# Two passes so name-hinted Lead/Rhythm guitars reserve their canonical role
# BEFORE unhinted guitars are filled in — otherwise an unhinted guitar that
# appears before a hinted one steals its role (real Rhythm → "Rhythm 2").
# Non-guitar roles are handled in pass 1. `name_map` keys by track index so
# this does not affect arrangement (selection) order, computed separately.
for idx, role in selected:
if role == 'guitar':
continue
if role == 'guitar_lead':
base = 'Lead'
elif role == 'guitar_rhythm':
base = 'Rhythm'
else:
counts[role] = counts.get(role, 0) + 1
c = counts[role]
names_for_role = RS_NAMES.get(role, (role.title(),))
base = names_for_role[min(c - 1, len(names_for_role) - 1)]
if c > len(names_for_role):
base = f"{names_for_role[-1]} {c}"
name_map[idx] = _unique(base)
for idx, role in selected:
counts[role] = counts.get(role, 0) + 1
c = counts[role]
names_for_role = RS_NAMES.get(role, (role.title(),))
arr_name = names_for_role[min(c - 1, len(names_for_role) - 1)]
if c > len(names_for_role):
arr_name = f"{names_for_role[-1]} {c}"
indices.append(idx)
name_map[idx] = arr_name
if role != 'guitar':
continue
base = next((r for r in ('Lead', 'Rhythm', 'Combo') if r not in used), 'Combo')
name_map[idx] = _unique(base)
indices = [idx for idx, _role in selected]
return indices, name_map
+56
View File
@@ -0,0 +1,56 @@
"""JSONC support — JSON with C-style comments.
Per feedpak-spec §8: when a manifest pointer resolves to a ``.jsonc`` file, a
Reader MUST strip ``//`` line comments and ``/* */`` block comments before
parsing the JSON content. This module implements that stripping in a single
shared place so every sloppak/feedpak reader in this repo parses ``.jsonc``
the same way (string-aware so comment-like text inside JSON strings survives).
The regex mirrors the reference implementation in ``feedpak-spec/tools/validate.py``.
``load_json(path)`` auto-detects ``.jsonc`` by suffix; plain ``.json`` (and any
other extension) goes straight through ``json.loads``. Use it as a drop-in
replacement for ``json.loads(path.read_text(encoding="utf-8"))``.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
# Match JSON string literals (preserved), // line comments, and /* block */
# comments. A single combined alternation processed by `sub` with a callback
# that keeps strings and replaces comments with the empty string — so
# comment-like text inside a string literal is never stripped.
_JSONC_STRIP_RE = re.compile(
r'"(?:[^"\\]|\\.)*"|' # string literal — keep as-is
r'//.*|' # // line comment — strip
r'/\*[\s\S]*?\*/', # /* block comment */ — strip
)
def parse_jsonc(text: str) -> object:
"""Parse a JSONC string, stripping C-style comments before JSON parsing.
Handles ``//`` line comments and ``/* */`` block comments, respecting
string boundaries so that comment-like text inside strings is preserved.
Raises ``json.JSONDecodeError`` on malformed JSON (after stripping).
"""
stripped = _JSONC_STRIP_RE.sub(
lambda m: m.group(0) if m.group(0).startswith('"') else '',
text,
)
return json.loads(stripped)
def load_json(path: Path) -> object:
"""Read and parse a JSON/JSONC file by path.
Files ending in ``.jsonc`` are stripped of comments via :func:`parse_jsonc`;
all other files are parsed as plain JSON. UTF-8 encoded, matching every
other reader in this repo.
"""
raw = path.read_text(encoding="utf-8")
if path.name.lower().endswith(".jsonc"):
return parse_jsonc(raw)
return json.loads(raw)
+362
View File
@@ -0,0 +1,362 @@
"""Text-matching engine for MusicBrainz metadata enrichment (P8).
Pure functions only no network, no database, no server imports so the
whole matching pipeline is unit-testable in isolation. server.py owns the
throttled HTTP transport and the song_enrichment writes; this module owns:
* denoise/tokenize: fold community chart-title noise (author suffixes,
``(440Hz)``/``(Live)``/``(No Lead)``/``(v2)`` parentheticals, punctuation,
diacritics, ``AC DC``/``ACDC``/``AC/DC`` spelling drift) into a comparable
token form,
* similarity + scoring: token-set similarity on artist+title with year and
duration proximity as corroborating bonuses,
* tier classification: auto (high) / review (medium) / none (low) the
design rule is that a WRONG match is worse than no match, so the auto
tier is deliberately strict and medium confidence goes to a human,
* MusicBrainz JSON parsing: normalize ``/ws/2`` recording documents into
the flat candidate dicts the review UI and song_enrichment store.
"""
import re
import unicodedata
# ── Tier thresholds ───────────────────────────────────────────────────────────
# Combined score = 0.5*artist_sim + 0.5*title_sim + corroboration bonuses
# (capped at 1.0). Wrong-match is worse than slow (design §5), so `auto`
# additionally requires BOTH fields to individually agree — a perfect title
# with a mismatched artist (a cover) must never auto-canonicalize, whatever
# the combined threshold is set to. AUTO_MIN is only the DEFAULT: the host
# surfaces it as the user-configurable "auto-apply confidence" setting and
# passes the chosen value into classify(auto_min=…).
AUTO_MIN = 0.90
AUTO_ARTIST_MIN = 0.8
AUTO_TITLE_MIN = 0.6
REVIEW_MIN = 0.65
YEAR_BONUS = 0.05 # candidate year within ±1 of the chart's year
DURATION_BONUS = 0.05 # candidate length within 5s of the chart's audio
DURATION_BONUS_LOOSE = 0.025 # …within 15s
_DURATION_TIGHT = 5
_DURATION_LOOSE = 15
# Release-group secondary types that mark a NON-canonical release (a live album,
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
# studio album for display and to reward studio recordings in ranking.
_SECONDARY_SKIP = {
"live", "compilation", "remix", "dj-mix", "mixtape/street",
"demo", "interview", "audiobook", "spokenword",
}
# ── Denoise ───────────────────────────────────────────────────────────────────
# A parenthetical/bracketed group is dropped when it contains any of these
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
# performance qualifiers) or when it reads as an author credit ("by X",
# "charted by X"). Both sides of a comparison are denoised symmetrically, so
# over-stripping a meaningful group costs a little precision but never
# produces an asymmetric mismatch.
_NOISE_TERMS = (
r"440\s*hz", r"a440", r"432\s*hz",
r"live", r"acoustic", r"instrumental",
r"no\s+(?:lead|rhythm|bass|vocals?|drums)",
r"(?:lead|rhythm|bass)\s+only",
r"v\d+", r"ver(?:sion)?\s*\d+",
r"remaster(?:ed)?(?:\s*\d{4})?", r"re-?recorded?",
r"fix(?:ed)?", r"updated?",
r"bonus", r"custom",
)
_NOISE_GROUP_RE = re.compile(
r"[(\[][^)\]]*\b(?:" + "|".join(_NOISE_TERMS) + r")\b[^)\]]*[)\]]",
re.IGNORECASE,
)
# Author credits: "(by SomeCharter)", "[charted by X]", "(chart by X)".
_AUTHOR_GROUP_RE = re.compile(
r"[(\[]\s*(?:chart(?:ed)?\s+)?by\s+[^)\]]*[)\]]", re.IGNORECASE)
# Trailing "- by SomeCharter" outside parens.
_AUTHOR_TAIL_RE = re.compile(r"\s+-\s+(?:chart(?:ed)?\s+)?by\s+.+$", re.IGNORECASE)
_PUNCT_RE = re.compile(r"[^\w\s]|_")
_WS_RE = re.compile(r"\s+")
def _strip_diacritics(s: str) -> str:
return "".join(
ch for ch in unicodedata.normalize("NFKD", s)
if not unicodedata.combining(ch)
)
def denoise(s, *, strip_leading_the: bool = False) -> str:
"""Fold a community metadata string into its comparable form:
lowercase, diacritics stripped, noise parentheticals and author credits
removed, punctuation collapsed to spaces. ``strip_leading_the`` drops a
leading "The " used for ARTIST comparison only ("The Beatles" ==
"Beatles"), never titles ("The Trooper" must keep its "the")."""
s = str(s or "")
s = _NOISE_GROUP_RE.sub(" ", s)
s = _AUTHOR_GROUP_RE.sub(" ", s)
s = _AUTHOR_TAIL_RE.sub(" ", s)
s = _strip_diacritics(s).casefold()
s = s.replace("&", " and ")
s = _PUNCT_RE.sub(" ", s)
s = _WS_RE.sub(" ", s).strip()
if strip_leading_the and s.startswith("the "):
s = s[4:]
return s
def tokens(s, **kw) -> list[str]:
d = denoise(s, **kw)
return d.split() if d else []
def _compact(toks: list[str]) -> str:
return "".join(toks)
def similarity(a, b, *, artist: bool = False) -> float:
"""Token-set similarity in [0, 1]. Dice coefficient over the denoised
token sets, with a compacted-string equality fold so spelling drift that
only moves token boundaries ("ACDC" / "AC DC" / "AC/DC", "Greenday" /
"Green Day") counts as identical."""
kw = {"strip_leading_the": artist}
ta, tb = tokens(a, **kw), tokens(b, **kw)
if not ta or not tb:
return 0.0
if _compact(ta) == _compact(tb):
return 1.0
sa, sb = set(ta), set(tb)
return 2.0 * len(sa & sb) / (len(sa) + len(sb))
def _year_int(v):
try:
y = int(str(v)[:4])
return y if y > 0 else None
except (TypeError, ValueError):
return None
def _duration_int(v):
try:
d = int(round(float(v)))
return d if d > 0 else None
except (TypeError, ValueError):
return None
def score_candidate(song: dict, cand: dict) -> float:
"""Combined confidence that MusicBrainz candidate `cand` is the song the
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
half classify() separately refuses to auto-match without both."""
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
title_sim = similarity(song.get("title"), cand.get("title"))
score = 0.5 * artist_sim + 0.5 * title_sim
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
if sy and cy and abs(sy - cy) <= 1:
score += YEAR_BONUS
sd, cd = _duration_int(song.get("duration")), _duration_int(cand.get("duration"))
if sd and cd:
diff = abs(sd - cd)
if diff <= _DURATION_TIGHT:
score += DURATION_BONUS
elif diff <= _DURATION_LOOSE:
score += DURATION_BONUS_LOOSE
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
# take is still the RIGHT SONG (same title/artist), so it must not change the
# auto/review confidence. Canonical-version preference lives in the RANK sort
# (rank_candidates) instead, where it only reorders same-song candidates.
return min(score, 1.0)
def classify(song: dict, cand: dict, score: float, auto_min: float | None = None) -> str:
"""Tier for a scored candidate: 'auto' | 'review' | 'none'.
`auto` (tier-2) needs the combined score AND per-field agreement AND
both fields present a perfect-title/wrong-artist cover, or a chart
with no artist at all, is at best a review item, never an auto match.
`auto_min` overrides the default combined-score threshold (the user's
"auto-apply confidence" setting); the per-field floors always apply.
"""
if auto_min is None:
auto_min = AUTO_MIN
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
title_sim = similarity(song.get("title"), cand.get("title"))
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
and title_sim >= AUTO_TITLE_MIN):
return "auto"
if score >= REVIEW_MIN:
return "review"
return "none"
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
"""Score every candidate against the song and return them sorted best-first.
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
Highway to Hell" recording) ties at the top — there the studio flag and, when
the caller knows the audio length, the duration match break the tie so the
canonical studio take wins over live/promo/extended cuts. Each returned dict
is a copy carrying `score` (rounded it's displayed and stored)."""
sd = _duration_int(song.get("duration"))
# For a chart that IS a live take (build_recording_query keeps live
# recordings for these) the studio take is the WRONG recording, so drop the
# studio tiebreak — duration proximity + text/mb score then pick the right
# live version instead of auto-matching the studio one.
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
def _dur_diff(c):
cd = _duration_int(c.get("duration"))
return abs(sd - cd) if (sd and cd) else 10 ** 6
ranked = []
for cand in candidates or []:
c = dict(cand)
c["score"] = round(score_candidate(song, cand), 4)
ranked.append(c)
ranked.sort(
key=lambda c: (c["score"],
(1 if c.get("studio") else 0) if prefer_studio else 0,
-_dur_diff(c), # closest to the audio length
c.get("mb_score") or 0),
reverse=True)
return ranked
# ── MusicBrainz query + response parsing ──────────────────────────────────────
def _lucene_escape_phrase(s: str) -> str:
"""Escape a string for use inside a quoted Lucene phrase."""
return s.replace("\\", "\\\\").replace('"', '\\"')
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
def build_recording_query(artist, title) -> str:
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
poison the search server's own scoring."""
t = denoise(title)
a = denoise(artist)
parts = []
if t:
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
if a:
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
q = " AND ".join(parts)
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
# take is never tagged Live, and this is the single biggest source of junk in
# a flat recording search. Compilations are deliberately NOT excluded: they
# REUSE the studio recording, so filtering them would drop the very recording
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
# AC/DC studio "Highway to Hell" recording entirely).
#
# EXCEPT when the source chart is itself a live take: denoise() strips the
# "(Live at …)" qualifier from the query, so filtering Live would leave the
# genuinely-live chart with NO correct recording. Only a parenthetical marker
# counts — a bare title word ("Live and Let Die") is a real word, not a live
# tag — mirroring what denoise removes.
if q and not _LIVE_GROUP_RE.search(str(title or "")):
q += " AND -secondarytype:Live"
return q
def _artist_credit(doc: dict) -> tuple[str, str, str]:
"""(display name, artist mbid, sort name) from an artist-credit array."""
credits = doc.get("artist-credit") or []
name = ""
for part in credits:
if isinstance(part, dict):
name += str(part.get("name", "")) + str(part.get("joinphrase", "") or "")
else: # ws/2 can emit bare join strings in older serializations
name += str(part)
first = next((p for p in credits if isinstance(p, dict)), None) or {}
artist = first.get("artist") or {}
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
def _is_clean_studio_album(rg: dict) -> bool:
"""A release-group that is a primary-type Album with NO non-canonical
secondary type (Live / Compilation / Remix / ) i.e. a studio album."""
if str(rg.get("primary-type", "")).lower() != "album":
return False
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
return not (secs & _SECONDARY_SKIP)
def _best_release(doc: dict) -> dict:
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
Album (primary Album with no Live/Compilation/ secondary type), then the
earliest date. Falls back to any release when none is clean. {} if none."""
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
if not releases:
return {}
def sort_key(r):
rg = r.get("release-group") or {}
clean = 0 if _is_clean_studio_album(rg) else 1
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
date = str(r.get("date", "") or "9999")
# Official FIRST, then prefer a clean studio album: this still surfaces
# the studio album over an (official) live/comp album for the display
# album/year, but never lets an UNofficial bootleg album outrank an
# official single/EP/comp — which `(clean, status_ok, …)` would.
return (status_ok, clean, date)
return sorted(releases, key=sort_key)[0]
def _genres(doc: dict, limit: int = 5) -> list[str]:
"""Genre names from a recording doc. Search results carry folksonomy
`tags`; lookups with inc=genres carry curated `genres`. Both are
[{name, count}] take the most-voted few."""
raw = doc.get("genres") or doc.get("tags") or []
entries = [e for e in raw if isinstance(e, dict) and e.get("name")]
entries.sort(key=lambda e: e.get("count") or 0, reverse=True)
return [str(e["name"]) for e in entries[:limit]]
def parse_recording_doc(doc: dict) -> dict | None:
"""Normalize one /ws/2 recording document (search hit or direct lookup)
into the flat candidate dict stored in song_enrichment.candidates and
rendered by the review drawer. Returns None for malformed docs."""
if not isinstance(doc, dict) or not doc.get("id") or not doc.get("title"):
return None
artist_name, artist_id, artist_sort = _artist_credit(doc)
release = _best_release(doc)
studio = _is_clean_studio_album(release.get("release-group") or {})
length = doc.get("length")
try:
duration = int(round(float(length) / 1000.0)) if length else None
except (TypeError, ValueError):
duration = None
isrcs = doc.get("isrcs") or []
isrcs = [str(i) for i in isrcs if isinstance(i, (str,))]
return {
"recording_id": str(doc["id"]),
"title": str(doc.get("title", "")),
"artist": artist_name,
"artist_id": artist_id,
"artist_sort": artist_sort,
"release_id": str(release.get("id", "") or ""),
"album": str(release.get("title", "") or ""),
"year": str(release.get("date", "") or "")[:4],
"duration": duration,
"isrc": isrcs[0] if isrcs else "",
"genres": _genres(doc),
"mb_score": int(doc.get("score") or 0),
"studio": studio,
}
def parse_search_response(body: dict) -> list[dict]:
"""Candidates from a /ws/2/recording search response."""
docs = (body or {}).get("recordings") or []
out = []
for doc in docs:
cand = parse_recording_doc(doc)
if cand:
out.append(cand)
return out
+21 -5
View File
@@ -114,11 +114,27 @@ def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
# Largest internal gap; ties resolve to the lowest such gap so the
# left hand keeps the tight low cluster.
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after] # lh: midi <= threshold
# Prefer middle C as the split boundary when notes straddle it —
# this correctly handles bass+treble chords from piano imports where
# the largest-gap heuristic picks the wrong split point (e.g.
# [G2, E3, C4]: largest gap is G2→E3 but the real split is E3|C4).
# BUT only when both resulting hands are themselves playable: a bass
# note under a treble voicing that merely dips below C4 (e.g.
# [E2, B3, D4, G4]) would otherwise land E2+B3 in one hand — a
# 19-semitone span that re-violates HAND_SPLIT_SPAN_SEMITONES. When
# the middle-C split produces an unplayable hand, fall back to the
# largest internal gap (which correctly isolates E2 there).
threshold = None
if pitches[0] < MIDDLE_C <= pitches[-1]:
_lh = [p for p in pitches if p < MIDDLE_C]
_rh = [p for p in pitches if p >= MIDDLE_C]
if (_lh[-1] - _lh[0] <= HAND_SPLIT_SPAN_SEMITONES
and _rh[-1] - _rh[0] <= HAND_SPLIT_SPAN_SEMITONES):
threshold = MIDDLE_C - 1 # lh: midi < MIDDLE_C
if threshold is None:
gaps = [pitches[i + 1] - pitches[i] for i in range(len(pitches) - 1)]
split_after = gaps.index(max(gaps))
threshold = pitches[split_after]
for n in group:
hands["lh" if n["midi"] <= threshold else "rh"].append(n)
else:
+7
View File
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
"""
if not name:
return None
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
# raises for an embedded NUL, so the byte would otherwise leak through
# containment. An explicit guard is strictly-more-rejection (no effect on
# the zip-slip / traversal contract).
if "\x00" in name:
return None
safe = name.replace("\\", "/")
try:
root_resolved = root.resolve()
+7 -6
View File
@@ -109,13 +109,14 @@ def _extract_meta_for_file(path: Path, dlc_root=None) -> dict:
the root it already resolved; in-process callers can pass the resolver
itself (e.g. `_get_dlc_dir`) to keep the lookup lazy.
FeedBack reads only its own `.sloppak` format and loose-folder XML
songs. Encrypted/proprietary archive formats are not supported and are
silently ignored (empty metadata) rather than decrypted.
FeedBack reads only its own song-package format (`.feedpak` / legacy
`.sloppak`) and loose-folder XML songs. Encrypted/proprietary archive
formats are not supported and are silently ignored (empty metadata)
rather than decrypted.
"""
# Sloppak is detected by `.sloppak` suffix only (cheap), so check it
# first — that way a user's loose folder named `foo.sloppak` still wins
# the sloppak branch instead of being misclassified.
# Packages are detected by suffix only (`.feedpak`/`.sloppak`, cheap), so
# check that first — that way a user's loose folder named `foo.feedpak`
# still wins the package branch instead of being misclassified.
if sloppak_mod.is_sloppak(path):
return _extract_meta_sloppak(path)
if loosefolder_mod.is_loose_song(path):
+45 -7
View File
@@ -13,7 +13,6 @@ See the format spec in the project's sloppak plan for the full layout.
from __future__ import annotations
import json
import logging
import math
import shutil
@@ -37,6 +36,7 @@ SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
import yaml
from jsonc import load_json
from safepath import safe_join
from song import (
Song,
@@ -367,6 +367,14 @@ class LoadedSloppak:
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
# absent so indexing by song.arrangements index is safe.
arrangement_ids: list[str | None] = field(default_factory=list)
# Manifest-relative path to the single full-mix audio file, taken from the
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
# pre-separation mixdown that exists alongside the per-instrument `stems`.
# None when the key is absent, points outside source_dir, or the file is
# missing on disk. Served to the front-end via the highway WS as
# `original_audio_url`; the stems plugin uses it to play the untouched mix
# when every stem slider is at unity (and the separate stems otherwise).
original_audio: str | None = None
def load_song(
@@ -415,7 +423,7 @@ def load_song(
if not arr_path.exists():
continue
try:
data = json.loads(arr_path.read_text(encoding="utf-8"))
data = load_json(arr_path)
except Exception as e:
log.debug("sloppak: failed to parse arrangement %r: %s", rel, e)
continue
@@ -481,7 +489,7 @@ def load_song(
raw_nt = None
if nt_path is not None and nt_path.exists():
try:
raw_nt = json.loads(nt_path.read_text(encoding="utf-8"))
raw_nt = load_json(nt_path)
except Exception as e:
log.warning("sloppak: failed to parse notation %r: %s", notation_rel, e)
if raw_nt is not None:
@@ -520,7 +528,7 @@ def load_song(
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = json.loads(dt_path.read_text(encoding="utf-8"))
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
@@ -580,7 +588,7 @@ def load_song(
st_path = None
if st_path is not None and st_path.exists():
try:
raw = json.loads(st_path.read_text(encoding="utf-8"))
raw = load_json(st_path)
except Exception as e:
log.warning("sloppak: failed to parse song_timeline %r: %s", song_timeline_rel, e)
raw = None
@@ -678,7 +686,7 @@ def load_song(
lyr_path = None
if lyr_path is not None and lyr_path.exists():
try:
raw = json.loads(lyr_path.read_text(encoding="utf-8"))
raw = load_json(lyr_path)
except Exception as e:
log.debug("sloppak: failed to parse lyrics %r: %s", lyrics_rel, e)
raw = None
@@ -765,7 +773,7 @@ def load_song(
k_path = None
if k_path is not None and k_path.exists():
try:
raw = json.loads(k_path.read_text(encoding="utf-8"))
raw = load_json(k_path)
except Exception as e:
log.warning("sloppak: failed to parse keys %r: %s", keys_rel, e)
raw = None
@@ -808,6 +816,29 @@ def load_song(
}
_fpv = manifest.get("feedpak_version")
# Optional full-mix audio — manifest `original_audio:` key. The single
# pre-separation mixdown that ships alongside the per-instrument stems.
# Same permissive, path-traversal-guarded posture as drum_tab above: a
# missing/escaping/absent file simply leaves the full mix unavailable (the
# player falls back to the separate stems) rather than aborting the load.
# We store the manifest-relative string so server.py can build its URL the
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
original_audio_data: str | None = None
original_audio_rel = manifest.get("original_audio")
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
rel = original_audio_rel.strip()
try:
oa_path = (source_dir / rel).resolve()
oa_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
oa_path = None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
oa_path = None
if oa_path is not None and oa_path.is_file():
original_audio_data = rel
return LoadedSloppak(
song=song,
stems=stems,
@@ -821,6 +852,7 @@ def load_song(
keys=keys_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
original_audio=original_audio_data,
)
@@ -887,6 +919,12 @@ def extract_meta(path: Path) -> dict:
"artist": str(manifest.get("artist", "")),
"album": str(manifest.get("album", "")),
"year": str(manifest.get("year", "") or ""),
# Primary genre from the feedpak `genres` list (spec 1.12.0); [0] = primary.
"genre": (lambda g: str(g[0]) if isinstance(g, list) and g else "")(manifest.get("genres")),
# Album track order from the feedpak `track`/`disc` fields (spec 1.12.0);
# None when unauthored (the album view then falls back to title order).
"track_number": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("track")),
"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
"arrangements": arrangements,
+14
View File
@@ -1090,6 +1090,20 @@ def parse_arrangement(xml_path: str) -> Arrangement:
while el.get(f"string{i}") is not None:
tuning.append(_int(el, f"string{i}"))
i += 1
# Authoritative string count, written by the GP/RS serializer
# (gp2rs._build_xml). The schema pads `<tuning>` to 6 slots, which
# erases the 4-vs-5-vs-6-string distinction for standard tunings;
# when the real count was recorded, trim the padded tail so
# arrangement_string_count / the editor see 4 or 5 instead of 6.
# Absent (archive / legacy sources) → leave the 6-slot tuning as-is.
sc = el.get("stringCount")
if sc is not None:
try:
n = int(sc)
except (TypeError, ValueError):
n = 0
if 1 <= n <= len(tuning):
tuning = tuning[:n]
# Capo
capo = 0
+61 -5
View File
@@ -10,9 +10,9 @@ source of truth, so the change survives both incremental and full rescans.
only the keys present are overwritten, so an edit of just the title can't blank
out the artist.
Only feedBack's own ``.sloppak`` format (zip- or directory-form) is writable.
Unknown / unsupported shapes return False and the caller keeps the DB-only
update.
Only feedBack's own song-package format (zip- or directory-form, ``.feedpak``
or the legacy ``.sloppak`` suffix) is writable. Unknown / unsupported shapes
return False and the caller keeps the DB-only update.
"""
from __future__ import annotations
@@ -107,19 +107,75 @@ def write_sloppak_metadata(path: Path, fields: dict) -> bool:
return _rewrite_zip_manifest(path, dumped)
def gap_fill_sloppak(path: Path, additions: dict) -> bool:
"""Append ABSENT top-level keys to a sloppak manifest (the gap-fill
contract: user-initiated, adds missing keys only, never replaces
anything the author set).
Unlike ``write_sloppak_metadata`` this does NOT re-serialize the
manifest because every added key is absent by definition, the new
lines can simply be appended, so the author's existing bytes (key
order, comments, formatting) survive verbatim. Directory form gets a
one-time ``manifest.yaml.bak`` + temp + atomic replace; zip form goes
through the same backup/temp/replace rewriter the metadata editor
uses. Returns True if anything was written; raises ``ValueError`` if
a requested key already exists (callers are expected to have checked
this is the last-line never-clobber guard)."""
import sloppak as sloppak_mod
path = Path(path)
if not additions:
return False
manifest = sloppak_mod.load_manifest(path) or {}
clash = sorted(k for k in additions if k in manifest)
if clash:
raise ValueError("gap-fill refused: key(s) already present: " + ", ".join(clash))
if path.is_dir():
mf = path / "manifest.yaml"
if not mf.exists() and (path / "manifest.yml").exists():
mf = path / "manifest.yml"
original = mf.read_text(encoding="utf-8")
else:
with zipfile.ZipFile(str(path), "r") as zin:
names = zin.namelist()
manifest_name = "manifest.yaml"
for cand in ("manifest.yaml", "manifest.yml"):
if cand in names:
manifest_name = cand
break
original = zin.read(manifest_name).decode("utf-8")
appended = original if original.endswith("\n") or not original else original + "\n"
appended += yaml.safe_dump(additions, sort_keys=False, allow_unicode=True)
if path.is_dir():
backup = mf.with_name(mf.name + ".bak")
if not backup.exists():
shutil.copy2(mf, backup)
tmp = mf.with_name(mf.name + ".tmp")
tmp.write_text(appended, encoding="utf-8")
tmp.replace(mf)
return True
return _rewrite_zip_manifest(path, appended)
def write_song_metadata(path: Path, fields: dict) -> bool:
"""Persist edited title/artist/album/year into the song's file.
Dispatches by shape: ``.sloppak`` files and sloppak directories
Dispatches by shape: zip-form song packages (``.feedpak`` / legacy
``.sloppak``, per ``sloppak.SONG_EXTS``) and package directories
(manifest.yaml present). Loose-folder and unknown shapes return False
(caller keeps the DB-only update). Returns True if the file was modified.
"""
from sloppak import SONG_EXTS
path = Path(path)
suffix = path.suffix.lower()
if path.is_dir():
if (path / "manifest.yaml").exists() or (path / "manifest.yml").exists():
return write_sloppak_metadata(path, fields)
return False
if suffix == ".sloppak":
if suffix in SONG_EXTS:
return write_sloppak_metadata(path, fields)
return False
+364 -33
View File
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
FastAPI / SQLite module-level side effects.
"""
from __future__ import annotations
import math
DEFAULT_REFERENCE_PITCH = 440.0
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
# tuning name. This is the authoritative source; tuner/routes.py previously
# held a copy — it was removed in favour of this one.
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
# Canonical open strings, low to high, as MIDI notes. This is the host-level
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
# frequencies, and semitone offsets from these absolute pitches.
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
"guitar-6": [40, 45, 50, 55, 59, 64],
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
"bass-4": [28, 33, 38, 43],
"bass-5": [23, 28, 33, 38, 43],
"bass-6": [23, 28, 33, 38, 43, 48],
}
# Curated built-in profiles. This intentionally starts by absorbing the useful
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
# tuner, practice tools, and plugins can converge on one profile model.
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
"guitar-6": {
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
"Standard": [40, 45, 50, 55, 59, 64],
"Eb Standard": [39, 44, 49, 54, 58, 63],
"D Standard": [38, 43, 48, 53, 57, 62],
"C# Standard": [37, 42, 47, 52, 56, 61],
"C Standard": [36, 41, 46, 51, 55, 60],
"Drop D": [38, 45, 50, 55, 59, 64],
"Drop C": [36, 43, 48, 53, 57, 62],
"Drop B": [35, 42, 47, 52, 56, 61],
"Drop A": [33, 40, 45, 50, 54, 59],
"Drop Ab": [32, 39, 44, 49, 53, 58],
"Open G": [38, 43, 50, 55, 59, 62],
"Open D": [38, 45, 50, 54, 57, 62],
"DADGAD": [38, 45, 50, 55, 57, 62],
"Open E": [40, 47, 52, 56, 59, 64],
},
"guitar-7": {
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
"Standard": [35, 40, 45, 50, 55, 59, 64],
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
"A Standard": [33, 38, 43, 48, 53, 57, 62],
"G Standard": [31, 36, 41, 46, 51, 55, 60],
"Drop A": [33, 40, 45, 50, 55, 59, 64],
"Drop G": [31, 38, 43, 48, 53, 57, 62],
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
},
"guitar-8": {
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
},
"bass-4": {
"Standard": [41.20, 55.00, 73.42, 98.00],
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
"Drop D": [36.71, 55.00, 73.42, 98.00],
"D Standard": [36.71, 48.99, 65.41, 87.31],
"Drop C": [32.70, 48.99, 65.41, 87.31],
"Standard": [28, 33, 38, 43],
"Eb Standard": [27, 32, 37, 42],
"D Standard": [26, 31, 36, 41],
"C# Standard": [25, 30, 35, 40],
"C Standard": [24, 29, 34, 39],
"Drop D": [26, 33, 38, 43],
"Drop C": [24, 31, 36, 41],
"BEAD": [23, 28, 33, 38],
},
"bass-5": {
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
"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],
"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],
},
}
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
"""Return the frequency for a MIDI note at the supplied A4 reference."""
return reference_pitch * math.pow(2, (midi - 69) / 12)
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
"""Return rounded frequencies for low-to-high MIDI open strings."""
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
"""Return semitone offsets from the instrument's standard open strings."""
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
if not standard or len(standard) != len(midis):
return None
return [int(m - s) for m, s in zip(midis, standard)]
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
"""Return absolute open-string MIDI notes for host semitone offsets."""
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
if not standard or len(standard) != len(offsets):
return None
return [int(s + o) for s, o in zip(standard, offsets)]
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
"""Return host semitone offsets for a named preset."""
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
if not midis:
return None
return tuning_offsets_from_midis(instrument_key, midis)
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
# tuning name. Kept for the existing /api/tunings contract.
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
instrument: {
name: open_midis_to_freqs(midis)
for name, midis in presets.items()
}
for instrument, presets in TUNING_PRESET_MIDIS.items()
}
@@ -67,6 +148,256 @@ def apply_reference_pitch(
}
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
PROFILE_DEFAULTS: dict[str, dict] = {
"guitar-lead": {
"id": "guitar-lead",
"label": "Lead Guitar",
"instrument": "guitar",
"role": "lead",
"string_count": 6,
"tuning": "Standard",
"reference_pitch": DEFAULT_REFERENCE_PITCH,
"pathway": "songs",
},
"guitar-rhythm": {
"id": "guitar-rhythm",
"label": "Rhythm Guitar",
"instrument": "guitar",
"role": "rhythm",
"string_count": 6,
"tuning": "Standard",
"reference_pitch": DEFAULT_REFERENCE_PITCH,
"pathway": "songs",
},
"bass": {
"id": "bass",
"label": "Bass",
"instrument": "bass",
"role": "bass",
"string_count": 4,
"tuning": "Standard",
"reference_pitch": DEFAULT_REFERENCE_PITCH,
"pathway": "songs",
},
}
def instrument_key(instrument: str, string_count: int) -> str:
return f"{instrument}-{string_count}"
def default_instrument_profiles() -> dict[str, dict]:
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
def _valid_reference_pitch(value) -> float | None:
if isinstance(value, bool):
return None
try:
ref = float(value)
except (TypeError, ValueError, OverflowError):
return None
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
return None
return ref
def _valid_tuning_for_key(key: str, tuning):
if isinstance(tuning, str):
if len(tuning) > 64:
return None
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
return tuning
# A name that IS a built-in preset for a different key is a misapplied
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
# reject it. A name unknown to every built-in table is a provider/custom
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
# layer can't resolve — accept it so settings round-trip; the provider
# owns its validity.
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
return None
return tuning
if isinstance(tuning, list):
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
if len(tuning) != expected:
return None
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
return None
return list(tuning)
return None
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
"""Validate one persisted host instrument profile."""
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
if not base:
return None, f"unknown instrument profile: {profile_id}"
if raw is None:
return base, None
if not isinstance(raw, dict):
return None, f"instrument_profiles.{profile_id} must be an object"
instrument = raw.get("instrument", base["instrument"])
if instrument not in ("guitar", "bass"):
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
try:
string_count = int(raw.get("string_count", base["string_count"]))
except (TypeError, ValueError, OverflowError):
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
key = instrument_key(instrument, string_count)
if key not in STANDARD_OPEN_MIDIS:
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
if tuning is None:
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
if ref is None:
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
label = raw.get("label", base["label"])
if not isinstance(label, str) or len(label) > 64:
return None, f"instrument_profiles.{profile_id}.label must be a short string"
role = raw.get("role", base["role"])
if not isinstance(role, str) or len(role) > 32:
return None, f"instrument_profiles.{profile_id}.role must be a short string"
pathway = raw.get("pathway", base["pathway"])
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
out = dict(base)
out.update({
"id": profile_id,
"label": label,
"instrument": instrument,
"role": role,
"string_count": string_count,
"tuning": tuning,
"reference_pitch": ref,
"pathway": pathway,
})
return out, None
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
if raw_profiles is None:
return default_instrument_profiles(), None
if not isinstance(raw_profiles, dict):
return None, "instrument_profiles must be an object"
profiles = {}
for profile_id in PROFILE_IDS:
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
if error:
return None, error
profiles[profile_id] = profile
return profiles, None
def active_profile_id(raw) -> str:
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
def profile_from_legacy_settings(cfg: dict) -> dict:
"""Build an active profile from the old flat settings keys."""
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
fallback_sc = 4 if instrument == "bass" else 6
try:
sc = int(cfg.get("string_count", fallback_sc))
except (TypeError, ValueError, OverflowError):
sc = fallback_sc
key = instrument_key(instrument, sc)
if key not in STANDARD_OPEN_MIDIS:
sc = fallback_sc
key = instrument_key(instrument, sc)
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
profile = dict(PROFILE_DEFAULTS[profile_id])
profile.update({
"instrument": instrument,
"string_count": sc,
"tuning": tuning,
"reference_pitch": ref,
"pathway": pathway,
})
return profile
def settings_with_instrument_profiles(cfg: dict) -> dict:
"""Return settings with canonical host profiles and mirrored flat keys."""
out = dict(cfg)
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
if profiles is None:
profiles = default_instrument_profiles()
if "instrument_profiles" not in out:
legacy = profile_from_legacy_settings(out)
profiles[legacy["id"]] = legacy
# Default the active profile to the one migrated from the legacy flat
# fields, but DON'T clobber an explicit request — a fresh-config
# `POST {"active_instrument_profile": "bass"}` must switch, not be
# overwritten by the guitar-lead inferred from defaults. active_profile_id
# below normalizes an invalid value.
out.setdefault("active_instrument_profile", legacy["id"])
active = active_profile_id(out.get("active_instrument_profile"))
selected = profiles[active]
out["instrument_profiles"] = profiles
out["active_instrument_profile"] = active
out["instrument"] = selected["instrument"]
out["string_count"] = selected["string_count"]
out["tuning"] = selected["tuning"]
out["reference_pitch"] = selected["reference_pitch"]
out["pathway"] = selected["pathway"]
return out
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
"""Mirror legacy flat instrument updates into the active host profile."""
out = settings_with_instrument_profiles(cfg)
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
return out
active = active_profile_id(out.get("active_instrument_profile"))
if "instrument" in updates:
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
out["active_instrument_profile"] = active
current = dict(out["instrument_profiles"][active])
if "instrument" in updates:
current["instrument"] = updates["instrument"]
if "string_count" not in updates:
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
if "string_count" in updates:
current["string_count"] = updates["string_count"]
if "reference_pitch" in updates:
current["reference_pitch"] = updates["reference_pitch"]
if "pathway" in updates:
current["pathway"] = updates["pathway"]
if "tuning" in updates:
current["tuning"] = updates["tuning"]
else:
key = instrument_key(current["instrument"], current["string_count"])
if _valid_tuning_for_key(key, current.get("tuning")) is None:
current["tuning"] = "Standard"
profile, error = normalize_instrument_profile(active, current)
if error:
raise ValueError(error)
out["instrument_profiles"][active] = profile
out.update({
"instrument": profile["instrument"],
"string_count": profile["string_count"],
"tuning": profile["tuning"],
"reference_pitch": profile["reference_pitch"],
"pathway": profile["pathway"],
})
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
+30
View File
@@ -1330,6 +1330,18 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
_category = manifest.get("category")
if not isinstance(_category, str) or not _category:
_category = None
# Settings-tab placement (tabbed settings page). When `settings` is a
# dict, an optional `category` field names which settings tab the
# plugin's panel mounts under (e.g. "graphics", "mic", "progression").
# Distinct from the top-level `category` above (which drives Pedalboard
# grouping) so the two don't collide. Absent/blank → None → the
# frontend falls back to the generic "Plugins" tab.
_settings_manifest = manifest.get("settings")
_settings_category = None
if isinstance(_settings_manifest, dict):
_sc = _settings_manifest.get("category")
if isinstance(_sc, str) and _sc:
_settings_category = _sc
_icon = manifest.get("icon")
if not isinstance(_icon, str) or not _icon:
_icon = None
@@ -1338,6 +1350,13 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
_icon = "assets/thumb.png"
except OSError:
_icon = None
# Immersive (full-screen) screen opt-in. A plugin that declares a
# top-level `"fullscreen": true` gets the whole content area when its
# screen is active: the v3 shell hides the topbar and collapses the
# sidebar to an icon rail (see static/v3/shell.js + v3.css). For
# DAW-style plugin UIs that need the viewport, not a scrolling content
# page. Strict `is True` so a stray truthy value can't silently opt in.
_fullscreen = manifest.get("fullscreen") is True
return {
"id": plugin_id,
"name": manifest.get("name", plugin_id),
@@ -1355,6 +1374,10 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
"has_screen": bool(manifest.get("screen")),
"has_script": bool(manifest.get("script")),
"has_settings": bool(manifest.get("settings")),
"settings_category": _settings_category,
# Drives the v3 shell's immersive (full-screen) mode for this
# plugin's screen. False unless the manifest declares it explicitly.
"fullscreen": _fullscreen,
"has_tour": _is_valid_tour_manifest(manifest.get("tour")),
# `styles` is an optional relpath (under the plugin's assets/) to a
# compiled, preflight-off stylesheet the frontend injects as a
@@ -2067,6 +2090,11 @@ def register_plugin_api(app: FastAPI):
"has_screen": p["has_screen"],
"has_script": p["has_script"],
"has_settings": p["has_settings"],
# v3 immersive screen opt-in (full-screen plugin UI).
"fullscreen": p.get("fullscreen", False),
# Settings-tab placement; None when the manifest's `settings`
# is absent, a bare string, or omits `category`.
"settings_category": p.get("settings_category"),
"has_tour": p.get("has_tour", False),
# `.get()` fallbacks keep stubbed test entries (built without
# _nav_entry) working — styles is None when unset.
@@ -2115,6 +2143,8 @@ def register_plugin_api(app: FastAPI):
"has_screen": e.get("has_screen", False),
"has_script": e.get("has_script", False),
"has_settings": e.get("has_settings", False),
"settings_category": e.get("settings_category"),
"fullscreen": e.get("fullscreen", False),
"has_tour": e.get("has_tour", False),
"has_styles": e.get("has_styles", False),
"styles": e.get("styles"),
+68
View File
@@ -0,0 +1,68 @@
{
"version": 1,
"global": [
{
"id": "first_steps",
"title": "First Steps",
"description": "Reach Mastery Rank 1 — finish onboarding.",
"category": "global",
"sourceId": "achievements",
"criterion": { "type": "mastery_rank", "tiers": [1] },
"tiers": [1]
},
{
"id": "ascendant",
"title": "Ascendant",
"description": "Climb the Mastery Rank ladder.",
"category": "global",
"sourceId": "achievements",
"criterion": { "type": "mastery_rank", "tiers": [10, 25, 50] },
"tiers": [10, 25, 50],
"tier_titles": ["Ascendant I", "Ascendant II", "Ascendant III"]
},
{
"id": "steady_hands",
"title": "Steady Hands",
"description": "Make a real advance on many separate days.",
"category": "global",
"sourceId": "achievements",
"criterion": { "type": "growth_streak_days", "tiers": [7, 30, 100] },
"tiers": [7, 30, 100],
"tier_titles": ["Steady Hands (7)", "Steady Hands (30)", "Steady Hands (100)"]
},
{
"id": "renaissance",
"title": "Renaissance",
"description": "Reach Level 10 across several distinct instrument paths.",
"category": "global",
"sourceId": "achievements",
"criterion": { "type": "paths_at_level", "level": 10, "tiers": [2, 3, 5] },
"tiers": [2, 3, 5],
"tier_titles": ["Renaissance (2)", "Renaissance (3)", "Renaissance (5)"]
}
],
"per_instrument": [
{
"id": "path_rank",
"title": "{Inst} Mastery",
"description": "Climb the {Inst} path: Apprentice, Journeyman, Master.",
"sourceId": "achievements",
"criterion": { "type": "path_level", "tiers": [10, 25, "max"] },
"tier_titles": ["{Inst} Apprentice", "{Inst} Journeyman", "{Inst} Master"]
},
{
"id": "personal_best",
"title": "Personal Best ({Inst})",
"description": "Beat your own best accuracy on any {Inst} chart.",
"sourceId": "achievements",
"criterion": { "type": "personal_best" }
},
{
"id": "challenger",
"title": "Challenger ({Inst})",
"description": "Clear a full level-up challenge set on the {Inst} path.",
"sourceId": "achievements",
"criterion": { "type": "challenge_set_cleared" }
}
]
}
@@ -0,0 +1,84 @@
/* Achievements & Feats of Power plugin styles (plain CSS, no Tailwind build;
* P-II: plugins ship their own stylesheet for non-core classes). Mirrors the v3
* dark surface tokens used across the Profile page. */
/* Secondary category pill row inside the Achievements tab (lighter .fb-tab). */
.fb-ach-pillrow {
display: flex;
flex-wrap: wrap;
gap: .4rem;
margin-bottom: 1rem;
}
.fb-ach-pill {
appearance: none;
background: rgba(30, 41, 59, .5);
border: 1px solid rgba(51, 65, 85, .6);
color: #94a3b8;
border-radius: 9999px;
padding: .35rem .75rem;
font-size: .8rem;
font-weight: 600;
cursor: pointer;
transition: color .15s, border-color .15s, background .15s;
}
.fb-ach-pill:hover { color: #e2e8f0; border-color: rgba(14, 165, 233, .4); }
.fb-ach-pill.active { color: #f8fafc; background: rgba(14, 165, 233, .15); border-color: #0ea5e9; }
.fb-ach-pill-badge {
font-size: .7rem;
font-weight: 700;
color: #64748b;
margin-left: .15rem;
}
.fb-ach-pill.active .fb-ach-pill-badge { color: #7dd3fc; }
/* Catalogue list. */
.fb-ach-list { display: flex; flex-direction: column; gap: .5rem; }
.fb-ach-item {
display: flex;
align-items: flex-start;
gap: .85rem;
padding: .75rem .9rem;
border-radius: .6rem;
border: 1px solid rgba(51, 65, 85, .5);
background: rgba(30, 41, 59, .35);
}
.fb-ach-item.locked { opacity: .45; filter: grayscale(.6); }
.fb-ach-item.earned { border-color: rgba(14, 165, 233, .35); background: rgba(14, 165, 233, .06); }
.fb-ach-item-icon { font-size: 1.4rem; line-height: 1.6rem; flex: 0 0 auto; }
.fb-ach-item-body { min-width: 0; }
.fb-ach-item-title {
font-size: .9rem;
font-weight: 700;
color: #f1f5f9;
display: flex;
align-items: center;
gap: .4rem;
}
.fb-ach-tier {
font-size: .65rem;
text-transform: uppercase;
letter-spacing: .04em;
font-weight: 700;
color: #7dd3fc;
border: 1px solid rgba(125, 211, 252, .35);
border-radius: 9999px;
padding: .05rem .4rem;
}
.fb-ach-item-desc { font-size: .78rem; color: #94a3b8; margin-top: .15rem; }
/* Feats of Power trophy shelf (profile main page). */
.fb-feat-shelf {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: .75rem;
}
.fb-feat-card {
text-align: center;
padding: 1rem .75rem;
border-radius: .7rem;
border: 1px solid rgba(234, 179, 8, .35);
background: linear-gradient(180deg, rgba(234, 179, 8, .1), rgba(30, 41, 59, .3));
}
.fb-feat-icon { font-size: 1.9rem; }
.fb-feat-title { font-size: .85rem; font-weight: 800; color: #fde68a; margin-top: .3rem; }
.fb-feat-desc { font-size: .72rem; color: #cbd5e1; margin-top: .25rem; }
+191
View File
@@ -0,0 +1,191 @@
"""Achievements & Feats of Power — pure evaluation helpers.
This module holds the side-effect-free core of the engine so it is unit
testable (constitution P-V): no IO, no SQLite, no clock. `routes.py` owns the
storage/HTTP shell and calls into these functions.
**Integration law (structural):** Feats are evaluated from *activity counters*
only (`evaluate_feats` / `apply_activity`); competency Achievements are recorded
from *competency events* the source reports (`report-unlock`). Nothing here ever
converts an activity count into a competency unlock or vice versa.
"""
from __future__ import annotations
# Counter keys the activity model owns. `*_max` keys take the running maximum;
# everything else is a cumulative running total. Kept here (not in routes) so a
# test can assert the contract without standing up a DB.
MAX_COUNTERS = frozenset({"notes_session_max", "streak_insong_max", "chart_encore_max"})
def tier_index_for(tiers, value):
"""Highest 0-based tier index whose threshold is met by ``value``.
Returns -1 when no tier is reached. Tiers are assumed ascending; we scan
all of them rather than short-circuit so an out-of-order catalogue still
resolves to the largest satisfied tier.
"""
idx = -1
for i, threshold in enumerate(tiers or []):
try:
if value >= threshold:
idx = i
except TypeError:
continue
return idx
def feat_counter_value(feat, counters):
"""Activity-counter value backing a Feat definition (0 when absent)."""
key = feat.get("counter")
if not key:
return 0
try:
return int(counters.get(key, 0) or 0)
except (TypeError, ValueError):
return 0
def evaluate_feats(feat_defs, counters):
"""Map ``feat_id -> highest reached tier index`` for all satisfied Feats.
A Feat with no tiers, or whose counter hasn't reached tier 0, is omitted.
Pure: takes the current counters snapshot, returns a plain dict.
"""
out = {}
for feat in feat_defs or []:
fid = feat.get("id")
if not fid:
continue
tiers = feat.get("tiers") or []
if not tiers:
continue
ti = tier_index_for(tiers, feat_counter_value(feat, counters))
if ti >= 0:
out[fid] = ti
return out
def apply_activity(counters, delta):
"""Return a NEW counters dict after folding in one activity ``delta``.
Cumulative keys add; ``*_max`` keys keep the running maximum. The caller
(routes.py) is responsible for the only stateful bit the per-chart play
count and passes the post-increment value as ``delta['chart_play_count']``
so this function stays pure.
Recognised delta fields (all optional, default 0):
notes -> notes_total (+=)
song_done -> songs_done (+=)
seconds -> time_total_seconds (+=)
session_notes -> notes_session_max (max)
in_song_streak -> streak_insong_max (max)
chart_play_count -> chart_encore_max (max)
"""
out = dict(counters or {})
def _cur(key):
try:
return int(out.get(key, 0) or 0)
except (TypeError, ValueError):
return 0
def _int(v):
try:
return int(v or 0)
except (TypeError, ValueError):
return 0
out["notes_total"] = _cur("notes_total") + _int(delta.get("notes"))
out["songs_done"] = _cur("songs_done") + _int(delta.get("song_done"))
out["time_total_seconds"] = _cur("time_total_seconds") + _int(delta.get("seconds"))
out["notes_session_max"] = max(_cur("notes_session_max"), _int(delta.get("session_notes")))
out["streak_insong_max"] = max(_cur("streak_insong_max"), _int(delta.get("in_song_streak")))
if delta.get("chart_play_count") is not None:
out["chart_encore_max"] = max(_cur("chart_encore_max"), _int(delta.get("chart_play_count")))
return out
def consecutive_run_length(dates):
"""Longest run of consecutive calendar dates in ``dates`` (ISO 'YYYY-MM-DD').
Used by the `secret_witching` Feat (practise in the 25am window on N
consecutive nights). Pure date arithmetic so it's unit-testable; routes.py
feeds it the distinct night-dates recorded in `comp_ledger`.
"""
from datetime import date
parsed = []
for d in dates or []:
try:
y, m, dd = (int(x) for x in str(d).split("-"))
parsed.append(date(y, m, dd))
except (ValueError, TypeError):
continue
if not parsed:
return 0
parsed = sorted(set(parsed))
best = run = 1
for prev, cur in zip(parsed, parsed[1:]):
if (cur - prev).days == 1:
run += 1
best = max(best, run)
else:
run = 1
return best
# ── Data-minimization contract (binding, code-enforced) ──────────────────────
# The wall payload key-set is frozen here and asserted by a unit test. The
# serializer below is the ONLY way outbound data is built — never dict(row) or
# **model — so a stray field cannot leak. Adding a key makes the test go red.
WALL_PAYLOAD_KEYS = ("display_name", "player_hash", "achievement_id", "unlocked_at")
def build_wall_payload(display_name, player_hash, achievement_id, unlocked_at):
"""Build the EXACT four-field wall payload. ``achievement_id`` must always be
a Feat id (the caller only ever invokes this for Feat unlocks competency
never syncs). Explicit literal dict on purpose; do not refactor into a
row/model splat."""
return {
"display_name": display_name,
"player_hash": player_hash,
"achievement_id": achievement_id,
"unlocked_at": unlocked_at,
}
def drain_decision(status):
"""Dead-letter state machine for one wall-sync attempt (pure).
``status`` is the HTTP status code, or ``None`` for a network error.
Returns one of:
'ack' server accepted; delete the row.
'retry' keep it pending (network error, 429 backoff, or 5xx).
'dead' any other 4xx; move to dead_letter (diagnosable/replayable).
A row is NEVER silently dropped it leaves the queue only on 'ack' (or a
user opt-out wiping it).
"""
if status is None:
return "retry"
if 200 <= status < 300:
return "ack"
if status == 429:
return "retry"
if 400 <= status < 500:
return "dead"
return "retry" # 5xx — transient server-side, try again later
def diff_unlocks(prev_tiers, new_tiers):
"""Feat ids whose tier advanced (incl. first unlock).
``prev_tiers`` / ``new_tiers`` are ``feat_id -> tier_index`` maps as
returned by :func:`evaluate_feats`. Returns the ids that are newly present
or moved to a higher tier i.e. the Feats to record + announce this round.
"""
out = []
for fid, tier in (new_tiers or {}).items():
if tier > prev_tiers.get(fid, -1):
out.append(fid)
return out
+101
View File
@@ -0,0 +1,101 @@
{
"version": 1,
"feats": [
{
"id": "notes_total",
"title": "Note Hunter",
"description": "Hit a colossal number of notes across all your practice.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": true,
"counter": "notes_total",
"tiers": [100000, 1000000, 10000000],
"tier_titles": ["Note Hunter", "Million-Note Maestro", "Ten-Million-Note Titan"]
},
{
"id": "notes_session",
"title": "Marathon",
"description": "Hit 25,000 notes in a single sitting.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": true,
"counter": "notes_session_max",
"tiers": [25000],
"tier_titles": ["Marathon"]
},
{
"id": "streak_insong",
"title": "Untouchable",
"description": "Land a huge run of consecutive in-song hits with no miss.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": true,
"counter": "streak_insong_max",
"tiers": [1000, 5000],
"tier_titles": ["Untouchable", "Truly Untouchable"]
},
{
"id": "songs_done",
"title": "Road Warrior",
"description": "Finish a mountain of songs.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": false,
"counter": "songs_done",
"tiers": [1000, 5000],
"tier_titles": ["Road Warrior", "Road Legend"]
},
{
"id": "time_total",
"title": "Time Served",
"description": "Pour hundreds of hours into practice.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": false,
"counter": "time_total_seconds",
"tiers": [1800000, 7200000],
"tier_titles": ["Time Served (500h)", "Time Served (2,000h)"]
},
{
"id": "chart_encore",
"title": "Encore",
"description": "Play the same chart again and again and again.",
"category": "global",
"sourceId": "achievements",
"secret": false,
"needs_notedetect": false,
"counter": "chart_encore_max",
"tiers": [100, 500],
"tier_titles": ["Encore", "Standing Ovation"]
},
{
"id": "secret_witching",
"title": "The Witching Hour",
"description": "Practise in the dead of night, seven nights running.",
"category": "global",
"sourceId": "achievements",
"secret": true,
"needs_notedetect": false,
"counter": "witching_nights_run",
"tiers": [7],
"tier_titles": ["The Witching Hour"]
},
{
"id": "secret_combo",
"title": "Hidden Track",
"description": "You found the hidden track.",
"category": "global",
"sourceId": "achievements",
"secret": true,
"needs_notedetect": false,
"counter": null,
"tiers": [],
"tier_titles": ["Hidden Track"]
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"id": "achievements",
"name": "Achievements",
"version": "0.1.0",
"bundled": true,
"private": false,
"description": "Achievements & Feats of Power — skill milestones on your Profile, plus rare activity Feats.",
"script": "screen.js",
"styles": "assets/achievements.css",
"settings": {
"html": "settings.html",
"category": "system",
"server_files": [
"achievements/"
]
},
"routes": "routes.py"
}
+585
View File
@@ -0,0 +1,585 @@
"""Achievements & Feats of Power — local engine (offline).
State lives under ``<config_dir>/achievements/``:
- ``achievements.db`` SQLite unlocks, activity counters, derived ledger,
and the (PR3) wall sync queue.
Two surfaces, one engine, kept structurally apart (the **integration law**):
* **Feats of Power** activity/volume. The engine OWNS raw activity counters
(`counters`), evaluates Feat thresholds, and records Feat unlocks. Feats are
the only thing that ever syncs to the public wall.
* **Achievements** demonstrated competency. The engine RECORDS unlocks the
source reports (`report-unlock`); it never re-derives them from activity.
A baseline catalogue ships here and is driven by the built-in progression
system; richer items are contributed by source plugins at runtime.
Endpoints (all under /api/plugins/achievements/):
POST /activity bump activity counters, eval Feats, return newly-unlocked
POST /report-unlock idempotent upsert of a competency/feat unlock
POST /report-criterion record a (criterion_id, token) pair distinct count
GET /catalog baseline competency defs + earned state
GET /earned all earned items (id, cls, category, tier, at)
GET /feats earned Feats (for the profile trophy shelf)
POST /remove-me wipe synced state (full wall-removal lands in PR2/PR3)
Pure threshold/criterion math lives in the sibling ``engine.py`` (P-V testable);
this module is the SQLite + HTTP shell.
"""
import hashlib
import json
import logging
import os
import sqlite3
import threading
import time
from pathlib import Path
from pydantic import BaseModel, Field
_lock = threading.Lock()
_state = {
"db_path": None,
"dir": None, # plugin directory (for catalog JSON)
"config_dir": None, # CONFIG_DIR (for reading the opt-in setting)
"meta_db": None, # MetadataDB (for the profile identity: name + hash)
"log": logging.getLogger("feedBack.plugin.achievements"),
"engine": None, # sibling engine.py module (pure helpers)
"feat_defs": [], # parsed feats.json -> list of feat defs
"baseline": {}, # parsed achievements.json
}
# ── SQLite ──────────────────────────────────────────────────────────────────
def _conn():
conn = sqlite3.connect(_state["db_path"], timeout=5)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_db():
conn = _conn()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS unlocks (
achievement_id TEXT PRIMARY KEY,
cls TEXT NOT NULL, -- 'competency' | 'feat'
disp_category TEXT, -- global/guitar/bass/...
source_id TEXT,
tier INTEGER NOT NULL DEFAULT 0,
unlocked_at TEXT,
synced INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS counters (
key TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS comp_ledger (
criterion_id TEXT NOT NULL,
token TEXT NOT NULL,
PRIMARY KEY (criterion_id, token)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, -- 'unlock' | 'remove'
payload TEXT NOT NULL,
state TEXT NOT NULL DEFAULT 'pending' -- 'pending' | 'dead_letter'
)
"""
)
conn.commit()
finally:
conn.close()
def _now_iso():
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _opted_in():
"""True only when the user has opted in (core setting ``achievements_enabled``).
Read straight from CONFIG_DIR/config.json the single source of truth the
/api/settings endpoint persists. Default OFF on any read failure: nothing
leaves the device unless explicitly enabled.
"""
try:
cfg_path = Path(_state["config_dir"]) / "config.json"
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
return bool(cfg.get("achievements_enabled") is True)
except (OSError, ValueError, TypeError):
return False
def _identity():
"""(display_name, player_hash) from the profile, or (None, None).
Reused as the wall identity (server.py's documented player_hash). Sync is
skipped entirely when either is missing.
"""
db = _state["meta_db"]
if db is None or not hasattr(db, "get_profile"):
return None, None
try:
prof = db.get_profile() or {}
return (prof.get("display_name") or None), (prof.get("player_hash") or None)
except Exception: # noqa: BLE001 — identity is best-effort; never break a request
return None, None
def _enqueue_feat_sync(conn, feat_id, unlocked_at):
"""Enqueue a wall-sync POST for a Feat unlock — opt-in gated, identity gated.
Builds the outbound payload through the SINGLE code-gated serializer
(engine.build_wall_payload, exactly four fields). Competency unlocks never
reach this path (integration law + data-minimization contract). The drain
worker (PR3) POSTs the queued rows; here we only persist intent.
"""
if not _opted_in():
return False
display_name, player_hash = _identity()
if not display_name or not player_hash:
return False
payload = _state["engine"].build_wall_payload(display_name, player_hash, feat_id, unlocked_at)
conn.execute(
"INSERT INTO sync_queue(kind, payload, state) VALUES ('unlock', ?, 'pending')",
(json.dumps(payload),),
)
return True
# ── Wall sync — background drain worker (dead-letter, never drop) ─────────────
# Idle unless a wall URL is configured. POSTs pending rows to the hosted
# feedback-achievements service; the decision state machine
# (engine.drain_decision) is pure + tested. A row leaves the queue only on a
# server ack (or a user opt-out wiping it) — never silently dropped.
# Canonical hosted Feats wall (the got-feedback service). Used by default so the
# drain worker targets it out of the box; override via env for self-hosting or a
# staging wall. Nothing is ever sent unless the user opted in AND has an identity
# (see _enqueue_feat_sync), so a default URL does not publish anything on its own.
_DEFAULT_WALL_URL = "https://feedback-achievements.onrender.com"
_WALL_URL = (os.environ.get("FEEDBACK_ACHIEVEMENTS_WALL_URL")
or os.environ.get("SLOPSMITH_ACHIEVEMENTS_WALL_URL")
or _DEFAULT_WALL_URL).rstrip("/")
_WALL_TOKEN = os.environ.get("FEEDBACK_ACHIEVEMENTS_CLIENT_TOKEN", "fb-wall-v1")
_DRAIN_INTERVAL_S = int(os.environ.get("FEEDBACK_ACHIEVEMENTS_DRAIN_INTERVAL", "30"))
_drain_started = False
def _post_to_wall(kind, payload):
"""POST one queued item; return the HTTP status code, or None on a network
error. Mirrors the lib/lyrics_transcribe outbound pattern (explicit timeout,
no raise on non-2xx the caller's state machine decides)."""
import requests # local import: only needed when a wall is configured
path = "/api/unlock" if kind == "unlock" else "/api/remove"
try:
resp = requests.post(
_WALL_URL + path, json=payload,
headers={"X-Client-Token": _WALL_TOKEN, "Content-Type": "application/json"},
timeout=10,
)
return resp.status_code
except requests.RequestException:
return None
def _drain_once(post_fn=None):
"""Process all pending queue rows once. ``post_fn(kind, payload) -> status``
is injectable for tests; defaults to the real wall POST."""
post_fn = post_fn or _post_to_wall
engine = _state["engine"]
with _lock:
conn = _conn()
try:
rows = conn.execute(
"SELECT id, kind, payload FROM sync_queue WHERE state='pending'").fetchall()
finally:
conn.close()
for row in rows:
try:
payload = json.loads(row["payload"]) if row["payload"] else {}
except ValueError:
payload = {}
status = post_fn(row["kind"], payload)
action = engine.drain_decision(status)
with _lock:
conn = _conn()
try:
if action == "ack":
conn.execute("DELETE FROM sync_queue WHERE id=?", (row["id"],))
elif action == "dead":
conn.execute("UPDATE sync_queue SET state='dead_letter' WHERE id=?", (row["id"],))
# 'retry' → leave it pending for the next pass
conn.commit()
finally:
conn.close()
def _drain_loop():
while True:
try:
_drain_once()
except Exception as e: # noqa: BLE001 — a worker crash must not kill the thread
_state["log"].warning("achievements wall drain error: %s", e)
time.sleep(_DRAIN_INTERVAL_S)
def _maybe_start_drain():
global _drain_started
if _drain_started or not _WALL_URL:
return
_drain_started = True
threading.Thread(target=_drain_loop, name="ach-wall-drain", daemon=True).start()
_state["log"].info("achievements wall drain worker started → %s", _WALL_URL)
def _chart_key(chart):
"""Stable per-chart counter key — a sha1 digest of the chart id. NOT the
builtin hash(), whose str hashing is salted per process (PYTHONHASHSEED), so
the same chart would land on a different counter after every restart and the
Encore Feat could never accumulate across sessions."""
return "chart_plays:" + hashlib.sha1(str(chart).encode("utf-8")).hexdigest()[:16]
def _read_counters(conn):
# Excludes the per-chart `chart_plays:*` rows: they are bumped + read
# individually via _bump_counter and would otherwise make this aggregate
# round-trip O(distinct charts played) on every activity POST.
return {
row["key"]: int(row["value"])
for row in conn.execute("SELECT key, value FROM counters WHERE key NOT LIKE 'chart_plays:%'")
}
def _write_counters(conn, counters):
for key, value in counters.items():
conn.execute(
"INSERT INTO counters(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, int(value)),
)
def _bump_counter(conn, key, delta):
"""Increment a counter and return the new value (used for per-chart plays)."""
conn.execute(
"INSERT INTO counters(key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=value+excluded.value",
(key, int(delta)),
)
row = conn.execute("SELECT value FROM counters WHERE key=?", (key,)).fetchone()
return int(row["value"]) if row else int(delta)
def _earned_feat_tiers(conn):
return {
row["achievement_id"]: int(row["tier"])
for row in conn.execute("SELECT achievement_id, tier FROM unlocks WHERE cls='feat'")
}
def _record_unlock(conn, ach_id, cls, disp_category, source_id, tier, at):
"""Idempotent upsert; only advances the tier upward. Returns True if changed."""
row = conn.execute("SELECT tier FROM unlocks WHERE achievement_id=?", (ach_id,)).fetchone()
if row is not None and int(row["tier"]) >= int(tier):
return False
conn.execute(
"""
INSERT INTO unlocks(achievement_id, cls, disp_category, source_id, tier, unlocked_at, synced)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(achievement_id) DO UPDATE SET
tier=excluded.tier,
cls=excluded.cls,
disp_category=COALESCE(excluded.disp_category, unlocks.disp_category),
source_id=COALESCE(excluded.source_id, unlocks.source_id)
""",
(ach_id, cls, disp_category, source_id, int(tier), at or _now_iso()),
)
return True
# ── Catalog loading ─────────────────────────────────────────────────────────
def _feat_by_id(fid):
for f in _state["feat_defs"]:
if f.get("id") == fid:
return f
return None
def _load_catalogs():
base = Path(_state["dir"])
try:
feats = json.loads((base / "feats.json").read_text(encoding="utf-8"))
_state["feat_defs"] = feats.get("feats", []) if isinstance(feats, dict) else []
except (OSError, ValueError) as e:
_state["log"].warning("achievements: could not load feats.json: %s", e)
_state["feat_defs"] = []
try:
_state["baseline"] = json.loads((base / "achievements.json").read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
_state["log"].warning("achievements: could not load achievements.json: %s", e)
_state["baseline"] = {}
# ── Request models ──────────────────────────────────────────────────────────
class ActivityIn(BaseModel):
notes: int = Field(ge=0, default=0)
session_notes: int = Field(ge=0, default=0)
in_song_streak: int = Field(ge=0, default=0)
song_done: int = Field(ge=0, default=0)
seconds: int = Field(ge=0, default=0)
chart: str | None = None
night_session: bool = False
night_date: str | None = None # 'YYYY-MM-DD', frontend supplies (no server clock)
class UnlockIn(BaseModel):
id: str
kind: str = "achievement" # 'achievement' | 'feat'
category: str | None = None # display category (global/guitar/...)
sourceId: str | None = None
tier: int = Field(ge=0, default=0)
at: str | None = None
class CriterionIn(BaseModel):
criterion_id: str
token: str
# ── FastAPI wiring ──────────────────────────────────────────────────────────
def setup(app, context):
config_dir = context["config_dir"]
base = Path(config_dir) / "achievements"
base.mkdir(parents=True, exist_ok=True)
_state["db_path"] = str(base / "achievements.db")
_state["dir"] = str(Path(__file__).resolve().parent)
_state["config_dir"] = str(config_dir)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
# Pure helpers via the per-plugin sibling loader (constitution P-III), with a
# plain-import fallback for pytest / standalone use.
load_sibling = context.get("load_sibling")
try:
_state["engine"] = load_sibling("engine") if load_sibling else __import__("engine")
except Exception: # noqa: BLE001 — last-ditch, keep the plugin alive
import importlib.util
spec = importlib.util.spec_from_file_location(
"achievements_engine", str(Path(__file__).resolve().parent / "engine.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
_state["engine"] = mod
_init_db()
_load_catalogs()
log = _state["log"]
@app.post("/api/plugins/achievements/activity")
def post_activity(body: ActivityIn):
engine = _state["engine"]
with _lock:
conn = _conn()
try:
# Per-chart play count is the only directly-stateful bit; bump it
# first (stable key) so apply_activity() just takes the new max.
chart_play_count = None
if body.song_done and body.chart:
chart_play_count = _bump_counter(conn, _chart_key(body.chart), 1)
# Night-window ledger → consecutive-night run. Computed here but
# NOT written before the prev snapshot: it is folded into the delta
# below so prev_tiers reflects the OLD run and new_tiers the new one
# (the same asymmetry chart_encore relies on). Pre-writing it would
# make prev already satisfy the Feat, so diff_unlocks would never
# see the freshly-earned witching unlock.
witching_run = None
if body.night_session and body.night_date:
conn.execute(
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES ('witching', ?)",
(body.night_date,),
)
nights = [r["token"] for r in conn.execute(
"SELECT token FROM comp_ledger WHERE criterion_id='witching'")]
witching_run = engine.consecutive_run_length(nights)
counters = _read_counters(conn)
prev_tiers = engine.evaluate_feats(_state["feat_defs"], counters)
new_counters = engine.apply_activity(counters, {
"notes": body.notes,
"session_notes": body.session_notes,
"in_song_streak": body.in_song_streak,
"song_done": body.song_done,
"seconds": body.seconds,
"chart_play_count": chart_play_count,
})
if witching_run is not None:
new_counters["witching_nights_run"] = max(
int(new_counters.get("witching_nights_run", 0) or 0), witching_run)
_write_counters(conn, new_counters)
new_tiers = engine.evaluate_feats(_state["feat_defs"], new_counters)
fresh = engine.diff_unlocks(prev_tiers, new_tiers)
unlocked = []
for fid in fresh:
f = _feat_by_id(fid) or {}
tier = new_tiers[fid]
at = _now_iso()
if _record_unlock(conn, fid, "feat", f.get("category"), f.get("sourceId"), tier, at):
_enqueue_feat_sync(conn, fid, at)
unlocked.append(_feat_payload(fid, f, tier))
conn.commit()
return {"ok": True, "unlocked": unlocked, "counters": new_counters}
finally:
conn.close()
@app.post("/api/plugins/achievements/report-unlock")
def post_report_unlock(body: UnlockIn):
cls = "feat" if body.kind == "feat" else "competency"
at = body.at or _now_iso()
with _lock:
conn = _conn()
try:
changed = _record_unlock(
conn, body.id, cls, body.category, body.sourceId, body.tier, at)
# Only Feats sync; competency never enqueues (integration law +
# data-minimization contract).
if changed and cls == "feat":
_enqueue_feat_sync(conn, body.id, at)
conn.commit()
return {"ok": True, "changed": changed, "id": body.id, "tier": body.tier}
finally:
conn.close()
@app.post("/api/plugins/achievements/report-criterion")
def post_report_criterion(body: CriterionIn):
"""Record a distinct (criterion_id, token); return the distinct count.
Lets a baseline subscriber aggregate multi-event criteria (e.g. the set
of distinct days with a real advance `steady_hands`) without us
re-deriving competency from activity. Bookkeeping over events only.
"""
with _lock:
conn = _conn()
try:
conn.execute(
"INSERT OR IGNORE INTO comp_ledger(criterion_id, token) VALUES (?, ?)",
(body.criterion_id, body.token),
)
row = conn.execute(
"SELECT COUNT(*) AS n FROM comp_ledger WHERE criterion_id=?",
(body.criterion_id,),
).fetchone()
conn.commit()
return {"ok": True, "count": int(row["n"]) if row else 0}
finally:
conn.close()
@app.get("/api/plugins/achievements/catalog")
def get_catalog():
with _lock:
conn = _conn()
try:
earned = _earned_map(conn)
finally:
conn.close()
return {"baseline": _state["baseline"], "earned": earned}
@app.get("/api/plugins/achievements/earned")
def get_earned():
with _lock:
conn = _conn()
try:
return {"earned": list(_earned_map(conn).values())}
finally:
conn.close()
@app.get("/api/plugins/achievements/feats")
def get_feats():
with _lock:
conn = _conn()
try:
rows = conn.execute(
"SELECT achievement_id, tier, unlocked_at FROM unlocks WHERE cls='feat'"
).fetchall()
finally:
conn.close()
out = []
for row in rows:
fid = row["achievement_id"]
f = _feat_by_id(fid) or {}
payload = _feat_payload(fid, f, int(row["tier"]))
payload["unlocked_at"] = row["unlocked_at"]
out.append(payload)
return {"feats": out}
@app.post("/api/plugins/achievements/remove-me")
def post_remove_me():
# Local removal works offline: drop the synced flag so nothing re-syncs,
# and enqueue a wall removal (drained in PR3). The wall identity
# (player_hash) is resolved server-side at drain time, not stored here.
_, player_hash = _identity()
with _lock:
conn = _conn()
try:
conn.execute("UPDATE unlocks SET synced=0 WHERE cls='feat'")
# Enqueue a wall removal only when we have an identity to key it
# by; the drain worker (below) POSTs it. Idempotent server-side.
if player_hash:
conn.execute(
"INSERT INTO sync_queue(kind, payload, state) VALUES ('remove', ?, 'pending')",
(json.dumps({"player_hash": player_hash}),),
)
conn.commit()
return {"ok": True}
finally:
conn.close()
_maybe_start_drain()
log.info("achievements engine ready (%d feats, baseline v%s)",
len(_state["feat_defs"]), str(_state["baseline"].get("version", "?")))
def _feat_payload(fid, feat, tier):
titles = feat.get("tier_titles") or []
title = titles[tier] if 0 <= tier < len(titles) else feat.get("title", fid)
return {
"id": fid,
"cls": "feat",
"tier": tier,
"title": title,
"description": feat.get("description", ""),
"category": feat.get("category", "global"),
"secret": bool(feat.get("secret", False)),
}
def _earned_map(conn):
out = {}
for row in conn.execute(
"SELECT achievement_id, cls, disp_category, tier, unlocked_at FROM unlocks"
):
out[row["achievement_id"]] = {
"id": row["achievement_id"],
"cls": row["cls"],
"category": row["disp_category"],
"tier": int(row["tier"]),
"unlocked_at": row["unlocked_at"],
}
return out
+395
View File
@@ -0,0 +1,395 @@
/*
* Achievements & Feats of Power frontend engine (vanilla, constitution P-II).
*
* Renders into the two core Profile mount points (achievements epic):
* #v3-profile-feats-slot earned Feats trophy shelf (hidden-until-earned)
* #v3-profile-achievements-mount full competency catalogue (locked = greyed),
* grouped by instrument via a secondary pill row.
* Re-injects on every `v3:profile-rendered` (core wipes the mounts each render).
*
* Also exposes the cross-plugin registration API `window.feedBack.achievements`
* (v1) so source plugins (Virtuoso, notedetect, ) contribute competency defs +
* report unlocks without us hardcoding their vocabulary. Load-order safe via the
* `window.__feedBackAchievementsPending` queue + `achievements:ready` event.
*
* INTEGRATION LAW: Feats read activity counters only (we POST batched activity on
* song:ended); competency Achievements are evaluated from progression EVENTS only.
* The two paths never cross.
*/
(function () {
'use strict';
var API = '/api/plugins/achievements';
var bus = window.feedBack;
if (!bus) return; // bus must exist (capabilities.js); nothing to attach to.
var esc = function (s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
};
// ── State ────────────────────────────────────────────────────────────────
var registered = {}; // id -> def (contributed + expanded baseline defs)
var earned = {}; // id -> { tier, cls, category }
var baseline = null; // /catalog baseline blob
var CAT_KEY = 'achievements:profile-cat'; // P-III: plugin localStorage keys prefixed with plugin id
var INSTRUMENTS = ['guitar', 'bass', 'drums', 'keys'];
function progState() {
return (window.v3Progression && window.v3Progression.get()) || null;
}
function notedetectPresent() {
return typeof window.createNoteDetector === 'function';
}
// ── Backend I/O ──────────────────────────────────────────────────────────
function fetchJSON(path, opts) {
return fetch(API + path, opts).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function postUnlock(def, tier) {
return fetch(API + '/report-unlock', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: def.id, kind: def.kind || 'achievement',
category: def.category || 'global', sourceId: def.sourceId || 'achievements',
tier: tier || 0,
}),
}).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function refreshEarned() {
return fetchJSON('/earned').then(function (data) {
earned = {};
((data && data.earned) || []).forEach(function (e) { earned[e.id] = e; });
});
}
// ── Tier math (mirrors engine.tier_index_for) ────────────────────────────
function tierIndexFor(tiers, value) {
var idx = -1;
(tiers || []).forEach(function (t, i) { if (value >= t) idx = i; });
return idx;
}
function alreadyEarnedAtLeast(id, tier) {
var e = earned[id];
return e && e.tier >= tier;
}
// ── Registration API (v1) ────────────────────────────────────────────────
function register(def) {
if (!def || !def.id) return;
registered[def.id] = {
id: def.id, kind: def.kind || 'achievement', category: def.category || 'global',
title: def.title || def.id, description: def.description || '',
secret: !!def.secret, sourceId: def.sourceId || 'unknown',
};
scheduleRender();
}
function registerAll(defs) { (defs || []).forEach(register); }
function unlock(id, opts) {
var def = registered[id] || { id: id, kind: 'achievement', category: 'global', sourceId: 'unknown' };
var tier = (opts && opts.tier) || 0;
if (alreadyEarnedAtLeast(id, tier)) return Promise.resolve();
return postUnlock(def, tier).then(function () {
return refreshEarned().then(function () {
// A contributed Feat unlock would enqueue a wall sync here when
// opted-in (PR2/PR3); competency never syncs (integration law).
scheduleRender();
});
});
}
function progress() { /* accepted, optional — display is greyed/earned, not bars */ }
var api = { version: 1, register: register, registerAll: registerAll, unlock: unlock, progress: progress };
bus.achievements = api;
// Drain sources that loaded before us (minigames pending-queue pattern).
try { (window.__feedBackAchievementsPending || []).forEach(function (fn) {
try { typeof fn === 'function' ? fn(api) : register(fn); } catch (_) { /* noop */ }
}); } catch (_) { /* noop */ }
window.__feedBackAchievementsPending = null;
try { bus.emit && bus.emit('achievements:ready', { version: 1 }); } catch (_) { /* noop */ }
// ── Baseline competency: evaluate from progression EVENTS only ────────────
function expandBaseline() {
// Register baseline defs (always present — built-in progression is always
// present) so they render greyed even before they're earned. Per-instrument
// templates expand across the REAL paths that exist (auto-extends).
if (!baseline) return;
(baseline.global || []).forEach(function (d) {
register({ id: d.id, kind: 'achievement', category: 'global', title: d.title,
description: d.description, sourceId: 'achievements' });
});
var paths = (progState() && progState().paths) || [];
var pathIds = paths.length ? paths.map(function (p) { return { id: p.id, name: p.name }; })
: INSTRUMENTS.map(function (i) { return { id: i, name: i.charAt(0).toUpperCase() + i.slice(1) }; });
(baseline.per_instrument || []).forEach(function (tpl) {
pathIds.forEach(function (pi) {
var inst = pi.name;
register({
id: tpl.id + ':' + pi.id, kind: 'achievement', category: pi.id,
title: (tpl.title || '').replace(/\{Inst\}/g, inst),
description: (tpl.description || '').replace(/\{Inst\}/g, inst),
sourceId: 'achievements',
});
});
});
}
function evaluateBaseline() {
var prog = progState();
if (!prog || !baseline) return;
var defById = {};
(baseline.global || []).forEach(function (d) { defById[d.id] = d; });
// mastery_rank → first_steps / ascendant
(baseline.global || []).forEach(function (d) {
var crit = d.criterion || {};
if (crit.type === 'mastery_rank') {
var ti = tierIndexFor(crit.tiers || d.tiers, prog.mastery_rank || 0);
if (ti >= 0) baselineUnlock(d.id, 'global', ti);
} else if (crit.type === 'paths_at_level') {
var n = ((prog.paths) || []).filter(function (p) { return (p.level || 0) >= (crit.level || 10); }).length;
var ti2 = tierIndexFor(crit.tiers || d.tiers, n);
if (ti2 >= 0) baselineUnlock(d.id, 'global', ti2);
}
});
// per-instrument path_rank → reach Lv 10/25/max in that path
var tpl = (baseline.per_instrument || []).filter(function (t) { return t.id === 'path_rank'; })[0];
if (tpl) {
((prog.paths) || []).forEach(function (p) {
var thresholds = (tpl.criterion && tpl.criterion.tiers) || [10, 25, 'max'];
var resolved = thresholds.map(function (t) { return t === 'max' ? (p.max_level || 9999) : t; });
var ti = tierIndexFor(resolved, p.level || 0);
if (ti >= 0) baselineUnlock('path_rank:' + p.id, p.id, ti);
});
}
}
function baselineUnlock(id, category, tier) {
if (alreadyEarnedAtLeast(id, tier)) return;
var def = registered[id] || { id: id, kind: 'achievement', category: category, sourceId: 'achievements' };
postUnlock(def, tier).then(function () { refreshEarned().then(scheduleRender); });
}
// Local calendar date 'YYYY-MM-DD' (one source of truth for both the
// steady-hands day ledger and the witching-night date below).
function localISODate(d) {
d = d || new Date();
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
}
// growth-streak + challenger record on real competency events (date ledger /
// distinct challenge sets) — kept as bookkeeping over EVENTS, never activity.
function recordGrowthDay() {
var iso = localISODate();
fetchJSON('/report-criterion', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ criterion_id: 'steady_hands_days', token: iso }),
}).then(function (res) {
if (!res) return;
var d = ((baseline && baseline.global) || []).filter(function (x) { return x.id === 'steady_hands'; })[0];
var ti = tierIndexFor((d && d.tiers) || [7, 30, 100], res.count || 0);
if (ti >= 0) baselineUnlock('steady_hands', 'global', ti);
});
}
// ── Activity (Feats): in-memory session counters, flushed on song:ended ───
var session = { notesTotal: 0 }; // cumulative across this sitting
// `active` gates note counting to an actual song in progress — without it,
// note:hit/miss from the tuner or input-calibration would inflate Feats from
// non-song input and flush a phantom streak with chart:null.
var song = { hits: 0, streak: 0, maxStreak: 0, chart: null, active: false };
function resetSong(chart) { song = { hits: 0, streak: 0, maxStreak: 0, chart: chart || null, active: true }; }
function flushActivity(seconds) {
if (!song.active) return; // no active song → nothing to flush (ignore stray events)
song.active = false;
// No notedetect → song.hits stays 0; notes-based Feats simply don't move
// (graceful degradation). song_done / seconds / chart still flow so the
// notedetect-free Feats (Road Warrior, Time Served, Encore) progress.
var hour = new Date().getHours();
var isNight = hour >= 2 && hour < 5;
var iso = localISODate();
var body = {
notes: song.hits,
session_notes: session.notesTotal,
in_song_streak: song.maxStreak,
song_done: 1,
seconds: Math.max(0, Math.round(seconds || 0)),
chart: song.chart,
night_session: isNight,
night_date: isNight ? iso : null,
};
fetchJSON('/activity', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(function (res) {
if (res && res.unlocked && res.unlocked.length) {
// A Feat just unlocked — refresh the shelf + toast via the bus.
fetchFeatsAndRender();
res.unlocked.forEach(function (f) {
try { bus.emit && bus.emit('achievements:feat-unlocked', f); } catch (_) { /* noop */ }
});
}
});
}
// ── Rendering ────────────────────────────────────────────────────────────
var _renderQueued = false;
function scheduleRender() {
if (_renderQueued) return;
_renderQueued = true;
(window.requestAnimationFrame || window.setTimeout)(function () { _renderQueued = false; renderAll(); }, 0);
}
function renderAll() {
renderCatalog();
fetchFeatsAndRender();
}
function categoriesForDisplay() {
var cats = [{ id: 'global', name: 'Global' }];
var paths = (progState() && progState().paths) || [];
if (paths.length) {
paths.forEach(function (p) { cats.push({ id: p.id, name: p.name }); });
} else {
// Fallback before progression loads: show the known instrument cats
// that actually have registered items.
INSTRUMENTS.forEach(function (i) {
if (Object.keys(registered).some(function (id) { return registered[id].category === i; })) {
cats.push({ id: i, name: i.charAt(0).toUpperCase() + i.slice(1) });
}
});
}
return cats;
}
function itemsForCategory(catId) {
return Object.keys(registered).map(function (id) { return registered[id]; })
.filter(function (d) { return (d.category || 'global') === catId; })
// Hide un-earned secret items (revealed only once earned).
.filter(function (d) { return !d.secret || earned[d.id]; });
}
function renderCatalog() {
var mount = document.getElementById('v3-profile-achievements-mount');
if (!mount) return;
// Hide the core empty-state note now that we own this mount.
var emptyNote = document.querySelector('[data-empty-for="v3-profile-achievements-mount"]');
if (emptyNote) emptyNote.style.display = 'none';
var cats = categoriesForDisplay();
var saved = null;
try { saved = localStorage.getItem(CAT_KEY); } catch (_) { /* noop */ }
// Default to the player's primary path (first path), fallback Global.
var primary = (progState() && progState().paths && progState().paths[0] && progState().paths[0].id) || 'global';
var active = cats.some(function (c) { return c.id === saved; }) ? saved
: (cats.some(function (c) { return c.id === primary; }) ? primary : 'global');
var pills = cats.map(function (c) {
var items = itemsForCategory(c.id);
var got = items.filter(function (d) { return earned[d.id]; }).length;
return '<button type="button" class="fb-ach-pill' + (c.id === active ? ' active' : '') +
'" data-cat="' + esc(c.id) + '">' + esc(c.name) +
' <span class="fb-ach-pill-badge">' + got + '/' + items.length + '</span></button>';
}).join('');
var items = itemsForCategory(active);
var list = items.length ? items.map(function (d) {
var got = !!earned[d.id];
var tier = got ? (earned[d.id].tier || 0) : -1;
return '<div class="fb-ach-item' + (got ? ' earned' : ' locked') + '">' +
'<div class="fb-ach-item-icon">' + (got ? '🏅' : '🔒') + '</div>' +
'<div class="fb-ach-item-body">' +
'<div class="fb-ach-item-title">' + esc(d.title) +
(got && tier > 0 ? ' <span class="fb-ach-tier">tier ' + (tier + 1) + '</span>' : '') + '</div>' +
'<div class="fb-ach-item-desc">' + esc(d.description) + '</div>' +
'</div></div>';
}).join('') : '<p class="fb-tabpanel-empty">No achievements in this category yet.</p>';
mount.innerHTML =
'<div class="fb-ach-pillrow">' + pills + '</div>' +
'<div class="fb-ach-list">' + list + '</div>';
mount.querySelectorAll('[data-cat]').forEach(function (b) {
b.addEventListener('click', function () {
try { localStorage.setItem(CAT_KEY, b.dataset.cat); } catch (_) { /* noop */ }
renderCatalog();
});
});
}
function fetchFeatsAndRender() {
return fetchJSON('/feats').then(function (data) { renderFeats((data && data.feats) || []); });
}
function renderFeats(feats) {
var slot = document.getElementById('v3-profile-feats-slot');
if (!slot) return;
if (!feats.length) { slot.innerHTML = ''; return; } // hidden-until-earned
var cards = feats.map(function (f) {
return '<div class="fb-feat-card" title="' + esc(f.description) + '">' +
'<div class="fb-feat-icon">🏆</div>' +
'<div class="fb-feat-title">' + esc(f.title) + '</div>' +
'<div class="fb-feat-desc">' + esc(f.description) + '</div>' +
'</div>';
}).join('');
// Opt-out users get a subtle wall hint linking to Settings (PR2 wires it).
var hint = '';
slot.innerHTML =
'<div class="bg-fb-card/80 backdrop-blur rounded-xl p-6 border border-fb-border/50">' +
'<h3 class="text-lg font-bold text-fb-text mb-3">Feats of Power</h3>' +
'<div class="fb-feat-shelf">' + cards + '</div>' + hint +
'</div>';
}
// ── Boot ─────────────────────────────────────────────────────────────────
function init() {
fetchJSON('/catalog').then(function (data) {
baseline = (data && data.baseline) || {};
((data && data.earned) && (earned = {}, Object.keys(data.earned).forEach(function (id) { earned[id] = data.earned[id]; })));
expandBaseline();
evaluateBaseline();
scheduleRender();
});
// Re-inject on every profile entry (core wipes the mounts each render).
document.addEventListener('v3:profile-rendered', function () { renderAll(); });
// Competency events → re-expand (paths may have appeared) + re-evaluate.
['progression:updated', 'progression:rank-changed', 'progression:path-level-up'].forEach(function (ev) {
bus.on && bus.on(ev, function () { expandBaseline(); evaluateBaseline(); });
});
// A real competency advance ticks the growth-streak day ledger.
['progression:rank-changed', 'progression:path-level-up', 'progression:challenge-completed'].forEach(function (ev) {
bus.on && bus.on(ev, function () { recordGrowthDay(); });
});
// challenger:<inst> — clearing a full level-up set for a path.
bus.on && bus.on('progression:path-level-up', function (e) {
var pid = e && e.detail && (e.detail.path_id || e.detail.id);
if (pid) baselineUnlock('challenger:' + pid, pid, 0);
});
// Activity (Feats) — in-memory counters, flushed once per song.
bus.on && bus.on('song:loading', function (e) {
resetSong(e && e.detail && e.detail.filename);
});
bus.on && bus.on('note:hit', function () {
if (!song.active) return; // ignore tuner/calibration note events
song.hits++; song.streak++; session.notesTotal++;
if (song.streak > song.maxStreak) song.maxStreak = song.streak;
});
bus.on && bus.on('note:miss', function () { if (song.active) song.streak = 0; });
bus.on && bus.on('song:ended', function (e) {
flushActivity(e && e.detail && (e.detail.time || e.detail.audioT));
});
// Song stopped/abandoned without a natural end → mark inactive so stray
// note events after it don't accrue against a phantom (chart:null) song.
bus.on && bus.on('song:stop', function () { song.active = false; });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();
+68
View File
@@ -0,0 +1,68 @@
<!-- Achievements plugin — Privacy panel (mounts under the Settings "System" tab
via settings.category). Owns the wall opt-in toggle (bound to the core
`achievements_enabled` setting) + the self-serve "Remove me from the wall"
action. Default OFF — nothing publishes until the user opts in. -->
<div class="text-sm text-gray-300 space-y-4" data-ach-privacy>
<div>
<p>Your <strong>Achievements</strong> and <strong>Feats of Power</strong> live on your
<em>Profile</em> page and are local &amp; private by default.</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox" id="setting-achievements-enabled"
class="mt-1 h-4 w-4 rounded border-gray-600 bg-gray-800 text-sky-500 focus:ring-sky-500">
<span>
<span class="font-medium text-gray-200">Share my Feats of Power on the public wall</span>
<span class="block text-gray-400">Publishes only your display name and the rare
<strong>Feats</strong> you earn (activity milestones) — never songs, skills, or scores.
You can turn this off and remove yourself at any time.</span>
</span>
</label>
<div>
<button type="button" id="ach-remove-me"
class="px-3 py-1.5 rounded-md text-sm border border-red-500/40 text-red-300 hover:bg-red-500/10 transition">
Remove me from the wall
</button>
<span id="ach-remove-status" class="ml-2 text-xs text-gray-400"></span>
</div>
</div>
<script>
(function () {
var root = document.currentScript && document.currentScript.previousElementSibling;
// The panel re-injects on each Settings entry; bind once per element.
var toggle = document.getElementById('setting-achievements-enabled');
if (!toggle || toggle.dataset.wired === '1') return;
toggle.dataset.wired = '1';
// Hydrate from the authoritative server setting.
fetch('/api/settings').then(function (r) { return r.ok ? r.json() : {}; }).then(function (d) {
toggle.checked = d && d.achievements_enabled === true;
}).catch(function () { /* offline — leave unchecked */ });
toggle.addEventListener('change', function () {
var on = !!toggle.checked;
try { localStorage.setItem('achievementsEnabled', on ? '1' : '0'); } catch (_) {}
fetch('/api/settings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ achievements_enabled: on }),
}).catch(function () { /* best-effort; revert on failure */ });
});
var removeBtn = document.getElementById('ach-remove-me');
var status = document.getElementById('ach-remove-status');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
removeBtn.disabled = true;
if (status) status.textContent = 'Removing…';
fetch('/api/plugins/achievements/remove-me', { method: 'POST' })
.then(function (r) {
if (status) status.textContent = r.ok ? 'Removed. Your Feats stay on your Profile.' : 'Could not reach the server — try again.';
})
.catch(function () { if (status) status.textContent = 'Offline — queued; it will sync when you reconnect.'; })
.finally(function () { removeBtn.disabled = false; });
});
}
})();
</script>
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.DS_Store
Thumbs.db
*.swp
.vscode/
.idea/
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+100
View File
@@ -0,0 +1,100 @@
# 3D Drum Highway — Mockup
![3D Drum Highway mockup screenshot](screenshots/drum-highway-3d.png)
An exploratory **pure-visual** sibling of `highway_3d`. Renders an 8-lane
drum highway (7 lanes for hand pieces + a full-width kick bar) populated
from a hardcoded demo pattern that loops indefinitely. Not yet wired to
song data, hit detection, audio, or note_detect — this is here to play
with the look-and-feel.
To see it, load any song in the player, then pick **3D Drum Highway**
from the viz picker. The mockup animates regardless of what song is
playing.
## Layout
```
[HH] [SNR] [TM1] [TM2] [FT] [CR] [RD] <- 7 lanes, left to right
| | | | | | |
v v v v v v v
----- hit line -----------------------------
▓▓▓▓▓▓▓▓▓▓▓▓▓ KICK BAR ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ <- full-width kick lane
```
- **Drums** (snare, toms, floor tom): flat disc geometry (`CylinderGeometry`),
palette-tinted, with subtle emissive pulse on approach. Snare gets a
thin white "wires" stripe.
- **Cymbals** (hi-hat, crash, ride): faceted gem geometry (truncated
`CylinderGeometry`), metallic material, slightly translucent.
- **Kick**: full-width amber bar across the base, brighter on approach.
## Variants visible in the demo
| Variant | How it reads |
|---|---|
| `accent` | Bigger note + white halo ring |
| `ghost` | Hollow ring (~65% size) instead of a solid disc |
| `flam` | Main note + a small grace disc offset slightly before |
| `bell` | Ride only — adds a bright dot in the center of the gem |
The **Fill showcase** demo pattern fires every variant at least once in
a few bars, plus a tom roll down the kit. Best read of what's possible.
## Settings (Settings → Plugins → 3D Drum Highway)
- **Palette** — shared 3 palettes with `highway_3d` (default / neon / pastel).
- **Demo pattern** — rock backbeat / jazz swing / fill showcase.
- **Camera angle** — 0 (down the lanes) to 1 (top-down). Default 0.35.
All settings persist in `localStorage` under the `drum_h3d_*` prefix.
## What this plugin is *not* doing yet
(Historical note: this started as a pure-visual mockup; it now reads
`bundle.drumTab` + `bundle.currentTime` and scores MIDI hits against the
chart, so the old "no song-data wiring" caveats are gone.)
- Sustain trails (drums don't sustain meaningfully)
- Sticking labels, double kick, rolls — see the TODOs in `screen.js`
for the variant backlog
## Ported helpers (keep in sync with highway_3d)
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
function names, signatures, and constants on purpose, marked with
`PORTED FROM highway_3d` comments at each site. If the guitar highway
tunes one of these, mirror the change here (and in `keys_highway_3d`):
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
(pool 160 here vs the guitar's 256)
- `_makeGaussTex()` — soft-falloff DataTexture for the additive lane-flash
quads
- `_timingHex()` — early/late/on-time feedback colors (green/cyan/amber)
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
checks, which it needs for input routing and we don't)
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
as the guitar's, except `default` which is this plugin's original
palette); one pick drives both of the guitar's background/highway axes
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
`_bgReadBands()` — background ambience + the stems-first audio-analyser
bridge (guitar's diagnostics plumbing dropped; butterchurn/image/video
out of scope)
- `_drawScoreFx()` — the guitar's drawScoreFx overlay adapted to this
plugin's internal scoring (pops / tier rings / milestone bursts /
streak-break wash)
## Why a separate plugin (vs. drum mode inside highway_3d)?
Cleaner iteration. The drum highway is its own geometry, its own
gameplay assumptions (lanes ≠ strings, no frets, no chord shapes,
no sustains), and likely its own chart format. Forking the visuals
in a sibling plugin lets the mockup move fast without risking
regressions in the guitar viz that ships today. If the drum highway
eventually matures, we can decide whether to merge or keep separate.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="3D Drum Highway placeholder thumbnail">
<rect width="256" height="256" rx="36" fill="#0f172a"/>
<rect x="18" y="18" width="220" height="220" rx="28" fill="#1e293b" stroke="#334155" stroke-width="3"/>
<text x="128" y="150" font-family="Rubik, Arial, sans-serif" font-size="104" font-weight="800" fill="#38bdf8" text-anchor="middle">3D</text>
</svg>

After

Width:  |  Height:  |  Size: 464 B

+39
View File
@@ -0,0 +1,39 @@
{
"id": "drum_highway_3d",
"name": "3D Drum Highway",
"version": "0.3.1",
"type": "visualization",
"bundled": true,
"script": "screen.js",
"settings": {
"html": "settings.html",
"category": "graphics"
},
"standards": [
"capability-pipelines.v1",
"plugin-runtime-idempotent.v1"
],
"capabilities": {
"midi-input": {
"roles": [
"requester"
],
"requests": [
"discover",
"list-sources",
"select-source",
"open-source",
"close-source"
],
"mode": "active",
"compatibility": "degrade-noop",
"ownership": "requester-only",
"safety": "sensitive",
"description": "Reads the e-kit/MIDI-pad input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.",
"version": 1
}
},
"category": "practice",
"description": "3D note highway for drum charts.",
"icon": "assets/thumb.svg"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+615
View File
@@ -0,0 +1,615 @@
<div role="group" aria-labelledby="drumh3d-heading">
<h3 id="drumh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Drum Highway</h3>
<p class="text-xs text-gray-500 mb-3">
Pick a song with a drum tab (sloppak-spec §5.3), choose <em>3D
Drum Highway</em> from the viz picker. The plugin auto-attaches
to your MIDI controller and routes chart pieces through the kit
you configure below.
</p>
<!-- Palette -->
<div class="mt-3">
<label for="drumh3d-palette" class="text-xs font-medium text-gray-400 mb-1 block">Palette</label>
<select id="drumh3d-palette"
onchange="window.drumH3dSetPalette && window.drumH3dSetPalette(this.value); (window._drumH3dRenderPaletteSwatches || function(){})(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (Classic)</option>
<option value="neon">Neon (saturated, electric)</option>
<option value="pastel">Pastel (soft, low contrast)</option>
</select>
<div class="mt-2 flex items-center gap-1" aria-hidden="true">
<span id="drumh3d-swatch-0" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-1" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-2" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-3" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-4" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-5" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-6" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-7" class="inline-block h-6 w-5 rounded border border-gray-700/60"
style="background:#ffa030"></span>
</div>
</div>
<!-- Camera angle -->
<div class="mt-4">
<label for="drumh3d-camera" class="text-xs font-medium text-gray-400 mb-1 block">
Camera angle <span id="drumh3d-camera-val" class="text-gray-500 font-mono">0.35</span>
</label>
<input type="range" id="drumh3d-camera"
min="0" max="1" step="0.05" value="0.35"
oninput="window.drumH3dSetCameraAngle && window.drumH3dSetCameraAngle(this.value); document.getElementById('drumh3d-camera-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">0 = down the lanes · 1 = top-down</p>
</div>
<!-- Graphics -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="drumh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="drumh3d-fx-theme"
onchange="window.drumH3dSetTheme && window.drumH3dSetTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (original)</option>
<option value="midnight">Midnight</option>
<option value="charcoal">Charcoal</option>
<option value="deeppurple">Deep Purple</option>
<option value="forest">Forest</option>
<option value="warmslate">Warm Slate</option>
<option value="deepfocus">Deep Focus</option>
<option value="deepsea">Deep Sea</option>
<option value="cathode">Cathode</option>
<option value="cathodegreen">Cathode Green</option>
<option value="hearth">Hearth</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background, floor and lane colours — the same theme names as the
guitar highway, so your look carries across instruments. Piece
colours stay with the Palette above.
</p>
<label for="drumh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="drumh3d-fx-cinematic" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('cinematic', this.checked)">
Cinematic lighting
</label>
<p class="text-xs text-gray-500 mt-1">
Dimmer ambience, stronger key light — more depth on the cymbals
and drumheads.
</p>
<label for="drumh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Glow strength <span id="drumh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="drumh3d-fx-glow"
min="0" max="1" step="0.05" value="0.5"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('glow', this.value); document.getElementById('drumh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
How hard the notes and hit line self-illuminate (0.5 = stock).
Pairs with bloom.
</p>
<label for="drumh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Lane vibrancy <span id="drumh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
</label>
<input type="range" id="drumh3d-fx-vibrancy"
min="0" max="1" step="0.05" value="0.85"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('vibrancy', this.value); document.getElementById('drumh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Lane stripe and accent-ring strength — lower for a calmer deck.
</p>
<label for="drumh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
<select id="drumh3d-fx-bgstyle"
onchange="window.drumH3dSetBgStyle && window.drumH3dSetBgStyle(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="off">Off</option>
<option value="particles" selected>Particles</option>
<option value="lights">Stage lights</option>
<option value="geometric">Geometric</option>
</select>
<label for="drumh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
Ambience intensity <span id="drumh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="drumh3d-fx-bgintensity"
min="0" max="1" step="0.05" value="0.5"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('bgIntensity', this.value); document.getElementById('drumh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<label for="drumh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
<input type="checkbox" id="drumh3d-fx-bgreactive" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bgReactive', this.checked)">
Audio-reactive ambience
</label>
<p class="text-xs text-gray-500 mt-1">
The backdrop pulses with the mix (stems analyser when a sloppak
is loaded). Off = it animates on time only.
</p>
<label for="drumh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-scorefx" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('scoreFx', this.checked)">
Score effects
</label>
<p class="text-xs text-gray-500 mt-1">
+1 pops on hits, a ring pulse every 10-combo, milestone bursts at
25/50/100, and a brief red flicker when a streak breaks.
</p>
<label for="drumh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-bloom" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('bloom', this.checked)">
Glow (bloom)
</label>
<p class="text-xs text-gray-500 mt-1">
Soft light-bleed around the hit line and bright notes. Applies
live; turn off to reclaim GPU headroom on weak machines.
</p>
<label for="drumh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-sparks" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('sparks', this.checked)">
Hit sparks
</label>
<p class="text-xs text-gray-500 mt-1">
A small particle burst on every scored pad hit.
</p>
<label for="drumh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-timing" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('timingFx', this.checked)">
Timing colours
</label>
<p class="text-xs text-gray-500 mt-1">
Tint hit feedback by timing — on-time green, early cyan, late
amber. Off = always green.
</p>
<label for="drumh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="drumh3d-fx-streak" checked
onchange="window.drumH3dSetFx && window.drumH3dSetFx('streakFx', this.checked)">
Streak feedback
</label>
<p class="text-xs text-gray-500 mt-1">
Spark bursts grow with your combo.
</p>
<label for="drumh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Hit feedback intensity <span id="drumh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="drumh3d-fx-hitfx"
min="0" max="1" step="0.05" value="0.7"
oninput="window.drumH3dSetFx && window.drumH3dSetFx('hitFx', this.value); document.getElementById('drumh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Drives the lane flashes, approach glow and the kick camera pulse.
0 turns them off.
</p>
</div>
<!-- MIDI input -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">MIDI input</h4>
<label for="drumh3d-midi-input" class="text-xs font-medium text-gray-400 mb-1 block">Device</label>
<select id="drumh3d-midi-input"
onchange="window.drumH3dSetMidiInput && window.drumH3dSetMidiInput(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
<option value="">— none —</option>
</select>
<p class="text-xs text-gray-500 mt-1">
Auto-picks the first non-passthrough input on load. Pick "none" to pause hit and miss tracking.
</p>
<label for="drumh3d-synth-vol" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Kit volume <span id="drumh3d-synth-vol-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="drumh3d-synth-vol"
min="0" max="1" step="0.01" value="0.7"
oninput="window.drumH3dSetSynthVolume && window.drumH3dSetSynthVolume(this.value); document.getElementById('drumh3d-synth-vol-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
</div>
<!-- Kit configuration -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">My kit</h4>
<p class="text-xs text-gray-500 mb-3">
Add the pieces you actually have, in the order you want them
on the highway (left → right). The default fallback routing
covers most common kit setups; use the fallbacks panel below
to reroute any chart piece to a different lane on your kit,
or set it to "—" to silently drop it.
</p>
<label class="text-xs font-medium text-gray-400 mb-1 block">Kit name</label>
<input type="text" id="drumh3d-kit-name" maxlength="80"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none mb-3"
oninput="window._drumH3dKit && window._drumH3dKit.onNameInput(this.value)">
<label class="text-xs font-medium text-gray-400 mb-1 block">Lanes (left → right on the highway; kick is always a full-width bar)</label>
<div id="drumh3d-kit-lanes" class="space-y-1 mb-2"></div>
<div class="mt-2 mb-3">
<label class="text-xs font-medium text-gray-400 mb-1 block">Add a piece</label>
<select id="drumh3d-kit-add"
onchange="window._drumH3dKit && window._drumH3dKit.onAddPiece(this.value); this.value=''"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
<option value="">— pick a piece to add —</option>
</select>
</div>
<details class="mt-2 mb-3">
<summary class="text-xs text-gray-400 cursor-pointer">Chart fallbacks for pieces I don't have</summary>
<p class="text-xs text-gray-500 mt-2 mb-2">
When a song uses a piece that isn't on your kit, route it
to one of your lanes. Set "—" to silently drop that piece.
</p>
<div id="drumh3d-kit-fallbacks" class="space-y-1"></div>
</details>
<div class="flex flex-wrap items-center gap-2 mt-3">
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.copyToClipboard()"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📋 Copy kit</button>
<button type="button"
onclick="document.getElementById('drumh3d-kit-import-row').classList.toggle('hidden')"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📥 Import kit…</button>
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.resetDefault()"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">Reset</button>
<span id="drumh3d-kit-status" class="text-xs text-gray-500 ml-auto"></span>
</div>
<div id="drumh3d-kit-import-row" class="hidden mt-3">
<label class="text-xs font-medium text-gray-400 mb-1 block">Paste a shared kit</label>
<textarea id="drumh3d-kit-import-text" rows="3"
placeholder="Paste a base64 kit string here…"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-[10px] font-mono text-gray-300 outline-none"></textarea>
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.importFromTextarea()"
class="mt-2 px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 rounded-lg text-xs">Apply imported kit</button>
</div>
</div>
<script>
(function () {
// Mirror of PALETTES in screen.js — kept in sync for swatch previews.
const PALETTES = {
default: [0xff2828, 0xffd400, 0x2080ff, 0xff8020, 0x30d040, 0xa040ff, 0xff6bd5, 0x6bffe6],
neon: [0xff0030, 0xffe800, 0x0080ff, 0xff8030, 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0],
pastel: [0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8],
};
function toHex(n) { return '#' + n.toString(16).padStart(6, '0'); }
// Palette swatch preview: read the FIRST 8 pieces of the active
// kit's lanes (or pad to 8) for colour reference.
window._drumH3dRenderPaletteSwatches = function (id) {
const pal = PALETTES[id] || PALETTES.default;
const kit = (window.drumH3dGetKit && window.drumH3dGetKit()) || null;
const lanes = (kit && kit.lanes) || [];
// The kick always shows amber — same as the renderer. Other
// lanes follow the piece's palette index from screen.js,
// retrieved via drumH3dGetPiecePaletteIdx() so settings.html
// stays in sync when pieces are added without any copy-paste.
const PALETTE_IDX = (window.drumH3dGetPiecePaletteIdx && window.drumH3dGetPiecePaletteIdx()) || {
kick: -1, // sentinel — render amber
snare: 0, snare_xstick: 0,
hh_closed: 7, hh_open: 7, hh_pedal: 7,
tom_hi: 4, tom_mid: 2, tom_low: 5, tom_floor: 5,
crash_l: 1, crash_r: 1, splash: 1, china: 1,
ride: 3, ride_bell: 3,
};
for (let i = 0; i < 8; i++) {
const el = document.getElementById('drumh3d-swatch-' + i);
if (!el) continue;
const ln = lanes[i];
if (!ln) { el.style.background = '#000'; el.style.opacity = '0.3'; continue; }
el.style.opacity = '1';
const idx = PALETTE_IDX[ln.piece];
if (idx === -1) { el.style.background = '#ffa030'; continue; }
el.style.background = toHex(pal[idx ?? 0]);
}
};
// Read fresh inside renderKit() — they're set by screen.js's IIFE,
// which may not have finished evaluating when this inline script
// first runs if the user opens Settings before the player ever loaded.
// Friendly piece labels — self-contained so renderKit's callbacks
// (which run after our local PIECE_LABELS scope has cleared)
// still work.
const FRIENDLY = {
kick: 'Kick',
snare: 'Snare', snare_xstick: 'Snare (cross-stick)',
hh_closed: 'Hi-hat (closed)', hh_open: 'Hi-hat (open)', hh_pedal: 'Hi-hat (pedal)',
tom_hi: 'Tom — high', tom_mid: 'Tom — mid', tom_low: 'Tom — low', tom_floor: 'Floor tom',
crash_l: 'Crash (left)', crash_r: 'Crash (right)', splash: 'Splash', china: 'China',
ride: 'Ride', ride_bell: 'Ride bell',
};
function friendly(p) { return FRIENDLY[p] || p; }
// Render the current kit's lane list with reorder + remove buttons.
function renderKit() {
const kit = window.drumH3dGetKit ? window.drumH3dGetKit() : null;
if (!kit) {
// screen.js hasn't published its API yet — defer one tick.
// Happens when the settings panel opens before the player
// has loaded a song.
setTimeout(renderKit, 100);
return;
}
const PIECE_CATEGORY = window.drumH3dGetPieceCategory ? window.drumH3dGetPieceCategory() : {};
const ALL_PIECES = window.drumH3dGetAllPieces ? window.drumH3dGetAllPieces() : [];
document.getElementById('drumh3d-kit-name').value = kit.name || '';
// Lanes panel — order matters: up/down buttons reorder, x
// removes (also clears any fallback that pointed at the
// removed piece on the way out).
const lanesEl = document.getElementById('drumh3d-kit-lanes');
lanesEl.innerHTML = '';
const taken = new Set(kit.lanes.map(l => l.piece));
kit.lanes.forEach((ln, i) => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 px-2 py-1 bg-dark-700/60 rounded';
// Screen readers should hear the user-facing label
// ("Hi-hat (closed)") not the internal piece id ("hh_closed").
const pieceName = friendly(ln.piece);
row.innerHTML =
`<span class="font-mono text-[10px] text-gray-500 w-6 text-center">${i}</span>` +
`<span class="flex-1 text-xs text-gray-300">${pieceName}</span>` +
`<button type="button" data-act="up" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === 0 ? 'disabled' : ''} title="Move up" aria-label="Move ${pieceName} up"></button>` +
`<button type="button" data-act="down" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === kit.lanes.length - 1 ? 'disabled' : ''} title="Move down" aria-label="Move ${pieceName} down"></button>` +
`<button type="button" data-act="rm" data-i="${i}" class="px-1.5 py-0.5 text-xs text-red-400 hover:text-red-300" title="Remove from kit" aria-label="Remove ${pieceName} from kit"></button>`;
lanesEl.appendChild(row);
});
lanesEl.querySelectorAll('button').forEach(btn => {
btn.onclick = () => {
const i = parseInt(btn.dataset.i, 10);
const act = btn.dataset.act;
const next = JSON.parse(JSON.stringify(kit));
if (act === 'up' && i > 0) {
[next.lanes[i - 1], next.lanes[i]] = [next.lanes[i], next.lanes[i - 1]];
} else if (act === 'down' && i < next.lanes.length - 1) {
[next.lanes[i + 1], next.lanes[i]] = [next.lanes[i], next.lanes[i + 1]];
} else if (act === 'rm') {
const removed = next.lanes[i].piece;
next.lanes.splice(i, 1);
// Drop any fallback that targeted the removed piece
// — it's no longer a valid target.
for (const k of Object.keys(next.fallbacks || {})) {
if (next.fallbacks[k] === removed) delete next.fallbacks[k];
}
} else { return; }
// Guard: drumH3dSetKit returns false when validation
// rejects the kit (e.g. empty lanes[] after last removal).
if (!window.drumH3dSetKit(next)) {
setStatus('Kit must have at least one lane', 'err');
}
};
});
// Add-a-piece dropdown — pieces NOT already in lanes, grouped
// by category (kick / drums / cymbals).
const addSel = document.getElementById('drumh3d-kit-add');
addSel.innerHTML = '<option value="">— pick a piece to add —</option>';
const groups = { kick: [], drum: [], cymbal: [] };
for (const p of ALL_PIECES) {
if (taken.has(p)) continue;
const cat = PIECE_CATEGORY[p] || 'drum';
if (groups[cat]) groups[cat].push(p);
}
for (const [cat, label] of [['kick', 'Kick'], ['drum', 'Drums'], ['cymbal', 'Cymbals']]) {
if (!groups[cat].length) continue;
const og = document.createElement('optgroup');
og.label = label;
for (const p of groups[cat]) {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = friendly(p);
og.appendChild(opt);
}
addSel.appendChild(og);
}
// Fallback list — every piece NOT in lanes gets a dropdown
// mapping it to one of the user's pieces (or "—" to drop).
const fbEl = document.getElementById('drumh3d-kit-fallbacks');
fbEl.innerHTML = '';
for (const p of ALL_PIECES) {
if (taken.has(p)) continue;
const row = document.createElement('div');
row.className = 'flex items-center gap-2 px-2 py-1';
const labelTxt = friendly(p);
const current = (kit.fallbacks && kit.fallbacks[p]) || '';
let optsHtml = '<option value="">— drop —</option>';
for (const ln of kit.lanes) {
const sel = ln.piece === current ? ' selected' : '';
optsHtml += `<option value="${ln.piece}"${sel}>${friendly(ln.piece)}</option>`;
}
row.innerHTML =
`<span class="flex-1 text-xs text-gray-400">${labelTxt}</span>` +
`<span class="text-xs text-gray-600"></span>` +
`<select data-from="${p}" class="bg-dark-700 border border-gray-800 rounded px-2 py-1 text-xs text-gray-300 outline-none">${optsHtml}</select>`;
fbEl.appendChild(row);
}
fbEl.querySelectorAll('select').forEach(s => {
s.onchange = () => {
const next = JSON.parse(JSON.stringify(kit));
next.fallbacks = next.fallbacks || {};
if (s.value) next.fallbacks[s.dataset.from] = s.value;
else delete next.fallbacks[s.dataset.from];
window.drumH3dSetKit(next); // event-driven renderKit() via drum_h3d:kit
};
});
// Refresh palette swatches so they reflect the new lane order.
if (window._drumH3dRenderPaletteSwatches) {
const pSel = document.getElementById('drumh3d-palette');
window._drumH3dRenderPaletteSwatches(pSel ? pSel.value : 'default');
}
}
function setStatus(msg, kind) {
const el = document.getElementById('drumh3d-kit-status');
if (!el) return;
el.textContent = msg;
el.className = 'text-xs ml-auto ' + (kind === 'err' ? 'text-red-400' : kind === 'ok' ? 'text-emerald-400' : 'text-gray-500');
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 3000);
}
// Expose the kit interaction surface to the inline onclick / onchange
// attributes used by the markup above.
window._drumH3dKit = {
onNameInput(name) {
// Use the name-only setter to avoid triggering 'drum_h3d:kit'
// on every keystroke — that event causes a full WebGL scene
// teardown/reinit which produces visible stutter while typing.
window.drumH3dSetKitName && window.drumH3dSetKitName(name);
},
onAddPiece(piece) {
if (!piece) return;
const kit = window.drumH3dGetKit();
kit.lanes.push({ piece });
window.drumH3dSetKit(kit); // event-driven renderKit() via drum_h3d:kit
},
copyToClipboard() {
const b64 = window.drumH3dExportKit && window.drumH3dExportKit();
if (!b64) return setStatus('export failed', 'err');
const _showImportFallback = (str) => {
// Clipboard unavailable — reveal the import row so the user can
// copy the string from the textarea manually.
document.getElementById('drumh3d-kit-import-row').classList.remove('hidden');
const ta = document.getElementById('drumh3d-kit-import-text');
ta.value = str;
ta.focus();
ta.select();
setStatus('clipboard blocked — copy from the textarea below', 'err');
};
if (!navigator.clipboard) {
_showImportFallback(b64);
return;
}
navigator.clipboard.writeText(b64).then(
() => setStatus('✓ kit copied', 'ok'),
() => _showImportFallback(b64),
);
},
importFromTextarea() {
const txt = document.getElementById('drumh3d-kit-import-text').value;
if (!txt.trim()) return setStatus('paste a kit string first', 'err');
const ok = window.drumH3dImportKit && window.drumH3dImportKit(txt);
// drumH3dImportKit calls drumH3dSetKit which dispatches drum_h3d:kit;
// the event listener re-renders the panel, so no explicit renderKit().
if (ok) { setStatus('✓ kit imported', 'ok'); }
else setStatus('invalid kit string', 'err');
},
resetDefault() {
window.drumH3dResetKit && window.drumH3dResetKit();
// drumH3dResetKit calls drumH3dSetKit → dispatches drum_h3d:kit
// → event listener re-renders. No explicit renderKit() needed.
setStatus('✓ reset to default', 'ok');
},
};
// Re-render the kit list whenever the kit changes (covers external
// changes from setKit() / importKit() not initiated by this panel).
window.addEventListener('drum_h3d:kit', () => renderKit());
// ─── MIDI device picker ───────────────────────────────
function renderMidiPicker() {
const sel = document.getElementById('drumh3d-midi-input');
if (!sel) return;
const inputs = window.drumH3dListMidiInputs ? window.drumH3dListMidiInputs() : [];
const current = window.drumH3dGetMidiInputId ? window.drumH3dGetMidiInputId() : '';
sel.innerHTML = '<option value="">— none —</option>';
for (const inp of inputs) {
const opt = document.createElement('option');
opt.value = inp.id;
opt.textContent = inp.name;
if (inp.id === current) opt.selected = true;
sel.appendChild(opt);
}
// Sync volume slider with the actual persisted value (default
// 0.7 if storage was empty or never written).
const vol = window.drumH3dGetSynthVolume ? window.drumH3dGetSynthVolume() : 0.7;
const volSlider = document.getElementById('drumh3d-synth-vol');
const volLabel = document.getElementById('drumh3d-synth-vol-val');
if (volSlider) volSlider.value = String(vol);
if (volLabel) volLabel.textContent = parseFloat(vol).toFixed(2);
}
window.addEventListener('drum_h3d:midi_devices', renderMidiPicker);
// Kick off MIDI initialisation if the player viz never ran (user
// opened Settings → 3D Drum Highway directly). _midiInit is
// idempotent so this is safe even when the viz is also active.
// Re-render the picker when the access promise settles.
(function pollMidi() {
if (window.drumH3dEnsureMidiInit) {
Promise.resolve(window.drumH3dEnsureMidiInit()).then(renderMidiPicker);
renderMidiPicker(); // first paint with whatever's cached
} else {
setTimeout(pollMidi, 100);
}
})();
// Hydrate controls from stored config on first paint.
// Narrow the try/catch to just localStorage reads — renderKit() and
// swatches must always run even when storage is blocked/disabled.
const pSel = document.getElementById('drumh3d-palette');
const cam = document.getElementById('drumh3d-camera');
const camVal = document.getElementById('drumh3d-camera-val');
try {
const storedPal = localStorage.getItem('drum_h3d_palette');
if (storedPal && PALETTES[storedPal]) pSel.value = storedPal;
const storedCam = parseFloat(localStorage.getItem('drum_h3d_camera_angle'));
if (Number.isFinite(storedCam)) {
const c = Math.min(1, Math.max(0, storedCam));
cam.value = String(c);
camVal.textContent = c.toFixed(2);
}
// FX toggles (drum_h3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const hydrateFxBool = (key, elId) => {
const raw = localStorage.getItem('drum_h3d_bg_' + key);
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
};
hydrateFxBool('bloom', 'drumh3d-fx-bloom');
hydrateFxBool('sparks', 'drumh3d-fx-sparks');
hydrateFxBool('timingFx', 'drumh3d-fx-timing');
hydrateFxBool('streakFx', 'drumh3d-fx-streak');
hydrateFxBool('cinematic', 'drumh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'drumh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'drumh3d-fx-scorefx');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('drum_h3d_bg_' + key));
if (!Number.isFinite(n)) return;
const v = Math.min(1, Math.max(0, n));
document.getElementById(elId).value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRange('hitFx', 'drumh3d-fx-hitfx', 'drumh3d-fx-hitfx-val');
hydrateFxRange('glow', 'drumh3d-fx-glow', 'drumh3d-fx-glow-val');
hydrateFxRange('vibrancy', 'drumh3d-fx-vibrancy', 'drumh3d-fx-vibrancy-val');
hydrateFxRange('bgIntensity', 'drumh3d-fx-bgintensity', 'drumh3d-fx-bgintensity-val');
const storedStyle = localStorage.getItem('drum_h3d_bg_style');
const styleSel = document.getElementById('drumh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
styleSel.value = storedStyle;
}
const storedTheme = localStorage.getItem('drum_h3d_bg_theme');
const themeSel = document.getElementById('drumh3d-fx-theme');
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
} catch (e) {
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
}
renderKit();
window._drumH3dRenderPaletteSwatches(pSel.value);
})();
</script>
</div>
@@ -0,0 +1,164 @@
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
// __test exports (no DOM, no WebGL, no network). Doubles as a lint that no
// module-scope code touches document/localStorage outside a try/catch —
// the vm window deliberately provides neither.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window.slopsmithViz_drum_highway_3d;
}
test('module loads in a bare vm (no DOM / localStorage at module scope)', () => {
const factory = load();
assert.equal(typeof factory, 'function');
assert.equal(factory.contextType, 'webgl2');
});
test('_variantForHit: ghost > flam > bell > accent > normal precedence', () => {
const { _variantForHit } = load().__test;
assert.equal(_variantForHit({ g: true, f: true, v: 120 }), 'ghost');
assert.equal(_variantForHit({ f: true, v: 120 }), 'flam');
assert.equal(_variantForHit({ p: 'ride_bell' }), 'bell');
assert.equal(_variantForHit({ v: 100 }), 'accent');
assert.equal(_variantForHit({ v: 127 }), 'accent');
assert.equal(_variantForHit({ v: 99 }), 'normal');
// Missing velocity defaults to 100 → accent.
assert.equal(_variantForHit({}), 'accent');
});
test('matchesArrangement: claims drum arrangements', () => {
const matches = load().matchesArrangement;
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drums' }), true);
assert.equal(matches({ has_drum_tab: true, arrangement: 'Drum Kit' }), true);
assert.equal(matches({ has_drum_tab: true, arrangement: 'Percussion' }), true);
});
test('matchesArrangement: never claims without a drum tab', () => {
const matches = load().matchesArrangement;
assert.equal(matches(null), false);
assert.equal(matches({}), false);
assert.equal(matches({ arrangement: 'Drums' }), false);
});
test('matchesArrangement: steal-guard — guitar arrangements stay with highway_3d', () => {
const matches = load().matchesArrangement;
// Full-band pack (drum_tab present) playing a guitar-family part:
// first-match-wins Auto order must not hand these to the drum highway.
for (const arr of ['Lead', 'Rhythm', 'Bass', 'Combo', 'Guitar 22', 'Alt. Lead']) {
assert.equal(matches({ has_drum_tab: true, arrangement: arr }), false, arr);
}
// Keys notation present → the keys/staff viz take it.
assert.equal(matches({ has_drum_tab: true, has_notation: true, arrangement: 'Piano' }), false);
});
test('matchesArrangement: claims packs nothing more specific can render', () => {
const matches = load().matchesArrangement;
// Drum tab + nondescript arrangement, no notation → drummable, claim it.
assert.equal(matches({ has_drum_tab: true, arrangement: '' }), true);
assert.equal(matches({ has_drum_tab: true }), true);
// Word-boundary check: "BasslineKeys"-style names don't contain a
// guitar-family word as a whole word.
assert.equal(matches({ has_drum_tab: true, arrangement: 'Bassline' }), true);
});
test('readFxSettings: defaults survive a localStorage-less environment', () => {
const { readFxSettings, FX_DEFAULTS } = load().__test;
// The vm window has no localStorage — the try/catch must eat the
// ReferenceError and hand back pure defaults (everything ON).
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
assert.equal(FX_DEFAULTS.bloom, true);
});
test('MIDI map: open hi-hat is a first-class piece (46 → hh_open)', () => {
const { MIDI_TO_PIECE, HIT_TOLERANCE_S } = load().__test;
assert.equal(MIDI_TO_PIECE[46], 'hh_open');
assert.equal(MIDI_TO_PIECE[42], 'hh_closed');
assert.equal(MIDI_TO_PIECE[35], 'kick');
assert.equal(MIDI_TO_PIECE[36], 'kick');
// ±50 ms window matches the 2D drums plugin.
assert.equal(HIT_TOLERANCE_S, 0.05);
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming, HIT_TOLERANCE_S } = load().__test;
const tol = HIT_TOLERANCE_S; // 0.05
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK'); // boundary inclusive
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(tol, tol), 'EARLY');
assert.equal(_classifyTiming(-tol, tol), 'LATE');
// Degenerate inputs read as on-time rather than throwing.
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('FX defaults: hit-FX controls ship enabled', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
});
test('themes: table ids match the guitar highway, default is the stock palette', () => {
const { BG_THEMES, _bgThemeColors } = load().__test;
// Same id set as highway_3d's BG_THEMES (cross-instrument consistency).
assert.deepEqual(Object.keys(BG_THEMES), [
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
]);
// 'default' preserves THIS plugin's original look byte-for-byte.
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
assert.equal(BG_THEMES.default.board, 0x0a0e1a);
assert.equal(BG_THEMES.default.lane, undefined); // stock stripes fallback
// Unknown ids fall back to default.
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
// Every non-default theme carries a lane pair (the axis differentiator).
for (const [id, t] of Object.entries(BG_THEMES)) {
if (id === 'default') continue;
assert.ok(t.lane != null && t.laneDim != null, id + ' lane pair');
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
}
});
test('readThemeSetting: defaults without localStorage; validates ids', () => {
const { readThemeSetting } = load().__test;
assert.equal(readThemeSetting(), 'default');
});
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.cinematic, true);
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
assert.equal(FX_DEFAULTS.vibrancy, 0.85); // ≈ the stock 0.32 stripe base
});
test('bg styles: validated id set, particles default, no out-of-scope styles', () => {
const { BG_STYLE_IDS, readBgStyleSetting } = load().__test;
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
assert.equal(readBgStyleSetting(), 'particles'); // no localStorage in the vm
});
test('FX defaults: ambience + score FX ship enabled', () => {
const { FX_DEFAULTS } = load().__test;
assert.equal(FX_DEFAULTS.scoreFx, true);
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
+345
View File
@@ -0,0 +1,345 @@
# Folder Library — AI Agent Guide
A FeedBack (fee[dB]ack) plugin that adds a **Folders** nav screen showing your `.sloppak` / `.feedpak` DLC songs grouped by the folder tree on disk. Create, rename, and delete folders (including **nested subfolders**) directly in the UI, move songs by drag-and-drop, and browse with sort and metadata filters.
> The host app is **FeedBack** (formerly "Slopsmith"). The frontend talks to the host through `window.feedBack`; `window.slopsmith` is a back-compat alias the host still exposes (`window.slopsmith = window.feedBack` in `static/app.js`). New code should prefer `window.feedBack`.
> ⚠️ **Status — bundled core plugin.** This plugin began as a standalone plugin and is now a bundled core plugin. `screen.js` has been unified into a **single surface factory** driving two entry points: the v3 library Folder view (host chrome — host search `#v3-search`/`#lib-filter`, host filter params, renders into `#lib-folder-tree`) and the classic v2 standalone Folders nav-tab (its own `#fb-search` + toolbar, renders into `#fb-tree`). **Folder search works on both surfaces** — typing in the relevant search box re-renders the tree. **Loose-folder songs** (directories with audio + an arrangement XML) are recognised as songs via the host `loosefolder.is_loose_song` predicate, so they appear in the tree alongside `.sloppak`/`.feedpak` bundles. Folder management, nested subfolders, collapsible folders + expand/collapse-all, drag-and-drop, move-song, sort, filters, and the hover metadata badges are wired on both surfaces; verify against a running build before relying on any of it.
## File Structure
```
plugin.json Plugin manifest — id, name, nav entry, file declarations ("bundled": true core plugin)
routes.py FastAPI backend — recursive DLC scan, folder tree + filters, folder/song mutations, two-level cache
screen.html Plugin screen content — injected by the host into the plugin div automatically
screen.js Frontend logic — recursive folder tree, search, sort, filters, drag-and-drop, modals
README.md User-facing docs
```
## Architecture
This plugin follows the standard FeedBack plugin pattern (see the repo-root `CLAUDE.md` for the full plugin system reference).
- **Backend** (`routes.py`) — registers routes under `GET/POST /api/plugins/folder_library/`. Uses `context["get_dlc_dir"]()`, `context["extract_meta"]()`, and `context["log"]`. Scans `<dlc>/sloppak/` if it exists, otherwise `<dlc>/`. Recursively walks the tree and handles create/rename/delete folder and move-song operations on slash-separated folder paths.
- **Frontend** (`screen.js`) — plain vanilla JS in an IIFE. Fetches the tree from the backend on screen load, recursively renders collapsible folder sections (any depth) and song rows or cards (grid view). Uses `window.feedBack.on('screen:changed', ...)` (via the `window.slopsmith` alias) to trigger load when the user navigates here. Calls `window.playSong(filename)` on song click with the full relative path from the DLC root.
- **No dependencies** — no npm, no build step. Tailwind utility classes available globally from the host; the plugin uses only core-guaranteed utilities and inline styles, so it ships **no** `styles` manifest key.
## Critical Layout Lessons (Hard-Won)
These are non-obvious behaviours of the FeedBack desktop app (Electron) that took significant debugging to discover. They still apply unchanged.
### 1. Do NOT put an outer wrapper div in screen.html
The host automatically creates `<div id="plugin-folder_library" class="screen">` and injects `screen.html` content inside it. If you add your own outer div with `class="screen"`, you get a nested screen element which gets `display:none` applied, hiding all content.
**Wrong:**
```html
<div id="plugin-folder_library" class="screen">
<div>toolbar</div>
<div>content</div>
</div>
```
**Correct:**
```html
<!-- no outer wrapper — the host provides it -->
<div>toolbar</div>
<div>content</div>
```
### 2. The .screen CSS class sets display:none by default
`.screen { display: none }` and `.screen.active { display: block }`. There is no height set. The screen div gets its height purely from its content. Do not try to set height via CSS classes — use inline styles or JS if needed.
### 3. The host navbar is position:fixed with z-index:50
The navbar sits at `top:0, z-index:50`. Plugin toolbars must use `position:fixed; top:64px; z-index:40` to sit below the navbar. Use a solid `background-color` (not Tailwind bg classes — those may not apply correctly) to prevent content showing through.
### 4. Content must have padding-top to clear the fixed toolbar
Since the toolbar is `position:fixed`, it floats above the content. The content container needs enough `padding-top` (~120px) to ensure the first item isn't hidden behind the toolbar — the host navbar (64px) plus the plugin toolbar height (~56px). Adding more toolbar buttons increases this height, so if content is clipped, increase the padding further.
### 5. Electron blocks window.prompt() and window.confirm()
The desktop app is built on Electron, which throws `Error: prompt() is not supported`. Use a custom inline modal instead. See `_showModal()` in `screen.js` — it returns a Promise and supports both text input and confirm modes.
### 6. The nav plugin dropdown has z-index:50 and blocks clicks
When navigating to a plugin screen via the Plugins dropdown, the dropdown stays open and sits on top of the screen. Call `_closeDropdown()` on screen load to dismiss it. The dropdown element id is `plugin-dropdown`.
### 7. playSong() expects a relative path from the DLC root
`window.playSong()` expects the path relative to the DLC root with forward slashes, e.g. `sloppak/CH/Artist - Title.sloppak`. Not just the filename. The backend builds this in `_meta()` via `"/".join(p.relative_to(dlc).parts)` and returns it as each song's `filename`.
### 8. FastAPI POST routes need `from fastapi import Request`
Routes that receive a JSON body must import `Request` from fastapi explicitly and use `async def route(request: Request)` with `body = await request.json()`. Missing this import crashes the server on plugin load.
### 9. Plugin id must be consistent everywhere
The plugin id (`folder_library`) must match in:
- `plugin.json``"id"` and `"nav.screen"`
- `screen.js``PLUGIN_ID` constant and `API` constant (`/api/plugins/folder_library`)
- `routes.py``APIRouter(prefix="/api/plugins/folder_library")`
A mismatch in any of these causes silent failures (blank screen, 404 API calls).
### 10. Use inline styles for grid layout, not Tailwind
Tailwind's `grid` and `grid-cols-*` classes may not apply reliably inside the plugin div. Use `element.style.cssText` with explicit `display:grid; grid-template-columns:...` for the grid container.
## Key Conventions
- **IIFE + `'use strict'`** — all frontend code wrapped in `(function(){ 'use strict'; ... })();`
- **localStorage prefixes** — plugin keys are prefixed `fo:` (e.g. `fo:view`, `fo:sort`, `fo:filters`); host-library-synced filter state uses `fo:lib:`. Open-folder state is tracked by **folder path** (so nested folders each remember their own state).
- **Safe storage access** — all `localStorage` reads/writes wrapped in try/catch
- **Logging** — backend uses `context["log"]`, never `print()`
- **Sibling imports** — use `context["load_sibling"]("name")` not bare `import name` (none needed today; keep this in mind if you add helper modules)
## Song Formats
The plugin treats both `.sloppak` and `.feedpak` as songs (`_is_song()` in `routes.py`). `feedpak` is the published name for the same on-disk format the codebase still calls `sloppak` internally — see the repo-root `CLAUDE.md`. Both file form (`.sloppak`/`.feedpak` zip) and directory form (`*.sloppak/` folder) are recognized.
## Backend Routes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/plugins/folder_library/tree` | Returns the folder tree. Accepts optional filter query params (below) applied server-side. |
| POST | `/api/plugins/folder_library/folder/create` | Body: `{name, parent?}` — creates a subfolder; `parent` (slash path) nests it inside an existing folder, omit/empty for top level |
| POST | `/api/plugins/folder_library/folder/rename` | Body: `{old, new}``old` is a slash path, `new` is a bare name; renames within the same parent |
| POST | `/api/plugins/folder_library/folder/delete` | Body: `{name}` (slash path) — moves all songs at any depth to the scan root, then removes the folder |
| POST | `/api/plugins/folder_library/song/move` | Body: `{filename, folder}` — moves a song to `folder` (slash path; empty = scan root / "Unsorted") |
### `/tree` filter query params
All optional, applied server-side over the cached full tree by `_apply_tree_filters()`. Comma-separated, case-insensitive:
- `arrangements_has`, `arrangements_lacks` — include/exclude by arrangement name
- `stems_has`, `stems_lacks` — include/exclude by stem name
- `has_lyrics``""` (any), `"1"`, or `"0"`
- `tunings` — comma-separated tuning names to include
The frontend forwards the host library's active filter params here (via `window.feedBackLibFilterParams()` when present, with `window.slopsmithLibFilterParams()` as a legacy fallback) so the Folders view can stay in sync with the main library filters, falling back to its own filter panel state otherwise.
### Path safety
`_safe_name()` rejects empty names, leading/trailing whitespace, the characters `\ / : * ? " < > |`, and `.`/`..`. `_safe_path()` applies `_safe_name()` to every slash-separated segment, so traversal (`..`) and absolute paths are rejected before any filesystem op. Always validate user-supplied folder paths through these before touching disk.
## Tree Shape
`/tree` returns:
```json
{
"folders": [
{
"name": "CH",
"path": "CH",
"songs": [ /* song objects */ ],
"children": [
{ "name": "Live", "path": "CH/Live", "songs": [], "children": [] }
]
}
],
"root_songs": [ /* songs sitting directly in the scan root — shown as "Unsorted" */ ]
}
```
Folder nodes are **recursive**: each has `name`, `path` (slash-separated, relative to the scan root), `songs`, and `children`. The frontend renders any depth — `_findFolderByPath()`, `_countDeep()`, and `_countFoldersDeep()` walk the `children` arrays.
## Song Metadata Format
Each song object (built by `_meta()`):
```json
{
"filename": "sloppak/CH/Artist - Title.sloppak",
"title": "Title",
"artist": "Artist",
"album": "Album Name",
"duration": 213.5,
"year": 1993,
"tuning": "E Standard",
"added": 1748132400.0,
"arrangements": ["Lead", "Rhythm", "Bass"],
"stems": ["Drums", "Bass", "Vocals"],
"lyrics": true
}
```
- `filename` is the full relative path from the DLC root — pass it directly to `window.playSong()`.
- `added` is a Unix timestamp (float, seconds) from `stat().st_mtime` — convert with `new Date(added * 1000)`. Always recomputed fresh (it changes when a file moves), even on a metadata-cache hit.
- `arrangements` / `stems` are flat lists of **strings**, even though `extract_meta()` returns them as objects.
### extract_meta returns arrangements/stems as objects, not strings
`context["extract_meta"]()` returns arrangements as a list of objects `{index, name, notes}`, not plain strings; stems similarly. `_meta()` normalizes to `.name`:
```python
raw_arr = raw.get("arrangements") or []
m["arrangements"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_arr
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
```
`lyrics` is coerced to a bool from several possible keys (`lyrics`, `hasLyrics`, `has_lyrics`, …). If you add new metadata fields from `extract_meta`, check the raw shape before assuming it's a plain value.
## Two-Level Cache
`routes.py` keeps two caches inside `setup()`:
- **`_meta_cache`** — expensive `extract_meta()` results keyed by absolute POSIX path. **Never cleared.** When files move (rename/delete/move), the keys are rewritten in-place so the warm data survives the operation.
- **`_cache`** — the assembled tree structure (`folders` / `root_songs`). Cleared by `_invalidate()` on **every** mutation so the next `/tree` rebuilds it — but the rebuild is fast because `_meta_cache` is still warm.
`filename` and `added` are deliberately **not** stored in `_meta_cache` (they depend on the file's current location) — they're recomputed on every `_meta()` call and merged onto the cached copy. When you add a mutation route, mirror the existing key-rewrite logic (see `rename_folder`, `delete_folder`, `move_song`) so the metadata cache stays valid.
## Folder Scan Logic
`routes.py` scans recursively starting at `<dlc>/sloppak/` (or `<dlc>/` if no `sloppak` subdir exists):
- Files/dirs matching `.sloppak` or `.feedpak` → song entries (root-level ones go to `root_songs`, shown as "Unsorted")
- Subdirectories → recursive folder nodes with their own `songs` + `children`
- Dot-prefixed entries are skipped; empty folders are still included (shown with a 0 count)
To add more grouping options (by artist, album, etc.), build an alternative projection over the scanned songs rather than the on-disk tree.
## Library provider (future, not implemented)
This plugin surfaces folders as a dedicated **view** over the existing library;
it does not (yet) register itself as a selectable library **source/provider**.
If you want a "Folders" entry to appear in the host's main library-source
picker (mapping top-level folder → "artist", subfolder → "album"), implement a
provider exposing the source-aware contract (`query_page`, `query_artists`,
`query_stats`, `tuning_names`) and register it in `setup()` via
`context["register_library_provider"](...)`, unregistering on teardown. (An
earlier inert `FolderLibraryProvider` scaffold was removed — it was never wired
and only duplicated the scan logic; re-add it only alongside real registration
and tests.)
## View Modes (List / Grid)
The toolbar has a list/grid toggle. Current view is stored in `localStorage` under `fo:view` (`'list'` or `'grid'`).
- **List view**`_songRow()`, rendered inside a `ml-5 space-y-0` div
- **Grid view**`_songCard()`, rendered inside a CSS grid div (`auto-fill, minmax(150px,1fr)`)
- Both the folder and unsorted section renderers branch on `_view` to pick the right renderer and container
- Album art is fetched via `/api/song/<encoded-path>/art` where each path segment is individually `encodeURIComponent`-encoded. On error the `<img>` is hidden and a placeholder SVG is shown
- The collapse/expand toggle restores `display:grid` (not just `display:''`) when reopening a folder in grid mode — always check this when changing toggle logic
### Lazy folder rendering
Folders do **not** render their song list on initial load. The folder renderer sets a `_listPopulated` flag and only populates the list the first time a folder is opened, keeping the initial render fast with large libraries. When search is active all folders are forced open and populated immediately (search overrides lazy loading).
## Sort System
The toolbar has a sort select (`#fb-sort`) and a direction toggle (`#fb-sort-dir`). State is stored under `fo:sort` and `fo:sortDir`.
- `_sort``'default' | 'title' | 'artist' | 'duration' | 'year' | 'tuning' | 'added'`
- `_sortDir``'asc' | 'desc'`
- `_sortSongs(songs)` returns a sorted copy; direction is applied by reversing after sort. Returns the array unchanged when `_sort === 'default'`.
- The sort direction button is dimmed (`opacity: 0.35`) and non-interactive when sort is `'default'`.
## Filter System
Client-side filters are stored under `fo:filters` as a JSON object. (The server `/tree` endpoint can also filter — see Backend Routes — used to sync with the host library.)
### Filter state shape
```js
_filters = {
arrangements: { Lead: 'on', Bass: 'exclude', Rhythm: 'off' },
stems: { Drums: 'off' },
lyrics: 'off', // 'off' | 'on' | 'exclude'
tunings: ['E Standard', 'Eb Standard'],
}
```
Each arrangement/stem value is `'off' | 'on' | 'exclude'`.
### Include vs exclude logic
`_matchFilters(song)` uses **OR logic for includes, AND logic for excludes**:
- **Include (`'on'`)** — song passes if it has *at least one* selected arrangement/stem. More includes widens the result set.
- **Exclude (`'exclude'`)** — each excluded tag independently removes songs that have it. More excludes narrows the result set.
This matches standard multi-select filter UX (Spotify/library style).
### Data-driven filter panel
All filter sections are built from the actual library data — nothing is hardcoded:
- `_getArrangements()` — unique arrangement names sorted by frequency (most common first), then alphabetically
- `_getStems()` — same pattern for stem names
- `_getAvailableFilters()` — returns `{ arrangements, stems, lyrics, tuning }` booleans gating the lyrics/tuning sections
Non-standard arrangement names (e.g. `"Bonus"`) appear as pills automatically — no constants to update. The stems section only appears if at least one song has stems data.
### Split pill UI
`_makeSplitPill(label, state, onChange)` renders a two-zone pill:
- Left zone (label) — toggles `'off' ↔ 'on'` (include, blue)
- Right zone (`✕`) — toggles `'off' ↔ 'exclude'` (exclude, red)
The filter badge (`#fb-filter-badge`) shows the active filter count via `_activeFilterCount()`.
## Hover Badges
Each song row/card has two hidden hover-reveal layers, built once and toggled via CSS `max-height` + `opacity` transitions.
### `_badge(text, active, type)`
Renders a single metadata badge. Type controls the inactive colour:
| type | inactive border | inactive text |
|---|---|---|
| `'arrangement'` | amber `#92400e` | amber `#fcd34d` |
| `'stem'` | violet `#5b21b6` | violet `#c4b5fd` |
| `'lyrics'` | rose `#9f1239` | rose `#fda4af` |
| `'tuning'` | teal `#0f766e` | teal `#5eead4` |
Active state is always blue (`#1d4ed8` fill, `#3b82f6` border, white text) regardless of type.
### `_buildSongBadges(song)`
Builds the badge row (arrangements, stems, lyrics, tuning), deduplicating within each category. Clicking a badge toggles that filter on/off and re-renders. Returns `null` if the song has no filterable metadata.
### `_buildSongDateInfo(song)`
Builds a separate plain-text hover line showing `year · date added` (e.g. `1993 · 24 May 2026`), `#cbd5e1` text. Always shown on hover regardless of filter state.
### Reveal / hide
```js
_revealBadges(el) // max-height:120px, opacity:1, margin-top:4px
_hideBadges(el) // max-height:0, opacity:0, margin-top:0
```
Both badge layers (badges + date-info) are wired to the same `mouseenter`/`mouseleave` events on the row or card element.
## Drag-and-Drop
Drag-and-drop uses **pointer events** (mousedown/mousemove/mouseup), not the HTML5 DnD API. HTML5 DnD blocks wheel events and gives unreliable edge positions inside Electron — pointer events give full control.
- `_makeDraggable(el, song, folderName)` — attaches a `mousedown` listener. A drag goes "live" only after the pointer moves more than `_DRAG_THRESH` (5 px), preventing accidental drags on clicks.
- Once live, a ghost `div` follows the cursor. Auto-scroll activates when the pointer is within `_DRAG_ZONE` (150 px) of the viewport top/bottom.
- `_makeDropTarget(el, targetFolder)` — sets `data-dropFolder` so an element can receive drops. Both folder headers and song-list containers are drop targets — including **nested** folders (drop onto a subfolder header moves the song there).
- `_dragFindTarget(x, y)` — uses `document.elementsFromPoint` to find the topmost element with `data-dropFolder` under the cursor.
- **Esc to cancel**`_onDragKeyDown` calls `_endPointerDrag()` on `Escape`, removing the ghost and clearing state without dropping.
- On a successful drop, `_executeDrop()` does an **optimistic UI update** (moves the song in the in-memory tree and re-renders) then calls `/song/move`. On API failure it reloads the full tree.
- A one-time `click` capture listener after mouseup suppresses the post-drag click so it doesn't trigger playback.
## Modal Behaviour
`_showModal(msg, withInput, defaultVal)` is the custom modal used for all prompts and confirms (Electron blocks `window.prompt()` / `window.confirm()`). It returns a Promise.
- `_confirm(msg)` — resolves `true` on OK, `null` on cancel
- `_prompt(msg, default)` — resolves the trimmed input string on OK, `null` on cancel
- **Esc cancels** — resolves with `null`, same as Cancel (applies to rename, delete, create folder/subfolder, move song)
- **Enter confirms** — submits, equivalent to OK
## Roadmap
Implemented since the original release: **nested subfolders** (recursive tree + create-inside-folder), drag-and-drop, sort, advanced filtering, server-side tree filtering synced to the host library, and the warm metadata cache.
Not yet implemented, in rough priority order:
- **Auto-play on hover** — with an on/off toggle saved to localStorage.
- **Bulk move** — multi-select songs and move them all at once.
- **Thumbnail performance** — faster loading and smoother scrolling with large libraries.
- **Adjustable thumbnail/row sizes** — user-resizable song cards and list rows.
- **Custom themes** — switchable colour schemes.
- **Favoriting songs** — likely a new backend route plus a `fo:favorites` localStorage key.
- **Editing song metadata** — edit title, artist, album etc. in-plugin; needs new backend write routes.
- **Folders as a library source** — register a library provider so a "Folders" entry appears in the host's main library-source picker (see "Library provider (future)" above).
+100
View File
@@ -0,0 +1,100 @@
# Folder Library — FeedBack Plugin
![Core plugin](https://img.shields.io/badge/fee%5BdB%5Dack-core%20plugin-blue)
![Platform](https://img.shields.io/badge/platform-fee%5BdB%5Dack-darkblue)
A FeedBack (fee[dB]ack) plugin that organizes your `.sloppak` / `.feedpak` DLC songs into a folder tree, grouped by the folders on disk. Browse your whole library visually with album art, nest folders as deep as you like, switch between list and grid layouts, and manage folders without ever leaving the app.
---
## Screenshots
![Grid view](assets/grid-view.webp)
*Grid view — album art cards with title and artist*
![Grid search](assets/grid-search.png)
*Live search filters instantly across all folders*
![List view](assets/list-view.png)
*List view — compact rows with album art thumbnails and duration*
![New folder](assets/new-folder.png)
*Create and manage folders directly in the UI*
---
> **Status — migrating to core.** Folder Library is being reworked from a standalone plugin into a bundled core plugin, and several previously-shipped features are not currently wired up in core (see the Roadmap). The list below reflects what works today; if something here is wrong, it's because this rework is still in progress.
## Features
- **List & Grid views** — toggle between a compact list with thumbnails or a full album art card grid
- **Album art** — pulls art automatically for every song in both views
- **One-click playback** — click any song to start playing immediately
- **Sort options** — sort songs by title, artist, duration, year, tuning, or recently added with an asc/desc toggle
- **Advanced filters** — filter by arrangements, stems, lyrics, and tuning with include and exclude support
- **Folder management** — create, rename, and delete folders without leaving the plugin
- **Nested subfolders** — organize as deep as you want; create a subfolder inside any folder, expand/collapse a whole branch in one click
- **Collapsible folders** — expand/collapse individual folders, plus Expand All / Collapse All
- **Move songs** — reassign any song to a different folder on the fly; press `Esc` to cancel
- **Drag-and-drop** — drag songs between folders (including into nested folders) with smooth auto-scroll; press `Esc` to cancel
- **Fast with big libraries** — folder song lists render lazily and metadata is cached so reopening folders is instant
---
## Installation
Folder Library ships bundled with FeedBack as a core plugin (`"bundled": true`), so there's nothing to install — the **Folders** screen appears in the navbar under **Plugins** automatically.
---
## Usage
| Action | How |
|--------|-----|
| Switch to grid view | Click the grid icon in the toolbar |
| Switch to list view | Click the list icon in the toolbar |
| Play a song | Click any song row or card |
| Sort songs | Use the sort dropdown in the toolbar |
| Toggle sort direction | Click the arrow button next to the sort dropdown |
| Open filters | Click the filter icon in the toolbar |
| Filter by arrangement/stem | Open filters → click a pill to include; click `✕` to exclude |
| Clear all filters | Open filters → click "Clear all" |
| Create a folder | Click the folder+ icon in the toolbar |
| Create a subfolder | Hover a folder header → click the new-subfolder icon |
| Rename a folder | Hover the folder header → click the pencil icon |
| Delete a folder | Hover the folder header → click the trash icon (songs move up to Unsorted) |
| Move a song | Hover the song row → click the folder icon |
| Drag a song to a folder | Click and hold a song → drag to a folder header or body (nested folders work too) |
| Cancel a drag | Press `Esc` while holding a song |
| Cancel a move dialog | Press `Esc` in the move prompt |
| Expand / collapse a folder | Click the folder header |
| Expand / collapse all subfolders | Use the expand/collapse-children buttons on a folder with subfolders |
---
## Changelog
Folder Library started life as a standalone plugin with its own version line, but it's now a **bundled core plugin** that ships with FeedBack. Its changes are tracked alongside the app in the repo-root [CHANGELOG.md](../../CHANGELOG.md), and it versions with the app rather than on its own. The **Features** section above reflects what's in the current build.
---
## Roadmap
- [ ] Auto play song on hover (with an on/off toggle)
- [ ] Bulk move — select multiple songs and move them at once
- [ ] Thumbnail performance — faster loading and smoother scrolling with large song libraries
- [ ] Adjustable thumbnail and row sizes — resize song cards and list rows to suit your preference
- [ ] Custom themes — switch between colour schemes to match your style
- [ ] Favoriting songs
- [ ] Editing song metadata
---
## Contributing
Pull requests are welcome. For major changes please open an issue first to discuss what you'd like to change.
1. Fork the repo
2. Create a feature branch (`git checkout -b feature/your-feature`)
3. Commit your changes
4. Push to the branch and open a pull request
+10
View File
@@ -0,0 +1,10 @@
{
"id": "folder_library",
"name": "Folder Library",
"version": "1.8.0",
"bundled": true,
"nav": { "label": "Folders", "screen": "plugin-folder_library" },
"screen": "screen.html",
"script": "screen.js",
"routes": "routes.py"
}
+440
View File
@@ -0,0 +1,440 @@
"""
Folder Library plugin routes.py
Surfaces the DLC folder structure as a navigable tree and provides in-app
folder management (create / rename / delete) and song moves. Every filesystem
mutation is confined to DLC_DIR and validated against path traversal.
"""
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
import shutil
import re
# ── Pure, testable helpers ─────────────────────────────────────────────────
_UNSAFE_NAME_RE = re.compile(r'[\\/:*?"<>|]')
def _safe_name(name: str) -> bool:
"""A single path segment is safe: no separators, no traversal dot-names,
no surrounding whitespace, no characters illegal across filesystems."""
if not name or name.strip() != name:
return False
if _UNSAFE_NAME_RE.search(name):
return False
if name in (".", ".."):
return False
return True
def _safe_path(path_str: str) -> bool:
"""A slash-separated path is safe iff every segment is a safe name."""
if not path_str:
return False
return all(_safe_name(p) for p in path_str.split("/"))
def _is_within(root: Path, candidate: Path) -> bool:
"""True iff ``candidate`` resolves to a location inside ``root`` (after
normalising ``..`` and symlinks). Containment backstop for file moves so a
crafted filename can't escape DLC_DIR even past the segment validator."""
try:
candidate.resolve().relative_to(root.resolve())
return True
except (ValueError, OSError):
return False
def _path_to_dir(root: Path, folder_path: str) -> Path:
"""Resolve a slash-separated folder path relative to ``root``."""
result = root
for part in folder_path.split("/"):
result = result / part
return result
def _load_is_loose_song():
"""The host's authoritative loose-folder predicate (lib/loosefolder.py),
imported lazily so the plugin still loads if it's ever unavailable. A
loose-folder song is a directory carrying audio + an arrangement XML rather
than a ``.sloppak`` bundle, so the plain suffix check below misses it."""
try:
from loosefolder import is_loose_song
return is_loose_song
except Exception:
return None
_IS_LOOSE_SONG = _load_is_loose_song()
def _is_song(p: Path) -> bool:
"""A song carrier is a ``.sloppak`` / ``.feedpak`` file or directory-form
bundle (extension on the leaf name), or a host-recognised loose-folder song
directory so loose-folder charts surface in the tree like any other song
instead of being walked into as if they were ordinary folders."""
if p.suffix.lower() in (".sloppak", ".feedpak"):
return True
if _IS_LOOSE_SONG is not None and p.is_dir():
try:
return bool(_IS_LOOSE_SONG(p))
except Exception:
return False
return False
def setup(app, context):
log = context["log"]
router = APIRouter(prefix="/api/plugins/folder_library")
# ── Two-level cache ────────────────────────────────────────────────
# _meta_cache — expensive extract_meta() results keyed by abs path
# (as_posix() string). Never cleared; keys are updated
# in-place when files are moved so the data stays valid.
# _cache — tree structure ("folders" / "root_songs"). Cleared on
# every mutation so the next /tree request rebuilds it —
# but that rebuild is now fast because _meta_cache is warm.
_cache = {} # "tree" → JSONResponse-ready dict
_meta_cache = {} # abs_posix_path → extracted meta (no filename/added)
def _invalidate():
"""Clear the tree structure cache only. _meta_cache is preserved."""
_cache.clear()
def _dlc_root() -> Path | None:
try:
return Path(context["get_dlc_dir"]())
except Exception:
return None
def _scan_root(dlc: Path) -> Path:
sloppak = dlc / "sloppak"
return sloppak if sloppak.exists() else dlc
def _meta(p: Path, dlc: Path) -> dict:
# filename and added are always computed fresh — they change when files move.
try:
filename = "/".join(p.relative_to(dlc).parts)
except ValueError:
filename = p.name
added = None
try:
added = p.stat().st_mtime
except Exception:
pass
# Return cached extracted metadata if available.
cache_key = p.as_posix()
if cache_key in _meta_cache:
m = dict(_meta_cache[cache_key]) # shallow copy
m["filename"] = filename
m["added"] = added
return m
# Cache miss — run the expensive extract.
m = {"title": None, "artist": None, "album": None, "duration": None,
"year": None, "tuning": None, "arrangements": [], "stems": [], "lyrics": False}
try:
raw = context["extract_meta"](p)
if raw:
m["title"] = raw.get("title") or raw.get("name")
m["artist"] = raw.get("artist") or raw.get("artistName")
m["album"] = raw.get("album") or raw.get("albumName")
m["duration"] = raw.get("duration")
m["year"] = raw.get("year")
m["tuning"] = raw.get("tuning")
# arrangements — objects with a "name" key e.g. [{name:"Lead",...}, ...]
raw_arr = raw.get("arrangements") or []
if isinstance(raw_arr, (list, tuple)):
m["arrangements"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_arr
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
# stems — may also be objects with a "name" key, same as arrangements
raw_stems = raw.get("stems") or []
for _key in ("stems", "stem_types", "available_stems", "stemTypes"):
_v = raw.get(_key)
if _v:
raw_stems = _v
break
if isinstance(raw_stems, (list, tuple)):
m["stems"] = [
a["name"] if isinstance(a, dict) else str(a)
for a in raw_stems
if (isinstance(a, dict) and "name" in a) or isinstance(a, str)
]
# lyrics — try common key variants
for _key in ("lyrics", "hasLyrics", "has_lyrics", "lyric", "hasLyric"):
_val = raw.get(_key)
if _val is not None:
if isinstance(_val, str):
m["lyrics"] = _val.lower() not in ("", "false", "no", "0")
else:
m["lyrics"] = bool(_val)
break
except Exception as exc:
log.debug("meta failed for %s: %s", p.name, exc)
if not m["title"]:
m["title"] = p.stem
_meta_cache[cache_key] = m # store without filename/added
result = dict(m)
result["filename"] = filename
result["added"] = added
return result
def _scan_dir(path: Path, root: Path, dlc: Path) -> dict:
"""Recursively scan a directory and return a folder node."""
songs = []
children = []
try:
for entry in sorted(path.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith("."):
continue
if _is_song(entry):
songs.append(_meta(entry, dlc))
elif entry.is_dir():
children.append(_scan_dir(entry, root, dlc))
except PermissionError:
log.warning("permission denied: %s", path)
try:
rel = path.relative_to(root)
folder_path = "/".join(rel.parts)
except ValueError:
folder_path = path.name
return {
"name": path.name,
"path": folder_path,
"songs": songs,
"children": children,
}
def _apply_tree_filters(tree, arrangements_has="", arrangements_lacks="",
stems_has="", stems_lacks="", has_lyrics="", tunings=""):
"""Filter a cached tree dict by arrangement/stem/lyrics/tuning params.
The cache always holds the full unfiltered tree; this is applied per-request."""
def _split(s):
return [x.strip().lower() for x in s.split(",") if x.strip()] if s else []
arr_has = _split(arrangements_has)
arr_lacks = _split(arrangements_lacks)
st_has = _split(stems_has)
st_lacks = _split(stems_lacks)
tun_set = set(_split(tunings))
lyr = None if has_lyrics == "" else (has_lyrics == "1")
if not any([arr_has, arr_lacks, st_has, st_lacks, tun_set, lyr is not None]):
return tree # no filters active — return as-is
def _song_ok(s):
arrs = [a.lower() for a in (s.get("arrangements") or [])]
stms = [x.lower() for x in (s.get("stems") or [])]
if arr_has and not any(a in arrs for a in arr_has): return False
if arr_lacks and any(a in arrs for a in arr_lacks): return False
if st_has and not any(x in stms for x in st_has): return False
if st_lacks and any(x in stms for x in st_lacks): return False
if lyr is not None and bool(s.get("lyrics")) != lyr: return False
if tun_set and (s.get("tuning") or "").lower() not in tun_set: return False
return True
def _filter_node(node):
return {
"name": node["name"],
"path": node["path"],
"songs": [s for s in node["songs"] if _song_ok(s)],
"children": [_filter_node(c) for c in node.get("children", [])],
}
return {
"folders": [_filter_node(f) for f in tree["folders"]],
"root_songs": [s for s in tree["root_songs"] if _song_ok(s)],
}
@router.get("/tree")
def get_tree(
arrangements_has: str = "",
arrangements_lacks: str = "",
stems_has: str = "",
stems_lacks: str = "",
has_lyrics: str = "",
tunings: str = "",
):
if "tree" not in _cache:
dlc = _dlc_root()
if not dlc or not dlc.exists():
return JSONResponse({"folders": [], "root_songs": [],
"error": "DLC directory not found"})
root = _scan_root(dlc)
log.info("folder_library: scanning %s", root)
folders = []
root_songs = []
try:
for entry in sorted(root.iterdir(), key=lambda p: p.name.lower()):
if entry.name.startswith("."):
continue
if _is_song(entry):
root_songs.append(_meta(entry, dlc))
elif entry.is_dir():
folders.append(_scan_dir(entry, root, dlc))
except PermissionError:
return JSONResponse({"folders": [], "root_songs": [],
"error": "Permission denied"})
_cache["tree"] = {"folders": folders, "root_songs": root_songs}
result = _apply_tree_filters(
_cache["tree"], arrangements_has, arrangements_lacks,
stems_has, stems_lacks, has_lyrics, tunings,
)
return JSONResponse(result)
@router.post("/folder/create")
async def create_folder(request: Request):
body = await request.json()
name = (body.get("name") or "").strip()
parent = (body.get("parent") or "").strip()
if not _safe_name(name):
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
if parent and not _safe_path(parent):
return JSONResponse({"error": "Invalid parent path"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
parent_dir = _path_to_dir(root, parent) if parent else root
if parent and not parent_dir.exists():
return JSONResponse({"error": "Parent folder not found"}, status_code=404)
target = parent_dir / name
if target.exists():
return JSONResponse({"error": "Folder already exists"}, status_code=400)
try:
target.mkdir(parents=False)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/folder/rename")
async def rename_folder(request: Request):
body = await request.json()
old = (body.get("old") or "").strip()
new = (body.get("new") or "").strip()
if not _safe_path(old) or not _safe_name(new):
return JSONResponse({"error": "Invalid folder name"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
src = _path_to_dir(root, old)
dst = src.parent / new # rename within the same parent
if not src.exists():
return JSONResponse({"error": "Folder not found"}, status_code=404)
if dst.exists():
return JSONResponse({"error": "Name already taken"}, status_code=400)
try:
# Pre-compute meta cache key updates (keys change because the
# folder path changes — all files under src get a new prefix).
old_prefix = src.as_posix() + "/"
new_prefix = dst.as_posix() + "/"
meta_updates = {
key: new_prefix + key[len(old_prefix):]
for key in list(_meta_cache)
if key.startswith(old_prefix)
}
src.rename(dst)
_invalidate()
for old_key, new_key in meta_updates.items():
if old_key in _meta_cache:
_meta_cache[new_key] = _meta_cache.pop(old_key)
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/folder/delete")
async def delete_folder(request: Request):
body = await request.json()
name = (body.get("name") or "").strip()
if not _safe_path(name):
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
root = _scan_root(dlc)
target = _path_to_dir(root, name)
if not target.exists():
return JSONResponse({"error": "Folder not found"}, status_code=404)
try:
# Relocate every song (at any depth) up to the scan root BEFORE
# removing the folder. Colliding filenames are de-duplicated so a
# name clash never leaves a song behind to be destroyed by rmtree
# (the folder is advertised as "moves its songs to Unsorted").
for song_path in sorted(target.rglob("*")):
if not song_path.exists():
continue # a parent song-dir was already relocated
if not _is_song(song_path):
continue
old_key = song_path.as_posix()
dest = root / song_path.name
if dest.exists():
stem, suffix = song_path.stem, song_path.suffix
n = 1
while dest.exists():
dest = root / f"{stem} ({n}){suffix}"
n += 1
song_path.rename(dest)
if old_key in _meta_cache:
_meta_cache[dest.as_posix()] = _meta_cache.pop(old_key)
shutil.rmtree(target)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
@router.post("/song/move")
async def move_song(request: Request):
body = await request.json()
filename = (body.get("filename") or "").strip()
dest_folder = (body.get("folder") or "").strip()
# Validate the source path like the folder ops, AND confirm it resolves
# inside DLC_DIR — without this a filename such as "../../etc/passwd"
# would be renamed (moved) into the served library and become readable.
if not filename or not _safe_path(filename):
return JSONResponse({"error": "Invalid filename"}, status_code=400)
dlc = _dlc_root()
if not dlc:
return JSONResponse({"error": "DLC dir not found"}, status_code=500)
src = dlc / Path(*filename.split("/"))
if not _is_within(dlc, src):
return JSONResponse({"error": "Invalid filename"}, status_code=400)
if not src.exists():
return JSONResponse({"error": "Song not found"}, status_code=404)
root = _scan_root(dlc)
if dest_folder:
if not _safe_path(dest_folder):
return JSONResponse({"error": "Invalid folder path"}, status_code=400)
dst_dir = _path_to_dir(root, dest_folder)
if not dst_dir.exists():
return JSONResponse({"error": "Destination folder not found"}, status_code=404)
else:
dst_dir = root
dst = dst_dir / src.name
if dst.exists():
return JSONResponse({"error": "File already exists at destination"}, status_code=400)
try:
old_key = src.as_posix()
src.rename(dst)
if old_key in _meta_cache:
_meta_cache[dst.as_posix()] = _meta_cache.pop(old_key)
_invalidate()
return JSONResponse({"ok": True})
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
app.include_router(router)
log.info("folder_library routes registered")
+159
View File
@@ -0,0 +1,159 @@
<!-- Folder Browser — screen.html
Slopsmith injects this into a div#plugin-folder_library.screen automatically.
Do NOT add an outer wrapper div with class="screen". -->
<!-- ── toolbar ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-2 px-4 py-3 border-b border-dark-400 flex-wrap"
style="position:fixed; top:64px; left:0; right:0; z-index:40; background-color:#0f1117; border-bottom: 1px solid #1f2937;">
<h2 class="text-base font-semibold text-white mr-1">Folders</h2>
<!-- search -->
<div class="relative flex-1 min-w-40 max-w-xs">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-500 pointer-events-none"
viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"/>
</svg>
<input id="fb-search" type="text" placeholder="Search songs…"
class="w-full pl-8 pr-3 py-1.5 rounded bg-dark-500 border border-dark-400
text-sm text-gray-200 placeholder-gray-500
focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"/>
</div>
<!-- new folder -->
<button id="fb-new-folder" title="New parent folder"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
<path fill-rule="evenodd" d="M10 9a1 1 0 011 1v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H8a1 1 0 110-2h1v-1a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
</button>
<!-- expand all -->
<button id="fb-expand-all" title="Expand all"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" class="w-4 h-4">
<path d="M5 8l5 5 5-5"/>
<path d="M5 4l5 5 5-5" opacity=".4"/>
</svg>
</button>
<!-- collapse all -->
<button id="fb-collapse-all" title="Collapse all"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" class="w-4 h-4">
<path d="M5 12l5-5 5 5"/>
<path d="M5 16l5-5 5 5" opacity=".4"/>
</svg>
</button>
<!-- sort -->
<select id="fb-sort" title="Sort songs within folders"
style="padding:4px 8px; border-radius:6px; border:1px solid #374151;
background:#1f2937; color:#d1d5db; font-size:12px; cursor:pointer; outline:none;">
<option value="default">Default</option>
<option value="title">Title</option>
<option value="artist">Artist</option>
<option value="duration">Duration</option>
<option value="year">Year</option>
<option value="tuning">Tuning</option>
<option value="added">Recently Added</option>
</select>
<!-- sort direction -->
<button id="fb-sort-dir" title="Ascending"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg id="fb-sort-dir-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" class="w-4 h-4">
<path d="M5 12l5-5 5 5"/>
</svg>
</button>
<!-- view toggle -->
<button id="fb-view-list" title="List view"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M3 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3zm0 4a1 1 0 000 2h14a1 1 0 100-2H3z"
clip-rule="evenodd"/>
</svg>
</button>
<button id="fb-view-grid" title="Grid view"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M5 3a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2V5a2 2 0 00-2-2H5zM5 11a2 2 0 00-2 2v2a2 2 0 002 2h2a2 2 0 002-2v-2a2 2 0 00-2-2H5zM11 5a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V5zM11 13a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
</button>
<!-- filters -->
<button id="fb-filter" title="Filters"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors"
style="position:relative;">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z"
clip-rule="evenodd"/>
</svg>
<span id="fb-filter-badge"
style="display:none; position:absolute; top:-2px; right:-2px; min-width:14px; height:14px;
padding:0 3px; border-radius:7px; background:#3b82f6; color:#fff;
font-size:9px; font-weight:700; line-height:14px; text-align:center;
box-sizing:border-box;"></span>
</button>
<!-- reload -->
<button id="fb-reload" title="Reload"
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-dark-400 transition-colors">
<svg viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"/>
</svg>
</button>
<span id="fb-status" class="text-xs text-gray-500 ml-1"></span>
</div>
<!-- ── tree ─────────────────────────────────────────────────────────── -->
<div id="fb-tree" class="px-2 py-2" style="padding-top: 120px;"></div>
<!-- ── filter backdrop ───────────────────────────────────────────────── -->
<div id="fb-filter-backdrop"
style="display:none; position:fixed; inset:0; z-index:44;"></div>
<!-- ── filter panel ──────────────────────────────────────────────────── -->
<div id="fb-filter-panel"
style="display:none; position:fixed; top:64px; right:0; bottom:0; width:300px;
z-index:45; background:#0f1117; border-left:1px solid #1f2937;
flex-direction:column; overflow:hidden;"></div>
<!-- ── custom modal ──────────────────────────────────────────────────── -->
<div id="fb-modal" style="display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,0.6); align-items:center; justify-content:center;">
<div style="background:#1e2130; border:1px solid #374151; border-radius:8px;
padding:24px; width:360px; max-width:90vw; box-shadow:0 20px 60px rgba(0,0,0,0.5);">
<p id="fb-modal-msg" style="color:#e5e7eb; font-size:14px; margin:0 0 16px 0;
white-space:pre-wrap; line-height:1.5;"></p>
<input id="fb-modal-input" type="text"
style="display:none; width:100%; box-sizing:border-box; padding:8px 12px;
background:#111827; border:1px solid #374151; border-radius:6px;
color:#e5e7eb; font-size:14px; outline:none; margin-bottom:16px;"
placeholder=""/>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button id="fb-modal-cancel"
style="padding:6px 16px; border-radius:6px; border:1px solid #374151;
background:transparent; color:#9ca3af; font-size:13px; cursor:pointer;">
Cancel
</button>
<button id="fb-modal-ok"
style="padding:6px 16px; border-radius:6px; border:none;
background:#3b82f6; color:#fff; font-size:13px; cursor:pointer; font-weight:500;">
OK
</button>
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+14 -2
View File
@@ -108,9 +108,19 @@ Each entry names the function or banner you should grep for, plus key sub-blocks
- **Beat lines** (downbeats highlighted) → `update()`, `// ── Beat lines ──` block. `mBeatM` (full opacity 0.25) for measure starts, `mBeatQ` (0.07) for other beats.
- **Section labels**`update()`, `// ── Section labels ──` block. Cyan (`#00cccc`) sprite at fret 12, above the highest string.
### Scene colors (two independent axes: Background + Highway)
- **Scene-color themes**`BG_THEMES` table near the top of `createFactory()`. One combined table is the single source of truth, but it drives **two independent axes that share the same id-set**:
- **Background axis** — setting key `bgTheme`, setter `window.h3dBgSetBgTheme`, state `bgThemeId`. Owns `clear` (WebGL clear color) + `fog`.
- **Highway axis** — setting key `hwTheme`, setter `window.h3dBgSetHwTheme`, state `hwThemeId`. Owns `board` (fretboard/highway-surface plane) + optional `lane`/`laneDim` (the lit lane strip).
Any background id can mix with any highway id; picking the same id in both gives the original "matched" look. Per-axis accessors are `_bgBackgroundColors(id)` / `_bgHighwayColors(id)` (both alias `_bgThemeColors`). Both axes default to `'default'` (byte-identical to the original look).
- **Applying a theme**`_applyBgTheme()`. Background half sets clear+fog from `bgThemeId` (skipped under the venue scene); highway half sets the board plane + lane materials (`mLaneOdd`/`mLaneEven`) from `hwThemeId`. Re-run on init, `buildBoard()`, and the settings listener (which fires for **both** `bgTheme` and `hwTheme`), so changing either dropdown retints only its half live.
- **Backward-compat migration**`_bgLoadSettings()`: the first time it loads with no stored `hwTheme` (`_bgHasStored` false), it seeds `hwThemeId` from `bgThemeId` **and persists `hwTheme` once** (a one-time backfill, written without `_bgEmitChange`). So a pre-split single-`bgTheme` pick is byte-identical right after the upgrade, and from then on the two axes are fully independent — changing the Background dropdown never drags the Highway surface, and the settings UI's Highway value can't disagree with what's rendered. settings.html shows the same first-load value via `storedHwTheme == null ? bgTheme : coerceHwTheme(...)`.
- **Adding/removing a theme** → edit `BG_THEMES` (the colors) AND `settings.html`'s `SCENE_THEMES` array (the `{id,label}` list — the single source the two dropdowns' `<option>`s and the `VALID_BG_THEMES` validator are both generated from). Keep the two id-sets aligned.
### Highway lane (the highlighted strip under active frets)
- **Lane drawing**`update()`, `// ── Dynamic highway lane ──` block. `pLane` is a single quad on the fretboard plane; `pLaneDivider` is thin vertical lines at each fret inside the lane. Width keys off the active-fret range; min width ≈ 4 frets.
- **Lane intensity**`highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it). `_laneTargetColor = 0x4488ff` (set in `initScene()`) is the "lit" color, blended toward from `0x112233`.
- **Lane intensity**`highwayIntensity` accumulated from upcoming notes (further notes dim it, near notes light it).
- **Lane color** → the lit quad color is `mLaneOdd.color` (stock `HWY_LANE_STRIPE_ODD_HEX = 0x103B5C`), the dimmer alternating row `mLaneEven.color` (`HWY_LANE_STRIPE_EVEN_HEX = 0x08283C`). These are now **theme-aware**: `_applyBgTheme()` recolors them from the active HIGHWAY theme's optional `lane`/`laneDim` fields, falling back to the stock hexes when a highway theme omits them. (`_laneTargetColor`, set in `initScene()`, is kept in sync with the lit color but has no live consumer today.)
### Lyrics & overlays
- **Lyrics overlay**`drawLyrics()`. 2D canvas, top centre, semi-transparent rounded background, syllable-level highlighting (current syllable in white, played in muted, upcoming in dim).
@@ -147,6 +157,8 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
`tuning` and `capo` aren't consumed by this plugin.
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
### Score FX (notedetect game-scoring layer)
- **"+N" score pops** → `_fxSpawnPop()` from `drawNote()` (just after the provider verdict-override block), drawn by `drawScoreFx()` (called from the `lyricsCtx` block in `draw()`, right after `drawNotedetectLabels()`). Fixed 24-slot pool (`_fxPops`), deduped per `popKey` via the TTL'd `_fxSeen` map (pruned in `drawScoreFx`). Pops rise/fade over 700 ms; font size scales with the multiplier tier.
@@ -201,7 +213,7 @@ The eight-color palette `S_COL` is the single source of truth for per-string col
If a planned color-palette feature lands (issue #10), expect it to swap the palette source array but keep this single-array indirection. Anything that hardcodes color today will break that swap; flag it during review.
Non-string colors (lane target `0x4488ff`, fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
Non-string colors (the stock lane hexes `HWY_LANE_STRIPE_ODD_HEX`/`_EVEN_HEX` — now overridable per Highway theme, see "Scene colors" above; fret-row label colors `#ffe84d` / `#9ab8cc`, fret-dot color `0x556677`, lyrics box rgba, chord-name gold `#e8d080`, etc.) are scattered as literals — that's intentional for now, since they're scene-wide accents rather than per-string. Pulling them into named constants is fine if you're already in that area.
## Tweaking text-sprite styling
+30
View File
@@ -0,0 +1,30 @@
3D Highway plugin — third-party notices
=======================================
The "Butterchurn (visualizer)" background style (the 'butterchurn' option in
the plugin's Background-style setting) bundles the third-party components below
under plugins/highway_3d/assets/vendor/. They are lazy-loaded only when a user
selects that background style.
The 3D Highway plugin and the feedBack project are licensed under the GNU Affero
General Public License v3.0 (AGPL-3.0-only). The components below are under their
own permissive (MIT) licenses, which are compatible with AGPL-3.0.
- Butterchurn — a WebGL implementation of the MilkDrop visualizer.
https://github.com/jberg/butterchurn
Copyright (c) Jordan Berg. MIT License.
Vendored at: plugins/highway_3d/assets/vendor/butterchurn.min.js
- butterchurn-presets — MilkDrop preset pack.
https://github.com/jberg/butterchurn-presets
Presets authored by the MilkDrop community (Geiss, Flexi, Martin, Rovastar,
Zylot, Aderrasi, Cope, Eo.S., Unchained, and many others); refer to each
preset's embedded attribution.
Vendored at: plugins/highway_3d/assets/vendor/butterchurnPresets.min.js
- Three.js (r170) — loaded at runtime from feedBack core's vendored copy
(/static/vendor/three/three.module.min.js). https://threejs.org MIT License.
Provenance: the Butterchurn background integration originated as the standalone
"3D Highway + Butterchurn" plugin and was merged into the bundled 3D Highway
renderer as the opt-in 'butterchurn' background style.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
/*
* Slopsmith visualizer audio feed AudioWorkletProcessor.
* Generates Butterchurn's drive signal off the main thread (replaces the
* deprecated ScriptProcessor): guitar PCM (gained) + a bass-band song-energy
* pulse + a mid-band chart-accent pulse. The main thread pushes the latest
* guitar frame and the song/chart/gain scalars via port messages.
*/
class VizFeedProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.frame = null; // latest guitar PCM (Float32Array)
this.readIdx = 0;
this.song = 0;
this.chart = 0;
this.gain = 6;
this.phase = 0;
this.phase2 = 0;
this.osc = (2 * Math.PI * 90) / sampleRate; // ~90 Hz → bass band (song)
this.osc2 = (2 * Math.PI * 520) / sampleRate; // ~520 Hz → mid band (chart)
this.port.onmessage = (e) => {
const d = e.data;
if (!d) return;
if (d.frame) { this.frame = d.frame; this.readIdx = 0; }
if (typeof d.song === 'number') this.song = d.song;
if (typeof d.chart === 'number') this.chart = d.chart;
if (typeof d.gain === 'number') this.gain = d.gain;
};
}
process(inputs, outputs) {
const out = outputs[0] && outputs[0][0];
if (!out) return true;
const f = this.frame, fl = f ? f.length : 0;
const TWO_PI = 2 * Math.PI;
for (let i = 0; i < out.length; i++) {
const g = (fl ? f[this.readIdx % fl] : 0) * this.gain;
this.readIdx++;
const song = this.song * (0.7 * Math.sin(this.phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
const chart = this.chart * (0.5 * Math.sin(this.phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
this.phase += this.osc; if (this.phase > TWO_PI) this.phase -= TWO_PI;
this.phase2 += this.osc2; if (this.phase2 > TWO_PI) this.phase2 -= TWO_PI;
const v = g + song + chart;
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
}
return true;
}
}
registerProcessor('viz-feed', VizFeedProcessor);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.26.0",
"version": "3.31.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
"styles": "assets/plugin.css",
"settings": { "html": "settings.html", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"routes": "routes.py",
"tour": "tour.json"
}
+2055 -109
View File
File diff suppressed because it is too large Load Diff
+325 -3
View File
@@ -14,6 +14,15 @@
<button type="button" onclick="window.hwcSaveTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Save as…</button>
<button type="button" onclick="window.hwcDeleteTheme?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-400">Delete</button>
</div>
<!-- One-click presets: apply a whole named palette to every string at once.
Buttons are generated from window.feedBack.highwayColors.presets so the
list stays in sync with core (app.js HWC_PRESETS). Each calls
applyPreset(id), which persists + applies to both highways and refreshes
the pickers below. Older core without the presets API leaves this empty. -->
<div class="mb-3">
<span class="text-xs text-gray-500 mb-1 block">Quick presets</span>
<div id="hwc-presets" class="flex flex-wrap gap-2"></div>
</div>
<div id="hwc-pickers" class="mb-3" style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;"></div>
<div class="flex items-center gap-2 mb-2">
<button type="button" onclick="window.hwcReset?.()" class="bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300">Reset to defaults</button>
@@ -36,6 +45,33 @@
if (typeof window.hwcInitSettingsUI === 'function') {
try { window.hwcInitSettingsUI(); } catch (e) { console.warn('[3D-Hwy] hwcInitSettingsUI failed', e); }
}
// Render the one-click preset buttons from core's preset list.
try {
const api = window.feedBack && window.feedBack.highwayColors;
const host = document.getElementById('hwc-presets');
if (api && Array.isArray(api.presets) && host) {
host.innerHTML = '';
// Slot order low → high for the preview swatch (bass-side first).
const order = ['lowE', 'A', 'D', 'G', 'B', 'highE'];
for (const p of api.presets) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'flex items-center gap-2 bg-dark-700 border border-gray-800 rounded-lg px-2 py-1 text-xs text-gray-300';
const sw = document.createElement('span');
const stops = order.map((k) => p.colors[k]).filter(Boolean).join(',');
sw.style.cssText = 'width:2.5rem;height:0.85rem;border-radius:3px;border:1px solid #0006;'
+ 'background:linear-gradient(90deg,' + stops + ');';
btn.appendChild(sw);
const txt = document.createElement('span');
txt.textContent = p.label;
btn.appendChild(txt);
btn.addEventListener('click', function () {
try { api.applyPreset(p.id); } catch (e) { console.warn('[3D-Hwy] applyPreset failed', e); }
});
host.appendChild(btn);
}
}
} catch (e) { console.warn('[3D-Hwy] preset render failed', e); }
})();
</script>
</div>
@@ -88,11 +124,145 @@
<option value="silhouettes">Silhouettes (parallax)</option>
<option value="lights">Lights (stage glows)</option>
<option value="geometric">Geometric (rotating shapes)</option>
<option value="butterchurn">Butterchurn (visualizer)</option>
<option value="image" disabled>Custom image (none uploaded)</option>
<option value="video" disabled>Custom video (none uploaded)</option>
</select>
</div>
<!-- Scene colors — TWO independent axes sharing one palette family:
Background (clear + fog) and Highway (board surface + lit lane). Same
id-set in both, so any background can mix with any highway; picking the
same in both gives the original "matched" look. "Default" is the
original look on both. -->
<div class="mt-3">
<label for="h3d-bg-theme" class="text-xs font-medium text-gray-400 mb-1 block">Background</label>
<!-- Options are populated from SCENE_THEMES (the single id+label source)
in the hydration script below, so the two dropdowns can't drift. -->
<select id="h3d-bg-theme"
onchange="window.h3dBgSetBgTheme && window.h3dBgSetBgTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
</select>
<p class="text-[10px] text-gray-500 mt-1">
Tints the background + distance fog. Applies to the 3D highway immediately.
</p>
</div>
<div class="mt-3">
<label for="h3d-hw-theme" class="text-xs font-medium text-gray-400 mb-1 block">Highway (surface + lane)</label>
<!-- Options populated from SCENE_THEMES (see below), same source as the
Background dropdown so the two stay in lockstep. -->
<select id="h3d-hw-theme"
onchange="window.h3dBgSetHwTheme && window.h3dBgSetHwTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
</select>
<p class="text-[10px] text-gray-500 mt-1">
Tints the fretboard surface + the lit lane. Applies to the 3D highway immediately.
</p>
</div>
<!-- Butterchurn (visualizer) options. These drive the 'butterchurn'
Background style. They used to live in a floating in-canvas panel; the
on/off + slider controls now live here in the standard settings UI.
The live preset browser (pick / favorite / ban / cycle) stays in-canvas.
Persisted in the 'viz3d_settings' localStorage blob (shared with the
in-canvas preset controls); window.h3dBcApplySettings() pushes changes
to a mounted highway live. -->
<div id="h3d-bc-section" class="mt-4">
<h3 class="text-sm font-medium text-gray-400 mb-2">Butterchurn (visualizer)</h3>
<p class="text-xs text-gray-500 mb-2">Active when Background style is set to <em>Butterchurn (visualizer)</em>.</p>
<div class="flex items-center gap-3 mt-3">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" id="h3d-bc-enabled" class="accent-accent"> Background on
</label>
</div>
<div class="mt-3">
<label for="h3d-bc-opacity" class="text-xs font-medium text-gray-400 mb-1 block">Opacity: <span id="h3d-bc-opacity-label">100%</span></label>
<input type="range" id="h3d-bc-opacity" min="0" max="100" class="w-full accent-accent">
</div>
<div class="flex items-center gap-3 mt-3">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" id="h3d-bc-laneDim" class="accent-accent"> Dim behind lane
</label>
</div>
<div class="mt-3">
<label for="h3d-bc-laneDimStrength" class="text-xs font-medium text-gray-400 mb-1 block">Dim strength</label>
<input type="range" id="h3d-bc-laneDimStrength" min="0" max="100" class="w-full accent-accent">
</div>
<div class="flex items-center gap-3 mt-3">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" id="h3d-bc-chartAccents" class="accent-accent"> Chart accents
</label>
</div>
<div class="mt-3">
<label for="h3d-bc-chartStrength" class="text-xs font-medium text-gray-400 mb-1 block">Chart strength</label>
<input type="range" id="h3d-bc-chartStrength" min="0" max="200" class="w-full accent-accent">
</div>
<div class="flex items-center gap-3 mt-3">
<label class="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" id="h3d-bc-colorTint" class="accent-accent"> Color tint
</label>
</div>
<div class="mt-3">
<label for="h3d-bc-tintStrength" class="text-xs font-medium text-gray-400 mb-1 block">Tint strength</label>
<input type="range" id="h3d-bc-tintStrength" min="0" max="100" class="w-full accent-accent">
</div>
<div class="mt-3">
<label for="h3d-bc-guitarGain" class="text-xs font-medium text-gray-400 mb-1 block">Guitar gain: <span id="h3d-bc-guitarGain-label">×6</span></label>
<input type="range" id="h3d-bc-guitarGain" min="1" max="12" step="0.5" class="w-full accent-accent">
</div>
<div class="mt-3">
<label for="h3d-bc-songGain" class="text-xs font-medium text-gray-400 mb-1 block">Song gain: <span id="h3d-bc-songGain-label">×1.8</span></label>
<input type="range" id="h3d-bc-songGain" min="0" max="5" step="0.1" class="w-full accent-accent">
</div>
</div>
<script>
// Self-contained Butterchurn settings wiring. Reads/writes the same
// 'viz3d_settings' blob the in-canvas preset controls use (read-modify-
// write preserves cyclePool/hold and the favorites/bans lists), then
// calls window.h3dBcApplySettings() so a mounted highway applies the
// change live. Guarded so it no-ops if the section isn't present.
(function () {
const BC_LS = 'viz3d_settings';
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8 };
function readBlob() { let s = {}; try { s = JSON.parse(localStorage.getItem(BC_LS) || '{}') || {}; } catch (e) {} return s; }
function patch(p) {
const s = readBlob();
Object.assign(s, p);
try { localStorage.setItem(BC_LS, JSON.stringify(s)); } catch (e) {}
try { if (typeof window.h3dBcApplySettings === 'function') window.h3dBcApplySettings(); } catch (e) {}
}
const s = Object.assign({}, BC_DEFAULTS, readBlob());
const $ = (id) => document.getElementById(id);
const en = $('h3d-bc-enabled');
if (!en) return;
const op = $('h3d-bc-opacity'), opv = $('h3d-bc-opacity-label');
const ld = $('h3d-bc-laneDim'), lds = $('h3d-bc-laneDimStrength');
const ca = $('h3d-bc-chartAccents'), cs = $('h3d-bc-chartStrength');
const ct = $('h3d-bc-colorTint'), ts = $('h3d-bc-tintStrength');
const gg = $('h3d-bc-guitarGain'), ggv = $('h3d-bc-guitarGain-label');
const sg = $('h3d-bc-songGain'), sgv = $('h3d-bc-songGain-label');
// Hydrate from saved settings.
en.checked = !!s.enabled;
op.value = Math.round((s.opacity != null ? s.opacity : 1) * 100); if (opv) opv.textContent = op.value + '%';
ld.checked = !!s.laneDim; lds.value = Math.round((s.laneDimStrength != null ? s.laneDimStrength : 0.45) * 100);
ca.checked = !!s.chartAccents; cs.value = Math.round((s.chartStrength != null ? s.chartStrength : 1) * 100);
ct.checked = !!s.colorTint; ts.value = Math.round((s.tintStrength != null ? s.tintStrength : 0.65) * 100);
gg.value = s.guitarGain != null ? s.guitarGain : 6; if (ggv) ggv.textContent = '×' + gg.value;
sg.value = s.songGain != null ? s.songGain : 1.8; if (sgv) sgv.textContent = '×' + sg.value;
// Wire changes (read-modify-write + live apply).
en.addEventListener('change', () => patch({ enabled: en.checked }));
op.addEventListener('input', () => { if (opv) opv.textContent = op.value + '%'; patch({ opacity: op.value / 100 }); });
ld.addEventListener('change', () => patch({ laneDim: ld.checked }));
lds.addEventListener('input', () => patch({ laneDimStrength: lds.value / 100 }));
ca.addEventListener('change', () => patch({ chartAccents: ca.checked }));
cs.addEventListener('input', () => patch({ chartStrength: cs.value / 100 }));
ct.addEventListener('change', () => patch({ colorTint: ct.checked }));
ts.addEventListener('input', () => patch({ tintStrength: ts.value / 100 }));
gg.addEventListener('input', () => { if (ggv) ggv.textContent = '×' + gg.value; patch({ guitarGain: parseFloat(gg.value) }); });
sg.addEventListener('input', () => { if (sgv) sgv.textContent = '×' + sg.value; patch({ songGain: parseFloat(sg.value) }); });
})();
</script>
<!-- Intensity -->
<div class="mt-3">
<label for="h3d-bg-intensity" class="text-xs font-medium text-gray-400 mb-1 block">
@@ -407,6 +577,80 @@
<span>Glowy</span>
</div>
</div>
<div class="mt-4">
<label for="h3d-hitfx" class="text-xs font-medium text-gray-400 mb-1 block">
Hit feedback intensity: <span id="h3d-hitfx-label">0.70</span>
</label>
<input type="range" id="h3d-hitfx" min="0" max="1" step="0.05" value="0.70"
oninput="document.getElementById('h3d-hitfx-label').textContent = parseFloat(this.value).toFixed(2); window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
onchange="window.h3dBgSetHitFx && window.h3dBgSetHitFx(this.value)"
class="w-full accent-accent">
<p class="text-[10px] text-gray-500 mt-1">
How much "juice" a nailed note gets — the strike-line flash and the
spark burst at the hit line. <em>0</em> = colour verdict only (no sparks).
</p>
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-sparks" class="text-xs font-medium text-gray-400">
Hit sparks
<span class="block text-[10px] text-gray-500 font-normal">The particle burst that pops off a note the instant it's detected as a hit. Turn off for a calmer highway — the strike-line flash and colour verdict stay.</span>
</label>
<input type="checkbox" id="h3d-sparks" checked
onchange="window.h3dBgSetSparks && window.h3dBgSetSparks(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-cinematic" class="text-xs font-medium text-gray-400">
Cinematic lighting
<span class="block text-[10px] text-gray-500 font-normal">Darker stage so the glowing notes pop against it. Turn off for the brighter, flatter look.</span>
</label>
<input type="checkbox" id="h3d-cinematic" checked
onchange="window.h3dBgSetCinematic && window.h3dBgSetCinematic(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-streakfx" class="text-xs font-medium text-gray-400">
Streak feedback
<span class="block text-[10px] text-gray-500 font-normal">A clean run quietly "heats up" — bigger sparks the longer you stay accurate. Eases back on a miss.</span>
</label>
<input type="checkbox" id="h3d-streakfx" checked
onchange="window.h3dBgSetStreakFx && window.h3dBgSetStreakFx(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-verdictmarks" class="text-xs font-medium text-gray-400">
Accessible verdict marks (✓ / ✗)
<span class="block text-[10px] text-gray-500 font-normal">Adds a shape mark to each hit/miss so the result doesn't rely on the green/red colour pair alone.</span>
</label>
<input type="checkbox" id="h3d-verdictmarks" checked
onchange="window.h3dBgSetVerdictMarks && window.h3dBgSetVerdictMarks(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-bloom" class="text-xs font-medium text-gray-400">
Glow bloom
<span class="block text-[10px] text-gray-500 font-normal">Real light-bleed around the glowing notes and hit flash (higher fidelity). Turns itself off in split-screen. If your machine struggles, turn this off first.</span>
</label>
<input type="checkbox" id="h3d-bloom" checked
onchange="window.h3dBgSetBloom && window.h3dBgSetBloom(this.checked)"
class="accent-accent mt-0.5">
</div>
<div class="mt-3 flex items-start justify-between gap-3">
<label for="h3d-timingfx" class="text-xs font-medium text-gray-400">
Timing feedback
<span class="block text-[10px] text-gray-500 font-normal">Colours a hit by your timing — on-time green, a touch <span style="color:#35d6ff">early (cyan)</span> or <span style="color:#ffb84d">late (amber)</span> — so you can feel where you sit in the beat.</span>
</label>
<input type="checkbox" id="h3d-timingfx" checked
onchange="window.h3dBgSetTimingFx && window.h3dBgSetTimingFx(this.checked)"
class="accent-accent mt-0.5">
</div>
</div>
</details>
</div>
@@ -712,8 +956,27 @@
no-selection / NaN state. -->
<script>
(function () {
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'image', 'video']);
const DEFAULTS = { style: 'particles', intensity: 0.5, reactive: true, palette: 'default', bgTheme: 'default', hwTheme: 'default', showFretOnNote: true, fretNumberGhostScope: 'chords', cameraSmoothing: 0.5, zoomSmoothing: 0.5, tiltSmoothing: 0.5, cameraLockLow: false, cameraLockZoom: 0.5, cameraMode: 'lookahead', nutHeadstockVisible: true, tuningLabelsVisible: true, nutColor: '#f5f3f0', headstockColor: '#d4b48a', textSize: 0.5, vibrancy: 0.85, glow: 0.25, customImageDataUrl: '', customImageName: '', customVideoName: '', chordDiagramVisible: true, chordDiagramSize: 0.5, chordDiagramPosition: 'tl', fretColumnMarkerCadence: 1, projectionVisible: true, inlayLabelsVisible: false, sectionLabelsOnHighway: false, sectionHudVisible: false, sectionHudPosition: 'tr', sectionHudSize: 0.5, toneHudVisible: false, toneHudPosition: 'tl', toneHudSize: 0.5, fpsVisible: false, fretDividersVisible: true, slideArrowApproachVisible: true, slideArrowNeckVisible: true, slideArrowChainPreviewVisible: true };
const VALID_STYLES = new Set(['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video']);
// Scene color themes — the single id+label source for BOTH the
// Background and Highway dropdowns AND the validator below. The
// <option>s are generated from this list (see hydration), so the two
// dropdowns can't drift from each other or from VALID_BG_THEMES.
// Mirror these ids with screen.js BG_THEMES (the color table).
const SCENE_THEMES = [
{ id: 'default', label: 'Default (blue-black)' },
{ id: 'midnight', label: 'Midnight (deep blue)' },
{ id: 'charcoal', label: 'Charcoal (neutral gray)' },
{ id: 'deeppurple', label: 'Deep purple' },
{ id: 'forest', label: 'Forest (dark green)' },
{ id: 'warmslate', label: 'Warm Slate (espresso)' },
{ id: 'deepfocus', label: 'Deep Focus (near-black)' },
{ id: 'deepsea', label: 'Deep Sea (dark teal)' },
{ id: 'cathode', label: 'Cathode (amber CRT)' },
{ id: 'cathodegreen', label: 'Cathode Green (green CRT)' },
{ id: 'hearth', label: 'Hearth (warm red)' },
];
const VALID_BG_THEMES = new Set(SCENE_THEMES.map((t) => t.id));
const VALID_CAMERA_MODES = new Set(['steady', 'lookahead']);
// Chord diagram is top-only (bl/br removed).
const VALID_CHORD_DIAG_POSITIONS = new Set(['tl', 'tr']);
@@ -771,6 +1034,9 @@
const VIDEO_UPLOAD_URL = '/api/plugins/highway_3d/files';
function coerceStyle(v) { return VALID_STYLES.has(v) ? v : DEFAULTS.style; }
function coerceBgTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.bgTheme; }
// Highway axis shares the same valid id-set as the background axis.
function coerceHwTheme(v) { return VALID_BG_THEMES.has(v) ? v : DEFAULTS.hwTheme; }
function coerceCameraMode(v) {
if (v === 'classic') v = 'steady';
return VALID_CAMERA_MODES.has(v) ? v : DEFAULTS.cameraMode;
@@ -812,7 +1078,7 @@
return fallback;
}
let storedStyle = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
let storedStyle = null, storedBgTheme = null, storedHwTheme = null, storedI = null, storedR = null, storedFretOnNote = null, storedFretNumberGhostScope = null;
let storedCameraSmoothing = null;
let storedCameraMode = null;
let storedCustomImageDataUrl = null, storedCustomImageName = null;
@@ -848,6 +1114,8 @@
let storedSlideArrowChainPreviewVisible = null;
try {
storedStyle = localStorage.getItem('h3d_bg_style');
storedBgTheme = localStorage.getItem('h3d_bg_bgTheme');
storedHwTheme = localStorage.getItem('h3d_bg_hwTheme');
storedI = localStorage.getItem('h3d_bg_intensity');
storedR = localStorage.getItem('h3d_bg_reactive');
storedFretOnNote = localStorage.getItem('h3d_bg_showFretOnNote');
@@ -889,6 +1157,15 @@
} catch (_) { /* storage blocked */ }
const style = coerceStyle(storedStyle);
const bgTheme = coerceBgTheme(storedBgTheme);
// Highway axis. BACKWARD-COMPAT: when hwTheme was never written
// (pre-split installs, or the panel opened before the highway has
// mounted and run its one-time backfill), show the background pick —
// exactly the value screen.js will persist on first load — so the
// dropdown matches what's rendered. Once hwTheme is stored (which the
// renderer's backfill or any explicit pick does) it reads
// independently and the two axes no longer track each other.
const hwTheme = (storedHwTheme == null) ? bgTheme : coerceHwTheme(storedHwTheme);
const intensity = coerceIntensity(storedI);
const reactive = coerceBool(storedR, DEFAULTS.reactive);
const showFretOnNote = coerceBool(storedFretOnNote, DEFAULTS.showFretOnNote);
@@ -959,6 +1236,20 @@
const customVideoName = (typeof storedCustomVideoName === 'string') ? storedCustomVideoName : DEFAULTS.customVideoName;
const sel = document.getElementById('h3d-bg-style');
const bgThemeSel = document.getElementById('h3d-bg-theme');
const hwThemeSel = document.getElementById('h3d-hw-theme');
// Populate both scene-theme dropdowns from the single SCENE_THEMES
// source so they can't drift. (Values are selected further below.)
for (const selEl of [bgThemeSel, hwThemeSel]) {
if (!selEl) continue;
selEl.innerHTML = '';
for (const t of SCENE_THEMES) {
const opt = document.createElement('option');
opt.value = t.id;
opt.textContent = t.label;
selEl.appendChild(opt);
}
}
const sli = document.getElementById('h3d-bg-intensity');
const lbl = document.getElementById('h3d-bg-intensity-label');
const rea = document.getElementById('h3d-bg-reactive');
@@ -1082,6 +1373,35 @@
if (thsi) thsi.value = String(toneHudSize);
if (thslbl) thslbl.textContent = toneHudSize.toFixed(2);
// Hit-feedback "juice" controls — hydrate from saved state so the
// panel reflects persistence on reopen (the renderer already reads
// these via _bgReadSetting; without this the controls always showed
// their default markup, misrepresenting a saved non-default). Reads
// h3d_bg_* directly; defaults mirror BG_DEFAULTS (all bools on,
// hitFx 0.70) and the _bgCoerceBool 'true'/'1' vs 'false'/'0' rules.
try {
const _bgBool = (k, def) => {
const v = localStorage.getItem('h3d_bg_' + k);
return v == null ? def : !(v === 'false' || v === '0');
};
const _setChk = (id, on) => { const el = document.getElementById(id); if (el) el.checked = on; };
_setChk('h3d-sparks', _bgBool('sparks', true));
_setChk('h3d-cinematic', _bgBool('cinematic', true));
_setChk('h3d-streakfx', _bgBool('streakFx', true));
_setChk('h3d-verdictmarks', _bgBool('verdictMarks', true));
_setChk('h3d-bloom', _bgBool('bloom', true));
_setChk('h3d-timingfx', _bgBool('timingFx', true));
const _hf = document.getElementById('h3d-hitfx');
if (_hf) {
let v = parseFloat(localStorage.getItem('h3d_bg_hitFx'));
if (!isFinite(v)) v = 0.70;
v = Math.max(0, Math.min(1, v));
_hf.value = String(v);
const _hfl = document.getElementById('h3d-hitfx-label');
if (_hfl) _hfl.textContent = v.toFixed(2);
}
} catch (_) { /* storage blocked — controls keep their default markup */ }
// (3D Highway palette picker removed — string colors are now set
// via the core "Highway String Colors" UI above, which drives both
// the 2D and 3D highways. The bg-settings 'palette' key still exists
@@ -1200,6 +1520,8 @@
sel.value = style;
}
}
if (bgThemeSel) bgThemeSel.value = bgTheme;
if (hwThemeSel) hwThemeSel.value = hwTheme;
if (fileInput) {
fileInput.addEventListener('change', function () {
+27 -11
View File
@@ -51,15 +51,16 @@
sources = sources.filter((s) => s
&& !/midi/i.test(String(s.providerId || ''))
&& !/^midi-input/i.test(String(s.label || '')));
// De-dupe by display label — the desktop engine enumerates the same
// device under several driver types, so the same name can repeat.
const seen = new Set();
sources = sources.filter((s) => {
const key = String(s.label || '').toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// No label de-dupe here. The audio-input capability already
// collapses exact duplicates by logicalSourceKey
// (_visibleInputSources), so nothing it returns shares a key. A
// device that enumerates under several driver types (ASIO / Windows
// Audio / DirectSound) has a DISTINCT key per type and is now
// labelled with its driver type (e.g. "Focusrite (ASIO)") — each is
// a real, separately-selectable input the user must be able to see.
// The old bare-label collapse also kept whichever variant sorted
// first, which could silently drop the one that was actually
// `selected` below.
const selected = sources.find((s) => s && s.selected) || null;
return { sources, selected };
} catch (_) { return { sources: [], selected: null }; }
@@ -163,10 +164,25 @@
host.querySelector('[data-is-cal]').addEventListener('click', () => {
if (hasDetector) {
// Hide our own full-screen overlay while note_detect's
// Calibration Wizard runs on top. That wizard goes
// transparent (pointer-events:none) when it minimizes to
// expose the Tuner; if our overlay stayed up it would show
// through — covering the tuner with the still-mounted
// "select your input" card and looking like a second
// wizard at the input step. Restore it on done/cancel
// (one of which always fires when that wizard closes).
const ov = document.getElementById('input-setup-overlay');
const prevDisplay = ov ? ov.style.display : '';
if (ov) ov.style.display = 'none';
const restore = () => {
const o = document.getElementById('input-setup-overlay');
if (o) o.style.display = prevDisplay;
};
window.noteDetect.launchCalibration({
instrument: inst,
onDone: () => advance(inst, true),
onCancel: () => { /* stay on this panel; user can skip or retry */ },
onDone: () => { restore(); advance(inst, true); },
onCancel: () => { restore(); /* stay on this panel; user can skip or retry */ },
});
} else {
advance(inst, true);
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+54
View File
@@ -0,0 +1,54 @@
# Keys Highway 3D
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue.
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
## Tests
```
node --test tests/*.test.js
```
## Ported helpers (keep in sync with highway_3d)
Visual-parity code copied from `plugins/highway_3d/screen.js` — same
function names, signatures, and constants on purpose, marked with
`PORTED FROM highway_3d` comments at each site. If the guitar highway
tunes one of these, mirror the change here (and in `drum_highway_3d`):
- `_bloomEnsure()` / `_bloomDispose()` — EffectComposer + UnrealBloomPass
(0.65/0.5/0.82) on a multisampled HalfFloat target, ACES↔None tone-
mapping switch in `draw()`; addons dynamic-imported from
`/static/vendor/three/addons/` (no CDN fallback — direct render is the
graceful degrade)
- `_sparkBurst()` / `_sparkUpdate()` — pooled additive Points hit sparks
(pool 96 here — the flame sprites carry most of the hit feedback)
- `_timingHex()` / `_classifyTiming()` — early/late/on-time feedback
colors (green/cyan/amber) + the 40%-window classifier
- `_ssActive()` — host splitscreen probe (minus the guitar's focus-API
checks, which it needs for input routing and we don't)
- `BG_THEMES` / `_bgThemeColors()` — the scene theme table (same ids/values
as the guitar's, except `default` which is this plugin's original
palette); one pick drives background gradient + floor + lane rails
- `_makeStudioEnv()` — procedural PMREM studio environment (shared with
drum_highway_3d; RoomEnvironment isn't vendored)
- `_applyCinematic()` — ambient/key rebalance (values tuned per plugin)
- `BG_STYLES` (off/particles/lights/geometric) + `_bgGetAnalyser()` /
`_bgReadBands()` — background ambience + the stems-first audio-analyser
bridge (shared with drum_highway_3d)
- `_drawScoreFx()` — score overlay (pops / tier rings / milestone bursts /
streak-break wash), drum_highway_3d pattern
## License
AGPL-3.0.
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-label="Keys Highway 3D placeholder thumbnail">
<rect width="256" height="256" rx="36" fill="#0f172a"/>
<rect x="18" y="18" width="220" height="220" rx="28" fill="#1e293b" stroke="#334155" stroke-width="3"/>
<text x="128" y="150" font-family="Rubik, Arial, sans-serif" font-size="104" font-weight="800" fill="#38bdf8" text-anchor="middle">KH</text>
</svg>

After

Width:  |  Height:  |  Size: 464 B

+68
View File
@@ -0,0 +1,68 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.1.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
"script": "screen.js",
"settings": {
"html": "settings.html",
"category": "graphics"
},
"standards": [
"capability-pipelines.v1",
"plugin-runtime-idempotent.v1"
],
"capabilities": {
"visualization": {
"roles": [
"provider"
],
"operations": [
"renderer.create",
"renderer.destroy"
],
"emits": [
"renderer-ready",
"renderer-failed"
],
"mode": "active",
"safety": "safe"
},
"note-detection": {
"roles": [
"provider"
],
"operations": [
"verify.target"
],
"emits": [
"hit",
"miss"
],
"mode": "active",
"safety": "sensitive"
},
"midi-input": {
"roles": [
"requester"
],
"requests": [
"discover",
"list-sources",
"select-source",
"open-source",
"close-source"
],
"mode": "active",
"compatibility": "degrade-noop",
"ownership": "requester-only",
"safety": "sensitive",
"description": "Reads the MIDI keyboard input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.",
"version": 1
}
},
"category": "practice",
"icon": "assets/thumb.svg"
}
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
<div role="group" aria-labelledby="keysh3d-heading">
<h3 id="keysh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Keys Highway</h3>
<p class="text-xs text-gray-500 mb-3">
Pick a song with keys notation (sloppak-spec §5.3) and choose
<em>Keys Highway 3D</em> from the viz picker (Auto selects it for
notation charts). The plugin auto-attaches to your MIDI keyboard;
device, channel and transpose are configured from the player for
now — this panel holds the graphics controls.
</p>
<!-- Graphics -->
<div class="mt-3">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="keysh3d-fx-theme"
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (original)</option>
<option value="midnight">Midnight</option>
<option value="charcoal">Charcoal</option>
<option value="deeppurple">Deep Purple</option>
<option value="forest">Forest</option>
<option value="warmslate">Warm Slate</option>
<option value="deepfocus">Deep Focus</option>
<option value="deepsea">Deep Sea</option>
<option value="cathode">Cathode</option>
<option value="cathodegreen">Cathode Green</option>
<option value="hearth">Hearth</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background gradient, floor and lane rails — the same theme names
as the guitar highway. Pitch-class note colours never change.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
<input type="checkbox" id="keysh3d-fx-cinematic" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
Cinematic lighting
</label>
<p class="text-xs text-gray-500 mt-1">
Dimmer ambience, stronger key light — deeper shading on the keys
and gems.
</p>
<label for="keysh3d-fx-glow" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Glow strength <span id="keysh3d-fx-glow-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-glow"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('glow', this.value); document.getElementById('keysh3d-fx-glow-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
How hard the notes, key glow and sustain consume-flash
self-illuminate (0.5 = stock). Pairs with bloom.
</p>
<label for="keysh3d-fx-bgstyle" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">Background ambience</label>
<select id="keysh3d-fx-bgstyle"
onchange="window.keys3dSetBgStyle && window.keys3dSetBgStyle(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="off">Off</option>
<option value="particles" selected>Particles</option>
<option value="lights">Stage lights</option>
<option value="geometric">Geometric</option>
</select>
<label for="keysh3d-fx-bgintensity" class="text-xs font-medium text-gray-400 mb-1 mt-2 block">
Ambience intensity <span id="keysh3d-fx-bgintensity-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-bgintensity"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('bgIntensity', this.value); document.getElementById('keysh3d-fx-bgintensity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<label for="keysh3d-fx-bgreactive" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-2">
<input type="checkbox" id="keysh3d-fx-bgreactive" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('bgReactive', this.checked)">
Audio-reactive ambience
</label>
<p class="text-xs text-gray-500 mt-1">
The backdrop pulses with the mix (stems analyser when a sloppak
is loaded). Off = it animates on time only.
</p>
<label for="keysh3d-fx-scorefx" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-scorefx" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('scoreFx', this.checked)">
Score effects
</label>
<p class="text-xs text-gray-500 mt-1">
+1 pops on scored presses, a ring pulse every 10-combo,
milestone bursts at 25/50/100, and a brief red flicker when a
streak breaks.
</p>
<label for="keysh3d-fx-bloom" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-bloom" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('bloom', this.checked)">
Glow (bloom)
</label>
<p class="text-xs text-gray-500 mt-1">
Soft light-bleed around the hit line, hit flames and consumed
sustains. Applies live; turn off to reclaim GPU headroom on
weak machines.
</p>
<label for="keysh3d-fx-sparks" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-sparks" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('sparks', this.checked)">
Hit sparks
</label>
<p class="text-xs text-gray-500 mt-1">
A small particle burst on every scored key press, beside the
pitch-colored flame.
</p>
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-timing" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
Timing colours
</label>
<p class="text-xs text-gray-500 mt-1">
Tint the sparks by timing — on-time green, early cyan, late
amber. Off = always green.
</p>
<label for="keysh3d-fx-streak" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-streak" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('streakFx', this.checked)">
Streak feedback
</label>
<p class="text-xs text-gray-500 mt-1">
Spark bursts grow with your combo.
</p>
<label for="keysh3d-fx-hitfx" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Hit feedback intensity <span id="keysh3d-fx-hitfx-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="keysh3d-fx-hitfx"
min="0" max="1" step="0.05" value="0.7"
oninput="window.keys3dSetFx && window.keys3dSetFx('hitFx', this.value); document.getElementById('keysh3d-fx-hitfx-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Drives the hit-line brightness kick on scored presses. 0 turns
it off.
</p>
<label for="keysh3d-fx-vibrancy" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Note vibrancy <span id="keysh3d-fx-vibrancy-val" class="text-gray-500 font-mono">0.85</span>
</label>
<input type="range" id="keysh3d-fx-vibrancy"
min="0" max="1" step="0.05" value="0.85"
oninput="window.keys3dSetFx && window.keys3dSetFx('vibrancy', this.value); document.getElementById('keysh3d-fx-vibrancy-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Note-gem solidity and lane-guide strength — lower to see more of
the keyboard through the notes.
</p>
</div>
<script>
(function () {
'use strict';
// Hydrate controls from stored config on first paint. Narrow the
// try/catch to just the localStorage reads (drum_highway_3d
// settings-hydration convention).
try {
// FX toggles (keys3d_bg_* — guitar-parity graphics controls).
// Only explicit values override; absent/corrupt keys keep the
// default (ON), matching screen.js readFxSettings.
const hydrateFxBool = (key, elId) => {
const raw = localStorage.getItem('keys3d_bg_' + key);
if (raw === '1' || raw === 'true') document.getElementById(elId).checked = true;
else if (raw === '0' || raw === 'false') document.getElementById(elId).checked = false;
};
hydrateFxBool('bloom', 'keysh3d-fx-bloom');
hydrateFxBool('sparks', 'keysh3d-fx-sparks');
hydrateFxBool('timingFx', 'keysh3d-fx-timing');
hydrateFxBool('streakFx', 'keysh3d-fx-streak');
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
const v = Math.min(1, Math.max(0, n));
document.getElementById(elId).value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRange('hitFx', 'keysh3d-fx-hitfx', 'keysh3d-fx-hitfx-val');
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
const storedStyle = localStorage.getItem('keys3d_bg_style');
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
styleSel.value = storedStyle;
}
const storedTheme = localStorage.getItem('keys3d_bg_theme');
const themeSel = document.getElementById('keysh3d-fx-theme');
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
}
})();
</script>
</div>
@@ -0,0 +1,190 @@
// Pure data-layer tests: load screen.js in a bare vm window and exercise the
// __test exports (no DOM, no WebGL, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window.slopsmithViz_keys_highway_3d.__test;
}
test('beatDurSec: base, dotted, double-dotted, tuplet', () => {
const { beatDurSec } = load();
// 120 BPM → quarter = 0.5s
assert.equal(beatDurSec({ dur: 4 }, 120), 0.5);
assert.equal(beatDurSec({ dur: 2 }, 120), 1.0);
assert.equal(beatDurSec({ dur: 8 }, 120), 0.25);
// dotted quarter = 0.75s; double-dotted = 0.875s
assert.equal(beatDurSec({ dur: 4, dot: 1 }, 120), 0.75);
assert.equal(beatDurSec({ dur: 4, dot: 2 }, 120), 0.875);
// triplet eighth: 0.25 * 2/3
assert.ok(Math.abs(beatDurSec({ dur: 8, tu: [3, 2] }, 120) - 0.25 * 2 / 3) < 1e-9);
// invalid → null
assert.equal(beatDurSec({ dur: 0 }, 120), null);
assert.equal(beatDurSec({ dur: 4 }, null), null);
});
function measure(idx, t, opts = {}) {
return { idx, t, ...opts };
}
test('flattenNotation: basic two-hand flatten, sorted, durSec from tempo', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: {
rh: { voices: [{ v: 1, beats: [
{ t: 0.5, dur: 4, notes: [{ midi: 64 }] },
{ t: 1.0, dur: 8, notes: [{ midi: 67 }] },
] }] },
lh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 2, notes: [{ midi: 48 }] },
] }] },
},
},
]);
assert.equal(notes.length, 3);
assert.deepEqual(JSON.parse(JSON.stringify(notes.map(n => n.midi))), [48, 64, 67]); // time-sorted
assert.equal(notes[0].hand, 'lh');
assert.equal(notes[0].durSec, 1.0); // half at 120
assert.equal(notes[1].durSec, 0.5); // quarter
assert.equal(notes[2].durSec, 0.25); // eighth
assert.equal(notes[1].measureIdx, 1);
});
test('flattenNotation: tempo state carries across measures and changes apply', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
measure(1, 0, { tempo: 120, staves: { rh: { voices: [{ v: 1, beats: [{ t: 0, dur: 4, notes: [{ midi: 60 }] }] }] } } }),
measure(2, 2, { staves: { rh: { voices: [{ v: 1, beats: [{ t: 2, dur: 4, notes: [{ midi: 62 }] }] }] } } }),
measure(3, 4, { tempo: 60, staves: { rh: { voices: [{ v: 1, beats: [{ t: 4, dur: 4, notes: [{ midi: 64 }] }] }] } } }),
]);
assert.equal(notes[0].durSec, 0.5); // 120 BPM
assert.equal(notes[1].durSec, 0.5); // tempo carried
assert.equal(notes[2].durSec, 1.0); // 60 BPM
});
test('flattenNotation: tied notes extend instead of emitting a new block', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 2, notes: [{ midi: 60 }] },
{ t: 1.0, dur: 2, notes: [{ midi: 60, tied: true }] },
] }] } },
},
]);
assert.equal(notes.length, 1);
assert.equal(notes[0].durSec, 2.0); // half + tied half
});
test('flattenNotation: no tempo anywhere falls back to next-onset gap', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 4, notes: [{ midi: 60 }] },
{ t: 0.8, dur: 4, notes: [{ midi: 62 }] },
] }] } },
},
]);
assert.ok(Math.abs(notes[0].durSec - 0.8) < 1e-9);
assert.equal(notes[1].durSec, 2.0); // final-beat fallback
});
test('flattenNotation: overlap clamp against next same-hand same-midi onset', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 30, // whole note = 8s — way past the next onset
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 1, notes: [{ midi: 60 }] },
{ t: 1.0, dur: 1, notes: [{ midi: 60 }] },
] }] } },
},
]);
assert.equal(notes[0].durSec, 1.0); // clamped to next onset
});
test('flattenNotation: rests, malformed beats, and out-of-range midi are skipped', () => {
const { flattenNotation } = load();
const notes = flattenNotation([
{
idx: 1, t: 0, tempo: 120,
staves: { rh: { voices: [{ v: 1, beats: [
{ t: 0.0, dur: 4, rest: true },
{ t: 0.5, dur: 4, notes: [{ midi: 200 }] },
null,
{ t: 1.0, dur: 4, notes: [{ midi: 64 }] },
] }] } },
},
]);
assert.equal(notes.length, 1);
assert.equal(notes[0].midi, 64);
});
test('keyRange pads and clamps to the 88-key piano, keeps active span explicit', () => {
const { keyRange } = load();
assert.deepEqual(
JSON.parse(JSON.stringify(keyRange([{ midi: 60 }, { midi: 72 }]))),
{ low: 58, high: 74, activeLow: 60, activeHigh: 72 },
);
// At the clamp edges the active span still reflects the chart extremes
// (not low+pad — that would mark A0/C8 inactive when actually played).
assert.deepEqual(
JSON.parse(JSON.stringify(keyRange([{ midi: 21 }, { midi: 108 }]))),
{ low: 21, high: 108, activeLow: 21, activeHigh: 108 },
);
const empty = keyRange([]);
assert.ok(empty.low < 60 && empty.high > 60);
assert.ok(empty.activeLow > empty.activeHigh, 'empty chart has an empty active span');
});
test('noteLetter maps midi to pitch-class letters', () => {
const { noteLetter } = load();
assert.equal(noteLetter(60), 'C');
assert.equal(noteLetter(61), 'C#');
assert.equal(noteLetter(69), 'A');
assert.equal(noteLetter(71), 'B');
assert.equal(noteLetter(72), 'C'); // octave wraps
assert.equal(noteLetter(21), 'A'); // A0
});
test('scrollZ: events sit at hitZ exactly at their time and approach from -Z', () => {
const { scrollZ } = load();
const hitZ = -0.5, speed = 2.0;
// At now === eventT the event is exactly on the hit-line.
assert.equal(scrollZ(10, 10, hitZ, speed), hitZ);
// 1s before its time it is `speed` units further away (towards -Z).
assert.equal(scrollZ(10, 9, hitZ, speed), hitZ - speed);
// After its time it has moved past the hit-line (towards +Z).
assert.equal(scrollZ(10, 11, hitZ, speed), hitZ + speed);
// Marker and note-front-edge maths agree by construction: a note of
// length L positioned at scrollZ(t) - L/2 has its front edge at
// scrollZ(t).
const len = 0.8;
assert.equal(scrollZ(10, 10, hitZ, speed) - len / 2 + len / 2, hitZ);
});
test('measureMarkers extracts idx/t pairs', () => {
const { measureMarkers } = load();
assert.deepEqual(
JSON.parse(JSON.stringify(measureMarkers([{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }, { bogus: true }]))),
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
);
});
@@ -0,0 +1,150 @@
// FX-settings scaffold tests (guitar-highway parity controls). Same bare-vm
// harness as data_layer.test.js — no DOM, no localStorage — which doubles as
// a lint that the new module-scope FX code stays side-effect safe.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(extraWindow) {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
...extraWindow,
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window;
}
test('readFxSettings: defaults survive a localStorage-less environment', () => {
const { readFxSettings, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(readFxSettings(), FX_DEFAULTS);
assert.equal(FX_DEFAULTS.bloom, true); // effects on by default
});
test('readFxSettings: reads keys3d_bg_* overrides and coerces types', () => {
const store = { keys3d_bg_bloom: '0' };
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
});
const { readFxSettings } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readFxSettings().bloom, false);
store.keys3d_bg_bloom = 'true';
assert.equal(readFxSettings().bloom, true);
store.keys3d_bg_bloom = 'false';
assert.equal(readFxSettings().bloom, false);
// Corrupt/foreign value → keep the default rather than silently
// disabling the effect.
store.keys3d_bg_bloom = 'banana';
assert.equal(readFxSettings().bloom, true);
});
test('keys3dSetFx: persists, coerces, and ignores unknown keys', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetFx('bloom', false);
assert.equal(store.keys3d_bg_bloom, '0');
// String forms round-trip like the reader's accepted representations.
win.keys3dSetFx('bloom', 'false');
assert.equal(store.keys3d_bg_bloom, '0');
win.keys3dSetFx('bloom', 'true');
assert.equal(store.keys3d_bg_bloom, '1');
win.keys3dSetFx('bloom', false);
assert.equal(events.length, 4);
assert.equal(events[0].type, 'keys3d:settings');
// Field-wise (the detail object was built inside the vm realm, so a
// deep-strict compare would trip on its foreign Object.prototype).
assert.equal(events[0].detail.fx.bloom, false);
assert.deepEqual(Object.keys(events[0].detail.fx), ['bloom']);
// Unknown key: no write, no event.
win.keys3dSetFx('nonsense', 1);
assert.equal(events.length, 4);
assert.ok(!('keys3d_bg_nonsense' in store));
});
test('_classifyTiming: OK band is 40% of the window, sign maps early/late', () => {
const { _classifyTiming } = load().slopsmithViz_keys_highway_3d.__test;
const tol = 0.10; // keys HIT_TOLERANCE_S
assert.equal(_classifyTiming(0, tol), 'OK');
assert.equal(_classifyTiming(tol * 0.4, tol), 'OK');
assert.equal(_classifyTiming(-tol * 0.4, tol), 'OK');
// delta = note.t - now: positive → struck before the note → EARLY.
assert.equal(_classifyTiming(tol * 0.41, tol), 'EARLY');
assert.equal(_classifyTiming(-tol * 0.41, tol), 'LATE');
assert.equal(_classifyTiming(NaN, tol), 'OK');
});
test('noteKey prefix round-trips the matched note time (timing-delta source)', () => {
const { noteKey } = load().slopsmithViz_keys_highway_3d.__test;
// _checkHit derives the timing delta as parseFloat(judgeHit's key) - t;
// this pins the serialization that makes that recovery valid.
assert.equal(parseFloat(noteKey(12.3456, 60)), 12.346);
assert.equal(parseFloat(noteKey(0, 21)), 0);
});
test('FX defaults: hit-FX + vibrancy controls ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.sparks, true);
assert.equal(FX_DEFAULTS.timingFx, true);
assert.equal(FX_DEFAULTS.streakFx, true);
assert.equal(FX_DEFAULTS.hitFx, 0.7);
assert.equal(FX_DEFAULTS.vibrancy, 0.85);
});
test('themes: table ids match the guitar highway, default is the stock palette', () => {
const { BG_THEMES, _bgThemeColors, readThemeSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(BG_THEMES), [
'default', 'midnight', 'charcoal', 'deeppurple', 'forest', 'warmslate',
'deepfocus', 'deepsea', 'cathode', 'cathodegreen', 'hearth',
]);
// 'default' preserves THIS plugin's original look.
assert.equal(BG_THEMES.default.clear, 0x1a1a2e);
assert.equal(BG_THEMES.default.board, 0x141422);
assert.equal(BG_THEMES.default.laneDim, 0x2a2a3e);
assert.equal(_bgThemeColors('nonsense'), BG_THEMES.default);
assert.equal(readThemeSetting(), 'default'); // no localStorage in the vm
for (const [id, t] of Object.entries(BG_THEMES)) {
assert.equal(t.clear, t.fog, id + ' clear==fog (horizon dissolve)');
assert.ok(t.laneDim != null, id + ' rail color');
}
});
test('FX defaults: theme-PR controls ship enabled at stock-neutral values', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.cinematic, true);
assert.equal(FX_DEFAULTS.glow, 0.5); // 0.5 = 1.0x multiplier (stock)
});
test('bg styles: validated id set, particles default', () => {
const { BG_STYLE_IDS, readBgStyleSetting } = load().slopsmithViz_keys_highway_3d.__test;
// Host-realm copy — the vm array's foreign prototype trips deepEqual.
assert.deepEqual([...BG_STYLE_IDS], ['off', 'particles', 'lights', 'geometric']);
assert.equal(readBgStyleSetting(), 'particles');
});
test('FX defaults: ambience + score FX ship enabled', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.scoreFx, true);
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
@@ -0,0 +1,154 @@
// Pure MIDI-scoring tests: load screen.js in a bare vm window and exercise
// the __test exports (no DOM, no WebGL, no MIDI device, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return window.slopsmithViz_keys_highway_3d.__test;
}
const TOL = 0.10;
test('accuracyOf/scoreOf mirror the notedetect stats formula', () => {
const { accuracyOf, scoreOf } = load();
// accuracy = hits / max(1, hits + misses)
assert.equal(accuracyOf(0, 0), 0);
assert.equal(accuracyOf(10, 0), 1);
assert.equal(accuracyOf(3, 1), 0.75);
// score = round(hits * 100 * accuracy)
assert.equal(scoreOf(0, 0), 0);
assert.equal(scoreOf(10, 0), 1000);
assert.equal(scoreOf(3, 1), Math.round(3 * 100 * 0.75));
// Monotonic in accuracy at fixed hits.
assert.ok(scoreOf(5, 0) > scoreOf(5, 5));
});
test('judgeHit: exact note inside ±0.10 s window hits; outside misses', () => {
const { judgeHit } = load();
const notes = [
{ midi: 60, t: 1.0 },
{ midi: 64, t: 1.0 },
{ midi: 62, t: 2.0 },
];
const hitKeys = new Set();
// On time.
assert.equal(judgeHit(notes, 60, 1.0, hitKeys, TOL), '1.000|60');
// Near the edge of the window (the exact ±0.10 boundary is float-
// representation dependent, same as the piano plugin).
assert.equal(judgeHit(notes, 62, 2.099, hitKeys, TOL), '2.000|62');
assert.equal(judgeHit(notes, 64, 0.901, hitKeys, TOL), '1.000|64');
// Just outside the window.
assert.equal(judgeHit(notes, 60, 1.11, hitKeys, TOL), null);
// Wrong note — nothing at that midi anywhere near.
assert.equal(judgeHit(notes, 65, 1.0, hitKeys, TOL), null);
});
test('judgeHit: dedupes by t|midi — a chart note can only be hit once', () => {
const { judgeHit } = load();
const notes = [{ midi: 60, t: 1.0 }, { midi: 60, t: 1.15 }];
const hitKeys = new Set();
const first = judgeHit(notes, 60, 1.02, hitKeys, TOL);
assert.equal(first, '1.000|60');
hitKeys.add(first);
// Second strike near the same time falls through to the NEXT un-hit
// chart note at the same midi (double-stop repeats).
const second = judgeHit(notes, 60, 1.06, hitKeys, TOL);
assert.equal(second, '1.150|60');
hitKeys.add(second);
// Third strike: both consumed → wrong note.
assert.equal(judgeHit(notes, 60, 1.1, hitKeys, TOL), null);
});
test('judgeHit: empty/absent chart never judges', () => {
const { judgeHit } = load();
assert.equal(judgeHit([], 60, 1.0, new Set(), TOL), null);
assert.equal(judgeHit(null, 60, 1.0, new Set(), TOL), null);
});
test('sweepMissed: marks elapsed unhit notes once, respects hit + floor', () => {
const { sweepMissed, noteKey } = load();
const notes = [
{ midi: 60, t: 1.0 },
{ midi: 62, t: 1.5 },
{ midi: 64, t: 5.0 },
];
const hitKeys = new Set([noteKey(1.0, 60)]); // 60@1.0 was hit
const missedKeys = new Set();
const missed = [];
// At t=2.0 the windows for 1.0 and 1.5 have elapsed; 5.0 is pending.
const n1 = sweepMissed(notes, 2.0, hitKeys, missedKeys, TOL, null, n => missed.push(n.midi));
assert.equal(n1, 1);
assert.deepEqual(missed, [62]);
assert.ok(missedKeys.has(noteKey(1.5, 62)));
// Sweeping again counts nothing new (idempotent per note).
assert.equal(sweepMissed(notes, 2.1, hitKeys, missedKeys, TOL, null), 0);
// Floor: a device connected at t=6 must not retro-miss the 5.0 note.
const hk2 = new Set(), mk2 = new Set();
assert.equal(sweepMissed(notes, 6.0, hk2, mk2, TOL, 6.0), 0);
});
test('sweepMissed: a note exactly at the connect floor is not retro-missed', () => {
// Off-by-one guard: floor is the connect instant; a note whose onset
// equals it (device connected exactly as the onset passed) must be
// excluded, not swept. Floor comparison is `<=`, not `<`.
const { sweepMissed } = load();
const notes = [{ midi: 60, t: 5.0 }, { midi: 62, t: 6.0 }];
const missedKeys = new Set();
const missed = [];
const n = sweepMissed(notes, 7.0, new Set(), missedKeys, TOL, 5.0,
m => missed.push(m.midi));
assert.equal(n, 1);
assert.deepEqual(missed, [62]);
});
test('sweepMissed: a long frame stall cannot let elapsed notes slip past', () => {
const { sweepMissed } = load();
const notes = [{ midi: 60, t: 1.0 }, { midi: 62, t: 3.0 }];
const missedKeys = new Set();
// The previous sweep ran at t≈0; the next runs 10 s later (backgrounded
// tab / render hitch). Both elapsed notes must still be counted.
assert.equal(sweepMissed(notes, 10.0, new Set(), missedKeys, TOL, null), 2);
});
test('sweepMissed: cursor advances monotonically and never recounts', () => {
const { sweepMissed } = load();
const notes = [
{ midi: 60, t: 1.0 },
{ midi: 62, t: 2.0 },
{ midi: 64, t: 9.0 },
];
const hitKeys = new Set(), missedKeys = new Set();
const cursor = { idx: 0 };
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 1);
assert.equal(cursor.idx, 1);
// Stall to t=8: the 2.0 note is counted exactly once from the cursor.
assert.equal(sweepMissed(notes, 8.0, hitKeys, missedKeys, TOL, null, null, cursor), 1);
assert.equal(cursor.idx, 2);
// Seek BACKWARDS: the cursor does not rewind, nothing is recounted.
assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 0);
// The cursor still advances past pre-floor notes without counting them.
const c2 = { idx: 0 };
const mk2 = new Set();
assert.equal(sweepMissed(notes, 8.0, new Set(), mk2, TOL, 5.0, null, c2), 0);
assert.equal(c2.idx, 2);
});
test('noteKey quantises time to ms so float drift cannot double-count', () => {
const { noteKey } = load();
assert.equal(noteKey(1.0004, 60), noteKey(1.0001, 60));
assert.notEqual(noteKey(1.002, 60), noteKey(1.0001, 60));
assert.notEqual(noteKey(1.0, 60), noteKey(1.0, 61));
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "minigames",
"name": "Minigames",
"version": "0.1.0",
"version": "0.1.1",
"bundled": true,
"private": false,
"screen": "screen.html",
+18
View File
@@ -961,7 +961,25 @@
// Drain queue of plugins that loaded before us.
(window.__feedBackMinigamesPending || []).forEach(register);
window.__feedBackMinigamesPending = null;
// ── Back-compat for pre-rename minigame plugins ───────────────────────
// Minigame plugins published before the slopsmith→feedBack rename (#537)
// register against `window.slopsmithMinigames` and, when the SDK isn't up
// yet, queue to `window.__slopsmithMinigamesPending`. The rename moved the
// SDK to `window.feedBackMinigames` and only drains the feedBack queue, so
// those plugins' specs — including community ones we don't control — get
// stranded in the legacy queue and never register. Their FeedBarcade tiles
// then render as dead "Loading…" placeholders that do nothing on click.
// Alias the SDK under the old name and drain the legacy pending queue too.
// register() is keyed on spec.id, so a plugin that queued under both names
// still registers exactly once.
window.slopsmithMinigames = sdk;
(window.__slopsmithMinigamesPending || []).forEach(register);
window.__slopsmithMinigamesPending = null;
window.dispatchEvent(new CustomEvent('feedBack-minigames-ready'));
// Legacy event name for any pre-rename listener still bound to it.
window.dispatchEvent(new CustomEvent('slopsmith-minigames-ready'));
// ── Wire hub render to screen lifecycle ───────────────────────────────
// FeedBack mounts plugin screens with id "plugin-<plugin_id>" and
+3 -3
View File
@@ -34,17 +34,17 @@ This plugin adds a floating "Tuner" button to the FeedBack interface, providing
## Installation
### Download a Release
1. Download one of the [Releases](https://github.com/OmikronApex/feedBack-plugin-tuner/releases)
1. Download one of the [Releases](https://github.com/OmikronApex/slopsmith-plugin-tuner/releases)
2. Extract it to your plugins folder
3. Restart FeedBack
### Update Manager
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/feedBack-update-manager)
The plugin is listed in the official plugin repository, so it can also be installed directly via the [Update Manager](https://github.com/masc0t/slopsmith-update-manager)
### Git
```bash
cd /path/to/feedBack/plugins
git clone https://github.com/OmikronApex/feedBack-plugin-tuner.git tuner
git clone https://github.com/OmikronApex/slopsmith-plugin-tuner.git tuner
# Restart FeedBack (or restart your docker container)
docker compose restart
```
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.1",
"version": "1.3.3",
"bundled": true,
"private": false,
"script": "screen.js",
+10 -14
View File
@@ -33,10 +33,9 @@ def setup(app: FastAPI, context: dict):
"lastInstrument": DEFAULT_INSTRUMENT,
"freeTune": False,
"customTunings": {},
"disabledTunings": [],
"showFloatingButton": True,
"visualizationMode": "default",
"audioInputMode": "auto",
"autoOpenOnTuningChange": False,
}
if not config_file.exists():
return defaults
@@ -50,16 +49,17 @@ def setup(app: FastAPI, context: dict):
res["lastInstrument"] = str(data.get("lastInstrument", DEFAULT_INSTRUMENT))
res["freeTune"] = bool(data.get("freeTune", False))
res["customTunings"] = data.get("customTunings", {})
res["disabledTunings"] = data.get("disabledTunings", [])
res["showFloatingButton"] = bool(data.get("showFloatingButton", True))
res["visualizationMode"] = str(data.get("visualizationMode", "default"))
raw_mode = str(data.get("audioInputMode", "auto"))
res["audioInputMode"] = raw_mode if raw_mode in ("auto", "browser") else "auto"
# Fail closed: only a real JSON boolean enables the opt-in. A hand-edited /
# migrated / bad-client value (e.g. the string "false" or "0") must NOT be
# coerced to True by bool().
_auto_open = data.get("autoOpenOnTuningChange", False)
res["autoOpenOnTuningChange"] = _auto_open if isinstance(_auto_open, bool) else False
if not isinstance(res["customTunings"], dict):
res["customTunings"] = {}
if not isinstance(res["disabledTunings"], list):
res["disabledTunings"] = []
# Migrate custom tunings from old flat-list format
res["customTunings"] = {
@@ -67,12 +67,6 @@ def setup(app: FastAPI, context: dict):
for name, val in res["customTunings"].items()
}
# Strip legacy disabledTunings entries that lack compound "instrument:name" format
res["disabledTunings"] = [
e for e in res["disabledTunings"]
if isinstance(e, str) and ":" in e
]
return res
except Exception:
return defaults
@@ -80,8 +74,10 @@ def setup(app: FastAPI, context: dict):
def _write(data: dict) -> None:
config_dir.mkdir(parents=True, exist_ok=True)
current = _read()
# Strip keys that belong to core, not to this plugin's config.
for key in ("defaultTunings", "referencePitch"):
# Strip keys that belong to core, not to this plugin's config, plus
# retired keys (disabledTunings/showFloatingButton — their settings UI
# was removed) so a stale client can't re-persist them.
for key in ("defaultTunings", "referencePitch", "disabledTunings", "showFloatingButton"):
data = {k: v for k, v in data.items() if k != key}
current.update(data)
config_file.write_text(json.dumps(current, indent=2), encoding="utf-8")
+443 -21
View File
@@ -13,8 +13,17 @@
let _lastAutoOpenSessionKey = null;
let _autoOpenDismissedSessionKey = null;
let _autoOpenGeneration = 0;
// Bumped on every enable()/disable() so an in-flight open (which awaits audio start
// with the panel already visible) can detect it was dismissed mid-open and NOT flip
// _state.enabled on afterwards — avoiding a zombie enabled-but-hidden tuner.
let _openGen = 0;
let _onAutoOpenSongLoading = null;
let _onAutoOpenSongReady = null;
// Autoplay gate (E2): when the feature is on, hold playback on song:loading and
// release it once we know we won't open (covered / unchanged) or the tuner is
// dismissed — so playback waits behind a genuinely-needed retune.
let _autoplayRelease = null;
let _gateClaimed = false;
// ── Shared mutable state (read/written by screen.js; UI reads via closure) ──
const _state = {
@@ -33,7 +42,6 @@
_allTunings: {},
referencePitch: 440,
visualizationMode: 'default',
showFloatingButton: true,
currentSongOffsets: null,
currentSongIsBass: false,
currentSongStringCount: 0,
@@ -83,10 +91,6 @@
}
// ── Tuning helpers ────────────────────────────────────────────────
function _isTuningEnabled(instrument, name) {
return !((_state._serverConfig ? _state._serverConfig.disabledTunings : null) || []).includes(instrument + ':' + name);
}
function _instrumentForTuning(name) {
for (var key in _state._allTunings) {
if (_state._allTunings[key] && _state._allTunings[key][name]) return key;
@@ -95,11 +99,7 @@
}
function _buildTuningsForInstrument(instrument) {
const all = _state._allTunings[instrument] || {};
const disabled = (_state._serverConfig ? _state._serverConfig.disabledTunings : null) || [];
return Object.fromEntries(
Object.entries(all).filter(([name]) => !disabled.includes(instrument + ':' + name))
);
return { ...(_state._allTunings[instrument] || {}) };
}
function _tuningIdentityKey(songInfo) {
@@ -133,15 +133,259 @@
return filename + '::' + arr;
}
// ── §4 instrument-coverage ────────────────────────────────────────────────
// FeedBack is tune-to-song: the highway draws tab in the SONG's tuning, so the
// player tunes their instrument to match. We therefore only auto-open when the
// player's CURRENT physical tuning doesn't already cover the song — i.e. the
// song's open-string tuning isn't an exact contiguous run inside the player's
// strings, OR the global reference differs. So an 8-string F# player isn't
// nagged for a 6-/7-string standard song (its top strings already match), but a
// Drop-A song whose dropped open string the player lacks still prompts.
function _openMidisFromFreqs(freqs) {
const u = window._tunerUtils;
if (!u || !Array.isArray(freqs)) return null;
return freqs.map((f) => Math.round(u.freqToMidi(f)));
}
// The song's open-string MIDI (at A440; centOffset handled separately as a
// global). Mirrors _tuningIdentityKey's isBass / string-count derivation.
function _songOpenMidis(songInfo) {
const u = window._tunerUtils;
if (!u || !songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return null;
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
? window.feedBack.songTuningContext(songInfo)
: { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name };
const isBass = (typeof window.feedBack?.isBassArrangement === 'function')
? window.feedBack.isBassArrangement(ctx)
: (songInfo.arrangement || '').toLowerCase().includes('bass');
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
: (songInfo.stringCount || songInfo.tuning.length);
if (!sc || sc <= 0) return null;
const offsets = songInfo.tuning.slice(0, sc);
if (!offsets.length) return null;
return _openMidisFromFreqs(u.offsetsToFreqs(offsets, isBass));
}
// The player's CURRENT physical tuning. Prefer the host-owned live working
// tuning (window.feedBack.workingTuning) — what the instrument is *actually* in
// right now, which advances as the player retunes — so coverage prompts fire in
// BOTH directions (E→C# and back), not only away from a fixed profile. Feature-
// detected: on a host without the working-tuning capability, or before it's been
// set, we fall back to the static /api/settings instrument tuning (today's
// behavior). Returns { midis, refCents } or null.
// The SELECTED physical instrument's working-tuning slot identity, from /api/settings.
// The read (_playerTuning) and the write (_publishWorkingTuning) MUST agree on this
// key — the working tuning is a property of the player's selected instrument, not of
// any one song — or a published tuning lands in a slot coverage never reads back.
// Returns { isBass, sc, key }.
function _selectedInstrument(s) {
const isBass = !!(s && s.instrument === 'bass');
const sc = (s && Number(s.string_count)) || (isBass ? 4 : 6);
return { isBass, sc, key: (isBass ? 'bass' : 'guitar') + '-' + sc };
}
// Memoize the player's tuning: it depends only on /api/settings + the working
// tuning, which change on instrument:changed / working-tuning-changed (NOT per song).
// Without this, a consumer that evaluates coverage for many items at once — the
// library's per-song tuning-match chips — would fire one /api/settings fetch PER item
// per paint. Cache the promise so they all share one read; invalidate on the events
// that change the answer, AND expire after a short TTL so a settings write that
// doesn't emit an event (e.g. the input-setup flow) still heals within seconds.
let _playerTuningPromise = null;
let _playerTuningAt = 0;
const _PLAYER_TUNING_TTL_MS = 3000;
function _playerTuning() {
const now = (typeof Date !== 'undefined' && Date.now) ? Date.now() : 0;
if (!_playerTuningPromise || (now - _playerTuningAt) > _PLAYER_TUNING_TTL_MS) {
_playerTuningAt = now;
const p = _computePlayerTuning();
_playerTuningPromise = p;
// Never pin a transient failure: if the read yields null (or rejects), drop
// the cache so the next call retries. A real result stays until invalidation/TTL.
p.then((r) => { if (r == null && _playerTuningPromise === p) _playerTuningPromise = null; },
() => { if (_playerTuningPromise === p) _playerTuningPromise = null; });
}
return _playerTuningPromise;
}
function _invalidatePlayerTuning() { _playerTuningPromise = null; }
async function _computePlayerTuning() {
const u = window._tunerUtils;
if (!u) return null;
// Instrument IDENTITY (which instrument is selected) + the static fallback
// tuning, from /api/settings.
let s = null;
try { s = await fetch('/api/settings').then((r) => (r && r.ok ? r.json() : null)); }
catch (_) { s = null; }
const { isBass, sc, key } = _selectedInstrument(s);
// Cache the resolved selection so the (synchronous) publish-on-clear writes to
// the EXACT slot this read path uses — no second /api/settings fetch that could
// race an instrument switch. Only cache when settings were actually read: on a
// fetch failure we keep the last confident selection (or none → publish skips)
// rather than recording a bogus default-instrument slot to publish into later.
// Coverage runs before any auto-open, so this is set by the time a clear can publish.
if (s) {
_state._playerSelected = { isBass, sc, key, refPitch: Number(s.reference_pitch) || 440 };
}
// The LIVE per-instrument working tuning (advances as the player retunes) —
// this is what makes coverage prompt in BOTH directions, not only away from a
// fixed profile. Feature-detected; falls back to the static settings tuning
// when unset / no host capability.
const wt = (window.feedBack && window.feedBack.workingTuning
&& typeof window.feedBack.workingTuning.get === 'function')
? window.feedBack.workingTuning.get(key) : null;
const wtHasOffsets = !!(wt && Array.isArray(wt.offsets) && wt.offsets.length);
let freqs = null;
if (wtHasOffsets) {
freqs = u.offsetsToFreqs(wt.offsets.slice(0, sc), isBass);
} else if (s && Array.isArray(s.tuning)) {
freqs = u.offsetsToFreqs(s.tuning.slice(0, sc), isBass);
} else if (s && typeof s.tuning === 'string') {
const named = _state._allTunings && _state._allTunings[key];
if (named && Array.isArray(named[s.tuning])) freqs = named[s.tuning];
}
if (!freqs) {
// No confident instrument identity — settings absent, OR present but carrying
// no instrument/string_count/tuning (a fresh profile: /api/settings omits them)
// — and no live working tuning. We can't tell the player's tuning, so fail
// toward prompting (null → not-covered) rather than silently assuming standard
// and suppressing a genuinely-needed prompt.
const hasIdentity = !!(s && (s.instrument || s.string_count || s.tuning));
if (!hasIdentity && !wtHasOffsets) return null;
freqs = u.offsetsToFreqs(new Array(sc).fill(0), isBass); // standard fallback
}
const midis = _openMidisFromFreqs(freqs);
if (!midis) return null;
const refPitch = (wt && Number(wt.referencePitch)) || (s && Number(s.reference_pitch)) || 440;
return { midis, refCents: 1200 * Math.log2(refPitch / 440) };
}
// PR: host workingTuning — when the player clears an AUTO-OPENED tuner we assume
// they tuned their (selected) instrument to the song, so publish the song's tuning
// as the live working tuning for that instrument ('assumed' — PR 4's explicit
// "I tuned / Skip" will replace this heuristic). After the instrument->chart routing
// PR the loaded arrangement matches the selected instrument, so the song's tuning IS
// the player's instrument's new tuning.
//
// We write to the SELECTED instrument's slot (the exact key _playerTuning reads),
// NOT a song-derived one, so the publish can't be stranded in a slot coverage never
// looks at. If the cleared song is a chart for the OTHER instrument (a manual switch
// to e.g. the bass part while guitar is selected), we skip — that isn't evidence the
// selected instrument was retuned, and writing it would pollute the wrong slot.
//
// Synchronous, off the selection _playerTuning last resolved (`_state._playerSelected`)
// — an auto-open always runs a coverage check first, so it's populated by the time a
// clear can publish. Reusing it (rather than re-fetching /api/settings here) keeps the
// write key identical to the read key and avoids racing an instrument switch.
function _publishWorkingTuning(songInfo) {
const wt = window.feedBack && window.feedBack.workingTuning;
if (!wt || typeof wt.set !== 'function') return;
if (!songInfo || !Array.isArray(songInfo.tuning) || !songInfo.tuning.length) return;
const sel = _state._playerSelected;
if (!sel || !sel.key || !(sel.sc > 0)) return; // coverage hasn't resolved the instrument yet
const ctx = (typeof window.feedBack?.songTuningContext === 'function')
? window.feedBack.songTuningContext(songInfo)
: { stringCount: songInfo.stringCount, arrangement: songInfo.arrangement, arrangement_smart_name: songInfo.arrangement_smart_name };
const songIsBass = (typeof window.feedBack?.isBassArrangement === 'function')
? window.feedBack.isBassArrangement(ctx)
: (songInfo.arrangement || '').toLowerCase().includes('bass');
if (songIsBass !== sel.isBass) return; // cross-instrument chart — don't pollute the selected slot
wt.set({
offsets: songInfo.tuning.slice(0, sel.sc),
stringCount: sel.sc,
instrument: sel.isBass ? 'bass' : 'guitar',
referencePitch: sel.refPitch,
source: 'tuner',
}, { instrument: sel.key, provenance: 'assumed' });
}
// The retune the player would need to match this song, as a structured report:
// { covered, retune: [{ from, to }], reference, cantCover }
// — covered: the physical tuning already matches (the song's open strings are an
// exact contiguous run inside the player's strings) → no retune;
// — retune: the per-string note changes (e.g. { from:'B', to:'A' }) of the best
// contiguous alignment — what the badge cue names;
// — reference: a whole-instrument A4/centOffset mismatch (A440 vs A432, octave);
// — cantCover: the song needs more strings than the instrument has.
// Conservative: any missing data → { covered:false } so a needed prompt/cue is
// never silently dropped on a fetch hiccup.
async function _computeCoverageReport(songInfo) {
const u = window._tunerUtils;
const none = { covered: false, retune: [], reference: false, cantCover: false };
if (!u) return none;
const song = _songOpenMidis(songInfo);
if (!song || !song.length) return none;
const player = await _playerTuning();
if (!player || !player.midis.length) return none;
const reference = Math.abs((Number(songInfo?.centOffset) || 0) - player.refCents) > 25;
if (player.midis.length < song.length) return { covered: false, retune: [], reference, cantCover: true };
// Best contiguous alignment = the run with the fewest per-string mismatches
// (extended-range adds strings at the ends — match by pitch, not index).
let best = null;
for (let start = 0; start + song.length <= player.midis.length; start++) {
const diffs = [];
for (let i = 0; i < song.length; i++) {
const pm = player.midis[start + i];
if (pm !== song[i]) diffs.push({ from: u.midiToNote(pm, false), to: u.midiToNote(song[i], false) });
}
if (!best || diffs.length < best.length) best = diffs;
if (!diffs.length) break;
}
const covered = !reference && best.length === 0;
return { covered, retune: covered ? [] : best, reference, cantCover: false };
}
// Dedup the coverage computation (which fetches /api/settings): the auto-open gate
// AND the badge cue both call this on the same song:ready. Cache the in-flight/last
// result per song so they share ONE fetch. Invalidated when anything that changes the
// answer happens — a new song (song:loading), an instrument switch (instrument:changed),
// or a retune (working-tuning-changed) — so the cache can never go stale within a song.
let _coverageCache = null; // { key, promise }
function _coverageReport(songInfo) {
const key = _autoOpenSessionKey(songInfo) + '|'
+ (songInfo && Array.isArray(songInfo.tuning) ? songInfo.tuning.join(',') : '')
+ '|' + (songInfo && songInfo.centOffset != null ? songInfo.centOffset : ''); // coverage uses centOffset
if (_coverageCache && _coverageCache.key === key) return _coverageCache.promise;
const promise = _computeCoverageReport(songInfo);
_coverageCache = { key, promise };
return promise;
}
function _invalidateCoverageCache() { _coverageCache = null; }
// Boolean form used to gate the auto-open prompt.
async function _coveredByPlayerInstrument(songInfo) {
return (await _coverageReport(songInfo)).covered;
}
function _releaseGate() {
if (_autoplayRelease) { try { _autoplayRelease(); } catch (_) { /* */ } _autoplayRelease = null; }
}
function _onAutoOpenSongLoadingHandler() {
_autoOpenGeneration++;
_autoOpenDismissedSessionKey = null;
_lastAutoOpenSessionKey = null;
_invalidateCoverageCache();
// Claim the autoplay gate NOW (synchronously, before song:ready) when the
// feature is on, so playback can wait behind a needed retune. Released on
// song:ready if we don't open, or when the tuner is dismissed.
_releaseGate();
_gateClaimed = false;
_autoplayRelease = (_state._serverConfig && _state._serverConfig.autoOpenOnTuningChange
&& window.feedBack && typeof window.feedBack.holdAutoplay === 'function')
? window.feedBack.holdAutoplay() : null;
}
async function _maybeAutoOpenOnTuningChange() {
if (!document.getElementById('player')?.classList.contains('active')) return;
// Opt-in (default off): only auto-open when the user enabled it in the
// tuner settings. Ensure config is loaded so the first song:ready after
// boot still reads the real flag; fail closed if it can't load.
if (!_state._serverConfig) { try { await loadConfig(); } catch (_) { /* */ } }
if (!_state._serverConfig || !_state._serverConfig.autoOpenOnTuningChange) return;
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
if (!songInfo) return;
@@ -165,10 +409,21 @@
if (_lastAutoOpenSessionKey === sessionKey) return;
if (!window.tuner || typeof window.tuner.enable !== 'function') return;
// §4: skip the prompt when the player's physical instrument already covers
// this song's tuning (e.g. an 8-string F# playing a 6-/7-string standard
// song). Async (fetches /api/settings) — re-check the generation after.
const covered = await _coveredByPlayerInstrument(songInfo);
if (myGen !== _autoOpenGeneration) return;
if (covered) return;
_lastAutoOpenSessionKey = sessionKey;
try {
await window.tuner.enable();
await window.tuner.enable({ auto: true });
if (myGen !== _autoOpenGeneration) return;
_gateClaimed = true; // tuner is open → keep the autoplay gate until it's dismissed
// The hold is now intentional and user-dismissable — cancel the fail-open
// backstop so it can't start playback while the player is still tuning.
if (_autoplayRelease && typeof _autoplayRelease.settle === 'function') _autoplayRelease.settle();
} catch (e) {
console.warn('Tuner: auto-open failed:', e && e.message ? e.message : e);
if (_lastAutoOpenSessionKey === sessionKey) _lastAutoOpenSessionKey = null;
@@ -183,9 +438,26 @@
function _installAutoOpenListeners() {
if (_onAutoOpenSongLoading || !window.feedBack?.on) return;
_onAutoOpenSongLoading = _onAutoOpenSongLoadingHandler;
_onAutoOpenSongReady = () => { _maybeAutoOpenOnTuningChange(); };
_onAutoOpenSongReady = async () => {
const myGen = _autoOpenGeneration;
await _maybeAutoOpenOnTuningChange();
// A newer song:loading may have superseded us while awaiting — it owns the
// gate/_gateClaimed now, so don't release its hold based on our stale view.
if (myGen !== _autoOpenGeneration) return;
if (!_gateClaimed) _releaseGate(); // not gating this song → let it play
};
window.feedBack.on('song:loading', _onAutoOpenSongLoading);
window.feedBack.on('song:ready', _onAutoOpenSongReady);
// The badge (static/v3/badges.js) emits this on the feedBack bus when the player
// switches instrument. Drop the cached selection so a publish-on-clear can't write
// to the previously-selected instrument's slot; the next coverage read re-resolves
// it. Until then _publishWorkingTuning skips (safe — no mis-slotted write).
window.feedBack.on('instrument:changed', () => {
_state._playerSelected = null; _invalidatePlayerTuning(); _invalidateCoverageCache();
});
// A retune (working tuning published on a tuner clear) changes coverage for the
// current song — drop the cached player tuning + report so a re-evaluation recomputes.
window.feedBack.on('working-tuning-changed', () => { _invalidatePlayerTuning(); _invalidateCoverageCache(); });
}
// ── Player sync helpers ───────────────────────────────────────────
@@ -209,6 +481,12 @@
const sc = (typeof window.feedBack?.effectiveStringCount === 'function')
? window.feedBack.effectiveStringCount(songInfo.tuning, ctx)
: (songInfo.stringCount || songInfo.tuning.length);
// A tuning change invalidates an in-flight mic-verify — its captured targets
// and offsets are now stale, so it must not complete against the old tuning.
if (_verify && String(songInfo.tuning.slice(0, sc)) !== String(_verify.offsets)) {
verifyCancel();
_tunerUIApi?.resetVerify?.();
}
_state.currentSongOffsets = songInfo.tuning.slice(0, sc);
_state.currentSongIsBass = isBass;
_state.currentSongStringCount = sc;
@@ -267,7 +545,6 @@
_state._serverConfig = config;
_state._allTunings = tuningsData.tunings || {};
_state.referencePitch = tuningsData.referencePitch || 440;
_state.showFloatingButton = config.showFloatingButton !== false;
_state.visualizationMode = config.visualizationMode || 'default';
_state.audioInputMode = config.audioInputMode || 'auto';
@@ -341,8 +618,16 @@
}
}
async function enable() {
async function enable(opts) {
if (_state.enabled) return;
const myOpen = ++_openGen; // this open's token; a disable()/newer open invalidates it
// An AUTO-open (the "this song needs a different tuning" nudge) must
// PERSIST: it is NOT dismissed by the autoplay song:play that follows
// song entry, a stray click, or a same-screen re-emit — only by the
// Skip/× buttons or leaving the song. A manual open keeps the classic
// click-away / play-to-close behaviour.
const auto = !!(opts && opts.auto);
_state.autoOpened = auto;
await _loadScript('/api/plugins/tuner/utils/tuning-utils.js');
await _loadScript('/api/plugins/tuner/utils/audio.js');
await _loadScript('/api/plugins/tuner/utils/ui.js');
@@ -371,15 +656,33 @@
_state.uiContainer.classList.add('flex');
_tunerUIApi.positionPanel();
_tunerUIApi.updateFreeTuneUI();
// "Skip" is the auto-open nudge's explicit dismiss; hidden for a manual
// open (the × / click-away already close those).
if (_state.skipBtn) _state.skipBtn.classList.toggle('hidden', !auto);
// Auto-open shows the "Back to library" escape hatch and hides the ×:
// the Skip / Back buttons + Esc are the auto-open's dismiss surface, so a
// gated retune always offers a way forward AND a way out.
if (_state.backBtn) _state.backBtn.classList.toggle('hidden', !auto);
if (_state.closeBtn) _state.closeBtn.classList.toggle('hidden', !!auto);
// Close when clicking outside the panel. Deferred so the badge's
// opening click doesn't bubble up to the document and fire immediately.
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
_outsideClickClose = () => { if (_state.enabled) disable(); };
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
// Close when clicking outside the panel. Deferred so the badge's opening
// click doesn't bubble up to the document and fire immediately. Skipped
// for an auto-open: the user never clicked to open it, so their first
// unrelated click must not dismiss it (it persists until Skip / Back to
// library / Esc).
if (!auto) {
if (_outsideClickClose) document.removeEventListener('click', _outsideClickClose);
_outsideClickClose = () => { if (_state.enabled) disable(); };
setTimeout(() => { if (_outsideClickClose) document.addEventListener('click', _outsideClickClose, { once: true }); }, 0);
}
if (window.feedBack && !_onScreenChanged) {
_onScreenChanged = () => { disable(); };
// Auto-opened: close only when we actually LEAVE the song — a player
// re-emit while staying put must not tear down the nudge. Manual:
// unchanged (any screen change closes it).
_onScreenChanged = () => {
if (!_state.autoOpened || !document.getElementById('player')?.classList.contains('active')) disable();
};
_onSongReady = () => {
_tunerUIApi.renderTuningOptions();
if (_state.selectedTuningName === '_current') _syncCurrentTuning();
@@ -396,6 +699,10 @@
{ deviceId: _state.selectedDeviceId, channel: _state.selectedChannel, audioInputMode: _state.audioInputMode },
_tunerUIApi.updateUI
);
// The panel is visible (with ×/Skip) across the audio-start await above, so a
// dismiss can land here. If so, disable() already tore the panel down and
// bumped _openGen — do NOT flip enabled on (that would leave enabled-but-hidden).
if (myOpen !== _openGen) return;
_state.enabled = true;
if (window.tuner?.updateButtons) window.tuner.updateButtons();
} catch (e) {
@@ -406,10 +713,16 @@
}
function disable() {
_openGen++; // invalidate any in-flight enable() so it won't re-enable after this teardown
const wasEnabled = _state.enabled;
const wasAutoOpened = _state.autoOpened;
const onPlayer = document.getElementById('player')?.classList.contains('active');
_state.enabled = false;
_state.autoOpened = false;
_releaseGate(); // dismissing a gated auto-open releases playback (it starts now)
_state.manualTargetFreq = null;
verifyCancel(); // a running mic-verify ends when the panel closes
_tunerUIApi?.resetVerify?.();
if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; }
if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; }
if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); }
@@ -427,8 +740,17 @@
}
if (wasEnabled && onPlayer) {
const songInfo = window.highway?.getSongInfo?.() || window.feedBack?.currentSong;
if (songInfo) _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
if (songInfo) {
_autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo);
// Clearing an auto-opened tuner = the player tuned to this song:
// publish the song's tuning as their instrument's live working tuning
// so coverage stops nagging for it (and prompts on the way back). But
// if a mic-verify already wrote 'verified' this session, a plain
// 'assumed' publish would immediately clobber it — leave it verified.
if (wasAutoOpened && !_verifiedPublished) _publishWorkingTuning(songInfo);
}
}
_verifiedPublished = false; // consumed — fresh for the next tuner session
}
window.tuner = {
@@ -472,10 +794,110 @@
_installAutoOpenListeners();
}).catch(e => console.error(e));
_installAutoOpenListeners();
// ── Mic-verify (working-tuning PR 9b) ──────────────────────────────────────
// A choreographed per-string check that promotes the current working tuning
// from 'assumed' to 'verified' — the ONLY thing that may ever claim 'verified'
// (audio-engine's honesty rule). The player plays each string; once every one
// reads in-tune (±VERIFY_TOL_CENTS) and holds for VERIFY_STABLE frames we stamp
// provenance:'verified' + verifiedStrings. Cancels on tuner close / tuning change.
const VERIFY_TOL_CENTS = 6;
const VERIFY_STABLE = 8;
let _verify = null;
let _verifiedPublished = false; // set when a mic-verify wrote 'verified' this session
function verifyState() {
if (!_verify) return null;
return {
complete: _verify.complete,
done: _verify.targets.map((t) => t.done),
remaining: _verify.targets.filter((t) => !t.done).length,
};
}
// Start a verify session for `targets` (freqs; defaults to the selected tuning).
// Captures the tuning's OFFSETS now (explicit arg, else the current song's) so that
// when it completes we stamp 'verified' onto the exact tuning that was confirmed.
// Per-string semitone offsets (from standard) of the tuning we're verifying, derived
// from its target freqs. This is authoritative for BOTH the song ('_current') tuning
// and a manually-selected tuning — unlike _state.currentSongOffsets, which for a manual
// tuning is a stale/different song's tuning that must NOT be stamped 'verified'. The
// player's reference pitch scales both the targets and the standard, so it cancels.
function _tuningOffsetsFromFreqs(freqs) {
const u = window._tunerUtils;
if (!u || typeof u.offsetsToFreqs !== 'function' || typeof u.freqToMidi !== 'function') return null;
const isBass = /^bass/.test(_state.selectedInstrument || ''); // the selected instrument, not the song's
const refScale = (Number(_state.referencePitch) || 440) / 440;
const std = u.offsetsToFreqs(new Array(freqs.length).fill(0), isBass);
if (!Array.isArray(std) || std.length !== freqs.length) return null;
const out = [];
for (let i = 0; i < freqs.length; i++) {
const f = Number(freqs[i]);
const s = Number(std[i]) * refScale;
if (!(f > 0) || !(s > 0)) return null;
out.push(Math.round(u.freqToMidi(f) - u.freqToMidi(s)) || 0); // normalize -0 → 0
}
return out;
}
function verifyStart(targets, offsets) {
const freqs = (Array.isArray(targets) && targets.length) ? targets : _state.selectedTuning;
if (!Array.isArray(freqs) || !freqs.length) return null;
// Explicit offsets win (callers/tests that already have them); otherwise derive
// them from the tuning actually being verified.
const offs = (Array.isArray(offsets) && offsets.length) ? offsets.slice() : _tuningOffsetsFromFreqs(freqs);
_verify = { targets: freqs.map((f) => ({ freq: f, streak: 0, done: false })), complete: false, offsets: offs };
_verifiedPublished = false;
return verifyState();
}
function verifyCancel() { _verify = null; }
// Feed one processed frame (its matched target freq + cents-off). Requires
// CONSECUTIVE in-tune frames per string: the one confirmed string advances, and
// every other not-yet-done string's streak resets — so a run can't accumulate across
// silence / wrong-string / out-of-tune frames. Completes + stamps 'verified' when all pass.
function verifyFeed(targetFreq, cents) {
if (!_verify || _verify.complete) return verifyState();
let hit = null;
if (targetFreq != null) {
const t = _verify.targets.find((x) => Math.abs(x.freq - targetFreq) < 0.5);
if (t && !t.done && isFinite(cents) && Math.abs(cents) <= VERIFY_TOL_CENTS) hit = t;
}
for (const t of _verify.targets) {
if (t.done) continue;
if (t === hit) { if (++t.streak >= VERIFY_STABLE) t.done = true; }
else t.streak = 0;
}
if (_verify.targets.every((x) => x.done)) {
_verify.complete = true;
_publishVerified();
}
return verifyState();
}
// Promote the working tuning to 'verified'. Write the CONFIRMED tuning's offsets
// atomically with provenance + verifiedStrings (into the selected instrument's slot),
// so 'verified' can never attach to stale offsets the slot happened to hold.
function _publishVerified() {
const wt = window.feedBack && window.feedBack.workingTuning;
if (!wt || typeof wt.set !== 'function') return;
const offsets = (_verify && Array.isArray(_verify.offsets)) ? _verify.offsets.slice() : null;
if (!offsets || !offsets.length) return; // nothing concrete to claim verified
const sel = _state._playerSelected;
const next = { offsets: offsets, stringCount: offsets.length, verifiedStrings: offsets.map(() => true) };
if (sel && sel.key) { next.instrument = sel.isBass ? 'bass' : 'guitar'; next.referencePitch = sel.refPitch; }
const opts = (sel && sel.key) ? { instrument: sel.key, provenance: 'verified' } : { provenance: 'verified' };
try { wt.set(next, opts); _verifiedPublished = true; } catch (_) { /* noop */ }
}
window._tunerAutoOpen = {
tuningIdentityKey: _tuningIdentityKey,
sessionKey: _autoOpenSessionKey,
maybeAutoOpenOnTuningChange: _maybeAutoOpenOnTuningChange,
coveredByPlayerInstrument: _coveredByPlayerInstrument,
coverageReport: _coverageReport,
playerTuning: _playerTuning,
publishWorkingTuning: _publishWorkingTuning,
verifyStart: verifyStart,
verifyFeed: verifyFeed,
verifyCancel: verifyCancel,
verifyState: verifyState,
onSongLoading: _onAutoOpenSongLoadingHandler,
getState() {
return {
+11 -137
View File
@@ -1,11 +1,11 @@
<div class="space-y-6 py-2">
<div class="flex items-center justify-between bg-dark-900/50 p-3 rounded-xl border border-gray-800/50">
<div>
<h3 class="text-sm font-medium text-gray-200">Floating Button</h3>
<p class="text-[11px] text-gray-500">Show the tuner button on the main interface.</p>
<h3 class="text-sm font-medium text-gray-200">Auto-open on tuning change</h3>
<p class="text-[11px] text-gray-500">When a song (or arrangement) needs a different tuning, pop the tuner open automatically. It stays open until you Skip or close it.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" id="tuner-show-floating" class="sr-only peer" onchange="window._tunerToggleFloating(this.checked)">
<input type="checkbox" id="tuner-auto-open" class="sr-only peer" onchange="window._tunerToggleAutoOpen(this.checked)">
<div class="w-9 h-5 bg-gray-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-accent"></div>
</label>
</div>
@@ -27,14 +27,6 @@
</script>
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Tuning Visibility</h3>
<p class="text-xs text-gray-500 mb-4">Toggle which built-in tunings appear in the tuner menu.</p>
<div id="tuner-visibility-list" class="space-y-2 pr-2">
<!-- Populated by JS -->
</div>
</div>
<div class="pt-4 border-t border-gray-800">
<h3 class="text-sm font-medium text-gray-400 mb-3">Custom Tunings</h3>
<div id="tuner-custom-list" class="space-y-2 mb-4">
<!-- Populated by JS -->
@@ -77,16 +69,7 @@
<script>
(function() {
let config = { customTunings: {}, disabledTunings: [], defaultTunings: {}, showFloatingButton: true, audioInputMode: 'auto' };
let expandedGroups = [];
var _INSTRUMENT_CAPTIONS = {
"guitar-6": "Guitar",
"guitar-7": "Guitar 7-string",
"guitar-8": "Guitar 8-string",
"bass-4": "Bass 4-string",
"bass-5": "Bass 5-string"
};
let config = { customTunings: {}, audioInputMode: 'auto' };
var _instrumentLabels = {
"guitar-6": "Guitar 6-string",
@@ -101,23 +84,23 @@
const resp = await fetch('/api/plugins/tuner/config');
config = await resp.json();
const floatingToggle = document.getElementById('tuner-show-floating');
if (floatingToggle) floatingToggle.checked = config.showFloatingButton !== false;
const browserAudioToggle = document.getElementById('tuner-force-browser-audio');
if (browserAudioToggle) browserAudioToggle.checked = config.audioInputMode === 'browser';
const autoOpenToggle = document.getElementById('tuner-auto-open');
if (autoOpenToggle) autoOpenToggle.checked = config.autoOpenOnTuningChange === true;
render();
} catch (e) { console.error('Tuner settings: load failed', e); }
}
window._tunerToggleFloating = (enabled) => {
config.showFloatingButton = enabled;
window._tunerToggleBrowserAudio = (forceBrowser) => {
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
save();
};
window._tunerToggleBrowserAudio = (forceBrowser) => {
config.audioInputMode = forceBrowser ? 'browser' : 'auto';
window._tunerToggleAutoOpen = (enabled) => {
config.autoOpenOnTuningChange = enabled;
save();
};
@@ -134,115 +117,6 @@
}
function render() {
const visList = document.getElementById('tuner-visibility-list');
visList.innerHTML = '';
const defaultTunings = config.defaultTunings || {};
Object.keys(defaultTunings).forEach(groupName => {
const group = defaultTunings[groupName];
const groupTunings = Object.keys(group);
const instrument = groupName;
// Compound keys for all tunings in this group
const compoundKeys = groupTunings.map(n => instrument + ':' + n);
const groupWrapper = document.createElement('div');
groupWrapper.className = 'mb-4';
const header = document.createElement('div');
header.className = 'flex items-center justify-between p-2 mt-2 bg-dark-900/80 rounded-t-lg border-x border-t border-gray-800/50 cursor-pointer hover:bg-dark-900 transition-colors';
const left = document.createElement('div');
left.className = 'flex items-center gap-2';
const chevron = document.createElement('span');
chevron.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>';
chevron.className = 'text-gray-500 transition-transform duration-200 rotate-0';
const groupLabel = document.createElement('span');
groupLabel.className = 'text-[11px] font-bold text-gray-400 uppercase tracking-wider';
groupLabel.textContent = _INSTRUMENT_CAPTIONS[groupName] || groupName;
left.appendChild(chevron);
left.appendChild(groupLabel);
const groupToggle = document.createElement('input');
groupToggle.type = 'checkbox';
const allEnabled = compoundKeys.every(k => !config.disabledTunings.includes(k));
const someEnabled = compoundKeys.some(k => !config.disabledTunings.includes(k));
groupToggle.checked = allEnabled;
groupToggle.indeterminate = someEnabled && !allEnabled;
groupToggle.className = 'accent-accent';
groupToggle.onclick = (e) => e.stopPropagation();
groupToggle.onchange = () => {
if (groupToggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => !compoundKeys.includes(k));
} else {
compoundKeys.forEach(k => {
if (!config.disabledTunings.includes(k)) config.disabledTunings.push(k);
});
}
save();
render();
};
header.appendChild(left);
header.appendChild(groupToggle);
groupWrapper.appendChild(header);
const groupContainer = document.createElement('div');
groupContainer.className = 'border-x border-b border-gray-800/50 rounded-b-lg overflow-hidden';
const isExpanded = expandedGroups.includes(groupName);
if (!isExpanded) {
groupContainer.classList.add('hidden');
chevron.classList.remove('rotate-90');
} else {
chevron.classList.add('rotate-90');
}
header.onclick = () => {
const idx = expandedGroups.indexOf(groupName);
if (idx === -1) {
expandedGroups.push(groupName);
} else {
expandedGroups.splice(idx, 1);
}
render();
};
groupTunings.forEach((name, idx) => {
const compoundKey = instrument + ':' + name;
const div = document.createElement('div');
div.className = `flex items-center justify-between p-2 bg-dark-800/30 hover:bg-dark-800/50 transition-colors ${idx === 0 ? 'border-t-0' : 'border-t border-gray-800/20'}`;
const label = document.createElement('span');
label.className = 'text-xs text-gray-300';
label.textContent = name;
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = !config.disabledTunings.includes(compoundKey);
toggle.className = 'accent-accent';
toggle.onchange = () => {
if (toggle.checked) {
config.disabledTunings = config.disabledTunings.filter(k => k !== compoundKey);
} else {
if (!config.disabledTunings.includes(compoundKey)) config.disabledTunings.push(compoundKey);
}
save();
render();
};
div.appendChild(label);
div.appendChild(toggle);
groupContainer.appendChild(div);
});
groupWrapper.appendChild(groupContainer);
visList.appendChild(groupWrapper);
});
const customList = document.getElementById('tuner-custom-list');
customList.innerHTML = '';
const customNames = Object.keys(config.customTunings);
+149 -3
View File
@@ -305,6 +305,12 @@ window._tunerUI = function(state, actions) {
function renderStringNotes() {
if (!state.stringNoteContainer) return;
state.stringNoteContainer.innerHTML = '';
// Show the mic-verify control only for a selected (non-free) tuning.
if (state.verifyRow) {
const hasTuning = !!(state.selectedTuning && state.selectedTuning.length && !state.freeTune);
state.verifyRow.classList.toggle('hidden', !hasTuning);
if (!hasTuning) resetVerifyUI();
}
if (!state.selectedTuning || state.selectedTuning.length === 0) {
_syncStringOrderHelp(0);
return;
@@ -327,6 +333,41 @@ window._tunerUI = function(state, actions) {
_syncStringOrderHelp(total);
}
// ── Mic-verify UI (working-tuning PR 9b) ───────────────────────────────────
function _markVerifiedStrings(done) {
if (!state.stringNoteContainer) return;
state.stringNoteContainer.querySelectorAll('[data-freq]').forEach((btn, i) => {
btn.classList.toggle('ring-2', !!done[i]);
btn.classList.toggle('ring-emerald-400', !!done[i]);
});
}
function _syncVerifyProgress(vs) {
if (!vs || !state.verifyStatus) return;
const total = vs.done.length;
const done = vs.done.filter(Boolean).length;
state.verifyStatus.classList.remove('hidden');
state.verifyStatus.textContent = vs.complete
? '✓ In tune — tuning verified'
: (done + ' of ' + total + ' strings in tune');
_markVerifiedStrings(vs.done);
if (vs.complete && state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning';
}
function _startVerify() {
if (!window._tunerAutoOpen || typeof window._tunerAutoOpen.verifyStart !== 'function') return;
const vs = window._tunerAutoOpen.verifyStart();
if (!vs) return;
if (state.verifyBtn) state.verifyBtn.textContent = 'Verifying — play each string…';
_syncVerifyProgress(vs);
}
function resetVerifyUI() {
if (window._tunerAutoOpen && typeof window._tunerAutoOpen.verifyCancel === 'function') {
window._tunerAutoOpen.verifyCancel();
}
if (state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning';
if (state.verifyStatus) { state.verifyStatus.classList.add('hidden'); state.verifyStatus.textContent = ''; }
_markVerifiedStrings([]);
}
function updateUI(result) {
const { smoothedFreq, rms, hasSignal } = result;
const vizMode = state.manualTargetFreq ? 'manual'
@@ -387,13 +428,20 @@ window._tunerUI = function(state, actions) {
if (window.feedBack && window.feedBack.emit) {
window.feedBack.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true });
}
// Mic-verify: feed the matched string + cents to a running verify session
// and reflect per-string progress on the panel.
if (!isManual && !state.freeTune && window._tunerAutoOpen
&& typeof window._tunerAutoOpen.verifyFeed === 'function') {
const vs = window._tunerAutoOpen.verifyFeed(targetFreq, Math.round(cents));
if (vs) _syncVerifyProgress(vs);
}
}
function updateFloatingButtonVisibility() {
const btn = document.getElementById('tuner-toggle-btn');
if (!btn) return;
const isPlayer = document.querySelector('.screen.active')?.id === 'player';
if (!state.showFloatingButton || isPlayer || window.feedBack?.isPlaying) {
if (isPlayer || window.feedBack?.isPlaying) {
btn.classList.add('hidden');
} else {
btn.classList.remove('hidden');
@@ -565,6 +613,17 @@ window._tunerUI = function(state, actions) {
title.textContent = 'TUNER';
header.appendChild(title);
// Explicit close (the panel had no in-box dismiss before; persist mode
// needs one). Mirrors the settings gear on the opposite side.
const closeBtn = document.createElement('button');
closeBtn.className = 'absolute left-0 text-fb-textDim hover:text-fb-text transition-colors text-lg leading-none';
closeBtn.setAttribute('aria-label', 'Close tuner');
closeBtn.title = 'Close';
closeBtn.textContent = '×';
closeBtn.onclick = () => actions.disable();
state.closeBtn = closeBtn;
header.appendChild(closeBtn);
const settingsBtn = document.createElement('button');
settingsBtn.className = 'absolute right-0 text-fb-textDim hover:text-fb-text transition-colors';
settingsBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>`;
@@ -624,8 +683,77 @@ window._tunerUI = function(state, actions) {
state.vizContainer.className = 'w-full';
state.uiContainer.appendChild(state.vizContainer);
// Mic-verify control (working-tuning PR 9b): play each string in tune to
// confirm your tuning — promotes 'assumed' → 'verified'. Shown only for a
// selected (non-free) tuning; visibility managed in renderStringNotes().
state.verifyRow = document.createElement('div');
state.verifyRow.className = 'w-full mt-2 hidden';
state.verifyBtn = document.createElement('button');
state.verifyBtn.className = 'tuner-verify-btn w-full text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
state.verifyBtn.textContent = 'Verify tuning';
state.verifyBtn.title = 'Play each string in tune to confirm — marks your tuning verified';
state.verifyBtn.onclick = () => _startVerify();
state.verifyRow.appendChild(state.verifyBtn);
state.verifyStatus = document.createElement('div');
state.verifyStatus.className = 'text-[10px] text-fb-textDim text-center mt-1 hidden';
state.verifyRow.appendChild(state.verifyStatus);
state.uiContainer.appendChild(state.verifyRow);
// Auto-open nudge's explicit dismiss (hidden unless auto-opened; enable()
// toggles it). Closes the same way as the × — disable().
const skipBtn = document.createElement('button');
skipBtn.className = 'tuner-skip-btn hidden w-full mt-3 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
skipBtn.textContent = 'Skip';
skipBtn.title = "I've tuned — play the song";
skipBtn.onclick = () => actions.disable();
state.skipBtn = skipBtn;
state.uiContainer.appendChild(skipBtn);
// Auto-open escape hatch: leave the song entirely instead of committing to
// the play-now choice — so a gated retune is never a one-way trap. Mirrors
// Escape (the player's "Back to library" shortcut) and, like Escape, does
// NOT record a tuning (you're leaving, not asserting you tuned).
const backBtn = document.createElement('button');
backBtn.className = 'tuner-back-btn hidden w-full mt-2 text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors';
backBtn.textContent = 'Back to library';
backBtn.title = 'Leave the song (Esc)';
backBtn.onclick = () => {
const exit = window.feedBack && window.feedBack.requestExitSong;
if (typeof exit === 'function') exit();
else if (typeof window.requestExitSong === 'function') window.requestExitSong();
};
state.backBtn = backBtn;
state.uiContainer.appendChild(backBtn);
document.body.appendChild(state.uiContainer);
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
// Re-anchor while the panel is open: the popover it hugs is
// vertically centered, so its rect shifts with viewport height.
// initUI() runs once (guarded above), so this binds a single listener.
window.addEventListener('resize', () => {
if (state.uiContainer && !state.uiContainer.classList.contains('hidden')) positionPanel();
});
}
// Resolve the sidebar Plugins popover to anchor the panel beside it.
// Prefer the host's stable plugin-control slot API and derive its popover
// container; fall back to the known popover id only if that's unavailable.
// Returns a visible rect, or null (→ caller uses the fixed fallback slot).
function _pluginsPopoverRect() {
let pop = null;
try {
if (window.feedBack?.ui && typeof window.feedBack.ui.playerControlSlot === 'function') {
const slot = window.feedBack.ui.playerControlSlot();
if (slot instanceof Element) pop = slot.closest('.v3-rail-pop') || slot;
}
} catch (_e) { /* host slot API failure → fall back to id lookup */ }
if (!pop) pop = document.getElementById('v3-rail-pop-plugins');
// offsetParent === null covers display:none on the element or any
// ancestor (more robust than testing a specific `hidden` class).
if (!pop || pop.offsetParent === null) return null;
const rect = pop.getBoundingClientRect();
return (rect.width || rect.height) ? rect : null;
}
function positionPanel() {
@@ -645,7 +773,20 @@ window._tunerUI = function(state, actions) {
.replace('right-0', '')
.replace('top-full', '')
.trim();
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
const anchor = _pluginsPopoverRect();
if (anchor) {
// Anchor to the right of the Plugins popover, clamped to the
// viewport so the panel never opens off-screen (right/bottom)
// on a narrow or short window.
const GAP = 8, MARGIN = 8;
const pw = state.uiContainer.offsetWidth || 288; // w-72
const ph = state.uiContainer.offsetHeight || 0;
const left = Math.max(MARGIN, Math.min(anchor.right + GAP, window.innerWidth - pw - MARGIN));
const top = Math.max(MARGIN, Math.min(anchor.top, window.innerHeight - ph - MARGIN));
state.uiContainer.style.cssText = `top:${top}px;left:${left}px`;
} else {
state.uiContainer.style.cssText = 'top:5rem;right:11rem';
}
return;
}
@@ -683,7 +824,11 @@ window._tunerUI = function(state, actions) {
const handlePlay = () => {
updateFloatingButtonVisibility();
if (state.enabled) actions.disable();
// A manually-opened tuner closes when playback starts (you don't tune
// while playing). An AUTO-opened tuner PERSISTS through the autoplay
// song:play that immediately follows song entry — that auto-close was
// the "opens then vanishes ~1s later" flash. It closes via Skip/×/leave.
if (state.enabled && !state.autoOpened) actions.disable();
};
const handleStop = () => updateFloatingButtonVisibility();
@@ -737,6 +882,7 @@ window._tunerUI = function(state, actions) {
renderTuningOptions,
renderStringNotes,
updateUI,
resetVerify: resetVerifyUI,
updateInstrumentDisplay: _updateInstrumentDisplay,
updateSaveAsCustomVisibility: _updateSaveAsCustomVisibility,
updateFreeTuneUI,
@@ -18,6 +18,8 @@
// ── Constants ─────────────────────────────────────────────────────
var _TUNER_LABEL_H = 12; // px height of each drum label
var _TUNER_NEEDLE_HALF_SWEEP = 90; // degrees — ±50 cents = horizontal (180° apart)
var _SETTLE_A = 0.05; // deg — needle "settled" threshold (sub-visible)
var _SETTLE_Y = 0.1; // px — drum-strip "settled" threshold
var _TUNER_IN_TUNE_THRESHOLD = 2;
var _TUNER_STRIP_START_MIDI = 14; // ~18 Hz — covers 20 Hz minimum
var _TUNER_STRIP_END_MIDI = 84; // ~1047 Hz C6
@@ -321,9 +323,34 @@
currentAngle += (targetAngle - currentAngle) * lf;
_setNeedle(currentAngle);
// Stop once the needle has settled on its target — a static needle
// needs no repaint. update() re-kicks the loop when a new reading
// moves the target, so this idles the always-on tuner (no signal /
// steady pitch) instead of pinning a core at 60 fps forever.
if (Math.abs(targetDrumY - currentDrumY) <= _SETTLE_Y
&& Math.abs(targetAngle - currentAngle) <= _SETTLE_A) {
currentDrumY = targetDrumY; currentAngle = targetAngle;
freqStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
noteStrip.style.transform = 'translateY(' + currentDrumY + 'px)';
_setNeedle(currentAngle);
rafId = null;
return;
}
rafId = requestAnimationFrame(_animate);
}
// Restart the loop only when there's actually something to animate toward
// (a new target). Reset lastTime so the first frame after an idle gap
// doesn't take one big easing step.
function _kick() {
if (rafId === null
&& (Math.abs(targetDrumY - currentDrumY) > _SETTLE_Y
|| Math.abs(targetAngle - currentAngle) > _SETTLE_A)) {
lastTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────
@@ -360,6 +387,7 @@
bulbEl.style.backgroundColor = '#2a1010';
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
_kick(); // animate back to rest, then the loop self-stops
return;
}
@@ -376,6 +404,7 @@
bulbEl.style.border = '2px solid #4a2020';
bulbEl.style.boxShadow = 'none';
}
_kick(); // a new reading moved the target → run until it settles
}
function destroy() {
+13
View File
@@ -620,8 +620,20 @@
if (_mt3Mode === 'strobe') { _computeStrobeStates(); }
_applyTickStates();
// No signal and both the glow and strobe drift have fully settled →
// idle the loop. update() re-kicks it on the next note.
if (!_mt3HasSignal && _mt3GlowOpacity < 0.004
&& Math.abs(_mt3SmoothedCents) <= 0.1) {
_mt3RafId = null;
_mt3LastTime = null;
return;
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_mt3RafId === null) { _mt3LastTime = null; _mt3RafId = requestAnimationFrame(_animateStrobe); }
}
_mt3RafId = requestAnimationFrame(_animateStrobe);
// ── MODE button ───────────────────────────────────────────────
@@ -677,6 +689,7 @@
_renderNote(' ');
_applyAccidental();
}
if (hasNote) { _kick(); } // new signal → restart the strobe loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
@@ -354,10 +354,19 @@
if (_smoothedCents > 0) { speed = -speed; }
_strobeOffset = ((_strobeOffset + speed * dt) % _totalDash + _totalDash) % _totalDash;
arcPath.setAttribute('stroke-dashoffset', String(_strobeOffset));
} else if (_currentCents === 0) {
// Fully decelerated and no live signal → idle the loop instead of
// rescheduling forever. update() re-kicks it on the next note.
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animateStrobe);
}
function _kick() {
if (_rafId === null) { _lastTime = null; _rafId = requestAnimationFrame(_animateStrobe); }
}
_rafId = requestAnimationFrame(_animateStrobe);
// ── Helper: derive octave number from frequency ───────────────
@@ -439,6 +448,7 @@
// Strobe state — smoothed animation decelerates naturally when _currentCents → 0
_currentCents = hasNote ? cents : 0;
if (hasNote) { _kick(); } // new signal → restart the decel loop if idled
}
// ── Public: destroy ───────────────────────────────────────────
+12
View File
@@ -142,9 +142,20 @@ window._tunerViz_strobe = function (container) {
strobeEl.style.opacity = '0';
}
// Idle the loop when there's no live signal — the strobe only needs to
// paint while a note is sounding. update() re-kicks it on the next note,
// so a silent tuner stops repainting instead of spinning at 60 fps.
if (!strobeActive) { rafId = null; return; }
rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (rafId === null) {
lastAnimateTime = performance.now();
rafId = requestAnimationFrame(_animate);
}
}
rafId = requestAnimationFrame(_animate);
// ── Public API ────────────────────────────────────────────────────
@@ -188,6 +199,7 @@ window._tunerViz_strobe = function (container) {
const inTune = Math.abs(cents) < 5;
strobeEl.style.opacity = inTune ? '1' : '0.6';
strobeEl.style.filter = inTune ? _STROBE_GLOW_IN_TUNE : _STROBE_GLOW_OUT;
_kick();
}
function destroy() {
@@ -122,6 +122,26 @@
plungerEl.style.left = _leftPct.toFixed(2) + '%';
plungerEl.style.top = _topPct.toFixed(2) + '%';
// No live signal and the plunger has eased back to its resting centre
// → idle the loop. update() re-kicks it on the next note.
if (_currentNote === null && !_plungerDipped
&& Math.abs(targetLeft - _leftPct) < 0.05) {
_leftPct = targetLeft;
plungerEl.style.left = _leftPct.toFixed(2) + '%';
_rafId = null;
_lastTime = null;
return;
}
_rafId = requestAnimationFrame(_animate);
}
function _kick() {
if (_rafId !== null) return;
// Already parked at rest with no signal → nothing to animate, stay idle.
if (_currentNote === null && !_plungerDipped
&& Math.abs(_TUNER_TT_CENTRE_PCT - _leftPct) < 0.05) return;
_lastTime = null;
_rafId = requestAnimationFrame(_animate);
}
@@ -130,6 +150,7 @@
_currentNote = note;
_currentCents = note === null ? 0 : cents;
if (!_plungerDipped) { noteEl.textContent = note || ''; }
_kick(); // a new reading may move the plunger → ensure the loop runs
}
function destroy() {
+8 -5
View File
@@ -60,6 +60,8 @@ import yaml # noqa: E402
import notation as notation_mod # noqa: E402, F401 (re-exported for tests)
from jsonc import load_json # noqa: E402
# The wire→notation heuristic core lives in ``lib/notation_lift.py`` so it can
# be reused in-process (e.g. by the Arrangement Editor's notation save path)
# rather than being copy-pasted out of this one-time CLI. Re-exported here so
@@ -106,15 +108,16 @@ def _parse_time_signature(raw: object) -> tuple[int, int]:
def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
"""Song-level beats: ``song_timeline.json`` when present, else the first
"""Song-level beats: the ``song_timeline`` file when present, else the first
arrangement JSON that carries a non-empty ``beats`` array (the loader's
legacy convention)."""
legacy convention). Both the timeline and arrangement files are read via
``load_json``, so either may be ``.json`` or ``.jsonc``."""
st_rel = manifest.get("song_timeline")
if isinstance(st_rel, str) and st_rel:
st_path = _safe_child(pak, st_rel)
if st_path is not None and st_path.is_file():
try:
data = json.loads(st_path.read_text(encoding="utf-8"))
data = load_json(st_path)
# An empty beats list is not an authoritative timeline — fall
# through to the arrangement JSONs rather than ending up with
# zero downbeats and skipping the whole sloppak.
@@ -134,7 +137,7 @@ def _load_song_beats(pak: Path, manifest: dict) -> list[dict]:
if arr_path is None or not arr_path.is_file():
continue
try:
data = json.loads(arr_path.read_text(encoding="utf-8"))
data = load_json(arr_path)
except (OSError, ValueError):
continue
if isinstance(data, dict):
@@ -219,7 +222,7 @@ def lift_sloppak(pak: Path, *, dry_run: bool = False) -> list[str]:
continue
try:
arr_data = json.loads(arr_path.read_text(encoding="utf-8"))
arr_data = load_json(arr_path)
except (OSError, ValueError) as e:
log.warning("%s/%s: unreadable arrangement JSON (%s) — skipped",
pak.name, arr_id, e)
+6026 -233
View File
File diff suppressed because it is too large Load Diff
+1280 -75
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
// Core interface-scale capability — the app-wide "Interface size" preference.
//
// Owns a single user setting: a multiplier applied to the ROOT font-size, so
// the rem-based v3 chrome (menus, buttons, text, spacing) scales together. This
// is the DOM lever — it deliberately does NOT touch the gameplay highway canvas
// (which is sized in device pixels, not rem), so scaling the UI never changes
// playback resolution or FPS.
//
// It is exposed as a host read/write API on `window.feedBack.scale` so surfaces
// that can't inherit `rem` — canvas / WebGL renderers such as the note-highway
// HUD or results scorecards — can read the number via `feedBack.scale.get()`
// and follow `scale:changed`. Shape mirrors the other host capabilities (a
// frozen, versioned object) and the working-tuning read-API: synchronous
// `get()`, a `set()` mutator, and a change event that also fires once on load.
//
// The visual apply ALSO runs pre-paint from a tiny inline <head> script (see
// index.html) so there is no flash-of-reflow on load; this module is the
// authoritative owner and re-applies idempotently.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
if (window.feedBack.scale && window.feedBack.scale.version === 1) return;
var STORE_KEY = 'v3-interface-scale';
var MIN = 0.85, MAX = 1.50, DEFAULT = 1.0;
// The named presets rendered by the Settings segmented control. Kept here so
// the control and any consumer read the ladder from one source of truth.
var PRESETS = [
{ step: 'small', value: 0.90 },
{ step: 'medium', value: 1.00 },
{ step: 'large', value: 1.15 },
{ step: 'x-large', value: 1.30 },
];
function clamp(n) {
n = Number(n);
if (!isFinite(n)) return DEFAULT;
return Math.min(MAX, Math.max(MIN, n));
}
function stepFor(value) {
for (var i = 0; i < PRESETS.length; i++) {
if (Math.abs(PRESETS[i].value - value) < 0.001) return PRESETS[i].step;
}
return 'custom';
}
function read() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw == null) return DEFAULT;
return clamp(parseFloat(raw));
} catch (_) { return DEFAULT; }
}
var current = read();
// Apply to the DOM. The lever is a RELATIVE root font-size (a percentage of
// the user-agent base), never a px literal — so a user who raised their
// browser/OS base font size is respected, not silently overridden. We also
// publish the always-present `--fb-scale` token for canvas consumers and CSS.
function apply(value) {
var el = document.documentElement;
if (!el) return;
el.style.setProperty('--fb-scale', String(value));
// Medium (1.0) clears the inline override so default rendering is
// byte-identical to before this feature existed (zero blast radius).
el.style.fontSize = (Math.abs(value - 1) < 0.001) ? '' : (value * 100).toFixed(2) + '%';
}
function persist(value) {
try {
if (Math.abs(value - DEFAULT) < 0.001) localStorage.removeItem(STORE_KEY);
else localStorage.setItem(STORE_KEY, String(value));
} catch (_) { /* private mode */ }
}
function announce() {
try {
if (typeof window.feedBack.emit === 'function') {
window.feedBack.emit('scale:changed', { value: current, step: stepFor(current) });
}
} catch (_) { /* noop */ }
}
// Hydrate on load (idempotent with the pre-paint inline script).
apply(current);
window.feedBack.scale = Object.freeze({
version: 1,
min: MIN,
max: MAX,
default: DEFAULT,
// Synchronous — valid immediately after this module parses.
get: function () { return { value: current, step: stepFor(current) }; },
// A copy of the preset ladder, so a UI can render it from one source.
presets: function () {
return PRESETS.map(function (p) { return { step: p.step, value: p.value }; });
},
// Set + apply + persist + announce. Pass { persist:false } for a
// transient preview (e.g. a live slider drag) that shouldn't be written.
set: function (value, opts) {
var v = clamp(value);
current = v;
apply(v);
if (!opts || opts.persist !== false) persist(v);
announce();
return current;
},
});
// Announce once after the document parses, so any listener wired during page
// load can sync without special-casing (consumers may also just call get()).
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', announce, { once: true });
} else {
announce();
}
})();
+390
View File
@@ -0,0 +1,390 @@
// Core "working tuning" capability domain — the live, host-authoritative CURRENT
// instrument tuning (session state), distinct from the soft opt-in default and from
// any one song's tuning. This is the single source of truth the whole app reads:
// the highway, the library/song-picker, Virtuoso, and the minigames all consult it,
// and the tuner is the sole WRITER (it updates this when the player retunes, clears
// the gate, or switches instruments).
//
// PER-INSTRUMENT: a player has separate physical instruments, each in its OWN tuning
// ("I'm not tuning two instruments when I pick a song"). So state is a MAP keyed by
// instrument — `${instrument}-${stringCount}` (e.g. "guitar-6", "bass-4"), the same key
// the v3 instrument selector uses. `get()` returns the CURRENTLY-SELECTED instrument's
// tuning; switching the selector surfaces that instrument's own remembered tuning. You
// only ever deal with the one you've picked.
//
// Design: WORKING-TUNING-STATE-DESIGN.md (host-first PR series, PR 1 = this file).
// Pattern mirrors `capabilities/tuning.js` (capability registration) + the host theme
// read-API (`window.feedBack.theme`): a synchronous `get()` plus a `working-tuning-
// changed` event that also fires once on hydration.
//
// State is IN-MEMORY and NOT persisted — reset-to-home on restart is deliberate (a
// stale "you're in drop-A" assumption is worse than re-asking). The opt-in "default
// tuning on app open" lands later; for now we seed the selected instrument from
// /api/settings.
//
// PR 1 is PURE PLUMBING: it introduces the state + read/write surface + event, but
// nothing writes to it yet and no behavior changes. The tuner becomes the writer (and
// the gate's E->C# asymmetry is fixed) in a later PR.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
// Idempotent: a second injection of this module must not replace the live state
// with a fresh (empty) one — once we're registered, re-running is a no-op.
if (window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1) return;
const capabilities = window.feedBack.capabilities;
const _byInstrument = {}; // key -> tuning state (the per-instrument map)
let _currentKey = null; // the selected instrument's key; cached so get() is sync
let _hydrated = false;
let _touched = false; // set once anything explicitly writes/selects; gates the async seed
function _normInstrument(instrument) {
return instrument === 'bass' ? 'bass' : 'guitar';
}
function _keyOf(instrument, stringCount) {
const inst = _normInstrument(instrument);
const sc = Number(stringCount) || (inst === 'bass' ? 4 : 6);
return inst + '-' + sc;
}
// Like _keyOf, but when the caller omits a string count we resolve it against the
// current selection (if it's the same instrument) before falling back to the
// per-instrument default — so `set({instrument:'bass'})` targets the selected
// bass-5, not a hard-coded bass-4.
function _keyOfResolved(instrument, stringCount) {
const inst = _normInstrument(instrument);
let sc = Number(stringCount);
if (!sc) {
if (_currentKey) {
const cur = _splitKey(_currentKey);
if (cur.instrument === inst) sc = cur.stringCount;
}
if (!sc) sc = (inst === 'bass' ? 4 : 6);
}
return inst + '-' + sc;
}
function _splitKey(key) {
const parts = (typeof key === 'string' ? key : '').split('-');
const inst = parts[0] === 'bass' ? 'bass' : 'guitar';
return { instrument: inst, stringCount: Number(parts[1]) || (inst === 'bass' ? 4 : 6) };
}
// The shape every consumer reads. `offsets` are per-string semitone offsets from
// standard (same vocabulary as song_info.tuning and /api/tunings); `instrument`
// disambiguates the open-string base so offsets resolve to real pitches. A drop-A
// 8-string is just an offsets array — fully custom tunings are first-class.
// `provenance` is the honesty flag: 'verified' means the tuner did a choreographed
// per-string mic check this session; everything else is 'assumed'.
function _defaultState(key) {
const id = _splitKey(key);
return {
offsets: null,
stringCount: id.stringCount,
instrument: id.instrument,
referencePitch: 440,
provenance: 'assumed',
verifiedStrings: null,
verifiedAt: null,
source: 'default',
};
}
// Resolve which instrument key a get/set targets: an explicit arg wins (a string
// key "guitar-6", a bare "guitar"/"bass", or { instrument, stringCount }); else the
// cached current selection.
function _resolveKey(instrument) {
if (instrument && typeof instrument === 'object') return _keyOfResolved(instrument.instrument, instrument.stringCount);
if (typeof instrument === 'string' && instrument) {
return instrument.indexOf('-') > 0 ? instrument : _keyOfResolved(instrument, null);
}
return _currentKey || _keyOf('guitar', 6);
}
// Synchronous read of an instrument's current tuning (default = selected
// instrument). Returns a deep-enough copy — the object plus its mutable array
// fields (`offsets`, `verifiedStrings`) — so a reader can't mutate the live state.
function get(instrument) {
const key = _resolveKey(instrument);
const state = Object.assign(_defaultState(key), _byInstrument[key] || {});
if (Array.isArray(state.offsets)) state.offsets = state.offsets.slice();
if (Array.isArray(state.verifiedStrings)) state.verifiedStrings = state.verifiedStrings.slice();
return state;
}
function _emitChanged(key) {
if (window.feedBack && typeof window.feedBack.emit === 'function') {
window.feedBack.emit('working-tuning-changed', { key: key, instrument: _splitKey(key).instrument, tuning: get(key) });
}
}
// The single mutator. The tuner calls this on retune / gate-clear / swap. Writes to
// the instrument the state targets (opts.instrument, or next.instrument+stringCount,
// or the current selection) and makes that the active instrument. `opts.provenance`
// stamps 'verified' (mic-confirmed) vs the default 'assumed'. Changing the tuning
// invalidates a prior verification unless fresh verifiedStrings are supplied — fail
// toward "assumed".
function set(next, opts) {
opts = opts || {};
next = next || {};
// Resolve the target key. An explicit opts.instrument wins; otherwise a
// next.instrument/next.stringCount targets that slot — but a bare stringCount
// (no instrument) applies to the CURRENTLY-SELECTED instrument, not a hard-coded
// guitar, so `set({stringCount:5})` on a selected bass writes bass-5.
let key;
if (opts.instrument) {
key = _resolveKey(opts.instrument);
} else if (next.instrument || next.stringCount) {
const inst = next.instrument ? _normInstrument(next.instrument)
: (_currentKey ? _splitKey(_currentKey).instrument : 'guitar');
key = _keyOfResolved(inst, next.stringCount);
} else {
key = _currentKey || _resolveKey();
}
const id = _splitKey(key);
const merged = Object.assign(get(key), next); // get() gives copies, so `merged` is ours to mutate
merged.instrument = id.instrument; // keep coherent with the key
merged.stringCount = id.stringCount; // the key is authoritative for string count
const tuningChanged = ('offsets' in next) || ('stringCount' in next) || ('referencePitch' in next);
// Provenance: explicit opts wins; a bare tuning change downgrades to 'assumed'.
if (opts.provenance) {
merged.provenance = opts.provenance;
} else if (tuningChanged) {
merged.provenance = 'assumed';
}
// Verification metadata is coherent by construction: a tuning change invalidates
// prior per-string verification unless the caller supplies a fresh bundle, and the
// metadata exists ONLY while provenance === 'verified'. So verified <=> we hold
// verifiedStrings — a "verified with no strings" state is impossible.
if (!('verifiedStrings' in next) && tuningChanged) {
merged.verifiedStrings = null;
}
if (merged.provenance === 'verified' && !Array.isArray(merged.verifiedStrings)) {
merged.provenance = 'assumed'; // claimed verified but no evidence — fail toward assumed
}
if (merged.provenance === 'verified') {
// verified always carries a real timestamp — a caller-supplied null/NaN/absent
// verifiedAt is stamped now, so 'verified' can never mean "at no known time".
if (typeof merged.verifiedAt !== 'number' || !isFinite(merged.verifiedAt)) {
merged.verifiedAt = Date.now();
}
} else {
merged.verifiedStrings = null;
merged.verifiedAt = null;
}
// Store copies of the mutable arrays so a caller can't mutate live state post-set.
if (Array.isArray(merged.offsets)) merged.offsets = merged.offsets.slice();
if (Array.isArray(merged.verifiedStrings)) merged.verifiedStrings = merged.verifiedStrings.slice();
_byInstrument[key] = merged;
_currentKey = key; // writing a tuning makes that instrument the active one
_touched = true; // an explicit write must not be clobbered by the async seed
_emitChanged(key);
return get(key);
}
// Tell the host which instrument is now selected (the v3 selector calls this when
// the player switches guitar<->bass / string count) so get() returns the right
// instrument's tuning. Emits if the selection actually changed.
function setCurrentInstrument(instrument, stringCount) {
const key = (typeof instrument === 'string' && instrument.indexOf('-') > 0) ? instrument : _keyOfResolved(instrument, stringCount);
_touched = true; // an explicit selection must not be reverted by the async seed
if (key === _currentKey) return get(key);
_currentKey = key;
_emitChanged(key);
return get(key);
}
// Reset an instrument's live tuning back to its baseline (the home/default).
function resetToDefault(instrument) {
const key = _resolveKey(instrument);
_byInstrument[key] = _defaultState(key);
_touched = true;
_emitChanged(key);
return get(key);
}
// Per-string semitone offsets of a named tuning relative to Standard, derived from
// the /api/tunings frequency tables. The reference pitch cancels in the ratio, so
// this is pitch-independent. Returns null if either row is missing/mismatched.
function _offsetsFromFreqs(named, standard) {
if (!Array.isArray(named) || !Array.isArray(standard) || named.length !== standard.length) return null;
const out = [];
for (let i = 0; i < named.length; i++) {
const a = Number(named[i]);
const b = Number(standard[i]);
if (!(a > 0) || !(b > 0)) return null;
out.push(Math.round(12 * Math.log2(a / b)));
}
return out;
}
// ---- Opt-in "launch tuning" default (soft, per-instrument) -------------------
// A convenience the player opts into: "start me in THIS tuning on app open." Off
// by default (nothing stored) → boot seeds from /api/settings as before. It is
// only a SEED — the live working tuning still resets on restart.
const LAUNCH_KEY = 'v3-working-tuning-launch-default';
function _readLaunchMap() {
try { return JSON.parse(localStorage.getItem(LAUNCH_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
function _writeLaunchMap(map) {
try {
if (map && Object.keys(map).length) localStorage.setItem(LAUNCH_KEY, JSON.stringify(map));
else localStorage.removeItem(LAUNCH_KEY);
} catch (_) { /* private mode */ }
}
function getLaunchDefault(instrument) {
const key = _resolveKey(instrument);
const d = _readLaunchMap()[key];
return d ? Object.assign(_defaultState(key), d, { source: 'launch-default' }) : null;
}
// Remember an instrument's CURRENT working tuning (or a supplied state) as its
// launch default. Opt-in — nothing calls this unless the player asks.
function setLaunchDefault(instrument, state) {
const key = _resolveKey(instrument);
const src = state || get(key);
const map = _readLaunchMap();
map[key] = {
offsets: Array.isArray(src.offsets) ? src.offsets.slice() : null,
stringCount: src.stringCount,
instrument: _splitKey(key).instrument,
referencePitch: src.referencePitch || 440,
};
_writeLaunchMap(map);
return getLaunchDefault(key);
}
function clearLaunchDefault(instrument) {
const key = _resolveKey(instrument);
const map = _readLaunchMap();
if (key in map) { delete map[key]; _writeLaunchMap(map); }
}
// Seed the SELECTED instrument's slot on boot (best-effort 'assumed' starting
// point, NOT a persisted working tuning): the player's opt-in launch default if one
// is set for this instrument, else /api/settings — where a NAMED tuning ("Drop D") is
// resolved to offsets via /api/tunings so it isn't lost. If neither can be read we
// still hydrate so consumers aren't stuck waiting; an explicit set()/select before we
// resolve wins (no clobber).
function _seedFromSettings() {
fetch('/api/settings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (s) {
if (!s || _touched) return; // nothing to seed, or a consumer already wrote — don't clobber
const inst = _normInstrument(s.instrument);
const sc = Number(s.string_count) || (inst === 'bass' ? 4 : 6);
const key = _keyOf(inst, sc);
function commit(offsets) {
if (_touched) return; // re-check: a write may have raced the /api/tunings fetch
_currentKey = key;
// Opt-in launch default wins over the raw profile; otherwise use the
// resolved `offsets` (a named settings tuning was already turned into
// offsets via /api/tunings before commit()).
const launch = _readLaunchMap()[key];
_byInstrument[key] = launch
? {
offsets: Array.isArray(launch.offsets) ? launch.offsets.slice(0, sc) : null,
stringCount: sc, instrument: inst,
referencePitch: Number(launch.referencePitch) || 440,
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
source: 'launch-default',
}
: {
offsets: Array.isArray(offsets) ? offsets.slice(0, sc) : null,
stringCount: sc, instrument: inst,
referencePitch: Number(s.reference_pitch) || 440,
provenance: 'assumed', verifiedStrings: null, verifiedAt: null,
source: 'settings',
};
}
if (Array.isArray(s.tuning)) { commit(s.tuning); return; }
if (typeof s.tuning === 'string' && s.tuning) {
return fetch('/api/tunings')
.then(function (r) { return r && r.ok ? r.json() : null; })
.then(function (t) {
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
})
.catch(function () { commit(null); });
}
commit(null);
})
.catch(function () { /* keep defaults */ })
.then(function () { _hydrate(); });
}
function _hydrate() {
if (_hydrated) return;
_hydrated = true;
_emitChanged(_currentKey || _resolveKey());
}
// A per-string mic verification is only trustworthy for the context it was done
// in — a new song means the player may have retuned, so a stale 'verified' must
// never suppress a needed prompt (fail toward re-checking). Decay the CURRENT
// instrument's verification back to 'assumed' on each song load; offsets are kept.
function _decayVerifiedOnSongLoad() {
const key = _currentKey || _resolveKey();
const st = _byInstrument[key];
if (st && st.provenance === 'verified') {
st.provenance = 'assumed';
st.verifiedStrings = null;
st.verifiedAt = null;
_emitChanged(key);
}
}
if (typeof window.feedBack.on === 'function') {
window.feedBack.on('song:loading', _decayVerifiedOnSongLoad);
}
// ---- Capability registration (mirrors capabilities/tuning.js) ----------------
if (capabilities && capabilities.version === 1 &&
!(window.feedBack.workingTuning && window.feedBack.workingTuning.version === 1)) {
capabilities.registerOwner('working-tuning', {
description: 'The live, host-authoritative current instrument tuning (session state), per ' +
'instrument: offsets + string-count + reference pitch + assumed/verified provenance. ' +
'Written by the tuner, read by the highway/library/Virtuoso/minigames.',
operations: ['get-working-tuning', 'set-working-tuning'],
events: ['working-tuning-changed'],
kind: 'command',
ownership: 'exclusive-owner',
});
capabilities.registerParticipant('plugin.tuner', {
'working-tuning': {
roles: ['contributor', 'requester'],
operations: ['get-working-tuning', 'set-working-tuning'],
emits: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
capabilities.registerParticipant('core.settings.instruments', {
'working-tuning': {
roles: ['requester'],
operations: ['get-working-tuning'],
events: ['working-tuning-changed'],
mode: 'active',
compatibility: 'none',
safety: 'safe',
},
});
}
// ---- Public read/write surface (attached defensively, like feedBack.theme) ----
window.feedBack.workingTuning = Object.freeze({
version: 1,
get: get,
set: set,
setCurrentInstrument: setCurrentInstrument,
resetToDefault: resetToDefault,
getLaunchDefault: getLaunchDefault,
setLaunchDefault: setLaunchDefault,
clearLaunchDefault: clearLaunchDefault,
});
_seedFromSettings();
})();
+197 -94
View File
@@ -122,6 +122,24 @@ function createHighway() {
// where offsetParent === null isn't enough.
let _visibleOverride = null;
let _lastVisible = null;
// Throttled DOM visibility sampling. Reading canvas.offsetParent
// every rAF frame forces a style/layout recalc — profiled at ~0.5 s
// main-thread self-time over a 63 s session. The displayed state
// changes rarely (navigate / splitscreen panel toggle), so the DOM
// is only re-sampled every _DOM_VIS_CHECK_FRAMES frames; the cached
// value serves the frames in between (worst-case transition latency
// ~10 frames ≈ 166 ms at 60 Hz — fine for a hide/show pause signal).
// Set _domVisSampledFrame to NaN to force a fresh sample on the next
// check (done on init, canvas replace, resize, and override-clear so
// deliberate transitions don't wait out the throttle window).
// NOTE those manual resets are LATENCY optimizations, not correctness
// requirements: the periodic re-sample runs every _DOM_VIS_CHECK_FRAMES
// frames regardless, so a visibility-affecting path that forgets to
// reset self-heals within ~10 frames — stale visibility can never be
// served indefinitely.
const _DOM_VIS_CHECK_FRAMES = 10;
let _domVisCached = false;
let _domVisSampledFrame = NaN;
let animFrame = null;
// Paused-render throttle (feedBack#654). The rAF loop runs
// unconditionally and only gates on visibility + ready, never on
@@ -248,6 +266,7 @@ function createHighway() {
let _frameMsEMA = 0; // smoothed frame interval (for the HUD)
let _lastFramePerf = 0;
let _lastAutoAdjustAt = 0;
let _lastUpscaleAt = 0; // separate, longer clock for UPscaling (lazy)
let _perfHud = null;
let _hudOn = false; // cached highwayPerfHud flag (re-read ~2x/sec, not per-frame)
let _hudFlagAt = 0;
@@ -266,6 +285,11 @@ function createHighway() {
return Number.isFinite(v) ? Math.max(_AUTO_SCALE_MIN, Math.min(1, v)) : _AUTO_SCALE_MIN;
})();
const _AUTO_ADJUST_COOLDOWN_MS = 600;
// Upscaling is deliberately LAZY (longer cooldown than the downscale path) so
// the resolution doesn't visibly hunt up/down on passages that hover near the
// budget — testers saw "quality going up and down" as parts got busier (#618
// charrette). Downscale stays prompt to protect the frame rate.
const _AUTO_UPSCALE_COOLDOWN_MS = 2500;
let _inverted = localStorage.getItem('invertHighway') === 'true';
let _lefty = localStorage.getItem('lefty') === '1';
let _lastChordOnFretLine = null; // chord object currently shown on fret line
@@ -727,103 +751,121 @@ function createHighway() {
// on the freshly-mounted canvas.
let _currentCanvasContextType = '2d';
// One persistent bundle object per createHighway() instance —
// _makeBundle() mutates its fields in place each call instead of
// allocating a fresh ~35-field object per rAF frame (steady GC churn
// on weak hardware, ×N under splitscreen). Consequences for
// consumers: the bundle OBJECT's identity is stable across frames
// and carries no meaning; its field values are only valid for the
// duration of the current draw call. Array fields (`notes`,
// `chords`, `handShapes`, `chordTemplates`, ...) still swap
// reference whenever chart data changes — field-identity caches
// (e.g. highway_3d's merge caches) rely on that invariant.
const _bundleReused = {};
function _makeBundle() {
// Snapshot of current factory state passed to each renderer call.
// Arrays and songInfo are LIVE references, not copies — the bundle
// itself is rebuilt each frame but its `notes`, `chords`,
// `anchors`, `beats`, etc. point at closure state. Renderers
// MUST NOT mutate these; treat them as read-only. We don't
// Object.freeze or deep-copy for per-frame allocation cost reasons.
return {
// Timing
currentTime,
songInfo,
isReady: ready,
// True while the chart clock is actively advancing; false when
// audio is paused / stalled / mid-seek (setTime has kept getting
// the same t for > _CHART_MAX_INTERP_MS). This is the same
// predicate getTime() uses to decide raw-vs-interpolated, and the
// no-anchor boot state reads as not-playing — matching getTime()
// returning raw chartTime there. Renderers that run their own
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
// back to raw instead of extrapolating forward against a frozen
// audio sample. Undefined on downlevel hosts → those renderers
// keep their own staleness-based fallback.
isPlaying: !Number.isNaN(_chartAnchorPerfNow)
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS,
// Arrays and songInfo are LIVE references, not copies — the
// bundle's `notes`, `chords`, `anchors`, `beats`, etc. point at
// closure state. Renderers MUST NOT mutate these; treat them as
// read-only. We don't Object.freeze or deep-copy for per-frame
// cost reasons.
const b = _bundleReused;
// Timing
b.currentTime = currentTime;
b.songInfo = songInfo;
b.isReady = ready;
// True while the chart clock is actively advancing; false when
// audio is paused / stalled / mid-seek (setTime has kept getting
// the same t for > _CHART_MAX_INTERP_MS). This is the same
// predicate getTime() uses to decide raw-vs-interpolated, and the
// no-anchor boot state reads as not-playing — matching getTime()
// returning raw chartTime there. Renderers that run their own
// sub-frame clock (highway_3d's smoothNow) gate on this to fall
// back to raw instead of extrapolating forward against a frozen
// audio sample. Undefined on downlevel hosts → those renderers
// keep their own staleness-based fallback.
b.isPlaying = !Number.isNaN(_chartAnchorPerfNow)
&& (performance.now() - _chartLastAdvanceAt) <= _CHART_MAX_INTERP_MS;
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
notes: _filteredNotes !== null ? _filteredNotes : notes,
chords: _filteredChords !== null ? _filteredChords : chords,
anchors: _filteredAnchors !== null ? _filteredAnchors : anchors,
beats,
sections,
chordTemplates,
stringCount,
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
tuning: songInfo?.tuning,
capo: songInfo?.capo,
lyrics,
lyricsSource,
toneChanges,
toneBase,
// Drum tab payload (or null when the active arrangement has
// no drum_tab). Live reference — renderers MUST treat as
// read-only. Plugins should prefer this over decoding the
// standard `notes` stream when present; absence is the
// signal to fall back to legacy MIDI-encoded drums.
drumTab,
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
b.notes = _filteredNotes !== null ? _filteredNotes : notes;
b.chords = _filteredChords !== null ? _filteredChords : chords;
b.anchors = _filteredAnchors !== null ? _filteredAnchors : anchors;
b.beats = beats;
b.sections = sections;
b.chordTemplates = chordTemplates;
b.stringCount = stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
b.tuning = songInfo?.tuning;
b.capo = songInfo?.capo;
b.lyrics = lyrics;
b.lyricsSource = lyricsSource;
b.toneChanges = toneChanges;
b.toneBase = toneBase;
// Drum tab payload (or null when the active arrangement has
// no drum_tab). Live reference — renderers MUST treat as
// read-only. Plugins should prefer this over decoding the
// standard `notes` stream when present; absence is the
// signal to fall back to legacy MIDI-encoded drums.
b.drumTab = drumTab;
// Master-difficulty (feedBack#48)
mastery: _mastery,
hasPhraseData: !!(_phrases && _phrases.length > 0),
// When phrase data authored ANY handshape, respect the filtered
// list strictly (even when this difficulty leaves it empty) —
// otherwise low-mastery levels would surface arp hints that
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
handShapes: (_filteredHandShapes !== null && _phrasesHaveHandShapes)
? _filteredHandShapes
: handShapes,
// Master-difficulty (feedBack#48)
b.mastery = _mastery;
b.hasPhraseData = !!(_phrases && _phrases.length > 0);
// When phrase data authored ANY handshape, respect the filtered
// list strictly (even when this difficulty leaves it empty) —
// otherwise low-mastery levels would surface arp hints that
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
b.handShapes = (_filteredHandShapes !== null && _phrasesHaveHandShapes)
? _filteredHandShapes
: handShapes;
// Display flags
inverted: _inverted,
lefty: _lefty,
renderScale: _effectiveRenderScale(),
lyricsVisible: showLyrics,
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
// finger-hint pref rides alongside (default on, independently hideable).
teachingMarksVisible: _showTeachingMarks,
fingerHintsVisible: _showFingerHints,
// Display flags
b.inverted = _inverted;
b.lefty = _lefty;
b.renderScale = _effectiveRenderScale();
b.lyricsVisible = showLyrics;
// Teaching marks sd/ch overlay pref (§6.2.2) so custom renderers
// (e.g. the 3D highway) can mirror the 2D opt-in toggle. The fg
// finger-hint pref rides alongside (default on, independently hideable).
b.teachingMarksVisible = _showTeachingMarks;
b.fingerHintsVisible = _showFingerHints;
// 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here —
// the factory-level version writes to the default renderer's
// closure ctx, which is null for custom renderers. Renderers
// that need lefty-aware text should check `bundle.lefty` and
// apply the mirror transform themselves on their own context.
project,
fretX,
// 2D-style helpers (renderers that don't need these can ignore).
// `fillTextUnmirrored` is deliberately NOT exposed here —
// the factory-level version writes to the default renderer's
// closure ctx, which is null for custom renderers. Renderers
// that need lefty-aware text should check `bundle.lefty` and
// apply the mirror transform themselves on their own context.
b.project = project;
b.fretX = fretX;
// Windowed-iteration helpers (stable references): lower-bound
// binary searches so custom viz don't full-scan chart arrays per
// frame. lowerBoundT keys on `.t` (notes / chords); lowerBoundTime
// keys on `.time` (beats / anchors / sections).
b.lowerBoundT = bsearch;
b.lowerBoundTime = bsearchTime;
// Per-note judgment overlay (feedBack#254). Renderers call
// this per visible note / chord-note to find out whether a
// scorer (note_detect) has flagged it hit / actively-held /
// missed, so the gem itself can light up instead of relying
// on an overlay ring. Returns null when no provider is set
// or it reports nothing for this note; otherwise
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
getNoteState: _noteState, // stable reference — no per-frame allocation
// Lets custom renderers (e.g. highway_3d) tell "is a provider
// attached" apart from "no provider, getNoteState always
// returns null" — `getNoteState` always exists on the bundle
// so its presence alone isn't a useful "detect mode" signal.
// Renderers gate verdict-window cull / draw extensions on this.
getNoteStateProvider: _getNoteStateProvider, // stable — see above
};
// Per-note judgment overlay (feedBack#254). Renderers call
// this per visible note / chord-note to find out whether a
// scorer (note_detect) has flagged it hit / actively-held /
// missed, so the gem itself can light up instead of relying
// on an overlay ring. Returns null when no provider is set
// or it reports nothing for this note; otherwise
// { state: 'hit'|'active'|'miss', alpha: 0..1, color: string|null }.
b.getNoteState = _noteState; // stable reference
// Lets custom renderers (e.g. highway_3d) tell "is a provider
// attached" apart from "no provider, getNoteState always
// returns null" — `getNoteState` always exists on the bundle
// so its presence alone isn't a useful "detect mode" signal.
// Renderers gate verdict-window cull / draw extensions on this.
b.getNoteStateProvider = _getNoteStateProvider; // stable — see above
return b;
}
const _defaultRenderer = {
@@ -1041,6 +1083,7 @@ function createHighway() {
// suppress the first transition. Reset to null so the next
// rAF tick re-emits unconditionally.
_lastVisible = null;
_domVisSampledFrame = NaN; // fresh canvas → fresh DOM sample
// Defensive notify for plugins / overlays that cache the
// canvas element across events. Lazy lookups via
// getElementById('highway') do not need this — they'll pick
@@ -1240,7 +1283,14 @@ function createHighway() {
// hosts that need those use setVisible() instead.
function _isHighwayVisible() {
if (_visibleOverride !== null) return _visibleOverride;
return !!(canvas && canvas.offsetParent !== null);
// Throttled offsetParent read — see _DOM_VIS_CHECK_FRAMES above.
if (Number.isNaN(_domVisSampledFrame)
|| ((_frameIdx - _domVisSampledFrame) | 0) >= _DOM_VIS_CHECK_FRAMES
|| ((_frameIdx - _domVisSampledFrame) | 0) < 0) {
_domVisCached = !!(canvas && canvas.offsetParent !== null);
_domVisSampledFrame = _frameIdx;
}
return _domVisCached;
}
// Emit only on transition so renderer-side listeners aren't woken
@@ -1291,9 +1341,22 @@ function createHighway() {
const eff = _effectiveRenderScale();
let next = _autoScale;
if (_drawMsEMA > _DRAW_BUDGET_HI_MS && eff > _autoScaleMin) {
// Over budget — downscale promptly to protect the frame rate, and reset
// the upscale clock so we don't immediately bounce back up.
next = _autoScale * 0.85;
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1) {
next = _autoScale * 1.1;
_lastUpscaleAt = nowP;
} else if (_drawMsEMA < _DRAW_BUDGET_LO_MS && eff < 1
&& nowP - _lastUpscaleAt >= _AUTO_UPSCALE_COOLDOWN_MS) {
// Headroom — upscale LAZILY: a smaller step on a longer cooldown, and
// only when the projected cost AFTER the step (cost scales ~with the
// pixel count, i.e. step²) still clears the high budget. That predictive
// guard is what stops the up→over-budget→down ping-pong testers saw: the
// scale settles just inside the deadband instead of oscillating across it.
const step = 1.06;
if (_drawMsEMA * step * step < _DRAW_BUDGET_HI_MS) {
next = _autoScale * step;
_lastUpscaleAt = nowP;
}
}
// Clamp so _renderScale * _autoScale stays within [_autoScaleMin, 1].
// Cap `lo` at 1: when the floor exceeds the manual ceiling (e.g. quality
@@ -1494,7 +1557,13 @@ function createHighway() {
}
function drawBeats(W, H) {
for (const beat of beats) {
// Window the beat scan — a long song carries thousands of beats
// and iterating (and projecting) all of them per frame was pure
// waste; project() culling stays as the safety net.
const lo = bsearchTime(beats, currentTime - 0.25);
const hi = bsearchTime(beats, currentTime + VISIBLE_SECONDS + 0.25);
for (let i = lo; i < hi; i++) {
const beat = beats[i];
const tOff = beat.time - currentTime;
const p = project(tOff);
if (!p || p.scale < 0.06) continue;
@@ -2605,6 +2674,19 @@ function createHighway() {
}
return lo;
}
// Lower-bound binary search for `.time`-keyed arrays (beats, anchors,
// sections) — bsearch/bsearchChords key on `.t` and would compare
// against undefined here. Exposed to custom viz as
// bundle.lowerBoundTime.
function bsearchTime(arr, time) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid].time < time) lo = mid + 1;
else hi = mid;
}
return lo;
}
// ── Chord rendering — chains, frames, fretline preview (feedBack#88) ──
//
@@ -2937,6 +3019,7 @@ function createHighway() {
init(canvasEl, container) {
canvas = canvasEl;
_resizeContainer = container || null;
_domVisSampledFrame = NaN; // new mount → fresh DOM sample
// Size the canvas BEFORE installing the renderer so
// _setRenderer's init/resize calls see the real dimensions
// instead of the default 300x150 backing store. Otherwise
@@ -2978,6 +3061,9 @@ function createHighway() {
resize() {
if (!canvas) return;
// Layout just changed (window resize / container swap) —
// re-sample DOM visibility on the next check.
_domVisSampledFrame = NaN;
let w, h;
if (_resizeContainer) {
const rect = _resizeContainer.getBoundingClientRect();
@@ -3511,6 +3597,13 @@ function createHighway() {
// matchesArrangement on this rather than the
// arrangement name.
hasNotation: Boolean(msg.has_notation),
// Feedpak contributor credits (manifest
// `authors:`, spec §5.4): [{name, role}].
// Only real feedpak plays carry these; loose/
// archive sources and synthetic highway uses
// (minigames) get []. app.js shows a credits
// overlay on song load when this is non-empty.
authors: Array.isArray(msg.authors) ? msg.authors : [],
};
window.feedBack.emit('song:loaded', window.feedBack.currentSong);
}
@@ -3745,6 +3838,10 @@ function createHighway() {
// rAF tick.
setVisible(v) {
_visibleOverride = (v === null || v === undefined) ? null : !!v;
// Clearing the override resumes DOM-based detection — force a
// fresh offsetParent sample so the resulting transition (if
// any) emits now, not after the throttle window.
if (_visibleOverride === null) _domVisSampledFrame = NaN;
_emitVisibilityIfChanged();
},
// Snapshot of the current visibility state (the override if
@@ -3753,6 +3850,12 @@ function createHighway() {
// can call this once to sync their initial state — the event
// is transition-only and won't re-fire for late subscribers.
isVisible() {
// Force a fresh DOM sample — this is a documented "live DOM
// check" for late subscribers seeding initial state, so it
// must not serve the rAF loop's throttled cache (up to
// ~166 ms stale). Called rarely; the layout-read cost that
// motivated the throttle only matters per-frame.
_domVisSampledFrame = NaN;
return _isHighwayVisible();
},
getNotes() { return notes; },
+16 -2
View File
@@ -24,6 +24,7 @@
<script src="/static/capabilities.js"></script>
<script src="/static/capabilities/library.js"></script>
<script src="/static/capabilities/tuning.js"></script>
<script src="/static/capabilities/working-tuning.js"></script>
<script src="/static/capabilities/audio-session.js"></script>
<script src="/static/capabilities/audio-effects.js"></script>
<script src="/static/capabilities/playback.js"></script>
@@ -71,7 +72,7 @@
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) ══════════════════════════════════════════ -->
<div id="home" class="screen active">
@@ -103,10 +104,13 @@
<button id="view-tree-btn" onclick="setLibView('tree')" class="px-3 py-2.5 text-sm transition" title="Artist/Album view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="3" rx="1"/><rect x="3" y="6" width="12" height="3" rx="1"/><rect x="3" y="11" width="12" height="3" rx="1"/></svg>
</button>
<button id="view-folder-btn" onclick="setLibView('folder')" class="px-3 py-2.5 text-sm transition" title="Folder view">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><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>
<!-- Grid controls -->
<select id="lib-sort" onchange="sortLibrary()"
class="lib-grid-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
class="lib-nontree-ctrl bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
<option value="artist">Artist A-Z</option>
<option value="artist-desc">Artist Z-A</option>
<option value="title">Title A-Z</option>
@@ -147,6 +151,9 @@
<div id="lib-tree" class="space-y-2 hidden">
<!-- Tree populated by JS -->
</div>
<div id="lib-folder-tree" class="space-y-1 hidden">
<!-- Folder tree populated by JS when Folders source is active -->
</div>
</section>
<!-- ══ Filters drawer (feedBack#129/#69/#22) ═════════════════════ -->
@@ -578,6 +585,13 @@
<option value="">Saved Loops</option>
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop"></button>
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
scrolled to the active loop (or the section at the playhead).
"↩ Editor" returns to the editing position you came from; it only
appears after a Loop-in-3D handoff. Both are hidden when the editor
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
</div>
</div>
+90
View File
@@ -863,3 +863,93 @@ html { scroll-behavior: smooth; }
box-shadow: 0 0 0 2px rgba(64, 128, 224, 0.7);
border-radius: 0.25rem;
}
/* Feedpak contributor credits shown over the highway when a song loads
(manifest `authors:`, spec §5.4). Anchored to the upper third so it sits
ABOVE the vertically-centered count-in number; click-through. */
.song-credits-overlay {
position: fixed;
left: 0;
right: 0;
top: 15%;
/* Above the modal layer (z-[200], incl. the "Loading audio" backdrop) and
the count-in number (z-[100]) so the credits stay prominent through the
whole load count-in play window. */
z-index: 205;
display: flex;
justify-content: center;
pointer-events: none;
animation: song-credits-fade-in 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.song-credits-card {
position: relative;
min-width: 16rem;
max-width: min(90vw, 34rem);
padding: 1.4rem 2.5rem 1.5rem;
text-align: center;
background:
radial-gradient(120% 140% at 50% 0%, rgb(56 78 130 / 0.45) 0%, transparent 60%),
linear-gradient(165deg, rgb(23 30 48 / 0.92) 0%, rgb(11 15 26 / 0.94) 100%);
border: 1px solid rgb(129 140 248 / 0.28);
border-radius: 1rem;
box-shadow:
0 18px 50px rgb(0 0 0 / 0.55),
0 0 0 1px rgb(0 0 0 / 0.35),
inset 0 1px 0 rgb(255 255 255 / 0.07);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
/* Accent bar across the top edge of the card. */
.song-credits-card::before {
content: "";
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 3.25rem;
height: 3px;
border-radius: 0 0 3px 3px;
background: linear-gradient(90deg, #38bdf8, #818cf8);
box-shadow: 0 0 12px rgb(99 102 241 / 0.7);
}
.song-credits-eyebrow {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: rgb(165 180 252 / 0.9);
margin-bottom: 0.4rem;
}
.song-credits-heading {
font-size: 1.3rem;
font-weight: 800;
color: #f8fafc;
margin-bottom: 0.7rem;
letter-spacing: 0.01em;
text-shadow: 0 1px 8px rgb(0 0 0 / 0.5);
}
.song-credits-line {
font-size: 1.1rem;
line-height: 1.55;
color: #e2e8f0;
}
.song-credits-role {
color: rgb(148 163 184 / 0.95);
font-weight: 500;
}
.song-credits-name {
font-weight: 700;
color: #ffffff;
}
@keyframes song-credits-fade-in {
from { opacity: 0; transform: translateY(-12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -71,7 +71,7 @@
'<div class="flex items-center gap-2 text-xs text-fb-textDim mb-3">' +
'<svg class="w-4 h-4 text-cyan-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 18V5l12-2v13M9 13l12-2"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>' +
'<span>Audio Routing</span></div>' +
'<div class="flex items-center gap-2 text-[11px] text-fb-textDim">' +
'<div class="flex items-center gap-2 text-[0.6875rem] text-fb-textDim">' +
dot(st.inputAvailable) + '<span>Input</span>' +
'<span class="flex-1 border-t border-dashed border-fb-border/70"></span>' +
dot(st.effectActive) + '<span>VST/NAM/IR</span>' +

Some files were not shown because too many files have changed in this diff Show More