mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 14:24:31 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8ec441b6 | ||
|
|
3b862ba117 | ||
|
|
cbc65458e3 | ||
|
|
7c87538d6b | ||
|
|
c8701991cb | ||
|
|
514461167e | ||
|
|
0dcc9136b6 | ||
|
|
6da01c55a4 | ||
|
|
a883f9213f | ||
|
|
4fd0cd49e7 | ||
|
|
b41361eb1b | ||
|
|
6c98aba433 | ||
|
|
b3215694e7 | ||
|
|
ebe59d3f97 | ||
|
|
d6f2df14f7 | ||
|
|
94a58b7a42 | ||
|
|
58120745bc | ||
|
|
e134f5c802 | ||
|
|
f1bae9774c | ||
|
|
751209b80e | ||
|
|
1c1a0e0268 | ||
|
|
0a16014698 | ||
|
|
aaf593bdd1 | ||
|
|
14d116d827 | ||
|
|
54b5d2e426 | ||
|
|
bcee2e8610 | ||
|
|
a6a5186180 | ||
|
|
1b3178037b | ||
|
|
845255e404 | ||
|
|
0d4d8229c7 | ||
|
|
ff8a638d28 | ||
|
|
5aa336961c | ||
|
|
950e348357 | ||
|
|
a18a818e8b | ||
|
|
5cb4ea0623 | ||
|
|
fadaa154e9 | ||
|
|
e446b05a99 |
@@ -123,3 +123,28 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
|
||||
@@ -7,7 +7,87 @@ 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). 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**.
|
||||
- **`appstate.py` — the router seam (R3).** Route modules moving out of `server.py`
|
||||
need `meta_db` and friends but must not `import server`, or the import graph goes
|
||||
circular the moment `server` imports them back. So `server.py` keeps *constructing*
|
||||
its singletons and now **injects** them once — `appstate.configure(meta_db=…,
|
||||
audio_effect_mappings=…)` — and a router reads them back as module attributes at call
|
||||
time (`import appstate; appstate.meta_db.…`). This is the Python analogue of the
|
||||
frontend refactor's injected `configureX({…})` seams and of the plugin
|
||||
`setup(app, context)` contract: dependencies flow one way, `server → routers →
|
||||
appstate`. Two properties are load-bearing and pinned by `tests/test_appstate.py`:
|
||||
(1) `import appstate` constructs nothing and touches no disk, so the ~49 test fixtures
|
||||
that `sys.modules.pop("server")` + re-import (to rebuild `meta_db` under a patched
|
||||
`CONFIG_DIR`) keep working untouched — a singleton *owned* by `appstate` would survive
|
||||
that pop and go stale; (2) reads must be late-bound (`appstate.meta_db`, never
|
||||
`from appstate import meta_db`), since a `from` import freezes the binding and defeats
|
||||
both a later `configure()` and `monkeypatch.setattr` — the same read-only-binding trap
|
||||
as ES `import`. `configure()` rejects an unknown slot rather than silently creating a
|
||||
global nothing reads, and the suite asserts `server` actually calls it (a seam whose
|
||||
wiring can no-op undetected is worse than no seam). Lives at `lib/appstate.py`.
|
||||
|
||||
### Changed
|
||||
- **`AudioEffectsMappingDB` moved out of `server.py` into `lib/audio_effects_db.py`
|
||||
(R3, move-only).** The core-owned song/tone → provider routing index follows
|
||||
`MetadataDB` out of the host file, byte-identical apart from the same constructor
|
||||
seam (`__init__` takes `config_dir`; `audio_effect_mappings = AudioEffectsMappingDB(CONFIG_DIR)`),
|
||||
so the module does no IO at import. The singleton stays in `server.py`; no route,
|
||||
no test and no `monkeypatch.setattr(server, …)` target moves. `server.py`:
|
||||
**9,705 → 9,433 lines**.
|
||||
- **`MetadataDB` moved out of `server.py` into `lib/metadata_db.py` (R3, move-only).**
|
||||
The library metadata cache — the `MetadataDB` class (4,018 lines) plus the query
|
||||
helpers it owns (keyset paging cursors, the tuning grouping key, smart-arrangement
|
||||
naming, tag normalisation, the startup DB-restore swap) — now lives in its own flat
|
||||
`lib/` module. `server.py` drops from **14,037 → 9,705 lines** and keeps the
|
||||
`meta_db` singleton, so `server.meta_db` and `server.app` resolve exactly as before
|
||||
and every route is untouched. The only non-verbatim change is the seam that lets the
|
||||
class leave `server.py`: `MetadataDB.__init__` now takes `config_dir` explicitly
|
||||
(`meta_db = MetadataDB(CONFIG_DIR)`) instead of reading the module-level `CONFIG_DIR`,
|
||||
which also means `lib/metadata_db.py` performs no IO at import (Principle V). Logging
|
||||
still goes through the `feedBack.server` logger, so existing log filters and `caplog`
|
||||
assertions resolve to the same logger object. `tests/test_settings_export_library_db.py`
|
||||
now imports `_apply_pending_db_restore` from `metadata_db` (the test moved with its
|
||||
subject); no other test changed. Every moved block is byte-identical to its
|
||||
`server.py` original.
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
@@ -29,6 +109,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
|
||||
@@ -117,6 +117,8 @@ Notes:
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Perf baseline — module-migration refactor
|
||||
|
||||
The refactor promises "measured runtime wins, no hand-waved perf claims" and
|
||||
"screen-entry and frame-time no worse." This is the baseline to hold it to.
|
||||
Rerun the harness after every phase (R0 → R3c) and compare.
|
||||
|
||||
## Running it
|
||||
|
||||
```
|
||||
# 1. start core against a library with real charts (see caveat below)
|
||||
CONFIG_DIR=… DLC_DIR=/path/to/songs PYTHONPATH=lib \
|
||||
python3 -m uvicorn server:app --host 127.0.0.1 --port 8000
|
||||
|
||||
# 2. capture (maintainer/CI-only; uses the committed Playwright chromium)
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 --n 60 --soak 30
|
||||
```
|
||||
|
||||
The script prints a markdown block; paste it under "Results" below with the date
|
||||
and the commit it was taken at.
|
||||
|
||||
## What it measures
|
||||
|
||||
- **Server latency** — p50/p95/p99 over N requests for `/api/version`,
|
||||
`/api/plugins`, `/api/library`, `/api/library/artists`.
|
||||
- **Cold boot → interactive** — full page load to `networkidle`.
|
||||
- **JS heap** — `performance.memory.usedJSHeapSize` after load and after an idle
|
||||
soak (a leak signal across a session).
|
||||
- **Plugin-script shape** — how many plugin `<script>`s the loader injected (a
|
||||
"the app booted with its plugins" sanity signal).
|
||||
|
||||
**Not yet captured — needs a seeded library with charts** (fill in when run
|
||||
against a real environment): playback **frame-time p95** on the 2D and 3D
|
||||
highway, and **screen-entry** (plugin inject → interactive) for
|
||||
editor / notedetect / highway_3d with a chart loaded. These are the
|
||||
perf-sensitive numbers that gate the `highway.js` split (R3c); the harness has
|
||||
the hooks, they just need real songs in `DLC_DIR`.
|
||||
|
||||
## Results
|
||||
|
||||
### R3c pre-lift baseline — 2026-07-10 (2D highway draw cost)
|
||||
|
||||
The gate for the `highway.js` split. Captured on a seeded library (the 33 MB
|
||||
Arcturus feedpak) with the new `--song` mode, which measures **per-frame draw
|
||||
cost** — rAF callbacks are tagged via `highway.addDrawHook`, so only frames the
|
||||
highway actually painted count (the other ~half are cheap no-op loops that would
|
||||
otherwise mask a regression). Any `highway.js` change must re-run this on the
|
||||
same machine and stay within noise of these numbers.
|
||||
|
||||
```bash
|
||||
node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 \
|
||||
--song "Arcturus - The Sham Mirrors - Kinetic.feedpak"
|
||||
```
|
||||
|
||||
| run | draw frames | p50 | p95 | p99 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 53/106 | 2.2 | 3.2 | 3.6 | 3.6 |
|
||||
| 2 | 50/100 | 2.2 | 2.9 | 3.2 | 3.2 |
|
||||
| 3 | 53/106 | 2.1 | 2.7 | 3.5 | 3.5 |
|
||||
|
||||
**p50 ≈ 2.2 ms · p95 spread 2.7–3.2 ms** (3 runs × 10 s playback, headless
|
||||
chromium on the dev box). The `H`-container lift changes each closure-slot read
|
||||
to a `H.<slot>` property load; this is the number that proves it doesn't cost the
|
||||
hot loop.
|
||||
|
||||
### R0 baseline — 2026-07-08 (branch `feat/r0-plugin-module-rails`)
|
||||
|
||||
> ⚠️ A quick capture (`--n 50 --soak 8`) against an **empty** library (no charts
|
||||
> in `DLC_DIR`), so the `/api/library*` and boot numbers are floor values —
|
||||
> re-take on a seeded environment with the recommended `--n 60 --soak 30` for the
|
||||
> real R0 baseline before comparing R1+ against it. Recorded here to prove the
|
||||
> harness and lock the methodology.
|
||||
|
||||
Server latency (ms), n=50:
|
||||
|
||||
| Endpoint | status | p50 | p95 | p99 |
|
||||
|---|---|---|---|---|
|
||||
| `/api/version` | 200 | 0.9 | 1.8 | 22.3 |
|
||||
| `/api/plugins` | 200 | 1.6 | 2.1 | 3.4 |
|
||||
| `/api/library?limit=60` | 200 | 1.4 | 1.7 | 2.9 |
|
||||
| `/api/library/artists` | 200 | 1.3 | 1.8 | 2.7 |
|
||||
|
||||
Client:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Cold boot → networkidle | 1268 ms |
|
||||
| JS heap after load | 10.1 MB |
|
||||
| JS heap after idle soak | 10.1 MB (no idle growth) |
|
||||
| Plugin scripts injected | 12 |
|
||||
|
||||
No plugin has migrated yet, so all 12 are classic. When the R1 pilot (stems)
|
||||
lands, cold-boot / heap should not regress.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Size-exemption register
|
||||
|
||||
The working norm (constitution Principle II; enforced by the `max-lines` lint
|
||||
gate) is **no source file over 1,500 lines**. A few files are allowed to exceed
|
||||
it because splitting them would do more harm than good — hot per-frame
|
||||
renderers, C++, offline generators, cohesive registries. This register is the
|
||||
list of those exceptions: each row is a **deliberate, signed** decision with a
|
||||
ceiling, a rationale, and a review trigger. Without it, "no file over 1,500
|
||||
without a *signed* exemption" is unenforceable.
|
||||
|
||||
**Rules**
|
||||
- One row per file: a ceiling, a rationale, a signer, a review trigger.
|
||||
- The `max-lines` per-file ceilings in `eslint.config.js` mirror this table —
|
||||
keep them in sync (this register is canonical).
|
||||
- Files with a scheduled split **plan** are *not* exempt — they live in
|
||||
"Planned, not exempt" at the bottom so nothing falls between the two states.
|
||||
- **Signers** (decided 2026-07-08): **Byron** signs core + bundled rows;
|
||||
**Christian** signs the authored-plugin row (virtuoso, its own repo/track).
|
||||
|
||||
## Permanent exemptions (structural rationale)
|
||||
|
||||
| Repo / file | Lines (7-07) | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `static/highway.js` → residual `renderer-2d.js` (post-split) | ~2,400–2,900 est. | **3,000** | 60 fps hot path; no module boundary inside the per-frame loop | Byron | after the highway.js split |
|
||||
| core `plugins/highway_3d/` → residual renderer | sized at split; likely **>3,000** | set at split, flagged now | same hot-path rule; the draw core can't be cut without behavior risk | Byron | after the highway_3d split |
|
||||
| core `static/capabilities.js` | 1,538 | 1,600 | cohesive registry + `window.feedBack` bus, 38 lines over; a split spends credibility for nothing | Byron | R4 |
|
||||
| tutorials `builtin/reading-the-highway/generate.py` | 1,818 | 2,000 | offline content generator, never imported at runtime, deps not in runtime requirements | Byron | if a 3rd builtin pack appears |
|
||||
| desktop `src/audio/NodeAddon.cpp` | 3,542 | as-is | C++, outside the ESM/routes playbooks; under active use-after-free crash work — do not churn | Byron | after crash-class work settles |
|
||||
| desktop `src/audio/AudioEngine.cpp` | 2,977 | as-is | same | Byron | same |
|
||||
| desktop `src/vst-host/main.cpp` | 1,928 | as-is | same | Byron | same |
|
||||
| virtuoso `screen.js` (authored, own track) | 25,741 | as-is until its own split | authored plugin on a separate roadmap; migrates on its own schedule | Christian | virtuoso split kickoff |
|
||||
|
||||
## Split-when-touched (no scheduled train; row retires when split)
|
||||
|
||||
| Repo / file | Lines | Ceiling | Rationale | Signer | Review |
|
||||
|---|---|---|---|---|---|
|
||||
| core `lib/gp2rs_gpx.py` | 2,540 | as-is | import converter, off the serve-path hot loop | Byron | when next touched |
|
||||
| core `lib/gp2rs.py` | 2,055 | as-is | same | Byron | when next touched |
|
||||
| core `lib/song.py` | 1,689 | as-is | data models + wire format; cohesive | Byron | when next touched |
|
||||
| core `lib/gp_autosync.py` | 1,572 | as-is | under active dev (#787/#791) — don't collide | Byron | after in-flight work lands |
|
||||
| core `plugins/capability_inspector/screen.js` | 1,752 | as-is | bundled diagnostics plugin, low churn | Byron | when next touched |
|
||||
| core `plugins/folder_library/screen.js` | 1,672 | as-is | bundled plugin, low churn | Byron | when next touched |
|
||||
|
||||
## Temporary rows (cleared by a scheduled PR)
|
||||
|
||||
| Repo / file | Lines | Cleared by |
|
||||
|---|---|---|
|
||||
| core `plugins/__init__.py` | ~2,470 (grew under R0) | the `plugins/_routes.py` + `plugins/_registry.py` split (rides the server.py router work) |
|
||||
|
||||
## Watch list (under the norm — no row needed, re-census each phase)
|
||||
|
||||
`musicxml-import/mxml2notation.py` (1,456) · core `static/capabilities/audio-effects.js`
|
||||
(1,436) · `studio routes.py` (1,399) · `update-manager screen.js` (1,492 — zero headroom).
|
||||
|
||||
## Planned, NOT exempt (owned by split plans — listed so nothing falls between states)
|
||||
|
||||
core `static/app.js` (11,852) · `static/highway.js` (4,168, whole file) · `server.py`
|
||||
(7,880 — was 14,037; ratcheted by the R3 `MetadataDB` + `AudioEffectsMappingDB`
|
||||
extractions and seven `routers/` modules) ·
|
||||
`lib/metadata_db.py` (4,373 — new in R3; the `MetadataDB` class alone is 4,018 lines
|
||||
and is a monolith in its own right, to be split per-table once the router train
|
||||
lands) · `static/v3/songs.js` (4,134) · `static/capabilities/audio-session.js`
|
||||
(2,974) · `plugins/highway_3d/screen.js` (15,656) · `plugins/keys_highway_3d/screen.js`
|
||||
(3,780) · `plugins/drum_highway_3d/screen.js` (3,597) — and every monolith with a PR
|
||||
train in the refactor plan. Test files (e.g. `tests/test_plugins.py`) are out of scope
|
||||
by policy — the norm governs source files.
|
||||
@@ -0,0 +1,66 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
"""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
|
||||
|
||||
# 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
|
||||
|
||||
_SLOTS = frozenset({
|
||||
"meta_db", "audio_effect_mappings",
|
||||
"config_dir", "dlc_dir", "dlc_dir_env",
|
||||
"static_dir", "sloppak_cache_dir", "audio_cache_dir",
|
||||
})
|
||||
|
||||
|
||||
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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
+43
-12
@@ -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.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
@@ -1836,9 +1843,10 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1894,18 +1902,29 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1946,9 +1965,21 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_rec["times"].sort()
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
+4373
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -634,10 +634,12 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -675,10 +677,13 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": []})
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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/``.
|
||||
"""
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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 1–100 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 1–100 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}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+1758
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,9 +8,12 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
+99
-15
@@ -18,6 +18,54 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||
def plugin_screen_js(plugin_id: str):
|
||||
def plugin_screen_js(request: Request, plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return Response("{}", status_code=404, media_type="application/json")
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||
def plugin_asset(plugin_id: str, asset_path: str):
|
||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
fixed screen.js/settings.html handlers above, this is a generic
|
||||
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
|
||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||
break
|
||||
if target.is_file():
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.3",
|
||||
"version": "3.31.5",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,13 +3,34 @@
|
||||
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
|
||||
|
||||
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-colour palettes** (settings → Note colours, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
|
||||
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
|
||||
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
|
||||
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
|
||||
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1).
|
||||
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
|
||||
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
|
||||
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
|
||||
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
|
||||
them, and each sharp leaned toward the edge natural beside it so the naturals come
|
||||
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
|
||||
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
|
||||
naturals always rendered full, full black keys drawn on top and only occluding a
|
||||
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
|
||||
**Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the
|
||||
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
|
||||
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
|
||||
rather than every lane — the notes keep their colors; toward 1 it fills in full,
|
||||
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
|
||||
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
|
||||
gap a touch at each B→C octave boundary. **Octave line contrast**
|
||||
(`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line
|
||||
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
|
||||
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
|
||||
switch needed. All are geometry-time — applied on the next chart build via
|
||||
`init()`'s re-read.
|
||||
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
|
||||
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
|
||||
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
|
||||
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.2",
|
||||
"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,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
||||
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
||||
// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a
|
||||
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
|
||||
// secondary brightness cue), active-range key highlighting with letters,
|
||||
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
||||
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
|
||||
@@ -59,7 +59,7 @@
|
||||
// World scroll speed (units / second) — matches the sibling highways.
|
||||
const TS = 130 * K;
|
||||
|
||||
// Per-pitch-class colours (Synthesia convention observed in the RS+
|
||||
// Per-pitch-class colors (Synthesia convention observed in the RS+
|
||||
// reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
|
||||
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
|
||||
// their neighbours so black-key notes stay distinguishable.
|
||||
@@ -79,11 +79,11 @@
|
||||
];
|
||||
|
||||
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
|
||||
// at full brightness, left hand slightly darkened — colour stays the
|
||||
// at full brightness, left hand slightly darkened — color stays the
|
||||
// pitch class.
|
||||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||||
|
||||
// Selectable note-colour palettes. Index = midi % 12, same contract as
|
||||
// Selectable note-color palettes. Index = midi % 12, same contract as
|
||||
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
|
||||
// entry, so anyone who never touches the setting sees the stock look.
|
||||
// Two palette families:
|
||||
@@ -151,7 +151,7 @@
|
||||
],
|
||||
};
|
||||
|
||||
// Octave-based colour scheme ('octaves'): every octave gets a distinct
|
||||
// Octave-based color scheme ('octaves'): every octave gets a distinct
|
||||
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
|
||||
// blend — so each octave is uniquely identifiable, but neighbouring
|
||||
// octaves stay close so the change isn't jarring). Loops if a song runs
|
||||
@@ -181,7 +181,80 @@
|
||||
function _isBlackPc(midi) {
|
||||
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
|
||||
}
|
||||
// Colour (24-bit int) for a midi note under the octave scheme: hue by
|
||||
// Which way a sharp leans to even out the naturals: toward the EDGE
|
||||
// natural next to it. +1 = up (toward the higher natural), −1 = down, 0 =
|
||||
// centred. C#/F# sit below an inner natural so they lean down to C/F;
|
||||
// D#/A# lean up to E/B; G# has an inner natural on both sides, so it can't
|
||||
// lean and stays put.
|
||||
function _sharpLeanDir(pc) {
|
||||
if (pc === 1 || pc === 6) return -1; // C#, F#
|
||||
if (pc === 3 || pc === 10) return 1; // D#, A#
|
||||
return 0; // G#
|
||||
}
|
||||
// Floor span [left,right] of a key's lane in the FLAT (piano-shaped)
|
||||
// layout, in world units, given the key's centre x (`cx`). Pure/isolated
|
||||
// on purpose — this ONE function defines the layout, so a variant is a
|
||||
// one-function swap. Zero-overlap tiling: a white lane is trimmed by
|
||||
// `sharpHalf` wherever it meets a sharp, and the sharp fills that gap. Each
|
||||
// sharp is nudged `shift` toward the edge natural beside it (see
|
||||
// _sharpLeanDir), which steals a sliver from that edge natural and widens
|
||||
// the squeezed inner natural — at shift = sharpHalf/3 the C-D-E-F-B
|
||||
// naturals come out equal. Lanes still tile edge-to-edge (no overlap, no
|
||||
// gap). With `gaps`, each B→C octave boundary opens an extra `octGap`
|
||||
// divider by shaving half of it off the B and the C (naturals only).
|
||||
// `range`, when given, gates the trim to a neighbouring sharp that is
|
||||
// itself inside `range.activeLow..range.activeHigh`. A white key at the
|
||||
// active-range boundary (see the `midi < range.activeLow ||
|
||||
// midi > range.activeHigh` skip around the lane-strip loop) may sit next
|
||||
// to a sharp pitch-class that falls just outside the active range — that
|
||||
// sharp's lane is never drawn, so trimming the white key's edge for it
|
||||
// leaves a dark, unfilled sliver. Gating on range keeps that edge full
|
||||
// while leaving the normal (fully in-range) zero-overlap tiling intact.
|
||||
// Callers that don't pass `range` (e.g. the unit tests exercising raw
|
||||
// tiling geometry) keep the unconditional trim.
|
||||
function laneSpanFlat(midi, black, cx, dims, gaps, range) {
|
||||
const { whiteW, sharpHalf, shift, octGap } = dims;
|
||||
if (black) {
|
||||
const c = cx + _sharpLeanDir(((midi % 12) + 12) % 12) * shift;
|
||||
return { left: c - sharpHalf, right: c + sharpHalf };
|
||||
}
|
||||
const neighborActive = (m) => !range || (m >= range.activeLow && m <= range.activeHigh);
|
||||
// White: each side that meets a sharp is trimmed to that (leaned) sharp's
|
||||
// near edge; a side that meets another white keeps the half-slot edge.
|
||||
let left = cx - whiteW / 2;
|
||||
let right = cx + whiteW / 2;
|
||||
if (_isBlackPc(midi - 1) && neighborActive(midi - 1)) {
|
||||
const bc = (cx - whiteW / 2) + _sharpLeanDir(((midi - 1) % 12 + 12) % 12) * shift;
|
||||
left = bc + sharpHalf;
|
||||
}
|
||||
if (_isBlackPc(midi + 1) && neighborActive(midi + 1)) {
|
||||
const bc = (cx + whiteW / 2) + _sharpLeanDir(((midi + 1) % 12 + 12) % 12) * shift;
|
||||
right = bc - sharpHalf;
|
||||
}
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (gaps) {
|
||||
if (pc === 11) right -= octGap / 2; // B: gap on its right (→ C)
|
||||
if (pc === 0) left += octGap / 2; // C: gap on its left (← B)
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
// 'realistic' layout span: every bar sized to the physical key it lands on.
|
||||
// Naturals are the same full width (2·natHalf) centred on the key; sharps are
|
||||
// the full black-key width (2·sharpHalf) at their standard half-slot, which
|
||||
// makes them overlap — the caller draws sharps on top. A natural therefore
|
||||
// always renders full and is only covered where a sharp note actually
|
||||
// coincides in time. `gaps` widens the B→C divider (naturals only).
|
||||
function laneSpanReal(midi, black, cx, dims, gaps) {
|
||||
const half = black ? dims.sharpHalf : dims.natHalf;
|
||||
let left = cx - half, right = cx + half;
|
||||
if (gaps && !black) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (pc === 11) right -= dims.octGap / 2;
|
||||
if (pc === 0) left += dims.octGap / 2;
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
// Color (24-bit int) for a midi note under the octave scheme: hue by
|
||||
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
|
||||
function octaveNoteColor(midi) {
|
||||
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
|
||||
@@ -210,8 +283,8 @@
|
||||
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
|
||||
// the note geometry so a block reads as a lit 3D gem instead of a flat fill —
|
||||
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
|
||||
// greyscale so one geometry serves every pitch-class colour; the material
|
||||
// multiplies its colour by it via vertexColors.
|
||||
// greyscale so one geometry serves every pitch-class color; the material
|
||||
// multiplies its color by it via vertexColors.
|
||||
const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight)
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
@@ -740,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;
|
||||
@@ -761,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();
|
||||
@@ -788,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
|
||||
@@ -801,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
|
||||
@@ -966,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; };
|
||||
@@ -1026,6 +1149,14 @@
|
||||
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
|
||||
bgIntensity: 0.5, // background-ambience density/strength
|
||||
bgReactive: true, // background reacts to the audio analyser
|
||||
// Highway-layout options (apply on the next chart build via init()'s
|
||||
// fx re-read). The sharp LAYOUT is a separate string setting
|
||||
// (keys3d_bg_sharpMode); these two are the booleans.
|
||||
octaveGaps: true, // ON: wider divider gap at each B→C octave boundary
|
||||
laneOpacity: 0.0, // 0–1: lane-color strength. 0 (default) = dark floor +
|
||||
// block guide lines (E→F, B→C); 1 = full colored lanes; crossfades.
|
||||
octaveContrast: 0.5, // 0–1: how strongly the B→C octave line stands out. It
|
||||
// auto-darkens with lane opacity and brightens as it fades.
|
||||
// Camera base-rig fine-tune. These shift the BASE vantage point the
|
||||
// auto-pan/zoom follow-motion is built on (they multiply/offset the
|
||||
// active CAM_PRESET before the per-frame pan + dolly), so the camera
|
||||
@@ -1223,7 +1354,7 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Note-colour palette id — string-valued like the theme, so it gets its
|
||||
// Note-color palette id — string-valued like the theme, so it gets its
|
||||
// own validated key + setter rather than an FX_DEFAULTS slot.
|
||||
const FX_LS_PALETTE = 'keys3d_bg_palette';
|
||||
function readPaletteSetting() {
|
||||
@@ -1231,7 +1362,7 @@
|
||||
const id = localStorage.getItem(FX_LS_PALETTE);
|
||||
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
|
||||
} catch (_) {}
|
||||
// Default: the octave scheme (each octave its own colour, darker
|
||||
// Default: the octave scheme (each octave its own color, darker
|
||||
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
||||
// selectable.
|
||||
return 'octaves';
|
||||
@@ -1244,6 +1375,30 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Sharp-display layout id — string-valued (3-way), its own validated key +
|
||||
// setter. 'floating' = the original raised-plane sharps with white-only
|
||||
// lanes; 'flat' = every note on one plane with piano-shaped tiled lanes
|
||||
// (sharps leaned to even the naturals); 'realistic'
|
||||
// = one plane with note bars sized like the physical keys (full naturals,
|
||||
// full sharps overlapping on top). Geometry-time — applied on the next chart
|
||||
// build via init()'s re-read.
|
||||
const FX_LS_SHARPMODE = 'keys3d_bg_sharpMode';
|
||||
const SHARP_MODES = ['floating', 'flat', 'realistic'];
|
||||
function readSharpModeSetting() {
|
||||
try {
|
||||
const id = localStorage.getItem(FX_LS_SHARPMODE);
|
||||
if (id && SHARP_MODES.indexOf(id) !== -1) return id;
|
||||
} catch (_) {}
|
||||
return 'realistic'; // default layout: physical-key-sized bars on one plane
|
||||
}
|
||||
window.keys3dSetSharpMode = function (id) {
|
||||
if (SHARP_MODES.indexOf(id) === -1) return;
|
||||
try { localStorage.setItem(FX_LS_SHARPMODE, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { sharpMode: id } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
|
||||
// (numerically identical to the historical constants, so 'classic' with the
|
||||
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
|
||||
@@ -1442,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) {
|
||||
@@ -1495,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
|
||||
* ====================================================================== */
|
||||
@@ -1561,6 +1753,7 @@
|
||||
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
||||
let _theme = readThemeSetting();
|
||||
let _palette = readPaletteSetting();
|
||||
let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
|
||||
let ambLight = null, dirLight = null;
|
||||
let _floorMat = null;
|
||||
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
||||
@@ -1628,7 +1821,7 @@
|
||||
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
||||
}
|
||||
|
||||
// Base colour (24-bit int, no hand dimming) for a midi note under the
|
||||
// Base color (24-bit int, no hand dimming) for a midi note under the
|
||||
// active palette — the octave scheme is procedural, every other
|
||||
// palette is a 12-entry pitch-class table.
|
||||
function _noteHex(midi) {
|
||||
@@ -1664,6 +1857,41 @@
|
||||
const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
|
||||
const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
|
||||
const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
|
||||
// 'flat' piano-shaped-lane geometry (see laneSpanFlat). Zero-overlap tiling:
|
||||
// white lanes are trimmed by FLAT_SHARP_HALF where they meet a sharp and
|
||||
// the sharp fills the gap, so nothing overlaps. To keep the naturals
|
||||
// even, each sharp is nudged FLAT_SHARP_SHIFT toward the EDGE natural
|
||||
// beside it (C#→C, D#→E, F#→F, A#→B; G# stays centred, no edge to lean
|
||||
// on) — that steals a sliver from the edge natural and hands it to the
|
||||
// squeezed inner natural. At shift = sharpHalf/3 the C-D-E-F-B naturals
|
||||
// come out exactly equal; G/A land a hair smaller (G# can't lean). The
|
||||
// sharps ride the same flat plane (no lift — they never overlap a
|
||||
// natural). OCT_GAP is the extra divider opened at each octave boundary
|
||||
// when the octaveGaps option is on.
|
||||
const FLAT_SHARP_HALF = 2.2 * K; // sharp half-width (4.4K wide)
|
||||
const FLAT_SHARP_SHIFT = FLAT_SHARP_HALF / 3; // sharp lean that evens the naturals
|
||||
const OCT_GAP = 0.9 * K;
|
||||
const LANE_DIMS_FLAT = {
|
||||
whiteW: WHITE_W, sharpHalf: FLAT_SHARP_HALF, shift: FLAT_SHARP_SHIFT, octGap: OCT_GAP,
|
||||
};
|
||||
// 'realistic' layout (laneSpanReal): every note bar is the size of the
|
||||
// physical key it lands on — naturals the full white-key width (always
|
||||
// rendered full, only occluded where a sharp note actually overlaps in
|
||||
// time) and sharps the full black-key width at their standard positions,
|
||||
// drawn on top with a hair of REAL_SHARP_LIFT (anti z-fight).
|
||||
const REAL_NAT_HALF = WHITE_W * 0.47; // natural bar ≈ physical white key (~11.3K)
|
||||
const REAL_SHARP_HALF = BLACK_W / 2; // sharp bar = physical black key (6.4K)
|
||||
const REAL_SHARP_LIFT = 0.3 * K;
|
||||
const LANE_DIMS_REAL = { natHalf: REAL_NAT_HALF, sharpHalf: REAL_SHARP_HALF, octGap: OCT_GAP };
|
||||
// Lane span for the active non-floating sharp mode. `range`
|
||||
// (activeLow/activeHigh) is optional and only consulted by the flat
|
||||
// layout, to gate the boundary-key edge trim (see laneSpanFlat).
|
||||
const _flatMode = () => _sharpMode === 'flat' || _sharpMode === 'realistic';
|
||||
function laneSpanFor(midi, black, cx, gaps, range) {
|
||||
return _sharpMode === 'realistic'
|
||||
? laneSpanReal(midi, black, cx, LANE_DIMS_REAL, gaps)
|
||||
: laneSpanFlat(midi, black, cx, LANE_DIMS_FLAT, gaps, range);
|
||||
}
|
||||
|
||||
// Camera — the default 'classic' preset is a low, near-telephoto rig
|
||||
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
||||
@@ -1708,7 +1936,19 @@
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
return _rigOut;
|
||||
}
|
||||
// Per-key approach glow: a key lights in its pitch-class colour ONLY while a
|
||||
|
||||
/**
|
||||
* 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
|
||||
const KEY_GLOW_STRENGTH = 1.15; // peak emissive intensity (note at the hit-line)
|
||||
@@ -2297,9 +2537,9 @@
|
||||
});
|
||||
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
||||
geo.translate(0, 0, -depth / 2);
|
||||
// Bake a vertical brightness ramp into vertex colours (bottom shade →
|
||||
// Bake a vertical brightness ramp into vertex colors (bottom shade →
|
||||
// top highlight) so the gem reads 3D; the material multiplies its
|
||||
// pitch-class colour by this (vertexColors).
|
||||
// pitch-class color by this (vertexColors).
|
||||
geo.computeBoundingBox();
|
||||
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
||||
const pos = geo.attributes.position;
|
||||
@@ -2314,10 +2554,10 @@
|
||||
return geo;
|
||||
}
|
||||
|
||||
// Glossy note material, cached per resolved colour. Keying by the
|
||||
// final colour int (hand brightness already baked in by noteColor)
|
||||
// Glossy note material, cached per resolved color. Keying by the
|
||||
// final color int (hand brightness already baked in by noteColor)
|
||||
// works for every palette — including 'octaves', where two notes of
|
||||
// the same pitch class in different octaves are DIFFERENT colours and
|
||||
// the same pitch class in different octaves are DIFFERENT colors and
|
||||
// must not share a material (a pitch-class key would collide them).
|
||||
function _noteMaterial(midi, hand) {
|
||||
const col = noteColor(midi, hand);
|
||||
@@ -2333,7 +2573,7 @@
|
||||
// share one shader program.)
|
||||
mat = new T.MeshPhysicalMaterial({
|
||||
color: col,
|
||||
vertexColors: true, // multiply colour by the baked gem ramp
|
||||
vertexColors: true, // multiply color by the baked gem ramp
|
||||
emissive: col,
|
||||
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
||||
roughness: 0.32,
|
||||
@@ -2355,7 +2595,10 @@
|
||||
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
}
|
||||
function _laneGuideOpacity() {
|
||||
return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
// Vibrancy sets the ceiling (much brighter than the old subtle
|
||||
// 0.10–0.22 range); the laneOpacity slider then scales 0 → ceiling.
|
||||
const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
|
||||
return vib * Math.min(1, Math.max(0, fx.laneOpacity));
|
||||
}
|
||||
|
||||
// Live vibrancy slider: retint everything already built — the
|
||||
@@ -2371,13 +2614,13 @@
|
||||
for (const m of _laneGuideMats) m.opacity = lop;
|
||||
}
|
||||
|
||||
// Live palette switch: recolour everything already built — cached
|
||||
// Live palette switch: recolor everything already built — cached
|
||||
// note materials (future clones), per-note clones, key emissives
|
||||
// (incl. the wrong-flash restore state), lane guides — and drop the
|
||||
// pitch-class flame textures so the next spawn bakes the new hues.
|
||||
// Same no-rebuild approach as _applyVibrancy.
|
||||
function _applyPalette() {
|
||||
// The base-material cache is keyed by resolved colour, so old
|
||||
// The base-material cache is keyed by resolved color, so old
|
||||
// entries are simply stale under a new palette — drop them and let
|
||||
// the next build re-cache. The live per-note clones below are
|
||||
// retinted directly from each note's midi (palette-correct).
|
||||
@@ -2441,10 +2684,10 @@
|
||||
}
|
||||
|
||||
// Vertical flame texture for hit flares / held-key halos: white-hot
|
||||
// base fading up into the note's colour, with a horizontal falloff.
|
||||
// Cached per resolved colour (bounded — 12 for pitch-class palettes,
|
||||
// base fading up into the note's color, with a horizontal falloff.
|
||||
// Cached per resolved color (bounded — 12 for pitch-class palettes,
|
||||
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
||||
// struck note's colour whatever the palette.
|
||||
// struck note's color whatever the palette.
|
||||
function _flameTexture(midi) {
|
||||
const c = _noteHex(midi);
|
||||
let tex = _flameTexCache.get(c);
|
||||
@@ -2624,9 +2867,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Lane guides: a faint colour strip running up the runway from each
|
||||
// active key, in that key's pitch-class colour. A falling note shares
|
||||
// its target key's colour, so the player can trace it straight down
|
||||
// Lane guides: a faint color strip running up the runway from each
|
||||
// active key, in that key's pitch-class color. A falling note shares
|
||||
// its target key's color, so the player can trace it straight down
|
||||
// its lane to the right key even when it sits near the frame edge.
|
||||
//
|
||||
// The lanes sit at the NOTES' travel height (coplanar), not on the
|
||||
@@ -2636,29 +2879,87 @@
|
||||
// lane, perfectly aligned with the lane and its key.
|
||||
const guideLen = HIGHWAY_LEN - WHITE_L;
|
||||
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
|
||||
const gaps = fx.octaveGaps;
|
||||
const floating = _sharpMode === 'floating';
|
||||
const t = Math.min(1, Math.max(0, fx.laneOpacity)); // lane-color opacity
|
||||
const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
|
||||
const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
|
||||
// A vertical guide line running the full runway at world x (skips
|
||||
// near-transparent lines so the crossfade never builds dead meshes).
|
||||
const addLine = (x, color, opacity, wpx, trackTheme) => {
|
||||
if (opacity < 0.02) return;
|
||||
const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
|
||||
if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
|
||||
const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
|
||||
line.rotation.x = -Math.PI / 2;
|
||||
line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(line);
|
||||
};
|
||||
for (const [midi, entry] of layout) {
|
||||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||||
if (entry.black) continue; // one strip per semitone-slot lands on whites
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false,
|
||||
});
|
||||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Thin brighter rails at the lane edges for crisp separation.
|
||||
const railMat = new T.MeshBasicMaterial({
|
||||
color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(),
|
||||
transparent: true, opacity: 0.5, depthWrite: false,
|
||||
});
|
||||
_railMats.push(railMat);
|
||||
const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat);
|
||||
rail.rotation.x = -Math.PI / 2;
|
||||
rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(rail);
|
||||
// Floating: white-only lanes (blacks float, lane-less). Flat/
|
||||
// realistic: every key gets a piano-shaped lane.
|
||||
if (entry.black && floating) continue;
|
||||
// Lane footprint per mode.
|
||||
let left, right, stripY = laneY;
|
||||
if (floating) {
|
||||
const cx = keyX(entry, whiteCount);
|
||||
left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
|
||||
if (gaps) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
|
||||
if (pc === 0) left += OCT_GAP / 2;
|
||||
}
|
||||
} else {
|
||||
const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
|
||||
left = span.left; right = span.right;
|
||||
if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
|
||||
}
|
||||
const center = (left + right) / 2;
|
||||
// Colored lane strip + a subtle per-lane separator — fade in with
|
||||
// lane opacity. (As lanes fade, the block/octave lines below take
|
||||
// over as the guide.)
|
||||
if (t > 0.02) {
|
||||
// Floating keeps the historical 0.84-wide white strip; the
|
||||
// piano-shaped lanes inset a touch for a dark separator.
|
||||
const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
|
||||
});
|
||||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(center, stripY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Per-lane separator, fading with the strips. Skip realistic
|
||||
// sharps (they overlap the white columns).
|
||||
if (!(entry.black && _sharpMode === 'realistic')) {
|
||||
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
|
||||
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
|
||||
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
|
||||
// dark layer (reads over bright lanes, scales with lane opacity) plus
|
||||
// a bright layer (reads over the dark floor, scales with the inverse),
|
||||
// so it auto-shifts dark→bright as you fade lanes; octaveContrast
|
||||
// scales the whole thing.
|
||||
for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
const isEF = pc === 4; // E → F block boundary
|
||||
const isBC = pc === 11; // B → C octave boundary
|
||||
if (!isEF && !isBC) continue;
|
||||
const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
|
||||
if (isBC) {
|
||||
addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false); // dark, over lanes
|
||||
addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
|
||||
} else {
|
||||
// E→F block divider — a guide that appears as the lanes fade.
|
||||
addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Keys (whites first so blacks overlay). Geometries are shared
|
||||
@@ -2678,7 +2979,7 @@
|
||||
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
||||
const material = new T.MeshStandardMaterial({
|
||||
color: black ? 0x070708 : 0xe8e8ee,
|
||||
// Pitch-class colour preset on emissive but OFF at rest — the key
|
||||
// Pitch-class color preset on emissive but OFF at rest — the key
|
||||
// is neutral until a note approaches, when updateScene ramps the
|
||||
// intensity up by proximity.
|
||||
emissive: noteColor(midi, 'rh'),
|
||||
@@ -2764,11 +3065,31 @@
|
||||
const entry = layout.get(note.midi);
|
||||
if (!entry) continue;
|
||||
const len = Math.max(4 * K, note.durSec * TS);
|
||||
const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
// Non-floating layouts: notes ride the naturals' plane and take
|
||||
// their piano-shaped lane's width/centre. Floating (default):
|
||||
// original elevated sharps, key-centred bars.
|
||||
let w, x, y;
|
||||
if (_flatMode()) {
|
||||
const span = laneSpanFor(
|
||||
note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
|
||||
// 'realistic' bars are full (physical-key size); 'flat' bars are
|
||||
// inset a touch for a dark separator in the tight tiling.
|
||||
const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
|
||||
w = (span.right - span.left) * inset;
|
||||
x = (span.left + span.right) / 2;
|
||||
// Coplanar; in 'realistic' the sharps ride a hair proud so they
|
||||
// draw over the naturals they overlap without z-fighting.
|
||||
const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
|
||||
y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
|
||||
} else {
|
||||
w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
x = keyX(entry, whiteCount);
|
||||
y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
}
|
||||
// Clone per note so each can glow independently while being consumed.
|
||||
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
|
||||
mesh.position.x = keyX(entry, whiteCount);
|
||||
mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
mesh.position.x = x;
|
||||
mesh.position.y = y;
|
||||
mesh.visible = false;
|
||||
notesGroup.add(mesh);
|
||||
// Note-name label: a camera-facing sprite (readable at this low camera
|
||||
@@ -3037,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) {
|
||||
@@ -3483,6 +3830,7 @@
|
||||
// listening (e.g. changed on the Settings screen, where the live
|
||||
// viz is torn down) must not come up stale on a later init().
|
||||
_palette = readPaletteSetting();
|
||||
_sharpMode = readSharpModeSetting();
|
||||
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||
_theme = readThemeSetting();
|
||||
_bgStyle = readBgStyleSetting();
|
||||
@@ -3533,6 +3881,10 @@
|
||||
_palette = d.palette;
|
||||
_applyPalette();
|
||||
}
|
||||
if (d && d.sharpMode && SHARP_MODES.indexOf(d.sharpMode) !== -1) {
|
||||
// Geometry-time — takes effect on the next chart build.
|
||||
_sharpMode = d.sharpMode;
|
||||
}
|
||||
if (d && d.camera && CAM_PRESETS[d.camera]) {
|
||||
_camPreset = CAM_PRESETS[d.camera];
|
||||
// Position/lookAt re-derive next frame; only the
|
||||
@@ -3733,6 +4085,8 @@
|
||||
};
|
||||
// Pure data-layer + scoring hooks for headless tests.
|
||||
window.slopsmithViz_keys_highway_3d.__test = {
|
||||
_resolveFreeCam,
|
||||
_ssApi,
|
||||
beatDurSec,
|
||||
flattenNotation,
|
||||
keyRange,
|
||||
@@ -3749,6 +4103,8 @@
|
||||
readBgStyleSetting,
|
||||
readPaletteSetting,
|
||||
readCameraSetting,
|
||||
readSharpModeSetting,
|
||||
SHARP_MODES,
|
||||
_bgThemeColors,
|
||||
BG_THEMES,
|
||||
BG_STYLE_IDS,
|
||||
@@ -3757,10 +4113,14 @@
|
||||
PALETTE_IDS,
|
||||
OCTAVE_HUES,
|
||||
octaveNoteColor,
|
||||
_isBlackPc,
|
||||
laneSpanFlat,
|
||||
laneSpanReal,
|
||||
CAM_PRESETS,
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
_pickMidiTarget,
|
||||
};
|
||||
|
||||
// Headless verification hook: lets Playwright drive synthetic note-ons
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colours</label>
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
||||
<select id="keysh3d-fx-palette"
|
||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="octaves" selected>Octaves (colour per octave, darker sharps)</option>
|
||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
@@ -24,7 +24,7 @@
|
||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Choose the colour scheme for the falling notes, key glow, lane
|
||||
Choose the color scheme for the falling notes, key glow, lane
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background gradient, floor and lane rails — the same theme names
|
||||
as the guitar highway. Note colours come from the
|
||||
"Note colours" palette above.
|
||||
as the guitar highway. Note colors come from the
|
||||
"Note colors" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
@@ -101,6 +101,64 @@
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
|
||||
|
||||
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps & flats</label>
|
||||
<select id="keysh3d-fx-sharpmode"
|
||||
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="floating">Floating</option>
|
||||
<option value="flat">Non-floating</option>
|
||||
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
|
||||
plane above the naturals. <em>Non-floating</em>: everything on one
|
||||
plane, each key its own even piano-shaped lane. <em>Realistic key
|
||||
sizes</em>: one plane, bars sized like the real keys (full naturals,
|
||||
full black keys on top). Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-laneopacity"
|
||||
min="0" max="1" step="0.05" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly each lane is tinted its note color. 0.00 (default) is a
|
||||
dark floor with plain guide lines only between the key blocks (at E–F
|
||||
and each octave); the notes keep their colors and pop off the floor.
|
||||
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
|
||||
open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
|
||||
Octave separators
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Widen the gap a little at each octave boundary (every B to the C
|
||||
above it) so octaves are easier to read. Applies next time you open
|
||||
a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-octavecontrast"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly the octave line (every B to C) stands out. It adapts to
|
||||
the lane color opacity automatically — darkening the line against
|
||||
bright lanes and brightening it as you fade them toward the dark
|
||||
floor. Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
<input type="checkbox" id="keysh3d-fx-cinematic" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
|
||||
@@ -184,7 +242,7 @@
|
||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||
Timing colours
|
||||
Timing colors
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
@@ -247,6 +305,9 @@
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
|
||||
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
|
||||
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
@@ -258,6 +319,8 @@
|
||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
|
||||
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
|
||||
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
|
||||
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||
@@ -291,6 +354,11 @@
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
|
||||
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
|
||||
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
|
||||
sharpSel.value = storedSharp;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Camera Director bridge resolver tests: per-panel select, global fallback,
|
||||
// null-when-absent, throw-safety, and the splitscreen global-name alias. Loads
|
||||
// screen.js in a bare vm window and exercises the __test exports (no DOM/WebGL).
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function load() {
|
||||
const window = {
|
||||
console,
|
||||
location: { protocol: 'http:', host: 'localhost' },
|
||||
slopsmith: {},
|
||||
};
|
||||
window.window = window;
|
||||
window.globalThis = window;
|
||||
const context = vm.createContext(window);
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8');
|
||||
vm.runInContext(src, context, { filename: 'screen.js' });
|
||||
return { window, __test: window.slopsmithViz_keys_highway_3d.__test };
|
||||
}
|
||||
|
||||
test('_resolveFreeCam: per-panel camera under splitscreen', () => {
|
||||
const { __test } = load();
|
||||
const c0 = {}, c1 = {};
|
||||
const ss = { panelIndexFor: (c) => (c === c0 ? 0 : 1) };
|
||||
const map = { 0: { id: 'p0' }, 1: { id: 'p1' } };
|
||||
assert.equal(__test._resolveFreeCam(c0, ss, map, { id: 'g' }).id, 'p0');
|
||||
assert.equal(__test._resolveFreeCam(c1, ss, map, { id: 'g' }).id, 'p1');
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when there is no panel map', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, null, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: falls back to global when the panel has no map entry', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => 3 }; // index 3 absent from map
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: null when Camera Director is absent (no global)', () => {
|
||||
const { __test } = load();
|
||||
assert.equal(__test._resolveFreeCam({}, null, null, null), null);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0 }, {}, undefined), null);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: throw-safe on panelIndexFor → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
const ss = { panelIndexFor: () => { throw new Error('boom'); } };
|
||||
assert.equal(__test._resolveFreeCam({}, ss, { 0: {} }, g), g);
|
||||
});
|
||||
|
||||
test('_resolveFreeCam: NaN/negative/float/string index → falls back to global', () => {
|
||||
const { __test } = load();
|
||||
const g = { id: 'global' };
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => NaN }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => -1 }, { 0: {} }, g), g);
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 0.5 }, { 0: {} }, g), g);
|
||||
// A string/prototype key must not resolve an inherited property (e.g. toString).
|
||||
assert.equal(__test._resolveFreeCam({}, { panelIndexFor: () => 'toString' }, {}, g), g);
|
||||
});
|
||||
|
||||
test('_ssApi: null when neither global set; slopsmith alias; feedBack canonical wins', () => {
|
||||
const { window, __test } = load();
|
||||
assert.equal(__test._ssApi(), null);
|
||||
const legacy = { panelIndexFor: () => 0 };
|
||||
window.slopsmithSplitscreen = legacy;
|
||||
assert.equal(__test._ssApi(), legacy); // legacy alias picked up
|
||||
const current = { panelIndexFor: () => 1 };
|
||||
window.feedBackSplitscreen = current;
|
||||
assert.equal(__test._ssApi(), current); // canonical name takes precedence
|
||||
});
|
||||
@@ -188,3 +188,99 @@ test('measureMarkers extracts idx/t pairs', () => {
|
||||
[{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }],
|
||||
);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: no plugin-local pick defers to the domain-wide selection, not "first device"', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// Fresh install / never picked here — must use the Input Setup global,
|
||||
// NOT fall through to inputs[0].
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: the domain-wide selection is the source of truth — it wins over a stale plugin-local pick', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
// A stale local pick (e.g. left by a pre-fix build's auto-connect) must
|
||||
// NOT override the device the user configured in Settings → Input Setup.
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, 'web-midi::b', true);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick is used as a fallback when no global is configured', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'a', name: 'Device A', key: 'web-midi::a' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a', name: 'Device A', key: 'web-midi::a' }, null, true);
|
||||
assert.equal(target.id, 'a');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: local pick name-recovers when its logicalSourceKey went stale (id regeneration)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
// Same physical device, new id/key across a reload; the saved key/id miss
|
||||
// but the name still matches.
|
||||
const inputs = [{ id: 'a2', name: 'Device A', key: 'web-midi::a2' }];
|
||||
const target = _pickMidiTarget(inputs, { id: 'a1', name: 'Device A', key: 'web-midi::a1' }, null, true);
|
||||
assert.equal(target.id, 'a2');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: domain-wide selection is ignored if it names a blocklisted loopback port', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'IAC Driver Bus 1', key: 'web-midi::thru' },
|
||||
{ id: 'b', name: 'Device B', key: 'web-midi::b' },
|
||||
];
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::thru', true);
|
||||
assert.equal(target.id, 'b'); // falls through to the first non-loopback device
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: when every present device is a loopback, connect to nothing (never a dead port)', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [
|
||||
{ id: 'thru', name: 'MIDI Through Port-0', key: 'web-midi::thru' },
|
||||
{ id: 'iac', name: 'IAC Driver Bus 1', key: 'web-midi::iac' },
|
||||
];
|
||||
// No non-loopback device exists — must NOT fall back to inputs[0] (a port
|
||||
// that carries no input and would silently eat every note).
|
||||
const target = _pickMidiTarget(inputs, null, null, true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: explicit "None" opt-out still wins over any global default', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'a', name: 'Device A', key: 'web-midi::a' }];
|
||||
const target = _pickMidiTarget(inputs, { id: '', name: '' }, 'web-midi::a', true);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: a present global wins even during hotplug recovery', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured global device is present — reconnect to it, don't bail.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::b', false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery (allowFallback=false) preserves an absent configured device instead of grabbing a random one', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
// The configured device ('x', global) is currently unplugged; a transient
|
||||
// recovery must NOT switch to the unrelated device that is present.
|
||||
const target = _pickMidiTarget(inputs, null, 'web-midi::x', false);
|
||||
assert.equal(target, null);
|
||||
});
|
||||
|
||||
test('_pickMidiTarget: recovery with no preference at all still allows a first-hotplug grab', () => {
|
||||
const { _pickMidiTarget } = load();
|
||||
const inputs = [{ id: 'b', name: 'Device B', key: 'web-midi::b' }];
|
||||
const target = _pickMidiTarget(inputs, null, null, false);
|
||||
assert.equal(target.id, 'b');
|
||||
});
|
||||
|
||||
@@ -397,3 +397,156 @@ test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () =>
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
|
||||
|
||||
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
|
||||
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
|
||||
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
|
||||
// Sharp LAYOUT is a string setting, not an FX bool.
|
||||
assert.equal('flatSharps' in FX_DEFAULTS, false);
|
||||
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
|
||||
});
|
||||
|
||||
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetFx('octaveGaps', true);
|
||||
assert.equal(store.keys3d_bg_octaveGaps, '1');
|
||||
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
|
||||
win.keys3dSetFx('laneOpacity', 0.35);
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
|
||||
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '1');
|
||||
win.keys3dSetFx('octaveContrast', 0.8);
|
||||
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
|
||||
});
|
||||
|
||||
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
|
||||
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events[0].detail.sharpMode, 'flat');
|
||||
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
|
||||
// Unknown id ignored (no write, no event).
|
||||
win.keys3dSetSharpMode('bogus');
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
|
||||
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const sh = 2.2, shift = 2.2 / 3;
|
||||
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
|
||||
// cx for one octave: whites on integer slots, blacks on half-slots — the
|
||||
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
|
||||
const CX = {
|
||||
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
|
||||
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
|
||||
};
|
||||
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
|
||||
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
|
||||
const wOf = (s) => s.right - s.left;
|
||||
const w = (m) => wOf(spans[midis.indexOf(m)]);
|
||||
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
|
||||
}
|
||||
// Sharps are all the same width.
|
||||
for (const m of [61, 63, 66, 68, 70]) {
|
||||
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
|
||||
}
|
||||
// The lean evens the naturals: C, D, E, F, B all come out equal.
|
||||
for (const m of [62, 64, 65, 71]) {
|
||||
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
|
||||
}
|
||||
// G and A are the only slightly-smaller naturals (G# can't lean) — still
|
||||
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
|
||||
// (which would leave D at 12−2·sh, far below C's 12−sh).
|
||||
assert.ok(Math.abs(w(67) - w(69)) < 1e-9, 'G == A');
|
||||
assert.ok(w(67) < w(60) && w(67) > 2 * sh, 'G/A a touch smaller, still wider than a sharp');
|
||||
assert.ok(w(60) - w(67) < sh, 'natural spread is under one sharp-width');
|
||||
});
|
||||
|
||||
test('laneSpanReal (V4): naturals uniform, sharps full-width and overlapping', () => {
|
||||
const { laneSpanReal } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { natHalf: 5.64, sharpHalf: 3.2, octGap: 0.9 }; // mirrors LANE_DIMS_REAL
|
||||
const wOf = (s) => s.right - s.left;
|
||||
// Every natural is the same full width, whatever its neighbours.
|
||||
for (const [midi, slot] of [[60, 0], [62, 1], [64, 2], [67, 4], [71, 6]]) {
|
||||
assert.ok(Math.abs(wOf(laneSpanReal(midi, false, slot * 12, dims, false)) - 2 * 5.64) < 1e-9,
|
||||
'natural ' + midi + ' uniform');
|
||||
}
|
||||
// Sharps are the full (wider) black-key width and overlap their naturals.
|
||||
const C = laneSpanReal(60, false, 0, dims, false);
|
||||
const Cs = laneSpanReal(61, true, 6, dims, false);
|
||||
assert.ok(Math.abs(wOf(Cs) - 2 * 3.2) < 1e-9, 'sharp full width');
|
||||
assert.ok(Cs.left < C.right, 'sharp overlaps (tucks over) the natural');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): octaveGaps widens B→C by octGap, sharps unaffected', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 };
|
||||
const gapOff = laneSpanFlat(72, false, 84, dims, false).left - laneSpanFlat(71, false, 72, dims, false).right;
|
||||
const gapOn = laneSpanFlat(72, false, 84, dims, true).left - laneSpanFlat(71, false, 72, dims, true).right;
|
||||
assert.ok(Math.abs((gapOn - gapOff) - dims.octGap) < 1e-9, 'B→C divider grows by octGap');
|
||||
// Sharps are unaffected by the octave-gap option.
|
||||
const s = laneSpanFlat(61, true, 6, dims, true);
|
||||
assert.ok(Math.abs((s.right - s.left) - 2 * dims.sharpHalf) < 1e-9, 'sharp width unchanged by gaps');
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): active-range boundary key is NOT trimmed by an out-of-range neighbor sharp', () => {
|
||||
const { laneSpanFlat } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const dims = { whiteW: 12, sharpHalf: 2.2, shift: 2.2 / 3, octGap: 0.9 }; // mirrors LANE_DIMS_FLAT
|
||||
// F (midi 65, cx 36): its upper neighbor F# (66) is a sharp. When F sits
|
||||
// at range.activeHigh and F# is excluded from the active range, F# never
|
||||
// gets a lane drawn (see the activeLow/activeHigh skip around the
|
||||
// lane-strip loop) — trimming F's right edge for it would leave a dark,
|
||||
// unfilled sliver. The edge should stay full instead.
|
||||
const highBoundary = { activeLow: 60, activeHigh: 65 };
|
||||
const fAtBoundary = laneSpanFlat(65, false, 36, dims, false, highBoundary);
|
||||
assert.ok(Math.abs(fAtBoundary.right - (36 + dims.whiteW / 2)) < 1e-9,
|
||||
'F right edge stays full when F# is out of the active range');
|
||||
// Same key, but now F# IS in the active range: normal zero-overlap
|
||||
// tiling applies — the trim matches the ungated (no-range) call exactly,
|
||||
// so in-range geometry is unaffected by this fix.
|
||||
const highIncluded = { activeLow: 60, activeHigh: 66 };
|
||||
const fWithSharpInRange = laneSpanFlat(65, false, 36, dims, false, highIncluded);
|
||||
const fUngated = laneSpanFlat(65, false, 36, dims, false);
|
||||
assert.ok(Math.abs(fWithSharpInRange.right - fUngated.right) < 1e-9,
|
||||
'F trims normally once F# is back in range');
|
||||
assert.ok(fWithSharpInRange.right < fAtBoundary.right, 'in-range trim is narrower than the boundary full edge');
|
||||
|
||||
// Symmetric case on the low edge: D (midi 62, cx 12), lower neighbor C#
|
||||
// (61) excluded when D sits at range.activeLow.
|
||||
const lowBoundary = { activeLow: 62, activeHigh: 72 };
|
||||
const dAtBoundary = laneSpanFlat(62, false, 12, dims, false, lowBoundary);
|
||||
assert.ok(Math.abs(dAtBoundary.left - (12 - dims.whiteW / 2)) < 1e-9,
|
||||
'D left edge stays full when C# is out of the active range');
|
||||
const lowIncluded = { activeLow: 61, activeHigh: 72 };
|
||||
const dWithSharpInRange = laneSpanFlat(62, false, 12, dims, false, lowIncluded);
|
||||
const dUngated = laneSpanFlat(62, false, 12, dims, false);
|
||||
assert.ok(Math.abs(dWithSharpInRange.left - dUngated.left) < 1e-9,
|
||||
'D trims normally once C# is back in range');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Perf-baseline harness for the module-migration refactor (R0).
|
||||
//
|
||||
// Rerun this after every phase (R0 → R3c) to prove the split does not regress
|
||||
// screen-entry, frame-time, memory, or server latency. It writes a markdown
|
||||
// results block to stdout; paste it into docs/perf-baseline.md (or redirect).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8000 [--n 60] [--soak 30]
|
||||
// node scripts/perf-baseline.mjs --base http://127.0.0.1:8300 --song "Arcturus ….feedpak"
|
||||
//
|
||||
// With --song, it additionally measures the 2D highway's PER-FRAME DRAW cost:
|
||||
// it wraps requestAnimationFrame before any page script runs, tags the frames
|
||||
// in which the highway actually painted (via highway.addDrawHook), starts
|
||||
// playback, and reports draw-frame p50/p95/p99 over --frames seconds. Tagging
|
||||
// matters — roughly half the rAF callbacks belong to other cheap loops, and
|
||||
// averaging them in hides a renderer regression behind ~0.1 ms no-op frames.
|
||||
// This is the metric that gates the highway.js split (R3c); run it before AND
|
||||
// after each highway change on the same machine.
|
||||
//
|
||||
// Maintainer/CI-only dev tooling (uses the committed @playwright/test browser);
|
||||
// never part of the serve or Docker path. Metrics that need a seeded library
|
||||
// with charts (playback frame-time, screen-entry into a live highway) are
|
||||
// clearly labelled — run those against an environment with real songs.
|
||||
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('@playwright/test');
|
||||
|
||||
const args = new Map();
|
||||
for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i].replace(/^--/, ''), process.argv[i + 1]);
|
||||
const BASE = args.get('base') || 'http://127.0.0.1:8000';
|
||||
const N = parseInt(args.get('n') || '60', 10);
|
||||
const SOAK_S = parseInt(args.get('soak') || '30', 10);
|
||||
const SONG = args.get('song') || null;
|
||||
const FRAME_S = parseInt(args.get('frames') || '10', 10);
|
||||
const RUNS = parseInt(args.get('runs') || '3', 10); // repeat frame sampling to show spread
|
||||
|
||||
const pct = (xs, p) => {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
};
|
||||
const ms = (x) => (x == null ? '—' : `${x.toFixed(1)}`);
|
||||
|
||||
// ── Server latency: p50/p95/p99 over N requests per endpoint ──────────────────
|
||||
async function serverLatency(paths) {
|
||||
const rows = [];
|
||||
for (const path of paths) {
|
||||
const t = [];
|
||||
let status = 0;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await fetch(BASE + path);
|
||||
status = r.status;
|
||||
await r.arrayBuffer();
|
||||
} catch { status = -1; }
|
||||
t.push(performance.now() - t0);
|
||||
}
|
||||
rows.push({ path, status, p50: pct(t, 50), p95: pct(t, 95), p99: pct(t, 99) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ── Client: cold boot-to-interactive + idle memory after a soak ───────────────
|
||||
async function clientMetrics() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
const t0 = Date.now();
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const bootMs = Date.now() - t0;
|
||||
|
||||
// performance.memory is Chromium-only; JS heap after settle.
|
||||
const mem0 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
await page.waitForTimeout(SOAK_S * 1000);
|
||||
const mem1 = await page.evaluate(() => (performance.memory ? performance.memory.usedJSHeapSize : null));
|
||||
|
||||
const scripts = await page.evaluate(() =>
|
||||
document.querySelectorAll('script[data-plugin-id]').length);
|
||||
|
||||
await browser.close();
|
||||
return { bootMs, memStartMB: mem0 && mem0 / 1048576, memSoakMB: mem1 && mem1 / 1048576, scripts };
|
||||
}
|
||||
|
||||
// ── 2D highway per-frame draw cost (needs --song + a seeded library) ──────────
|
||||
async function frameTimeOnce(browser) {
|
||||
const page = await browser.newPage();
|
||||
let f, t2, notes;
|
||||
try {
|
||||
// Wrap rAF before any page script; mark frames the highway actually drew.
|
||||
await page.addInitScript(() => {
|
||||
window.__f = [];
|
||||
window.__drew = false;
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.requestAnimationFrame = (cb) => raf((t) => {
|
||||
window.__drew = false;
|
||||
const t0 = performance.now();
|
||||
try { cb(t); } finally { window.__f.push({ ms: performance.now() - t0, drew: window.__drew }); }
|
||||
});
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.evaluate(() => document.getElementById('v3-onboarding')?.remove());
|
||||
await page.evaluate((s) => window.playSong(s), SONG);
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length >= 0 && window.highway?.getSongInfo?.(),
|
||||
null, { timeout: 45000 }).catch(() => {});
|
||||
await page.waitForFunction(() => (window.highway?.getNotes?.() || []).length > 0, null, { timeout: 45000 });
|
||||
await page.evaluate(() => window.highway.addDrawHook(() => { window.__drew = true; }));
|
||||
// Start playback so draw() leaves its paused-throttle path; confirm the clock advances.
|
||||
await page.evaluate(async () => { const a = window.highway.getAudioElement?.(); if (a) a.muted = true; await a?.play?.(); });
|
||||
await page.waitForTimeout(1500);
|
||||
const t1 = await page.evaluate(() => window.highway.getTime());
|
||||
await page.evaluate(() => { window.__f.length = 0; });
|
||||
await page.waitForTimeout(FRAME_S * 1000);
|
||||
({ f, t2, notes } = await page.evaluate(() => ({
|
||||
f: window.__f.slice(), t2: window.highway.getTime(), notes: window.highway.getNotes().length,
|
||||
})));
|
||||
if (!(t2 > t1 + 1)) throw new Error(`clock did not advance (${t1}→${t2}) — measured the paused path`);
|
||||
} finally {
|
||||
// Always close, even when an await above throws — otherwise a failing
|
||||
// run leaks its page until the final browser.close().
|
||||
await page.close();
|
||||
}
|
||||
const drew = f.filter((x) => x.drew).map((x) => x.ms);
|
||||
return { drew, total: f.length, notes };
|
||||
}
|
||||
|
||||
async function frameTime() {
|
||||
const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] });
|
||||
const rows = [];
|
||||
for (let i = 0; i < RUNS; i++) {
|
||||
try {
|
||||
const r = await frameTimeOnce(browser);
|
||||
rows.push({
|
||||
p50: pct(r.drew, 50), p95: pct(r.drew, 95), p99: pct(r.drew, 99),
|
||||
max: Math.max(...r.drew), n: r.drew.length, total: r.total, notes: r.notes,
|
||||
});
|
||||
} catch (e) { rows.push({ error: String(e.message || e) }); }
|
||||
}
|
||||
await browser.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
const server = await serverLatency([
|
||||
'/api/version',
|
||||
'/api/plugins',
|
||||
'/api/library?limit=60',
|
||||
'/api/library/artists',
|
||||
]);
|
||||
const client = await clientMetrics();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let out = `\n<!-- generated by scripts/perf-baseline.mjs @ ${now} against ${BASE} (n=${N}, soak=${SOAK_S}s) -->\n\n`;
|
||||
out += `### Server latency (ms)\n\n| Endpoint | status | p50 | p95 | p99 |\n|---|---|---|---|---|\n`;
|
||||
for (const r of server) out += `| \`${r.path}\` | ${r.status} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} |\n`;
|
||||
out += `\n### Client\n\n| Metric | Value |\n|---|---|\n`;
|
||||
out += `| Cold boot → networkidle | ${client.bootMs} ms |\n`;
|
||||
out += `| JS heap after load | ${client.memStartMB ? client.memStartMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| JS heap after ${SOAK_S}s idle soak | ${client.memSoakMB ? client.memSoakMB.toFixed(1) + ' MB' : '—'} |\n`;
|
||||
out += `| Plugin scripts injected | ${client.scripts} |\n`;
|
||||
if (SONG) {
|
||||
const frames = await frameTime();
|
||||
out += `\n### 2D highway per-frame draw cost — \`${SONG}\` (${RUNS} runs × ${FRAME_S}s)\n\n`;
|
||||
out += `| run | draw frames | p50 | p95 | p99 | max |\n|---|---|---|---|---|---|\n`;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const r = frames[i];
|
||||
if (r.error) { out += `| ${i + 1} | — | \`${r.error}\` | | | |\n`; continue; }
|
||||
out += `| ${i + 1} | ${r.n}/${r.total} | ${ms(r.p50)} | ${ms(r.p95)} | ${ms(r.p99)} | ${ms(r.max)} |\n`;
|
||||
}
|
||||
const p95s = frames.filter((r) => !r.error).map((r) => r.p95);
|
||||
if (p95s.length) out += `\n**p95 spread across runs: ${ms(Math.min(...p95s))}–${ms(Math.max(...p95s))} ms**\n`;
|
||||
} else {
|
||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
||||
out += `> on the 2D highway — pass \`--song "<filename in DLC_DIR>"\` to capture it.\n`;
|
||||
out += `> (3D highway_3d + screen-entry timings are a separate R4 concern.)\n`;
|
||||
}
|
||||
|
||||
console.log(out);
|
||||
+292
-10
@@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
||||
const _LIB_SORT_VALUES = new Set([
|
||||
'artist', 'artist-desc', 'title', 'title-desc',
|
||||
'recent', 'year-desc', 'year', 'tuning',
|
||||
'difficulty', 'difficulty-desc',
|
||||
]);
|
||||
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
||||
// Tree-view expand/collapse persistence. Three states per tree:
|
||||
@@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
</div>
|
||||
${retuneBtn}
|
||||
@@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>`;
|
||||
if (duration)
|
||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||
if (stdRetune)
|
||||
@@ -4860,6 +4864,47 @@ 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;
|
||||
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);
|
||||
}
|
||||
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,
|
||||
@@ -4900,8 +4945,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) {
|
||||
@@ -5119,8 +5168,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.
|
||||
@@ -5138,13 +5191,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
|
||||
@@ -5159,9 +5229,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) {
|
||||
@@ -5193,6 +5264,209 @@ 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(() => {});
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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
|
||||
@@ -11563,6 +11837,14 @@ async function loadPlugins() {
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
|
||||
+58
-5
@@ -3409,21 +3409,57 @@ function createHighway() {
|
||||
if (msg.audio_url) {
|
||||
const audio = document.getElementById('audio');
|
||||
const audioFilename = msg.audio_url.split('/').pop();
|
||||
// Only attempt JUCE routing for /audio/ URLs — sloppak stems
|
||||
// (/api/sloppak/…) are not resolvable via audio-local-path.
|
||||
// /audio/ URLs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack: original audio, no stems) is routable too,
|
||||
// but ONLY under an exclusive-style output — the actual
|
||||
// exclusive check happens at routing time (app.js watcher /
|
||||
// the async block below), not here, because the share mode
|
||||
// can change while the song is loaded. Sloppak stem URLs are
|
||||
// never routable.
|
||||
const isAudioUrl = msg.audio_url.startsWith('/audio/');
|
||||
// "Full mix" covers BOTH single-mix pack shapes:
|
||||
// - stem-less packs (original_audio: in the manifest,
|
||||
// audio_url == original_audio_url), and
|
||||
// - single-stem packs (stems: [full.ogg] only) — the server
|
||||
// puts the full mix in the stems list, has_original_audio
|
||||
// is false, and audio_url points at the one stem. With one
|
||||
// stem there is no per-stem mix to preserve, so routing it
|
||||
// natively loses nothing. Real multi-stem (>1) stays out
|
||||
// until Phase 2.
|
||||
const isFeedpakFullMix = !isAudioUrl
|
||||
&& msg.audio_url.startsWith('/api/sloppak/')
|
||||
&& ((!!msg.has_original_audio && !msg.has_stems)
|
||||
|| (msg.stems || []).length === 1);
|
||||
// Record the loaded song's audio so app.js can re-route it
|
||||
// between the HTML5 and JUCE paths if the audio engine is
|
||||
// started/stopped after the song is already loaded. Set this
|
||||
// unconditionally (not just on reload): when alreadyLoaded is
|
||||
// true the watcher must still see correct, current metadata.
|
||||
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl };
|
||||
window._currentSongAudio = {
|
||||
url: msg.audio_url,
|
||||
juceEligible: isAudioUrl,
|
||||
feedpakFullMix: isFeedpakFullMix,
|
||||
};
|
||||
const alreadyLoaded = window._juceMode
|
||||
? window._juceAudioUrl === msg.audio_url
|
||||
: (audio.src && audio.src.includes(audioFilename));
|
||||
// [feedpak-route] diagnostics: every eligibility input in one
|
||||
// line — shows up in the exported diagnostics bundle. If
|
||||
// has_stems is true the pack is multi-stem and Phase 1
|
||||
// deliberately does not route it (Phase 2 work).
|
||||
console.log('[feedpak-route] song-load:',
|
||||
'url=', msg.audio_url,
|
||||
'isAudioUrl=', isAudioUrl,
|
||||
'isFeedpakFullMix=', isFeedpakFullMix,
|
||||
'has_stems=', !!msg.has_stems,
|
||||
'stems=', (msg.stems || []).length,
|
||||
'has_original_audio=', !!msg.has_original_audio,
|
||||
'format=', msg.format,
|
||||
'alreadyLoaded=', alreadyLoaded,
|
||||
'juceApi=', !!window.feedBackDesktop?.audio);
|
||||
if (!alreadyLoaded) {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (isAudioUrl && juceApi) {
|
||||
if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
|
||||
// Run JUCE routing off the critical message-processing chain
|
||||
// so subsequent notes/chords/ready messages aren't blocked
|
||||
// waiting for IPC + HTTP round-trips. The 'ready' handler
|
||||
@@ -3466,7 +3502,24 @@ function createHighway() {
|
||||
clearTimeout(barrierTimer);
|
||||
if (gen !== _wsGen) return; // navigated away during the wait
|
||||
}
|
||||
if (await juceApi.isAudioRunning()) {
|
||||
// Feedpak full-mix rides the engine ONLY under an
|
||||
// exclusive-style output (shared mode falls through to
|
||||
// the HTML5 fallback below, keeping the WebAudio path
|
||||
// fully working). /audio/ songs route whenever the
|
||||
// engine runs, as before. If the share mode changes
|
||||
// later, the app.js watcher re-evaluates and migrates.
|
||||
let routeToJuce = await juceApi.isAudioRunning();
|
||||
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
|
||||
if (routeToJuce) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
if (isFeedpakFullMix) {
|
||||
const exclFn = window._juceOutputIsExclusive;
|
||||
routeToJuce = !!(await exclFn?.());
|
||||
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
|
||||
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
|
||||
}
|
||||
}
|
||||
if (routeToJuce) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -38,6 +38,9 @@
|
||||
// Mastery = best accuracy across arrangements (song_stats); unscored songs
|
||||
// sort last either way. Ascending surfaces what needs work; never default.
|
||||
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
|
||||
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
|
||||
// songs sort last either way.
|
||||
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
|
||||
];
|
||||
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
|
||||
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
||||
|
||||
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
|
||||
|
||||
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
|
||||
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
|
||||
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
|
||||
|
||||
const audio = {
|
||||
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
const juceApi = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
|
||||
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
|
||||
getBackingDuration: () => Promise.resolve(180),
|
||||
seekBacking: () => Promise.resolve(),
|
||||
startBacking: () => Promise.resolve(),
|
||||
@@ -152,6 +153,87 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'Windows Audio (Exclusive Mode)',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'ASIO',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
|
||||
});
|
||||
|
||||
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
|
||||
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: shared,
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
|
||||
let type = 'Windows Audio (Exclusive Mode)';
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: () => type,
|
||||
});
|
||||
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = url;
|
||||
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
|
||||
|
||||
// Still exclusive: routing is consistent, no switch.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
|
||||
|
||||
// Device switched to shared mid-song: must return to HTML5.
|
||||
type = 'Windows Audio';
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
|
||||
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
|
||||
});
|
||||
|
||||
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||
// graph loads, while classic plugins stay untouched.
|
||||
//
|
||||
// The injection is a single line inside the large async loadPlugins() closure
|
||||
// (it depends on loadedScripts, _removePluginScriptTags, and the
|
||||
// _loadingPluginId completion window), so a faithful behavioural harness would
|
||||
// need to stub the whole loader. Instead this asserts the *structural*
|
||||
// contract in source — the guard exists, is gated (not unconditional), and sits
|
||||
// inside the screen.js injection block before appendChild. The behavioural proof
|
||||
// is the R0 end-to-end live-edit check (a real module plugin booting in-browser).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
function injectionBlock() {
|
||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
test('module plugins are injected as <script type="module">', () => {
|
||||
const block = injectionBlock();
|
||||
assert.match(
|
||||
block,
|
||||
/if\s*\(\s*plugin\.script_type\s*===\s*['"]module['"]\s*\)\s*script\.type\s*=\s*['"]module['"]\s*;/,
|
||||
'expected a guarded `script.type = "module"` keyed on plugin.script_type === "module"',
|
||||
);
|
||||
});
|
||||
|
||||
test('the module type is gated, never set unconditionally', () => {
|
||||
const block = injectionBlock();
|
||||
// Every assignment of script.type in the block must be on the same line as
|
||||
// the plugin.script_type guard (i.e. no bare `script.type = 'module'`).
|
||||
for (const line of block.split('\n')) {
|
||||
if (/script\.type\s*=/.test(line)) {
|
||||
assert.match(line, /plugin\.script_type\s*===\s*['"]module['"]/,
|
||||
`unguarded script.type assignment: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||
const guardAt = src.indexOf('script.type = \'module\'');
|
||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||
'the module guard must live inside the screen.js injection block');
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
// pushes it into the desktop engine's renderer bus while the output device is
|
||||
// exclusive-style — the Phase 2 path for audio the native backing transport
|
||||
// cannot carry. These tests extract that IIFE from source and exercise
|
||||
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
|
||||
// under exclusive output, disengagement on return to shared mode, inertness
|
||||
// in shared mode / while the native transport owns the song, and the
|
||||
// element-capture fallback.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
|
||||
const tail = src.slice(i, i + 5);
|
||||
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
|
||||
return src.slice(start, i) + ')();';
|
||||
}
|
||||
|
||||
function makeFakeContext(sampleRate = 48000) {
|
||||
const ctx = {
|
||||
sampleRate,
|
||||
state: 'running',
|
||||
sinkIdCalls: [],
|
||||
destination: { isDestination: true },
|
||||
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
|
||||
resume() { this.state = 'running'; return Promise.resolve(); },
|
||||
audioWorklet: { addModule: () => Promise.resolve() },
|
||||
createMediaElementSource(el) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
constructor() { this.port = { onmessage: null }; }
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
|
||||
Blob: class { constructor() {} },
|
||||
AudioWorkletNode: FakeWorkletNode,
|
||||
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
|
||||
WeakSet, WeakMap, Promise, Float32Array, Math,
|
||||
setInterval: () => 0,
|
||||
document: {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
getElementById: () => sandbox.__audioEl,
|
||||
},
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
feedBackDesktop: { audio: api },
|
||||
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
|
||||
_juceMode: false,
|
||||
_currentSongAudio: null,
|
||||
feedBack: { stems: {} },
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
'feeder must expose window._reevaluateRendererBus');
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function makeStemsGraph() {
|
||||
return {
|
||||
context: makeFakeContext(),
|
||||
masterNode: { connect() {}, disconnect() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
});
|
||||
|
||||
test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
let excl = true;
|
||||
const sb = makeSandbox({ exclusive: () => excl });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
|
||||
});
|
||||
|
||||
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => false });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
running = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
|
||||
});
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
-1
@@ -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),
|
||||
|
||||
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
|
||||
|
||||
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36, 54],
|
||||
beats=[
|
||||
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
@@ -247,6 +247,44 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
# Times are captured (rounded to 3 dp).
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[54]["times"] == [1.5]
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
|
||||
"""Multi-voice measures can capture times out of order; the final sort
|
||||
must reorder velocities WITH their times, not leave them behind."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[
|
||||
# Deliberately reversed chronology within the measure.
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]),
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[56]["velocities"] == [88, 44], \
|
||||
"velocity must follow its time through the sort"
|
||||
|
||||
|
||||
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
|
||||
"""A corrupt/zero GP velocity records the 100 import default rather
|
||||
than poisoning the aligned list."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["velocities"] == [100]
|
||||
|
||||
|
||||
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -293,6 +293,45 @@ def test_year_sort_asc_oldest_first(client, seeded):
|
||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
|
||||
|
||||
def test_difficulty_sort_pushes_unrated_to_bottom(client, server_mod):
|
||||
"""Personal difficulty (song_user_meta.user_difficulty) sorts like
|
||||
mastery: an unrated (NULL) row must fall to the bottom in BOTH
|
||||
directions rather than colliding with a real 1..5 rating at either
|
||||
end."""
|
||||
_put(server_mod, filename="easy.archive", title="Easy", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="hard.archive", title="Hard", artist="B",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="C",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("easy.archive", user_difficulty=1)
|
||||
server_mod.meta_db.set_song_user_meta("hard.archive", user_difficulty=5)
|
||||
|
||||
asc = [s["filename"] for s in _get(client, sort="difficulty")["songs"]]
|
||||
assert asc == ["easy.archive", "hard.archive", "unrated.archive"]
|
||||
|
||||
desc = [s["filename"] for s in _get(client, sort="difficulty-desc")["songs"]]
|
||||
assert desc == ["hard.archive", "easy.archive", "unrated.archive"]
|
||||
|
||||
|
||||
def test_tree_view_songs_carry_user_difficulty(client, server_mod):
|
||||
"""`/api/library/artists` (the classic tree view's `query_artists`) must
|
||||
batch-attach `user_difficulty` the same way `query_page` does for the
|
||||
grid — otherwise the tree view's difficulty badge silently never
|
||||
renders (song.user_difficulty stays undefined for every row)."""
|
||||
_put(server_mod, filename="rated.archive", title="Rated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("rated.archive", user_difficulty=4)
|
||||
|
||||
data = client.get("/api/library/artists").json()
|
||||
songs = data["artists"][0]["albums"][0]["songs"]
|
||||
by_filename = {s["filename"]: s for s in songs}
|
||||
assert by_filename["rated.archive"]["user_difficulty"] == 4
|
||||
assert by_filename["unrated.archive"]["user_difficulty"] is None
|
||||
|
||||
|
||||
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
|
||||
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
|
||||
must come before the up-tuned one so the order matches the chart's
|
||||
|
||||
@@ -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
|
||||
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
|
||||
|
||||
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
|
||||
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=44, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
|
||||
unmapped: dict[int, dict] = {}
|
||||
@@ -209,6 +209,10 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
|
||||
assert all(isinstance(t, float) for t in unmapped[56]["times"])
|
||||
assert len(unmapped[56]["times"]) == 2
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_non_channel9_events_ignored(tmp_path):
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,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()
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for the plugin `src/` module-serving route and the live-edit cache
|
||||
contract added in R0 (module-migration rails).
|
||||
|
||||
Covers:
|
||||
* GET /api/plugins/{id}/src/{path} serves a plugin's ES-module source tree
|
||||
with the right Content-Type, including nested paths.
|
||||
* Path containment: `..`, absolute, and NUL are rejected (404) — the same
|
||||
`safe_join` guard the assets/ route uses.
|
||||
* The live-edit cache contract: no-cache + a weak ETag, a bodyless 304 on
|
||||
matching If-None-Match, and no stale 304 after an in-place edit.
|
||||
* screen.js and assets/ now also emit an ETag and honor If-None-Match
|
||||
(previously screen.js sent no headers and assets/ never returned 304).
|
||||
|
||||
The routes read the module-global `plugins.LOADED_PLUGINS`, so each test
|
||||
registers a fake ready plugin directly (save/restore that global) and drives
|
||||
`register_plugin_api` on a fresh FastAPI app — no full server import needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import plugins
|
||||
|
||||
|
||||
PLUGIN_ID = "srctest"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
"""A TestClient with `register_plugin_api` wired and a single fake ready
|
||||
plugin whose dir (`tmp_path`) holds a src/ tree, an asset, and a screen.js.
|
||||
Restores LOADED_PLUGINS afterward."""
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.js").write_text("import './util/x.js';\nexport const boot = 1;\n")
|
||||
(tmp_path / "src" / "util").mkdir()
|
||||
(tmp_path / "src" / "util" / "x.js").write_text("export const x = 42;\n")
|
||||
(tmp_path / "src" / "theme.css").write_text(".a{color:red}\n")
|
||||
(tmp_path / "assets").mkdir()
|
||||
(tmp_path / "assets" / "worklet.js").write_text("// worklet\n")
|
||||
(tmp_path / "screen.js").write_text("import './src/main.js';\n")
|
||||
|
||||
saved = list(plugins.LOADED_PLUGINS)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.append({
|
||||
"id": PLUGIN_ID,
|
||||
"status": "ready",
|
||||
"_dir": tmp_path,
|
||||
"_manifest": {"script": "screen.js", "scriptType": "module"},
|
||||
})
|
||||
app = FastAPI()
|
||||
plugins.register_plugin_api(app)
|
||||
c = TestClient(app, raise_server_exceptions=True)
|
||||
try:
|
||||
yield c, tmp_path
|
||||
finally:
|
||||
c.close()
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved)
|
||||
|
||||
|
||||
def test_src_file_served_with_js_media_type(client):
|
||||
c, _ = client
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
assert r.status_code == 200
|
||||
# Either application/javascript or text/javascript is a valid module-script
|
||||
# MIME (guess_type returns text/javascript on newer platforms); browsers
|
||||
# accept both for <script type=module>.
|
||||
assert "javascript" in r.headers["content-type"]
|
||||
assert "export const boot" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.headers.get("etag")
|
||||
|
||||
|
||||
def test_src_nested_path_and_css_media_type(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/util/x.js").status_code == 200
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/theme.css")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/css")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"..%2f..%2fplugin.json", # escape the src/ dir
|
||||
"..%2f..%2f..%2fetc%2fpasswd",
|
||||
"%2fetc%2fpasswd", # absolute
|
||||
"util%2f..%2f..%2fscreen.js",
|
||||
])
|
||||
def test_src_traversal_rejected(client, bad):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}").status_code == 404
|
||||
|
||||
|
||||
def test_src_missing_is_404(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/nope.js").status_code == 404
|
||||
|
||||
|
||||
def test_src_conditional_304(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
assert r2.content == b""
|
||||
|
||||
|
||||
def test_src_no_stale_304_after_edit(client):
|
||||
c, root = client
|
||||
etag = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").headers["etag"]
|
||||
(root / "src" / "main.js").write_text("export const boot = 2; // edited, longer body\n")
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r.status_code == 200
|
||||
assert "boot = 2" in r.text
|
||||
assert r.headers["etag"] != etag
|
||||
|
||||
|
||||
def test_screen_js_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
|
||||
assert r1.status_code == 200
|
||||
assert r1.headers["cache-control"] == "no-cache"
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_asset_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
|
||||
assert r1.status_code == 200
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_unready_plugin_src_is_404(client):
|
||||
c, _ = client
|
||||
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user