Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 f0223ba007 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>
2026-07-11 11:28:30 +02:00
byrongamatosandClaude Opus 4.8 9093230320 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>
2026-07-11 11:22:31 +02:00
byrongamatosandClaude Opus 4.8 5ce9f3768f 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>
2026-07-11 11:07:26 +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
82 changed files with 12468 additions and 9427 deletions
+77
View File
@@ -7,6 +7,83 @@ 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), 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.
+25
View File
@@ -37,6 +37,31 @@ 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
+6 -2
View File
@@ -54,8 +54,12 @@ without a *signed* exemption" is unenforceable.
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
core `static/app.js` (11,821) · `static/highway.js` (4,154, whole file) · `server.py`
(13,948) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
(5,988 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
extractions and fifteen `routers/` modules; 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
+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
+122
View File
@@ -0,0 +1,122 @@
"""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
_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",
})
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
+10 -3
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)))
+4373
View File
File diff suppressed because it is too large Load Diff
+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/``.
"""
+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}
+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
+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)
+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.3",
"version": "3.31.5",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+89 -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
@@ -14664,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;
@@ -14691,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;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
"version": "0.2.0",
"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,
+162 -34
View File
@@ -813,20 +813,36 @@
_writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' }));
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input, because _midiConnect persists the pick and that would
// overwrite the user's saved device on a transient multi-device unplug
// (the original returns on replug and reconnects then).
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
if (!inputs.length) return;
const saved = _readSavedPick();
// Explicit "None" opt-out.
if (saved && saved.id === '' && saved.name === '') return;
// Prefer the globally-unique logicalSourceKey, then the legacy bare
// sourceId, then case-insensitive name (Chrome on Linux regenerates ids
// per page load), then first non-loopback.
// Pure decision logic (exported via __test): pick which device to
// auto-connect to from the current source list, the domain-wide selection
// (`globalKey`, from Settings → Input Setup), and this plugin's own legacy
// saved pick. Returns null for "connect to nothing" (explicit None opt-out,
// or the configured device currently absent during hotplug recovery).
//
// The domain-wide selection is the SOURCE OF TRUTH (checked first): a device
// configured globally must never be overridden by a stale plugin-local pick
// or an arbitrary first-device fallback — that override was the bug. The
// local pick is retained only as a fallback BELOW the global (and for
// name-recovery when the global's logicalSourceKey went stale, e.g. a
// browser that regenerates MIDI port ids across reloads). Auto-connect no
// longer writes the local pick, so it only ever holds a value an explicit
// selection put there (or a stale one from a pre-fix build — the global
// still wins over it).
function _pickMidiTarget(inputs, saved, globalKey, allowFallback) {
if (!inputs.length) return null;
const notBlocked = (i) => !!i && !_MIDI_BLOCKLIST_RE.test(i.name || '');
// Explicit "None" opt-out (set only via the device-select API).
if (saved && saved.id === '' && saved.name === '') return null;
// 1. Domain-wide selection (Settings → Input Setup) — source of truth.
if (globalKey) {
const g = inputs.find(i => i.key === globalKey);
if (notBlocked(g)) return g;
}
// 2. Legacy plugin-local pick, as a fallback below the global. Prefer the
// globally-unique logicalSourceKey, then the legacy bare sourceId, then
// case-insensitive name (Chrome on Linux regenerates ids per page load).
let target = null;
if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null;
if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null;
@@ -834,23 +850,50 @@
const n = saved.name.toLowerCase();
target = inputs.find(i => (i.name || '').toLowerCase() === n) || null;
}
// Never honour a saved pick that's a loopback / "Midi Through" port — it
// carries no device input, so a stale pick silently eats every note. The
// saved-pick lookups above bypass the block-list; re-apply it here.
if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null;
if (!target) {
// Skip the substitute ONLY when a saved pick exists but is currently
// absent (recovery: preserve it, don't clobber on a transient unplug).
// With no saved pick at all, a fallback is the intended first-hotplug
// auto-connect — allow it even in recovery.
const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name));
if (!allowFallback && hasSavedPick) return;
target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0];
}
// Never honour a saved pick that resolves to a loopback / "Midi Through"
// port — it carries no device input, so it silently eats every note.
if (target && !notBlocked(target)) target = null;
if (target) return target;
// 3. Nothing configured resolved to a present device. In recovery
// (allowFallback=false) with a configured preference — a global pick or a
// saved pick — that's currently absent, preserve it rather than switching
// to an arbitrary device on a transient multi-device unplug. With no
// preference at all, a first-device grab is the intended first-hotplug
// auto-connect, allowed even in recovery.
const hasPreference = !!(globalKey || (saved && (saved.key || saved.id || saved.name)));
if (!allowFallback && hasPreference) return null;
// Connect to nothing rather than a loopback: if every present device is
// blocklisted, a first-device grab would attach to a "Midi Through"/IAC
// port that carries no input and silently eats every note.
return inputs.find(notBlocked) || null;
}
function _midiAutoConnect(allowFallback) {
// Recovery (sources-changed after unplug) passes false: never switch to a
// fallback input on a transient multi-device unplug (the configured
// device returns on replug and reconnects then). Auto-connect is
// non-persisting (persist omitted → false): it opens the resolved device
// for this session WITHOUT writing the plugin-local pick or the shared
// domain selection, so opening this highway can't clobber the user's
// globally-configured device.
if (allowFallback === undefined) allowFallback = true;
const inputs = _midiSources();
const saved = _readSavedPick();
const mi = _mi();
const globalKey = mi && typeof mi.getSelected === 'function' ? mi.getSelected() : null;
const target = _pickMidiTarget(inputs, saved, globalKey, allowFallback);
if (!target) return;
_midiConnect(target.id, target.name, target.key);
}
async function _midiConnect(id, name, key) {
// `persist` gates the two preference writes. Only an EXPLICIT device
// selection (the device-select API) persists: it writes the plugin-local
// pick AND the shared domain selection (`mi.select`, so the user's choice
// becomes the global default). Auto-connect and programmatic opens pass
// falsy — they open the resolved device for this session only, never
// touching either store, so they can't clobber a globally-configured device.
async function _midiConnect(id, name, key, persist) {
// Capture our generation AFTER _midiDetach()'s own bump, so a later
// detach (device removal / new connect / opt-out) reliably supersedes us.
_midiDetach();
@@ -861,7 +904,7 @@
for (const inst of _instances) {
if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld();
}
_writeSavedPick(id || '', name || '', key || '');
if (persist) _writeSavedPick(id || '', name || '', key || '');
const mi = _mi();
if ((id || key) && mi) {
// Prefer the globally-unique logicalSourceKey so two providers that
@@ -874,13 +917,19 @@
const lkey = src.key || ('web-midi::' + src.id);
_midiInput = { id: src.id, name: src.name, key: lkey };
_midiJustConnected = true;
// Only an explicit selection writes the shared global default;
// open takes the logicalSourceKey directly, so select() is not
// needed to open — it exists purely to set the global. Persist it
// BEFORE the no-instance early return so a settings-panel pick with
// no live renderer still updates the shared default (best-effort:
// a select hiccup must not abort the connect).
if (persist) { try { await mi.select(lkey); } catch (_) { /* best-effort */ } }
// No live renderer to consume OR release a session — don't hold one
// open (settings-only ensure-init, or the last instance was torn
// down during async discovery). The pick is saved; a later renderer
// mount re-runs auto-connect and opens for real, releasing on destroy.
// down during async discovery). A later renderer mount re-runs
// auto-connect and opens for real, releasing on destroy.
if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; }
try {
await mi.select(lkey);
const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey });
// A newer _midiConnect (device switch / None / replug) ran while
// we awaited open — discard this stale session so we don't wire a
@@ -1039,10 +1088,11 @@
window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; };
window.keysH3dSetMidiInput = function (id) {
// `id` may be a logicalSourceKey (new host calls) or a legacy sourceId.
// Explicit user selection → persist (local pick + shared global default).
const src = id
? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id))
: null;
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '');
_midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : '', true);
return true;
};
window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; };
@@ -1547,6 +1597,8 @@
function _aiOpen(req) {
// Opening a MIDI source connects the corresponding Web MIDI input.
// Programmatic open (audio-input source.open) — non-persisting: it must
// not rewrite the user's saved pick or the shared global default.
const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey));
const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs()
if (idx == null || idx >= inputs.length) {
@@ -1600,6 +1652,41 @@
_aiRegisteredCount = 0;
}
/* ======================================================================
* 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 100% 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
* ====================================================================== */
@@ -1849,6 +1936,18 @@
_rigOut.lookZ = _camPreset.lookZ;
return _rigOut;
}
/**
* 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-key approach glow: a key lights in its pitch-class color ONLY while a
// note is heading for it, ramping up the closer that note gets to the hit-line.
const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light
@@ -3259,7 +3358,33 @@
}
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
{
const r = _rig();
let _cx = _camX, _cy = r.y * K * _camZoom, _cz = r.z * K * _camZoom;
let _lx = _camX, _ly = r.lookY * K * _camZoom, _lz = r.lookZ * K * _camZoom;
// Camera Director free-cam offsets (per-panel-aware), layered on top
// of the auto-framing so pan/zoom-follow still works. Dolly/height/
// orbit act on the camera-from-target vector; pan/pitch shift the
// look target. NaN-safe; null/disabled bridge → stock.
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);
}
for (const km of keyMeshes.values()) km.userData.glow = 0;
for (const { mesh, note, len, label } of noteMeshes) {
@@ -3960,6 +4085,8 @@
};
// Pure data-layer + scoring hooks for headless tests.
window.slopsmithViz_keys_highway_3d.__test = {
_resolveFreeCam,
_ssApi,
beatDurSec,
flattenNotation,
keyRange,
@@ -3993,6 +4120,7 @@
FX_DEFAULTS,
FX_RANGES,
_classifyTiming,
_pickMidiTarget,
};
// Headless verification hook: lets Playwright drive synthetic note-ons
@@ -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');
});
+87 -4
View File
@@ -6,6 +6,16 @@
//
// 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
@@ -21,6 +31,9 @@ for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replac
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;
@@ -69,6 +82,64 @@ async function clientMetrics() {
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',
@@ -86,9 +157,21 @@ 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`;
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\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);
+228 -8233
View File
File diff suppressed because it is too large Load Diff
+323 -10
View File
@@ -4864,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,
@@ -4904,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) {
@@ -5123,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.
@@ -5142,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
@@ -5163,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) {
@@ -5197,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
+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);
+951 -895
View File
File diff suppressed because it is too large Load Diff
+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.
+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',
);
});
+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',
);
});
+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;
+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');
});
+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)
+10 -9
View File
@@ -12,6 +12,7 @@ tests/test_art_layer.py.
"""
import importlib
import enrichment
import io as _io
import sys
@@ -119,8 +120,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
@@ -274,10 +275,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,7 +341,7 @@ 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(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: (checked.append(u), False)[1])
data = server._fetch_art_url("https://coverartarchive.example/release/x/front-500")
@@ -354,7 +355,7 @@ 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(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal",
lambda u: "internal" in u)
with pytest.raises(ValueError):
@@ -365,7 +366,7 @@ 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(enrichment, "_enrich_network_enabled", lambda: True)
monkeypatch.setattr(server, "_url_host_is_internal", lambda u: False)
with pytest.raises(server.EnrichTransportError):
with pytest.raises(enrichment.EnrichTransportError):
server._fetch_art_url("https://public.example/x.png")
+19 -18
View File
@@ -7,6 +7,7 @@ here opens a socket, and the offline default is itself asserted.
"""
import importlib
import enrichment
import io as _io
import sys
@@ -176,15 +177,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 +194,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 +205,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 +215,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 +227,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 +241,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 +283,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
+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
+3 -2
View File
@@ -6,6 +6,7 @@ song (delete_song). Locks pin a field against a later auto-match.
"""
import importlib
import enrichment
import sys
import pytest
@@ -149,7 +150,7 @@ def test_locked_fields_reader(server):
def test_compose_lock_filter_strips_locked_cand_keys(server):
f = server._compose_lock_filter(None, {"artist", "year"})
f = enrichment._compose_lock_filter(None, {"artist", "year"})
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
"year": "1990", "album": "A", "genres": ["rock"]}
out = f(cand)
@@ -158,7 +159,7 @@ def test_compose_lock_filter_strips_locked_cand_keys(server):
# …identity + unlocked display fields survive
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
# no locks → base filter returned unchanged (zero-copy common path)
assert server._compose_lock_filter(None, set()) is None
assert enrichment._compose_lock_filter(None, set()) is None
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
+4 -1
View File
@@ -121,7 +121,10 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
assert [ebeat.get("time") for ebeat in ebeats] == expected_times
# Compare by value, not string: beat times are written at 6-decimal
# (microsecond) precision so the derived per-bar tempo matches the authored
# GP value, but these tests only care about the spacing, not the format.
assert [float(ebeat.get("time")) for ebeat in ebeats] == [float(t) for t in expected_times]
assert [ebeat.get("measure") for ebeat in ebeats] == [
"1",
*["-1"] * (len(expected_times) - 1),
+8 -3
View File
@@ -16,6 +16,8 @@ import pytest
import yaml
from fastapi.testclient import TestClient
from routers.ws_highway import _sanitize_authors
# ── _sanitize_authors unit tests ────────────────────────────────────────────
@@ -35,7 +37,7 @@ def server_mod(monkeypatch, tmp_path):
def test_sanitize_authors_valid(server_mod):
out = server_mod._sanitize_authors(
out = _sanitize_authors(
{
"authors": [
{"name": "Azure", "role": "charter", "email": "a@b.c", "url": "x"},
@@ -53,7 +55,7 @@ def test_sanitize_authors_valid(server_mod):
def test_sanitize_authors_skips_malformed(server_mod):
out = server_mod._sanitize_authors(
out = _sanitize_authors(
{
"authors": [
{"name": ""}, # blank name → skipped
@@ -69,7 +71,7 @@ def test_sanitize_authors_skips_malformed(server_mod):
@pytest.mark.parametrize("manifest", [None, {}, {"authors": None}, {"authors": "x"}, "nope"])
def test_sanitize_authors_absent_or_nonlist(server_mod, manifest):
assert server_mod._sanitize_authors(manifest) == []
assert _sanitize_authors(manifest) == []
# ── song_info WS integration ────────────────────────────────────────────────
@@ -118,6 +120,9 @@ def make_client(tmp_path, monkeypatch):
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
# ws_highway reads the cache dir through the appstate seam now.
import appstate as _appstate
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
@@ -96,6 +96,9 @@ def make_client(tmp_path, monkeypatch):
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
# ws_highway reads the cache dir through the appstate seam now.
import appstate as _appstate
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
+3
View File
@@ -123,6 +123,9 @@ def make_client(tmp_path, monkeypatch):
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
monkeypatch.setattr(server, "SLOPPAK_CACHE_DIR", tmp_path / "cache")
# ws_highway reads the cache dir through the appstate seam now.
import appstate as _appstate
monkeypatch.setattr(_appstate, "sloppak_cache_dir", tmp_path / "cache")
return server
(tmp_path / "dlc").mkdir()
+84
View File
@@ -0,0 +1,84 @@
"""Concurrent unnamed loop saves must get unique names.
`save_loop` auto-names an unnamed loop `Loop {count+1}`. If the `COUNT(*)` runs
outside the DB lock (as it did before the fix), two simultaneous unnamed POSTs
read the same count and both mint the same name. A `threading.Barrier` releases
all workers into `save_loop` at once to force that interleave.
Single-user app, so this race is unlikely in practice but the fix is one lock
scope, and the test pins it.
"""
import threading
import pytest
import appstate
from metadata_db import MetadataDB
from routers import loops
@pytest.fixture()
def meta_db(tmp_path):
prev = appstate.meta_db
db = MetadataDB(tmp_path)
appstate.configure(meta_db=db)
yield db
db.conn.close()
appstate.configure(meta_db=prev)
def test_concurrent_unnamed_saves_get_unique_names(meta_db):
workers = 16
barrier = threading.Barrier(workers)
names, errors = [], []
lock = threading.Lock()
def save():
try:
barrier.wait() # release all at once
r = loops.save_loop({"filename": "song.feedpak", "start": 0.0, "end": 1.0})
with lock:
names.append(r["name"])
except Exception as e: # noqa: BLE001 — surface any thread error
with lock:
errors.append(e)
threads = [threading.Thread(target=save) for _ in range(workers)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors
assert len(names) == workers
# The load-bearing assertion: no two auto-named loops collide.
assert len(set(names)) == workers, f"duplicate loop names: {sorted(names)}"
# And the DB agrees — every insert landed.
stored = meta_db.conn.execute(
"SELECT COUNT(*) FROM loops WHERE filename = ?", ("song.feedpak",)
).fetchone()[0]
assert stored == workers
def test_list_and_save_do_not_error_under_interleave(meta_db):
"""A read overlapping writes must not raise (shared connection, one lock)."""
stop = threading.Event()
errors = []
def reader():
while not stop.is_set():
try:
loops.list_loops("song.feedpak")
except Exception as e: # noqa: BLE001
errors.append(e)
r = threading.Thread(target=reader)
r.start()
try:
for i in range(40):
loops.save_loop({"filename": "song.feedpak", "name": f"n{i}", "start": 0.0, "end": 1.0})
finally:
stop.set()
r.join()
assert not errors, errors
+53 -52
View File
@@ -1,6 +1,6 @@
"""Server-level tests for the P8 MusicBrainz matcher + Match-Review flow.
The HTTP transport is a fake installed over `server._mb_http_get` the ONE
The HTTP transport is a fake installed over `enrichment._mb_http_get` the ONE
seam enrichment uses to reach the network so nothing here ever opens a
socket. The offline default is itself under test: without explicitly
enabling the network flag, a pass must skip matching entirely (pytest can
@@ -8,6 +8,7 @@ never hit MusicBrainz, whatever a test triggers).
"""
import importlib
import enrichment
import sys
import pytest
@@ -50,7 +51,7 @@ class FakeMB:
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 == "recording":
return self.search_response
@@ -71,8 +72,8 @@ def mb(server, monkeypatch):
disables it by default see test_offline_default_skips_matching)."""
fake = FakeMB()
fake._srv = 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
@@ -111,8 +112,8 @@ def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
return {"recordings": []}
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
cands = enrichment._mb_search_recordings("Junko Ohashi", "Telephone Number")
assert len(cands) == 1
assert len(calls) == 2 # strict first, then the loose retry
assert calls[0].startswith("recording:") # strict is the field-phrase form
@@ -128,8 +129,8 @@ def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
calls.append(params.get("query", ""))
return {"recordings": [mb_doc()]}
monkeypatch.setattr(server, "_mb_http_get", _routed)
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
cands = enrichment._mb_search_recordings("AC/DC", "Thunderstruck")
assert len(cands) == 1
assert len(calls) == 1
@@ -147,10 +148,10 @@ def test_artist_aliases_fetched_and_cached(server, monkeypatch):
return {"sort-name": "Ohashi, Junko",
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
monkeypatch.setattr(server, "_mb_http_get", fake)
names = server._mb_artist_aliases(_AID)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
names = enrichment._mb_artist_aliases(_AID)
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
server._mb_artist_aliases(_AID) # cached → no second request
enrichment._mb_artist_aliases(_AID) # cached → no second request
assert len(calls) == 1
@@ -158,8 +159,8 @@ def test_artist_aliases_rejects_bad_id(server, monkeypatch):
def boom(path, params):
raise AssertionError("must not fetch for a non-UUID id")
monkeypatch.setattr(server, "_mb_http_get", boom)
assert server._mb_artist_aliases("not-a-uuid") == []
monkeypatch.setattr(enrichment, "_mb_http_get", boom)
assert enrichment._mb_artist_aliases("not-a-uuid") == []
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
@@ -176,9 +177,9 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
artist="大橋純子", artist_id=_AID)]} # loose hit
monkeypatch.setattr(server, "_mb_http_get", _routed)
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
assert row["match_state"] == "matched"
@@ -190,10 +191,10 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
monkeypatch.setattr(server, "_mb_http_get",
monkeypatch.setattr(enrichment, "_mb_http_get",
lambda path, params: {"recordings": [mb_doc()]})
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
server._background_enrich()
monkeypatch.setattr(enrichment, "_enrich_network_enabled", lambda: True)
enrichment._background_enrich()
row = server.meta_db.get_enrichment("x.sloppak")
assert row["match_state"] == "matched" # still matches (identity applies)…
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
@@ -208,9 +209,9 @@ def test_offline_default_skips_matching(server, monkeypatch):
but never matches even with a transport installed."""
fake = FakeMB()
fake._srv = server
monkeypatch.setattr(server, "_mb_http_get", fake)
monkeypatch.setattr(enrichment, "_mb_http_get", fake)
_put(server, "a.sloppak")
server._background_enrich()
enrichment._background_enrich()
assert fake.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
@@ -218,15 +219,15 @@ def test_offline_default_skips_matching(server, monkeypatch):
def test_real_transport_refuses_when_offline(server):
"""_mb_http_get itself raises (before any socket) when the network is
disabled defence in depth under pytest."""
with pytest.raises(server.EnrichTransportError):
server._mb_http_get("recording", {"query": "x"})
with pytest.raises(enrichment.EnrichTransportError):
enrichment._mb_http_get("recording", {"query": "x"})
def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak", title="Other Song")
mb.raise_transport = True
server._background_enrich()
enrichment._background_enrich()
for fn in ("a.sloppak", "b.sloppak"):
row = server.meta_db.get_enrichment(fn)
assert row["match_state"] == "unscanned"
@@ -234,7 +235,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
# Network comes back → the next kick matches both.
mb.raise_transport = False
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -243,7 +244,7 @@ def test_transport_error_pauses_pass_without_burning_attempts(server, mb):
def test_high_confidence_auto_matches_and_settles(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
@@ -256,13 +257,13 @@ def test_high_confidence_auto_matches_and_settles(server, mb):
assert row["genres"] == ["hard rock"]
# Settled: another pass makes NO further network calls…
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
# …until the identity changes, which re-matches.
_put(server, "a.sloppak", title="Back in Black")
mb.search_response = {"recordings": [mb_doc(rid="rec-2", title="Back in Black",
album="Back in Black", date="1980-07-25")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["mb_recording_id"] == "rec-2"
@@ -271,7 +272,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
# Partial artist agreement → medium confidence.
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "review"
assert row["match_source"] == "text"
@@ -281,7 +282,7 @@ def test_medium_confidence_goes_to_review_not_canonical(server, mb):
assert row["candidates"] and row["candidates"][0]["recording_id"] == "rec-1"
# A review row is settled while its identity is unchanged — no re-query.
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
@@ -289,14 +290,14 @@ def test_low_confidence_fails_with_backoff(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc(rid="rec-x", title="Sunrise",
artist="Norah Jones")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "failed"
assert row["attempts"] == 1
assert row["last_attempt_at"] is not None
# Immediately after, the backoff hasn't elapsed → no retry, no network.
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 1
# Rewind the clock two hours → eligible again, attempts increments.
@@ -304,7 +305,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
server.meta_db.conn.execute(
"UPDATE song_enrichment SET last_attempt_at = last_attempt_at - 7200")
server.meta_db.conn.commit()
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n + 1
assert server.meta_db.get_enrichment("a.sloppak")["attempts"] == 2
@@ -312,7 +313,7 @@ def test_low_confidence_fails_with_backoff(server, mb):
def test_no_results_fails(server, mb):
_put(server, "a.sloppak")
mb.search_response = {"recordings": []}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "failed"
@@ -322,7 +323,7 @@ def test_cache_hit_copies_match_without_network(server, mb):
_put(server, "a.sloppak")
_put(server, "b.sloppak") # identical identity → same content_hash
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert len(mb.search_calls) == 1 # ONE search covered both charts
a = server.meta_db.get_enrichment("a.sloppak")
b = server.meta_db.get_enrichment("b.sloppak")
@@ -347,7 +348,7 @@ def test_manifest_mbid_tier0(server, mb):
_write_sloppak_manifest(server, "a.sloppak", f"mbid: {mbid}\n")
_put(server, "a.sloppak")
mb.recording_lookups[mbid] = mb_doc(rid=mbid)
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "mbid"
@@ -360,7 +361,7 @@ def test_manifest_isrc_tier1(server, mb):
_write_sloppak_manifest(server, "a.sloppak", "isrc: AUAP09000045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
@@ -374,7 +375,7 @@ def test_manifest_isrc_display_hyphens_stripped(server, mb):
_write_sloppak_manifest(server, "a.sloppak", "isrc: AU-AP0-90-00045\n")
_put(server, "a.sloppak")
mb.isrc_lookups["AUAP09000045"] = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "isrc"
@@ -387,7 +388,7 @@ def test_bad_manifest_mbid_falls_through_to_text(server, mb):
_put(server, "a.sloppak")
mb.recording_lookups.clear() # lookup 404s (typo'd manifest)
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["match_source"] == "text"
@@ -401,7 +402,7 @@ def test_manual_never_overwritten_by_matcher(server, mb):
"a.sloppak", {"recording_id": "user-pick", "title": "Thunderstruck",
"artist": "AC/DC"}, source="search")
mb.search_response = {"recordings": [mb_doc(rid="machine-pick")]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "manual"
assert row["mb_recording_id"] == "user-pick"
@@ -419,7 +420,7 @@ def _seed_review(server, mb, fn="a.sloppak", title="Thunderstruck (v2)"):
# legitimately copies an earlier row instead of running the text tiers).
_put(server, fn, title=title, artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment(fn)["match_state"] == "review"
@@ -457,11 +458,11 @@ def test_review_reject_route_never_retries(server, mb, client):
assert row["match_source"] == "rejected"
# Rejected rows are excluded from the retry backoff forever…
n = len(mb.calls)
server._background_enrich()
enrichment._background_enrich()
assert len(mb.calls) == n
# …but an identity edit re-queues (the user fixed the metadata).
_put(server, "a.sloppak", artist="AC/DC")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
# Rejecting a manual row is refused.
r = client.post("/api/enrichment/review/a.sloppak/reject")
@@ -502,8 +503,8 @@ def test_search_proxy(server, mb, client, monkeypatch):
assert body["candidates"][0]["score"] > 0.9
# Transport failure surfaces as 503, not a 500.
def _down(path, params):
raise server.EnrichTransportError("down")
monkeypatch.setattr(server, "_mb_http_get", _down)
raise enrichment.EnrichTransportError("down")
monkeypatch.setattr(enrichment, "_mb_http_get", _down)
r = client.get("/api/enrichment/search", params={"title": "x"})
assert r.status_code == 503
@@ -523,8 +524,8 @@ def test_match_facet_filters_grid_and_stats(server, mb, client, monkeypatch):
if "revsong" in q:
return {"recordings": [mb_doc(rid="rec-r", title="Revsong")]}
return {"recordings": []}
monkeypatch.setattr(server, "_mb_http_get", _routed)
server._background_enrich()
monkeypatch.setattr(enrichment, "_mb_http_get", _routed)
enrichment._background_enrich()
# Pendsong got failed by the pass (no results); reset it to unscanned to
# represent the not-yet-scanned band.
with server.meta_db._lock:
@@ -566,14 +567,14 @@ def test_auto_threshold_setting_moves_the_auto_review_boundary(server, mb, clien
client.post("/api/settings", json={"enrich_auto_threshold": 0.95})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0)
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
# Lower the bar to the default 0.90 → an identity edit re-queues, and the
# same 0.90-scored candidate now auto-applies.
client.post("/api/settings", json={"enrich_auto_threshold": 0.9})
_put(server, "a.sloppak", title="Highway to Hell", artist="AC/DC",
year="", duration=0, album="Different Album")
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert abs(row["match_score"] - 0.9) < 1e-6
@@ -583,7 +584,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
client.post("/api/settings", json={"enrich_enabled": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert mb.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "unscanned"
# Manual search/fix stays available while the background matcher is off.
@@ -591,7 +592,7 @@ def test_enrich_enabled_setting_gates_background_matching(server, mb, client):
assert r.status_code == 200
# Re-enable → the next pass matches.
client.post("/api/settings", json={"enrich_enabled": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -625,7 +626,7 @@ def test_review_queue_orders_missing_data_first(server, mb, client):
_seed_review(server, mb, fn="aa.sloppak", title="Thunderstruck (v2)")
_put(server, "zz.sloppak", title="Thunderstruck (Live)",
artist="AC/DC ft Nobody", album="", year="")
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("zz.sloppak")["match_state"] == "review"
songs = client.get("/api/enrichment/review").json()["songs"]
assert [s["filename"] for s in songs] == ["zz.sloppak", "aa.sloppak"]
+112
View File
@@ -0,0 +1,112 @@
"""Guard: every first-party module `server.py` imports must be one the packagers copy.
feedback-desktop's `scripts/bundle-slopsmith.sh` copies a **hardcoded list** from
core into the app bundle `server.py`, `VERSION`, `lib/`, `data/`, `static/`,
`plugins/__init__.py`. A new root-level module (say `appstate.py`) ships fine in
Docker, imports fine under pytest, and is then *silently dropped* from the
packaged desktop app, which dies at startup with:
File ".../Resources/slopsmith/server.py", line 71, in <module>
import appstate
ModuleNotFoundError: No module named 'appstate'
That shipped once. This test is why it can't ship twice: it walks `server.py`'s
module-level imports, keeps the ones that resolve inside this repo, and asserts
each lives under a directory every packaging path already copies wholesale.
If you add a first-party module for `server.py`, put it in `lib/` the one core
directory the Dockerfile (`COPY lib/`), `docker-compose.yml`, and the desktop
bundler (`cp -r lib`) all copy, and that all three put on `sys.path`. If you
genuinely need it at the repo root, you must also teach `bundle-slopsmith.sh`,
the `Dockerfile`, `.dockerignore`, and `docker-compose.yml` about it and then
update `BUNDLED_ROOTS` below.
"""
import ast
import importlib.util
import pathlib
import pytest
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
# Directories every packaging path copies wholesale, plus the files copied by name.
BUNDLED_ROOTS = ("lib", "plugins", "data", "static")
BUNDLED_FILES = ("server.py", "main.py")
def _server_toplevel_imports():
"""Module names imported at `server.py`'s top level (not inside a function)."""
tree = ast.parse((REPO_ROOT / "server.py").read_text())
names = set()
for node in tree.body: # top level only — lazy imports are fine
if isinstance(node, ast.Import):
names.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
names.add(node.module.split(".")[0])
return sorted(names)
# `spec.origin` is not always a path: built-in and frozen stdlib modules use
# these sentinels. `Path("frozen").resolve()` would land inside the repo and
# report `os` as first-party — so filter them before touching the filesystem.
_NON_PATH_ORIGINS = {"built-in", "frozen", "namespace"}
def _first_party_origin(name):
"""Path of `name` if it resolves inside this repo, else None (stdlib/site-package)."""
try:
spec = importlib.util.find_spec(name)
except (ImportError, ValueError):
return None
if spec is None:
return None
if spec.origin and spec.origin not in _NON_PATH_ORIGINS:
origin = pathlib.Path(spec.origin)
else:
# Namespace/frozen: fall back to the first search location, if any.
locations = list(getattr(spec, "submodule_search_locations", None) or [])
if not locations:
return None
origin = pathlib.Path(locations[0])
if not origin.is_absolute():
return None # a sentinel, not a real path
origin = origin.resolve()
try:
origin.relative_to(REPO_ROOT)
except ValueError:
return None # outside the repo → a dependency
return origin
@pytest.mark.parametrize("name", _server_toplevel_imports())
def test_server_import_is_bundled(name):
origin = _first_party_origin(name)
if origin is None:
return # stdlib or an installed dependency
rel = origin.relative_to(REPO_ROOT)
if rel.as_posix() in BUNDLED_FILES or rel.parts[0] in BUNDLED_ROOTS:
return
raise AssertionError(
f"server.py imports `{name}` from {rel}, which no packager copies.\n"
f"The desktop bundler (scripts/bundle-slopsmith.sh) copies only "
f"{BUNDLED_FILES} and {BUNDLED_ROOTS}/, so the packaged app would die "
f"at startup with ModuleNotFoundError: No module named '{name}'.\n"
f"Move it under lib/, or teach bundle-slopsmith.sh + Dockerfile + "
f".dockerignore + docker-compose.yml about it and update BUNDLED_ROOTS."
)
def test_the_seam_and_routers_live_under_lib():
"""Pin the two that already caused a shipped break."""
for name in ("appstate", "routers"):
origin = _first_party_origin(name)
assert origin is not None, f"{name} does not resolve inside the repo"
assert origin.relative_to(REPO_ROOT).parts[0] == "lib", (
f"{name} resolved to {origin.relative_to(REPO_ROOT)}; it must live "
f"under lib/ or the packaged desktop app will not ship it"
)
+107
View File
@@ -0,0 +1,107 @@
"""Playlist cover upload: client vs server errors, no temp litter, no leak.
The pre-split handler caught decode AND persistence failures in one `except`,
returned 400 for both, and echoed the exception (`Invalid image: {e}`) so a
disk/permission failure was mislabeled as a client error and could leak a
filesystem path. These pin the split.
"""
import base64
import io
import pytest
from fastapi.testclient import TestClient
from PIL import Image
import appstate
from metadata_db import MetadataDB
from routers import playlists
@pytest.fixture()
def client(tmp_path):
prev = (appstate.meta_db, appstate.config_dir)
db = MetadataDB(tmp_path)
appstate.configure(meta_db=db, config_dir=tmp_path)
app_ = __import__("fastapi").FastAPI()
app_.include_router(playlists.router)
try:
yield TestClient(app_), tmp_path
finally:
db.conn.close()
appstate.configure(meta_db=prev[0], config_dir=prev[1])
def _png_b64():
buf = io.BytesIO()
Image.new("RGB", (8, 8), (10, 20, 30)).save(buf, "PNG")
return base64.b64encode(buf.getvalue()).decode()
def _make_playlist(client):
return client.post("/api/playlists", json={"name": "P"}).json()["id"]
def test_valid_cover_saves_and_leaves_no_temp(client):
c, tmp_path = client
pid = _make_playlist(c)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert r.status_code == 200
cover_dir = tmp_path / "playlist_covers"
assert (cover_dir / f"{pid}.png").exists()
# The atomic-publish temp file must not linger.
assert not list(cover_dir.glob("*.tmp"))
def test_undecodable_image_is_a_400_without_leaking(client):
c, _ = client
pid = _make_playlist(c)
# valid base64, not a valid image
r = c.post(f"/api/playlists/{pid}/cover", json={"image": base64.b64encode(b"not an image").decode()})
assert r.status_code == 400
body = r.json()["error"]
assert body == "Invalid image" # generic — no exception detail echoed
assert "playlist_covers" not in body # no filesystem path leak
def test_save_failure_is_a_500_not_a_400(client, monkeypatch):
"""A persistence failure (here: Image.save raising) must be a logged 500,
not a 400 the whole point of the decode/persist split. Negative-checks
against the pre-fix behavior, which returned 400 for exactly this."""
c, tmp_path = client
pid = _make_playlist(c)
payload = _png_b64() # build BEFORE patching save
def boom(self, fp, *a, **k):
raise OSError("disk full")
monkeypatch.setattr(Image.Image, "save", boom)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
assert r.status_code == 500
assert "disk full" not in r.json()["error"] # no internal detail
assert not list((tmp_path / "playlist_covers").glob("*.tmp")) # temp cleaned up
def test_temp_creation_failure_is_a_500(client, monkeypatch):
"""mkstemp raising (unwritable dir / full disk) must hit the same logged 500
path as a save failure, not escape as an unhandled server error."""
import tempfile as _tempfile
c, _ = client
pid = _make_playlist(c)
payload = _png_b64()
def boom(*a, **k):
raise OSError("read-only file system")
monkeypatch.setattr(_tempfile, "mkstemp", boom)
r = c.post(f"/api/playlists/{pid}/cover", json={"image": payload})
assert r.status_code == 500
assert "read-only" not in r.json()["error"]
def test_upload_to_missing_playlist_is_404(client):
c, _ = client
r = c.post("/api/playlists/9999/cover", json={"image": _png_b64()})
assert r.status_code == 404
+6 -2
View File
@@ -6,6 +6,10 @@ import sys
import pytest
from fastapi.testclient import TestClient
# Moved to routers/playlists in R3; reads appstate.config_dir, which the
# `server` fixture configures via CONFIG_DIR before this is called.
from routers.playlists import _playlist_cover_path
@pytest.fixture()
def server(tmp_path, monkeypatch, isolate_logging):
@@ -168,6 +172,6 @@ def test_cover_rejects_non_string_image_with_400_not_500(client):
def test_deleting_playlist_removes_custom_cover(client, server):
pid = client.post("/api/playlists", json={"name": "Doomed"}).json()["id"]
client.post(f"/api/playlists/{pid}/cover", json={"image": _png_b64()})
assert server._playlist_cover_path(pid).exists()
assert _playlist_cover_path(pid).exists()
client.delete(f"/api/playlists/{pid}")
assert not server._playlist_cover_path(pid).exists()
assert not _playlist_cover_path(pid).exists()
+16 -15
View File
@@ -8,6 +8,7 @@ flag is only force-enabled where a test needs the pipeline to run.
"""
import importlib
import enrichment
import io as _io
import sys
@@ -57,8 +58,8 @@ class FakeMB:
@pytest.fixture()
def mb(server, monkeypatch):
fake = FakeMB()
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
@@ -116,13 +117,13 @@ def test_musicbrainz_source_off_stamps_without_matching(server, mb, client):
client.post("/api/settings", json={"enrich_src_musicbrainz": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert mb.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "unscanned" # hash stamped, no match
# Re-enabling picks the same row up on the next pass.
client.post("/api/settings", json={"enrich_src_musicbrainz": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "matched"
@@ -133,7 +134,7 @@ def test_field_toggles_strip_auto_applied_fields(server, mb, client):
"enrich_apply_genres": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] == "AC/DC"
@@ -150,7 +151,7 @@ def test_names_toggle_keeps_ids_and_other_fields(server, mb, client):
client.post("/api/settings", json={"enrich_apply_names": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_artist"] is None
@@ -170,7 +171,7 @@ def test_review_accept_applies_all_fields_despite_toggles(server, mb, client):
# Partial artist agreement → review tier (candidates stored unfiltered).
_put(server, "a.sloppak", artist="AC/DC ft Nobody")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["match_state"] == "review"
r = client.post("/api/enrichment/review/a.sloppak/accept",
json={"recording_id": "rec-1"})
@@ -192,21 +193,21 @@ def test_reenabling_field_backfills_matched_row(server, mb, client):
client.post("/api/settings", json={"enrich_apply_year": False})
_put(server, "a.sloppak")
mb.search_response = {"recordings": [mb_doc()]}
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] is None # suppressed
assert row["apply_mask"] == "enrich_apply_year" # …and remembered
# Re-enable → next pass re-queues and backfills the year (hash unchanged).
client.post("/api/settings", json={"enrich_apply_year": True})
server._background_enrich()
enrichment._background_enrich()
row = server.meta_db.get_enrichment("a.sloppak")
assert row["match_state"] == "matched"
assert row["canon_year"] == "1990" # backfilled
assert row["apply_mask"] in (None, "") # fully applied now
# Converged: a fully-applied row is not re-queued again.
assert server.meta_db.enrichment_pending(
allowed_keys=frozenset(server._ENRICH_APPLY_FIELDS)) == []
allowed_keys=frozenset(enrichment._ENRICH_APPLY_FIELDS)) == []
def test_partial_match_is_not_a_cache_donor(server):
@@ -271,8 +272,8 @@ def caa(server, monkeypatch):
calls.append(release_id)
return art.get(release_id)
fake.calls = calls
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
@@ -280,13 +281,13 @@ def test_caa_source_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_src_caa": False})
server._background_enrich()
enrichment._background_enrich()
assert caa.calls == []
row = server.meta_db.get_enrichment("a.sloppak")
assert row["art_state"] is None # not forfeited, just skipped
# Re-enable → the same row is picked up.
client.post("/api/settings", json={"enrich_src_caa": True})
server._background_enrich()
enrichment._background_enrich()
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] == "caa"
@@ -294,7 +295,7 @@ def test_apply_art_toggle_gates_art_fetch(server, client, caa):
make_sloppak(server, "a.sloppak")
_match_row(server, "a.sloppak")
client.post("/api/settings", json={"enrich_apply_art": False})
server._background_enrich()
enrichment._background_enrich()
assert caa.calls == []
assert server.meta_db.get_enrichment("a.sloppak")["art_state"] is None
+1 -1
View File
@@ -50,7 +50,7 @@ def client(tmp_path, monkeypatch):
# Point CONFIG_DIR at a per-test temp path BEFORE server's
# import-time side effects run. server.py reads CONFIG_DIR from the
# environment at module load (line 35) and immediately constructs
# `meta_db = MetadataDB()` at module level, which calls
# `meta_db = MetadataDB(CONFIG_DIR)` at module level, which calls
# CONFIG_DIR.mkdir(...) and opens a sqlite file — a plain
# post-import monkeypatch on server.CONFIG_DIR wouldn't catch those
# side effects, and the real user config dir would get written to.
+9 -4
View File
@@ -22,6 +22,11 @@ from pathlib import Path
import pytest
from fastapi.testclient import TestClient
# The startup DB swap lives with the DB layer it guards, not with server.py.
# metadata_db reads no environment at import, so a plain module import is safe
# alongside the env-patched `server_mod` fixture below.
from metadata_db import _apply_pending_db_restore
@pytest.fixture()
def server_mod(tmp_path, monkeypatch):
@@ -219,7 +224,7 @@ def test_apply_pending_db_restore_swaps_and_clears_sidecars(server_mod, tmp_path
(tmp_path / "web_library.db-shm").write_bytes(b"OLD-SHM")
(tmp_path / "web_library.db.restore").write_bytes(new_db)
server_mod._apply_pending_db_restore(tmp_path)
_apply_pending_db_restore(tmp_path)
assert main.read_bytes() == new_db # swapped in
assert not (tmp_path / "web_library.db.restore").exists()
@@ -234,7 +239,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
main.write_bytes(b"LIVE-GOOD-DB")
(tmp_path / "web_library.db.restore").write_bytes(b"SQLite format 3\x00" + b"\xff" * 64)
server_mod._apply_pending_db_restore(tmp_path)
_apply_pending_db_restore(tmp_path)
assert main.read_bytes() == b"LIVE-GOOD-DB" # live DB preserved
assert not (tmp_path / "web_library.db.restore").exists() # bad restore dropped
@@ -242,7 +247,7 @@ def test_apply_pending_db_restore_discards_corrupt_keeps_live(server_mod, tmp_pa
def test_apply_pending_db_restore_noop_without_staging(server_mod, tmp_path):
(tmp_path / "web_library.db").write_bytes(b"LIVE")
server_mod._apply_pending_db_restore(tmp_path) # nothing staged
_apply_pending_db_restore(tmp_path) # nothing staged
assert (tmp_path / "web_library.db").read_bytes() == b"LIVE"
@@ -266,7 +271,7 @@ def test_full_db_backup_restore_round_trip(client, server_mod, tmp_path):
# Simulate a restart: close the live conn, apply the staged restore,
# reopen — the song is back.
server_mod.meta_db.conn.close()
server_mod._apply_pending_db_restore(tmp_path)
_apply_pending_db_restore(tmp_path)
conn = sqlite3.connect(str(tmp_path / "web_library.db"))
try:
rows = conn.execute(
+27
View File
@@ -249,3 +249,30 @@ def test_flat_string_count_patch_resets_incompatible_named_tuning():
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
assert patched["string_count"] == 7
assert patched["tuning"] == "Standard"
# ── freqs_to_midis (the /api/tunings tuningMidis inverse) ────────────────────
def test_freqs_to_midis_round_trips_every_builtin_at_440():
from tunings import freqs_to_midis
for key, presets in TUNING_PRESET_MIDIS.items():
for name, midis in presets.items():
assert freqs_to_midis(open_midis_to_freqs(midis)) == midis, f"{key}/{name}"
def test_freqs_to_midis_round_trips_at_nonstandard_reference():
# The consumer footgun this exists to kill: frequencies served at a 432/450
# reference must recover the SAME integer midis when inverted at that
# reference (client-side log2-at-440 reconstruction drifts here).
from tunings import freqs_to_midis
for ref in (430.0, 432.0, 444.0, 450.0):
for midis in (TUNING_PRESET_MIDIS["guitar-8"]["Standard"], TUNING_PRESET_MIDIS["bass-5"]["Standard"]):
freqs = open_midis_to_freqs(midis, ref)
assert freqs_to_midis(freqs, ref) == midis, f"ref={ref}"
def test_freqs_to_midis_rejects_garbage():
from tunings import freqs_to_midis
assert freqs_to_midis([82.41, 0]) is None # non-positive
assert freqs_to_midis([82.41, "x"]) is None # non-numeric
assert freqs_to_midis([]) == [] # vacuously fine
+110
View File
@@ -0,0 +1,110 @@
"""A client that disconnects mid-stream is routine, not a logged error.
The highway WS streams ~15 message batches before its keep-alive loop. A
disconnect during that streaming used to fall through to the blanket
`except Exception` and log `highway_ws unhandled error`. The dedicated
`except WebSocketDisconnect: return` makes it quiet.
"""
import asyncio
import importlib
import json
import logging
import sys
import pytest
import yaml
from fastapi import WebSocketDisconnect
def _write_sloppak(dlc_root):
pak = dlc_root / "disc.sloppak"
pak.mkdir(parents=True)
(pak / "arrangements").mkdir()
(pak / "arrangements" / "lead.json").write_text(json.dumps({
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [{"time": 0.0, "measure": 1}],
"sections": [{"name": "intro", "number": 1, "time": 0.0}],
}))
(pak / "manifest.yaml").write_text(yaml.safe_dump({
"title": "Disc", "artist": "T", "album": "", "year": 2026, "duration": 10.0,
"arrangements": [{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}],
"stems": [],
}, sort_keys=False))
return pak
class _DisconnectingWS:
"""Accepts, then raises WebSocketDisconnect on the first streamed send —
i.e. a client that drops mid-stream."""
def __init__(self):
self.sends = 0
async def accept(self):
pass
async def send_json(self, data):
self.sends += 1
# Raise only on the FIRST send. If the handler wrongly kept streaming
# after catching the disconnect, later sends would succeed and `sends`
# would climb past 1 — the exactly-one assertion catches that.
if self.sends == 1:
raise WebSocketDisconnect(code=1001)
async def receive_text(self):
raise WebSocketDisconnect(code=1001)
async def close(self):
pass
@pytest.fixture()
def server(tmp_path, monkeypatch):
(tmp_path / "dlc").mkdir()
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
sys.modules.pop("server", None)
mod = importlib.import_module("server")
yield mod, tmp_path / "dlc"
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(mod, "_join_background_db_threads", lambda: None)()
conn.close()
sys.modules.pop("server", None)
class _Capture(logging.Handler):
def __init__(self):
super().__init__()
self.messages = []
def emit(self, record):
self.messages.append(record.getMessage())
def test_midstream_disconnect_is_not_logged_as_error(server):
"""The first streamed send (`loading`) raises WebSocketDisconnect; the
handler must return quietly, not log `highway_ws unhandled error`.
A raw handler on `feedBack.server` is used rather than pytest's `caplog`
because `configure_logging()` (run at server import) reroutes that logger
through the structlog pipeline, which caplog's fixture doesn't observe.
"""
_server, dlc = server
_write_sloppak(dlc)
from routers.ws_highway import highway_ws
cap = _Capture()
cap.setLevel(logging.ERROR)
lg = logging.getLogger("feedBack.server")
lg.addHandler(cap)
try:
ws = _DisconnectingWS()
asyncio.run(highway_ws(ws, "disc.sloppak", arrangement=0)) # must not raise
finally:
lg.removeHandler(cap)
assert ws.sends == 1, f"handler kept streaming after the disconnect ({ws.sends} sends)"
unhandled = [m for m in cap.messages if "highway_ws unhandled error" in m]
assert not unhandled, f"disconnect logged as unhandled error: {unhandled}"