Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Fable 5 ce25f4152e chore(career): refresh bundled bar pack to v4 (desynced anims, intro, sfx)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:03:51 +02:00
byrongamatosandClaude Fable 5 8dde3b3f3a feat(career): crowd-SFX settings toggle + sfx/intro manifest validation
Settings → System → Career panel with the 'Crowd sound reactions' toggle
(writes the localStorage key the crowd layer reads). Pack manifests may
carry sfx {up, down} mp3s, validated like intro files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 13:12:21 +02:00
byrongamatos 68e83597fe feat(career): bundle dive bar venue pack 2026-07-12 22:53:25 +02:00
166 changed files with 23137 additions and 43829 deletions
-17
View File
@@ -1,17 +0,0 @@
## What
<!-- What does this PR do, and why? Link the issue it addresses. -->
## feedpak surface
<!-- The feedpak spec is sacrosanct: the spec defines the format, this app implements it.
Delete this section ONLY if your change doesn't touch how the app reads or writes packs. -->
- [ ] This PR does **not** change how the app reads/writes feedpaks (manifest keys, pack files, folder layout)
- [ ] …or it does, and the spec change landed first via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) — FEP / spec PR: `got-feedback/feedpak-spec#___` (once it merges, re-run this PR's checks and the gate goes green)
## Checklist
- [ ] `CHANGELOG.md` `[Unreleased]` updated (user-visible changes)
- [ ] Tests added/updated for new behaviour
- [ ] Commits are DCO signed off (`git commit -s`)
-95
View File
@@ -63,18 +63,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# npm ci runs third-party postinstall scripts; don't leave the token in
# git config for them (this job never pushes).
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Rebuild Tailwind CSS
run: bash scripts/build-tailwind.sh
@@ -131,94 +124,6 @@ jobs:
print(f"Validated {len(manifests)} manifest(s) — OK")
EOF
feedpak-spec:
# Guard that core stays faithful to the feedpak format spec, which lives in
# its own repo (got-feedback/feedpak-spec) and is the contract third-party
# packers and players build against. Four surface checks: core reads/writes
# only manifest keys the spec declares (and the scanned-module list can't
# fall behind); the exception allowlist never grows, so the FEP process is
# the only way a new key lands; core ingests the spec's example packs; packs
# committed here pass the spec's reference validator. Motivated by
# #933, where a manifest key (`original_audio`) shipped in core without ever
# reaching the spec.
#
# The gate checks against the spec repo's HEAD, deliberately: the app must
# conform to the LIVING spec, always. The dev flow is self-serve — a gated
# PR opens a FEP, the spec PR merges, re-running this job goes green; no
# pin file to bump, nothing to maintain. Accepted trade-off: a BREAKING
# spec change (rare, deliberate, MAJOR per the spec's compatibility policy)
# reddens every PR here until core conforms — which is the correct
# org-wide signal that the app is out of conformance. The normal FEP is
# additive and can never redden this job.
name: feedpak-spec
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# This job runs repository code (tools/check_spec_conformance.py) and
# never pushes; don't leave the token in git config for it.
# fetch-depth: 0 so the base branch is available — the gate must prove
# the exception allowlist didn't grow in this PR.
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Check out feedpak-spec at HEAD
uses: actions/checkout@v4
with:
repository: got-feedback/feedpak-spec
ref: main
path: .feedpak-spec
persist-credentials: false
- name: Record the spec commit this run verified against
# HEAD-tracking means CI results can differ across time on the same
# commit. Log the exact spec SHA so a red run is reproducible.
run: git -C .feedpak-spec rev-parse HEAD
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# CI-only: the spec's reference validator needs jsonschema. Not a
# runtime dependency — this gate never runs on the serve/Docker path
# (constitution Principle I). Pinned for the same reason the spec SHA
# is: an upstream release must not turn this job red on a PR that
# changed neither this repo nor the spec.
pip install 'jsonschema==4.26.0'
- name: Fetch the base branch's exception allowlist
id: baseline
run: |
# The allowlist is closed: it grandfathers keys that predate this gate
# and may only shrink. Prove that by diffing against the base branch —
# without this, anyone could append an entry and route around the FEP
# process from inside this repo.
#
# Resolve the base rather than hardcoding `main`: ship-ci.yml also runs
# this workflow for PRs into release/** and for pushes to release/**,
# where a main baseline would diff against the wrong branch.
# PR -> the branch it merges into
# push -> the branch itself (its tip already contains the change, so
# this is a no-op; enforcement happens at PR time)
BASE="${{ github.event.pull_request.base.ref || github.ref_name }}"
echo "diffing the allowlist against origin/$BASE"
git fetch --no-tags --depth=1 origin "$BASE"
if git cat-file -e FETCH_HEAD:feedpak-spec-exceptions.yml 2>/dev/null; then
git show FETCH_HEAD:feedpak-spec-exceptions.yml > "$RUNNER_TEMP/baseline-exceptions.yml"
echo "args=--baseline-exceptions $RUNNER_TEMP/baseline-exceptions.yml" >> "$GITHUB_OUTPUT"
else
# Only true until the PR that introduces this gate lands.
echo "args=--bootstrap-allowlist" >> "$GITHUB_OUTPUT"
fi
- name: Check feedpak spec conformance
run: python tools/check_spec_conformance.py --spec .feedpak-spec ${{ steps.baseline.outputs.args }}
lint:
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
# dev tooling, never on the serve/Docker path — same category as
-90
View File
@@ -1,90 +0,0 @@
name: Content packs
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
#
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
# manual dispatch (not push): a media change means a new version, a human call.
on:
workflow_dispatch:
inputs:
venues:
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
required: true
default: "club"
version:
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
required: true
default: "1"
concurrency:
group: content-packs
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
# moves venue-packs/** to Git LFS.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build & publish packs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Never interpolate dispatch inputs straight into the shell — a crafted
# value would execute on the runner with this job's write token. Pass
# via env, validate the formats, and use a Bash argument array.
VENUES: ${{ github.event.inputs.venues }}
VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
read -r -a venues <<< "$VENUES"
dirs=()
for v in "${venues[@]}"; do
dirs+=("plugins/career/venue-packs/$v")
done
python tools/content_packs.py "${dirs[@]}" \
--version "$VERSION" \
--publish \
--manifest /tmp/packs-manifest.json
cat /tmp/packs-manifest.json
- name: Apply url/sha256/bytes to venues.json
run: |
python - <<'PY'
import json, pathlib
manifest = json.load(open("/tmp/packs-manifest.json"))
vpath = pathlib.Path("plugins/career/venues.json")
data = json.loads(vpath.read_text())
for v in data["venues"]:
m = manifest.get(v["id"])
if m and v.get("pack"):
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
vpath.write_text(json.dumps(data, indent=4) + "\n")
PY
- name: Open manifest-bump PR
uses: peter-evans/create-pull-request@v6
with:
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
body: |
Automated by the content-packs workflow after publishing
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
branch: content-packs/manifest-bump
delete-branch: true
+329 -639
View File
@@ -1,639 +1,329 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand
from its release when you reach the venue (sha256-verified), keeping the
starter `bar` venue bundled for offline play. Trims ~678 MB from the desktop
download; an unpublished pack shows "coming soon" and plays on the standard
stage until its release lands.
- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A
deliberately dumb JSON fan-out room: a text frame received from one client is
forwarded verbatim to every other client on the same session id; the server
interprets nothing (message schemas are owned by consumers). Rooms are created
on first join and garbage-collected when the last socket leaves — no history,
no replay, no persistence, so a host that crashes and rejoins the same id
resumes publishing to reconnecting subscribers with no server-side
coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS
hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets),
total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps —
over-limit sockets are closed with a policy code and the room carries on, and
a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout —
mid-fan-out is dropped without disturbing delivery to the rest. `main.py`
also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from
uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
frames from a host window to view-only followers on other LAN devices.
Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`.
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
carries several drum charts, a **Drum part** selector appears beside the
arrangement switcher (advanced settings) so a player can choose which drummer
to play. Selecting one re-streams that part's tab over the highway WS
(`?drum_part=<id>`, mirroring the arrangement switch); the choice persists
across an arrangement change, and the picker reflects the server's
authoritative part (unknown/absent selection falls back to the primary). The
row hides for single-drum and non-drum songs, so nothing changes there. Builds
on the loader below; no plugin change needed — the drum renderer just draws
whatever tab streams.
- **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak
loader now reads `type: drums` arrangement entries carrying per-arrangement
`drum_tab` file pointers — a song can ship several drum charts (a second
drummer, an aux-percussion layer). Parts surface as `LoadedSloppak.drum_parts`
(primary first; the entry aliasing the song-level `drum_tab:` key is the
primary and is never loaded twice), the highway WS `song_info` gains a
`drum_parts` name list, and `?drum_part=<id>` on the WS URL selects which
part's tab streams (`drum_tab` messages carry `part_id` when multiple parts
exist; unknown ids fall back to the primary). Pointer entries are **never**
loaded as fretted arrangements — the loader's file/notation gate keeps a drum
part out of the fretted pipeline (and out of note-detection grading), pinned
by test. Legacy single-drum packs read exactly as before, as a one-part list.
- **`chart-transform` capability domain (#952)** — plugins can now remap the
chart before rendering and scoring through a core-owned provider
coordinator. Synchronous transforms run after difficulty filtering; host
data is isolated from providers, accepted timelines are time-sorted, and
failures fall back to the original chart with a fixed public reason.
Effective chart arrays and metadata are available to 2D/custom renderers
and highway getters, while `getSongInfo()` retains the original metadata.
Provider selection persists and applies to primary and splitscreen highways.
- **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's
stems section gains a **Not split** shortcut that selects "lacks every instrument stem"
in one tap — the same query Stem Splitter's missing-stems view runs — instead of
cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered
five of the canonical six stems, so a piano-only song wrongly matched a hand-built
"lacks all" filter). "No lyrics" already existed in the Lyrics section.
- **Gold tier (career passports)** — an earned badge turns **gold** when
Virtuoso verifies an improvised jam in the passport's style (the
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
family's style, gained-only, and gold never substitutes for the badge bar
itself). Gold gets its own ceremony, stamp slam, foil chip, and gold ink on
the shelf cover, profile wall, and passport card; the bronze page's "Gold
rung coming" preview becomes a live invitation to jam it.
- **Gigs (the career verb, frontend)** — book a gig from any opened passport:
a gig poster proposes the setlist (re-roll for a different bill; save or
copy the poster as a PNG), "Play the gig" hands the set to the play queue
with the venue on stage, a floating strip tracks the set, and finishing it
logs dated entries with per-song accuracies in the passport book — with an
encore celebration (crowd eruption + confetti) when the whole set clears
the bar, and a summary poster to share. Quitting mid-set simply abandons
it: no log, no fail state.
- **Career on the Profile and Home pages** — the Profile gains a passport
wall (earned-badge covers per instrument, hours, gig count; absent until a
passport exists), injected through the same mount-point + rendered-event
seam the achievements plugin uses (now documented in docs/plugin-v3-ui.md).
The home page's plugin-count stat tile becomes a career trading card
(badges, hours, the closest stamp ask, foil shine) with the old stat as the
built-in fallback when career has no state. Earned passports gain **Save
card / Copy card** — a natively-drawn PNG passport card, downloadable or
copied straight to the clipboard for pasting outside the app (shared
`blob-io` helpers replace the download idiom previously duplicated in
settings-io and diagnostics-export).
- **Gigs (backend)** — career mode gains its verb: `POST
/api/plugins/career/gigs/propose` builds a playable setlist for an
instrument+genre (your qualifying songs plus a couple of stakes songs near
the bar; a young passport fills from unplayed genre songs — the first gig
is how stubs start; re-roll by calling again), naming the room your stars
can book. `POST /gigs` logs a **completed** set — per-song accuracies read
from the set's own freshly-recorded stats, an encore flag at the
data-driven bar (avg ≥ 75%) — into the career state; abandoned sets never
log (no fail state: the gig you finished is the gig you played). Passports
carry their gig log; instruments their gig count.
- **3D Highway: fret wires flash on a confirmed hit** — when a scorer (note_detect)
confirms a note through the `getNoteState` provider (feedBack#254), the fret wires
bracketing it light up. A fretted note lights the wire behind it and the wire it's
pressed against; a chord lights only the outermost wires of its shape; an open
string lights the anchor lane's edge wires (its gem is drawn as a slab spanning the
lane, so those are the wires it sits between); a chord likewise lights the lane's edge
wires — the lit lane strip can run a fret past the chord's outermost fret, and a
bracket one wire inside the lit lane reads as misaligned (the shape's own outer pair
survives only as the fallback on anchor-less charts). At most **two wires are ever lit at
once**: when overlapping decay tails (fast passages) would light a run of wires, the
flash collapses to the outermost pair of the lit span — one bracket, never a picket
fence. The gem's rim joins in: on a confirmed hit the outline flashes in the
string's own colour with the same intensity treatment as the wires, fading with the
scorer's alpha. With no scorer attached, nothing changes.
- **Background controls in the player (3D Highway)** — change the highway
background mid-song from the player's Plugin Controls popover instead of
opening Settings: a style dropdown, a Reactive toggle, and an Intensity
slider, all kept in sync with the Settings page. Controls that the active
style ignores are greyed out with a reason on hover (Custom video and
Butterchurn use neither; Custom image uses Intensity but not Reactive), so
a knob is never present-but-inert. The control disappears when a non-3D
renderer is selected. The whole group also greys out while the Venue scene
override is active, since none of the three controls reach a mounted style
in that mode.
### Changed
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
list (`[{id, url, default}]` + `full_mix_url`), the same list the highway's WS
`ready` sends. The stems plugin could only learn it from that WS message, which
arrives once the highway is already on screen — so it decoded and then copied the
whole song's PCM to its audio worklet with the player visible: over half a gigabyte
of memcpy in one frame for a 6-stem pack, a measured 698 ms freeze right as the
song-credits card appeared. With the list available at `song:loading` the plugin
does all of it before the highway is drawn. Built by calling `load_song` itself, so
it cannot drift from what the WS sends. Opt-in, so the library's metadata calls pay
nothing.
- **Folder library renders only the songs on screen** (#965) — a song list used to
render *every* song it held. On a flat 50,944-song library that was one `<div>`
with 50,938 children and ~1.3 **million** DOM nodes (~4.2 GB of renderer memory),
built even while another screen was showing. A document that size also punishes
unrelated code: any `document.querySelector` that misses has to walk the whole
tree — which is how the song-preview menu check ended up eating ~50% of the
renderer and dropping the app to 2.7 fps. Lists longer than 200 songs are now
windowed (2531 rows in the DOM instead of 50,000); shorter lists are unchanged.
- **3D Highway: fret wires read as a focus cue** — the contrast between the active
anchor lane and the rest of the neck is widened (the lane's wires brighter, the rest
dimmer), and the wires themselves are slightly thicker.
- **The full mix is a stem** (#933) — core no longer depends on `original_audio:`, a
top-level manifest key this repo invented (#583) that the feedpak spec never had.
The format already carried the pre-separation mixdown as a stem; feedpak 1.15.0
(feedpak-spec#53) reserves the id **`full`** for it, so that is where core reads it
from now.
`full` is a mixdown, not a layer — it already contains every instrument — so
`load_song()` lifts it OUT of `LoadedSloppak.stems` onto `LoadedSloppak.full_mix`.
Nothing that sums stems or renders one fader per stem can see it, which is what
makes retaining it safe; leaving it in the list would double the whole song and
leave "guitar" audible with the guitar fader muted. That trap is exactly why the
packer invented the key instead of putting the mixdown where the format says it
goes — the bug was in the reader, and this fixes the reader.
Consequences worth knowing:
- The highway WS `song_info` frame gains `full_mix_url` / `has_full_mix`.
`original_audio_url` / `has_original_audio` remain as **deprecated aliases**
(same values) for one release so a client built against the old frame keeps
working; they go with the fallback below (#945).
- `stems` on `song_info`, and `stem_ids` / `stem_count` in the library index, now
describe *instrument* stems only — a separated pack that retains its mixdown no
longer advertises a bogus "full" stem chip or an inflated stem count.
- Audio fingerprinting (`lib/enrichment.py`) now resolves the mixdown the same
way, which **widens** its coverage: it previously returned `None` for any pack
without the invented key, so fingerprinting silently did nothing for the
overwhelming majority of packs.
- Core still **reads** `original_audio:` as a deprecated fallback, because every
pack written before the spec caught up carries it and would otherwise lose its
pristine mix. `tools/migrate_full_mix_stem.py` rewrites those packs into the
spec shape (moves `original/full.ogg` → `stems/full.ogg`, adds the `full` stem
at `default: off`, drops the key); the fallback and the aliases are removed once
they are migrated (#945).
### Added
- **Genres fall back to MusicBrainz enrichment** — the effective genre now
resolves override → pack genre → the enrichment match's primary genre
(matched or user-pinned manual rows only). Converted packs rarely carry a `genres` manifest key,
which starved the library genre facet and career passports on real
libraries; with the fallback, every enriched song's genre is browsable and
passport-able immediately, and coverage grows as enrichment runs.
- **Badge ceremony in the venue** — earning a genre badge now stages a moment:
the crowd layer erupts (new public `v3VenueCrowd.celebrate()` — instant
ecstatic loop bypassing the stability/dwell hysteresis, plus a cheer stinger;
a no-op without a venue pack) and a full-screen overlay drops the bronze
stamp with a shine sweep and a confetti burst over whatever screen is active
(badges land right after `stats:recorded`, while the player is still up).
Click or wait ~4s to dismiss; `prefers-reduced-motion` gets the existing
chime + notification only. The stamp still slams into the passport book on
next open, unchanged.
- **Hours-per-genre odometer (career passports)** the app now measures real
play time: the stats recorder accrues **wall-clock** seconds across
play/resume ↔ pause/stop/end spans (wall time, not song position — position
deltas double-count A-B loops and mis-read seeks; single spans clamp at 2h
against suspend/sleep inflation) and piggybacks them as `seconds` on the
`POST /api/stats` calls it already makes. New additive
`song_stats.seconds_total` column; a seconds-only POST banks time for
unscored plays that run to the song's natural end without touching the
resume position (and still counts as playing today for the streak).
Passports surface it honestly: "14.2 h in Blues" under the badge and on the
shelf cover — a true fact that only grows, never a target or a meter.
- **Career passport drills, curated** — Bronze in blues/rock/metal/funk/jazz
now also asks for that genre's signature Virtuoso drill (Blues Shuffle,
Power Chords & Backbeat, Gallop Picking, 16th Pocket, Shell Voicings — one
per genre, data-driven in `passports.json` with display labels). Drill
lists are per-instrument (`virtuoso_nodes: {instrument: [nodes]}`; a flat
list still means guitar), so a keys passport never demands a guitar drill.
A drill counts as cleared on the first real completion artifact — a
top-tier clean pass in one key (`keysCleared`), any depth rung, or
mastery — rather than only the maxed-speed depth flips. Genres without a
curated drill stay songs-only.
- **Career passport visuals pack** — earned covers and badge stamps become
trading cards (pointer-tracked tilt + light glint, hover-capable devices
only); the ghost stamp visibly "carves in" as qualifying songs land (a
conic ink fill, no numbers added); the Gold rung preview is a small foil
chip with a shimmer sweep, still honestly labeled coming. All theatrics
disabled under `prefers-reduced-motion`.
- **Career passports (backend)** — the badge-journey layer on top of career stars.
New career-plugin endpoints: `GET /api/plugins/career/passports` (per-instrument
passport walls: genre badges computed on read from `song_stats` × the library's
effective genre — Bronze = N genre songs at K★, data-driven in
`plugins/career/passports.json`, default 5 songs at 2★ — plus qualifying-song
"ticket stubs", the library genre list, and drill status), `POST /passports/commit`
(instrument commitment), `POST /passports/open` (open a genre
passport), and `POST /drill-state` (intake for the relayed Virtuoso
`virtuoso.progress` snapshot, so drill requirements can gate badges
server-side). Badges are never stored; the only persisted state (commitments,
opened passports, drill snapshot) lives under `CONFIG_DIR/career/` and rides the
settings export/import bundle via `settings.server_files`. Instruments are
attributed via the existing progression arrangement→instrument mapping;
non-graded instruments (bass, drums) render shown-not-judged — repertoire
without a pass bar, never a false badge denial.
- **Career passports (UI)** — the Career screen gains a Passports tab beside
Venues: a physical per-instrument passport book (embossed leather cover, 3D
page-turn) with a wax-seal commitment ceremony (Stage 0), rubber-stamp badge
slam with ink bleed and deterministic per-genre jitter, qualifying songs as
collected ticket stubs, and unopened genres as an "Explore next"
travel-brochure rack (invitations, never greyed-out slots or completion
meters). Badge earns chime + notify immediately; the stamp slam plays when
the passport is next opened. Four small synthesized sound effects ship as
plugin assets. The career screen also relays the Virtuoso `virtuoso.progress`
localStorage snapshot to the drill-state intake on `virtuoso:progress` bus
events (debounced, plus a one-time bootstrap), closing the
fires-into-a-void seam without touching the virtuoso plugin.
- **CI gate: core must stay faithful to the feedpak spec (`feedpak-spec` job).** feedpak is published as
an open format with its own repo, normative spec, JSON Schemas, and reference validator — but nothing
stopped core from reading a manifest key the spec never defined, which is exactly what happened with
`original_audio` (#583 → #933). `tools/check_spec_conformance.py` now enforces four surface properties
in CI: (1) **key-coverage** — every manifest key core reads *or writes* is declared in the spec's
`manifest.schema.json`, found by walking the AST of `lib/sloppak.py`, `lib/enrichment.py`, and
`lib/songmeta.py`, `lib/gp2notation.py`, and `lib/routers/ws_highway.py` (writes are gated too — including
`setdefault()` — and reported separately: a key core writes lands in every pack we emit, so an undeclared
one seeds the ecosystem with non-spec data; a **readers-complete** guard fails the build if that module
list falls behind the codebase); (2) **allowlist-closed** — `feedpak-spec-exceptions.yml` never grows;
(3) **forward** — core's `load_song()` ingests every example pack the spec ships;
(4) **reverse** — every pack committed here passes the spec's own `tools/validate.py` (7/7 pass today).
The gate verifies against the spec repo's **HEAD** — the app must conform to the living spec, and the
flow is self-serve: a gated PR opens a FEP, the spec PR merges, re-running checks goes green. Nothing to
pin, nothing to bump. Each run logs the spec SHA it verified against so results are reproducible.
**There is no in-repo escape hatch, by design.** A blocked PR has exactly one route: land the key in the
spec via the [FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md), then
re-run the PR's checksthe gate verifies against the spec's HEAD, so it goes green once the key is real. `feedpak-spec-exceptions.yml` is a **closed
grandfather list** for keys that predate the gate, not a bypass: a fourth check (**allowlist-closed**)
diffs it against the base branch and fails any PR that *adds* an entry, so it may only shrink.
`original_audio` is grandfathered there against #933 so the gate lands green and starts blocking the
*next* instance immediately; the gate takes no position on how #933 resolves (the expected outcome is
removing the key, since the spec already carries the mixdown as a stem — not adopting it). Docs:
[docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md).
### Removed
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
every subsequent step of that migration would otherwise have to be made, and verified,
twice. Removing the fallback now halves that surface before any of it is touched.
Incidentally fixes a latent bug in the old `index()` route — its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
Principle II's frontend file list now names `static/v3/index.html`.
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
itself is no longer read; the `SLOPSMITH_*`→`FEEDBACK_*` compat shim is unaffected. No
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **GP8 asset resolution honours the directory the registry named.**
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
losslessly instead of transcoded) — but the search was not restricted to the
declared directory, so an unrelated file elsewhere in the archive that merely
shared the stem could stand in for the declared asset. That is the exact
substitution the registry lookup exists to prevent. Candidates are now
confined to the registry path's own directory; a genuinely absent asset
falls through as documented.
- **Guitar Pro 8: the right backing track is extracted when a file carries more than one.**
`BackingTrack/AssetId` is a key into the GPIF's `<Assets>` registry — `<Asset
id="0"><EmbeddedFilePath>` names the exact path inside the archive — but it
was being matched against embedded *filename stems*. GP8 names embedded audio
by hash while ids are small integers, so that match essentially never hit: every
such file warned and fell through to "first audio asset". That was silently
correct while a file carried exactly one recording — with two, a backing track
declaring id 1 resolved to asset 0, i.e. the wrong take. Resolution now reads
the registry first (verifying the path is really in the archive, so a stale
entry falls through rather than resolving to nothing), then the legacy stem
match, then the first asset.
- **Guitar Pro import no longer fails on non-ASCII song metadata (Windows).**
The GP→arrangement-XML writers wrote their output with `Path.write_text()`
and no explicit encoding, so on Windows (cp1252 default) a metadata
character like the © in an album name ("Chrysalis©1982") was written as a
lone `0xA9` byte — invalid UTF-8 — and import died with
`not well-formed (invalid token): line N, column 22`. All three arrangement
XML writes now pin `encoding="utf-8"`.
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
frames clamp to `Math.min(0, dZ(dt))`), so it read as lane with no notes on it.
The floor geometry now ends at the hit line; its far edge is unchanged, still
`-AHEAD*TS` at the note horizon.
- **Career passports review polish** — the passport tabs and book overlay carry
proper ARIA semantics (`aria-selected`/`aria-controls`/`tabpanel`;
`role="dialog"` + `aria-modal` with focus moved to the close button on open
and restored on close), and a corrupt stored seen-badges value (e.g. a stray
`"null"`) can no longer throw on every passport refresh.
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
`static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in
R3 shipped correctly in Docker and passed every test, and were then silently dropped
from the packaged app, which died at startup. Both now live under **`lib/`** — the one
core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on
Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no
change in feedback-desktop and no new release to take effect. Placing them there is also
correct under Principle V: with the injection seam, `appstate.py` constructs nothing and
does no import-time IO, and a route module only builds an `APIRouter`. The
`Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout
are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and
fails if any first-party module resolves outside a directory the packagers copy, so the
next root-level module can't ship broken.
### Added
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
where they used to be defined** — FastAPI matches routes in registration order, so the
mount site preserves it. Verified: the full 143-route table (paths, methods, *and*
order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are
the decorator receiver (`@app.get` → `@router.get`) and the singleton read
(`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute
resolved at call time). This proves the seam from #833 under a real consumer, including
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
routes with 403, and `Query(...)` validation still 422s — both checked against a running
server. `server.py`: **9,445 → 9,386 lines**.
- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988.
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
need `meta_db` and friends but must not `import server`, or the import graph goes
circular the moment `server` imports them back. So `server.py` keeps *constructing*
its singletons and now **injects** them once — `appstate.configure(meta_db=…,
audio_effect_mappings=…)` — and a router reads them back as module attributes at call
time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the
frontend refactor's injected `configureX({…})` seams and of the plugin
`setup(app, context)` contract: dependencies flow one way, `server → routers →
appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`:
(1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures
that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched
`CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive
that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never
`from appstate import meta_db`), since a `from` import freezes the binding and defeats
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
### Changed
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
(R3, move-only).** The core-owned song/tone → provider routing index follows
`MetadataDB` out of the host file, byte-identical apart from the same constructor
seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`),
so the module does no IO at import. The singleton stays in `server.py`; no route,
no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`:
**9,705 → 9,433 lines**.
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
and every route is untouched. The only non-verbatim change is the seam that lets the
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
subject); no other test changed. Every moved block is byte-identical to its
`server.py` original.
### Added
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 15 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
their bars.** The keys/drums note converters always computed a tempo-aware
tick→seconds map internally (to bake note times to absolute seconds) and then threw
it away — and never read `time_signature` meta at all — so every MIDI import landed
with no measures and an implied 4/4 regardless of what the file said. The new helper
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
timelines must never share a grid); mid-bar signature events apply at the next bar
boundary; times are computed from absolute ticks through the cumulative tempo table
and rounded once at emit, so rounding error never accumulates with song length.
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
`tests/test_midi_tempo_map.py`.
### Fixed
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 01, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 01) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
### Fixed
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
### Added
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()` → `{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (01, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 01 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 01 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
- **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass.
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
Library group, just below Songs — via the existing `PROMOTED_PLUGINS`
mechanism in `static/v3/shell.js`, instead of being reachable only through
the generic Plugins gallery. Gated on the plugin actually being installed
(`renderPromotedNav` checks `/api/plugins`), so it appears only when the
editor is loaded. The displayed label comes from the plugin's manifest
`nav.label`.
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for 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 carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (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 written `dot: 2` agrees 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 a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) 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 carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before 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 `.sloppak` files 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`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. 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.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; 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.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[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 when `notation:` 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 chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag 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. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (feedBack#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-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 in `lib/sloppak.py` reads and validates the file (must be a dict with `beats` and `sections` as lists), clears and repopulates `Song.beats` / `Song.sections` from it when present, and stores the raw dict on `LoadedSloppak.song_timeline`. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omit `song_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 in `song_timeline.json` only. See `docs/sloppak-spec.md` §2 and §5.3.
- **`note-detection` capability domain promoted — control plane (spec 009)** (feedBack#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.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-coupled `highway.setNoteStateProvider` surface 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.
- **`visualization` capability domain promoted (cap:6)** (feedBack#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-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.js` attributes 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.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on 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 attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, 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` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport` → `{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
- **v3 Songs AZ rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps** — `pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen 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 receiving `draw()` through its own override-hide. The `highway:visibility` event 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.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
### Changed
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 712 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
### Removed
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
### Added
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer 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-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — 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 the `notedetect:skin` bus 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-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.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_3d` v3.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_3d` v3.26.0.
- **Enable/disable plugins from the v3 Pedalboard (footswitch backend).** Every `/api/plugins` entry now carries an `enabled` boolean (default `true`), and a new `POST /api/plugins/{plugin_id}/enabled` endpoint (`{"enabled": <bool>}` → `{"id", "enabled"}`) persists the choice to `CONFIG_DIR/plugin_state.json` (only non-default `enabled:false` entries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader **skips disabled plugins at startup** — no requirements install, no `routes.setup()`, no screen/nav/capabilities — while still surfacing them in `/api/plugins` as 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/plugins` reflects 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 keep `capability_inspector` and `app_tour_*` always enabled (disable → `400`); unknown id → `404`; missing/non-boolean `enabled` → `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`, and `FEEDBACK_*` env vars all keep the `feedBack` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `FEEDBACK_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/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 (`#home` library, `#favorites`, `#settings`, `#player`, `#audio`, plugin nav containers) are kept verbatim so `static/app.js` boots **unmodified** and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the shared `window.showScreen` across `#v3-*`, reused legacy, and `#plugin-*` screens, with a responsive hamburger and a `localStorage`/`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.js` wraps `window.showScreen` via 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_profile` tables in `web_library.db`, additive + idempotent): display name + avatar, a stable `player_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 under `CONFIG_DIR/avatars/`), `GET /api/profile/avatar/{name}` (safe-joined), `GET /api/profile/avatars` (bundled defaults under `static/v3/avatars/`), `GET /api/profile/progress` (one call for the badge), and `POST /api/xp/award`. **Unified XP:** `lib/xp.py` is the single XP curve (same math the minigames plugin shipped); the core `xp_profile` store is the one source of truth the profile badge reads, exposed to plugins via `context["award_xp"]`/`get_xp_progress`/`seed_xp`. The bundled **minigames** plugin now delegates XP to the core store (seeding once from its existing `profile.json` so 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-profile` screen. 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_stats` table (`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 award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `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.js` tallies the `note:hit`/`note:miss` events the optional `feedBack-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), 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 via `lib/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_songs` tables in `web_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`, and `GET /api/session/continue` (derives the resume song + last position from `song_stats`, no new table). Frontend `static/v3/playlists.js` renders the `#v3-playlists` list + detail (drag-reorder, play, remove) and `#v3-saved`, and exposes `window.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-home` dashboard (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 via `playSong` + best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from `/api/library/stats`, plugins count from `/api/plugins` where `status==="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/settings` fields (`reference_pitch` clamped 430450, `instrument`, `string_count` 48, `tuning` name or semitone offsets); changing it emits `instrument:changed` so 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-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `feedBack-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-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 touches `audio-mixer.js` internals or `nam_tone` routes directly; "Not Connected" is the honest browser default (no native route), and it degrades on `no-owner`/`no-handler`/`failed` or absent capabilities. Refreshes on `instrument: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-plugins` screen 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 the `library` capability, 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 new `GET /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-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.feedBack.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-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 existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
- **`centOffset` exposed via `getSongInfo()`** — the arrangement `<centOffset>` field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent as `centOffset` in the `song_info` WebSocket message. Plugins can read `getSongInfo().centOffset` to obtain the arrangement's pitch-shift offset — commonly `-1200.0` for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to `0.0` when absent.
- **`highway.getPhrases()` and `highway.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. Returns `null` when the song has no phrase data (GP imports, single-difficulty charts). Pair with the existing `hasPhraseData()` to gate phrase-aware code paths.
- **Tailwind freshness guard + wider plugin scan.** A new `tailwind-fresh` CI job (`.github/workflows/tests.yml`) rebuilds `static/tailwind.min.css` with the pinned `tailwindcss@3.4.19` and 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). The `tailwind.config.js` plugin content glob is widened to `./plugins/**/*.{js,html}`, which also scans non-`screen.js` plugin 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 plugin `styles` capability (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/plugins` now exposes validated capability declarations, validation warnings, unsupported-version metadata, UI/runtime domain declarations, and compatibility shim summaries for legacy `nav` / `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 coordinated `stems` diagnostics 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.session` coordinates `stems` without replacing the Stems plugin as the owner of actual stem playback/state.
- **Audio-mix control plane** — makes `audio-mix` the player mixer source of truth. Core now exposes `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, and `inspect-analyser` through 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-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations 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-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, 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 `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps 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 for `window.playSong`, legacy `song:*` events, `window.feedBack` transport helpers, loop helpers, and browser/native route handoff.
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains 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, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` 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.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`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 unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`feedBack-plugin-song-preview`](https://github.com/got-feedback/feedBack-plugin-song-preview) to 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 own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`feedBack-plugin-minigames`](https://github.com/got-feedback/feedBack-plugin-minigames) repo into the core bundle so every FeedBack install gets the framework out of the box (same promotion path used for `highway_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 under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.feedBackMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), 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`](https://github.com/got-feedback/feedBack-plugin-flappy-bend), shipped separately.
- **Alpha-build heads-up banner** — when `/api/version` reports 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) and `bell` (MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) to `lib/drums.py` PIECES. Inserted in the iteration order so the editor / highway lane ordering is *hi-hat → stack → crash → … → ride bell → bell*. Both are cymbals; default shape `circle_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`) and `convert_drum_track_from_midi` (`lib/midi_import.py`) gain an optional keyword-only `out_unmapped` parameter. 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.py` defines 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 permissive `drum_tab.json` validator. `lib/sloppak.py::load_song` reads the manifest's optional top-level `drum_tab:` key, parses + validates the JSON, and surfaces it on `LoadedSloppak.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 chunked `drum_hits` (500 hits per frame, same chunking as notes) — exposed to renderers via `bundle.drumTab`. `song_info` carries a `has_drum_tab` flag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected. `lib/gp2rs.py::convert_drum_track_to_drumtab` converts a Guitar Pro drum track to a `drum_tab.json` dict, 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.py` gains `list_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.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `feedBack-plugin-loosefolder` plugin 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 in `drawNote` / `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 via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.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 adds `highway.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 via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + 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-level `manifest.json` lists 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, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.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 in `docs/diagnostics-bundle-spec.md`.
- Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output: `LOG_LEVEL` (default `INFO`), `LOG_FORMAT` (`text` for coloured console, `json` for one-JSON-object-per-line suitable for Loki/ELK/Promtail), and `LOG_FILE` (optional path, rotated at 10 MB with 5 backups). HTTP responses now include a `X-Request-ID` correlation header (via `CorrelationIdMiddleware`); the same request ID appears as `request_id` in structured log lines emitted via the stdlib `logging` / `structlog` APIs during that request.
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `feedBack.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
- **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.pyin` pitch 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_files` in `plugin.json` (list of relpaths under `CONFIG_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-names` returns 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}]`) via `getLyrics()`, mirroring `getBeats()`/`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.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'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=1` URL param: `console.log` p50/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 (each `createHighway()` 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](LICENSE) and [CONTRIBUTING.md](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_zip` and `server.py::_resolve_dlc_path` previously 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 new `lib/safepath.py::safe_join` helper 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 in `lib/sloppak_convert.py::split_stems` and `scripts/split_stems.py` previously called `ZipFile.extractall()` directly on user-supplied sloppaks; both now delegate to the same hardened `lib/sloppak.py::_unpack_zip` so every sloppak-unzip site in the codebase shares one containment guarantee. Tests in `tests/test_archive_traversal.py` and `tests/test_safepath.py` pin the contract for `../`, deep traversal, absolute paths, mixed `subdir/../../` 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.py` now 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 `fretHandMute` is now parsed into each note's `fret_hand_mute` (wire `fhm`) instead of being folded into `mute` (`mt`), matching `_parse_note` and preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-`chordNote` paths. The 3D highway renders the fret-hand-mute X for `mt` *or* `fhm` notes, 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 through `dt = 0`).
- `gp2rs` now 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.json` when 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 on `change` via the partial-merge `/api/settings` endpoint, 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 the `POST /api/settings` handler now serializes its read-modify-write of `config.json` under 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/import` shares 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.dll` or `ImportError: TorchCodec is required for save_with_torchcodec`. The demucs subprocess now bootstraps a `torchaudio.save` → `soundfile.write` shim 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=1` and 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 new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to 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-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on 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 `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one 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 like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.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, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI 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/version` endpoint + `VERSION` file)
- `CHANGELOG.md` and semantic versioning
- Step Mode plugin
- `gp2midi` improvements 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_DIR` moved to `CONFIG_DIR` for AppImage compatibility
- Improved error message when plugin requirements fail to install
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Removed
- **The classic v2 UI shell is gone — v3 is the only UI (R3a).** `static/index.html`, the
`/v2` route, and the `FEEDBACK_UI` v2/legacy opt-out are deleted; `/` and `/v3` both serve
`static/v3/index.html`, which has been the default since 0.3.0. This is the first step of
the core-frontend ES-module migration (R3a): both shells load the same `static/app.js`, so
every subsequent step of that migration would otherwise have to be made, and verified,
twice. Removing the fallback now halves that surface before any of it is touched.
Incidentally fixes a latent bug in the old `index()` route — its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value, so `FEEDBACK_UI=v3` actually served
the **v2** shell. `static/tailwind.min.css` is regenerated (the content globs scanned the
deleted file, so v2-only utility classes are now purged). Constitution amended to 1.3.0:
Principle II's frontend file list now names `static/v3/index.html`.
**Migration notes:** if you set `FEEDBACK_UI=v2` (or `=legacy`), or bookmarked `/v2`, there
is no longer a classic shell to fall back to — unset the variable and use `/`. The env var
itself is no longer read; the `SLOPSMITH_*``FEEDBACK_*` compat shim is unaffected. No
chart, settings, or plugin data changes, and no plugin API changes: v3 reuses the same
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **The packaged desktop app could not start (`ModuleNotFoundError: No module named
'appstate'`).** feedback-desktop's `scripts/bundle-slopsmith.sh` copies a *hardcoded
list* of core files into the app bundle — `server.py`, `VERSION`, `lib/`, `data/`,
`static/`, `plugins/__init__.py`. The root-level `appstate.py` and `routers/` added in
R3 shipped correctly in Docker and passed every test, and were then silently dropped
from the packaged app, which died at startup. Both now live under **`lib/`** — the one
core directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
bundler (`cp -r lib`) all copy wholesale, and that all three put on `sys.path` (on
Windows via the embeddable-Python `._pth`, where `PYTHONPATH` is ignored). This needs no
change in feedback-desktop and no new release to take effect. Placing them there is also
correct under Principle V: with the injection seam, `appstate.py` constructs nothing and
does no import-time IO, and a route module only builds an `APIRouter`. The
`Dockerfile` / `.dockerignore` / `docker-compose.yml` entries added for the root layout
are reverted. New `tests/test_packaging.py` walks `server.py`'s module-level imports and
fails if any first-party module resolves outside a directory the packagers copy, so the
next root-level module can't ship broken.
### Added
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
endpoints move out of `server.py` into `lib/routers/audio_effects.py` as a
`fastapi.APIRouter`, mounted with `app.include_router(...)` **at the point in the file
where they used to be defined** — FastAPI matches routes in registration order, so the
mount site preserves it. Verified: the full 143-route table (paths, methods, *and*
order) is byte-for-byte identical to `main`. Bodies are verbatim; the only edits are
the decorator receiver (`@app.get` → `@router.get`) and the singleton read
(`audio_effect_mappings` → `appstate.audio_effect_mappings`, a module attribute
resolved at call time). This proves the seam from #833 under a real consumer, including
the second slot. The `_demo_mode_guard` middleware still blocks all four moved write
routes with 403, and `Query(...)` validation still 422s — both checked against a running
server. `server.py`: **9,445 → 9,386 lines**.
- **`lib/enrichment.py` — the metadata-enrichment subsystem leaves `server.py` (R3, move-only).** MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and the background enrichment worker (~930 lines, 61 defs) move out as one cohesive unit. Bodies are verbatim; the only changes are seam reads — `meta_db`/`config_dir`/`sloppak_cache_dir`/`art_cache_dir` and the two shared art helpers (`song_pack_art_exists`, `art_override_paths`, which stay in `server.py` for the art/delete routes) are reached through `appstate` at call time, and the User-Agent VERSION lookup is corrected for the module's new depth. `server.py` drives the worker through the module (`import enrichment`; the routes + scan lifecycle call `enrichment.X`); tests that faked the network on `server` now patch the same names on `enrichment` (module attribute resolved at call time, so one `setattr` reaches both the routes and the worker's internal callers). Acyclic — `enrichment` imports no `server`. Route table byte-identical; full suite green. `server.py`: 6,917 → 5,988.
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
need `meta_db` and friends but must not `import server`, or the import graph goes
circular the moment `server` imports them back. So `server.py` keeps *constructing*
its singletons and now **injects** them once — `appstate.configure(meta_db=…,
audio_effect_mappings=…)` — and a router reads them back as module attributes at call
time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the
frontend refactor's injected `configureX({…})` seams and of the plugin
`setup(app, context)` contract: dependencies flow one way, `server → routers →
appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`:
(1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures
that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched
`CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive
that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never
`from appstate import meta_db`), since a `from` import freezes the binding and defeats
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
### Changed
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
(R3, move-only).** The core-owned song/tone → provider routing index follows
`MetadataDB` out of the host file, byte-identical apart from the same constructor
seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`),
so the module does no IO at import. The singleton stays in `server.py`; no route,
no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`:
**9,705 → 9,433 lines**.
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
and every route is untouched. The only non-verbatim change is the seam that lets the
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
subject); no other test changed. Every moved block is byte-identical to its
`server.py` original.
### Added
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 15 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
their bars.** The keys/drums note converters always computed a tempo-aware
tick→seconds map internally (to bake note times to absolute seconds) and then threw
it away — and never read `time_signature` meta at all — so every MIDI import landed
with no measures and an implied 4/4 regardless of what the file said. The new helper
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
timelines must never share a grid); mid-bar signature events apply at the next bar
boundary; times are computed from absolute ticks through the cumulative tempo table
and rounded once at emit, so rounding error never accumulates with song length.
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
`tests/test_midi_tempo_map.py`.
### Fixed
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
### Added
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 01, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 01) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing OkabeIto "Colorblind-friendly" preset — contributed by a deuteranopic player who found the OkabeIto set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
### Changed
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
### Fixed
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
### Added
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()` → `{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).
- **3D Drum Highway: real materials + scene themes (parity slice 3).** The scene gets **image-based lighting**: a procedural PMREM "studio" environment (dark room + three emissive light strips — cool overhead key, warm/cool side fills; no vendored-addon dependency) feeds `scene.environment`, so the cymbals' metalness **finally reads as metal** (retuned to roughness 0.2 / metalness 0.85 / envMapIntensity 1.2 — the old 0.7-metalness look was matte because there was nothing to reflect), drumheads get a satin sheen, and the floor (roughness 0.95 → 0.7) catches the strips without turning into a mirror. **Scene themes arrive** — the same 11 theme names as the guitar highway (Midnight, Charcoal, Deep Purple, Forest, Warm Slate, Deep Focus, Deep Sea, Cathode, Cathode Green, Hearth) retint the background/fog, floor and lane stripes so your look carries across instruments; `default` preserves the original drum palette byte-for-byte, and piece colours stay with the existing Palette picker (themes own the scene, palettes own the kit). Plus **Cinematic lighting** (dimmer ambient / stronger key, on by default), a **Glow strength** slider (01, 0.5 = stock) multiplying every emissive base — notes, hit line, snare wires — and a **Lane vibrancy** slider driving stripe/halo/ghost-ring strength (the hit-FX approach highlight stacks on top). Everything applies live from the plugin settings (`drum_h3d_bg_theme` + `drum_h3d_bg_*` keys); the PMREM render target is rebuilt across kit-change renderer recreation and disposed in both teardown paths — and the floor/hit-bar geometry+materials that previously leaked on every kit change are now tracked and disposed too. Tests: theme-table parity with the guitar ids + default-look preservation + fallbacks (13 total).
- **3D Drum Highway: hit FX — sparks, timing-colored lane flashes, kick camera pulse, approach glow, and open hi-hat notation (parity slice 2).** Striking a pad now *feels* struck: a pooled additive **spark burst** fires at the lane (ported from the guitar highway's Points-cloud system, pool 160), colored by **timing** — on-time green, early cyan, late amber (same `_timingHex` vocabulary as `highway_3d`, classified against the ±50 ms hit window with the inner 40% reading as on-time); with **Streak feedback** on, bursts grow with your combo. The **lane flash** feedback that was removed when note-recoloring landed is resurrected properly: pooled additive quads with a soft gaussian falloff light up the struck lane at the hit line (timing-colored; red for wrong-pad hits), and a **kick** hit fires triple amber bursts across the bar plus a subtle **camera dip + amber floor wash** that decays exponentially. Lanes also glow ahead of time: each stripe brightens as its next note approaches the hit line, so the eye is led to where the next hit lands. **Open hi-hat finally renders distinctly** — `hh_open` chart hits get a thin warm ring around the cymbal gem (standard notation's "o"), closing the long-standing TODO; the flag is orthogonal to accents/ghosts/flams so combined cues stack. All of it is settings-gated (Graphics → Hit sparks / Timing colours / Streak feedback / a 01 **Hit feedback intensity** slider driving flashes, approach glow and the kick pulse; everything on by default, `drum_h3d_bg_*` keys, live-applying) and GPU-frugal: every new visual is pooled or shares geometry/materials — zero per-note allocation on top of the per-frame notes rebuild, all registered in both dispose paths (kit-change renderer recreation included). Tests: timing-classifier boundaries + FX defaults added to `plugins/drum_highway_3d/tests/data_layer.test.js` (10 total).
- **3D Keys Highway: hit FX — vibrant note gems, timing-colored sparks, and a hit-line that reacts to your playing (parity slice 2).** The washed-out note look is gone: gem opacity is now driven by a **Note vibrancy** slider (default 0.85 → opacity 0.92, up from a fixed 0.8; lane guides scale with it too, live-applying without a chart rebuild) and the resting emissive glow rises 0.08 → 0.22, so the falling notes finally read saturated against the dark floor. Scored key presses fire a pooled additive **spark burst** at the struck key (guitar-highway port, pool 96) **colored by timing** — on-time green, early cyan, late amber, classified against the ±100 ms window with the inner 40% reading as on-time (the timing delta is recovered from the matched note's key, so `judgeHit`'s tested contract is untouched); the per-pitch-class flame sprite keeps its identity color so pitch and timing stay separate signals. With **Streak feedback** on, bursts grow with the combo. The **hit line kicks brighter** for a beat on every scored press (exponential decay, scaled by a 01 **Hit feedback intensity** slider). All new controls live in the plugin's Graphics settings (on by default, `keys3d_bg_*` keys, live-applying), and the spark pool is disposed with the scene like every other GPU resource. Tests: timing-classifier boundaries, the noteKey time round-trip that the delta recovery relies on, and the new FX defaults (26 total).
- **3D Drum Highway: bloom glow + adaptive-resolution support — the first slice of visual parity with the guitar highway.** The drum highway now renders through the same post-processing path as `highway_3d`: an `UnrealBloomPass` (strength 0.65, radius 0.5, threshold 0.82 — high, so only emissive/bright surfaces bleed) on a multisampled HalfFloat target with ACES filmic tone mapping, so the white hit-line bar and proximity-lit notes get a real glow instead of a flat emissive tint. **On by default**, with a new **Graphics → "Glow (bloom)"** toggle in the plugin settings (`drum_h3d_bg_bloom`, applies live, no reload); if the vendored postprocessing addons can't load (older self-hosted core), the plugin silently falls back to the direct render path. The plugin also now honors the host's **adaptive render scale** (`bundle.renderScale` — the Quality/"Min res" controls that the guitar highway already respected), multiplying it into the device pixel ratio, and caps DPR at 1.25 when more than one viz instance is live (splitscreen) so two panels don't double the GPU fill cost. Groundwork for the rest of the parity series: an FX-settings scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.drumH3dSetFx`, `drum_h3d_bg_*` localStorage keys) that the sparks/themes/backgrounds PRs extend, plus a first node test suite for the plugin (`plugins/drum_highway_3d/tests/data_layer.test.js` — vm-loaded like the keys plugin's, covering the hit-variant precedence, the Auto-mode steal-guard predicate, and FX defaults; 8 tests, runs in CI via the `plugins/*/tests/*.test.js` glob).
- **3D Keys Highway: sharp HiDPI rendering, bloom glow, a live combo HUD, and a graphics settings panel — the first slice of visual parity with the guitar highway.** The biggest single fix is resolution: the plugin never called `setPixelRatio`, so on HiDPI/retina displays (and Windows display scaling) it rendered at CSS resolution and was upscaled — soft and aliased. It now multiplies the device pixel ratio (capped at 2, or 1.25 when two viz panels are live in splitscreen) with the host's **adaptive render scale** (`bundle.renderScale`, the Quality/"Min res" controls), exactly like `highway_3d`. On top of that: the same **bloom** post-processing path as the guitar highway (UnrealBloomPass 0.65/0.5/0.82 on a multisampled HalfFloat target + ACES tone mapping — the cyan hit-line, hit flames and the sustain "consume" glow finally bleed light instead of reading flat), **on by default** with a graceful direct-render fallback when the vendored addons can't load. The plugin gains its first **settings panel** (`settings.html`, Settings → graphics category, `"settings"` block in plugin.json) with a live-applying "Glow (bloom)" toggle (`keys3d_bg_bloom`), plus the FX scaffold (`FX_DEFAULTS`/`readFxSettings`/`window.keys3dSetFx`, `keys3d_bg_*` keys) the later parity PRs extend. And the score state the plugin was already tracking is finally visible: a **combo / accuracy / best-streak HUD** overlay (drum-highway pattern), shown only while a MIDI keyboard session is wired so it never renders a frozen 0× combo. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (defaults, localStorage overrides + type coercion, setter persist/dispatch/unknown-key guard; 3 tests alongside the existing 20).
- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins/<id>/` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged.
- **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass.
- **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end).
- **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins).
- **Host "working tuning" — a live, app-wide record of what tuning your instrument is *actually* in right now (foundation; no behavior change yet).** Introduces `window.feedBack.workingTuning`, a host-owned, session-lived state distinct from any one song's tuning and from a soft opt-in default: the offsets + string-count + reference pitch the player's instrument is currently in, plus an `assumed`/`verified` provenance flag. It's **per-instrument** — your guitar's current tuning and your bass's are kept *separately* (keyed like the instrument selector, e.g. `guitar-6` / `bass-4`), so switching instruments surfaces that instrument's own remembered tuning and you only ever deal with the one you've selected. It exists so a retune — or an instrument swap mid-session — is reflected **everywhere** (the highway, the library/song-picker, and plugins like the tuner, Virtuoso, and the minigames) instead of being re-derived per surface or wrongly assumed from a fixed profile. Modeled on the shipped `tuning` capability + the `feedBack.theme` read-API: a **synchronous `get(instrument?)`** (returns the selected instrument's state, defaulting to the seed until known), a `set(state, {provenance, instrument})` mutator (the tuner becomes the sole writer in the next change), `setCurrentInstrument()` for the selector, `resetToDefault()`, and a `working-tuning-changed` event that fires on every change **and once on hydration** (carrying which instrument changed) so a late-mounting consumer is never stuck on stale state. State is **in-memory, seeded from `/api/settings` on boot and reset on restart** — a stale "you're in drop-A" assumption is worse than re-asking. Registered as a separate `working-tuning` **exclusive-owner** capability (tuner = writer, the rest = requesters). This is the foundation (plumbing only — nothing writes to it yet) of the working-tuning series, which fixes the tuner gate only ever prompting *away from* a fixed "home" tuning (never back) and makes the current tuning a first-class signal the whole app shares. Offsets use the same per-string semitone vocabulary as song tunings, so fully custom/extended tunings (e.g. a drop-A 8-string) are first-class. Frontend-only: new `static/capabilities/working-tuning.js`, loaded from `static/index.html` + `static/v3/index.html`.
- **The v3 Songs grid is now DOM-virtualized — card-node count stays bounded no matter how big the library is or how far you scroll.** The grid used to append every scrolled page and never let go, so a 2000-song library grew the DOM from 24 → 624 → 2001 card nodes as you scrolled (layout/memory cost scaling with depth). It now renders only the **visible window** of cards (± a small overscan); a sizer element sized to the whole library (`ceil(total/cols) × rowH`) gives the scrollbar its full geometry while the grid is absolutely positioned to the first visible row. `state.songs` is a sparse, absolutely-indexed store fetched a page at a time on demand — using the stage-1 **keyset cursor** for contiguous forward scroll (O(page)) and falling back to `OFFSET page=` for jumps/restore/non-keyset providers (collections, remote). Verified bounded (~60 nodes for a 2001-song library while the count still reads "2001 songs"). The **AZ rail now seeks directly**: `sort_letters` gives a letter's first-row index (cumulative of prior buckets), converted to a scrollTop in O(1) — no more paging through every intervening row (a bounded forward scan covers the rare legacy provider without `sort_letters`). Select-mode selections, accuracy badges, the ⋮ card menu, plugin card actions, scroll-restore (now scrollTop-based, since geometry is stable), and the tree/folder views all survive cards leaving and re-entering the DOM. Plugins that decorate cards get a stable `window.v3Songs.visibleCards()` accessor + a `v3:library-window-rendered` event instead of assuming every card is present (the highway-stutter lesson). Stage 2 of the virtualized-grid project (got-feedback/feedBack#636 item 3), building on the stage-1 keyset data layer below. Frontend-only: `static/v3/songs.js`, `static/v3/v3.css`. Tests: `tests/browser/v3-grid-virtualization.spec.ts` (bounded-DOM invariant across a 2001-song scroll + direct rail jump), updated `tests/js/v3_az_rail.test.js` + `tests/js/v3_songs_scroll.test.js`.
- **Keyset (cursor) pagination for the library grid — the data layer for an upcoming virtualized grid, and a latent paging bug fixed along the way.** Every library sort now carries a unique `filename` tiebreak, making the order **total** — which fixes a latent bug where rows sharing a sort key (e.g. two songs by the same artist) could be skipped or duplicated across `OFFSET` pages. `GET /api/library` gains an opaque `after` cursor + a `next_cursor` in the response: passing the cursor back fetches the next page with a **WHERE-seek** instead of `OFFSET`, so deep paging is O(page) regardless of depth. The seek is NULL-aware and exactly `OFFSET`-equivalent (verified across artist/title/recent, ascending + descending, including the legacy `dir=desc` shape and NULL sort keys); unknown/compound sorts and bad cursors fall back to `OFFSET`, and only the local provider is handed a cursor (collections/remote page by `OFFSET`). New composite `(artist NOCASE, filename)` / `(title NOCASE, filename)` / `(mtime, filename)` indexes cover the order. This is stage 1 of the virtualized-grid project (got-feedback/feedBack#636 item 3); the DOM-recycling render window builds on it next. Tests: `tests/test_library_keyset.py` (keyset==OFFSET parity, stable tiebreak, dir=desc, NULL keys, cursor fallback).
- **Smart collections — save a set of library filters as a live, auto-updating source.** A collection is a saved `/api/library` query (e.g. "Drop-D tunings", "sloppak only", "recently added") that stays live: it's registered as a **library provider**, so it shows up in the v3 Songs source picker and inherits the whole grid UI — paging, stats, the AZ rail, art — for free, with **no new screen**. Storage reuses the playlist subsystem (a `playlists.rules` JSON blob = a smart collection; membership is the live filter result, not stored songs, and collections are excluded from the manual-playlist list + read-only to playlist mutations). New `GET`/`POST`/`PUT`/`DELETE /api/collections`; a per-collection `SmartCollectionProvider` delegates `query_page`/`query_stats`/`query_artists` to the local DB with the stored rules applied; providers are re-registered from a boot scan so collections survive a restart. Rules mirror the raw `/api/library` query params (unknown keys dropped, never 500). Frontend: a " Save as collection" action in the v3 filter drawer (shown when filters are active) names the current filter set and switches to it. The charrette's "the homelab primitive FeedBack was missing" pick (got-feedback/feedBack#636 item 2); richer rule fields (accuracy, genre, difficulty) follow as the metadata work lands. Tests: `tests/test_collections_api.py`, `tests/js/v3_collections.test.js`.
- **The settings backup now includes your library database + custom art — your scores, favorites, playlists, and play history are no longer the one thing a backup can't save.** `GET /api/settings/export` gains an additive `core_server_files` section carrying a **consistent snapshot of `web_library.db`** (taken via the SQLite online-backup API, so it's a complete single file even while the server is running) plus any custom **playlist covers** and **avatar** (`CONFIG_DIR/playlist_covers/`, `CONFIG_DIR/avatars/`). On `POST /api/settings/import` the database is **staged** to `web_library.db.restore` rather than written over the live, open DB; it's swapped in at the next startup (`_apply_pending_db_restore`, before the connection opens), which also clears the old WAL sidecars so a stale `-wal` can't be replayed onto the restored file — the import response sets `restart_required: true` and warns accordingly. Custom art is written immediately. The bundle stays backward-compatible (older servers ignore the new section). Came out of the library design charrette (dev-ops lens's top "protect irreplaceable data" pick, got-feedback/feedBack#636). _Known gap:_ custom uploaded **song** art is still commingled with the rebuildable thumbnail cache in `art_cache/`, so it isn't bundled yet (a tracked follow-up). Tests: `tests/test_settings_export_library_db.py` (snapshot consistency, staged-not-live restore, sidecar clearing, traversal rejection, full round-trip).
- **A persisted wishlist — keep a list of songs you want but don't own yet.** New `wanted` table + `GET`/`POST`/`DELETE /api/wanted` give FeedBack the *arr-style "Wanted/Monitored" primitive it was missing: an entry is a *not-owned* song (artist/title/source/source_ref/note), so it lives in its own table rather than the playlist subsystem (which references owned local files). The API is idempotent on identity (case-insensitive artist+title, plus source+source_ref), so a producer — the `find_more` ownership-diff, or a manual add — can re-post without duplicating. Newest-first. Backend primitive for the charrette's wishlist finding (got-feedback/feedBack#636 item 4); the consuming UI lives in the producing plugin. Tests: `tests/test_wanted_api.py`.
- **Practice-aware library home — a "Repertoire" meter + a "Keep practicing" shelf on the v3 Songs page.** The library opened cold into a flat sorted grid; now the unfiltered grid front door leads with two practice-aware surfaces built entirely from data already on hand (no new endpoints or stored state). A **Repertoire meter** shows how much of your library you can actually play — *"Repertoire: 12 of 80 songs · 7 in progress"* with a progress bar — counting songs at or above the same mastery threshold the green accuracy badge uses (≥ 90% best accuracy) over the unfiltered library total. A **"Keep practicing" shelf** is a horizontal row of your recently-played-but-not-yet-mastered songs (newest first, click to play) — the practice-accuracy-driven "continue" rail a media server can't do. Both reuse `/api/stats/best` (already loaded for the card badges) + `/api/stats/recent`; they show **only** on the grid view when you aren't searching/filtering/selecting, refresh after a song is scored, and collapse to nothing on an empty library. Soft-gamification only — descriptive encouragement (goal-gradient / endowed-progress), never content-gating, decay, or nagging. Frontend-only: `static/v3/songs.js` (`renderLibraryHome`/`_repertoireCounts`), `static/v3/v3.css`. Came out of the library design charrette (the UX + gamification lenses' top pick). Tests: `tests/js/v3_keep_practicing.test.js`.
- **AZ fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`.
- **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`.
- **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`.
- **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption).
- **Optional "Ask before leaving a song" confirm (Gameplay tab, default OFF).** A new client-only toggle (`confirmExitSong` in `localStorage`, in the v3 Gameplay settings + the Gameplay "Reset" set) for players who want a guard against an accidental exit. **Off by default — Escape leaves instantly, zero change for everyone else.** When on, a *user-initiated* exit (the player-scope Escape shortcut, or the player's ✕) opens a small true-modal confirm instead of leaving; auto-exit on song-end and a results screen's own Close are unaffected (they call `closeCurrentSong()` directly, which stays the unguarded actual-exit). The confirm honors the team's refined asks: **opening it pauses the song** (so it isn't running or being scored behind the prompt) and **Stay resumes exactly what was paused**; **Escape = Stay** — the dialog's capture-phase handler *dismisses* it (now consistent with every other modal and the generic `_confirmDialog`'s Esc=cancel), so a second Escape returns you to the (resumed) song rather than leaving; **Space/Enter (or click) Leave** by natively activating the default-focused "Leave" button ("just get me out"). Pause/resume run through the canonical `togglePlay()` path (HTML5 + `_juceMode`), guarded so a count-in, an already-paused song, or a teardown/seek/end behind the modal can't mis-resume. It's a real modal (`role="dialog" aria-modal="true"` / `.feedBack-modal`) with **Tab trapped inside it** and a **backdrop click that also Stays**, so the Escape/Space focus carve-outs treat it as a trap and don't fire player-back / play-pause behind it. The player Escape shortcut and the v3 ✕ route through a shared `window.requestExitSong()` gate (the ✕ also becomes origin-aware, matching Escape). Tests: `tests/browser/exit-confirm.spec.ts` (default-off instant exit, confirm-on opens + stays, second-Escape stays, backdrop stays, Stay/Leave, Enter-leaves); the audio pause/resume is verified manually on web + desktop (the mock song has no backing track).
- **Folder Library — a bundled core plugin (`plugins/folder_library/`) that browses the DLC library by its on-disk folder tree.** Surfaces top-level folders → subfolders → songs (root-level songs land in `(Unsorted)`), with in-app folder management (create / rename / delete nested folders), song moves via dialog or drag-and-drop, and sort/filter that mirrors the host library's filter state. Wired into both the classic (v2) library toolbar and the v3 Songs page as a third **Folders** view alongside grid/tree; the plugin's `screen.js` is loaded once by the host and reused (idempotent IIFEs). Supersedes the former standalone "Folder Organizer" community plugin (removed from the README list). Backend (`routes.py`) registers `/api/plugins/folder_library/{tree,folder/create,folder/rename,folder/delete,song/move}`; **all filesystem mutations are confined to `DLC_DIR` and validated against path traversal** (per-segment name validation plus a resolved-containment check on `song/move`), and folder deletion relocates every song — de-duplicating colliding names — so a name clash never destroys a song. A two-level cache keeps re-opening folders fast. Tests: `tests/plugins/folder_library/test_routes.py` (path-safety helpers + move-traversal and delete-no-data-loss end-to-end).
- **Full-screen (immersive) plugin screens — opt-in via `"fullscreen": true` in `plugin.json`.** DAW-style plugin UIs (e.g. a practice studio) need the whole viewport, not a scrolling content page below the topbar — embedded in the v3 shell they get cut off at the bottom with dead space up top. A plugin can now declare a top-level `"fullscreen": true`; `plugins/__init__.py` surfaces it as the `fullscreen` boolean on `/api/plugins` (mirroring the `settings_category` plumbing). When such a plugin's screen is active, `static/v3/shell.js` toggles `html.fb-immersive` from `syncActive()` (so it tracks every navigation incl. deep-link), and `static/v3/v3.css` hides the topbar, collapses the sidebar to a functional **icon rail** (kept reachable — Escape is bound only on player/settings scopes, so a fully-hidden sidebar would trap the user), and lets the active plugin screen fill `#v3-main`. Mirrors the existing `ss-follower-pre` chrome-hide pattern. Additive + opt-in: plugins without the flag are unaffected. Tests: `tests/test_plugins.py::test_fullscreen_flag_parsed_from_manifest`.
- **Achievements wall sync — background drain worker (epic PR3, client side).** The bundled `achievements` plugin gains a dead-letter sync worker that POSTs queued Feat unlocks (and removals) to the hosted **feedback-achievements** wall service (separate repo). Idle unless a wall URL is configured (`FEEDBACK_ACHIEVEMENTS_WALL_URL`); uses `requests` with the baked-in client-token header, mirroring `lib/lyrics_transcribe`'s outbound pattern (explicit timeout, no raise on non-2xx). **Dead-letter, never drop** (pure `engine.drain_decision`): network error / `429` / `5xx` → keep `pending` (retry); other `4xx` → `dead_letter` (diagnosable, replayable); `2xx` → delete on server ack. A row leaves the queue only on ack or a user opt-out. `remove-me` now enqueues a wall removal keyed by the reused `player_hash`. Verified by an end-to-end staging round-trip (earn a Feat → drains onto the wall with name + short hash → `remove-me` → wall empties) with **no IP** in tables or access logs. Tests: `tests/plugins/achievements/test_sync.py` (decision table + ack/retry/dead-letter retention + four-field payload on the wire). The hosted service itself (FastAPI + SQLite-on-disk, Feats-only, hidden-until-first-global-unlock, profanity filter, in-memory rate limit, Render blueprint, migration tool) lives in the new `feedback-achievements` repo.
- **Achievements wall — opt-in, privacy controls & data-minimization gate (epic PR2).** Sharing earned **Feats** on the (forthcoming) public wall is strictly opt-in. A new **onboarding step** (`static/v3/profile.js`, inserted after song-directory / before instrument paths — the wizard is now five steps) presents a plain-language card: it publishes only your display name and the Feats you earn, never songs/skills/scores, and is **off by default**. The bundled plugin's Settings panel (`plugins/achievements/settings.html`, mounted under the **System** tab via `settings.category`) carries the same toggle plus a **"Remove me from the wall"** button (`POST /api/plugins/achievements/remove-me` — wipes local synced state offline + enqueues a wall removal). Core adds `achievements_enabled` (bool, default `false`) to `_default_settings()` + the `/api/settings` validation block + `_RESETTABLE_SETTINGS_KEYS` in `server.py`, mirrored to `localStorage` in `app.js loadSettings()`. **Data-minimization contract (binding, code-enforced):** every outbound payload is built by a single explicit-dict serializer (`engine.build_wall_payload`, never `dict(row)`/`**model`) whose key-set is **exactly** `{display_name, player_hash, achievement_id, unlocked_at}` with `achievement_id` always a **Feat** id — a unit test asserts the four-field set and goes red on a fifth. Enqueue is doubly gated: it happens only when opted-in **and** a profile identity (name + the reused `player_hash`) exists; **competency unlocks never enqueue** (integration law). Tests: `tests/plugins/achievements/test_datamin.py` (key-set, opt-out/identity/competency gating) + `tests/test_settings_api.py` (flag persists/validates/resettable).
- **Achievements & Feats of Power — local engine + tabbed Profile (epic PR1).** The Profile screen (`static/v3/profile.js`) becomes **tabbed** exactly like the v3 Settings page (`.fb-tabbar` / `.fb-tab[data-tab]` / `.fb-tabpanel[data-tab]`, active-tab persisted in `localStorage 'v3-profile-tab'`): a **Profile** (main) tab carrying the existing header + best-scores cards plus a new **Feats of Power** trophy shelf mount (`#v3-profile-feats-slot`, earned-only / hidden-until-earned), and an **Achievements** tab with a plugin mount (`#v3-profile-achievements-mount`) + `[data-empty-for]` empty note. Core dispatches a new **`v3:profile-rendered`** event after every render (mirrors `v3:settings-rendered`) so the plugin re-injects on each profile entry. A new bundled **`plugins/achievements/`** plugin owns the engine: SQLite under `<config_dir>/achievements/achievements.db` (`unlocks` / `counters` / `comp_ledger` / `sync_queue`), pure threshold/criterion math in the testable sibling `engine.py` (P-V), and routes under `/api/plugins/achievements/` (`activity`, `report-unlock`, `report-criterion`, `catalog`, `earned`, `feats`, `remove-me`). **Two surfaces, one engine, structurally separated (integration law):** **Feats** (activity/volume — Note Hunter, Marathon, Untouchable, Road Warrior, Time Served, Encore, two 🥚 secrets) read activity counters only, evaluated from a batched `song:ended` activity POST (notes only when **notedetect** is present — graceful degradation, no fake progress); **competency Achievements** (baseline: First Steps / Ascendant / Steady Hands / Renaissance + per-instrument Apprentice·Journeyman·Master / Personal Best / Challenger) are evaluated from **progression events only** and never re-derived from activity. The Achievements catalogue is always shown (locked = greyed), grouped by a secondary pill row over the **real progression paths** (Global / Guitar / Bass / Drums / Keys — auto-extends to new paths) with a per-category "X / Y earned" badge, defaulting to the player's primary path. Source plugins contribute their own competency defs and report unlocks through a versioned **`window.feedBack.achievements`** API (`register`/`registerAll`/`unlock`/`progress`), load-order-safe via the `window.__feedBackAchievementsPending` queue + an `achievements:ready` event (minigames pending-queue pattern); an absent source contributes nothing (no dead greyed rows). Opt-in publishing to a hosted Feats wall, the Settings privacy toggle, and the data-minimization gate land in epic PR2/PR3. Tests: `tests/plugins/achievements/test_engine.py` + `test_routes.py` (incl. the integration-law assertion that a competency unlock never reaches the Feats shelf).
- **v3 settings page redesigned as a tabbed, card-row layout.** The single long scrolling settings screen becomes a horizontal tab bar (Gameplay / Audio / Graphics / Keybinds / Progression / Mic / Plugins / System) over card rows — each a leading icon + title + description with the control (toggle/dropdown/slider) on the right, plus a per-category "Reset" action. The markup lives in `static/v3/index.html` (so existing element ids keep hydrating through the unchanged `app.js` `loadSettings()`/`persistSetting()` path); a new `static/v3/settings.js` owns tab switching + active-tab persistence (`localStorage 'v3-settings-tab'`), the per-category reset, and a read-only **Keybinds** reference built from the live shortcut registry (`window.getAllShortcuts()`); styling is plain CSS in `static/v3/v3.css` (no Tailwind rebuild). **Plugins choose their settings tab** via a new optional `settings.category` field in `plugin.json` (`plugins/__init__.py` surfaces it as `settings_category`; `app.js` mounts each plugin's `<details>` panel into `#plugin-settings-<category>`, falling back to the generic Plugins tab) — `highway_3d` ships `category: "graphics"`; the out-of-repo notedetect/progression plugins should declare `"mic"` / `"progression"`. **New gameplay settings:** **Countdown before song** (a four-beat count-in before playback, wired end-to-end via the existing count-in engine + the song-start autostart path; key `countdown_before_song`, default off); **Miss penalty** (`miss_penalty`) and **Fail behavior** (`fail_behavior`) are persisted now but not yet consumed by scoring (shown with a "Not yet active" badge). "Note highway speed" surfaces the existing `master_difficulty` and stays in sync with the player-popover difficulty slider. New `POST /api/settings/reset` clears chosen keys back to defaults. Tests: `tests/test_settings_api.py` (new keys + reset), `tests/test_plugins.py::test_settings_category_parsed_from_manifest`, `tests/browser/settings-tabbed.spec.ts`.
- **Full-mix audio exposed alongside stems for the stem mixer's auto-switch.** `lib/sloppak.py::load_song` now parses the optional manifest `original_audio:` key (the single pre-separation mixdown, e.g. `original/full.ogg`) into a new `LoadedSloppak.original_audio` field, with the same path-traversal guard and permissive "missing → disabled" posture as the `drum_tab` loader. The highway WS `song_info` frame additively carries three new fields next to `stems`: `original_audio_url` (served by the existing `/api/sloppak/{filename}/file/{rel_path}` endpoint, `None` when the pack ships stems only), `has_original_audio`, and `has_stems` (mirroring the `has_drum_tab`/`has_keys` flag convention). The stems plugin consumes `original_audio_url` to play the untouched single file while every stem slider is at unity and switch to the separate stems the moment one drops below 100%. **Migration notes:** the `song_info` message shape is a stable contract — these are purely additive; all existing fields are unchanged. `audio_url` still points at stem[0] when stems exist (it is only the degraded native fallback); the one behavioural change is that a stem-less, full-mix-only sloppak now sets `audio_url` to the full mix instead of emitting `audio_error`, so it plays natively.
- **Autoplay & auto-exit — a global "click it, it plays; finish, you're back at the menu" option (default ON).** New single Settings toggle (`autoplayExit` in `localStorage`, surfaced in both the v3 and classic settings screens; absence of the key = enabled) that closes the friction at both ends of the play loop. **Autoplay:** `playSong()` previously loaded a chart paused, requiring a Play press; a one-shot flag armed per fresh load is now consumed by the next `song:ready` (highway.js) to auto-start via the existing `togglePlay()` path (HTML5 + `_juceMode` + count-in). Arrangement switches / seeks reuse the same `song:ready` event but never arm the flag, so they don't auto-restart. **Auto-exit:** on `song:ended`, core returns to the launching menu after a short grace delay — unless a visible full-screen results/dialog overlay is on top (detected via `[role=dialog][aria-modal]` / `.fixed.inset-0` with a `getClientRects()` visibility test that works for `position:fixed`), in which case the return is deferred so that score screen's own Close button (calling `window.closeCurrentSong()`) drives the exit. A plugin can also defer explicitly via the new `window.feedBack.holdAutoExit()` (called synchronously from its own `song:ended` handler — core's listener runs first). Both paths mean **no external plugin PR is required** for a results screen to be respected. **Context-aware destination:** the player's remembered origin (`_playerOriginScreen`) now honours any real launch screen instead of clamping to library/home/favorites, and a one-shot `window.feedBack.setReturnScreen(id)` override lets the lessons catalog (`static/v3/lessons.js`) send a finished lesson back to the lessons screen — not the song library — even though the external tutorials plugin owns the `playSong` call. Also exposes a read-only `window.feedBack.autoplayExit` getter for plugins. Songs and lessons share the same `playSong` → highway path, so both inherit the behaviour. Core-only (`static/app.js`, `static/v3/lessons.js`, both `index.html`s); the end-of-song score screen itself remains a plugin. Optional polish (not required — the overlay heuristic already covers it): external scoring/note-detection plugins (e.g. SlopScale) may call `holdAutoExit()` + `closeCurrentSong()` for an exact, heuristic-free handoff.
- **"Song Editor" promoted to a first-class v3 sidebar item.** The editor
plugin (`id: editor`) now gets its own dedicated sidebar entry — under the
Library group, just below Songs — via the existing `PROMOTED_PLUGINS`
mechanism in `static/v3/shell.js`, instead of being reachable only through
the generic Plugins gallery. Gated on the plugin actually being installed
(`renderPromotedNav` checks `/api/plugins`), so it appears only when the
editor is loaded. The displayed label comes from the plugin's manifest
`nav.label`.
- **Guitar Pro → notation importer (`lib/gp2notation.py`)** (feedBack#825 WS4b, epic #828). Piano/keys tracks imported from Guitar Pro (GPIF: `.gpx` GP6 / `.gp` GP7-8) now produce real Sloppak Notation Format data (sloppak-spec §5.3) alongside the `midi = string*24 + fret` guitar wire encoding. `gp2rs_gpx.convert_file` writes a `<stem>.notation.json` sidecar next to each keys arrangement XML (best-effort — a notation bug never breaks the RS-XML conversion), and `gp2notation.attach_notation_to_sloppak()` is the assembly-side helper that renames it into `notation_<id>.json` + adds the per-arrangement `notation:` manifest sub-key. Voice→staff routing salvages the logic from PR #703 (whose `stf` wire-field approach this supersedes): GP voice position 0 → `rh` staff (`G2`), positions ≥ 1 → `lh` (`F4`); a forced-LH track (the merged `Piano LH` partner from `_find_piano_pairs`, or a standalone track named `… LH`) routes everything to `lh` — preserving authored hand crossings instead of inferring hands from pitch. Emits measures with absolute `t` from the bar-indexed tempo map, change-only `ts`/`tempo`/`ks`, and `beat_groups` for 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 carry `dur`/`dot`/`tu`/`rest` from GP rhythms and notes carry absolute `midi` (String+Fret resolves via the string template's concert pitches, Tone+Octave via `(octave+1)*12 + step`) with `tied` continuations kept as real beats (engraving needs the tied notehead — unlike the RS-XML walk, which drops them and extends sustain). Timing reuses the `gp2rs_gpx` machinery (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 written `dot: 2` agrees 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 a `notation_<id>.json` plus the per-arrangement `notation:` manifest sub-key. Measures derive from the song-level `beats` downbeats (`measure >= 0`; `song_timeline.json` preferred, first-arrangement fallback), with per-measure tempo from downbeat spacing (emitted only on a > 1 BPM change). Durations come from the wire sustain (`sus`, legacy `l` alias) 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 carrying `notation:` are skipped; an orphan `notation_<id>.json` without the manifest key is refused, not overwritten) with `--dry-run` support; every payload is checked via `notation.validate_notation` before 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 `.sloppak` files 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`; measure `pickup` (anacrusis); beat `arp` (arpeggiate), `ferm` (fermata), and **typed grace notes** — `grace: "a"` (acciaccatura, MusicXML `grace/@slash=yes`) / `"p"` (appoggiatura); note `stem` (`"up"`/`"down"` force). Pedal is settled as the existing `spd`/`sph`/`spu` trio with a documented MusicXML `<pedal start|change|stop>` mapping — no separate `ped` field. 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.py` gains the `GRACE_TYPES`, `STEM_DIRECTIONS`, and `DYNAMICS` vocabularies; 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.py` defines the canonical vocabulary (`CLEFS`, `DURATIONS`, `SCHEMA_VERSION`), a permissive `validate_notation()` check, and `measures_to_wire()` / `measure_to_wire()` wire helpers. `lib/sloppak.py::load_song` reads a new per-arrangement `notation:` sub-key from each arrangement entry in the manifest (Option B: per-arrangement, not song-wide), applies path-traversal guards, validates the parsed JSON via `validate_notation()`, and surfaces all notation payloads on `LoadedSloppak.notation_by_id` (a `dict[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 when `notation:` 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 chunked `notation_measures` (32 measures per chunk) — streamed after `sections` and before `anchors`; `song_info` carries a new `has_notation: bool` flag 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. See `docs/sloppak-spec.md` §5.3 for the full schema. Open questions resolved per the piano/keys epic (feedBack#828 / #822): Option B (per-arrangement `notation:` sub-key) and `file:`-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 in `lib/sloppak.py` reads and validates the file (must be a dict with `beats` and `sections` as lists), clears and repopulates `Song.beats` / `Song.sections` from it when present, and stores the raw dict on `LoadedSloppak.song_timeline`. The existing arrangement-JSON fallback is fully preserved: all existing sloppaks that omit `song_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 in `song_timeline.json` only. See `docs/sloppak-spec.md` §2 and §5.3.
- **`note-detection` capability domain promoted — control plane (spec 009)** (feedBack#727/#728, epic #828). New core host `static/capabilities/note-detection.js`: provider registry (kinds `midi`/`engine`/`js`, primitives `pitch.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-coupled `highway.setNoteStateProvider` surface 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.
- **`visualization` capability domain promoted (cap:6)** (feedBack#828). New core host `static/capabilities/visualization.js` registers a provider-coordinator owning the highway renderer surface: commands `inspect` / `list-providers` / `select-renderer` / `clear-renderer` (selection delegates to the existing picker so persistence, WebGL2 gating, and fallback stay single-sourced), events `providers-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.js` attributes 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.currentSong` gains `hasNotation` (sibling of `hasDrumTab`) from the `song_info` frame's `has_notation` flag, so notation viz plugins (Staff View, Keys Highway 3D) can gate `matchesArrangement` on 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 attributes `type: piano|keys` arrangements — and names matching `keys`/`piano`/`keyboard`/`synth` on a word boundary — to the new `keys` instrument, 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` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
### Fixed
- **GP8 multi-staff (piano/keys) tracks now import both hands — the bass stave was being silently dropped, and hand-splits landed on the wrong hand.** A GP8 grand-staff keyboard part is one `<Track>` with two `<Staff>` entries, and `MasterBar/Bars` lists one bar id per **stave**, not per track (`lib/gp2rs_gpx.py`). Two bugs fell out of assuming one stave per track: (1) the bar-column lookup used a raw `enumerate(Tracks)` index, so every track *after* a multi-stave track read the wrong column; (2) the string-tuning parse scanned all `.//Property` descendants and let the last stave's `<Tuning>` overwrite the first, so a treble note indexed against the 5-entry bass tuning fell out of range in `_note_midi` and was **dropped without a trace**. The importer now advances a bar-column counter by each track's stave count, reads tuning **per stave** (with a per-staff fall-back to the track-level property so an untuned staff never yields empty pitches), and folds **every** extra stave's notes into the arrangement (not just stave 1), keeping the `note_count` import-preview honest. A grand-staff track is now classified as keys end-to-end so the stave-0 and folded stave-1+ notes share one encoding. Separately, `notation_lift.split_hands` no longer forces a hard middle-C split when doing so produces a physically unplayable hand (e.g. a bass note under an Em7-shape voicing dipping below C4 would put a 19-semitone span in one hand) — it uses the middle-C boundary only when both resulting hands are within `HAND_SPLIT_SPAN_SEMITONES`, else falls back to the largest-gap heuristic. The GPX LH/RH pair merge and the GP8 stave fold now share one `_collect_column_notes` / `_merge_lh_notes` pair so the two formats can't drift in tie/timing/dedup handling. Companion editor change: got-feedback/feedBack-plugin-editor#38. Tests: `tests/test_gp2notation.py` (grand-staff fold + bar-column offset), `tests/test_notation_lift.py` (both middle-C split cases). Follow-up: `lib/gp_autosync.py` still carries the pre-fix bar-column + tuning logic (CLI/tests only, no production caller).
- **Tuner auto-open is now opt-in and persists instead of flashing open-then-shut.** When you entered a song (or switched arrangement) whose tuning differed from the last, the tuner auto-opened and — for some testers — vanished ~1s later (reported macOS+Windows, 0.3.0). Root cause: the tuner closes itself on `song:play` (`plugins/tuner/utils/ui.js` — you don't tune while playing), so a **song switch** fired autoplay → `song:play` → the just-auto-opened tuner closed; an **arrangement switch** (which never arms autoplay) had no `song:play`, so it stayed open — exactly why two testers saw opposite behaviour (it wasn't the mic). Now: (1) the feature is a **new opt-in setting** ("Auto-open on tuning change", in the tuner's Settings panel, persisted as `autoOpenOnTuningChange`, **default OFF**); (2) an **auto**-opened tuner *persists* — it ignores the autoplay `song:play`, stray outside-clicks, and same-screen re-emits, closing only via the new in-panel **`×`** / **"Skip"** buttons or when you leave the song. A *manually* opened tuner keeps its classic click-away / play-to-close behaviour. The panel previously had no in-box close at all; this adds one (`×` + contextual Skip). All in the tuner plugin (`routes.py` config, `screen.js` gate + persist, `utils/ui.js` buttons + `song:play` guard, `settings.html` toggle) — **no core `app.js` changes**. Tests: `tests/js/tuner_auto_open.test.js` (opt-in gate, `{ auto: true }` persist mode, play/click-proofing). **Default (opt-in vs opt-out) is teed up for Byron to decide — flip one boolean.**
- **Tuner auto-open is now tuning-coverage-aware — extended-range players aren't nagged for songs their instrument already covers.** With the opt-in auto-open on, it now prompts only when your **current physical tuning** (from your instrument selection in Settings) doesn't already cover the song. FeedBack is tune-to-song — the highway draws tab in the song's tuning — so the check aligns the song's open-string tuning string-for-string against your instrument: an **8-string F♯-standard** player gets **no** prompt for a 6- or 7-string standard song (its top strings already match those tunings), while a song needing an open string you don't have (e.g. a **Drop-A 7-string**, whose low A isn't an open string on an F♯ 8-string) **still** prompts. A whole-instrument reference difference also prompts — A440 vs A432, or an octave-down `centOffset` (which the auto-open now accounts for; it was previously ignored). The player's instrument is read from core **`/api/settings`** (the v3 instrument selector — a stable physical reference, not the tuner's song-tracking selection); when nothing's declared or the lookup is unavailable it falls back to a conservative prompt, so a real retune is never silently skipped. **v3-only** (the instrument selector is v3). All in the tuner plugin (`plugins/tuner/screen.js`) — **no core changes**. Tests: `tests/js/tuner_auto_open.test.js` (covered vs uncovered, the Drop-A case, reference-pitch mismatch, contiguous alignment). _Follow-up (E1.6): a passive "different tuning" badge cue that names the string(s) to retune, plus the splitscreen / no-usable-input guards._
- **The tuner badge now passively flags when a song needs a different tuning — and names the retune.** Building on the coverage check: when you enter a song your current instrument doesn't cover, the topbar tuner badge gets an amber ring and a tooltip that **names the change** — e.g. *"retune B→A"* for a Drop-A song on an F♯ 8-string, or *"the reference pitch"* for an A440-vs-A432 mismatch. It's purely **advisory** (it never auto-opens the panel — tap the badge to tune), recomputed on `song:ready` and cleared when a new song loads or you leave the player. The retune diff comes from the tuner plugin's coverage report (`window._tunerAutoOpen.coverageReport` → `{ covered, retune: [{ from, to }], reference, cantCover }`); the cue is CSS-free (an inline ring + native tooltip — no Tailwind rebuild) and no-ops when the tuner plugin isn't installed. **v3-only.** Touches `static/v3/badges.js` (the cue) + `plugins/tuner/screen.js` (the report). Tests: `tests/js/tuner_auto_open.test.js` (the report names the strings; reference mismatch; the badge wiring). _(The splitscreen-suppress and no-usable-input guards move to the playback-gate stage, where they matter for its no-trap rule.)_
- **Tuner auto-open can now gate playback until you've tuned — the "tune before you play" model — via a new core `holdAutoplay()` hook.** With the opt-in auto-open on, when a song needs a retune the tuner opens and **playback waits** for your choice — **Skip** (you've tuned → play, and record the song's tuning as your instrument's current working tuning), **Back to library** / **Esc** (leave the song; a gated retune is never a one-way trap), or press **Play** (always wins). For an auto-open the in-panel **×** is dropped — Skip / Back to library / Esc are its dismiss surface. Previously the song played with the tuner overlaid; now it holds — which also definitively kills the original flash, since autoplay's `song:play` can't fire while playback is held. Implemented as a small **core hook** `window.feedBack.holdAutoplay()` (mirrors the existing `holdAutoExit()`): a plugin claims it **synchronously on `song:loading`** (so it beats the `song:ready` autostart), and `release()` — or a **12-second fail-open backstop** — runs the deferred start. **Generation-guarded** (a new song invalidates a stale hold) and **fail-open** (a wedged or crashed plugin can never permanently strand a song); **manual Play always wins** (it doesn't flow through the autostart path). The tuner claims the gate only when the feature is on, and **releases it the instant** it decides not to open (song already covered / tuning unchanged) or when you Skip. Touches core `static/app.js` (the hook + an autostart refactor) and the tuner plugin (`plugins/tuner/screen.js` — the claim/release; `plugins/tuner/utils/ui.js` — the Skip / Back-to-library buttons, × dropped on auto-open); the hook is generic and shell-agnostic (a test asserts `app.js` still doesn't reference the tuner's internals). Tests: `tests/js/tuner_auto_open.test.js` (claim on `song:loading`, release on dismiss, feature-off no-claim, the core hook + fail-open backstop, the Skip / Back-to-library / Esc escape-hatch) + a `speed_reset.test.js` stub. ⚠️ **Needs a manual smoke-test before shipping** — this is a core playback change; verify on desktop that the tuner mic doesn't contend with note_detect's scoring input (ASIO/exclusive mode), per the design charrette.
- **v3 Songs List View: favoriting a song now turns the heart red immediately (no re-search needed).** In the tree / "List View" (Songs → List → expand an artist), clicking the heart flipped the glyph ♡→♥ but it stayed dim grey until you re-searched the library — reported on macOS + Windows, open since 0.3.0 / 2026-06-25. One shared `wireCards()` `[data-fav]` handler (`static/v3/songs.js`) serves both the grid card and the List-View row, but the two render with different idle colours — grid `text-white`, List View `text-fb-textDim` — and the handler only ever removed the grid's `text-white`. So in List View the row kept `text-fb-textDim` alongside the freshly-added `text-fb-accent`, and the dim class won by CSS source order (glyph changed, colour didn't). Each heart now declares its idle colour via a `data-fav-idle` attribute and the handler swaps exactly that class, so only one colour class is ever present; the handler also writes the new state back onto the in-memory song model so a re-render / virtualized-grid recycle agrees instead of reverting. Tests: `tests/js/v3_favorites_toggle.test.js`.
- **v3 Songs AZ rail: taps now land reliably, a drag releases exactly on the let-go letter, and the rail is large enough to hit on hi-res displays.** Follow-up to the rail's debut (#634); three bugs reported on macOS + Windows (0.3.0, 2026-06-29): a tap often did nothing ("clicked O, nothing happened"), a drag "got you kind of there but where you release isn't where you get sent," and the rail was "way too small" at 1440p and didn't scale with resolution. Root causes & fixes, all in `static/v3/songs.js` + `static/v3/v3.css` (`bindRailOnce`/`jumpToLetter`/`.v3-azrail`): (1) **taps** — `pointerdown` calls `setPointerCapture`, after which the browser **retargets the follow-up `click` to the rail container**, so the click handler's `closest('.v3-azrail-letter')` resolved `null` and a plain tap (no `pointermove`) had no other path → no-op. The jump is now driven from `pointerdown` itself (seek on press); the `click` handler is reduced to **keyboard activation only** (`e.detail === 0`, Enter/Space). (2) **drag precision** — every letter crossed fired `jumpToLetter` with `behavior:'smooth'`; stacked smooth-scroll animations over the virtualized grid lagged and settled short of the release. `jumpToLetter(letter, smooth)` now scrolls **instantly while scrubbing** (`'auto'`) and only animates discrete taps/keyboard jumps, so the grid tracks the finger and the release lands on the let-go letter. (3) **size** — the letters were a fixed `.62rem` glued at `right:2px` (~13px-tall target on the screen edge); they now scale with the viewport (`clamp(.72rem, 1.4vh, 1.05rem)`), sit off the edge with taller/wider equal-width hit targets and a hover/active highlight so the scrub target is visible. Keyboard arrow-nav + the present-letter gating are unchanged. Reported by =Scr4tch= and MajorMokoto. Tests: `tests/js/v3_az_rail.test.js` (pointerdown-seek, keyboard-only click guard, instant-vs-smooth scroll).
- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`.
- **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`.
- **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible.
- **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.)
- **3D Highway fret-number row no longer clips off the bottom edge when the camera zooms in on a centred span.** The heat-coloured fret-number row is drawn as a band *below* the board (`sY(lowest) S_GAP*1.4`), but the camera's self-correcting framing only anchors the board **centre** to the lower third of the screen — it reserved no headroom for that row. So a tight zoom on a centred active span (worst around mid-neck; fine when the span sits at either end of the neck, which is why testers saw it "only when centered" and "not every song") dropped the numbers past the bottom edge. Tilt can't fix it there (it would only trade a bottom clip for a top clip), so `camUpdate()` now **dollies the camera back just enough to bring the row back into frame**: it projects the row band with the final camera and, when it falls below a safe NDC line (`FRET_ROW_FIT_NDC_MIN`), raises a capped, hysteretic `_fretRowFitBoost` applied to the `curDist` lerp target (the span-driven zoom still owns zooming *in*). The boost rises promptly (proportional to the deficit), relaxes lazily past a deadband, and is capped (`FRET_ROW_FIT_BOOST_MAX`, +60%) so the zoom can't pop or hunt; it cooperates with the tilt loop (pull-back shrinks the scene, tilt keeps the centre anchored) and yields entirely to the Camera Director free-cam. Surgical: passages where the row is already visible never trigger it, so framing is unchanged everywhere it already worked. `plugins/highway_3d/plugin.json` version → `3.30.2` (cache-buster). Tests: `tests/js/highway_3d_camera_framing.test.js` (guard constants, the boosted `curDist` lerp, the projected-row hysteresis, free-cam yield).
- **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring).
- **Edit Metadata modal: the Year is now editable.** You could set a year when authoring a pak but the Songs → Edit Metadata modal had no Year field, so it could never be changed afterward. The backend (`POST /api/song/<f>/meta`) already accepted and normalized `year` (writes it into the file via `songmeta`, survives a rescan) — only the UI omitted it. Added a **Year** input to `openEditModal()` (populated from the song's existing year) and included `year` in `saveEditModal()`'s POST body (`static/app.js`). Both the v3 card menu and the legacy edit button already pass the year through, so both surfaces get the field.
- **Edit Metadata modal no longer closes when a click-drag is released on the backdrop.** Selecting text inside a field and releasing the mouse past the modal's edge dismissed the form without warning (the `click` event's target resolved to the backdrop), discarding the edit. Backdrop dismissal now requires the **mousedown to have started on the backdrop** too — tracked per-modal and decided by a new pure `_editModalShouldClose(clickTarget, modalEl, downOnBackdrop)` helper (`static/app.js`). Cancel / ✕ still close on a normal click. Tests: `tests/js/edit_metadata_modal.test.js` (year in the POST body + the backdrop-close decision table).
- **Built-in diagnostic sloppak rebranded "Slopsmith" → "FeedBack" in the song name.** PR #586 renamed the file to `feedBack-diagnostic-basic-guitar.sloppak` but never regenerated the archive, so the manifest inside still carried `title: Slopsmith Diagnostic — Basic Guitar` / `artist: Slopsmith` (and the same heading in `DIAGNOSTIC.md`) — the stale name testers saw in the library/player and the onboarding calibration step, even though the build script, server, and docs all already say "FeedBack Diagnostic — Basic Guitar". Regenerated `docs/diagnostics/feedBack-diagnostic-basic-guitar.sloppak` from `docs/diagnostics/build_diagnostic_basic_guitar.py` so the committed artifact matches its source generator (title/artist/heading now "FeedBack"; chart, stem, and `diagnostic:` metadata unchanged). No code change — the rename in #586 just needed the rebuild.
- **v3 song/lesson accuracy badges now refresh on the first return from a song — no restart needed.** PR #574 added a `stats:recorded` → in-place badge repaint, but the repaint never matched a card. The event (like `song:loading`) carries the filename **`encodeURIComponent`'d** — exactly as `playCard` hands it to `playSong` (the highway WS `decodeURIComponent`s it back) — whereas library cards key on the **decoded** `localFilename` (`data-fn`), and `/api/stats/best` is server-canonicalized to that same decoded key (`server.py` `_canonical_song_filename`). So `repaintAccuracy`'s `data-fn !== key` check rejected every card and `state.accuracy[encoded]` was `undefined`, leaving the just-earned badge stale until a full `render()` (app restart / search / re-enter the screen) — which is why it "came back after a restart." `static/v3/songs.js` now decodes the `stats:recorded` filename back into the card / `state.accuracy` key space via a small `decFn` helper before marking dirty and repainting (idempotent for already-decoded names; falls back to the original on malformed input so a real filename containing a literal `%` is never corrupted), so both the immediate repaint and the `onV3SongsScreenEnter` deferred path land on the right card. Tests: `tests/js/v3_songs_score_badge_refresh.test.js`.
- **Escape now exits a song (and leaves Settings) even when a transport/rail control button holds keyboard focus.** Clicking a player control (Play / FF / RW / Restart) left that `<button>` focused, and `_shortcutDispatchBlocked()` in `static/app.js` treats any focused `INPUT/SELECT/TEXTAREA/BUTTON` as an "interactive control" and bails before the shortcut registry runs — so the player-scope `Escape → Back` shortcut never fired until the user clicked empty canvas to blur the control ("Escape in song not consistent"). Space already had a player-screen carve-out (#593) that let it fire through a focused control; Escape did not. Generalized that carve-out to Escape, scoped to the player **and** settings screens (both register an `Escape = Back` shortcut, and settings had the identical latent bug). The earlier guards are preserved and still win: text inputs are exempted first (Escape there clears/blurs the field), the Section Practice popover already claims Escape before the carve-out, and a true modal layered over the screen (`[role="dialog"][aria-modal="true"]` / `.feedBack-modal`) still traps Escape so it closes the modal rather than ejecting past it. Escape becomes a reliable, focus-independent "Back" — making it monotonic groundwork for an optional exit-confirm. Plugins that register a player-scope `Escape` shortcut benefit identically (they were broken the same way). Tests: `tests/browser/keyboard-shortcuts.spec.ts` (focused-button repro, text-input no-exit, no-escape-past-modal, Section Practice popover, settings twin-bug).
- **The v3 "Up Next" pill can now be turned off — new "Show 'Up Next'" gameplay toggle (default ON).** The v0.3.0 player chrome's persistent upcoming-section pill (`#v3-upnext`, drawn by `static/v3/player-chrome.js`'s `updateUpNext()`) shipped with no off switch, so it always showed during playback whenever a section was upcoming — overlapping the top-right FPS HUD and ignoring the 3D-highway "Show 'Up Next' section card" checkbox (a *different*, in-canvas widget that was demoted to default-off precisely because this pill is the canonical readout). Users reading the pill as the same setting saw "disabled in settings but still there." Adds a real core toggle following the `autoplayExit` idiom: a client-only `showUpNext` `localStorage` pref (absence = enabled), a **Show "Up Next"** switch in the Gameplay settings tab (`static/v3/index.html`), reader/writer + `loadSettings()` hydration + a read-only `window.feedBack.showUpNext` getter in `static/app.js`, and a gate at the top of `updateUpNext()` that hides the pill when off. Disabling mid-playback hides it immediately; re-enabling re-shows it on the next chrome tick (~6 Hz). Added to `RESET_MAP.gameplay.local` in `static/v3/settings.js` so the Gameplay "Reset" restores the default-on state. Default ON = zero change for existing users. No Tailwind rebuild (plain markup + existing classes).
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
- **v3 library scroll-restore no longer breaks the classic v2 UI or drops off-screen searches** (feedBack#857). Two regressions in the scroll-restore work above: (1) `playSong` remapped `home`-launched songs to return to the `#v3-songs` screen unconditionally, but `static/app.js` is shared with the v2 UI (served at `/v2` / `FEEDBACK_UI=v2`) where that screen does not exist — Esc-from-player then called `showScreen('v3-songs')`, which threw on the missing element and stranded the user on a blank screen with playback still running; the remap now applies only when `#v3-songs` is present. (2) The Songs screen-entry fast-path skips reloading to preserve scroll, but the global topbar search routed through it, so once Songs had been visited, searching from another screen navigated there without applying the new query; the screen now tracks the state hash each fetch reflects and refetches when it has drifted, keeping the scroll-preserving no-op only when nothing changed.
- **An active custom highway renderer is no longer starved of `draw()` when it hides the canvas** (#819). The per-frame draw gate in `static/highway.js` bailed on `if (!_lastVisible) return`, which conflated two different "hidden" states: a genuine off-screen canvas (`offsetParent === null` — navigate-away / `display:none` splitscreen 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 receiving `draw()` through its own override-hide. The `highway:visibility` event 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.js` now holds a [Screen Wake Lock](https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API) (`navigator.wakeLock.request('screen')`) so the OS display/screensaver stays awake even though only audio + the highway animation are active and the keyboard/mouse are idle. The lock is acquired on `song:play`/`song:resume` and released on `song:pause`/`song:ended`/`song:stop` (kept only while actually playing), and re-acquired on `visibilitychange` when the tab refocuses (the API auto-releases a lock whenever the page is hidden). Both the HTML5 `<audio>` and JUCE desktop playback paths emit the same `song:*` events, so the fix covers both. In feedBack-desktop (Electron), where `navigator.wakeLock` is unreliable, it also drives a native `powerSaveBlocker` bridge via the optional `window.feedBackDesktop.power.setScreenAwake` hook when present; both calls degrade silently where unsupported. Note: the browser Wake Lock API is secure-context only, so in a plain browser this is active on `localhost` / HTTPS only — a session opened over plain HTTP to a LAN IP (e.g. `http://192.168.1.100:8000`) won't keep the screen awake; front it with HTTPS or use the desktop app (see README → reverse-proxy notes).
### Changed
- **Practice plugin first-class sidebar slot now points at Virtuoso.** The bundled practice plugin was rebranded/re-homed from the SlopScale fork (`id: slopscale`) to `got-feedback/feedBack-plugin-virtuoso` (`id: virtuoso`); the desktop bundle swap is feedBack-desktop#31. `static/v3/shell.js` still promoted `slopscale`, whose id no longer ships — so `renderPromotedNav()` (gated on the plugin appearing in `/api/plugins`) would have found no match and the dedicated sidebar slot would have gone dark, dropping Virtuoso to the generic Plugins gallery. Update the NAV entry + `PROMOTED_PLUGINS` slot `slopscale` → `virtuoso` (`screen: plugin-virtuoso`, label "Virtuoso - Practice", same FeedBarcade anchor + `target` icon) so the practice plugin keeps its first-class entry. Also clear the now-dead `slopscale` id from the Plugins-gallery curated category map (`static/v3/plugins-page.js`) and add `virtuoso: 'practice'` as a defensive fallback (the manifest's `category: "practice"` is authoritative, so it lands on the practice board regardless), and refresh the stale SlopScale references in `README.md` + `docs/plugin-capability-inventory.md`. Must land with the bundle swap or the practice plugin regresses in the UI.
- **3D highway: realistic curved metal frets.** The fret wires are now bowed `TubeGeometry` (the middle strings push away from the camera so the row of frets reads as wrapping a cylindrical neck — a depth cue) rendered with a lit `MeshStandardMaterial` instead of the old flat, straight `MeshBasicMaterial` boxes, so the scene's ambient + directional light glints across the rounded surface for a polished-steel look. The existing per-frame highlight is preserved unchanged: frets inside the active anchor still turn gold (`0xD8A636`), which under the metallic shading reads as brass. Metalness is kept moderate (0.4, not full-metal) because the scene has no envMap — a PBR full-metal surface would reflect black — with a dim emissive floor so frets stay legible down the fogged neck. Backported from the `highway_babylon` plugin's "hit-zone fret bars". All knobs (`FRET_BOW_DZ`, metalness/roughness/emissive) are tunable constants. `plugins/highway_3d` v3.25.0.
- **3D highway: section + tone HUD cards now default OFF.** The v0.3.0 player chrome carries a persistent "Up Next" pill, making the in-canvas section card redundant by default (it doubled the readout, feedBack feedback); the tone HUD follows the same less-is-more default. Both remain available in Settings → 3D Highway (visibility/position/size unchanged); users who previously toggled either explicitly keep their stored preference — only the untouched default flips. `plugins/highway_3d` v3.24.1.
- **Perf**: replace runtime Tailwind Play CDN with a prebuilt static stylesheet (`static/tailwind.min.css`). The Play CDN's runtime JIT scanned the DOM ~1.8x/sec on the main thread (~37 ms blocking spans), dropping ~26% of frames in long playback sessions with the 3D highway as default. Theme extensions (dark/accent/gold colors, Inter font) move to `tailwind.config.js`; regen via `bash scripts/build-tailwind.sh`. No runtime build step — the generated CSS is committed. Fixes feedBack-desktop#110.
- **Perf**: reduce per-frame allocations in the 2D highway chord + lyric render paths. `_ensureChordRenderCache` now also caches `sortedNotes` / `nonZeroNotes` / `nonZeroFrets` / `allMuted` / `hasMultipleNotes` (computed once per chord, invalidated on `src` / `_inverted` / `chordTemplates` change — the third key catches a stale `isOpen`-derived classification when the WS `chord_templates` message lands after the final `chords` chunk), so `drawChords` no longer re-sorts / re-filters / spreads min-max per visible chord per frame. The in-chord unison bend classification is folded inline (no `chordPositions.filter` × 2 per frame). `drawLyrics` memoizes `ctx.measureText` results in a two-level `Map<fontSize, Map<text, width>>` so cache hits don't allocate a composite string key. Lit-sustain shimmer in `drawSustains` swaps the 4 per-note-per-frame `Math.random()` calls for a 64-entry precomputed jitter LUT (xorshift32-seeded — the LUT contents are reload-stable and test-reproducible; rendered shimmer is deterministic per `createHighway()` instance, since the seed includes that instance's `_frameIdx`) indexed by `(frameIdx + n.s + ⌊n.t·60⌋)`, visually indistinguishable and allocation-free.
- **Perf**: the load-adaptive render scale (`_adaptRenderScale`, #654) no longer visibly hunts up/down on passages that hover near the frame budget (testers saw "quality going up and down" with the 3D highway). Downscaling stays prompt to protect the frame rate, but **upscaling is now lazy**: a smaller step (×1.06 vs ×1.1) on a longer cooldown (`_AUTO_UPSCALE_COOLDOWN_MS` 2500 ms vs the 600 ms adjust cooldown), reset on any downscale, and gated by a predictive guard — it only upscales when the projected cost *after* the step (≈ cost × step², since draw cost tracks the pixel count) still clears the high budget. The scale therefore settles just inside the 712 ms deadband instead of oscillating across it. No new public API; the `_autoScaleMin` "Min res" floor is unchanged.
### Removed
- **`c` library hotkey ("Convert to .sloppak") removed from core.** Core hardcoded a plugin-specific shortcut: a documentation-only `registerShortcut({ key: 'c', scope: 'library' })` no-op plus a `c → button.sloppak-convert-btn` entry in the library keydown handler that fired the Sloppak Converter plugin's button. Per the plugins-own-their-behavior principle, core no longer ships this hotkey — the convert button still works by click, and the Sloppak Converter plugin can register its own `c` shortcut via `window.registerShortcut()` if keyboard access is wanted. The `f` (favorite) and `e` (edit) library hotkeys, which drive core buttons, are unchanged. Help-modal/registry tests in `tests/browser/keyboard-shortcuts.spec.ts` updated to drop the `c` assertions.
### Added
- **Player progression: Mastery Rank, instrument-path challenges, daily/weekly quests, Decibels currency, cosmetics shop (spec 010).** Onboarding gains two steps: pick one or more **instrument paths** (Guitar / Bass / Drums — data-driven, more can ship as content) and a **calibration challenge** offer (play the bundled FeedBack Diagnostic with note detection at 100% accuracy — or skip; either way you reach **Mastery Rank 1**, and a skipped calibration can still be completed later from the Progress screen). Each path levels by completing a content-defined number of **challenges** (any order) from that level's set; Mastery Rank = onboarding rank + the sum of path levels, starting at 0 on a fresh install. The existing unified XP backend is untouched but the frontend renames it to **Decibels (dB)** — a spendable currency earned ONLY by playing (songs, FeedBarcade rounds, quest rewards; no real-money path exists or may be added) — with spend tracked in a separate wallet so lifetime earnings stay monotonic. Rotating **daily/weekly quests** (deterministic per period, lazy instantiation, local-midnight / Monday resets) award dB and feed `quest_completed` challenges. A new **Progress** screen (rank hero, per-path checklists, quest countdowns, add-a-path) and **Shop** screen (themes via CSS-variable swaps under `html[data-fb-theme]`, avatar frames; atomic balance-checked purchases — 402 on insufficient dB, 409 on re-buy) join the v3 nav, and the topbar badge now shows Rank + challenge-set progress + dB balance. All definitions live in `data/progression/` JSON (paths/levels/challenges, quest pools, shop catalog) — adding a rank, challenge, quest, or cosmetic is a content edit + restart, never code; invalid content degrades to logged warnings. New tables (additive + idempotent): `progression_state`, `player_paths`, `challenge_progress`, `quest_state`, `wallet`, `shop_owned`, `shop_equipped`. New endpoints: `GET /api/progression`, `POST /api/progression/paths|onboarding|events` (events whitelists `minigame_run`; `song_completed` stays server-derived inside `POST /api/stats`, which now resolves the instrument server-side and reports an additive `progression` outcome key), `GET /api/shop`, `POST /api/shop/buy|equip`; equipped cosmetics ride along on `GET /api/profile`. A new **`progression` capability domain** (core-owned, kind: command, safety: safe — `inspect`, `record-event`, `list-shop`, `buy-item`/`equip-item` gated on user action) emits `challenge-completed`/`quest-completed`/`path-level-up`/`rank-changed`/`db-changed`/`calibration-completed`/`cosmetic-equipped`, mirrored as `progression:*` window events, with a redaction-safe diagnostics contributor; backend plugins get the symmetric `record_progression_event` context hook (the bundled minigames hub reports runs through it, guarded for standalone). Spec: `specs/010-progression-domain/`. Tests: `tests/test_progression.py`, `tests/test_progression_api.py`. **Migration notes:** existing XP totals carry over as lifetime dB (balance = lifetime spent); resetting a per-source XP contribution (e.g. a minigames profile reset) after spending can clamp the spendable balance to 0 until new dB is earned; drums-path v1 content uses currently-satisfiable goals (arcade rounds, quests, any-instrument plays) until drums scoring lands.
- **3D highway score FX (notedetect game-scoring layer).** The bundled `highway_3d` renderer 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-level `popKey`, so a chord pops once, not once per string), plus session-level FX from the new `notedetect:fx` event — 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 the `notedetect:skin` bus 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-panel `notedetect:fx` dispatch. `plugins/highway_3d` v3.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_3d` v3.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_3d` v3.26.0.
- **Enable/disable plugins from the v3 Pedalboard (footswitch backend).** Every `/api/plugins` entry now carries an `enabled` boolean (default `true`), and a new `POST /api/plugins/{plugin_id}/enabled` endpoint (`{"enabled": <bool>}` → `{"id", "enabled"}`) persists the choice to `CONFIG_DIR/plugin_state.json` (only non-default `enabled:false` entries are stored; a missing/corrupt file is tolerated and never crashes startup). The loader **skips disabled plugins at startup** — no requirements install, no `routes.setup()`, no screen/nav/capabilities — while still surfacing them in `/api/plugins` as 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/plugins` reflects 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 keep `capability_inspector` and `app_tour_*` always enabled (disable → `400`); unknown id → `404`; missing/non-boolean `enabled` → `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`, and `FEEDBACK_*` env vars all keep the `feedBack` name, so existing deployments and data are unaffected. The redesigned UI is additive and served behind a feature flag: `FEEDBACK_UI=v3` flips the `/` route to the new `static/v3/` shell, and `GET /v3` always serves it; the default `/` stays byte-identical to 0.2.9 until 0.3.0 flips the default. This release adds the `static/v3/` scaffold (navy app shell + styled fee[dB]ack wordmark, brand SVG + favicon + PWA manifest with 192/512 icons), an additive `fb` Tailwind color palette (legacy `dark`/`accent`/`gold` retained) with `static/v3/**` in the content globs, and the regenerated `static/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 (`#home` library, `#favorites`, `#settings`, `#player`, `#audio`, plugin nav containers) are kept verbatim so `static/app.js` boots **unmodified** and the whole engine (player/highway, plugin loader, capabilities, audio, library, settings) is reused as-is. Navigation is the shared `window.showScreen` across `#v3-*`, reused legacy, and `#plugin-*` screens, with a responsive hamburger and a `localStorage`/`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.js` wraps `window.showScreen` via 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_profile` tables in `web_library.db`, additive + idempotent): display name + avatar, a stable `player_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 under `CONFIG_DIR/avatars/`), `GET /api/profile/avatar/{name}` (safe-joined), `GET /api/profile/avatars` (bundled defaults under `static/v3/avatars/`), `GET /api/profile/progress` (one call for the badge), and `POST /api/xp/award`. **Unified XP:** `lib/xp.py` is the single XP curve (same math the minigames plugin shipped); the core `xp_profile` store is the one source of truth the profile badge reads, exposed to plugins via `context["award_xp"]`/`get_xp_progress`/`seed_xp`. The bundled **minigames** plugin now delegates XP to the core store (seeding once from its existing `profile.json` so 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-profile` screen. 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_stats` table (`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 award** `xp_for_run(score)` and a **streak** bump, both behind try/except so a side-effect failure never drops the stat write; or position-only `lastPlayPosition` → resume touch with no `plays` change), `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.js` tallies the `note:hit`/`note:miss` events the optional `feedBack-plugin-notedetect` already emits (and also accepts an explicit `note_detect:session-ended` summary), 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 via `lib/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_songs` tables in `web_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`, and `GET /api/session/continue` (derives the resume song + last position from `song_stats`, no new table). Frontend `static/v3/playlists.js` renders the `#v3-playlists` list + detail (drag-reorder, play, remove) and `#v3-saved`, and exposes `window.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-home` dashboard (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 via `playSong` + best-effort seek), a stats row (audio-routing widget placeholder until prompt 18, library count from `/api/library/stats`, plugins count from `/api/plugins` where `status==="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/settings` fields (`reference_pitch` clamped 430450, `instrument`, `string_count` 48, `tuning` name or semitone offsets); changing it emits `instrument:changed` so 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-free `static/v3/tuner-core.js` (YIN + frequency→note/cents, honoring the reference pitch); clicking opens the full `feedBack-plugin-tuner` screen when installed. CPU-friendly (~20 Hz, paused when the tab is hidden, respects `prefers-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 touches `audio-mixer.js` internals or `nam_tone` routes directly; "Not Connected" is the honest browser default (no native route), and it degrades on `no-owner`/`no-handler`/`failed` or absent capabilities. Refreshes on `instrument: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-plugins` screen 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 the `library` capability, 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 new `GET /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-injection` capability + native song-card actions (fee[dB]ack v0.3.0).** New core capability (`static/capabilities/library-card-actions.js`, owner `core.ui.library-card-injection`, exposed as `window.feedBack.libraryCardActions`) lets plugins **register** per-song library-card actions (id, label, placement, applicability, enabled state, run handler) with `action-registered`/`action-result` events — replacing the legacy `.song-card` DOM-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 existing `openEditModal`/`retuneSong` globals). Songs cards also gain **arrangement chips** (play a specific arrangement) and a **multi-select** mode with batch **Add to playlist** / **Save for Later**. Recipe in `docs/capability-recipes.md`; tests in `tests/js/library_card_actions.test.js`. Migrating the external card-action plugins (Sloppak Converter, Find More, editor) onto `register(...)` is a follow-up.
- **`centOffset` exposed via `getSongInfo()`** — the arrangement `<centOffset>` field (float, cents) is now parsed from all chart sources (loose folder XML, sloppak wire format) and sent as `centOffset` in the `song_info` WebSocket message. Plugins can read `getSongInfo().centOffset` to obtain the arrangement's pitch-shift offset — commonly `-1200.0` for extended-range bass (one octave down) or a small non-zero value for true-tuned content (e.g. A443 ≈ +11.8 cents). Defaults to `0.0` when absent.
- **`highway.getPhrases()` and `highway.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. Returns `null` when the song has no phrase data (GP imports, single-difficulty charts). Pair with the existing `hasPhraseData()` to gate phrase-aware code paths.
- **Tailwind freshness guard + wider plugin scan.** A new `tailwind-fresh` CI job (`.github/workflows/tests.yml`) rebuilds `static/tailwind.min.css` with the pinned `tailwindcss@3.4.19` and 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). The `tailwind.config.js` plugin content glob is widened to `./plugins/**/*.{js,html}`, which also scans non-`screen.js` plugin 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 plugin `styles` capability (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/plugins` now exposes validated capability declarations, validation warnings, unsupported-version metadata, UI/runtime domain declarations, and compatibility shim summaries for legacy `nav` / `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 coordinated `stems` diagnostics 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.session` coordinates `stems` without replacing the Stems plugin as the owner of actual stem playback/state.
- **Audio-mix control plane** — makes `audio-mix` the player mixer source of truth. Core now exposes `list-faders`, `get-fader-value`, `set-fader-value`, `inspect-route`, and `inspect-analyser` through 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-input` the redaction-safe source of truth for instrument input discovery and lifecycle. Core now exposes `list-sources`, `select-source`, `open-source`, and `close-source` through the capability runtime, persists selected logical sources, keeps inspect/list/select prompt-free, routes provider `source.open`/`source.close` operations 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-monitoring` the shared live-monitoring coordinator. Core now exposes provider registration/list/selection, explicit user-action `start`, requester-counted `stop`, prompt-free `inspect`/`monitoring.status`, and `set-direct-monitor` through the capability runtime. Monitoring starts integrate with selected `audio-input` readiness, 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 `playback` to an active core capability domain for song transport, timing, loop, route, requester/observer, bridge, and diagnostics state. Core now exposes `inspect`, user-authorized `start`, `pause`, `resume`, `stop`, `seek`, `set-loop`, and `clear-loop` through the capability runtime while `static/app.js` keeps 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 for `window.playSong`, legacy `song:*` events, `window.feedBack` transport helpers, loop helpers, and browser/native route handoff.
- **3D highway — Tone HUD, fret dividers, chord-diagram toggle, FPS counter.** The bundled `plugins/highway_3d` gains 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, via `h3dBgSetFretDividersVisible`), a **chord-diagram visibility** toggle (`h3dBgSetChordDiagramVisible`), and an **FPS counter** setting migrated to `BG_DEFAULTS.fpsVisible` (drops the legacy `h3d_showFps` localStorage key). Chord-diagram position is restricted to `tl`/`tr`; legacy `bl`/`br` values are coerced on load. Perf: accent-halo shell descriptors are pre-built per string in `initScene()` and the chord-verdict cache key is encoded as a number, eliminating per-frame allocations in the `drawNote()` 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.ogg` at the sloppak root and records it under a new top-level `preview:` manifest key (POSIX relpath, same shape as `lyrics`/`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 unknown `preview` key, so the change is purely additive (sloppak-spec.md §5.5 backward-compat). Documented in `docs/sloppak-spec.md` §2 alongside the other optional top-level keys. Enables [`feedBack-plugin-song-preview`](https://github.com/got-feedback/feedBack-plugin-song-preview) to 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 own `assets/` directory (AudioWorklet modules, WASM, images, etc.), so plugins can self-host browser-fetchable assets without a CDN (Principle II). Containment is enforced by `lib/safepath.safe_join` against `<plugin>/assets/`, so `..` traversal, absolute paths, and NUL bytes cannot escape `assets/` to reach a plugin's Python modules. `.js` is served as `application/javascript`. First consumer: the stems plugin's pitch-preserving time-stretch worklet.
- **Minigames framework — bundled as a core plugin (`plugins/minigames/`).** Promotes the upstream [`feedBack-plugin-minigames`](https://github.com/got-feedback/feedBack-plugin-minigames) repo into the core bundle so every FeedBack install gets the framework out of the box (same promotion path used for `highway_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 under `CONFIG_DIR/minigames/` and opted into the settings export. Other plugins that want to ship a minigame add a `minigame` block to their `plugin.json` and call `window.feedBackMinigames.register(spec)`; the SDK exposes scoring (`createContinuous` runs a self-contained YIN tracker; `createDiscrete` / `createChord` wrap `note_detect`'s `createNoteDetector`), 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`](https://github.com/got-feedback/feedBack-plugin-flappy-bend), shipped separately.
- **Alpha-build heads-up banner**when `/api/version` reports 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) and `bell` (MIDI 80 "Mute Triangle", also unused in real drum-kit MIDIs) to `lib/drums.py` PIECES. Inserted in the iteration order so the editor / highway lane ordering is *hi-hat → stack → crash → … → ride bell → bell*. Both are cymbals; default shape `circle_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`) and `convert_drum_track_from_midi` (`lib/midi_import.py`) gain an optional keyword-only `out_unmapped` parameter. 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.py` defines 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 permissive `drum_tab.json` validator. `lib/sloppak.py::load_song` reads the manifest's optional top-level `drum_tab:` key, parses + validates the JSON, and surfaces it on `LoadedSloppak.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 chunked `drum_hits` (500 hits per frame, same chunking as notes) — exposed to renderers via `bundle.drumTab`. `song_info` carries a `has_drum_tab` flag so viz pickers can auto-activate the drums highway regardless of which guitar arrangement is selected. `lib/gp2rs.py::convert_drum_track_to_drumtab` converts a Guitar Pro drum track to a `drum_tab.json` dict, 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.py` gains `list_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.json` and album art, is now discovered, indexed, and playable as a first-class library format alongside Sloppak. The scanner walks `DLC_DIR` for non-preview audio files and treats each parent directory that also contains XMLs as a loose song. Metadata follows a `manifest.json` → XML tags → folder-name priority chain (see `lib/loosefolder.py`). Songs are tagged `format: "loose"`, render an amber `FOLDER` badge in the library, and are filterable via the new "Folder" option in the format dropdown. Audio uses the shared vgmstream/`convert_wem` pipeline, cached under `AUDIO_CACHE_DIR`. The chart `<offset>` from the first non-vocals XML is now propagated to the frontend via `song_info.offset` and applied in `highway.setTime()` so loose folders authored against non-silence-padded audio stay in sync. Pairs with the companion `feedBack-plugin-loosefolder` plugin 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 in `drawNote` / `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 via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain on hit/active, red outline + suppressed body on miss). Custom renderers opt in by calling `bundle.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 adds `highway.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 via `nvidia-smi`/`rocm-smi`/`system_profiler`, container/Electron/bare runtime detection), full plugin inventory with git SHA + remote URL (read directly from `.git/HEAD` so it works in minimal runtime images without `git` installed) + 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-level `manifest.json` lists 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, and `key=`/`token=` query strings are replaced. Plugins opt their backend diagnostics in via a new `diagnostics` manifest field (`server_files` allowlist mirroring `settings.server_files` semantics, plus an optional `callable: "<module>:<function>"` resolved lazily via `load_sibling`). Frontend plugins push diagnostics via `window.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 in `docs/diagnostics-bundle-spec.md`.
- Structured logging bootstrap (phase 1 of #155). Three new environment variables control server log output: `LOG_LEVEL` (default `INFO`), `LOG_FORMAT` (`text` for coloured console, `json` for one-JSON-object-per-line suitable for Loki/ELK/Promtail), and `LOG_FILE` (optional path, rotated at 10 MB with 5 backups). HTTP responses now include a `X-Request-ID` correlation header (via `CorrelationIdMiddleware`); the same request ID appears as `request_id` in structured log lines emitted via the stdlib `logging` / `structlog` APIs during that request.
- Structured logging migration completed (phase 2 of #155, #159, #242). The 42 `print()` calls and 6 `traceback.print_exc()` calls across `server.py` and `lib/` have been migrated to levelled `feedBack.*` loggers. Silent `except: pass` blocks in `lib/sloppak.py` and `lib/sloppak_convert.py` now surface as `log.warning` / `log.debug` with the exception attached. WebSocket handlers (`highway_ws`, `ws_retune`) bind a `ws_conn_id` contextvar at accept time so every log line within a session carries a connection ID. A CI grep guard in `.github/workflows/tests.yml` fails the build if either `print(` or `traceback.print_exc(` reappears in `server.py` or `lib/`.
- **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.pyin` pitch 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_files` in `plugin.json` (list of relpaths under `CONFIG_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-names` returns 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}]`) via `getLyrics()`, mirroring `getBeats()`/`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.js` object pools at board init. Previously the pool factory grew lazily on first `.get()` past the high-water mark, allocating a fresh `T.Mesh` mid-rAF on dense 7/8-string charts and stalling those frames; the meshes were then permanently added to `noteG` (the pool only hides on `reset()`, never removes), bloating the scene graph for the rest of the session. Pre-warming spends the cost up front. Fold the per-frame `updateStringHighlights()` per-string loop with the post-call `mGlow`/`mAccentCore` emissive writes — one walk over the per-string scratch arrays instead of two. Replace `longestConsecutiveRun`'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=1` URL param: `console.log` p50/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 (each `createHighway()` 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](LICENSE) and [CONTRIBUTING.md](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_zip` and `server.py::_resolve_dlc_path` previously 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 new `lib/safepath.py::safe_join` helper 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 in `lib/sloppak_convert.py::split_stems` and `scripts/split_stems.py` previously called `ZipFile.extractall()` directly on user-supplied sloppaks; both now delegate to the same hardened `lib/sloppak.py::_unpack_zip` so every sloppak-unzip site in the codebase shares one containment guarantee. Tests in `tests/test_archive_traversal.py` and `tests/test_safepath.py` pin the contract for `../`, deep traversal, absolute paths, mixed `subdir/../../` 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.py` now 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 `fretHandMute` is now parsed into each note's `fret_hand_mute` (wire `fhm`) instead of being folded into `mute` (`mt`), matching `_parse_note` and preserving wire-format fidelity for both the template-expanded (synthetic-note) and explicit-`chordNote` paths. The 3D highway renders the fret-hand-mute X for `mt` *or* `fhm` notes, 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 through `dt = 0`).
- `gp2rs` now 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.json` when 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 on `change` via the partial-merge `/api/settings` endpoint, 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 the `POST /api/settings` handler now serializes its read-modify-write of `config.json` under 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/import` shares 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.dll` or `ImportError: TorchCodec is required for save_with_torchcodec`. The demucs subprocess now bootstraps a `torchaudio.save` → `soundfile.write` shim 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=1` and 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 new `lib/tones.py` helper and embeds them inline in the arrangement JSON under a `tones` key (`base`, `changes`, `definitions` — see `docs/sloppak-spec.md` §3.9). The highway WebSocket reads `base`/`changes` for sloppaks, and the Tones plugin (≥ 1.1.0) reads `definitions` to 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-hud` and `#player-controls` dynamically and insets both edges; a `ResizeObserver` on 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 `tickPosition` to the start of the *next* beat, so the cursor would race ahead by 500ms+ at typical tempos. The plugin now sends `tickPosition` one 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 like `w-[37px]` — MUST ship its own compiled stylesheet via the new `styles` manifest key, built with `corePlugins.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, run `bash scripts/build-tailwind.sh` and commit the regenerated CSS, or the `tailwind-fresh` CI 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/version` endpoint + `VERSION` file)
- `CHANGELOG.md` and semantic versioning
- Step Mode plugin
- `gp2midi` improvements 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_DIR` moved to `CONFIG_DIR` for AppImage compatibility
- Improved error message when plugin requirements fail to install
-57
View File
@@ -400,14 +400,6 @@ highway.setNoteStateProvider((note, chartTime) => {
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
#### 4. Chart-transform provider — remap the chart before rendering AND scoring (feedBack#952)
The core-owned `chart-transform` provider coordinator applies synchronous chart substitutions after difficulty filtering. Register and select providers through the capability domain; it owns persistence, refresh, splitscreen propagation, failure attribution, and diagnostics.
Provider inputs and staged outputs are isolated copies. Async returns or provider errors fail back to the original chart and expose only a fixed public failure reason. `getSongInfo()` remains the original chart contract; transform-aware consumers use the renderer bundle or `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
See [docs/capability-recipes.md](docs/capability-recipes.md#chart-transform-provider) for the manifest and registration example.
### Audio mixer fader registration (feedBack#87)
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
@@ -473,40 +465,6 @@ window.feedBack.diagnostics.contribute('my_plugin', {
Loaded from `static/diagnostics.js` ASAP in `<head>` so the console-wrap is in place before any other script runs. Available on the `window.feedBack.diagnostics` namespace alongside `snapshotConsole()`, `snapshotHardware()`, `snapshotUa()`, `snapshotLocalStorage()`, `snapshotContributions()`. Keep your payload small (< 100 KB) and don't include secrets — bundles are shared with maintainers.
### Detachable panes — pop your panel out into its own window
If your plugin has a floating panel that sits over the player — a mixer, a camera rig, a settings board — you can let the user pop it out into its own OS window and leave it there: while they play, across song switches, on a second monitor, minimized to the tray. Two calls:
```js
feedBack.panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, exactly as it is
});
feedBack.panes.attachChip(panelEl, 'camera_director');
```
**The host moves your real element.** Not a copy, not a re-render — the actual DOM node, adopted into the pop-out window, keeping its listeners and its closures. Your panel goes on running *your* code against *your* state. It looks and behaves like what was popped out because it **is** what was popped out. Nothing to mirror, nothing to keep in sync.
The rules below are all things that have already gone wrong. Full contract: **[docs/plugin-panes.md](docs/plugin-panes.md)**.
- **Your code still runs in the main window.** The element is *displayed* elsewhere; its closures, timers and `document` references still belong to the main realm. That is exactly why everything keeps working — and exactly why `document.body.appendChild(myPopover)` lands in the **main window, not the pane**. Anchor tooltips, popovers and menus to your panel, not to `document.body`. Measure with `el.ownerDocument.defaultView`, never a cached `window`.
- **Don't hide your panel yourself when it pops out.** Core hides it and leaves a "bring it back" stub. If you also hide it, you will hide the node that just moved — and blank the pane window.
- **Prefer `hidden` or a class over inline `display` for show/hide.** While popped out, core neutralises *placement* with `.fb-paned` (`position`, `inset`, `width`, `z-index`, `box-shadow`). An inline `display:none` on your panel reasserts itself the moment the pane docks back and the class is removed, so your panel returns invisible.
- **`element` is a function so it can be resolved late.** Return the *live* node. If you rebuild your panel (Camera Director rebuilds on every mode change), re-run `attachChip` — it returns a `detach()`; call it before re-attaching, and again in your teardown.
- **`isConnected` does not mean "docked".** A panel sitting in a pane window is very much connected — just not to *this* document. Test `el.ownerDocument === document`, or take the `onHost(hostId, el)` callback.
- **rAF is throttled while the main window is backgrounded** — and it will be, whenever the user is looking at your pane. Event-driven panels (sliders, buttons) are unaffected. A panel that *animates continuously* may run slowly while it is the only thing on screen.
- **Don't reach for BroadcastChannel, `postMessage`, or a second copy of your state.** There is one realm and one panel. If you find yourself synchronising, you have misunderstood the model.
- **Nothing is required.** No panes API on the host → skip both calls, and your panel behaves exactly as it does today.
### Keyboard Shortcuts
Plugins can register keyboard shortcuts via the global `window.registerShortcut()` function. Shortcuts appear in the `?` help panel.
@@ -596,21 +554,6 @@ tab, key/scale annotations, etc.). Published as **feedpak**; this codebase still
**sloppak** name internally — same on-disk format. [docs/sloppak-spec.md](docs/sloppak-spec.md) is
a local pointer + code map.
**The spec is sacrosanct — read it BEFORE changing how this app reads or writes packs.** The
spec repo defines the format; this app merely implements it ("a change is not part of the format
until it lands here" — feedpak-spec/GOVERNANCE.md). Any new manifest key, file, or directory the
app touches must land in the spec **first**, via the
[FEP process](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md) (proposal
issue → one spec PR updating spec + schemas + example + changelog → then re-run your PR's checks
here; the gate verifies against the spec's HEAD, so it goes green the moment your key is real).
CI enforces this: the `feedpak-spec` job
([docs/feedpak-spec-gate.md](docs/feedpak-spec-gate.md)) fails any PR whose code touches a
manifest key the spec doesn't declare, and there is **no in-repo bypass** — the exceptions
file is a closed grandfather list that only shrinks. If the format seems to be missing something
you need, that's a FEP conversation, not a workaround. (Cautionary tale: `original_audio`, #933 —
shipped without a spec entry, and third-party packers reverse-engineered a folder convention out
of a code comment.)
**Key code:**
- `lib/sloppak.py` — format detection, zip/directory resolution, metadata extraction, song loading
- `lib/sloppak_convert.py` — sloppak assembly pipeline, Demucs stem splitting
+3 -12
View File
@@ -153,14 +153,6 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
## Chart-Transform Domain
The chart-transform slice (#952) is a core-owned provider coordinator implemented by [static/capabilities/chart-transform.js](../static/capabilities/chart-transform.js). Its commands register, select, clear, and refresh providers; `chart.transform` is the provider operation. Selection persists by provider id and applies to the primary highway and announced splitscreen instances.
The synchronous `highway.setChartTransform` data-plane hook runs at chart ready, mastery changes, and refresh—not per frame. Transforms receive isolated chart data after difficulty filtering and may replace notes, chords, anchors, hand shapes, chord templates, string count, tuning, capo, and cent offset. Outputs are isolated and timeline arrays are time-sorted before the built-in renderer, renderer bundle, or public getters read them. Async returns and other provider failures clear the stage and retain the original chart.
`getSongInfo()` retains original metadata; effective values are exposed by the renderer bundle and dedicated highway getters. Diagnostics under `feedBack.chart_transform.diagnostics.v1` contain provider selection/install state and a fixed public failure reason, never chart data, song identity, or raw exceptions. The domain has no compatibility shim because no earlier chart-substitution surface exists.
## MIDI-Input Domain
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
@@ -200,7 +192,7 @@ Core domains include review metadata in diagnostics:
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
- `diagnostic`: support/inspection-only runtime surfaces.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane, and the chart-transform slice (#952) promotes `chart-transform` as the pre-render/pre-scoring chart substitution coordinator. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
@@ -256,7 +248,7 @@ UI placement and settings contributions are real FeedBack surfaces, but they are
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade. The `chart-transform` domain follows this doctrine: its substitution runs through the synchronous `highway.setChartTransform` hook (staged once per chart change), while the capability surface owns only registration, selection, and diagnostics.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
## First-Party Management Plugins
@@ -303,9 +295,8 @@ From the `feedBack/` directory:
```bash
node --check static/app.js
node --check static/capabilities.js
node --check static/capabilities/chart-transform.js
node --check static/diagnostics.js
node --check plugins/capability_inspector/screen.js
node --test tests/js/*.test.js
pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q
```
```
-51
View File
@@ -499,57 +499,6 @@ window.feedBack.on('progression:quest-completed', (e) => {
});
```
## Chart-Transform Provider
Plugins that transpose, simplify, annotate, or otherwise rewrite chart data register as `chart-transform` providers (#952). The effective chart reaches the built-in highway, custom renderers, and highway getters on primary and splitscreen instances.
```json
{
"id": "my_transform",
"name": "My Transform",
"standards": ["capability-pipelines.v1"],
"capabilities": {
"chart-transform": {
"roles": ["provider"],
"operations": ["chart.transform"],
"mode": "active",
"compatibility": "none",
"ownership": "multi-provider",
"safety": "safe",
"version": 1
}
}
}
```
```js
const api = window.feedBack.capabilities;
await api.dispatch({
capability: 'chart-transform',
command: 'register-provider',
source: 'my_transform',
payload: {
providerId: 'my_transform',
label: 'My Transform',
transform(input) {
const notes = rewriteNotes(input.notes);
const allNotes = input.allNotes === input.notes ? notes : rewriteNotes(input.allNotes);
return { notes, allNotes };
},
},
});
await api.dispatch({ capability: 'chart-transform', command: 'select-provider',
source: 'my_transform', payload: { providerId: 'my_transform' } });
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: 'my_transform' });
```
`transform(input)` receives filtered `notes`, `chords`, `anchors`, and `handShapes`, plus full-difficulty `allNotes`/`allChords`, `chordTemplates`, `stringCount`, and `songInfo`. It may synchronously return any subset of those arrays plus `tuning`, `capo`, or `centOffset`; null leaves the chart unchanged. The host isolates provider inputs and outputs, time-sorts accepted timelines, and falls back to the original chart on failure.
Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
## Future Expansion Domains
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
-4
View File
@@ -60,10 +60,6 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable.
## Chart-Transform Control Plane Slice
The chart-transform slice (#952) is an active provider-coordinator domain. It owns provider lifecycle, persisted selection, refresh, failure attribution, and redaction-safe diagnostics. Its synchronous highway hook applies isolated provider output after difficulty filtering to built-in, custom-renderer, and getter consumers across primary and splitscreen highways. No compatibility shim is needed; per-panel independent selection remains a follow-up.
## Recommended Next Slices
The plugin inventory suggests this migration order after the audio graph/session and playback slices:
+1 -1
View File
@@ -20,7 +20,7 @@ Core domains also have a review scope. **Active contract** domains are wired to
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
| chart-transform | provider-coordinator | safe | inspect, list-providers, register-provider, unregister-provider, select-provider, clear-provider, refresh | chart.transform | Synchronous chart substitution after difficulty filtering (#952). Provider data is isolated, timelines are sorted, and failures retain the original chart with a fixed public reason. Diagnostics contain provider and selection state, never chart data, song identity, or raw exceptions. |
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
-132
View File
@@ -1,132 +0,0 @@
# The feedpak spec-conformance gate
`tools/check_spec_conformance.py`, run in CI as the `feedpak-spec` job.
## Why
feedpak is published as an **open format**: its own repo
([got-feedback/feedpak-spec](https://github.com/got-feedback/feedpak-spec)), a normative spec, JSON
Schemas, and a reference validator. That is a promise to everyone outside this codebase — third-party
packers, converters, and players build against the spec, and the spec is meant to be the complete and
authoritative description of a pack.
The moment core reads a manifest key the spec doesn't define, that promise breaks silently:
- A spec-compliant pack is no longer guaranteed to be a fully-working pack.
- The reference validator can't warn authors about a key it has never heard of — it will happily green-light
the key, and every misspelling of it.
- The format's real definition drifts into our source tree. In the case that motivated this gate
([#933](https://github.com/got-feedback/feedback/issues/933)), third-party tooling started emitting an
`original/` directory that no code anywhere requires — the convention was reverse-engineered from an
example in a *code comment*.
The rule this gate enforces: **any manifest key core reads _or writes_ must be in the spec before core
ships code that depends on it.** Spec first, implementation second. Writes are not exempt — a key core
writes lands in every pack we emit, so an undeclared one seeds the ecosystem with non-spec data.
Note that "get it into the spec" is not automatically the right fix for an existing violation — for
`original_audio` it isn't. The spec already carries the pre-separation mixdown as a stem
(`{id: full, file: stems/full.ogg}`), so that key added a *second, redundant* location for audio to a format
that already had one, and the resolution is to remove it rather than bless it. The gate takes no position on
which way a violation resolves; it only insists that one of the two happens deliberately, in the open,
before the code merges.
## What it checks
We can't mechanically prove core *interprets* a key the way the spec means. We can prove four surface
properties, and they cover the drift that actually occurs.
| Layer | Check | Catches |
|---|---|---|
| 1. key-coverage | Every manifest key core reads **or writes** is declared in the spec's `manifest.schema.json`. | Core growing a key the spec never defined — the #933 class. |
| 2. allowlist-closed | `feedpak-spec-exceptions.yml` has not **grown** relative to the base branch. | Someone routing around the FEP process by allowlisting their own new key. |
| 3. forward | Core's `load_song()` ingests every example pack the spec ships. | The spec adding or tightening something core ignores or breaks on. |
| 4. reverse | Every pack committed to this repo passes the spec's `tools/validate.py`. | Core (or a contributor) committing a pack the spec would reject. |
Layer 1 works by walking the AST of the modules listed in `READERS` and collecting every literal key touched
on a manifest dict (`manifest.get("x")`, `manifest["x"]`, and the wrapped
`(load_manifest(p) or {}).get("x")` form used in `lib/enrichment.py`).
**Reads and writes are both checked, and reported differently.** A key core *writes*
(`manifest["x"] = v`, as `lib/songmeta.py` does) is spec surface pointed outward: it puts a key into every
pack we emit, so an undeclared one seeds the ecosystem with non-spec data. Subscripts are classified by AST
context — `Store` is a write, `Load` is a read — so `manifest["year"] = ...` is not miscounted as a read.
## When it fails
You added a manifest key the spec doesn't define. **There is exactly one way forward, and it is not in this
repo.**
Land the key in the spec through the **feedpak Enhancement Proposal (FEP)** process
([feedpak-spec/CONTRIBUTING.md](https://github.com/got-feedback/feedpak-spec/blob/main/CONTRIBUTING.md)):
1. **Open a FEP issue** on `got-feedback/feedpak-spec` — the problem, the proposed on-disk shape (manifest
key and/or side-file), backward compatibility, and the version bump it implies.
2. **Discuss**, until it has a clear shape and rough consensus.
3. **Land one PR there** that updates the normative spec (`spec/feedpak-v1.md`), the relevant JSON
Schema(s), an example in `examples/` that exercises it, and the changelog — *together*. A PR touching
only one of those is incomplete.
4. **Back here**, just re-run your PR's checks. The gate verifies against the spec's HEAD, so the moment
your key is genuinely part of the format, your PR goes green — nothing to bump, nothing to maintain.
That's deliberately the only route — no experimental prefix, no self-serve allowlist — and it's usually a
quick one for additive keys. The reason it's worth the round-trip: the gate checks the whole repo against
the living spec, so if non-conformance ever lands, it shows up as red CI on *every* teammate's open PR, and
only the person who introduced it can clear it. Going through the FEP keeps your change clean and keeps
everyone else unblocked.
The spec's own governance says the same thing:
> This repository defines the format only. Applications that read or write feedpak ... track this spec as a
> dependency; they do not drive it. **A change is not part of the format until it lands here.**
> — [feedpak-spec/GOVERNANCE.md](https://github.com/got-feedback/feedpak-spec/blob/main/GOVERNANCE.md)
### `feedpak-spec-exceptions.yml` is a closed grandfather list, not a hatch
It exists solely because `original_audio` predates the gate. **CI fails any PR that adds an entry** (layer 2
diffs it against the base branch), so the list can only ever shrink. Entries are debt, each carries a
tracking issue, and each disappears when the underlying key is removed from core. The gate also fails on a
*stale* entry — the spec caught up, or core stopped touching the key — so the file cannot quietly become
somewhere drift accumulates.
Deleting an entry does not, by itself, get you past the gate: layer 1 still fails while core reads the key.
The entry goes when the **code** goes.
## Tracking the spec's HEAD
The gate checks out `feedpak-spec` at **HEAD**, on purpose: the app must conform to the *living* spec, and
nobody should have to maintain a pin. The dev flow is fully self-serve — gated PR → FEP → spec merge →
re-run checks → green.
Two properties to know about:
- **The normal FEP is additive** (a new optional key), which only ever makes the gate *looser* — it cannot
redden anyone's PR. Only a **breaking** spec change (removing/renaming a key the app uses, tightening the
validator against committed packs) turns PRs red repo-wide — and per the spec's compatibility policy that
is a rare, deliberate MAJOR event, exactly when an org-wide "the app is out of conformance" signal is the
right outcome. The CI job logs the exact spec SHA each run verified against, so a red run is reproducible.
- **CI results can change over time on the same commit** — that is inherent to tracking a living contract,
and it is the point: green means "conformant *now*", not "conformant when written".
## Limitations
Known, and worth fixing in follow-ups rather than blocking on:
- **Layer 1's receiver detection is heuristic.** Locals *assigned from* `load_manifest(...)` are discovered
flow-aware whatever they're called (chart.py's `m` taught us that), and the inline
`(load_manifest(p) or {}).get(...)` form is recognised — but a manifest that arrives as a **function
parameter** is only recognised by name (`MANIFEST_VARS`: `manifest`, `mf`). A parameter called something
else would slip. The hardening step is to route all manifest access through a single declared
`KNOWN_MANIFEST_KEYS` registry in `lib/sloppak.py`; the gate then compares registry against schema exactly
instead of inferring.
- **Layer 1 covers top-level keys only.** Nested structure (`arrangements[].file`, `.id`, `.notation`) isn't
checked. Extending to it means walking the schema's `$ref` subschemas.
- **Layer 1 recognises `get`, `setdefault`, subscripts, and the known gap-fill helper** as key access.
`update()` and `pop()` aren't used against a feedpak manifest anywhere in the tree, so they're deliberately
not special-cased rather than speculatively handled. `readers-complete` reuses the same scanner
(`keys_touched()`), so this blind spot is shared, not doubled: a module using only unrecognised access forms
would evade both.
- **Layer 4 can't catch unknown keys**, because `manifest.schema.json` sets `additionalProperties: true` and
the reference validator deliberately "treats unknown keys/files as forward-compatible". Fixing this
properly belongs in the spec (tighten the schema, or give the validator a `--strict` mode). Until then,
layer 1 is the only thing standing between us and the next `original_audio`.
-354
View File
@@ -1,354 +0,0 @@
# Detachable panes (`window.feedBack.panes`)
Pop a panel out of the app into its own OS window, and leave it there: while you
play, across song switches, on a second monitor, minimized to the system tray.
Panes exist because the player's rail popovers are **exclusive** — opening one
closes the last. You cannot watch the mixer while riding the camera, and both
vanish the moment you want to look at the highway.
---
## The whole idea, in one sentence
**We move the real element.**
Not a copy of your panel. Not a re-implementation of it in the pop-out window.
The actual DOM node. Same-origin windows can adopt each other's nodes, and an
adopted node keeps its event listeners and its closures — so your panel goes on
running *your* code, against *your* state, in *your* realm. The app's stylesheets
are copied into the pane window, so it looks identical too.
What you popped out is what you get. That is the promise, and it is the reason
there is no `ctx`, no state mirroring, no cross-window RPC and no second copy of
your UI to keep in step with the first. Those are all solutions to a problem we
simply do not have.
---
## Adding a pane to your plugin
Two lines.
```js
// Guard: the panes API is optional. On a host without it, skip both calls and
// your panel behaves exactly as it does today.
const panes = window.feedBack && window.feedBack.panes;
if (panes && typeof panes.register === 'function') {
panes.register({
id: 'camera_director',
title: 'Camera Director',
icon: '🎥',
element: () => panelEl, // your existing panel, as it is
});
panes.attachChip(panelEl, 'camera_director');
}
```
`attachChip()` injects **the** standard pop-out chip (`⇱`) — same glyph, same
place, same behaviour in every plugin. Clicking it moves your panel to whichever
**host** the router picks — usually a pop-out window, but the dock when a window
can't be had (a blocked pop-up, or `defaultHost: 'dock'`) — and leaves a
"⇲ … is popped out" stub in its place. Clicking the stub brings the panel back, to
exactly the spot it left. Core owns the chip, the hiding and the stub, so you write
no show/hide logic.
That's it. Your sliders, your presets, your tabs, your CSS, your event handlers,
your state — all of it comes along, because none of it moved anywhere except into
a different window's document.
### `element` is a function for a reason
It is resolved at open time, not at registration. Plugins commonly build their
panel lazily on first use, or rebuild it wholesale when something changes (Camera
Director rebuilds its panel on every mode change). Asking for it when we need it
means we always move the live one.
**If you rebuild your panel, re-attach the chip.** Rebuilding takes the chip with
it. `attachChip()` returns a `detach()`; call it before re-attaching, and again in
your teardown — otherwise you leave a stub pointing at DOM that no longer exists.
```js
if (chipDetach) chipDetach();
chipDetach = panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Re-attaching is safe while the pane is popped out: the chip reconciles against the
pane's real state, so a panel rebuilt mid-pop-out stays correctly stubbed.
### The two things core changes about your element
**1. Placement.** `.fb-paned` is added while the pane is out:
```css
position: static; inset: auto; margin: 0; width: 100%;
max-width: none; max-height: none; z-index: auto; box-shadow: none;
```
Your panel was almost certainly a fixed overlay pinned to a corner of the app
(`position:fixed; top:72px; right:18px; width:288px`). Alone in its own window,
every one of those is wrong — it would float 72px down from the top of a 380px
window, still 288px wide, still casting a shadow over nothing.
Note there is deliberately **no `display` override**: a panel that is
`display:flex` or `grid` stays that way. Colours, borders, radius, padding, fonts
and your panel's own internal layout are untouched.
**2. Visibility.** A panel is usually hidden until its launcher is clicked, and a
pane can be opened from the tray or the rail without that ever happening — so core
un-hides it, in the two ways a panel is actually hidden:
```js
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
```
**Both are restored exactly as they were when the pane docks**, along with the
`.fb-paned` class. A panel that was closed when you opened its pane from the tray
goes back to being closed; one that was open stays open.
---
## Spec
```js
feedBack.panes.register({
id, // required, unique
element, // required — an Element, or a function returning one
title, // shown in the pane window's title bar, the dock card, the tray
icon, // one glyph, for the dock/tray/launcher lists
width, height, // the pane window's initial size (it remembers yours after that)
defaultHost, // 'window' (default) or 'dock'
onHost, // optional (hostId | null, el) => void — re-measure/re-anchor
});
```
```js
feedBack.panes.attachChip(el, paneId, { header }) // → detach()
feedBack.panes.open(id, { host }) / close(id) / detach(id) / dock(id) / focus(id)
feedBack.panes.isOpen(id) / hostOf(id) / get(id) / list()
```
`attachChip` puts the chip in the `header` element you pass, else in
`el.querySelector('[data-pane-header]')` if it finds one, else at the top of `el`.
An explicit `header` always wins.
---
## Hosts
`detach(id)` puts a pane in the best host available:
| host | | |
|---|---|---|
| `window` | 10 | A real OS window. In the desktop app: remembered bounds, always-on-top, system tray. |
| `dock` | 0 | A card in the in-window stack. **The floor** — always available, so opening a pane can never fail. |
You don't pick; you declare `defaultHost` and the router does the rest.
In the **desktop app** a pane you left popped out comes back popped out on next
launch. In a **browser** it comes back **docked** — a browser blocks
`window.open()` without a user gesture, so restoring it would only ever produce a
"pop-up blocked" toast. The chip pops it out again on your next click.
---
## Best practices
Every item below is something that has already gone wrong, in this codebase, on
this feature. They are cheap to get right up front and confusing to diagnose later
— a broken pane usually *looks* perfect.
### 1. Your code still runs in the main window
The element is *displayed* in the pane window, but its closures, its timers and its
`document` references all still belong to the main realm. **That is precisely why
everything keeps working** — and it has one sharp consequence:
```js
// WRONG — lands in the MAIN window, not the pane the user is looking at.
document.body.appendChild(myTooltip);
// RIGHT — anchored to the panel, so it travels with it.
panelEl.appendChild(myTooltip);
```
**And every lookup for something inside your panel.** Once the panel has moved,
`document.getElementById('my-panel-thing')` returns `null` — so every update it
guards silently stops happening, precisely while the user is looking at the panel.
No error. Just a UI that quietly goes dead.
```js
// WRONG — null once the panel is popped out.
document.getElementById('my-panel-hint').textContent = msg;
// RIGHT — search FROM the panel; works in either document.
panelEl.querySelector('#my-panel-hint').textContent = msg;
```
Elements that live outside your panel (your plugin's *screen*, host chrome) never
move, and should keep using `document.getElementById`. Audit which is which — in
the stem mixer, four ids were inside the panel and a dozen were not.
Same for measuring and popovers. `window.innerWidth` is the *main* window's, and a
dismiss listener on `window` watches a window the user isn't clicking in. Use
`el.ownerDocument` / `el.ownerDocument.defaultView` when you need the window your
panel is actually in.
### 2. Don't hide your panel yourself
Core hides it and leaves a "bring it back" stub. If your plugin *also* hides it,
you are hiding the node that just moved — and the pane window renders nothing.
(This is not hypothetical: core's own chip did exactly this, and the first
pop-out shipped blank because of it.)
### 3. Prefer `hidden` or a class for show/hide
Core makes your panel visible while it's hosted — it clears `hidden`, and clears an
inline `display: none` if that's how you hide — and **restores both on dock**. So
either style works.
`hidden` is still the better choice: it composes with everything, and it leaves
your panel's `display` mode (`flex`, `grid`, whatever it is) entirely alone. Core
deliberately does not override `display` for exactly that reason.
```js
panel.hidden = true; // best
panel.style.display = 'none'; // works — core saves and restores it
```
### 4. `element` is a function — return the *live* node
It is resolved when the pane opens, not when you register. Plugins build panels
lazily, and rebuild them wholesale (Camera Director rebuilds on every mode
change). If you rebuild yours, **re-attach the chip**:
```js
if (chipDetach) chipDetach(); // attachChip returns a detach()
chipDetach = feedBack.panes.attachChip(panel, PANE_ID, { header: toolsEl });
```
Call `chipDetach()` in your teardown too, or you leave a stub pointing at DOM that
no longer exists.
### 5. `isConnected` lies about a panel that is a pane
This one has cost more debugging than everything else on this page combined, and
it lies in **both directions**.
**It says `true` when your panel is not here.** A panel sitting in a pane window is
`isConnected` — just not to *this* document. Code asking "am I still mounted?" gets
`true` and then acts on a panel that is somewhere else entirely.
**It says `false` when your panel is perfectly fine.** The host *detaches* the
element the moment a pop-out starts, before the new window has even loaded. In that
gap `isConnected` is `false` — and any code that rebuilds on that basis builds a
**second panel**, while the host is still holding the first.
That second panel is the one your module variables now point at. The one the user
can *see* is the original, owned by nobody. So:
- its close button closes the *other*, invisible panel — "the X doesn't work"
- your chip gets re-attached to the impostor — "the pop-out icon vanished"
Two baffling symptoms, one duplicate, and nothing in the stack trace to suggest it.
**Ask the pane system, not the DOM.** It knows where your element is:
```js
function paneOwnsPanel() {
const panes = window.feedBack && window.feedBack.panes;
return !!(panes && panes.isOpen && panes.isOpen(MY_PANE_ID));
}
// "Is my panel gone?" — not "is it in this document?"
if (panel && (panel.isConnected || paneOwnsPanel())) return panel; // alive; possibly elsewhere
```
Every `isConnected` check on a panel that can be a pane needs this. In the stem
mixer that was `ensureMixerPanel()` (which rebuilt) *and* the MutationObserver's
fast path (which decided the UI was unmounted and swept on every mutation).
For "which document is it in right now", use `el.ownerDocument === document`, or
take the optional `onHost(hostId, el)` callback, which fires on both moves.
### 6. If your plugin can be re-injected, it must be able to remove itself
The host may run your script more than once — a screen re-entry, a version change.
Without a teardown, the second run builds a second panel while the first one is
still on screen, and every module variable in the new instance points at the new,
invisible one. The user clicks the panel they can see; nothing happens.
Everything stateful duplicates: observers, timers, listeners. And one thing is
worse than duplicated — **your pane registration**:
```js
panes.register({ id, element: () => panel }); // resolved LAZILY, at open time
```
First registration wins, so a stale one hands the host `panel` from a **dead
instance**. Popping out then moves a panel nobody owns.
So publish a teardown handle and call it at the top of your script:
```js
if (window.__myPluginInstance?.destroy) {
try { window.__myPluginInstance.destroy(); } catch (e) { /* tear down what we can */ }
}
window.__myPluginInstance = {
destroy() {
observer?.disconnect();
clearTimeout(myTimer);
chipDetach?.(); // attachChip() returned this
panes?.unregister?.(MY_PANE_ID); // ← the one people forget
document.querySelectorAll('#my-panel').forEach((n) => n.remove());
},
};
```
Belt and braces: when you build your panel, remove any node carrying its id that
isn't yours. A zombie panel is worse than no panel — it looks alive and does
nothing.
### 7. Expect rAF to be throttled while your pane has focus
Chromium throttles a **backgrounded** window's `requestAnimationFrame` — and the
main window is exactly what's backgrounded while the user is looking at your pane.
Your rAF lives in the main window.
Event-driven panels (sliders, buttons, presets) don't care. A panel that
*animates continuously* may run slowly precisely when it's the only thing on
screen. Drive such animation from data you already have, or accept the stutter.
### 8. Don't synchronise anything
No `BroadcastChannel`, no `postMessage`, no second copy of your state, no mirrored
UI. There is **one** realm and **one** panel. If you find yourself writing sync
code, you have misunderstood the model — the whole point is that there is nothing
to sync.
### 9. Nothing here is required
On a host without the panes API, `feedBack.panes` is `undefined`. Skip both calls
and your panel behaves exactly as it does today. Guard, don't depend:
```js
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') return;
```
---
## Things core guarantees
- **The element goes home exactly where it came from** — same parent, same position
among its siblings. Don't move it yourself while it's popped out.
- **It comes home alive.** Core evacuates the element *before* the pane window's
document is destroyed. (Get this wrong — dock after the window dies — and the
node returns looking perfect with every listener in its subtree silently gone.
That bug is why this section exists.)
- **A pane window the user closes, or that crashes, is reaped** and the element
docked back. Your panel is never stranded in a dead document.
- **The app's stylesheets are copied into the pane window**, so your panel looks
identical — including your plugin's own `styles` sheet.
-20
View File
@@ -189,23 +189,3 @@ out of the capability graph.
- [ ] `#player` overlays keep `z-index` ≤ the chrome layers (transport/HUD 20,
rail 30, popovers 40).
- [ ] Verify at `/` — it and `/v3` serve the same (and only) v3 shell.
## Injecting into core shells (profile, dashboard)
Core screens that accept plugin sections render **mount points** — usually
empty, sometimes holding core's own **fallback content** (the Dashboard's
career slot ships the plugin-count stat) — and announce each (re)build with a
DOM event, because their `innerHTML` swap wipes anything previously injected.
A plugin listens for the event and **replaces the mount's content** (never
append — a fallback may be present) by id — the same seam every time:
| Shell | Event | Mounts |
| --- | --- | --- |
| Profile | `v3:profile-rendered` | `#v3-profile-passports-mount` (career wall), `#v3-profile-feats-slot`, `#v3-profile-achievements-mount` |
| Dashboard | `v3:dashboard-rendered` | `#v3-dash-career-slot` (career card; core's plugin-count stat is the fallback content a plugin may replace) |
| Settings | `v3:settings-rendered` | per-plugin `settings.html` panels |
Rules: inject on every event (the mount is fresh), keep the section
**absent-not-empty** (no state → leave the mount alone / empty), and guard
re-wired listeners with a `dataset` flag when your own refresh path can run
against an unwiped mount.
+1 -3
View File
@@ -61,8 +61,6 @@ extractions and twenty-two `routers/` modules, plus lib/library_registry.py for
and is a monolith in its own right, to be split per-table once the router train
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) · `plugins/career/screen.js`
(1,530 — career v3 gigs + gold pushed it over; split plan: carve the gig block into a
`scriptType: module` file when career work next touches it) — and every monolith with a PR
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
by policy — the norm governs source files.
-56
View File
@@ -1,56 +0,0 @@
# CLOSED grandfather list — manifest keys core reads or writes that predate the
# spec-conformance gate and that the feedpak spec does not define.
#
# Please don't add entries here — CI will flag any PR that grows this list, so
# it can only shrink over time. That's by design, not distrust: the moment the
# app touches a key the spec doesn't define, every teammate's PR starts failing
# the conformance gate too, and whoever added the key is the only person who
# can fix it. The FEP process below avoids putting anyone in that spot. The
# feedpak spec's own governance is explicit:
#
# "This repository defines the format only. Applications that read or write
# feedpak ... track this spec as a dependency; they do not drive it.
# A change is not part of the format until it lands here."
# — got-feedback/feedpak-spec, GOVERNANCE.md
#
# So a new manifest key goes through the feedpak Enhancement Proposal (FEP)
# process — see feedpak-spec/CONTRIBUTING.md:
#
# 1. Open a FEP issue on got-feedback/feedpak-spec describing the problem, the
# on-disk shape, backward compatibility, and the version bump implied.
# 2. Land one PR there updating the normative spec, the JSON Schemas, an
# example that exercises it, and the changelog — together.
# 3. Back here, re-run this PR's checks. The gate verifies against the spec's
# HEAD, so once your key is in the spec, the gate goes green.
#
# That's the supported route — and usually a quick one for additive keys. If
# your PR is blocked by this gate, a FEP will get you unblocked properly; an
# entry here won't (CI rejects it).
#
# Entries below exist ONLY because they predate the gate. Each is debt with a
# tracking issue, and each disappears when its issue is fixed. The gate also
# fails if an entry goes stale — the spec caught up, or core no longer reads or
# writes the key — so this file cannot quietly become a place drift hides.
exceptions:
- key: original_audio
issue: https://github.com/got-feedback/feedback/issues/945
reason: >-
Added by #583 (the full mix played while every stem fader sits at unity,
since demucs recombination is lossy). It never went through a FEP and the
spec does not define it — the drift this gate exists to prevent.
#933 fixed the drift: feedpak 1.15.0 RESERVES the stem id `full` for the
complete mixdown (feedpak-spec#53), and core now reads the full mix from
that stem. Nothing depends on this key any more — not the loader, not
lib/enrichment.py, not the stems plugin, and the packer no longer writes it.
What remains is a READ-ONLY deprecated fallback in lib/sloppak.py
(_legacy_full_mix), kept for one release because every pack produced before
the spec caught up carries `original_audio: original/full.ogg` and would
otherwise silently lose its pristine mix. tools/migrate_full_mix_stem.py
rewrites those packs into the spec shape.
This entry disappears with that fallback — tracked by #945, which cannot be
forgotten: the gate fails if the entry goes stale, and deleting the read is
what makes it stale.
+1 -9
View File
@@ -14,7 +14,6 @@ import os
from pathlib import Path
import appstate
from safepath import resolved_root
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
@@ -87,14 +86,7 @@ def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
or PureWindowsPath(safe).drive):
return None
try:
# The library root is fixed for the life of the process, but this
# function runs once per song / art fetch / scanned row — and
# `.resolve()` lstats every path component. Re-resolving here was
# ~23,500 stat calls/sec on a 50,944-song library, which pins a core
# when the library sits on a FUSE mount (NTFS-3G, SMB, sshfs) where each
# stat is a userspace round trip. Resolve the root once; see
# safepath.resolved_root for the caching contract.
root = resolved_root(dlc)
root = dlc.resolve()
# normpath collapses `.`/`..`/duplicate separators purely lexically —
# it never touches the filesystem, so an in-library junction component
# is preserved (allowed) while `..`/absolute segments still escape and
+5 -20
View File
@@ -368,12 +368,10 @@ def _acoustid_gate() -> "JSONResponse | None":
def _song_audio_file(filename: str) -> "str | None":
"""Resolve a LIBRARY song (by filename/id) to a local master-audio file for
fingerprinting: a sloppak's complete mixdown, or a loose folder's audio. None
when the song can't be found or carries no mixdown (a pack that kept only its
separated stems — an acoustic fingerprint of one re-summed from them would not
match the recording, so we decline rather than submit a lossy reconstruction).
Mirrors serve_sloppak_file's containment guards so a crafted filename can't
read outside DLC_DIR / the pack."""
fingerprinting: the full-mix `original_audio` extracted from a sloppak, or a
loose folder's audio. None when the song can't be found or ships no full-mix
audio (some packs carry only stems). Mirrors serve_sloppak_file's containment
guards so a crafted filename can't read outside DLC_DIR / the pack."""
dlc = _get_dlc_dir()
if not dlc:
return None
@@ -385,20 +383,7 @@ def _song_audio_file(filename: str) -> "str | None":
canon = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError:
return None
manifest = sloppak_mod.load_manifest(resolved) or {}
# The mixdown is the RESERVED `full` stem (spec §5.3). Unlike playback,
# fingerprinting wants it even when it is the pack's ONLY stem — a
# single-mix pack is exactly the master audio we want to fingerprint —
# so this asks find_full_mix() rather than partition_stems().
stems = manifest.get("stems") or []
full = sloppak_mod.find_full_mix(
[s for s in stems if isinstance(s, dict)]
)
rel = full.get("file") if full else None
# DEPRECATED fallback: packs written before the spec reserved `full` put
# the mixdown behind a top-level `original_audio:` key instead (#933).
if not isinstance(rel, str) or not rel.strip():
rel = manifest.get("original_audio")
rel = (sloppak_mod.load_manifest(resolved) or {}).get("original_audio")
if not isinstance(rel, str) or not rel.strip():
return None
src = sloppak_mod.get_cached_source_dir(canon)
+1 -1
View File
@@ -2080,7 +2080,7 @@ def convert_file(
safe_name = track.name.strip().replace(" ", "_").replace("/", "_")
filename = f"{safe_name}_{arr_name or 'arr'}.xml"
filepath = out / filename
filepath.write_text(xml_str, encoding="utf-8")
filepath.write_text(xml_str)
output_files.append(str(filepath))
return output_files
+2 -2
View File
@@ -1680,7 +1680,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str, encoding="utf-8")
filepath.write_text(xml_str)
output_files.append(str(filepath))
continue
@@ -2108,7 +2108,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str, encoding="utf-8")
filepath.write_text(xml_str)
output_files.append(str(filepath))
# Keys/piano tracks additionally get a standard-notation sidecar
+6 -76
View File
@@ -72,59 +72,15 @@ def _parse_gpif(data: bytes):
return ET.fromstring(data)
def _asset_path_from_registry(root, asset_id: str) -> str | None:
"""The ZIP path an ``<Asset id=...>`` declares, or None.
GPIF shape::
<Assets>
<Asset id="0">
<EmbeddedFilePath>Content/Assets/&lt;hash&gt;.mp3</EmbeddedFilePath>
Separators are normalised (a writer may emit backslashes) and the
result is returned as-is for the caller to verify against the
archive this function never decides that a path exists.
"""
if root is None or not asset_id:
return None
try:
for asset in root.iter('Asset'):
if (asset.get('id') or '').strip() != asset_id:
continue
node = asset.find('EmbeddedFilePath')
path = (node.text or '').strip() if node is not None else ''
if not path:
return None
return path.replace('\\', '/').lstrip('./')
except Exception:
# A malformed registry is not fatal — the caller has two more
# resolution steps behind this one.
return None
return None
def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
"""Resolve the embedded backing-track audio asset inside a .gp ZIP.
``BackingTrack/AssetId`` is a key into the GPIF's ``<Assets>``
registry ``<Asset id="0"><EmbeddedFilePath>`` names the exact path
inside the ZIP NOT a filename stem. Resolution order:
1. the registry entry for the declared id (authoritative);
2. a filename-stem match (files whose stem IS the id);
3. the archive's first audio asset.
Step 2 was previously the only lookup, which mattered because GP8
names embedded files by hash while ids are small integers, so the
stem match essentially never hit: every such file logged a warning
and fell through to step 3. That was silently correct only because a
file almost always carries exactly ONE audio asset with two, a
backing track declaring id 1 resolved to asset 0, i.e. the wrong
recording.
Returns ``(asset_stem, audio_zip_path)``, or ``('', None)`` when the
archive has no audio asset. Shared by ``extract_sync`` and
``extract_audio`` so the matching logic can't drift between them.
Matches ``BackingTrack/AssetId`` against the audio files under
``Content/Assets/`` (OGG, MP3, M4A, ) and falls back to the first
audio asset when the declared id is missing or unmatched. Returns
``(asset_stem, audio_zip_path)``, or ``('', None)`` when the archive
has no audio asset. Shared by ``extract_sync`` and ``extract_audio``
so the matching logic can't drift between them.
"""
audio_files = [
n for n in zf.namelist()
@@ -159,32 +115,6 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
declared = (aid.text or '').strip() if aid is not None else ''
if declared:
# 1. The <Assets> registry is authoritative: it maps the id to the
# embedded path directly. Membership in the archive is verified
# rather than trusted — the path comes out of the file, and a
# stale/edited entry must fall through, not resolve to nothing.
registry_path = _asset_path_from_registry(root, declared)
if registry_path:
# Matched on STEM, not the whole path, so a format variant of the
# same recording can win (see _prefer_ogg) — but constrained to the
# directory the registry actually named. Without that constraint an
# unrelated file that merely shares the stem could stand in for the
# declared asset, which is the failure the registry lookup exists
# to prevent.
declared_path = Path(registry_path)
same_stem = [
n for n in audio_files
if Path(n).stem == declared_path.stem
and Path(n).parent == declared_path.parent
]
if same_stem:
return declared_path.stem, _prefer_ogg(same_stem)
_log.warning(
'gp8_audio_sync: AssetId %r maps to %r, which is not an audio '
'asset in the archive; falling back',
declared, registry_path,
)
# 2. Legacy shape: files whose stem IS the declared id.
matched = [n for n in audio_files if Path(n).stem == declared]
if matched:
return declared, _prefer_ogg(matched)
+26 -105
View File
@@ -17,12 +17,7 @@ import threading
from typing import ClassVar
import appstate
from metadata_db import (
MetadataDB, _effective_tuning_cols_sql, _perspective_is_inferred_sql,
_tuning_group_key_sql,
)
import tunings as tunings_mod
from tunings import DEFAULT_PERSPECTIVE, PERSPECTIVES
from metadata_db import MetadataDB, _tuning_group_key_sql
from routers import art as art_router
import logging
@@ -44,6 +39,9 @@ def _safe_art_redirect_url(url: str) -> str | None:
return None
_TUNING_GROUP_KEY_SQL = _tuning_group_key_sql("songs")
class LocalLibraryProvider:
id = "local"
label = "My Library"
@@ -71,43 +69,28 @@ class LocalLibraryProvider:
def query_stats(self, **kwargs) -> dict:
return self._db.query_stats(**kwargs)
def tuning_names(self, instrument: str = DEFAULT_PERSPECTIVE) -> dict:
def tuning_names(self) -> dict:
# Group custom tunings on their raw offsets so distinct ones stay
# distinct (tuning_name collapses them all to "Custom Tuning"); named
# tunings keep grouping by name (stable across the rescan boundary, no
# offsets/name split). `key` is the value the client sends back as the
# filter selector — equal to the name for named tunings, the offsets
# string for customs; offsets also feed the client's custom-pill label.
#
# `instrument=bass` swaps every column for its effective bass-facing
# expression (bass arrangement's tuning, guitar fallback) — the SAME
# expressions _build_intrinsic_where filters on, so a facet entry
# always selects exactly the songs it counted.
name_sql, offsets_sql, sort_sql = _effective_tuning_cols_sql("songs", instrument)
gkey_sql = _tuning_group_key_sql("songs", instrument)
# How many of a row's songs are showing an INFERRED tuning — i.e. have
# no bass chart of their own and are falling back to the guitar-derived
# one. Reported per entry so the UI can be honest about it instead of
# presenting a borrowed tuning as a measured one. Always 0 for guitar.
inferred_sql = f"SUM({_perspective_is_inferred_sql('songs', instrument)})"
with self._db._lock:
rows = self._db.conn.execute(
f"SELECT {name_sql}, {gkey_sql} AS gkey, "
f"MIN({sort_sql}), COUNT(*), MIN({offsets_sql}), {inferred_sql} "
f"FROM songs WHERE title != '' AND COALESCE({name_sql}, '') != '' "
f"SELECT tuning_name, {_TUNING_GROUP_KEY_SQL} AS gkey, "
"MIN(tuning_sort_key), COUNT(*), MIN(tuning_offsets) "
"FROM songs WHERE title != '' AND COALESCE(tuning_name, '') != '' "
"GROUP BY gkey COLLATE NOCASE "
f"ORDER BY ABS(COALESCE(MIN({sort_sql}), 0)), "
f"COALESCE(MIN({sort_sql}), 0) ASC, "
f"{name_sql} COLLATE NOCASE"
"ORDER BY ABS(COALESCE(MIN(tuning_sort_key), 0)), "
"COALESCE(MIN(tuning_sort_key), 0) ASC, "
"tuning_name COLLATE NOCASE"
).fetchall()
return {
"instrument": instrument,
"tunings": [
{"name": name, "key": gkey, "offsets": offs or "",
"sort_key": int(sk or 0), "count": count,
# Portion of `count` borrowed from the guitar chart.
"inferred_count": int(inferred or 0)}
for name, gkey, sk, count, offs, inferred in rows
"sort_key": int(sk or 0), "count": count}
for name, gkey, sk, count, offs in rows
],
}
@@ -347,16 +330,9 @@ class SmartCollectionProvider:
# have been hand-edited; never let a bad value reach a query.
self._rules = _sanitize_collection_rules(collection.get("rules") or {})
def _filter_kwargs(self, instrument: str = "", playable_from_pitch=None) -> dict:
# `instrument` is the CALLER's play perspective (rides every request),
# never part of the saved rules — a collection saved by a guitarist
# must still read in bass tunings for a bass player, and vice versa.
args = _library_filter_args(**{k: v for k, v in self._rules.items()
def _filter_kwargs(self) -> dict:
return _library_filter_args(**{k: v for k, v in self._rules.items()
if k in _LIBRARY_FILTER_PARAM_KEYS})
args["instrument"] = _normalize_instrument(instrument)
# The caller's CURRENT tuning is likewise per-request, never a saved rule.
args["playable_from_pitch"] = playable_from_pitch
return args
def _sort(self, fallback: str) -> str:
# A collection may pin its own sort (e.g. "recently added"); query_page
@@ -364,31 +340,28 @@ class SmartCollectionProvider:
return self._rules.get("sort") or fallback
def query_page(self, *, page=0, size=24, sort="artist", direction="asc",
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
naming_mode="legacy", **_ignore):
return self._local._db.query_page(
page=page, size=size, sort=self._sort(sort), direction=direction,
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
naming_mode=naming_mode, **self._filter_kwargs())
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy",
instrument="", playable_from_pitch=None, **_ignore):
def query_artists(self, *, letter="", page=0, size=50, naming_mode="legacy", **_ignore):
return self._local._db.query_artists(
letter=letter, page=page, size=size, naming_mode=naming_mode,
**self._filter_kwargs(instrument, playable_from_pitch))
**self._filter_kwargs())
def query_albums(self, *, page=0, size=120, naming_mode="legacy",
instrument="", playable_from_pitch=None, **_ignore):
def query_albums(self, *, page=0, size=120, naming_mode="legacy", **_ignore):
return self._local._db.query_albums(
page=page, size=size, naming_mode=naming_mode,
**self._filter_kwargs(instrument, playable_from_pitch))
page=page, size=size, naming_mode=naming_mode, **self._filter_kwargs())
def query_stats(self, *, sort="artist", want_sort_letters=False,
naming_mode="legacy", instrument="", playable_from_pitch=None, **_ignore):
naming_mode="legacy", **_ignore):
return self._local._db.query_stats(
sort=self._sort(sort), want_sort_letters=want_sort_letters,
naming_mode=naming_mode, **self._filter_kwargs(instrument, playable_from_pitch))
naming_mode=naming_mode, **self._filter_kwargs())
def tuning_names(self, instrument: str = "guitar"):
return self._local.tuning_names(instrument=_normalize_instrument(instrument))
def tuning_names(self):
return self._local.tuning_names()
async def get_art(self, song_id: str):
return await self._local.get_art(song_id)
@@ -417,10 +390,7 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
artist: str = "", album: str = "",
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "",
instrument: str = "", tuning_match: str = "",
playable_offsets: str = "", playable_instrument: str = "",
playable_string_count: str = "") -> dict:
has_lyrics: str = "", tunings: str = "") -> dict:
fmt = format if format in ("archive", "sloppak", "loose") else ""
return {
"q": q,
@@ -434,58 +404,9 @@ def _library_filter_args(q: str = "", favorites: int = 0, format: str = "",
"stems_lacks": _split_csv(stems_lacks),
"has_lyrics": _parse_has_lyrics(has_lyrics),
"tunings": _split_csv(tunings),
# Which perspective the tuning facet/filter/sort speaks for (the
# caller's play role, NOT a saved rule — see _sanitize_collection_rules).
"instrument": _normalize_instrument(instrument),
# "Playable without retuning" mode: the caller's CURRENT tuning,
# resolved to the one number the comparison needs. None = exact-match
# mode (the default), so the tuning pills behave exactly as before.
"playable_from_pitch": (
_playable_from_pitch(playable_offsets, playable_instrument,
playable_string_count)
if tuning_match == "playable" else None),
}
def _playable_from_pitch(offsets_csv: str, instrument: str, string_count: str):
"""Lowest open-string MIDI pitch of the CALLER's current tuning.
The client sends its live working tuning (offsets + instrument + string
count) rather than a precomputed pitch, so the pitch tables stay in one
place (lib/tunings.py) instead of being duplicated in JS.
Returns None for anything unusable the caller then applies NO playable
filter at all. That is the neutral state, not a claim: a malformed tuning
must not silently assert that everything is playable OR that nothing is.
"""
try:
offsets = [int(x) for x in _split_csv(offsets_csv)]
except (TypeError, ValueError):
return None
if not offsets:
return None
inst = "bass" if instrument == "bass" else "guitar"
try:
sc = int(string_count)
except (TypeError, ValueError):
sc = len(offsets)
key = tunings_mod.instrument_key(inst, sc)
if key not in tunings_mod.STANDARD_OPEN_MIDIS or len(offsets) != sc:
return None
midis = tunings_mod.tuning_midis_from_offsets(key, offsets)
return min(midis) if midis else None
def _normalize_instrument(raw: str) -> str:
"""Resolve a tuning PERSPECTIVE id (guitar-lead | guitar-rhythm | bass).
Tolerates the legacy two-valued vocabulary ("guitar" -> guitar-lead) and
falls back to the default for anything unknown an unrecognised value
must never silently change filter semantics."""
return raw if raw in PERSPECTIVES else (
DEFAULT_PERSPECTIVE if raw != "bass" else "bass")
def _sync_collection_provider(collection: dict) -> None:
"""Register (or replace) the provider for one collection."""
appstate.library_providers.register(
+1 -21
View File
@@ -225,18 +225,13 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
Returns (arrangements_list, shared_meta).
shared_meta contains title/artist/album/year/duration/tuning_offsets
sourced from the highest-priority arrangement (lead > combo > rhythm >
bass) picking the guitar tuning when both bass and lead are present
plus `bass_tuning_offsets` from the first bass arrangement (None when the
folder has none), so the index can carry both tunings.
bass) picking the guitar tuning when both bass and lead are present.
"""
arrangements = []
# Track which arrangement priority sourced shared_meta so a later,
# higher-priority arrangement (lead < bass in sort order) overrides.
shared_meta = {}
shared_priority = None
# First tuning seen per arrangement ROLE, kept alongside the guitar-first
# song tuning so the library can answer for the part a player plays.
role_tunings: dict[str, list[int] | None] = {"bass": None, "rhythm": None}
for xml in sorted(_iter_local_xmls(path)):
# Trust the XML root over the filename — a custom named
@@ -274,10 +269,6 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
"duration", "tuning_offsets")}
shared_priority = priority
if (arr_type in role_tunings and role_tunings[arr_type] is None
and meta.get("tuning_offsets")):
role_tunings[arr_type] = list(meta["tuning_offsets"])
arrangements.append({
"type": arr_type,
"name": arr_name,
@@ -290,8 +281,6 @@ def _detect_arrangements(path: Path) -> tuple[list[dict], dict]:
a["index"] = i
del a["priority"]
for role, offs in role_tunings.items():
shared_meta[f"{role}_tuning_offsets"] = offs
return arrangements, shared_meta
@@ -423,14 +412,6 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
xml_meta.get("duration", 0))
tuning_offsets = _coerce_tuning_offsets(manifest.get("tuning_offsets"),
xml_meta.get("tuning_offsets"))
# Per-role tunings: XML-derived only. A manifest `tuning_offsets` overrides
# the SONG tuning (above) but says nothing about WHICH chart it describes,
# so it must never be mistaken for a specific part's tuning.
role_tunings = {}
for role in ("bass", "rhythm"):
offs = xml_meta.get(f"{role}_tuning_offsets")
role_tunings[f"{role}_tuning_offsets"] = (
offs if isinstance(offs, list) and offs else None)
manifest_arr = _validate_manifest_arrangements(manifest.get("arrangements"))
if manifest_arr is not None:
@@ -446,7 +427,6 @@ def extract_meta(path: Path, dlc_root: Path | None = None) -> dict:
"year": year,
"duration": duration,
"tuning_offsets": tuning_offsets,
**role_tunings, # None = no arrangement in that role
"arrangements": arrangements,
"audio_path": str(audio) if audio else None,
"art_path": str(art) if art else None,
+15 -98
View File
@@ -23,18 +23,9 @@ Engine selection
Two transcription paths share a common output:
* `transcribe_vocals_remote(path, server_url, ...)` POST the vocal
stem to the `/transcribe` endpoint on a feedBack-demucs-server
(got-feedBack's reference server already hosts WhisperX alongside
Demucs at the same URL).
It used to POST to `/align`, which is *forced alignment* "here are
the lyrics, tell me when each word is sung". Its `text` field is
required and we have no lyrics (transcribing them is the point), so
the server answered 422 from FastAPI's validation layer before its
handler ran, and remote transcription never worked for anyone
(feedBack-plugin-stem-splitter#17). `/transcribe` takes only audio.
Requires feedBack-demucs-server the revision adding that endpoint;
an older server answers 404 and the error says so.
stem to the `/align` endpoint on a feedBack-demucs-server (got-feedBack's
reference server already hosts WhisperX alongside Demucs at the same
URL).
* `transcribe_vocals_local(path, ...)` load WhisperX in-process. Heavy
(~3 GB of model weights for `large-v2` + the wav2vec2 aligner) and
@@ -425,38 +416,6 @@ def transcribe_vocals_local(
# ── Remote transcription ────────────────────────────────────────────────────
_MAX_ERR_BODY = 4000
def _err_body(resp) -> str:
"""The server's error body, whole if it plausibly is one, and marked when it isn't.
This was capped at 300 chars, which is enough for "Internal Server Error" and not much else.
The bodies carrying the most diagnosis are the long ones a FastAPI validation body naming
the field it rejected, a 500 whose traceback answers on its LAST line and those are exactly
the ones a 300-char cap decapitates. The cap survives so a server answering with a 2 MB HTML
error page can't dump a novel into a log line.
"""
# Strip FIRST, then measure: a body that is 300 chars of JSON and 3900 of trailing whitespace
# is not a long body, and truncating it would cut real content to make room for blanks.
text = (getattr(resp, "text", "") or "").strip()
if len(text) <= _MAX_ERR_BODY:
return text
# Keep the HEAD **and the TAIL**. Head-only truncation throws away the exception line — and
# on a traceback the exception line is the answer. This docstring said as much while the code
# did the opposite: it cut off precisely the part it exists to preserve, which is the same
# mistake, one level up, as the 300-char cap it replaced.
#
# The marker sits inside the bound, not past it: otherwise _MAX_ERR_BODY is a suggestion, and
# the callers who trust it (a log line, a job record persisted to disk) are the ones surprised.
marker = f"\n… [truncated, {len(text)} chars total] …\n"
budget = max(0, _MAX_ERR_BODY - len(marker))
head = budget * 2 // 3 # context: what was being attempted
tail = budget - head # verdict: what actually went wrong
return text[:head].rstrip() + marker + text[len(text) - tail:].lstrip()
def transcribe_vocals_remote(
vocals_path: Path,
server_url: str,
@@ -467,17 +426,7 @@ def transcribe_vocals_remote(
min_word_score: float = 0.35,
progress_cb: ProgressCB = None,
) -> list[dict]:
"""POST the vocal stem to `{server_url}/transcribe` and parse the response.
NOT `/align` that endpoint is forced alignment ("here are the lyrics,
tell me when each word is sung") and its `text` field is required. We
have no lyrics; producing them is the point. Posting there returned a
422 from FastAPI's validation layer before the server's handler ran, so
remote transcription never worked at all
(feedBack-plugin-stem-splitter#17).
Requires a feedBack-demucs-server carrying `/transcribe`; an older one
answers 404 and the raised error says so.
"""POST the vocal stem to `{server_url}/align` and parse the response.
Expects the server to respond with a JSON object carrying a `words` (or
`segments`) field in WhisperX's native shape; `_whisperx_to_sloppak`
@@ -505,53 +454,21 @@ def transcribe_vocals_remote(
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# POST to /transcribe, not /align.
#
# /align is FORCED ALIGNMENT: "here are the lyrics, tell me when each word is sung". Its
# `text` field is required, and we have no lyrics — transcription is the whole point. So the
# server rejected every request with a 422 in FastAPI's validation layer, before its handler
# ever ran, and remote transcription has never worked for anyone. /transcribe answers the
# question we are actually asking and takes only the audio.
# (feedBack-plugin-stem-splitter#17; endpoint added in feedBack-demucs-server#14.)
#
# `language` goes in the FORM BODY, not the query string: the server reads it with
# Form(""), and a query param would be silently ignored — so an explicit language hint would
# do nothing and Whisper's auto-detection would quietly decide instead, which is exactly the
# kind of "it works but it's wrong" that hides for months.
form: dict[str, str] = {}
params: dict[str, str] = {}
if language:
form["language"] = language
params["language"] = language
# Everything that can go wrong out here comes back as RuntimeError, which is what the
# docstring promises and what the caller catches. A DNS failure, a timeout, a reset
# connection or an unreadable stem file would otherwise surface as requests.RequestException
# or OSError and escape the one handler written to log-and-continue — turning "this song's
# lyrics failed" into "the whole batch died".
try:
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/transcribe",
files={"file": (vocals_path.name, f, "audio/ogg")},
data=form or None,
headers=headers or None,
timeout=timeout,
)
except requests.RequestException as e:
raise RuntimeError(f"could not reach the WhisperX server at {server_url}: {e}") from e
except OSError as e:
raise RuntimeError(f"could not read the vocal stem {vocals_path.name}: {e}") from e
if resp.status_code == 404:
# The endpoint isn't there. Say what that means, because "404" on its own sends someone
# hunting for a typo in their URL when the real answer is that their server predates the
# feature. (feedBack-demucs-server#14 added /transcribe.)
raise RuntimeError(
f"the WhisperX server at {server_url} has no /transcribe endpoint (404) — it "
f"predates remote transcription support. Update the server, or use 'Check for "
f"update' if it is the plugin-managed one."
with open(vocals_path, "rb") as f:
resp = requests.post(
f"{server_url}/align",
files={"file": (vocals_path.name, f, "audio/ogg")},
params=params,
headers=headers or None,
timeout=timeout,
)
if resp.status_code != 200:
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {_err_body(resp)}")
raise RuntimeError(f"WhisperX server error ({resp.status_code}): {resp.text[:300]}")
data = resp.json()
+55 -438
View File
@@ -25,8 +25,6 @@ import time
from pathlib import Path
from song import compute_smart_names
from tunings import DEFAULT_PERSPECTIVE, ROLE_PERSPECTIVES
from tunings import perspective as _perspective
log = logging.getLogger("feedBack.server")
@@ -36,113 +34,17 @@ log = logging.getLogger("feedBack.server")
# raw offsets so distinct customs stay distinct, while named tunings keep
# grouping by name (stable across the offsets-column migration). Used by both
# the tuning-names listing and the filter WHERE so the contract matches.
#
# A non-default PERSPECTIVE (guitar-rhythm / bass) swaps every tuning column
# for its EFFECTIVE expression: that role's indexed tuning when the song has
# such an arrangement, falling back to the guitar-derived song tuning
# otherwise — so a song with no rhythm/bass chart (or a row that predates the
# columns, NULL there) still groups/filters/sorts instead of disappearing.
# guitar-lead reads the original unprefixed columns, so it is byte-identical
# to the historical behaviour.
def _effective_tuning_cols_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> tuple[str, str, str]:
"""(name_sql, offsets_sql, sort_key_sql) for the given perspective."""
persp = _perspective(perspective)
if not persp.column_prefix:
return (f"{alias}.tuning_name", f"{alias}.tuning_offsets", f"{alias}.tuning_sort_key")
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
return (
f"COALESCE(NULLIF({alias}.{persp.column('name')}, ''), {alias}.tuning_name)",
f"CASE WHEN {has_own} THEN {alias}.{persp.column('offsets')} ELSE {alias}.tuning_offsets END",
f"CASE WHEN {has_own} THEN {alias}.{persp.column('sort_key')} ELSE {alias}.tuning_sort_key END",
)
def _effective_low_pitch_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
"""Lowest open-string MIDI pitch under this perspective, with the same
fallback as the tuning columns the "playable without retuning"
comparison reads it (see tunings.chart_is_playable_in)."""
persp = _perspective(perspective)
if not persp.column_prefix:
return f"{alias}.tuning_low_pitch"
has_own = f"COALESCE({alias}.{persp.column('name')}, '') != ''"
return (f"CASE WHEN {has_own} THEN {alias}.{persp.column('low_pitch')} "
f"ELSE {alias}.tuning_low_pitch END")
def _perspective_is_inferred_sql(alias: str, perspective: str) -> str:
"""1 when this row is BORROWING the guitar-derived song tuning because it
has no chart in the perspective's role. Always 0 for guitar-lead, which is
never a fallback."""
persp = _perspective(perspective)
if not persp.column_prefix:
return "0"
return f"(CASE WHEN COALESCE({alias}.{persp.column('name')}, '') = '' THEN 1 ELSE 0 END)"
# ── The custom-tuning group key ──────────────────────────────────────────────
#
# Named tunings group by NAME, which is already serialization-agnostic. Custom
# tunings group on a raw offsets STRING, which is not: the same physical bass
# tuning stored as "-2 0 0 0" and "-2 0 0 0 0 0" would fragment into two facet
# rows with split counts.
#
# For BASS we therefore group customs on `bass_tuning_key` — the tuning's
# absolute open-string PITCHES, computed once at scan time
# (tunings.bass_tuning_key) after the padded tail is truncated away. Pitch is
# the identity that matters musically and it is serialization-independent, so
# one physical tuning is one entry however it was authored. Guitar keeps the
# offsets string (unchanged; six-element guitar arrays are not padded).
#
# The key is built HERE, once, and read by the facet listing, the filter WHERE
# and the grouped member-match alike — a facet row that selected a different
# set than it counted is exactly the bug this shared expression prevents.
def _tuning_group_key_sql(alias: str, perspective: str = DEFAULT_PERSPECTIVE) -> str:
"""The tuning grouping key (name for named tunings, canonical pitches or
raw offsets for customs) against an explicit table alias the grouped
filter law (§7.1) evaluates chart-intrinsic predicates inside a member
subquery, where bare column names would resolve against the wrong scope."""
persp = _perspective(perspective)
name_sql, offsets_sql, _ = _effective_tuning_cols_sql(alias, perspective)
if persp.column_prefix:
# Fall back to the offsets string when the canonical key is absent
# (a fallback row borrowing the guitar tuning, or a row scanned before
# the key column existed) so a custom never groups under an empty key.
offsets_sql = (f"COALESCE(NULLIF({alias}.{persp.column('key')}, ''), "
f"{offsets_sql})")
return (f"CASE WHEN {name_sql} = 'Custom Tuning' AND COALESCE({offsets_sql}, '') != '' "
f"THEN {offsets_sql} ELSE {name_sql} END")
def _put_perspective_value(meta: dict, col: str):
"""Value to store for one per-perspective column on a freshly-scanned row."""
if col.endswith("_low_pitch"):
val = meta.get(col)
return int(val) if isinstance(val, int) else None
if col.endswith("_sort_key"):
return int(meta.get(col, 0) or 0)
return meta.get(col, "") or ""
def _tuning_group_key_sql(alias: str) -> str:
"""The tuning grouping key (name for named tunings, raw offsets for
customs) against an explicit table alias the grouped filter law (§7.1)
evaluates chart-intrinsic predicates inside a member subquery, where bare
column names would resolve against the wrong scope."""
return (f"CASE WHEN {alias}.tuning_name = 'Custom Tuning' AND COALESCE({alias}.tuning_offsets, '') != '' "
f"THEN {alias}.tuning_offsets ELSE {alias}.tuning_name END")
# ── SQLite metadata cache ─────────────────────────────────────────────────────
def _arrangements_all_bass(raw) -> bool:
"""True when EVERY arrangement on a chart is a bass part (raw ``arrangements``
JSON, as stored). Mirrors the library grid's card rule: such a chart's tuning
must be scored against bass base pitches, or a 4-string bass tuning read as
guitar can false-match a guitarist. A chart with no arrangements is not bass.
"""
try:
arrs = json.loads(raw) if raw else []
except (ValueError, TypeError):
return False
if not isinstance(arrs, list) or not arrs:
return False
return all(
isinstance(a, dict) and re.search(r"\bbass\b", str(a.get("name") or ""), re.I)
for a in arrs
)
def _ensure_smart_names(arrangements: list[dict]) -> list[dict]:
"""Fill in missing ``smart_name`` fields and sort arrangements by smart order.
@@ -479,18 +381,7 @@ class MetadataDB:
tuning_offsets TEXT DEFAULT '',
genre TEXT DEFAULT '',
track_number INTEGER,
disc INTEGER,
bass_tuning_name TEXT,
bass_tuning_sort_key INTEGER,
bass_tuning_offsets TEXT,
bass_tuning_key TEXT,
bass_tuning_low_pitch INTEGER,
rhythm_tuning_name TEXT,
rhythm_tuning_sort_key INTEGER,
rhythm_tuning_offsets TEXT,
rhythm_tuning_key TEXT,
rhythm_tuning_low_pitch INTEGER,
tuning_low_pitch INTEGER
disc INTEGER
)
""")
# Idempotent migrations for installs that predate each column.
@@ -517,32 +408,6 @@ class MetadataDB:
# falls back to title order. Cache; repopulated on rescan.
"ALTER TABLE songs ADD COLUMN track_number INTEGER",
"ALTER TABLE songs ADD COLUMN disc INTEGER",
# Bass-arrangement tuning (the KwasimodoZAZA report): the song-level
# tuning columns above are guitar-first, so the library filter lied
# to bass players when the bass chart is tuned differently. Caches;
# repopulated on rescan. NULL (no literal default) is deliberate —
# it marks a pre-migration row the scanner must re-extract, while
# '' means "extracted, song has no bass arrangement" (see scan.py).
"ALTER TABLE songs ADD COLUMN bass_tuning_name TEXT",
"ALTER TABLE songs ADD COLUMN bass_tuning_sort_key INTEGER",
"ALTER TABLE songs ADD COLUMN bass_tuning_offsets TEXT",
# Canonical grouping key: the bass tuning's absolute open-string
# pitches. Keyed on PITCH, not the serialization-dependent offsets
# string, so one physical tuning is one facet entry however it was
# stored. See tunings.bass_tuning_key.
"ALTER TABLE songs ADD COLUMN bass_tuning_key TEXT",
# Lowest open-string MIDI pitch per perspective — the "playable
# without retuning" comparison (tunings.chart_is_playable_in).
"ALTER TABLE songs ADD COLUMN bass_tuning_low_pitch INTEGER",
"ALTER TABLE songs ADD COLUMN tuning_low_pitch INTEGER",
# The RHYTHM chart's own tuning: lead and rhythm arrangements can
# be tuned differently, which is the same bug a bassist hit,
# inside guitar. Same NULL-vs-'' contract as the bass family.
"ALTER TABLE songs ADD COLUMN rhythm_tuning_name TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_sort_key INTEGER",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_offsets TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_key TEXT",
"ALTER TABLE songs ADD COLUMN rhythm_tuning_low_pitch INTEGER",
):
try:
self.conn.execute(ddl)
@@ -749,14 +614,6 @@ class MetadataDB:
)
""")
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_stats_recent ON song_stats(last_played_at DESC)")
# Cumulative wall-clock play time (career "hours in genre" odometer).
# Fed by the same POST /api/stats the recorder already sends; additive
# + idempotent like every other song_stats change.
try:
self.conn.execute(
"ALTER TABLE song_stats ADD COLUMN seconds_total REAL NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Playlists + the reserved "Saved for Later" system playlist. Additive.
self.conn.execute("""
CREATE TABLE IF NOT EXISTS playlists (
@@ -802,16 +659,6 @@ class MetadataDB:
self.conn.execute(_ddl)
except sqlite3.OperationalError:
pass
# Manual playlist ordering (tester ask): `position` orders the
# PLAYLISTS themselves (playlist_songs.position orders songs within
# one). NULL = unpositioned — those sort alphabetically AFTER the
# manually positioned ones, and system playlists stay pinned first
# regardless (see list_playlists). Additive, idempotent — same
# pattern as `rules`/`kind` above.
try:
self.conn.execute("ALTER TABLE playlists ADD COLUMN position INTEGER")
except sqlite3.OperationalError:
pass
# Wishlist / "wanted" (feedBack#636 item 4): a persisted, actionable
# list of songs the user does NOT own yet — the *arr "Wanted/Monitored"
# analogue. Unlike playlists (which reference owned local songs by
@@ -1054,9 +901,6 @@ class MetadataDB:
"best_accuracy": max(cur["best_accuracy"] or 0.0, r["best_accuracy"] or 0.0),
"last_score": newer["last_score"], "last_accuracy": newer["last_accuracy"],
"last_position": newer["last_position"],
# Play time is additive: both encodings' hours belong to
# the one canonical song.
"seconds_total": (cur.get("seconds_total") or 0.0) + (r.get("seconds_total") or 0.0),
"last_played_at": newer["last_played_at"], "updated_at": newer["updated_at"],
}
# Atomic swap: clear and reinsert the canonicalized set in one txn.
@@ -1230,31 +1074,16 @@ class MetadataDB:
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
return vals
# Effective genre precedence: per-song OVERRIDE (Fix-metadata popup)
# scanned pack genre → MusicBrainz enrichment primary genre (matched/manual rows
# only — a 'review'/'failed' candidate's genres could belong to the wrong
# recording). Applied at FILTER/FACET time (like the P4 artist alias) so a
# corrected or enriched genre is browsable. The vast majority of converted
# packs carry no `genres` manifest key, so without the enrichment leg the
# genre facet (and career passports) starve on real libraries. The
# correlated subqueries are used ONLY when overrides/enrichment genres
# actually exist; the common case stays on the plain indexed `genre`
# column. Genre stays a library-only overlay (it isn't a write-to-file
# field), so it never touches the pack.
_EFFECTIVE_GENRE_OVERRIDE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
# so a corrected genre is browsable — the correlated subquery is used ONLY
# when genre overrides actually exist; the common case stays on the plain
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
# write-to-file field), so it never touches the pack.
_EFFECTIVE_GENRE_SQL = (
"COALESCE((SELECT o.value FROM song_field_override o "
"WHERE o.filename = songs.filename AND o.field = 'genre' "
"AND o.value IS NOT NULL AND o.value != ''), "
"NULLIF(genre, ''), "
"(SELECT json_extract(e.genres, '$[0]') FROM song_enrichment e "
"WHERE e.filename = songs.filename AND e.match_state IN ('matched', 'manual') "
"AND e.genres IS NOT NULL AND e.genres NOT IN ('', '[]')), "
"'')"
"AND o.value IS NOT NULL AND o.value != ''), genre)"
)
def _has_genre_overrides(self) -> bool:
@@ -1262,25 +1091,9 @@ class MetadataDB:
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
def _has_enrichment_genres(self) -> bool:
try:
return self.conn.execute(
"SELECT 1 FROM song_enrichment WHERE match_state IN ('matched', 'manual') "
"AND genres IS NOT NULL AND genres NOT IN ('', '[]') "
"LIMIT 1").fetchone() is not None
except sqlite3.OperationalError:
return False # stand-ins / DBs without the enrichment table
def _effective_genre_expr(self) -> str:
"""`genre` normally; the enrichment-aware COALESCE only when trusted
enrichment genres exist (which also proves the table exists a
stand-in DB without song_enrichment must never receive SQL that
references it); the override-only form when just overrides exist."""
if self._has_enrichment_genres():
return self._EFFECTIVE_GENRE_SQL
if self._has_genre_overrides():
return self._EFFECTIVE_GENRE_OVERRIDE_SQL
return "genre"
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
def set_song_tags(self, filename: str, tags) -> list:
"""Replace ALL of a song's tags with the given set (each normalized;
@@ -1880,8 +1693,7 @@ class MetadataDB:
# ── Per-song practice stats ───────────────────────────────────────────---
_STATS_COLS = (
"filename", "arrangement", "plays", "best_score", "best_accuracy",
"last_score", "last_accuracy", "last_position", "seconds_total",
"last_played_at", "updated_at",
"last_score", "last_accuracy", "last_position", "last_played_at", "updated_at",
)
def _stats_row(self, filename: str, arrangement: int) -> dict | None:
@@ -2248,9 +2060,8 @@ class MetadataDB:
self.conn.commit()
def record_session(self, filename: str, arrangement: int, *, score: int,
accuracy: float, last_position=None, seconds: float = 0) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new.
`seconds` (wall-clock play time from the recorder) accrues."""
accuracy: float, last_position=None) -> dict:
"""Record a scored play: plays += 1, best_* = max, last_* = new."""
from song_score import merge_stats
with self._lock:
existing = self._stats_row(filename, int(arrangement))
@@ -2260,9 +2071,8 @@ class MetadataDB:
self.conn.execute(
"""INSERT INTO song_stats
(filename, arrangement, plays, best_score, best_accuracy,
last_score, last_accuracy, last_position, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
last_score, last_accuracy, last_position, last_played_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
plays = excluded.plays,
@@ -2271,62 +2081,32 @@ class MetadataDB:
last_score = excluded.last_score,
last_accuracy = excluded.last_accuracy,
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), merged["plays"], merged["best_score"],
merged["best_accuracy"], merged["last_score"], merged["last_accuracy"],
merged["last_position"], float(seconds or 0)),
merged["last_position"]),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def touch_position(self, filename: str, arrangement: int, last_position: float,
seconds: float = 0) -> dict:
def touch_position(self, filename: str, arrangement: int, last_position: float) -> dict:
"""Persist just the resume position (no plays/score change), so
Continue-Playing works for non-scored plays. Also stamps
last_played_at both /api/stats/recent and /api/session/continue
filter/order on it, so a position-only touch must set it or the song
never surfaces as 'recent' / 'continue playing'. `seconds` accrues
wall-clock play time (career hours odometer)."""
never surfaces as 'recent' / 'continue playing'."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, last_position,
seconds_total, last_played_at, updated_at)
VALUES (?, ?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
last_position = excluded.last_position,
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(last_position), float(seconds or 0)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
def add_play_seconds(self, filename: str, arrangement: int, seconds: float) -> dict:
"""Accrue wall-clock play time (no plays/score/position change) —
the recorder's seconds-only flush for unscored plays that ran to the
song's natural end (no resume position to touch there: `song:ended`
must not overwrite Continue with the end-of-song offset). Stamps
last_played_at like touch_position does: the song WAS played, so
/api/stats/recent and Continue ordering must see it. Accepted skew:
the recorder retries FAILED flushes later, which stamps recency at
retry time rare (offline corner), self-healing on the next play,
and preferable to the alternative (keep-existing would leave repeat
plays looking stale, the common case)."""
with self._lock:
self.conn.execute(
"""INSERT INTO song_stats (filename, arrangement, seconds_total,
last_played_at, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%d %H:%M:%f','now'),
strftime('%Y-%m-%d %H:%M:%f','now'))
ON CONFLICT(filename, arrangement) DO UPDATE SET
seconds_total = song_stats.seconds_total + excluded.seconds_total,
last_position = excluded.last_position,
last_played_at = excluded.last_played_at,
updated_at = excluded.updated_at""",
(filename, int(arrangement), float(seconds)),
(filename, int(arrangement), float(last_position)),
)
self.conn.commit()
return self._stats_row(filename, int(arrangement))
@@ -2550,14 +2330,10 @@ class MetadataDB:
def list_playlists(self) -> list[dict]:
from urllib.parse import quote
# Order: system playlists pinned first, then manually positioned user
# playlists (position = drag order), then unpositioned ones
# alphabetically — so a manual order wins and a playlist created after
# a reorder still lands somewhere predictable (see reorder_playlists).
rows = self.conn.execute(
"SELECT id, name, system_key, created_at, updated_at, kind FROM playlists "
"WHERE rules IS NULL " # smart collections live in the source picker, not here
"ORDER BY (system_key IS NULL), (position IS NULL), position, name COLLATE NOCASE"
"ORDER BY (system_key IS NULL), name COLLATE NOCASE"
).fetchall()
out = []
for r in rows:
@@ -2715,9 +2491,7 @@ class MetadataDB:
rows = self.conn.execute(
f"""SELECT ps.filename, ps.position, s.title, s.artist, s.tuning_name,
ps.arrangement, ps.work_key, s.arrangements,
(s.filename IS NULL) AS dead, s.tuning_offsets,
s.bass_tuning_name, s.bass_tuning_offsets,
s.rhythm_tuning_name, s.rhythm_tuning_offsets
(s.filename IS NULL) AS dead
FROM playlist_songs ps LEFT JOIN songs s ON s.filename = ps.filename
WHERE ps.playlist_id = ? {dead_filter}
ORDER BY ps.position, ps.filename""",
@@ -2729,17 +2503,6 @@ class MetadataDB:
entry = {
"filename": r[0], "position": r[1],
"title": r[2] or r[0], "artist": r[3] or "", "tuning_name": r[4] or "",
# Offsets + the bass-only flag let the playlist tuning check score a
# row against the player's working tuning the same way the library
# grid's chips do: a NAME alone can't be scored (two "Custom Tuning"
# rows are different tunings), and coverage needs to know whether to
# measure against bass or guitar base pitches.
"tuning_offsets": r[9] or "",
"bass_tuning_name": r[10] or "",
"bass_tuning_offsets": r[11] or "",
"rhythm_tuning_name": r[12] or "",
"rhythm_tuning_offsets": r[13] or "",
"bass_only": _arrangements_all_bass(r[7]),
"art_url": f"/api/song/{quote(r[0])}/art",
}
if is_album:
@@ -2767,9 +2530,7 @@ class MetadataDB:
if work_key:
self._ensure_work_display()
row = self.conn.execute(
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements, "
"s.tuning_offsets, s.bass_tuning_name, s.bass_tuning_offsets, "
"s.rhythm_tuning_name, s.rhythm_tuning_offsets "
"SELECT wd.filename, s.title, s.artist, s.tuning_name, s.arrangements "
"FROM work_display wd JOIN songs s ON s.filename = wd.filename "
"WHERE wd.effective_work_key = ? AND wd.is_group_representative = 1",
(work_key,)).fetchone()
@@ -2779,16 +2540,8 @@ class MetadataDB:
arrs = _ensure_smart_names(json.loads(row[4]) if row[4] else [])
except Exception:
arrs = []
# An orphan-resolved slot PLAYS a different chart, so it must report
# that chart's tuning to the check — not the dead pin's.
return {"resolved_filename": row[0], "title": row[1] or row[0],
"artist": row[2] or "", "tuning_name": row[3] or "",
"tuning_offsets": row[5] or "",
"bass_tuning_name": row[6] or "",
"bass_tuning_offsets": row[7] or "",
"rhythm_tuning_name": row[8] or "",
"rhythm_tuning_offsets": row[9] or "",
"bass_only": _arrangements_all_bass(row[4]),
"arrangements": arrs,
"art_url": f"/api/song/{quote(row[0])}/art",
"resolved_from_orphan": True}
@@ -2882,30 +2635,6 @@ class MetadataDB:
self.conn.commit()
return True
def reorder_playlists(self, ordered_ids: list[int]) -> bool:
"""Persist a manual ordering of the playlists THEMSELVES: position =
index in `ordered_ids` (the songs-within sibling is reorder_playlist).
Caller (the route) validates the list is an exact permutation of the
current non-system playlist ids."""
with self._lock:
for pos, pid in enumerate(ordered_ids):
self.conn.execute(
"UPDATE playlists SET position = ?, updated_at = datetime('now') WHERE id = ?",
(pos, pid),
)
self.conn.commit()
return True
def clear_playlist_positions(self) -> bool:
"""Drop every manual playlist position → back to alphabetical
(the "Sort AZ" affordance)."""
with self._lock:
self.conn.execute(
"UPDATE playlists SET position = NULL, updated_at = datetime('now') "
"WHERE position IS NOT NULL")
self.conn.commit()
return True
def toggle_saved(self, filename: str) -> bool:
"""Add/remove a song on the Saved-for-Later playlist. Returns new state.
The presence check and the add/remove run under one lock so two
@@ -3006,39 +2735,16 @@ class MetadataDB:
def favorite_set(self) -> set[str]:
return {r[0] for r in self.conn.execute("SELECT filename FROM favorites").fetchall()}
# Every per-perspective column, in one place, so the SELECT, the INSERT and
# the scanner's "was this ever extracted?" check can never drift apart.
# NULL is meaningful on `name`/`key`/`low_pitch`: it marks a row written
# before the column existed, which the scanner re-extracts (see
# scan._has_unextracted_columns). '' / 0 means "extracted, no such chart".
_PERSPECTIVE_COLS = tuple(
p.column(suffix)
for p in ROLE_PERSPECTIVES
for suffix in ("name", "sort_key", "offsets", "key", "low_pitch")
) + ("tuning_low_pitch",)
# Columns whose NULL means "never extracted" rather than "no such chart".
#
# low_pitch is deliberately NOT a marker: a song with no chart in that role
# legitimately has NULL there (nothing to compute a pitch from), so keying
# re-extraction on it would re-scan those rows on every single pass and
# never converge. `name` and `key` carry the signal instead — they are ''
# when extracted-but-absent, NULL only when the column predates the row.
_EXTRACTION_MARKER_COLS = tuple(
p.column(suffix) for p in ROLE_PERSPECTIVES for suffix in ("name", "key")
)
def get(self, filename: str, mtime: float, size: int) -> dict | None:
cache_key = str(filename)
pcols = ", ".join(self._PERSPECTIVE_COLS)
with self._lock:
row = self.conn.execute(
"SELECT mtime, size, title, artist, album, year, duration, tuning, arrangements, has_lyrics, "
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, "
f"{pcols} "
"format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets "
"FROM songs WHERE filename = ?", (cache_key,)
).fetchone()
if row and row[0] == mtime and row[1] == size and row[2]:
out = {
return {
"title": row[2], "artist": row[3], "album": row[4],
"year": row[5], "duration": row[6], "tuning": row[7],
"arrangements": json.loads(row[8]) if row[8] else [],
@@ -3050,15 +2756,6 @@ class MetadataDB:
"tuning_sort_key": int(row[14] or 0),
"tuning_offsets": row[15] or "",
}
for i, col in enumerate(self._PERSPECTIVE_COLS, start=16):
val = row[i]
if col in self._EXTRACTION_MARKER_COLS:
out[col] = val # NULL preserved — drives re-extraction
elif col.endswith("_sort_key"):
out[col] = int(val or 0)
else:
out[col] = val or ""
return out
return None
def put(self, filename: str, mtime: float, size: int, meta: dict):
@@ -3066,9 +2763,8 @@ class MetadataDB:
self.conn.execute(
"INSERT OR REPLACE INTO songs "
"(filename, mtime, size, title, artist, album, year, duration, tuning, arrangements, "
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc, "
+ ", ".join(self._PERSPECTIVE_COLS) + ") "
"VALUES (" + ", ".join(["?"] * (20 + len(self._PERSPECTIVE_COLS))) + ")",
"has_lyrics, format, stem_count, stem_ids, tuning_name, tuning_sort_key, tuning_offsets, genre, track_number, disc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(filename, mtime, size, meta.get("title", ""), meta.get("artist", ""),
meta.get("album", ""), meta.get("year", ""), meta.get("duration", 0),
meta.get("tuning", ""), json.dumps(meta.get("arrangements", [])),
@@ -3081,14 +2777,7 @@ class MetadataDB:
meta.get("tuning_offsets", "") or "",
meta.get("genre", "") or "",
meta.get("track_number"),
meta.get("disc"),
# A put() row is by definition freshly extracted, so the
# marker columns must never be written NULL — that state is
# reserved for rows predating the column, which re-extract.
# low_pitch is the exception: NULL there means "this tuning
# has no computable pitch" (unusable offsets), and the
# playable filter treats unknown as not-playable.
*[_put_perspective_value(meta, col) for col in self._PERSPECTIVE_COLS]),
meta.get("disc")),
)
self.conn.commit()
# A song's identity may have changed → the grouping read-model is stale.
@@ -3568,8 +3257,6 @@ class MetadataDB:
match_states: list[str] | None = None,
genre: list[str] | None = None,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None,
include_intrinsic: bool = True) -> tuple[str, list]:
"""Shared WHERE-clause builder for query_page / query_artists /
query_stats. Returns (where_sql, params). Leading 'WHERE' is
@@ -3676,8 +3363,7 @@ class MetadataDB:
"songs", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
where += ifrag
params += iparams
return where, params
@@ -3689,9 +3375,7 @@ class MetadataDB:
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[str, list]:
naming_mode: str = "legacy") -> tuple[str, list]:
"""CHART-INTRINSIC predicates (format / arrangements / stems / lyrics /
tuning) as ' AND …' fragments against an explicit table alias. Flat
queries apply them to `songs` directly; grouped queries evaluate them
@@ -3834,32 +3518,10 @@ class MetadataDB:
placeholders = ",".join(["?"] * len(tn))
# Match the same grouping key tuning_names() returns so a single
# "Custom Tuning" pill selects exactly its offset set while named
# tunings still match by name. `instrument` swaps in the
# effective bass tuning key (guitar fallback) — the facet and
# this WHERE must use the same expression or they disagree.
where += (f" AND {_tuning_group_key_sql(alias, instrument)} "
# tunings still match by name.
where += (f" AND {_tuning_group_key_sql(alias)} "
f"COLLATE NOCASE IN ({placeholders})")
params += tn
if playable_from_pitch is not None:
# "Playable without retuning" — the mode the tester actually wants
# ("don't make me retune"), offered ALONGSIDE exact match, not
# instead of it. A chart needs no retune when its lowest required
# pitch is reachable, and every pitch above your lowest open string
# is reachable by fretting, so the comparison is:
#
# your lowest open pitch <= the chart's lowest open pitch
#
# That is why a 5-string bass (low B) covers every 4-string
# standard AND every drop-D chart untouched.
#
# CONSERVATIVE BY CONSTRUCTION: a chart whose low pitch we could
# not compute (NULL) is EXCLUDED rather than assumed playable —
# wrongly claiming playability costs a mid-practice retune, which
# is the failure this whole feature exists to prevent. See
# tunings.chart_is_playable_in for the full reasoning + limits.
low_sql = _effective_low_pitch_sql(alias, instrument)
where += f" AND {low_sql} IS NOT NULL AND {low_sql} >= ?"
params.append(int(playable_from_pitch))
return where, params
# Under group=1, chart-intrinsic filters match if ANY member of the work
@@ -4127,9 +3789,7 @@ class MetadataDB:
genre: list[str] | None = None,
after: str | None = None,
group: bool = False,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
naming_mode: str = "legacy") -> tuple[list[dict], int]:
"""Server-side paginated search. Returns (songs, total_count).
`after` is an opaque keyset cursor (the last row of the previous page).
@@ -4158,9 +3818,7 @@ class MetadataDB:
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
tags_has=tags_has, user_difficulty_in=user_difficulty_in,
match_states=match_states, genre=genre,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
include_intrinsic=not group,
naming_mode=naming_mode, include_intrinsic=not group,
)
ifrag, iparams = "", []
if group:
@@ -4169,14 +3827,12 @@ class MetadataDB:
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
where += self._GROUP_REP_PREDICATE
_eff_tuning_name, _, _eff_tuning_sort = _effective_tuning_cols_sql("songs", instrument)
sort_map = {
# Artist sorts order WITHIN an artist by title (the tree view's
# artist -> album -> title feel) instead of raw filename — the
@@ -4210,15 +3866,11 @@ class MetadataDB:
# behind, and a NULL `tuning_name` in `(tuning_name = '')`
# evaluates to NULL itself (which sorts ahead of 0 in
# ASC), defeating the push-to-bottom intent.
#
# Under `instrument=bass` the effective expressions swap in
# the bass arrangement's tuning (guitar fallback) so a bass
# player's tuning sort orders by the tuning they'd play.
"tuning": (
f"(COALESCE({_eff_tuning_name}, '') = '') ASC, "
f"ABS(COALESCE({_eff_tuning_sort}, 0)), "
f"COALESCE({_eff_tuning_sort}, 0) ASC, "
f"COALESCE({_eff_tuning_name}, '') COLLATE NOCASE"
"(COALESCE(tuning_name, '') = '') ASC, "
"ABS(COALESCE(tuning_sort_key, 0)), "
"COALESCE(tuning_sort_key, 0) ASC, "
"COALESCE(tuning_name, '') COLLATE NOCASE"
),
# Year sort (feedBack#128). Empty-year rows pushed to the
# bottom for both directions; otherwise CAST so '2010' >
@@ -4311,9 +3963,7 @@ class MetadataDB:
cols = ("SELECT filename, title, artist, album, year, duration, tuning, "
"arrangements, has_lyrics, mtime, format, stem_count, stem_ids, "
"tuning_name, tuning_offsets, bass_tuning_name, bass_tuning_offsets, "
"rhythm_tuning_name, rhythm_tuning_offsets "
"FROM songs ")
"tuning_name, tuning_offsets FROM songs ")
cursor = _decode_cursor(after) if after else None
eff_sort = _effective_keyset_sort(sort, direction)
if cursor and eff_sort in _KEYSET_SORTS:
@@ -4346,30 +3996,8 @@ class MetadataDB:
"stem_ids": json.loads(r[12]) if r[12] else [],
"tuning_name": r[13] or "",
"tuning_offsets": r[14] or "",
# '' when the song has no bass arrangement (or the row predates
# '' when the song has no such chart (or the row predates the
# columns) — clients fall back to tuning_name.
"bass_tuning_name": r[15] or "",
"bass_tuning_offsets": r[16] or "",
"rhythm_tuning_name": r[17] or "",
"rhythm_tuning_offsets": r[18] or "",
"has_estd": r[0] in estd, "favorite": r[0] in favs,
})
# PROVENANCE (non-default perspectives): a row shown to a bass or
# rhythm player either carries that chart's own tuning (native) or is
# borrowing the guitar-derived song tuning (inferred). The fallback is
# deliberate — a third of a real library has no bass chart and
# excluding it would be worse — but it must never be SILENT, or we
# reproduce the original bug in a new place. The client marks inferred
# rows; it can't infer this itself without duplicating the COALESCE.
#
# guitar-lead adds NOTHING here, so the default payload is unchanged.
_persp = _perspective(instrument)
if _persp.column_prefix:
_name_key = _persp.column("name")
for s in songs:
s["tuning_perspective"] = _persp.id
s["tuning_inferred"] = not s.get(_name_key)
# Personal layer (difficulty + tags) rides along like `favorite`, so a
# card can badge it without a second request. Notes stay OUT of the list
# payload (they can be long) — fetch per-song via /user-meta. Batched to
@@ -4466,7 +4094,7 @@ class MetadataDB:
rows = self.conn.execute(
"SELECT mw.effective_work_key, m.filename, m.title, m.duration, m.tuning, "
"m.arrangements, m.has_lyrics, m.mtime, m.format, m.stem_count, m.stem_ids, "
"m.tuning_name, m.tuning_offsets, m.bass_tuning_name, m.bass_tuning_offsets "
"m.tuning_name, m.tuning_offsets "
"FROM songs m JOIN work_display mw ON mw.filename = m.filename "
f"WHERE mw.effective_work_key IN ({ph}){intrinsic_frag} "
"ORDER BY mw.is_group_representative DESC, m.mtime DESC, m.filename",
@@ -4487,7 +4115,6 @@ class MetadataDB:
"stem_count": int(m[9] or 0),
"stem_ids": json.loads(m[10]) if m[10] else [],
"tuning_name": m[11] or "", "tuning_offsets": m[12] or "",
"bass_tuning_name": m[13] or "", "bass_tuning_offsets": m[14] or "",
}
def query_artists(self, letter: str = "", q: str = "",
@@ -4502,9 +4129,7 @@ class MetadataDB:
stems_lacks: list[str] | None = None,
has_lyrics: int | None = None,
tunings: list[str] | None = None,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> tuple[list[dict], int]:
naming_mode: str = "legacy") -> tuple[list[dict], int]:
"""Get artists grouped by letter with their albums and songs. Returns (artists, total_artists)."""
where, params = self._build_where(
q=q, favorites_only=favorites_only, format_filter=format_filter,
@@ -4512,7 +4137,6 @@ class MetadataDB:
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch,
)
# Canonicalize artists at display when aliases exist (P4): dedupe / group /
# letter / order on the EFFECTIVE artist so "ACDC" + "AC/DC" list as one
@@ -4548,7 +4172,7 @@ class MetadataDB:
rows = self.conn.execute(
f"SELECT filename, title, ({art_expr}) as artist, album, year, duration, tuning, arrangements, has_lyrics, "
f"format, stem_count, stem_ids, tuning_name, bass_tuning_name "
f"format, stem_count, stem_ids, tuning_name "
f"FROM songs {song_where} ORDER BY ({art_expr}) COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE",
song_params
).fetchall()
@@ -4581,7 +4205,6 @@ class MetadataDB:
"stem_count": int(r[10] or 0),
"stem_ids": json.loads(r[11]) if r[11] else [],
"tuning_name": r[12] or "",
"bass_tuning_name": r[13] or "",
"has_estd": r[0] in estd,
"favorite": r[0] in favs,
"user_difficulty": udm.get(r[0]),
@@ -4603,8 +4226,7 @@ class MetadataDB:
stems_has=None, stems_lacks=None,
has_lyrics=None, tunings=None, mastery=None,
match_states=None, genre=None,
naming_mode="legacy", instrument=DEFAULT_PERSPECTIVE,
playable_from_pitch=None, page=0, size=120):
naming_mode="legacy", page=0, size=120):
"""Distinct (artist, album) groups with a track count + a representative
cover song, for the album-condensed browse (paged by album). Rows with no
album name are excluded -- they can't form an album card. Same filters as
@@ -4616,8 +4238,7 @@ class MetadataDB:
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, mastery=mastery,
match_states=match_states, genre=genre,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
naming_mode=naming_mode,
)
awhere = where + " AND album IS NOT NULL AND album != ''"
total = self.conn.execute(
@@ -4648,9 +4269,7 @@ class MetadataDB:
sort: str = "artist",
want_sort_letters: bool = False,
group: bool = False,
naming_mode: str = "legacy",
instrument: str = DEFAULT_PERSPECTIVE,
playable_from_pitch: int | None = None) -> dict:
naming_mode: str = "legacy") -> dict:
"""Aggregate stats for the letter bar. Accepts the same filter
params as query_page so the letter counts stay synchronized
with the grid when filters are active.
@@ -4677,8 +4296,7 @@ class MetadataDB:
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, match_states=match_states,
naming_mode=naming_mode, instrument=instrument,
playable_from_pitch=playable_from_pitch,
naming_mode=naming_mode,
include_intrinsic=not group,
)
if group:
@@ -4690,8 +4308,7 @@ class MetadataDB:
"m", format_filter=format_filter,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode,
instrument=instrument, playable_from_pitch=playable_from_pitch)
has_lyrics=has_lyrics, tunings=tunings, naming_mode=naming_mode)
mfrag, mparams = self._grouped_member_match(ifrag, iparams)
where += mfrag
params += mparams
+11 -37
View File
@@ -54,20 +54,15 @@ MIDDLE_C = 60
def decode_wire_notes(arr_data: dict) -> list[dict]:
"""Decode an arrangement JSON's notes + chord notes to
``[{"t": float, "midi": int, "sus": float, "hand": str|None}, ...]``
sorted by time.
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
legacy alias). ``hand`` is the authored per-note hand assignment
(``'lh'``/``'rh'`` e.g. from a MusicXML grand-staff import via the
editor); a strict enum decode, anything else reads as ``None``
(unassigned) so junk can never steer the hand split. Entries with
malformed fields are skipped.
legacy alias). Entries with malformed fields are skipped.
"""
out: list[dict] = []
def _push(t, s, f, sus, hand):
def _push(t, s, f, sus):
try:
t = float(t)
midi = int(s) * 24 + int(f)
@@ -75,15 +70,11 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
except (TypeError, ValueError):
return
if 0 <= midi <= 127:
out.append({
"t": t, "midi": midi, "sus": max(0.0, sus),
"hand": hand if hand in ("lh", "rh") else None,
})
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
for n in arr_data.get("notes") or []:
if isinstance(n, dict):
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")),
n.get("hand"))
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
for ch in arr_data.get("chords") or []:
if not isinstance(ch, dict):
continue
@@ -92,7 +83,7 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
if isinstance(cn, dict):
# Chord notes carry no own time — they sound at the chord's t.
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
cn.get("sus", cn.get("l")), cn.get("hand"))
cn.get("sus", cn.get("l")))
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
@@ -112,31 +103,14 @@ def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
"""Assign every note to ``rh`` or ``lh``.
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
An AUTHORED per-note ``hand`` ('lh'/'rh' a MusicXML grand-staff import
or a hand edit in the editor) always wins: those notes go straight to
their hand and are REMOVED from the group before any heuristic math runs,
so one explicit assignment can never skew its chordmates' guesses (e.g.
an authored LH melody note above middle C must not drag the group mean
down and flip the remaining notes).
The remaining unassigned notes take the heuristic, per simultaneous
group: a span > 12 semitones splits at the largest internal interval gap
(low side lh); otherwise the whole group goes by mean pitch vs middle C
( 60 rh).
Per simultaneous group: a span > 12 semitones splits at the largest
internal interval gap (low side lh); otherwise the whole group goes by
mean pitch vs middle C ( 60 rh).
"""
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
for full_group in group_simultaneous(notes):
# Authored hands first — explicit notes leave the group entirely.
group = []
for n in full_group:
if n.get("hand") in ("lh", "rh"):
hands[n["hand"]].append(n)
else:
group.append(n)
if not group:
continue
for group in group_simultaneous(notes):
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
+13 -42
View File
@@ -19,7 +19,7 @@ from starlette.concurrency import run_in_threadpool
import appstate
from library_registry import (
_library_filter_args, _normalize_instrument, _sanitize_collection_rules,
_library_filter_args, _sanitize_collection_rules,
_safe_art_redirect_url, _split_csv, _sync_collection_provider,
_unregister_collection_provider,
)
@@ -52,8 +52,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N
_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters", "after",
"mastery", "match_states", "instrument",
"playable_from_pitch")
"mastery", "match_states")
def _filter_provider_kwargs(method: object, kwargs: dict) -> dict:
@@ -236,20 +235,9 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
has_lyrics: str = "", tunings: str = "", provider: str = "local",
mastery: str = "", tags: str = "", user_difficulty: str = "",
match: str = "", genre: str = "", after: str = "", group: int = 0,
naming_mode: str = "legacy", instrument: str = "",
tuning_match: str = "", playable_offsets: str = "",
playable_instrument: str = "", playable_string_count: str = ""):
naming_mode: str = "legacy"):
"""Paginated library search through the selected library provider.
`instrument` is the tuning PERSPECTIVE ("guitar-lead" default |
"guitar-rhythm" | "bass"): which arrangement's tuning the tuning
filter/sort speaks for, with a guitar fallback when a song has no chart in
that role.
`tuning_match=playable` switches the tuning filter from exact-match to
"playable without retuning" against the caller's current tuning
(`playable_offsets` + `playable_instrument` + `playable_string_count`).
`after` is an opaque keyset cursor (feedBack#636 item 3): pass back the
`next_cursor` from the previous response to fetch the next page with a
WHERE-seek instead of OFFSET. Providers that don't support it ignore it and
@@ -282,10 +270,7 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
tuning_match=tuning_match, playable_offsets=playable_offsets,
playable_instrument=playable_instrument,
playable_string_count=playable_string_count,
has_lyrics=has_lyrics, tunings=tunings,
),
)
# The cursor to resume after this page (effective sort folds in dir=desc).
@@ -307,7 +292,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", mastery: str = "",
match: str = "", genre: str = "",
provider: str = "local", instrument: str = ""):
provider: str = "local"):
"""Album-condensed browse: distinct (artist, album) groups with a track count
and a representative cover song. Paged by album. Same filters as /api/library."""
size = min(size, 500)
@@ -321,7 +306,7 @@ async def list_library_albums(q: str = "", page: int = 0, size: int = 120,
q=q, favorites=favorites, format=format, artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
has_lyrics=has_lyrics, tunings=tunings,
),
)
return {"albums": albums, "total": total, "page": page, "size": size}
@@ -334,9 +319,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
arrangements_has: str = "", arrangements_lacks: str = "",
stems_has: str = "", stems_lacks: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
naming_mode: str = "legacy", instrument: str = "",
tuning_match: str = "", playable_offsets: str = "",
playable_instrument: str = "", playable_string_count: str = ""):
naming_mode: str = "legacy"):
"""Get artists grouped by letter with albums and songs (for tree view)."""
size = min(size, 100)
library_provider = _get_library_provider(provider)
@@ -353,7 +336,7 @@ async def list_artists(letter: str = "", q: str = "", favorites: int = 0, page:
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
has_lyrics=has_lyrics, tunings=tunings,
),
)
return {"artists": artists, "total_artists": total, "page": page, "size": size}
@@ -367,10 +350,7 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
has_lyrics: str = "", tunings: str = "", provider: str = "local",
match: str = "",
sort: str = "artist", sort_letters: int = 0,
group: int = 0, naming_mode: str = "legacy",
instrument: str = "", tuning_match: str = "",
playable_offsets: str = "", playable_instrument: str = "",
playable_string_count: str = ""):
group: int = 0, naming_mode: str = "legacy"):
"""Aggregate stats for the UI. Accepts the same filter params as
/api/library so the letter bar mirrors the active grid filter set.
`sort` selects the column the jump rail's `sort_letters` keys on;
@@ -395,10 +375,7 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "",
artist=artist, album=album,
arrangements_has=arrangements_has, arrangements_lacks=arrangements_lacks,
stems_has=stems_has, stems_lacks=stems_lacks,
has_lyrics=has_lyrics, tunings=tunings, instrument=instrument,
tuning_match=tuning_match, playable_offsets=playable_offsets,
playable_instrument=playable_instrument,
playable_string_count=playable_string_count,
has_lyrics=has_lyrics, tunings=tunings,
),
)
@@ -430,20 +407,14 @@ def library_genres(provider: str = "local"):
@router.get("/api/library/tuning-names")
async def list_tuning_names(provider: str = "local", instrument: str = ""):
async def list_tuning_names(provider: str = "local"):
"""Distinct tuning names present in the library, with per-tuning
counts. Powers the tuning multi-select. Sorted by `tuning_sort_key`
so names appear in the same musical order the sort uses
(feedBack#22) — E Standard first, then nearest neighbors.
`instrument=bass` groups by each song's bass-arrangement tuning
(guitar-derived fallback for songs without a bass chart) so bass
players see the tunings they'd actually play. Providers that predate
the kwarg simply don't receive it (signature-filtered)."""
(feedBack#22) — E Standard first, then nearest neighbors."""
library_provider = _get_library_provider(provider)
_require_library_provider_capability(library_provider, "library.read")
return await _call_library_provider_async(
library_provider, "tuning_names", instrument=_normalize_instrument(instrument))
return await _call_library_provider_async(library_provider, "tuning_names")
@router.get("/api/library/practice-suggestions")
-31
View File
@@ -70,37 +70,6 @@ def api_create_playlist(data: dict):
return appstate.meta_db.create_playlist(name, kind=kind)
@router.post("/api/playlists/reorder")
def api_reorder_playlists(data: dict):
"""Manual ordering of the playlists themselves (position = index in
`order`); the songs-within sibling is /api/playlists/{pid}/reorder.
System playlists stay pinned first and are not part of the order."""
order = data.get("order")
if not isinstance(order, list) or not all(
isinstance(i, int) and not isinstance(i, bool) for i in order):
return JSONResponse({"error": "order must be a list of playlist ids"}, status_code=400)
# Require an exact permutation of the current non-system playlist ids: a
# list with duplicates, omissions, extras, unknown ids, or a system id
# would otherwise produce duplicate positions / a partial reorder while
# still returning 200 (mirrors the songs-within validation).
current = [p["id"] for p in appstate.meta_db.list_playlists() if not p["system_key"]]
if len(order) != len(current) or sorted(order) != sorted(current):
return JSONResponse(
{"error": "order must be a permutation of your playlists' ids"},
status_code=400,
)
appstate.meta_db.reorder_playlists(order)
return api_list_playlists()
@router.post("/api/playlists/sort-alpha")
def api_sort_playlists_alpha():
"""Clear every manual playlist position → back to the alphabetical
default (system playlists were pinned first either way)."""
appstate.meta_db.clear_playlist_positions()
return api_list_playlists()
@router.get("/api/playlists/{pid}")
def api_get_playlist(pid: int):
pl = appstate.meta_db.get_playlist(pid)
+5 -70
View File
@@ -829,61 +829,9 @@ def post_song_gap_fill(filename: str, data: dict):
return {"ok": True, "written": additions, "skipped": skipped}
def _playable_stems_payload(filename: str, dlc) -> dict:
"""The playable stems (id/url/default) + full-mix URL for a sloppak.
Why it exists: the stems plugin could only learn its stem list from the
highway's WS `ready`, which arrives once the highway is already up. So it
decoded, and then copied the whole song's PCM to its worklet, with the player
on screen half a gigabyte of memcpy in one frame, ~700 ms, freezing the
venue video. Given the list at `song:loading` it can do all of that BEFORE the
highway appears, behind the loading overlay where a stall costs nothing.
The list MUST be the same one the WS sends a moment later. If it is not, the
plugin preloads a graph and then throws it away and rebuilds strictly worse
than not preloading. So this does not reimplement the WS's construction, it
calls THE SAME FUNCTION: load_song, whose LoadedSloppak already carries the
partitioned stems and the resolved full mix, and then builds the URLs exactly
as ws_highway does. Drift is impossible by construction rather than by
agreement which matters, because `full_mix` in particular is not simply the
`full` stem: load_song falls back to the deprecated `original_audio:` key for
every pack written before feedpak 1.15.0, and reimplementing that (I did, at
first) silently dropped the pristine full mix for most real libraries.
Opt-in (`?stems=1`) so the library's own metadata calls — the hot path — pay
nothing for it. Non-sloppak sources (archives, loose folders) have no stems
to preload: load_song raises and we return the empty list.
"""
from urllib.parse import quote
try:
loaded = sloppak_mod.load_song(filename, dlc, appstate.sloppak_cache_dir)
except Exception:
return {"stems": [], "full_mix_url": None}
q_fn = quote(filename, safe="")
def _url(rel: str) -> str:
return f"/api/sloppak/{q_fn}/file/{quote(rel)}"
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
}
@router.get("/api/song/{filename:path}")
async def get_song_info(filename: str, stems: int = 0):
"""Return song metadata, from cache or by extracting it from the song source.
`?stems=1` additionally returns the playable stem list with URLs, so the
stems plugin can start fetching/decoding on `song:loading` instead of waiting
for the highway's WS `ready` (see _playable_stems_payload).
"""
async def get_song_info(filename: str):
"""Return song metadata, from cache or by extracting it from the song source."""
import asyncio
dlc = _get_dlc_dir()
if not dlc:
@@ -906,21 +854,8 @@ async def get_song_info(filename: str, stems: int = 0):
mtime, size = appstate.stat_for_cache(song_path)
cached = appstate.meta_db.get(cache_key, mtime, size)
loop = asyncio.get_event_loop()
# The stem list is NOT stored in the metadata cache: that is a fixed-column
# table, and widening it would mean a migration plus a stale row for every
# song already scanned. It is cheap to read on demand (the pack is unpacked
# by then, so this is a plain manifest read), and only the opt-in caller pays.
async def _with_stems(meta: dict) -> dict:
if not stems:
return meta
extra = await loop.run_in_executor(
None, _playable_stems_payload, filename, dlc)
return {**meta, **extra}
if cached:
return await _with_stems(cached)
return cached
# Extract in thread pool
def _extract():
@@ -928,5 +863,5 @@ async def get_song_info(filename: str, stems: int = 0):
appstate.meta_db.put(cache_key, mtime, size, meta)
return meta
meta = await loop.run_in_executor(None, _extract)
return await _with_stems(meta)
meta = await asyncio.get_event_loop().run_in_executor(None, _extract)
return meta
+2 -34
View File
@@ -76,22 +76,6 @@ def api_record_stats(data: dict):
last_pos = data.get("lastPlayPosition", data.get("last_position"))
if isinstance(last_pos, bool): # float(False)=0.0 would otherwise store a bogus position
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# Optional wall-clock play time (career hours odometer). Bounded per POST:
# the recorder flushes on pause/stop/end, so a single delta beyond 6h is a
# clock artifact (suspend/sleep), not practice.
seconds = data.get("seconds")
if seconds is not None:
if isinstance(seconds, bool):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
try:
seconds = float(seconds)
if not math.isfinite(seconds):
raise ValueError("non-finite")
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "seconds must be a positive number"}, status_code=400)
if not (0 < seconds <= 6 * 3600):
return JSONResponse({"error": "seconds must be between 0 and 21600"}, status_code=400)
seconds = seconds or 0.0
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
@@ -131,8 +115,7 @@ def api_record_stats(data: dict):
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
row = appstate.meta_db.record_session(filename, arrangement, score=score,
accuracy=accuracy, last_position=last_pos,
seconds=seconds)
accuracy=accuracy, last_position=last_pos)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
@@ -169,21 +152,6 @@ def api_record_stats(data: dict):
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Seconds-only accrual: an unscored play that ran to the song's natural
# end has play time to bank but no resume position to touch (song:ended
# must not overwrite Continue with the end-of-song offset). Still counts
# as playing today for the streak below.
if last_pos is None and seconds:
row = appstate.meta_db.add_play_seconds(filename, arrangement, seconds)
progress = None
try:
from datetime import date
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (streak) failed", exc_info=True)
return {"stats": row, "progress": progress}
# Position-only touch.
if last_pos is None:
return JSONResponse(
@@ -194,7 +162,7 @@ def api_record_stats(data: dict):
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
row = appstate.meta_db.touch_position(filename, arrangement, pos, seconds=seconds)
row = appstate.meta_db.touch_position(filename, arrangement, pos)
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "lastPlayPosition must be a finite number"}, status_code=400)
# A resume session still counts as playing today: advance the streak (no XP —
+32 -97
View File
@@ -26,7 +26,6 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from song import (
anchor_to_wire,
arrangement_is_bass,
arrangement_string_count,
base_open_string_midis,
chord_template_to_wire,
@@ -144,21 +143,9 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]:
return out
def _drum_part_id_for_wire(drum_parts: list[dict] | None, selected_id: str | None) -> str | None:
"""Expose a part id only when the pack genuinely has multiple parts."""
return selected_id if selected_id is not None and len(drum_parts or []) > 1 else None
@router.websocket("/ws/highway/{filename:path}")
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
naming_mode: str = "legacy", drum_part: str = ""):
"""Stream song data for the highway renderer over WebSocket.
`drum_part` selects WHICH drum part's tab streams when the pack carries
several (feedpak 1.17.0 "drums as arrangements") a part id from
song_info's `drum_parts`. Empty / unknown ids fall back to the primary,
so a stale or mistyped selection degrades to today's behavior instead of
silencing drums."""
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"):
"""Stream song data for the highway renderer over WebSocket."""
await websocket.accept()
structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8])
@@ -274,8 +261,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
bass_idxs = [
i
for i, a in enumerate(song.arrangements)
if arrangement_is_bass(a)
if getattr(a, "path_bass", False)
or (smart_names[i] or "").lower().startswith("bass")
or "bass" in (getattr(a, "name", "") or "").lower()
]
if bass_idxs:
# Among the bass parts: (1) honor the saved default-arrangement
@@ -333,16 +321,11 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
audio_url = None
audio_error: str | None = None # Surfaced in song_info when audio_url is None
stems_payload: list[dict] = []
# URL of the pack's complete mixdown — the RESERVED `full` stem (spec
# §5.3), which sloppak.load_song() lifts out of `stems` because it is a
# mixdown, not a layer. The stems plugin plays it while every stem slider
# is at unity (separation is lossy, so it beats re-summing the stems) and
# crosses to the separated stems as soon as one is attenuated.
#
# None when the pack has no mixdown to offer separately from its stems:
# a single-mix pack (its one stem IS the mixdown), a loose folder, or an
# archive.
full_mix_url: str | None = None
# URL of the single full-mix audio (sloppak `original_audio:`), when the
# pack ships one. The stems plugin uses this to play the untouched mix
# while every stem slider is at unity; None otherwise (separate stems
# only, loose folder, or archive).
original_audio_url: str | None = None
if is_loose:
# Loose folder filenames are relative paths (artist/album/song).
# Hash the *canonical* dlc-relative path (so two URL spellings
@@ -380,29 +363,23 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
q_fn = quote(filename, safe="")
for s in loaded_slop.stems:
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append(
{"id": s["id"], "url": url, "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}})
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.full_mix:
full_mix_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.full_mix)}"
if loaded_slop is not None and loaded_slop.original_audio:
original_audio_url = (
f"/api/sloppak/{q_fn}/file/{quote(loaded_slop.original_audio)}"
)
if stems_payload:
# Stems present: keep the core <audio> pointed at stem[0]. This
# URL is only ever heard in the degraded path (stems plugin
# refuses takeover / decode fails); the full-mix↔stems switch is
# driven client-side by `full_mix_url`, not `audio_url`.
# driven client-side by `original_audio_url`, not `audio_url`.
audio_url = stems_payload[0]["url"]
elif full_mix_url:
elif original_audio_url:
# Stem-less full-mix pack: nothing to separate, so play the full
# mix natively through the core <audio>. The stems plugin's
# onSongReady returns early on an empty stems list (no graph).
# Reachable only via the deprecated `original_audio:` key, whose
# packs put the mixdown outside `stems` — a pack that carries its
# mixdown as the `full` stem has it IN `stems`, so it lands in the
# branch above with stems_payload == [full].
audio_url = full_mix_url
audio_url = original_audio_url
else:
audio_error = "This sloppak has no playable stems."
else:
@@ -544,31 +521,16 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# for the credits overlay, so minigames / synthetic highway uses
# (no manifest) never trigger it.
"authors": _sanitize_authors(loaded_slop.manifest) if (is_slop and loaded_slop is not None) else [],
# Instrument stems ONLY. The pack's complete mixdown (the RESERVED
# `full` stem, spec §5.3) is deliberately NOT in this list: consumers
# sum `stems` into one mix and render one fader per entry, and the
# mixdown is neither a layer nor an instrument — summing it would
# double the whole song. It is surfaced separately, below.
"stems": stems_payload,
# The complete mixdown, served by the same /api/sloppak/.../file/
# endpoint as the stems. The stems plugin plays this single file
# while every stem slider is at unity and crosses to the separated
# stems the moment one drops below 100% — separation is lossy, so the
# mixdown is strictly better audio when nothing is muted. None when
# the pack has no mixdown apart from its stems. The `has_*` flags
# mirror the has_drum_tab/has_keys convention so a client can branch
# without re-deriving from the URLs.
"full_mix_url": full_mix_url,
"has_full_mix": bool(full_mix_url),
# Full-mix audio (sloppak `original_audio:`) served alongside the
# separate `stems`. The stems plugin plays this single file while
# every stem slider is at unity and switches to the separate stems
# the moment one drops below 100%. None when the pack ships stems
# only. `has_*` flags mirror the has_drum_tab/has_keys convention so
# a client can branch without re-deriving from the URLs.
"original_audio_url": original_audio_url,
"has_original_audio": bool(original_audio_url),
"has_stems": bool(stems_payload),
# DEPRECATED aliases of the two keys above, kept so a client built
# against the old frame keeps working across one release. They were
# named after `original_audio:` — a manifest key this repo invented
# and the feedpak spec never had (#933). The key is gone; the mixdown
# is a stem. Remove these once the shipped stems plugin reads
# `full_mix_url` (#945).
"original_audio_url": full_mix_url,
"has_original_audio": bool(full_mix_url),
# Surface a drum_tab presence flag so the visualization picker
# can auto-activate the drums plugin even when the chosen
# arrangement isn't named "Drums" (drum_tab.json lives next
@@ -576,15 +538,6 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
"has_drum_tab": bool(
is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None
),
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"),
# primary first — names only; the selected part's payload streams
# as the `drum_tab`/`drum_hits` messages below. Always a list
# (empty when the pack has no drums, and a single entry for a
# legacy one-drum pack), so a part picker can bind unconditionally.
"drum_parts": [
{"id": p["id"], "name": p["name"]}
for p in (loaded_slop.drum_parts or [])
] if is_slop and loaded_slop is not None else [],
"has_notation": bool(
is_slop
and loaded_slop is not None
@@ -608,36 +561,18 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# client-side drums plugin keeps a fallback decoder for them.
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
dt = loaded_slop.drum_tab
# Multiple drum parts: `?drum_part=<id>` picks which part's tab
# streams; the default (and any unknown id) is the PRIMARY —
# exactly the pre-parts behavior, so legacy clients notice nothing.
_dt_part_id = None
if loaded_slop.drum_parts:
_dt_part_id = loaded_slop.drum_parts[0]["id"]
if drum_part:
for _p in loaded_slop.drum_parts:
if _p["id"] == drum_part:
dt = _p["drum_tab"]
_dt_part_id = _p["id"]
break
kit = drums_mod.normalise_kit(dt.get("kit"))
hits_wire = drums_mod.hits_to_wire(dt.get("hits") or [])
_dt_name = dt.get("name")
_dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums"
_dt_msg = {
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
}
# Only multi-part packs identify a part on the wire. Legacy packs
# synthesize a one-item list internally but keep their old frame.
_wire_part_id = _drum_part_id_for_wire(loaded_slop.drum_parts, _dt_part_id)
if _wire_part_id is not None:
_dt_msg["part_id"] = _wire_part_id
try:
await websocket.send_json(_dt_msg)
await websocket.send_json({
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
})
for i in range(0, len(hits_wire), 500):
await websocket.send_json({
"type": "drum_hits",
@@ -1014,7 +949,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# base[string] + offset + capo + fret (matches the tuner / open-string
# labels). arrangement_string_count is O(notes), so compute once here.
_base = base_open_string_midis(
arrangement_string_count(arr), arrangement_is_bass(arr))
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
_capo = int(getattr(arr, "capo", 0) or 0)
def _fill_scale_degree(wire: dict, n, t: float) -> None:
-140
View File
@@ -1,140 +0,0 @@
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
A deliberately dumb fan-out room: a JSON text frame received from one client
is forwarded verbatim to every OTHER client connected to the same session id.
The server interprets nothing beyond the limits below message schemas are
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
frames from a host window to view-only followers on other LAN devices.
Design points (full spec in the issue):
- Rooms are created on first join and garbage-collected when the last socket
leaves. No history, no replay, no persistence a late joiner simply waits
for the next frame. Consumers that need state on join re-send it themselves
(splitscreen answers every follower ``hello`` with a fresh ``config``).
- That statelessness is what makes consumer crash-recovery work: a host that
relaunches and rejoins the same session id resumes publishing to its
reconnecting subscribers with no server-side coordination, and an idle room
is indistinguishable from a nonexistent one.
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
consumers pick their own id policy (splitscreen uses a short typeable,
persistent room key).
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
sockets are closed with a policy code; the room carries on. A peer that dies
mid-fan-out is dropped without wedging delivery to the rest.
"""
import asyncio
import logging
import re
import time
from fastapi import APIRouter, WebSocket
log = logging.getLogger("feedBack.server")
router = APIRouter()
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
# Limits. Sized generously above the first consumer's needs (splitscreen
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
# what an open LAN port can be made to do. All module-level so tests (and a
# desperate operator) can override them.
MAX_FRAME_BYTES = 16 * 1024
MAX_CLIENTS_PER_ROOM = 16
MAX_ROOMS = 32
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
RATE_BURST = 240.0 # token-bucket burst headroom
# A peer that stops draining its socket would leave send_text() pending
# forever — and since publishers await the fan-out gather, one stalled peer
# would stall every publisher's receive loop behind it. Bounding the send
# turns the stall into an eviction through the normal failed-send drop path.
SEND_TIMEOUT_SECONDS = 5.0
# RFC 6455 close codes.
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
_WS_MSG_TOO_BIG = 1009
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
# fan-out sends to the same peer (two publishers relaying at once must not
# interleave writes on a third socket's transport).
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
async with lock:
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
@router.websocket("/ws/sync/{session_id}")
async def sync_ws(websocket: WebSocket, session_id: str):
"""Join the fan-out room *session_id*; relay every inbound text frame."""
await websocket.accept()
if not _SESSION_ID_RE.fullmatch(session_id):
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
return
# Capacity checks and insertion run with no await between them, so
# concurrent joiners on the event loop can't race past the caps.
room = _rooms.get(session_id)
if room is None:
if len(_rooms) >= MAX_ROOMS:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
return
room = _rooms[session_id] = {}
log.debug("ws_sync: room %s created", session_id)
elif len(room) >= MAX_CLIENTS_PER_ROOM:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
return
room[websocket] = asyncio.Lock()
tokens = RATE_BURST
last_refill = time.monotonic()
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
text = message.get("text")
if text is None:
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
break
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
break
now = time.monotonic()
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
last_refill = now
tokens -= 1.0
if tokens < 0:
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
break
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
if not peers:
continue
results = await asyncio.gather(
*(_send_locked(ws, lock, text) for ws, lock in peers),
return_exceptions=True,
)
# A peer that failed mid-send is dropped from the room here; its
# own handler finishes cleanup (the finally below) when its
# receive loop observes the disconnect.
for (peer, _lock), result in zip(peers, results):
if isinstance(result, Exception):
room.pop(peer, None)
finally:
room.pop(websocket, None)
# Guard against deleting a NEW room another joiner created after this
# one emptied (only possible for a dict that is no longer ours).
if not room and _rooms.get(session_id) is room:
del _rooms[session_id]
log.debug("ws_sync: room %s closed", session_id)
+3 -31
View File
@@ -4,34 +4,9 @@ under a server-owned root.
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=16)
def resolved_root(root: Path) -> Path:
"""Canonical (link-resolved) form of a server-owned root directory.
``Path.resolve()`` is a filesystem call: it lstats every component of the
path. The roots we join against the DLC library, a plugin's asset dir —
are fixed for the life of the process, but the containment helpers below
(and ``dlc_paths._resolve_dlc_path``) were re-resolving them on EVERY call,
and those are called once per song, per art fetch, per scanned row.
On a real 50,944-song library that cost ~23,500 stat/lstat calls per second,
pinning a core. It is brutal when the library lives on a FUSE mount
(NTFS-3G, SMB, sshfs), where every stat is a userspace round trip: the same
three parent directories were being walked over and over.
Cached because a root is a constant here, not because resolution is cheap.
Consequence: if a root's symlink/junction is re-pointed at a NEW target
while the server is running, the old target stays in effect until restart.
That is fine for a library path fixed at startup, and the cache is keyed on
the Path, so switching to a different library dir is a different key.
"""
return root.resolve()
def safe_join(root: Path, name: str) -> Path | None:
"""Resolve ``name`` under ``root`` and return the resolved Path, or
``None`` if it would escape ``root`` or is unrepresentable.
@@ -60,12 +35,9 @@ def safe_join(root: Path, name: str) -> Path | None:
return None
safe = name.replace("\\", "/")
try:
# The ROOT is a constant — resolve it once (see resolved_root). The
# CANDIDATE must still be resolved on every call: following its symlinks
# is exactly the zip-slip / traversal defence, so it is never cached.
root_res = resolved_root(root)
candidate = (root_res / safe).resolve()
if not candidate.is_relative_to(root_res):
root_resolved = root.resolve()
candidate = (root_resolved / safe).resolve()
if not candidate.is_relative_to(root_resolved):
return None
except (ValueError, OSError):
return None
+5 -169
View File
@@ -51,120 +51,6 @@ from scan_worker import _relpath, _scan_one
log = logging.getLogger("feedBack.scan")
import json
# ── Directory-signature fast path ─────────────────────────────────────────────
#
# A startup scan globs the whole library twice (*.feedpak, *.wem) and stats every
# file to detect what changed. On a 50k-song library that lives on a slow mount
# (an NTFS-3G FUSE volume here) it is ~100k filesystem round trips every launch —
# the "big drive churns on every startup" report.
#
# But adds / removes / renames of songs all bump the mtime of the DIRECTORY that
# holds them (verified on the target NTFS-3G mount), and so does the addition of
# a subdirectory (a new entry in its parent). So after a scan we record every
# library directory and its mtime; on the next scan we re-stat ONLY those
# directories (a handful, vs 100k file ops). If none changed, the file set is
# unchanged and the whole listing/stat pass is skipped.
#
# The one thing this cannot see is a file edited IN PLACE under the same name —
# that bumps the file's mtime but not its directory's. That is rare for a song
# library (you add and remove packs, you don't rewrite them under the same name),
# and the manual Refresh forces a full scan (force=True) for exactly that case.
def _dir_signature_file() -> Path:
return appstate.config_dir / "scan_dir_signature.json"
def _load_dir_signature() -> dict | None:
try:
data = json.loads(_dir_signature_file().read_text(encoding="utf-8"))
if isinstance(data, dict) and isinstance(data.get("dirs"), dict):
return data
except (OSError, ValueError):
pass
return None
def _save_dir_signature(dlc: Path, dirs: dict[str, int]) -> None:
# Keyed by the DLC path so switching libraries never matches a stale
# signature. Best-effort: a failed write just means the next scan is a full
# one, never a wrong one.
try:
_dir_signature_file().write_text(
json.dumps({"dlc": str(dlc), "dirs": dirs}), encoding="utf-8")
except OSError as e:
log.debug("scan: could not persist dir signature: %s", e)
def _library_dirs(all_songs, dlc: Path) -> set[str]:
"""Every directory whose mtime reflects an add/remove of a library song:
each song's containing directory and all of its ancestors up to the DLC
root (the root itself always included, as "."). Derived from the already-
listed songs no extra filesystem walk. The builtin carve-outs
(tutorials-builtin / minigames-builtin) are absent because the caller
already excluded them from `all_songs`, so a minigame writing a drill there
never invalidates the fast path.
Directory-form songs (loose-song folders, directory sloppak bundles) also
record their OWN directory: a file added/removed/replaced INSIDE the folder
bumps that folder's mtime but not its parent's, so tracking only the parent
would miss an in-place change to such a song. File-form sloppaks (a single
.feedpak zip) aren't dirs, so they add nothing here — the flat file library
stays at a handful of dir stats."""
rels = {"."}
for f in all_songs:
rel = Path(_relpath(f, dlc))
if f.is_dir():
rels.add(rel.as_posix())
parent = rel.parent
rels.add(parent.as_posix())
for anc in parent.parents:
rels.add(anc.as_posix())
return rels
def _has_unextracted_columns() -> bool:
"""True while any `songs` row still carries NULL in a column added by an
additive migration i.e. metadata the current extractor would fill but
that no existing row has yet (currently `bass_tuning_name`).
The tree-signature fast path only asks "did the file set change"; on a
settled library the answer is no forever, so a schema addition would never
reach extraction. This one-row probe forces the full pass exactly until the
backfill completes `put()` writes '' rather than NULL, so it self-clears
after the rescan instead of disabling the fast path permanently."""
try:
from metadata_db import MetadataDB
cond = " OR ".join(f"{c} IS NULL" for c in MetadataDB._EXTRACTION_MARKER_COLS)
row = appstate.meta_db.conn.execute(
f"SELECT 1 FROM songs WHERE {cond} LIMIT 1").fetchone()
except Exception as e:
# A probe failure must not take the scan down; falling back to the fast
# path costs at most a delayed backfill.
log.debug("scan: unextracted-column probe failed: %s", e)
return False
return row is not None
def _record_dir_signature(all_songs, dlc: Path) -> None:
sig = _stat_dirs(dlc, _library_dirs(all_songs, dlc))
if sig is not None: # a dir vanished mid-scan → skip; next scan is full
_save_dir_signature(dlc, sig)
def _stat_dirs(dlc: Path, rels) -> dict[str, int] | None:
"""{reldir: mtime_ns} for the given library dirs, or None if any is gone or
unreadable a vanished recorded dir means the tree changed, so fail to a
full scan rather than a false match."""
out: dict[str, int] = {}
for rel in rels:
try:
out[rel] = (dlc if rel == "." else dlc / rel).stat().st_mtime_ns
except OSError:
return None
return out
_SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "current": "", "error": None, "is_first_scan": False, "added": 0, "removed": 0}
@@ -213,12 +99,9 @@ def _make_scan_executor():
)
def background_scan(force: bool = False):
def background_scan():
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full
listing/stat pass the manual Refresh sets it (see _dir_signature_file).
Never sets `_scan_status["running"] = False` ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner.
@@ -238,22 +121,6 @@ def background_scan(force: bool = False):
builtin_content.seed_builtin_diagnostic_sloppaks(appstate.server_root, dlc)
builtin_content.seed_builtin_starter_content(appstate.server_root, dlc)
# Fast path: if every library directory recorded by the last scan still has
# the same mtime, nothing was added, removed, or renamed, so the whole
# glob-and-stat pass below can be skipped (see the signature comment above).
# `force` (manual Refresh) always does the full pass. Seeding above is
# idempotent — it only writes when a builtin is missing — so it does not
# perturb the mtimes on a settled library.
if not force and not _has_unextracted_columns():
stored = _load_dir_signature()
if stored is not None and stored.get("dlc") == str(dlc):
current = _stat_dirs(dlc, stored["dirs"].keys())
if current is not None and current == stored["dirs"]:
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete"}
log.info("Scan: library tree unchanged (%d dirs) — skipped the full listing/stat pass",
len(current))
return
# Listing can fail on macOS without Full Disk Access, or on Docker if the
# path isn't shared. Report the failure explicitly rather than silently
# appearing to scan nothing.
@@ -342,15 +209,6 @@ def background_scan(force: bool = False):
cached = None
if not cached:
to_scan.append((f, mtime, size, dlc))
elif any(cached.get(c) is None for c in appstate.meta_db._EXTRACTION_MARKER_COLS):
# Row predates one of the per-perspective tuning columns (NULL
# from the additive migration), so that perspective's tuning was
# never extracted for it. Without this
# re-queue an existing library would keep every bass column empty
# forever — mtime/size still match, so nothing else would ever
# bring the row back through extraction. Converges: put() always
# writes '' (never NULL), so a rescanned row is never re-queued.
to_scan.append((f, mtime, size, dlc))
elif cached.get("arrangements") and any(
"smart_name" not in a for a in cached["arrangements"]
):
@@ -365,9 +223,6 @@ def background_scan(force: bool = False):
to_scan.append((f, mtime, size, dlc))
if not to_scan:
# Full pass completed with the DB already up to date — record the tree
# signature so the next startup can take the fast path.
_record_dir_signature(all_songs, dlc)
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
log.info("Scan: nothing new to scan (%d songs, all cached)", len(all_songs))
return
@@ -392,9 +247,6 @@ def background_scan(force: bool = False):
_scan_status["done"] += 1
_scan_status["current"] = fname
# Record the tree signature after a completed full pass so the next startup
# can skip it when nothing has changed.
_record_dir_signature(all_songs, dlc)
log.info("Scan complete: %d songs cached", len(to_scan))
_scan_status = {**_SCAN_STATUS_INIT, "running": True, "stage": "complete", "added": added, "removed": removed}
@@ -403,9 +255,6 @@ _scan_kick_lock = threading.Lock()
_scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared
@@ -416,15 +265,9 @@ _scan_force_next = False
_scan_thread: threading.Thread | None = None
def kick_scan(force: bool = False) -> bool:
def kick_scan() -> bool:
"""Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the
manual Refresh uses it so an in-place same-name edit the one thing the
fast path can't see — is always picked up). A forced request that coalesces
onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it.
Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload
@@ -432,10 +275,8 @@ def kick_scan(force: bool = False) -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up.
"""
global _scan_rescan_pending, _scan_thread, _scan_force_next
global _scan_rescan_pending, _scan_thread
with _scan_kick_lock:
if force:
_scan_force_next = True
if _scan_status["running"]:
_scan_rescan_pending = True
return False
@@ -449,15 +290,10 @@ def kick_scan(force: bool = False) -> bool:
def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending, _scan_force_next
global _scan_rescan_pending
while True:
# Consume the force flag for THIS pass; a forced request queued mid-scan
# sets it again for the follow-up.
with _scan_kick_lock:
forced = _scan_force_next
_scan_force_next = False
try:
background_scan(force=forced)
background_scan()
except Exception:
log.exception("background scan failed unexpectedly")
+1 -59
View File
@@ -27,11 +27,7 @@ import logging
from pathlib import Path
from song import compute_smart_names
from tunings import (
DEFAULT_PERSPECTIVE, PERSPECTIVES, ROLE_PERSPECTIVES, normalize_offsets,
perspective_low_pitch, perspective_tuning_key, perspective_tuning_name,
tuning_name,
)
from tunings import tuning_name
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
@@ -47,56 +43,6 @@ def _relpath(f: Path, dlc: Path) -> str:
return f.name
def _apply_role_tunings(meta: dict) -> None:
"""Derive each ROLE perspective's tuning columns from the raw offsets the
extractor emitted (currently bass + rhythm; guitar-lead reads the
song-level columns the scanner has always written).
The domain rules live in `tunings` (see the PERSPECTIVES table and the
block above it for the evidence behind each):
1. NORMALIZE FIRST. Stored bass arrays are commonly six elements whose
last two slots are padding, so bass truncates to four strings before
anything looks at them padding must never reach the namer or the
grouping key. Guitar does NOT truncate (a 7-string array is real).
2. Refuse to name data the perspective distrusts (bass up-tuning), so the
library can't send a player off to a tuning nobody plays.
3. Group on CANONICAL PITCHES, not the raw offsets string the same
physical tuning serialized two ways must be ONE facet entry.
A song with no arrangement in that role gets EMPTY strings / 0, not NULL:
'' is the indexed "we looked, there is no such chart" state the library's
fallback keys on, while NULL means "never extracted" and re-scans.
"""
for persp in ROLE_PERSPECTIVES:
raw = meta.pop(f"{persp.role}_tuning_offsets", None)
offsets = normalize_offsets(raw, persp)
if offsets is None:
meta[persp.column("name")] = ""
meta[persp.column("sort_key")] = 0
meta[persp.column("offsets")] = ""
meta[persp.column("key")] = ""
meta[persp.column("low_pitch")] = None
continue
meta[persp.column("name")] = perspective_tuning_name(offsets, persp)
meta[persp.column("sort_key")] = sum(offsets)
# The NORMALIZED offsets are what we store: padding is not data, and a
# client rendering target notes must not print phantom strings.
meta[persp.column("offsets")] = " ".join(str(o) for o in offsets)
meta[persp.column("key")] = perspective_tuning_key(offsets, persp)
meta[persp.column("low_pitch")] = perspective_low_pitch(offsets, persp)
def _apply_song_low_pitch(meta: dict, offsets: list[int]) -> None:
"""Lowest open-string pitch of the SONG-level (guitar-lead) tuning, for
the "playable without retuning" comparison. Indexed here, on the existing
manifest-only pass never by reopening chart JSON."""
persp = PERSPECTIVES[DEFAULT_PERSPECTIVE]
norm = normalize_offsets(offsets, persp)
meta["tuning_low_pitch"] = (
perspective_low_pitch(norm, persp) if norm is not None else None)
def _extract_meta_sloppak(path: Path) -> dict:
"""Extract metadata for a sloppak (file or directory)."""
meta = sloppak_mod.extract_meta(path)
@@ -106,8 +52,6 @@ def _extract_meta_sloppak(path: Path) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
_apply_song_low_pitch(meta, offsets)
_apply_role_tunings(meta)
meta["format"] = "sloppak"
# `extract_meta` already populates `stem_ids` (feedBack#129);
# default to empty for older callers / mocks.
@@ -142,8 +86,6 @@ def _extract_meta_loosefolder(path: Path, dlc_root: Path | None) -> dict:
meta["tuning_name"] = name
meta["tuning_sort_key"] = sum(offsets)
meta["tuning_offsets"] = " ".join(str(o) for o in offsets)
_apply_song_low_pitch(meta, offsets)
_apply_role_tunings(meta)
meta["format"] = "loose"
meta.setdefault("stem_ids", [])
# The library helper exposes absolute filesystem paths for audio/art
+94 -620
View File
@@ -15,7 +15,6 @@ from __future__ import annotations
import logging
import math
import os
import shutil
import threading
import zipfile
@@ -35,21 +34,6 @@ FEEDPAK_EXT = ".feedpak"
SLOPPAK_EXT = ".sloppak"
SONG_EXTS = (FEEDPAK_EXT, SLOPPAK_EXT) # accepted on read/discovery
# ── The full mix ──────────────────────────────────────────────────────────────
#
# Spec §5.3 RESERVES the stem id `full` for the song's complete mixdown: the
# whole song in one file, as heard before source separation. It is a stem — it
# lives in `stems` like every other audio file in a pack — but it is a *mixdown,
# not a layer*. A reader that sums stems must never include it in the sum: it
# already contains every instrument, so summing it doubles the whole song and
# muting `guitar` still leaves guitar audible inside it.
#
# Keeping it matters because separation is lossy: re-summing guitar+bass+drums+
# vocals does NOT reproduce the file they came from. The mixdown is the only
# faithful rendering of the song a pack can carry, so we play it whenever every
# stem sits at unity and nothing is muted.
FULL_MIX_STEM_ID = "full"
import yaml
from jsonc import load_json
@@ -67,111 +51,6 @@ import drums as drums_mod
import notation as notation_mod
def find_full_mix(stems: list[dict]) -> dict | None:
"""The RESERVED `full` stem (spec §5.3) — the pack's complete mixdown — or None.
Answers "what is this pack's master audio", which is what fingerprinting
wants. For playback use partition_stems() instead: a pack whose *only* stem
is `full` has no mixdown to play *separately from* its stems, and this
function still returns it.
"""
return next(
(s for s in stems if str(s.get("id", "")) == FULL_MIX_STEM_ID), None
)
def stem_default_on(raw) -> bool:
"""Whether a manifest stem entry plays by default.
Absent means on. A string is honoured so a hand-written manifest can say
`default: off`. Extracted so the WS `ready` payload and the REST song-info
payload cannot drift: the stems plugin now preloads from REST and then has
to agree with what the WS says a moment later, or it would rebuild the whole
graph for nothing.
"""
if isinstance(raw, str):
return raw.lower() not in ("off", "false", "0", "no")
return bool(raw)
def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
"""Split stem descriptors into (mixdown, instrument_stems) for PLAYBACK.
The mixdown is lifted OUT of the stem list because every consumer of `stems`
treats that list as layers to sum or to show as mixer channels, and `full` is
neither (spec §5.3). Leaving it in is precisely the bug that made the packer
invent `original_audio` in the first place: a listed full mix plays on top of
the stems.
A pack whose only stem is `full` is a single-mix pack, not a separated one:
there are no instruments to be pristine *against*, so `full` stays the sole
playable stem and no mixdown is surfaced. That keeps the freshly-converted
single-stem pack much the most common shape behaving exactly as before.
EVERY entry with the reserved id is removed, not just the one we surface. A
malformed pack that lists `full` twice would otherwise leave a copy of the
whole song behind in the stem list, to be summed with the instruments the
precise failure this function exists to prevent, reintroduced by a duplicate.
"""
if len(stems) < 2:
return None, stems
full = find_full_mix(stems)
if full is None:
return None, stems
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
Before feedpak 1.15.0 reserved `full`, §5.3 said the mixdown was "commonly
replaced" by the per-instrument stems on splitting — so it had nowhere to
live, and this repo invented a top-level key pointing at a parallel
`original/` directory (#583) to hold it. That key was never in the spec, and
#933 removed our dependence on it: the mixdown is a stem.
We still READ it, because every pack written before the spec caught up
carries `original_audio: original/full.ogg` and would otherwise lose its full
mix. We never write it. Delete this once those packs are migrated (#945);
`tools/migrate_full_mix_stem.py` is the migration.
NOTE the string literal below. tools/check_spec_conformance.py AST-scans for
`manifest.get("<literal>")` to prove every manifest key core reads is one the
spec declares. Hoisting "original_audio" into a named constant would hide
this read from that scan the gate would conclude core no longer touches the
key, and the grandfather entry that documents this debt would go stale. The
literal is what keeps the deprecation honest and visible to CI. Leave it.
Same permissive, path-traversal-guarded posture as the optional side-files: a
missing / escaping / unreadable file leaves the pack without a full mix (the
player falls back to the separated stems) rather than aborting the load.
Returns the manifest-relative string, so callers build its URL exactly as
they build a stem's.
"""
rel_raw = manifest.get("original_audio")
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None
rel = rel_raw.strip()
try:
target = (source_dir / rel).resolve()
target.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
return None
if not target.is_file():
return None
log.info(
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
"is a stem (id `full`, feedpak spec §5.3). Re-pack with "
"tools/migrate_full_mix_stem.py; support for this key will be removed.",
rel,
)
return rel
# ── Format detection ──────────────────────────────────────────────────────────
def is_sloppak(path: Path) -> bool:
@@ -202,116 +81,6 @@ _unpack_semaphore = threading.BoundedSemaphore(_UNPACK_MAX_CONCURRENCY)
_unpack_locks: dict[str, threading.Lock] = {}
_unpack_locks_guard = threading.Lock()
# Destinations with an unpack in flight right now. Eviction MUST skip these: two
# unpacks run concurrently, so one finishing could otherwise rmtree the other's
# half-written directory and leave that resolver caching an incomplete song.
_unpacking: set[Path] = set()
_unpacking_guard = threading.Lock()
# Cap the unpack cache. Stems are already-compressed audio, so an unpacked song
# is ~1.1x its zip — the cache is effectively a second, DECOMPRESSED copy of
# every song it holds, and it used to grow without any bound at all. A tester
# reached 60 GB from a 1800-song library: their whole library, unpacked, because
# one caller looped the library calling load_song(). Nothing ever deleted any of
# it — not even when the song itself was deleted.
#
# Default 4 GB ≈ 130 average songs of recency, which is far more than the "the
# song I'm playing, and the last few I played" that this cache actually exists
# to serve. Override with FEEDBACK_SLOPPAK_CACHE_MAX_MB (0 disables eviction).
def _unpack_cache_cap_bytes() -> int:
raw = os.environ.get("FEEDBACK_SLOPPAK_CACHE_MAX_MB", "").strip()
try:
mb = int(raw) if raw else 4096
except ValueError:
mb = 4096
return max(0, mb) * 1024 * 1024
def _dir_size(path: Path) -> int:
total = 0
for f in path.rglob("*"):
try:
if f.is_file():
total += f.stat().st_size
except OSError:
continue
return total
def _touch(path: Path) -> None:
"""Bump mtime so the LRU sweep below treats this song as recently used.
Reading files out of an unpacked dir doesn't change the DIRECTORY's mtime,
so without this the song you are actively playing looks as stale as one you
unpacked days ago and a burst of unpacks could evict it mid-song.
"""
try:
os.utime(path, None)
except OSError:
pass
def _evict_unpack_cache(root: Path, keep: Path | None = None) -> None:
"""Bound the unpack cache: drop least-recently-used songs until under the cap.
`keep` is never evicted it's the song the caller just resolved, i.e. almost
certainly the one about to be played.
Evicting a directory MUST also drop its `_source_cache` entry. Otherwise
get_cached_source_dir() keeps handing out a path that no longer exists and
the media route 404s on every stem instead of re-unpacking (it only falls
back to resolve_source_dir when the cache returns None).
"""
cap = _unpack_cache_cap_bytes()
if cap <= 0:
return
try:
entries = []
total = 0
for d in root.iterdir():
if not d.is_dir():
continue
try:
size = _dir_size(d)
mtime = d.stat().st_mtime
except OSError:
continue
entries.append((mtime, size, d))
total += size
if total <= cap:
return
keep_resolved = keep.resolve() if keep else None
entries.sort(key=lambda e: e[0]) # oldest first
for _mtime, size, d in entries:
if total <= cap:
break
try:
if keep_resolved and d.resolve() == keep_resolved:
continue
except OSError:
continue
# Check-and-delete under ONE hold of the guard. Releasing between the
# two would let a resolver mark this dest in-flight and start writing
# into it in the gap, and we'd rmtree a song mid-unpack. A resolver
# that blocks here simply proceeds afterwards — _unpack_zip recreates
# the directory anyway.
with _unpacking_guard:
if d in _unpacking:
continue # another thread is writing this
shutil.rmtree(d, ignore_errors=True)
if d.exists():
continue # couldn't remove — don't claim the bytes back
total -= size
with _source_lock:
for fn, (cached_dir, _m, _s) in list(_source_cache.items()):
if cached_dir == d:
_source_cache.pop(fn, None)
log.info("sloppak: evicted %s from the unpack cache (%.0f MB)",
d.name, size / 1e6)
except OSError:
log.warning("sloppak: unpack-cache eviction failed", exc_info=True)
def _unpack_lock_for(filename: str) -> threading.Lock:
"""Return a stable per-file lock so concurrent unpacks of the same sloppak
@@ -376,17 +145,10 @@ def resolve_source_dir(
re-unpacks if mtime/size changed, then returns that dir.
Caches the resolution so subsequent calls are ~free.
NOTE: this writes the WHOLE pack every stem to disk. Only call it for a
song you are about to play. To read a *part* of a song (an arrangement, the
lyrics, a tone blob), use read_member_bytes(): unpacking a pack to read a few
KB of JSON is ~45x write amplification, and doing it in a loop over the
library fills the disk with a decompressed copy of every song.
"""
path = dlc_root / filename
stat = path.stat()
mtime, size = stat.st_mtime, stat.st_size
guarded: Path | None = None # a dir WE unpacked, shielded from eviction
with _source_lock:
cached = _source_cache.get(filename)
@@ -397,76 +159,42 @@ def resolve_source_dir(
and cached_size == size
and cached_dir.exists()
):
# Mark it recently-used before returning — see _touch().
if cached_dir != path:
_touch(cached_dir)
return cached_dir
try:
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
# Shield `dest` from eviction from the moment we start writing
# until it is safely in _source_cache. `keep` only shields it
# from OUR OWN sweep — a concurrent resolver sweeping with a
# different `keep` would delete it, and we would then cache and
# return a path that no longer exists. The `finally` below
# releases it on EVERY exit, including a failed unpack: leaving
# a dest marked in-flight would make it un-evictable forever.
with _unpacking_guard:
_unpacking.add(dest)
guarded = dest
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
# The only moment this cache grows. Sweep here rather than on a
# timer so it can never drift far past the cap.
_evict_unpack_cache(unpack_cache_root, keep=dest)
if path.is_dir():
resolved = path
else:
# Zip form — unpack to the cache. Serialize per-file (so concurrent
# callers don't rmtree + re-extract the same dest at once) and cap
# global unpack concurrency (so a burst can't saturate disk/CPU).
dest = unpack_cache_root / _safe_id(filename)
with _unpack_lock_for(filename):
# Re-check the cache inside the per-file lock — a prior holder may
# have just finished unpacking this exact (mtime, size).
with _source_lock:
cached = _source_cache.get(filename)
if (
cached
and cached[1] == mtime
and cached[2] == size
and cached[0].exists()
):
resolved = cached[0]
else:
with _unpack_semaphore:
_unpack_zip(path, dest)
resolved = dest
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
finally:
if guarded is not None:
with _unpacking_guard:
_unpacking.discard(guarded)
with _source_lock:
_source_cache[filename] = (resolved, mtime, size)
return resolved
def get_cached_source_dir(filename: str) -> Path | None:
"""Return the cached source dir for a sloppak if one is known AND still there.
The existence check is load-bearing: callers (media.py) only fall back to
resolve_source_dir() when this returns None, so handing back a path that has
been evicted or that the user deleted by hand to reclaim disk would 404
every stem for the rest of the process instead of re-unpacking.
"""
"""Return the cached source dir for a sloppak if one is known."""
with _source_lock:
cached = _source_cache.get(filename)
if not cached:
return None
src = cached[0]
if not src.is_dir():
_source_cache.pop(filename, None)
return None
_touch(src)
return src
return cached[0] if cached else None
# ── Manifest + song loading ───────────────────────────────────────────────────
@@ -505,82 +233,6 @@ def load_manifest(path: Path) -> dict:
return _read_manifest_from_zip(path)
_ZIP_ROOT = Path("/_root").resolve()
def _zip_member_key(name: str) -> str | None:
"""Canonical lookup key for a zip member name, or None if it escapes the root.
Collapses './', 'a/../b' and backslash separators the same normalization
_unpack_zip()/safe_join() apply when extracting. Both the name the caller asks
for AND the names the archive actually stores must go through this, or a pack
that stores './arrangements/lead.json' unpacks fine but reads back as missing.
"""
safe = safe_join(_ZIP_ROOT, name or "")
# None → escapes the root; == root → a degenerate name like "." or "a/..".
if safe is None or safe == _ZIP_ROOT:
return None
return safe.relative_to(_ZIP_ROOT).as_posix()
def read_member_bytes(path: Path, rel: str) -> bytes | None:
"""Return the bytes of ONE file inside a sloppak, or None if it isn't there.
For a zipped sloppak this opens that single member instead of unpacking the
archive the same trick read_cover_bytes() uses to keep the library grid
from exploding every pack just to show a cover.
Reach for this whenever you want a *part* of a song (an arrangement's JSON,
the lyrics, a tone blob) rather than a song you're about to play. The
alternative, load_song(), calls resolve_source_dir() and writes the WHOLE
pack every stem into the unpack cache. That is a ~45x write amplification
when all you wanted was a few KB of JSON, and looping the library on it
unpacks the entire library (got-feedBack/feedBack: a tester hit 60 GB that
way). Stems are already-compressed audio, so an unpacked song is ~1.1x its
zip: the cache becomes a second, decompressed copy of everything it touches.
"""
rel = (rel or "").strip()
if not rel:
return None
if path.is_dir():
target = safe_join(path.resolve(), rel)
if target is None or not target.is_file():
return None
try:
return target.read_bytes()
except OSError:
return None
# Zip form — read just that member, no unpack. Zip-slip is rejected before we
# open anything, and both sides of the comparison are normalized, so a
# non-canonical-but-valid name ('./arrangements/lead.json') resolves the same
# way it did when we unpacked first.
member = _zip_member_key(rel)
if member is None:
log.warning("sloppak: rejected unsafe member name %r in %r", rel, path)
return None
try:
with zipfile.ZipFile(str(path), "r") as zf:
# Match on the NORMALIZED stored name, and take the LAST match — the
# archive may store './x' or a backslash path (Windows tooling), and
# if it stores two names that normalize to the same file, _unpack_zip
# writes them in order so the last one wins. Reading the raw member by
# exact name would miss the first case and return the wrong bytes in
# the second. A pack has a handful of members; the scan is free.
info = None
for cand in zf.infolist():
if _zip_member_key(cand.filename) == member:
info = cand
if info is None or info.is_dir():
return None
with zf.open(info) as f:
return f.read()
except (zipfile.BadZipFile, OSError, RuntimeError) as e:
log.warning("sloppak: failed to read %r from %s: %s", rel, path.name, e)
return None
_COVER_MEDIA_TYPES = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
@@ -715,140 +367,14 @@ class LoadedSloppak:
# song.arrangements (not to manifest["arrangements"]) — skipped entries are
# absent so indexing by song.arrangements index is safe.
arrangement_ids: list[str | None] = field(default_factory=list)
# Manifest-relative path to the pack's complete mixdown — the whole song in
# one file, as heard before source separation. This is the RESERVED `full`
# stem (spec §5.3), lifted out of `stems` above precisely because it is NOT
# an instrument layer: summing it with the per-instrument stems it was split
# into would double the entire song. See partition_stems().
#
# None when the pack has no mixdown to offer *separately* from its stems —
# which includes the common single-mix pack, whose only stem IS the mixdown
# (there is nothing to be pristine against, so it stays in `stems`).
#
# Served to the front-end via the highway WS as `full_mix_url`; the stems
# plugin plays it while every stem slider sits at unity and crosses to the
# separated stems the moment one drops below 100% — demucs recombination is
# lossy, so the mixdown is strictly the better audio when nothing is muted.
full_mix: str | None = None
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): one dict
# {"id", "name", "drum_tab"} per part, primary FIRST. A part comes from a
# `type: drums` arrangement entry carrying a per-arrangement `drum_tab`
# file pointer and NO note `file` — entries this loader deliberately never
# turns into fretted Arrangements (see the file/notation gate in
# load_song; that skip IS the grading invariant). The primary part's
# payload is the SAME object as `drum_tab` above (the song-level key is
# its back-compat alias). None when the pack has no drums at all; a
# single-part list for a legacy pack with only the song-level key.
drum_parts: list[dict] | None = None
def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
"""Load + schema-validate one drum-tab JSON named by a manifest-relative
path. Shared by the song-level `drum_tab:` key and the per-arrangement
drum-part pointers (feedpak 1.17.0), so every tab gets the same posture:
permissive a missing file disables that part silently; a traversal,
parse, or validation failure disables it with a warning, never aborting
the load."""
# Constrain to source_dir to prevent a crafted manifest from reading
# files outside the sloppak directory via path traversal (e.g. ../../etc).
# Wrap both resolve() calls in a broad handler: symlink loops and
# permission errors on .resolve() should disable drums, not abort load.
try:
dt_path = (source_dir / rel).resolve()
dt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
return None
except OSError as e:
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
return None
if not dt_path.exists():
return None
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse %s %r: %s", label, rel, e)
return None
ok, reason = drums_mod.validate_drum_tab(raw)
if not ok:
log.warning("sloppak: %s %r failed validation: %s", label, rel, reason)
return None
return raw
def _resolve_drum_parts(
source_dir: Path,
drum_tab_rel: object,
drum_tab_data: dict | None,
drum_pointer_entries: list[dict],
) -> tuple[dict | None, list[dict] | None]:
"""Resolve drum pointers into a primary-first list with unique ids."""
if drum_tab_data is None and not drum_pointer_entries:
return drum_tab_data, None
primary_id = "drums"
primary_name = None
extra_parts: list[dict] = []
seen_rels: set[str] = set()
# Use the same canonical, traversal-safe identity as zip member lookup so
# equivalent spellings ("x.json", "./x.json", or backslashes) identify
# one file. Otherwise an alias pointer can reload and duplicate the primary.
primary_rel_key = (
_zip_member_key(drum_tab_rel.strip())
if isinstance(drum_tab_rel, str) and drum_tab_rel.strip() else None
)
for entry in drum_pointer_entries:
rel = str(entry.get("drum_tab") or "").strip()
rel_key = _zip_member_key(rel) if rel else None
rel_identity = rel_key or rel
if not rel or rel_identity in seen_rels:
continue
seen_rels.add(rel_identity)
entry_id = str(entry.get("id") or "").strip()
entry_name = str(entry.get("name") or "").strip()
if primary_rel_key is not None and rel_key == primary_rel_key:
if entry_id:
primary_id = entry_id
if entry_name:
primary_name = entry_name
continue
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
if tab is None:
continue
tab_name = tab.get("name")
extra_parts.append({
"id": entry_id,
"name": entry_name
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
"drum_tab": tab,
})
parts: list[dict] = []
used_ids: set[str] = set()
if drum_tab_data is not None:
if primary_name is None:
tab_name = drum_tab_data.get("name")
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
parts.append({"id": primary_id, "name": primary_name, "drum_tab": drum_tab_data})
used_ids.add(primary_id)
next_generated_id = 2
for part in extra_parts:
part_id = part["id"]
if not part_id or part_id in used_ids:
while f"drums-{next_generated_id}" in used_ids:
next_generated_id += 1
part_id = f"drums-{next_generated_id}"
next_generated_id += 1
part["id"] = part_id
used_ids.add(part_id)
parts.append(part)
if not parts:
return drum_tab_data, None
if drum_tab_data is None:
drum_tab_data = parts[0]["drum_tab"]
return drum_tab_data, parts
# Manifest-relative path to the single full-mix audio file, taken from the
# manifest `original_audio:` key (e.g. "original/full.ogg"). This is the
# pre-separation mixdown that exists alongside the per-instrument `stems`.
# None when the key is absent, points outside source_dir, or the file is
# missing on disk. Served to the front-end via the highway WS as
# `original_audio_url`; the stems plugin uses it to play the untouched mix
# when every stem slider is at unity (and the separate stems otherwise).
original_audio: str | None = None
def load_song(
@@ -873,7 +399,6 @@ def load_song(
notation_acc: dict[str, dict] = {}
any_notation = False
arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements
drum_pointer_entries: list[dict] = [] # feedpak 1.17.0 drum-part pointers
for entry in manifest.get("arrangements", []) or []:
if not isinstance(entry, dict):
log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__)
@@ -882,30 +407,7 @@ def load_song(
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
notation_raw = entry.get("notation")
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
_etype = str(entry.get("type") or "").strip().lower()
is_drums = _etype in ("drums", "drum")
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
# absence — a malformed drums entry that also carries a note file/
# notation would otherwise fall through and grade as garbage.
if is_drums or (not rel and not has_notation_key):
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
# file. Collect it for the drum-parts load after this loop.
if is_drums and isinstance(entry.get("drum_tab"), str):
drum_pointer_entries.append(entry)
elif is_drums:
# Drums-typed but no drum_tab pointer — drop it (any note
# file/notation it carries is ignored), never fret it.
log.warning(
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
entry.get("id"),
)
elif isinstance(entry.get("drum_tab"), str):
log.warning(
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
entry.get("drum_tab"), entry.get("type"),
)
if not rel and not has_notation_key:
continue
data = None
if rel:
@@ -935,11 +437,6 @@ def load_song(
# the arrangement JSON (name, tuning, capo, centOffset).
if entry.get("name"):
arr.name = str(entry["name"])
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335).
# Drives arrangement_string_count's bass fallback so a bass authored on
# an arrangement whose NAME doesn't say "bass" still reports 4 strings.
if entry.get("type"):
arr.type = str(entry["type"]).strip().lower()
if "tuning" in entry:
arr.tuning = list(entry["tuning"])
if "capo" in entry:
@@ -1016,13 +513,32 @@ def load_song(
drum_tab_data: dict | None = None
drum_tab_rel = manifest.get("drum_tab")
if isinstance(drum_tab_rel, str) and drum_tab_rel:
drum_tab_data = _load_drum_tab_file(source_dir, drum_tab_rel, "drum_tab")
# Keep the dense compatibility logic independently testable and guarantee
# ids are unique before the highway exposes them as selectors.
drum_tab_data, drum_parts = _resolve_drum_parts(
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
)
# Constrain to source_dir to prevent a crafted manifest from reading
# files outside the sloppak directory via path traversal (e.g. ../../etc).
# Wrap both resolve() calls in a broad handler: symlink loops and
# permission errors on .resolve() should disable drums, not abort load.
try:
dt_path = (source_dir / drum_tab_rel).resolve()
dt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel)
dt_path = None
except OSError as e:
log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e)
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
if raw is not None:
ok, reason = drums_mod.validate_drum_tab(raw)
if ok:
drum_tab_data = raw
else:
log.warning("sloppak: drum_tab %r failed validation: %s",
drum_tab_rel, reason)
# Drum-only sloppak: every GP track was percussion, so it ships a
# drum_tab but no pitched arrangements. The highway WS rejects an empty
@@ -1243,26 +759,12 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
entry = {
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
}
# Optional presentational fields (feedpak 1.16.0, spec §5.3). Omitted —
# not None — when absent, so payload builders can pass entries through
# without every stem growing null keys.
for key in ("name", "description"):
val = s.get(key)
if isinstance(val, str) and val.strip():
entry[key] = val
stems.append(entry)
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
# chips, the WS payload — sums it with, or lists it beside, the instruments
# it was separated into. `full_mix_stem` is None for a single-mix pack,
# whose only stem IS the mixdown and stays in the list.
full_mix_stem, stems = partition_stems(stems)
default_val = s.get("default", True)
if isinstance(default_val, str):
default_on = default_val.lower() not in ("off", "false", "0", "no")
else:
default_on = bool(default_val)
stems.append({"id": sid, "file": sfile, "default": default_on})
# Optional keys.json — song-level, instrument-independent key/scale track
# (manifest `keys:` key, spec §7.7). Permissive like the other side-files:
@@ -1326,22 +828,28 @@ def load_song(
}
_fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
# stems and its URL is built the same way. Only when the pack has no `full`
# stem do we fall back to the DEPRECATED `original_audio:` key, which is the
# shape every pack written before feedpak 1.15.0 uses.
if full_mix_stem is not None:
full_mix_data: str | None = full_mix_stem["file"]
elif find_full_mix(stems) is not None:
# Single-mix pack: its ONE stem is the mixdown, so there is no mixdown to
# offer *apart from* the stems. Never fall through to the legacy key here
# — a pack that both carries a `full` stem and names the old key would
# otherwise surface the mixdown twice (once as the stem the player is
# already playing, once as a "pristine" track to cross to).
full_mix_data = None
else:
full_mix_data = _legacy_full_mix(manifest, source_dir)
# Optional full-mix audio — manifest `original_audio:` key. The single
# pre-separation mixdown that ships alongside the per-instrument stems.
# Same permissive, path-traversal-guarded posture as drum_tab above: a
# missing/escaping/absent file simply leaves the full mix unavailable (the
# player falls back to the separate stems) rather than aborting the load.
# We store the manifest-relative string so server.py can build its URL the
# same way it builds stem URLs (via the /api/sloppak/.../file/ endpoint).
original_audio_data: str | None = None
original_audio_rel = manifest.get("original_audio")
if isinstance(original_audio_rel, str) and original_audio_rel.strip():
rel = original_audio_rel.strip()
try:
oa_path = (source_dir / rel).resolve()
oa_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
oa_path = None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
oa_path = None
if oa_path is not None and oa_path.is_file():
original_audio_data = rel
return LoadedSloppak(
song=song,
@@ -1350,14 +858,13 @@ def load_song(
manifest=manifest,
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
drum_tab=drum_tab_data,
drum_parts=drum_parts,
song_timeline=song_timeline_data,
tempos=tempos_data,
time_signatures=time_sigs_data,
keys=keys_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data,
original_audio=original_audio_data,
)
@@ -1378,27 +885,6 @@ def _tuning_for_meta(arrangements_manifest: list[dict]) -> list[int]:
return [0] * 6
def _role_tuning_for_meta(arrangements_manifest: list[dict], role: str) -> list[int] | None:
"""Per-ROLE companion to _tuning_for_meta: the tuning of the arrangement
playing `role` ("bass" / "rhythm"), or None when the pack has no such
arrangement with a tuning the index then leaves that perspective's
columns empty and the library falls back to the song (guitar-first)
tuning, marking the row inferred.
Exact name first, then a looser containment pass so an alt/bonus chart
("Bass 2", "Alt Rhythm") still beats pretending the part is in the lead
guitar's tuning."""
for match_exact in (True, False):
for entry in arrangements_manifest:
name = str(entry.get("name", "")).lower()
tun = entry.get("tuning")
if not (tun and isinstance(tun, list)):
continue
if name == role if match_exact else role in name:
return list(tun)
return None
def extract_meta(path: Path) -> dict:
"""Fast metadata for the library scanner. Reads only the manifest."""
manifest = load_manifest(path)
@@ -1421,13 +907,9 @@ def extract_meta(path: Path) -> dict:
has_lyrics = bool(manifest.get("lyrics"))
tuning_offsets = _tuning_for_meta(arr_list)
# Per-role tunings alongside the song-level one, so the library can answer
# for whichever arrangement the player actually plays.
role_tunings = {f"{role}_tuning_offsets": _role_tuning_for_meta(arr_list, role)
for role in ("bass", "rhythm")}
stems_list = manifest.get("stems", []) or []
valid_stems: list[dict] = []
stem_ids: list[str] = []
for s in stems_list:
if not isinstance(s, dict):
continue
@@ -1441,13 +923,7 @@ def extract_meta(path: Path) -> dict:
isinstance(sid, str) and sid
and isinstance(sfile, str) and sfile
):
valid_stems.append({"id": sid, "file": sfile})
# Partition exactly as load_song() does, for the same reason the library
# filter must not lie: `full` is the mixdown, not an instrument (spec §5.3).
# A separated pack that retains it would otherwise offer the user a "full"
# stem chip alongside guitar/bass/drums and count it as a seventh stem.
_full, instrument_stems = partition_stems(valid_stems)
stem_ids = [s["id"] for s in instrument_stems]
stem_ids.append(sid)
stem_count = len(stem_ids)
return {
@@ -1463,8 +939,6 @@ def extract_meta(path: Path) -> dict:
"disc": (lambda v: int(v) if str(v if v is not None else "").strip().isdigit() else None)(manifest.get("disc")),
"duration": float(manifest.get("duration", 0) or 0),
"tuning_offsets": tuning_offsets, # caller maps to a name via tunings.tuning_name
# None = the pack has no arrangement in that role.
**role_tunings,
"arrangements": arrangements,
"has_lyrics": has_lyrics,
"stem_count": stem_count,
+7 -58
View File
@@ -56,13 +56,6 @@ class Note:
strum_group: int = -1
scale_degree: int = -1
ignore: bool = False
# Keys hand assignment ('lh'/'rh', None = unassigned) — authored per-note,
# e.g. from a MusicXML grand staff import in the editor. Lets the notation
# hand split and hands-separate practice honor the author instead of the
# mean-pitch heuristic. Distinct from `right_hand` (the bass plucking
# finger); spelled-out `hand` on the wire because `rh` is taken.
# Default-omitted on the wire; older readers ignore it.
hand: str | None = None
@dataclass
@@ -182,12 +175,6 @@ class Arrangement:
# `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel.
tones: dict | None = None
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335):
# "bass" | "guitar" | "piano" | "keys" | "drums" | "". First-class DATA that
# lets a user author an instrument on an arrangement whose NAME doesn't say
# so. Lifted from the sloppak manifest entry by sloppak.load_song(); "" for
# archive/loose sources, which instead carry the path_* flags below.
type: str = ""
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False
@@ -285,10 +272,6 @@ def note_to_wire(n: Note) -> dict:
out["ch"] = n.strum_group
if n.scale_degree != -1:
out["sd"] = n.scale_degree
# Keys hand assignment — default-omitted; validated on emit so a
# directly-constructed Note can't put junk ('LH', True, …) on the wire.
if n.hand in ("lh", "rh"):
out["hand"] = n.hand
return out
@@ -509,8 +492,8 @@ def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
O(notes) via ``arrangement_string_count`` for a whole arrangement, hoist
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
per note instead."""
base = base_open_string_midis(arrangement_string_count(arr),
arrangement_is_bass(arr))
is_bass = "bass" in (arr.name or "").lower()
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
arr.tuning or [], note.string, note.fret)
@@ -549,10 +532,6 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
strum_group=_wire_int_optional(d.get("ch"), -1),
scale_degree=_wire_int_optional(d.get("sd"), -1),
ignore=bool(d.get("ig", False)),
# Keys hand assignment — strict enum decode: anything but 'lh'/'rh'
# (junk, wrong case, bools) falls back to unassigned rather than
# poisoning downstream hand-split/practice logic.
hand=d.get("hand") if d.get("hand") in ("lh", "rh") else None,
)
@@ -639,23 +618,6 @@ def phrase_from_wire(d: dict) -> Phrase:
)
def arrangement_is_bass(arr: Arrangement) -> bool:
"""Whether ``arr`` is a bass, most-authoritative signal first: an
editor-authored ``type == "bass"`` (feedpak-spec §5.2 / editor PR #335,
lifted onto sloppaks by :func:`sloppak.load_song`), then the archive
``path_bass`` <arrangementProperties> flag, then the legacy "bass"
case-insensitive substring in the name. Single source of the bass decision
so string-count derivation and the open-string pitch base (via
:func:`base_open_string_midis`) agree a bass authored on an arrangement
whose NAME doesn't say "bass" must get both 4 lanes AND the bass MIDI base,
not 4 lanes on a guitar octave."""
return (
(arr.type or "").strip().lower() == "bass"
or bool(arr.path_bass)
or "bass" in (arr.name or "").lower()
)
def arrangement_string_count(arr: Arrangement) -> int:
"""Derive the active arrangement's string count.
@@ -673,17 +635,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
But this is a LOWER BOUND only a 6-string lead chart that
never plays string 5 reports 5, undercounting by 1.
2. **Instrument-type fallback.** An arrangement whose authoritative
instrument signal says bass defaults to 4; everything else
defaults to 6. This catches the partial-string-usage case where
notes don't span all the instrument's strings. The bass signal is
any of: an editor-authored ``type == "bass"`` (feedpak-spec §5.2 /
editor PR #335 — lifted onto sloppaks by ``sloppak.load_song``),
the ``path_bass`` <arrangementProperties> flag (archive/DLC
sources), or the legacy "bass" case-insensitive substring in the
name. Trusting ``type``/``path_bass`` closes the gap where a user
authors a bass instrument on an arrangement whose NAME doesn't say
"bass" (the editor lays out 4 lanes; core must agree).
2. **Name-based fallback.** Arrangements named "Bass" (case-
insensitive substring match) default to 4; everything else
defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings.
A third signal ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 folds in for sloppak / GP-imported sources
@@ -714,10 +669,6 @@ def arrangement_string_count(arr: Arrangement) -> int:
max(0, 4, 0) = 4
* Empty arrangement named "Lead" (tuning len 6)
max(0, 6, 0) = 6
* Editor-authored bass named "Low End" (type "bass", tuning len 6,
notes 0..3) name_based=4 max(4, 4, 0) = 4
* DLC bass named "Low End" (pathBass flag set, tuning len 6, notes
0..3) name_based=4 max(4, 4, 0) = 4
Topkoa's issue argues plugins shouldn't do arrangement-name
matching; server-side fallback IS the right place for it
@@ -733,9 +684,7 @@ def arrangement_string_count(arr: Arrangement) -> int:
if cn.string > max_s:
max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0
# Bass signal (type / pathBass / name substring) — see arrangement_is_bass.
# Any one being bass pulls the fallback to 4.
name_based = 4 if arrangement_is_bass(arr) else 6
name_based = 4 if "bass" in arr.name.lower() else 6
# Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP.
+8 -241
View File
@@ -101,16 +101,14 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
def freqs_to_midis(freqs: list[float], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[int] | None:
"""Return absolute open-string MIDI notes for frequencies at the supplied
A4 reference the inverse of open_midis_to_freqs. None if any entry is
non-numeric, non-finite, or non-positive (a provider could hand us
anything; NaN/Infinity would otherwise raise inside int(round(...)) and
500 the /api/tunings endpoint)."""
non-numeric or non-positive (a provider could hand us anything)."""
out: list[int] = []
for f in freqs:
try:
f = float(f)
except (TypeError, ValueError):
return None
if not math.isfinite(f) or f <= 0:
if f <= 0:
return None
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
return out
@@ -416,258 +414,27 @@ def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
})
return out
# ── Bass tuning normalization (library indexing) ─────────────────────────────
#
# Bass charts in the wild store SIX-element tuning arrays even when the chart is
# a 4-string part: slots 4-5 are PADDING. Confirmed by inspecting the charts
# themselves — across every pack whose bass and guitar tunings diverge, no bass
# note ever references string index 4 or 5 (the deepest reach is index 3).
#
# The feedpak spec carries NO string-count field (manifest `arrangement.tuning`
# is an untyped integer array, `minItems: 1`), and counting strings for real
# would mean parsing the 600KB-1.2MB arrangement JSON of every song on the
# manifest-only fast scan path — unacceptable for scan time. So we DEFAULT BASS
# TO 4 STRINGS and truncate.
#
# KNOWN GAP (deliberate, documented): a genuine 5- or 6-string bass is
# truncated to its low four. That is harmless for the overwhelmingly common
# case — a 5-string in standard truncates to [0,0,0,0] and still names
# "Standard" — and only misreads a tuning that DIFFERS at string 4 or above.
# Revisit if the spec ever gains a string count.
BASS_DEFAULT_STRING_COUNT = 4
# Bassists tune DOWN, essentially never up: a whole-instrument up-tune fights
# string tension. Anything above +1 semitone across the board is data we do not
# trust, not a tuning a human plays (the real-world example that motivated this
# is a bass array of [5,5,5,5,4,4] — "all four strings up a perfect fourth" —
# on a song whose guitar chart is dead standard and whose own note content is
# consistent with standard tuning; the offsets were almost certainly computed
# against a 6-string-bass reference with an uninitialised tail).
#
# Such a tuning MUST NOT be named: printing "A Standard" would send a player
# off to retune to something nobody plays. It degrades to the custom path,
# where it stays visible and distinct but makes no pitch claim.
BASS_MAX_PLAUSIBLE_OFFSET = 1
# ── Tuning PERSPECTIVES ──────────────────────────────────────────────────────
#
# The library's tuning facet/filter/sort always answers for ONE arrangement
# role. There are three, matching `active_instrument_profile`:
#
# guitar-lead the song-level (guitar-first) tuning — the historical
# default. Its columns are the original unprefixed
# `tuning_*` family, so today's behaviour is byte-identical.
# guitar-rhythm the RHYTHM chart's own tuning. Lead and rhythm charts can
# disagree (the same bug a bassist hit, inside guitar).
# bass the BASS chart's own tuning.
#
# One table drives extraction, the derived columns, the SQL, and the labels —
# rather than three near-identical column families maintained in parallel.
class TuningPerspective:
__slots__ = ("id", "role", "instrument", "string_count", "column_prefix",
"truncate", "guard_up_tuning", "label")
def __init__(self, id, role, instrument, string_count, column_prefix,
truncate, guard_up_tuning, label):
self.id = id
self.role = role # arrangement name to look for ('' = song-level)
self.instrument = instrument
self.string_count = string_count
self.column_prefix = column_prefix # '' | 'rhythm_' | 'bass_'
self.truncate = truncate
self.guard_up_tuning = guard_up_tuning
self.label = label
@property
def instrument_key(self) -> str:
return instrument_key(self.instrument, self.string_count)
def column(self, suffix: str) -> str:
return f"{self.column_prefix}tuning_{suffix}"
PERSPECTIVES: dict[str, TuningPerspective] = {
"guitar-lead": TuningPerspective(
"guitar-lead", "", "guitar", 6, "", False, False, "lead"),
"guitar-rhythm": TuningPerspective(
"guitar-rhythm", "rhythm", "guitar", 6, "rhythm_", False, False, "rhythm"),
# Bass alone truncates (padded arrays) and guards against up-tuned data —
# both are bass-specific findings, see the block above.
"bass": TuningPerspective(
"bass", "bass", "bass", BASS_DEFAULT_STRING_COUNT, "bass_", True, True, "bass"),
}
DEFAULT_PERSPECTIVE = "guitar-lead"
# Perspectives that carry their OWN indexed columns (guitar-lead reads the
# song-level ones, which the scanner has always written).
ROLE_PERSPECTIVES = tuple(p for p in PERSPECTIVES.values() if p.column_prefix)
def perspective(perspective_id) -> TuningPerspective:
"""Resolve a perspective id, tolerating the legacy two-valued vocabulary
('guitar' -> guitar-lead) and anything unknown (-> the default). An
unrecognised value must never change filter semantics."""
if perspective_id in PERSPECTIVES:
return PERSPECTIVES[perspective_id]
if perspective_id == "guitar":
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
return PERSPECTIVES[DEFAULT_PERSPECTIVE]
def normalize_offsets(offsets, persp: TuningPerspective) -> list[int] | None:
"""Coerce a stored tuning array to the strings the perspective's
instrument actually has. Returns None for anything unusable (empty /
non-integer / too short), so callers leave the index empty rather than
record a guess."""
if not isinstance(offsets, list) or not offsets:
return None
if any(isinstance(o, bool) for o in offsets):
return None
try:
vals = [int(o) for o in offsets]
except (TypeError, ValueError):
return None
if len(vals) < persp.string_count:
return None
# Only bass truncates: its arrays are padded (see above). A guitar array
# longer than 6 is a genuine 7/8-string chart, and cutting it to 6 would
# invent a tuning the chart does not have.
if persp.truncate:
return vals[:persp.string_count]
return vals
def offsets_are_plausible(offsets: list[int], persp: TuningPerspective) -> bool:
"""False for data the perspective refuses to trust — currently only the
bass up-tuning guard (see BASS_MAX_PLAUSIBLE_OFFSET)."""
if not persp.guard_up_tuning:
return True
return all(o <= BASS_MAX_PLAUSIBLE_OFFSET for o in offsets)
def perspective_tuning_name(offsets: list[int], persp: TuningPerspective) -> str:
"""Name a NORMALIZED tuning for this perspective, refusing to name data the
perspective distrusts that becomes "Custom Tuning", which stays distinct
by its canonical pitches without asserting a tuning anyone plays."""
if not offsets_are_plausible(offsets, persp):
return "Custom Tuning"
return tuning_name(offsets)
def perspective_tuning_key(offsets: list[int], persp: TuningPerspective) -> str:
"""CANONICAL grouping key: the tuning's absolute open-string pitches, so
the same physical tuning groups as ONE facet entry no matter how it was
serialized. Keyed on pitch rather than the raw offsets string, which is
serialization-dependent and fragments.
Joined with ':' and NOT ',' this key travels back as a `tunings` filter
selector, and that query param is a COMMA-separated list, so a comma here
would be split into meaningless fragments and match nothing.
"""
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
if not midis:
return ""
return persp.id + ":" + ":".join(str(m) for m in midis)
def perspective_low_pitch(offsets: list[int], persp: TuningPerspective) -> int | None:
"""Absolute MIDI pitch of the tuning's LOWEST open string — the value the
"playable without retuning" comparison is built on (see
`chart_is_playable_in`)."""
midis = tuning_midis_from_offsets(persp.instrument_key, offsets)
if not midis:
return None
return min(midis)
# ── "Playable without retuning" ──────────────────────────────────────────────
#
# What the player actually wants is "don't make me retune", not "match this
# label". A chart is playable as-is when every pitch it needs is reachable on
# the instrument as currently tuned.
#
# WHAT WE CAN HONESTLY COMPUTE. We index open-string TUNINGS, not the notes a
# chart plays — note data lives in the 600KB-1.2MB arrangement JSON, and the
# library scan is deliberately manifest-only, so we do not read it (indexing a
# per-song lowest note would mean opening every chart on every scan).
#
# So the comparison is on OPEN-STRING PITCH, with a conservative assumption:
# a chart may require its own lowest open string. That gives
#
# playable <=> your lowest open pitch <= the chart's lowest open pitch
#
# On a fretted instrument every pitch ABOVE your lowest open string is
# reachable by fretting (strings sit within an octave of each other and the
# neck gives ~2 octaves), so the low end is the binding constraint. This is
# exactly the dominant real case: a 5-string bass (low B) plays every 4-string
# standard chart AND every drop-D chart untouched, because the low D is just
# fretted on the B string.
#
# DELIBERATE LIMITATIONS, both erring toward NOT claiming playability:
# * A chart that never actually touches its lowest open string is excluded
# anyway. Conservative: excluding a playable chart costs a scroll;
# including an unplayable one costs a mid-practice retune, which is the
# failure this feature exists to prevent.
# * The UPPER bound is not checked — a chart tuned far above you could in
# principle exceed your neck. Checking it needs the note range we do not
# have. It is the rare direction (and the guard above already refuses
# up-tuned bass data), but it is a real gap, not an oversight.
def chart_is_playable_in(chart_low_pitch, your_low_pitch) -> bool:
"""True when a chart whose lowest open string is `chart_low_pitch` needs no
retune for a player tuned to `your_low_pitch`. Unknown chart pitch => False
(never claim playability we cannot support)."""
if chart_low_pitch is None or your_low_pitch is None:
return False
return int(your_low_pitch) <= int(chart_low_pitch)
# Back-compat wrappers over the generic helpers — bass was the first
# perspective and reads better spelled out at bass-specific call sites.
def normalize_bass_offsets(offsets) -> list[int] | None:
return normalize_offsets(offsets, PERSPECTIVES["bass"])
def bass_offsets_are_plausible(offsets: list[int]) -> bool:
return offsets_are_plausible(offsets, PERSPECTIVES["bass"])
def bass_tuning_name(offsets: list[int]) -> str:
return perspective_tuning_name(offsets, PERSPECTIVES["bass"])
def bass_tuning_key(offsets: list[int]) -> str:
return perspective_tuning_key(offsets, PERSPECTIVES["bass"])
def tuning_name(offsets: list[int]) -> str:
# The pattern checks below are gated on `len(offsets)` being 6 or 4. The
# naming conventions are E-standard-rooted — e.g. a 7-string all-zeros
# All three pattern checks below are gated on `len(offsets) == 6`. The
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
# tuning has a low B, not an E, so labeling it "E Standard" would be wrong.
# 7+-string community content falls through to the numeric fallback (#43).
#
# Length 4 is accepted because a bass's open strings (EADG) are the low
# four of the guitar, so the same standard/drop names apply at the same
# offsets. Bass callers must normalize FIRST (`normalize_bass_offsets`):
# stored bass arrays are commonly six elements with a padded tail, and the
# padding must never reach this namer. See the block above.
# 7+-string community content falls through to the numeric fallback. See #43.
# Standard tunings (all strings same offset)
# Standard tunings (all six strings same offset)
standard = {
0: "E Standard", -1: "Eb Standard", -2: "D Standard",
-3: "C# Standard", -4: "C Standard", -5: "B Standard",
-6: "Bb Standard", -7: "A Standard",
1: "F Standard", 2: "F# Standard",
}
if len(offsets) in (4, 6) and all(o == offsets[0] for o in offsets):
if len(offsets) == 6 and all(o == offsets[0] for o in offsets):
name = standard.get(offsets[0])
if name:
return name
# Drop tunings (low string 2 semitones below the rest)
# Named after the low string's note: e.g. offsets[-2,0,0,0,0,0] = Drop D (low E dropped to D)
if len(offsets) in (4, 6) and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
if len(offsets) == 6 and offsets[0] == offsets[1] - 2 and all(o == offsets[1] for o in offsets[1:]):
note_names = ["E", "F", "F#", "G", "Ab", "A", "Bb", "B", "C", "C#", "D", "Eb"]
low_note = note_names[offsets[0] % 12]
return f"Drop {low_note}"
-7
View File
@@ -44,13 +44,6 @@ def run() -> None:
# record — including early startup messages — passes through the same
# structured pipeline.
log_config=None,
# Cap inbound WebSocket frames at the transport, before uvicorn
# materializes them in memory (its default is 16 MB). No client sends
# large frames to this server: the highway WS receives only small
# control messages, and the /ws/sync relay enforces its own tighter
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
# the defense-in-depth bound above it.
ws_max_size=64 * 1024,
)
+1 -964
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -14,7 +14,6 @@
"devDependencies": {
"@playwright/test": "^1.59.1",
"eslint": "^9.39.4",
"eslint-plugin-import-x": "^4.17.1",
"tailwindcss": "^3.4.19"
"eslint-plugin-import-x": "^4.17.1"
}
}
+2 -3
View File
@@ -25,8 +25,8 @@
Object.freeze({
id: 'player-audio',
label: 'Player and Audio Runtime',
summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
summary: 'Playback, renderer, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
}),
Object.freeze({
id: 'plugin-defined',
@@ -50,7 +50,6 @@
'audio-monitoring': 'headphones',
stems: 'sliders',
'note-detection': 'activity',
'chart-transform': 'box',
diagnostics: 'fileSearch',
pipeline: 'activity',
'ui.navigation': 'list',
-725
View File
@@ -64,728 +64,3 @@
.career-star-row .song .artist { color: #9ca3af; }
.career-star-row .hint { color: #6b7280; white-space: nowrap; }
.career-star-row .hint.close { color: #22d3ee; }
/* ── Passports (badge journey) ─────────────────────────────────────────── */
.career-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(55, 65, 81, 0.6);
}
.career-tab {
padding: 0.375rem 0.875rem;
font-size: 0.85rem;
color: #9ca3af;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.career-tab:hover { color: #e5e7eb; }
.career-tab.active { color: #fff; border-bottom-color: #06b6d4; }
.pp-instruments { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pp-inst {
padding: 0.3rem 0.8rem;
border-radius: 999px;
font-size: 0.8rem;
color: #d1d5db;
background-color: rgba(31, 41, 55, 0.7);
border: 1px solid transparent;
}
.pp-inst:hover { background-color: rgba(55, 65, 81, 0.9); }
.pp-inst.active { border-color: #06b6d4; color: #fff; }
.pp-inst.uncommitted { color: #6b7280; border-style: dashed; border-color: rgba(107, 114, 128, 0.5); }
.pp-inst-badges { color: #fbbf24; font-size: 0.7rem; }
.pp-inst-plus { color: #6b7280; }
/* Leather covers per-instrument hue, embossed with layered shadows and a
subtle grain gradient (no image assets). Keep the hex pairs in sync with
PP_LEATHER_HEX in screen.js (the canvas card draws the same leather). */
.pp-leather-guitar { background: linear-gradient(160deg, #5c2321, #401412); }
.pp-leather-bass { background: linear-gradient(160deg, #1f3252, #131f36); }
.pp-leather-keys { background: linear-gradient(160deg, #1e4034, #122a21); }
.pp-leather-drums { background: linear-gradient(160deg, #3f3f46, #26262b); }
.pp-shelf { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
.pp-cover, .pp-commit-cover {
position: relative;
width: 9.5rem;
height: 13rem;
border-radius: 0.5rem 0.75rem 0.75rem 0.5rem;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
inset 0.5rem 0 0.75rem -0.5rem rgba(0, 0, 0, 0.8),
0 6px 16px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.75rem;
text-align: center;
}
.pp-cover { transition: transform 0.15s ease, box-shadow 0.15s ease; }
.pp-cover:not(.pp-tilt):hover { transform: translateY(-4px) !important; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.55); }
.pp-cover-title {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.14em;
color: rgba(240, 226, 195, 0.92);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7), 0 -1px 0 rgba(255, 255, 255, 0.12);
overflow-wrap: anywhere;
}
.pp-cover-inst {
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: rgba(240, 226, 195, 0.55);
}
.pp-cover-sub {
position: absolute;
bottom: 0.6rem;
font-size: 0.6rem;
color: rgba(240, 226, 195, 0.5);
}
.pp-commit-card {
display: flex;
gap: 1.25rem;
align-items: center;
padding: 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(55, 65, 81, 0.6);
background-color: rgba(31, 41, 55, 0.35);
}
.pp-commit-card .pp-commit-cover { width: 7rem; height: 9.5rem; flex: none; }
.pp-rack { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr)); }
.pp-brochure {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
padding: 0.75rem 0.875rem;
border-radius: 0.5rem;
text-align: left;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.55), rgba(31, 41, 55, 0.55));
border: 1px solid rgba(75, 85, 99, 0.5);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.pp-brochure:hover { transform: translateY(-2px); border-color: #06b6d4; }
.pp-brochure-art { font-size: 1.4rem; }
.pp-brochure-name { color: #e5e7eb; font-size: 0.85rem; font-weight: 600; }
.pp-brochure-sub { color: #6b7280; font-size: 0.65rem; }
/* The open book */
.pp-overlay { position: fixed; inset: 0; z-index: 60; }
.pp-book-wrap {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.72);
backdrop-filter: blur(2px);
}
.pp-book {
position: relative;
width: min(92vw, 720px);
height: min(72vh, 470px);
perspective: 1800px;
}
.pp-page {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
background:
linear-gradient(105deg, rgba(0, 0, 0, 0.08), transparent 12%),
#efe6d0;
color: #3f3428;
padding: 1.1rem 1.2rem;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.pp-page-left { left: 0; border-radius: 0.6rem 0 0 0.6rem; opacity: 0; transition: opacity 0.35s ease 0.3s; align-items: center; }
.pp-page-right { right: 0; border-radius: 0 0.6rem 0.6rem 0; box-shadow: inset 0.4rem 0 0.6rem -0.4rem rgba(0, 0, 0, 0.35); }
.pp-book.open .pp-page-left { opacity: 1; }
.pp-book-cover {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 50%;
border-radius: 0 0.6rem 0.6rem 0;
transform-origin: left center;
transform: rotateY(0deg);
backface-visibility: hidden;
transition: transform 0.8s cubic-bezier(0.4, 0.1, 0.2, 1);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 6px 20px rgba(0, 0, 0, 0.5);
}
.pp-book.open .pp-book-cover { transform: rotateY(-180deg); }
.pp-book-close {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 8;
width: 2rem;
height: 2rem;
border-radius: 999px;
background: rgba(17, 24, 39, 0.95);
color: #d1d5db;
border: 1px solid rgba(107, 114, 128, 0.5);
}
.pp-book-close:hover { color: #fff; border-color: #06b6d4; }
.pp-page-head {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #8a7a5e;
border-bottom: 1px solid rgba(138, 122, 94, 0.35);
padding-bottom: 0.4rem;
width: 100%;
text-align: center;
}
/* The rubber stamp */
.pp-stamp {
--pp-rot: 0deg;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 9rem;
height: 9rem;
border-radius: 999px;
border: 3px solid #9a5b16;
box-shadow: inset 0 0 0 3px #efe6d0, inset 0 0 0 4px #9a5b16;
color: #9a5b16;
transform: rotate(var(--pp-rot));
margin-top: 1.25rem;
text-align: center;
padding: 0.75rem;
opacity: 0.92;
}
.pp-stamp-genre { font-size: 0.72rem; font-weight: 800; letter-spacing: 0.16em; overflow-wrap: anywhere; }
.pp-stamp-tier { font-size: 0.58rem; letter-spacing: 0.3em; }
.pp-stamp-ghost {
border-style: dashed;
box-shadow: none;
border-color: #b3a68b;
color: #b3a68b;
opacity: 0.8;
}
.pp-stamp-hidden { opacity: 0; }
.pp-stamp-mini {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: auto;
height: auto;
border-width: 2px;
box-shadow: none;
border-radius: 999px;
font-size: 0.5rem;
font-weight: 800;
letter-spacing: 0.2em;
color: #d9a253;
border-color: #d9a253;
padding: 0.2rem 0.4rem;
margin: 0;
display: inline-block;
transform: rotate(var(--pp-rot));
opacity: 0.95;
}
.pp-stamp-page::after {
content: '';
position: absolute;
inset: -10%;
border-radius: 999px;
background: radial-gradient(closest-side, rgba(154, 91, 22, 0.25), transparent 72%);
filter: blur(5px);
opacity: 0;
pointer-events: none;
}
.pp-slam { animation: pp-slam 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards; }
.pp-slam::after { animation: pp-ink 0.45s ease-out 0.12s forwards; }
@keyframes pp-slam {
0% { transform: rotate(calc(var(--pp-rot) - 15deg)) scale(2.5); opacity: 0; }
55% { transform: rotate(var(--pp-rot)) scale(0.92); opacity: 1; }
75% { transform: rotate(var(--pp-rot)) scale(1.05); }
100% { transform: rotate(var(--pp-rot)) scale(1); opacity: 0.92; }
}
@keyframes pp-ink {
from { opacity: 0; transform: scale(0.6); }
to { opacity: 1; transform: scale(1); }
}
.pp-shake { animation: pp-shake 0.4s ease-out 0.28s; }
@keyframes pp-shake {
0%, 100% { transform: translate(0, 0) rotate(0); }
25% { transform: translate(2px, 1px) rotate(0.3deg); }
50% { transform: translate(-2px, 2px) rotate(-0.25deg); }
75% { transform: translate(1px, -1px) rotate(0.15deg); }
}
.pp-invite, .pp-snj, .pp-gold-note { font-size: 0.75rem; text-align: center; }
.pp-invite { color: #6d5d40; }
.pp-snj { color: #6d5d40; margin-top: 2rem; font-style: italic; max-width: 15rem; }
.pp-gold-note { color: #a8946d; font-size: 0.62rem; margin-top: 0.5rem; }
.pp-drills { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.7rem; color: #6d5d40; }
.pp-drill.cleared { color: #4d7c0f; }
/* Ticket stubs */
.pp-stubs { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.5rem; padding-right: 0.25rem; }
.pp-stub {
background: #f7f1e3;
border: 1px solid #d8cbaa;
border-left: 2px dashed #b6a98c;
border-radius: 0.25rem 0.4rem 0.4rem 0.25rem;
padding: 0.4rem 0.6rem 0.4rem 0.75rem;
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.6rem;
align-items: baseline;
box-shadow: 0 1px 2px rgba(63, 52, 40, 0.15);
}
.pp-stub-stars { color: #b8860b; font-size: 0.7rem; letter-spacing: 0.08em; grid-row: span 2; }
.pp-stub-title { font-size: 0.78rem; font-weight: 600; color: #3f3428; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pp-stub-artist { grid-column: 2; font-size: 0.65rem; color: #6d5d40; }
.pp-stub-meta { grid-column: 2; font-size: 0.6rem; color: #8a7a5e; }
.pp-stub-empty { font-size: 0.72rem; color: #8a7a5e; font-style: italic; padding: 0.75rem 0.25rem; }
/* Wax-seal commitment ceremony */
.pp-ceremony { width: 11rem; height: 15rem; }
.pp-wax {
position: absolute;
bottom: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
width: 3.4rem;
height: 3.4rem;
border-radius: 999px;
background:
radial-gradient(circle at 32% 30%, #d24545 0%, #a41f1f 42%, #7c1414 100%);
box-shadow:
inset 0 0 0 4px rgba(124, 20, 20, 0.9),
inset 0 2px 4px rgba(255, 255, 255, 0.25),
0 3px 8px rgba(0, 0, 0, 0.55);
color: rgba(255, 235, 235, 0.9);
font-weight: 700;
font-size: 1.15rem;
animation: pp-seal-drop 0.9s cubic-bezier(0.25, 0.9, 0.3, 1.15) 0.35s backwards;
}
@keyframes pp-seal-drop {
0% { transform: translateY(-120px) scale(2.1); opacity: 0; }
60% { transform: translateY(0) scale(0.9); opacity: 1; }
80% { transform: translateY(0) scale(1.05); }
100% { transform: translateY(0) scale(1); }
}
/* Small screens: the spread stacks; the flip cover would straddle both
pages, so the book simply opens. */
@media (max-width: 640px) {
.pp-book { height: min(80vh, 620px); }
.pp-page { position: static; width: 100%; height: 50%; border-radius: 0; }
.pp-page-left { border-radius: 0.6rem 0.6rem 0 0; opacity: 1; }
.pp-page-right { border-radius: 0 0 0.6rem 0.6rem; }
.pp-book-cover { display: none; }
.pp-book { display: flex; flex-direction: column; }
}
@media (prefers-reduced-motion: reduce) {
.pp-book-cover, .pp-page-left, .pp-cover { transition: none; }
.pp-slam, .pp-slam::after, .pp-shake, .pp-wax { animation: none; }
.pp-slam, .pp-stamp-page::after { opacity: 1; }
.pp-stamp-hidden { opacity: 0.92; }
}
/* Badge ceremony (body-level overlay — shows over the player) */
.pp-ceremony-overlay {
position: fixed;
inset: 0;
z-index: 220;
display: flex;
align-items: center;
justify-content: center;
background: rgba(3, 7, 18, 0.55);
backdrop-filter: blur(1.5px);
animation: pp-ceremony-in 0.3s ease-out;
cursor: pointer;
}
.pp-ceremony-out { opacity: 0; transition: opacity 0.3s ease-out; }
.pp-confetti { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.pp-ceremony-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
}
.pp-ceremony-stamp {
position: relative;
overflow: hidden;
background: rgba(239, 230, 208, 0.97);
transform: rotate(var(--pp-rot)) scale(1.25);
animation: pp-slam 0.55s cubic-bezier(0.2, 0.8, 0.3, 1) 0.15s backwards;
margin-top: 0;
}
.pp-ceremony-stamp::before {
content: '';
position: absolute;
inset: -40%;
background: linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.55) 50%, transparent 58%);
transform: translateX(-120%);
animation: pp-shine 1.1s ease-out 0.75s forwards;
pointer-events: none;
}
@keyframes pp-shine {
to { transform: translateX(120%); }
}
@keyframes pp-ceremony-in {
from { opacity: 0; }
to { opacity: 1; }
}
.pp-ceremony-title {
margin-top: 1rem;
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #f0e2c3;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8);
}
.pp-ceremony-sub { font-size: 0.8rem; color: #d1d5db; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); }
/* Hours odometer (Stage 5 post-cap — a true fact, never a meter) */
.pp-hours {
font-size: 0.72rem;
letter-spacing: 0.08em;
color: #8a7a5e;
margin-top: 0.75rem;
font-variant-numeric: tabular-nums;
}
/* ── Visuals pack: trading-card tilt, emerging ink, gold foil ──────────── */
/* Trading-card tilt (earned artifacts; JS feeds --pp-tilt-* on hover-capable
pointers only). */
.pp-tilt {
position: relative;
overflow: hidden;
will-change: transform;
}
.pp-cover.pp-tilt {
transform: perspective(700px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-cover-rot, 0deg));
transition: transform 0.12s ease, box-shadow 0.15s ease;
}
.pp-cover.pp-tilt:hover { box-shadow: 0 12px 26px rgba(0, 0, 0, 0.6); }
.pp-stamp-page.pp-tilt {
overflow: visible;
transform: perspective(600px)
rotateX(var(--pp-tilt-x, 0deg)) rotateY(var(--pp-tilt-y, 0deg))
rotate(var(--pp-rot));
transition: transform 0.12s ease;
}
.pp-tilt::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(105deg,
transparent calc(var(--pp-glint-x, 50%) - 14%),
rgba(255, 255, 255, 0.16) var(--pp-glint-x, 50%),
transparent calc(var(--pp-glint-x, 50%) + 14%));
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.pp-tilt:hover::after { opacity: 1; }
/* Emerging-stamp ink: the ghost fills as qualifying songs land. */
.pp-stamp-ghost::before {
content: '';
position: absolute;
inset: 7%;
border-radius: 999px;
background: conic-gradient(rgba(154, 91, 22, 0.16) var(--pp-fill, 0%), transparent 0);
pointer-events: none;
}
/* Gold ink — a REAL gold badge (comb-verified improv). */
.pp-stamp-gold {
border-color: #b8860b;
color: #a97b1b;
box-shadow: inset 0 0 0 3px #f3e8c8, inset 0 0 0 4px #b8860b;
}
.pp-stamp-mini.pp-stamp-gold {
color: #f0c75e;
border-color: #f0c75e;
box-shadow: none;
}
/* Gold foil chip — rendered only alongside an earned gold stamp. */
.pp-gold-foil {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 0.9rem;
padding: 0.28rem 0.85rem;
border-radius: 999px;
border: 2px solid #d9a253;
color: #c89040;
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.32em;
}
.pp-gold-foil::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 40%, rgba(255, 223, 128, 0.35) 50%, transparent 60%);
transform: translateX(-120%);
animation: pp-foil 3.4s ease-in-out infinite;
}
@keyframes pp-foil {
0%, 55% { transform: translateX(-120%); }
100% { transform: translateX(120%); }
}
@media (prefers-reduced-motion: reduce) {
.pp-gold-foil::after { animation: none; }
.pp-cover.pp-tilt, .pp-stamp-page.pp-tilt { transition: none; }
/* The hover glint is motion theatrics too — not just the JS tilt. */
.pp-tilt::after { display: none; }
}
/* Practice invitations — closest stamps + bring-these-up */
.pp-closest {
border: 1px solid rgba(75, 85, 99, 0.45);
border-radius: 0.6rem;
background: linear-gradient(165deg, rgba(45, 55, 72, 0.4), rgba(31, 41, 55, 0.4));
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.pp-closest-head {
font-size: 0.62rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #9ca3af;
}
.pp-closest-row {
display: flex;
align-items: baseline;
gap: 0.75rem;
text-align: left;
font-size: 0.8rem;
padding: 0.15rem 0.25rem;
border-radius: 0.35rem;
}
.pp-closest-row:hover { background: rgba(55, 65, 81, 0.5); }
.pp-closest-genre { color: #e5e7eb; font-weight: 600; white-space: nowrap; }
.pp-closest-ask { color: #9ca3af; font-size: 0.72rem; }
.pp-closest-ask em { color: #cbd5e1; font-style: italic; }
.pp-nearest { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-nearest-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-nearest-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-nearest-row em { color: #3f3428; }
/* ── Career surfaces outside the plugin: profile wall + home card ───────── */
.pp-wall { display: flex; flex-direction: column; gap: 0.6rem; }
.pp-wall-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-weight: 600;
color: #e5e7eb;
font-size: 0.9rem;
}
.pp-wall-meta { color: #9ca3af; font-size: 0.7rem; font-weight: 400; }
.pp-wall-shelf {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(75, 85, 99, 0.25);
}
.pp-wall-inst {
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #6b7280;
min-width: 3.6rem;
}
.pp-wall-cover {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
width: 4.2rem;
height: 5.6rem;
border-radius: 0.3rem 0.45rem 0.45rem 0.3rem;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.07),
inset 0.25rem 0 0.4rem -0.25rem rgba(0, 0, 0, 0.8),
0 3px 8px rgba(0, 0, 0, 0.4);
padding: 0.3rem;
transition: transform 0.15s ease;
}
.pp-wall-cover:hover { transform: translateY(-3px); }
.pp-wall-cover span {
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: rgba(240, 226, 195, 0.9);
overflow-wrap: anywhere;
text-align: center;
}
.pp-wall-cover em {
font-size: 0.42rem;
letter-spacing: 0.22em;
font-style: normal;
color: #d9a253;
}
.pp-wall-none { font-size: 0.7rem; color: #6b7280; font-style: italic; }
.pp-wall-link {
align-self: flex-end;
font-size: 0.72rem;
color: #22d3ee;
padding: 0.15rem 0.3rem;
}
.pp-wall-link:hover { text-decoration: underline; }
/* The home-page career card — a trading card among stat tiles. */
.pp-dash-card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2rem;
text-align: left;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid rgba(217, 162, 83, 0.35);
background:
linear-gradient(135deg, rgba(92, 35, 33, 0.85), rgba(30, 27, 34, 0.92)),
linear-gradient(160deg, #2b1414, #17111c);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 4px 14px rgba(0, 0, 0, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.pp-dash-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5); }
.pp-dash-shine {
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 42%, rgba(255, 223, 128, 0.18) 50%, transparent 58%);
transform: translateX(-130%);
pointer-events: none;
}
.pp-dash-card:hover .pp-dash-shine { animation: pp-foil 1.4s ease-out; }
.pp-dash-head {
font-size: 0.58rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: #d9a253;
}
.pp-dash-badges { color: #f3ead2; font-size: 1.05rem; }
.pp-dash-badges b { font-weight: 700; margin: 0 0.25rem 0 0.35rem; }
.pp-dash-meta { color: #b5a488; font-size: 0.72rem; }
.pp-dash-ask { color: #8d9aa8; font-size: 0.66rem; }
.pp-dash-ask em { color: #cbd5e1; }
.pp-card-actions { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
@media (prefers-reduced-motion: reduce) {
.pp-dash-card:hover .pp-dash-shine { animation: none; }
.pp-wall-cover, .pp-dash-card { transition: none; }
}
/* ── Gigs: poster, runner strip, summary, log ───────────────────────────── */
.pp-poster {
position: relative;
width: min(92vw, 420px);
padding: 2rem 1.6rem 1.4rem;
border-radius: 0.5rem;
background: linear-gradient(180deg, #141019, #241318);
border: 2px solid rgba(217, 162, 83, 0.45);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
text-align: center;
}
.pp-poster-venue { color: rgba(240, 226, 195, 0.7); font-size: 0.95rem; letter-spacing: 0.08em; }
.pp-poster-presents { color: rgba(240, 226, 195, 0.4); font-size: 0.58rem; letter-spacing: 0.4em; text-transform: uppercase; }
.pp-poster-title {
color: #d9a253;
font-size: 1.7rem;
font-weight: 800;
letter-spacing: 0.1em;
line-height: 1.15;
overflow-wrap: anywhere;
}
.pp-poster-inst { color: rgba(240, 226, 195, 0.5); font-size: 0.68rem; letter-spacing: 0.2em; text-transform: uppercase; }
.pp-poster-bill { margin: 0.9rem 0 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; width: 100%; }
.pp-poster-line { color: rgba(240, 226, 195, 0.85); font-size: 0.85rem; }
.pp-poster-line span { color: rgba(217, 162, 83, 0.7); margin-right: 0.35rem; }
.pp-poster-line em { color: rgba(240, 226, 195, 0.5); font-style: italic; font-size: 0.72rem; }
.pp-poster-line b { color: #f3d179; margin-left: 0.3rem; }
.pp-poster-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; margin-top: 0.6rem; }
.pp-poster-summary { cursor: default; }
.pp-gig-strip {
position: fixed;
top: 0.5rem;
left: 50%;
transform: translateX(-50%);
z-index: 35; /* above the rail (30), under popovers (40) — the chrome invariant */
background: rgba(10, 8, 14, 0.85);
border: 1px solid rgba(217, 162, 83, 0.4);
border-radius: 999px;
color: rgba(240, 226, 195, 0.85);
font-size: 0.72rem;
padding: 0.3rem 0.9rem;
pointer-events: none;
backdrop-filter: blur(2px);
}
.pp-gig-strip b { color: #d9a253; letter-spacing: 0.2em; }
.pp-gig-strip em { color: #f3ead2; font-style: italic; }
.pp-giglog { margin-top: 0.6rem; border-top: 1px dashed rgba(138, 122, 94, 0.4); padding-top: 0.5rem; }
.pp-giglog-head {
font-size: 0.58rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #8a7a5e;
margin-bottom: 0.25rem;
}
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-43
View File
@@ -1,43 +0,0 @@
{
"badge_requirement": {
"songs": 5,
"min_stars": 2
},
"gig": {
"min_songs": 3,
"max_songs": 5,
"stakes_songs": 2,
"encore_accuracy": 0.75
},
"families": [
{ "key": "metal", "match": ["metal", "djent", "grindcore", "thrash", "doom"] },
{ "key": "blues", "match": ["blues"] },
{ "key": "jazz", "match": ["jazz", "bebop", "swing", "bossa"] },
{ "key": "funk", "match": ["funk", "disco"] },
{ "key": "rock", "match": ["rock", "punk", "grunge", "shoegaze"] }
],
"genres": {
"blues": { "virtuoso_nodes": { "guitar": ["blues_shuffle"] } },
"rock": { "virtuoso_nodes": { "guitar": ["rock_power_backbeat"] } },
"metal": { "virtuoso_nodes": { "guitar": ["melodic_metal_gallop"] } },
"funk": { "virtuoso_nodes": { "guitar": ["sixteenth_pocket"] } },
"jazz": { "virtuoso_nodes": { "guitar": ["vl_shells"] } }
},
"drill_labels": {
"blues_shuffle": "Blues Shuffle",
"rock_power_backbeat": "Power Chords & Backbeat",
"melodic_metal_gallop": "Gallop Picking",
"sixteenth_pocket": "16th Pocket",
"vl_shells": "Shell Voicings"
},
"graded_instruments": [
"guitar",
"keys"
],
"instruments": [
"guitar",
"bass",
"keys",
"drums"
]
}
+5 -7
View File
@@ -1,18 +1,16 @@
{
"id": "career",
"name": "Career",
"version": "0.2.0",
"version": "0.1.0",
"bundled": true,
"private": false,
"description": "Career mode gig your way from a local bar to the arena, and build a passport wall of genre badges per instrument. Earn stars per song; the crowd reacts to how you play.",
"description": "Career mode \u2014 gig your way from a local bar to the arena. Earn stars per song; the crowd reacts to how you play.",
"screen": "screen.html",
"script": "screen.js",
"styles": "assets/career.css",
"routes": "routes.py",
"settings": {
"html": "settings.html",
"server_files": [
"career/"
]
},
"routes": "routes.py"
"category": "system"
}
}
+9 -714
View File
@@ -10,54 +10,32 @@ the plugin under ``venue-packs/<id>/`` or downloaded on demand into
``CONFIG_DIR/plugin_uploads/career/venues/<id>/``. Downloaded packs override
bundled packs so release assets can replace a built-in starter venue.
Passports (badge journey per instrument × genre the identity layer on top
of the same stars): badges are COMPUTED on read from ``song_stats`` × the
library's effective genre, never stored. The only persisted career state is
what cannot be derived instrument commitment, opened passports, and the
relayed virtuoso drill snapshot as JSON under ``CONFIG_DIR/career/``
(exported via ``settings.server_files``).
Endpoints (all under /api/plugins/career/):
GET /state stars + per-venue unlock/install/download status
POST /packs/{venue_id}/download start background pack download (409 if running)
DELETE /packs/{venue_id} remove an installed pack
GET /venues/{venue_id}/{filename} serve pack files (manifest.json, loops, stingers)
GET /passports passport walls: badges, stubs, genres, drill status
POST /passports/commit commit to an instrument (the wax seal, Stage 0)
POST /passports/open open a genre passport for an instrument
POST /drill-state relayed virtuoso.progress snapshot (drill intake)
POST /gigs/propose build a playable setlist for a genre gig
POST /gigs log a COMPLETED gig (abandoned sets never log)
"""
import hashlib
import json
import logging
import random
import re
import shutil
import tempfile
import threading
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Body, HTTPException
from fastapi import HTTPException
from fastapi.responses import FileResponse
import sloppak
from dlc_paths import _resolve_dlc_path
from progression import instrument_for_arrangement
PLUGIN_ID = "career"
VENUE_ID_RE = re.compile(r"^[a-z0-9_-]{1,40}$")
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
REQUIRED_LOOPS = ("bored", "neutral", "engaged", "ecstatic")
DOWNLOAD_CHUNK = 1024 * 256
# A setlist is a handful of songs; this endpoint unpacks zips, so cap the work an
# arbitrary caller can ask for.
MAX_GIG_SONGS = 32
_lock = threading.Lock()
_state = {
@@ -104,13 +82,6 @@ def _bundled(venue_id):
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
def _pack_published(pack):
"""A remote pack is downloadable only once a publish has stamped its real
size the committed manifest carries a 0-byte placeholder (and an all-zero
sha) until then, so don't offer a download that can't succeed yet."""
return bool(pack and (pack.get("bytes") or 0) > 0)
def _stars():
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
@@ -129,9 +100,10 @@ def _stars():
detail = []
for filename, acc, title, artist in rows:
acc = acc or 0.0
stars, next_at = _star_progress(acc, thresholds)
stars = sum(1 for t in thresholds if acc >= t)
if stars:
per_song[filename] = stars
next_at = next((t for t in thresholds if acc < t), None)
detail.append({
"filename": filename,
"title": title or filename,
@@ -146,424 +118,6 @@ def _stars():
return sum(per_song.values()), per_song, detail
# ── Passports ─────────────────────────────────────────────────────────────────
GENRE_MAX_LEN = 64
DRILL_SNAPSHOT_MAX_BYTES = 256 * 1024
def _now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _genre_display(genre):
return " ".join(str(genre or "").strip().split())
def _genre_key(genre):
return _genre_display(genre).lower()
def _state_file() -> Path:
return _state["state_dir"] / "passports-state.json"
def _drill_file() -> Path:
return _state["state_dir"] / "drill-state.json"
def _load_json(path: Path, default):
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return default
def _save_json(path: Path, obj):
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(obj, indent=2), encoding="utf-8")
tmp.replace(path)
def _career_state():
st = _load_json(_state_file(), {})
if not isinstance(st, dict):
st = {}
if not isinstance(st.get("instruments"), dict):
st["instruments"] = {}
if not isinstance(st.get("passports"), dict):
st["passports"] = {}
return st
def _genre_expr(db):
# Reuse the host's override-aware effective-genre SQL (Fix-metadata popup
# overrides); plain `genre` on stand-ins that don't implement it.
fn = getattr(db, "_effective_genre_expr", None)
return fn() if callable(fn) else "genre"
def _instrument_of(arrangements, arrangement):
"""Progression's arrangement→instrument mapping, via the song_stats
arrangement index into the song's arrangements JSON."""
entry = None
try:
idx = int(arrangement)
if isinstance(arrangements, list) and 0 <= idx < len(arrangements):
entry = arrangements[idx]
except (TypeError, ValueError):
entry = None
return instrument_for_arrangement(entry)
def _played_by_instrument_genre():
"""((instrument, genre_key) → {filename: stub dict},
(instrument, genre_key) total played seconds).
Best accuracy per (instrument, song); seconds sum across every
arrangement row; the JOIN keeps the same dead-song filter as _stars()."""
db = _state["meta_db"]
if db is None:
return {}, {}
thresholds = _state["content"]["star_accuracy_thresholds"]
rows = db.conn.execute(
"SELECT s.filename, s.arrangement, s.best_accuracy, s.last_played_at, "
" s.seconds_total, songs.title, songs.artist, songs.arrangements, "
f" {_genre_expr(db)} "
"FROM song_stats s JOIN songs ON songs.filename = s.filename"
).fetchall()
arrs_cache = {}
out = {}
seconds = {}
for filename, arrangement, acc, played_at, secs, title, artist, arrs_json, genre in rows:
gkey = _genre_key(genre)
if not gkey:
continue
if filename not in arrs_cache:
try:
arrs_cache[filename] = json.loads(arrs_json) if arrs_json else None
except (TypeError, ValueError):
arrs_cache[filename] = None
instrument = _instrument_of(arrs_cache[filename], arrangement)
key = (instrument, gkey)
seconds[key] = seconds.get(key, 0.0) + (secs or 0.0)
acc = acc or 0.0
stub = out.setdefault(key, {}).get(filename)
if stub is None:
out[key][filename] = {
"filename": filename,
"title": title or filename,
"artist": artist or "",
"best_accuracy": acc,
"last_played_at": played_at,
}
else:
stub["best_accuracy"] = max(stub["best_accuracy"], acc)
stub["last_played_at"] = max(stub["last_played_at"] or "", played_at or "") or None
for stubs in out.values():
for stub in stubs.values():
acc = stub["best_accuracy"]
stub["best_accuracy"] = round(acc, 4)
stub["stars"], stub["next_star_at"] = _star_progress(acc, thresholds)
return out, seconds
def _star_progress(acc, thresholds):
"""(stars, next_star_at) — the one place the ascending-thresholds
assumption lives; _stars() and the passport stubs both use it."""
stars = sum(1 for t in thresholds if acc >= t)
next_at = next((t for t in thresholds if acc < t), None)
return stars, next_at
def _library_genres():
"""Distinct effective genres across the live library (the brochure rack)."""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT {_genre_expr(db)} AS g, COUNT(*) FROM songs GROUP BY g").fetchall()
by_key = {}
for genre, count in rows:
display = _genre_display(genre)
key = display.lower()
if not key:
continue
cur = by_key.get(key)
if cur: # case-variant duplicates collapse onto the first-seen casing
cur["songs_in_library"] += count
else:
by_key[key] = {"genre_key": key, "genre": display,
"songs_in_library": count}
return sorted(by_key.values(),
key=lambda r: (-r["songs_in_library"], r["genre_key"]))
def _genre_family(gkey):
"""First family whose keyword appears in the genre key (substring — MB's
vocabulary is open: 'metalcore' must hit the 'metal' family without an
exact alias). List order decides ambiguity: families are checked top to
bottom, so 'blues rock' lands on whichever of blues/rock is listed first."""
for fam in _state["passports_content"].get("families") or []:
if not isinstance(fam, dict):
continue
for kw in fam.get("match") or []:
if isinstance(kw, str) and kw and kw in gkey:
return fam.get("key")
return None
def _badge_requirement(gkey, instrument="guitar"):
cfg = _state["passports_content"]
req = dict(cfg.get("badge_requirement") or {})
req.setdefault("songs", 5)
req.setdefault("min_stars", 2)
# Exact per-genre override wins; otherwise the genre inherits its FAMILY's
# requirement — so 'death metal' / 'metalcore' passports carry the metal
# drill without curating every MB sub-genre by hand.
genres_cfg = cfg.get("genres") or {}
override = genres_cfg.get(gkey)
if not isinstance(override, dict):
family = _genre_family(gkey)
override = genres_cfg.get(family) if family else None
if isinstance(override, dict):
req.update(override)
# virtuoso_nodes: {instrument: [node_ids]} — a passport only carries its
# own instrument's drills. A flat list keeps meaning guitar (back-compat;
# virtuoso's drill content is guitar-first).
nodes = req.get("virtuoso_nodes") or []
if isinstance(nodes, dict):
nodes = nodes.get(instrument) or []
elif instrument != "guitar":
nodes = []
req["virtuoso_nodes"] = [n for n in nodes if isinstance(n, str)]
return req
def _drill_by_node():
doc = _load_json(_drill_file(), {})
if not isinstance(doc, dict):
return None, {}, {}
snapshot = doc.get("snapshot") if isinstance(doc.get("snapshot"), dict) else {}
by_node = snapshot.get("byNode") if isinstance(snapshot.get("byNode"), dict) else {}
gold = snapshot.get("goldImprov") if isinstance(snapshot.get("goldImprov"), dict) else {}
return doc.get("received_at"), by_node, gold
def _merge_drill_nodes(old, new):
"""Gained-only merge of virtuoso byNode snapshots: a completion artifact
once relayed never un-earns via a stale snapshot (multi-browser races,
settings import, the once-per-session boot relay). Incoming wins the
descriptive fields; masteredAt / depth flips / keysCleared only grow."""
out = dict(old)
for node_id, incoming in new.items():
if not isinstance(incoming, dict):
continue
cur = out.get(node_id)
if not isinstance(cur, dict):
out[node_id] = incoming
continue
merged = dict(cur)
merged.update(incoming)
merged["masteredAt"] = cur.get("masteredAt") or incoming.get("masteredAt")
d_old = cur.get("depth") if isinstance(cur.get("depth"), dict) else {}
d_new = incoming.get("depth") if isinstance(incoming.get("depth"), dict) else {}
depth = dict(d_new)
for axis, val in d_old.items():
if val and not depth.get(axis):
depth[axis] = val
if depth:
merged["depth"] = depth
keys_old = cur.get("keysCleared") if isinstance(cur.get("keysCleared"), list) else []
keys_new = incoming.get("keysCleared") if isinstance(incoming.get("keysCleared"), list) else []
merged["keysCleared"] = keys_old + [k for k in keys_new if k not in keys_old]
out[node_id] = merged
return out
def _merge_gold(old, new):
"""Gained-only merge of goldImprov artifacts: a minted style never
un-mints via a stale relay; the FIRST artifact per style is kept."""
out = dict(old)
for style_id, art in (new or {}).items():
if isinstance(art, dict) and style_id not in out:
out[style_id] = art
return out
def _node_cleared(by_node, node_id):
"""A drill counts as cleared on real completion evidence: mastered, any
depth rung flipped true, or a key cleared (a top-tier clean pass in one
key virtuoso's first gained-only artifact, and an achievable Bronze
bar; the depth rungs additionally require a maxed speed tier)."""
entry = by_node.get(node_id)
if not isinstance(entry, dict):
return False
depth = entry.get("depth") if isinstance(entry.get("depth"), dict) else {}
keys = entry.get("keysCleared")
return (bool(entry.get("masteredAt"))
or any(bool(v) for v in depth.values())
or bool(isinstance(keys, list) and keys))
def _passports_view():
cfg = _state["passports_content"]
graded = set(cfg.get("graded_instruments") or [])
st = _career_state()
all_gigs = st.get("gigs") if isinstance(st.get("gigs"), list) else []
played, played_seconds = _played_by_instrument_genre()
received_at, by_node, gold_improv = _drill_by_node()
instruments = {}
for inst in cfg.get("instruments") or []:
committed_at = (st["instruments"].get(inst) or {}).get("committed_at")
opened = st["passports"].get(inst)
opened = opened if isinstance(opened, dict) else {}
passports = []
for gkey, meta in sorted(opened.items(),
key=lambda kv: ((kv[1] or {}).get("opened_at") or "", kv[0])):
meta = meta if isinstance(meta, dict) else {}
req = _badge_requirement(gkey, inst)
songs = list(played.get((inst, gkey), {}).values())
for s in songs:
s["qualifies"] = s["stars"] >= req["min_stars"]
songs.sort(key=lambda s: (not s["qualifies"], -s["stars"],
s["title"].lower()))
qualifying = sum(1 for s in songs if s["qualifies"])
required = req["virtuoso_nodes"]
cleared = [n for n in required if _node_cleared(by_node, n)]
is_graded = inst in graded
if not is_graded:
# Where the engine can't fairly grade the instrument's job
# (bass pocket, feel) the passport shows repertoire, never a
# false badge denial — the doc's shown-not-judged rule.
badge = "shown_not_judged"
elif qualifying >= req["songs"] and len(cleared) == len(required):
# Bronze is earned; GOLD upgrades it when a verified improv
# artifact exists for this genre's jam style. Virtuoso mints
# under raw STYLE_PALETTES ids ('punk', 'djent', 'disco', ...),
# which are mostly NOT family keys — so match in family space:
# the same keyword bucketing genres get ('punk' and 'punk
# rock' both bucket to 'rock'), with the exact key as a direct
# hit. Bronze remains a standalone win; gold never becomes an
# obligation.
fam = _genre_family(gkey)
gold = any(
s == gkey or (fam is not None and _genre_family(s) == fam)
for s in gold_improv
)
badge = "gold" if gold else "earned"
else:
badge = "in_progress"
# Practice invitation: the non-qualifying songs closest to the
# QUALIFYING bar (the badge ask), nearest first — invitation
# data, the UI voices it without meters.
thresholds = _state["content"]["star_accuracy_thresholds"]
bar = (thresholds[req["min_stars"] - 1]
if 0 < req["min_stars"] <= len(thresholds) else None)
nearest = [] if bar is None else sorted(
(s for s in songs if not s["qualifies"]),
key=lambda s: bar - s["best_accuracy"])[:3]
for s in nearest:
s["bar_at"] = bar
passports.append({
"genre_key": gkey,
"genre": meta.get("genre") or gkey,
"opened_at": meta.get("opened_at"),
"requirement": req,
"graded": is_graded,
"songs": songs,
"qualifying_count": qualifying,
"nearest": nearest,
# Honest hours odometer (Stage 5 post-cap): a true fact that
# only grows — never a target, never a meter.
"seconds_total": round(played_seconds.get((inst, gkey), 0.0), 1),
"drills": {"required": required, "cleared": cleared},
"badge": badge,
})
inst_gigs = [g for g in all_gigs if g.get("instrument") == inst]
for p in passports:
p["gigs"] = [g for g in inst_gigs if g.get("genre_key") == p["genre_key"]][-20:][::-1]
instruments[inst] = {"committed_at": committed_at, "passports": passports,
"gig_count": len(inst_gigs)}
return {
"config": {
"badge_requirement": cfg.get("badge_requirement") or {},
"graded_instruments": sorted(graded),
"instruments": list(cfg.get("instruments") or []),
# Career-side display names for virtuoso drill node ids.
"drill_labels": dict(cfg.get("drill_labels") or {}),
},
"instruments": instruments,
"genres": _library_genres(),
"drill_state": {"received_at": received_at},
}
def _gig_config():
cfg = _state["passports_content"].get("gig")
cfg = cfg if isinstance(cfg, dict) else {}
def _num(key, default, cast):
# Tuning data, not code: junk falls back instead of 500ing both gig
# endpoints, and a legitimate 0 (stakes_songs: 0) is respected.
val = cfg.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return default
return cast(val)
return {
"min_songs": max(1, _num("min_songs", 3, int)),
"max_songs": max(1, _num("max_songs", 5, int)),
"stakes_songs": max(0, _num("stakes_songs", 2, int)),
"encore_accuracy": _num("encore_accuracy", 0.75, float),
}
def _current_venue():
"""Highest unlocked venue (the room you can book today)."""
stars_total, _, _ = _stars()
best = None
for v in _state["content"]["venues"]:
if stars_total >= v["star_threshold"]:
if best is None or v["star_threshold"] >= best["star_threshold"]:
best = v
return best
def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked.
Was `_unplayed_genre_songs`, restricted to `filename NOT IN song_stats`.
That restriction created a hole: a song you'd played on a DIFFERENT
instrument's arrangement has a stats row, so it was excluded here — and it
lives in the played bucket for THAT instrument, not this passport's, so it
was excluded there too. It could never be gigged. A player with 137 metalcore
songs, all played on another instrument, got a 404 (reproduced). The player's
library is the pool; whether a song has stats on some other instrument has no
bearing on whether it can be in THIS gig.
Shuffled, so re-roll actually changes the set. The old version returned the
library's first N in table order every time, so re-roll was a no-op for any
set drawn from the filler (reproduced).
ponytail: full genre scan + python-side match + shuffle (a few ms at 7k
songs, single-user); push into SQL if propose ever feels slow.
"""
db = _state["meta_db"]
if db is None:
return []
rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall()
pool = [
{"filename": filename, "title": title or filename, "artist": artist or ""}
for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and filename not in exclude
]
random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit]
def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json"
@@ -580,9 +134,10 @@ def _validate_pack_dir(pack_dir: Path):
for name in (manifest.get("stingers") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"stinger file '{name}' invalid or missing")
for name in (manifest.get("intro") or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"intro file '{name}' invalid or missing")
for block in ("intro", "sfx"):
for name in (manifest.get(block) or {}).values():
if name and (not PACK_FILENAME_RE.fullmatch(name) or not (pack_dir / name).is_file()):
raise ValueError(f"{block} file '{name}' invalid or missing")
def _download_pack(venue_id, pack, progress):
@@ -643,13 +198,6 @@ def setup(app, context):
_state["venues_dir"] = (
Path(context["config_dir"]) / "plugin_uploads" / PLUGIN_ID / "venues")
_state["venues_dir"].mkdir(parents=True, exist_ok=True)
_state["passports_content"] = json.loads(
(plugin_dir / "passports.json").read_text(encoding="utf-8"))
# Persisted career state (commitment / opened passports / drill snapshot)
# lives under CONFIG_DIR/career/ — declared in settings.server_files so it
# rides the settings export/import bundle. Packs stay out (they're media).
_state["state_dir"] = Path(context["config_dir"]) / PLUGIN_ID
_state["state_dir"].mkdir(parents=True, exist_ok=True)
_state["meta_db"] = context.get("meta_db")
_state["log"] = context.get("log") or _state["log"]
for v in _state["content"]["venues"]:
@@ -671,7 +219,7 @@ def setup(app, context):
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
"download": dl,
})
return {
@@ -682,266 +230,13 @@ def setup(app, context):
"venues": venues,
}
@app.get(f"/api/plugins/{PLUGIN_ID}/passports")
def get_passports():
with _lock:
return _passports_view()
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/commit")
def commit_instrument(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
with _lock:
st = _career_state()
entry = st["instruments"].setdefault(inst, {})
# Idempotent: the wax seal is pressed once; re-commits keep the
# original date (only-gained-never-lost).
if not entry.get("committed_at"):
entry["committed_at"] = _now_iso()
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst,
"committed_at": entry["committed_at"]}
@app.post(f"/api/plugins/{PLUGIN_ID}/passports/open")
def open_passport(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
with _lock:
st = _career_state()
# Opening a passport implies the instrument commitment (permissive
# server, ceremony ordering is the UI's job).
st["instruments"].setdefault(inst, {}).setdefault(
"committed_at", _now_iso())
genres = st["passports"].setdefault(inst, {})
if gkey not in genres:
genres[gkey] = {"genre": genre, "opened_at": _now_iso()}
_save_json(_state_file(), st)
return {"ok": True, "instrument": inst, "passport": genres[gkey]}
@app.post(f"/api/plugins/{PLUGIN_ID}/drill-state")
def post_drill_state(body: dict = Body(...)):
# The relayed virtuoso.progress snapshot (career's screen.js listens to
# the virtuoso:progress bus event and forwards the localStorage doc).
# Only the fields the badge check reads are kept.
if not isinstance(body, dict) or not isinstance(body.get("byNode"), dict):
raise HTTPException(400, "Expected a progress snapshot with byNode.")
# Bound the INCOMING snapshot before the merge — the gained-only merge
# drops junk entries, which must not become a size-guard bypass.
if len(json.dumps(body["byNode"])) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
gold_in = body.get("goldImprov", {})
if not isinstance(gold_in, dict):
# A relay bug must be LOUD, not a silent 200 that drops gold.
raise HTTPException(400, "goldImprov must be an object keyed by style id.")
# Keep only plausible artifacts: a dict that names its verifier —
# an empty {} must not mint an evidence-free gold.
gold_in = {k: v for k, v in gold_in.items()
if isinstance(v, dict) and v.get("verifier")}
# Same pre-merge bound byNode gets: the gained-only merge dropping
# junk must not become a size-guard bypass (nor lock-held CPU burn).
if len(json.dumps(gold_in)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
with _lock:
_, existing, existing_gold = _drill_by_node()
snapshot = {"mode": body.get("mode"), "xp": body.get("xp"),
"byNode": _merge_drill_nodes(existing, body["byNode"]),
"goldImprov": _merge_gold(existing_gold, gold_in)}
if len(json.dumps(snapshot)) > DRILL_SNAPSHOT_MAX_BYTES:
raise HTTPException(413, "Snapshot too large.")
_save_json(_drill_file(), {"received_at": _now_iso(),
"snapshot": snapshot})
return {"ok": True}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/prepare")
def prepare_gig(body: dict = Body(...)):
"""Unpack every song of the set BEFORE the gig starts.
A feedpak is a zip: the first play of one pays for its extraction into
sloppak_cache. Inside a set that cost landed BETWEEN songs the player
finished a number and then sat waiting for the next one to unpack, mid-
gig. A set is a known list up front, so extract it all while the player
is still looking at the poster.
Idempotent and cheap on a warm cache: resolve_source_dir() returns the
already-unpacked dir without rewriting it. Best-effort per song one
bad feedpak must not block the set from starting (the play itself will
surface the error, exactly as it does outside a gig).
"""
raw = (body or {}).get("songs")
# A str is iterable: without the list check, "abc" would prepare three
# one-character "songs". Cap the count too — this endpoint unpacks zips,
# so an oversized list is real work, and a setlist is a handful of songs.
if not isinstance(raw, list):
return {"ok": True, "prepared": 0, "failed": []}
files = [f for f in raw if isinstance(f, str) and f.strip()][:MAX_GIG_SONGS]
if not files:
return {"ok": True, "prepared": 0, "failed": []}
# .get, not []: a host that doesn't hand us the resolvers (or has no
# library configured) must degrade to "extract lazily, as before" — this
# is an optimisation, and it is never allowed to be the thing that stops
# a gig from starting.
get_dlc = context.get("get_dlc_dir")
get_cache = context.get("get_sloppak_cache_dir")
dlc_root = get_dlc() if callable(get_dlc) else None
cache_root = get_cache() if callable(get_cache) else None
if dlc_root is None or cache_root is None:
return {"ok": False, "prepared": 0, "failed": files, "error": "no library"}
root = Path(dlc_root)
prepared, failed = 0, []
for fn in files:
# CONTAINMENT FIRST. resolve_source_dir() does a bare
# `dlc_root / filename` with no guard, so a crafted `../..` would
# walk straight out of the library. Every other filename-bound
# handler validates through _resolve_dlc_path; so does this one.
safe = _resolve_dlc_path(root, fn)
if safe is None:
_state["log"].warning("career: gig pre-extract rejected unsafe path %r", fn)
failed.append(fn)
continue
try:
sloppak.resolve_source_dir(fn, root, Path(cache_root))
prepared += 1
except Exception as exc: # noqa: BLE001 — one bad pak can't sink the set
_state["log"].warning("career: gig pre-extract failed for %s: %s", fn, exc)
failed.append(fn)
return {"ok": True, "prepared": prepared, "failed": failed}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs/propose")
def propose_gig(body: dict = Body(...)):
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
cfg = _gig_config()
try:
size = int((body or {}).get("size") or 4)
except (TypeError, ValueError):
raise HTTPException(400, "size must be a number.")
size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values())
req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]]
# The set: mostly songs you own, plus a couple of stakes songs near
# the bar; a young passport fills from unplayed genre songs so the
# first gig is how stubs start. random per call = free re-roll.
random.shuffle(qualifying)
rest.sort(key=lambda s: -s["best_accuracy"])
qtaken = max(1, size - cfg["stakes_songs"])
picks = qualifying[:qtaken]
for s in rest:
if len(picks) >= size:
break
picks.append(s)
# Surplus qualifying songs backfill a short set — a mature passport
# with no near-bar songs left must still fill the bill. Offset by how
# many QUALIFYING songs were taken, not len(picks): rest's stakes
# additions would otherwise skip eligible qualifying songs entirely.
for s in qualifying[qtaken:]:
if len(picks) >= size:
break
picks.append(s)
if len(picks) < size:
exclude = {s["filename"] for s in picks}
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks:
raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue()
return {
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or ""} for s in picks[:size]],
}
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
def log_gig(body: dict = Body(...)):
# Called by the runner ONLY when the set completed — an abandoned set
# never logs (no fail state; the gig you finished is the gig you
# played). Accuracies come from song_stats, freshly written by the
# set's own plays.
inst = str((body or {}).get("instrument") or "")
genre = _genre_display((body or {}).get("genre"))
gkey = genre.lower()
venue_id = str((body or {}).get("venue_id") or "")
songs = (body or {}).get("songs")
if inst not in (_state["passports_content"].get("instruments") or []):
raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.")
if venue_id and (not VENUE_ID_RE.fullmatch(venue_id) or _venue(venue_id) is None):
raise HTTPException(400, "Unknown venue.")
if (not isinstance(songs, list) or not songs or len(songs) > 8
or not all(isinstance(f, str) and f.strip() for f in songs)):
raise HTTPException(400, "songs must be 1-8 filenames.")
db = _state["meta_db"]
entries = []
accuracies = []
for filename in songs:
title = filename
accuracy = None
if db is not None:
# The NEWEST row is the set's own just-recorded play — a
# MAX(last_accuracy) across arrangements would happily log a
# stale higher score from another instrument's old session.
row = db.conn.execute(
"SELECT last_accuracy FROM song_stats WHERE filename = ? "
"ORDER BY last_played_at DESC LIMIT 1",
(filename,)).fetchone()
if row and row[0] is not None:
accuracy = round(float(row[0]), 4)
accuracies.append(accuracy)
trow = db.conn.execute(
"SELECT title FROM songs WHERE filename = ?", (filename,)).fetchone()
if trow and trow[0]:
title = trow[0]
entries.append({"filename": filename, "title": title, "accuracy": accuracy})
# Encore needs the WHOLE set scored at the bar — one scored song must
# not earn an encore for a set that was 4/5 unheard.
encore = (len(accuracies) == len(songs) and
sum(accuracies) / len(accuracies) >= _gig_config()["encore_accuracy"])
gig = {
"at": _now_iso(),
"venue_id": venue_id or None,
"instrument": inst,
"genre": genre,
"genre_key": gkey,
"songs": entries,
"encore": encore,
}
with _lock:
st = _career_state()
if not isinstance(st.get("gigs"), list):
st["gigs"] = []
st["gigs"].append(gig)
# ponytail: hard cap — nothing reads past the last 20 per
# passport; the state file must not grow (and export) forever.
st["gigs"] = st["gigs"][-500:]
_save_json(_state_file(), st)
return {"ok": True, "gig": gig}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
if not _pack_published(pack):
if not pack:
raise HTTPException(404, "No pack published for this venue yet.")
stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
+12 -34
View File
@@ -3,41 +3,19 @@
<h1 class="text-2xl font-bold text-white">Career</h1>
<div id="career-stars-summary" class="text-sm text-gray-400"></div>
</div>
<div class="career-tabs" role="tablist">
<button class="career-tab" data-career-tab="venues" role="tab" id="career-tab-btn-venues" aria-controls="career-tab-venues">Venues</button>
<button class="career-tab" data-career-tab="passports" role="tab" id="career-tab-btn-passports" aria-controls="career-tab-passports">Passports</button>
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-tab-venues" role="tabpanel" aria-labelledby="career-tab-btn-venues">
<p class="text-sm text-gray-400 mb-4">Earn stars by playing songs well — 60% accuracy is a star, 75% two, 85% three. Stars unlock bigger stages, and the crowd plays along with you.</p>
<div id="career-progress-wrap" class="mb-6">
<div class="career-bar-track">
<div id="career-progress-bar" class="career-bar-fill" style="width:0%"></div>
</div>
<div id="career-progress-label" class="text-xs text-gray-500 mt-1"></div>
</div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="career-tab-passports" class="hidden" role="tabpanel" aria-labelledby="career-tab-btn-passports">
<p class="text-sm text-gray-400 mb-4">Commit to an instrument, pick a genre, and stamp your way to its badge — five ★★ songs mint a Bronze. Your passport wall is who you are as a musician.</p>
<div id="pp-instruments" class="pp-instruments"></div>
<div id="pp-closest" class="mt-4"></div>
<div id="pp-shelf-wrap" class="mt-5">
<div id="pp-shelf" class="pp-shelf"></div>
</div>
<div id="pp-rack-wrap" class="mt-8">
<h2 class="text-lg font-semibold text-white mb-1">Explore next</h2>
<p class="text-xs text-gray-500 mb-3">More genres, whenever you want them — your wall is complete as it is.</p>
<div id="pp-rack" class="pp-rack"></div>
<div id="career-venues" class="career-venues"></div>
<div class="mt-8">
<div class="flex items-end justify-between flex-wrap gap-2 mb-2">
<h2 class="text-lg font-semibold text-white">Your star collection</h2>
<div id="career-star-summary" class="text-xs text-gray-400"></div>
</div>
<div id="career-star-list" class="career-star-list"></div>
</div>
</div>
<div id="pp-overlay" class="pp-overlay hidden"></div>
+1 -1311
View File
File diff suppressed because it is too large Load Diff
-11
View File
@@ -1,14 +1,3 @@
<!-- Career plugin — data panel. Exists so the passport/drill state declared in
settings.server_files has a visible home in Settings; nothing to configure. -->
<div class="text-sm text-gray-300 space-y-2">
<p><strong>Career</strong> computes stars and genre badges from your play
stats — they are never stored, so there is nothing to back up or reset.</p>
<p class="text-gray-400">What <em>is</em> saved server-side: your instrument
commitments, opened genre passports, and the practice-drill snapshot the
Virtuoso plugin reports. These ride along in
<em>Settings → Export</em> automatically.</p>
</div>
<hr class="border-gray-800 my-3">
<div class="space-y-3 text-sm">
<label class="flex items-center justify-between gap-4">
<span>
-249
View File
@@ -1,249 +0,0 @@
// Passport UI pure-logic tests: load screen.js in a bare vm window and
// exercise the __careerPassportTest seam (no DOM beyond stubs, no network).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load(seed) {
const store = Object.assign({}, seed);
const window = {
console,
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
},
document: {
readyState: 'complete',
getElementById: () => null,
querySelectorAll: () => [],
addEventListener: () => {},
},
notifications: [],
};
window.window = window;
window.globalThis = window;
window.fbNotify = { show: (n) => window.notifications.push(n) };
const context = vm.createContext(window);
// `document` and `localStorage` resolve as bare names inside the IIFE.
context.document = window.document;
context.localStorage = window.localStorage;
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'career/screen.js' });
return window;
}
test('module loads (and boots) in a bare vm window', () => {
const w = load();
assert.equal(typeof w.__careerPassportTest.ppKey, 'function');
});
test('ppKey normalizes case and whitespace', () => {
const { ppKey } = load().__careerPassportTest;
assert.equal(ppKey(' Blues Rock '), 'blues rock');
assert.equal(ppKey('FUNK'), 'funk');
assert.equal(ppKey(''), '');
assert.equal(ppKey(null), '');
});
test('ppJitter is deterministic and bounded', () => {
const { ppJitter } = load().__careerPassportTest;
assert.equal(ppJitter('blues', 8), ppJitter('blues', 8));
for (const seed of ['blues', 'funk', 'jazz', 'metal']) {
const j = ppJitter(seed, 8);
assert.ok(j >= -8 && j <= 8, `${seed}${j}`);
}
assert.notEqual(ppJitter('blues', 8), ppJitter('funk', 8));
});
test('detectNewBadges notifies once per badge, never after it is seen', () => {
const w = load();
const t = w.__careerPassportTest;
const view = {
instruments: {
guitar: {
passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress' },
],
},
},
};
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].message, /Blues/);
// Same view again in the same session: no duplicate notification.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Seen (slam played) → a fresh session stays quiet too.
t.markBadgeSeen('guitar', 'blues');
// JSON-compare: vm objects carry a foreign Object prototype.
assert.equal(JSON.stringify(t.seenBadges()), '{"guitar/blues":1}');
// Fresh session (new vm, empty notify cache) with the badge already seen:
// detection must stay silent.
const w2 = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a new badge triggers the crowd celebrate() exactly once', () => {
const w = load();
let calls = 0;
w.v3VenueCrowd = { celebrate: () => { calls += 1; } };
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
// Same session, same view: no re-celebration.
w.__careerPassportTest.detectNewBadges(view);
assert.equal(calls, 1);
});
test('ceremony degrades when the crowd layer is absent or throws', () => {
const w = load();
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
// No v3VenueCrowd at all (already exercised elsewhere, explicit here).
w.__careerPassportTest.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// celebrate() throwing must not break detection.
const w2 = load();
w2.v3VenueCrowd = { celebrate: () => { throw new Error('no pack'); } };
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 1);
});
test('seenBadges tolerates corrupt stored values', () => {
for (const bad of ['null', '[1,2]', '"x"', '{{{']) {
const w = load({ 'feedBack-career-badges-seen': bad });
const t = w.__careerPassportTest;
assert.equal(JSON.stringify(t.seenBadges()), '{}', `stored ${bad}`);
// And detection still works on top of the recovered empty state.
t.detectNewBadges({ instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } });
assert.equal(w.notifications.length, 1, `stored ${bad}`);
}
});
test('fmtHours: silent under a minute, minutes under an hour, tenths after', () => {
const { fmtHours } = load().__careerPassportTest;
assert.equal(fmtHours(0), '');
assert.equal(fmtHours(59), '');
assert.equal(fmtHours(60), '1 min');
assert.equal(fmtHours(1800), '30 min');
assert.equal(fmtHours(3600), '1 h');
assert.equal(fmtHours(51120), '14.2 h');
assert.equal(fmtHours(null), '');
assert.equal(fmtHours('junk'), '');
});
test('ppFillFraction: song progress toward the bar, in-progress only', () => {
const { ppFillFraction } = load().__careerPassportTest;
const p = (badge, q, songs) => ({ badge, qualifying_count: q, requirement: { songs } });
assert.equal(ppFillFraction(p('in_progress', 3, 5)), 0.6);
assert.equal(ppFillFraction(p('in_progress', 0, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 7, 5)), 1); // clamped
assert.equal(ppFillFraction(p('earned', 5, 5)), 0); // no fill once earned
assert.equal(ppFillFraction(p('shown_not_judged', 3, 5)), 0);
assert.equal(ppFillFraction(p('in_progress', 3, 0)), 0); // no bar → no fill
assert.equal(ppFillFraction(null), 0);
});
test('careerTotals / wall + dash card stay absent without commitment', () => {
const w = load();
const t = w.__careerPassportTest;
// No _pp at all → null; committed-less view → null (absent-not-empty).
assert.equal(t.careerTotals(), null);
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: null, passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed but zero passports opened: still absent (no zero-wall).
t.setView({ config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 'x', passports: [] } } });
assert.equal(t.careerTotals(), null);
// Committed with an earned badge + hours → totals aggregate.
t.setView({ config: { instruments: ['guitar', 'bass'] },
instruments: {
guitar: { committed_at: 'x', passports: [
{ badge: 'earned', seconds_total: 3600, genre: 'Blues', genre_key: 'blues' },
{ badge: 'in_progress', seconds_total: 120, genre: 'Funk', genre_key: 'funk',
qualifying_count: 4, requirement: { songs: 5, min_stars: 2 } }] },
bass: { committed_at: null, passports: [] },
} });
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.seconds, 3720);
assert.equal(totals.walls.length, 1);
});
test('gig runner lifecycle: advance on ended, abandon on dead-queue stop', () => {
const w = load();
const t = w.__careerPassportTest;
let remaining = 1;
w.feedBack = { playQueue: { remaining: () => remaining, active: () => remaining > 0 } };
t.setGigRun({
songs: [{ filename: 'a', title: 'A' }, { filename: 'b', title: 'B' }],
venue_id: null, genre: 'Soul', genre_key: 'soul', instrument: 'guitar', idx: 0,
});
// First song ends, one remains → the strip advances, no completion.
t.onGigSongEnded();
assert.equal(t.getGigRun().idx, 1);
// Stop while the queue is still active (end-of-song teardown) → run survives.
t.onGigSongStop();
assert.notEqual(t.getGigRun(), null);
// User quits: queue cleared → stop with a dead queue abandons (no log).
remaining = 0;
t.onGigSongStop();
assert.equal(t.getGigRun(), null);
});
test('a gold upgrade notifies even when the bronze moment was already seen', () => {
// Bronze seen under the legacy un-suffixed id; the badge then turns gold.
const w = load({ 'feedBack-career-badges-seen': '{"guitar/blues":1}' });
const t = w.__careerPassportTest;
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold' }] } } };
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
assert.match(w.notifications[0].title, /Gold/);
// Same session: no duplicate.
t.detectNewBadges(view);
assert.equal(w.notifications.length, 1);
// Gold slam seen → fresh session stays silent.
t.markBadgeSeen('guitar', 'blues', 'gold');
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(t.seenBadges()) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('a gold slam marks the bronze moment seen too — never both ceremonies', () => {
const w = load();
const t = w.__careerPassportTest;
t.markBadgeSeen('guitar', 'blues', 'gold');
const seen = JSON.parse(JSON.stringify(t.seenBadges()));
assert.equal(seen['guitar/blues@gold'], 1);
assert.equal(seen['guitar/blues'], 1);
// A later view where the badge reads 'earned' (e.g. gold state lost
// server-side) must not replay the bronze ceremony.
const view = { instruments: { guitar: { passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'earned' }] } } };
const w2 = load({ 'feedBack-career-badges-seen': JSON.stringify(seen) });
w2.__careerPassportTest.detectNewBadges(view);
assert.equal(w2.notifications.length, 0);
});
test('careerTotals counts gold badges on the wall', () => {
const t = load().__careerPassportTest;
t.setView({
config: { instruments: ['guitar'] },
instruments: { guitar: { committed_at: 1, gig_count: 0, passports: [
{ genre_key: 'blues', genre: 'Blues', badge: 'gold', seconds_total: 60 },
{ genre_key: 'funk', genre: 'Funk', badge: 'in_progress', seconds_total: 0 },
] } },
});
const totals = t.careerTotals();
assert.equal(totals.badges, 1);
assert.equal(totals.walls[0].earned[0].badge, 'gold');
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
{
"venue": "arena",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "arena-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +0,0 @@
{
"venue": "club",
"version": 1,
"loops": {"bored": "bored.mp4", "neutral": "neutral.mp4", "engaged": "engaged.mp4", "ecstatic": "ecstatic.mp4"},
"stingers": {"clap": "clap.mp4", "cheer": "cheer.mp4"},
"intro": {"video": "intro.mp4", "audio": "club-ambience.mp3"},
"sfx": {"up": "sfx-up.mp3", "down": "sfx-down.mp3"}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -10
View File
@@ -17,22 +17,14 @@
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
"star_threshold": 50,
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
}
"pack": null
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
"star_threshold": 150,
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
"bytes": 351284599
}
"pack": null
}
]
}
+6 -162
View File
@@ -878,146 +878,6 @@ function createFolderSurface(cfg) {
var _dragRafId = null;
var _DRAG_THRESH = 5, _DRAG_ZONE = 150, _DRAG_SPEED = 50;
// ── Windowed song lists ─────────────────────────────────────────────
// A song list used to render EVERY song it held. On a flat 50,944-song
// library that is one <div> with 50,938 children and ~1.3 MILLION DOM nodes
// (~25 per row) — ~4.2 GB of renderer RSS, for a screen the user may not
// even be looking at. It also poisons unrelated code: any
// `document.querySelector` miss anywhere in the app must walk that whole
// tree, which is how song_preview's per-frame menu check ended up eating
// ~50% of the renderer and dropping the app to 2.7 fps (feedBack#965).
//
// So render only what is on screen. Rows are uniform height (and grid cards
// uniform size), so the window is pure arithmetic — no per-row observers.
// Off-window rows are represented by padding on the list itself rather than
// spacer elements: a spacer <div> would become a grid ITEM in grid view and
// shift the columns, whereas padding works identically for both layouts.
var VIRTUAL_MIN = 200; // below this, render everything — no behaviour change
var VIRTUAL_BUFFER = 6; // rows kept rendered above/below the viewport
var _virtualCleanups = [];
var _virtualLists = []; // repaint fns, one per live windowed list
// Which slice of the list is on screen. Pure arithmetic — kept separate from
// the DOM so it can be tested directly (see tests/virtual_list.test.js).
//
// top : list's offset relative to the scroller viewport's top. NEGATIVE
// once the user has scrolled the list's start above the fold.
// rows : total ROWS (grid packs `perRow` songs into one row; list view is 1)
//
// Returns the song index range [start, end) to render, plus how many ROWS of
// padding stand in for the songs above and below it.
function _visibleWindow(top, viewportH, itemH, perRow, rows, total) {
if (!(itemH > 0) || !(rows > 0)) return { start: 0, end: total, padRowsTop: 0, padRowsBottom: 0 };
var firstRow = Math.max(0, Math.floor(-top / itemH) - VIRTUAL_BUFFER);
var lastRow = Math.min(rows, Math.ceil((-top + viewportH) / itemH) + VIRTUAL_BUFFER);
// Scrolled entirely past the list (either direction): keep one row alive
// rather than emptying it, so the padding math stays anchored.
if (lastRow <= firstRow) {
firstRow = Math.min(firstRow, rows - 1);
lastRow = firstRow + 1;
}
return {
start: firstRow * perRow,
end: Math.min(total, lastRow * perRow),
padRowsTop: firstRow,
padRowsBottom: Math.max(0, rows - lastRow),
};
}
function _clearVirtualLists() {
_virtualCleanups.forEach(function (fn) { try { fn(); } catch (_) {} });
_virtualCleanups = [];
_virtualLists = [];
}
// Fill `list` with `songs`, windowed when the list is big enough to matter.
// `make(song)` builds one row/card.
function _fillSongList(list, songs, make) {
var sorted = _sortSongs(songs);
if (sorted.length <= VIRTUAL_MIN) {
sorted.forEach(function (s) { list.appendChild(make(s)); });
return;
}
var scroller = _getScrollEl();
var basePadTop = parseFloat(window.getComputedStyle(list).paddingTop) || 0;
var basePadBot = parseFloat(window.getComputedStyle(list).paddingBottom) || 0;
// Measure one real row once — no hardcoded row height to drift out of
// sync with the CSS. (The list is shown before it is populated, so this
// measures a laid-out row, not a zero-height one.)
var probe = make(sorted[0]);
probe.style.visibility = 'hidden';
list.appendChild(probe);
var probeRect = probe.getBoundingClientRect();
var rowH = probeRect.height || 44;
var cardW = probeRect.width || 150;
list.removeChild(probe);
var GRID_GAP = 12; // matches the grid's `gap:12px`
var raf = 0, lastStart = -1, lastEnd = -1;
// Recomputed on EVERY paint, not captured once: a window resize changes
// the grid's column count, and therefore the row count and the height of
// the padding standing in for off-window rows. paint() runs on resize, so
// stale metrics would slice the wrong songs and mis-size the list.
function metrics() {
var perRow = 1, itemH = rowH;
if (_view === 'grid') {
perRow = Math.max(1, Math.floor((list.clientWidth + GRID_GAP) / (cardW + GRID_GAP)));
itemH = rowH + GRID_GAP;
}
return { perRow: perRow, itemH: itemH, rows: Math.ceil(sorted.length / perRow) };
}
function paint() {
raf = 0;
// Collapsed (display:none) or detached: nothing to paint, and don't
// pay for layout on every scroll tick of a section nobody can see.
// Forget the last window so re-showing repaints from scratch against
// the new position rather than short-circuiting on a stale memo.
if (!list.isConnected || list.offsetParent === null) {
lastStart = -1; lastEnd = -1;
return;
}
var m = metrics();
// Where the list sits relative to the scroller's viewport.
var top = list.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
var vh = scroller.clientHeight || window.innerHeight;
var w = _visibleWindow(top, vh, m.itemH, m.perRow, m.rows, sorted.length);
if (w.start === lastStart && w.end === lastEnd) return; // nothing moved
lastStart = w.start; lastEnd = w.end;
var frag = document.createDocumentFragment();
for (var i = w.start; i < w.end; i++) frag.appendChild(make(sorted[i]));
list.textContent = '';
list.style.paddingTop = (basePadTop + w.padRowsTop * m.itemH) + 'px';
list.style.paddingBottom = (basePadBot + w.padRowsBottom * m.itemH) + 'px';
list.appendChild(frag);
}
function schedule() { if (!raf) raf = window.requestAnimationFrame(paint); }
scroller.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
// Expanding or collapsing ANY section moves every list below it. Those
// lists' windows are computed from their position, so they must repaint
// too — otherwise they keep the window from their old position and show
// blank padding where songs should be until the user happens to scroll.
_virtualLists.push(schedule);
_virtualCleanups.push(function () {
scroller.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
if (raf) window.cancelAnimationFrame(raf);
});
paint();
}
// Re-window every live list — call after anything that can move them
// vertically (a folder expanding/collapsing, a section being shown).
function _repaintVirtualLists() {
_virtualLists.forEach(function (fn) { try { fn(); } catch (_) {} });
}
function _getScrollEl() {
var el = _treeEl();
while (el && el !== document.documentElement) {
@@ -1299,8 +1159,8 @@ function createFolderSurface(cfg) {
var _listPopulated = open;
function _populateList() {
_fillSongList(list, folder.songs, function (s) {
return _view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path);
_sortSongs(folder.songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, folder.path) : _songRow(s, folder.path));
});
(folder.children || []).forEach(function (child) {
childrenWrap.appendChild(_folderSection(child, depth + 1));
@@ -1335,18 +1195,12 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
var nowOpen = content.style.display === 'none';
// Show BEFORE populating: a windowed list measures a real row and the
// scroller viewport, and both are zero while display:none.
content.style.display = nowOpen ? '' : 'none';
if (nowOpen && !_listPopulated) { _populateList(); _listPopulated = true; }
content.style.display = nowOpen ? '' : 'none';
chev.style.transform = nowOpen ? 'rotate(90deg)' : '';
if (nowOpen) _openFolders.add(folder.path);
else _openFolders.delete(folder.path);
_storeJSON('open', [..._openFolders]);
// This toggle moved everything below it — re-window the other lists,
// and re-window THIS one if it was already populated (its saved
// window was computed at its old position).
_repaintVirtualLists();
});
wrap.appendChild(hdr); wrap.appendChild(content);
@@ -1391,8 +1245,8 @@ function createFolderSurface(cfg) {
}
var _populated = _unsortedOpen;
function _populate() {
_fillSongList(list, songs, function (s) {
return _view === 'grid' ? _songCard(s, '') : _songRow(s, '');
_sortSongs(songs).forEach(function (s) {
list.appendChild(_view === 'grid' ? _songCard(s, '') : _songRow(s, ''));
});
}
if (_unsortedOpen) { _populate(); } else { list.style.display = 'none'; }
@@ -1401,12 +1255,10 @@ function createFolderSurface(cfg) {
hdr.addEventListener('click', function () {
if (_query()) return;
_unsortedOpen = list.style.display === 'none';
// Show BEFORE populating — see the folder toggle above.
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
if (_unsortedOpen && !_populated) { _populate(); _populated = true; }
list.style.display = _unsortedOpen ? (_view === 'grid' ? 'grid' : '') : 'none';
chev.style.transform = _unsortedOpen ? 'rotate(90deg)' : '';
_store(cfg.unsortedKey, String(_unsortedOpen));
_repaintVirtualLists(); // this toggle moved every list below it
});
wrap.appendChild(hdr); wrap.appendChild(list);
@@ -1488,10 +1340,6 @@ function createFolderSurface(cfg) {
// ── Render ──────────────────────────────────────────────────────────
function _render() {
_hoveredFolder = null; // DOM is rebuilt; discard any stale reference
// Drop the scroll listeners of the previous render's windowed lists —
// their `list` nodes are about to be detached, and a surviving listener
// would keep painting into orphaned DOM (and leak on every re-render).
_clearVirtualLists();
var treeEl = _treeEl();
if (!treeEl) return;
var data = _filtered();
@@ -1603,7 +1451,6 @@ function createFolderSurface(cfg) {
// ── Unload (lib surface) ────────────────────────────────────────────
function _unload() {
_clearVirtualLists(); // don't leave scroll listeners behind on teardown
if (!cfg.searchInputId) return;
var el = _el(cfg.searchInputId);
if (el) el.style.maxWidth = '';
@@ -1707,8 +1554,6 @@ function createFolderSurface(cfg) {
init: _init,
onScreenChanged: _onScreenChanged,
render: _render,
// Pure window arithmetic, exposed for tests (no DOM needed).
__test: { visibleWindow: _visibleWindow, VIRTUAL_MIN: VIRTUAL_MIN, VIRTUAL_BUFFER: VIRTUAL_BUFFER },
};
}
@@ -1811,7 +1656,6 @@ if (!window.__folderLibraryLib) {
window.folderLibrary = {
load: function (force) { return _lib.load(force); },
unload: function () { _lib.unload(); },
__test: _lib.__test,
};
// Auto-load if folder view was already active when this script was injected.
@@ -1,165 +0,0 @@
// Windowed song lists (feedBack#965).
//
// A song list used to render EVERY song. On a flat 50,944-song library that is
// one div with 50,938 children and ~1.3 MILLION DOM nodes (~25 per row) —
// ~4.2 GB of renderer RSS, for a screen the user may not even be looking at. It
// also poisoned unrelated code: any `document.querySelector` miss anywhere in
// the app had to walk that whole tree.
//
// _visibleWindow is the arithmetic that decides which slice is on screen. If it
// is wrong the list silently shows the wrong songs, or scrolls to the wrong
// place, so it is tested directly — the DOM glue around it is not the risky bit.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
document: {
readyState: 'complete',
addEventListener() {},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
createElement() { return { style: {}, classList: { add() {}, remove() {}, contains() { return false; } }, addEventListener() {}, appendChild() {} }; },
},
addEventListener() {},
localStorage: { getItem() { return null; }, setItem() {} },
performance: { now: () => 0 },
setInterval() { return 0; },
clearInterval() {},
requestAnimationFrame() { return 0; },
cancelAnimationFrame() {},
getComputedStyle() { return { overflowY: 'visible', paddingTop: '0px', paddingBottom: '0px' }; },
innerHeight: 800,
};
window.window = window;
window.globalThis = window;
const ctx = vm.createContext(window);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'), ctx, { filename: 'screen.js' });
assert.ok(window.folderLibrary && window.folderLibrary.__test, 'plugin must expose __test');
return window.folderLibrary.__test;
}
const { visibleWindow, VIRTUAL_BUFFER, VIRTUAL_MIN } = load();
// A flat 50k library in list view: 1 song per row, 44px rows, 800px viewport.
const ROW = 44;
const VH = 800;
const TOTAL = 50938;
test('the whole point: a 50k list renders a bounded window, not 50k rows', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
const rendered = w.end - w.start;
assert.ok(rendered < 60, `expected a small window, got ${rendered} rows`);
// ~18 rows fit in 800px, plus buffer above and below.
assert.ok(rendered >= Math.ceil(VH / ROW), 'must at least fill the viewport');
});
test('at the top: starts at 0, all remaining rows are bottom padding', () => {
const w = visibleWindow(0, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, TOTAL - w.end);
});
test('scrolled into the middle: window tracks the scroll, padding adds up', () => {
const scrolled = 10000 * ROW; // row 10,000 at the fold
const w = visibleWindow(-scrolled, VH, ROW, 1, TOTAL, TOTAL);
assert.equal(w.start, (10000 - VIRTUAL_BUFFER) * 1);
assert.ok(w.end > w.start);
// The invariant that keeps the scrollbar honest: padding rows + rendered
// rows must account for every song, or the list changes height as you scroll.
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('at the very bottom: no bottom padding, end lands on the last song', () => {
const rows = TOTAL;
const scrolled = rows * ROW - VH; // scrolled to the end
const w = visibleWindow(-scrolled, VH, ROW, 1, rows, TOTAL);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsBottom, 0);
assert.equal(w.padRowsTop + (w.end - w.start), TOTAL);
});
test('grid view: perRow songs collapse into one row', () => {
const perRow = 6;
const rows = Math.ceil(TOTAL / perRow);
const w = visibleWindow(0, VH, 190, perRow, rows, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.start % perRow, 0, 'a window must start on a row boundary');
assert.ok(w.end <= TOTAL);
assert.ok((w.end - w.start) < 200, 'grid window must stay bounded');
});
test('scrolled far past the list: keeps one row, never a negative window', () => {
const w = visibleWindow(-99999999, VH, ROW, 1, TOTAL, TOTAL);
assert.ok(w.end > w.start, 'window must never invert');
assert.ok(w.start >= 0 && w.end <= TOTAL);
assert.equal(w.padRowsTop + (w.end - w.start) + w.padRowsBottom, TOTAL);
});
test('list not yet scrolled to (below the fold): still yields a valid window', () => {
const w = visibleWindow(5000, VH, ROW, 1, TOTAL, TOTAL); // list starts below viewport
assert.equal(w.start, 0);
assert.ok(w.end > 0);
});
test('degenerate inputs fall back to rendering everything, never to a broken window', () => {
// Measured height of 0 (e.g. list still display:none) must not divide by zero
// and must not silently render an empty list.
const w = visibleWindow(0, VH, 0, 1, TOTAL, TOTAL);
assert.equal(w.start, 0);
assert.equal(w.end, TOTAL);
assert.equal(w.padRowsTop, 0);
assert.equal(w.padRowsBottom, 0);
});
test('small lists are below the virtualization threshold', () => {
assert.ok(VIRTUAL_MIN >= 100, 'threshold must be high enough that normal folders are untouched');
});
// ── the grid must be re-measured when the window resizes (CodeRabbit, #967) ──
// perRow and rows were originally captured once at fill time. paint() also runs
// on resize, so a narrower/wider window changed the column count while the
// window maths still used the OLD one — slicing the wrong songs and mis-sizing
// the padding. These pin that the geometry is a function of perRow, so a stale
// perRow cannot silently survive.
test('resizing the grid to fewer columns re-windows against the new row count', () => {
const total = 10000;
const wide = visibleWindow(0, VH, 190, 6, Math.ceil(total / 6), total);
const narrow = visibleWindow(0, VH, 190, 3, Math.ceil(total / 3), total);
// Same viewport, half the columns -> about half as many songs on screen.
assert.ok(narrow.end < wide.end, 'fewer columns must render fewer songs per screen');
// ...and the total must still add up, or the scrollbar lies after a resize.
for (const [w, perRow] of [[wide, 6], [narrow, 3]]) {
const rows = Math.ceil(total / perRow);
assert.equal(w.padRowsTop + Math.ceil((w.end - w.start) / perRow) + w.padRowsBottom, rows,
`rows must account for every song at perRow=${perRow}`);
}
});
test('a stale perRow would break the total-height invariant (the bug)', () => {
const total = 10000;
// Grid re-laid out to 3 columns, but windowed with the OLD perRow of 6:
// the row count no longer matches the geometry, and the padding is wrong.
const stalePerRow = 6, actualRows = Math.ceil(total / 3);
const bad = visibleWindow(0, VH, 190, stalePerRow, actualRows, total);
const accounted = bad.padRowsTop + Math.ceil((bad.end - bad.start) / 3) + bad.padRowsBottom;
assert.notEqual(accounted, actualRows,
'this asserts the FAILURE mode: mismatched perRow/rows must not silently look correct — ' +
'metrics() recomputes both together on every paint so this cannot happen in practice');
});
test('scrolled grid window always starts on a row boundary', () => {
const total = 10000, perRow = 4;
const rows = Math.ceil(total / perRow);
const w = visibleWindow(-5000, VH, 190, perRow, rows, total);
assert.equal(w.start % perRow, 0, 'a partial row would shift every card in the grid');
});
+1 -1
View File
@@ -155,7 +155,7 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
- `getNoteState(note, chartTime)` — feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'``mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'``mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
`tuning` and `capo` feed only the nut's open-string pitch labels. They prefer the bundle's effective values; `songInfo` remains the original metadata fallback. Note placement never reads them.
`tuning` and `capo` aren't consumed by this plugin.
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.34.1",
"version": "3.31.5",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+15829 -16677
View File
File diff suppressed because it is too large Load Diff
@@ -1,543 +0,0 @@
// Player-chrome background control.
//
// The control mounts a Background picker (style / Reactive / Intensity) into
// the player's Plugin Controls popover so the background can be changed
// mid-song. Two things about it are easy to get wrong and invisible when they
// are:
//
// * It is REFCOUNTED. Several renderer instances can be live at once (a
// splitscreen host creates one per panel), but the settings it writes are
// global — N controls would be N ways to set one value, and a leaked
// refcount pins a dead control in the UI. The multi-instance behaviour is
// exercised here with stubbed instances; it is NOT verified against a real
// splitscreen session, whose visualizer does not currently work.
// * It GREYS OUT controls the active style ignores. Not every background
// style reads `intensity`, and none of them read audio bands under
// Butterchurn, so a live-looking knob that does nothing is a real bug.
//
// screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
// self-contained `_pc*` block is sliced out of the real source and evaluated
// with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
// _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
// or rename the block and this fails loudly rather than testing nothing.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const START = ' const _PC_LABELS = {';
const END_CRLF = ' /* ======================================================================\r\n * Factory';
const END_LF = ' /* ======================================================================\n * Factory';
// What each style is expected to consume, derived by reading the BG_STYLES
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
// table, which would only assert that the table equals itself.
// intensity: true => the style's build() reads settings.intensity
// reactive: true => the style's update() dereferences its `bands` argument
// 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
// owns its controller and drives its own audio tap + canvas opacity (only
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
const EXPECTED_USES = {
off: { intensity: false, reactive: false },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false },
video: { intensity: false, reactive: false },
butterchurn: { intensity: false, reactive: false },
};
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video'];
// Minimal DOM: only what the control touches.
function makeDom() {
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.parentNode = null;
this.listeners = {};
this.style = { cssText: '' };
this.disabled = false;
this._on = false;
}
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
removeChild(c) {
const i = this.children.indexOf(c);
if (i >= 0) this.children.splice(i, 1);
c.parentNode = null;
return c;
}
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
setAttribute(k, v) { this[k] = v; }
removeAttribute(k) { delete this[k]; }
get isConnected() {
let n = this;
while (n.parentNode) n = n.parentNode;
return n === root;
}
querySelector(sel) {
const m = /^option\[value="(.+)"\]$/.exec(sel);
const want = m ? m[1] : null;
const walk = (n) => {
for (const c of n.children) {
if (want != null && c.tagName === 'OPTION' && c.value === want) return c;
const r = walk(c);
if (r) return r;
}
return null;
};
return walk(this);
}
fire(type) { (this.listeners[type] || []).forEach((fn) => fn()); }
}
const root = new El('root');
const slot = new El('div');
root.appendChild(slot);
return { El, root, slot };
}
function load({ store: initialStore } = {}) {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const start = src.indexOf(START);
assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
let end = src.indexOf(END_CRLF);
if (end === -1) end = src.indexOf(END_LF);
assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
assert.ok(end > start, 'slice markers found out of order in screen.js');
const block = src.slice(start, end);
const dom = makeDom();
const store = Object.assign({
style: 'particles',
reactive: true,
intensity: 0.5,
customImageDataUrl: '',
customVideoName: '',
}, initialStore);
const bus = {};
const listeners = new Set();
const emit = (key) => { for (const fn of listeners) fn(key); };
const writes = [];
const timers = [];
const sandbox = {
console,
BG_STYLE_IDS,
// Module-scope in screen.js; the _pc* block reads it to resolve the
// effective style under the Venue override. Tests flip it via
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
_venueSceneOverride: false,
_bgReadSetting: (_panelKey, key) => store[key],
_bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn),
setTimeout: (fn) => { timers.push(fn); return timers.length; },
clearTimeout: () => {},
document: {
createElement: (t) => new dom.El(t),
// The Settings-panel mirror looks these up; absent here so it no-ops.
getElementById: () => null,
},
window: {
feedBack: {
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
ui: { playerControlSlot: () => dom.slot },
// The real bus is an EventTarget wrapper exposing on/off. Modelled
// here so the screen:changed subscription — and its removal — are
// observable.
on: (ev, fn) => { (bus[ev] || (bus[ev] = [])).push(fn); },
off: (ev, fn) => {
const l = bus[ev];
if (!l) return;
const i = l.indexOf(fn);
if (i >= 0) l.splice(i, 1);
},
},
h3dBgSetStyle: (v) => { writes.push(['style', v]); store.style = v; emit('style'); },
h3dBgSetReactive: (v) => { writes.push(['reactive', v]); store.reactive = v; emit('reactive'); },
h3dBgSetIntensity: (v) => { writes.push(['intensity', v]); store.intensity = v; emit('intensity'); },
},
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(
block
+ '\n({ _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } })',
sandbox,
);
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
const screenHooks = () => (bus['screen:changed'] || []).length;
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
}
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
// against a localStorage stub. The main suite stubs both helpers identically,
// so it can't tell the #2 refactor from a no-op; this one proves the actual
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
// override that _bgReadSetting(panelKey, ...) still honours.
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
const block = src.slice(rgStart, rgEnd);
const storage = new Map();
const sandbox = {
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
_bgMemFallback: Object.create(null),
BG_DEFAULTS: { style: 'particles' },
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
storage.set('h3d_bg_style', 'lights'); // global
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
// The renderer, reading with a panel key, honours the per-panel override...
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
// ...but the shared control's global read must NOT see it - this is the
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
// 'h3d_bg_null_style' never existing).
assert.equal(api._bgReadGlobal('style'), 'lights');
// In-memory staged value wins over the persisted global (matches
// _bgReadSetting's precedence).
api._bgMemFallback.style = 'aurora';
assert.equal(api._bgReadGlobal('style'), 'aurora');
delete api._bgMemFallback.style;
// Nothing stored -> BG_DEFAULTS.
assert.equal(api._bgReadGlobal('style'), 'lights');
storage.delete('h3d_bg_style');
assert.equal(api._bgReadGlobal('style'), 'particles');
});
test('mounts one control into the player-control slot', () => {
const { api, dom } = load();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1);
assert.ok(api.sel, 'style dropdown was not created');
assert.equal(api.sel.children.length, BG_STYLE_IDS.length, 'one option per style');
});
test('multiple renderer instances share a single control', () => {
const { api, dom } = load();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1, 'four instances must not mount four controls');
assert.equal(api.refs, 4);
api._pcRelease();
api._pcRelease();
api._pcRelease();
assert.equal(dom.slot.children.length, 1, 'still held by the last instance');
api._pcRelease();
assert.equal(dom.slot.children.length, 0, 'last release must unmount');
assert.equal(api.el, null);
});
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
const ctl = load();
// Cold load: on a fresh page the renderer can init before the event bus is
// wired AND before the rail popover exists. Simulate both being absent.
const savedOn = ctl.sandbox.window.feedBack.on;
const savedUi = ctl.sandbox.window.feedBack.ui;
delete ctl.sandbox.window.feedBack.on;
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
// Bus + slot come online; the retry tick must bind the hook, not only mount.
ctl.sandbox.window.feedBack.on = savedOn;
ctl.sandbox.window.feedBack.ui = savedUi;
ctl.timers.shift()(); // run one retry tick
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
assert.ok(ctl.api.el, 'and it should have mounted too');
ctl.api._pcRelease();
});
test('the last release unbinds the screen:changed hook', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 'acquire should subscribe once');
ctl.api._pcAcquire();
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 1, 'a partial release must keep the hook');
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 0, 'the hook outlived the control');
// And re-acquiring must re-subscribe exactly once, not zero times (the
// bind is guarded on _pcScreenHook, so failing to null it would leave the
// control permanently deaf to chrome rebuilds).
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 're-acquire did not re-subscribe');
ctl.api._pcRelease();
});
test('teardown unsubscribes from the settings bus', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.listenerCount(), 1);
ctl.api._pcRelease();
assert.equal(ctl.listenerCount(), 0, 'listener leaked after unmount');
});
test('tracks changes made from the Settings page', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'lights';
emit('style');
assert.equal(api.sel.value, 'lights');
});
test('custom media options stay disabled until something is uploaded', () => {
const { api, store, emit } = load();
api._pcAcquire();
assert.equal(api.sel.querySelector('option[value="image"]').disabled, true);
store.customImageDataUrl = 'data:image/png;base64,AAAA';
emit('customImageDataUrl');
assert.equal(api.sel.querySelector('option[value="image"]').disabled, false);
assert.equal(api.sel.querySelector('option[value="video"]').disabled, true, 'video is independent');
});
test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
const { api, dom, sandbox, listenerCount } = load();
api._pcAcquire();
const first = api.el;
dom.root.removeChild(dom.slot);
const fresh = new dom.El('div');
dom.root.appendChild(fresh);
sandbox.window.feedBack.ui.playerControlSlot = () => fresh;
api._pcAcquire();
assert.equal(fresh.children.length, 1, 'did not remount into the new slot');
assert.notEqual(api.el, first, 'stale node was reused');
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
});
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
const ctl = load();
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
ctl.api._pcAcquire();
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
assert.equal(ctl.dom.slot.children.length, 0);
// A non-v3 shell has no slot and never will, so no retry should be scheduled
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
ctl.api._pcRelease();
});
test('a host with no player-control slot mounts nothing and does not throw', () => {
const { api, dom, sandbox, timers } = load();
sandbox.window.feedBack.ui = {};
api._pcAcquire();
assert.equal(api.el, null);
assert.equal(dom.slot.children.length, 0);
let guard = 0;
while (timers.length && guard++ < 100) timers.shift()();
assert.ok(guard < 100, 'retry loop did not terminate');
});
test('intensity writes once on release, not on every drag step', () => {
const { api, writes } = load();
api._pcAcquire();
for (const v of ['0.10', '0.20', '0.30', '0.40', '0.50']) {
api.intens.value = v;
api.intens.fire('input');
}
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 0,
'dragging must not write — every write rebuilds the background scene');
api.intens.fire('change');
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 1,
'releasing must write exactly once');
});
test('the dropdown and Reactive pill drive the real setters', () => {
const { api, store, writes } = load();
api._pcAcquire();
api.sel.value = 'geometric';
api.sel.fire('change');
assert.equal(store.style, 'geometric');
const before = store.reactive;
api.react.fire('click');
assert.equal(store.reactive, !before, 'Reactive pill must toggle');
assert.ok(writes.some((w) => w[0] === 'reactive'));
});
test('exposes state and reasons to assistive tech', () => {
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
ctl.api._pcAcquire();
// The reason live-region must be a REAL mounted element with the id the
// controls reference - not a dangling pointer. Assert resolution, not a
// literal (a wrong id in code would still equal the literal).
const reason = ctl.api.reason;
assert.ok(reason, 'the reason span was not created');
assert.equal(reason.id, 'h3d-pc-reason');
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
// aria-pressed: a toggle button must expose its state. image greys
// Reactive, so not-pressed AND disabled, and it points at the reason.
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
assert.equal(ctl.api.react['aria-disabled'], 'true');
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
// and the span must carry the current reason text (kills a never-set-text
// mutation).
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
// The intensity describe path: a style where INTENSITY is inert.
ctl.store.style = 'video'; ctl.emit('style');
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
// Both enabled: describedby drops, aria-pressed follows the value.
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
assert.equal(ctl.api.intens['aria-describedby'], undefined);
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
ctl.store.reactive = false; ctl.emit('reactive');
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
// Accessible names on the non-label controls.
assert.equal(ctl.api.sel['aria-label'], 'Background style');
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
ctl.api._pcRelease();
});
test('greys out exactly the controls each style ignores', () => {
const { api, store, emit } = load();
api._pcAcquire();
for (const [style, want] of Object.entries(EXPECTED_USES)) {
store.style = style;
emit('style');
assert.equal(!api.intens.disabled, want.intensity, `${style}: intensity enabled-ness`);
assert.equal(!api.react.disabled, want.reactive, `${style}: reactive enabled-ness`);
}
});
test('the Venue override greys the whole Background group', () => {
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
ctl.api._pcAcquire();
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
assert.equal(ctl.api.react.disabled, false);
// Venue turns on: the effective style is now 'venue', which uses neither.
// The transition arrives on the settings bus as the 'venueScene' key.
ctl.sandbox._venueSceneOverride = true;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
// All three inert controls point at the reason under Venue (kills a
// 'describe reactive only' regression on the select/intensity paths).
const vReason = ctl.api.reason.id;
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
// The dropdown still shows the stored style (venue has no option), but
// selecting must not write while it's inert.
assert.equal(ctl.api.sel.value, 'particles');
const before = ctl.writes.length;
ctl.api.sel.value = 'lights';
ctl.api.sel.fire('change');
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
// Venue off: controls come back per the stored style.
ctl.sandbox._venueSceneOverride = false;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
assert.equal(ctl.api.react.disabled, false);
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
ctl.api._pcRelease();
});
test('an unknown style enables both controls (fails open)', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'some_future_style';
emit('style');
assert.equal(api.intens.disabled, false);
assert.equal(api.react.disabled, false);
});
test('greyed-out controls cannot reach the setters', () => {
const { api, store, emit, writes } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
const before = writes.length;
api.intens.fire('change');
api.react.fire('click');
assert.equal(writes.length, before, 'an inert control must not write');
});
test('greyed-out controls explain themselves on hover', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'butterchurn';
emit('style');
assert.match(api.react.title, /butterchurn/i);
assert.match(api.intens.title, /butterchurn/i);
});
// A native-disabled <button>/<input> fires no pointer events, so its own
// `title` never shows on hover. The reason must therefore also sit on the
// non-disabled wrapper, and the disabled control must let the hover fall
// through (pointer-events:none) — otherwise the "says why on hover" feature is
// dead in the browser while these tests pass on the swallowed control title.
test('the greyed-out reason reaches a hoverable wrapper', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
assert.match(api.react.parentNode.title, /nothing to adjust/i,
'reactive reason must be on the wrapper, not only the disabled pill');
assert.equal(api.react.style.pointerEvents, 'none',
'disabled pill must pass hover through to its wrapper');
assert.match(api.intens.parentNode.title, /nothing to adjust/i,
'intensity reason must be on the wrapper, not only the disabled slider');
assert.equal(api.intens.style.pointerEvents, 'none',
'disabled slider must pass hover through to its wrapper');
// ...and an enabled style clears the wrapper so the control's own title wins.
store.style = 'particles';
emit('style');
assert.equal(api.react.parentNode.title, '');
assert.equal(api.intens.parentNode.title, '');
assert.equal(api.intens.style.pointerEvents, '');
});
+7 -18
View File
@@ -7,26 +7,15 @@
#
# Pin to Tailwind 3.x so the input/config syntax matches what was
# already shipped via the Play CDN (Tailwind 4 has breaking changes).
#
# Run this from a checkout with NO untracked plugin directories present (a
# `git worktree add --detach` of this branch is the safest way). The content
# glob (tailwind.config.js) scans `./plugins/**` on disk regardless of
# .gitignore — a dev machine with private/out-of-tree plugins checked out
# locally (e.g. audio_engine, plugin_manager) will silently bake their classes
# into the committed CSS, which CI's clean checkout can never reproduce and
# will permanently fail the tailwind-fresh gate.
set -euo pipefail
cd "$(dirname "$0")/.."
# Byte-stable rebuilds require the exact same resolved dependency tree, not
# just the same top-level tailwindcss version: `npx -y tailwindcss@x.y.z`
# installs into a scratch npx cache and lets npm re-resolve transitive deps
# (postcss, cssnano, autoprefixer) to whatever's current on the registry at
# invocation time — those drift independently of the pinned version and
# silently produced non-reproducible output between two machines. tailwindcss
# is now a pinned devDependency (package.json/package-lock.json); `npm ci`
# before this script (both here and in CI) is what actually makes the output
# reproducible.
exec npx tailwindcss \
# Pin to the exact version used to generate the committed CSS — committed
# artifacts must rebuild byte-stable for diff-friendly maintenance. The
# pinned version is the one that produced the current static/tailwind.min.css
# (visible in its top-of-file header comment); bump deliberately when you
# want to track upstream Tailwind 3.x updates, and regenerate the CSS in
# the same commit.
exec npx -y tailwindcss@3.4.19 \
-c tailwind.config.js \
-i static/_tailwind.src.css \
-o static/tailwind.min.css \
+1717 -1742
View File
File diff suppressed because it is too large Load Diff
+5 -58
View File
@@ -218,7 +218,6 @@ import {
setAvOffsetMs,
setInstrumentPathway,
setupAppUpdates,
setupWindowOptions,
syncDefaultArrangementPin,
} from './js/settings.js';
import {
@@ -1150,7 +1149,7 @@ window.feedBack.on('song:ready', () => {
let _arrBusyGen = 0;
let _arrBusyTimeout = null;
async function changeArrangement(index, drumPart) {
async function changeArrangement(index) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
@@ -1276,38 +1275,11 @@ async function changeArrangement(index, drumPart) {
_resetSectionPracticeLog();
invalidateParentCount();
// Carry the selected drum part across the re-stream. An explicit
// `drumPart` (a drum-part switch, from changeDrumPart) wins; otherwise
// preserve the current picker selection so an ARRANGEMENT switch keeps
// the chosen part (drum parts are song-level, not per-arrangement).
const part = drumPart !== undefined
? drumPart
: (document.getElementById('drum-part-select')?.value || '');
window.highway.reconnect(currentFilename, index, part);
window.highway.reconnect(currentFilename, index);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
}
}
// Switch which drum part plays (feedpak 1.17.0 "drums as arrangements"). A part
// switch re-streams the same song with a different drum tab — the same
// transition as an arrangement switch — so it delegates to changeArrangement
// with the CURRENT arrangement held and the new part applied. Wired to
// #drum-part-select's onchange; the select is populated + shown by
// highway.js's song_info handler only when the song has 2+ drum parts.
async function changeDrumPart(partId) {
if (!currentFilename) return;
let index = 0;
const si = window.highway && typeof window.highway.getSongInfo === 'function'
? window.highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
index = si.arrangement_index;
} else {
const arrSel = document.getElementById('arr-select');
if (arrSel && arrSel.value !== '') index = Number(arrSel.value) || 0;
}
return changeArrangement(index, partId);
}
// Restart the current song from the beginning (or from loop A when an AB
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
// audio.currentTime directly and never reloads via playSong().
@@ -1361,25 +1333,12 @@ if (window.feedBack) window.feedBack.closeCurrentSong = closeCurrentSong;
// leaving the player still leaves — and abandons the queue.
window.feedBack.playQueue = (function () {
let list = [], idx = -1, source = '', arrangements = null;
// Set true by _play() right before it drives playSong, consumed once by
// playSong's clear-guard. The primary "don't clear the queue I'm driving"
// signal is options.fromQueue, but a chain of plugin playSong wrappers
// (nam_tone, midi_amp, fretboard, invert_highway, tabview, ...) forward only
// (filename, arrangement) and silently drop the options object — so the flag
// never arrived and the queue cleared itself the instant its first song
// started (a gig/album/playlist never advanced). This flag rides beside the
// wrapper chain, not through it.
let _internalPlay = false;
const active = () => idx >= 0 && idx < list.length;
const hasNext = () => active() && idx < list.length - 1;
function clear() { list = []; idx = -1; source = ''; arrangements = null; }
function _play(i) {
const fn = list[i];
// fromQueue is the in-band signal; _internalPlay is the out-of-band one
// that survives wrapper chains dropping the options arg. Both set; either
// suffices. playSong runs its clear-guard synchronously at entry, and the
// wrapper chain reaches it synchronously, so the flag is still set then.
_internalPlay = true;
// fromQueue keeps the queue from clearing itself; playSong decodeURIs.
window.playSong(encodeURIComponent(fn), arrangements ? arrangements[i] : undefined, { fromQueue: true });
}
function start(files, opts) {
@@ -1411,15 +1370,6 @@ window.feedBack.playQueue = (function () {
}
return {
start: start, advance: advance, hasNext: hasNext, active: active, clear: clear,
// True when the current song is a queue ADVANCE (song 2..N of a set),
// false for its first song or a standalone play. The venue uses this to
// fly in once on arrival at the set, then continue the room between
// songs instead of replaying the arrival flyover every track.
isContinuation: function () { return active() && idx > 0; },
// One-shot: true iff _play just kicked off this playSong. Consumed on
// read so a later MANUAL play still clears the queue. playSong calls this
// instead of trusting options.fromQueue to survive the wrapper chain.
_consumeInternalPlay: function () { const v = _internalPlay; _internalPlay = false; return v; },
source: function () { return source; },
remaining: function () { return active() ? list.length - idx - 1 : 0; },
// What's coming, for consumers that RENDER the queue (a results
@@ -2346,14 +2296,11 @@ configureHost({
currentFilename: () => currentFilename,
});
// `esc` is here for out-of-tree plugins only: their screen.js loads as a classic
// script and called esc() back when app.js was one too and it was an implicit
// global. Nothing in core reads window.esc — import it from ./js/dom.js instead.
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
+1 -2
View File
@@ -128,7 +128,6 @@
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
});
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
@@ -1537,4 +1536,4 @@
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
} catch (_) {}
})();
})();
-336
View File
@@ -1,336 +0,0 @@
// Chart-transform provider registration, selection, and diagnostics.
// Transformation stays on the synchronous highway data plane and runs after
// difficulty filtering; the selected provider is shared by highway instances.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
// providerId → { id, label, pluginId, transform }
const providers = new Map();
let activeProviderId = null;
let activeSource = 'startup';
let lastFailure = null;
// Count of highway instances the active provider is installed on
// (the primary window.highway plus any announced via highway:created —
// e.g. splitscreen panels). 0 = nothing capable exists yet.
let installedCount = 0;
// Known highway surfaces beyond window.highway, held weakly so closed
// splitscreen panels can be collected. WeakRef is guarded for minimal
// test environments; the strong-ref fallback only over-retains there.
const _HasWeakRef = typeof WeakRef === 'function';
let _surfaces = [];
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
function _snapshot(extra = {}) {
return {
available: true,
active: activeProviderId,
activeSource,
installed: installedCount > 0,
surfaces: installedCount,
providers: [...providers.values()].map(p => ({
id: p.id,
label: p.label,
pluginId: p.pluginId,
})),
lastFailure: lastFailure ? { ...lastFailure } : null,
...extra,
};
}
function _emit(name, detail) {
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
catch (_) { /* eventing must not break rendering */ }
}
function _contributeDiagnostics() {
const diagnostics = window.feedBack && window.feedBack.diagnostics;
if (diagnostics && typeof diagnostics.contribute === 'function') {
try {
diagnostics.contribute('chart-transform-capability', {
schema: 'feedBack.chart_transform.diagnostics.v1',
..._snapshot(),
});
} catch (_) { /* diagnostics must not break rendering */ }
}
}
function _persistSelection(providerId) {
try {
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
else window.localStorage.removeItem(STORAGE_KEY);
} catch (_) { /* storage unavailable → in-memory selection only */ }
}
function _persistedSelection() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _capable(hw) {
return !!(hw && typeof hw.setChartTransform === 'function');
}
// Every capable highway surface: window.highway plus live announced
// instances (splitscreen panels), deduped, dead refs pruned in place.
function _eachSurface(fn) {
const seen = new Set();
const primary = window.highway;
if (_capable(primary)) { seen.add(primary); fn(primary); }
const live = [];
for (const ref of _surfaces) {
const hw = _HasWeakRef ? ref.deref() : ref;
if (!hw) continue;
live.push(ref);
if (seen.has(hw) || !_capable(hw)) continue;
seen.add(hw);
fn(hw);
}
_surfaces = live;
return seen.size;
}
function _rememberSurface(hw) {
if (!_capable(hw) || hw === window.highway) return;
let known = false;
_eachSurface(() => {});
for (const ref of _surfaces) {
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
}
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
}
// Hand the current selection to every highway surface (or clear it).
// Selection survives with zero surfaces — it re-applies as instances
// appear (song:ready for the primary, highway:created for panels).
function _install() {
const provider = activeProviderId ? providers.get(activeProviderId) : null;
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
installedCount = 0;
_eachSurface((hw) => {
try {
hw.setChartTransform(payload);
if (payload) installedCount += 1;
} catch (_) { /* one broken surface must not block the rest */ }
});
return installedCount > 0 || payload === null;
}
function _setActive(providerId, source) {
const from = activeProviderId;
activeProviderId = providerId;
activeSource = String(source || 'unknown');
_persistSelection(providerId);
_install();
if (from !== providerId) {
_emit('transform-changed', { from, to: providerId, source: activeSource });
}
_contributeDiagnostics();
}
function _payload(ctx = {}) {
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
}
function _providersForParticipant(participantId) {
return [...providers.values()].filter(provider => provider.pluginId === participantId);
}
function _registerProviderParticipant(participantId) {
const owned = _providersForParticipant(participantId);
if (!owned.length) return;
capabilities.registerParticipant(participantId, {
'chart-transform': {
roles: ['provider'],
operations: ['chart.transform'],
events: [],
mode: 'active',
compatibility: 'none',
safety: 'safe',
runtime: true,
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
provider_policy: {
providerIds: owned.map(provider => provider.id),
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
},
},
});
}
function _registerProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
if (typeof payload.transform !== 'function') {
return _degraded('Provider registration requires a transform(input) function', _snapshot());
}
const participantId = String(ctx.source || ctx.requester || providerId);
const existing = providers.get(providerId);
if (existing && existing.pluginId !== participantId) {
return _degraded(
`Provider ${providerId} is already registered by a different participant`,
_snapshot(),
);
}
providers.set(providerId, {
id: providerId,
label: String(payload.label || providerId),
pluginId: participantId,
transform: payload.transform,
});
_registerProviderParticipant(participantId);
_emit('provider-registered', { providerId });
// Restore a persisted selection the moment its provider appears.
if (!activeProviderId && _persistedSelection() === providerId) {
_setActive(providerId, 'restore-selection');
} else if (activeProviderId === providerId) {
// Re-registration after script rehydration: reinstall the fresh
// transform closure so the highway isn't holding a stale one.
_install();
}
_contributeDiagnostics();
return _handled(_snapshot({ registered: providerId }));
}
function _unregisterProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
const provider = providers.get(providerId);
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
const callerId = String(ctx.source || ctx.requester || providerId);
if (provider.pluginId !== callerId) {
return _degraded(
`Provider ${providerId} can only be unregistered by its original registrant`,
_snapshot(),
);
}
providers.delete(providerId);
if (activeProviderId === providerId) {
// Keep the persisted selection so the provider re-activates on
// its next registration; just detach it from the highway.
activeProviderId = null;
_install();
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
}
const remainingProviders = _providersForParticipant(provider.pluginId);
if (remainingProviders.length) {
_registerProviderParticipant(provider.pluginId);
} else if (typeof capabilities.unregisterParticipant === 'function') {
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
const providerOnly = roles.length === 1 && roles[0] === 'provider';
if (!participant || providerOnly) {
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
catch (_) { /* participant cleanup is best-effort */ }
}
}
_emit('provider-unregistered', { providerId });
_contributeDiagnostics();
return _handled(_snapshot({ unregistered: providerId }));
}
function _targetProviderId(ctx = {}) {
const payload = _payload(ctx);
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
return String(
target.providerId || target.provider_id || target.id
|| payload.providerId || payload.provider_id || payload.id
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
).trim();
}
function _selectProvider(ctx = {}) {
const providerId = _targetProviderId(ctx);
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
if (!providers.has(providerId)) {
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
}
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ selected: providerId }));
}
function _clearProvider(ctx = {}) {
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ cleared: true }));
}
function _refresh() {
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
let refreshed = 0;
_eachSurface((hw) => {
if (typeof hw.refreshChartTransform !== 'function') return;
try { hw.refreshChartTransform(); refreshed += 1; }
catch (_) { /* one broken surface must not block the rest */ }
});
return _handled(_snapshot({ refreshed: refreshed > 0 }));
}
capabilities.registerOwner('chart-transform', {
pluginId: 'core.chart-transform',
kind: 'provider-coordinator',
safety: 'safe',
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
operations: ['chart.transform'],
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-providers': () => _handled(_snapshot()),
'register-provider': (ctx) => _registerProvider(ctx),
'unregister-provider': (ctx) => _unregisterProvider(ctx),
'select-provider': (ctx) => _selectProvider(ctx),
'clear-provider': (ctx) => _clearProvider(ctx),
refresh: () => _refresh(),
},
});
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
const sm = window.feedBack;
if (typeof sm.on === 'function') {
try {
sm.on('highway:chart-transform-failed', (e) => {
const detail = (e && e.detail) || e || {};
lastFailure = {
providerId: String(detail.id || activeProviderId || 'unknown'),
reason: PUBLIC_FAILURE_REASON,
};
_emit('transform-failed', { ...lastFailure });
_contributeDiagnostics();
});
// The primary highway is created after this module evaluates —
// install a pending selection once a song is loading/ready.
sm.on('song:ready', () => {
if (activeProviderId && installedCount === 0 && _install()) {
// setChartTransform restages immediately, so the chart
// that just became ready picks the transform up now.
_contributeDiagnostics();
}
});
// Additional instances restage the active provider against their
// own chart state.
sm.on('highway:created', (e) => {
const detail = (e && e.detail) || e || {};
if (!_capable(detail.highway)) return;
_rememberSurface(detail.highway);
if (activeProviderId) _install();
_contributeDiagnostics();
});
} catch (_) { /* bus mirroring is best-effort */ }
}
window.feedBack.chartTransformDomain = {
version: 1,
snapshot: _snapshot,
};
_contributeDiagnostics();
})();
+35 -282
View File
@@ -267,19 +267,6 @@ function createHighway() {
hwState._filteredChords = null;
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
// Transform stage; null fields fall through to filtered/original data.
hwState._xfProvider = null; // { id, transform } or null
hwState._xfNotes = null; // effective (post-filter) views
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null; // full-difficulty views (getNotes/getChords)
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null; // number or null
hwState._xfTuning = null; // array or null
hwState._xfCapo = null; // number or null
hwState._xfHandShapes = null; // array or null
hwState._xfCentOffset = null; // number or null
// Tracks whether ANY phrase level carries handshape data. Lets us
// distinguish "this difficulty has none" (respect strictly — even
// when empty) from "the chart's phrase data never authored any
@@ -410,8 +397,7 @@ function createHighway() {
function getAnchorAt(t) {
// Same master-difficulty fallback as the render loops — the
// anchor ladder pairs with the note ladder.
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let a = src[0] || { fret: 1, width: 4 };
for (const anc of src) {
if (anc.time > t) break;
@@ -422,8 +408,7 @@ function createHighway() {
function getMaxFretInWindow(t) {
// Find the highest fret needed across all anchors visible on screen
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let maxFret = 0;
for (const anc of src) {
if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff)
@@ -556,20 +541,17 @@ function createHighway() {
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
b.notes = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.beats = hwState.beats;
b.sections = hwState.sections;
b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
// Effective tuning metadata; live references like the chart arrays.
b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
b.chordTemplates = hwState.chordTemplates;
b.stringCount = hwState.stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
b.tuning = hwState.songInfo?.tuning;
b.capo = hwState.songInfo?.capo;
b.lyrics = hwState.lyrics;
b.lyricsSource = hwState.lyricsSource;
b.toneChanges = hwState.toneChanges;
@@ -590,10 +572,9 @@ function createHighway() {
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
// Display flags
b.inverted = hwState._inverted;
@@ -1005,22 +986,6 @@ function createHighway() {
// inline arrow function.
function _handleAsyncInitFailure(e) {
if (hwState._renderer !== _installedRenderer) return;
// ...and ignore a rejection from a SUPERSEDED init cycle.
//
// A renderer mints a fresh readyPromise on every init(), and
// rejects the previous one ("superseded") when a newer init
// starts. The renderer object is unchanged, so the identity
// check above does not catch it — and we would tear down a
// perfectly healthy renderer that is merely re-initialising.
//
// This is exactly what starting a gig did: setViz('venue')
// installed the 3D renderer, then the queue's playSong()
// re-initialised it a tick later; init #1's promise rejected,
// and the gig dropped to the fallback 2D highway with the
// venue gone. A superseded init is not a failed init — the
// NEW cycle owns the outcome, and its own promise is what we
// must judge.
if (_installedRenderer.readyPromise !== rp) return;
console.error('renderer async init failure:', e);
_destroyCurrentIfInited();
hwState._renderer = _defaultRenderer;
@@ -1194,17 +1159,6 @@ function createHighway() {
' (user ' + hwState._renderScale.toFixed(2) + ' / auto ' + hwState._autoScale.toFixed(2) + ')';
}
// Optional renderer capability: "my picture keeps moving even when the chart
// clock is stopped". Anything a renderer animates on its own clock (the 3D
// highway's venue video + crowd) has to opt out of the paused-frame throttle
// or it renders at 10 fps while the song is paused. Absent / throwing =
// false, so every existing renderer keeps the throttle unchanged.
function _rendererNeedsContinuousFrames() {
const r = hwState._renderer;
if (!r || typeof r.needsContinuousFrames !== 'function') return false;
try { return r.needsContinuousFrames() === true; } catch (_) { return false; }
}
function draw() {
hwState.animFrame = requestAnimationFrame(draw);
if (!hwState.canvas || !hwState._renderer) return;
@@ -1269,15 +1223,7 @@ function createHighway() {
const _nowP = performance.now();
if (_nowP - hwState._chartLastAdvanceAt > _CHART_MAX_INTERP_MS) {
_paused = true;
// ...unless the renderer says its picture is NOT static while
// paused. The throttle assumes a paused chart is a still frame,
// but a renderer can own content on a clock of its own — the 3D
// highway draws the venue's video backdrop and its reactive crowd
// into this same canvas, so throttling the highway throttled the
// whole room to 10 fps whenever the song was paused. Optional
// method: renderers that don't implement it keep the throttle.
if (!_rendererNeedsContinuousFrames()
&& _nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
if (_nowP - hwState._lastPausedDrawAt < _PAUSED_FRAME_INTERVAL_MS) return;
hwState._lastPausedDrawAt = _nowP;
}
}
@@ -1391,10 +1337,9 @@ function createHighway() {
// slots, so 4 strings spread across the full band rather than
// using the upper 4/6ths of the 6-string layout. The Math.max
// guards against a hypothetical 1-string instrument (denom=0).
const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
const span = Math.max(1, sc - 1);
for (let i = 0; i < sc; i++) {
const yi = hwState._inverted ? (sc - 1 - i) : i;
const span = Math.max(1, hwState.stringCount - 1);
for (let i = 0; i < hwState.stringCount; i++) {
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i;
const y = strTop + (yi / span) * (strBot - strTop);
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
hwState.ctx.lineWidth = 3;
@@ -1497,7 +1442,6 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
_restageChartTransform();
return;
}
const outNotes = [];
@@ -1545,116 +1489,6 @@ function createHighway() {
}
hwState._filteredHandShapes = outHandShapes;
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases;
_restageChartTransform();
}
function _clearChartTransformStage() {
hwState._xfNotes = null;
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null;
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null;
hwState._xfTuning = null;
hwState._xfCapo = null;
hwState._xfHandShapes = null;
hwState._xfCentOffset = null;
}
function _cloneChartTransformValue(value, seen = new WeakMap()) {
if (!value || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const copy = Array.isArray(value) ? new Array(value.length) : {};
seen.set(value, copy);
for (const key of Object.keys(value)) {
Object.defineProperty(copy, key, {
value: _cloneChartTransformValue(value[key], seen),
enumerable: true,
configurable: true,
writable: true,
});
}
return copy;
}
function _sortedChartTransformArray(items, key) {
return items.slice().sort((a, b) => a[key] - b[key]);
}
function _reportChartTransformFailure(provider, error) {
_clearChartTransformStage();
console.error('chart transform:', error);
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try {
window.feedBack.emit('highway:chart-transform-failed', {
id: provider.id,
});
} catch (_) { /* eventing must not break rendering */ }
}
}
// Stage one synchronous transform over the difficulty-filtered chart.
function _restageChartTransform() {
_clearChartTransformStage();
const p = hwState._xfProvider;
if (!p) return;
// Pre-ready there is nothing meaningful to transform (chart arrays
// are still streaming, songInfo may be empty) — keep the provider
// attached and let the `ready` path (which sets hwState.ready BEFORE
// _rebuildMasteryFilter) run the first real staging.
if (!hwState.ready) return;
const filterActive = hwState._filteredNotes !== null;
try {
let out = p.transform(_cloneChartTransformValue({
notes: filterActive ? hwState._filteredNotes : hwState.notes,
chords: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords,
anchors: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors,
allNotes: hwState.notes,
allChords: hwState.chords,
chordTemplates: hwState.chordTemplates,
// Same effective selection the bundle uses (see b.handShapes).
handShapes: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes,
stringCount: hwState.stringCount,
songInfo: hwState.songInfo,
}));
if (out && typeof out.then === 'function') {
try {
const catchAsyncFailure = out.catch;
if (typeof catchAsyncFailure === 'function') {
catchAsyncFailure.call(out, error => console.error('chart transform async:', error));
}
} catch (_) { /* the synchronous failure below remains authoritative */ }
throw new TypeError('Chart transform providers must return synchronously');
}
if (!out || typeof out !== 'object') return;
out = _cloneChartTransformValue(out);
if (Array.isArray(out.notes)) hwState._xfNotes = _sortedChartTransformArray(out.notes, 't');
if (Array.isArray(out.chords)) hwState._xfChords = _sortedChartTransformArray(out.chords, 't');
if (Array.isArray(out.anchors)) hwState._xfAnchors = _sortedChartTransformArray(out.anchors, 'time');
// Full-difficulty views: explicit allNotes/allChords, or reuse the
// effective output when no filter is active (effective === raw then).
if (Array.isArray(out.allNotes)) hwState._xfNotesAll = _sortedChartTransformArray(out.allNotes, 't');
else if (!filterActive && Array.isArray(out.notes)) hwState._xfNotesAll = hwState._xfNotes;
if (Array.isArray(out.allChords)) hwState._xfChordsAll = _sortedChartTransformArray(out.allChords, 't');
else if (hwState._filteredChords === null && Array.isArray(out.chords)) hwState._xfChordsAll = hwState._xfChords;
if (Array.isArray(out.chordTemplates)) hwState._xfChordTemplates = out.chordTemplates;
if (Number.isFinite(out.stringCount) && out.stringCount >= 1) {
// Same [1, 8] clamp as the song_info stringCount handler.
hwState._xfStringCount = Math.max(1, Math.min(8, Math.trunc(out.stringCount)));
}
if (Array.isArray(out.tuning) && out.tuning.length) hwState._xfTuning = out.tuning;
if (Number.isFinite(out.capo) && out.capo >= 0) hwState._xfCapo = Math.trunc(out.capo);
if (Array.isArray(out.handShapes)) {
hwState._xfHandShapes = _sortedChartTransformArray(out.handShapes, 'start_time');
}
if (Number.isFinite(out.centOffset)) hwState._xfCentOffset = out.centOffset;
} catch (e) {
_reportChartTransformFailure(p, e);
return;
}
}
// ── Public API ───────────────────────────────────────────────────────
@@ -1699,8 +1533,6 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
},
@@ -2076,21 +1908,17 @@ function createHighway() {
// never routable.
const isAudioUrl = msg.audio_url.startsWith('/audio/');
// "Full mix" covers BOTH single-mix pack shapes:
// - single-stem packs (stems: [full.ogg] only) — the pack's
// one stem IS its mixdown, so the server leaves it in the
// stems list, has_full_mix is false, and audio_url points
// at that one stem; and
// - legacy stem-less packs, whose mixdown sits outside stems
// behind the deprecated original_audio: key, so has_stems
// is false and audio_url == full_mix_url.
// Either way there is one audible source and no per-stem mix
// to preserve, so routing it natively loses nothing. A pack
// that retains its `full` stem ALONGSIDE separated stems is
// multi-stem (has_full_mix && has_stems) and stays out until
// Phase 2 — routing it natively would drop the mixer.
// - stem-less packs (original_audio: in the manifest,
// audio_url == original_audio_url), and
// - single-stem packs (stems: [full.ogg] only) — the server
// puts the full mix in the stems list, has_original_audio
// is false, and audio_url points at the one stem. With one
// stem there is no per-stem mix to preserve, so routing it
// natively loses nothing. Real multi-stem (>1) stays out
// until Phase 2.
const isFeedpakFullMix = !isAudioUrl
&& msg.audio_url.startsWith('/api/sloppak/')
&& ((!!msg.has_full_mix && !msg.has_stems)
&& ((!!msg.has_original_audio && !msg.has_stems)
|| (msg.stems || []).length === 1);
// Record the loaded song's audio so app.js can re-route it
// between the HTML5 and JUCE paths if the audio engine is
@@ -2115,7 +1943,7 @@ function createHighway() {
'isFeedpakFullMix=', isFeedpakFullMix,
'has_stems=', !!msg.has_stems,
'stems=', (msg.stems || []).length,
'has_full_mix=', !!msg.has_full_mix,
'has_original_audio=', !!msg.has_original_audio,
'format=', msg.format,
'alreadyLoaded=', alreadyLoaded,
'juceApi=', !!window.feedBackDesktop?.audio);
@@ -2298,31 +2126,6 @@ function createHighway() {
sel.appendChild(opt);
}
}
// Drum-part picker (feedpak 1.17.0 "drums as
// arrangements"): a song can carry several drum
// charts. Populate the picker beside the
// arrangement switcher; show it only when there
// are 2+ parts to choose between. `drum_parts`
// is always present (empty for non-drum songs),
// so a single-drum / no-drum song hides it. The
// currently-streaming part is marked selected by
// the `drum_tab` handler below (authoritative
// `part_id`), so we don't guess here.
{
const dpSel = document.getElementById('drum-part-select');
if (dpSel) {
const parts = Array.isArray(msg.drum_parts) ? msg.drum_parts : [];
dpSel.textContent = '';
for (const p of parts) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
dpSel.appendChild(opt);
}
const dpRow = document.getElementById('v3-drum-part-row');
if (dpRow) dpRow.classList.toggle('hidden', parts.length <= 1);
}
}
}
// Plugin context API — broadcast current song state
if (window.feedBack) {
@@ -2405,22 +2208,7 @@ function createHighway() {
name: (typeof msg.name === 'string' && msg.name) ? msg.name : 'Drums',
kit: Array.isArray(msg.kit) ? msg.kit : [],
hits: [],
// Which drum part this stream carries (feedpak
// 1.17.0). Present only for multi-part packs;
// null otherwise. Plugins can read it via
// bundle.drumTab.part_id.
part_id: (typeof msg.part_id === 'string' && msg.part_id) ? msg.part_id : null,
};
// Reflect the authoritative streaming part in the
// picker (the server resolves an unknown/absent
// selection to the primary, so this keeps the
// dropdown honest even after a fallback).
if (hwState.drumTab.part_id) {
const dpSel = document.getElementById('drum-part-select');
if (dpSel && dpSel.value !== hwState.drumTab.part_id) {
dpSel.value = hwState.drumTab.part_id;
}
}
break;
case 'drum_hits':
if (hwState.drumTab && Array.isArray(msg.data)) {
@@ -2627,11 +2415,8 @@ function createHighway() {
hwState._domVisSampledFrame = NaN;
return _isHighwayVisible();
},
// When a chart transform is active these return its full-difficulty
// views (falling through to the original arrays if the provider
// supplied only the filtered view).
getNotes() { return hwState._xfNotesAll !== null ? hwState._xfNotesAll : hwState.notes; },
getChords() { return hwState._xfChordsAll !== null ? hwState._xfChordsAll : hwState.chords; },
getNotes() { return hwState.notes; },
getChords() { return hwState.chords; },
// Difficulty-filtered variants of getNotes()/getChords(). Returns the
// master-difficulty-filtered arrays when the current song has phrase-level
// data (i.e. the mastery slider is active). For songs with a single
@@ -2639,14 +2424,8 @@ function createHighway() {
// these fall through to the raw arrays, the same as getNotes()/getChords().
// Plugins that score or analyse only the notes the player is currently
// expected to play should prefer these over getNotes()/getChords(). Read-only.
getFilteredNotes() {
if (hwState._xfNotes !== null) return hwState._xfNotes;
return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
},
getFilteredChords() {
if (hwState._xfChords !== null) return hwState._xfChords;
return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
},
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; },
// Live reference to the chord-template lookup table —
// `getChords()[i].id` is an index into this array. Each
// template carries `{ name, fingers, frets }`:
@@ -2661,7 +2440,7 @@ function createHighway() {
// its entries. Not difficulty-filter-aware (templates are
// static metadata; every chord_id referenced by `getChords()`
// is guaranteed valid).
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
getChordTemplates() { return hwState.chordTemplates; },
getToneChanges() { return hwState.toneChanges; },
getToneBase() { return hwState.toneBase; },
getSections() { return hwState.sections; },
@@ -2689,10 +2468,7 @@ function createHighway() {
// string-indexed UI / geometry against THIS rather than
// assuming 6. Defaults to 6 between songs (until the next
// song_info message arrives).
getStringCount() { return hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount; },
getTuning() { return hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning; },
getCapo() { return hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo; },
getCentOffset() { return hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset; },
getStringCount() { return hwState.stringCount; },
addDrawHook(fn) {
hwState._drawHooks.push(fn);
},
@@ -2716,17 +2492,6 @@ function createHighway() {
*/
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
getNoteStateProvider() { return hwState._noteStateProvider; },
// Install one synchronous provider for this highway. The capability
// domain owns registration and selection; null clears the provider.
setChartTransform(p) {
hwState._xfProvider = (p && typeof p.transform === 'function')
? { id: String(p.id || 'anonymous'), transform: p.transform }
: null;
_restageChartTransform();
},
getChartTransform() { return hwState._xfProvider; },
// Re-run the installed provider (e.g. its target settings changed).
refreshChartTransform() { _restageChartTransform(); },
/** Current per-string base colors (copy). Index 0..7. */
getStringColors() { return hwState.STRING_COLORS.slice(); },
/**
@@ -2813,7 +2578,7 @@ function createHighway() {
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
},
reconnect(filename, arrangement, drumPart) {
reconnect(filename, arrangement) {
// Close old WS but keep audio + animation running
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
hwState.ready = false;
@@ -2834,16 +2599,9 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
// Multiple drum parts (feedpak 1.17.0 "drums as arrangements"):
// carry the selected part id so the WS streams ITS drum tab. Empty
// / undefined → the primary part (server default), i.e. today's
// one-drum behavior for any pack the picker never touched.
if (drumPart) wsParams.set('drum_part', drumPart);
let namingMode = 'smart';
if (typeof window._getArrangementNamingMode === 'function') {
const v = window._getArrangementNamingMode();
@@ -2938,11 +2696,6 @@ function createHighway() {
*/
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; },
};
// Let cross-instance coordinators discover this highway.
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try { window.feedBack.emit('highway:created', { highway: api }); }
catch (e) { console.error('highway:created emit:', e); }
}
return api;
}
const highway = createHighway();
-28
View File
@@ -1,28 +0,0 @@
// Blob export helpers — the download idiom that used to be duplicated in
// settings-io.js and diagnostics-export.js, plus image-to-clipboard for
// shareable cards/posters. A LEAF module: imports nothing. Classic-script
// plugins reach it via dynamic import('/static/js/blob-io.js').
export function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Copy an image blob to the system clipboard. Returns true on success, false
// when the Clipboard API is unavailable or refuses (insecure context, no user
// gesture, permission denied) — callers fall back to downloadBlob and say so.
export async function copyImageBlob(blob) {
try {
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') return false;
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
return true;
} catch (_) {
return false;
}
}
+8 -3
View File
@@ -21,8 +21,6 @@
// redact toggles.
// 3. Stream the returned zip to disk.
import { downloadBlob } from './blob-io.js';
function _diagIncludeFromUI() {
const v = (id) => document.getElementById(id)?.checked !== false;
return {
@@ -267,7 +265,14 @@ export async function exportDiagnostics() {
}
try {
const blob = await resp.blob();
downloadBlob(blob, filename);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed during download: ${e.message}`;
+10 -21
View File
@@ -400,9 +400,8 @@ export function drawSustains(hwState, W, H) {
// Same master-difficulty fallback as drawNotes/drawChords —
// without this, sustain bars for filtered-out notes would
// still render, leaving orphan rectangles where no note head
// is drawn. An active chart transform substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// is drawn.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
for (const n of src) {
if (n.sus <= 0.01) continue;
const end = n.t + n.sus;
@@ -502,9 +501,7 @@ export function drawNotes(hwState, W, H) {
// phrase-level ladder data, render from the mastery-filtered
// array. _filteredNotes stays null for slider-disabled sources
// so rendering falls through to the flat notes array unchanged.
// An active chart transform (_xfNotes) substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// Binary search for visible range
const tMin = hwState.currentTime - 0.25;
const tMax = hwState.currentTime + VISIBLE_SECONDS;
@@ -652,8 +649,7 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
export function drawChords(hwState, W, H) {
// See drawNotes — _filteredChords is null for slider-disabled
// sources so we fall through to the flat chords array.
const src = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
_ensureChordRenderCache(hwState, src);
const tMin = hwState.currentTime - 0.25;
@@ -678,7 +674,7 @@ export function drawChords(hwState, W, H) {
const actualSpread = Math.max(spread, minSpread);
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1);
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const hasNonZero = nonZeroNotes.length >= 1;
const frameLeftFret = baseFret;
@@ -1128,22 +1124,15 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
return { tmpl, tmplFrets, getTemplateFret, isOpen };
}
// Effective chord templates: an active chart transform substitutes its
// re-indexed table (identity change also invalidates the render cache).
export function _effChordTemplates(hwState) {
return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
}
// Build _chordRenderInfo for every chord in `src` if the cache is stale.
// Two passes over the array: chain bounds, then base-fret resolution
// (which can read previous chord's cached baseFret).
export function _ensureChordRenderCache(hwState, src) {
const effTemplates = _effChordTemplates(hwState);
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
hwState._chordRenderCacheSrc = src;
hwState._chordRenderCacheInverted = hwState._inverted;
hwState._chordRenderCacheTemplates = effTemplates;
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
// Templates feed isOpen() — when they land after `chords`,
// _updateFretLinePreview's stashed open/non-open classification
// for the currently-active chord is also stale. It only refreshes
@@ -1199,7 +1188,7 @@ export function _ensureChordRenderCache(hwState, src) {
for (let i = 0; i < src.length; i++) {
const ch = src[i];
const info = hwState._chordRenderInfo.get(ch);
const { isOpen } = getChordTemplateInfo(ch.id, effTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
const nonZero = sortedNotes.filter(cn => !isOpen(cn));
const nonZeroFrets = nonZero.map(cn => cn.f);
@@ -1259,7 +1248,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
ch.t > bestChordTime) {
bestChordTime = ch.t;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
}
@@ -1271,7 +1260,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
const p = project(ch.t - hwState.currentTime);
if (!p) continue;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
break;
+4 -71
View File
@@ -410,51 +410,6 @@ function _applyLibraryProviderToParams(params) {
return params;
}
// ── Instrument-aware tuning (the bass-player tuning-filter report) ───────────
// A song's bass chart is often tuned differently from its guitar chart, so the
// tuning facet, the `tunings` filter, the tuning sort and the row's tuning
// badge must all speak for the instrument the player actually plays. Read the
// host's working-tuning capability (the live selection, seeded from
// /api/settings at boot) rather than adding another settings fetch; hosts
// without the capability keep the guitar behaviour.
const _LIB_PERSPECTIVES = ['guitar-lead', 'guitar-rhythm', 'bass'];
let _libSettingsProfile = '';
export function _setLibraryProfile(profileId) {
_libSettingsProfile = _LIB_PERSPECTIVES.includes(profileId) ? profileId : '';
}
export function _libraryInstrument() {
// The PROFILE is the only three-valued source (lead / rhythm / bass); the
// working-tuning capability knows guitar-vs-bass but not lead-vs-rhythm,
// so it is only the fallback.
if (_libSettingsProfile) return _libSettingsProfile;
try {
const wt = window.feedBack?.workingTuning;
if (wt && typeof wt.get === 'function') {
const cur = wt.get();
if (cur?.instrument === 'bass') return 'bass';
}
} catch { /* capability absent/erroring — lead guitar is the safe default */ }
return 'guitar-lead';
}
export function _libraryInstrumentLabel() {
const p = _libraryInstrument();
return p === 'bass' ? 'bass' : p === 'guitar-rhythm' ? 'rhythm' : 'lead';
}
// The tuning a row should SHOW: the bass chart's for a bass player, falling
// back to the song (guitar-derived) tuning when the song has no bass
// arrangement — the common case, not an edge path.
function _rowTuningRaw(song) {
const p = _libraryInstrument();
const field = p === 'bass' ? 'bass_tuning_name'
: p === 'guitar-rhythm' ? 'rhythm_tuning_name' : '';
if (field && song[field]) return song[field];
return song.tuning || song.tuning_name || '';
}
export function _resetLibraryProviderViewState() {
L.libEpoch++;
L.currentPage = 0;
@@ -813,8 +768,6 @@ export function _applyLibFiltersToParams(params) {
if (_libFilters.stemsLacks.length) params.set('stems_lacks', _libFilters.stemsLacks.join(','));
if (_libFilters.lyrics !== null) params.set('has_lyrics', String(_libFilters.lyrics));
if (_libFilters.tunings.length) params.set('tunings', _libFilters.tunings.join(','));
// Which instrument's tuning the `tunings` filter + the tuning sort read.
if (_libraryInstrument() !== 'guitar-lead') params.set('instrument', _libraryInstrument());
return params;
}
@@ -898,7 +851,6 @@ async function _renderTuningList() {
c.innerHTML = '<div class="text-xs text-gray-500 px-2">Loading...</div>';
try {
const params = _applyLibraryProviderToParams(new URLSearchParams());
params.set('instrument', _libraryInstrument());
const resp = await fetch(`/api/library/tuning-names?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
@@ -917,11 +869,6 @@ async function _renderTuningList() {
fetchError = e.message || 'request failed';
}
}
// NAME the perspective: silent instrument-following is the original bug in
// a new place — the user must be able to see which instrument these
// tunings describe.
const labelEl = document.getElementById('filter-tunings-label');
if (labelEl) labelEl.textContent = `Tuning (${_libraryInstrumentLabel()})`;
c.innerHTML = '';
if (fetchError) {
c.innerHTML = `<div class="text-xs text-red-400 px-2">Failed to load tunings (${esc(fetchError)}). Reopen the drawer to retry.</div>`;
@@ -947,17 +894,10 @@ async function _renderTuningList() {
const checked = _libFilters.tunings.includes(val);
const row = document.createElement('label');
row.className = 'tuning-row';
// Be honest about the fallback: songs with no bass arrangement borrow
// the guitar chart's tuning, and that must be visible rather than
// presented as a measured bass tuning.
const inferred = t.inferred_count || 0;
if (inferred) {
row.title = `${inferred} of ${t.count} inferred from the guitar chart (no bass arrangement)`;
}
row.innerHTML =
`<input type="checkbox" ${checked ? 'checked' : ''} class="rounded border-gray-600 bg-dark-700 text-accent">` +
`<span class="flex-1">${esc(label)}</span>` +
`<span class="tuning-count">${t.count}${inferred ? ` (${inferred}~)` : ''}</span>`;
`<span class="tuning-count">${t.count}</span>`;
const cb = row.querySelector('input');
cb.onchange = () => {
const i = _libFilters.tunings.indexOf(val);
@@ -1304,10 +1244,6 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
// The BADGE follows the player's instrument; `tuning` above stays the
// song's guitar-derived tuning because the retune action below rewrites
// the chart to E Standard and must not key on the bass part.
const tuningBadge = displayTuningName(_rowTuningRaw(song));
const artUrl = _librarySongArtUrl(song, providerId);
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
@@ -1363,7 +1299,7 @@ export function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace
</div>
<div class="flex items-center flex-wrap gap-1.5 mt-3 text-xs">
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuningBadge ? `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>` : ''}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
@@ -1534,9 +1470,6 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
const duration = song.duration ? formatTime(song.duration) : '';
const tuningRaw = song.tuning || song.tuning_name || '';
const tuning = displayTuningName(tuningRaw);
// Badge follows the player's instrument; the retune action below
// keeps operating on the song's guitar-derived tuning.
const tuningBadge = displayTuningName(_rowTuningRaw(song));
const isLocalProvider = _isLocalLibraryProvider(providerId);
const isSloppak = song.format === 'sloppak';
const stdRetune = isLocalProvider && localFilename && !isSloppak && tuningRaw && !song.has_estd &&
@@ -1563,8 +1496,8 @@ export async function renderTreeInto(containerId, countId, stats, letter, q, fav
{ const _nm = _getArrangementNamingMode();
for (const arrangement of (song.arrangements || []))
html += _arrangementBadgeHtml(arrangement, _nm); }
if (tuningBadge)
html += `<span class="px-1.5 py-0.5 rounded ${tuningBadge === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuningBadge)}</span>`;
if (tuning)
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
if (song.has_lyrics)
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
if (song.user_difficulty != null)
+3 -12
View File
@@ -638,18 +638,9 @@ export let artAbortController = null;
export async function playSong(filename, arrangement, options) {
console.log('playSong called:', filename);
// A manual (non-queue) play abandons any active play-queue, so a stale queue
// can't hijack the next song's end. The queue signals a play it is DRIVING
// two ways: options.fromQueue (in-band) and _consumeInternalPlay() (out-of-
// band). The out-of-band one exists because plugin playSong wrappers forward
// only (filename, arrangement) and drop the options object — with just the
// in-band flag, the queue cleared itself the instant its first song played
// and a gig never advanced. Consume the flag whether or not we go on to clear,
// so it can't leak into a later manual play.
const _pq = window.feedBack && window.feedBack.playQueue;
const _queueDriven = (options && options.fromQueue)
|| (_pq && typeof _pq._consumeInternalPlay === 'function' && _pq._consumeInternalPlay());
if (!_queueDriven && _pq) {
_pq.clear();
// can't hijack the next song's end. The queue passes fromQueue to keep itself.
if ((!options || !options.fromQueue) && window.feedBack && window.feedBack.playQueue) {
window.feedBack.playQueue.clear();
}
if (!options || options.bridge !== false) {
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
+9 -4
View File
@@ -1,6 +1,6 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). Imports only the blob-io leaf.
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
@@ -29,8 +29,6 @@
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
import { downloadBlob } from './blob-io.js';
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
@@ -68,7 +66,14 @@ export async function exportSettings() {
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
downloadBlob(blob, filename);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
+78 -302
View File
@@ -18,7 +18,7 @@
// back-import would close a cycle. player-controls keeps reading it through the host seam, and
// app.js — the root, which imports both — wires it. That is exactly what the seam is for.
import { hwcInitSettingsUI } from './highway-colors.js';
import { _getArrangementNamingMode, _setLibraryProfile } from './library.js';
import { _getArrangementNamingMode } from './library.js';
import {
_applyMastery, _autoplayExitEnabled, _exitConfirmEnabled, _showUpNextEnabled,
} from './player-controls.js';
@@ -100,7 +100,6 @@ export async function loadSettings() {
// failed fetch below still leaves the desktop updater wired up.
// setupAppUpdates() is idempotent via _appUpdatesWired.
setupAppUpdates();
setupWindowOptions();
const resp = await fetch('/api/settings');
const data = await resp.json();
// Null-guard the form fields: on the v3 tabbed settings page the markup is
@@ -111,10 +110,6 @@ export async function loadSettings() {
if (dlcEl) dlcEl.value = data.dlc_dir || '';
_defaultArrangement = data.default_arrangement || '';
_syncDefaultArrangementSelect(_defaultArrangement);
// Feed the library its tuning PERSPECTIVE (lead / rhythm / bass) — the
// tuning facet, filter, sort and badges all answer for the profile the
// player actually plays.
_setLibraryProfile(data.active_instrument_profile);
const pathwayEl = document.getElementById('setting-instrument-pathway');
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
const demucsEl = document.getElementById('demucs-server-url');
@@ -172,107 +167,9 @@ export async function loadSettings() {
hwcInitSettingsUI();
}
// ── Window options (desktop-only) ────────────────────────────────────────
// Desktop-only window preferences (start-in-fullscreen, …). The whole block
// stays hidden in the plain web / Docker app; unhide + wire only when the
// feedBack-desktop bridge (window.feedBackDesktop.window) exposes the getter
// and setter. Persistence lives desktop-side because only the Electron main
// process can read the pref at window-creation time — core just proxies.
export let _windowOptionsWired = false;
export function setupWindowOptions() {
const block = document.getElementById('window-options-block');
if (!block) return;
const winApi = window.feedBackDesktop?.window;
// Per-method capability check: a partial/older bridge may expose `window`
// without this shape. Leave the block hidden rather than half-wiring it.
if (!winApi
|| typeof winApi.getStartFullscreen !== 'function'
|| typeof winApi.setStartFullscreen !== 'function') {
return;
}
block.classList.remove('hidden');
const cb = document.getElementById('setting-start-fullscreen');
if (!cb) return;
// Hydrate from the desktop-persisted value. The getter may be sync or
// async (IPC round-trip); Promise.resolve normalises both.
Promise.resolve(winApi.getStartFullscreen()).then(function (on) {
cb.checked = !!on;
}).catch(function () { /* leave unchecked on error */ });
// Guard only the listener against double-binding; unhide + re-hydrate
// stay idempotent so re-entering Settings refreshes the checkbox.
if (!_windowOptionsWired) {
_windowOptionsWired = true;
cb.addEventListener('change', function () {
try { winApi.setStartFullscreen(cb.checked); } catch (_) { /* best-effort */ }
});
}
}
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
export let _appUpdatesWired = false;
// Poll handle for the active-download watcher (module-scoped so re-running
// setupAppUpdates on a panel re-render never stacks a second poll).
let _appUpdatePollTimer = null;
// Last channel main actually acknowledged (initial sync or a successful
// user switch). Used to revert the dropdown/localStorage if a switch fails,
// so the UI/persisted state can never end up ahead of the real updater state.
let _appUpdateAckedChannel = null;
// Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and
// repeated no-op re-renders) don't flood the diagnostics ring buffer with
// byte-identical lines and evict genuinely useful trace. Every real state or
// percent change still differs and logs; the structured contribute() snapshot
// (with its own ts) is unconditional, so liveness is never lost.
let _appUpdateLastRenderLog = null;
// Pure status → view model for the App-updates panel. DOM-free and exported so
// the button/channel/text state machine can be unit-tested without a browser;
// renderFrom() applies the returned shape to the DOM. `canApply` is whether the
// bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp`
// formats the "last checked" time, `channelValue` is the dropdown's fallback
// when the status omits a channel.
export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) {
if (!s) return { kind: 'unavailable' };
if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' };
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`;
let action;
let btnLabel = 'Check for updates';
let btnMode = 'check';
let btnDisabled = false;
// Lock the channel selector only while a check/download is in flight —
// switching mid-operation abandons it. Enabled for every other status.
const channelDisabled = s.status === 'checking' || s.status === 'downloading';
switch (s.status) {
case 'checking':
action = 'checking for updates…';
btnDisabled = true;
break;
case 'downloading': {
const pct = typeof s.percent === 'number' ? s.percent : null;
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
btnDisabled = true;
break;
}
case 'downloaded':
action = 'update ready';
if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; }
else { action = 'update ready — restart to apply'; }
break;
case 'error':
action = s.message ? `update error: ${s.message}` : 'update check failed';
break;
case 'idle':
default:
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
break;
}
return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled };
}
export function setupAppUpdates() {
const block = document.getElementById('app-updates-block');
@@ -305,29 +202,13 @@ export function setupAppUpdates() {
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
channelSelect.value = stored;
_appUpdateAckedChannel = stored;
// Diagnostic: every entry into this function, with whether the one-time
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
// so it only resets to false on a genuine fresh evaluation of this
// script (a real page reload/navigation) — not on loadSettings() simply
// being called again within the same page. A second "wired=false" in one
// exported log is direct proof of a reload; a series of "wired=true"
// entries proves it's just repeated Settings-panel visits (harmless).
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
const isLinux = window.feedBackDesktop?.platform === 'linux';
function showLinuxFallback(message) {
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
// usually just means "the channel isn't Nightly yet", and the dropdown
// is the only way to switch to Nightly. Disabling it would trap the
// user on whatever channel they booted with. Only the check button and
// the note reflect the unsupported state.
if (linuxNote) linuxNote.classList.remove('hidden');
channelSelect.disabled = true;
checkBtn.disabled = true;
// Reset the button out of any leftover "Restart now" state (e.g. an
// update was staged on nightly, then the user switched channels).
checkBtn.textContent = 'Check for updates';
checkBtn.dataset.mode = 'check';
statusEl.textContent = message || 'Auto-update is not available on this platform.';
}
@@ -339,140 +220,61 @@ export function setupAppUpdates() {
} catch (_) { return 'never'; }
}
// Render one status object. Always keeps the current version + channel
// visible and appends what's happening, so the download progress never
// obscures which build you're on.
function renderFrom(s, extra) {
// Diagnostic trace: log the raw status object before any branching —
// auto-captured by diagnostics.js's console wrap into the exportable
// ring buffer, so "Export Diagnostics" in this same Settings → System
// panel captures exactly what the app saw and decided, not just what
// the UI showed. Deduped so a steady poll doesn't flood the ring buffer
// (see _appUpdateLastRenderLog); a real state/percent change differs and
// still logs; the structured contribute() snapshot below is unconditional.
const logKey = `${JSON.stringify(s)}|${extra || ''}`;
if (logKey !== _appUpdateLastRenderLog) {
_appUpdateLastRenderLog = logKey;
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
}
const view = _appUpdateStatusView(s, {
channelValue: channelSelect.value,
canApply: typeof updateApi.apply === 'function',
fmtTimestamp,
});
if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (view.kind === 'unsupported') {
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
return;
}
// Healthy for the current channel — clear any "unsupported" UI left
// over from a prior channel selection.
if (linuxNote) linuxNote.classList.add('hidden');
// The button is a little state machine (dataset.mode drives the click
// handler's restart-vs-check branch); the channel selector locks only
// while a check/download is active. See _appUpdateStatusView.
channelSelect.disabled = view.channelDisabled;
checkBtn.textContent = view.btnLabel;
checkBtn.dataset.mode = view.btnMode;
checkBtn.disabled = view.btnDisabled;
const line = view.line;
statusEl.textContent = extra ? `${extra} · ${line}` : line;
// Live structured snapshot (overwrites, not a log) via the existing
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
// own registered plugin id, so the server's diagnostics export won't
// filter it out. Always current, no scrolling through console history
// needed to answer "what does the app think is going on right now."
try {
window.feedBack?.diagnostics?.contribute('audio_engine', {
update: {
channel: s.channel || channelSelect.value,
status: s.status,
currentVersion: s.currentVersion ?? null,
lastChecked: s.lastChecked ?? null,
percent: typeof s.percent === 'number' ? s.percent : null,
message: s.message ?? null,
rendered: line,
ts: Date.now(),
},
});
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
// A download runs in the background (the check returns immediately), so
// poll for the terminal state rather than relying solely on a one-shot
// "downloaded" event that could be missed or arrive out of order.
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
}
function renderStatus(extra) {
try {
// Wrap in Promise.resolve so a future getStatus() that returns
// synchronously won't blow up on .then().
void Promise.resolve(updateApi.getStatus())
.then((s) => renderFrom(s, extra))
.catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
void Promise.resolve(updateApi.getStatus()).then((s) => {
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (s.status === 'unsupported' || s.platform === 'linux') {
showLinuxFallback('Auto-update is not available on Linux.');
return;
}
if (s.status === 'error') {
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
return;
}
const parts = [
`Version ${s.currentVersion || '?'}`,
`channel ${s.channel || channelSelect.value}`,
`last checked ${fmtTimestamp(s.lastChecked)}`,
];
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
}).catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
} catch (e) {
console.warn('[updater] getStatus threw:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
}
}
// While a download (or check) is active, re-read the authoritative status
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
// what guarantees the panel leaves "downloading… 100%" and lands on "update
// ready" (or surfaces a swap error) even if the completion event is lost.
function pollWhileBusy() {
if (_appUpdatePollTimer) return;
_appUpdatePollTimer = setInterval(() => {
void Promise.resolve(updateApi.getStatus()).then((s) => {
renderFrom(s);
const st = s && s.status;
if (st !== 'downloading' && st !== 'checking') {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
}
}).catch(() => {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
});
}, 1500);
}
// Inform main of the persisted channel — but ONLY the first time this page
// wires up, not on every loadSettings() re-render. This used to run
// unconditionally on every call and was caught (via Export Diagnostics)
// stomping an in-flight check/download: a redundant setChannel() call
// mid-download bumps main's checkGeneration and resets progress state,
// so the download silently loses its ability to report completion even
// though the file swap itself still happens in the background. Once
// wired, the channel select's own 'change' handler is the only thing
// that needs to tell main about a channel switch.
if (!_appUpdatesWired) {
if (isLinux) {
showLinuxFallback('Auto-update is not available on Linux.');
// Keep main informed of the persisted channel even on Linux so
// cross-platform reasoning about the channel stays consistent.
// setChannel() may return a Promise — chain .catch() so a rejected
// promise doesn't surface as an unhandled rejection.
try {
// Render from THIS call's own result (same reasoning as the check
// button and the 'change' handler below), not just catch its
// errors. The unconditional renderStatus() at the bottom of this
// function fires a SEPARATE getStatus() round-trip immediately
// after — if that resolves before main has processed this
// setChannel() (e.g. main is still on its 'stable' boot default),
// the UI would render 'unsupported' and — since this call's own
// eventual success was never rendered — get stuck there
// permanently, even once main correctly switches channel a moment
// later. Rendering here too means whichever of the two calls
// resolves LAST wins and shows the true state, regardless of
// which order they land in.
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
_appUpdateAckedChannel = stored;
renderFrom(result);
}).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(linux) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
console.warn('[updater] setChannel(linux) threw:', e);
}
return;
}
// Inform main of the persisted channel on each load. setChannel() on
// main is idempotent when the channel already matches.
try {
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
}
if (!_appUpdatesWired) {
@@ -482,82 +284,56 @@ export function setupAppUpdates() {
channelSelect.addEventListener('change', async () => {
const val = channelSelect.value;
if (!APP_UPDATE_CHANNELS.includes(val)) return;
console.log('[update-diag] user switched channel to', val);
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
try {
// Render from setChannel()'s own return value (same reasoning
// as the check button: it's computed synchronously at the
// moment of the switch, so it can't be stale, unlike a
// follow-up getStatus() call).
const result = await Promise.resolve(updateApi.setChannel(val));
// Only persist once main has actually acknowledged the switch —
// a failed setChannel() must never leave localStorage (or the
// dropdown) ahead of what main is really using.
_appUpdateAckedChannel = val;
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
renderFrom(result, `Channel set to ${val}.`);
// Await setChannel so the status line reflects what actually
// happened — rendering "Channel set" unconditionally would
// mislead users when the IPC rejects.
await Promise.resolve(updateApi.setChannel(val));
renderStatus(`Channel set to ${val}.`);
} catch (e) {
console.warn('[updater] setChannel failed:', e);
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
}
});
checkBtn.addEventListener('click', async () => {
// In restart mode (set by renderFrom once an update is staged) the
// button applies the update instead of checking again.
if (checkBtn.dataset.mode === 'restart') {
console.log('[update-diag] user clicked Restart now');
checkBtn.disabled = true;
checkBtn.textContent = 'Restarting…';
try {
const r = await updateApi.apply();
if (r?.status === 'error') {
console.warn('[updater] apply returned error:', r.message || 'unknown');
renderFrom(r, 'Restart failed.');
}
// On success the app quits + relaunches — nothing to render.
} catch (e) {
console.warn('[updater] apply failed:', e);
statusEl.textContent = `Restart failed: ${e?.message || e}`;
checkBtn.textContent = 'Restart now';
checkBtn.disabled = false;
}
return;
}
console.log('[update-diag] user clicked Check for updates');
checkBtn.disabled = true;
statusEl.textContent = 'Checking for updates…';
let result;
let reEnableBtn = true;
try {
// The Linux check returns immediately (any download runs in the
// background).
result = await updateApi.checkNow();
const result = await updateApi.checkNow();
const status = result?.status || 'unknown';
let msg;
switch (status) {
case 'idle':
msg = "You're on the newest version in this channel.";
break;
case 'downloading':
msg = 'Update available — downloading…';
break;
case 'downloaded':
msg = 'Update downloaded — restart to apply.';
break;
case 'unsupported':
reEnableBtn = false;
showLinuxFallback('Auto-update is not available on Linux.');
return;
case 'error':
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
break;
default:
msg = `Update check returned: ${status}`;
}
renderStatus(msg);
} catch (e) {
console.warn('[updater] checkNow failed:', e);
statusEl.textContent = `Update check failed: ${e?.message || e}`;
checkBtn.disabled = false;
return;
} finally {
if (reEnableBtn) checkBtn.disabled = false;
}
// Render straight from checkNow()'s own return value rather than a
// follow-up getStatus() call. checkNow() computes that value
// synchronously at the moment it decides the outcome, so it can't
// be stale; a separate getStatus() round-trip right after it can
// race with anything that resets state in between (a concurrent
// channel switch, another in-flight check settling) and show a
// blanked "up to date · last checked never" even though this check
// just succeeded.
renderFrom(result);
});
// Main-process events (checkNow/download decisions in update-manager.ts)
// are invisible to this page's console — forward them into it so a
// single "Export Diagnostics" click captures both sides of the story.
if (typeof updateApi.onDiag === 'function') {
updateApi.onDiag((payload) => {
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
});
}
_appUpdatesWired = true;
}
-190
View File
@@ -1,190 +0,0 @@
/*
* fee[dB]ack the pop-out chip.
*
* One affordance, core-owned, identical everywhere: the small button a plugin
* drops into the panel it already has.
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* That is the entire adoption cost. Clicking the chip pops the panel out; a stub
* takes its place so the user can find it again; closing the pane brings the panel
* home and restores the chip. The plugin writes no show/hide logic if it did,
* every plugin would invent a slightly different one, which is exactly the
* inconsistency this exists to prevent.
*
* The panel a chip is attached to is USUALLY the very element the pane moves into
* the pop-out window so most of the time there is nothing here left to hide, and
* the job is simply to mark the hole it left. Hiding it would in fact be actively
* harmful: `.fb-pane-detached` is `display:none !important`, and it would travel
* with the node straight into the pane window and blank it.
*
* When the chip IS attached to something the pane didn't take (a wrapper, a
* launcher row), that element stays put and is hidden with `.fb-pane-detached`
* a dedicated class, not `.hidden`/[hidden], because the panels we attach to
* already toggle those themselves.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.register !== 'function') {
console.error('[panes] pane-manager.js must load before pane-chip.js');
return;
}
// paneId -> { el, chip, stub, spec }
const attached = new Map();
function _makeChip(spec) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'fb-pane-chip';
b.title = 'Pop out';
b.setAttribute('aria-label', 'Pop out ' + spec.title);
b.textContent = '⇱';
b.addEventListener('click', (e) => {
// Rail popovers close on any document click that lands outside them
// (player-chrome.js). Without this the popover would close under the
// chip mid-click, which reads as the button not working.
e.stopPropagation();
e.preventDefault();
panes.detach(spec.id);
});
return b;
}
function _makeStub(spec) {
const s = document.createElement('button');
s.type = 'button';
s.className = 'fb-pane-stub';
s.setAttribute('aria-label', 'Bring ' + spec.title + ' back');
s.title = 'Bring it back';
const glyph = document.createElement('span');
glyph.className = 'fb-pane-stub-glyph';
glyph.textContent = '⇲';
const label = document.createElement('span');
label.textContent = spec.title + ' is popped out';
s.appendChild(glyph);
s.appendChild(label);
s.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
panes.close(spec.id);
});
return s;
}
// The pane is out. Leave a stub where its panel used to be.
//
// The subtlety: the panel a chip is attached to is USUALLY the very element the
// pane moved into the pop-out window. It is no longer in this document at all —
// so hiding it would be worse than pointless (the `display:none` travels with
// the node and blanks the pane window, which is exactly the bug this fixes), and
// the stub cannot be inserted "before it", because it is not here to be before.
//
// Hence `home`: the manager tells us where the element used to live, and the
// stub goes there. If the chip is attached to something the pane did NOT take —
// a wrapper, a launcher row — that element is still here, and we hide it as
// before.
function _onOpened(rec, detail) {
// Did the pane take MY element?
//
// Ask the manager, which knows exactly what it handed to the host. Do not
// try to infer it from the element:
//
// - `isConnected` says "still here" for a panel sitting in a pane window.
// It IS connected — to that window.
// - `ownerDocument` says "still here" for a panel moved into the DOCK,
// which is in this very document. Hiding it there would blank a pane the
// user is looking at.
//
// Both were live bugs. The manager's answer is the only one that holds for
// every host, and it works when reconciling after the fact (detail == null),
// which is what a plugin rebuilding its panel mid-pop-out triggers.
const takenEl = (detail && detail.el) || panes.elementOf(rec.spec.id);
const moved = takenEl === rec.el;
if (!moved && rec.el.isConnected) {
rec.el.classList.add('fb-pane-detached');
if (!rec.stub.isConnected && rec.el.parentNode) rec.el.parentNode.insertBefore(rec.stub, rec.el);
return;
}
// Mark the hole the element left. `home` comes with the event, or from the
// manager when we are reconciling after the fact.
const home = (detail && detail.home) || panes.homeOf(rec.spec.id);
if (!rec.stub.isConnected && home && home.parent && home.parent.isConnected) {
const next = (home.next && home.next.parentNode === home.parent) ? home.next : null;
home.parent.insertBefore(rec.stub, next);
}
}
function _onClosed(rec) {
// The element is back. Whatever we did to hide it, undo — including a class
// it might have carried out of the document and back.
rec.el.classList.remove('fb-pane-detached');
rec.stub.remove();
}
/**
* attachChip(el, paneId, opts)
*
* `el` the dialog to hide when the pane pops out. The chip is injected
* into `el.querySelector('[data-pane-header]')` when present, else
* prepended to `el` itself.
* `opts` { header: Element } to place the chip somewhere specific.
*
* Returns a detach function that removes the chip and stub and restores the
* dialog call it if your plugin tears its dialog down.
*/
function attachChip(el, paneId, opts) {
opts = opts || {};
if (!(el instanceof Element)) throw new TypeError('panes.attachChip: el must be an Element');
// Validate here, not at the insertBefore below. This is a public plugin API,
// and a truthy non-Element `header` (a selector string, a jQuery-ish wrapper,
// a ref object) is an easy mistake to make — one that would otherwise surface
// as a confusing DOM exception from deep inside core.
if (opts.header != null && !(opts.header instanceof Element)) {
throw new TypeError('panes.attachChip(' + paneId + '): opts.header must be an Element');
}
const spec = panes.get(paneId);
if (!spec) { console.warn('[panes] attachChip: register the pane first:', paneId); return () => {}; }
if (attached.has(paneId)) { console.warn('[panes] attachChip: already attached:', paneId); return () => {}; }
const chip = _makeChip(spec);
const stub = _makeStub(spec);
const host = opts.header || el.querySelector('[data-pane-header]') || el;
if (host === el) host.insertBefore(chip, host.firstChild);
else host.appendChild(chip);
const rec = { el, chip, stub, spec };
attached.set(paneId, rec);
// Reconcile immediately: register() reopens a pane the user left open at
// last unload, and that can land before (or after) attachChip runs.
if (panes.isOpen(paneId)) _onOpened(rec, null);
return () => {
if (attached.get(paneId) !== rec) return;
attached.delete(paneId);
chip.remove();
_onClosed(rec);
};
}
// One pair of bus listeners for every chip, rather than one pair per chip.
const bus = window.feedBack;
if (bus && typeof bus.on === 'function') {
bus.on('panes:opened', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onOpened(rec, e.detail);
});
bus.on('panes:closed', (e) => {
const rec = attached.get(e.detail && e.detail.id);
if (rec) _onClosed(rec);
});
}
window.feedBack.panes.attachChip = attachChip;
})();
-47
View File
@@ -1,47 +0,0 @@
/*
* fee[dB]ack desktop upgrades for pane windows.
*
* In the desktop app a pane window is a real BrowserWindow: it remembers where you
* put it, it stays off the taskbar, it minimizes to the system tray, and the tray
* lists every pane you have.
*
* Note what this file does NOT do: it does not open the window, and it does not
* close it. That stays in pane-window-host.js, and it stays `window.open()`
* because the pane's element is MOVED into that window's document, and a window
* the main process created for us would give this realm no handle to adopt into.
*
* Electron turns our same-origin `window.open()` into a real BrowserWindow anyway,
* and the main process recognises it by its frame name (`fbpane-<id>`) and takes
* over the OS-level behaviour from there. So the only thing left to say across IPC
* is "here are the panes that exist" for the tray and to listen for the tray
* saying "open that one".
*
* In a browser, or on an older desktop build, this file does nothing and pop-out
* works anyway. Everything here is an upgrade, not a dependency.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
const desktop = window.feedBackDesktop && window.feedBackDesktop.panes;
if (!panes || !bus || !desktop) return;
// The tray asked to toggle a pane. Only this realm knows what that means — the
// pane might belong in the dock, and its element lives here.
desktop.onToggle((paneId) => {
if (panes.isOpen(paneId)) panes.close(paneId);
else panes.detach(paneId);
});
// Keep the tray's menu in step with the registry. Cheap and rare — panes are
// registered at load and toggled by hand, never on a playback path.
function sync() {
desktop.sync(panes.list().map((p) => ({ id: p.id, title: p.title, icon: p.icon, open: p.open })));
}
bus.on('panes:registered', sync);
bus.on('panes:unregistered', sync);
bus.on('panes:opened', sync);
bus.on('panes:closed', sync);
sync();
})();
-121
View File
@@ -1,121 +0,0 @@
/*
* fee[dB]ack pane dock (the in-window pane host).
*
* A right-edge stack of cards, one per open pane. Deliberately NOT a rail popover:
* the rail is exclusive (player-chrome.js's openPopFor closes the last one before
* opening the next), which is exactly why you cannot watch the mixer while riding
* the camera. Cards here coexist.
*
* As everywhere in this system, the card holds the plugin's REAL element moved,
* not copied. The dock is a frame; the panel inside it is the panel.
*
* Song-switch survival is structural, not defended: #fb-pane-dock is a <body>
* child outside every .screen, so the per-song teardown never sees it.
*
* Registers as the `dock` host at priority 0 the floor. Whatever else exists
* (an OS window), a pane can always land here, so opening one can never fail.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-dock.js');
return;
}
let dockEl = null;
const cards = new Map(); // paneId -> card element
function dock() {
if (dockEl && dockEl.isConnected) return dockEl;
dockEl = document.getElementById('fb-pane-dock');
if (!dockEl) {
dockEl = document.createElement('div');
dockEl.id = 'fb-pane-dock';
// `is-empty` from the start: panes.css hides an empty dock, and a dock
// born without the class is a visible-to-CSS, announced-to-screen-readers
// `role="region"` landmark with nothing in it until the first card
// arrives. Born empty, because it is.
dockEl.className = 'fb-pane-dock is-empty';
dockEl.setAttribute('role', 'region');
dockEl.setAttribute('aria-label', 'Panes');
document.body.appendChild(dockEl);
}
return dockEl;
}
function _syncEmpty() {
dock().classList.toggle('is-empty', cards.size === 0);
}
function place(spec, el) {
const card = document.createElement('section');
card.className = 'fb-pane-card';
card.dataset.paneId = spec.id;
card.setAttribute('aria-label', spec.title);
const head = document.createElement('header');
head.className = 'fb-pane-card-head';
const title = document.createElement('span');
title.className = 'fb-pane-card-title';
// textContent, not innerHTML — a pane title comes from a plugin.
title.textContent = spec.icon + ' ' + spec.title;
const close = document.createElement('button');
close.type = 'button';
close.className = 'fb-pane-card-btn';
close.setAttribute('aria-label', 'Close ' + spec.title);
close.title = 'Close';
close.textContent = '✕';
close.addEventListener('click', () => panes.close(spec.id));
head.appendChild(title);
head.appendChild(close);
const body = document.createElement('div');
body.className = 'fb-pane-card-body';
// Same neutralisation as the window host: the panel was a fixed overlay
// pinned to a corner of the app, and inside a card that positioning is
// nonsense. .fb-paned unpins it and nothing else.
el.classList.add('fb-paned');
body.appendChild(el);
card.appendChild(head);
card.appendChild(body);
dock().appendChild(card);
cards.set(spec.id, card);
_syncEmpty();
}
function unplace(id, el) {
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const card = cards.get(id);
if (card) card.remove();
cards.delete(id);
_syncEmpty();
}
function focus(id) {
const card = cards.get(id);
if (!card) return;
// Honour prefers-reduced-motion, as the flash animation below already does
// in panes.css. A smooth scroll is motion too, and a user who asked for less
// of it meant this as well.
const calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
card.scrollIntoView({ block: 'nearest', behavior: calm ? 'auto' : 'smooth' });
// Re-trigger the flash even if the class is still there — repeat focus of
// the same card would otherwise be a no-op animation.
card.classList.remove('is-flash');
void card.offsetWidth;
card.classList.add('is-flash');
setTimeout(() => card.classList.remove('is-flash'), 700);
}
panes.registerHost({ id: 'dock', priority: 0, available: () => !!document.body, place, unplace, focus });
})();
-71
View File
@@ -1,71 +0,0 @@
/*
* fee[dB]ack pane launcher (the "Panes" rail popover).
*
* A chip only works for a pane that already has a dialog to hide. Panes with no
* dialog a readout, a plugin's optional extra need somewhere to be opened
* from, so every registered pane gets one: a checkbox list in the rail.
*
* The rail popover is the right home for this precisely because it IS exclusive
* and transient. It's a menu, not a workspace; the panes it opens are the
* workspace, and they persist.
*
* Populated from the registry, so a plugin that calls panes.register() appears
* here with no further work. (The system tray will mirror this list.)
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
const bus = window.feedBack;
if (!panes || !bus || typeof bus.on !== 'function') return;
let listEl = null;
function render() {
if (!listEl || !listEl.isConnected) listEl = document.getElementById('v3-rail-panes-list');
if (!listEl) return;
const all = panes.list();
// Toggling a pane from this list fires panes:opened/closed, which re-renders
// the list — destroying the very button the user just pressed and dropping
// focus to <body>. Remember which one had it and give it back, so keyboard
// and screen-reader users can toggle several panes without losing their place.
const focusedId = (listEl.contains(document.activeElement) && document.activeElement.dataset)
? document.activeElement.dataset.paneId : null;
listEl.replaceChildren();
if (!all.length) {
const empty = document.createElement('div');
empty.className = 'v3-pop-empty';
empty.textContent = 'No panes available.';
listEl.appendChild(empty);
return;
}
all.forEach((p) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'v3-pop-btn';
b.dataset.paneId = p.id;
b.setAttribute('aria-pressed', p.open ? 'true' : 'false');
b.textContent = (p.open ? '● ' : '○ ') + p.icon + ' ' + p.title;
b.addEventListener('click', (e) => {
e.stopPropagation();
if (panes.isOpen(p.id)) panes.close(p.id); else panes.detach(p.id);
});
listEl.appendChild(b);
if (p.id === focusedId) b.focus();
});
}
// The registry changes when plugins load and when panes open/close. Render is
// cheap and rare (never on a playback path), so just re-run it.
bus.on('panes:registered', render);
bus.on('panes:unregistered', render);
bus.on('panes:opened', render);
bus.on('panes:closed', render);
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', render);
else render();
})();
-381
View File
@@ -1,381 +0,0 @@
/*
* fee[dB]ack pane manager.
*
* The registry and host router behind `window.feedBack.panes`.
*
* A "pane" is a piece of UI a plugin already has a mixer panel, a camera rig,
* a settings board that the user can pop out into its own OS window and leave
* open: while they play, across song switches, on a second monitor, minimized to
* the tray.
*
* The whole design is one sentence: WE MOVE THE REAL ELEMENT.
*
* Not a copy of it, not a re-implementation of it in the pop-out window the
* actual DOM node. Same-origin windows can adopt each other's nodes, and an
* adopted node keeps its event listeners and its closures. So the panel goes on
* running the plugin's own code, against the plugin's own state, in the plugin's
* own realm. It looks and behaves exactly like the thing that was popped out,
* because it IS the thing that was popped out.
*
* That is what makes the plugin's side of this two lines:
*
* feedBack.panes.register({ id: 'camera_director', title: 'Camera', element: () => panelEl });
* feedBack.panes.attachChip(panelEl, 'camera_director');
*
* No state mirroring, no cross-window RPC, no second copy of the UI to keep in
* step with the first. Those were all workarounds for a problem we simply do not
* have once the node itself moves.
*
* The manager owns which pane is open and where, and crucially where each
* pane's element CAME FROM, so docking it puts it back exactly where it was.
*/
(function () {
'use strict';
const HOSTS_KEY = 'fbPaneHosts'; // { paneId: hostId } — panes open at last unload
// id -> normalized spec
const specs = new Map();
// id -> { spec, hostId, el, home: { parent, next } }
const open = new Map();
// hostId -> host provider
const hosts = new Map();
// ── Persistence ──────────────────────────────────────────────────────────
// Only which pane was open, and where. A pane's CONTENTS are the plugin's own
// DOM and the plugin's own state — none of our business.
// A pane id is plugin-controlled and is used as a key in the persisted
// host map. `__proto__` and friends are not ids, they are booby traps: writing
// `map['__proto__'] = 'window'` on a plain object corrupts the map (and can
// reach Object.prototype), and reading `map[id]` can pick a value straight off
// the prototype chain for a pane that was never remembered at all.
//
// Rejected at registration, so the id never reaches storage — and the reads
// below are own-property checks anyway, because defence in depth is cheap here.
const UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
function _isUnsafeId(id) { return UNSAFE_KEYS.indexOf(id) >= 0; }
function _readJSON(key, fallback) {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return fallback;
// Re-key onto a null-prototype object: whatever was in storage (hand
// edited, corrupt, polluted) can no longer smuggle in a prototype.
const safe = Object.create(null);
Object.keys(parsed).forEach((k) => { if (!_isUnsafeId(k)) safe[k] = parsed[k]; });
return safe;
} catch (e) { return fallback; } // private mode / corrupt value
}
function _writeJSON(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { /* quota / private mode: non-fatal */ }
}
function _rememberHost(id, hostId) {
if (_isUnsafeId(id)) return;
const map = _readJSON(HOSTS_KEY, Object.create(null));
if (hostId) map[id] = hostId; else delete map[id];
_writeJSON(HOSTS_KEY, map);
}
function _rememberedHost(id) {
const map = _readJSON(HOSTS_KEY, Object.create(null));
return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : undefined;
}
// ── Spec ─────────────────────────────────────────────────────────────────
// A pane window's initial size. Plugin-controlled, and the window host builds
// window.open()'s feature string by concatenation — so this has to come out the
// other side as a number, not merely as something number-ish.
const MIN_PANE_PX = 120;
const MAX_PANE_PX = 4000; // wider than any real display; a guard, not a policy
function _size(v, fallback) {
const n = Math.round(Number(v));
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.min(MAX_PANE_PX, Math.max(MIN_PANE_PX, n));
}
function _normalize(spec) {
if (!spec || typeof spec !== 'object') throw new TypeError('panes.register: spec must be an object');
if (!spec.id || typeof spec.id !== 'string') throw new TypeError('panes.register: spec.id is required');
// See UNSAFE_KEYS: a pane id becomes a key in the persisted host map.
if (_isUnsafeId(spec.id)) throw new TypeError('panes.register: unsafe pane id: ' + spec.id);
if (typeof spec.element !== 'function' && !(spec.element instanceof Element)) {
throw new TypeError('panes.register(' + spec.id + '): spec.element must be an Element, or a function returning one');
}
return {
id: spec.id,
title: spec.title || spec.id,
icon: spec.icon || '▣',
// Resolved lazily: a plugin often builds its panel on first use, so the
// element may not exist at registration time — and it may be rebuilt
// later (Camera Director rebuilds its panel on every mode change).
// Asking for it at open time means we always move the live one.
element: typeof spec.element === 'function' ? spec.element : () => spec.element,
// Coerced to real numbers, because these are plugin-controlled and the
// window host concatenates them into window.open()'s feature string. A
// `width` of '300,menubar=1' would not merely be an invalid size — it
// would inject window features. Anything that isn't a finite positive
// number falls back to the default, and absurd sizes are clamped rather
// than honoured.
width: _size(spec.width, 380),
height: _size(spec.height, 560),
defaultHost: spec.defaultHost || 'window',
// Called after the element lands in (or returns from) a pane window,
// for a plugin that needs to re-measure or re-anchor something.
onHost: typeof spec.onHost === 'function' ? spec.onHost : null,
};
}
// ── Host routing ─────────────────────────────────────────────────────────
function _resolveHost(preferred) {
const wanted = hosts.get(preferred);
if (wanted && wanted.available()) return wanted;
// Fall back to the best available host. The dock registers at priority 0
// and is always available, so a pane can never fail to open.
let best = null;
hosts.forEach((h) => {
if (!h.available()) return;
if (!best || h.priority > best.priority) best = h;
});
return best;
}
function _emit(name, detail) {
const bus = window.feedBack;
if (bus && typeof bus.emit === 'function') bus.emit(name, detail);
}
// ── Open / close ─────────────────────────────────────────────────────────
function openPane(id, opts) {
opts = opts || {};
const spec = specs.get(id);
if (!spec) { console.warn('[panes] open: no such pane:', id); return false; }
if (open.has(id)) { focusPane(id); return true; }
let el;
try { el = spec.element(); } catch (e) { el = null; }
if (!(el instanceof Element)) {
console.warn('[panes] open: pane has no element yet:', id);
return false;
}
const host = _resolveHost(opts.host || spec.defaultHost);
if (!host) { console.error('[panes] open: no host available for', id); return false; }
// Where the element lives right now, so docking can put it back EXACTLY
// there — same parent, same position among its siblings. Anything less and
// a docked panel reappears at the bottom of its container, or not at all.
const home = { parent: el.parentNode, next: el.nextSibling };
// An element on its way OUT of this document must not carry a class whose
// whole job is to hide it IN this document. `.fb-pane-detached` is
// `display:none !important`, and it travels with the node — straight into
// the pane window, which then renders nothing at all.
el.classList.remove('fb-pane-detached');
// Make it visible, and remember exactly how it wasn't.
//
// A plugin's panel is usually hidden until its launcher is clicked, and a
// pane can be opened from the tray or the rail without that ever happening.
// So we un-hide it — but only in the two ways a panel is actually hidden
// (`hidden`, or an inline `display:none`), and we put both back on dock.
//
// Note what we do NOT do: force a `display`. A panel that is `display:flex`
// must stay flex. Neutralising placement is one thing; silently re-laying
// out someone's panel is another.
const vis = { hidden: el.hidden, display: el.style.display };
el.hidden = false;
if (el.style.display === 'none') el.style.display = '';
try {
host.place(spec, el);
} catch (e) {
console.error('[panes] host', host.id, 'failed to take', id, e);
el.hidden = vis.hidden;
el.style.display = vis.display;
return false;
}
open.set(id, { spec, hostId: host.id, el, home, vis });
if (opts.remember !== false) _rememberHost(id, host.id);
if (spec.onHost) { try { spec.onHost(host.id, el); } catch (e) { console.error('[panes]', id, 'onHost threw', e); } }
// `home` rides along because the element has LEFT this document — anything
// that wants to mark the hole it left (the chip's stub) needs to know where
// the hole is, and can no longer ask the element itself.
_emit('panes:opened', { id: id, host: host.id, el: el, home: home });
return true;
}
function closePane(id, opts) {
opts = opts || {};
const entry = open.get(id);
if (!entry) return false;
open.delete(id);
// ORDER IS LOAD-BEARING: bring the element home BEFORE the host lets go of
// it. The host's unplace() closes the pane window, and closing a window
// tears down its document — with the element still inside it. The node
// survives (we hold a reference) but comes back stripped of its event
// listeners, so the panel returns looking perfect and completely dead: no
// buttons, no sliders, nothing.
//
// Adopt first, while the pane window is still alive, and the node moves out
// of a living document into a living document, which is the only case the
// DOM actually guarantees.
// ADOPT UNCONDITIONALLY, INSERT CONDITIONALLY. The rescue and the
// re-homing are two different jobs, and only one of them is allowed to
// fail.
//
// Adopting is what saves the element: it transfers ownership away from the
// pane window's document, so that document can be destroyed without taking
// the listeners with it. Do that FIRST, and always — even when there is
// nowhere to put the element afterwards.
//
// Re-homing can legitimately be impossible: the panel may never have had a
// parent (a plugin that builds it lazily and hands it straight to us), or
// its container may have been torn down while the pane was out (a screen
// change). Gating the adopt on a reachable home would mean that in exactly
// those cases we leave the element inside a window we are about to close —
// which is the "comes home dead" failure this whole ordering exists to
// prevent. It just moves it from the common path to the rare one, where it
// is far harder to spot.
//
// With no home, the element ends up owned by this document but not in it:
// detached, intact, listeners alive, and ready for the plugin to re-insert
// whenever it rebuilds its UI.
try {
// adoptNode, not appendChild: the node's owner is currently the pane
// window's document, and adopting is what transfers ownership back.
const node = document.adoptNode(entry.el);
const home = entry.home;
if (home && home.parent && home.parent.isConnected) {
if (home.next && home.next.parentNode === home.parent) home.parent.insertBefore(node, home.next);
else home.parent.appendChild(node);
} else {
console.warn('[panes]', id, 'has no home to return to — the element is detached but intact');
}
} catch (e) {
console.error('[panes] could not bring', id, 'back out of its pane window', e);
}
const host = hosts.get(entry.hostId);
try { if (host) host.unplace(id, entry.el); } catch (e) { console.error('[panes] host', entry.hostId, 'threw releasing', id, e); }
// Put its visibility back exactly as we found it. A panel that was closed
// when the pane was opened from the tray goes back to being closed; one that
// was open stays open. We forced it visible; we un-force it.
if (entry.vis) {
entry.el.hidden = entry.vis.hidden;
entry.el.style.display = entry.vis.display;
}
if (opts.remember !== false) _rememberHost(id, null);
if (entry.spec.onHost) { try { entry.spec.onHost(null, entry.el); } catch (e) { /* non-fatal */ } }
_emit('panes:closed', { id: id, host: entry.hostId });
return true;
}
function focusPane(id) {
const entry = open.get(id);
if (!entry) return false;
const host = hosts.get(entry.hostId);
if (host && typeof host.focus === 'function') host.focus(id);
return true;
}
// What the pop-out chip calls: put this pane wherever a pane most wants to
// live. That is a window if one can be had, and the dock otherwise.
function detach(id) {
const spec = specs.get(id);
return openPane(id, { host: (spec && spec.defaultHost) || 'window' });
}
function dock(id) {
if (open.has(id)) closePane(id, { remember: false });
return openPane(id, { host: 'dock' });
}
// ── Registry ─────────────────────────────────────────────────────────────
function register(spec) {
const s = _normalize(spec);
if (specs.has(s.id)) {
// First registration wins, matching libraryCardActions.register. A
// silent overwrite would swap the element out from under an open pane.
console.warn('[panes] pane already registered, ignoring:', s.id);
return () => {};
}
specs.set(s.id, s);
_emit('panes:registered', { id: s.id, title: s.title });
// Reopen where the user left it. Deferred a tick so a plugin can call
// register() and attachChip() back to back — the chip must exist before
// the pane opens, or it has nothing to hide.
//
// A host may refuse to be auto-restored: a browser blocks window.open()
// without a user gesture, so restoring a popped-out pane on page load
// would only ever produce a "pop-up blocked" toast. Such a pane comes back
// in the dock, and the chip pops it out again on the user's next click.
let remembered = _rememberedHost(s.id);
if (remembered) {
const h = hosts.get(remembered);
if (h && h.autoRestore === false) remembered = 'dock';
setTimeout(() => { if (specs.has(s.id) && !open.has(s.id)) openPane(s.id, { host: remembered, remember: false }); }, 0);
}
return () => unregister(s.id);
}
function unregister(id) {
if (open.has(id)) closePane(id, { remember: false });
specs.delete(id);
_emit('panes:unregistered', { id: id });
}
function registerHost(host) {
if (!host || !host.id) throw new TypeError('panes: host needs an id');
hosts.set(host.id, {
id: host.id,
priority: host.priority || 0,
autoRestore: host.autoRestore !== false,
available: typeof host.available === 'function' ? host.available : () => true,
place: host.place,
unplace: host.unplace,
focus: host.focus,
});
}
const api = {
version: 2,
register,
unregister,
open: openPane,
close: closePane,
detach,
dock,
focus: focusPane,
isOpen: (id) => open.has(id),
hostOf: (id) => { const e = open.get(id); return e ? e.hostId : null; },
// Where an open pane's element came from. The chip needs this to mark the
// hole the element left, since it can no longer ask the element itself.
homeOf: (id) => { const e = open.get(id); return e ? e.home : null; },
// The element a host actually took. The chip needs this to tell "the pane
// took MY element" from "the pane took something else" — and it cannot ask
// the element, which may now be in a dock card or another window entirely.
elementOf: (id) => { const e = open.get(id); return e ? e.el : null; },
get: (id) => specs.get(id) || null,
list: () => Array.from(specs.values()).map((s) => ({
id: s.id, title: s.title, icon: s.icon,
open: open.has(s.id), host: (open.get(s.id) || {}).hostId || null,
})),
registerHost,
};
window.feedBack = window.feedBack || {};
window.feedBack.panes = Object.assign(window.feedBack.panes || {}, api);
})();
-355
View File
@@ -1,355 +0,0 @@
/*
* fee[dB]ack the pop-out window host.
*
* Opens a real OS window and MOVES THE PANE'S ELEMENT INTO IT.
*
* The move is the whole trick, and it works because the pane window is same-origin
* and opener-linked: `document.adoptNode()` re-parents a live node into another
* window's document, and an adopted node keeps its event listeners, its closures,
* and every reference anything else holds to it. So the plugin's panel goes on
* running the plugin's own code in the plugin's own realm it is just being
* *displayed* somewhere else. It looks and behaves exactly like what was popped
* out, because it is exactly what was popped out.
*
* That is why this file must use `window.open()` and not ask the desktop's main
* process to make a BrowserWindow: a window we didn't open gives us no handle to
* its document, and without the handle there is nothing to adopt into.
*
* Electron turns this same-origin `window.open()` into a real BrowserWindow anyway
* its setWindowOpenHandler answers same-origin URLs with `action: 'allow'` and
* the main process then recognises the window by its frame name and gives it
* remembered bounds, skip-taskbar and a system-tray entry. So we get the OS window
* AND the DOM link. (That code lives in the separate desktop repo,
* got-feedback/feedBack-desktop: src/main/main.ts and src/main/pane-hosts.ts. It is
* not in this repo, and nothing here depends on it in a plain browser this is
* simply a pop-up.)
*
* Styles come across too the pane document starts empty, so we copy the app's
* stylesheets into it. Without that the panel would land unstyled, which is the
* one thing a "pop out exactly this" feature cannot do.
*/
(function () {
'use strict';
const panes = window.feedBack && window.feedBack.panes;
if (!panes || typeof panes.registerHost !== 'function') {
console.error('[panes] pane-manager.js must load before pane-window-host.js');
return;
}
// The frame name every pane window is opened with. In the desktop app the main
// process matches on this prefix to recognise a pane window and give it its
// remembered bounds, skip-taskbar and tray entry — so changing it here without
// changing it there silently downgrades every pane to a plain pop-up.
//
// The other half lives in a DIFFERENT REPO (got-feedback/feedBack-desktop,
// src/main/pane-hosts.ts). There is no build-time link between them; this comment
// is the link.
const FRAME_PREFIX = 'fbpane-';
const wins = new Map(); // paneId -> Window
let reaper = null;
// A pane window the user closed with the OS X button gets no reliable
// beforeunload (a crashed renderer certainly gets none). Poll `closed` and
// reap — otherwise the pane stays "open" forever, its chip stays stubbed out,
// and the element it holds is stranded in a dead document with no way back.
function _startReaper() {
if (reaper != null) return;
reaper = setInterval(() => {
wins.forEach((w, id) => { if (w.closed) panes.close(id); });
if (!wins.size) { clearInterval(reaper); reaper = null; }
}, 400);
}
// Give the pane document the app's styles, so the panel looks identical.
// Cloned rather than shared: a <link> node can only live in one document, and
// we are not about to steal the app's own stylesheet out of its head.
function _copyStyles(doc) {
// pane.html already links panes.css, so don't clone a second copy of it —
// duplicate sheets cost a redundant fetch and an extra style recalc for no
// change in appearance.
const own = Array.from(doc.querySelectorAll('link[rel="stylesheet"]'));
const have = new Set(own.map((l) => l.href));
// Insert the app's sheets BEFORE pane.html's own, not after.
//
// Cascade order is the whole game here. In the app document panes.css loads
// LAST, after tailwind/style/v3 — so its rules win ties. Appending the app's
// sheets into the pane document would put them after panes.css and silently
// invert that, letting core styles override the pane chrome and the .fb-paned
// placement rules. "Looks identical" has to include the order things are
// said in.
const anchor = own[0] || null;
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
if (node.tagName === 'LINK' && have.has(node.href)) return;
try { doc.head.insertBefore(node.cloneNode(true), anchor); } catch (e) { /* skip a node we can't clone */ }
});
_syncChrome(doc);
}
// The theme/scale hooks the app hangs on <html> and <body>. v3 keys off these
// for its colour tokens and its interface scale, and a panel that lands without
// them renders in the wrong palette at the wrong size.
//
// MERGE, don't assign: pane.html sets `class="fb-pane-window"` on <html>, and
// panes.css hangs the pane window's own chrome off it. Overwriting the class
// list would take that with it and the window would lose its own layout — the
// app's classes and the pane document's are both wanted.
//
// Re-run on every theme/scale change for as long as the pane is open (see
// _followChrome). A one-time snapshot would leave an already-open pane rendering
// at the old scale the moment the user touched Interface size — "looks identical"
// has to keep being true, not merely start out true.
function _syncChrome(doc) {
try {
document.documentElement.classList.forEach((c) => doc.documentElement.classList.add(c));
document.body.classList.forEach((c) => doc.body.classList.add(c));
// The inline style on <html> carries the interface-scale custom property
// (--fb-scale). Assign it wholesale: unlike the class lists, pane.html
// sets no inline style of its own, so there is nothing here to preserve —
// and merging by concatenation would grow the attribute without bound as
// the user dragged the scale slider.
doc.documentElement.style.cssText = document.documentElement.style.cssText;
} catch (e) { /* the window may be closing under us */ }
}
// paneId -> stop following the app's theme/scale
const chromeFollowers = new Map();
function _followChrome(paneId, doc) {
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
const sync = () => _syncChrome(doc);
bus.on('scale:changed', sync);
bus.on('theme:changed', sync);
bus.on('v3:cosmetics-applied', sync);
chromeFollowers.set(paneId, () => {
bus.off('scale:changed', sync);
bus.off('theme:changed', sync);
bus.off('v3:cosmetics-applied', sync);
});
}
function _unfollowChrome(paneId) {
const off = chromeFollowers.get(paneId);
if (off) { off(); chromeFollowers.delete(paneId); }
}
// How long a "we cannot even see the pop-out's document" condition has to persist
// before we call it fatal. A SecurityError means the window is not reachable from
// this realm at all, and waiting cannot fix that — but we give it a moment anyway
// rather than bailing on the first tick, because a throw *during* the navigation
// from about:blank to /pane would otherwise take down a pop-out that was about to
// work perfectly. A second is far more than that transition needs, and far less
// than the 10s a user would otherwise stare at a detached panel for.
const UNREACHABLE_GRACE_MS = 1000;
// Wait for the REAL pane document.
//
// window.open() returns immediately, with an `about:blank` document that is
// already readyState 'complete'. Adopt into that and it works for a few
// milliseconds — and then /pane finishes loading, replaces the document, and
// takes the panel with it. The window is left blank and the element is gone.
//
// So we do not trust readyState, and we do not trust 'load' (which may have
// fired for about:blank before we could listen). We wait for the one thing that
// only exists in the document we actually want: pane.html's #fb-pane-root.
function _whenReady(w, onReady, onFail) {
const deadline = performance.now() + 10000;
let reachFailure = null; // why we could never see the pop-out's document
let reachFailureAt = 0; // when we first couldn't
const tick = () => {
if (w.closed) return;
let doc = null;
try { doc = w.document; }
catch (e) {
// A SecurityError here is the one that matters: it means the pop-out
// is not reachable from this realm at all (a separate process /
// browsing-context group), and no amount of waiting will fix it —
// adoptNode can never work.
doc = null;
if (!reachFailure) reachFailureAt = performance.now();
reachFailure = e;
}
// Unreachable, and it has stayed that way. Fail now rather than leaving
// the panel detached and the UI mid-pop-out for the full 10s deadline,
// when we already know this can never succeed.
if (reachFailure && !doc && performance.now() - reachFailureAt > UNREACHABLE_GRACE_MS) {
onFail(new Error('the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it'));
return;
}
if (doc && doc.readyState !== 'loading') {
// Only ever adopt into the document we actually navigated TO.
// about:blank reports readyState 'complete' from the moment
// window.open() returns, and adopting into it means the panel is
// destroyed when /pane replaces it a moment later.
const href = (doc.location && doc.location.href) || '';
const isPaneDoc = href.indexOf('/pane') >= 0;
if (isPaneDoc) {
// Prefer pane.html's own root, but never fail for want of it —
// a stale cached copy of the page (or a future rename) must not
// leave the user with a blank window and no panel.
const root = doc.getElementById('fb-pane-root') || doc.body;
if (root) { onReady(root); return; }
}
}
if (performance.now() > deadline) {
let why;
if (reachFailure) {
why = 'the pane window\'s document is NOT reachable from this window ('
+ reachFailure.name + ': ' + reachFailure.message
+ ') — it is in a separate process, so the element cannot be moved into it';
} else if (!doc) {
why = 'the pane window exposed no document at all';
} else {
why = 'the pane window never loaded /pane (it is showing '
+ ((doc.location && doc.location.href) || 'an unknown URL')
+ ', readyState ' + doc.readyState + ')';
}
onFail(new Error(why));
return;
}
setTimeout(tick, 25);
};
tick();
}
function _adopt(w, root, spec, el) {
const doc = w.document;
_copyStyles(doc);
// The panel was almost certainly a fixed/absolute overlay pinned to a
// corner of the app. In a window of its own that positioning is nonsense —
// it would sit 72px from the top of a 380px window, still 288px wide, still
// casting a drop shadow over nothing. Neutralise the *placement* while
// touching nothing else about how it looks.
el.classList.add('fb-paned');
root.appendChild(doc.adoptNode(el));
doc.title = spec.title + ' — fee[dB]ack';
// Keep the pane window's theme and interface scale in step with the app for
// as long as it is open. Stopped in unplace().
_followChrome(spec.id, doc);
// THE ELEMENT MUST LEAVE BEFORE THE DOCUMENT DIES.
//
// When the user closes a pane window, its document is torn down — and the
// panel is inside it. The node itself survives (we hold a reference) and
// comes home looking perfect: right markup, right classes, right size. But
// it comes home DEAD: every event listener in the subtree is gone with the
// document that hosted them. A panel that renders and does nothing.
//
// The `closed` poll cannot save us: by the time `w.closed` is true, the
// document is already gone. `beforeunload` fires while it is still alive, so
// this is the last moment we can get the element out — and panes.close()
// adopts it back into the main document synchronously.
//
// We attach it HERE, not when the window was opened: back then the window
// still held its throwaway about:blank document, and a listener registered
// on that is discarded when /pane replaces it.
w.addEventListener('beforeunload', () => {
if (panes.isOpen(spec.id)) panes.close(spec.id);
});
}
function place(spec, el) {
const w = window.open(
window.location.origin + '/pane',
FRAME_PREFIX + spec.id,
'popup,width=' + spec.width + ',height=' + spec.height,
);
if (!w) {
// Popup blocked. Throw BEFORE the manager records anything, so the
// caller's panel stays exactly where it is — and say so out loud rather
// than appearing to do nothing.
if (window.fbNotify) {
window.fbNotify.show({
title: 'Pop-out blocked',
message: 'Allow pop-ups for this site to detach ' + spec.title + '.',
icon: '⚠️', accent: '#f59e0b',
});
}
throw new Error('pop-up blocked');
}
wins.set(spec.id, w);
_startReaper();
// Take the element out of the document NOW, not when the window is ready.
//
// Everything below this line is async: the window has to load /pane before
// there is anything to adopt into. But the manager emits `panes:opened` as
// soon as we return, and the chip reacts by putting its "popped out" stub
// where the element used to be — so for that whole gap the user would see
// BOTH the real panel and a stub claiming it had left. On a window that
// never loads, that lasts the full 10s timeout.
//
// Detaching is not destructive: the node keeps its owner document (this
// one), its listeners and its closures. It is simply out of the tree,
// waiting — and if the window never loads, closePane() puts it straight
// back at its home.
el.remove();
_whenReady(w, (root) => {
try { _adopt(w, root, spec, el); }
catch (e) {
console.error('[panes] failed to move', spec.id, 'into its window', e);
panes.close(spec.id); // brings the element home
}
}, (err) => {
console.error('[panes]', spec.id, err);
panes.close(spec.id); // never strand the element in a dead window
});
// The pane window's 'beforeunload' listener is registered in _adopt(), NOT
// here: a listener added now would attach to the window's throwaway
// about:blank document and be discarded when /pane replaces it.
}
function unplace(id, el) {
_unfollowChrome(id);
// Hand the element back unmarked. The manager returns it to its home right
// after this, and it must arrive as the plugin left it — a panel that
// stayed .fb-paned would come back with its own positioning stripped.
if (el) el.classList.remove('fb-paned');
const w = wins.get(id);
wins.delete(id);
// The manager adopts the element back into this document immediately after
// this returns, so the window is empty by the time it closes.
if (w && !w.closed) { try { w.close(); } catch (e) { /* already gone */ } }
}
function focus(id) {
const w = wins.get(id);
if (w && !w.closed) { try { w.focus(); } catch (e) { /* the OS may refuse */ } }
}
// A BROWSER blocks window.open() outside a user gesture, so a pane remembered
// here cannot be restored on page load — it would only ever produce a "blocked"
// toast. Such a pane comes back in the dock, and the chip pops it out again on
// the user's next click. The DESKTOP app has no such restriction, so there a
// pane left popped out comes back popped out, where you left it.
const isDesktop = !!(window.feedBackDesktop && window.feedBackDesktop.panes);
panes.registerHost({
id: 'window',
priority: 10,
autoRestore: isDesktop,
place, unplace, focus,
});
// Our windows; they must not outlive us. A pane window whose opener is gone
// holds an element belonging to a dead document — there is nothing left to
// dock it back into.
window.addEventListener('beforeunload', () => {
wins.forEach((w) => { if (!w.closed) { try { w.close(); } catch (e) { /* ignore */ } } });
});
})();

Some files were not shown because too many files have changed in this diff Show More