mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 08:18:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a3c256fa | ||
|
|
8c23270442 | ||
|
|
f3104b35a9 |
@@ -123,28 +123,3 @@ jobs:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||||
EOF
|
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,30 +48,11 @@ output is committed, so Docker / desktop / end users never build. New
|
|||||||
features extend `app.js` and the existing globals (`window.playSong`,
|
features extend `app.js` and the existing globals (`window.playSong`,
|
||||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
`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**
|
**Non-negotiable rules**
|
||||||
|
|
||||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
- 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
|
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||||
source-served.
|
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
|
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||||
classes present in core source at build time. Core's committed
|
classes present in core source at build time. Core's committed
|
||||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||||
@@ -233,15 +214,6 @@ no `..`, no absolute paths).
|
|||||||
runs first). Plugins MUST tolerate dependent globals being absent
|
runs first). Plugins MUST tolerate dependent globals being absent
|
||||||
at load time and check at runtime
|
at load time and check at runtime
|
||||||
(`typeof window.X === 'function'`).
|
(`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
|
## Development Workflow
|
||||||
|
|
||||||
@@ -284,4 +256,4 @@ no `..`, no absolute paths).
|
|||||||
higher-numbered principle's escape hatch is to live in a plugin
|
higher-numbered principle's escape hatch is to live in a plugin
|
||||||
with its own bundled assets.
|
with its own bundled assets.
|
||||||
|
|
||||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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
|
- **`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
|
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
|
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||||
@@ -33,8 +29,6 @@ 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.
|
- **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
|
### 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`.
|
- **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`).
|
- **"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`.
|
- **`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,8 +117,6 @@ 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.
|
**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.
|
**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
|
## Plugin Best Practices
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
### 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.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# 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,821) · `static/highway.js` (4,154, whole file) · `server.py`
|
|
||||||
(13,948) · `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.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
// 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) } })),
|
|
||||||
];
|
|
||||||
+11
-42
@@ -1114,7 +1114,7 @@ def _build_xml(
|
|||||||
ET.SubElement(root, "arrangement").text = arrangement
|
ET.SubElement(root, "arrangement").text = arrangement
|
||||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||||
ET.SubElement(root, "artistName").text = artist
|
ET.SubElement(root, "artistName").text = artist
|
||||||
ET.SubElement(root, "albumName").text = album
|
ET.SubElement(root, "albumName").text = album
|
||||||
@@ -1139,17 +1139,10 @@ def _build_xml(
|
|||||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||||
ET.SubElement(root, "capo").text = "0"
|
ET.SubElement(root, "capo").text = "0"
|
||||||
|
|
||||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
# Ebeats
|
||||||
# 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)))
|
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||||
for b in beats:
|
for b in beats:
|
||||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||||
|
|
||||||
# Sections
|
# Sections
|
||||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||||
@@ -1843,10 +1836,9 @@ def convert_drum_track_to_drumtab(
|
|||||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
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
|
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
times capped at 100 samples per note) so they can surface a warning or
|
||||||
100 samples per note — velocities carry the source notes' real dynamics)
|
offer a manual mapping UI.
|
||||||
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
|
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||||
@@ -1902,29 +1894,18 @@ def convert_drum_track_to_drumtab(
|
|||||||
# NB: do NOT shadow the outer `entry` loop
|
# NB: do NOT shadow the outer `entry` loop
|
||||||
# variable from `for entry in schedule:`.
|
# variable from `for entry in schedule:`.
|
||||||
unmapped_rec = out_unmapped.setdefault(
|
unmapped_rec = out_unmapped.setdefault(
|
||||||
int(midi_note),
|
int(midi_note), {"count": 0, "times": []})
|
||||||
{"count": 0, "times": [], "velocities": []})
|
|
||||||
unmapped_rec["count"] += 1
|
unmapped_rec["count"] += 1
|
||||||
if len(unmapped_rec["times"]) < 100:
|
if len(unmapped_rec["times"]) < 100:
|
||||||
unmapped_rec["times"].append(round(t, 3))
|
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
|
continue
|
||||||
|
|
||||||
hit: dict = {"t": round(t, 3), "p": piece}
|
hit: dict = {"t": round(t, 3), "p": piece}
|
||||||
|
|
||||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||||
# this is GP's *authoring* default (95, Velocities.default)
|
# is 95 (Velocities.default). Pass through verbatim,
|
||||||
# — unrelated to the drumtab render default of 100
|
# clamping defensively so a corrupt file can't poison the
|
||||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
# wire format.
|
||||||
# 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)
|
vel = int(getattr(note, "velocity", 0) or 0)
|
||||||
if 1 <= vel <= 127:
|
if 1 <= vel <= 127:
|
||||||
hit["v"] = vel
|
hit["v"] = vel
|
||||||
@@ -1965,20 +1946,8 @@ def convert_drum_track_to_drumtab(
|
|||||||
# Times for unmapped notes were collected in beat-iteration order;
|
# Times for unmapped notes were collected in beat-iteration order;
|
||||||
# multi-voice measures can produce out-of-order beats, so sort each
|
# multi-voice measures can produce out-of-order beats, so sort each
|
||||||
# entry's `times` list chronologically before returning to the caller.
|
# 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:
|
if out_unmapped is not None:
|
||||||
for _rec in out_unmapped.values():
|
for _rec in out_unmapped.values():
|
||||||
_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()
|
_rec["times"].sort()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
+5
-10
@@ -634,12 +634,10 @@ def convert_drum_track_from_midi(
|
|||||||
|
|
||||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
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
|
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
capped at 100 samples per note). The default path skips this
|
||||||
capped at 100 samples per note — velocities carry the source notes'
|
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
no extra work.
|
||||||
default). The default path skips this capture entirely so MIDIs
|
|
||||||
heavy with cowbell/tambourine/etc. take no extra work.
|
|
||||||
"""
|
"""
|
||||||
offset = float(audio_offset)
|
offset = float(audio_offset)
|
||||||
if not math.isfinite(offset):
|
if not math.isfinite(offset):
|
||||||
@@ -677,13 +675,10 @@ def convert_drum_track_from_midi(
|
|||||||
continue
|
continue
|
||||||
t = tick_to_seconds(abs_tick) + offset
|
t = tick_to_seconds(abs_tick) + offset
|
||||||
entry = out_unmapped.setdefault(
|
entry = out_unmapped.setdefault(
|
||||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
midi_note, {"count": 0, "times": []})
|
||||||
entry["count"] += 1
|
entry["count"] += 1
|
||||||
if len(entry["times"]) < 100:
|
if len(entry["times"]) < 100:
|
||||||
entry["times"].append(round(t, 3))
|
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
|
continue
|
||||||
# Mapped note: compute t once for the raw entry.
|
# Mapped note: compute t once for the raw entry.
|
||||||
t = tick_to_seconds(abs_tick) + offset
|
t = tick_to_seconds(abs_tick) + offset
|
||||||
|
|||||||
Generated
+1
-1758
File diff suppressed because it is too large
Load Diff
+2
-5
@@ -8,12 +8,9 @@
|
|||||||
"test:headed": "playwright test --headed",
|
"test:headed": "playwright test --headed",
|
||||||
"test:debug": "playwright test --debug",
|
"test:debug": "playwright test --debug",
|
||||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
"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": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1"
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-plugin-import-x": "^4.17.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-99
@@ -18,54 +18,6 @@ from safepath import safe_join
|
|||||||
log = logging.getLogger("feedBack.plugins")
|
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
|
PLUGINS_DIR = Path(__file__).parent
|
||||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||||
@@ -1421,12 +1373,6 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
|||||||
"version": manifest.get("version"),
|
"version": manifest.get("version"),
|
||||||
"has_screen": bool(manifest.get("screen")),
|
"has_screen": bool(manifest.get("screen")),
|
||||||
"has_script": bool(manifest.get("script")),
|
"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")),
|
"has_settings": bool(manifest.get("settings")),
|
||||||
"settings_category": _settings_category,
|
"settings_category": _settings_category,
|
||||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||||
@@ -2143,11 +2089,6 @@ def register_plugin_api(app: FastAPI):
|
|||||||
"fallback": p.get("fallback", False),
|
"fallback": p.get("fallback", False),
|
||||||
"has_screen": p["has_screen"],
|
"has_screen": p["has_screen"],
|
||||||
"has_script": p["has_script"],
|
"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"],
|
"has_settings": p["has_settings"],
|
||||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||||
"fullscreen": p.get("fullscreen", False),
|
"fullscreen": p.get("fullscreen", False),
|
||||||
@@ -2201,9 +2142,6 @@ def register_plugin_api(app: FastAPI):
|
|||||||
"fallback": False,
|
"fallback": False,
|
||||||
"has_screen": e.get("has_screen", False),
|
"has_screen": e.get("has_screen", False),
|
||||||
"has_script": e.get("has_script", 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),
|
"has_settings": e.get("has_settings", False),
|
||||||
"settings_category": e.get("settings_category"),
|
"settings_category": e.get("settings_category"),
|
||||||
"fullscreen": e.get("fullscreen", False),
|
"fullscreen": e.get("fullscreen", False),
|
||||||
@@ -2369,7 +2307,7 @@ def register_plugin_api(app: FastAPI):
|
|||||||
return HTMLResponse("", status_code=404)
|
return HTMLResponse("", status_code=404)
|
||||||
|
|
||||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||||
def plugin_screen_js(request: Request, plugin_id: str):
|
def plugin_screen_js(plugin_id: str):
|
||||||
with PLUGINS_LOCK:
|
with PLUGINS_LOCK:
|
||||||
snapshot = list(LOADED_PLUGINS)
|
snapshot = list(LOADED_PLUGINS)
|
||||||
for p in snapshot:
|
for p in snapshot:
|
||||||
@@ -2377,11 +2315,8 @@ def register_plugin_api(app: FastAPI):
|
|||||||
if p.get("status", "ready") != "ready":
|
if p.get("status", "ready") != "ready":
|
||||||
break
|
break
|
||||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||||
if script_file.is_file():
|
if script_file.exists():
|
||||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||||
# 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)
|
return Response("", status_code=404)
|
||||||
|
|
||||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||||
@@ -2442,7 +2377,7 @@ def register_plugin_api(app: FastAPI):
|
|||||||
return Response("{}", status_code=404, media_type="application/json")
|
return Response("{}", status_code=404, media_type="application/json")
|
||||||
|
|
||||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
def plugin_asset(plugin_id: str, asset_path: str):
|
||||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||||
fixed screen.js/settings.html handlers above, this is a generic
|
fixed screen.js/settings.html handlers above, this is a generic
|
||||||
@@ -2464,35 +2399,16 @@ def register_plugin_api(app: FastAPI):
|
|||||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||||
break
|
break
|
||||||
if target.is_file():
|
if target.is_file():
|
||||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
media_type = mimetypes.guess_type(target.name)[0]
|
||||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
# .js must come back as JavaScript so addModule() / <script>
|
||||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
# accept it; guess_type can miss this on some platforms.
|
||||||
break
|
if media_type is None and target.suffix == ".js":
|
||||||
return Response("", status_code=404)
|
media_type = "application/javascript"
|
||||||
|
# .css must come back as text/css so a <link rel=stylesheet>
|
||||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
# (the styles capability) is honoured; guess_type can miss it
|
||||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
# on a stripped platform mimetypes registry, same as .js.
|
||||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
elif media_type is None and target.suffix == ".css":
|
||||||
|
media_type = "text/css"
|
||||||
This is the R0 host capability that lets a migrated plugin's
|
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||||
``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
|
break
|
||||||
return Response("", status_code=404)
|
return Response("", status_code=404)
|
||||||
|
|||||||
@@ -3,34 +3,13 @@
|
|||||||
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).
|
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).
|
- 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 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.
|
- 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.
|
||||||
- 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).
|
- 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.
|
- 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.
|
- 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).
|
- **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).
|
- **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 color, ~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 colour, ~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.
|
- **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`.
|
- **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() }`.
|
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "keys_highway_3d",
|
"id": "keys_highway_3d",
|
||||||
"name": "Keys Highway 3D",
|
"name": "Keys Highway 3D",
|
||||||
"version": "0.2.0",
|
"version": "0.1.2",
|
||||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||||
"type": "visualization",
|
"type": "visualization",
|
||||||
"bundled": true,
|
"bundled": true,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//
|
//
|
||||||
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
||||||
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
||||||
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
|
// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a
|
||||||
// secondary brightness cue), active-range key highlighting with letters,
|
// secondary brightness cue), active-range key highlighting with letters,
|
||||||
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
||||||
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
|
// 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.
|
// World scroll speed (units / second) — matches the sibling highways.
|
||||||
const TS = 130 * K;
|
const TS = 130 * K;
|
||||||
|
|
||||||
// Per-pitch-class colors (Synthesia convention observed in the RS+
|
// Per-pitch-class colours (Synthesia convention observed in the RS+
|
||||||
// reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
|
// 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
|
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
|
||||||
// their neighbours so black-key notes stay distinguishable.
|
// their neighbours so black-key notes stay distinguishable.
|
||||||
@@ -79,11 +79,11 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
|
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
|
||||||
// at full brightness, left hand slightly darkened — color stays the
|
// at full brightness, left hand slightly darkened — colour stays the
|
||||||
// pitch class.
|
// pitch class.
|
||||||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||||||
|
|
||||||
// Selectable note-color palettes. Index = midi % 12, same contract as
|
// Selectable note-colour palettes. Index = midi % 12, same contract as
|
||||||
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
|
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
|
||||||
// entry, so anyone who never touches the setting sees the stock look.
|
// entry, so anyone who never touches the setting sees the stock look.
|
||||||
// Two palette families:
|
// Two palette families:
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Octave-based color scheme ('octaves'): every octave gets a distinct
|
// Octave-based colour scheme ('octaves'): every octave gets a distinct
|
||||||
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
|
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
|
||||||
// blend — so each octave is uniquely identifiable, but neighbouring
|
// blend — so each octave is uniquely identifiable, but neighbouring
|
||||||
// octaves stay close so the change isn't jarring). Loops if a song runs
|
// octaves stay close so the change isn't jarring). Loops if a song runs
|
||||||
@@ -181,80 +181,7 @@
|
|||||||
function _isBlackPc(midi) {
|
function _isBlackPc(midi) {
|
||||||
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
|
return [1, 3, 6, 8, 10].indexOf(((midi % 12) + 12) % 12) !== -1;
|
||||||
}
|
}
|
||||||
// Which way a sharp leans to even out the naturals: toward the EDGE
|
// Colour (24-bit int) for a midi note under the octave scheme: hue by
|
||||||
// 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.
|
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
|
||||||
function octaveNoteColor(midi) {
|
function octaveNoteColor(midi) {
|
||||||
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
|
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
|
||||||
@@ -283,8 +210,8 @@
|
|||||||
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
|
// 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 —
|
// 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
|
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
|
||||||
// greyscale so one geometry serves every pitch-class color; the material
|
// greyscale so one geometry serves every pitch-class colour; the material
|
||||||
// multiplies its color by it via vertexColors.
|
// multiplies its colour 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 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'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
@@ -1099,14 +1026,6 @@
|
|||||||
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
|
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
|
||||||
bgIntensity: 0.5, // background-ambience density/strength
|
bgIntensity: 0.5, // background-ambience density/strength
|
||||||
bgReactive: true, // background reacts to the audio analyser
|
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
|
// Camera base-rig fine-tune. These shift the BASE vantage point the
|
||||||
// auto-pan/zoom follow-motion is built on (they multiply/offset 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
|
// active CAM_PRESET before the per-frame pan + dolly), so the camera
|
||||||
@@ -1304,7 +1223,7 @@
|
|||||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Note-color palette id — string-valued like the theme, so it gets its
|
// Note-colour palette id — string-valued like the theme, so it gets its
|
||||||
// own validated key + setter rather than an FX_DEFAULTS slot.
|
// own validated key + setter rather than an FX_DEFAULTS slot.
|
||||||
const FX_LS_PALETTE = 'keys3d_bg_palette';
|
const FX_LS_PALETTE = 'keys3d_bg_palette';
|
||||||
function readPaletteSetting() {
|
function readPaletteSetting() {
|
||||||
@@ -1312,7 +1231,7 @@
|
|||||||
const id = localStorage.getItem(FX_LS_PALETTE);
|
const id = localStorage.getItem(FX_LS_PALETTE);
|
||||||
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
|
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
// Default: the octave scheme (each octave its own color, darker
|
// Default: the octave scheme (each octave its own colour, darker
|
||||||
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
||||||
// selectable.
|
// selectable.
|
||||||
return 'octaves';
|
return 'octaves';
|
||||||
@@ -1325,30 +1244,6 @@
|
|||||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
} 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
|
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
|
||||||
// (numerically identical to the historical constants, so 'classic' with the
|
// (numerically identical to the historical constants, so 'classic' with the
|
||||||
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
|
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
|
||||||
@@ -1666,7 +1561,6 @@
|
|||||||
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
||||||
let _theme = readThemeSetting();
|
let _theme = readThemeSetting();
|
||||||
let _palette = readPaletteSetting();
|
let _palette = readPaletteSetting();
|
||||||
let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
|
|
||||||
let ambLight = null, dirLight = null;
|
let ambLight = null, dirLight = null;
|
||||||
let _floorMat = null;
|
let _floorMat = null;
|
||||||
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
||||||
@@ -1734,7 +1628,7 @@
|
|||||||
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base color (24-bit int, no hand dimming) for a midi note under the
|
// Base colour (24-bit int, no hand dimming) for a midi note under the
|
||||||
// active palette — the octave scheme is procedural, every other
|
// active palette — the octave scheme is procedural, every other
|
||||||
// palette is a 12-entry pitch-class table.
|
// palette is a 12-entry pitch-class table.
|
||||||
function _noteHex(midi) {
|
function _noteHex(midi) {
|
||||||
@@ -1770,41 +1664,6 @@
|
|||||||
const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
|
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 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
|
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
|
// Camera — the default 'classic' preset is a low, near-telephoto rig
|
||||||
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
||||||
@@ -1849,7 +1708,7 @@
|
|||||||
_rigOut.lookZ = _camPreset.lookZ;
|
_rigOut.lookZ = _camPreset.lookZ;
|
||||||
return _rigOut;
|
return _rigOut;
|
||||||
}
|
}
|
||||||
// Per-key approach glow: a key lights in its pitch-class color ONLY while a
|
// Per-key approach glow: a key lights in its pitch-class colour ONLY while a
|
||||||
// note is heading for it, ramping up the closer that note gets to the hit-line.
|
// 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_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)
|
const KEY_GLOW_STRENGTH = 1.15; // peak emissive intensity (note at the hit-line)
|
||||||
@@ -2438,9 +2297,9 @@
|
|||||||
});
|
});
|
||||||
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
||||||
geo.translate(0, 0, -depth / 2);
|
geo.translate(0, 0, -depth / 2);
|
||||||
// Bake a vertical brightness ramp into vertex colors (bottom shade →
|
// Bake a vertical brightness ramp into vertex colours (bottom shade →
|
||||||
// top highlight) so the gem reads 3D; the material multiplies its
|
// top highlight) so the gem reads 3D; the material multiplies its
|
||||||
// pitch-class color by this (vertexColors).
|
// pitch-class colour by this (vertexColors).
|
||||||
geo.computeBoundingBox();
|
geo.computeBoundingBox();
|
||||||
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
||||||
const pos = geo.attributes.position;
|
const pos = geo.attributes.position;
|
||||||
@@ -2455,10 +2314,10 @@
|
|||||||
return geo;
|
return geo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glossy note material, cached per resolved color. Keying by the
|
// Glossy note material, cached per resolved colour. Keying by the
|
||||||
// final color int (hand brightness already baked in by noteColor)
|
// final colour int (hand brightness already baked in by noteColor)
|
||||||
// works for every palette — including 'octaves', where two notes of
|
// works for every palette — including 'octaves', where two notes of
|
||||||
// the same pitch class in different octaves are DIFFERENT colors and
|
// the same pitch class in different octaves are DIFFERENT colours and
|
||||||
// must not share a material (a pitch-class key would collide them).
|
// must not share a material (a pitch-class key would collide them).
|
||||||
function _noteMaterial(midi, hand) {
|
function _noteMaterial(midi, hand) {
|
||||||
const col = noteColor(midi, hand);
|
const col = noteColor(midi, hand);
|
||||||
@@ -2474,7 +2333,7 @@
|
|||||||
// share one shader program.)
|
// share one shader program.)
|
||||||
mat = new T.MeshPhysicalMaterial({
|
mat = new T.MeshPhysicalMaterial({
|
||||||
color: col,
|
color: col,
|
||||||
vertexColors: true, // multiply color by the baked gem ramp
|
vertexColors: true, // multiply colour by the baked gem ramp
|
||||||
emissive: col,
|
emissive: col,
|
||||||
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
||||||
roughness: 0.32,
|
roughness: 0.32,
|
||||||
@@ -2496,10 +2355,7 @@
|
|||||||
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||||
}
|
}
|
||||||
function _laneGuideOpacity() {
|
function _laneGuideOpacity() {
|
||||||
// Vibrancy sets the ceiling (much brighter than the old subtle
|
return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||||
// 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
|
// Live vibrancy slider: retint everything already built — the
|
||||||
@@ -2515,13 +2371,13 @@
|
|||||||
for (const m of _laneGuideMats) m.opacity = lop;
|
for (const m of _laneGuideMats) m.opacity = lop;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live palette switch: recolor everything already built — cached
|
// Live palette switch: recolour everything already built — cached
|
||||||
// note materials (future clones), per-note clones, key emissives
|
// note materials (future clones), per-note clones, key emissives
|
||||||
// (incl. the wrong-flash restore state), lane guides — and drop the
|
// (incl. the wrong-flash restore state), lane guides — and drop the
|
||||||
// pitch-class flame textures so the next spawn bakes the new hues.
|
// pitch-class flame textures so the next spawn bakes the new hues.
|
||||||
// Same no-rebuild approach as _applyVibrancy.
|
// Same no-rebuild approach as _applyVibrancy.
|
||||||
function _applyPalette() {
|
function _applyPalette() {
|
||||||
// The base-material cache is keyed by resolved color, so old
|
// The base-material cache is keyed by resolved colour, so old
|
||||||
// entries are simply stale under a new palette — drop them and let
|
// entries are simply stale under a new palette — drop them and let
|
||||||
// the next build re-cache. The live per-note clones below are
|
// the next build re-cache. The live per-note clones below are
|
||||||
// retinted directly from each note's midi (palette-correct).
|
// retinted directly from each note's midi (palette-correct).
|
||||||
@@ -2585,10 +2441,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vertical flame texture for hit flares / held-key halos: white-hot
|
// Vertical flame texture for hit flares / held-key halos: white-hot
|
||||||
// base fading up into the note's color, with a horizontal falloff.
|
// base fading up into the note's colour, with a horizontal falloff.
|
||||||
// Cached per resolved color (bounded — 12 for pitch-class palettes,
|
// Cached per resolved colour (bounded — 12 for pitch-class palettes,
|
||||||
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
||||||
// struck note's color whatever the palette.
|
// struck note's colour whatever the palette.
|
||||||
function _flameTexture(midi) {
|
function _flameTexture(midi) {
|
||||||
const c = _noteHex(midi);
|
const c = _noteHex(midi);
|
||||||
let tex = _flameTexCache.get(c);
|
let tex = _flameTexCache.get(c);
|
||||||
@@ -2768,9 +2624,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lane guides: a faint color strip running up the runway from each
|
// Lane guides: a faint colour strip running up the runway from each
|
||||||
// active key, in that key's pitch-class color. A falling note shares
|
// active key, in that key's pitch-class colour. A falling note shares
|
||||||
// its target key's color, so the player can trace it straight down
|
// its target key's colour, so the player can trace it straight down
|
||||||
// its lane to the right key even when it sits near the frame edge.
|
// 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
|
// The lanes sit at the NOTES' travel height (coplanar), not on the
|
||||||
@@ -2780,87 +2636,29 @@
|
|||||||
// lane, perfectly aligned with the lane and its key.
|
// lane, perfectly aligned with the lane and its key.
|
||||||
const guideLen = HIGHWAY_LEN - WHITE_L;
|
const guideLen = HIGHWAY_LEN - WHITE_L;
|
||||||
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
|
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) {
|
for (const [midi, entry] of layout) {
|
||||||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||||||
// Floating: white-only lanes (blacks float, lane-less). Flat/
|
if (entry.black) continue; // one strip per semitone-slot lands on whites
|
||||||
// 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({
|
const gmat = new T.MeshBasicMaterial({
|
||||||
color: noteColor(midi, 'rh'), transparent: true,
|
color: noteColor(midi, 'rh'), transparent: true,
|
||||||
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
|
opacity: _laneGuideOpacity(), depthWrite: false,
|
||||||
});
|
});
|
||||||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||||||
_laneGuideMats.push(gmat);
|
_laneGuideMats.push(gmat);
|
||||||
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
|
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
|
||||||
strip.rotation.x = -Math.PI / 2;
|
strip.rotation.x = -Math.PI / 2;
|
||||||
strip.position.set(center, stripY, hitZ - guideLen / 2);
|
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
|
||||||
keyboardGroup.add(strip);
|
keyboardGroup.add(strip);
|
||||||
// Per-lane separator, fading with the strips. Skip realistic
|
// Thin brighter rails at the lane edges for crisp separation.
|
||||||
// sharps (they overlap the white columns).
|
const railMat = new T.MeshBasicMaterial({
|
||||||
if (!(entry.black && _sharpMode === 'realistic')) {
|
color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(),
|
||||||
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
|
transparent: true, opacity: 0.5, depthWrite: false,
|
||||||
}
|
});
|
||||||
}
|
_railMats.push(railMat);
|
||||||
}
|
const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat);
|
||||||
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
|
rail.rotation.x = -Math.PI / 2;
|
||||||
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
|
rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2);
|
||||||
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
|
keyboardGroup.add(rail);
|
||||||
// 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
|
// Keys (whites first so blacks overlay). Geometries are shared
|
||||||
@@ -2880,7 +2678,7 @@
|
|||||||
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
||||||
const material = new T.MeshStandardMaterial({
|
const material = new T.MeshStandardMaterial({
|
||||||
color: black ? 0x070708 : 0xe8e8ee,
|
color: black ? 0x070708 : 0xe8e8ee,
|
||||||
// Pitch-class color preset on emissive but OFF at rest — the key
|
// Pitch-class colour preset on emissive but OFF at rest — the key
|
||||||
// is neutral until a note approaches, when updateScene ramps the
|
// is neutral until a note approaches, when updateScene ramps the
|
||||||
// intensity up by proximity.
|
// intensity up by proximity.
|
||||||
emissive: noteColor(midi, 'rh'),
|
emissive: noteColor(midi, 'rh'),
|
||||||
@@ -2966,31 +2764,11 @@
|
|||||||
const entry = layout.get(note.midi);
|
const entry = layout.get(note.midi);
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
const len = Math.max(4 * K, note.durSec * TS);
|
const len = Math.max(4 * K, note.durSec * TS);
|
||||||
// Non-floating layouts: notes ride the naturals' plane and take
|
const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||||
// 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.
|
// 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());
|
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
|
||||||
mesh.position.x = x;
|
mesh.position.x = keyX(entry, whiteCount);
|
||||||
mesh.position.y = y;
|
mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||||
mesh.visible = false;
|
mesh.visible = false;
|
||||||
notesGroup.add(mesh);
|
notesGroup.add(mesh);
|
||||||
// Note-name label: a camera-facing sprite (readable at this low camera
|
// Note-name label: a camera-facing sprite (readable at this low camera
|
||||||
@@ -3705,7 +3483,6 @@
|
|||||||
// listening (e.g. changed on the Settings screen, where the live
|
// listening (e.g. changed on the Settings screen, where the live
|
||||||
// viz is torn down) must not come up stale on a later init().
|
// viz is torn down) must not come up stale on a later init().
|
||||||
_palette = readPaletteSetting();
|
_palette = readPaletteSetting();
|
||||||
_sharpMode = readSharpModeSetting();
|
|
||||||
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||||
_theme = readThemeSetting();
|
_theme = readThemeSetting();
|
||||||
_bgStyle = readBgStyleSetting();
|
_bgStyle = readBgStyleSetting();
|
||||||
@@ -3756,10 +3533,6 @@
|
|||||||
_palette = d.palette;
|
_palette = d.palette;
|
||||||
_applyPalette();
|
_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]) {
|
if (d && d.camera && CAM_PRESETS[d.camera]) {
|
||||||
_camPreset = CAM_PRESETS[d.camera];
|
_camPreset = CAM_PRESETS[d.camera];
|
||||||
// Position/lookAt re-derive next frame; only the
|
// Position/lookAt re-derive next frame; only the
|
||||||
@@ -3976,8 +3749,6 @@
|
|||||||
readBgStyleSetting,
|
readBgStyleSetting,
|
||||||
readPaletteSetting,
|
readPaletteSetting,
|
||||||
readCameraSetting,
|
readCameraSetting,
|
||||||
readSharpModeSetting,
|
|
||||||
SHARP_MODES,
|
|
||||||
_bgThemeColors,
|
_bgThemeColors,
|
||||||
BG_THEMES,
|
BG_THEMES,
|
||||||
BG_STYLE_IDS,
|
BG_STYLE_IDS,
|
||||||
@@ -3986,9 +3757,6 @@
|
|||||||
PALETTE_IDS,
|
PALETTE_IDS,
|
||||||
OCTAVE_HUES,
|
OCTAVE_HUES,
|
||||||
octaveNoteColor,
|
octaveNoteColor,
|
||||||
_isBlackPc,
|
|
||||||
laneSpanFlat,
|
|
||||||
laneSpanReal,
|
|
||||||
CAM_PRESETS,
|
CAM_PRESETS,
|
||||||
FX_DEFAULTS,
|
FX_DEFAULTS,
|
||||||
FX_RANGES,
|
FX_RANGES,
|
||||||
|
|||||||
@@ -12,11 +12,11 @@
|
|||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||||
|
|
||||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colours</label>
|
||||||
<select id="keysh3d-fx-palette"
|
<select id="keysh3d-fx-palette"
|
||||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
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">
|
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
<option value="octaves" selected>Octaves (colour per octave, darker sharps)</option>
|
||||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||||
<option value="ice">Ice (blue, darker sharps)</option>
|
<option value="ice">Ice (blue, darker sharps)</option>
|
||||||
<option value="classic">Rainbow (per-pitch)</option>
|
<option value="classic">Rainbow (per-pitch)</option>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||||
Choose the color scheme for the falling notes, key glow, lane
|
Choose the colour scheme for the falling notes, key glow, lane
|
||||||
guides and hit flames. Each option is described in its own label.
|
guides and hit flames. Each option is described in its own label.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||||
Background gradient, floor and lane rails — the same theme names
|
Background gradient, floor and lane rails — the same theme names
|
||||||
as the guitar highway. Note colors come from the
|
as the guitar highway. Note colours come from the
|
||||||
"Note colors" palette above.
|
"Note colours" palette above.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||||
@@ -101,64 +101,6 @@
|
|||||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||||
</p>
|
</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">
|
<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
|
<input type="checkbox" id="keysh3d-fx-cinematic" checked
|
||||||
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
|
onchange="window.keys3dSetFx && window.keys3dSetFx('cinematic', this.checked)">
|
||||||
@@ -242,7 +184,7 @@
|
|||||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
<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
|
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||||
Timing colors
|
Timing colours
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
Tint the sparks by timing — on-time green, early cyan, late
|
Tint the sparks by timing — on-time green, early cyan, late
|
||||||
@@ -305,9 +247,6 @@
|
|||||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
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 hydrateFxRange = (key, elId, valId) => {
|
||||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||||
if (!Number.isFinite(n)) return;
|
if (!Number.isFinite(n)) return;
|
||||||
@@ -319,8 +258,6 @@
|
|||||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-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
|
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||||
@@ -354,11 +291,6 @@
|
|||||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||||
paletteSel.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) {
|
} catch (e) {
|
||||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -397,156 +397,3 @@ test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () =>
|
|||||||
assert.equal(readCameraSetting(), 'overhead');
|
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');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
// 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]
|
|
||||||
//
|
|
||||||
// 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 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
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`;
|
|
||||||
out += `\n> **Requires a seeded library** (not captured by this run): playback frame-time p95\n`;
|
|
||||||
out += `> on the 2D + 3D highway, and screen-entry (plugin inject → interactive) for\n`;
|
|
||||||
out += `> editor / notedetect / highway_3d with a real chart loaded. Run this harness\n`;
|
|
||||||
out += `> against an environment with charts in \`DLC_DIR\` to fill those in.\n`;
|
|
||||||
|
|
||||||
console.log(out);
|
|
||||||
@@ -1348,23 +1348,16 @@ class MetadataDB:
|
|||||||
return [{"tag": r[0], "count": r[1]} for r in rows]
|
return [{"tag": r[0], "count": r[1]} for r in rows]
|
||||||
|
|
||||||
def user_meta_map(self, filenames) -> dict:
|
def user_meta_map(self, filenames) -> dict:
|
||||||
"""Batch {filename: user_difficulty} for a set of rows (set values
|
"""Batch {filename: user_difficulty} for a page of rows (set values
|
||||||
only). Lets query_page / query_artists embed difficulty without an
|
only). Lets query_page embed difficulty without an N+1."""
|
||||||
N+1. Chunked under SQLite's variable limit — query_artists can pass
|
|
||||||
every song across 50 artists, well past a single IN (...)."""
|
|
||||||
fns = list(filenames)
|
fns = list(filenames)
|
||||||
out: dict = {}
|
if not fns:
|
||||||
for i in range(0, len(fns), 400):
|
return {}
|
||||||
chunk = fns[i:i + 400]
|
ph = ",".join("?" * len(fns))
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
ph = ",".join("?" * len(chunk))
|
|
||||||
rows = self.conn.execute(
|
rows = self.conn.execute(
|
||||||
f"SELECT filename, user_difficulty FROM song_user_meta "
|
f"SELECT filename, user_difficulty FROM song_user_meta "
|
||||||
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", chunk).fetchall()
|
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall()
|
||||||
for fn, diff in rows:
|
return {r[0]: r[1] for r in rows}
|
||||||
out[fn] = diff
|
|
||||||
return out
|
|
||||||
|
|
||||||
def tags_map(self, filenames) -> dict:
|
def tags_map(self, filenames) -> dict:
|
||||||
"""Batch {filename: [tags]} for a page of rows."""
|
"""Batch {filename: [tags]} for a page of rows."""
|
||||||
@@ -4114,18 +4107,6 @@ class MetadataDB:
|
|||||||
"((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, "
|
"((SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) IS NULL) ASC, "
|
||||||
"(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
|
"(SELECT MAX(best_accuracy) FROM song_stats s WHERE s.filename = songs.filename) DESC"
|
||||||
),
|
),
|
||||||
# Personal difficulty rating (song_user_meta.user_difficulty, 1..5 —
|
|
||||||
# manually set or seeded by the difficulty_tagger plugin), via a
|
|
||||||
# correlated subquery like mastery above (drops to OFFSET paging).
|
|
||||||
# Unrated songs push to the bottom in both directions.
|
|
||||||
"difficulty": (
|
|
||||||
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
|
|
||||||
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) ASC"
|
|
||||||
),
|
|
||||||
"difficulty-desc": (
|
|
||||||
"((SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) IS NULL) ASC, "
|
|
||||||
"(SELECT user_difficulty FROM song_user_meta u WHERE u.filename = songs.filename) DESC"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if group and sort in ("mastery", "mastery-desc"):
|
if group and sort in ("mastery", "mastery-desc"):
|
||||||
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
|
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
|
||||||
@@ -4400,11 +4381,6 @@ class MetadataDB:
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
estd = self._estd_set()
|
estd = self._estd_set()
|
||||||
favs = self.favorite_set()
|
favs = self.favorite_set()
|
||||||
# Personal difficulty rides along here too (feedBack#810 follow-up),
|
|
||||||
# same batched pattern as query_page — without this the tree view's
|
|
||||||
# difficulty badge silently never renders (song.user_difficulty was
|
|
||||||
# always undefined for every row).
|
|
||||||
udm = self.user_meta_map([r[0] for r in rows])
|
|
||||||
artists = OrderedDict()
|
artists = OrderedDict()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
artist = r[2] or "Unknown Artist"
|
artist = r[2] or "Unknown Artist"
|
||||||
@@ -4426,7 +4402,6 @@ class MetadataDB:
|
|||||||
"tuning_name": r[12] or "",
|
"tuning_name": r[12] or "",
|
||||||
"has_estd": r[0] in estd,
|
"has_estd": r[0] in estd,
|
||||||
"favorite": r[0] in favs,
|
"favorite": r[0] in favs,
|
||||||
"user_difficulty": udm.get(r[0]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
# Pick most common name variant per artist/album
|
# Pick most common name variant per artist/album
|
||||||
|
|||||||
@@ -1110,7 +1110,6 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
|||||||
const _LIB_SORT_VALUES = new Set([
|
const _LIB_SORT_VALUES = new Set([
|
||||||
'artist', 'artist-desc', 'title', 'title-desc',
|
'artist', 'artist-desc', 'title', 'title-desc',
|
||||||
'recent', 'year-desc', 'year', 'tuning',
|
'recent', 'year-desc', 'year', 'tuning',
|
||||||
'difficulty', 'difficulty-desc',
|
|
||||||
]);
|
]);
|
||||||
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
||||||
// Tree-view expand/collapse persistence. Three states per tree:
|
// Tree-view expand/collapse persistence. Three states per tree:
|
||||||
@@ -2079,7 +2078,6 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
|
|||||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
${(() => { 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>` : ''}
|
${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.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>` : ''}
|
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${retuneBtn}
|
${retuneBtn}
|
||||||
@@ -2279,8 +2277,6 @@ 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>`;
|
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)
|
if (song.has_lyrics)
|
||||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
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)
|
if (duration)
|
||||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||||
if (stdRetune)
|
if (stdRetune)
|
||||||
@@ -11567,14 +11563,6 @@ async function loadPlugins() {
|
|||||||
// URL ?v=mtime convention elsewhere in this file).
|
// URL ?v=mtime convention elsewhere in this file).
|
||||||
const v = encodeURIComponent(wantedVersion);
|
const v = encodeURIComponent(wantedVersion);
|
||||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
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.pluginId = plugin.id;
|
||||||
script.dataset.pluginVersion = wantedVersion;
|
script.dataset.pluginVersion = wantedVersion;
|
||||||
window.feedBack._loadingPluginId = plugin.id;
|
window.feedBack._loadingPluginId = plugin.id;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
||||||
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
||||||
|
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
|
||||||
|
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||||
const OWNER_ID = 'core.audio.effects';
|
const OWNER_ID = 'core.audio.effects';
|
||||||
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
||||||
const DEFAULT_TIMEOUT_MS = 2000;
|
const DEFAULT_TIMEOUT_MS = 2000;
|
||||||
@@ -734,7 +736,7 @@
|
|||||||
const errors = [];
|
const errors = [];
|
||||||
const source = _plainObject(rawPlan);
|
const source = _plainObject(rawPlan);
|
||||||
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
||||||
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||||
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
||||||
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
||||||
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
||||||
|
|||||||
@@ -119,8 +119,6 @@
|
|||||||
<option value="year-desc">Year (newest)</option>
|
<option value="year-desc">Year (newest)</option>
|
||||||
<option value="year">Year (oldest)</option>
|
<option value="year">Year (oldest)</option>
|
||||||
<option value="tuning">Tuning</option>
|
<option value="tuning">Tuning</option>
|
||||||
<option value="difficulty">Difficulty (easiest first)</option>
|
|
||||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
|
||||||
</select>
|
</select>
|
||||||
<!-- Format filter (shared) -->
|
<!-- Format filter (shared) -->
|
||||||
<select id="lib-format" onchange="sortLibrary()"
|
<select id="lib-format" onchange="sortLibrary()"
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -38,9 +38,6 @@
|
|||||||
// Mastery = best accuracy across arrangements (song_stats); unscored songs
|
// Mastery = best accuracy across arrangements (song_stats); unscored songs
|
||||||
// sort last either way. Ascending surfaces what needs work; never default.
|
// sort last either way. Ascending surfaces what needs work; never default.
|
||||||
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
|
['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 FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
|
||||||
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// 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');
|
|
||||||
});
|
|
||||||
+1
-4
@@ -121,10 +121,7 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
|
|||||||
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
||||||
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
||||||
|
|
||||||
# Compare by value, not string: beat times are written at 6-decimal
|
assert [ebeat.get("time") for ebeat in ebeats] == expected_times
|
||||||
# (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] == [
|
assert [ebeat.get("measure") for ebeat in ebeats] == [
|
||||||
"1",
|
"1",
|
||||||
*["-1"] * (len(expected_times) - 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):
|
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
times) so a caller can surface a warning / mapping UI."""
|
||||||
_setup(monkeypatch)
|
_setup(monkeypatch)
|
||||||
track = _fake_track(
|
track = _fake_track(
|
||||||
string_midis=[56, 36, 54],
|
string_midis=[56, 36, 54],
|
||||||
beats=[
|
beats=[
|
||||||
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
|
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||||
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
|
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||||
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
|
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
song = SimpleNamespace(tracks=[track])
|
song = SimpleNamespace(tracks=[track])
|
||||||
@@ -247,44 +247,6 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
|||||||
# Times are captured (rounded to 3 dp).
|
# Times are captured (rounded to 3 dp).
|
||||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||||
assert unmapped[54]["times"] == [1.5]
|
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):
|
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||||
|
|||||||
@@ -293,45 +293,6 @@ def test_year_sort_asc_oldest_first(client, seeded):
|
|||||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
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):
|
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
|
"""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
|
must come before the up-tuned one so the order matches the chart's
|
||||||
|
|||||||
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
|
|||||||
|
|
||||||
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
times) so a caller can surface a warning / mapping UI."""
|
||||||
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
||||||
track = mido.MidiTrack()
|
track = mido.MidiTrack()
|
||||||
mid.tracks.append(track)
|
mid.tracks.append(track)
|
||||||
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
|
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
|
||||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
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_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_off", channel=9, note=36, velocity=0, time=240))
|
||||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
|
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
|
||||||
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
|
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=44, time=0)) # cowbell again — drop
|
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
|
||||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||||
|
|
||||||
unmapped: dict[int, dict] = {}
|
unmapped: dict[int, dict] = {}
|
||||||
@@ -209,10 +209,6 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
|||||||
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
|
# 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 all(isinstance(t, float) for t in unmapped[56]["times"])
|
||||||
assert len(unmapped[56]["times"]) == 2
|
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):
|
def test_non_channel9_events_ignored(tmp_path):
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
"""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
|
|
||||||
Reference in New Issue
Block a user