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>
116 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Added
- 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.songsis 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 toOFFSET 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 A–Z rail now seeks directly:sort_lettersgives 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 withoutsort_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 stablewindow.v3Songs.visibleCards()accessor + av3:library-window-renderedevent 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), updatedtests/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
filenametiebreak, 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 acrossOFFSETpages.GET /api/librarygains an opaqueaftercursor + anext_cursorin the response: passing the cursor back fetches the next page with a WHERE-seek instead ofOFFSET, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactlyOFFSET-equivalent (verified across artist/title/recent, ascending + descending, including the legacydir=descshape and NULL sort keys); unknown/compound sorts and bad cursors fall back toOFFSET, and only the local provider is handed a cursor (collections/remote page byOFFSET). 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/libraryquery (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 A–Z rail, art — for free, with no new screen. Storage reuses the playlist subsystem (aplaylists.rulesJSON 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). NewGET/POST/PUT/DELETE /api/collections; a per-collectionSmartCollectionProviderdelegatesquery_page/query_stats/query_artiststo 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/libraryquery 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/exportgains an additivecore_server_filessection carrying a consistent snapshot ofweb_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/). OnPOST /api/settings/importthe database is staged toweb_library.db.restorerather 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-walcan't be replayed onto the restored file — the import response setsrestart_required: trueand 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 inart_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
wantedtable +GET/POST/DELETE /api/wantedgive 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 — thefind_moreownership-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. - A–Z 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/statsnow acceptssortand returns an additivesort_lettersmap (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacyletters(distinct-artist) field is unchanged for the dashboard + classic tree. Frontend:static/v3/songs.js(refreshRail/jumpToLetter, cards tagged withdata-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 songart_urls;GET /api/playlistsandGET /api/playlists/{id}addcover_urlwhen a custom cover exists. New routesPOST/GET/DELETE /api/playlists/{id}/coverstore a small PNG thumbnail underCONFIG_DIR/playlist_covers/(PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend:playlistCoverHtml(p)instatic/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 instatic/v3/songs.js(both grid and tree rows, since they shareopenCardMenu). 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}tolocalStorage(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_audioSeekfunnel;playSong()gains a{ resume: {position, speed} }option that arms asong: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 (
confirmExitSonginlocalStorage, 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 callcloseCurrentSong()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 canonicaltogglePlay()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 sharedwindow.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'sscreen.jsis 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 toDLC_DIRand validated against path traversal (per-segment name validation plus a resolved-containment check onsong/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": trueinplugin.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__.pysurfaces it as thefullscreenboolean on/api/plugins(mirroring thesettings_categoryplumbing). When such a plugin's screen is active,static/v3/shell.jstoggleshtml.fb-immersivefromsyncActive()(so it tracks every navigation incl. deep-link), andstatic/v3/v3.csshides 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 existingss-follower-prechrome-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
achievementsplugin 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); usesrequestswith the baked-in client-token header, mirroringlib/lyrics_transcribe's outbound pattern (explicit timeout, no raise on non-2xx). Dead-letter, never drop (pureengine.drain_decision): network error /429/5xx→ keeppending(retry); other4xx→dead_letter(diagnosable, replayable);2xx→ delete on server ack. A row leaves the queue only on ack or a user opt-out.remove-menow enqueues a wall removal keyed by the reusedplayer_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 newfeedback-achievementsrepo. - 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 viasettings.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 addsachievements_enabled(bool, defaultfalse) to_default_settings()+ the/api/settingsvalidation block +_RESETTABLE_SETTINGS_KEYSinserver.py, mirrored tolocalStorageinapp.js loadSettings(). Data-minimization contract (binding, code-enforced): every outbound payload is built by a single explicit-dict serializer (engine.build_wall_payload, neverdict(row)/**model) whose key-set is exactly{display_name, player_hash, achievement_id, unlocked_at}withachievement_idalways 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 reusedplayer_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 inlocalStorage '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 newv3:profile-renderedevent after every render (mirrorsv3:settings-rendered) so the plugin re-injects on each profile entry. A new bundledplugins/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 siblingengine.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 batchedsong:endedactivity 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 versionedwindow.feedBack.achievementsAPI (register/registerAll/unlock/progress), load-order-safe via thewindow.__feedBackAchievementsPendingqueue + anachievements:readyevent (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 unchangedapp.jsloadSettings()/persistSetting()path); a newstatic/v3/settings.jsowns 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 instatic/v3/v3.css(no Tailwind rebuild). Plugins choose their settings tab via a new optionalsettings.categoryfield inplugin.json(plugins/__init__.pysurfaces it assettings_category;app.jsmounts each plugin's<details>panel into#plugin-settings-<category>, falling back to the generic Plugins tab) —highway_3dshipscategory: "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; keycountdown_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 existingmaster_difficultyand stays in sync with the player-popover difficulty slider. NewPOST /api/settings/resetclears 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_songnow parses the optional manifestoriginal_audio:key (the single pre-separation mixdown, e.g.original/full.ogg) into a newLoadedSloppak.original_audiofield, with the same path-traversal guard and permissive "missing → disabled" posture as thedrum_tabloader. The highway WSsong_infoframe additively carries three new fields next tostems:original_audio_url(served by the existing/api/sloppak/{filename}/file/{rel_path}endpoint,Nonewhen the pack ships stems only),has_original_audio, andhas_stems(mirroring thehas_drum_tab/has_keysflag convention). The stems plugin consumesoriginal_audio_urlto 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: thesong_infomessage shape is a stable contract — these are purely additive; all existing fields are unchanged.audio_urlstill 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 setsaudio_urlto the full mix instead of emittingaudio_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 (
autoplayExitinlocalStorage, 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 nextsong:ready(highway.js) to auto-start via the existingtogglePlay()path (HTML5 +_juceMode+ count-in). Arrangement switches / seeks reuse the samesong:readyevent but never arm the flag, so they don't auto-restart. Auto-exit: onsong: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-0with agetClientRects()visibility test that works forposition:fixed), in which case the return is deferred so that score screen's own Close button (callingwindow.closeCurrentSong()) drives the exit. A plugin can also defer explicitly via the newwindow.feedBack.holdAutoExit()(called synchronously from its ownsong:endedhandler — 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-shotwindow.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 theplaySongcall. Also exposes a read-onlywindow.feedBack.autoplayExitgetter for plugins. Songs and lessons share the sameplaySong→ highway path, so both inherit the behaviour. Core-only (static/app.js,static/v3/lessons.js, bothindex.htmls); 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 callholdAutoExit()+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 Library group, just below Songs — via the existingPROMOTED_PLUGINSmechanism instatic/v3/shell.js, instead of being reachable only through the generic Plugins gallery. Gated on the plugin actually being installed (renderPromotedNavchecks/api/plugins), so it appears only when the editor is loaded. The displayed label comes from the plugin's manifestnav.label. - Guitar Pro → notation importer (
lib/gp2notation.py) (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF:.gpxGP6 /.gpGP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside themidi = string*24 + fretguitar wire encoding.gp2rs_gpx.convert_filewrites a<stem>.notation.jsonsidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), andgp2notation.attach_notation_to_sloppak()is the assembly-side helper that renames it intonotation_<id>.json+ adds the per-arrangementnotation:manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whosestfwire-field approach this supersedes): GP voice position 0 →rhstaff (G2), positions ≥ 1 →lh(F4); a forced-LH track (the mergedPiano LHpartner from_find_piano_pairs, or a standalone track named… LH) routes everything tolh— preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolutetfrom the bar-indexed tempo map, change-onlyts/tempo/ks, andbeat_groupsfor compound/irregular meters (6/8 →[3,3], 9/8 →[3,3,3], 5/8 →[2,3], 7/8 →[2,2,3]— cf. the feedBack#261 denominator pitfalls); beats carrydur/dot/tu/restfrom GP rhythms and notes carry absolutemidi(String+Fret resolves via the string template's concert pitches, Tone+Octave via(octave+1)*12 + step) withtiedcontinuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses thegp2rs_gpxmachinery (bar-indexed tempo map, per-beat rhythm durations,_note_midi) so notation lines up with the RS XML the highway plays — with one deliberate divergence: double dots advance time ×1.75 (vs. the RS-XML walk's single-dot ×1.5 approximation) so a writtendot: 2agrees with the emitted beat times; sharing the walk itself stays tracked in feedBack#618. Tests:tests/test_gp2notation.py. - Legacy keys → notation lifter (
scripts/lift_keys_notation.py) (feedBack#825 WS4c, epic #828). One-time batch converter that lifts existing directory-form piano/keys sloppaks from the legacy guitar wire encoding (midi = s*24 + f) into real Sloppak Notation Format files (sloppak-spec §5.3). Candidates are arrangements whose name matches\b(keys|piano|keyboard|synth)\b(case-insensitive); each gets anotation_<id>.jsonplus the per-arrangementnotation:manifest sub-key. Measures derive from the song-levelbeatsdownbeats (measure >= 0;song_timeline.jsonpreferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (sus, legacylalias) when present, else the gap to the next onset in the same hand — quantized to the nearest plain/single-dotted{1,2,4,8,16,32}denominator at the local tempo, floored at a 32nd. Hands are split heuristically: onsets within 10 ms form a group; a group spanning > 12 semitones splits at its largest internal interval gap (low side →lh), otherwise the whole group goes by mean pitch vs middle C — single-staff output when everything lands on one hand. Idempotent (arrangements already carryingnotation:are skipped; an orphannotation_<id>.jsonwithout the manifest key is refused, not overwritten) with--dry-runsupport; every payload is checked vianotation.validate_notationbefore write. Honest caveat: the manifest is round-tripped through PyYAML (safe_load+safe_dump(sort_keys=False)) — key order survives, YAML comments/custom formatting do not (the script warns when comments are present). Zip-form.sloppakfiles are reported and skipped. Tests:tests/test_lift_keys_notation.py. - Notation schema v1 freeze — completeness batch (feedBack#822, epic #828). Adds the low-hanging-fruit fields ahead of content production: top-level credits
rights/lyricist/arranger; measurepickup(anacrusis); beatarp(arpeggiate),ferm(fermata), and typed grace notes —grace: "a"(acciaccatura, MusicXMLgrace/@slash=yes) /"p"(appoggiatura); notestem("up"/"down"force). Pedal is settled as the existingspd/sph/sputrio with a documented MusicXML<pedal start|change|stop>mapping — no separatepedfield. A new "v1 non-features" spec subsection pins the accepted limitations (microtonal, figured bass, mid-measure key/time/clef changes,ott/barline/ornaments/trem/glis) as additive-v1.x territory.lib/notation.pygains theGRACE_TYPES,STEM_DIRECTIONS, andDYNAMICSvocabularies; the validator stays permissive by design. - Notation format — standard musical notation as a first-class sloppak type. Promotes keys, piano, violin, and any other staff-notation instrument out of the guitar wire format and into their own data structure, following the same promotion path used for drums (feedBack#344). New
lib/notation.pydefines the canonical vocabulary (CLEFS,DURATIONS,SCHEMA_VERSION), a permissivevalidate_notation()check, andmeasures_to_wire()/measure_to_wire()wire helpers.lib/sloppak.py::load_songreads a new per-arrangementnotation:sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON viavalidate_notation(), and surfaces all notation payloads onLoadedSloppak.notation_by_id(adict[str, dict]keyed by arrangement id). A failed or missing notation file for one arrangement does not abort or skip the arrangement itself — partial-failure isolation mirrors the drum tab loader.file:is now optional whennotation:is present: the loader creates a minimal stub arrangement so a notation-only arrangement entry does not require a guitar wire format JSON./ws/highway/{filename}gains two new message types —notation_info(staves, instrument, total measure count) and chunkednotation_measures(32 measures per chunk) — streamed aftersectionsand beforeanchors;song_infocarries a newhas_notation: boolflag so viz pickers can auto-activate the notation plugin regardless of arrangement name. The notation file schema is measure-structured (measure → staff → voice → beat → note), uses MIDI for pitch (no string/fret/tuning indirection), and carries the full set of effects that alphaTab can render. Seedocs/sloppak-spec.md§5.3 for the full schema. Open questions resolved per the piano/keys epic (feedBack#828 / #822): Option B (per-arrangementnotation:sub-key) andfile:-optional-when-notation:-present are the endorsed design. song_timeline.json— beats and sections as a top-level file. A new optional top-level file pointed at by a new manifest key (song_timeline: song_timeline.json) provides the correct home for song-wide beats and sections, replacing the legacy convention of embedding them in the first arrangement JSON. The loader inlib/sloppak.pyreads and validates the file (must be a dict withbeatsandsectionsas lists), clears and repopulatesSong.beats/Song.sectionsfrom it when present, and stores the raw dict onLoadedSloppak.song_timeline. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omitsong_timeline:continue to load without any change. This is a prerequisite for notation-only sloppaks, which may have no arrangement JSON at all and therefore no carrier for beats/sections data. New sloppaks should put beats/sections insong_timeline.jsononly. Seedocs/sloppak-spec.md§2 and §5.3.note-detectioncapability domain promoted — control plane (spec 009) (feedBack#727/#728, epic #828). New core hoststatic/capabilities/note-detection.js: provider registry (kindsmidi/engine/js, primitivespitch.estimate/verify.target), requester-owned context-scoped detection bindings (open-binding/close-binding/set-target/clear-target— each binding carries its own redacted tuning context, independent of the host's loaded song, per spec-009 FR-003), and hit/miss/verdict observability events (consumers own judgment). The legacy chart-coupledhighway.setNoteStateProvidersurface keeps working and is wrapped for compatibility-shim hit accounting. Diagnostics (feedBack.note_detection_capability.v1) carry provider/binding summaries and bounded outcomes — no raw audio, device labels, or song identity. Migrating the chart path, Step Mode verify, minigames YIN, and the engine verifier onto bindings is the remainder of the spec-009 slice.visualizationcapability domain promoted (cap:6) (feedBack#828). New core hoststatic/capabilities/visualization.jsregisters a provider-coordinator owning the highway renderer surface: commandsinspect/list-providers/select-renderer/clear-renderer(selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), eventsproviders-refreshed/renderer-changed/renderer-ready/renderer-failed. Legacy discovery (type: "visualization"manifests,window.feedBackViz_*globals) keeps working unchanged and is accounted as compatibility shims with hit counts.static/app.jsattributes every renderer change (auto-match / user-select / fallback) and auto-match outcomes into the domain. Diagnostics (feedBack.visualization_capability.v1) carry provider ids/labels/context types, active renderer + selection source, last auto-match outcome, and last failure — no song filenames/titles. Per-panel (splitscreen) selection is a tracked follow-up.- Viz picker routes notation arrangements (feedBack#826, epic #828).
window.feedBack.currentSonggainshasNotation(sibling ofhasDrumTab) from thesong_infoframe'shas_notationflag, so notation viz plugins (Staff View, Keys Highway 3D) can gatematchesArrangementon data presence instead of arrangement-name heuristics. When a notation-only arrangement (no wire notes —file:omitted per sloppak-spec §5.3) falls through Auto with no notation plugin installed, the built-in highway still takes the canvas but the Auto label reads "no notation view installed" and a one-shot dismissable hint points at the visualization picker — never a silently blank board. - Keys instrument path in progression (feedBack#828). New
data/progression/paths/keys.json(5 levels / 15 challenges at parity with the guitar path) plus keys-flavoured daily/weekly quest pool entries (d.keys-one"Ivory Tower",w.keys-three"Grand Recital").lib/progression.py::instrument_for_arrangement()now attributestype: piano|keysarrangements — and names matchingkeys/piano/keyboard/synthon a word boundary — to the newkeysinstrument, so scored keys runs advance the path automatically. Purely content + attribution: no schema or API changes. - 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/albumquery params threaded throughMetadataDB._build_where→query_page/query_artists/query_statsand the/api/library,/api/library/artists,/api/library/statsendpoints (the free-textqsearch 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 asessionStoragesnapshot 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
- 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 bylogicalSourceKey(_visibleInputSourcesinstatic/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 actuallyselected. 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'sscreen.jsmeasures 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-framequerySelector, per the plugin perf rules) and only consulted while the counter is actually drawn; gated onwindow.feedBack.uiVersion === 'v3'so the classic (v2) UI is byte-for-byte unaffected.plugins/highway_3d/plugin.jsonversion →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.) - 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_scanre-readsconfig.jsonfresh — but the v3 grid never reloaded). The Settings Rescan / Full Rescan handlers only refreshed the classic (v2) library vialoadLibrary(); 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 viawatchUploadScan), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit alibrary:changedevent (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_libraryDirtyshort-circuit ahead of every cached-DOM fast-path inonV3SongsScreenEnter). 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 normalizedyear(writes it into the file viasongmeta, survives a rescan) — only the UI omitted it. Added a Year input toopenEditModal()(populated from the song's existing year) and includedyearinsaveEditModal()'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
clickevent'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.sloppakbut never regenerated the archive, so the manifest inside still carriedtitle: Slopsmith Diagnostic — Basic Guitar/artist: Slopsmith(and the same heading inDIAGNOSTIC.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". Regenerateddocs/diagnostics/feedBack-diagnostic-basic-guitar.sloppakfromdocs/diagnostics/build_diagnostic_basic_guitar.pyso the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, anddiagnostic: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 (likesong:loading) carries the filenameencodeURIComponent'd — exactly asplayCardhands it toplaySong(the highway WSdecodeURIComponents it back) — whereas library cards key on the decodedlocalFilename(data-fn), and/api/stats/bestis server-canonicalized to that same decoded key (server.py_canonical_song_filename). SorepaintAccuracy'sdata-fn !== keycheck rejected every card andstate.accuracy[encoded]wasundefined, leaving the just-earned badge stale until a fullrender()(app restart / search / re-enter the screen) — which is why it "came back after a restart."static/v3/songs.jsnow decodes thestats:recordedfilename back into the card /state.accuracykey space via a smalldecFnhelper 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 theonV3SongsScreenEnterdeferred 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()instatic/app.jstreats any focusedINPUT/SELECT/TEXTAREA/BUTTONas an "interactive control" and bails before the shortcut registry runs — so the player-scopeEscape → Backshortcut 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 anEscape = Backshortcut, 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-scopeEscapeshortcut 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 bystatic/v3/player-chrome.js'supdateUpNext()) 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 theautoplayExitidiom: a client-onlyshowUpNextlocalStoragepref (absence = enabled), a Show "Up Next" switch in the Gameplay settings tab (static/v3/index.html), reader/writer +loadSettings()hydration + a read-onlywindow.feedBack.showUpNextgetter instatic/app.js, and a gate at the top ofupdateUpNext()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 toRESET_MAP.gameplay.localinstatic/v3/settings.jsso 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 bydata-artist) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders viareload()) 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 bywireCards()), 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'shidden sm:flexarrangement chips and the new action cluster) were never compiled in, so they rendereddisplay:noneon the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinnedtailwindcss@3.4.19viascripts/build-tailwind.shso 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 treatsBUTTON/Aas interactive), so the Space shortcut never reached the dispatcher andtogglePlay()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 callse.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 intests/browser/keyboard-shortcuts.spec.tscover 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 intostate.accuracyat 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-ranrender(). Thestats-recordernow emits astats:recordedevent (carryingfilename/arrangement) once the scoredPOST /api/statsresolves on the server — the correct moment, sincesong:stopfires before the POST completes.songs.jslistens: if the library is the active screen it re-fetches/api/stats/bestand patches the affected card/row badge in place; otherwise it marks the filename dirty andonV3SongsScreenEnterapplies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a sharedaccuracyBadge(filename, variant)(grid pill + tree-row percentage, both tagged.fb-acc-badge) so the in-placerepaintAccuracycan find and replace them without a full list re-render (scroll/pagination preserved). The old emptysong:stop"refresh lazily next render" placeholder is replaced. - Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen. The
highway_3dplugin'sh3dSetFretSpacingwas the lone 3D-highway setting that calledlocation.reload()to apply — and since the SPA boots with#homeas 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_h3dFretUniformflag (so panels mounted later this session pick up the new mode), recomputes the twofretX-derived scalars that were baked at init (_fretLabelScaleRefWfor fret-label sprite scaling,FRET_WIDTH_MIDfor camera hysteresis), and broadcasts afretSpacingchange over the existing_bgEmitChangepub-sub so every mounted panel rebuilds its board viabuildBoard(). Per-frame note geometry already readsfretXlive and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests intests/js/highway_3d_fret_spacing.test.jsnow 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)
playSongremappedhome-launched songs to return to the#v3-songsscreen unconditionally, butstatic/app.jsis shared with the v2 UI (served at/v2/FEEDBACK_UI=v2) where that screen does not exist — Esc-from-player then calledshowScreen('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-songsis 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. - An active custom highway renderer is no longer starved of
draw()when it hides the canvas (#819). The per-frame draw gate instatic/highway.jsbailed onif (!_lastVisible) return, which conflated two different "hidden" states: a genuine off-screen canvas (offsetParent === null— navigate-away /display:nonesplitscreen panel, #246) versus a renderer-set override-hide (setVisible(false), where an opaque overlay covers the canvas but the active renderer keeps painting its own surface). The gate now only pauses everything for the off-screen case (and still pauses the default 2D renderer on an override-hide); the active custom renderer keeps receivingdraw()through its own override-hide. Thehighway:visibilityevent still fires before the gate, so sibling overlay renderers (e.g. 3D Highway's.h3d-wrap) still pause. This is the core-side root cause behind the Tab View cursor freezing in single-player (feedBack#734; worked around plugin-side in feedBack-plugin-tabview#25). - Screensaver no longer kicks in during windowed-mode playback (#686). While a song is playing,
static/app.jsnow holds a Screen Wake Lock (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 onsong:play/song:resumeand released onsong:pause/song:ended/song:stop(kept only while actually playing), and re-acquired onvisibilitychangewhen 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 samesong:*events, so the fix covers both. In feedBack-desktop (Electron), wherenavigator.wakeLockis unreliable, it also drives a nativepowerSaveBlockerbridge via the optionalwindow.feedBackDesktop.power.setScreenAwakehook 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 onlocalhost/ 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) togot-feedback/feedBack-plugin-virtuoso(id: virtuoso); the desktop bundle swap is feedBack-desktop#31.static/v3/shell.jsstill promotedslopscale, whose id no longer ships — sorenderPromotedNav()(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_PLUGINSslotslopscale→virtuoso(screen: plugin-virtuoso, label "Virtuoso - Practice", same FeedBarcade anchor +targeticon) so the practice plugin keeps its first-class entry. Also clear the now-deadslopscaleid from the Plugins-gallery curated category map (static/v3/plugins-page.js) and addvirtuoso: 'practice'as a defensive fallback (the manifest'scategory: "practice"is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references inREADME.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 litMeshStandardMaterialinstead of the old flat, straightMeshBasicMaterialboxes, 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 thehighway_babylonplugin's "hit-zone fret bars". All knobs (FRET_BOW_DZ, metalness/roughness/emissive) are tunable constants.plugins/highway_3dv3.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_3dv3.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 totailwind.config.js; regen viabash 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.
_ensureChordRenderCachenow also cachessortedNotes/nonZeroNotes/nonZeroFrets/allMuted/hasMultipleNotes(computed once per chord, invalidated onsrc/_inverted/chordTemplateschange — the third key catches a staleisOpen-derived classification when the WSchord_templatesmessage lands after the finalchordschunk), sodrawChordsno longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (nochordPositions.filter× 2 per frame).drawLyricsmemoizesctx.measureTextresults in a two-levelMap<fontSize, Map<text, width>>so cache hits don't allocate a composite string key. Lit-sustain shimmer indrawSustainsswaps the 4 per-note-per-frameMath.random()calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic percreateHighway()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_MS2500 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 7–12 ms deadband instead of oscillating across it. No new public API; the_autoScaleMin"Min res" floor is unchanged.
Removed
clibrary hotkey ("Convert to .sloppak") removed from core. Core hardcoded a plugin-specific shortcut: a documentation-onlyregisterShortcut({ key: 'c', scope: 'library' })no-op plus ac → button.sloppak-convert-btnentry 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 owncshortcut viawindow.registerShortcut()if keyboard access is wanted. Thef(favorite) ande(edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests intests/browser/keyboard-shortcuts.spec.tsupdated to drop thecassertions.
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_completedchallenges. A new Progress screen (rank hero, per-path checklists, quest countdowns, add-a-path) and Shop screen (themes via CSS-variable swaps underhtml[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 indata/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 whitelistsminigame_run;song_completedstays server-derived insidePOST /api/stats, which now resolves the instrument server-side and reports an additiveprogressionoutcome key),GET /api/shop,POST /api/shop/buy|equip; equipped cosmetics ride along onGET /api/profile. A newprogressioncapability domain (core-owned, kind: command, safety: safe —inspect,record-event,list-shop,buy-item/equip-itemgated on user action) emitschallenge-completed/quest-completed/path-level-up/rank-changed/db-changed/calibration-completed/cosmetic-equipped, mirrored asprogression:*window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetricrecord_progression_eventcontext 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. -
3D highway score FX (notedetect game-scoring layer). The bundled
highway_3drenderer now visualizes the scoring layer shipped in feedBack-plugin-notedetect ≥1.13: floating "+N" score pops above each judged gem (sourced from the note-state provider's new{ points, mult, popKey }verdict fields — chord members share the chord-levelpopKey, so a chord pops once, not once per string), plus session-level FX from the newnotedetect:fxevent — a particle burst at the strike line on streak milestones (25/50/every 100), an expanding ring pulse on multiplier tier-ups (×2/×3/×4), and a brief red wash when a ≥10 streak breaks. Colors and the pop font follow the user's notedetect scoring-UI skin (feedBack_notedetect_skin: neon/esports/metal, refreshed live on thenotedetect:skinbus event). Everything renders on the existing 2D overlay canvas from fixed-size slot pools — no Three.js geometry, no text-sprite cache traffic, near-zero cost when idle — and degrades to a silent no-op with older notedetect builds (the new fields/events simply never arrive). Splitscreen panels scope FX to their own detector instance via the bubbling per-panelnotedetect:fxdispatch.plugins/highway_3dv3.24.1. -
3D highway: slide direction arrows + gem-follow animation. Slide notes now show a ›/‹ arrow indicating which way the slide goes — on the note/gem itself, as an early preview on the neck before the note arrives, and (optionally) chained further ahead for multi-leg slides — each independently toggleable in Settings → 3D Highway (
slideArrowApproachVisible,slideArrowNeckVisible,slideArrowChainPreviewVisible). The note gem also now visually glides from its starting fret to the slide's destination over the note's sustain and holds there through the brief post-sustain linger, instead of snapping back to the starting fret — most noticeable on unpitched "slide to nothing" notes.plugins/highway_3dv3.25.2. -
3D highway: up to 3 upcoming-note ghost previews per string, with fade-in/grow. Each string now previews up to 3 upcoming notes (was 1) on a fixed 0.6 s fade-in/grow ramp, so tight same-string runs no longer pop in at full size right before impact and the player can read note order ahead of time.
isBlocked(the pre-impact ghost suppression in a note's last 150 ms) is now scoped to chord notes only — for lead notes it had been blinking the ghost out right before each sustained note in dense runs. (Slide notes stay excluded too, per the slide-arrow work above, since their gem glides off the start fret.)plugins/highway_3dv3.26.0. -
Enable/disable plugins from the v3 Pedalboard (footswitch backend). Every
/api/pluginsentry now carries anenabledboolean (defaulttrue), and a newPOST /api/plugins/{plugin_id}/enabledendpoint ({"enabled": <bool>}→{"id", "enabled"}) persists the choice toCONFIG_DIR/plugin_state.json(only non-defaultenabled:falseentries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader skips disabled plugins at startup — no requirements install, noroutes.setup(), no screen/nav/capabilities — while still surfacing them in/api/pluginsas a disabled entry (status:"disabled",enabled:false) so the UI can show an "off" pedal you can switch back on. Toggling persists immediately and flips the in-memory flag so the next/api/pluginsreflects it at once (a runtime-disabled plugin's already-mounted routes/screen remain until the next restart; re-enabling a startup-skipped plugin mounts on restart). A disabled plugin is excluded from the capability pipeline — its capability metadata is emptied in/api/plugins. Guard rails keepcapability_inspectorandapp_tour_*always enabled (disable →400); unknown id →404; missing/non-booleanenabled→400. Backend only; the v3 Pedalboard frontend consumes this contract. Docs:docs/plugin-v3-ui.md. -
fee[dB]ack v0.3.0 rebrand + UI redesign (opt-in, isolated). The visible product is being renamed from FeedBack to fee[dB]ack (the
[dB]is a decibel pun on the practice "feedback" loop) alongside a full dashboard-style UI redesign. Rebrand scope is the app + docs wordmark only — the repository, Python package, ghcr Docker image,CONFIG_DIR, andFEEDBACK_*env vars all keep thefeedBackname, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag:FEEDBACK_UI=v3flips the/route to the newstatic/v3/shell, andGET /v3always serves it; the default/stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds thestatic/v3/scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additivefbTailwind color palette (legacydark/accent/goldretained) withstatic/v3/**in the content globs, and the regeneratedstatic/tailwind.min.css. Vanilla JS, prebuilt Tailwind, no Play CDN (Principle II). Shell wiring, screens, profile/scoring backends, and capability-runtime integration land in subsequent v0.3.0 changes. -
fee[dB]ack v0.3.0 app shell (sidebar + topbar + routing). The v3 shell (
static/v3/index.html) is now a re-chromed copy of the legacy app: the new left sidebar (HOME / LIBRARY groups) and topbar (secondary nav, search, Support, badge-cluster mount points) replace the hidden legacy navbar, and new#v3-*screens (dashboard/plugins/profile/playlists/saved) are added — while all legacy screens (#homelibrary,#favorites,#settings,#player,#audio, plugin nav containers) are kept verbatim sostatic/app.jsboots unmodified and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the sharedwindow.showScreenacross#v3-*, reused legacy, and#plugin-*screens, with a responsive hamburger and alocalStorage/v3:-namespaced shell. Plugin nav is mirrored into the sidebar from/api/plugins(UI placement is a deferred capability domain, so this uses the legacy loader, not capability dispatch).static/v3/shell.jswrapswindow.showScreenvia the idempotent rehydration pattern to keep sidebar/topbar active-state in sync. -
fee[dB]ack v0.3.0 player profile + first-run onboarding + unified XP + streak. Adds a single-user core profile (
profile/profile_progress/xp_profiletables inweb_library.db, additive + idempotent): display name + avatar, a stableplayer_hash(SHA-256 of the first name + a once-generated salt — stable across later renames; a future-leaderboard label, never auth), and a streak (any session on a calendar day keeps it; a missed day resets to 1). New endpoints:GET/POST /api/profile,POST /api/profile/avatar(base64, re-encoded to a ≤512px PNG underCONFIG_DIR/avatars/),GET /api/profile/avatar/{name}(safe-joined),GET /api/profile/avatars(bundled defaults understatic/v3/avatars/),GET /api/profile/progress(one call for the badge), andPOST /api/xp/award. Unified XP:lib/xp.pyis the single XP curve (same math the minigames plugin shipped); the corexp_profilestore is the one source of truth the profile badge reads, exposed to plugins viacontext["award_xp"]/get_xp_progress/seed_xp. The bundled minigames plugin now delegates XP to the core store (seeding once from its existingprofile.jsonso earned levels carry over) — so song-play, minigames, and tutorials all feed one level. Frontend: a blocking first-run onboarding overlay (name + avatar grid + upload), the topbar profile badge (avatar, 🔥 streak, level + XP bar), and the#v3-profilescreen. Tests:tests/test_xp.py,tests/test_profile_api.py. -
fee[dB]ack v0.3.0 song-stats store (best score + accuracy, plays, resume position). A core
song_statstable (web_library.db, additive + idempotent, PK(filename, arrangement)) records per-song/arrangement best/last score + accuracy, play count, and last position. Endpoints:POST /api/stats(scored session →plays += 1,best_*= max,last_*= new, plus unified-XP awardxp_for_run(score)and a streak bump, both behind try/except so a side-effect failure never drops the stat write; or position-onlylastPlayPosition→ resume touch with noplayschange),GET /api/stats/{filename}(aggregated across arrangements),GET /api/stats/recent(joined to song title/artist/art for "Jump back in"). Scoring stays frontend-driven:static/v3/stats-recorder.jstallies thenote:hit/note:missevents the optionalfeedBack-plugin-notedetectalready emits (and also accepts an explicitnote_detect:session-endedsummary), then POSTs on song end; it also persists resume position on pause/stop. No note-detect edit required — note-detection is a deferred capability domain, so the recorder uses those legacy events and degrades to "no accuracy" when the plugin isn't installed. Score/accuracy math is shared with the server vialib/song_score.py. Tests:tests/test_song_score.py,tests/test_song_stats_api.py. -
fee[dB]ack v0.3.0 playlists, Saved for Later, and Continue-Playing. Core playlist management (
playlists+playlist_songstables inweb_library.db, additive + idempotent): create/rename/delete, add/remove/reorder songs, plus a reserved Saved for Later system playlist (created on first use; protected from rename/delete). Endpoints:GET/POST /api/playlists,GET/PATCH/DELETE /api/playlists/{id},POST /api/playlists/{id}/songs,DELETE /api/playlists/{id}/songs/{filename},POST /api/playlists/{id}/reorder,POST /api/saved/toggle, andGET /api/session/continue(derives the resume song + last position fromsong_stats, no new table). Frontendstatic/v3/playlists.jsrenders the#v3-playlistslist + detail (drag-reorder, play, remove) and#v3-saved, and exposeswindow.v3Saved.toggle()for a "Save for later" affordance on song cards. Favorites reuse the existing favorites screen/API. Core REST, no capability domain. Tests:tests/test_playlists_api.py. -
fee[dB]ack v0.3.0 Dashboard / Home. The
#v3-homedashboard (matching the v0.3.0 design target) composes the new backends: a "Welcome back, {name}!" banner with a patch-notes link (/api/version), a hero card (Start Playing / Create Lobby), a Continue-Playing card (/api/session/continue→ art, tuning chip, 4-segment progress; click resumes viaplaySong+ best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from/api/library/stats, plugins count from/api/pluginswherestatus==="ready"), and a Recently Played grid (/api/stats/recent) with per-song accuracy badges (good/mid/low ramp). Each widget fetches + renders independently and degrades gracefully (missing/empty endpoint → placeholder, never blocks first paint).static/v3/dashboard.js; re-renders on return to Home and on profile update. -
fee[dB]ack v0.3.0 tuner + instrument topbar badges. The topbar gains an instrument selector (guitar/bass + string count + tuning + reference pitch) persisted via additive
/api/settingsfields (reference_pitchclamped 430–450,instrument,string_count4–8,tuningname or semitone offsets); changing it emitsinstrument:changedso the note_detect scorer can re-tune (consumed once the external plugin adopts it). A live tuner badge stays idle until the user enables the mic (explicit gesture;getUserMedia), then shows a YIN note readout with a cents needle (green within ±5¢) using a new dependency-freestatic/v3/tuner-core.js(YIN + frequency→note/cents, honoring the reference pitch); clicking opens the fullfeedBack-plugin-tunerscreen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respectsprefers-reduced-motion). Tests:tests/test_settings_instrument.py,tests/js/tuner_core.test.js. -
fee[dB]ack v0.3.0 audio-routing widget (dashboard). The dashboard's audio stat tile now reads the live audio session through the capability runtime —
audio-mix inspect(route + faders + required kinds),audio-input list-sources(selected/available input),audio-monitoring inspect— and renders Audio Input → VST/NAM/IR → Audio Output with per-node state dots and a Connected/Not Connected line. It never touchesaudio-mixer.jsinternals ornam_toneroutes directly; "Not Connected" is the honest browser default (no native route), and it degrades onno-owner/no-handler/failedor absent capabilities. Refreshes oninstrument:changed, play/stop, capability audio events, and each Home visit.static/v3/audio-routing.js. -
fee[dB]ack v0.3.0 Plugins page. The
#v3-pluginsscreen renders the enriched/api/plugins: a "{N} active" header (status==="ready"), a card grid per plugin (icon, name, version, status pill with the error on failed, capability summary badges — declared domains / validation warnings / unsupported versions / shim hits / bundled / type), an Open → action that navigates to the plugin's injected#plugin-<id>screen, and All/Bundled/Visualizations filters. Surfaces a deep-link to the bundled Capability Inspector rather than re-implementing the graph. No new backend.static/v3/plugins-page.js. -
fee[dB]ack v0.3.0 Songs / Library screen (
#v3-songs). A native vanilla-JS library browser over the existing/api/library*endpoints: provider selector (via thelibrarycapability, not DOM scraping), grid + tree views, sort, format filter, a tri-state filter drawer (arrangements / stems / lyrics / tunings), topbar-driven search (debounced), infinite scroll, fb song cards with accuracy badges (good/mid/low ramp, batched via a newGET /api/stats/best), favorite + save-for-later affordances, and upload (reuses the existing uploader). The "Songs" sidebar nav now opens this screen. No regression to/api/library*.static/v3/songs.js. -
ui.library-card-injectioncapability + native song-card actions (fee[dB]ack v0.3.0). New core capability (static/capabilities/library-card-actions.js, ownercore.ui.library-card-injection, exposed aswindow.feedBack.libraryCardActions) lets plugins register per-song library-card actions (id, label, placement, applicability, enabled state, run handler) withaction-registered/action-resultevents — replacing the legacy.song-cardDOM-injection pattern (roadmap domain #9, now delivered as a frontend host). The native Songs grid renders registered actions in each card's "⋮" menu; the built-in Edit metadata and Convert to E Standard (retune) actions ship through it (static/v3/card-actions-core.js, calling the existingopenEditModal/retuneSongglobals). Songs cards also gain arrangement chips (play a specific arrangement) and a multi-select mode with batch Add to playlist / Save for Later. Recipe indocs/capability-recipes.md; tests intests/js/library_card_actions.test.js. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) ontoregister(...)is a follow-up. -
centOffsetexposed viagetSongInfo()— the arrangement<centOffset>field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent ascentOffsetin thesong_infoWebSocket message. Plugins can readgetSongInfo().centOffsetto obtain the arrangement's pitch-shift offset — commonly-1200.0for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to0.0when absent. -
highway.getPhrases()andhighway.getMastery()public plugin API — exposes phrase timing windows ([{ index, start_time, end_time, max_difficulty }]) and the current mastery slider value (0..1) as documented, stable plugin API. Both values were already in memory and reachable via internal names; this surfaces them with intent so plugins can implement section-aware logic (e.g. tracking accuracy per phrase, suppressing difficulty changes during a hard solo) without reaching into undocumented internals. Returnsnullwhen the song has no phrase data (GP imports, single-difficulty charts). Pair with the existinghasPhraseData()to gate phrase-aware code paths. -
Tailwind freshness guard + wider plugin scan. A new
tailwind-freshCI job (.github/workflows/tests.yml) rebuildsstatic/tailwind.min.csswith the pinnedtailwindcss@3.4.19and hard-fails on any diff, so the committed prebuilt stylesheet can no longer silently lag source (after PR #411 removed the runtime Play CDN, a stale file shipped unstyled elements with no guard). Thetailwind.config.jsplugin content glob is widened to./plugins/**/*.{js,html}, which also scans non-screen.jsplugin JS (e.g.plugins/app_tour_*/script.js) that was previously invisible to the build. Regenerating under the wider glob is a no-op for runtime behaviour — it only adds classes that were already used in source. Groundwork for the pluginstylescapability (constitution 1.1.0, Principle II): runtime-installed plugins ship their own compiled CSS rather than relying on core's build-time scan. -
Plugin capability pipelines — adds the first versioned capability coordination layer for plugin authors and support tooling.
/api/pluginsnow exposes validated capability declarations, validation warnings, unsupported-version metadata, UI/runtime domain declarations, and compatibility shim summaries for legacynav/screen/settings/routes/ visualization surfaces. The browser runtime now tracks manifest participants separately from live handlers, explicit dispatch outcomes (no-owner,no-handler,unsupported-command,incompatible-version), claim lifecycle cleanup, manual override precedence, deterministic ownership conflicts, multi-provider ordering, shim hit counts, and a redaction-safe diagnostics snapshot capped at 64 KB. A bundled Capability Inspector plugin shows the live graph, and new docs cover the manifest schema, recipes, safety matrix, lifecycle cleanup, and diagnostics contract. -
Audio graph/session capability slice — promotes
audio-mix,audio-input,audio-monitoring, and coordinatedstemsdiagnostics into the capability runtime. The new audio session host records song route/fader state, redaction-safe input sources, monitoring lifecycle outcomes, stem automation claims/overrides/orphans, and compatibility bridge hits for legacy faders, song volume, Stems master volume, 3D Highway analyser taps, audio startup barriers, and input source handoffs.core.audio.sessioncoordinatesstemswithout replacing the Stems plugin as the owner of actual stem playback/state. -
Audio-mix control plane — makes
audio-mixthe player mixer source of truth. Core now exposeslist-faders,get-fader-value,set-fader-value,inspect-route, andinspect-analyserthrough the capability runtime, routes native and compatibility-backed fader provider operations with a 2-second timeout, reports committed values back to the mixer UI, suppresses matching legacy faders when a native participant owns the same logical control, and expands audio-session diagnostics/Capability Inspector rendering for fader availability, source modes, bridge hits, route/analyser summaries, and timeout failures. -
Audio-input control plane — makes
audio-inputthe redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposeslist-sources,select-source,open-source, andclose-sourcethrough the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes providersource.open/source.closeoperations with bounded outcomes, shares compatible open sessions across requesters, suppresses compatibility-backed duplicate sources when a native provider owns the same logical key, and expands audio-session diagnostics/Capability Inspector rendering for selected input, open sessions, bridge hits, storage status, and permission/device failures without exposing raw device labels or live audio handles. -
Audio-monitoring control plane — makes
audio-monitoringthe shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-actionstart, requester-countedstop, prompt-freeinspect/monitoring.status, andset-direct-monitorthrough the capability runtime. Monitoring starts integrate with selectedaudio-inputreadiness, background requesters can only attach to active compatible sessions, active sessions survive song/playback stops without auto-resuming after reload, native providers suppress compatibility-backed legacy monitor paths, and diagnostics/Capability Inspector now show providers, sessions, requesters, direct-monitor state, bridge hits, and distinct safe outcomes (provider-selection-required,user-action-required,incompatible,unavailable,stopped, etc.) without exposing raw audio/device data. -
Playback control plane — promotes
playbackto an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposesinspect, user-authorizedstart,pause,resume,stop,seek,set-loop, andclear-loopthrough the capability runtime whilestatic/app.jskeeps raw<audio>/JUCE handles private behind a redaction-safe adapter. Playback diagnostics use pseudonymous targets in exported bundles, local display labels only in the Capability Inspector, bounded recent outcomes/events, and bridge accounting forwindow.playSong, legacysong:*events,window.feedBacktransport helpers, loop helpers, and browser/native route handoff. -
3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter. The bundled
plugins/highway_3dgains an amber Tone-change HUD (shows the active tone and the next scheduled tone change; position / size / visibility configurable in settings), a fret-dividers toggle (vertical dividers on the highway, on by default, viah3dBgSetFretDividersVisible), a chord-diagram visibility toggle (h3dBgSetChordDiagramVisible), and an FPS counter setting migrated toBG_DEFAULTS.fpsVisible(drops the legacyh3d_showFpslocalStorage key). Chord-diagram position is restricted totl/tr; legacybl/brvalues are coerced on load. Perf: accent-halo shell descriptors are pre-built per string ininitScene()and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in thedrawNote()and chord hot paths. -
Sloppak assembly preserves a short preview clip. When a source chart carries a separate short browser-preview audio clip alongside the full song, the sloppak assembler now decodes it to
preview.oggat the sloppak root and records it under a new top-levelpreview:manifest key (POSIX relpath, same shape aslyrics/cover). A failed preview decode is logged at debug and skipped without aborting the overall build. Sources with no separate preview are unaffected. Older sloppak readers ignore the unknownpreviewkey, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented indocs/sloppak-spec.md§2 alongside the other optional top-level keys. EnablesfeedBack-plugin-song-previewto render hover-to-listen previews for sloppaks without seeking into the full audio. -
Generic plugin asset route —
GET /api/plugins/{plugin_id}/assets/{path}serves arbitrary static files a plugin bundles under its ownassets/directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced bylib/safepath.safe_joinagainst<plugin>/assets/, so..traversal, absolute paths, and NUL bytes cannot escapeassets/to reach a plugin's Python modules..jsis served asapplication/javascript. First consumer: the stems plugin's pitch-preserving time-stretch worklet. -
Minigames framework — bundled as a core plugin (
plugins/minigames/). Promotes the upstreamfeedBack-plugin-minigamesrepo into the core bundle so every FeedBack install gets the framework out of the box (same promotion path used forhighway_3d). The plugin adds a top-level Minigames nav link (alongside Library / Favorites / Upload — not buried in the Plugins dropdown), a library-style card grid of installed minigame plugins, and a shared profile layer (XP, level, per-game leaderboards, cross-minigame unlocks) persisted underCONFIG_DIR/minigames/and opted into the settings export. Other plugins that want to ship a minigame add aminigameblock to theirplugin.jsonand callwindow.feedBackMinigames.register(spec); the SDK exposes scoring (createContinuousruns a self-contained YIN tracker;createDiscrete/createChordwrapnote_detect'screateNoteDetector), HUD primitives, run persistence, and a scheduler so individual minigames don't need their own DSP or backend. Backend endpoints live under/api/plugins/minigames/{runs,profile,registry}. The framework is plugin-shaped (not core code) per Principle III, but bundled so it ships with every install. First consumer:feedBack-plugin-flappy-bend, shipped separately. -
Alpha-build heads-up banner — when
/api/versionreports a version string containing "alpha" (case-insensitive), an amber banner appears at the top of the library section warning users that the build is in active development and may have bugs or breaking changes. The banner stays hidden on stable / beta / RC builds. No persistence or dismiss state — it's a passive notice, not a modal. -
Drum vocabulary expanded to 18 pieces — adds
stack(MIDI 30, from GM's extended-percussion range, unused by real drum-kit MIDIs) andbell(MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) tolib/drums.pyPIECES. Inserted in the iteration order so the editor / highway lane ordering is hi-hat → stack → crash → … → ride bell → bell. Both are cymbals; default shapecircle_jagged(stack) /circle_dot(bell). Old drum tabs round-trip unchanged — the schema is permissive and existing piece-ids are untouched. -
GP / MIDI drum import surfaces unmapped notes —
convert_drum_track_to_drumtab(lib/gp2rs.py) andconvert_drum_track_from_midi(lib/midi_import.py) gain an optional keyword-onlyout_unmappedparameter. Callers that pass an empty dict receive a per-MIDI record of every silently-skipped percussion note ({midi: {"count": int, "times": [float, ...]}}, times capped at 100 samples per note). This lets the editor plugin show a warning + manual-mapping UI on import instead of silently dropping unmapped notes. Default behavior unchanged for callers that don't opt in. -
Drum support from scratch — drums are now a first-class arrangement type with their own JSON payload on disk and their own WS stream to the highway. New
lib/drums.pydefines the closed piece-id vocabulary (kick, snare, snare_xstick, hh_closed/open/pedal, tom_hi/mid/low/floor, crash_l/r, splash, china, ride, ride_bell), default GM-MIDI mappings, three preset lane configurations, and a permissivedrum_tab.jsonvalidator.lib/sloppak.py::load_songreads the manifest's optional top-leveldrum_tab:key, parses + validates the JSON, and surfaces it onLoadedSloppak.drum_tab; the load stays permissive so a missing or malformed tab silently disables drums rather than failing the sloppak load./ws/highway/{filename}gains two new message types —drum_tab(metadata + kit legend) and chunkeddrum_hits(500 hits per frame, same chunking as notes) — exposed to renderers viabundle.drumTab.song_infocarries ahas_drum_tabflag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected.lib/gp2rs.py::convert_drum_track_to_drumtabconverts a Guitar Pro drum track to adrum_tab.jsondict, preserving velocity verbatim, mapping hi-hat openness through the canonical piece-ids, and flagging flam / ghost / cymbal-choke articulations from GP effects.lib/midi_import.pygainslist_drum_tracks+convert_drum_track_from_midi(channel-9 only) with heuristic flam-collapse (≤30 ms same-piece) and choke detection (cymbal note-off ≤120 ms).docs/sloppak-spec.md§5.3 promotes drum_tab from worked-example to canonical with the closed piece-id table and wire format. Sloppaks without a drum_tab are unaffected; legacy drums-as-guitar-notes sloppaks keep playing via the drums plugin's fallback decoder. -
Loose folder support — a directory containing an audio file + arrangement XMLs, with optional
manifest.jsonand album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walksDLC_DIRfor non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows amanifest.json→ XML tags → folder-name priority chain (seelib/loosefolder.py). Songs are taggedformat: "loose", render an amberFOLDERbadge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/convert_wempipeline, cached underAUDIO_CACHE_DIR. The chart<offset>from the first non-vocals XML is now propagated to the frontend viasong_info.offsetand applied inhighway.setTime()so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companionfeedBack-plugin-loosefolderplugin which adds an in-player Fix Sync UI for nudging and saving offset corrections. -
Highway note-state hook (#254). New
highway.setNoteStateProvider(fn)lets a scorer plugin publish a per-note judgment ('hit'/'active'for a sustain currently held correctly /'miss', or{ state, alpha, color }) so the renderer lights up the gem itself on a correct hit and keeps a sustain trail glowing while it's still being played right — instead of a separate overlay ring near the note. The built-in 2D highway honors it indrawNote/drawSustains/ the chord-frame path (bright string colour + additive halo on hits, bright vs dim sustain trail, faint red wash on misses); the bundled 3D highway reads the same data viabundle.getNoteState(bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by callingbundle.getNoteState(note, chartTime). note_detect registers the provider (and still owns its HUD / diagnostic miss markers / "currently detected" indicator); renderers that ignore the hook simply don't light gems. On a confirmed hit/active the renderers add a contained "sparkle/sizzle" on the note — the 2D highway: additive throbbing halo + flickering hot core + crackling spark lines (+ an expanding shockwave ring on a fresh strike) on the gem and a glowing/jittery sustain trail; the 3D highway: a few twinkling bright dots and short crackling arc segments hugging the note's rectangle (no bloom past the note), drawn on its overlay and projected through the camera so they ride the note. Also addshighway.isDefaultRenderer()so overlays that position with the 2D-highway helpers (project/fretX) can skip rendering when a custom renderer is active — fixes note_detect's miss markers appearing in random places over the 3D highway. New 3D-highway setting Show note preview on the fretboard (on by default) toggles the board-projection ghost — the translucent preview of the upcoming note on the fretboard surface. (Note: the companion change in the note_detect plugin repo turns its full-screen green/red edge flash off by default and adds a toggle to re-enable it — ships separately with note_detect, not in this feedBack release.) -
Diagnostic bundle export (#166). New "Export Diagnostics" + "Preview Bundle" buttons in Settings produce a single redacted zip combining server logs (tail of
LOG_FILE), system info (Python/OS/version), hardware probe (CPU model + cores + freq + RAM, GPU vianvidia-smi/rocm-smi/system_profiler, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from.git/HEADso it works in minimal runtime images withoutgitinstalled) + orphan/failed-to-load detection, the browser console transcript (all levels: log/info/warn/error/debug + window.onerror + unhandledrejection, 500-entry ring buffer), browser hardware (WebGL/WebGPU adapter info, navigator + userAgentData), filtered localStorage, and per-plugin contributed diagnostics. Top-levelmanifest.jsonlists every file with its versioned schema id (system.hardware.v1,client.console.v1, etc.) so AI agents can dispatch by schema. Redaction is on by default: DLC paths, song filenames (<song:HASH8>stable per-bundle), IPv4/IPv6 addresses, bearer tokens, andkey=/token=query strings are replaced. Plugins opt their backend diagnostics in via a newdiagnosticsmanifest field (server_filesallowlist mirroringsettings.server_filessemantics, plus an optionalcallable: "<module>:<function>"resolved lazily viaload_sibling). Frontend plugins push diagnostics viawindow.feedBack.diagnostics.contribute(plugin_id, payload). Three new endpoints:POST /api/diagnostics/export,GET /api/diagnostics/preview,GET /api/diagnostics/hardware. Full bundle format spec indocs/diagnostics-bundle-spec.md. -
Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output:
LOG_LEVEL(defaultINFO),LOG_FORMAT(textfor coloured console,jsonfor one-JSON-object-per-line suitable for Loki/ELK/Promtail), andLOG_FILE(optional path, rotated at 10 MB with 5 backups). HTTP responses now include aX-Request-IDcorrelation header (viaCorrelationIdMiddleware); the same request ID appears asrequest_idin structured log lines emitted via the stdliblogging/structlogAPIs during that request. -
Structured logging migration completed (phase 2 of #155, #159, #242). The 42
print()calls and 6traceback.print_exc()calls acrossserver.pyandlib/have been migrated to levelledfeedBack.*loggers. Silentexcept: passblocks inlib/sloppak.pyandlib/sloppak_convert.pynow surface aslog.warning/log.debugwith the exception attached. WebSocket handlers (highway_ws,ws_retune) bind aws_conn_idcontextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in.github/workflows/tests.ymlfails the build if eitherprint(ortraceback.print_exc(reappears inserver.pyorlib/. -
Lyrics Karaoke plugin — end-to-end karaoke setup for Sloppak songs in one workflow. The setup screen shows a per-song checklist (vocals stem / synced lyrics / per-syllable pitch) and a single "Build Karaoke" button that runs whatever's missing: Whisper alignment of pasted lyric text against the vocals stem, then
librosa.pyinpitch extraction. Both artifacts persist inside the Sloppak (lyrics.json,vocal_pitch.json). In the player, a "Karaoke" toggle swaps the text-lyrics overlay for a horizontal pitch ribbon (one bar per syllable, vertically positioned by pitch, sweeping playhead). -
Settings export/import (#113). Two buttons on the Settings page bundle server config, browser localStorage, and opted-in plugin server-side files into a single versioned JSON file for backup, migration, or sharing a calibrated setup. Server-side import is all-or-nothing for safety-critical failures: phase-1 validates the entire bundle (schema, path-traversal, encoding) before any disk writes; phase-2 commits each file via temp+rename. Plugin-state mismatches between export and import are handled leniently: files referenced for a plugin that isn't loaded are skipped with a warning, files referenced for a plugin whose manifest no longer declares them are skipped with a warning, and localStorage is merged (not cleared) so first-run defaults from plugins installed after the export are preserved. Path-traversal, absolute paths, schema mismatch, and decode failures remain hard refusals. Plugins opt their server-side files in by declaring
settings.server_filesinplugin.json(list of relpaths underCONFIG_DIR; trailing/denotes a directory). -
Library filtering by parts present or missing (#129, #69). New right-side Filters drawer (single button next to the format/sort row, with active-filter count badge and dismissible chips below) lets you require or exclude arrangements (Lead/Rhythm/Bass/Combo), specific stems on Sloppaks (drums/bass/vocals/piano/other), lyrics, and tuning. Multi-select within an axis is OR (Lead OR Rhythm); cross-axis is AND. State persists across reloads. New endpoint
GET /api/library/tuning-namesreturns distinct tunings present in the library, ordered by musical distance. -
Sort library by year (#128). Two new options in the sort dropdown: "Year (newest)" and "Year (oldest)". Songs without a year are pushed to the bottom for both directions.
-
highway.getLyrics()accessor.createHighway()now exposes the parsed timed lyric syllables ([{t, d, w}]) viagetLyrics(), mirroringgetBeats()/getSections(), so overlay plugins can render karaoke without opening a second highway WebSocket. Pure accessor; no behavior change.
Changed
- Perf (3D highway, feedBack#226): pre-warm
plugins/highway_3d/screen.jsobject pools at board init. Previously the pool factory grew lazily on first.get()past the high-water mark, allocating a freshT.Meshmid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added tonoteG(the pool only hides onreset(), never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frameupdateStringHighlights()per-string loop with the post-callmGlow/mAccentCoreemissive writes — one walk over the per-string scratch arrays instead of two. ReplacelongestConsecutiveRun's per-call array allocations with a{start, len}index pair (trades two per-call sub-array allocations for one small 2-key object — net reduction in per-visible-chord allocation churn). Opt-in perf bench harness via?h3dbench=1URL param:console.logp50/p95/max for six update() segments every 5 seconds; when the URL flag is absent the mark helpers are bound to empty functions at renderer-instance init (eachcreateHighway()panel re-checks the flag), so the hot-path calls are no-ops with negligible overhead (typically JIT-inlined). - License: Relicensed to AGPL-3.0-only. Prior versions claimed MIT in the README, but the bundled desktop build statically links JUCE 8 (AGPL-3.0), so AGPL terms have effectively governed the desktop distribution since JUCE was added. AGPL-3.0-only is now the canonical license for the project — see LICENSE and CONTRIBUTING.md (DCO sign-off + plugin licensing policy). Bundled and vendored third-party code keeps its original license.
- Tuning sort is now ordered by musical distance from E Standard (#22) instead of alphabetical: E Standard first, then Drop D / F Standard at distance 2, then Eb Standard / F# Standard at distance 6, etc. Within a magnitude tier, down-tuned variants come before up-tuned, then alphabetical.
- Settings page restructured into separate "FeedBack" (core) and "Plugins" sections, with each plugin's settings rendered as a collapsible panel (collapsed by default). "Plugin Updates" moved into the Plugins section.
- Lyrics Sync is now a redirect stub. Its alignment + save endpoints moved into the new Lyrics Karaoke plugin alongside the pitch extraction. Existing nav entries and bookmarks land on a "moved" page that auto-redirects to the merged plugin.
Security
- Path traversal in archive extractors and library path resolution.
lib/sloppak.py::_unpack_zipandserver.py::_resolve_dlc_pathpreviously concatenated attacker-controlled entry names or filenames directly onto the extraction or library directory, so a crafted sloppak zip member or library filename with..segments, an absolute path, or backslash separators could write or read outside the intended directory. Any code path that unpacks a user-supplied archive (library upload, click-to-play, retune) or resolves a library path was reachable. Both locations now delegate to a newlib/safepath.py::safe_joinhelper that resolves each destination once and rejects entries that don't fall under the target directory; rejected entries are logged and skipped, the rest of the archive still extracts. The stem-split paths inlib/sloppak_convert.py::split_stemsandscripts/split_stems.pypreviously calledZipFile.extractall()directly on user-supplied sloppaks; both now delegate to the same hardenedlib/sloppak.py::_unpack_zipso every sloppak-unzip site in the codebase shares one containment guarantee. Tests intests/test_archive_traversal.pyandtests/test_safepath.pypin the contract for../, deep traversal, absolute paths, mixedsubdir/../../forms, Windows-style separators, NUL bytes, names that resolve to the unpack root, and symlinked roots.
Fixed
- E Standard retune now stays metadata-consistent across a chart's arrangement files (feedBack-plugin-notedetect#50). Previously the retune path could shift the audio and update manifests while leaving some arrangement metadata untouched, so
load_song()later exposed the original tuning at runtime.lib/retune.pynow updates every arrangement's tuning metadata consistently before applying the E Standard tuning, and raises on a partial update instead of silently packing split tuning metadata. EStd files generated before this fix should be re-converted so their metadata is consistent. - Keyboard shortcut help now opens from the Player/3D Highway context when Linux/Electron reports Shift+Slash as
key="/", including while player controls such as the visualization picker are focused (#598). - 3D Highway left-handed mode now has regression coverage for fret-axis mirroring, board rebuilds on runtime lefty changes, and mirrored camera state including the lookahead target and shoulder offset; the maintainer guide no longer claims the renderer ignores
bundle.lefty(#321). - Chord-level
fretHandMuteis now parsed into each note'sfret_hand_mute(wirefhm) instead of being folded intomute(mt), matching_parse_noteand preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-chordNotepaths. The 3D highway renders the fret-hand-mute X formtorfhmnotes, so the muted-chord overlay still shows. Also fixes the per-note fret-connector label vanishing exactly at the hit line (the fade now holds full opacity throughdt = 0). gp2rsnow respects the time-signature denominator when emitting ebeat subdivisions, fixing misaligned beat grids in 6/8 and other non-quarter-note meters.- Settings dropdowns (Default Arrangement, Platform Filter) now persist immediately when changed. Previously a dropdown selection was only written to
config.jsonwhen an unrelated "Save" button (Library Folder or Demucs Server) was clicked, so picking a default arrangement and navigating away silently discarded it. Both<select>controls now POST the single changed field onchangevia the partial-merge/api/settingsendpoint, matching the auto-save behaviour of the A/V Sync Offset and mastery sliders. The text inputs (Library Folder Path, Demucs Server URL) keep their explicit Save buttons. Autosaves are sent through a single client-side queue (one request in flight at a time, in selection order), and thePOST /api/settingshandler now serializes its read-modify-write ofconfig.jsonunder a lock so concurrent partial updates can no longer overwrite each other and drop a key. The config write is also atomic (temp + rename) and/api/settings/importshares the same lock, so readers never observe a half-written file and a settings import can't race a concurrent partial save. - Demucs stem split failing on Windows desktop with
OSError: Could not load this library: libtorchcodec_core4.dllorImportError: TorchCodec is required for save_with_torchcodec. The demucs subprocess now bootstraps atorchaudio.save→soundfile.writeshim before importing demucs, sidestepping the torchcodec dependency entirely. The override stays in place across torchaudio versions — soundfile's WAV writes are behaviorally equivalent for demucs's float32 outputs. - Splitscreen pop-out windows briefly flashed the library/song grid before showing the popped panel. A popup loads the full app (whose default screen,
#home, is the library) and only swaps to the player once the splitscreen plugin loads; app init now detects?ssFollower=1and switches to the player screen up front, so the popup shows player chrome the whole time. - Sloppak assembly dropped all tone data — affected sloppaks showed no signal chain in the Tones plugin and no tone-change markers on the highway. The assembler (
lib/sloppak_convert.py) now lifts each arrangement's tones from the source chart via the newlib/tones.pyhelper and embeds them inline in the arrangement JSON under atoneskey (base,changes,definitions— seedocs/sloppak-spec.md§3.9). The highway WebSocket readsbase/changesfor sloppaks, and the Tones plugin (≥ 1.1.0) readsdefinitionsto render the gear chain. Sloppaks built before this release carry no tone data and must be rebuilt from their source chart to gain it. - Tab View (feedBack-plugin-tabview ≥ 3.0.1): the bottom row of tablature was permanently hidden behind the player controls bar (#336). The overlay reserved 60px at the top (clearing the transparent HUD) and extended all the way to the bottom of
#player, where the opaque#player-controls(z-index 10) drew over the last row. The overlay now measures#player-hudand#player-controlsdynamically and insets both edges; aResizeObserveron the controls bar re-runs the inset when it wraps to a second row on narrow viewports. - Tab View (feedBack-plugin-tabview ≥ 3.0.1): the cursor highlight led playback by roughly one beat (#336). alphaTab snaps
tickPositionto the start of the next beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sendstickPositionone beat earlier so the snap lands on the current beat, and the highlight overlay tracks the bar cursor (.at-cursor-bar) instead of the next-beat cursor (.at-cursor-beat).
Migration notes
- Constitution amended to 1.1.0 (Principle II — Vanilla Frontend). Prebuilt Tailwind (
static/tailwind.min.css) is now codified as non-negotiable: no Play CDN / runtime CSS JIT anywhere, core or plugin. Plugin authors: a plugin that uses Tailwind classes not guaranteed in core — especially arbitrary values likew-[37px]— MUST ship its own compiled stylesheet via the newstylesmanifest key, built withcorePlugins.preflight = false. Plugins that use only core-guaranteed utilities, or that ship no Tailwind at all, need no change. Contributors: after adding any Tailwind class to core or a bundled plugin, runbash scripts/build-tailwind.shand commit the regenerated CSS, or thetailwind-freshCI job fails. - The library filters depend on three new columns (
stem_ids,tuning_name,tuning_sort_key) that are populated as songs are scanned. If filters look empty after upgrading, run Settings → Full Rescan to repopulate; alternatively the periodic background rescan picks them up over time.
[0.2.4] - 2026-04-22
Added
- Version badge in navbar (
/api/versionendpoint +VERSIONfile) CHANGELOG.mdand semantic versioning- Step Mode plugin
gp2midiimprovements and expanded test coverage- Note Detection plugin factory-pattern refactor with multi-instance/splitscreen support
- Per-panel note detection in Split Screen plugin with M/L/R channel routing for multi-input interfaces
Fixed
SLOPPAK_CACHE_DIRmoved toCONFIG_DIRfor AppImage compatibility- Improved error message when plugin requirements fail to install