Compare commits

..
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 d1cd1b3bbb refactor(server): extract the settings routes into routers/settings.py (R3)
GET/POST /api/settings, /api/settings/reset, and the two-phase atomic
export/import bundle (/api/settings/export|import) move to lib/routers/settings.py
with their exclusive helpers (the relpath allowlist validator, the atomic writer,
the library-DB snapshot + sqlite integrity gate, the config-type validator, the
bundle schema). Bodies verbatim except @app->@router and the seam reads:
meta_db->appstate.meta_db, CONFIG_DIR->appstate.config_dir,
_running_version->appstate.running_version(), and _default_settings->
appstate.default_settings (the canonical defaults builder stays in server.py —
the scan + artist-links code share it — and is injected as a new seam callable).

server.py: 5,539 -> 4,478 (-1,061).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2397 passed (154 settings cases incl the
export→import round-trip + library-DB snapshot/restore + relpath-allowlist SSRF/
traversal guards, retargeted onto the settings module). eslint 0.

BEHAVIORAL — needs an on-device settings export→import round-trip sign-off before
merge (do not merge on green CI alone).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:16:36 +02:00
73127d5416 refactor(server): extract the album-art routes into routers/art.py (R3) (#862)
The six song-art routes — GET /api/song/{f}/art, .../art/cover-search,
.../art/candidates, POST .../art/upload, .../art/url, DELETE /api/art/{f}/override
— plus their exclusive helpers (the ETag/304 response machinery, _save_art_override,
_url_host_is_internal, _fetch_art_url + the art size/redirect caps) move to
lib/routers/art.py. Bodies verbatim except @app->@router and the seam reads:
meta_db->appstate.meta_db, ART_CACHE_DIR->appstate.art_cache_dir, and the three
shared art helpers that stay in server.py (used by the song/delete routes too)
-> appstate.<callable> (_song_pack_art_exists, _art_override_paths — already
seam-injected for the enrichment worker — plus a new art_safe_name slot). The
CAA / release-search transport lives in lib/enrichment.py and is reached as
enrichment.X. LocalLibraryProvider.get_art now calls art_router.get_song_art.

server.py: 5,988 -> 5,540 (-448).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2399 passed (33 art serve/candidates/
override/url cases incl the SSRF-guard _url_host_is_internal + _fetch_art_url
size-cap tests, retargeted onto the art module); test_packaging 43; eslint 0.
Boot smoke: /art 404, /art/candidates 404, DELETE /override 200 from the router.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:56:53 +02:00
165475d115 refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3) (#861)
* refactor(server): move the metadata-enrichment subsystem into lib/enrichment.py (R3)

MusicBrainz / Cover-Art-Archive / AcoustID transport, the match-scorer glue, and
the background enrichment worker (~930 lines, 61 defs) leave server.py as one
cohesive unit. Bodies are verbatim; the only changes are seam reads:

  meta_db / config_dir / sloppak_cache_dir / art_cache_dir -> appstate.<slot>
  _song_pack_art_exists / _art_override_paths (stay in server.py for the art +
    delete routes) -> appstate.<callable> (new seam slots, injected by reference)
  _env_flag -> env_compat.env_flag_compat (the existing identical helper)
  _artist_title_from_filename -> imported from metadata_db (its home)
  the User-Agent VERSION lookup: Path(__file__).parent ->
    Path(__file__).resolve().parents[1] (lib/enrichment.py -> app root)

server.py drives the worker through the module (import enrichment; the routes +
scan lifecycle call enrichment.X). Tests that faked the network on `server`
(_mb_http_get, _enrich_network_enabled, _caa_http_get, ...) now patch the same
names on `enrichment` — the module attribute is resolved at call time, so one
setattr reaches both the routes and the worker's internal callers. Acyclic:
enrichment imports appstate/appconfig/dlc_paths/metadata_db/mb_match/
acoustid_match/sloppak/loosefolder, never server.

server.py: 6,917 -> 5,988 (-929).

Verified: pyflakes clean (bar the pre-existing File/safe_join/tuning_name/ET);
route table IDENTICAL (143); full pytest 2400 passed (140 enrichment/art cases
incl the offline-safety + transport-error-pauses-pass contracts that fake the
network); test_packaging 44 passed (enrichment.py resolves under lib/); eslint 0.
Boot smoke: /api/enrichment/status + POST /kick serve from the new module.

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

* test: reset enrichment worker state between tests (CodeRabbit)

lib/enrichment.py now owns the worker, and it stays imported for the whole
session while the `server` fixtures pop-and-reimport `server` — so the cancel
Event / status dict / caches would leak across tests, and a stale `_enrich_cancel`
could short-circuit a later direct `_background_enrich()`. An autouse conftest
fixture clears that process-global state before each test.

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

* test: tighten the enrichment-reset fixture (CodeRabbit)

Narrow the import guard to ImportError (not blind Exception, BLE001), and stop
clearing _caa_index_locks — it's guarded by _caa_index_locks_guard, so an
unlocked clear() would race a still-alive worker, and its per-release mutexes
carry no test state.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:32:21 +02:00
508829c012 refactor(server): extract /api/tunings into routers/tunings.py + lib/appconfig.py (R3) (#858)
ship-ci / ci (push) Waiting to run
The merged-tuning-catalog route moves to lib/routers/tunings.py, verbatim except
@app->@router, CONFIG_DIR->appstate.config_dir, and the two seam substrates it
needed:

  - lib/appconfig.py — the pure config.json reader `_load_config` (used by ~11
    server sites + future config-reading routers). server.py re-imports it, so
    those call sites and any `server._load_config` test reference are unchanged.
  - appstate.tuning_providers — the TuningProviderRegistry instance injected by
    reference (a stable object mutated in place via register()/unregister()), so
    the router reads the same registry plugins populate through plugin_context.
    The instance stays defined in server.py, so `server.tuning_providers` still
    resolves — zero test retargets.

The tuning constants (DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS,
freqs_to_midis) already live in lib/tunings.py and are imported directly.

server.py: 6,960 -> 6,917.

Verified: pyflakes clean (bar the pre-existing unused `tuning_name` import);
route table IDENTICAL (143); full pytest 2400 passed (110 tuning/config cases);
eslint 0. Boot smoke: /api/tunings serves referencePitch + tunings + tuningMidis.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:43:19 +02:00
cce95cbd1e refactor(server): extract the diagnostics routes into routers/diagnostics.py (R3) (#857)
The three /api/diagnostics/* routes (export, preview, hardware) plus their
exclusive payload-cap helpers and the `_diag_*` normalisers. Bodies verbatim
except @app->@router, CONFIG_DIR->appstate.config_dir, _running_version()->
appstate.running_version() (a new seam slot; the impl stays in server.py where
the settings region also calls it), and the builtin-plugins lookup in
_diag_plugins_roots: Path(__file__).parent -> Path(__file__).resolve().parents[2]
(routers -> lib -> app root; plugins/ ships at the app root in every packaging
path).

The pure caps/normalisers (_diag_cap_console/_dict/_contributions,
_diag_coerce_bool, _diag_normalize_include, _DIAG_MAX_*) are re-exported from
server.py so the existing `server._diag_*` / `server._DIAG_*` tests keep
resolving — none of them monkeypatch these, so no test retargets.

server.py: 7,216 -> 6,960.

Verified: pyflakes clean (bar the intentional re-export lines); route table
IDENTICAL (143); full pytest 2399 passed (77 diag/packaging + 122 diagnostic-
matched cases incl the cap/coerce/normalize suites); eslint 0; Codex pending.
Boot smoke: /hardware, /preview, and POST /export (200 application/zip) all
serve from the new router location.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:27:52 +02:00
32ebc7671e refactor(server): extract the version route into routers/version.py (R3) (#856)
ship-ci / ci (push) Waiting to run
GET /api/version + its exclusive _safe_http_url URL validator. Bodies verbatim
except @app -> @router and the VERSION-file lookup: Path(__file__).parent (the
app root when this lived at the top level) -> Path(__file__).resolve().parents[2]
(routers -> lib -> app root). VERSION ships at the app root in every packaging
path (Dockerfile COPY VERSION /app/, desktop bundle).

server.py: 7,275 -> 7,214.

Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (33 in
test_version_endpoint, incl the URL-validation + env-override cases); eslint 0.
Boot smoke: /api/version returns the real version + validated source/license URLs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:56:36 +02:00
46f3be7fd7 refactor(server): extract XP + per-song stats into routers/stats.py (R3) (#855)
The last of the progression cluster. XP award (1 route) + per-song practice stats
(record/recent/best/top/per-song, 5 routes) — meta_db-only apart from the two
seam accessors record_stats uses (get_progression_content, builtin_diagnostic_
filename, both landed by shop/progression). Bodies verbatim; @app -> @router,
meta_db -> appstate.meta_db, _as_int from metadata_db, _clean_str from reqfields.

Three scattered source blocks (xp, the stats block, and the separated
/api/stats/{filename:path} which the /api/library/practice-suggestions route
splits off) are assembled into one module and mounted once. Registration order
is preserved WHERE IT MATTERS: the /api/stats/{filename:path} catch-all is
assembled LAST inside the router, so it still can't shadow the fixed /recent
/best /top paths — verified against the live route table (recent/best/top all
precede the catch-all) and the route SET is identical to origin/main (143).

server.py: 7,478 -> 7,275.

Verified: pyflakes clean; route set identical + catch-all-last; pytest 2401
passed (113 across song_stats/profile/progression); eslint 0. Boot smoke:
/stats/recent /best /top all 200 (not shadowed), xp/award 200.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:45:35 +02:00
76159c16cd feat(diag): --debug ASIO routing diagnostics in static bundle (#852)
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

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

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

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

* feat(diag): --debug ASIO routing diagnostics in static bundle

Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug);
inert in the Docker sphere and normal desktop runs.

- [asio-diag] getCurrentDevice= full device object on outputType change
  (catches ASIO drivers reporting a non-'ASIO' type name)
- [asio-diag] renderer-bus: full feeder decision vector, change-gated
  (running/exclusive/stems/juceMode/elementSong/want/mode)
- [asio-diag] setSink: every sink flip with ctx state + rate

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:42:41 +02:00
4cc8fa3b4d refactor(server): extract the profile routes into routers/profile.py (R3) (#854)
6 routes (get/set profile, bundled+custom avatars, avatar upload/serve, progress)
+ the exclusive _list_bundled_avatars helper. Bodies verbatim; @app -> @router,
meta_db -> appstate.meta_db, CONFIG_DIR/STATIC_DIR -> appstate.config_dir/
static_dir (seam), _clean_str from reqfields, _get_progression_content() ->
appstate.get_progression_content().

No STATIC_DIR test retarget: _list_bundled_avatars reads appstate.static_dir but
test_profile_api doesn't patch STATIC (only the sloppak/audio/traversal suites
do, for handlers that stay in server.py). server.py: 7,594 -> 7,478.

Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed (61 in
test_profile_api); eslint 0. Boot smoke: GET /api/profile 200 (drives
get_progression_content), /avatars lists via appstate.static_dir, /progress 200.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:38:03 +02:00
f9f33320ac refactor(server): extract the progression routes into routers/progression.py (R3) (#853)
4 routes (overview/add-paths/onboarding/events, spec 010) + their EXCLUSIVE
helpers (_goal_ui_progress, _progression_overview 112L) + the
_PROGRESSION_EVENT_TYPES whitelist. Bodies verbatim; @app -> @router,
meta_db -> appstate.meta_db, _clean_str from reqfields.

The two SHARED server accessors read through the seam: get_progression_content
(added for shop #851) and builtin_diagnostic_filename (new slot — a trivial
const-returning fn shared with the stats router's api_record_stats). Both are
injected via the second appstate.configure() after their defs (the import-top
configure runs before them). The cache + fns stay in server.py, so
test_progression_api's server._progression_content patch is untouched — 0 retarget.

server.py: 7,798 -> 7,594.

Verified: pyflakes clean; route table IDENTICAL (143); both seam accessors wired;
pytest 2401 passed (63 in test_progression_api); eslint 0. Boot smoke: GET
/api/progression 200 (drives _progression_overview + both accessors), events
400 on bad body.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 00:25:53 +02:00
5f58af4faa refactor(server): extract the shop routes + inject get_progression_content into the seam (R3) (#851)
The progression-content substrate: `_get_progression_content` (a lazy,
double-checked-locking content cache) is now published into the appstate seam as
a CALLABLE. The cache global + lock + the function stay in server.py (startup
uses it, and test_progression_api patches `server._progression_content`
directly), so ZERO test retargeting — routers just call
`appstate.get_progression_content()`.

Because the accessor is defined at server.py:1152 but the import-top configure()
runs at :346, a second `appstate.configure(get_progression_content=...)` publishes
it right after the def (configure is idempotent/additive).

First consumer: routers/shop.py (3 routes: buy/equip/list). Bodies verbatim;
@app -> @router, meta_db -> appstate.meta_db, _clean_str from reqfields,
_get_progression_content() -> appstate.get_progression_content(). This unblocks
stats/progression/profile next (all share the accessor).

server.py: 7,880 -> 7,845.

Verified: pyflakes clean; route table IDENTICAL (143); pytest 2401 passed
(test_progression_api's server._progression_content patch still works via the
kept cache); packaging guard; eslint 0. Boot smoke: GET /api/shop 200 (drives
appstate.get_progression_content), buy 400 on bad body.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:42:02 +02:00
ea8834862d refactor(server): batch the small meta_db user-state endpoints into routers/library_extras.py (R3) (#850)
work keeper-chart prefs (3) + favorites toggle + tags list + saved toggle +
session/continue — 7 routes across 5 domains, all meta_db-only (0 setattr, 0
helpers). Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db,
_clean_str from reqfields.

These were scattered singletons with no natural neighbor, so they're grouped as
"small library/user-state endpoints" and mounted once. All paths are distinct
and non-overlapping, so registering them together doesn't change routing:
verified the route SET is identical to origin/main (143) AND that no moved path
shadows or is shadowed by another (order-independence check).

server.py: 7,880 -> 7,833.

Verified: pyflakes clean; route set identical + order-independent; pytest 2401
passed; packaging guard 45; eslint 0. Boot smoke: tags/session GET 200,
favorites/saved toggle (400 on missing filename), work/charts 200.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:37:24 +02:00
2281cac438 refactor(highway): lift 79 per-instance closure vars into hwState (R3c H lift) (#849)
* refactor(highway): lift 79 per-instance closure vars into `hwState` (R3c H lift)

Collapses createHighway()'s 79 mutable closure `let`s into one per-instance
`hwState` object. Scope-resolved rewrite via acorn + eslint-scope: 1059 edits
(1057 references + 79 defs - the deleted `let canvas, ctx, ws`), with the four
names shadowed in inner scopes (chartTime/ctx/notes/chordTemplates) resolved
correctly so only closure-bound refs move. Enables the later module split:
extracted renderer/ws modules close over `hwState` as a factory arg, so
multi-panel plugins (highway_3d, note_detect, splitscreen) don't share one
highway's state.

Container is `hwState`, NOT `H` — `H` is already canvas height (70 uses). The
frame-time gate caught that collision instantly (0 draws, `H._drawHooks is not
iterable` in the shared draw-hook path).

PERF (the whole risk): identical to the pre-lift baseline. Draw p50 2.1-2.2 ms,
p95 2.7-3.0 ms (pre-lift 2.7-3.2), measured on the Arcturus feedpak, headless.
Each closure-slot read became a `hwState.<slot>` monomorphic property load; the
hot loop pays nothing. On-device: Byron confirmed the 2D highway plays smoothly.

Tests: the ~30 highway JS suites brace-extract functions/patterns from the
source; their state references + the monotonic-clock vm sandbox now use
`hwState.<slot>` (the const _CHART_MAX_INTERP_MS etc. stay top-level, not
lifted). node --test: 1030/1030 green. Two self-inflicted over-replacements
caught and reverted (`_lefty` is a prefix of the 3D-local `_leftyCached`;
`STRING_COLORS` a suffix of `DEFAULT_STRING_COLORS`) — substring replaces on the
brace-extract regexes need word care.

Transformer saved at ~/.local/share/feedback-editor/highway-h-lift.mjs.

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

* test(highway): pin the setNoteStateProvider assertion to hwState._noteStateProvider (CodeRabbit)

The [^}]* form matched an unqualified _noteStateProvider =, so a regression to
closure-level state could still pass. Require the hwState-qualified assignment.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:19:20 +02:00
b6098e3695 perf(harness): add 2D-highway frame-time measurement + capture the R3c gate (H0) (#848)
* perf(harness): add 2D-highway frame-time measurement + capture the R3c gate (H0)

scripts/perf-baseline.mjs gains a `--song` mode: it wraps requestAnimationFrame
before any page script, TAGS the frames the highway actually painted (via
highway.addDrawHook), starts playback, and reports draw-frame p50/p95/p99 over
`--frames` seconds across `--runs` runs. Tagging is the point — ~half the rAF
callbacks are other cheap loops (~0.1 ms); averaging them in would hide a
renderer regression, so only draw frames are counted.

This is the metric the plan says gates the highway.js split (R3c) but the harness
never measured (it did server latency + boot + heap only, and the R0 numbers were
against an empty library). docs/perf-baseline.md now records the pre-lift baseline
on the Arcturus feedpak: p50 ~2.2 ms, p95 spread 2.7-3.2 ms across 3 runs. The
H-container lift (next) re-runs this on the same box and must stay within noise.

Maintainer/CI-only tooling; the existing no-`--song` run is unchanged.

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

* perf(harness): close page on error + MD040 fence + CHANGELOG (CodeRabbit)

- frameTimeOnce now closes its page in a finally, so a failing run doesn't leak
  the page until the final browser.close() (CodeRabbit).
- fenced code block gets a bash language hint (MD040).
- CHANGELOG mentions the new --song frame-time mode.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:15:07 +02:00
cbc65458e3 refactor(server): extract the wishlist routes into routers/wanted.py (R3) (#847)
Free after _clean_str moved to lib (#841): wanted's deps are JSONResponse,
_clean_str (reqfields), app + meta_db (seam). 3 routes (list/add/remove),
0 setattr targets, 0 helpers to relocate.

Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at
the original site; 143-route table identical. No test retargeting.

server.py: 8,003 -> 7,974 (this branch is independent of the chart PR).

Verified: pyflakes clean; route table identical; pytest 2401 passed (52 in
test_wanted_api); eslint 0. Boot smoke: add wishlist entry -> list -> delete,
400 on missing artist+title (_clean_str path).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:20:10 +02:00
7c87538d6b refactor(server): extract the chart routes into routers/chart.py (R3) (#846)
Unblocked by the DLC-path substrate (#843): chart's only server-module deps are
now app + meta_db (both seam); _get_dlc_dir/_resolve_dlc_path come from dlc_paths,
sloppak/loose detection from the shared lib modules. 4 routes (split/unsplit/
work/fileinfo), meta_db-only otherwise. 0 setattr targets, 0 helpers to relocate.

Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db. include_router at
the original site; 143-route table identical to origin/main. No test retargeting.

server.py: 8,003 -> 7,909.

Verified: pyflakes clean on the router; no new undefined/dead in server.py; route
table identical; pytest 2401 passed (74 across work_charts/context_menu/
group_filter/packaging); eslint 0. Boot smoke: chart/work 200, chart/fileinfo
resolves the real pack path through _resolve_dlc_path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:15:25 +02:00
c8701991cb fix(ws_highway): swallow a mid-stream WebSocketDisconnect quietly (#845)
* fix(ws_highway): swallow a mid-stream WebSocketDisconnect quietly

Pre-existing (byte-identical to origin/main), flagged by CodeRabbit on #844: the
outer try's only guard was `except Exception as e: log.exception("highway_ws
unhandled error")`. The inner `except WebSocketDisconnect` covers ONLY the
post-`ready` keep-alive loop, and the drum_tab/notation sends have their own
localized guards — but the ~13 other streamed sends (beats, sections, notes,
chords, anchors, …) fall through to the blanket handler. Since
WebSocketDisconnect is an Exception subclass, a routine tab-close mid-load was
logged as an error at whichever send was in flight.

Fix: a dedicated `except WebSocketDisconnect: return` before the blanket handler,
matching the two localized guards and the lib/ coding guideline.

tests/test_ws_highway_disconnect.py drives highway_ws with a fake websocket that
raises WebSocketDisconnect on the first streamed send (`loading`), over a real
minimal sloppak. Negative-checked: removing the guard makes it fail (the
disconnect is logged as `highway_ws unhandled error`); the fix passes. Uses a
raw handler on `feedBack.server` rather than caplog, since configure_logging()
reroutes that logger through structlog where caplog doesn't observe it.

Full suite green. Closes the review thread on #844.

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

* test(ws_highway): assert streaming stops after disconnect (exactly one send) — CodeRabbit

The stub now raises only on the first send and the test asserts ws.sends == 1,
so a handler that caught the disconnect and kept streaming would fail. Still
negative-checked: removing the guard fails the test.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:30:53 +02:00
514461167e refactor(server): extract the highway WebSocket into routers/ws_highway.py (R3) (#844)
The single largest handler in server.py — the 902-line /ws/highway/{filename}
chart streamer — plus its 3 exclusive helpers (_pick_smart_arrangement,
_sanitize_authors, _sanitized_song_offset). server.py: 9,008 -> 8,003 (-1,005,
the biggest single R3 cut).

Clean move despite the size: the handler's only server-module deps are the 4
path constants + 3 exclusive helpers + app + log. Everything else it uses is
either NESTED inside the handler (_evict_audio_cache, _fill_scale_degree,
_manifest_entries, _tone_names, _xml_rank, _send_keepalives) or imported from
the shared lib modules (song/audio/sloppak/drums/notation/dlc_paths/metadata_db).

- Path constants read through the appstate seam: added static_dir /
  sloppak_cache_dir / audio_cache_dir slots (config_dir already there);
  server.py configures them. Bodies otherwise verbatim (@app.websocket ->
  @router.websocket, PATHS -> appstate.*, log -> module logger).
- sloppak_cache_dir IS setattr-patched, so the 3 test_highway_ws_* suites now
  also `setattr(appstate, "sloppak_cache_dir", ...)` next to their existing
  server patch. _sanitize_authors unit tests import it from routers.ws_highway
  (it moved). No other test churn.
- Removed 23 now-dead imports from server.py (song/audio/drums/notation/
  bisect/contextvars/structlog/WebSocket*/_arr_smart_sort_key) — diffed against
  the origin/main unused-import baseline so only NEWLY-dead ones went.

owns_tmp (assigned, never read) moved verbatim — it's pre-existing dead on
origin/main too; left as-is to keep the move faithful.

Verified: route table identical to origin/main (143, paths/methods/order);
handler body verbatim spot-checked; pyflakes clean (server has no new
undefined/dead); pytest 2400 passed; packaging guard 53; eslint 0. Boot smoke:
the highway WS streams the full chart (song_info/beats/sections/notes/chords/
notation/anchors/drum_tab -> ready) BYTE-for-byte the same message sequence as
origin/main across arrangements 0/1/2, zero tracebacks.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:12:54 +02:00
0dcc9136b6 refactor(server): move DLC path resolution to lib/dlc_paths.py (R3 substrate) (#843)
The keystone for the path-dependent routers (ws_highway, song, audio-local-path,
sloppak): the "where do the song files live + safe containment" layer leaves
server.py so a router can reach it.

- `_resolve_dlc_path` (pure — args only) moves verbatim.
- `_get_dlc_dir` reads the env-derived paths through the appstate seam
  (appstate.dlc_dir/dlc_dir_env/config_dir) instead of module globals, so
  lib/dlc_paths.py does no import-time IO.
- appstate gains `dlc_dir`/`dlc_dir_env` slots (config_dir already there);
  server.py configures them. All three are env-derived, so a setenv+reimport
  fixture reconfigures them for free — ZERO setattr retargeting.
- server.py RE-EXPORTS both (`from dlc_paths import _get_dlc_dir,
  _resolve_dlc_path`), so its 24+16 call sites AND the tests that reach
  `server._get_dlc_dir()` / `server._resolve_dlc_path()` directly
  (test_dlc_junction, test_highway_ws_*) resolve unchanged — no test edits.

server.py: 9,085 -> 9,008.

Verified: _resolve_dlc_path byte-identical to origin/main; _get_dlc_dir's only
change is the three path identifiers -> appstate.*; pyflakes clean; route table
identical (143); pytest 2407 passed (test_dlc_junction + both highway_ws suites
green via re-export); packaging guard 52; eslint 0. Boot smoke: library scans
(8 songs, _get_dlc_dir), a real song resolves + serves art (_resolve_dlc_path),
and the highway WS reaches `ready` with notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:41:42 +02:00
6da01c55a4 fix(playlists): split cover decode (400) from persist (500), unique temp, existence re-check (#842)
Two pre-existing cover-upload issues CodeRabbit flagged on #841 (verbatim move,
so correctly not fixed there):

1. One `except Exception as e` wrapped BOTH the PIL decode and the img.save/
   tmp.replace, returned 400 for both, and echoed `e` — so a disk/permission
   failure was mislabeled a client error and could leak a filesystem path. Now:
   decode/validation -> 400 "Invalid image" (generic); save/replace failure ->
   logged 500 "could not save cover" (no detail).
2. A shared `{pid}.png.tmp` let two concurrent uploads clobber each other's temp
   file. Now a unique `tempfile.mkstemp` in the cover dir, atomic replace to
   publish. Plus an existence re-check just before publishing so an upload that
   raced a playlist delete can't leave an orphan cover.

mkstemp itself is INSIDE the try (Codex catch): an unwritable dir / full disk
raises there and is the same persistence failure as save/replace, so it hits the
logged generic-500 path instead of escaping as an unhandled 500. Cleanup guards
`tmp is not None` for the mkstemp-failed case.

Did NOT add a full per-playlist critical section (CodeRabbit's "heavy lift"):
FeedBack is single-user (Principle I), so a cover upload racing a delete on the
same id can't happen — documented in the code rather than building a lock
framework for a precluded race.

tests/test_playlist_cover_errors.py pins all of it; negative-checked three ways:
the old single-except-400 shape fails the 500 + no-leak-400 tests, and moving
mkstemp back outside the try fails the temp-creation-500 test. Fix passes 5/5.
Full suite 2405 passed; boot smoke: valid cover 200, bad image -> generic
"Invalid image" 400, no .tmp litter.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:16:22 +02:00
a883f9213f refactor(server): extract the playlists routes into routers/playlists.py (R3) (#841)
The biggest router yet — 12 playlist routes + custom covers — and the first that
needs the config-path seam. server.py: 9,302 -> 9,085 (-217).

Three causally-linked pieces, all required for playlists:
- `config_dir` joins the appstate seam (the plan always put the path constants
  there; deferred in S3, needed now). It's env-derived, so the ~49
  pop-and-reimport fixtures reconfigure it for free — ZERO setattr retargeting.
  STATIC_DIR/SLOPPAK_CACHE_DIR (patched via setattr) stay in server.py until a
  router that reads them is extracted, and get retargeted then.
- `_clean_str` (pure request-field sanitizer, 14 callers) -> lib/reqfields.py;
  server.py imports it back. Unblocks wanted/saved/collections/profile/... later.
- routers/playlists.py: bodies verbatim, `@app`->`@router`, `meta_db`->
  `appstate.meta_db`, `CONFIG_DIR`->`appstate.config_dir`, `_clean_str` from
  reqfields, `_ART_CACHE_HEADERS` as a local const (art keeps server.py's).
  The two exclusive cover helpers (_playlist_cover_path/_url) move with it.

include_router at the original site; full 143-route table identical to
origin/main. One test retarget: test_playlists_api called
`server._playlist_cover_path` directly -> now imports it from routers.playlists
(reads appstate.config_dir, which the `server` fixture configures).

Verified: pyflakes clean; route table identical; pytest 2401 passed (28 in
playlists+collections+appstate); packaging guard 51 (auto-picked up reqfields);
eslint 0; boot smoke drives create/rename/add-song/cover-upload/serve/delete —
the cover writes 1.png under CONFIG_DIR THROUGH appstate.config_dir and serves
200 with an mtime cache-bust token; a wrong-typed name field still 400s via
_clean_str; demo untouched.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:58:09 +02:00
4fd0cd49e7 fix(loops): take the DB lock for the count+insert and the list read (#840)
Two pre-existing races in the loops routes, flagged by CodeRabbit on #839 (the
verbatim extraction moved them unchanged from server.py, so they correctly
weren't fixed there):

1. save_loop computed `COUNT(*)` OUTSIDE meta_db._lock, then inserted inside it.
   Two simultaneous unnamed POSTs read the same count and both mint "Loop N".
   Fix: one lock scope around COUNT + INSERT.
2. list_loops read the shared single connection (check_same_thread=False) with
   no lock, so it could overlap a POST/DELETE commit. Fix: read under the lock,
   like every writer.

Low severity in context — FeedBack is single-user (Principle I), so concurrent
unnamed-loop POSTs essentially can't happen — but each fix is one lock scope.

tests/test_loops_concurrency.py pins both with a threading.Barrier that releases
16 workers into save_loop at once. Negative-checked: reverting the COUNT back
outside the lock fails the uniqueness assertion 5/5 runs; the fix passes 3/3.
pytest 2400 passed; on-device two unnamed POSTs -> ['Loop 1','Loop 2'].

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:16:01 +02:00
b41361eb1b refactor(server): extract the loops routes into routers/loops.py (R3) (#839)
Third router. Practice loops (saved A/B regions per song): GET/POST/DELETE
/api/loops, meta_db-only (0 setattr targets, 0 helpers to relocate per
router_scan.py). Bodies verbatim; @app -> @router, meta_db -> appstate.meta_db.
include_router at the original site; 143-route table identical to origin/main.

server.py: 9,337 -> 9,301.

No test retargeting (test_demo_mode only names the paths in middleware regexes).
Verified: pyflakes clean; route table identical; pytest 2398 passed; packaging
guard green; eslint 0; boot smoke drives POST (auto-names "Loop N") / GET / DELETE
/ missing-fields error, and demo mode 403s both writes while allowing the read.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:05:01 +02:00
6c98aba433 refactor(server): extract the artist-alias routes into routers/artist_aliases.py (R3) (#838)
* refactor(server): extract the artist-alias routes into routers/artist_aliases.py (R3)

Second router, picked by router_scan.py: the artist-aliases / Tidy-up (P4) group
ranks at 0 monkeypatch.setattr targets and 0 helpers to relocate. 5 routes
(list/set/merge/delete aliases + raw-artist picker), all meta_db-only.

Bodies verbatim; only @app.<m> -> @router.<m> and meta_db -> appstate.meta_db.
include_router mounts at the original site; full 143-route table identical to
origin/main (paths, methods, order). No test retargets: test_artist_alias drives
via TestClient(server.app) + server.meta_db, neither of which moved.

Verified: pyflakes clean on the router; no new undefined in server.py; JSONResponse
still used in server.py (not dead); pytest 2398 passed (18 in test_artist_alias);
packaging guard green; eslint 0; boot smoke drives all 5 routes end-to-end
(set ACDC->AC/DC, read back, 400 on missing fields, delete).

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

* docs: credit both router extractions in the size-exemptions rationale (CodeRabbit)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:55:03 +02:00
b3215694e7 fix(build): move appstate.py + routers/ under lib/ so the desktop app ships them (#836)
The packaged desktop app died at startup:

    File ".../Resources/slopsmith/server.py", line 71, in <module>
        import appstate
    ModuleNotFoundError: No module named 'appstate'

feedback-desktop's scripts/bundle-slopsmith.sh copies a HARDCODED list of core
files into the bundle -- server.py, VERSION, lib/, data/, static/,
plugins/__init__.py. The root-level appstate.py (#833) and routers/ (#834)
shipped fine in Docker, passed every test, and were silently dropped from the
packaged app.

Both now live under lib/, the one core directory every packaging path already
copies wholesale -- Dockerfile `COPY lib/`, docker-compose.yml, and the desktop
bundler's `cp -r lib` -- and that all three put on sys.path (on Windows via the
embeddable-Python ._pth, where PYTHONPATH is ignored: build-windows.sh writes
`../slopsmith` and `../slopsmith/lib`). No feedback-desktop change and no new
release are needed for this to take effect.

lib/ is also the CORRECT home under Principle V, and always was once the design
settled: with the injection seam, appstate.py constructs nothing and does no
import-time IO, and a route module only builds an APIRouter. The premise that
forced root placement -- "appstate opens sqlite at import" -- stopped being true
when configure() replaced ownership. The Dockerfile / .dockerignore /
docker-compose.yml entries added for the root layout are reverted; nothing else
in core changes (git mv, so --follow survives).

tests/test_packaging.py is the guard: it walks server.py's module-level imports,
keeps the ones resolving inside this repo, and fails if any lives outside a
directory the packagers copy -- with the ModuleNotFoundError spelled out. So the
next root-level core module can't ship broken. Negative-checked: restoring
appstate.py to the root fails it; the message names the file and the four
packaging files a root module would have to teach.
(It also has to skip `origin in {"built-in","frozen"}` -- on 3.14 the frozen
stdlib reports origin="frozen", and Path("frozen").resolve() lands inside the
repo, which flagged `os` and `stat` as first-party.)

Verified: the bundler's copy replicated exactly into a temp dir and booted with
PYTHONPATH=<bundle>:<bundle>/lib -- `import server`, `import appstate`,
`import routers.audio_effects` all resolve, appstate.meta_db is server.meta_db,
143 routes. The same simulation against origin/main reproduces the production
ModuleNotFoundError. pytest 2398 passed (2348 + 50 new); route table still
identical to origin/main (paths, methods, order); docker build context reaches
lib/appstate.py and lib/routers/ with no __pycache__; native uvicorn boot smoke
serves /api/version, /api/library, the moved audio-effects router, and all three
migrated plugins' src/ graphs; eslint 0 errors.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 17:16:34 +02:00
ebe59d3f97 refactor(server): extract the audio-effects routes into routers/audio_effects.py (R3) (#834)
ship-ci / ci (push) Waiting to run
The first route module through the appstate seam (#833). Picked BY MEASUREMENT,
not by the plan's guess: a transitive dep-closure scan over every route group
ranked audio-effects at 0 monkeypatch.setattr targets and exactly one exclusive
helper. (The same scan disproved the plan's assumption that artists/aliases was
free -- api_artist_links reaches _mb_http_get and _enrich_network_enabled, both
setattr targets.)

Bodies are verbatim. The only edits are mechanical:
  @app.get(...)            -> @router.get(...)
  audio_effect_mappings.x  -> appstate.audio_effect_mappings.x

The singleton read must stay a module attribute resolved at call time, so a
re-imported server re-publishes a fresh DB into the seam and monkeypatch reaches
this module. `routers/` never imports `server`: server -> routers -> appstate.

`app.include_router(...)` sits exactly where the routes used to be defined --
FastAPI matches in registration order, so the mount site preserves it. Verified
by diffing the FULL route table against origin/main: 143 routes, identical
paths, methods AND order.

server.py: 9,445 -> 9,386 lines. `fastapi.Query` went dead with the move and was
removed (the other four unused imports are pre-existing on main).

Packaging: COPY routers/ /app/routers/ plus `!routers/` + `!routers/**` in
.dockerignore (that file opens with a blanket `*`). Verified against the real
docker daemon: routers/ reaches the build context, __pycache__ does not.

Verified: pyflakes clean on routers/; no new undefined name in server.py;
pytest 2348 passed (75 in the audio-effects + demo-mode suites); eslint 0
errors; boot smoke drives all five routes end-to-end (create -> read back ->
activate -> clear -> delete -> 404 on missing -> 400 on bad body), Query(...)
still 422s on a missing required param, and demo mode still 403s all four
moved write routes while allowing the read.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:24:31 +02:00
d6f2df14f7 feat(server): add appstate.py, the router seam (R3) (#833)
* feat(server): add appstate.py, the router seam (R3)

Routes moving out of server.py need `meta_db` and friends, but must not
`import server` -- that goes circular the moment server imports them back.
server.py keeps CONSTRUCTING its singletons and now injects them once via
`appstate.configure(...)`; routers read them back as module attributes at call
time (`import appstate; appstate.meta_db`). The Python analogue of the frontend
refactor's `configureX({...})` seams and of the plugin `setup(app, context)`
contract: dependencies flow one way, server -> routers -> appstate.

Two properties are load-bearing, both pinned by tests/test_appstate.py:

1. `import appstate` constructs nothing and touches no disk. This is why 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 -- verified.
2. Reads must be late-bound. `from appstate import meta_db` freezes the binding
   and defeats both a later configure() and monkeypatch.setattr -- the same
   read-only-binding trap as ES imports.

configure() raises on an unknown slot instead of silently creating a global
nothing reads, and the suite asserts server ACTUALLY calls it. Negative-checked:
dropping the configure() call fails exactly the two wiring tests while the other
five stay green -- those five are the false-green a seam test must not be.

The new suite imports server through an `isolated_server` fixture that patches
CONFIG_DIR to tmp_path and closes both DB connections on teardown. An unguarded
`import server` constructs MetadataDB + AudioEffectsMappingDB under the real
`~/.local/share/feedback` (reproduced: running the file alone created
web_library.db + audio_effects.db there). The full suite now leaves the real
config dir untouched.

Packaging: `COPY appstate.py /app/` plus a .dockerignore allowlist entry. That
file opens with a blanket `*` exclusion, so root-level Python must be re-allowed
explicitly -- without it the image build fails on the COPY. Verified against the
real docker daemon (build context reaches /app/appstate.py). docker-compose.yml
gains the dev bind-mount; docker-compose.nas.yml runs the baked image, so the
COPY covers it. `routers/` will need the same two entries when it lands.

Verified: pyflakes clean; pytest 2348 passed (2341 + 7 new); eslint 0 errors;
boot smoke serves /api/version, /api/library, /api/audio-effects/mappings, and
all three migrated plugins' src/ graphs, with `appstate.meta_db is server.meta_db`
asserted against the live import.

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

* test(appstate): address CodeRabbit — restore slots on teardown, really re-import

Two real findings on #833, both fixed:

(1) `isolated_server` closed server's DB connections but left `appstate.meta_db`
    and `appstate.audio_effect_mappings` published and pointing at the closed
    handles -- a live-looking, dead singleton for any later test. Teardown now
    snapshots and restores both slots.

(2) `test_reimporting_server_republishes_the_fresh_singletons` never performed a
    second import: it only re-asserted what `test_server_wires_the_seam` already
    covers, so it could not detect the very staleness it names. (I introduced
    that regression while fixing Codex's CONFIG_DIR isolation finding.) It now
    pops `server`, re-imports under a SECOND CONFIG_DIR, and asserts the seam
    republishes -- `second_server.meta_db is not first_db` and
    `appstate.meta_db is second_server.meta_db`.

Negative-checked both directions: simulating an appstate-OWNED singleton
(configure() only-first-wins) now fails the re-import test, and dropping
server's configure() call still fails exactly the two wiring tests.

NB CodeRabbit's committable suggestion inserted the snapshot above the
fixture docstring, which would have demoted it from __doc__; written by hand
instead.

pytest 2348 passed; the full suite leaves the real ~/.local/share/feedback
untouched.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 15:06:22 +02:00
94a58b7a42 refactor(server): extract AudioEffectsMappingDB into lib/audio_effects_db.py (R3) (#831)
Move-only, same shape as the MetadataDB extraction. The core-owned song/tone ->
audio-effect-provider routing index leaves server.py for a flat lib/ module.
The class body is byte-identical; server.py reconstructs exactly from
origin/main minus the cut range plus the import-back and the call site.

server.py: 9,705 -> 9,433 lines.

The only non-verbatim change is the constructor seam: `__init__` takes
`config_dir` instead of reading the module-level CONFIG_DIR, so the module does
no IO at import (Principle V). The `audio_effect_mappings` singleton stays in
server.py -- no route, no test, and none of the `monkeypatch.setattr(server, ...)`
targets move. No import went dead.

Verified: pyflakes clean on the new module; no new undefined name in server.py;
pytest 2341 passed; eslint 0 errors; boot smoke drives the extracted DB
end-to-end (POST a mapping -> GET reads it back -> audio_effects.db lands in
CONFIG_DIR, proving the config_dir seam) and all three migrated plugins still
serve their src/ module graphs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:30:05 +02:00
58120745bc refactor(server): extract MetadataDB into lib/metadata_db.py (R3) (#830)
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) --
moves out of server.py into a flat `lib/` module. Every moved block is
byte-identical to its server.py original; server.py is exactly origin/main
minus the six cut ranges, minus the now-dead `import contextlib`, plus the
import-back block and the constructor call site.

server.py: 14,037 -> 9,705 lines.

The one non-verbatim change is the seam that lets the class leave server.py:
`MetadataDB.__init__` now takes `config_dir` explicitly instead of reading the
module-level CONFIG_DIR, so `lib/metadata_db.py` does no IO at import
(Principle V). The `meta_db` singleton stays in server.py, so `server.meta_db`
(282 refs) and `server.app` (67 refs) resolve unchanged and no route moves.
None of the 114 `monkeypatch.setattr(server, ...)` targets moved.

Logging still goes through the `feedBack.server` logger, so log filters and
caplog assertions resolve to the same logger object.

`tests/test_settings_export_library_db.py` imports `_apply_pending_db_restore`
from metadata_db (the test moves with its subject); no other test changed.

Verified: pyflakes clean on the new module (zero undefined names, zero unused
imports) and no new undefined name in server.py; pytest 2341 passed;
node --test 1030 passed; eslint 0 errors; uvicorn boot smoke serves
/api/version, /api/library, and all three migrated plugins' src/ module graphs
(stems, studio, editor -> 200).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:18:59 +02:00
e134f5c802 fix(highway_3d): size Butterchurn output canvas buffer to fill the highway (#820)
* fix(highway_3d): size Butterchurn output canvas buffer to fill the highway

The 3D-highway Butterchurn background set only the output canvas CSS size
and called setRendererSize(), but never sized the canvas DRAWING BUFFER
(canvas.width/height). Butterchurn does not size the output canvas itself
(renderToScreen viewports to the reported size into the default
framebuffer), so the buffer stayed at the browser default 300x150 while the
viewport was the full highway. Only the bottom-left ~300x150 of the pattern
was drawn, then CSS-stretched across the whole highway -- zoomed, soft, and
aspect-wrong, worse the larger the panel.

Add _bcApplySize(cssW, cssH): set the drawing buffer to the device-pixel
render size (round(css * min(DPR, 1.5))), confine every layer (canvas,
backdrop, scrim, tint) to the highway rect, and report the same device px
to setRendererSize so buffer == on-screen viewport. Seed the buffer at
create and switch createVisualizer to pixelRatio:1, textureRatio:1 (DPR is
now folded into the reported size, so buffer == viewport == internal
texsize, no double-counting). render() and resize() both route through it.

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

* chore(highway_3d): bump to 3.31.5 (3.31.4 taken by #823 on main)

Rebased onto main; #823 already shipped 3.31.4 (per-panel camera), so this
Butterchurn buffer-sizing fix advances to 3.31.5.

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

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-10 13:12:16 +02:00
f1bae9774c fix(gp2rs): write beat times at 6-decimal precision so imported tempo matches Guitar Pro (#819)
* fix(gp2rs): write beat times at 6-decimal precision

The editor/timeline derives per-bar BPM from beat spans
(bpm = beats*60/span), which amplifies rounding: at millisecond
(3-decimal) precision a constant-tempo GP import (e.g. 140 BPM) shows a
spurious per-bar "tempo drift" of ~0.05-0.7 BPM because most bar lengths
don't land on a ms boundary (worse for fast/odd meters). gp2rs computes
these beat times exactly from the GP tempo map, so the only precision
loss is the ebeat/startBeat format string. Writing them at 6 decimals
(microseconds) makes the derived tempo match GP's authored value.

Verified on GP5 imports (Highway to Hell 116, Equivalence 140, Living
After Midnight 138): the derived per-bar BPM collapses from two drifting
values to the single authored constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013JgxKh99UAeQqmhzSc73tv
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* test(gp2rs): compare ebeat times by value, not string

The 6-decimal beat-time write makes _assert_ebeats' exact-string compare fail
("0.500" vs "0.500000"). These tests only assert spacing, so parse both sides
to float — precision-agnostic, no need to rewrite every parametrized list.

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

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-10 13:12:10 +02:00
751209b80e Serve exact MIDI notes from GET /api/tunings (tuningMidis) (#829)
The tunings catalog is served as frequencies scaled to the reference pitch,
so every consumer that needs note identities (the v3 instrument badge's
TUNING_NOTE, plugins converging on the host profile model) reconstructs MIDI
numbers client-side via log2 — a rounding footgun at non-440 references, and
N copies of code the host can run once.

Add `tuningMidis` to the response: the same catalog keyed instrument-count →
name → absolute open-string MIDI notes (low → high). Built-ins come straight
from TUNING_PRESET_MIDIS (no float round-trip at all); provider-contributed
entries are inverted from their frequencies at the served reference via the
new freqs_to_midis() (the inverse of open_midis_to_freqs, garbage-guarded).
Purely additive — referencePitch/tunings are unchanged.

Tests: every built-in round-trips at 440; round-trip holds at 430/432/444/450
(the exact case client-side reconstruction drifts on); garbage rejected.


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

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:05:39 +02:00
1c1a0e0268 feat(audio): renderer-bus feeder — song audio into engine output under exclusive mode (Phase 2) (#828)
ship-ci / ci (push) Waiting to run
* feat(audio): route feedpak full-mix natively under exclusive output

Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

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

* feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)

Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:51:09 +02:00
0a16014698 fix(keys_highway_3d): stop auto-connect clobbering the global MIDI device (#825)
Opening the keys highway could silently switch the user's configured MIDI
device. Two coupled defects in the plugin's MIDI selection:

1. _midiAutoConnect only consulted the plugin's own localStorage pick
   (keys3d_midi_pick); with none saved it fell straight through to "first
   non-loopback device", ignoring the core midi-input domain's global
   selection (Settings -> Input Setup, window.slopsmith.midiInput.getSelected()).

2. _midiConnect unconditionally persisted every connect to BOTH the local
   pick and the shared domain selection (mi.select). So the first-device
   guess got frozen locally and overwrote the global default that other
   consumers (drums, Input Setup) rely on.

Make the domain-wide selection the source of truth: _pickMidiTarget now
resolves global -> legacy local pick (fallback + name-recovery for stale
ids) -> first device, and gates the "don't grab a random device" recovery
guard on any configured preference. Gate persistence behind an explicit
`persist` flag so only a deliberate device selection writes the local pick
and the shared global; auto-connect and programmatic (audio-input) opens
open the resolved device for the session without touching either store.
mi.select() is not needed to open (open takes the logicalSourceKey directly),
so dropping it from the auto path costs nothing.

Interim step toward instrument-scoped selection in the midi-input domain
itself (the input_setup wizard is already per-instrument, but the domain
stores a single selection); tracked as a separate core follow-up.

Pure decision logic extracted to _pickMidiTarget and covered by unit tests
in data_layer.test.js.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 22:40:33 +02:00
K. O. A.andGitHub aaf593bdd1 Merge pull request #823 from got-feedBack/feat/highway-per-panel-camera
feat(highways): per-splitscreen-panel Camera Director cameras
2026-07-09 16:38:01 -04:00
Kris AndersonandClaude Opus 4.8 14d116d827 fix(highways): validate panel index before indexing the camera map
_resolveFreeCam() (keys/drum) and _freeCamFor() (highway_3d) guarded the panel
map lookup with only `i != null`, so a non-integer / negative / string index
from panelIndexFor() could resolve an unintended or inherited property (e.g.
map['toString']) instead of cleanly falling back to the global camera. Gate the
index on `Number.isInteger(i) && i >= 0` before `map[i]`, matching the hardening
already applied in _bgPanelKey(). Extend the resolver tests with float/string
(prototype-key) cases. Behavior change only for malformed indices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:33:57 -04:00
Kris AndersonandClaude Opus 4.8 54b5d2e426 docs(highways): JSDoc the delegating _freeCamFor wrappers (keys, drum)
Finish the docstring pass for the CamDir bridge functions changed in this PR:
convert the two per-panel _freeCamFor delegating wrappers to JSDoc, matching the
pure _resolveFreeCam / _ssApi helpers. Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:28:58 -04:00
Kris AndersonandClaude Opus 4.8 bcee2e8610 fix(highway_3d): _bgPanelKey rejects non-integer panel index; JSDoc bridge fns
- _bgPanelKey() treated any non-null panelIndexFor() return as a valid panel id,
  so a NaN/non-finite index minted a bogus "panelNaN" localStorage key instead
  of falling back to "main". Gate on Number.isInteger(idx) && idx >= 0. (The
  camera path is already NaN-safe — panelsMap[NaN] misses and falls through.)
- Add a NaN/negative-index case to the resolver tests (drum 22, keys 57, pass).
- Convert the camera-bridge helpers' comments to JSDoc (_bgPanelKey, _freeCamFor,
  _resolveFreeCam, _ssApi across the three plugins) to lift docstring coverage on
  the changed surface. Comment/robustness only; no behavior change beyond the
  NaN guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:27:56 -04:00
Kris AndersonandClaude Opus 4.8 a6a5186180 fix(highway_3d): make _bgPanelKey throw-safe on panelIndexFor
Follow-up to the _bgPanelKey alias fix: _freeCamFor already treats
panelIndexFor as potentially throwy and catches to keep framing stable, but
_bgPanelKey called it bare. A throwing splitscreen build would take down
background-settings resolution (and the render path) even though the camera
path falls back safely. Wrap the call in try/catch, falling back to 'main'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:21:14 -04:00
1b3178037b feat(audio): route feedpak full-mix natively under exclusive output (#824)
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:00 +02:00
845255e404 fix(audio-effects): accept pre-rebrand chain plan schema as alias (#816)
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

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

* docs(changelog): note loop-api bridge throttle fix

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

* fix(audio-effects): accept pre-rebrand chain plan schema as alias

The rebrand renamed PLAN_SCHEMA to 'feedBack.audio_effects.chain_plan.v1'
but shipped plugin bundles (rig_builder <= 2.9.x) still send the
slopsmith-era id, so _validatePlan rejected every plan and providers fell
back to their heavyweight legacy load paths (full chain rebuild per poll
cycle — audible as continuous distortion during songs). Accept the old id
as an explicit alias.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:17:57 +02:00
Kris AndersonandClaude Opus 4.8 0d4d8229c7 fix(highways): address review — bg-key alias, drum cam guard, resolver tests
Three review findings on the per-panel camera work:

- highway_3d: _bgPanelKey() resolved splitscreen via window.feedBackSplitscreen
  only, while _freeCamFor() uses the feedBackSplitscreen||slopsmithSplitscreen
  alias it claims to "mirror". If the rename lands, per-panel background settings
  would silently stop being per-panel while the camera stayed per-panel. Resolve
  the alias the same way in _bgPanelKey.
- drum_highway_3d: applyCamera()'s "before first positionCamera()" guard tested
  `_camBaseH == null`, but _camBaseH/_camBaseD were initialized to 0, so the guard
  never fired (and could apply a base-0 pose for a frame). Initialize to null.
- keys + drum: the PR claimed the Camera Director resolver was unit-checked, but
  nothing exercised it. Extract the resolver into pure, exported helpers
  (_resolveFreeCam + _ssApi), delegate the per-instance _freeCamFor to them, and
  add tests/camera_bridge.test.js covering per-panel select, global fallback,
  null-when-absent, throw-safety, and the slopsmith-alias resolution. Drum 15→21,
  keys 50→56, all pass; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 16:15:29 -04:00
Kris AndersonandClaude Opus 4.8 ff8a638d28 docs(highway_3d): name the concrete camera-bridge globals in comments
Address a review note on the free-camera block: the comments described the
bridge as "per-panel-aware" without naming the actual globals. Spell out that
_freeCam comes from _freeCamFor(highwayCanvas) — window.__h3dCamCtlPanels[
panelIndexFor(canvas)] when split, else the global window.__h3dCamCtl, else
null — and update the nearby comment that mentioned only __h3dCamCtl. Comment-
only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 15:58:12 -04:00
Kris AndersonandClaude Fable 5 5aa336961c feat(highways): per-splitscreen-panel Camera Director cameras
Make the three 3D highways read the Camera Director bridge per panel so each
splitscreen panel renders its own camera (independent orbit/height/zoom/tilt/pan),
instead of all panels sharing the focused camera.

- Add a shared `_freeCamFor(canvas)` resolver to each highway: prefer this panel's
  entry in `window.__h3dCamCtlPanels[panelIndexFor(canvas)]`, fall back to the
  global `window.__h3dCamCtl`, else null (100% stock). Defensive on the splitscreen
  global name (feedBackSplitscreen || slopsmithSplitscreen), NaN-safe, allocation-free.
- highway_3d (guitar): source `_freeCam` from the resolver (was global-only).
- keys_highway_3d: adopt the bridge for the first time — layer dolly/height/orbit +
  pan/pitch offsets onto the pan/zoom follow rig at the camera write.
- drum_highway_3d: adopt the bridge — new per-frame `applyCamera()` folds the static
  base pose + kick-pulse dip + free-cam offsets.
- In a follower (popped-out) window there is one panel, so the resolver yields
  whatever camera the plugin set in that window; no highway change needed for pop-out.

Camera Director absent → resolver returns null → renderers behave exactly as before.
Bump each plugin patch version. Existing plugin tests pass (drum 15, keys 30); the
keys "default look unchanged" test confirms the stock path is byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>
2026-07-09 12:24:29 -04:00
Byron GamatosandGitHub 950e348357 R0: module-migration rails (src/ serving, live-edit cache, scriptType loading, governance) (#812)
ship-ci / ci (push) Has been cancelled
Host enablement for the plugin ES-module migration: sandboxed /api/plugins/{id}/src/ serving, no-cache+weak-ETag/304 live-edit caching on src/+screen.js+assets, scriptType:module loader injection + scriptType/minHost manifest passthrough; constitution v1.2.0 + module playbook + signed size-exemptions register + maintainer/CI-only ESLint gate; rerunnable perf-baseline harness. Reviewed by Codex (local), Copilot, and CodeRabbit.
2026-07-08 10:14:40 +02:00
a18a818e8b fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B (#811)
ship-ci / ci (push) Waiting to run
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

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

* docs(changelog): note loop-api bridge throttle fix

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:00:58 +02:00
5cb4ea0623 feat(drums): capture velocities alongside times in unmapped-percussion reporting (#808)
* feat(drums): capture velocities alongside times in unmapped-percussion reporting

Both drum converters opt-in out_unmapped capture (convert_drum_track_from_midi,
convert_drum_track_to_drumtab) gain an index-aligned `velocities` list next to
`times`, carrying each dropped note real dynamics — MIDI velocity verbatim; GP
velocity with the same 1-127 gate as mapped hits, falling back to the 100
import default. A hand-mapping UI (the editor unmapped-notes dialog) can then
restore mapped notes at their source dynamics instead of flattening to v:100
(editor-side consumer: feedBack-plugin-editor#111).

The GP path chronological sort now reorders times and velocities in LOCKSTEP
so multi-voice measures cannot silently reassign dynamics. Additive: callers
that ignore the new key are unaffected.

Tests: extended tests/test_midi_import_drums.py + tests/test_gp2rs_drums.py
(alignment, lockstep sort, out-of-range fallback) — 26 passing.

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

* docs(gp2rs): clarify velocity-default comment, mark dead-path fallback

- The mapped-GP velocity comment conflated GP's authoring default (95,
  Velocities.default) with the drumtab render default (100,
  DEFAULT_VELOCITY in lib/drums.py) used when `v` is omitted. Clarify
  both defaults and that only the latter applies to omitted hits.
- Mark the `else: times.sort()` fallback in the unmapped-percussion
  time/velocity sort as belt-and-suspenders — times and velocities are
  always appended together under the same len<100 guard, so lengths
  can't actually diverge.

No behavior change; comment-only maintainability nits from PR review.

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

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:58:49 +02:00
fadaa154e9 feat(library): sort and badge by personal difficulty rating (#810)
* feat(library): sort and badge by personal difficulty rating

Adds sort=difficulty/difficulty-desc to the library API (correlated
subquery over song_user_meta.user_difficulty, unrated songs pushed to
the bottom either direction, same pattern as the existing mastery
sort) and surfaces the rating as a badge on library cards in both the
v2 grid/tree views and the v3 grid. The rating itself already existed
(song_user_meta) — this just makes it sortable and visible, so it's
no longer only readable in the per-song edit drawer.

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

* fix(library): escape difficulty badge, wire tree view, add changelog+tests

- Wrap song.user_difficulty in esc() at both badge call sites
  (static/app.js ~2082 and ~2283) for XSS-consistency with the
  sibling tuning badge, which already uses esc().
- server.py: query_artists (the classic tree view's data source, used
  by /api/library/artists) never batch-attached user_difficulty the
  way query_page does for the grid, so the tree-view difficulty badge
  added in 75673c3 was unreachable dead code (song.user_difficulty was
  always undefined there). Now attaches it via the existing
  user_meta_map() helper, same pattern as query_page.
- Add an [Unreleased] CHANGELOG.md entry for the difficulty sort +
  badge feature, matching the repo's existing entry format.
- Add tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom
  asserting unrated songs sort to the bottom in both sort=difficulty
  and sort=difficulty-desc directions, and
  ::test_tree_view_songs_carry_user_difficulty covering the
  query_artists fix above.

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

* fix(library): chunk user_meta_map + rebuild stale tailwind css

Address review-bot findings on the difficulty sort/badge:
- user_meta_map now chunks filenames into 400-row batches (like
  overrides_map) before the IN (...) query. query_artists (tree view)
  passes every song across up to 50 artists, which could push the
  placeholder count past SQLite's older variable limit; query_page's
  small pages are unaffected. (CodeRabbit: Stability & Availability)
- Rebuild static/tailwind.min.css: the ◆N difficulty badge introduced
  bg-blue-900/30 + text-blue-300, which were never compiled into the
  committed stylesheet, failing the tailwind-fresh CI gate.

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:20 +02:00
LegionaryLeaderGitHubClaude Opus 4.8coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>byrongamatos
e446b05a99 feat(keys_highway_3d): key layout modes, lane-color opacity & octave lines (#803)
* feat(keys_highway_3d): sharp-layout modes, lane-color opacity, octave lines

Add a Highway layout section to the settings with a rebuilt way to draw
sharps/flats and lanes on the 3D piano highway:

- Sharps & flats layout (keys3d_bg_sharpMode): floating (original raised
  plane), flat (one plane, zero-overlap piano-shaped tiled lanes with the
  naturals evened out), and realistic (one plane, bars sized like the
  physical keys). All geometry lives in pure laneSpanFlat()/laneSpanReal()
  helpers. Default: realistic.
- Lane color opacity (keys3d_bg_laneOpacity, 0-1): fades the pitch-class
  lane tint; at 0 it is a dark floor with guide lines only at the key-block
  boundaries (E-F and each octave), toward 1 full vivid colored lanes. The
  strips, per-lane separators and block lines crossfade with the value.
  Default: 0.
- Octave separators (keys3d_bg_octaveGaps, default on) and Octave line
  contrast (keys3d_bg_octaveContrast, 0-1): the B->C octave line is a dark
  layer scaled by lane opacity plus a bright layer scaled by its inverse,
  so it auto-shifts dark->bright as the lanes fade.

Settings re-read on init() so they apply on the next chart build. All other
behavior (MIDI scoring, palettes, camera, themes, hit feedback) is unchanged.
Unit tests cover the new defaults, the sharp-mode setting, and the lane
geometry (tiling/evening for flat, uniform/overlap for realistic).

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

* Update plugins/keys_highway_3d/settings.html

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update plugins/keys_highway_3d/screen.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(keys_highway_3d): don't trim active-range boundary lane when neighbor sharp is out of range

laneSpanFlat() trimmed a white key's edge for its neighboring black
key's lane even when that neighbor midi fell outside
range.activeLow..range.activeHigh — the neighbor's lane is never
drawn (see the activeLow/activeHigh skip around the lane-strip loop),
so the trim left a dark, unfilled sliver at the active-range boundary
with no sharp lane to fill it. Gate the trim on the neighbor being
in-range; callers that don't pass a range (e.g. the raw-tiling unit
tests) keep the prior unconditional-trim behavior.

Also add the CHANGELOG entry for this PR's feature set, following the
existing keys_highway_3d wording convention (no plugin-local
CHANGELOG exists; plugin.json was already bumped 0.1.2 -> 0.2.0 by
the original commits).

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

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 23:57:14 +02:00
115c3529e9 fix(midi): guard non-positive division in the legacy inline tempo path (#805)
ship-ci / ci (push) Waiting to run
convert_midi_track_to_keys_wire builds its own inline tempo map and
divides by the raw midi.ticks_per_beat at two sites (the tempo-table
precompute and the tick_to_seconds closure). A malformed header with
division == 0 raised ZeroDivisionError, and an SMPTE division (which
mido returns as a NEGATIVE signed short) produced negative/garbage
note times.

Guard the divisor with `ticks_per_beat if ticks_per_beat > 0 else 480`
so both the zero and negative cases fall back to the SMF default. The
`> 0` form (not `or 480`) is required because a negative value is
truthy and would slip past `or`. Positive-division behavior is
unchanged.

Follow-up to #796, which fixed the same class of bug in the newer
convert_midi_tempo_map / _build_tick_to_seconds path.

Adds two focused tests: division == 0 no longer crashes and emits a
non-negative time, and a negative/SMPTE division yields sane
non-negative times.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 10:27:45 +02:00
1bccb8a9e8 feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid (#796)
* feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid

The keys/drums MIDI converters always built a tempo-aware tick->seconds
map internally (to bake note times) and then discarded it — and never
read time_signature meta at all — so every MIDI import landed with no
bars, no measures, and an implied 4/4 no matter what the file said.

New lib/midi_import.py helper convert_midi_tempo_map(midi_path,
track_index) extracts the grid a .mid actually carries:

- tempos: {time, bpm} per tempo event (deduped per tick, 120 default)
- time_signatures: {time, ts: [num, den]} — the song-timeline shape
- beats: one row per beat on the editor grid shape — numbered downbeats
  with a den hint, measure:-1 interior beats; the beat unit follows the
  active signature (6/8 = six eighth-note rows per bar)

Event scope mirrors _build_tick_to_seconds: SMF type 0/1 merge meta
from all tracks, type 2 reads ONLY the chosen track (independent
timelines — callers must never share one grid across type-2 tracks).
Mid-bar signature events (ill-formed but seen in the wild) apply at the
next bar boundary. All times compute from absolute ticks through the
cumulative tempo table and round once at emit — rounding error never
accumulates with song length. A bar-count safety valve guards malformed
files. Consumer: the editor's multitrack MIDI import (tempo-seed
dialog, feedBack-plugin-editor roadmap Phase 3).

Tests: tests/test_midi_tempo_map.py — 10 cases driving the REAL
function against real .mid files built with mido (no stubs): default
grid, tempo bends, 500-bar rounding-drift check, 4/4->3/4 and 6/8
signatures, mid-bar signature deferral, duplicate-tick last-wins,
type-2 meta isolation from a bogus sibling track, empty files, grid
coverage bounds. Full MIDI-adjacent suite green: 55 passed
(test_midi_tempo_map + test_midi_import + test_midi_import_drums +
test_gp2midi) under the project venv.

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

* fix(midi): make convert_midi_tempo_map robust — real division guard, symmetric tempo default, single-pass meta

- Push the ticks-per-beat fallback into _build_tick_to_seconds (the single
  place ticks route through), guarding on `> 0` so a division==0 (malformed)
  or negative SMPTE-division header no longer raises ZeroDivisionError or
  walks the beat grid into negative times. Mirror the guard at the
  convert_midi_tempo_map beat_ticks site. The local `or 480` was cosmetic
  before — the closure still divided by the raw division.
- Seed tempos_out with a 120 BPM row at time 0 when the first set_tempo
  lands after tick 0, symmetric with the (0, 4, 4) time-signature default,
  so the sidecar matches the grid the head of the song actually used.
- Collapse the duplicated meta_source/note_source lists into one
  source_tracks walked in a single pass (meta collection + end_tick).
- Fix a weak assert in test_mid_bar_signature_applies_at_the_next_boundary
  (operator-precedence `(A and B) or C`) to assert den == 4 outright.
- Add tests: non-positive division (0 + negative SMPTE), first tempo after
  start seeds 120@0, explicit SMF type-0 file, and the _TEMPO_MAP_MAX_BARS
  safety valve.

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

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 10:27:17 +02:00
Byron GamatosandGitHub 010edc239b Merge pull request #806 from got-feedBack/fix/tuner-inject-player-button-render
fix(tuner): anchor injectPlayerButton to a direct-child button (feedBack#800)
2026-07-07 10:01:20 +02:00
byrongamatosandClaude Opus 4.8 9fb63fd3b5 fix(tuner): anchor injectPlayerButton to a direct-child button (feedBack#800)
`injectPlayerButton()` anchored the injected Tuner button with
`controls.querySelector('button:last-child')`, which can match a NESTED
button that is not a direct child of `#player-controls`. `insertBefore(btn,
nestedButton)` then throws `NotFoundError` (the reference node must be a
direct child); since injection runs from the tuner's `screen:changed`
handler, the throw propagated out of the player-screen transition and
stalled its render. The v3 path was already safe (plugin-control slot);
only the classic anchor was bad.

Use `:scope > button:last-of-type` (direct child only) with a
`parentNode === controls` guard before insertBefore, falling back to
appendChild. Bump plugins/tuner 1.3.3 → 1.3.4.

Test: tests/plugins/tuner/js/inject_player_button.test.js — extracts the
real function and runs it over a faithful DOM model whose insertBefore
enforces the direct-child invariant; the nested-last-button case reproduces
the throw on the old anchor and passes on the new one (5 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>
2026-07-07 09:54:06 +02:00
K. O. A.andGitHub cb72c5ab34 Merge pull request #802 from got-feedBack/fix/v3-library-filters-drawer
ship-ci / ci (push) Waiting to run
Fix v3 library Filters drawer crash on saved prefs
2026-07-06 19:51:38 -04:00
topkoa 92e78be62d Fix v3 library Filters drawer crash on saved prefs; rename Stems label
applySavedPrefs() rebuilt state.filters without the `genre` key, so with
saved prefs restored from localStorage state.filters.genre was undefined.
Clicking Filters ran renderDrawer(), which indexes f.genre.includes(g)
whenever the library has >=1 genre -> TypeError, renderDrawer aborts, and
openDrawer never removes translate-x-full. The drawer stayed off-screen so
the menu appeared dead. Only triggered for users with saved prefs AND a
non-empty genre list, matching the intermittent report.

Carry genre: [] alongside the other session-only facets (mastery, match),
mirroring the default and clear-all shapes which already include it.

Also rename the visible "Stems (sloppak)" drawer label to "Stems (feedpak)"
to match the public format name used elsewhere in the UI.

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 19:50:34 -04:00
K. O. A.andGitHub 1840170e95 Merge pull request #799 from got-feedBack/chore/lyrics-source-transcribed
Accept spec lyrics_source values (authored, transcribed)
2026-07-06 17:31:11 -04:00
topkoaandClaude Opus 4.8 7cbf9824b1 Drop dead whisperx entry from allowed lyrics_source set
The whisperx->transcribed alias runs before the membership check, so the literal whisperx never reaches _ALLOWED_LYRICS_SOURCES (same reason sng is omitted). Remove the dead entry. Per CodeRabbit review on #799.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:30:26 -04:00
topkoaandClaude Opus 4.8 33146cc7f6 Accept spec lyrics_source values (authored, transcribed)
The feedpak spec (§7.1) defines the lyrics_source vocabulary as {authored, transcribed, user}, but the reader only accepted the legacy {xml, notechart, whisperx, user} set and silently downgraded anything else to "xml". A spec-compliant writer (e.g. the stem_splitter plugin, which emits transcribed for WhisperX-produced lyrics) therefore lost its provenance badge.

Widen the allowed set to the union of the spec vocabulary and the legacy values so both validate, and alias the legacy whisperx engine name to the spec transcribed so existing packs normalise to the spec badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-06 17:24:26 -04:00
69c8ad4e0c fix(settings): don't let a stale DLC path block saving the Demucs server address (#795)
ship-ci / ci (push) Waiting to run
The v3 Settings "Save" button posts dlc_dir together with demucs_server_url,
default_arrangement and av_offset_ms in one request. POST /api/settings
validated dlc_dir first and early-returned "DLC directory not found" before it
ever processed demucs_server_url, so on a machine whose DLC path doesn't resolve
(fresh install, unplugged/network drive, a path carried over from another
machine) setting the Demucs server address silently failed — reported in
got-feedBack/feedBack-demucs-server#3 (macOS 07-05 nightly).

- server: a non-resolving dlc_dir is now recorded as a warning and skipped
  rather than aborting the whole POST, so the co-submitted keys still persist.
  The bad path is surfaced via a new additive `warnings` field and folded into
  `message` so the settings status line still shows it.
- client (v3): the Demucs input now autosaves on blur/enter via a single-key
  persistSetting POST, like every other v3 setting, so it never depends on the
  coupled Save button.
- tests: cover the decoupling and the unchanged happy path.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:51 +02:00
a20dca21bb feat(keys_highway_3d): add note-colour palettes and selectable camera angles (#794)
ship-ci / ci (push) Waiting to run
* feat(keys_highway_3d): add note-colour palettes and selectable camera angles

Give the 3D keys highway player-facing view options and a tuned default
look, so the piano highway is readable out of the box and customisable
from the settings panel without touching code.

Note colours (settings -> Note colours, `keys3d_bg_palette`):
- Octaves (new default): each octave its own hue climbing the rainbow,
  darker sharps, so pitch height reads at a glance on any note range.
- Rainbow: the original per-pitch table (colours unchanged).
- Vivid / Pastel: per-pitch variants.
- Emerald / Ice: single-hue two-tone (uniform naturals, darker sharps).
The pick drives the notes, key glow, lane guides and hit flames, live.

Camera (settings -> Camera angle, `keys3d_bg_camera`):
- Classic (the original low rig) / Elevated / Overhead (new default).
- Height, distance and tilt fine-tune sliders nudge the base vantage the
  auto-pan/zoom follow-motion orbits; presets apply live.

The new defaults are opinionated for plug-and-play (octaves palette,
overhead camera, tilt -0.6); anyone who prefers the original look can
pick Rainbow + Classic. Settings changes are re-read on init() so they
apply on return from the settings screen, not only after a relaunch.

Scoring, hit-timing and MIDI handling are untouched -- these are purely
visual. Numeric FX keys clamp to declared ranges (FX_RANGES); the pure
colour/camera helpers are covered by unit tests (node --test).

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

* fix(keys_highway_3d): make Classic camera reproduce the original rig + drop per-frame camera alloc

Review follow-ups on the palettes/camera feature:

- "Classic" preset now reproduces the historical rig exactly. The tuned
  plug-and-play downward aim (camTilt -0.6 x CAM_TILT_UNITS = -33) is baked
  into CAM_PRESETS.overhead.lookY, and camTilt now defaults to 0 (neutral).
  The default overhead look is byte-identical (effective lookY still -33),
  but "pick Classic for the original look" is now actually true instead of
  leaving a -33 down-tilt applied. settings.html tilt slider defaults to 0.

- _rig() writes into a hoisted reusable object instead of allocating a fresh
  {y,z,lookY,lookZ} literal every frame, honoring the module's documented
  "no per-frame allocations in draw()" discipline. Callers read it
  synchronously and never retain it, so one shared instance is safe.

Tests updated for the neutral camTilt default; adds an invariant test that
the default overhead framing is unchanged and Classic + neutral tilt == the
historical LOOK_Y. Full JS suite green (1069 pass).

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

---------

Signed-off-by: LegionaryLeader <legionaryleader@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-06 10:34:09 +02:00
021ee55f2a Allow .feedpak files in library when uploading (#770)
Signed-off-by: Rob Sassack <rsassack25@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 10:05:27 +02:00
1f621e5fe5 perf(highway): draw the held-sustain glow without ctx.shadowBlur (#729)
shadowBlur cost scales with the blurred device-pixel area. The lit-sustain
trail can span half the canvas, the canvas is DPR-scaled (4x pixels on a
2x Mac), and the blur ran on every frame exactly while a sustain is HELD
— i.e. at the moment the player most notices a hitch. Sustain-heavy songs
(e.g. fingerpicked acoustic charts) hit this constantly.

Replace the blur with three inflated low-alpha fills of the same trail
quad: reads as the same soft shimmering glow (the shimmer LUT still
drives per-frame flicker, feedBack#254 intent preserved) at a flat,
area-independent cost. Also drops the now-dead shadowBlur reset in the
crackle pass; no shadowBlur uses remain in highway.js.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-06 09:49:06 +02:00
612b1f2e0d feat(v3): choose handedness in the instrument selector + onboarding (give lefties a break) (#793)
ship-ci / ci (push) Waiting to run
* feat(v3): add a handedness (left-handed) choice to the instrument selector + 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 went through the tour,
the tuner and calibration all right-handed first (community callout).

Add a "Handedness: Right / Left" row to the v3 instrument badge popover, alongside
Instrument / Strings / Tuning (all player-orientation choices). It writes the same
lefty preference -- highway.setLefty when a live highway exists (flips it
immediately + persists), else the 'lefty' localStorage key the highway reads on
init -- and keeps the Settings "Left-handed" checkbox 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. Full core JS suite green (938). Tests:
tests/js/badges_handedness.test.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UR2Cr7GEu3yMY7SrfxH6c1

* docs: split the spliced Handedness/Colorblind CHANGELOG entries

A rebase pasted the Handedness bullet over the Colorblind preset entry's bold
lead, merging two unrelated Added entries into one run-on bullet. Restore them
as two separate bullets.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 23:50:30 +02:00
ChrisBeWithYouandGitHub 4f6dc233f1 feat(player): seed editor region handoff state (#762)
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
2026-07-05 23:37:39 +02:00
ChrisBeWithYouandGitHub 5be70939e4 feat(v3 library): searchable Cover Art Archive picker in Change-cover (#783)
The cover picker only offered CAA covers from a song's MATCHED release, so an
unmatched song (the city-pop pile) got nothing but Current/Pack/Upload/URL. Add
a search box: GET /api/song/{fn}/art/cover-search?q= searches MusicBrainz
release-groups and returns each album's CAA front-250 thumb; the picker renders
them as pickable tiles (same apply→/art/url path; covers with no CAA art
self-hide). Pre-filled from the song's artist + album/title (romaji fallback), so
a blank-artist pack pre-fills "Junko Yagami …". Reuses the throttled _mb_http_get.
2026-07-05 23:36:49 +02:00
ChrisBeWithYouandGitHub 6aaa2dcf47 feat(v3 library): batch→popup handoff + English-base romaji (metadata-curation capstone) (#782)
* feat(v3 library): click a "No match" badge to fix it — batch → popup handoff

Connects the two halves: the "No match" badge (the unmatched pile) now opens the
Fix-metadata popup for that song in one click, instead of right-click → menu.
The resting badge becomes interactive (pointer-events-auto + hover), carrying a
data-meta-fix hook; wireCards opens window.__fbFixMatch(playTarget) on click and
stops propagation so it doesn't also play the card. Batch tile states stay
non-interactive. Loop becomes: Unmatched filter → see the pile → click one →
fix it. tailwind.min.css regenerated for the badge's hover classes.

* feat(v3 library): show the author's romaji, not blank/native script (English base)

Two changes so an English-speaking base never sees a blank name or native script:

- Filename romaji fallback: a blank-artist CDLC pack ("Artist_Title_v1_p") shows
  nothing useful (artist blank; title = the raw filename), and a match fills it
  with kanji/kana. query_page + pack_fields now surface the author's own romaji
  parsed from the filename ("Junko Yagami — BAY CITY") when the pack has no
  artist of its own — display-only, keyset-safe (raw title stashed for the
  cursor), a real pack artist or a user override still wins.
- Smart adopt: "Use these values" now KEEPS the readable romaji name + title the
  card already shows and takes only album/year/genre (+ art via the pin) from the
  match, so identifying a Japanese song gives "Junko Yagami — BAY CITY — FULL MOON"
  with the right cover, never native script.

Tests: romaji fallback fires for a blank-artist CDLC pack (grid + pack_fields
agree) and is left alone when the pack has a real artist.
2026-07-05 23:35:58 +02:00
1a8540935b fix(gp_autosync): slope-constrained DTW steps — stop path collapse on riff-based songs (#791)
librosa.sequence.dtw's default step sizes permit pure horizontal/vertical
moves; on songs whose chroma is self-similar for long stretches the flat
cost surface let the path collapse (minutes of score onto one audio frame),
so auto-sync produced monotonic-but-garbage sync points and the per-bar
warp imported charts badly out of sync while reporting success.

Use the standard music-sync step pattern [[1,1],[1,2],[2,1]] (local tempo
ratio bounded to 0.5x-2x), falling back to unconstrained steps if the
global length ratio makes it infeasible.

Validated on the reported song (138 BPM tab, YouTube audio): coarse points
now track 1:1, refine holds slopes 0.77-1.04, warped downbeats hit onset
peaks at 3.3x background energy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:12:31 +02:00
OmikronApexandGitHub d567fd5597 Change nightly workflow schedule time
ship-ci / ci (push) Waiting to run
2026-07-05 22:48:01 +02:00
b914612f9d fix(highway_3d): recover from WebGL context loss instead of crashing on alt-tab (#790)
Switching the active window / alt-tabbing away (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 removed in
teardown.

Root cause is a strong hypothesis -- the crash is intermittent and
unreproducible -- but the fix is low-risk and additive and closes a real gap:
there was no context-loss handling anywhere in the renderer.

plugins/highway_3d 3.31.2 -> 3.31.3. Tests:
tests/js/highway_3d_context_loss.test.js (source-contract, like the other
highway_* tests). The sibling keys_highway_3d / drum_highway_3d renderers share
the same gap -- follow-up in their repos.


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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:20:32 +02:00
de002cdc24 feat(highway): add "Colorblind (deuteranope)" string-color preset (#788)
Adds a one-click preset to the shared "Highway String Colors" picker,
next to the existing Okabe-Ito "Colorblind-friendly" preset. Contributed
by a deuteranopic player who found the Okabe-Ito set still hard to
separate: it retunes the six main strings and keeps that set's 7/8-string
colors. Applies to both the 2D and 3D highways via the shared picker,
which writes the slot->hex map the renderers already consume.

Additive frontend-only change to HWC_PRESETS in static/app.js; the picker
UI and both highways pick it up automatically (the preset list is
generated from HWC_PRESETS and applied by id). All 20 highway
string-color JS tests pass; app.js syntax-checks clean.


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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:13:13 +02:00
3100d68a45 feat(v3 library): genre field in the Fix-metadata popup Details tab (#780)
Adds Genre as a fifth Details field (edit / lock / revert / Yours-Pack
provenance), backed by the existing override store. To make it actually useful,
the genre FILTER and FACET now resolve the per-song override (effective genre =
override else scanned pack genre) — guarded so the common no-override case stays
on the plain indexed column — so a corrected/added genre is immediately
browsable. Genre stays a library-only overlay: it is NOT a write-to-file field
(split WRITE_FIELDS = the four file-safe fields from the five DETAIL_FIELDS), so
Write to file leaves the genre override in place and the copy says so. The
Match→Details bridge also carries a candidate's first genre.

Tests: effective-genre facet + filter, and that a value-less lock doesn't invent
an effective genre.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:12:55 +02:00
6397a959a4 feat(library): Match→Details "use these values" bridge (#779)
Connects the popup's two tabs. A match only improves the underlying canon+art;
by design it never silently re-titles the grid. This adds the explicit opt-in
path: each Match candidate (search or Identify-by-audio) gets a "Use these
values →" action that copies its title/artist/album/year into the Details tab
as pending (unsaved) inputs and lands you there for review — pinning the match
too so the art/canon follow. You then Save (overlay) or Write to file. Queue-
review candidates are unchanged (they still accept/pin on click).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:11:14 +02:00
5f499a8a3a feat(v3 library): "Write to file" in the Fix-metadata popup (#778)
* feat(library): "Write to file" in the Fix-metadata popup's Details tab

Completes the confirmed edit model: Save keeps edits as a reversible display
overlay (files untouched); "Write to file" bakes the shown title/artist/album/
year into the pack itself via the existing POST /api/song/{fn}/meta (writes the
manifest, re-stats, coalesces a rescan). On a real file write the now-redundant
override values are cleared (locks kept) and the tab re-renders, so the fields
read from the file as "Pack". Loose-folder / unwritable packs fall back to a
DB-only update and say so (may revert on a full rescan). Secondary button next
to Save; touches only the four file-safe fields, the rest of the pack verbatim.

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

* fix(library): mirror server year coercion in Write-to-file grid sync

update_song_meta coerces a non-numeric/empty year to "" before persisting,
but writeToFile optimistically set song.year to the raw typed text — so the
library card flashed e.g. "abcd" until the next natural refresh. Apply the
same integer coercion client-side so the in-memory song matches what was
written.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-05 21:10:34 +02:00
122 changed files with 19231 additions and 10993 deletions
+25
View File
@@ -123,3 +123,28 @@ jobs:
sys.exit(1)
print(f"Validated {len(manifests)} manifest(s) — OK")
EOF
lint:
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
# dev tooling, never on the serve/Docker path — same category as
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
name: lint
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: ESLint (size norm + module hygiene)
run: npm run lint
+1 -1
View File
@@ -6,7 +6,7 @@ name: Nightly
# builds from release/** come from rc.yml instead.
on:
schedule:
- cron: '0 2 * * *'
- cron: '0 23 * * *'
workflow_dispatch:
permissions:
+29 -1
View File
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
features extend `app.js` and the existing globals (`window.playSong`,
`window.showScreen`, `window.createHighway`, `window.feedBack`).
Native ES modules are a first-class, build-free extension mechanism.
Because `<script type="module">` and `import` are browser features — not
a bundler — a large source file MAY be split into an `import`-ed module
graph of plain source files, with **no build step and no framework**. A
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
`screen.js` becomes a one-line `import './src/main.js'`, and the host
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
route and injects the entry as `<script type="module">`. The classic
global-scope `screen.js` path remains fully supported; both coexist, and
module scripts are still source-served — the no-bundler, no-transpiler,
build-free-at-serve rule is unchanged. Core's own `static/` tree may
migrate to the same module-graph shape (`static/js/…`) over time under
this rule.
**Non-negotiable rules**
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
core. Plugins MAY ship their own bundled assets but core MUST remain
source-served.
- ES-module plugins remain source-served: no bundler or transpiler, and
their own asset URLs (worklets, WASM, images) resolve via
`import.meta.url` — never `document.currentScript`, which is `null`
inside a module. `scriptType:"module"` and the optional `minHost`
version floor are the only new `plugin.json` keys the module path adds.
- Because the core Tailwind stylesheet is prebuilt, it contains only the
classes present in core source at build time. Core's committed
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
runs first). Plugins MUST tolerate dependent globals being absent
at load time and check at runtime
(`typeof window.X === 'function'`).
- **Module load contract**: a `scriptType:"module"` plugin is injected
as `<script type="module">`, whose load event fires only after its
whole static-import graph fetches and evaluates — so the loader's
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
order above) is preserved exactly. The host loads `screen.js` once per
version and `showScreen` re-injects nothing, so a plugin's per-visit
re-initialization comes from its `screen:changed` handler, not from
screen.js re-running; ES-module plugins inherit this unchanged (module
top-level code does not re-execute on same-version re-mount).
## Development Workflow
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
higher-numbered principle's escape hatch is to live in a plugin
with its own bundled assets.
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
+108
View File
@@ -7,7 +7,113 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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 `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`.
@@ -15,6 +121,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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`).
+2
View File
@@ -117,6 +117,8 @@ Notes:
**Frontend scripts**`screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url``document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
## Plugin Best Practices
+92
View File
@@ -0,0 +1,92 @@
# Perf baseline — module-migration refactor
The refactor promises "measured runtime wins, no hand-waved perf claims" and
"screen-entry and frame-time no worse." This is the baseline to hold it to.
Rerun the harness after every phase (R0 → R3c) and compare.
## Running it
```
# 1. start core against a library with real charts (see caveat below)
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
```
The script prints a markdown block; paste it under "Results" below with the date
and the commit it was taken at.
## What it measures
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
`/api/plugins`, `/api/library`, `/api/library/artists`.
- **Cold boot → interactive** — full page load to `networkidle`.
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
soak (a leak signal across a session).
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
"the app booted with its plugins" sanity signal).
**Not yet captured — needs a seeded library with charts** (fill in when run
against a real environment): playback **frame-time p95** on the 2D and 3D
highway, and **screen-entry** (plugin inject → interactive) for
editor / notedetect / highway_3d with a chart loaded. These are the
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
the hooks, they just need real songs in `DLC_DIR`.
## Results
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
highway actually painted count (the other ~half are cheap no-op loops that would
otherwise mask a regression). Any `highway.js` change must re-run this on the
same machine and stay within noise of these numbers.
```bash
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
```
| run | draw frames | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
**p50 ≈ 2.2 ms · p95 spread 2.73.2 ms** (3 runs × 10 s playback, headless
chromium on the dev box). The `H`-container lift changes each closure-slot read
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
hot loop.
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
> harness and lock the methodology.
Server latency (ms), n=50:
| Endpoint | status | p50 | p95 | p99 |
|---|---|---|---|---|
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
Client:
| Metric | Value |
|---|---|
| Cold boot → networkidle | 1268 ms |
| JS heap after load | 10.1 MB |
| JS heap after idle soak | 10.1 MB (no idle growth) |
| Plugin scripts injected | 12 |
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
lands, cold-boot / heap should not regress.
+103
View File
@@ -0,0 +1,103 @@
# Plugin ES-module migration playbook
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
ES-module graph — **no build step, no framework, no bundler**. This is the
mechanism the monolith-killing refactor uses; the host rails for it shipped in
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
contract" in Operating Constraints).
## The shape
```
my-plugin/
plugin.json + "scriptType": "module" ← opt in
screen.js import './src/main.js'; ← the entire file
src/
state.js (0) module state + accessors
util/… (1) pure helpers — real-import testable
…/… (2..4) model → render/audio/io → input
globals.js (5) THE ONLY file that writes window.*
main.js (5) boot: wire modules, register screen:changed
assets/… worklets / WASM / images (unchanged, served as today)
```
`screen.js` becomes a one-line static `import`. The host injects it as
`<script type="module">`, whose load event fires **only after the whole
static-import graph fetches and evaluates** — so the loader's
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
order are all preserved. (A classic IIFE that fired a fire-and-forget
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
## Non-negotiable rules
1. **Source-served, no build.** Modules are plain source files fetched from
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
2. **Layering points downward** — `state → util → commands/model →
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
enforces acyclicity; extract bottom-up so each move only imports
already-extracted layers.
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
surface shrinks to one auditable file; everything else is module-scoped.
4. **Import-time purity.** `node --test` runs a module's top-level code on
import, so a module you want to unit-test must be side-effect-free at import:
no `document` / `window` / `localStorage` at module top level — lift init
into an exported `init()` called by `main.js`. (Constitution Principle V's
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
and use real `import`, retiring the regex/`extractFunction` harness.
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
inside a module. `assets/` lives at the plugin root, so a `src/` module must
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
import.meta.url)` (deeper modules need more `../`). Simpler and
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
cannot share modules with `src/`.
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
`screen.js` once per version and `showScreen` re-injects nothing, so module
top-level code does **not** re-run when the user re-enters the screen at the
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
'screen:changed', …)` handler — exactly as classic plugins (tuner,
minigames) already do. Do not rely on the IIFE re-running.
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
keeps every referenced symbol on `window`); retire inline handlers to
module-side `addEventListener` opportunistically, never as a blocking step.
## The live-edit loop
The host serves `screen.js`, `src/**`, and `assets/**` with
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
So: edit a `src/` file → **refresh the browser** → the edited module returns
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
query on `screen.js` is the legacy version buster; it does **not** propagate
into the `src/` graph and does not need to — ETag/mtime is the correctness
authority for the whole graph.
## Host-version floor (`minHost`)
A migrated plugin *requires* a host new enough to serve `src/` and inject
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
on an older host — is deferred, so bundled plugins are unaffected. Community
plugins should state the floor and not migrate below it.)
## Migration mechanics
- **Move-only PRs.** One slice extracts one module: cut code, add
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
are separate PRs. (Init-lifts for import purity are the one non-pure move —
budget them.)
- **Bottom-up, layer by layer.** Within a layer, independent modules are
independent PRs (a DAG, not a chain); use a git worktree per branch.
- Tests move with their subject and convert to real `.mjs` imports in the same
PR (assertions unchanged).
- Size norm: no source file over **1,500 lines**; legitimate exceptions
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
## Verifying a migration
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
Network); edit a `src/` file → refresh → change visible (`200` on the edited
file, `304` on the rest); leave and re-enter the screen at the same version →
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
this end-to-end before the flagship repos migrate.
+66
View File
@@ -0,0 +1,66 @@
# Size-exemption register
The working norm (constitution Principle II; enforced by the `max-lines` lint
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
it because splitting them would do more harm than good — hot per-frame
renderers, C++, offline generators, cohesive registries. This register is the
list of those exceptions: each row is a **deliberate, signed** decision with a
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
without a *signed* exemption" is unenforceable.
**Rules**
- One row per file: a ceiling, a rationale, a signer, a review trigger.
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
keep them in sync (this register is canonical).
- Files with a scheduled split **plan** are *not* exempt — they live in
"Planned, not exempt" at the bottom so nothing falls between the two states.
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
## Permanent exemptions (structural rationale)
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|---|---|---|---|---|---|
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,4002,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
## Split-when-touched (no scheduled train; row retires when split)
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|---|---|---|---|---|---|
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
## Temporary rows (cleared by a scheduled PR)
| Repo / file | Lines | Cleared by |
|---|---|---|
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
## Watch list (under the norm — no row needed, re-census each phase)
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
(4,478 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and seventeen `routers/` modules (album-art in `lib/routers/art.py`, the settings + export/import bundle in `lib/routers/settings.py`); the ~930-line metadata-enrichment subsystem — MB/CAA/AcoustID transport, matcher, background worker — now lives in `lib/enrichment.py`) ·
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
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) — 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.
+66
View File
@@ -0,0 +1,66 @@
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
// path (constitution Principle I: dev-only tooling is exempt, same category as
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
//
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
// monoliths warn (the "this is over the norm, split it" signal) and shrink
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
// exempted below, mirroring the signed register in docs/size-exemptions.md.
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
// tests). no-unresolved (a HARD error) catches broken import paths;
// no-cycle enforces the downward-only layering rule. Core's classic scripts
// have no import graph, so both are dormant today and become live gates the
// moment module code appears — validated against the first real module
// plugin (R1 pilot).
const importX = require('eslint-plugin-import-x');
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
// Keep in sync; each entry corresponds to a signed row in the register.
const SIZE_EXEMPTIONS = [
{ files: ['**/static/capabilities.js'], max: 1600 },
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
];
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
module.exports = [
{
ignores: [
'node_modules/**',
'static/vendor/**',
'plugins/**/assets/vendor/**',
'**/*.min.js',
'static/tailwind.min.css',
],
},
// Size norm across all first-party JS. Classic scripts are parsed as
// scripts (no import/export); module files get their own block below.
{
files: ['**/*.js', '**/*.cjs'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
rules: { 'max-lines': sizeRule(1500) },
},
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
// entry `import './src/main.js'` screen.js must parse as a module — add its
// glob here in that plugin's migration PR (classic screen.js stays a script).
{
files: ['**/src/**/*.js', '**/*.mjs'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: { 'import-x': importX },
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
// it the import rules silently skip imports they can't resolve.
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
rules: {
'max-lines': sizeRule(1500),
'import-x/no-unresolved': 'error',
'import-x/no-cycle': 'error',
},
},
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
// registered files don't warn below it.
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
];
+28
View File
@@ -0,0 +1,28 @@
"""Reading the app's config.json — the one shared, pure helper (R3).
Extracted verbatim from server.py so route modules that need a config value
(reference pitch, server_config, …) can read it without reaching back into the
host file. server.py re-imports it, so its ~11 call sites and any
`server._load_config` test reference keep resolving unchanged.
"""
import json
def _load_config(config_file):
"""Read and parse config.json. Returns the parsed dict, or None if
the file is missing, unreadable, invalid JSON, or parses to a
non-dict (e.g. the file contains `[]` or `42`). Callers treat None
as "fall back to defaults". Shared between GET and POST so both
handle bad files the same way."""
if not config_file.exists():
return None
try:
# Explicit UTF-8: save_settings()/import write config.json as
# UTF-8 bytes, so the read must not depend on the platform's
# default text encoding (cp1252 on Windows would mojibake or
# UnicodeDecodeError on a non-ASCII DLC path).
parsed = json.loads(config_file.read_text(encoding="utf-8"))
except Exception:
return None
return parsed if isinstance(parsed, dict) else None
+127
View File
@@ -0,0 +1,127 @@
"""Shared application state — the seam that lets route modules reach core
singletons without importing ``server``.
``server.py`` is the host: it owns the FastAPI ``app``, constructs the DB
singletons, and runs the lifecycle. As routes move out into ``routers/`` (R3),
those modules need ``meta_db`` and friends — but they must not ``import
server``, or the import graph goes circular the moment ``server`` imports them
back.
So ``server`` **injects** its singletons here once, at the point it builds them::
# server.py
meta_db = MetadataDB(CONFIG_DIR)
appstate.configure(meta_db=meta_db, ...)
and a router reads them back as **module attributes, at call time**::
# routers/artists.py
import appstate
@router.get("/api/artist/{name}/page")
def artist_page(name):
return appstate.meta_db.artist_page(name)
This is the Python analogue of the injected `configureX({...})` seams the
frontend refactor uses (stems' ``configureStreaming``, studio's
``configureAudioGraph``, the editor's ``src/host.js``), and of the plugin
``setup(app, context)`` contract in Principle III: dependencies flow one way,
``server -> routers -> appstate``, and nothing imports back up.
Two properties this shape buys, both load-bearing:
* **``import appstate`` performs no IO and constructs nothing.** ``server``
still owns construction, so the ~49 test fixtures that do
``sys.modules.pop("server")`` + re-import (to rebuild ``meta_db`` under a
patched ``CONFIG_DIR``) keep working untouched — a singleton *owned* here
would survive that pop and go stale.
* **Reads are late-bound.** Routers must use ``appstate.meta_db``, never
``from appstate import meta_db`` — a ``from`` import freezes the binding at
its current value, so a later ``configure()`` (or a
``monkeypatch.setattr(appstate, "meta_db", fake)``) would not reach the
router. This is the same read-only-binding trap as ES ``import``.
Defaults are ``None`` on purpose: they are inert but *type-honest*, so a router
that runs before ``configure()`` fails loudly on ``NoneType`` instead of
quietly operating on a stand-in.
Slots are added here only when a router actually needs one — this is a seam,
not a grab-bag for everything in ``server.py``.
**Why this lives in ``lib/`` and not the repo root.** Because it constructs
nothing and does no import-time IO, it satisfies Principle V's rule for ``lib/``
modules — and ``lib/`` is the only core directory every packaging path already
copies: the Dockerfile (``COPY lib/``), ``docker-compose.yml``, and
feedback-desktop's ``bundle-slopsmith.sh`` (``cp -r lib``). All three also put
both the bundle root and ``lib/`` on ``sys.path``. A root-level module ships in
Docker but is silently dropped from the packaged desktop app, whose bundler
copies a hardcoded file list — that regression is what moved this file here.
"""
# The singletons routers may read. Every name here must also be a `_SLOTS` key.
meta_db = None
audio_effect_mappings = None
# The tuning-provider registry instance (built-ins + plugin-contributed). A
# stable object mutated in place via register()/unregister() — injected here by
# reference so routers read the same registry plugins populate.
tuning_providers = None
# Config paths. server.py derives these from the environment (fresh on every
# import, so the ~49 pop-and-reimport fixtures keep working) and injects them
# here. Routers read them as `appstate.config_dir` etc. — a module attribute at
# call time. NOTE: config_dir/dlc_dir are env-derived, so a `setenv`+reimport
# test reconfigures them for free; STATIC_DIR/SLOPPAK_CACHE_DIR are patched via
# `setattr(server, …)` in a few tests, so those slots (when added) need their
# tests retargeted to appstate in the same PR.
config_dir = None
dlc_dir = None # the DLC_DIR env value as a Path (Path("") if unset)
dlc_dir_env = None # the raw DLC_DIR env string, "" if unset — distinguishes
# "unset" from Path("")→"." (see dlc_paths._get_dlc_dir)
# Cache/asset dirs. static_dir + sloppak_cache_dir are patched via
# `setattr(server, …)` in a few tests, so a router reading them here needs those
# setattr sites retargeted to `setattr(appstate, …)` in the same PR (ws_highway
# retargets the 3 test_highway_ws_* SLOPPAK sites). config_dir-derived dirs are
# reconfigured for free on a setenv+reimport.
static_dir = None
sloppak_cache_dir = None
audio_cache_dir = None
# Injected callables (not values): server owns the impl + its state, routers call
# through the seam. get_progression_content wraps a lazy content cache that stays
# in server.py (its `setattr(server, "_progression_content")` test is untouched).
get_progression_content = None
builtin_diagnostic_filename = None
running_version = None
# Art helpers that stay in server.py (shared with the art/delete routes) but are
# also called by the enrichment worker in lib/enrichment.py — injected as
# callables to keep enrichment acyclic. art_cache_dir is server's ART_CACHE_DIR.
art_cache_dir = None
song_pack_art_exists = None
art_override_paths = None
art_safe_name = None
# The canonical settings-defaults builder — stays in server.py (shared with the
# scan/artist-links code) but the settings router calls it through the seam.
default_settings = None
_SLOTS = frozenset({
"meta_db", "audio_effect_mappings", "tuning_providers",
"config_dir", "dlc_dir", "dlc_dir_env",
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
"get_progression_content", "builtin_diagnostic_filename",
"running_version",
"art_cache_dir", "song_pack_art_exists", "art_override_paths", "art_safe_name",
"default_settings",
})
def configure(**kwargs) -> None:
"""Publish `server`'s singletons into this module. Called once per
`server` import (and again on re-import), so it must be idempotent."""
unknown = set(kwargs) - _SLOTS
if unknown:
raise TypeError(
f"appstate.configure() got unknown slot(s): {sorted(unknown)}. "
f"Known slots: {sorted(_SLOTS)}. Add the name to _SLOTS if a router "
f"genuinely needs it."
)
globals().update(kwargs)
+287
View File
@@ -0,0 +1,287 @@
"""Core-owned song/tone -> audio-effect-provider mapping index.
Extracted verbatim from ``server.py`` (R3). ``server.py`` still owns the
``audio_effect_mappings`` singleton; this module only supplies the class, so
nothing here touches config paths at import time — the caller passes
``config_dir`` in.
"""
import json
import sqlite3
import threading
from pathlib import Path
class AudioEffectsMappingDB:
"""Core-owned public song/tone -> provider mapping index.
Providers own the preset/chain rows addressed by provider_ref. Core owns
the cross-provider routing index and the active mapping per song/tone.
"""
def __init__(self, config_dir: Path):
config_dir.mkdir(parents=True, exist_ok=True)
self.db_path = str(config_dir / "audio_effects.db")
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA foreign_keys=ON")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS audio_effect_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
song_key TEXT NOT NULL,
filename TEXT NOT NULL DEFAULT '',
tone_key TEXT NOT NULL,
provider_id TEXT NOT NULL,
provider_ref TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'manual',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(song_key, tone_key, provider_id)
)
""")
self.conn.execute("""
CREATE TABLE IF NOT EXISTS audio_effect_active_mappings (
song_key TEXT NOT NULL,
tone_key TEXT NOT NULL,
mapping_id INTEGER NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (song_key, tone_key),
FOREIGN KEY (mapping_id) REFERENCES audio_effect_mappings(id) ON DELETE CASCADE
)
""")
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_provider "
"ON audio_effect_mappings(provider_id)"
)
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_audio_effect_mappings_filename "
"ON audio_effect_mappings(filename)"
)
self.conn.commit()
self._lock = threading.Lock()
@staticmethod
def _text(value, *, field: str, limit: int, allow_empty: bool = False) -> str:
if value is None:
text = ""
elif not isinstance(value, str):
raise ValueError(f"{field} must be a string")
else:
text = value.strip()
if not text and not allow_empty:
raise ValueError(f"{field} is required")
if len(text) > limit:
raise ValueError(f"{field} is too long")
return text
@staticmethod
def _mapping_id(value) -> int | None:
# Bind only values SQLite can store as an INTEGER; an out-of-range id is a
# clean miss (404), not a 500 at bind time.
if isinstance(value, int) and not isinstance(value, bool) and -(2 ** 63) <= value < 2 ** 63:
return value
return None
@staticmethod
def _field(data: dict, *keys):
# Select the first present snake/camel alias by key, not by truthiness, so a
# falsey non-string value (false/0) still reaches _text() and is rejected
# instead of being silently swallowed by an `or` chain.
for key in keys:
if key in data:
return data[key]
return None
@staticmethod
def _metadata(value) -> str:
if value is None:
return "{}"
if not isinstance(value, dict):
raise ValueError("metadata must be an object")
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True)
if len(encoded) > 8192:
raise ValueError("metadata is too large")
return encoded
@staticmethod
def _row(row) -> dict | None:
if row is None:
return None
metadata = {}
try:
metadata = json.loads(row[8]) if row[8] else {}
except Exception:
metadata = {}
return {
"id": int(row[0]),
"song_key": row[1],
"filename": row[2] or "",
"tone_key": row[3],
"provider_id": row[4],
"provider_ref": row[5],
"label": row[6] or "",
"source": row[7] or "manual",
"metadata": metadata if isinstance(metadata, dict) else {},
"created_at": row[9] or "",
"updated_at": row[10] or "",
"active": bool(row[11]),
}
def _select_sql(self) -> str:
return """
SELECT m.id, m.song_key, m.filename, m.tone_key, m.provider_id,
m.provider_ref, m.label, m.source, m.metadata_json,
m.created_at, m.updated_at,
CASE WHEN a.mapping_id IS NULL THEN 0 ELSE 1 END AS active
FROM audio_effect_mappings m
LEFT JOIN audio_effect_active_mappings a
ON a.song_key = m.song_key AND a.tone_key = m.tone_key AND a.mapping_id = m.id
"""
def list(self, *, song_key: str = "", filename: str = "", tone_key: str = "", provider_id: str = "") -> list[dict]:
clauses: list[str] = []
params: list[str] = []
song_key = self._text(song_key, field="song_key", limit=240, allow_empty=True)
filename = self._text(filename, field="filename", limit=500, allow_empty=True)
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
if song_key and filename:
clauses.append("(m.song_key = ? OR m.filename = ?)")
params.extend([song_key, filename])
elif song_key:
clauses.append("m.song_key = ?")
params.append(song_key)
elif filename:
clauses.append("(m.song_key = ? OR m.filename = ?)")
params.extend([filename, filename])
if tone_key:
clauses.append("m.tone_key = ?")
params.append(tone_key)
if provider_id:
clauses.append("m.provider_id = ?")
params.append(provider_id)
sql = self._select_sql()
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY m.song_key COLLATE NOCASE, m.tone_key COLLATE NOCASE, m.provider_id COLLATE NOCASE"
with self._lock:
rows = self.conn.execute(sql, params).fetchall()
return [self._row(row) for row in rows]
def get(self, mapping_id: int) -> dict | None:
mapping_id = self._mapping_id(mapping_id)
if mapping_id is None:
return None
with self._lock:
row = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
return self._row(row)
def upsert(self, data: dict) -> dict:
if not isinstance(data, dict):
raise ValueError("mapping body must be an object")
filename = self._text(data.get("filename", ""), field="filename", limit=500, allow_empty=True)
song_key_raw = self._field(data, "song_key", "songKey")
if song_key_raw is None or song_key_raw == "":
song_key_raw = filename
song_key = self._text(song_key_raw, field="song_key", limit=240)
tone_key = self._text(self._field(data, "tone_key", "toneKey"), field="tone_key", limit=160, allow_empty=True)
provider_id = self._text(self._field(data, "provider_id", "providerId"), field="provider_id", limit=96)
provider_ref = self._text(self._field(data, "provider_ref", "providerRef"), field="provider_ref", limit=240)
label = self._text(data.get("label", ""), field="label", limit=160, allow_empty=True)
source = self._text(data.get("source", "manual"), field="source", limit=40, allow_empty=True) or "manual"
metadata_json = self._metadata(data.get("metadata", {}))
with self._lock:
self.conn.execute(
"""
INSERT INTO audio_effect_mappings
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(song_key, tone_key, provider_id) DO UPDATE SET
-- Only overwrite filename when a non-empty one was supplied; an
-- omitted/empty filename must preserve the stored value (it's an
-- alternate lookup key for list(..., filename=...)).
filename=CASE WHEN excluded.filename <> '' THEN excluded.filename ELSE audio_effect_mappings.filename END,
provider_ref=excluded.provider_ref,
label=excluded.label,
source=excluded.source,
metadata_json=excluded.metadata_json,
updated_at=datetime('now')
""",
(song_key, filename, tone_key, provider_id, provider_ref, label, source, metadata_json),
)
row = self.conn.execute(
"SELECT id FROM audio_effect_mappings WHERE song_key = ? AND tone_key = ? AND provider_id = ?",
(song_key, tone_key, provider_id),
).fetchone()
if row is None:
raise ValueError("failed to create audio-effects mapping")
mapping_id = int(row[0])
if data.get("active") is True:
self.conn.execute(
"""
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(song_key, tone_key) DO UPDATE SET
mapping_id=excluded.mapping_id,
updated_at=datetime('now')
""",
(song_key, tone_key, mapping_id),
)
self.conn.commit()
return self.get(mapping_id)
def delete(self, mapping_id: int, *, provider_id: str = "") -> bool:
mapping_id = self._mapping_id(mapping_id)
if mapping_id is None:
return False
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
with self._lock:
if provider_id:
cur = self.conn.execute(
"DELETE FROM audio_effect_mappings WHERE id = ? AND provider_id = ?",
(mapping_id, provider_id),
)
else:
cur = self.conn.execute("DELETE FROM audio_effect_mappings WHERE id = ?", (mapping_id,))
self.conn.commit()
return cur.rowcount > 0
def activate(self, mapping_id: int, *, provider_id: str = "") -> dict | None:
mapping_id = self._mapping_id(mapping_id)
if mapping_id is None:
return None
provider_id = self._text(provider_id, field="provider_id", limit=96, allow_empty=True)
with self._lock:
row = self.conn.execute(
self._select_sql() + " WHERE m.id = ?",
(mapping_id,),
).fetchone()
mapping = self._row(row)
if not mapping or (provider_id and mapping["provider_id"] != provider_id):
return None
self.conn.execute(
"""
INSERT INTO audio_effect_active_mappings (song_key, tone_key, mapping_id, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(song_key, tone_key) DO UPDATE SET
mapping_id=excluded.mapping_id,
updated_at=datetime('now')
""",
(mapping["song_key"], mapping["tone_key"], mapping_id),
)
self.conn.commit()
selected = self.conn.execute(self._select_sql() + " WHERE m.id = ?", (mapping_id,)).fetchone()
return self._row(selected)
def clear_active(self, *, song_key: str, tone_key: str) -> bool:
song_key = self._text(song_key, field="song_key", limit=240)
tone_key = self._text(tone_key, field="tone_key", limit=160, allow_empty=True)
with self._lock:
cur = self.conn.execute(
"DELETE FROM audio_effect_active_mappings WHERE song_key = ? AND tone_key = ?",
(song_key, tone_key),
)
self.conn.commit()
return cur.rowcount > 0
+99
View File
@@ -0,0 +1,99 @@
"""DLC library path resolution — where the song files live, plus safe containment.
Extracted from ``server.py`` (R3). ``_resolve_dlc_path`` is pure and moved
verbatim. ``_get_dlc_dir`` reads the env-derived paths through the ``appstate``
seam (``server.py`` configures ``dlc_dir``/``dlc_dir_env``/``config_dir`` at
import, fresh on every re-import), so this module does no import-time IO and the
pop-and-reimport fixtures keep working. ``server.py`` re-exports both names, so
existing ``server._get_dlc_dir`` / ``server._resolve_dlc_path`` references
(tests, other handlers) resolve unchanged.
"""
import json
import os
from pathlib import Path
import appstate
def _get_dlc_dir(cfg: dict | None = None) -> Path | None:
# Only consider DLC_DIR if the env var was non-empty. `Path("")` collapses
# to `.` and reports `.is_dir() == True`, which would silently shadow the
# config.json fallback. Checking the raw env string preserves
# `DLC_DIR=.` as a valid opt-in for cwd while keeping unset/empty out.
if appstate.dlc_dir_env and appstate.dlc_dir.is_dir():
return appstate.dlc_dir
if cfg is None:
config_file = appstate.config_dir / "config.json"
if config_file.exists():
try:
cfg = json.loads(config_file.read_text(encoding="utf-8"))
except Exception:
pass
if isinstance(cfg, dict):
raw = str(cfg.get("dlc_dir", "")).strip()
if raw:
p = Path(raw)
if p.is_dir():
return p
return None
def _resolve_dlc_path(dlc: Path, filename: str) -> Path | None:
"""Resolve `filename` under DLC_DIR and refuse anything that escapes.
`filename` arrives from `:path` route params and can contain `..`
segments. The Sloppak and archive paths happen to fail safely later
because their loaders raise on missing/invalid files, but loose-
folder format detection (`is_loose_song`) globs and parses XML on
disk first, which lets a crafted path trigger filesystem reads
outside DLC_DIR before any guard fires. Centralise the containment
check so every filename-bound handler validates before touching the
filesystem.
Containment here is LEXICAL (normalize `.`/`..` WITHOUT following
symlinks), not `safe_join`'s `.resolve()`-based check — because users
commonly mount their song library through a directory JUNCTION/symlink
(a library shared across app installs; the desktop app's own mounts).
`.resolve()` follows that junction to its real target, sees it sits
outside DLC_DIR, and wrongly rejects every song reached through it — the
scanner's `rglob` indexes those songs, but art/load then 403/404s (broken
covers, unplayable songs). Lexical normalization still rejects the only
escapes a `:path` filename can express — `..` traversal and absolute
paths — which the traversal tests pin. `safe_join` stays strict (it is
the zip-slip / plugin-asset guard, where following a symlink out IS the
defense); the loose-folder art handler keeps its own per-file symlink
re-check for defence-in-depth.
Returns the validated Path (not necessarily link-resolved), or None if
the filename is empty, contains a NUL, or escapes the DLC root.
"""
if not filename:
return None
# Backslashes → forward slashes so a Windows-style `..\\x` traversal is
# rejected identically on POSIX (mirrors safe_join's normalisation).
safe = filename.replace("\\", "/")
if "\x00" in safe:
return None
# Reject drive-letter / absolute paths in BOTH conventions. A POSIX "/x" is
# caught by the containment check below (the `/` operator discards `root`),
# but a Windows drive-absolute "C:/x" is treated as a relative "C:" dir on
# POSIX and would otherwise slip in as `<root>/C:/x` — so the contract must
# hold cross-platform (a shared library is reached from either OS).
from pathlib import PurePosixPath, PureWindowsPath
if (PurePosixPath(safe).is_absolute()
or PureWindowsPath(safe).is_absolute()
or PureWindowsPath(safe).drive):
return None
try:
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
# get caught by the containment check below.
candidate = Path(os.path.normpath(root / safe))
if not candidate.is_relative_to(root):
return None
except (ValueError, OSError):
return None
return candidate
+1103
View File
File diff suppressed because it is too large Load Diff
+43 -12
View File
@@ -1114,7 +1114,7 @@ def _build_xml(
ET.SubElement(root, "arrangement").text = arrangement
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
ET.SubElement(root, "averageTempo").text = str(tempo)
ET.SubElement(root, "artistName").text = artist
ET.SubElement(root, "albumName").text = album
@@ -1139,10 +1139,17 @@ def _build_xml(
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
ET.SubElement(root, "capo").text = "0"
# Ebeats
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
# constant-tempo GP (e.g. 140) shows a spurious ±0.050.7 BPM per-bar drift
# (worse for fast/odd meters) because most bar lengths don't land on a ms
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
# only loss is this format string — 6 decimals makes the derived tempo match
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
for b in beats:
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
# Sections
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
@@ -1836,9 +1843,10 @@ def convert_drum_track_to_drumtab(
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
skipped — round-tripping them would require teaching `lib/drums.py` first.
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
times capped at 100 samples per note) so they can surface a warning or
offer a manual mapping UI.
record of every skipped note (``{midi: {"count": int, "times": [...],
"velocities": [...]}}``, times/velocities index-aligned and capped at
100 samples per note — velocities carry the source notes' real dynamics)
so they can surface a warning or offer a manual mapping UI.
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
``expand_repeats`` is true — same `_build_playback_schedule` machinery
@@ -1894,18 +1902,29 @@ def convert_drum_track_to_drumtab(
# NB: do NOT shadow the outer `entry` loop
# variable from `for entry in schedule:`.
unmapped_rec = out_unmapped.setdefault(
int(midi_note), {"count": 0, "times": []})
int(midi_note),
{"count": 0, "times": [], "velocities": []})
unmapped_rec["count"] += 1
if len(unmapped_rec["times"]) < 100:
unmapped_rec["times"].append(round(t, 3))
# Index-aligned with times: the note's real
# dynamics (same 1-127 gate as mapped hits,
# falling back to the 100 import default) so
# a hand-mapping UI doesn't flatten them.
_uv = int(getattr(note, "velocity", 0) or 0)
unmapped_rec["velocities"].append(
_uv if 1 <= _uv <= 127 else 100)
continue
hit: dict = {"t": round(t, 3), "p": piece}
# Velocity: GP stores 1-127 MIDI velocity directly; default
# is 95 (Velocities.default). Pass through verbatim,
# clamping defensively so a corrupt file can't poison the
# wire format.
# Velocity: GP stores 1-127 MIDI velocity directly. Note
# this is GP's *authoring* default (95, Velocities.default)
# — unrelated to the drumtab render default of 100
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
# when `v` is omitted from a hit. Pass the GP value through
# verbatim, clamping defensively so a corrupt file can't
# poison the wire format.
vel = int(getattr(note, "velocity", 0) or 0)
if 1 <= vel <= 127:
hit["v"] = vel
@@ -1946,9 +1965,21 @@ def convert_drum_track_to_drumtab(
# Times for unmapped notes were collected in beat-iteration order;
# multi-voice measures can produce out-of-order beats, so sort each
# entry's `times` list chronologically before returning to the caller.
# Velocities are index-aligned with times, so they must sort in
# LOCKSTEP — sorting times alone would silently reassign dynamics.
if out_unmapped is not None:
for _rec in out_unmapped.values():
_rec["times"].sort()
_vels = _rec.get("velocities")
if _vels and len(_vels) == len(_rec["times"]):
_pairs = sorted(zip(_rec["times"], _vels))
_rec["times"] = [p[0] for p in _pairs]
_rec["velocities"] = [p[1] for p in _pairs]
else:
# Belt-and-suspenders: times & velocities are always appended
# together under the same `len(times) < 100` guard above, so
# in practice the lengths can't diverge. Kept as a defensive
# fallback, not a real divergence case.
_rec["times"].sort()
return {
"version": drums_mod.SCHEMA_VERSION,
+24 -1
View File
@@ -538,9 +538,32 @@ def _dtw_align(
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
"""
import librosa
import numpy as np
cs = _safe_normalise(chroma_score)
ca = _safe_normalise(chroma_audio)
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
# music-sync config): every step advances BOTH axes, bounding the local
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
# horizontal/vertical runs, and on riff-based music (long self-similar
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
# collapse — whole minutes of score mapped onto a single audio frame,
# producing garbage sync points. The constrained pattern makes that
# degenerate path impossible.
steps = np.array([[1, 1], [1, 2], [2, 1]])
weights = np.array([1.0, 1.0, 1.0])
try:
_D, wp = librosa.sequence.dtw(
cs, ca, metric='cosine',
step_sizes_sigma=steps, weights_mul=weights,
)
except Exception as exc:
# The constrained pattern needs the global length ratio within its
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
# against a 20-minute video) is infeasible and librosa raises. Fall
# back to the unconstrained path rather than failing the whole sync.
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
"falling back to unconstrained steps", exc)
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
return wp[::-1] # reverse to forward order
# ── Sync point extraction from DTW path ──────────────────────────────────────
+4373
View File
File diff suppressed because it is too large Load Diff
+160 -7
View File
@@ -203,7 +203,13 @@ def convert_midi_track_to_keys_wire(
# a foreign track's tempo events do NOT apply to the chosen
# track. Merging would mis-time the notes — restrict the tempo
# scan to the selected track only.
ticks_per_beat = midi.ticks_per_beat
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
# division (mido returns the signed short as-is). Both feed the two
# divisions below (tempo-table build + tick_to_seconds), so guard here:
# 0 would raise ZeroDivisionError and a negative value would yield
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
# case also falls back to the SMF default.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
@@ -352,7 +358,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
- type 1: parallel tracks share the timeline; merge tempo events.
- type 2: independent timelines; tempo only from the chosen track.
"""
ticks_per_beat = midi.ticks_per_beat
# A metrical header carries positive ticks-per-beat. mido reads the SMF
# division as a signed short, so an SMPTE-division file surfaces as a
# negative value and a malformed header as 0 — both make the two division
# sites below divide by a non-positive number (ZeroDivisionError, or
# negative seconds that send the bar walk off the rails). Fall back to the
# SMF default here, the single place every caller routes ticks through, so
# each caller's own fallback is real rather than cosmetic.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
midi_type = getattr(midi, "type", 1)
tempo_source = (
@@ -393,6 +406,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
return tick_to_seconds
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
# trailing meta) could otherwise imply millions of bars. Real charts sit
# orders of magnitude below this.
_TEMPO_MAP_MAX_BARS = 20000
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
signatures, and a full beat grid — the data the note converters here
always computed internally (to bake note times) and then threw away,
which left every MIDI import with no bars, no measures, and an implied
4/4 no matter what the file said.
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
the song-timeline sidecar shape (feedpak-spec §7.4).
- ``beats``: one row per beat on the editor grid shape — downbeats carry
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
denominator), interior beats carry ``measure: -1``. The beat unit
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
(independent timelines — callers must never share one grid across
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
file places one mid-bar (ill-formed but seen in the wild). All times
are computed from absolute ticks through the cumulative tempo table and
rounded once at emit — rounding error never accumulates with song
length. An SMF with no note events yields empty ``beats``.
"""
midi = mido.MidiFile(midi_path)
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
# read as a signed short) otherwise — fall back so beat_ticks below stays
# sane, mirroring the guard inside _build_tick_to_seconds.
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
midi_type = getattr(midi, "type", 1)
# Same scope both converters use: type 2 reads only the chosen track
# (independent timelines); type 0/1 merge all tracks (shared timeline).
source_tracks = (
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
)
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
# ── collect meta + the end of musical content in one pass ────────────
sig_events: list[tuple[int, int, int]] = []
tempo_events: list[tuple[int, int]] = []
end_tick = 0
for tr in source_tracks:
abs_tick = 0
for msg in tr:
abs_tick += msg.time
if msg.type == "time_signature":
num = int(getattr(msg, "numerator", 4) or 4)
den = int(getattr(msg, "denominator", 4) or 4)
if num > 0 and den > 0:
sig_events.append((abs_tick, num, den))
elif msg.type == "set_tempo":
tempo_events.append((abs_tick, int(msg.tempo)))
elif msg.type in ("note_on", "note_off"):
end_tick = max(end_tick, abs_tick)
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
sig_events.sort(key=lambda e: e[0])
sigs: list[tuple[int, int, int]] = []
for ev in sig_events:
if sigs and sigs[-1][0] == ev[0]:
sigs[-1] = ev
else:
sigs.append(ev)
if not sigs or sigs[0][0] > 0:
sigs.insert(0, (0, 4, 4))
tempo_events.sort(key=lambda e: e[0])
seen_tempo_ticks: dict[int, int] = {}
for ev_tick, ev_tempo in tempo_events:
seen_tempo_ticks[ev_tick] = ev_tempo
sorted_tempo_ticks = sorted(seen_tempo_ticks)
tempos_out: list[dict] = []
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
# lands after the start (or there are none). The beat grid already runs
# at 120 for the head of the song, so the sidecar must say so too —
# symmetric with the (0, 4, 4) default seeded into the signatures above.
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
tempos_out.append({"time": 0.0, "bpm": 120.0})
for ev_tick in sorted_tempo_ticks:
tempos_out.append({
"time": round(tick_to_seconds(ev_tick), 3),
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
})
time_signatures_out = [
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
for t, num, den in sigs
]
# ── walk bars from tick 0 to the end of the notes ────────────────────
beats: list[dict] = []
if end_tick > 0:
cur_tick = 0.0
measure = 1
sig_idx = 0
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
# Active signature: the latest event at or before this bar's
# start. Mid-bar events wait for the next boundary by
# construction (we only re-read between bars).
while (sig_idx + 1 < len(sigs)
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
sig_idx += 1
_, num, den = sigs[sig_idx]
beat_ticks = ticks_per_beat * 4.0 / den
beats.append({
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
"measure": measure,
"den": den,
})
for k in range(1, num):
sub_tick = cur_tick + k * beat_ticks
if sub_tick >= end_tick:
break
beats.append({
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
"measure": -1,
})
cur_tick += num * beat_ticks
measure += 1
return {
"tempos": tempos_out,
"time_signatures": time_signatures_out,
"beats": beats,
}
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
@@ -486,10 +634,12 @@ def convert_drum_track_from_midi(
Callers can pass an empty dict as ``out_unmapped`` to receive a
per-MIDI record of every channel-9 note_on that didn't resolve to a
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
capped at 100 samples per note). The default path skips this
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
no extra work.
piece-id (``{midi: {"count": int, "times": [float, ...],
"velocities": [int, ...]}}``, times/velocities index-aligned and
capped at 100 samples per note — velocities carry the source notes'
real dynamics so a hand-mapping UI doesn't have to flatten them to a
default). The default path skips this capture entirely so MIDIs
heavy with cowbell/tambourine/etc. take no extra work.
"""
offset = float(audio_offset)
if not math.isfinite(offset):
@@ -527,10 +677,13 @@ def convert_drum_track_from_midi(
continue
t = tick_to_seconds(abs_tick) + offset
entry = out_unmapped.setdefault(
midi_note, {"count": 0, "times": []})
midi_note, {"count": 0, "times": [], "velocities": []})
entry["count"] += 1
if len(entry["times"]) < 100:
entry["times"].append(round(t, 3))
# Index-aligned with times: the note's real dynamics,
# so hand-mapping doesn't flatten everything to 100.
entry["velocities"].append(int(msg.velocity))
continue
# Mapped note: compute t once for the raw entry.
t = tick_to_seconds(abs_tick) + offset
+13
View File
@@ -0,0 +1,13 @@
"""Request-field coercion helpers shared by the raw-`dict` POST handlers.
Extracted verbatim from ``server.py`` (R3). Pure no IO, no globals so it
imports cleanly from both ``server`` and any ``routers/`` module.
"""
def _clean_str(value) -> str:
"""Trim a request field to a string; non-strings (or missing) → ''.
Lets the raw-`dict` POST handlers treat wrong-typed JSON (an int/list/etc.
where a string was expected) as "empty" and answer 400, instead of raising
AttributeError/TypeError 500 on a later .strip()/`in`."""
return value.strip() if isinstance(value, str) else ""
+31
View File
@@ -0,0 +1,31 @@
"""FastAPI route modules extracted from ``server.py`` (R3).
Each module here exposes a module-level ``router`` (a ``fastapi.APIRouter``)
that ``server.py`` mounts with ``app.include_router(...)`` at the point in the
file where those routes used to be defined FastAPI matches routes in
registration order, so keeping the mount site preserves it.
**Routers must never ``import server``.** They reach core singletons through
the injected seam instead::
import appstate
@router.get("/api/thing")
def get_thing():
return appstate.meta_db.thing()
and always as a **module attribute, at call time** never
``from appstate import meta_db``, which freezes the binding and defeats both a
later ``appstate.configure()`` and ``monkeypatch.setattr``. See ``appstate.py``.
Dependencies flow one way: ``server -> routers -> appstate``.
**Why this lives under ``lib/``.** ``lib/`` is the only core directory every
packaging path already copies wholesale the Dockerfile (``COPY lib/``),
``docker-compose.yml``, and feedback-desktop's ``bundle-slopsmith.sh``
(``cp -r lib``) and all three put it on ``sys.path``. A root-level package
ships in Docker but is silently dropped from the packaged desktop app, whose
bundler copies a hardcoded file list. Route modules import nothing at module
scope beyond FastAPI and ``appstate``, so they do no import-time IO and satisfy
Principle V's rule for ``lib/``.
"""
+513
View File
@@ -0,0 +1,513 @@
"""Album-art routes: serve / cover-search / candidates / upload / url / remove
(/api/song/{filename}/art*, /api/art/{filename}/override).
Extracted verbatim from server.py (R3). Only the decorators (@app -> @router) and
the seam reads change: meta_db -> appstate.meta_db, ART_CACHE_DIR ->
appstate.art_cache_dir, and the three shared art helpers that stay in server.py
(they are also used by the song/delete routes) -> appstate.<callable>
(_song_pack_art_exists, _art_override_paths, _art_safe_name). The CAA / release
search transport lives in lib/enrichment.py and is reached as enrichment.X.
"""
import asyncio
import hashlib
import ipaddress
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, Response
import appstate
import enrichment
import loosefolder as loosefolder_mod
import sloppak as sloppak_mod
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
import logging
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _if_none_match_hits(header: str | None, etag: str) -> bool:
"""True if an If-None-Match header matches `etag` (weak comparison).
Handles the `*` wildcard and comma-separated lists, and ignores a weak
`W/` prefix on either side the standard semantics for a conditional GET.
"""
if not header:
return False
bare = etag.removeprefix("W/")
for tok in header.split(","):
t = tok.strip()
if t == "*" or t.removeprefix("W/") == bare:
return True
return False
# Album art is served with a strong validator (an ETag on the sloppak byte
# path; FileResponse's own ETag/Last-Modified on the file paths) and revalidated
# with `no-cache`. That keeps re-scroll cheap — a conditional GET returns a
# bodyless 304 — without ever serving a stale cover. A long `immutable` max-age
# was rejected: the frontend's `?v=<mtime>` buster is only second-resolution, so
# a same-second cover rewrite would keep the URL and pin the old bytes for the
# cache lifetime. Validation cost is negligible for a localhost backend.
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _art_etag(path: Path) -> str | None:
"""Strong validator for an art file: nanosecond mtime + size (so a
same-second rewrite still changes it). None if the file can't be stat'd."""
try:
st = path.stat()
return f'"{st.st_mtime_ns}-{st.st_size}"'
except OSError:
return None
def _art_conditional(etag: str | None, request: Request | None):
"""Return (headers, not_modified) for an art response. `not_modified` is
True when the client's If-None-Match already matches `etag` → caller should
return a bodyless 304. Starlette's FileResponse emits an ETag but does NOT
itself evaluate If-None-Match, so every art path routes through here to get
real conditional handling."""
headers = dict(_ART_CACHE_HEADERS)
if etag:
headers["ETag"] = etag
inm = request.headers.get("if-none-match") if request is not None else None
return headers, bool(etag) and _if_none_match_hits(inm, etag)
def _file_art_response(path: Path, media_type: str, request: Request | None):
"""FileResponse for an on-disk art file, with no-cache + ETag and a bodyless
304 when the client's validator still matches."""
headers, not_modified = _art_conditional(_art_etag(path), request)
if not_modified:
return Response(status_code=304, headers=headers)
return FileResponse(str(path), media_type=media_type, headers=headers)
@router.get("/api/song/{filename:path}/art")
async def get_song_art(filename: str, request: Request = None, source: str = ""):
"""Serve album art for a song, walking the R3 override chain:
1. USER OVERRIDE (upload / URL-fetch, {safe_name}.gif|.png in the art
cache) art the user explicitly pinned outranks everything, pack
art included. GIF is allowed HERE only: an animated cover is a
local-only bonus; packs stay jpg/png/webp and nothing ever writes
art into a pack file.
2. PACK ART sloppak cover (single member read, no full unpack) or
the loose folder's discovered image.
3. COVER ART ARCHIVE cache fetched by the enrichment art worker for
matched songs that lack pack art, keyed by release MBID.
`?source=pack` narrows the chain to step 2 only (no override, no CAA):
the cover picker's "Pack original" tile must show the pack's own art
even while a user override is what the plain route serves. 404 when the
song ships no art of its own.
"""
dlc = _get_dlc_dir()
if not dlc:
return JSONResponse({"error": "not configured"}, 404)
song_path = _resolve_dlc_path(dlc, filename)
if song_path is None:
return JSONResponse({"error": "forbidden"}, 403)
if not song_path.exists():
return JSONResponse({"error": "not found"}, 404)
pack_only = source == "pack"
# 1. User override — GIF first (it wins over a stale PNG override).
if not pack_only:
for cached in appstate.art_override_paths(filename):
mt = "image/gif" if cached.suffix == ".gif" else "image/png"
return _file_art_response(cached, mt, request)
# 2a. Sloppak: read the cover (manifest-declared or default) straight from
# the package. For a zip-form sloppak this opens just the cover member —
# NOT the whole archive — so the library grid never triggers a full unpack
# of stems just to paint a thumbnail.
if sloppak_mod.is_sloppak(song_path):
# Read the cover (cheap — single member, no full unpack) and validate by
# its CONTENT. A stat-based ETag would be wrong for directory-form
# sloppaks: editing cover.jpg in place changes the file's mtime, not the
# directory's, so a dir-stat ETag could emit a stale 304. Content hashing
# is correct for both dir- and zip-form. Raw byte Response lacks
# FileResponse's validators, so we attach the ETag + honor If-None-Match.
try:
art = await asyncio.to_thread(sloppak_mod.read_cover_bytes, song_path)
except Exception:
art = None
if art is not None:
data, mt = art
etag = f'"{hashlib.sha1(data).hexdigest()}"'
headers, not_modified = _art_conditional(etag, request)
if not_modified:
return Response(status_code=304, headers=headers)
return Response(content=data, media_type=mt, headers=headers)
# 2b. Loose folder: serve the discovered art file directly.
# song_path is already validated against DLC_DIR by _resolve_dlc_path.
elif loosefolder_mod.is_loose_song(song_path):
art_path = loosefolder_mod.find_art(song_path)
if art_path:
# Re-resolve in case the matched file is a symlink — a crafted
# custom song could put `album_art.jpg` as a symlink to anywhere on
# disk. Insist the final target stays inside the song folder.
art_resolved = art_path.resolve()
try:
art_resolved.relative_to(song_path)
except ValueError:
return JSONResponse({"error": "forbidden"}, 403)
if art_resolved.is_file():
mt = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp",
}.get(art_resolved.suffix.lower(), "image/jpeg")
return _file_art_response(art_resolved, mt, request)
# 3. Cover Art Archive cache (the enrichment art worker's fetch).
if not pack_only:
row = appstate.meta_db.get_enrichment(filename)
if row and row.get("art_state") == "caa" and row.get("art_cache_path"):
caa = Path(row["art_cache_path"])
if caa.is_file():
return _file_art_response(caa, "image/jpeg", request)
return JSONResponse({"error": "no art"}, 404)
# ── Cover picker (PR-C): candidate assembly ───────────────────────────────────
# Enumerated ON OPEN, never at scan time (charrette §8), and NO image bytes
# are fetched here — Cover Art Archive release INDEX jsons only (1-3 throttled
# calls on a cache miss); the tiles' thumbnails load straight from the archive
# in the client. Applying a pick never grows a new write path: the client
# POSTs the chosen thumb URL to the EXISTING …/art/url route (the override
# lane — never evicted, survives a re-match), "Pack original" DELETEs the
# override, uploads keep the existing upload route.
_ART_PICKER_MAX_CAA = 12
@router.get("/api/song/{filename:path}/art/cover-search")
def api_art_cover_search(filename: str, q: str = ""):
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
powers the Change-cover picker's search box, so a cover can be found even
for a song with no metadata match (the unmatched city-pop pile, where
/art/candidates is empty). `q` defaults to the song's own artist + album/
title (romaji fallback applied). Read-only; the picker renders the thumbs and
applies a pick through the existing /art/url route."""
query = (q or "").strip()
if not query:
pack = appstate.meta_db.pack_fields(appstate.meta_db._canonical_song_filename(filename))
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
if not query:
return {"query": "", "covers": []}
try:
return {"query": query, "covers": enrichment._mb_search_release_groups(query, limit=8)}
except enrichment.EnrichTransportError:
return {"query": query, "covers": [], "error": "unavailable"}
@router.get("/api/song/{filename:path}/art/candidates")
def get_song_art_candidates(filename: str):
"""Everything the cover picker can offer for one song, without fetching a
single image: the current cover (with its provenance), the pack original
when the song ships art, and CAA candidates for the matched/manual
release plus any distinct releases among the stored review candidates.
Sync route on purpose (the CAA index fetch sleeps in the shared
throttle FastAPI runs `def` routes in the threadpool). One response,
`pending` always False the client shows a spinner for the request's own
latency; offline / CAA-down just means an empty caa tail (the instant
tiles keep working), never an error."""
from urllib.parse import quote
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
row = appstate.meta_db.get_enrichment(filename) or {}
has_pack = appstate.song_pack_art_exists(filename)
art_url = f"/api/song/{quote(filename)}/art"
# What the plain art route would serve right now — the serve chain's
# order (override > pack > CAA cache) restated as provenance.
if appstate.art_override_paths(filename):
provenance = "yours"
elif has_pack:
provenance = "pack"
elif row.get("art_state") == "caa" and row.get("art_cache_path"):
provenance = "matched"
else:
provenance = "none"
candidates: list[dict] = [{
"id": "current", "kind": "current", "label": "Current",
"thumb_url": art_url, "provenance": provenance,
}]
if has_pack:
candidates.append({
"id": "pack", "kind": "pack", "label": "Pack original",
"thumb_url": art_url + "?source=pack", "provenance": "pack",
})
# Releases worth asking the archive about: the matched/manual release
# first (it seeds the best candidates), then any distinct release among
# the stored review candidates (a review row has no mb_release_id of its
# own — its releases live in the candidates JSON).
# Only spend the shared CAA rate budget on rows whose match warrants it:
# a matched/manual release seeds the best candidates, and a review row's
# stored candidates are still live proposals. A failed/rejected (or
# unscanned) row has no accepted match — asking would burn the budget and
# surface releases already rejected as non-matches. The Current + Pack
# tiles above serve regardless, so those songs still get a picker.
rids: list[str] = []
if row.get("match_state") in ("matched", "manual", "review"):
if row.get("match_state") in ("matched", "manual") and row.get("mb_release_id"):
rids.append(str(row["mb_release_id"]))
for cand in (row.get("candidates") or []):
rid = str(cand.get("release_id") or "") if isinstance(cand, dict) else ""
if rid and rid not in rids:
rids.append(rid)
caa_entries: list[dict] = []
for rid in rids:
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
try:
imgs = enrichment._caa_index_cached(rid)
except enrichment.EnrichTransportError:
# Offline / archive down — stop asking (each further miss would
# only burn a timeout). The instant tiles still serve; a later
# picker-open retries naturally (failures are never cached).
break
# Front covers first, approved before pending, otherwise index order
# (the picker grammar is a RANKED list — §7/§9).
def _rank(img):
types = img.get("types") or []
is_front = bool(img.get("front")) or "Front" in types
return (not is_front, not bool(img.get("approved")))
for img in sorted((i for i in imgs if isinstance(i, dict)), key=_rank):
if len(caa_entries) >= _ART_PICKER_MAX_CAA:
break
thumbs = img.get("thumbnails") or {}
if not isinstance(thumbs, dict):
continue
thumb = (thumbs.get("500") or thumbs.get("large")
or thumbs.get("250") or thumbs.get("small"))
if not thumb:
continue
types = [str(t) for t in (img.get("types") or []) if isinstance(t, str)]
caa_entries.append({
"id": f"caa-{rid}-{img.get('id', '')}",
"kind": "caa",
"label": ", ".join(types) or "Cover",
"thumb_url": str(thumb),
"provenance": "matched",
"types": types,
"approved": bool(img.get("approved")),
"release_id": rid,
})
return {"candidates": candidates + caa_entries, "pending": False}
def _save_art_override(filename: str, img_data: bytes) -> dict:
"""Persist a user art override into the art cache (R3). One override per
song: GIF input is validated and kept VERBATIM as .gif (animation intact
the local-only bonus; it is never written into the pack file), everything
else is normalized to RGB PNG via PIL. Saving either kind removes the
other so the serve chain has exactly one user file to find."""
appstate.art_cache_dir.mkdir(parents=True, exist_ok=True)
stem = appstate.art_safe_name(filename)
png_path = appstate.art_cache_dir / f"{stem}.png"
gif_path = appstate.art_cache_dir / f"{stem}.gif"
from PIL import Image
import io as _io
if img_data[:6] in (b"GIF87a", b"GIF89a"):
try:
probe = Image.open(_io.BytesIO(img_data))
probe.verify() # decodes headers/frames without keeping the image
if probe.format != "GIF":
raise ValueError("not a GIF")
except Exception as e:
return {"error": f"Invalid image: {e}"}
gif_path.write_bytes(img_data)
png_path.unlink(missing_ok=True)
return {"ok": True, "kind": "gif"}
try:
img = Image.open(_io.BytesIO(img_data)).convert("RGB")
img.save(str(png_path), "PNG")
except Exception as e:
return {"error": f"Invalid image: {e}"}
gif_path.unlink(missing_ok=True)
return {"ok": True, "kind": "png"}
@router.post("/api/song/{filename:path}/art/upload")
async def upload_song_art_b64(filename: str, data: dict):
"""Upload a custom cover as base64 (PNG/JPG/WebP → normalized PNG;
GIF kept animated, local-only). The override outranks pack art in the
serve chain; remove it via DELETE /art/override."""
import base64
# Reject art for a filename that doesn't resolve to a real song (mirrors the
# url route's guard) — no writing stray override files for unknown keys.
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
b64 = data.get("image", "")
if not b64:
return {"error": "No image data"}
# Strip data URL prefix if present
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
img_data = base64.b64decode(b64)
except Exception:
return {"error": "Invalid base64"}
if len(img_data) > _ART_URL_MAX_BYTES:
raise HTTPException(status_code=400, detail="image larger than 10 MB")
return _save_art_override(filename, img_data)
# Art-by-URL fetch cap — a cover, not a wallpaper pack.
_ART_URL_MAX_BYTES = 10 * 1024 * 1024
def _url_host_is_internal(url: str) -> bool:
"""True when a user-supplied URL's host resolves to a loopback, private,
link-local, reserved, multicast or unspecified address an SSRF target we
refuse to fetch on the user's behalf (e.g. 169.254.169.254 metadata, LAN
services). Fails CLOSED: an unresolvable or unparseable host is treated as
internal. Every resolved address must be public for the URL to pass."""
from urllib.parse import urlparse
import socket
host = urlparse(url).hostname
if not host:
return True
try:
infos = socket.getaddrinfo(host, None)
except OSError:
return True
if not infos:
return True
for info in infos:
raw = info[4][0].split("%", 1)[0] # strip any zone id
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return True
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return True
return False
# Art-by-URL redirect budget. Cover hosts commonly answer with a redirect —
# the Cover Art Archive (whose thumbs the cover picker applies through this
# very route) 307s every image to archive.org — so redirects must work; 5
# hops is generous for any real CDN chain while still bounding the walk.
_ART_URL_MAX_REDIRECTS = 5
def _fetch_art_url(url: str) -> bytes:
"""The one place art-by-URL touches the network (tests fake this seam).
User-initiated, so not throttled like the background workers but the
same offline guard applies (pytest can never fetch), the host is checked
against internal/reserved ranges (SSRF), redirects are followed MANUALLY
with the scheme + internal-host guard re-applied to every hop (so a
redirect can't smuggle the request to an internal target — a blanket
no-redirect rule would break every Cover Art Archive pick, which always
redirects to archive.org), and the size cap is enforced while streaming
so a huge response never fully downloads.
Residual, accepted: each hop's host is resolved here and again by
requests, so a rebinding DNS name is a theoretical TOCTOU. Not closed
with an IP-pinned connection because (a) this is a single-user, no-auth
app (constitution §I) and the route is demo-blocked, so there is no
untrusted submission path, and (b) no other in-tree client (MusicBrainz,
CAA) pins either a bespoke pinned+SNI adapter here would be
inconsistent and disproportionate. The cheap guards above still stop the
realistic vectors (direct internal URL, redirect-to-internal)."""
if not enrichment._enrich_network_enabled():
raise enrichment.EnrichTransportError("art fetch disabled (offline)")
import requests
from urllib.parse import urljoin, urlparse
for _hop in range(_ART_URL_MAX_REDIRECTS + 1):
# Re-validate EVERY hop, not just the user's original URL: the whole
# point of handling redirects ourselves is that each target gets the
# same scheme + SSRF gate before any request is made.
if urlparse(url).scheme not in ("http", "https"):
raise ValueError("url must be http(s)")
if _url_host_is_internal(url):
raise ValueError("url host is not allowed")
try:
with requests.get(url, timeout=15, stream=True, allow_redirects=False,
headers={"User-Agent": enrichment._enrich_user_agent()}) as resp:
if resp.status_code in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location") or ""
if not loc:
raise enrichment.EnrichTransportError(
f"HTTP {resp.status_code} without a Location")
url = urljoin(url, loc)
continue
if resp.status_code != 200:
raise enrichment.EnrichTransportError(f"HTTP {resp.status_code}")
data = b""
for chunk in resp.iter_content(65536):
data += chunk
if len(data) > _ART_URL_MAX_BYTES:
raise ValueError("image larger than 10 MB")
return data
except requests.RequestException as e:
raise enrichment.EnrichTransportError(str(e)) from e
raise enrichment.EnrichTransportError("too many redirects")
@router.post("/api/song/{filename:path}/art/url")
def set_song_art_from_url(filename: str, data: dict):
"""Paste-a-link cover art (the media-server idiom): the server fetches the
image and stores it as this song's local override — identical result to an
upload, including the GIF-stays-local rule. http(s) only."""
url = str((data or {}).get("url") or "").strip()
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise HTTPException(status_code=400, detail="url must be http(s)")
dlc = _get_dlc_dir()
song_path = _resolve_dlc_path(dlc, filename) if dlc else None
if song_path is None or not song_path.exists():
raise HTTPException(status_code=404, detail="unknown song")
try:
img_data = _fetch_art_url(url)
except enrichment.EnrichTransportError as e:
return JSONResponse({"error": "could not fetch image", "detail": str(e)},
status_code=502)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return _save_art_override(filename, img_data)
@router.delete("/api/art/{filename:path}/override")
def remove_song_art_override(filename: str):
"""Drop the user art override — the serve chain falls back to pack art,
then the Cover Art Archive cache. Lives under /api/art (NOT /api/song) so
the greedy DELETE /api/song/{path} catch-all can't shadow it — the same
dodge the chart split/unsplit routes use."""
removed = False
for p in appstate.art_override_paths(filename):
try:
p.unlink()
removed = True
except OSError:
pass
if removed:
# The art worker may have settled this row as 'user' (override present,
# no pack art). Reset it so the next enrichment pass re-evaluates and the
# CAA fallback resumes — otherwise a removed override strands the row
# (enrichment_art_pending only re-queues art_state IS NULL) and the song
# is left with no art at all.
try:
appstate.meta_db.set_enrichment_art(filename, None, None)
except Exception:
log.exception("art override delete: failed to reset enrichment state")
return {"ok": True, "removed": removed}
+68
View File
@@ -0,0 +1,68 @@
"""Artist aliases / Tidy-up (P4) — canonicalize messy artist tags at DISPLAY
("ACDC" -> "AC/DC") without touching feedpak files or the scanner-derived
songs.artist. All DB-only.
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
(``@app`` -> ``@router``) and the singleton read (``meta_db`` ->
``appstate.meta_db``) changed. The read stays a module attribute so a re-imported
``server`` re-publishes a fresh DB into the seam see ``appstate.py``.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
router = APIRouter()
@router.get("/api/artist-aliases")
def list_artist_aliases():
"""Existing raw→canonical overrides (the Tidy-up 'current merges' list)."""
return {"aliases": appstate.meta_db.list_artist_aliases()}
@router.get("/api/artists/raw")
def list_raw_artists(limit: int = 2000):
"""Distinct RAW artist names + song counts + current canonical — the Tidy-up
picker (you merge raw variants into one canonical)."""
return {"artists": appstate.meta_db.raw_artists(limit)}
@router.post("/api/artist-aliases")
def set_artist_alias(data: dict):
"""Upsert one override: {raw_name, canonical_name, mb_artist_id?}. A self-alias
(raw == canonical) clears the row instead (un-merge)."""
raw = (data.get("raw_name") or "").strip()
canon = (data.get("canonical_name") or "").strip()
if not raw or not canon:
return JSONResponse({"error": "raw_name and canonical_name are required"}, 400)
result = appstate.meta_db.set_artist_alias(raw, canon, (data.get("mb_artist_id") or None))
if not result.get("ok"):
# Would form a cycle (raw → … → raw) — refuse rather than corrupt the chain.
return JSONResponse(
{"error": "alias would create a cycle", "raw_name": raw, "canonical_name": canon},
409)
return {"ok": True, "raw_name": raw, "canonical_name": result.get("canonical_name", canon)}
@router.post("/api/artist-aliases/merge")
def merge_artist_aliases(data: dict):
"""Merge several raw artist variants into one canonical:
{raw_names: [...], canonical_name}. The canonical's own self-alias is skipped.
Returns {merged: N}."""
canon = (data.get("canonical_name") or "").strip()
raws = data.get("raw_names")
if not canon:
return JSONResponse({"error": "canonical_name is required"}, 400)
if not isinstance(raws, list) or not raws:
return JSONResponse({"error": "raw_names must be a non-empty array"}, 400)
n = appstate.meta_db.merge_artists(raws, canon)
return {"merged": n, "canonical_name": canon}
@router.delete("/api/artist-aliases/{raw_name:path}")
def delete_artist_alias(raw_name: str):
"""Remove one override so that raw artist stands on its own again."""
appstate.meta_db.remove_artist_alias(raw_name)
return {"ok": True}
+80
View File
@@ -0,0 +1,80 @@
"""Audio-effects mapping API — the core-owned song/tone -> provider routing index.
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
(``@app`` -> ``@router``) and the singleton read (``audio_effect_mappings`` ->
``appstate.audio_effect_mappings``) changed. The read must stay a module
attribute so a re-imported ``server`` re-publishes a fresh DB into the seam and
`monkeypatch.setattr` reaches this module see ``appstate.py``.
"""
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse
import appstate
router = APIRouter()
def _audio_effects_error(exc: Exception):
return JSONResponse({"error": str(exc)}, status_code=400)
@router.get("/api/audio-effects/mappings")
def list_audio_effect_mappings(
song_key: str = Query(""),
filename: str = Query(""),
tone_key: str = Query(""),
provider_id: str = Query(""),
):
try:
return {
"mappings": appstate.audio_effect_mappings.list(
song_key=song_key,
filename=filename,
tone_key=tone_key,
provider_id=provider_id,
)
}
except ValueError as exc:
return _audio_effects_error(exc)
@router.post("/api/audio-effects/mappings")
def upsert_audio_effect_mapping(data: dict = Body(...)):
try:
mapping = appstate.audio_effect_mappings.upsert(data)
except ValueError as exc:
return _audio_effects_error(exc)
return {"ok": True, "mapping": mapping}
@router.delete("/api/audio-effects/mappings/{mapping_id}")
def delete_audio_effect_mapping(mapping_id: int, provider_id: str = Query("")):
try:
deleted = appstate.audio_effect_mappings.delete(mapping_id, provider_id=provider_id)
except ValueError as exc:
return _audio_effects_error(exc)
if not deleted:
return JSONResponse({"error": "mapping not found"}, status_code=404)
return {"ok": True}
@router.post("/api/audio-effects/mappings/{mapping_id}/activate")
def activate_audio_effect_mapping(mapping_id: int, data: dict = Body(default_factory=dict)):
try:
provider_id = data.get("provider_id") if "provider_id" in data else data.get("providerId")
mapping = appstate.audio_effect_mappings.activate(mapping_id, provider_id="" if provider_id is None else provider_id)
except ValueError as exc:
return _audio_effects_error(exc)
if not mapping:
return JSONResponse({"error": "mapping not found"}, status_code=404)
return {"ok": True, "mapping": mapping}
@router.delete("/api/audio-effects/active-mapping")
def clear_audio_effect_active_mapping(song_key: str = Query(...), tone_key: str = Query("")):
try:
cleared = appstate.audio_effect_mappings.clear_active(song_key=song_key, tone_key=tone_key)
except ValueError as exc:
return _audio_effects_error(exc)
return {"ok": True, "cleared": cleared}
+115
View File
@@ -0,0 +1,115 @@
"""Chart-level endpoints — split/unsplit a chart from its work, resolve work
membership, and the context-menu "Get info" file inspector.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``. DLC path resolution comes from
``dlc_paths``; sloppak/loose detection from the shared lib modules.
"""
from fastapi import APIRouter, HTTPException
import appstate
from dlc_paths import _get_dlc_dir, _resolve_dlc_path
import sloppak as sloppak_mod
import loosefolder as loosefolder_mod
router = APIRouter()
@router.post("/api/chart/{filename:path}/split")
def api_split_chart(filename: str):
"""'These aren't the same song' — split this chart out as its own singleton
work. Under /api/chart (NOT /api/song) so the DELETE /api/song/{path}
catch-all can't shadow it."""
key = appstate.meta_db._canonical_song_filename(filename)
appstate.meta_db.split_chart(key)
return {"ok": True, "filename": key}
@router.post("/api/chart/{filename:path}/unsplit")
def api_unsplit_chart(filename: str):
"""Undo a split — rejoin the chart to its work."""
key = appstate.meta_db._canonical_song_filename(filename)
appstate.meta_db.unsplit_chart(key)
return {"ok": True, "filename": key}
@router.get("/api/chart/{filename:path}/work")
def api_get_chart_work(filename: str):
"""Resolve a chart's work membership: {work_key, chart_count}. For openers
on rows that came from an ungrouped query (the tree view) grouped grid
rows already carry both fields inline."""
return appstate.meta_db.chart_work(filename)
@router.get("/api/chart/{filename:path}/fileinfo")
def api_chart_fileinfo(filename: str):
"""The context menu's "Get info": where the file lives + what the pack
contains. Under /api/chart the GET /api/song/{path} catch-all would
swallow a /api/song//fileinfo suffix. Read-only; demo-mode blocks it
because it exposes filesystem paths."""
dlc = _get_dlc_dir()
if not dlc:
raise HTTPException(status_code=404, detail="not configured")
p = _resolve_dlc_path(dlc, filename)
if p is None:
raise HTTPException(status_code=403, detail="forbidden")
if not p.exists():
raise HTTPException(status_code=404, detail="not found")
# Restrict to actual charts — sloppak or loose song. Without this the route
# would stat ANY file the user happens to keep under DLC_DIR (e.g. notes),
# leaking its path/size; the app only recognises these two song formats.
is_pak = sloppak_mod.is_sloppak(p)
is_loose = loosefolder_mod.is_loose_song(p)
if not (is_pak or is_loose):
raise HTTPException(status_code=404, detail="not a chart")
st = p.stat()
info = {
"filename": filename,
"path": str(p),
"folder": str(p.parent),
"format": "sloppak" if is_pak else "loose",
# Directory-form songs report the tree's total (covers loose folders
# and dir-form paks); zip-form paks report the archive size. Symlinked
# entries are skipped so a link inside the folder can't pull in — or
# leak the size of — a file outside it.
"size": (st.st_size if p.is_file()
else sum(f.stat().st_size for f in p.rglob("*")
if f.is_file() and not f.is_symlink())),
"mtime": st.st_mtime,
}
if is_pak:
try:
m = sloppak_mod.load_manifest(p) or {}
except Exception:
m = {}
arrs = [str(a.get("name", a.get("id", ""))) for a in (m.get("arrangements") or [])
if isinstance(a, dict)]
stems = [str(s.get("id", "")) for s in (m.get("stems") or []) if isinstance(s, dict)]
try:
has_cover = sloppak_mod.read_cover_bytes(p, m) is not None
except Exception:
has_cover = False
# The optional identity/catalog keys, listed only when present — the
# Get-info panel's "what this pack carries vs what's missing" readout.
identity = {k: m.get(k) for k in
("mbid", "isrc", "genres", "track", "disc", "album_artist",
"feedpak_version", "language")
if m.get(k) not in (None, "", [])}
info["manifest"] = {
"title": str(m.get("title", "")), "artist": str(m.get("artist", "")),
"album": str(m.get("album", "")), "year": str(m.get("year", "") or ""),
"arrangements": arrs, "stems": stems,
"has_cover": has_cover, "has_lyrics": bool(m.get("lyrics")),
"authors": [a.get("name", "") if isinstance(a, dict) else str(a)
for a in (m.get("authors") or [])],
"identity": identity,
}
# The enrichment verdict, so Get info can say "Matched (auto, 96%)" /
# "Pinned by you" / "Not matched" alongside the file facts.
row = appstate.meta_db.get_enrichment(filename)
if row:
info["match"] = {k: row.get(k) for k in
("match_state", "match_source", "match_score",
"canon_artist", "canon_title", "canon_album", "canon_year")}
return info
+295
View File
@@ -0,0 +1,295 @@
"""Diagnostic bundle export + hardware probe (/api/diagnostics/*).
One-click "Export Diagnostics" in Settings produces a redacted zip combining
server logs, system info, hardware (CPU/GPU/RAM), plugin inventory, and the
browser-side console transcript + hardware probe. Bundle format is specified in
docs/diagnostics-bundle-spec.md.
Extracted verbatim from server.py (R3) except:
- the decorators (@app -> @router),
- CONFIG_DIR -> appstate.config_dir and _running_version() ->
appstate.running_version() (both read through the appstate seam),
- the builtin-plugins lookup in _diag_plugins_roots: Path(__file__).parent
(the app root when this lived at the top level) ->
Path(__file__).resolve().parents[2] (routers -> lib -> app root). The
plugins/ dir ships at the app root in every packaging path.
The pure helpers + caps here are re-exported from server.py so the existing
`server._diag_*` / `server._DIAG_*` tests keep resolving (none monkeypatch them).
"""
import json
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Body, Response
import appstate
from dlc_paths import _get_dlc_dir
from diagnostics_bundle import build_bundle as _diag_build, preview_bundle as _diag_preview
from diagnostics_hardware import collect as _diag_hardware
from env_compat import getenv_compat
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _diag_log_file() -> Path | None:
raw = os.environ.get("LOG_FILE", "").strip()
if not raw:
return None
return Path(raw)
def _diag_plugins_roots() -> list[Path]:
"""Return all plugin root directories for orphan scanning.
Includes both the built-in ``plugins/`` directory and
``FEEDBACK_PLUGINS_DIR`` when set, so user-installed plugins and
orphans in the external dir are reflected in the bundle.
"""
roots: list[Path] = []
user_dir = getenv_compat("FEEDBACK_PLUGINS_DIR", "").strip()
if user_dir:
p = Path(user_dir)
if p.is_dir():
roots.append(p)
builtin = Path(__file__).resolve().parents[2] / "plugins" # R3: app root from lib/routers/
if builtin not in roots:
roots.append(builtin)
return roots
def _diag_coerce_bool(v, *, default: bool = True) -> bool:
"""Coerce a request-side value to bool, accepting both JSON booleans and
string representations.
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` ``False``
- ``None`` *default*
- Everything else (including ``"true"``, ``"1"``) ``True``
"""
if v is None:
return default
if isinstance(v, bool):
return v
if isinstance(v, str):
return v.strip().lower() not in ("false", "0", "no", "")
return bool(v)
def _diag_normalize_include(include: dict | None) -> dict:
"""Coerce request-side flags to the booleans build_bundle expects.
Missing keys default to True so a bare {} request still produces
the full bundle.
Accepts both JSON booleans (``true``/``false``) and string
representations so callers that serialize flags as strings behave
consistently with the preview endpoint:
- Falsy strings: ``"false"``, ``"0"``, ``"no"``, ``""`` ``False``
- Everything else (including ``"true"``, ``"1"``, ``"yes"``) ``True``
"""
keys = ("system", "hardware", "logs", "console", "plugins")
if not isinstance(include, dict):
return {k: True for k in keys}
return {k: _diag_coerce_bool(include.get(k), default=True) for k in keys}
# Server-side caps on client-supplied payload sections. diagnostics.js
# enforces a 500-entry / ~250 KB ring buffer on the browser side; these
# bounds give generous headroom while still preventing a crafted POST from
# forcing the server to allocate arbitrarily large in-memory bundles.
_DIAG_MAX_CONSOLE_ENTRIES = 1000 # hard cap: truncate silently
_DIAG_MAX_CONSOLE_BYTES = 2 * 1024 * 1024 # 2 MB hard cap on total console list
_DIAG_MAX_CLIENT_PAYLOAD_BYTES = 2 * 1024 * 1024 # 2 MB per dict section
_DIAG_MAX_CONTRIBUTIONS_BYTES = 4 * 1024 * 1024 # 4 MB aggregate cap for contributions
def _diag_cap_console(v) -> list | None:
"""Return *v* if it is a list, truncated to _DIAG_MAX_CONSOLE_ENTRIES entries
and _DIAG_MAX_CONSOLE_BYTES total. Entries are accumulated until either cap
is reached; no partial-entry splitting occurs."""
if not isinstance(v, list):
return None
result = v[:_DIAG_MAX_CONSOLE_ENTRIES]
# Also enforce a byte cap — the count cap alone does not bound memory when
# entries contain arbitrarily large strings.
try:
out = []
total = 0
for entry in result:
encoded = json.dumps(entry, separators=(",", ":")).encode("utf-8", errors="replace")
if total + len(encoded) > _DIAG_MAX_CONSOLE_BYTES:
break
out.append(entry)
total += len(encoded)
return out
except (TypeError, ValueError):
return None
def _diag_cap_dict(v) -> dict | None:
"""Return *v* if it is a dict whose JSON serialisation fits within
_DIAG_MAX_CLIENT_PAYLOAD_BYTES, otherwise return None."""
if not isinstance(v, dict):
return None
try:
encoded = json.dumps(v, separators=(",", ":")).encode("utf-8", errors="replace")
except (TypeError, ValueError) as e:
log.warning("diagnostics client payload is not JSON-serialisable, dropping: %s", e)
return None
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
return None
return v
def _diag_cap_contributions(v, known_ids=None) -> dict | None:
"""Apply per-plugin and aggregate size caps on client_contributions.
Unlike _diag_cap_dict(), which drops the whole dict when any plugin
exceeds the limit, this function caps each plugin independently so
one noisy plugin does not silence every other plugin's contribution.
Parameters
----------
v:
The raw contributions dict from the POST payload.
known_ids:
When provided, contributions from plugins not in this set are
skipped *before* serialisation, preventing a malicious caller
from forcing the server to JSON-encode hundreds of near-limit
payloads that ``build_bundle()`` would later discard anyway.
``None`` means "accept all plugin ids" (used in tests / preview).
"""
if not isinstance(v, dict):
return None
result = {}
total_bytes = 0
for pid, contribution in v.items():
if not isinstance(pid, str):
continue
# Filter unknown plugin ids early — before serialising — so a
# crafted request cannot force large allocations for plugins that
# build_bundle() would drop.
if known_ids is not None and pid not in known_ids:
continue
try:
encoded = json.dumps(contribution, separators=(",", ":")).encode("utf-8", errors="replace")
except (TypeError, ValueError) as e:
log.warning(
"client_contributions[%r] is not JSON-serialisable, dropping: %s", pid, e
)
continue
if len(encoded) > _DIAG_MAX_CLIENT_PAYLOAD_BYTES:
log.warning(
"client_contributions[%r] exceeds %d bytes, dropping",
pid, _DIAG_MAX_CLIENT_PAYLOAD_BYTES,
)
continue
if total_bytes + len(encoded) > _DIAG_MAX_CONTRIBUTIONS_BYTES:
log.warning(
"client_contributions aggregate size limit (%d bytes) reached, "
"dropping remaining entries",
_DIAG_MAX_CONTRIBUTIONS_BYTES,
)
break
result[pid] = contribution
total_bytes += len(encoded)
return result or None
@router.post("/api/diagnostics/export")
def export_diagnostics(payload: dict = Body(default_factory=dict)):
"""Build a diagnostic bundle and stream it back as a zip download.
The browser layers in `client_console`, `client_hardware`,
`client_ua`, and `local_storage` before posting; the server adds
server logs, hardware, plugin inventory, and packages everything
into a single zip.
Errors during plugin diagnostics callables are caught and logged
to the bundle's manifest `notes` rather than failing the export.
"""
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
redact = _diag_coerce_bool(payload.get("redact", True), default=True)
include = _diag_normalize_include(payload.get("include"))
client_console = _diag_cap_console(payload.get("client_console"))
client_hardware = _diag_cap_dict(payload.get("client_hardware"))
client_ua = _diag_cap_dict(payload.get("client_ua"))
local_storage = _diag_cap_dict(payload.get("local_storage"))
# Fetch the plugin list first so we can filter contributions to known
# plugin ids before serialising — prevents a crafted request from
# forcing large allocations for plugins build_bundle() would drop.
with PLUGINS_LOCK:
plugins_snapshot = list(LOADED_PLUGINS)
known_ids = {p.get("id") for p in plugins_snapshot if isinstance(p.get("id"), str)}
client_contributions = _diag_cap_contributions(
payload.get("client_contributions"), known_ids=known_ids
)
zip_bytes, filename, _manifest = _diag_build(
feedBack_version=appstate.running_version(),
config_dir=appstate.config_dir,
dlc_dir=_get_dlc_dir(),
log_file=_diag_log_file(),
loaded_plugins=plugins_snapshot,
include=include,
redact=redact,
client_console=client_console,
client_hardware=client_hardware,
client_ua=client_ua,
local_storage=local_storage,
client_contributions=client_contributions,
log=log,
plugins_root=_diag_plugins_roots(),
)
return Response(
content=zip_bytes,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/api/diagnostics/preview")
def preview_diagnostics(
redact: bool = True,
system: bool = True,
hardware: bool = True,
logs: bool = True,
console: bool = True,
plugins: bool = True,
):
"""Return what `/api/diagnostics/export` would produce, minus the
actual file contents file tree, sizes, schemas, redaction counts.
Lets the Settings UI show the user what's about to be sent."""
from plugins import LOADED_PLUGINS, PLUGINS_LOCK
include = {
"system": system,
"hardware": hardware,
"logs": logs,
"console": console,
"plugins": plugins,
}
with PLUGINS_LOCK:
plugins_snapshot = list(LOADED_PLUGINS)
return _diag_preview(
feedBack_version=appstate.running_version(),
config_dir=appstate.config_dir,
dlc_dir=_get_dlc_dir(),
log_file=_diag_log_file(),
loaded_plugins=plugins_snapshot,
include=include,
redact=redact,
log=log,
plugins_root=_diag_plugins_roots(),
)
@router.get("/api/diagnostics/hardware")
def diagnostics_hardware():
"""Backend hardware probe (cross-platform). Reusable independently
of the bundle export handy for "what's my GPU" plugin queries."""
return _diag_hardware()
+75
View File
@@ -0,0 +1,75 @@
"""Small meta_db-backed library / user-state endpoints — work keeper-chart
prefs, favorites, personal tags, saved-for-later, and continue-playing.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``. All paths
are distinct and non-overlapping, so mounting them together (rather than at each
original scattered site) does not change routing.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
@router.get("/api/work/{work_key:path}/charts")
def api_get_work_charts(work_key: str):
"""All charts in a work + which is the keeper (your pick vs auto-pick)."""
return appstate.meta_db.work_charts(work_key)
@router.put("/api/work/{work_key:path}/preferred")
def api_set_work_preferred(work_key: str, data: dict):
"""Set the keeper chart of a work: body {filename}. The filename must be a
current member of the work. Returns the refreshed chart list."""
fn = (data.get("filename") or "").strip()
if not fn:
return JSONResponse({"error": "filename is required"}, 400)
members = {c["filename"] for c in appstate.meta_db.work_charts(work_key)["charts"]}
if fn not in members:
return JSONResponse({"error": "filename is not a chart of this work"}, 400)
appstate.meta_db.set_chart_preferred(work_key, fn)
return appstate.meta_db.work_charts(work_key)
@router.delete("/api/work/{work_key:path}/preferred")
def api_reset_work_preferred(work_key: str):
"""Reset a work to auto-pick (drop the explicit preferred)."""
appstate.meta_db.clear_chart_preferred(work_key)
return appstate.meta_db.work_charts(work_key)
@router.post("/api/favorites/toggle")
def toggle_favorite(data: dict):
"""Toggle a song's favorite status."""
filename = data.get("filename", "")
if not filename:
return {"error": "No filename"}
new_state = appstate.meta_db.toggle_favorite(filename)
return {"favorite": new_state}
@router.get("/api/tags")
def list_tags():
"""All personal tags in use (over still-present songs), most-used first —
powers the tag filter UI."""
return {"tags": appstate.meta_db.all_tags()}
@router.post("/api/saved/toggle")
def api_toggle_saved(data: dict):
"""Add/remove a song on the reserved Saved-for-Later playlist."""
filename = _clean_str(data.get("filename"))
if not filename:
return JSONResponse({"error": "filename required"}, status_code=400)
return {"saved": appstate.meta_db.toggle_saved(filename)}
@router.get("/api/session/continue")
def api_session_continue():
"""The Continue-Playing card's song (most recent play) or null."""
return appstate.meta_db.continue_session()
+60
View File
@@ -0,0 +1,60 @@
"""Practice loops — saved A/B regions per song.
Extracted verbatim from ``server.py`` (R3); only the decorator receiver
(``@app`` -> ``@router``) and the singleton reads (``meta_db`` ->
``appstate.meta_db``) changed. See ``appstate.py`` for why the reads stay
module attributes.
"""
from fastapi import APIRouter
import appstate
router = APIRouter()
@router.get("/api/loops")
def list_loops(filename: str):
# Hold the DB lock for the read: the shared single connection
# (check_same_thread=False) is serialized through meta_db._lock by every
# writer, so an unlocked SELECT here can overlap a POST/DELETE commit.
db = appstate.meta_db
with db._lock:
rows = db.conn.execute(
"SELECT id, name, start_time, end_time FROM loops WHERE filename = ? ORDER BY start_time",
(filename,)
).fetchall()
return [{"id": r[0], "name": r[1], "start": r[2], "end": r[3]} for r in rows]
@router.post("/api/loops")
def save_loop(data: dict):
filename = data.get("filename", "")
name = data.get("name", "").strip()
start = data.get("start")
end = data.get("end")
if not filename or start is None or end is None:
return {"error": "Missing fields"}
db = appstate.meta_db
with db._lock:
# COUNT + INSERT under one lock so two unnamed POSTs can't read the same
# count and both mint "Loop N" (the count is only used to name the row).
if not name:
count = db.conn.execute(
"SELECT COUNT(*) FROM loops WHERE filename = ?", (filename,)
).fetchone()[0]
name = f"Loop {count + 1}"
db.conn.execute(
"INSERT INTO loops (filename, name, start_time, end_time) VALUES (?, ?, ?, ?)",
(filename, name, float(start), float(end))
)
db.conn.commit()
return {"ok": True, "name": name}
@router.delete("/api/loops/{loop_id}")
def delete_loop(loop_id: int):
with appstate.meta_db._lock:
appstate.meta_db.conn.execute("DELETE FROM loops WHERE id = ?", (loop_id,))
appstate.meta_db.conn.commit()
return {"ok": True}
+267
View File
@@ -0,0 +1,267 @@
"""Playlists + custom playlist covers (fee[dB]ack v0.3.0).
Extracted verbatim from ``server.py`` (R3). Edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR`` -> ``appstate.config_dir``
(both read at call time through the seam), and ``_clean_str`` now imports from
``reqfields``. See ``appstate.py``.
"""
import logging
import os
import tempfile
from pathlib import Path
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
import appstate
from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter()
# Cache policy for the custom-cover file response: revalidate every time so a
# replaced cover is never served stale (pairs with the mtime-ns URL token).
_ART_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def _playlist_cover_path(pid) -> Path | None:
"""Filesystem path of a playlist's optional custom cover image (PNG),
stored under CONFIG_DIR. Returns None for a non-integer id."""
try:
pid = int(pid)
except (TypeError, ValueError):
return None
return appstate.config_dir / "playlist_covers" / f"{pid}.png"
def _playlist_cover_url(pid) -> str | None:
cover = _playlist_cover_path(pid)
if not cover or not cover.exists():
return None
try:
# Nanosecond mtime so a same-second replace/remove/re-upload still
# changes the cache-bust token (int seconds could collide → stale image).
mt = cover.stat().st_mtime_ns
except OSError:
mt = 0
return f"/api/playlists/{pid}/cover?v={mt}"
@router.get("/api/playlists")
def api_list_playlists():
lists = appstate.meta_db.list_playlists()
for pl in lists:
pl["cover_url"] = _playlist_cover_url(pl["id"])
return lists
@router.post("/api/playlists")
def api_create_playlist(data: dict):
name = _clean_str(data.get("name"))
if not (1 <= len(name) <= 100):
return JSONResponse({"error": "Playlist name must be 1100 characters."}, status_code=400)
# kind='album' = a curated album (§7.2): hand-picked works, a chosen chart
# per slot, played front-to-back on the queue. Absent/None = a regular mix.
kind = _clean_str(data.get("kind")) or None
if kind not in (None, "album"):
return JSONResponse({"error": "kind must be 'album' or omitted"}, status_code=400)
return appstate.meta_db.create_playlist(name, kind=kind)
@router.get("/api/playlists/{pid}")
def api_get_playlist(pid: int):
pl = appstate.meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
pl["cover_url"] = _playlist_cover_url(pid)
return pl
@router.patch("/api/playlists/{pid}")
def api_rename_playlist(pid: int, data: dict):
pl = appstate.meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
if pl["system_key"]:
return JSONResponse({"error": "System playlists cannot be renamed."}, status_code=400)
name = _clean_str(data.get("name"))
if not (1 <= len(name) <= 100):
return JSONResponse({"error": "Playlist name must be 1100 characters."}, status_code=400)
appstate.meta_db.rename_playlist(pid, name)
return appstate.meta_db.get_playlist(pid)
@router.delete("/api/playlists/{pid}")
def api_delete_playlist(pid: int):
pl = appstate.meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
if pl["system_key"]:
return JSONResponse({"error": "System playlists cannot be deleted."}, status_code=400)
if not appstate.meta_db.delete_playlist(pid): # vanished under us (concurrent delete)
return JSONResponse({"error": "not found"}, status_code=404)
cover = _playlist_cover_path(pid) # drop any custom cover with the playlist
if cover and cover.exists():
try:
cover.unlink()
except OSError:
pass
return {"ok": True}
@router.post("/api/playlists/{pid}/songs")
def api_add_playlist_song(pid: int, data: dict):
if appstate.meta_db.get_playlist(pid) is None:
return JSONResponse({"error": "not found"}, status_code=404)
filename = _clean_str(data.get("filename"))
if not filename:
return JSONResponse({"error": "filename required"}, status_code=400)
if appstate.meta_db.add_playlist_song(pid, filename) is None: # playlist vanished under us
return JSONResponse({"error": "not found"}, status_code=404)
pl = appstate.meta_db.get_playlist(pid)
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
@router.patch("/api/playlists/{pid}/songs/{filename:path}")
def api_update_playlist_slot(pid: int, filename: str, data: dict):
"""Edit one curated-album slot: {"arrangement": name|null} pins/clears the
slot's arrangement; {"chart_filename": fn} swaps the slot to another chart
of the same work (position + pin kept). Albums only a mix has no slots."""
pl = appstate.meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
if pl.get("kind") != "album":
return JSONResponse({"error": "Slot editing is for albums."}, status_code=400)
kwargs = {}
if "chart_filename" in data:
new_fn = _clean_str(data.get("chart_filename"))
if not new_fn:
return JSONResponse({"error": "chart_filename must be a filename"}, status_code=400)
kwargs["new_filename"] = new_fn
if "arrangement" in data:
arr = data.get("arrangement")
if arr is not None and not (isinstance(arr, str) and 1 <= len(arr.strip()) <= 100):
return JSONResponse({"error": "arrangement must be a name or null"}, status_code=400)
kwargs["arrangement"] = arr.strip() if isinstance(arr, str) else None
if not kwargs:
return JSONResponse({"error": "nothing to update"}, status_code=400)
if appstate.meta_db.update_playlist_slot(pid, filename, **kwargs) is None:
return JSONResponse(
{"error": "no such slot, or the chart isn't a version of this song"},
status_code=400)
return appstate.meta_db.get_playlist(pid)
@router.delete("/api/playlists/{pid}/songs/{filename:path}")
def api_remove_playlist_song(pid: int, filename: str):
if appstate.meta_db.get_playlist(pid) is None:
return JSONResponse({"error": "not found"}, status_code=404)
appstate.meta_db.remove_playlist_song(pid, filename)
pl = appstate.meta_db.get_playlist(pid)
return pl if pl is not None else JSONResponse({"error": "not found"}, status_code=404)
@router.post("/api/playlists/{pid}/reorder")
def api_reorder_playlist(pid: int, data: dict):
pl = appstate.meta_db.get_playlist(pid)
if pl is None:
return JSONResponse({"error": "not found"}, status_code=404)
order = data.get("order")
if not isinstance(order, list) or not all(isinstance(f, str) for f in order):
return JSONResponse({"error": "order must be a list of filenames"}, status_code=400)
# Require an exact permutation of the playlist's current songs: a list with
# duplicates, omissions, or extras would otherwise produce duplicate
# positions / a partial reorder while still returning 200.
current = [s["filename"] for s in pl["songs"]]
if len(order) != len(current) or sorted(order) != sorted(current):
return JSONResponse(
{"error": "order must be a permutation of the playlist's current songs"},
status_code=400,
)
appstate.meta_db.reorder_playlist(pid, order)
return appstate.meta_db.get_playlist(pid)
@router.post("/api/playlists/{pid}/cover")
async def api_set_playlist_cover(pid: int, data: dict):
"""Set a playlist's custom cover from a base64 / data-URL image (PNG/JPG).
Overrides the content-dependent (song-art) cover. Stored as a small PNG
thumbnail under CONFIG_DIR/playlist_covers/."""
if appstate.meta_db.get_playlist(pid) is None:
return JSONResponse({"error": "not found"}, status_code=404)
import base64
import io
b64 = data.get("image", "")
# Guard the type before the `","` membership test — a non-string image
# (e.g. {"image": 123} / null) would otherwise raise TypeError → 500.
# Mirrors the avatar/song-art upload guard.
if not isinstance(b64, str) or not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
if "," in b64:
b64 = b64.split(",", 1)[1]
if not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
try:
img_data = base64.b64decode(b64)
except Exception:
return JSONResponse({"error": "Invalid base64"}, status_code=400)
cover = _playlist_cover_path(pid)
cover.parent.mkdir(parents=True, exist_ok=True)
# Decode/validate the image — a bad payload is a CLIENT error (400), and the
# message stays generic so it can't echo internals.
try:
from PIL import Image
img = Image.open(io.BytesIO(img_data)).convert("RGB")
img.thumbnail((640, 640)) # covers stay small
except Exception:
return JSONResponse({"error": "Invalid image"}, status_code=400)
# Persist. A save/replace failure is a SERVER error (500, logged, no
# filesystem detail leaked) — the pre-split handler mislabeled these as 400
# and echoed the exception. A unique temp name in the cover dir (not a shared
# `{pid}.png.tmp`) means two concurrent uploads can't clobber each other's
# temp file; the atomic replace publishes. Re-check the playlist still exists
# just before publishing so a delete that raced the decode above can't leave
# an orphan cover — cheap belt-and-braces; FeedBack is single-user
# (Principle I), so a full per-playlist lock would be for a race the
# deployment model precludes.
tmp = None
try:
# mkstemp is inside the try too: an unwritable dir / full disk raises
# here, and that's the same class of persistence failure as save/replace.
fd, tmp_name = tempfile.mkstemp(prefix=f".{pid}.", suffix=".png.tmp", dir=str(cover.parent))
tmp = Path(tmp_name)
with os.fdopen(fd, "wb") as f:
img.save(f, "PNG")
if appstate.meta_db.get_playlist(pid) is None:
tmp.unlink(missing_ok=True)
return JSONResponse({"error": "not found"}, status_code=404)
tmp.replace(cover)
except Exception:
if tmp is not None:
tmp.unlink(missing_ok=True)
log.exception("playlist cover save failed (pid=%s)", pid)
return JSONResponse({"error": "could not save cover"}, status_code=500)
return {"ok": True, "cover_url": _playlist_cover_url(pid)}
@router.get("/api/playlists/{pid}/cover")
def api_get_playlist_cover(pid: int):
cover = _playlist_cover_path(pid)
if not cover or not cover.exists():
return JSONResponse({"error": "not found"}, status_code=404)
# no-cache (revalidate) like song art, so a replaced cover is never served
# stale — pairs with the mtime-ns cache-bust token on the URL.
return FileResponse(str(cover), media_type="image/png", headers=_ART_CACHE_HEADERS)
@router.delete("/api/playlists/{pid}/cover")
def api_delete_playlist_cover(pid: int):
cover = _playlist_cover_path(pid)
if cover and cover.exists():
try:
cover.unlink()
except OSError:
pass
return {"ok": True}
+138
View File
@@ -0,0 +1,138 @@
"""Player profile — identity, avatars (bundled + custom uploads), and progress.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``CONFIG_DIR``/``STATIC_DIR`` ->
``appstate.config_dir``/``appstate.static_dir`` (seam), ``_clean_str`` from
``reqfields``, ``_get_progression_content()`` ->
``appstate.get_progression_content()``. The bundled-avatar lister moves with it.
"""
import logging
import secrets
from fastapi import APIRouter
from fastapi.responses import FileResponse, JSONResponse
import appstate
from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter()
def _list_bundled_avatars() -> list[str]:
"""Bundled default avatar filenames under static/v3/avatars/."""
d = appstate.static_dir / "v3" / "avatars"
if not d.is_dir():
return []
exts = {".svg", ".png", ".webp"}
return sorted(
p.name for p in d.iterdir()
if p.is_file() and p.suffix.lower() in exts and not p.name.startswith(".")
)
@router.get("/api/profile")
def api_get_profile():
profile = appstate.meta_db.get_profile()
# Equipped cosmetics ride along (resolved to their payloads) so the theme
# and avatar frame apply at boot without an extra request. Never let a
# cosmetics/content problem break the profile read.
cosmetics = {}
try:
shop = appstate.get_progression_content()["shop"]
for slot, item_id in appstate.meta_db.get_equipped().items():
item = shop.get(item_id)
if item:
cosmetics[slot] = {"item_id": item_id, "payload": item["payload"]}
except Exception:
log.warning("profile cosmetics enrich failed", exc_info=True)
profile["cosmetics"] = cosmetics
return profile
@router.post("/api/profile")
def api_set_profile(data: dict):
"""Set/update the player profile. Body: {display_name, avatar:{type,value}}.
avatar.type is 'default' (value = bundled filename) or 'upload' (value =
the /api/profile/avatar/<name> URL returned by the upload endpoint); omit
avatar to keep the existing one (name-only edit)."""
name = _clean_str(data.get("display_name"))
if not (1 <= len(name) <= 32):
return JSONResponse({"error": "Display name must be 132 characters."}, status_code=400)
avatar = data.get("avatar")
if avatar is None:
avatar = {} # omitted → keep the current avatar (name-only edit)
elif not isinstance(avatar, dict):
return JSONResponse({"error": "avatar must be an object."}, status_code=400)
atype = avatar.get("type")
aval = _clean_str(avatar.get("value"))
avatar_url = None
if atype == "default":
if aval not in _list_bundled_avatars():
return JSONResponse({"error": "Unknown default avatar."}, status_code=400)
avatar_url = f"/static/v3/avatars/{aval}"
elif atype == "upload":
from safepath import safe_join
fname = aval.rsplit("/", 1)[-1] if aval.startswith("/api/profile/avatar/") else ""
target = safe_join(appstate.config_dir / "avatars", fname) if fname else None
if target is None or not target.is_file():
return JSONResponse({"error": "Uploaded avatar not found."}, status_code=400)
avatar_url = f"/api/profile/avatar/{fname}"
elif atype:
return JSONResponse({"error": "Unknown avatar type."}, status_code=400)
# atype None/missing → keep the current avatar (name-only edit).
return appstate.meta_db.set_profile(name, avatar_url)
@router.get("/api/profile/avatars")
def api_list_avatars():
return [{"name": n, "url": f"/static/v3/avatars/{n}"} for n in _list_bundled_avatars()]
@router.post("/api/profile/avatar")
def api_upload_avatar(data: dict):
"""Upload a custom avatar as base64 (mirrors the album-art upload pattern).
Re-encodes to a 512px PNG under appstate.config_dir/avatars/."""
import base64
import io
b64 = data.get("image", "")
if not isinstance(b64, str) or not b64:
return JSONResponse({"error": "No image data"}, status_code=400)
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
raw = base64.b64decode(b64)
except Exception:
return JSONResponse({"error": "Invalid base64"}, status_code=400)
if len(raw) > 6 * 1024 * 1024:
return JSONResponse({"error": "Image too large (max 6 MB)."}, status_code=400)
avatars_dir = appstate.config_dir / "avatars"
avatars_dir.mkdir(parents=True, exist_ok=True)
try:
from PIL import Image
img = Image.open(io.BytesIO(raw)).convert("RGB")
img.thumbnail((512, 512))
fname = f"upload-{secrets.token_hex(4)}.png" # token busts caches on change
img.save(str(avatars_dir / fname), "PNG")
except Exception as e:
return JSONResponse({"error": f"Invalid image: {e}"}, status_code=400)
return {"url": f"/api/profile/avatar/{fname}"}
@router.get("/api/profile/avatar/{name}")
def api_get_avatar(name: str):
from safepath import safe_join
target = safe_join(appstate.config_dir / "avatars", name)
if target is None or not target.is_file():
return JSONResponse({"error": "not found"}, status_code=404)
return FileResponse(str(target), media_type="image/png")
@router.get("/api/profile/progress")
def api_profile_progress():
"""One call for the whole profile badge: {level, xp, xp_in_level,
xp_to_next, current_streak, best_streak, last_active_date}."""
return appstate.meta_db.get_progress()
+230
View File
@@ -0,0 +1,230 @@
"""Progression (spec 010) — mastery rank, challenges, quests, onboarding paths.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``, and the
two shared server accessors read through the seam:
``_get_progression_content()`` -> ``appstate.get_progression_content()`` and
``_builtin_diagnostic_filename()`` -> ``appstate.builtin_diagnostic_filename()``.
The exclusive helpers (_goal_ui_progress, _progression_overview) + the
_PROGRESSION_EVENT_TYPES whitelist move with it.
"""
import math
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
def _goal_ui_progress(goal: dict, state: dict, streak: int, xp_total: int) -> tuple:
"""(count, target) for a challenge/quest progress bar. Count goals show
n/target; threshold goals show how far the live stat is along the line."""
import progression as progression_mod
gtype = goal.get("type")
if gtype in progression_mod.COUNT_GOAL_TYPES:
target = int(goal.get("target") or 1)
count = target if state.get("completed") else min(int(state.get("count") or 0), target)
return count, target
if gtype == "streak_reached":
target = int(goal.get("days") or 1)
return (target if state.get("completed") else min(streak, target)), target
if gtype == "db_earned":
target = int(goal.get("amount") or 1)
return (target if state.get("completed") else min(xp_total, target)), target
return 0, 1
def _progression_overview() -> dict:
"""The full GET /api/progression payload (also the capability `inspect`
result): rank, onboarding, per-path challenge checklists, quests, wallet."""
import progression as progression_mod
from datetime import datetime as _dt
content = appstate.get_progression_content()
now = _dt.now()
appstate.meta_db.ensure_quest_period(content, now)
state = appstate.meta_db.get_progression_state()
player_paths = appstate.meta_db.get_player_paths()
challenge_state = appstate.meta_db.get_challenge_state()
wallet = appstate.meta_db.get_wallet()
streak_progress = appstate.meta_db.get_progress()
streak = int(streak_progress.get("current_streak") or 0)
xp_total = wallet["lifetime_db"]
keys = progression_mod.period_keys(now)
def _path_order(pid):
pdef = content["paths"].get(pid) or {}
return (pdef.get("order") or 0, pid)
paths_payload = []
for pid in sorted(player_paths, key=_path_order):
pdef = content["paths"].get(pid)
level = player_paths[pid]
if not pdef:
# Path selected under older content that no longer ships: keep its
# rank contribution visible rather than silently dropping it.
paths_payload.append({"id": pid, "name": pid, "icon": "", "level": level,
"max_level": level, "next": None})
continue
next_block = None
active = progression_mod.active_challenges(content, pid, level)
if active:
level_def = next(e for e in pdef["levels"] if e["level"] == level + 1)
challenges = []
completed_count = 0
for ch in active:
st = challenge_state.get(ch["id"]) or {}
count, target = _goal_ui_progress(ch["goal"], st, streak, xp_total)
if st.get("completed"):
completed_count += 1
challenges.append({
"id": ch["id"],
"title": ch["title"],
"description": ch["description"],
"count": count,
"target": target,
"completed": bool(st.get("completed")),
"completed_at": st.get("completed_at"),
})
next_block = {
"level": level + 1,
"required": level_def["required"],
"completed": completed_count,
"challenges": challenges,
}
paths_payload.append({
"id": pid,
"name": pdef["name"],
"icon": pdef["icon"],
"level": level,
"max_level": progression_mod.path_max_level(content, pid),
"next": next_block,
})
available = [
{"id": pid, "name": pdef["name"], "icon": pdef["icon"]}
for pid, pdef in sorted(content["paths"].items(), key=lambda kv: (kv[1].get("order") or 0, kv[0]))
if pid not in player_paths
]
quest_rows = appstate.meta_db.get_quest_rows(keys)
quests_payload = {}
for period_type in ("daily", "weekly"):
pool = content["quests"][period_type]["pool"]
quests = []
for row in quest_rows:
if row["period_type"] != period_type:
continue
qdef = pool.get(row["quest_id"])
if not qdef:
continue # removed from the pool mid-period: hide, keep the row
count, target = _goal_ui_progress(qdef["goal"], row, streak, xp_total)
quests.append({
"id": row["quest_id"],
"title": qdef["title"],
"description": qdef["description"],
"reward_db": row["reward_db"],
"count": count,
"target": target,
"completed": row["completed"],
"completed_at": row["completed_at"],
})
quests_payload[period_type] = {
"period_key": keys[period_type],
"resets_at": progression_mod.period_resets_at(period_type, now).isoformat(),
"quests": quests,
}
return {
"mastery_rank": progression_mod.mastery_rank(state["calibration_status"], player_paths),
"onboarding": {
"calibration_status": state["calibration_status"],
"calibration_completed_at": state["calibration_completed_at"],
"diagnostic_filename": appstate.builtin_diagnostic_filename(),
},
"paths": paths_payload,
"available_paths": available,
"quests": quests_payload,
"wallet": wallet,
}
@router.get("/api/progression")
def api_progression():
return _progression_overview()
@router.post("/api/progression/paths")
def api_progression_add_paths(data: dict):
"""Select instrument paths. Body: {add: [path_id, ...]}. Idempotent;
removal is unsupported (Mastery Rank never decreases)."""
add = data.get("add")
if not isinstance(add, list) or not add:
return JSONResponse({"error": "add must be a non-empty list of path ids"}, status_code=400)
content = appstate.get_progression_content()
for pid in add:
if not isinstance(pid, str) or pid not in content["paths"]:
return JSONResponse({"error": f"unknown path: {pid!r}"}, status_code=400)
appstate.meta_db.add_player_paths(add)
return _progression_overview()
@router.post("/api/progression/onboarding")
def api_progression_onboarding(data: dict):
"""Onboarding calibration choice. Body: {action: "skip"} — completing the
calibration needs no endpoint, it flows through the normal /api/stats path."""
if _clean_str(data.get("action")) != "skip":
return JSONResponse({"error": "action must be 'skip'"}, status_code=400)
# Spec invariant: onboarding requires picking at least one instrument path
# before finishing, so skipping straight to rank 1 with no paths would
# leave a rank that can never grow. Only enforced when the content bundle
# actually defines paths — broken/empty content must never brick onboarding.
if appstate.get_progression_content()["paths"] and not appstate.meta_db.get_player_paths():
return JSONResponse(
{"error": "select at least one instrument path before skipping calibration"},
status_code=400,
)
appstate.meta_db.skip_calibration()
return _progression_overview()
# Externally postable progression events. song_completed is deliberately NOT
# here: it is server-derived inside /api/stats so the scored-session authority
# stays in one place.
_PROGRESSION_EVENT_TYPES = {"minigame_run"}
@router.post("/api/progression/events")
def api_progression_events(data: dict):
"""Generic progression-event intake for plugins (capability `record-event`).
Body: {type, payload}. Whitelisted types, scalar payload values only."""
etype = _clean_str(data.get("type"))
if etype not in _PROGRESSION_EVENT_TYPES:
return JSONResponse(
{"error": f"event type must be one of {sorted(_PROGRESSION_EVENT_TYPES)}"},
status_code=400,
)
payload = data.get("payload")
if payload is None:
payload = {}
if not isinstance(payload, dict) or len(payload) > 16:
return JSONResponse({"error": "payload must be a small object"}, status_code=400)
clean = {}
for key, value in payload.items():
if not isinstance(key, str) or len(key) > 64:
return JSONResponse({"error": "payload keys must be short strings"}, status_code=400)
if value is None:
continue
if isinstance(value, bool) or (
not isinstance(value, (int, float, str))
) or (isinstance(value, float) and not math.isfinite(value)) or (
isinstance(value, str) and len(value) > 256
):
return JSONResponse({"error": "payload values must be short strings or finite numbers"}, status_code=400)
clean[key] = value
summary = appstate.meta_db.record_progression_event(etype, clean, appstate.get_progression_content())
return {"ok": True, "progression": summary}
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
"""Cosmetics shop (spec 010) — buy/equip avatars & themes with earned currency.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` ->
``appstate.get_progression_content()`` (the accessor is injected into the seam;
its lazy content cache stays in server.py).
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
@router.get("/api/shop")
def api_shop():
content = appstate.get_progression_content()
owned = appstate.meta_db.get_owned_items()
equipped = appstate.meta_db.get_equipped()
items = [
{**item, "owned": iid in owned, "equipped": equipped.get(item["slot"]) == iid}
for iid, item in sorted(content["shop"].items())
]
return {"items": items, "wallet": appstate.meta_db.get_wallet()}
@router.post("/api/shop/buy")
def api_shop_buy(data: dict):
"""Spend Decibels on a cosmetic. Atomic: balance check + spend + ownership
in one transaction. Decibels are earned by playing only never purchasable."""
item_id = _clean_str(data.get("item_id"))
item = appstate.get_progression_content()["shop"].get(item_id)
if not item:
return JSONResponse({"error": f"unknown item: {item_id!r}"}, status_code=400)
status, wallet = appstate.meta_db.buy_shop_item(item)
if status == "owned":
return JSONResponse({"error": "already owned", "wallet": wallet}, status_code=409)
if status == "insufficient":
return JSONResponse({"error": "insufficient balance", "wallet": wallet}, status_code=402)
return {"ok": True, "item_id": item_id, "wallet": wallet}
@router.post("/api/shop/equip")
def api_shop_equip(data: dict):
"""Equip an owned cosmetic into its slot. Body: {slot, item_id|null}
(null unequips, restoring the default look)."""
import progression as progression_mod
slot = _clean_str(data.get("slot"))
if slot not in progression_mod.SHOP_SLOTS:
return JSONResponse({"error": f"slot must be one of {sorted(progression_mod.SHOP_SLOTS)}"}, status_code=400)
item_id = data.get("item_id")
if item_id is not None:
item_id = _clean_str(item_id)
item = appstate.get_progression_content()["shop"].get(item_id)
if not item or item["slot"] != slot:
return JSONResponse({"error": f"unknown item for slot {slot}: {item_id!r}"}, status_code=400)
if item_id not in appstate.meta_db.get_owned_items():
return JSONResponse({"error": "item not owned"}, status_code=403)
return {"ok": True, "equipped": appstate.meta_db.equip_item(slot, item_id)}
+233
View File
@@ -0,0 +1,233 @@
"""Gameplay scoring — XP award + per-song practice stats (record / recent / best /
top / per-song). The `/api/stats/{filename:path}` route is registered LAST so its
catch-all doesn't shadow the fixed /recent /best /top paths.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_get_progression_content()`` /
``_builtin_diagnostic_filename()`` read through the seam.
"""
import logging
import math
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from metadata_db import _as_int
from reqfields import _clean_str
log = logging.getLogger("feedBack.server")
router = APIRouter()
@router.post("/api/xp/award")
def api_award_xp(data: dict):
"""Award XP into the unified store. Body: {source, amount}. Returns the
new progress payload. The single XP authority song-play, minigames, and
tutorials all feed this (no second curve)."""
try:
amount = _as_int(data.get("amount", 0)) # rejects bool / non-integral / inf
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "amount must be an integer"}, status_code=400)
# Upper-bound it: an unbounded value overflows SQLite's 64-bit INTEGER on
# bind (→ 500) and no real run awards anywhere near this.
if not (0 <= amount <= 10_000_000):
return JSONResponse({"error": "amount must be between 0 and 10,000,000"}, status_code=400)
appstate.meta_db.award_xp(amount)
return appstate.meta_db.get_progress()
@router.post("/api/stats")
def api_record_stats(data: dict):
"""Record a play. With `score`+`accuracy` → a scored session (plays += 1,
best_* = max, last_* = new) plus unified-XP + streak side-effects. With
only `lastPlayPosition`/`last_position` a lightweight resume-position
touch (no plays change) so Continue-Playing works for non-scored plays."""
filename = _clean_str(data.get("filename"))
if not filename:
return JSONResponse({"error": "filename required"}, status_code=400)
# The recorder hands us URL-encoded filenames; canonicalize to the library
# key so stored rows line up with `songs` (and so the arrangement-count bound
# below resolves the real song). See MetadataDB._canonical_song_filename.
filename = appstate.meta_db._canonical_song_filename(filename)
arr_raw = data.get("arrangement", 0)
if arr_raw is None:
arrangement = 0
else:
try:
arrangement = _as_int(arr_raw) # rejects bool / non-integral (1.9) / inf
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
# Reject (don't silently coerce to 0) so a malformed/out-of-range index
# can't corrupt arrangement 0's stats; also keeps it bindable to INTEGER.
if not (0 <= arrangement < 2**63):
return JSONResponse({"error": "arrangement must be a non-negative integer"}, status_code=400)
# Bound against the song's real arrangement count when it's a known library
# song, so a bad index can't create fake arrangement buckets that poison the
# per-song aggregate / Continue. Skipped when the song isn't in the library
# yet (count unknown — dead-song reads are filtered anyway).
_acount = appstate.meta_db.arrangement_count(filename)
if _acount and arrangement >= _acount:
return JSONResponse({"error": "arrangement out of range for this song"}, status_code=400)
score = data.get("score")
accuracy = data.get("accuracy")
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)
# A scored session needs BOTH score and accuracy. Exactly one provided is
# ambiguous — don't silently fall through to the position-only branch.
if (score is None) != (accuracy is None):
return JSONResponse({"error": "score and accuracy must be provided together"}, status_code=400)
if score is not None and accuracy is not None:
# Reject booleans explicitly — float(True) would otherwise record a play.
if isinstance(score, bool) or isinstance(accuracy, bool):
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
# Reject NaN/Inf too: round(inf) raises OverflowError (→ 500), and a
# stored Inf/NaN later breaks JSON serialization of /api/stats reads.
try:
score = float(score)
accuracy = float(accuracy)
if not (math.isfinite(score) and math.isfinite(accuracy)):
raise ValueError("non-finite")
score = int(round(score))
except (TypeError, ValueError, OverflowError):
return JSONResponse({"error": "score/accuracy must be finite numbers"}, status_code=400)
# A huge-but-finite score passes isfinite() yet overflows SQLite's
# 64-bit INTEGER on bind (→ 500). Bound it to the int64 range.
if not (0 <= score < 2**63):
return JSONResponse({"error": "score out of range"}, status_code=400)
# accuracy is a 0..1 fraction (the recorder's contract); reject
# out-of-range values so they don't surface as >100% / negative in
# /api/stats/best and the badge UI.
if not (0 <= accuracy <= 1):
return JSONResponse({"error": "accuracy must be between 0 and 1"}, status_code=400)
# Validate the optional resume position in this branch too (the
# position-only branch below already rejects non-finite).
if last_pos is not None:
try:
last_pos = float(last_pos)
if not math.isfinite(last_pos):
raise ValueError("non-finite")
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)
# Unified XP + streak side-effects — never let these drop the stat write.
progress = None
try:
from xp import xp_for_run
from datetime import date
appstate.meta_db.award_xp(xp_for_run(score))
appstate.meta_db.record_active_day(date.today().isoformat())
progress = appstate.meta_db.get_progress()
except Exception:
log.warning("stats side-effects (xp/streak) failed", exc_info=True)
# Progression engine (spec 010) — same never-drop-the-stat-write
# contract. Scored sessions are the server-derived `song_completed`
# authority (scored == note detection by construction); instrument is
# resolved from library arrangement metadata, after the XP award so
# db_earned goals see this run's Decibels.
progression_summary = None
try:
import progression as progression_mod
instrument = progression_mod.instrument_for_arrangement(
appstate.meta_db.arrangement_entry(filename, arrangement)
)
progression_summary = appstate.meta_db.record_progression_event(
"song_completed",
{
"filename": filename,
"instrument": instrument,
"accuracy": accuracy,
"score": score,
"is_diagnostic": filename == appstate.builtin_diagnostic_filename(),
},
appstate.get_progression_content(),
)
except Exception:
log.warning("stats side-effects (progression) failed", exc_info=True)
return {"stats": row, "progress": progress, "progression": progression_summary}
# Position-only touch.
if last_pos is None:
return JSONResponse(
{"error": "provide score+accuracy (scored) or lastPlayPosition (resume)"},
status_code=400,
)
try:
pos = float(last_pos)
if not math.isfinite(pos):
raise ValueError("non-finite")
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 —
# that's scoring-only) so a non-scored practice day keeps the streak alive,
# consistent with these sessions also surfacing in recent / continue.
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}
@router.get("/api/stats/recent")
def api_recent_stats(limit: int = 12):
"""Recently-played rows joined to song metadata for 'Jump back in'."""
from urllib.parse import quote
out = []
for r in appstate.meta_db.recent_stats(limit):
meta = appstate.meta_db.conn.execute(
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
(r["filename"],),
).fetchone()
title, artist, tuning_name = meta if meta else (None, None, None)
out.append({
**r,
"title": title or r["filename"],
"artist": artist or "",
"tuning_name": tuning_name or "",
"art_url": f"/api/song/{quote(r['filename'])}/art",
})
return out
@router.get("/api/stats/best")
def api_stats_best():
"""{filename: best_accuracy} for all songs with a recorded best — one call
to badge the library grid (defined before the {filename} catch-all)."""
return appstate.meta_db.best_accuracy_map()
@router.get("/api/stats/top")
def api_top_stats(limit: int = 5):
"""Top scored songs (best first), joined to song metadata, for the profile
'Your best scores' panel (defined before the {filename} catch-all)."""
from urllib.parse import quote
out = []
for r in appstate.meta_db.top_stats(limit):
meta = appstate.meta_db.conn.execute(
"SELECT title, artist, tuning_name FROM songs WHERE filename = ?",
(r["filename"],),
).fetchone()
title, artist, tuning_name = meta if meta else (None, None, None)
out.append({
**r,
"title": title or r["filename"],
"artist": artist or "",
"tuning_name": tuning_name or "",
"art_url": f"/api/song/{quote(r['filename'])}/art",
})
return out
@router.get("/api/stats/{filename:path}")
def api_song_stats(filename: str):
return appstate.meta_db.get_song_stats(filename)
+46
View File
@@ -0,0 +1,46 @@
"""The merged tuning catalog (/api/tunings).
Extracted verbatim from server.py (R3) except @app->@router, CONFIG_DIR->
appstate.config_dir, _load_config imported from lib/appconfig, and the tuning
registry read through the appstate seam (appstate.tuning_providers the same
instance plugins register into via the plugin_context in server.py).
"""
from fastapi import APIRouter
import appstate
from appconfig import _load_config
from tunings import DEFAULT_REFERENCE_PITCH, TUNING_PRESET_MIDIS, freqs_to_midis
router = APIRouter()
@router.get("/api/tunings")
def get_tunings():
cfg = _load_config(appstate.config_dir / "config.json") or {}
ref = cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)
try:
ref = float(ref)
if not (430.0 <= ref <= 450.0):
ref = DEFAULT_REFERENCE_PITCH
except (TypeError, ValueError):
ref = DEFAULT_REFERENCE_PITCH
merged = appstate.tuning_providers.get_merged(ref)
# tuningMidis: the same catalog as exact integer MIDI notes (low → high).
# Built-ins come straight from TUNING_PRESET_MIDIS (no float round-trip);
# provider-contributed entries are recovered from their frequencies at the
# served reference pitch. Every consumer today (the v3 badges, plugins)
# reconstructs midis client-side via log2 — a rounding footgun at non-440
# references — so serve the integers once, host-side. Additive: the
# existing referencePitch/tunings shape is unchanged.
tuning_midis: dict[str, dict[str, list[int]]] = {}
for key, names in merged.items():
builtin = TUNING_PRESET_MIDIS.get(key, {})
resolved: dict[str, list[int]] = {}
for name, freqs in names.items():
midis = builtin.get(name) or freqs_to_midis(freqs, ref)
if midis:
resolved[name] = list(midis)
if resolved:
tuning_midis[key] = resolved
return {"referencePitch": ref, "tunings": merged, "tuningMidis": tuning_midis}
+81
View File
@@ -0,0 +1,81 @@
"""App version + source/license URLs (/api/version).
Extracted verbatim from ``server.py`` (R3) except the decorator (``@app`` ->
``@router``) and the VERSION-file lookup: ``Path(__file__).parent`` (app root
when this lived at the top level) -> ``Path(__file__).resolve().parents[2]``
(routers -> lib -> app root). VERSION ships at the app root in every packaging
path (Dockerfile COPY, desktop bundle).
"""
import os
from pathlib import Path
from fastapi import APIRouter
router = APIRouter()
def _safe_http_url(raw):
"""Return `raw` stripped + trailing-slash-stripped if it parses as an
http(s) URL with a non-empty host; else None.
Used to validate operator-supplied `APP_SOURCE_URL` / `APP_LICENSE_URL`
env vars before they reach `<a href>` in the UI. A bare prefix check
like `startswith(("http://","https://"))` accepts malformed inputs
such as `"https://"` (no host) or `"https:///foo"` (empty host) that
still produce broken hrefs and, when used as a base for the default
`license_url`, garbage like `"https:///blob/main/LICENSE"`.
"""
from urllib.parse import urlsplit
if not raw:
return None
s = raw.strip().rstrip("/")
if not s:
return None
try:
parsed = urlsplit(s)
except ValueError:
return None
if parsed.scheme.lower() not in ("http", "https"):
return None
# `netloc` includes any `user:pass@` and `:port` — strings like
# "http://:80/path" have non-empty netloc (":80") but no real
# hostname. Validate `hostname` so only URLs with an actual host
# are accepted.
if not parsed.hostname:
return None
return s
@router.get("/api/version")
def get_version():
env_version = os.environ.get("APP_VERSION", "").strip()
if env_version:
version = env_version
else:
version_file = Path(__file__).resolve().parents[2] / "VERSION" # R3: app root from lib/routers/
version = "unknown"
if version_file.exists():
try:
version = version_file.read_text().strip()
except (OSError, UnicodeDecodeError):
pass
default_source_url = "https://github.com/got-feedback/feedBack"
# APP_SOURCE_URL / APP_LICENSE_URL flow straight into <a href> in the UI,
# so validate with urllib.parse rather than a bare prefix check — a prefix
# check accepts malformed values like "https://" (no host) which produce
# broken hrefs (and a constructed license_url like "https:///blob/main/LICENSE").
# _safe_http_url requires scheme in {http,https} AND a non-empty hostname
# (not just netloc — that would still accept port-only authorities like
# "http://:80/path"); fall back to the safe default otherwise.
source_url = _safe_http_url(os.environ.get("APP_SOURCE_URL")) or default_source_url
# APP_LICENSE_URL: explicit override for the LICENSE link. The default
# constructed value (source_url + "/blob/main/LICENSE") is GitHub-
# specific and assumes the repo's default branch is `main`; non-GitHub
# hosts (GitLab, Gitea, self-hosted) need an explicit value.
license_url = _safe_http_url(os.environ.get("APP_LICENSE_URL")) or (source_url + "/blob/main/LICENSE")
return {
"version": version,
"source_url": source_url,
"license_url": license_url,
}
+45
View File
@@ -0,0 +1,45 @@
"""Wishlist / "wanted" API (feedBack#636) — songs the user wants but doesn't own.
Extracted verbatim from ``server.py`` (R3); edits: ``@app`` -> ``@router``,
``meta_db`` -> ``appstate.meta_db``, ``_clean_str`` from ``reqfields``.
"""
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import appstate
from reqfields import _clean_str
router = APIRouter()
@router.get("/api/wanted")
def api_list_wanted():
"""The wishlist — songs the user wants but doesn't own yet (newest first)."""
return {"wanted": appstate.meta_db.list_wanted()}
@router.post("/api/wanted")
def api_add_wanted(data: dict):
"""Add a not-owned song to the wishlist. `artist`/`title` are required (at
least one non-empty); `source`/`source_ref`/`note` are optional. Idempotent
on identity so producers (find_more ownership-diff, manual add) can re-post."""
if not isinstance(data, dict):
return JSONResponse({"error": "body must be an object"}, status_code=400)
artist = _clean_str(data.get("artist"))
title = _clean_str(data.get("title"))
if not artist and not title:
return JSONResponse({"error": "artist or title required"}, status_code=400)
row = appstate.meta_db.add_wanted(
artist=artist, title=title,
source=_clean_str(data.get("source")) or "manual",
source_ref=_clean_str(data.get("source_ref")),
note=_clean_str(data.get("note")),
)
return {"ok": True, "wanted": row}
@router.delete("/api/wanted/{wanted_id}")
def api_remove_wanted(wanted_id: int):
"""Remove a wishlist entry by id."""
return {"ok": appstate.meta_db.remove_wanted(wanted_id)}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
This module is deliberately kept apart from ``server.py`` so that
``ProcessPoolExecutor`` workers can import and unpickle ``_scan_one``
without dragging in ``server.py``'s import-time side effects
(``configure_logging()``, ``meta_db = MetadataDB()`` opening/migrating
(``configure_logging()``, ``meta_db = MetadataDB(CONFIG_DIR)`` opening/migrating
SQLite, and ``register_plugin_api(app)`` registering routes).
The background scan spawns its pool with the ``spawn`` start method (see
+26 -14
View File
@@ -703,20 +703,32 @@ def load_song(
and isinstance(e.get("d"), (int, float))
]
if song.lyrics:
# Provenance — populated by the converter (xml/notechart),
# the WhisperX fallback (whisperx), or hand-edits
# (user). Validate against the closed enum so a
# hand-edited (or otherwise malformed) manifest can't
# propagate a YAML dict / list / arbitrary string
# into the highway WS `lyrics.source` field and out
# to plugin badges. Anything outside the enum (or
# the wrong type) falls back to "xml" — the spec's
# back-compat default — instead of being stringified
# and trusted.
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
# Legacy alias: older manifests labelled note-chart-derived
# lyrics with the source format's name; normalise it.
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
# Provenance. The feedpak spec (§7.1) vocabulary is
# {authored, transcribed, user}; older manifests + the
# in-tree readers also use the source-format names
# (xml/notechart) and the WhisperX engine name
# (whisperx). Accept the union so both spec-compliant
# writers (e.g. the stem_splitter plugin emitting
# `transcribed`) and legacy packs validate. Validate
# against the closed enum so a hand-edited (or otherwise
# malformed) manifest can't propagate a YAML dict / list /
# arbitrary string into the highway WS `lyrics.source`
# field and out to plugin badges. Anything outside the
# enum (or the wrong type) falls back to "xml" — the
# back-compat default — instead of being stringified and
# trusted.
# Post-alias values only: `whisperx` is normalised to
# `transcribed` before the membership check below, so (like
# `sng`) it is intentionally absent from this set.
_ALLOWED_LYRICS_SOURCES = {
"xml", "notechart", "user",
"authored", "transcribed",
}
# Legacy aliases: older manifests labelled note-chart-derived
# lyrics with the source format's name, and the WhisperX
# fallback with the engine name — normalise both to the
# spec vocabulary the badges now expect.
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
raw_source = manifest.get("lyrics_source")
if isinstance(raw_source, str):
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
+16
View File
@@ -98,6 +98,22 @@ def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFER
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
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 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 f <= 0:
return None
out.append(int(round(69 + 12 * math.log2(f / reference_pitch))))
return out
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
"""Return semitone offsets from the instrument's standard open strings."""
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
+1758 -1
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -8,9 +8,12 @@
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
"install:playwright": "playwright install chromium"
"install:playwright": "playwright install chromium",
"lint": "eslint ."
},
"devDependencies": {
"@playwright/test": "^1.59.1"
"@playwright/test": "^1.59.1",
"eslint": "^9.39.4",
"eslint-plugin-import-x": "^4.17.1"
}
}
+99 -15
View File
@@ -18,6 +18,54 @@ from safepath import safe_join
log = logging.getLogger("feedBack.plugins")
def _plugin_media_type(path: Path) -> str:
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
accept them; `mimetypes.guess_type` can miss these on a stripped platform
registry, so fall back explicitly (mirrors the assets/ route)."""
media_type = mimetypes.guess_type(path.name)[0]
if media_type is None and path.suffix == ".js":
return "application/javascript"
if media_type is None and path.suffix == ".css":
return "text/css"
return media_type or "application/octet-stream"
def _plugin_file_etag(path: Path) -> str | None:
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
This is what makes the live-edit loop work for module graphs: a conditional
GET revalidates and 304s unchanged files on refresh instead of re-downloading
the whole `src/` tree. Returns None if the file can't be stat'd."""
try:
st = path.stat()
except OSError:
return None
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
def _if_none_match(request: Request, etag: str) -> bool:
"""True when the client's If-None-Match already holds `etag`."""
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
# a direct compare is enough (comma-split tolerates a proxy concatenation).
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
"""Serve a plugin source/asset file with the live-edit cache contract:
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
ETag, and a bodyless 304 when the client's If-None-Match already matches.
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
itself, so the conditional handling has to live here."""
headers = {"Cache-Control": "no-cache"}
etag = _plugin_file_etag(path)
if etag:
headers["ETag"] = etag
if _if_none_match(request, etag):
return Response(status_code=304, headers=headers)
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
return FileResponse(path, media_type=media_type, headers=headers)
PLUGINS_DIR = Path(__file__).parent
# Holds only *ready* (loaded) plugins — those whose dependencies installed
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
"version": manifest.get("version"),
"has_screen": bool(manifest.get("screen")),
"has_script": bool(manifest.get("script")),
# Module-migration (R0): `scriptType:"module"` tells the loader to
# inject screen.js as <script type="module">; `minHost` is the
# min core version a migrated plugin needs (passthrough only in R0 —
# enforcement is deferred to R4, master §4b). None when unset.
"script_type": manifest.get("scriptType"),
"min_host": manifest.get("minHost"),
"has_settings": bool(manifest.get("settings")),
"settings_category": _settings_category,
# Drives the v3 shell's immersive (full-screen) mode for this
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
"fallback": p.get("fallback", False),
"has_screen": p["has_screen"],
"has_script": p["has_script"],
# Module-migration passthrough (R0). Re-read from the manifest
# like `version` above so stubbed test entries (built without
# _nav_entry) don't need the key.
"script_type": (p.get("_manifest") or {}).get("scriptType"),
"min_host": (p.get("_manifest") or {}).get("minHost"),
"has_settings": p["has_settings"],
# v3 immersive screen opt-in (full-screen plugin UI).
"fullscreen": p.get("fullscreen", False),
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
"fallback": False,
"has_screen": e.get("has_screen", False),
"has_script": e.get("has_script", False),
# Pending entries come from _nav_entry, so they carry these.
"script_type": e.get("script_type"),
"min_host": e.get("min_host"),
"has_settings": e.get("has_settings", False),
"settings_category": e.get("settings_category"),
"fullscreen": e.get("fullscreen", False),
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
return HTMLResponse("", status_code=404)
@app.get("/api/plugins/{plugin_id}/screen.js")
def plugin_screen_js(plugin_id: str):
def plugin_screen_js(request: Request, plugin_id: str):
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
if p.get("status", "ready") != "ready":
break
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
if script_file.exists():
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
if script_file.is_file():
# no-cache + ETag/304 so an edited screen.js reloads on
# refresh while an unchanged one revalidates cheaply — the
# same live-edit contract the src/ module graph relies on.
return _plugin_file_response(request, script_file, "application/javascript")
return Response("", status_code=404)
@app.get("/api/plugins/{plugin_id}/settings.html")
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
return Response("{}", status_code=404, media_type="application/json")
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
def plugin_asset(plugin_id: str, asset_path: str):
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
"""Serve a static file a plugin bundles under its own ``assets/``
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
fixed screen.js/settings.html handlers above, this is a generic
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
break
if target.is_file():
media_type = mimetypes.guess_type(target.name)[0]
# .js must come back as JavaScript so addModule() / <script>
# accept it; guess_type can miss this on some platforms.
if media_type is None and target.suffix == ".js":
media_type = "application/javascript"
# .css must come back as text/css so a <link rel=stylesheet>
# (the styles capability) is honoured; guess_type can miss it
# on a stripped platform mimetypes registry, same as .js.
elif media_type is None and target.suffix == ".css":
media_type = "text/css"
return FileResponse(target, media_type=media_type or "application/octet-stream")
# no-cache + ETag/304 so a live-edited worklet/asset reloads
# on refresh (bare FileResponse emits an ETag but never 304s).
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
def plugin_src(request: Request, plugin_id: str, src_path: str):
"""Serve a file from a plugin's ES-module source tree under ``src/``.
This is the R0 host capability that lets a migrated plugin's
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
module graph. Containment mirrors the assets/ route exactly
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
and NUL bytes and the live-edit cache contract (no-cache + ETag/304)
makes an edited module reload on refresh while unchanged ones 304.
Read-only; the src/ tree is source files, never executed server-side.
"""
with PLUGINS_LOCK:
snapshot = list(LOADED_PLUGINS)
for p in snapshot:
if p["id"] == plugin_id:
if p.get("status", "ready") != "ready":
break
target = safe_join(p["_dir"] / "src", src_path)
if target is None:
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
break
if target.is_file():
return _plugin_file_response(request, target, _plugin_media_type(target))
break
return Response("", status_code=404)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "drum_highway_3d",
"name": "3D Drum Highway",
"version": "0.3.1",
"version": "0.3.2",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+85 -3
View File
@@ -1361,6 +1361,41 @@
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
};
/* ======================================================================
* Camera Director bridge resolver (pure exported via createFactory.__test)
* ====================================================================== */
/**
* The active splitscreen API, defensive on the global-name rename in flight
* (feedBackSplitscreen is canonical; slopsmithSplitscreen is the legacy alias).
* @returns {object|null} the splitscreen API, or null when not present
*/
function _ssApi() { return window.feedBackSplitscreen || window.slopsmithSplitscreen || null; }
/**
* Resolve the Camera Director camera for a canvas: this panel's camera under
* splitscreen, else the global, else null (Camera Director absent stock
* framing). Throw-safe on panelIndexFor so a misbehaving splitscreen build
* can't break framing.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @param {object|null} ss the splitscreen API (see _ssApi)
* @param {object|null} panelsMap window.__h3dCamCtlPanels (per-panel cameras by index)
* @param {object|null} globalCam window.__h3dCamCtl (single global camera)
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _resolveFreeCam(canvas, ss, panelsMap, globalCam) {
if (panelsMap && ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the panel map — a non-int /
// negative / string index (or a prototype key) must not resolve an
// unintended/inherited property; fall through to the global then.
if (Number.isInteger(i) && i >= 0 && panelsMap[i]) return panelsMap[i];
} catch (e) { /* ignore */ }
}
return globalCam || null;
}
/* ======================================================================
* Renderer factory
* ====================================================================== */
@@ -1414,7 +1449,7 @@
let _sparkPts = null, _sparkPos = null, _sparkCol = null, _sparkVel = null, _sparkLife = null;
let _fxLastWall = 0; // wall clock for FX integration (sparks, pulse decay)
let _kickPulse = 0; // kick-hit camera-dip + floor-wash envelope
let _camBaseH = 0, _camBaseD = 0; // positionCamera's unpulsed pose
let _camBaseH = null, _camBaseD = null; // positionCamera's unpulsed pose (null until it first runs; applyCamera's guard depends on this)
let _gaussTex = null; // shared soft-falloff texture for flash quads
let _laneFlashQuads = []; // pooled additive quad per hand lane (z=0)
let _kickFlashQuad = null; // full-width flash quad for the kick bar
@@ -2651,6 +2686,48 @@
cam.lookAt(0, 0, -AHEAD * TS * 0.45);
}
/**
* Camera Director bridge for THIS panel delegates to the pure, unit-
* tested _resolveFreeCam / _ssApi (resolver block above the factory).
* Reads the live globals: per-panel map __h3dCamCtlPanels this panel's
* camera, else the global __h3dCamCtl, else null (stock framing).
* @param {HTMLCanvasElement} canvas this panel's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
return _resolveFreeCam(canvas, _ssApi(), window.__h3dCamCtlPanels, window.__h3dCamCtl);
}
// Per-frame camera write: static base pose (positionCamera) + kick-pulse Y
// dip, then layer Camera Director free-cam offsets (dolly/height/orbit on
// the camera-from-target vector; pan/pitch on the look target). Runs every
// frame so a live free-cam drag is smooth; allocation-free; NaN-safe; a
// null/disabled bridge reproduces the stock static+pulse pose exactly.
function applyCamera() {
if (_camBaseH == null) return; // before first positionCamera()
const _dip = (_kickPulse > 0.001) ? (0.8 * K * _kickPulse * fx.hitFx) : 0;
let _cx = 0, _cy = _camBaseH - _dip, _cz = _camBaseD;
let _lx = 0, _ly = 0, _lz = -AHEAD * TS * 0.45;
const _fc = _freeCamFor(highwayCanvas);
if (_fc && _fc.enabled) {
const _dm = Number.isFinite(_fc.distMul) ? _fc.distMul : 1;
const _hm = Number.isFinite(_fc.heightMul) ? _fc.heightMul : 1;
const _yaw = Number.isFinite(_fc.yaw) ? _fc.yaw : 0;
let _vx = _cx - _lx, _vy = _cy - _ly, _vz = _cz - _lz;
_vx *= _dm; _vy *= _dm; _vz *= _dm; // dolly (zoom)
_vy *= _hm; // height
const _cyw = Math.cos(_yaw), _syw = Math.sin(_yaw);
const _rx = _vx * _cyw - _vz * _syw, _rz = _vx * _syw + _vz * _cyw; // orbit around Y
_cx = _lx + _rx; _cy = _ly + _vy; _cz = _lz + _rz;
const _px = Number.isFinite(_fc.panX) ? _fc.panX : 0;
const _py = Number.isFinite(_fc.panY) ? _fc.panY : 0;
const _pt = Number.isFinite(_fc.pitch) ? _fc.pitch : 0;
_lx += _px * K; _ly += (_pt + _py) * K;
}
cam.position.set(_cx, _cy, _cz);
cam.lookAt(_lx, _ly, _lz);
}
function buildLanes(_floorW, floorD) {
laneGroup = new T.Group();
laneStripeMats = [];
@@ -3431,15 +3508,18 @@
BG_STYLES[_bgState._style].update(_bgState.s, bands, fdt, nowMs / 1000);
} catch (_) { /* visual-only */ }
}
// Kick pulse decays each frame; it drives the floor flash and,
// via applyCamera(), the camera Y dip.
if (_kickPulse > 0.001) {
_kickPulse *= Math.exp(-fdt * 7);
cam.position.y = _camBaseH - 0.8 * K * _kickPulse * fx.hitFx;
if (_floorFlash) _floorFlash.material.opacity = 0.25 * _kickPulse * fx.hitFx;
} else if (_kickPulse !== 0) {
_kickPulse = 0;
cam.position.y = _camBaseH;
if (_floorFlash) _floorFlash.material.opacity = 0;
}
// Write the camera every frame: static base pose + kick dip +
// Camera Director free-cam offsets (per-panel-aware).
applyCamera();
}
// Approach highlight: raise each lane stripe toward its next
// note (accumulated by the rebuildNotes walk above).
@@ -3565,6 +3645,8 @@
// vm-loaded with no DOM/WebGL; everything here must stay side-effect
// free to call).
window.slopsmithViz_drum_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
_variantForHit,
_classifyTiming,
readFxSettings,
@@ -0,0 +1,78 @@
// Camera Director bridge resolver tests: per-panel select, global fallback,
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return { window, __test: window.slopsmithViz_drum_highway_3d.__test };
}
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
const { __test } = load();
const c0 = {}, c1 = {};
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
});
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
});
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
const { __test } = load();
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
});
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
// A string/prototype key must not resolve an inherited property (e.g. toString).
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
});
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
const { window, __test } = load();
assert.equal(__test._ssApi(), null);
const legacy = { panelIndexFor: () => 0 };
window.slopsmithSplitscreen = legacy;
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
const current = { panelIndexFor: () => 1 };
window.feedBackSplitscreen = current;
assert.equal(__test._ssApi(), current); // canonical name takes precedence
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.31.2",
"version": "3.31.5",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+128 -17
View File
@@ -548,9 +548,20 @@
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
// render size and report that SAME size to Butterchurn. Its on-screen
// pass viewports to the reported size but never sizes the output canvas
// itself — leaving the buffer at the 300x150 default blits the whole
// visualizer into a corner that CSS then stretches across the highway.
// pixelRatio:1 because DPR is now folded into the reported size, so
// buffer == viewport == internal texsize (no double-counting).
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
canvas.width = _bcW0; canvas.height = _bcH0;
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
width: sz.w || 1280, height: sz.h || 720,
pixelRatio: Math.min(window.devicePixelRatio || 1, 1.5), textureRatio: 1,
width: _bcW0, height: _bcH0,
pixelRatio: 1, textureRatio: 1,
});
if (_bcIsDesktop()) {
try {
@@ -584,6 +595,27 @@
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
_bcControllers.delete(ctrl);
});
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
// device-pixel render size AND report that same size, so buffer ==
// on-screen viewport == full fill. Butterchurn never sizes the output
// canvas itself; the previous code set only CSS size, leaving the buffer
// at the 300x150 default -> the viz showed a stretched lower-left corner
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
function _bcApplySize(cssW, cssH) {
if (!(cssW > 0 && cssH > 0)) return;
ctrl.lastW = cssW; ctrl.lastH = cssH;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
if (canvas.width !== bw) canvas.width = bw;
if (canvas.height !== bh) canvas.height = bh;
const wpx = cssW + 'px', hpx = cssH + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
}
return {
applySettings() { ctrl.applySettings(); },
dead() { return ctrl.dead; },
@@ -612,18 +644,11 @@
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
const sz = sizeProvider && sizeProvider();
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
ctrl.lastW = sz.w; ctrl.lastH = sz.h;
const wpx = sz.w + 'px', hpx = sz.h + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
try { ctrl.viz.setRendererSize(sz.w, sz.h); } catch (e) {}
_bcApplySize(sz.w, sz.h);
}
try { ctrl.viz.render(); } catch (e) {}
},
resize(w, h) { if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(w, h); } catch (e) {} ctrl.lastW = w; ctrl.lastH = h; } },
resize(w, h) { _bcApplySize(w, h); },
destroy() {
ctrl.dead = true;
_bcControllers.delete(ctrl);
@@ -2595,10 +2620,51 @@
}
const FRET_NUMBER_GHOST_SCOPE_IDS = ['chords', 'all'];
/**
* localStorage panel key for per-panel background settings ('main' or
* 'panel<index>'). Defensive on the splitscreen global-name rename in flight,
* and throw-safe on panelIndexFor same as _freeCamFor so a misbehaving
* splitscreen build can't take down background-settings resolution. Only a
* non-negative integer index yields a 'panel<N>' key; anything else (null,
* NaN, negative, non-integer) falls back to 'main' so a bad index can never
* mint a bogus "panelNaN"-style key.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @returns {string} 'main' or 'panel<index>'
*/
function _bgPanelKey(canvas) {
const ss = window.feedBackSplitscreen;
const idx = (ss && typeof ss.panelIndexFor === 'function') ? ss.panelIndexFor(canvas) : null;
return (idx == null) ? 'main' : 'panel' + idx;
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
let idx = null;
if (ss && typeof ss.panelIndexFor === 'function') {
try { idx = ss.panelIndexFor(canvas); } catch (e) { idx = null; }
}
return (Number.isInteger(idx) && idx >= 0) ? 'panel' + idx : 'main';
}
/**
* Camera Director bridge resolver. Prefers THIS panel's per-panel camera under
* splitscreen (window.__h3dCamCtlPanels[panelIndex]) and falls back to the
* single global (window.__h3dCamCtl); returns null when Camera Director is
* absent 100% stock framing. Defensive on the splitscreen global-name rename
* in flight (feedBackSplitscreen vs slopsmithSplitscreen); throw-safe on
* panelIndexFor. Mirrors the panel resolution in _bgPanelKey.
* @param {HTMLCanvasElement} canvas this renderer's highway canvas
* @returns {object|null} the resolved free-camera bridge, or null
*/
function _freeCamFor(canvas) {
const map = window.__h3dCamCtlPanels;
if (map) {
const ss = window.feedBackSplitscreen || window.slopsmithSplitscreen;
if (ss && typeof ss.panelIndexFor === 'function') {
try {
const i = ss.panelIndexFor(canvas);
// Only a non-negative integer indexes the map (same hardening
// as _bgPanelKey) — a non-int / negative / string index must not
// resolve an unintended/inherited property; fall through then.
if (Number.isInteger(i) && i >= 0 && map[i]) return map[i];
} catch (e) { /* ignore */ }
}
}
return window.__h3dCamCtl || null;
}
// In-memory fallback for when localStorage is blocked (private mode,
// sandboxed iframes, some test runners). _bgWriteGlobal stages the
@@ -3747,6 +3813,15 @@
// ── Per-instance Three.js state ───────────────────────────────────
let scene = null, cam = null, ren = null;
let wrap = null;
// WebGL context-loss recovery. Switching the active window / alt-tabbing
// (especially on Windows) can trigger a GPU context reset; with no
// handler the lost context escalates into a render-process crash. The
// listeners (bound in initScene on ren.domElement, removed in teardown)
// preventDefault the loss so the browser keeps the context restorable,
// _ctxLost gates draw() off the dead context, and on restore we reset the
// viewport + resume (Three re-uploads scene resources on the next render).
let _ctxLost = false;
let _onCtxLost = null, _onCtxRestored = null;
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
@@ -6526,6 +6601,26 @@
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
wrap.appendChild(ren.domElement);
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
// on Three's own canvas — the context that actually resets on a GPU
// reset / alt-tab. preventDefault() keeps the context restorable
// instead of letting the loss escalate to a render-process crash;
// _ctxLost then makes draw() bail so no GL work runs on the dead
// context; on restore we reset the viewport and resume (Three
// re-uploads geometry/materials/textures lazily on the next render).
_onCtxLost = (e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
_ctxLost = true;
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
};
_onCtxRestored = () => {
_ctxLost = false;
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
};
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
lyricsCanvas = document.createElement('canvas');
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
lyricsCtx = lyricsCanvas.getContext('2d');
@@ -14635,7 +14730,10 @@
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
const _dirActive = !!(window.__h3dCamCtl && window.__h3dCamCtl.enabled);
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = _freeCamFor(highwayCanvas);
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && _paneAspect > _startAspect) && !_dirActive;
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
@@ -14662,13 +14760,16 @@
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via window.__h3dCamCtl.
// Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
const _freeCam = window.__h3dCamCtl;
// _freeCam resolved above via _freeCamFor(highwayCanvas): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
@@ -14829,6 +14930,15 @@
// mid-teardown settings change doesn't try to rebuild a torn-
// down scene; then dispose the active style's resources.
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
// WebGL context-loss listeners (bound in initScene on ren.domElement).
// Remove before ren is disposed below so a torn-down instance can't
// keep firing them; reset the flag so a reused instance starts clean.
if (ren && ren.domElement) {
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
}
_onCtxLost = _onCtxRestored = null;
_ctxLost = false;
// Notedetect listeners (issue #9). Remove on destroy so a
// panel that stops doesn't keep accumulating marks. Marks
// arrays are cleared too — they hold stale chart positions
@@ -15154,6 +15264,7 @@
draw(bundle) {
if (!_isReady) return;
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
if (!_chartPrewarmed) {
_chartPrewarmed = true;
_prewarmChart(bundle);
+24 -2
View File
@@ -3,12 +3,34 @@
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue.
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 01).
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
them, and each sharp leaned toward the edge natural beside it so the naturals come
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
naturals always rendered full, full black keys drawn on top and only occluding a
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
**Lane color opacity** (`keys3d_bg_laneOpacity`, 01, default 0) fades the
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
rather than every lane — the notes keep their colors; toward 1 it fills in full,
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
gap a touch at each B→C octave boundary. **Octave line contrast**
(`keys3d_bg_octaveContrast`, 01, default 0.5) scales how hard the B→C octave line
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
switch needed. All are geometry-time — applied on the next chart build via
`init()`'s re-read.
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.1.1",
"version": "0.2.1",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
File diff suppressed because it is too large Load Diff
+161 -2
View File
@@ -12,6 +12,22 @@
<div class="mt-3">
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
<select id="keysh3d-fx-palette"
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
<option value="emerald">Emerald (green, darker sharps)</option>
<option value="ice">Ice (blue, darker sharps)</option>
<option value="classic">Rainbow (per-pitch)</option>
<option value="vivid">Vivid (per-pitch, punchier)</option>
<option value="pastel">Pastel (per-pitch, soft)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Choose the color scheme for the falling notes, key glow, lane
guides and hit flames. Each option is described in its own label.
</p>
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
<select id="keysh3d-fx-theme"
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
@@ -30,7 +46,117 @@
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Background gradient, floor and lane rails — the same theme names
as the guitar highway. Pitch-class note colours never change.
as the guitar highway. Note colors come from the
"Note colors" palette above.
</p>
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
<select id="keysh3d-fx-camera"
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="classic">Classic (low, deep runway)</option>
<option value="elevated">Elevated (higher, more board)</option>
<option value="overhead" selected>Overhead (top-down reading view)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
Where the camera sits. Classic is the original low rig; Elevated
lifts it for a fuller view of the keybed; Overhead looks down the
lanes for a sheet-reading feel. Applies live, keeps the
auto-pan/zoom that follows your hands.
</p>
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
</label>
<input type="range" id="keysh3d-fx-camheight"
min="0.4" max="2.2" step="0.02" value="1"
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Raise or lower the camera around the angle above (higher = more
top-down). Fine-tunes the base view; the follow-motion stays.
</p>
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
</label>
<input type="range" id="keysh3d-fx-camdist"
min="0.4" max="2.2" step="0.02" value="1"
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">
Pull the camera back or push it in (larger = further away, smaller
= closer).
</p>
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
</label>
<input type="range" id="keysh3d-fx-camtilt"
min="-1" max="1" step="0.02" value="0"
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
Tilt the view up (+) or down () without moving the camera —
aims higher up the runway or down toward the keys. 0 = neutral.
</p>
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps &amp; flats</label>
<select id="keysh3d-fx-sharpmode"
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="floating">Floating</option>
<option value="flat">Non-floating</option>
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
</select>
<p class="text-xs text-gray-500 mt-1 mb-3">
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
plane above the naturals. <em>Non-floating</em>: everything on one
plane, each key its own even piano-shaped lane. <em>Realistic key
sizes</em>: one plane, bars sized like the real keys (full naturals,
full black keys on top). Applies next time you open a song.
</p>
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
</label>
<input type="range" id="keysh3d-fx-laneopacity"
min="0" max="1" step="0.05" value="0"
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
How strongly each lane is tinted its note color. 0.00 (default) is a
dark floor with plain guide lines only between the key blocks (at EF
and each octave); the notes keep their colors and pop off the floor.
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
open a song.
</p>
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
Octave separators
</label>
<p class="text-xs text-gray-500 mt-1 mb-3">
Widen the gap a little at each octave boundary (every B to the C
above it) so octaves are easier to read. Applies next time you open
a song.
</p>
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
</label>
<input type="range" id="keysh3d-fx-octavecontrast"
min="0" max="1" step="0.05" value="0.5"
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1 mb-3">
How strongly the octave line (every B to C) stands out. It adapts to
the lane color opacity automatically — darkening the line against
bright lanes and brightening it as you fade them toward the dark
floor. Applies next time you open a song.
</p>
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
@@ -116,7 +242,7 @@
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
<input type="checkbox" id="keysh3d-fx-timing" checked
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
Timing colours
Timing colors
</label>
<p class="text-xs text-gray-500 mt-1">
Tint the sparks by timing — on-time green, early cyan, late
@@ -179,6 +305,9 @@
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
const hydrateFxRange = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
@@ -190,6 +319,26 @@
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
// Camera fine-tune sliders live outside 0-1 — clamp to the
// control's own min/max (mirrors screen.js FX_RANGES).
const hydrateFxRangeIn = (key, elId, valId) => {
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
if (!Number.isFinite(n)) return;
const el = document.getElementById(elId);
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
el.value = String(v);
document.getElementById(valId).textContent = v.toFixed(2);
};
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
const storedCamera = localStorage.getItem('keys3d_bg_camera');
const cameraSel = document.getElementById('keysh3d-fx-camera');
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
cameraSel.value = storedCamera;
}
const storedStyle = localStorage.getItem('keys3d_bg_style');
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
@@ -200,6 +349,16 @@
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
themeSel.value = storedTheme;
}
const storedPalette = localStorage.getItem('keys3d_bg_palette');
const paletteSel = document.getElementById('keysh3d-fx-palette');
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
paletteSel.value = storedPalette;
}
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
sharpSel.value = storedSharp;
}
} catch (e) {
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
}
@@ -0,0 +1,78 @@
// Camera Director bridge resolver tests: per-panel select, global fallback,
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function load() {
const window = {
console,
location: { protocol: 'http:', host: 'localhost' },
slopsmith: {},
};
window.window = window;
window.globalThis = window;
const context = vm.createContext(window);
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
vm.runInContext(src, context, { filename: 'screen.js' });
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
}
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
const { __test } = load();
const c0 = {}, c1 = {};
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
});
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
});
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
const { __test } = load();
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
});
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
});
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
const { __test } = load();
const g = { id: 'global' };
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
// A string/prototype key must not resolve an inherited property (e.g. toString).
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
});
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
const { window, __test } = load();
assert.equal(__test._ssApi(), null);
const legacy = { panelIndexFor: () => 0 };
window.slopsmithSplitscreen = legacy;
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
const current = { panelIndexFor: () => 1 };
window.feedBackSplitscreen = current;
assert.equal(__test._ssApi(), current); // canonical name takes precedence
});
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
);
});
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// Fresh install / never picked here — must use the Input Setup global,
// NOT fall through to inputs[0].
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
// NOT override the device the user configured in Settings → Input Setup.
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
assert.equal(target.id, 'a');
});
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
const { _pickMidiTarget } = load();
// Same physical device, new id/key across a reload; the saved key/id miss
// but the name still matches.
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
assert.equal(target.id, 'a2');
});
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
];
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
});
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
const { _pickMidiTarget } = load();
const inputs = [
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
];
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
// that carries no input and would silently eat every note).
const target = _pickMidiTarget(inputs, null, null, true);
assert.equal(target, null);
});
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
assert.equal(target, null);
});
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured global device is present — reconnect to it, don't bail.
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
assert.equal(target.id, 'b');
});
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
// The configured device ('x', global) is currently unplugged; a transient
// recovery must NOT switch to the unrelated device that is present.
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
assert.equal(target, null);
});
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
const { _pickMidiTarget } = load();
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
const target = _pickMidiTarget(inputs, null, null, false);
assert.equal(target.id, 'b');
});
@@ -148,3 +148,405 @@ test('FX defaults: ambience + score FX ship enabled', () => {
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
assert.equal(FX_DEFAULTS.bgReactive, true);
});
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
test('note palettes: 12 entries each, classic IS the stock table', () => {
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(NOTE_PALETTES),
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
for (const c of colors) {
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
id + ' colours are 24-bit ints');
}
}
// 'classic' preserves the shipped look byte-identically — it is the
// same array, not a copy that could drift.
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
});
test('note palettes: two-tone tables use darker sharps than naturals', () => {
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
for (const id of ['emerald', 'ice']) {
const p = NOTE_PALETTES[id];
for (const sharp of [1, 3, 6, 8, 10]) {
assert.ok(luma(p[sharp]) < luma(p[0]),
id + ' sharp pc ' + sharp + ' darker than naturals');
}
}
});
test('readPaletteSetting: octaves default, validated overrides only', () => {
// No localStorage in the vm → the plug-and-play default.
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(bare.readPaletteSetting(), 'octaves');
// An explicit non-default value (classic) overrides.
const store = { keys3d_bg_palette: 'classic' };
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
});
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readPaletteSetting(), 'classic');
// Corrupt/foreign value → the default rather than an undefined scheme.
store.keys3d_bg_palette = 'banana';
assert.equal(readPaletteSetting(), 'octaves');
});
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetPalette('emerald');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.palette, 'emerald');
// Unknown id: no write, no event.
win.keys3dSetPalette('banana');
assert.equal(store.keys3d_bg_palette, 'emerald');
assert.equal(events.length, 1);
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
win.keys3dSetPalette('octaves');
assert.equal(store.keys3d_bg_palette, 'octaves');
assert.equal(events.length, 2);
});
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...PALETTE_IDS],
[...Object.keys(NOTE_PALETTES), 'octaves']);
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
});
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
const luma = (c) =>
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
// Naturals across one octave (C1..B1 whites) all share the octave hue.
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
}
// Sharps in an octave are a DARKER shade of that same hue.
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
'sharp ' + sharp + ' darker than the octave natural');
}
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
// different from the red octave-1 start.
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
// Loop: an octave past the table wraps (safety for out-of-88 midi).
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
});
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.camHeight, 1.0);
assert.equal(FX_DEFAULTS.camDist, 1.0);
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
// and 'classic' + this default reproduces the exact historical rig.
assert.equal(FX_DEFAULTS.camTilt, 0.0);
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
});
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
win.keys3dSetFx('camTilt', -0.5);
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
win.keys3dSetFx('camTilt', -99);
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
});
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
// Setter: same clamp on the way in; a value above 1 must survive
// (the historical 0-1 clamp would have crushed 1.3 to 1).
win.keys3dSetFx('camHeight', 1.3);
assert.equal(store.keys3d_bg_camHeight, '1.3');
win.keys3dSetFx('camDist', 99);
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
// Un-ranged keys keep the historical 0-1 clamp.
win.keys3dSetFx('vibrancy', 2);
assert.equal(store.keys3d_bg_vibrancy, '1');
});
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
const hitZ = 0;
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
assert.equal(d2, d1 * 2);
// At the hit moment the note is at the hit-line regardless of speed.
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
});
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
// 'classic' preserves the historical constants (pre-K units) even though
// it is no longer the default — anyone who picks it gets the old rig back
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
assert.deepEqual({ ...CAM_PRESETS.classic },
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
for (const [id, p] of Object.entries(CAM_PRESETS)) {
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
}
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
}
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
});
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
// The shipped default look = overhead preset + the default camTilt. Before,
// that was lookY 0 + (0.6 × 55) = 33; the tuned aim now lives in the
// preset (lookY 33) with a neutral camTilt (0), so the effective aim — and
// thus the out-of-the-box framing — is byte-identical.
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effOverhead, -33);
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
// the "pick Classic for the original look" promise, now actually true.
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
assert.equal(effClassic, 8);
});
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class CustomEvent {
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
},
});
win.keys3dSetCamera('overhead');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
assert.equal(events[0].type, 'keys3d:settings');
assert.equal(events[0].detail.camera, 'overhead');
win.keys3dSetCamera('helicopter');
assert.equal(store.keys3d_bg_camera, 'overhead');
assert.equal(events.length, 1);
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
assert.equal(readCameraSetting(), 'overhead');
store.keys3d_bg_camera = 'garbage';
assert.equal(readCameraSetting(), 'overhead');
});
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
// Sharp LAYOUT is a string setting, not an FX bool.
assert.equal('flatSharps' in FX_DEFAULTS, false);
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
});
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
const store = {};
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: () => true,
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetFx('octaveGaps', true);
assert.equal(store.keys3d_bg_octaveGaps, '1');
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
win.keys3dSetFx('laneOpacity', 0.35);
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
assert.equal(store.keys3d_bg_laneOpacity, '1');
win.keys3dSetFx('octaveContrast', 0.8);
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
});
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
const bare = load().slopsmithViz_keys_highway_3d.__test;
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
const store = {};
const events = [];
const win = load({
localStorage: {
getItem: (k) => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = v; },
},
dispatchEvent: (ev) => { events.push(ev); return true; },
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
});
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events[0].detail.sharpMode, 'flat');
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
// Unknown id ignored (no write, no event).
win.keys3dSetSharpMode('bogus');
assert.equal(store.keys3d_bg_sharpMode, 'flat');
assert.equal(events.length, 1);
});
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
const sh = 2.2, shift = 2.2 / 3;
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
// cx for one octave: whites on integer slots, blacks on half-slots — the
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
const CX = {
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
};
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
const wOf = (s) => s.right - s.left;
const w = (m) => wOf(spans[midis.indexOf(m)]);
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
for (let i = 1; i < spans.length; i++) {
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
}
// Sharps are all the same width.
for (const m of [61, 63, 66, 68, 70]) {
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
}
// The lean evens the naturals: C, D, E, F, B all come out equal.
for (const m of [62, 64, 65, 71]) {
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
}
// G and A are the only slightly-smaller naturals (G# can't lean) — still
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
// (which would leave D at 122·sh, far below C's 12sh).
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
});
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
const wOf = (s) => s.right - s.left;
// Every natural is the same full width, whatever its neighbours.
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
'natural ' + midi + ' uniform');
}
// Sharps are the full (wider) black-key width and overlap their naturals.
const C = laneSpanReal(60, false, 0, dims, false);
const Cs = laneSpanReal(61, true, 6, dims, false);
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
});
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
// Sharps are unaffected by the octave-gap option.
const s = laneSpanFlat(61, true, 6, dims, true);
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
});
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
// at range.activeHigh and F# is excluded from the active range, F# never
// gets a lane drawn (see the activeLow/activeHigh skip around the
// lane-strip loop) — trimming F's right edge for it would leave a dark,
// unfilled sliver. The edge should stay full instead.
const highBoundary = { activeLow: 60, activeHigh: 65 };
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
'F right edge stays full when F# is out of the active range');
// Same key, but now F# IS in the active range: normal zero-overlap
// tiling applies — the trim matches the ungated (no-range) call exactly,
// so in-range geometry is unaffected by this fix.
const highIncluded = { activeLow: 60, activeHigh: 66 };
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
const fUngated = laneSpanFlat(65, false, 36, dims, false);
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
'F trims normally once F# is back in range');
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
// (61) excluded when D sits at range.activeLow.
const lowBoundary = { activeLow: 62, activeHigh: 72 };
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
'D left edge stays full when C# is out of the active range');
const lowIncluded = { activeLow: 61, activeHigh: 72 };
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
const dUngated = laneSpanFlat(62, false, 12, dims, false);
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
'D trims normally once C# is back in range');
});
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "tuner",
"name": "Guitar/Bass Tuner",
"version": "1.3.3",
"version": "1.3.4",
"bundled": true,
"private": false,
"script": "screen.js",
+9 -2
View File
@@ -869,8 +869,15 @@ window._tunerUI = function(state, actions) {
btn.textContent = 'Tuner';
btn.title = 'Open Tuner';
btn.onclick = window.tuner.toggle;
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
if (closeBtn) controls.insertBefore(btn, closeBtn);
// Anchor to the last DIRECT-child button of `controls` (the classic
// transport's close/exit button). A bare `button:last-child` can match
// a NESTED button that is not a direct child of `controls`, and
// `insertBefore()` then throws NotFoundError — which propagated out of
// the player-screen transition and aborted its render (feedBack#800).
// `:scope > button:last-of-type` restricts the anchor to a direct child;
// the parentNode check is a belt-and-suspenders guard before insertBefore.
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
else controls.appendChild(btn);
updatePlayerButton();
}
+177
View File
@@ -0,0 +1,177 @@
// Perf-baseline harness for the module-migration refactor (R0).
//
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
// screen-entry, frame-time, memory, or server latency. It writes a markdown
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
//
// Usage:
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
//
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
// it wraps requestAnimationFrame before any page script runs, tags the frames
// in which the highway actually painted (via highway.addDrawHook), starts
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
// matters — roughly half the rAF callbacks belong to other cheap loops, and
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
// This is the metric that gates the highway.js split (R3c); run it before AND
// after each highway change on the same machine.
//
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
// never part of the serve or Docker path. Metrics that need a seeded library
// with charts (playback frame-time, screen-entry into a live highway) are
// clearly labelled — run those against an environment with real songs.
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { chromium } = require('@playwright/test');
const args = new Map();
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
const BASE = args.get('base') || 'http://127.0.0.1:8000';
const N = parseInt(args.get('n') || '60', 10);
const SOAK_S = parseInt(args.get('soak') || '30', 10);
const SONG = args.get('song') || null;
const FRAME_S = parseInt(args.get('frames') || '10', 10);
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
const pct = (xs, p) => {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
};
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
async function serverLatency(paths) {
const rows = [];
for (const path of paths) {
const t = [];
let status = 0;
for (let i = 0; i < N; i++) {
const t0 = performance.now();
try {
const r = await fetch(BASE + path);
status = r.status;
await r.arrayBuffer();
} catch { status = -1; }
t.push(performance.now() - t0);
}
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
}
return rows;
}
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
async function clientMetrics() {
const browser = await chromium.launch();
const page = await browser.newPage();
const t0 = Date.now();
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
const bootMs = Date.now() - t0;
// performance.memory is Chromium-only; JS heap after settle.
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
await page.waitForTimeout(SOAK_S * 1000);
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
const scripts = await page.evaluate(() =>
document.querySelectorAll('script[data-plugin-id]').length);
await browser.close();
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
}
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
async function frameTimeOnce(browser) {
const page = await browser.newPage();
let f, t2, notes;
try {
// Wrap rAF before any page script; mark frames the highway actually drew.
await page.addInitScript(() => {
window.__f = [];
window.__drew = false;
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => raf((t) => {
window.__drew = false;
const t0 = performance.now();
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
});
});
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
await page.evaluate((s) => window.playSong(s), SONG);
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
null, { timeout: 45000 }).catch(() => {});
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
await page.waitForTimeout(1500);
const t1 = await page.evaluate(() => window.highway.getTime());
await page.evaluate(() => { window.__f.length = 0; });
await page.waitForTimeout(FRAME_S * 1000);
({ f, t2, notes } = await page.evaluate(() => ({
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
})));
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}${t2}) — measured the paused path`);
} finally {
// Always close, even when an await above throws — otherwise a failing
// run leaks its page until the final browser.close().
await page.close();
}
const drew = f.filter((x) => x.drew).map((x) => x.ms);
return { drew, total: f.length, notes };
}
async function frameTime() {
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
const rows = [];
for (let i = 0; i < RUNS; i++) {
try {
const r = await frameTimeOnce(browser);
rows.push({
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
});
} catch (e) { rows.push({ error: String(e.message || e) }); }
}
await browser.close();
return rows;
}
const server = await serverLatency([
'/api/version',
'/api/plugins',
'/api/library?limit=60',
'/api/library/artists',
]);
const client = await clientMetrics();
const now = new Date().toISOString();
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
out += `| Plugin scripts injected | ${client.scripts} |\n`;
if (SONG) {
const frames = await frameTime();
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
for (let i = 0; i < frames.length; i++) {
const r = frames[i];
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
}
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}${ms(Math.max(...p95s))} ms**\n`;
} else {
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
}
console.log(out);
+226 -9597
View File
File diff suppressed because it is too large Load Diff
+390 -21
View File
@@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
const _LIB_SORT_VALUES = new Set([
'artist', 'artist-desc', 'title', 'title-desc',
'recent', 'year-desc', 'year', 'tuning',
'difficulty', 'difficulty-desc',
]);
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
// Tree-view expand/collapse persistence. Three states per tree:
@@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
${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>` : ''}
</div>
${retuneBtn}
@@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
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)
html += `<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>`;
if (duration)
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
if (stdRetune)
@@ -2870,6 +2874,10 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - colorblind_deuteranope: a deuteranope-tuned variant of the OkabeIto set
// above, contributed by a deuteranopic player who still found that set hard
// to separate. Retunes the six main strings (red / yellow-green / blue /
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
@@ -2906,6 +2914,10 @@ const HWC_PRESETS = [
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
@@ -4371,7 +4383,7 @@ async function uploadSongs(fileList) {
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
files.push(f);
} else {
failures.push(`${f.name}: only .feedpak accepted`);
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
}
}
if (files.length === 0) {
@@ -4852,6 +4864,65 @@ window.jucePlayer = jucePlayer;
// (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry.
let _rerouteRejectedUrl = null;
// Exclusive-style output backends silence every other client on the
// endpoint — including our own <audio> element. The share mode IS the
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
// shared and must NOT match.
function _isExclusiveOutputType(t) {
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
}
// [feedpak-route] diagnostics: log the raw outputType string once per
// value change (this runs on a 350ms poll — logging every tick would
// flood the diagnostics buffer).
let _loggedOutputType;
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
// audio.debugEnabled). Resolved once at install; until it resolves the
// flag stays false and verbose lines are skipped. Shared with the
// renderer-bus feeder below via window._asioDiagEnabled.
let _asioDiag = false;
if (typeof juceApi.debugEnabled === 'function') {
juceApi.debugEnabled().then((v) => { _asioDiag = !!v; }).catch(() => {});
}
window._asioDiagEnabled = () => _asioDiag;
async function _outputIsExclusive() {
if (typeof juceApi.getCurrentDevice !== 'function') {
if (_loggedOutputType !== '<no-getCurrentDevice>') {
_loggedOutputType = '<no-getCurrentDevice>';
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
}
return false;
}
try {
const dev = await juceApi.getCurrentDevice();
const t = dev?.outputType || dev?.type || '';
const excl = _isExclusiveOutputType(t);
if (t !== _loggedOutputType) {
_loggedOutputType = t;
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
// [asio-diag] full device object on every type change — shows
// the exact strings the predicate saw (inputType vs outputType,
// device names, duplex), so a driver reporting a non-'ASIO'
// type name is visible in tester logs.
if (_asioDiag) {
try {
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
} catch (_) { /* circular/hostile object — skip */ }
}
}
return excl;
} catch (e) {
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
_loggedOutputType = '<getCurrentDevice-failed>';
console.warn('[feedpak-route] getCurrentDevice failed:', e);
}
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact
// snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity,
@@ -4892,8 +4963,12 @@ window.jucePlayer = jucePlayer;
audio.pause();
try {
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error('HTTP ' + res.status);
if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
throw new Error('HTTP ' + res.status);
}
const { path } = await res.json();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path);
if (ok === false) {
@@ -5111,8 +5186,12 @@ window.jucePlayer = jucePlayer;
async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio;
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.
if (!songAudio || !songAudio.juceEligible) return;
// /audio/ songs are always JUCE-routable. A feedpak full-mix
// (single-mix pack, no stems) is routable ONLY under an
// exclusive-style output — in shared mode it must stay on HTML5 so
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL.
@@ -5130,13 +5209,30 @@ window.jucePlayer = jucePlayer;
try { running = await juceApi.isAudioRunning(); }
catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC
if (!!running === !!window._juceMode) return; // routing already consistent
const wantJuce = running && !window._juceMode;
// Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
// Audio Engine panel), and a feedpak full-mix must follow it —
// exclusive → ride the engine; back to shared → return to HTML5.
let eligible = !!songAudio.juceEligible;
if (!eligible && songAudio.feedpakFullMix && running) {
eligible = await _outputIsExclusive();
if (_isStale(songAudio)) return; // song changed during IPC
}
const wantJuce = !!(running && eligible);
// [feedpak-route] diagnostics: one line per decision change (the
// watcher polls at 350ms; steady state must not spam the buffer).
const _decision = 'running=' + running + ' eligible=' + eligible
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
if (_decision !== window._lastFeedpakRouteDecision) {
window._lastFeedpakRouteDecision = _decision;
console.log('[feedpak-route] watcher:', _decision);
}
if (wantJuce === !!window._juceMode) return; // routing already consistent
// Don't keep retrying a track JUCE explicitly rejected.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (running) {
if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed
@@ -5151,9 +5247,10 @@ window.jucePlayer = jucePlayer;
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else {
await _switchJuceToHtml5(songAudio);
// The engine just stopped. Clear any hard-reject memo so a
// later engine restart re-evaluates the track at least once —
// the rejection may have been a transient device/decoder state.
// The engine stopped (or a feedpak's output left exclusive
// mode). Clear any hard-reject memo so a later engine restart
// or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null;
}
} catch (e) {
@@ -5185,6 +5282,234 @@ window.jucePlayer = jucePlayer;
}, 350);
})();
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
// output endpoint in an exclusive-style mode, Chromium cannot reach the
// device, so any song audio still played by the renderer goes silent. The
// Phase 1 watcher above already migrates what a single-file transport can
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
// transport. This feeder covers the rest — the stems plugin's multi-stem
// WebAudio graph, plus <audio>-element songs the native transport could not
// take (e.g. a codec loadBackingTrack rejected).
//
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
// re-point the owning AudioContext at a null sink so it keeps rendering
// without a device, and push ~10 ms chunks over IPC into the engine's
// renderer bus, where they are mixed into the exclusive output like a
// backing track (~10-20 ms added latency on song audio only; the guitar
// monitoring path is untouched). Validated by the fix12 tester spike:
// null-sink rendering works, clocks hold (drift → 0), no overflow.
//
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
// captured contexts keep/regain their default sink.
(function _installRendererBusFeeder() {
const api = window.feedBackDesktop?.audio;
if (!api || typeof api.setRendererBus !== 'function'
|| typeof api.pushRendererAudio !== 'function') return;
const TAP_WORKLET = `
class FeedbackBusTap extends AudioWorkletProcessor {
process(inputs) {
const inp = inputs[0];
if (inp && inp[0]) {
const L = inp[0], R = inp[1] || inp[0];
const out = new Float32Array(L.length * 2);
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
this.port.postMessage(out, [out.buffer]);
}
return true;
}
}
registerProcessor('feedback-bus-tap', FeedbackBusTap);
`;
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
// One tap per captured graph. `active` gates the push (the worklet keeps
// running when inactive — it's silent bookkeeping, not audio).
function _makeTap(ctx) {
const state = { node: null, active: false, batch: [], batchFrames: 0 };
state.attach = async (sourceNode) => {
if (!_tapModuleLoaded.has(ctx)) {
await ctx.audioWorklet.addModule(_tapModuleUrl);
_tapModuleLoaded.add(ctx);
}
if (!state.node) {
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
state.node.port.onmessage = (e) => {
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
state.batch.push(e.data);
state.batchFrames += e.data.length / 2;
if (state.batchFrames >= BATCH) {
const merged = new Float32Array(state.batchFrames * 2);
let o = 0;
for (const c of state.batch) { merged.set(c, o); o += c.length; }
api.pushRendererAudio(merged, ctx.sampleRate);
state.batch = []; state.batchFrames = 0;
}
};
}
sourceNode.connect(state.node);
// No onward connection: the tap is a sink-side observer; audibility
// in shared mode comes from the graph's own destination path.
};
state.detach = (sourceNode) => {
state.active = false;
state.batch = []; state.batchFrames = 0;
if (state.node && sourceNode) {
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
}
};
return state;
}
// ── Core <audio> element capture ─────────────────────────────────────────
// createMediaElementSource permanently reroutes the element into its
// context, so it is created lazily — only the first time an exclusive
// device actually needs it — and never torn down. From then on the element
// always plays through _elCtx; sink toggling routes it to the speakers
// (shared mode) or the null sink + bus (exclusive mode).
let _elCtx = null, _elSource = null, _elTap = null;
async function _ensureElementCapture() {
if (_elCtx) return;
const el = document.getElementById('audio');
if (!el) throw new Error('no core audio element');
_elCtx = new AudioContext();
_elSource = _elCtx.createMediaElementSource(el);
_elSource.connect(_elCtx.destination);
_elTap = _makeTap(_elCtx);
await _elTap.attach(_elSource);
}
// ── Engagement state machine ─────────────────────────────────────────────
// 'off' | 'element' | 'stems'
let _mode = 'off';
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
let _stemsTap = null;
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
let _busy = false;
async function _setSink(ctx, exclusive) {
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
// [asio-diag] a context left on the default sink while the bus is
// engaged is exactly the "song on the wrong device" symptom — record
// every successful sink flip (failures throw and are logged upstream).
if (window._asioDiagEnabled?.()) {
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
'state=', ctx.state, 'rate=', ctx.sampleRate);
}
}
async function _disengage() {
if (_mode === 'off') return;
const prev = _mode;
_mode = 'off';
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
if (prev === 'element' && _elCtx) {
_elTap.active = false;
await _setSink(_elCtx, false).catch(() => {});
} else if (prev === 'stems' && _stemsGraph) {
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
await _setSink(_stemsGraph.context, false).catch(() => {});
_stemsGraph = null; _stemsTap = null;
}
console.log('[renderer-bus] disengaged (' + prev + ')');
}
async function _engageStems(graph) {
await _setSink(graph.context, true);
let tap = _stemsTaps.get(graph.context);
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
await tap.attach(graph.masterNode);
await api.setRendererBus(true, 1.0);
tap.active = true;
_stemsGraph = graph; _stemsTap = tap;
_mode = 'stems';
console.log('[renderer-bus] engaged: stems graph → engine bus');
}
async function _engageElement() {
await _ensureElementCapture();
await _setSink(_elCtx, true);
await api.setRendererBus(true, 1.0);
_elTap.active = true;
_mode = 'element';
console.log('[renderer-bus] engaged: <audio> element → engine bus');
}
async function _reevaluate() {
if (_busy) return;
_busy = true;
try {
let running = false, exclusive = false;
try {
running = await api.isAudioRunning();
} catch (_) { /* engine unreachable → treat as not running */ }
if (running) {
// Reuse the Phase 1 predicate installed by the routing watcher
// (getCurrentDevice + exclusive-type check with change-logged
// diagnostics). Fail closed if it is somehow absent.
exclusive = !!(await window._juceOutputIsExclusive?.());
}
// The stems plugin publishes its live graph while a multi-stem
// song is loaded (and removes it on teardown).
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
// Element songs: a song is loaded, it is NOT riding the native
// transport (Phase 1 owns those), and the stems graph is not the
// player. Covers native-transport rejects (codec) in exclusive
// mode — without this they would be silent.
const songAudio = window._currentSongAudio;
const elementSong = !!songAudio && !window._juceMode && !stems;
let want = 'off';
if (running && exclusive) {
if (stems) want = 'stems';
else if (elementSong) want = 'element';
}
// [asio-diag] full decision vector, change-gated (500ms poll —
// steady state must not flood the buffer). This is the feeder-side
// counterpart of the watcher's [feedpak-route] decision line: it
// shows WHY the bus did or didn't engage (exclusive predicate,
// stems graph presence, native transport ownership, element song).
if (window._asioDiagEnabled?.()) {
const d = 'running=' + running + ' exclusive=' + exclusive
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
+ ' juceMode=' + !!window._juceMode
+ ' elementSong=' + elementSong
+ ' want=' + want + ' mode=' + _mode;
if (d !== window._lastRendererBusDecision) {
window._lastRendererBusDecision = d;
console.log('[asio-diag] renderer-bus:', d);
}
}
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
if (want !== _mode || stemsGraphChanged) {
await _disengage();
if (want === 'stems') await _engageStems(stems);
else if (want === 'element') await _engageElement();
}
} catch (e) {
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
_mode = 'off';
} finally {
_busy = false;
}
}
// Same cadence/rationale as the routing watcher above. Also re-check on
// visibility return so a device switch made while hidden is reconciled.
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
window._reevaluateRendererBus = _reevaluate;
})();
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
@@ -5556,9 +5881,24 @@ function _playbackApi() {
: null;
}
// Bridge hits are a "this legacy surface is still in use" signal, not a call
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
// snapshot serialization on the main thread and saturated the inspector's
// hitCount. Throttle per surface: the first call records immediately, repeats
// within the window are dropped.
const _bridgeRecordLast = new Map();
const _BRIDGE_RECORD_MIN_MS = 5000;
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
const playback = _playbackApi();
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
const key = `${bridgeId}|${legacySurface}`;
const now = Date.now();
const last = _bridgeRecordLast.get(key);
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
_bridgeRecordLast.set(key, now);
playback.recordBridgeHit({
bridgeId,
legacySurface,
@@ -7932,6 +8272,10 @@ function setLoopEnd() {
if (loopB <= loopA) { loopB = null; return; }
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
// transport event so event-driven consumers (note_detect drill sync) see
// button-armed loops without having to poll getLoop().
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
function clearLoop(options) {
@@ -8092,6 +8436,26 @@ function _resolveEditRegion() {
return { a: Math.max(0, t - 4), b: t + 4 };
}
/* @pure:editor-pending-view:start */
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
const options = opts || {};
const view = {
filename,
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
barSel: region ? { startTime: region.a, endTime: region.b } : null,
};
if (options.returnToHighway) view.returnToHighway = true;
if (typeof options.cursorTime === 'number') {
view.cursorTime = options.cursorTime;
} else if (region && typeof region.a === 'number') {
view.cursorTime = region.a;
}
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
return view;
}
/* @pure:editor-pending-view:end */
// Enable "Edit region" whenever the editor plugin is present and a song is
// loaded; show "↩ Editor" only while a return context is pending.
function _updateEditRegionBtn() {
@@ -8118,12 +8482,9 @@ function editRegionInEditor() {
arrangement = si.arrangement_index;
}
} catch (_) { /* default to 0 */ }
window._editorPendingView = {
filename: currentFilename,
arrangement,
barSel: { startTime: region.a, endTime: region.b },
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
returnToHighway: true,
};
});
window.editSong(currentFilename);
}
window.editRegionInEditor = editRegionInEditor;
@@ -8135,14 +8496,14 @@ function returnToEditorFromHighway() {
const ctx = window._highwayReturnCtx;
if (!ctx || typeof window.editSong !== 'function') return;
window._highwayReturnCtx = null;
window._editorPendingView = {
filename: ctx.filename,
arrangement: ctx.arrangement,
const region = ctx.barSel
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
: null;
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
scrollX: ctx.scrollX,
zoom: ctx.zoom,
cursorTime: ctx.cursorTime,
barSel: ctx.barSel,
};
});
window.editSong(ctx.filename);
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
@@ -11519,6 +11880,14 @@ async function loadPlugins() {
// URL ?v=mtime convention elsewhere in this file).
const v = encodeURIComponent(wantedVersion);
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
// Module-migration (R0): a migrated plugin declares
// scriptType:"module" and its screen.js is `import
// './src/main.js'`. A <script type="module"> fires load
// only after its whole static-import graph evaluates, so
// the await-onload completion + _loadingPluginId contract
// below is preserved (a classic-IIFE dynamic import()
// would not). Classic plugins are unaffected.
if (plugin.script_type === 'module') script.type = 'module';
script.dataset.pluginId = plugin.id;
script.dataset.pluginVersion = wantedVersion;
window.feedBack._loadingPluginId = plugin.id;
+3 -1
View File
@@ -9,6 +9,8 @@
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
const OWNER_ID = 'core.audio.effects';
const DEFAULT_ROUTE_KEY = 'desktop-main';
const DEFAULT_TIMEOUT_MS = 2000;
@@ -734,7 +736,7 @@
const errors = [];
const source = _plainObject(rawPlan);
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
+966 -896
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -119,6 +119,8 @@
<option value="year-desc">Year (newest)</option>
<option value="year">Year (oldest)</option>
<option value="tuning">Tuning</option>
<option value="difficulty">Difficulty (easiest first)</option>
<option value="difficulty-desc">Difficulty (hardest first)</option>
</select>
<!-- Format filter (shared) -->
<select id="lib-format" onchange="sortLibrary()"
+1 -1
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -451,6 +451,12 @@
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
pill('strings', v, v + '', settings.string_count === v)).join('')) +
// Handedness — a left-hander flips the whole highway (frets mirror).
// Lives with the other player-orientation choices so it's part of the
// same "Choose your instrument" step the onboarding tour spotlights —
// i.e. set before you ever tune up or calibrate.
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
pill('hand', 'left', 'Left', _leftyPref())) +
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
// An offset-array tuning has no named option — surface it as a
@@ -513,6 +519,10 @@
setWorkingInstrument(settings.instrument, newSc);
renderInstrument(); keepOpen();
}));
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
_setLeftyPref(b.getAttribute('data-val') === 'left');
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
}));
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
const ref = menu.querySelector('[data-inst-ref]');
@@ -528,6 +538,22 @@
function instRow(label, inner) {
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
}
// Handedness (left-handed) preference. The canonical store is the highway's
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
// it immediately. Feature-detected so it works on the dashboard before any
// highway has been created (the value is read on the highway's next init).
function _leftyPref() {
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
}
function _setLeftyPref(on) {
try {
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
else localStorage.setItem('lefty', on ? '1' : '0');
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
}
function pill(group, val, label, active) {
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
+56 -1
View File
@@ -138,6 +138,17 @@
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
'</div>' +
// Search Cover Art Archive — find an album cover even when the song has
// no match (the auto candidates above are empty then). Pre-filled from
// the song's artist + album/title; the source is rate-limited.
'<div class="space-y-2 pt-1">' +
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
'<div class="flex gap-2">' +
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
'</div>' +
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
'</div>' +
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
'</div></div>' +
'<input type="file" accept="image/*" data-ip-file class="hidden">';
@@ -185,9 +196,47 @@
}
});
});
const searchInput = panel.querySelector('[data-ip-search-input]');
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
panel.querySelector('[data-ip-close]')?.focus();
}
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
// render the album covers as pickable tiles — the same apply('url') path as
// the auto candidates. Covers with no CAA art self-hide (img onerror).
async function coverSearch(panel, query) {
const out = panel.querySelector('[data-ip-search-results]');
const fn = _cur && _cur.filename;
if (!out || !fn) return;
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
let body = null;
try {
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
if (r.ok) body = await r.json();
} catch (_) { /* falls through to the empty state */ }
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
const covers = (body && body.covers) || [];
if (!covers.length) {
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
'</div>';
return;
}
out.innerHTML = covers.map((c, i) =>
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
const img = btn.querySelector('img');
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
btn.addEventListener('click', () => {
if (_busy) return;
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
if (c) apply('url', c.thumb_url);
});
});
}
// The one candidates fetch, cancelled if the modal closes first. Failure
// (offline, demo mode, aborted) is silent: the skeletons just clear and
// the instant tiles remain — never an error wall.
@@ -281,7 +330,13 @@
const filename = opts && opts.filename;
if (!filename) return;
_lastFocus = document.activeElement;
_cur = { filename: filename, title: (opts && opts.title) || filename };
const title = (opts && opts.title) || filename;
const artist = (opts && opts.artist) || '';
const album = (opts && opts.album) || '';
// Pre-fill the cover search: "artist album" when the album is known, else
// just the artist, else the title — the server default backs it up.
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
_cur = { filename: filename, title: title, query: query };
_busy = false;
const m = ensureModal();
const panel = document.getElementById('v3-imgpick-panel');
+6 -1
View File
@@ -194,7 +194,7 @@
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
level so it stays reachable regardless of which screen is active. -->
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
<div id="home" class="screen">
@@ -641,7 +641,12 @@
</div>
</div>
<div class="fb-srow-control">
<!-- Autosave on blur/enter via a single-key POST (like every other v3
setting), so setting the address never depends on the shared Save
button — whose bundled dlc_dir could otherwise block it. Save button
kept for discoverability. -->
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
onchange="persistSetting('demucs_server_url', this.value.trim())"
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
</div>
+53 -6
View File
@@ -303,6 +303,7 @@
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
diffChips(song, c) +
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
'</button>';
}
@@ -461,7 +462,11 @@
// Each field sits on its pack value: editing above the pack makes it an
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
// it; revert (↺) drops back to the pack value.
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year']];
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
// Only these four are written into the pack file; genre is a library-only
// overlay (drives the genre filter/facet + the auto-match lock), never baked
// to the file — so Write to file leaves genre's override in place.
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
async function renderDetailsTab(body, song) {
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
@@ -483,7 +488,47 @@
};
}
song._detailsState = st;
// Match→Details bridge: a candidate picked with "Use these values" lands
// its fields here as the pending (unsaved) input values, shown pre-filled
// for review — the grid never adopts a match silently, so the user still
// Saves (or Writes to file).
const adopted = song._pendingDetails;
if (adopted) {
for (const [f] of DETAIL_FIELDS) {
if (f in adopted) st[f].value = String(adopted[f] || '');
}
song._pendingDetails = null;
}
paintDetails(body, song);
if (adopted) {
const s = body.querySelector('[data-df-status]');
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
}
}
// Match→Details bridge: adopt a candidate's display fields into the Details
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
// then land on Details pre-filled for review.
async function useTheseValues(song, cand) {
if (!cand) return;
// Smart adopt for an English base: KEEP the readable name + title the card
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
// match is often native script (kanji/kana). Take only what the pack lacks
// — album / year / genre — from the match; the pin below still brings the
// correct art + identity. The user can still edit any field.
song._pendingDetails = {
artist: String(song.artist || cand.artist || ''),
title: String(song.title || cand.title || ''),
album: String(cand.album || song.album || ''),
year: String(cand.year || song.year || ''),
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
};
try {
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
} catch (_) { /* pin is best-effort; the values still populate Details */ }
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
_tab = 'details';
renderTabbed();
}
function paintDetails(body, song) {
@@ -514,7 +559,7 @@
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
'<div class="flex items-start gap-3">' +
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes them into the pack itself. Lock a field to keep an auto-match from changing it.</p>' +
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
'</div>' +
DETAIL_FIELDS.map(row).join('') +
'<p data-df-status class="text-xs leading-relaxed"></p>' +
@@ -584,7 +629,7 @@
async function writeToFile(body, song) {
const st = song._detailsState;
const fields = {};
for (const [f] of DETAIL_FIELDS) fields[f] = String(st[f].value || '').trim();
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
const status = body.querySelector('[data-df-status]');
const writeBtn = body.querySelector('[data-df-write]');
const saveBtn = body.querySelector('[data-df-save]');
@@ -617,11 +662,11 @@
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
applied.year = yr ? String(yr) : '';
}
for (const [f] of DETAIL_FIELDS) song[f] = applied[f];
for (const f of WRITE_FIELDS) song[f] = applied[f];
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
if (persisted) {
const clear = {};
for (const [f] of DETAIL_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
try {
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
method: 'PUT',
@@ -651,7 +696,7 @@
'</div>';
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
}
});
}
@@ -705,6 +750,7 @@
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
@@ -757,6 +803,7 @@
btn.addEventListener('click', async () => {
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
if (!cand) return;
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
{ candidate: cand });
settle(song);
+1 -1
View File
@@ -42,7 +42,7 @@
id: 'instrument', shape: 'spotlight', position: 'bottom',
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
title: 'Choose your instrument',
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
},
{
id: 'tuner', shape: 'spotlight', position: 'bottom',
+30 -13
View File
@@ -38,6 +38,9 @@
// Mastery = best accuracy across arrangements (song_stats); unscored songs
// sort last either way. Ascending surfaces what needs work; never default.
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
// songs sort last either way.
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
];
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
@@ -166,15 +169,15 @@
const f = saved.filters;
if (f && typeof f === 'object') {
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
// mastery + match are session-only facets (deliberately not
// mastery + match + genre are session-only facets (deliberately not
// persisted), but the restored object must still CARRY the keys —
// the filter drawer indexes f.mastery/f.match unconditionally, so
// dropping them here breaks the drawer for anyone with saved prefs.
// the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
// so dropping them here breaks the drawer for anyone with saved prefs.
state.filters = {
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
lyrics: f.lyrics || '', tunings: arr(f.tunings),
mastery: [], match: [],
mastery: [], match: [], genre: [],
};
}
}
@@ -515,15 +518,22 @@
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
// Resting indicator: subtle, so a mostly-unmatched library isn't a
// wall of loud badges; points at the manual fix.
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'No metadata match found — right-click to fix it by hand'],
// wall of loud badges. Clickable — a one-click handoff into the
// Fix-metadata popup for this song (see the [data-meta-fix] wiring).
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
};
const conf = M[st] || M.queued;
const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
// top-10 clears the tuning chip (top-2) in both normal and select mode;
// z-20 sits it above the art. Non-interactive so it never eats a click.
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none"' +
(conf[2] ? ' title="' + conf[2] + '"' : '') + '>' + conf[1] + '</span>';
// z-20 sits it above the art. Batch states are non-interactive; the
// resting "no match" badge is the handoff into the popup.
const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
(fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
return '<span class="' + cls + '"' +
(fixable ? ' data-meta-fix="1"' : '') +
(conf[2] ? ' title="' + conf[2] + '"' : '') +
'>' + conf[1] + '</span>';
}
// After a song is scored, the badge for that card is stale until the next
@@ -1000,7 +1010,7 @@
// the group's work_key/chart_count and pre-ticks the shown chart.)
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
if (id === '__cover') {
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
return;
}
if (id === '__refreshmeta') {
@@ -1467,6 +1477,13 @@
e.stopPropagation();
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
});
// "No match" badge → straight into the Fix-metadata popup for this
// song (the batch → fix handoff). stopPropagation so it doesn't also
// trigger the card's play. Follows the displayed chart, like the menu.
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
e.stopPropagation();
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
});
// Artist line → the artist page (PR-B). In select mode the grid's
// capture-phase toggle intercepts first, so selection still wins.
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
@@ -2807,7 +2824,7 @@
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
// Progress (mastery bands) — multi-select; server filters via song_stats.
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
@@ -3112,7 +3129,7 @@
// when image-picker.js isn't loaded.
artWrap.addEventListener('click', () => {
if (window.__fbOpenImagePicker) {
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
} else {
artFile.click();
}
+30
View File
@@ -9,6 +9,36 @@ import structlog
_LOGGING_NAMES = ("feedBack", "uvicorn", "uvicorn.error", "uvicorn.access")
@pytest.fixture(autouse=True)
def _reset_enrichment_state():
"""Reset the enrichment worker's process-global state between tests.
The `server` fixtures pop-and-reimport `server`, but `lib/enrichment.py`
(which now owns the worker) stays imported for the whole session, so its
module globals the cancel Event, the status dict, the caches would
otherwise leak across tests. A test that set `_enrich_cancel` (or a stale
`running` status) could silently short-circuit a later direct
`_background_enrich()` call. Clear it up front so each test starts clean.
"""
try:
import enrichment
except ImportError:
yield
return
enrichment._enrich_cancel.clear()
enrichment._enrich_pending_pass = False
enrichment._enrich_status.update(
{"running": False, "processed": 0, "last_pass_at": None,
"total": 0, "matched": 0, "current": None})
enrichment._enrich_last_fetch = 0.0
enrichment._artist_alias_cache.clear()
# _caa_index_locks is deliberately left alone: it's guarded by
# _caa_index_locks_guard, so clearing it here (unlocked) would race a
# still-alive worker thread, and its entries are stateless per-release
# mutexes that don't leak test state anyway.
yield
@pytest.fixture()
def isolate_logging():
"""Restore feedBack / uvicorn logger state after each test.
+47
View File
@@ -0,0 +1,47 @@
// Pins the onboarding handedness control: a Right/Left choice lives in the
// instrument selector (the "Choose your instrument" onboarding step, which the
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
// highway 'lefty' preference. Source-level, matching the other tests/js/
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
test('instrument selector offers a Handedness Right/Left choice', () => {
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
});
test('clicking a handedness pill writes the lefty preference from its value', () => {
assert.match(
BADGES,
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
'the handedness click handler sets lefty from the pill value');
});
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
assert.ok(setter, '_setLeftyPref must exist');
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
});
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
assert.match(
BADGES,
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
'_leftyPref reads the current handedness with a storage fallback');
});
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
assert.match(TOUR, /Choose your instrument/);
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
});
+1 -1
View File
@@ -42,7 +42,7 @@ test('beats:loaded emit is wired into the WS beats case', () => {
);
assert.match(
block,
/count:\s*beats\.length/,
/count:\s*hwState\.beats\.length/,
'beats:loaded payload must include count = beats.length',
);
});
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
if (!m) throw new Error('pending-view helper block not found');
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
test('edit-region handoff defaults cursor to region start and marks return path', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 2,
barSel: { startTime: 12.5, endTime: 20 },
returnToHighway: true,
cursorTime: 12.5,
});
});
test('return-trip handoff preserves explicit viewport state', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
scrollX: -4,
zoom: 160,
cursorTime: 9.25,
});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 1,
barSel: { startTime: 8, endTime: 14 },
cursorTime: 9.25,
scrollX: 0,
zoom: 160,
});
});
test('missing region still produces a stable pending view shell', () => {
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
assert.deepStrictEqual(out, {
filename: 'song.sloppak',
arrangement: 0,
barSel: null,
});
});
+2 -2
View File
@@ -33,7 +33,7 @@ test('handshapes WS case accumulates incoming chunks into handShapes', () => {
const block = getCaseBlock(src, 'handshapes');
assert.match(
block,
/handShapes\s*=\s*handShapes\.concat\(\s*msg\.data\s*\)/,
/hwState\.handShapes\s*=\s*hwState\.handShapes\.concat\(\s*msg\.data\s*\)/,
'handshapes case must concat msg.data into the handShapes accumulator',
);
});
@@ -62,7 +62,7 @@ test('bundle exposes handShapes to renderers with flat-list fallback', () => {
const src = fs.readFileSync(HIGHWAY_JS, 'utf8');
assert.match(
src,
/\bhandShapes\s*[:=]\s*\([^)]*_filteredHandShapes[^)]*\)\s*\?\s*_filteredHandShapes\s*:\s*handShapes\b/,
/\bhandShapes\s*[:=]\s*\([^)]*hwState\._filteredHandShapes[^)]*\)\s*\?\s*hwState\._filteredHandShapes\s*:\s*hwState\.handShapes\b/,
'bundle must expose handShapes with the _filteredHandShapes-vs-handShapes ternary fallback',
);
});
+45
View File
@@ -0,0 +1,45 @@
// Contract test: 3D Highway WebGL context-loss recovery.
//
// Switching the active window / alt-tabbing (especially on Windows) can trigger
// a GPU context reset. Without a handler the lost WebGL context escalates into a
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
// lifecycle (too much to construct in a vm sandbox), so — like the other
// highway_* source-contract tests here — this pins the wiring at the source
// level: the loss must be preventDefault()'d (so the browser restores it), draw
// must bail while lost, and the listeners must be torn down.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
'must listen for webglcontextrestored on ren.domElement');
});
test('the context-lost handler preventDefaults and pauses drawing', () => {
// Without preventDefault() the browser will not attempt to restore the
// context and the loss can escalate to a renderer crash.
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
assert.ok(m, '_onCtxLost handler must exist');
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
});
test('draw() early-returns while the context is lost', () => {
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
});
test('teardown removes the context-loss listeners', () => {
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
'teardown must remove the webglcontextlost listener');
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
'teardown must remove the webglcontextrestored listener');
});
+1 -1
View File
@@ -20,7 +20,7 @@ function src(file) {
test('highway renderer bundles surface the core lefty flag', () => {
assert.match(
src(HIGHWAY_JS),
/lefty\s*[:=]\s*_lefty/,
/lefty\s*[:=]\s*hwState\._lefty/,
'custom renderer bundles must include lefty: _lefty',
);
});
@@ -49,12 +49,12 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
// within the interp cap.
assert.match(
fn,
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*_chartAnchorPerfNow\s*\)/,
/isPlaying\s*[:=]\s*!Number\.isNaN\(\s*hwState\._chartAnchorPerfNow\s*\)/,
'isPlaying must gate on a live anchor (_chartAnchorPerfNow not NaN)',
);
assert.match(
fn,
/_chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
/hwState\._chartLastAdvanceAt\s*\)\s*<=\s*_CHART_MAX_INTERP_MS/,
'isPlaying must require the clock advanced within _CHART_MAX_INTERP_MS',
);
});
+5 -5
View File
@@ -29,7 +29,7 @@ function extractBlock(src, signature) {
test('highway declares adaptive-scale state with a floor', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_autoScale\s*=\s*1/, 'missing _autoScale multiplier');
assert.match(src, /hwState\._autoScale\s*=\s*1/, 'missing _autoScale multiplier');
assert.match(src, /const\s+_AUTO_SCALE_MIN\s*=\s*0?\.25/, 'missing _AUTO_SCALE_MIN floor (0.25)');
assert.match(src, /const\s+_DRAW_BUDGET_HI_MS\s*=\s*\d+/, 'missing high draw budget');
assert.match(src, /const\s+_DRAW_BUDGET_LO_MS\s*=\s*\d+/, 'missing low draw budget');
@@ -49,18 +49,18 @@ test('_effectiveRenderScale clamps user ceiling * auto factor to [MIN, 1]', () =
test('min render scale floor is user-configurable + exposed on the api (#654)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
// Hard floor constant kept; configurable floor read from localStorage.
assert.match(src, /let\s+_autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
assert.match(src, /hwState\._autoScaleMin\s*=/, 'missing configurable _autoScaleMin');
assert.match(src, /localStorage\.getItem\('highwayMinRenderScale'\)/,
'configurable floor must load from localStorage.highwayMinRenderScale');
assert.match(src, /setMinRenderScale\(/, 'api.setMinRenderScale missing');
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+_autoScaleMin/, 'api.getMinRenderScale missing');
assert.match(src, /getMinRenderScale\(\)\s*\{\s*return\s+hwState\._autoScaleMin/, 'api.getMinRenderScale missing');
// Floor is clamped to the user ceiling so it can never exceed the manual cap.
const eff = extractBlock(src, 'function _effectiveRenderScale()');
assert.match(eff, /Math\.min\(\s*_autoScaleMin\s*,\s*user\s*\)/,
assert.match(eff, /Math\.min\(\s*hwState\._autoScaleMin\s*,\s*user\s*\)/,
'effective scale must clamp the floor to the user ceiling');
// _adaptRenderScale must cap the lo bound at 1 so _autoScale stays in [_,1].
const adapt = extractBlock(src, 'function _adaptRenderScale(');
assert.match(adapt, /Math\.min\(\s*1\s*,\s*_autoScaleMin\s*\/\s*_renderScale\s*\)/,
assert.match(adapt, /Math\.min\(\s*1\s*,\s*hwState\._autoScaleMin\s*\/\s*hwState\._renderScale\s*\)/,
'lo bound must be capped at 1 to keep _autoScale a [0,1] multiplier');
});
+5 -5
View File
@@ -34,9 +34,9 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
const neqEither = (a, b) => new RegExp(
`\\b${a}\\b\\s*!==\\s*\\b${b}\\b|\\b${b}\\b\\s*!==\\s*\\b${a}\\b`
);
assert.match(src, eqEither('_chordRenderCacheSrc', 'src'), 'cache must key on src');
assert.match(src, eqEither('_chordRenderCacheInverted', '_inverted'), 'cache must key on _inverted');
assert.match(src, neqEither('_chordRenderCacheTemplates', 'chordTemplates'),
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
'cache must key on chordTemplates (detected via !== for change-flag)');
});
@@ -50,9 +50,9 @@ test('chordTemplates change resets fretline preview and frame-mismatch warner',
// block inside the `if (templatesChanged) { … }` branch (e.g. an
// inner conditional reset) doesn't break the match by introducing
// a `}` before the symbol we're checking for.
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._chordFretLineNotes\s*=\s*\[\][\s\S]*?\}/,
'templatesChanged branch must reset _chordFretLineNotes');
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?hwState\._lastChordOnFretLine\s*=\s*null[\s\S]*?\}/,
'templatesChanged branch must null _lastChordOnFretLine');
assert.match(src, /if\s*\(\s*templatesChanged\s*\)\s*\{[\s\S]*?_frameMismatchWarned\.clear\(\)[\s\S]*?\}/,
'templatesChanged branch must clear _frameMismatchWarned');
+2 -2
View File
@@ -23,7 +23,7 @@ test('getFilteredNotes falls through to notes when _filteredNotes is null', () =
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*notes/,
/getFilteredNotes\s*\(\s*\)\s*\{[^}]*_filteredNotes[^}]*:\s*hwState\.notes/,
'getFilteredNotes must return notes as fallback',
);
});
@@ -41,7 +41,7 @@ test('getFilteredChords falls through to chords when _filteredChords is null', (
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*chords/,
/getFilteredChords\s*\(\s*\)\s*\{[^}]*_filteredChords[^}]*:\s*hwState\.chords/,
'getFilteredChords must return chords as fallback',
);
});
+28 -22
View File
@@ -35,7 +35,10 @@ function extractBlock(src, signature) {
// + getTime methods so behavioral tests can exercise the real
// implementation in isolation.
function buildClockSandbox(perfNowImpl) {
const sandbox = {
// The lifted per-instance state now lives on `hwState` (the R3c H lift);
// the extracted setTime/getTime bodies reference hwState.<slot>. The const
// _CHART_MAX_INTERP_MS was NOT lifted, so it stays a top-level global here.
const hwState = {
chartTime: 0,
currentTime: 0,
avOffsetSec: 0,
@@ -50,6 +53,9 @@ function buildClockSandbox(perfNowImpl) {
_chartAnchorPerfNow: NaN,
_chartLastAdvanceAt: 0,
_chartObservedRate: 1,
};
const sandbox = {
hwState,
_CHART_MAX_INTERP_MS: 100,
performance: { now: perfNowImpl },
};
@@ -72,10 +78,10 @@ test('highway declares chart anchor + stall-detect + rate state', () => {
// particular MUST start as NaN, not 0, otherwise setTime(0) on the
// very first 60 Hz tick fails the `t !== _chartAnchorAudioT` check
// and never re-anchors, leaving the clock uninitialized.
assert.match(src, /let\s+_chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
assert.match(src, /let\s+_chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
assert.match(src, /let\s+_chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
assert.match(src, /let\s+_chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
assert.match(src, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'missing _chartAnchorAudioT (NaN sentinel)');
assert.match(src, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'missing _chartAnchorPerfNow (NaN sentinel)');
assert.match(src, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'missing _chartLastAdvanceAt (pause detection)');
assert.match(src, /hwState\._chartObservedRate\s*=\s*1/, 'missing _chartObservedRate (playback rate awareness)');
assert.match(src, /const\s+_CHART_MAX_INTERP_MS\s*=\s*100/, 'missing _CHART_MAX_INTERP_MS cap');
});
@@ -86,7 +92,7 @@ test('getTime scales interpolation by _chartObservedRate (speed-slider safe)', (
const slice = m[0];
assert.match(
slice,
/_chartObservedRate\s*\*\s*elapsedMs/,
/hwState\._chartObservedRate\s*\*\s*elapsedMs/,
'getTime must scale interpolation by observed rate so audio.playbackRate != 1 stays accurate',
);
});
@@ -100,12 +106,12 @@ test('setTime re-anchors and updates _chartLastAdvanceAt only when t actually ch
// The implementation may capture performance.now() into a local
// (e.g. newPerfNow) and assign that to both fields; accept either
// direct or via-local writes.
const m = src.match(/if\s*\(\s*t\s*!==\s*_chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
const m = src.match(/if\s*\(\s*t\s*!==\s*hwState\._chartAnchorAudioT\s*\)\s*\{[\s\S]+?\}\s*\},/);
assert.ok(m, 'if (t !== _chartAnchorAudioT) block not found inside setTime');
const block = m[0];
assert.match(block, /_chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
assert.match(block, /_chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
assert.match(block, /_chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
assert.match(block, /hwState\._chartAnchorAudioT\s*=\s*t/, 'must assign _chartAnchorAudioT = t');
assert.match(block, /hwState\._chartAnchorPerfNow\s*=/, 'must assign _chartAnchorPerfNow');
assert.match(block, /hwState\._chartLastAdvanceAt\s*=/, 'must assign _chartLastAdvanceAt');
});
test('getTime falls back to chartTime when audio has stalled (paused)', () => {
@@ -119,7 +125,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
// Must check stall-since-last-advance against the cap.
assert.match(
slice,
/nowP\s*-\s*_chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
/nowP\s*-\s*hwState\._chartLastAdvanceAt\s*>\s*_CHART_MAX_INTERP_MS/,
'getTime must short-circuit when audio has stalled past the cap',
);
// Must interpolate when active.
@@ -127,7 +133,7 @@ test('getTime falls back to chartTime when audio has stalled (paused)', () => {
// Rate-scaled formula: _chartAnchorAudioT + (_chartObservedRate * elapsedMs) / 1000
assert.match(
slice,
/_chartAnchorAudioT\s*\+\s*\(\s*_chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
/_chartAnchorAudioT\s*\+\s*\(\s*hwState\._chartObservedRate\s*\*\s*elapsedMs\s*\)\s*\/\s*1000/,
'getTime must compute anchor + rate-scaled elapsed during play',
);
});
@@ -138,10 +144,10 @@ test('api.stop() clears the chart anchor state so re-init starts fresh', () => {
// the actual stop() body — a fixed-size slice would falsely match
// resets that landed in an adjacent method.
const stopBlock = extractBlock(src, 'stop() {');
assert.match(stopBlock, /_chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
assert.match(stopBlock, /_chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
assert.match(stopBlock, /_chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
assert.match(stopBlock, /_chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
assert.match(stopBlock, /hwState\._chartAnchorAudioT\s*=\s*NaN/, 'stop() must reset _chartAnchorAudioT to the NaN sentinel');
assert.match(stopBlock, /hwState\._chartAnchorPerfNow\s*=\s*NaN/, 'stop() must reset _chartAnchorPerfNow to the NaN sentinel');
assert.match(stopBlock, /hwState\._chartLastAdvanceAt\s*=\s*0/, 'stop() must reset _chartLastAdvanceAt');
assert.match(stopBlock, /hwState\._chartObservedRate\s*=\s*1/, 'stop() must reset _chartObservedRate to 1x');
});
// ── Behavioral tests (run extracted setTime/getTime in vm sandbox) ──────
@@ -195,12 +201,12 @@ test('behavior: seek discontinuity resets observed rate to 1x', () => {
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb._chartObservedRate}`);
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, `prior segment must measure ≈0.5, got ${sb.hwState._chartObservedRate}`);
// Seek: large t jump in same perf delta — observed-rate clamp
// rejects this segment, resets to 1.
now = 70;
sb.setTime(120); // dPerf=20ms, dT=110s → observed=5500 (out of clamp)
assert.equal(sb._chartObservedRate, 1, 'seek must reset rate to 1x');
assert.equal(sb.hwState._chartObservedRate, 1, 'seek must reset rate to 1x');
});
test('behavior: getTime caps interpolation at _CHART_MAX_INTERP_MS', () => {
@@ -225,8 +231,8 @@ test('behavior: setTime(0) on first tick anchors correctly (boot edge case)', ()
const sb = buildClockSandbox(() => now);
sb.setTime(0);
// Anchor must now be initialized.
assert.equal(sb._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
assert.equal(sb._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
assert.equal(sb.hwState._chartAnchorAudioT, 0, 'setTime(0) on first tick must set anchor.audioT');
assert.equal(sb.hwState._chartAnchorPerfNow, 16, 'setTime(0) on first tick must set anchor.perfNow');
// getTime should return a finite value, not NaN.
const t = sb.getTime();
assert.ok(!Number.isNaN(t), `getTime must not return NaN after setTime(0); got ${t}`);
@@ -252,10 +258,10 @@ test('behavior: long anchor gap resets observed rate to 1x', () => {
sb.setTime(10);
now = 50;
sb.setTime(10.025); // observed rate ≈ 0.5
assert.ok(Math.abs(sb._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
assert.ok(Math.abs(sb.hwState._chartObservedRate - 0.5) < 0.001, 'first segment measured 0.5x');
// Long gap (1 second) before next setTime — out of the dPerf < 0.5
// window, so the rate must reset to 1.
now = 1100;
sb.setTime(10.5);
assert.equal(sb._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
assert.equal(sb.hwState._chartObservedRate, 1, 'long anchor gap must reset rate to 1x');
});
+6 -6
View File
@@ -34,16 +34,16 @@ function extractBlock(src, signature) {
test('highway declares the note-state provider slot', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
assert.match(src, /hwState\._noteStateProvider\s*=\s*null/, 'missing _noteStateProvider (provider slot, null = none)');
});
test('public API exposes setNoteStateProvider / getNoteStateProvider / getNoteState / isDefaultRenderer', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*_noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*hwState\._noteStateProvider\s*=/, 'setNoteStateProvider must assign _noteStateProvider');
assert.match(src, /setNoteStateProvider\s*\(\s*fn\s*\)\s*\{[^}]*typeof\s+fn\s*===\s*['"]function['"][^}]*:\s*null/, 'setNoteStateProvider must coerce non-functions (incl. null) to null');
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider/, 'getNoteStateProvider must return the slot');
assert.match(src, /getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider/, 'getNoteStateProvider must return the slot');
assert.match(src, /getNoteState\s*\(\s*note\s*,\s*chartTime\s*\)\s*\{\s*return\s+_noteState\s*\(/, 'getNoteState must delegate to _noteState');
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+_renderer\s*===\s*_defaultRenderer\s*\|\|\s*_renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
assert.match(src, /isDefaultRenderer\s*\(\s*\)\s*\{\s*return\s+hwState\._renderer\s*===\s*_defaultRenderer\s*\|\|\s*hwState\._renderer\s*==\s*null/, 'isDefaultRenderer must be (_renderer === _defaultRenderer || _renderer == null)');
});
test('_makeBundle exposes getNoteState (stable reference, no per-frame alloc)', () => {
@@ -72,7 +72,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
// slot, so renderers see a live "is a provider registered?" view.
assert.match(
src,
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+_noteStateProvider\s*;?\s*\}/,
/function\s+_getNoteStateProvider\s*\(\s*\)\s*\{\s*return\s+hwState\._noteStateProvider\s*;?\s*\}/,
'_getNoteStateProvider must be defined as a stable named function returning _noteStateProvider'
);
});
@@ -80,7 +80,7 @@ test('_makeBundle exposes getNoteStateProvider as a stable reference (feedBack#2
test('_noteState normalizes provider output as documented', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _noteState(note, chartTime)');
assert.match(fn, /if\s*\(\s*!_noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
assert.match(fn, /if\s*\(\s*!hwState\._noteStateProvider\s*\)\s*return\s+null/, 'must short-circuit when no provider is registered');
assert.match(fn, /try\s*\{[\s\S]*_noteStateProvider\s*\([\s\S]*catch[\s\S]*return\s+null/, 'must call the provider inside try/catch and return null on throw');
assert.match(fn, /state\s*!==\s*['"]hit['"]\s*&&\s*state\s*!==\s*['"]active['"]\s*&&\s*state\s*!==\s*['"]miss['"]/, 'must reject states other than hit/active/miss');
assert.match(fn, /Math\.max\(\s*0\s*,\s*Math\.min\(\s*1\s*,\s*raw\.alpha\s*\)\s*\)/, 'must clamp alpha to [0,1]');
+3 -3
View File
@@ -32,7 +32,7 @@ function extractBlock(src, signature) {
test('highway declares the paused-render throttle state', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /const\s+_PAUSED_FRAME_INTERVAL_MS\s*=\s*\d+/, 'missing _PAUSED_FRAME_INTERVAL_MS cap');
assert.match(src, /let\s+_lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
assert.match(src, /hwState\._lastPausedDrawAt\s*=\s*0/, 'missing _lastPausedDrawAt accumulator');
});
test('draw() throttles full renders while the audio clock is stalled', () => {
@@ -42,7 +42,7 @@ test('draw() throttles full renders while the audio clock is stalled', () => {
assert.match(fn, /_chartLastAdvanceAt/, 'throttle must key off _chartLastAdvanceAt (the advance timestamp)');
assert.match(fn, /_CHART_MAX_INTERP_MS/, 'throttle must reuse the _CHART_MAX_INTERP_MS pause threshold');
assert.match(fn, /_PAUSED_FRAME_INTERVAL_MS/, 'throttle must cap paused draws to _PAUSED_FRAME_INTERVAL_MS');
assert.match(fn, /_lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
assert.match(fn, /hwState\._lastPausedDrawAt\s*=\s*_nowP/, 'throttle must record the last paused draw time');
});
test('throttle runs after the ready gate, before bundle/draw', () => {
@@ -51,7 +51,7 @@ test('throttle runs after the ready gate, before bundle/draw', () => {
// Regex landmarks (not exact-string indexOf) so harmless spacing /
// semicolon changes don't break the ordering guard — matches the
// search-based style of the other highway source-guard tests.
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return;/);
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return;/);
const throttleIdx = fn.search(/_PAUSED_FRAME_INTERVAL_MS/);
const drawIdx = fn.search(/_renderer\.draw\s*\(/);
assert.ok(readyIdx !== -1, 'ready gate not found');
+1 -1
View File
@@ -22,7 +22,7 @@ test('getPhrases returns null when _phrases is falsy or empty', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(
src,
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*_phrases[^}]*return null/,
/getPhrases\s*\(\s*\)\s*\{[^}]*!\s*hwState\._phrases[^}]*return null/,
'getPhrases must return null when no phrase data is available',
);
});
@@ -44,9 +44,9 @@ test('_setRenderer captures the outgoing renderer before overwriting it', () =>
// prev must be captured BEFORE _destroyCurrentIfInited and the
// `_renderer = next` assignment, otherwise the swap detection below
// would always compare next against itself.
const prevIdx = fn.search(/const\s+prev\s*=\s*_renderer/);
const prevIdx = fn.search(/const\s+prev\s*=\s*hwState\._renderer/);
const destroyIdx = fn.search(/_destroyCurrentIfInited\(\)/);
const assignIdx = fn.search(/^\s*_renderer\s*=\s*next\s*;/m);
const assignIdx = fn.search(/^\s*hwState\._renderer\s*=\s*next\s*;/m);
assert.ok(prevIdx !== -1, 'must capture `const prev = _renderer`');
assert.ok(destroyIdx !== -1, 'must call _destroyCurrentIfInited');
assert.ok(assignIdx !== -1, 'must assign `_renderer = next`');
@@ -67,7 +67,7 @@ test('_setRenderer replaces the canvas on a context-type change OR a viz change'
);
assert.match(
fn,
/if\s*\(\s*nextType\s*!==\s*_currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
/if\s*\(\s*nextType\s*!==\s*hwState\._currentCanvasContextType\s*\|\|\s*_vizChanged\s*\)\s*\{\s*_replaceCanvas\(nextType\)/,
'replace guard must be `nextType !== _currentCanvasContextType || _vizChanged`',
);
});
+8 -8
View File
@@ -40,20 +40,20 @@ test('2D palette arrays are mutable (let) with frozen DEFAULT_* originals', () =
assert.match(src, /const\s+DEFAULT_STRING_COLORS\s*=/, 'DEFAULT_STRING_COLORS must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_DIM\s*=/, 'DEFAULT_STRING_DIM must exist for reset');
assert.match(src, /const\s+DEFAULT_STRING_BRIGHT\s*=/, 'DEFAULT_STRING_BRIGHT must exist for reset');
assert.match(src, /let\s+STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
assert.match(src, /let\s+STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
assert.match(src, /let\s+STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
assert.match(src, /hwState\.STRING_COLORS\s*=\s*DEFAULT_STRING_COLORS\.slice\(\)/, 'STRING_COLORS must be a mutable copy of the defaults');
assert.match(src, /hwState\.STRING_DIM\s*=\s*DEFAULT_STRING_DIM\.slice\(\)/, 'STRING_DIM must be a mutable copy of the defaults');
assert.match(src, /hwState\.STRING_BRIGHT\s*=\s*DEFAULT_STRING_BRIGHT\.slice\(\)/, 'STRING_BRIGHT must be a mutable copy of the defaults');
});
test('2D public API exposes getStringColors / setStringColors', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
assert.match(src, /getStringColors\s*\(\s*\)\s*\{\s*return\s+hwState\.STRING_COLORS\.slice\(\)/, 'getStringColors must return a copy');
const fn = extractBlock(src, 'setStringColors(arr)');
// Each provided index sets base + derived dim/bright; missing → default.
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
assert.match(fn, /STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
assert.match(fn, /STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
assert.match(fn, /STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*base/, 'setStringColors must set the base color');
assert.match(fn, /hwState\.STRING_DIM\[i\]\s*=\s*_darken\(/, 'setStringColors must derive the dim variant');
assert.match(fn, /hwState\.STRING_BRIGHT\[i\]\s*=\s*_lighten\(/, 'setStringColors must derive the bright variant');
assert.match(fn, /hwState\.STRING_COLORS\[i\]\s*=\s*DEFAULT_STRING_COLORS\[i\]/, 'setStringColors must restore defaults for missing/invalid indices');
});
// ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
+9 -9
View File
@@ -32,14 +32,14 @@ function extractBlock(src, signature) {
test('highway declares visibility state (_visibleOverride + _lastVisible)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /let\s+_visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
assert.match(src, /let\s+_lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
assert.match(src, /hwState\._visibleOverride\s*=\s*null/, 'missing _visibleOverride (override sentinel)');
assert.match(src, /hwState\._lastVisible\s*=\s*null/, 'missing _lastVisible (last-emitted state)');
});
test('_isHighwayVisible respects _visibleOverride and falls back to offsetParent', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _isHighwayVisible()');
assert.match(fn, /_visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
assert.match(fn, /hwState\._visibleOverride\s*!==\s*null/, 'must check the override before the DOM');
assert.match(fn, /canvas\.offsetParent\s*!==\s*null/, 'DOM fallback must use offsetParent !== null');
});
@@ -47,9 +47,9 @@ test('_emitVisibilityIfChanged is transition-only (no per-frame spam)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function _emitVisibilityIfChanged()');
// Must short-circuit when the current state equals the cached one.
assert.match(fn, /v\s*===\s*_lastVisible/, 'must compare current vs _lastVisible and bail when equal');
assert.match(fn, /v\s*===\s*hwState\._lastVisible/, 'must compare current vs _lastVisible and bail when equal');
// Must update the cache and emit the event with the documented payload shape.
assert.match(fn, /_lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
assert.match(fn, /hwState\._lastVisible\s*=\s*v/, 'must update _lastVisible after a transition');
assert.match(
fn,
/window\.feedBack\.emit\(\s*['"]highway:visibility['"][\s\S]*?visible:\s*v[\s\S]*?canvas/,
@@ -66,7 +66,7 @@ test('rAF draw() loop calls _emitVisibilityIfChanged and skips when hidden', ()
// transitions during loading/reconnect windows still propagate.
const emitIdx = fn.search(/_emitVisibilityIfChanged\(\)/);
const skipIdx = fn.search(/if\s*\(\s*!_rendering\s*\)\s*return/);
const readyIdx = fn.search(/if\s*\(\s*!ready\s*\)\s*return/);
const readyIdx = fn.search(/if\s*\(\s*!hwState\.ready\s*\)\s*return/);
const drawIdx = fn.search(/_renderer\.draw\(/);
assert.ok(emitIdx !== -1 && skipIdx !== -1 && readyIdx !== -1 && drawIdx !== -1, 'all four landmarks must be present');
assert.ok(emitIdx < readyIdx, 'emit must run BEFORE the !ready gate (transitions during loading must still fire)');
@@ -84,19 +84,19 @@ test('draw() keeps an active custom renderer painting through an override-hide (
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = extractBlock(src, 'function draw()');
// Single render decision drives both the perf-HUD reset and the gate.
assert.match(fn, /let\s+_rendering\s*=\s*_lastVisible/, 'must derive a single _rendering decision from _lastVisible');
assert.match(fn, /let\s+_rendering\s*=\s*hwState\._lastVisible/, 'must derive a single _rendering decision from _lastVisible');
// Assert the exact boolean RELATIONSHIP, not just the tokens (CodeRabbit):
// the exemption must AND together override-hide, an active custom renderer,
// and the canvas still in layout. A weakened guard (e.g. `||`, or a dropped
// offsetParent clause) must fail this — that's the regression being fixed.
assert.match(
fn,
/!_rendering\s*&&\s*_visibleOverride\s*===\s*false\s*&&\s*_renderer\s*!==\s*_defaultRenderer\s*&&\s*canvas\s*&&\s*canvas\.offsetParent\s*!==\s*null/,
/!_rendering\s*&&\s*hwState\._visibleOverride\s*===\s*false\s*&&\s*hwState\._renderer\s*!==\s*_defaultRenderer\s*&&\s*hwState\.canvas\s*&&\s*hwState\.canvas\.offsetParent\s*!==\s*null/,
'exemption must AND override-hide + active custom renderer + canvas-in-layout (genuine off-screen still pauses, #246)',
);
// Both the HUD reset and the gate key off _rendering, not _lastVisible,
// so the HUD doesn\'t churn while the custom renderer is actually drawing.
assert.match(fn, /_perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
assert.match(fn, /hwState\._perfHud\s*&&\s*\(\s*!_rendering/, 'perf-HUD reset must key off _rendering, not _lastVisible');
assert.match(fn, /if\s*\(\s*!_rendering\s*\)\s*return/, 'the draw gate must bail on !_rendering');
});
+83 -1
View File
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
const audio = {
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
getBackingDuration: () => Promise.resolve(180),
seekBacking: () => Promise.resolve(),
startBacking: () => Promise.resolve(),
@@ -152,6 +153,87 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
assert.equal(sb.__calls.loadBackingTrack.length, 0);
});
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'Windows Audio (Exclusive Mode)',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
assert.equal(sb.__calls.loadBackingTrack.length, 1);
});
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'ASIO',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
});
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: shared,
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
assert.equal(sb.__calls.loadBackingTrack.length, 0);
}
});
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
let type = 'Windows Audio (Exclusive Mode)';
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: () => type,
});
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
sb.window._juceMode = true;
sb.window._juceAudioUrl = url;
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
// Still exclusive: routing is consistent, no switch.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
// Device switched to shared mid-song: must return to HTML5.
type = 'Windows Audio';
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
});
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false;
@@ -0,0 +1,59 @@
// Guards the R0 module-migration loader change in static/app.js: a migrated
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
// injected as <script type="module"> so its screen.js `import './src/main.js'`
// graph loads, while classic plugins stay untouched.
//
// The injection is a single line inside the large async loadPlugins() closure
// (it depends on loadedScripts, _removePluginScriptTags, and the
// _loadingPluginId completion window), so a faithful behavioural harness would
// need to stub the whole loader. Instead this asserts the *structural*
// contract in source — the guard exists, is gated (not unconditional), and sits
// inside the screen.js injection block before appendChild. The behavioural proof
// is the R0 end-to-end live-edit check (a real module plugin booting in-browser).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const src = fs.readFileSync(APP_JS, 'utf8');
// Isolate the screen.js <script> injection block: from where its src is built
// to where the element is appended.
function injectionBlock() {
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
const end = src.indexOf('document.body.appendChild(script)', start);
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
return src.slice(start, end);
}
test('module plugins are injected as <script type="module">', () => {
const block = injectionBlock();
assert.match(
block,
/if\s*\(\s*plugin\.script_type\s*===\s*['"]module['"]\s*\)\s*script\.type\s*=\s*['"]module['"]\s*;/,
'expected a guarded `script.type = "module"` keyed on plugin.script_type === "module"',
);
});
test('the module type is gated, never set unconditionally', () => {
const block = injectionBlock();
// Every assignment of script.type in the block must be on the same line as
// the plugin.script_type guard (i.e. no bare `script.type = 'module'`).
for (const line of block.split('\n')) {
if (/script\.type\s*=/.test(line)) {
assert.match(line, /plugin\.script_type\s*===\s*['"]module['"]/,
`unguarded script.type assignment: ${line.trim()}`);
}
}
});
test('the module guard sits before appendChild, after the src assignment', () => {
const guardAt = src.indexOf('script.type = \'module\'');
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
assert.ok(guardAt > srcAt && guardAt < appendAt,
'the module guard must live inside the screen.js injection block');
});
+195
View File
@@ -0,0 +1,195 @@
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
//
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
// pushes it into the desktop engine's renderer bus while the output device is
// exclusive-style — the Phase 2 path for audio the native backing transport
// cannot carry. These tests extract that IIFE from source and exercise
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
// under exclusive output, disengagement on return to shared mode, inertness
// in shared mode / while the native transport owns the song, and the
// element-capture fallback.
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 APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFeederIIFE(src) {
const marker = '(function _installRendererBusFeeder() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
const tail = src.slice(i, i + 5);
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
return src.slice(start, i) + ')();';
}
function makeFakeContext(sampleRate = 48000) {
const ctx = {
sampleRate,
state: 'running',
sinkIdCalls: [],
destination: { isDestination: true },
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
resume() { this.state = 'running'; return Promise.resolve(); },
audioWorklet: { addModule: () => Promise.resolve() },
createMediaElementSource(el) {
this.mediaSourceEl = el;
return { connect() {}, disconnect() {} };
},
};
return ctx;
}
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
const calls = { setRendererBus: [], pushRendererAudio: [] };
const api = {
isAudioRunning: () => Promise.resolve(isAudioRunning()),
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
};
class FakeWorkletNode {
constructor() { this.port = { onmessage: null }; }
connect() {}
disconnect() {}
}
const sandbox = {
console: { log() {}, warn() {}, error() {} },
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
Blob: class { constructor() {} },
AudioWorkletNode: FakeWorkletNode,
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
WeakSet, WeakMap, Promise, Float32Array, Math,
setInterval: () => 0,
document: {
hidden: false,
addEventListener() {},
getElementById: () => sandbox.__audioEl,
},
__createdContexts: [],
__audioEl: { id: 'audio' },
__calls: calls,
window: null,
};
sandbox.window = {
feedBackDesktop: { audio: api },
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
_juceMode: false,
_currentSongAudio: null,
feedBack: { stems: {} },
};
sandbox.globalThis = sandbox;
const src = fs.readFileSync(APP_JS, 'utf8');
vm.createContext(sandbox);
vm.runInContext(extractFeederIIFE(src), sandbox);
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
'feeder must expose window._reevaluateRendererBus');
return sandbox;
}
function makeStemsGraph() {
return {
context: makeFakeContext(),
masterNode: { connect() {}, disconnect() {} },
};
}
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
const sb = makeSandbox({ exclusive: () => true });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
});
test('output returns to shared → bus disabled, sink restored', async () => {
let excl = true;
const sb = makeSandbox({ exclusive: () => excl });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
excl = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
});
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
const sb = makeSandbox({ exclusive: () => false });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
});
test('element song + exclusive → element captured into bus', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
sb.window._juceMode = false;
await sb.window._reevaluateRendererBus();
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
});
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
sb.window._juceMode = true;
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
});
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
const sb = makeSandbox({ exclusive: () => true });
const g1 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g1;
await sb.window._reevaluateRendererBus();
const g2 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g2;
await sb.window._reevaluateRendererBus();
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
});
test('engine stops → bus disabled', async () => {
let running = true;
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
running = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
});
@@ -0,0 +1,191 @@
// Regression test for feedBack#800: tuner injectPlayerButton() must anchor the
// injected button to a DIRECT-child button of #player-controls. The old
// `controls.querySelector('button:last-child')` could resolve to a NESTED
// button, and `controls.insertBefore(btn, nestedButton)` then throws
// NotFoundError — which propagated out of the player-screen transition and
// aborted its render.
//
// Same isolation strategy as the core tests/js suite: extract the real function
// from source with extractFunction() and run it in a vm sandbox over a small
// but faithful DOM model. The model's insertBefore() enforces the real DOM
// invariant (reference node must be a direct child, else NotFoundError), and
// querySelector() implements the exact semantics of both the old
// (`button:last-child`) and new (`:scope > button:last-of-type`) selectors — so
// reverting the fix makes this test throw.
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 { extractFunction } = require('../../../js/test_utils');
const UI_JS = path.join(__dirname, '..', '..', '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
const SRC = fs.readFileSync(UI_JS, 'utf8');
const FN_SRC = extractFunction(SRC, 'function injectPlayerButton(');
// ── Minimal, faithful DOM model ──────────────────────────────────────────────
class El {
constructor(tag, id = '') {
this.tagName = tag.toUpperCase();
this.id = id;
this.children = [];
this.parentNode = null;
this.textContent = '';
this.title = '';
this.onclick = null;
}
appendChild(node) {
node.parentNode = this;
this.children.push(node);
return node;
}
insertBefore(node, ref) {
const idx = this.children.indexOf(ref);
if (ref == null || idx === -1) {
// Faithful to the browser: ref must be a direct child.
const e = new Error(
"Failed to execute 'insertBefore' on 'Node': The node before which the "
+ 'new node is to be inserted is not a child of this node.'
);
e.name = 'NotFoundError';
throw e;
}
node.parentNode = this;
this.children.splice(idx, 0, node);
return node;
}
querySelector(sel) {
if (sel === ':scope > button:last-of-type') {
// Last direct-child <button>.
const btns = this.children.filter((c) => c.tagName === 'BUTTON');
return btns.length ? btns[btns.length - 1] : null;
}
if (sel === 'button:last-child') {
// First descendant <button> (document order) that is the last child
// of its own parent — the buggy legacy anchor.
let found = null;
const walk = (node) => {
for (const c of node.children) {
if (found) return;
const isLast = c.parentNode.children[c.parentNode.children.length - 1] === c;
if (c.tagName === 'BUTTON' && isLast) { found = c; return; }
walk(c);
}
};
walk(this);
return found;
}
throw new Error(`unhandled selector in stub: ${sel}`);
}
}
function findById(node, id) {
if (!node) return null;
if (node.id === id) return node;
for (const c of node.children) {
const r = findById(c, id);
if (r) return r;
}
return null;
}
// Run the extracted injectPlayerButton() against a given controls tree.
// Returns { controls, threw }.
function run({ controls, isV3 = false, slot = null }) {
const roots = [controls, slot].filter(Boolean);
const document = {
getElementById(id) {
if (id === 'player-controls') return controls;
for (const r of roots) {
const hit = findById(r, id);
if (hit) return hit;
}
return null;
},
createElement(tag) { return new El(tag); },
};
const window = {
feedBack: isV3
? { uiVersion: 'v3', ui: { playerControlSlot: () => slot } }
: { uiVersion: 'v2' },
tuner: { toggle: () => {} },
};
const sandbox = {
window,
document,
Element: El,
updatePlayerButton: () => {},
};
vm.createContext(sandbox);
let threw = null;
try {
vm.runInContext(FN_SRC + '\nglobalThis.__run = injectPlayerButton;\n__run();', sandbox);
} catch (e) {
threw = e;
}
return { controls, slot, threw };
}
// ── Tests ────────────────────────────────────────────────────────────────────
test('does not throw when the last button is nested (feedBack#800 repro)', () => {
// controls > div.transport > [play, close]; `close` is button:last-child of
// the div but NOT a direct child of controls. The old anchor threw here.
const controls = new El('div', 'player-controls');
const transport = new El('div');
transport.appendChild(new El('button', 'play'));
transport.appendChild(new El('button', 'close'));
controls.appendChild(transport);
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
// With no direct-child button, it appends to controls.
assert.ok(findById(controls, 'btn-tuner-player'), 'tuner button was added');
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
});
test('inserts before the last direct-child button when one exists', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('button', 'play'));
controls.appendChild(new El('button', 'close'));
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
const ids = controls.children.map((c) => c.id);
// tuner button sits immediately before the last direct-child button.
assert.deepEqual(ids, ['play', 'btn-tuner-player', 'close']);
});
test('appends when controls has no buttons at all', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('span'));
const { threw } = run({ controls });
assert.equal(threw, null, threw && threw.message);
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
});
test('is idempotent — a second call does not add a duplicate', () => {
const controls = new El('div', 'player-controls');
controls.appendChild(new El('button', 'close'));
run({ controls });
run({ controls });
const injected = controls.children.filter((c) => c.id === 'btn-tuner-player');
assert.equal(injected.length, 1);
});
test('v3 mounts into the plugin-control slot and never uses the legacy anchor', () => {
const slot = new El('div', 'plugin-control-slot');
// A nested button in the slot would trip the legacy anchor; v3 must ignore it.
const inner = new El('div');
inner.appendChild(new El('button', 'other'));
slot.appendChild(inner);
const controls = new El('div', 'player-controls');
const { threw } = run({ controls, isV3: true, slot });
assert.equal(threw, null, threw && threw.message);
assert.ok(findById(slot, 'btn-tuner-player'), 'tuner button mounted into the slot');
assert.equal(findById(controls, 'btn-tuner-player'), null, 'not mounted into #player-controls');
});
+153
View File
@@ -0,0 +1,153 @@
"""The router seam (`appstate.py`).
The load-bearing assertion here is `test_server_wires_the_seam`: that `server`
actually calls `appstate.configure(...)`. Every other test in this file would
pass just fine against a seam nothing ever wires up the same class of silent
no-op that bit the frontend refactor twice when a scripted `setHostHooks` edit
stopped matching its anchor. Unit tests cannot see wiring unless you make them
look at it.
"""
import importlib
import sys
import pytest
import appstate
def _close_server_dbs(mod):
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(mod, "_join_background_db_threads", lambda: None)()
conn.close()
ae_conn = getattr(getattr(mod, "audio_effect_mappings", None), "conn", None)
if ae_conn is not None:
ae_conn.close()
@pytest.fixture()
def isolated_server(tmp_path, monkeypatch):
"""A freshly imported `server` bound to a throwaway CONFIG_DIR.
Importing `server` constructs `MetadataDB` + `AudioEffectsMappingDB` at
module level, so it MUST be re-imported under a patched CONFIG_DIR an
unguarded `import server` would create/mutate the developer's real
`~/.local/share/feedback` databases. Same idiom as the other ~49
server-importing suites.
Teardown restores the appstate slots as well as closing the connections:
leaving `appstate.meta_db` published but pointing at a closed sqlite handle
would hand a later test (or router) a live-looking, dead singleton.
"""
previous = (appstate.meta_db, appstate.audio_effect_mappings)
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod
_close_server_dbs(mod)
# Leave no half-torn-down `server` behind: the next fixture re-imports it.
sys.modules.pop("server", None)
appstate.configure(meta_db=previous[0], audio_effect_mappings=previous[1])
def test_import_is_side_effect_free():
"""`import appstate` must construct nothing and touch no disk.
This is why the ~49 fixtures that `sys.modules.pop("server")` and re-import
(to rebuild `meta_db` under a patched CONFIG_DIR) keep working untouched:
server owns construction, appstate only mirrors it. A singleton *owned*
here would survive that pop and go stale.
"""
sys.modules.pop("appstate", None)
fresh = importlib.import_module("appstate")
try:
assert fresh.meta_db is None
assert fresh.audio_effect_mappings is None
finally:
sys.modules["appstate"] = appstate
def test_configure_publishes_known_slots():
sentinel = object()
original = appstate.meta_db
try:
appstate.configure(meta_db=sentinel)
assert appstate.meta_db is sentinel
finally:
appstate.configure(meta_db=original)
def test_configure_is_idempotent():
"""server re-imports call configure() again; the last write must win."""
original = appstate.meta_db
try:
appstate.configure(meta_db="first")
appstate.configure(meta_db="second")
assert appstate.meta_db == "second"
finally:
appstate.configure(meta_db=original)
def test_configure_rejects_an_unknown_slot():
"""A typo'd or stale keyword must raise, not silently create a global that
nothing reads. A seam whose wiring can no-op undetected is worse than none."""
with pytest.raises(TypeError, match="unknown slot"):
appstate.configure(met_db="typo")
assert not hasattr(appstate, "met_db")
def test_late_bound_read_sees_a_later_configure():
"""Routers must read `appstate.meta_db`, never `from appstate import meta_db`.
This pins the property that makes that rule work."""
def router_style_read():
return appstate.meta_db # module attribute, resolved at call time
original = appstate.meta_db
try:
appstate.configure(meta_db="before")
assert router_style_read() == "before"
appstate.configure(meta_db="after")
assert router_style_read() == "after"
finally:
appstate.configure(meta_db=original)
def test_server_wires_the_seam(isolated_server):
"""The one that catches a dropped `appstate.configure(...)` call.
Identity, not truthiness, so a stray re-assignment or a half-applied edit
fails here rather than in some router months later.
"""
assert appstate.meta_db is isolated_server.meta_db
assert appstate.audio_effect_mappings is isolated_server.audio_effect_mappings
assert appstate.meta_db is not None
def test_reimporting_server_republishes_the_fresh_singletons(
isolated_server, tmp_path, monkeypatch
):
"""The 49-fixture contract, exercised end to end.
Those fixtures `sys.modules.pop("server")` + re-import to rebuild `meta_db`
under a new CONFIG_DIR, and know nothing about appstate. So the seam must
re-publish on that second import. This is the test that would fail if
`appstate` ever *owned* the singletons: a module-level `meta_db` there
survives the pop and the assertions below would still see the FIRST DB.
"""
first_db = isolated_server.meta_db
assert appstate.meta_db is first_db
assert str(tmp_path) in first_db.db_path
second_config = tmp_path / "second"
monkeypatch.setenv("CONFIG_DIR", str(second_config))
sys.modules.pop("server", None)
second_server = importlib.import_module("server")
try:
assert second_server.meta_db is not first_db # genuinely rebuilt
assert str(second_config) in second_server.meta_db.db_path
assert appstate.meta_db is second_server.meta_db # ...and re-published
assert appstate.audio_effect_mappings is second_server.audio_effect_mappings
finally:
_close_server_dbs(second_server)
sys.modules.pop("server", None)
+18 -16
View File
@@ -12,6 +12,8 @@ tests/test_art_layer.py.
"""
import importlib
import enrichment
from routers import art
import io as _io
import sys
@@ -119,8 +121,8 @@ def caa_index(server, monkeypatch):
calls.append(release_id)
return indexes.get(release_id) # unknown release → None (a CAA 404)
fake.calls, fake.indexes = calls, indexes
monkeypatch.setattr(server, "_caa_release_index", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_caa_release_index", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
@@ -251,7 +253,7 @@ def test_caa_candidates_capped_at_12(server, client, caa_index):
caa_index.indexes["rel-big"] = {
"images": [_img(300 + i, front=(i == 0)) for i in range(20)]}
_match_row(server, "a.sloppak", release_id="rel-big")
assert len(_caa(_get(client))) == server._ART_PICKER_MAX_CAA == 12
assert len(_caa(_get(client))) == art._ART_PICKER_MAX_CAA == 12
def test_demo_mode_blocks_candidates(server, client, monkeypatch):
@@ -274,10 +276,10 @@ def test_malicious_release_id_rejected_no_fetch_no_write(server, caa_index):
"""A crafted release id (path traversal) never matches _CAA_ID_RE, so it
yields no images, opens no socket, and writes no cache file inside the
art dir or anywhere else."""
art_dir = server._enrichment_art_dir()
art_dir = enrichment._enrichment_art_dir()
before = set(art_dir.glob("*"))
assert not server._CAA_ID_RE.match("../../etc/x")
assert server._caa_index_cached("../../etc/x") == []
assert not enrichment._CAA_ID_RE.match("../../etc/x")
assert enrichment._caa_index_cached("../../etc/x") == []
assert caa_index.calls == [] # the seam was never asked
assert set(art_dir.glob("*")) == before # nothing written
# And nothing landed at the traversal target beside the cache dir either.
@@ -340,10 +342,10 @@ def test_fetch_art_url_follows_redirects_validating_each_hop(server, monkeypatch
return _FakeResp(200, chunks=[b"IMGDATA"])
monkeypatch.setattr(requests, "get", fake_get)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
data = art._fetch_art_url("https://coverartarchive.example/release/x/front-500")
assert data == b"IMGDATA"
assert fetched == ["https://coverartarchive.example/release/x/front-500",
"https://archive.example/img.png"]
@@ -354,18 +356,18 @@ def test_fetch_art_url_blocks_redirect_to_internal(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
302, {"Location": "http://internal.example/x.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
server._fetch_art_url("https://public.example/x.png")
art._fetch_art_url("https://public.example/x.png")
def test_fetch_art_url_redirect_budget(server, monkeypatch):
import requests
monkeypatch.setattr(requests, "get", lambda url, **kw: _FakeResp(
307, {"Location": "https://public.example/next.png"}))
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(art, "_url_host_is_internal", lambda u: False)
with pytest.raises(enrichment.EnrichTransportError):
art._fetch_art_url("https://public.example/x.png")
+30 -28
View File
@@ -7,6 +7,8 @@ here opens a socket, and the offline default is itself asserted.
"""
import importlib
import enrichment
from routers import art
import io as _io
import sys
@@ -121,7 +123,7 @@ def test_bad_upload_rejected(server, client):
def test_art_url_fetches_and_overrides(server, client, monkeypatch):
make_sloppak(server, "a.sloppak", with_cover=True)
monkeypatch.setattr(server, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
monkeypatch.setattr(art, "_fetch_art_url", lambda url: png_bytes((9, 9, 9)))
body = client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/cover.png"}).json()
assert body == {"ok": True, "kind": "png"}
@@ -138,7 +140,7 @@ def test_art_url_validation(server, client, monkeypatch):
# Oversize → 400 (the seam raises ValueError at the cap).
def _huge(url):
raise ValueError("image larger than 10 MB")
monkeypatch.setattr(server, "_fetch_art_url", _huge)
monkeypatch.setattr(art, "_fetch_art_url", _huge)
assert client.post("/api/song/a.sloppak/art/url",
json={"url": "https://example.com/x.png"}).status_code == 400
@@ -176,15 +178,15 @@ def caa(server, monkeypatch):
calls.append(release_id)
return art.get(release_id)
fake.calls, fake.art = calls, art
monkeypatch.setattr(server, "_caa_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_caa_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
def test_caa_fetch_fills_missing_art(server, client, caa):
make_sloppak(server, "a.sloppak") # no pack art
_match_row(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] == "caa"
assert row["art_cache_path"] and row["art_cache_path"].endswith("caa_rel-1.jpg")
@@ -193,7 +195,7 @@ def test_caa_fetch_fills_missing_art(server, client, caa):
assert r.headers["content-type"] == "image/jpeg"
# Settled: the next pass never re-fetches.
n = len(caa.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(caa.calls) == n
@@ -204,7 +206,7 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
_match_row(server, "haspack.sloppak")
_match_row(server, "b.sloppak") # same release as c
_match_row(server, "c.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("haspack.sloppak")["art_state"] == "pack"
assert server.meta_db.get_enrichment("b.sloppak")["art_state"] == "caa"
assert server.meta_db.get_enrichment("c.sloppak")["art_state"] == "caa"
@@ -214,10 +216,10 @@ def test_caa_skips_pack_art_and_dedupes_by_release(server, caa):
def test_caa_404_marks_none(server, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak", release_id="rel-missing")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "none"
n = len(caa.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(caa.calls) == n # never re-hammered
@@ -226,13 +228,13 @@ def test_caa_transport_error_leaves_row_unevaluated(server, caa, monkeypatch):
_match_row(server, "a.sloppak")
def _down(release_id):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_caa_http_get", _down)
server._background_enrich()
raise enrichment.EnrichTransportError("down")
monkeypatch.setattr(enrichment, "_caa_http_get", _down)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# Network back → next pass completes it.
monkeypatch.setattr(server, "_caa_http_get", caa)
server._background_enrich()
monkeypatch.setattr(enrichment, "_caa_http_get", caa)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
@@ -240,22 +242,22 @@ def test_offline_default_skips_art_worker(server, monkeypatch):
"""Under the plain test env the whole art phase is skipped with the rest
of the network work."""
calls = []
monkeypatch.setattr(server, "_caa_http_get", lambda rid: calls.append(rid))
monkeypatch.setattr(enrichment, "_caa_http_get", lambda rid: calls.append(rid))
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
def test_lru_prune_evicts_oldest_and_resets_rows(server, caa, monkeypatch):
monkeypatch.setattr(server, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
monkeypatch.setattr(enrichment, "_CAA_CACHE_CAP_BYTES", 1) # everything over cap
make_sloppak(server, "a.sloppak", title="One")
make_sloppak(server, "b.sloppak", title="Two")
caa.art["rel-2"] = png_bytes((1, 1, 1))
_match_row(server, "a.sloppak", release_id="rel-1")
_match_row(server, "b.sloppak", release_id="rel-2")
server._background_enrich()
enrichment._background_enrich()
# With a 1-byte cap every fetch immediately evicts — the rows that pointed
# at evicted files were reset to unevaluated.
caa_files = list(server.ART_CACHE_DIR.glob("caa_*.jpg"))
@@ -282,13 +284,13 @@ def test_delete_override_restores_caa_fallback(server, client, caa):
_match_row(server, "a.sloppak")
# Pin an override BEFORE the art worker runs → the pass stamps art_state='user'.
client.post("/api/song/a.sloppak/art/upload", json={"image": b64(png_bytes())})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "user"
# Remove it → the row resets to unevaluated…
assert client.delete("/api/art/a.sloppak/override").json()["removed"]
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
# …and the next pass fetches + serves the release's front cover.
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
r = client.get("/api/song/a.sloppak/art")
assert r.status_code == 200
@@ -302,7 +304,7 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
assert server._art_override_paths("ghost.sloppak") == []
# Oversize decoded payload → 400 (bounds the base64 upload path).
make_sloppak(server, "a.sloppak")
huge = b64(b"\x00" * (server._ART_URL_MAX_BYTES + 1))
huge = b64(b"\x00" * (art._ART_URL_MAX_BYTES + 1))
assert client.post("/api/song/a.sloppak/art/upload",
json={"image": huge}).status_code == 400
@@ -310,10 +312,10 @@ def test_upload_rejects_unknown_song_and_oversize(server, client):
def test_fetch_art_url_blocks_internal_hosts(server):
"""The SSRF guard refuses loopback / link-local / private targets before
any request is made (the real seam, not the faked one)."""
assert server._url_host_is_internal("http://127.0.0.1/x.png")
assert server._url_host_is_internal("http://localhost/x.png")
assert server._url_host_is_internal("http://169.254.169.254/latest/meta-data")
assert server._url_host_is_internal("http://10.0.0.5/x.png")
assert server._url_host_is_internal("http://[::1]/x.png")
assert server._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
assert not server._url_host_is_internal("http://93.184.216.34/x.png") # public literal
assert art._url_host_is_internal("http://127.0.0.1/x.png")
assert art._url_host_is_internal("http://localhost/x.png")
assert art._url_host_is_internal("http://169.254.169.254/latest/meta-data")
assert art._url_host_is_internal("http://10.0.0.5/x.png")
assert art._url_host_is_internal("http://[::1]/x.png")
assert art._url_host_is_internal("http://nonexistent.invalid/x.png") # unresolvable → closed
assert not art._url_host_is_internal("http://93.184.216.34/x.png") # public literal
+5 -4
View File
@@ -10,7 +10,7 @@ Two halves, mirroring the design's split:
* GET /api/artist/{name}/links + POST .../links/refresh the lazy, cached,
opt-in external-links layer. The HTTP transport is a fake over
`server._mb_http_get` (the ONE network seam same pattern as
`enrichment._mb_http_get` (the ONE network seam same pattern as
tests/test_mb_enrichment.py), so nothing here opens a socket. Covers the
url-rel whitelist mapping, the http(s) scheme gate (a hostile javascript:
resource never reaches a link slot), cache-hit second calls making no
@@ -19,6 +19,7 @@ Two halves, mirroring the design's split:
"""
import importlib
import enrichment
import json
import sys
from urllib.parse import quote
@@ -88,7 +89,7 @@ class FakeMBArtist:
def __call__(self, path, params):
if self.raise_transport:
raise self._srv.EnrichTransportError("fake network down")
raise enrichment.EnrichTransportError("fake network down")
self.calls.append((path, dict(params)))
if path == f"artist/{MBID}":
return self.doc
@@ -100,8 +101,8 @@ def mb_artist(server, monkeypatch):
"""Install the fake transport AND enable the network flag (the test env
disables it by default see test_links_offline_returns_empty)."""
fake = FakeMBArtist(server)
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
return fake
+90 -4
View File
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
assert "error" in r.json()
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
# ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
def test_rejects_sloppak_url(client_and_server):
@pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
# Module-level sloppak source-dir cache survives re-import; clear it so a
# prior test's filename key can't shadow this test's temp DLC_DIR.
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def _make_sloppak(dlc, name="song.sloppak"):
"""Create a minimal directory-form sloppak with a full-mix file."""
pak = dlc / name
(pak / "stems").mkdir(parents=True)
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
return pak
def test_sloppak_url_resolves_to_local_path(dlc_client):
tc, _server, dlc = dlc_client
pak = _make_sloppak(dlc)
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc, name="My Song.sloppak")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
def test_sloppak_url_rel_traversal_is_403(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc)
(dlc / "secret.txt").write_text("top secret")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_filename_traversal_is_403(dlc_client):
tc, _server, _dlc = dlc_client
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
client, _ = client_and_server
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"})
assert r.status_code == 400
r = client.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
)
assert r.status_code == 404
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
def test_rejects_empty_url(client_and_server):
+4 -3
View File
@@ -4,6 +4,7 @@ contents). The refresh flow reuses the P8 fake-transport pattern — nothing
here opens a socket."""
import importlib
import enrichment
import sys
import pytest
@@ -85,9 +86,9 @@ def test_refresh_resets_even_a_manual_pin_and_rematches(server, client, monkeypa
"status": "Official", "date": "1990-09-24",
"release-group": {"primary-type": "Album"}}],
}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["mb_recording_id"] == "rec-new"
+33 -32
View File
@@ -5,6 +5,7 @@ contracts it will inherit: rename-survivable idempotent hashing, manual rows
never auto-reset, never purged on rescan, purged on explicit delete."""
import importlib
import enrichment
import sys
import pytest
@@ -58,7 +59,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
_put(server, "a.archive")
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# stubbed → still unscanned → still pending (the matcher hasn't run)
server._background_enrich()
enrichment._background_enrich()
assert [r["filename"] for r in server.meta_db.enrichment_pending()] == ["a.archive"]
# a matched row with the CURRENT hash is settled…
h = server.meta_db.enrichment_content_hash("Artist", "Song", "", 100)
@@ -76,7 +77,7 @@ def test_pending_covers_new_unscanned_and_changed(server):
def test_hash_change_resets_matched_but_never_manual(server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'matched' WHERE filename = 'a.archive'")
@@ -86,7 +87,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
# identity edits…
_put(server, "a.archive", title="Song v2")
_put(server, "b.archive", title="Other v2")
server._background_enrich()
enrichment._background_enrich()
a = server.meta_db.get_enrichment("a.archive")
b = server.meta_db.get_enrichment("b.archive")
# …drop a stale MATCH back to unscanned with the fresh hash
@@ -99,7 +100,7 @@ def test_hash_change_resets_matched_but_never_manual(server):
def test_failed_rows_not_requeued_by_pending(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state = 'failed' WHERE filename = 'a.archive'")
@@ -113,7 +114,7 @@ def test_failed_rows_not_requeued_by_pending(server):
def test_enrich_pass_stamps_every_song(server):
for i in range(5):
_put(server, f"s{i}.archive", title=f"Song {i}")
server._background_enrich()
enrichment._background_enrich()
for i in range(5):
row = server.meta_db.get_enrichment(f"s{i}.archive")
assert row is not None
@@ -126,7 +127,7 @@ def test_enrich_pass_stamps_every_song(server):
def test_rescan_never_purges_enrichment(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
server.meta_db.delete_missing(set()) # file vanished from a scan snapshot
assert server.meta_db.get_enrichment("a.archive") is not None # row survives
# …and is invisible in the read-time-filtered counts
@@ -138,7 +139,7 @@ def test_rescan_never_purges_enrichment(server):
def test_status_endpoint_counts(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
body = client.get("/api/enrichment/status").json()
assert body["states"] == {"unscanned": 2}
assert body["total_songs"] == 2
@@ -147,7 +148,7 @@ def test_status_endpoint_counts(client, server):
def test_art_cache_dir_created(server):
d = server._enrichment_art_dir()
d = enrichment._enrichment_art_dir()
assert d.is_dir()
assert d.name == "art_cache"
@@ -156,7 +157,7 @@ def test_art_cache_dir_created(server):
def test_states_for_returns_only_known_filenames(server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
assert got == {"a.archive": "unscanned"} # unknown filename absent
assert server.meta_db.enrichment_states_for([]) == {}
@@ -165,7 +166,7 @@ def test_states_for_returns_only_known_filenames(server):
def test_states_endpoint(client, server):
_put(server, "a.archive")
_put(server, "b.archive", title="Other")
server._background_enrich()
enrichment._background_enrich()
body = client.post("/api/enrichment/states",
json={"filenames": ["a.archive", "zzz.missing"]}).json()
assert body["states"] == {"a.archive": "unscanned"}
@@ -175,7 +176,7 @@ def test_states_endpoint(client, server):
def test_status_exposes_progress_fields(client, server):
_put(server, "a.archive")
server._background_enrich()
enrichment._background_enrich()
body = client.get("/api/enrichment/status").json()
for k in ("total", "matched", "current", "cancelling"):
assert k in body
@@ -186,7 +187,7 @@ def test_cancel_is_noop_when_idle(client, server):
body = client.post("/api/enrichment/cancel").json()
assert body == {"ok": True, "was_running": False}
# A no-op must not arm the flag (which would then poison the next pass).
assert server._enrich_cancel.is_set() is False
assert enrichment._enrich_cancel.is_set() is False
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
@@ -195,28 +196,28 @@ def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
# Force the matcher path on (the test env is offline by default) and stub the
# per-song matcher so nothing touches the network — it just trips Stop after
# the first song, exactly as the /cancel route would mid-pass.
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
calls = []
def fake_enrich_one(row, **_kw):
calls.append(row["filename"])
server._enrich_cancel.set()
enrichment._enrich_cancel.set()
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
server._enrich_cancel.clear()
server._background_enrich()
monkeypatch.setattr(enrichment, "_enrich_one", fake_enrich_one)
enrichment._enrich_cancel.clear()
enrichment._background_enrich()
# The loop checks cancel BEFORE each song, so exactly one is processed before
# it breaks — not the whole 4-row queue.
assert calls == ["s0.archive"]
assert server._enrich_status["total"] == 4
assert server._enrich_status["matched"] == 1
assert enrichment._enrich_status["total"] == 4
assert enrichment._enrich_status["matched"] == 1
def test_rematch_requeues_visible_but_skips_manual(server, client):
_put(server, "a.archive") # will be 'matched'
_put(server, "b.archive", title="Other") # will be 'failed'
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
server._background_enrich()
enrichment._background_enrich()
with server.meta_db._lock:
server.meta_db.conn.execute(
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
@@ -240,7 +241,7 @@ def test_rematch_requeues_visible_but_skips_manual(server, client):
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
def test_filename_artist_title_parse(server):
f = server._artist_title_from_filename
f = enrichment._artist_title_from_filename
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
@@ -255,18 +256,18 @@ def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Tatsuro"))
server._enrich_one(row)
enrichment._enrich_one(row)
# the blank pack artist was replaced by the filename-derived identity for
# the search (this is exactly what rescues the 'failed' pile)
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
@@ -276,18 +277,18 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
"arrangements": [{"name": "Lead", "index": 0}]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(enrichment, "_manifest_exact_ids", lambda fn: {})
seen = {}
def fake_search(artist, title, limit=8):
seen["artist"], seen["title"] = artist, title
return []
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
monkeypatch.setattr(enrichment, "_mb_search_recordings", fake_search)
row = next(r for r in server.meta_db.enrichment_pending()
if r["filename"].startswith("Weird"))
server._enrich_one(row)
enrichment._enrich_one(row)
# a pack that DOES carry an artist keeps it — the filename is never consulted
assert seen == {"artist": "Real Artist", "title": "Real Title"}
@@ -295,7 +296,7 @@ def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
def test_kick_clears_a_stale_cancel(server):
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
# flag so the fresh pass isn't aborted the instant it checks.
server._enrich_cancel.set()
server._kick_enrich()
enrichment._enrich_cancel.set()
enrichment._kick_enrich()
server._join_background_db_threads()
assert server._enrich_cancel.is_set() is False
assert enrichment._enrich_cancel.is_set() is False
+2 -1
View File
@@ -14,6 +14,7 @@ back-compat for `.sloppak` libraries or stop accepting the new `.feedpak`:
from __future__ import annotations
import importlib
from routers import settings as settings_router
import io
import sys
import zipfile
@@ -242,7 +243,7 @@ def test_settings_dlc_count_includes_both_suffixes(tmp_path, settings_server):
(dlc / "c.FEEDPAK").write_bytes(b"") # case-insensitive (suffix.lower())
(dlc / "notes.txt").write_bytes(b"") # ignored
result = settings_server.save_settings({"dlc_dir": str(dlc)})
result = settings_router.save_settings({"dlc_dir": str(dlc)})
assert "error" not in result, result
# save_settings joins its notices into a single ``message`` string.

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