From 2413991c5a3fb023375439854a2fb70dbbc92c0d Mon Sep 17 00:00:00 2001 From: "K. O. A." Date: Sat, 18 Jul 2026 21:11:10 -0400 Subject: [PATCH] feat(highway_3d): fret wires flash on a confirmed hit (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(highway_3d): fret wires flash on a confirmed hit The fret wires were static scenery: gold inside the anchor lane, grey outside, and nothing tied them to what the player was actually doing. Give them a job. Widen the lane/neck contrast so the wires around the active lane read as a focus cue, and flash the wires bracketing a note when a scorer confirms it. A fretted note lights the wire behind it and the wire it is pressed against; a chord lights only the outermost wires of its shape, so it reads as one bracketed block rather than a picket fence; an open string has no fret of its own and its gem is drawn as a slab spanning the lane, so it lights the lane's edge wires instead. Gated on the provider verdict, never the proximity heuristic -- the latter only means "near the strike line", so it would flash on every passing note whether or not it was played. With no scorer attached the neck behaves exactly as before. Emissive (and emissiveIntensity) carry the flash, not albedo: these are MeshStandard materials in a scene with no envMap, so raising albedo alone barely brightens them. Every value is a named constant -- see FRET_WIRE_* -- because the look is a taste call that wants tuning by eye, not a derivation. Signed-off-by: Kris Anderson * feat(highway_3d): cap the fret-wire flash at one outer pair Fast passages overlap their decay tails: consecutive notes on nearby frets left three, four, five wires glowing at once — the picket fence the chord rule was written to avoid, arriving through time instead of through a shape. The apply pass now decays every wire's glow state as before, but flashes only the outermost pair of the lit span (or the single wire when only one is above threshold). Interior wires keep decaying invisibly — the base tier loop re-seeds their materials each frame — so the bracket tightens naturally as the outer tails expire, and a hit inside the current span widens nothing. Net effect: at most two wires are ever lit, and everything currently glowing reads as one bracket, exactly like a chord. Signed-off-by: topkoa * feat(highway_3d): chord flash frames the lane, not the shape The lit lane strip spans the anchor's width (minimum ~4 frets), which can run a fret past the chord's outermost fret. The chord flash bracketed the shape (wire behind its lowest fret, wire at its highest), so on those anchors the bracket sat one wire INSIDE the lit lane — reading as misaligned rather than as a frame around what's lit. Chord hits now light the anchor lane's edge wires: the exact wires the lane strip itself spans, and the same pair open strings already use, so every hit shape inside a lane produces the same bracket. The shape's own outer pair survives only as the fallback for charts with no anchors. Fretted and open intensities merge into one entry (they light the same two wires now), and an all-open chord on an anchor-less chart still degrades to no flash rather than a bad index. Signed-off-by: topkoa * feat(highway_3d): gem rims flash string-coloured, wire-fashion On a confirmed hit the gem's outline now flashes in the STRING'S OWN colour with the same intensity treatment as the fret wires — the FRET_WIRE_HIT_INTENSITY emissive ramp, faded by the provider's alpha — instead of the fixed spring-green mHitBright rim. Just the rims: the lateral face fill keeps its existing green, and the sustain trail is untouched. Mechanics mirror the wires' pattern. mRimFlash[s] is one material per string (created with the other per-string materials, palette-retint aware, fog-exempt, disposed in teardown); drawNote() assigns it as the outline on a good verdict and records the verdict alpha into a per-frame per-string max (_rimFlashIn); the flash pass applies the intensity ramp once per string. Shared-per-string is the same compromise mGlow already makes — two same-string gems flashing in different phases share the brighter alpha. No decay tail of our own, deliberately: the material is only assigned while the provider confirms the note, and the provider's alpha already fades. When it goes silent the outline reverts, so idle intensity never shows. Signed-off-by: topkoa * feat(highway_3d): wire flash is a lightning strike, not a lingering glow The flash was instant-on with a 0.32 s exponential tail, and a held sustain kept re-feeding it — wires stayed lit for the whole note. The requested feel is a shock: light hits the frets, they jolt, it's over. The flash is now a one-shot pulse triggered on the input's rising edge: a near-instant crack up (RISE 25 ms), a fast fall (FALL 160 ms) shaped (1-u)^2 so it drops hard then eases out, with a 26 Hz flicker biting into the fall (the electric shudder — the crack itself stays clean), then hard zero. A held 'active' verdict keeps the input high continuously, which by construction triggers nothing new: one strike per hit, and the wires go dark while the note rings on. A re-strike after the provider goes silent re-triggers cleanly. Seeking backward or a long stall clears all pulse state, and a pulse whose strike time lands ahead of the playhead after a seek is discarded. The outer-pair bracket rule is unchanged — it now selects across pulses instead of decay tails. Knobs: FRET_WIRE_HIT_RISE / _FALL / _FLICKER_HZ / _FLICKER_DEPTH (replacing FRET_WIRE_HIT_DECAY). Signed-off-by: topkoa * fix(highway_3d): one wire strike per judged hit, not per wire edge The strike trigger was a rising edge on each WIRE's input, which merged distinct hits: two consecutive correct notes on the same fret kept that wire's input continuously high, so the second note produced no strike at all. The wires must respond to what the player did — one strike per judged hit-zone event. The trigger is now per event identity, using the same seen-map pattern as _sparkSeen: the first frame a note gets a good verdict its key (string|fret|time — or the chord key for a strum, which strikes once as a unit) lands in _fwStruck and requests a strike on its wires; the event never fires again however long its verdict stays live. Because every producer is gated, any nonzero input in the apply pass IS a fresh strike, so it restarts a pulse already in flight — a rapid re-hit on the same wire re-cracks instead of being swallowed. Seeks clear the map (replayed notes strike again); it is size-bounded like _sparkSeen. Envelope, flicker, and the outer-pair rule unchanged. Signed-off-by: topkoa * Revert the lightning-strike experiment — back to the decaying glow Reverts d003532 and e05d90e. The wire flash returns to its original behaviour: instant-on at the provider's alpha with a smooth exponential fade (FRET_WIRE_HIT_DECAY 0.32 s), held sustains keep their wires lit while the note rings, and no flicker. The outer-pair bracket, lane-framed chords, and string-coloured gem rims are untouched. Signed-off-by: topkoa * test: wire-tier assertions follow the named constants The two render-order tests pinned the old literal hexes (idle 0x666688). The tiers moved to named constants with a retuned idle (FRET_WIRE_IDLE_HEX 0x4A4A60); the tests now assert the code uses the constants AND pin the constants' values, so a future retune is a deliberate two-line change here rather than a silent one. Signed-off-by: topkoa * fix(highway_3d): clamp provider alpha in the rim-flash path (review) The wire-flash path clamps the note-state provider's alpha to 0..1; the rim-flash accumulation used it raw, so a provider returning >1 would over-drive emissiveIntensity. Clamped to match. Signed-off-by: topkoa * test: add fret inlay dots (renderOrder 3) to the hierarchy header (review) Signed-off-by: topkoa * test: accurate depth-flag claims, anchored depth assertions (review) Two review findings on the render-order test file, both correct: The header claimed ALL 3D-highway materials use depthTest:false, making renderOrder "the only" draw-order control — but the accent halo materials set depthTest:true. Now says "nearly all", names the exception, and calls renderOrder the primary control. A header someone trusts mid-debug must not overclaim. The fret-wire depthTest/depthWrite assertions matched anywhere in screen.js, which is full of other depthTest:false materials — the test would keep passing if the wire material dropped the flags. Both are now anchored to the wire material literal via FRET_WIRE_IDLE_HEX (unique to it), as two separate anchored matches so property order inside the literal still isn't pinned. Signed-off-by: topkoa --------- Signed-off-by: Kris Anderson Signed-off-by: topkoa --- CHANGELOG.md | 1067 +- plugins/highway_3d/plugin.json | 2 +- plugins/highway_3d/screen.js | 31994 +++++++++++---------- tests/js/highway_3d_render_order.test.js | 941 +- 4 files changed, 17135 insertions(+), 16869 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 224037e..3769d89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,525 +1,542 @@ -# 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 -- **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. - -### 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 `
` - 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 (25–31 rows in the DOM instead of 50,000); shorter lists are unchanged. -- **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 checks — the 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 -- **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 `