mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 13:54:29 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5d8396b56 | ||
|
|
39e2e8d100 | ||
|
|
fb354f9c38 | ||
|
|
7915f94ab6 | ||
|
|
51085048aa | ||
|
|
c49871484f | ||
|
|
117d260723 |
@@ -123,28 +123,3 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Nightly
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 23 * * *'
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -233,15 +214,6 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -284,4 +256,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
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,44 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Plugins can ship an ES-module `src/` tree (module-migration rails, R0).** The host gains three things so a plugin can move off a single global-scope `screen.js` IIFE onto native ES modules with **no build step**: (1) a new sandboxed `GET /api/plugins/{id}/src/{path}` route that serves a plugin's `src/` source subtree, containment-checked by the same `safe_join` guard as `assets/` (traversal/absolute/NUL → 404); (2) the live-edit cache contract — `Cache-Control: no-cache` + a weak mtime/size `ETag` + `If-None-Match`→`304` — applied to `src/`, `screen.js`, and `assets/` (previously `screen.js` sent no cache headers and `assets/` emitted an ETag but never revalidated), so an edited module reloads on refresh while unchanged ones `304`; and (3) `scriptType`/`minHost` passthrough from `plugin.json` to `/api/plugins`, with the loader injecting a plugin that declares `"scriptType":"module"` as `<script type="module">` (its screen.js becomes `import './src/main.js'`). A `<script type=module>` fires its load event only after its whole static-import graph evaluates, preserving the loader's completion-by-`onload` + `_loadingPluginId` contract. Classic plugins are unaffected; `minHost` is passthrough-only for now (enforcement deferred). Tests: `tests/test_plugin_src_route.py` (serve/media-type/traversal/304/no-stale-304/screen.js+assets conditional), `tests/js/plugin_loader_script_type.test.js` (guarded module injection).
|
||||
- **Module-migration governance & rails (R0).** Constitution amended to **v1.2.0**: Principle II now names native ES modules as a first-class, *build-free* extension mechanism (the `scriptType:"module"` load path, both plugins and — over time — core's `static/js/`), keeping the no-bundler/no-transpiler/source-served rule intact; Operating Constraints gains a "Module load contract" clause (a `<script type=module>` load event awaits the whole static-import graph, so completion-by-`onload` is preserved; per-visit re-init comes from the `screen:changed` event, not screen.js re-execution). Mirrored into `CLAUDE.md`. New `docs/plugin-modules.md` (the migration playbook — layering, import-time purity, `import.meta.url` assets, the ETag live-edit loop) and `docs/size-exemptions.md` (the signed 1,500-line size-norm register; Byron signs core/bundled rows, Christian the authored virtuoso row). Adds a **maintainer/CI-only** ESLint gate (`eslint.config.js` + a `lint` CI job): `max-lines` warns at 1,500 as a non-blocking ratchet (ceilings for exempt files mirror the register), and `import-x/no-unresolved` + `import-x/no-cycle` hard-error on ES-module graphs — dormant until module code lands, never on the serve/Docker path.
|
||||
- **Perf-baseline harness (R0).** `scripts/perf-baseline.mjs` (maintainer-only, Playwright-driven) captures server p50/p95/p99 latency, cold boot-to-interactive, JS-heap after an idle soak, and the injected plugin-script shape (how many are `type=module`), so every refactor phase can be checked for "screen-entry and frame-time no worse." Methodology + the R0 baseline live in `docs/perf-baseline.md`; playback frame-time and chart-loaded screen-entry rows need a seeded library and are re-taken per environment.
|
||||
- **Sort and filter the library by your personal difficulty rating — now visible at a glance, not just in the edit drawer.** `song_user_meta.user_difficulty` (the 1–5 planning rating, settable manually or seeded by a plugin like the community `difficulty_tagger`) already existed but was only readable by opening a song's per-song details drawer. The library API gains `sort=difficulty` / `sort=difficulty-desc` — a correlated subquery over `song_user_meta`, following the same unrated-rows-sort-to-the-bottom-in-both-directions pattern as the existing `mastery` sort — and library cards now show the rating as a `◆N` badge (v2 grid/tree views and the v3 grid alike), next to the tuning and lyrics badges. The classic tree view's `query_artists` batch-attaches `user_difficulty` the same way `query_page` already did for the grid, so the badge actually renders there too instead of staying dark. Tests: `tests/test_library_filters.py::test_difficulty_sort_pushes_unrated_to_bottom`, `tests/test_library_filters.py::test_tree_view_songs_carry_user_difficulty`.
|
||||
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||
it away — and never read `time_signature` meta at all — so every MIDI import landed
|
||||
with no measures and an implied 4/4 regardless of what the file said. The new helper
|
||||
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
|
||||
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
|
||||
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
|
||||
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
|
||||
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
|
||||
timelines must never share a grid); mid-bar signature events apply at the next bar
|
||||
boundary; times are computed from absolute ticks through the cumulative tempo table
|
||||
and rounded once at emit, so rounding error never accumulates with song length.
|
||||
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
|
||||
`tests/test_midi_tempo_map.py`.
|
||||
|
||||
### Fixed
|
||||
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||
|
||||
### Added
|
||||
- **3D Keys Highway: key layout modes, lane-color opacity & octave lines.** A new **Highway layout** settings section rebuilds how sharps/flats and lanes draw on the 3D piano highway. **Sharps & flats layout** (`keys3d_bg_sharpMode`) picks between **floating** (the original raised-sharp look), **flat** (one plane, zero-overlap piano-shaped tiled lanes with the naturals evened out), and **realistic** (one plane, bars sized like the physical keys) — default **realistic**; the geometry lives in pure, unit-tested `laneSpanFlat()`/`laneSpanReal()` helpers. **Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the pitch-class lane tint from full vivid color down to a dark floor with guide lines only at the key-block boundaries (E→F and each octave); the lane strips, per-lane separators and block lines crossfade with the value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) and **Octave line contrast** (`keys3d_bg_octaveContrast`, 0–1) control the B→C octave divider, which auto-shifts from a dark to a bright layer as lane opacity fades. Settings re-read on init and apply on the next chart build. `plugins/keys_highway_3d` → 0.2.0. Tests: `plugins/keys_highway_3d/tests/fx_settings.test.js` (new defaults, sharp-mode setting, lane-geometry tiling/evening for flat, uniform/overlap for realistic, and an active-range boundary case where a white key's edge stays untrimmed when its neighboring sharp falls outside the active range).
|
||||
- **Unmapped-percussion capture now records velocities alongside times.** Both drum converters' opt-in `out_unmapped` reporting (`lib/midi_import.py` `convert_drum_track_from_midi`, `lib/gp2rs.py` `convert_drum_track_to_drumtab`) gain an index-aligned `velocities` list next to `times`, carrying each dropped note's real dynamics (MIDI velocity verbatim; GP velocity with the same 1–127 gate as mapped hits, falling back to the 100 import default). This lets a hand-mapping UI (the editor's unmapped-notes dialog) restore mapped notes at their source dynamics instead of flattening everything to `v:100`. The GP path's chronological sort now reorders times and velocities in lockstep so multi-voice measures can't silently reassign dynamics. Additive — callers that ignore the new key are unaffected. Tests: `tests/test_midi_import_drums.py`, `tests/test_gp2rs_drums.py`.
|
||||
- **Handedness (left-handed) is now a first-class choice in the instrument selector — and surfaced during onboarding.** Left-handed players could already mirror the highway, but only via a buried Settings toggle they had to find *after* setup — so a lefty hit the tour, the tuner and calibration all right-handed first. The v3 instrument badge popover now has a **Handedness: Right / Left** row alongside Instrument / Strings / Tuning (all player-orientation choices), writing the same `lefty` preference (`highway.setLefty` when a live highway exists, else the `lefty` localStorage key it reads on init; the Settings checkbox stays in sync). The first-run tour's "Choose your instrument" step — which runs **before** the tuner/audio-calibration steps — now calls it out so lefties flip it up front. Frontend-only, additive: `static/v3/badges.js`, `static/v3/onboarding-tour.js`. Tests: `tests/js/badges_handedness.test.js`.
|
||||
- **"Colorblind (deuteranope)" highway string-color preset.** Adds a one-click preset to the shared "Highway String Colors" picker, sitting next to the existing Okabe–Ito "Colorblind-friendly" preset — contributed by a deuteranopic player who found the Okabe–Ito set still hard to separate. It retunes the six main strings (red / yellow-green / blue / orange / teal / deep-purple) and keeps that set's 7/8-string colors, and applies to **both** the 2D and 3D highways via the shared picker. Frontend-only, additive: `static/app.js` (`HWC_PRESETS`).
|
||||
- **`lib/gp_autosync.py`: piecewise time-warp helpers + a working `refine_sync()`.** `auto_sync()` has always computed per-bar sync points (DTW), but consumers could only apply the scalar bar-1 `audio_offset`, so any tempo difference between the recording and the tab's authored tempo accumulated audibly over the song. New librosa-free helpers expose the full mapping: `bar_start_times(gp_path)` (per-bar score times on the same axis as the sync points — GPIF bar-resolution map for `.gp`/`.gpx`, per-tick integration for GP3/4/5), `build_warp_anchors(points, bar_starts)` (strictly-monotonic `(score, audio)` anchor pairs), `warp_time(t, anchors)` (piecewise-linear map with edge-slope extrapolation for count-ins/tails), `warp_song_times(song, warp)` (retimes a `lib.song.Song` in place: notes + sustains, chords, beats, sections, anchors, handshapes, per-phrase difficulty levels, tone changes, tempo overrides), and `gp_has_expandable_repeats(gp_path)` (detects GP3/4/5 repeat/volta/direction markup whose playback expansion the as-written sync points cannot map — callers fall back to offset-only sync). Also implements `refine_sync()`, which the editor plugin's refine-sync endpoint has imported since the snapshot but which never existed in core (the Refine button 500'd): it densifies the coarse DTW points to every Nth bar and re-times each with a local onset phase sweep (sweep radius clamped under half a beat so periodic material can't lock a full beat off; short scoring grid + median residual snap). Synthetic click-track validation: ~13ms mean / ~40ms max error from ±180ms coarse input across 117–123 BPM recordings of a 120 BPM tab. Tests: `tests/test_gp_autosync_warp.py`.
|
||||
- **Playlist shuffle.** The v3 playlist detail page gains a crossing-arrows shuffle toggle next to Play all / Play album. When on, `playQueue.start` Fisher-Yates-shuffles the queue once at start (on a copy — the stored playlist order is untouched), swapping any per-slot album arrangements in lockstep so each slot keeps its pinned arrangement. The preference is global and persists in `localStorage` (`v3PlaylistShuffle`). Tests: `tests/js/play_queue_shuffle.test.js`.
|
||||
|
||||
### Changed
|
||||
- **Player frame-time hotspots removed (trace-backed) + weak-hardware hardening.** A Chrome performance trace of a 3D-highway session surfaced two core per-frame layout-thrash sources, now fixed: the highway's visibility check read `canvas.offsetParent` every rAF frame (forces style/layout recalc — now sampled every 10th frame with a cached value, force-refreshed on init/canvas-replace/resize/override-clear), and the v3 player chrome loop called `matches(':hover')` per frame and unconditionally rewrote the Up-Next pill's `textContent`/bar width at 6 Hz (now hover-tracked via mouseenter/mouseleave, DOM writes only on value change, progress bar moved from `width` to compositor-only `scaleX`). The 3D highway pre-warms shader programs (`ren.compile`) and deterministic label textures at init — and chart-dependent chord/section label textures on first draw — so first-appearance shader-compile/texture-upload frame spikes move into the load spinner. For weaker hardware: the per-frame renderer bundle is now a single reused object instead of a fresh ~35-field allocation per frame (object identity is stable and meaningless; array fields still swap reference on chart changes), custom viz get `bundle.lowerBoundT`/`bundle.lowerBoundTime` binary-search helpers for visible-window culling, the default 2D highway's beat lines no longer scan every beat in the song per frame, and the 3D highway stops reading `localStorage` per frame (1 Hz poll) and caches its lyrics text-measurement layout per displayed line instead of re-measuring every syllable every frame. A second, throttled-CPU trace pass additionally removed: shader-program re-resolution churn from label texture swaps (`material.needsUpdate` is now only set on a null↔texture transition — swapping between two cached label textures never changes the compiled program), the 3D highway's per-frame `getBoundingClientRect` layout read in its canvas-size self-check (now every 10th frame, still immediate on backing-store change), and the core 60 Hz HUD clock rewriting `textContent` on every tick (now write-on-change, ~1/s). The dominant residual — steady `getParameters` shader-program re-resolution (~4% of throttled main thread) — turned out to be Three r158+'s transparent-DoubleSide two-pass rendering, which sets `material.needsUpdate` twice per object per frame; all 18 of the 3D highway's transparent DoubleSide materials are flat unlit quads (labels, rails, chord frames, lanes), so they now declare `forceSinglePass: true`, eliminating the recompile churn and halving those objects' draw calls.
|
||||
|
||||
### Fixed
|
||||
- **`playback.loop-api` bridge no longer fires dozens of times per second.** Every `window.feedBack.getLoop()` call recorded a full bridge hit — compat-shim bookkeeping, a `playback:bridge-hit` event, and a diagnostics snapshot rebuild + stringify — so a plugin polling loop state from a HUD tick (note_detect at ~30 Hz) flooded the capability inspector and burned main-thread time even with no song playing. `_recordPlaybackBridge` now throttles per bridge/surface (5 s window): bridge hits are a "surface still in use" signal, not a call counter. The manual A/B loop buttons (`setLoopEnd`) also now emit the same `loop-set` transport event as `setLoop()`, so plugins can react to loop changes via `playback:loop-set` / `playback:loop-cleared` events instead of polling `getLoop()`.
|
||||
- **3D Highway: recover from a WebGL context loss instead of crashing on alt-tab.** Switching the active window / alt-tabbing away from the app (most often on Windows) can trigger a GPU context reset; the 3D highway's WebGL renderer had **no `webglcontextlost` handler**, so a lost context was left to escalate into a render-process crash — matching the intermittent "randomly crashes when I change windows" desktop reports. The renderer now binds `webglcontextlost`/`webglcontextrestored` on its own WebGL canvas (`ren.domElement`): the loss is `preventDefault()`'d so the browser keeps the context restorable, `draw()` bails while the context is down so no GL work runs on a dead context, and on restore the viewport is re-applied and rendering resumes (Three re-uploads scene resources on the next frame). Listeners are torn down with the renderer. `plugins/highway_3d` → 3.31.3. Tests: `tests/js/highway_3d_context_loss.test.js`. (The sibling `keys_highway_3d` / `drum_highway_3d` renderers share the same gap — tracked as a follow-up in their repos.)
|
||||
- **Guitar Pro 6 (`.gpx`) import no longer fails on every real file.** The GPX BCFS container reader (`lib/gp2rs_gpx.py`) rejected any file whose final sector wasn't a full `0x1000` block — but a real `.gpx`'s BCFZ-declared decompressed size isn't sector-aligned, so the last (small) container file always lands in a partial trailing sector. The bounds check *raised* `GPX BCFS sector pointer out of range (malformed file)` instead of clamping the tail read, so `_load_gpif` threw before `score.gpif` could be extracted and **no GP6 file could be imported into the song editor** (both real test files failed identically — this wasn't file-specific). GP7/GP8 `.gp` files were unaffected — they take the ZIP path, not BCFS, which is why prior GP-import work didn't surface it. The reader now **clamps the final sector read to the buffer end** (the per-file size field trims the padding anyway), matching canonical GPX readers (alphaTab / PyGuitarPro); a sector whose *start* is past the end still raises, preserving the malformed-file guard. Verified against two real GP6 files — both now unpack to valid GPIF with all tracks. Tests: `tests/test_gp2rs_gpx.py` (partial-final-sector round-trip, multi-file container, sector-aligned baseline, and the preserved out-of-range guard).
|
||||
- **v3 Songs grid: fixed the scroll stutter that "skips every so many scrolls," up or down.** The virtualized grid rebuilt its **entire** visible window (`grid.innerHTML = …` + a full `wireCards` pass) every time it slid by one row, so each row-boundary crossing was a heavy synchronous frame that stalled the main thread and buffered held-arrow key-repeats into a visible lurch (a tester's "super fast for a second then slowed down") at fixed scroll offsets — in **both directions and regardless of whether the page was already loaded** (the cost was DOM teardown, not fetching, which is why scrolling back up over cached songs hitched too). `renderWindow()` now **reconciles the window in place**: it reuses the card nodes that stay on-screen and builds only the row that enters/leaves (~6 nodes per slide instead of ~60), keyed by absolute index with a real-vs-skeleton + select-mode signature so hole-fills (after a page fetch) and select-mode toggles still rebuild exactly the nodes that changed. `wireCards`'s `data-wired` guard then wires only the freshly-built nodes, so per-slide listener churn drops with it. Follow-up to the stage-2 virtualized grid (got-feedback/feedBack#636 item 3). Frontend-only: `static/v3/songs.js`. Tests: `tests/js/v3_songs_window_recycle.test.js` (window stays `[start,end)` contiguous + in-window node identity reused across a down-then-up scroll; select-mode toggle and rail-seek jump rebuild correctly).
|
||||
- **Starter content seeds again (and now ships The Adicts' "Ode to Joy").** `_BUILTIN_STARTER_SOURCES` still listed `beethoven-ode_to_joy.feedpak` after that pack was deleted, and never wired up its replacement `the_adicts-ode-to-joy_vst_cover.feedpak` that landed on disk. The listed-but-missing file made the all-present gate never fire, so **no** starter songs seeded on first run. Synced the manifest to what's on disk (Für Elise, Star Spangled Banner, The Adicts' Ode to Joy). Tests: `tests/test_builtin_starter_seed.py` (the present/unlisted guards were red on `main`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
@@ -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) } })),
|
||||
];
|
||||
+9
-33
@@ -1836,10 +1836,9 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1895,29 +1894,18 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1958,21 +1946,9 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_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 {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
@@ -1506,10 +1506,6 @@ def convert_file(
|
||||
# Surface that to the caller rather than only the docstring: if the score
|
||||
# actually uses repeats, the produced bar count/timing will differ from the
|
||||
# equivalent .gp5. Warn once so plugin code/logs don't silently drift.
|
||||
# NB: lib/gp_autosync.gp_has_expandable_repeats() encodes this single-pass
|
||||
# behaviour (.gp/.gpx never expand). Implementing GPIF expansion here MUST
|
||||
# update that helper in the same change, or the editor's per-bar sync warp
|
||||
# would silently retime repeated sections onto the wrong bars.
|
||||
if expand_repeats and any(
|
||||
mb.find('Repeat') is not None or mb.find('AlternateEndings') is not None
|
||||
for mb in masterbars
|
||||
|
||||
+29
-524
@@ -18,22 +18,8 @@ plugin is installed; graceful ImportError otherwise with clear message).
|
||||
Public API:
|
||||
is_available() -> bool
|
||||
auto_sync(gp_path, audio_path, ...) -> GpSyncData
|
||||
refine_sync(sync, audio_path, ...) -> GpSyncData
|
||||
estimate_audio_offset(gp_path,
|
||||
audio_path) -> float
|
||||
bar_start_times(gp_path) -> list[float]
|
||||
gp_has_expandable_repeats(gp_path) -> bool
|
||||
build_warp_anchors(sync_points,
|
||||
bar_starts) -> list[tuple[float, float]]
|
||||
warp_time(t, anchors) -> float
|
||||
warp_song_times(song, warp) -> None
|
||||
|
||||
The warp helpers (bar_start_times / build_warp_anchors / warp_time /
|
||||
warp_song_times) are librosa-free: they turn a GpSyncData produced by
|
||||
auto_sync (or extracted from a GP8 file) into a piecewise-linear
|
||||
score-time -> audio-time mapping and apply it to a lib.song.Song, so
|
||||
converted charts follow the recording's actual tempo drift instead of a
|
||||
single scalar offset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -367,22 +353,15 @@ def _synthesise_score_chroma(
|
||||
return chroma
|
||||
|
||||
_GP345_TICKS_PER_QUARTER = 960
|
||||
# PyGuitarPro absolute ticks start at quarterTime (measure 1 begins at tick
|
||||
# 960, not 0). All tick math in this module runs on a 0-based axis (cumulative
|
||||
# measure starts), so raw beat.start values must be shifted by this origin —
|
||||
# mixing the two axes applied every mid-song tempo change a quarter note late
|
||||
# and skewed the synthesised chroma against the bar timeline.
|
||||
_GP345_TICK_ORIGIN = 960
|
||||
|
||||
|
||||
def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
"""Sorted, tick-deduplicated ``[(tick, bpm)]`` tempo events for a GP3/4/5 song.
|
||||
|
||||
Seeds with the song's initial tempo at tick 0, then appends every
|
||||
``mixTableChange`` tempo. Ticks are normalised to the 0-based axis
|
||||
(raw ``beat.start`` minus ``_GP345_TICK_ORIGIN``). Shared by chroma
|
||||
synthesis and bar-time computation so both use one identical tempo
|
||||
model (mirrors ``gp2rs._build_tempo_map``).
|
||||
``mixTableChange`` tempo. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -392,10 +371,7 @@ def _gp345_tempo_events(song) -> list[tuple[int, float]]:
|
||||
if beat.effect and beat.effect.mixTableChange:
|
||||
mtc = beat.effect.mixTableChange
|
||||
if mtc.tempo and mtc.tempo.value > 0:
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.append((beat.start, float(mtc.tempo.value)))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -483,9 +459,8 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -538,75 +513,13 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
def _gpif_bar_starts(root: ET.Element) -> list[float]:
|
||||
"""Score-time (seconds) at the start of each masterbar in a GPIF score.
|
||||
|
||||
Integrates bar durations from the bar-resolution tempo map and each
|
||||
masterbar's time signature — the same time model _synthesise_score_chroma
|
||||
uses, so bar times land where the bars sit in the synthesised chroma.
|
||||
"""
|
||||
tempo_map = _get_tempo_map(root)
|
||||
masterbars = _children(root, 'MasterBars')
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts: list[float] = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
return bar_starts
|
||||
|
||||
|
||||
def _gp345_measure_start_ticks(song) -> list[int]:
|
||||
"""Cumulative start tick of each measure in a PyGuitarPro song."""
|
||||
starts: list[int] = []
|
||||
cum = 0
|
||||
for mh in song.measureHeaders:
|
||||
starts.append(cum)
|
||||
ts = mh.timeSignature
|
||||
cum += int(ts.numerator * (4.0 / ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
return starts
|
||||
|
||||
|
||||
def _extract_sync_points(
|
||||
wp: 'np.ndarray',
|
||||
root: ET.Element,
|
||||
@@ -652,7 +565,22 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
tempo_iter = iter(tempo_map)
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, tempo_map[0][1]))
|
||||
ct = tempo_map[0][1]
|
||||
t_cur = 0.0
|
||||
bar_starts_score = []
|
||||
for mb_idx, mb in enumerate(masterbars):
|
||||
while mb_idx >= next_tb:
|
||||
ct = next_bpm
|
||||
next_tb, next_bpm = next(tempo_iter, (999999, ct))
|
||||
bar_starts_score.append(t_cur)
|
||||
ts = mb.findtext('Time', '4/4')
|
||||
try:
|
||||
n_b, d_b = [int(x) for x in ts.split('/')]
|
||||
except ValueError:
|
||||
n_b, d_b = 4, 4
|
||||
t_cur += n_b * (4.0 / d_b) * (60.0 / ct)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -735,224 +663,6 @@ def _tempo_at_bar(tempo_map: list[tuple[int, float]], bar: int) -> float:
|
||||
|
||||
# ── Audio offset estimation ───────────────────────────────────────────────────
|
||||
|
||||
# ── Piecewise time warp (librosa-free) ───────────────────────────────────────
|
||||
#
|
||||
# auto_sync's per-bar sync points describe where each sampled bar of the tab
|
||||
# falls in the real recording. Applying only the scalar audio_offset (bar 1)
|
||||
# assumes the recording holds the authored tempo for the whole song — any
|
||||
# drift accumulates. These helpers build the full piecewise-linear
|
||||
# score-time -> audio-time mapping and apply it to a converted Song, so the
|
||||
# chart follows the recording bar by bar (Songsterr-style sync).
|
||||
|
||||
def bar_start_times(gp_path: str) -> list[float]:
|
||||
"""Score-time (seconds) at the start of every bar of a GP file.
|
||||
|
||||
Uses the same tempo models as auto_sync's chroma synthesis (GPIF
|
||||
bar-resolution map for .gp/.gpx, per-tick integration for .gp3/4/5), so
|
||||
the returned times share an axis with auto_sync's sync points.
|
||||
|
||||
Raises ValueError if the file cannot be parsed, ImportError if the file
|
||||
is GP3/4/5 and PyGuitarPro is not installed.
|
||||
"""
|
||||
try:
|
||||
root = _load_gpif(gp_path)
|
||||
except _Gp345FileError:
|
||||
import guitarpro
|
||||
try:
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot parse GP3/4/5 file {gp_path!r}: {exc}") from exc
|
||||
tempo_events = _gp345_tempo_events(song)
|
||||
return [
|
||||
_gp345_tick_to_secs(tempo_events, tick)
|
||||
for tick in _gp345_measure_start_ticks(song)
|
||||
]
|
||||
return _gpif_bar_starts(root)
|
||||
|
||||
|
||||
def gp_has_expandable_repeats(gp_path: str) -> bool:
|
||||
"""True when converting `gp_path` expands repeats into a longer timeline
|
||||
than the as-written score auto_sync aligned against.
|
||||
|
||||
gp2rs.convert_file walks the GP3/4/5 playback graph (repeat brackets,
|
||||
voltas, D.S./D.C. directions), so a file using any of those produces an
|
||||
as-performed timeline that auto_sync's as-written sync points cannot be
|
||||
mapped onto. GPIF (.gp/.gpx) conversion is single-pass as-written today,
|
||||
so those files always return False — both sides share one bar order.
|
||||
|
||||
Returns False when the file cannot be parsed (callers fall back to
|
||||
offset-only sync on parse failure anyway).
|
||||
"""
|
||||
if Path(gp_path).suffix.lower() in ('.gp', '.gpx'):
|
||||
return False
|
||||
try:
|
||||
import guitarpro
|
||||
song = guitarpro.parse(gp_path)
|
||||
except Exception:
|
||||
return False
|
||||
for mh in song.measureHeaders:
|
||||
if mh.isRepeatOpen or mh.repeatClose >= 0 or mh.repeatAlternative:
|
||||
return True
|
||||
# Both jump SOURCES (fromDirection: D.C., D.S., Da Coda) and jump
|
||||
# TARGETS (direction: Segno, Coda, Fine) count — a plain Da Capo
|
||||
# needs no target marker, so checking `direction` alone would miss
|
||||
# it while gp2rs's playback walker still expands the jump.
|
||||
if (getattr(mh, 'direction', None) is not None
|
||||
or getattr(mh, 'fromDirection', None) is not None):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def build_warp_anchors(
|
||||
sync_points: list[SyncPoint],
|
||||
bar_starts: list[float],
|
||||
) -> list[tuple[float, float]]:
|
||||
"""Turn sync points into (score_secs, audio_secs) anchor pairs.
|
||||
|
||||
Drops points whose bar index is out of range, points that would break
|
||||
strict monotonicity on either axis (DTW can locally fold on noisy audio;
|
||||
a non-monotonic anchor would make the warp non-invertible and reorder
|
||||
notes), and points whose segment slope implies a physically implausible
|
||||
tempo ratio (outside 0.2x-5x authored). Returns [] when fewer than 2
|
||||
usable anchors remain — callers should fall back to scalar-offset sync
|
||||
in that case.
|
||||
"""
|
||||
anchors: list[tuple[float, float]] = []
|
||||
for sp in sorted(sync_points, key=lambda p: p.bar):
|
||||
if not 0 <= sp.bar < len(bar_starts):
|
||||
continue
|
||||
score_t = bar_starts[sp.bar]
|
||||
audio_t = float(sp.time_secs)
|
||||
if anchors and (score_t <= anchors[-1][0] + 1e-6
|
||||
or audio_t <= anchors[-1][1] + 1e-3):
|
||||
continue
|
||||
if anchors:
|
||||
# Slope sanity gate: a segment whose audio/score tempo ratio is
|
||||
# outside [0.2, 5] is not a performance — it's a DTW fold onto a
|
||||
# repeated section, an abridged recording, or a run of
|
||||
# monotonicity-clamped refine points. Keeping it would crush (or
|
||||
# absurdly stretch) every bar in the span, which is far worse
|
||||
# than interpolating through from the neighbouring anchors.
|
||||
slope = (audio_t - anchors[-1][1]) / (score_t - anchors[-1][0])
|
||||
if not 0.2 <= slope <= 5.0:
|
||||
continue
|
||||
anchors.append((score_t, audio_t))
|
||||
return anchors if len(anchors) >= 2 else []
|
||||
|
||||
|
||||
def warp_time(t: float, anchors: list[tuple[float, float]]) -> float:
|
||||
"""Map a score-time (seconds) to audio-time via piecewise-linear anchors.
|
||||
|
||||
Between anchors: linear interpolation. Outside the anchor range: the
|
||||
nearest segment's slope is extended, so a count-in before bar 1 and the
|
||||
tail after the last sampled bar keep the local tempo ratio.
|
||||
|
||||
`anchors` must be the >=2-point strictly-monotonic list produced by
|
||||
build_warp_anchors.
|
||||
"""
|
||||
lo = 0
|
||||
hi = len(anchors) - 1
|
||||
if t <= anchors[0][0]:
|
||||
seg = (anchors[0], anchors[1])
|
||||
elif t >= anchors[hi][0]:
|
||||
seg = (anchors[hi - 1], anchors[hi])
|
||||
else:
|
||||
# Binary search for the segment containing t
|
||||
while hi - lo > 1:
|
||||
mid = (lo + hi) // 2
|
||||
if anchors[mid][0] <= t:
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
seg = (anchors[lo], anchors[hi])
|
||||
(s0, a0), (s1, a1) = seg
|
||||
slope = (a1 - a0) / (s1 - s0)
|
||||
return a0 + (t - s0) * slope
|
||||
|
||||
|
||||
def warp_song_times(song, warp) -> None:
|
||||
"""Apply a monotonic time-mapping callable to every absolute time in a
|
||||
lib.song.Song, in place.
|
||||
|
||||
Covers beats, sections, song_length, and per-arrangement notes (onset +
|
||||
sustain), chords (incl. chord notes), anchors, hand shapes, per-phrase
|
||||
difficulty levels, tone changes, and tempo overrides. Durations (note
|
||||
sustain, handshape span) are warped as end-start so they stretch with the
|
||||
local tempo ratio; sub-second intra-note envelopes (bend curves, which are
|
||||
relative to the note onset) are left untouched.
|
||||
|
||||
Duck-typed: accepts any object with the lib.song.Song surface.
|
||||
|
||||
Identity-safe: parse_arrangement shares the SAME Note/Chord/Anchor/
|
||||
HandShape objects between the flat arrangement lists and the
|
||||
max-difficulty phrase level, so each object is warped at most once no
|
||||
matter how many containers reference it.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
|
||||
def _once(obj) -> bool:
|
||||
key = id(obj)
|
||||
if key in seen:
|
||||
return False
|
||||
seen.add(key)
|
||||
return True
|
||||
|
||||
def _warp_notes(notes):
|
||||
for n in notes or []:
|
||||
if not _once(n):
|
||||
continue
|
||||
end = warp(n.time + n.sustain)
|
||||
n.time = warp(n.time)
|
||||
n.sustain = max(0.0, end - n.time)
|
||||
|
||||
def _warp_chords(chords):
|
||||
for c in chords or []:
|
||||
if not _once(c):
|
||||
continue
|
||||
c.time = warp(c.time)
|
||||
_warp_notes(c.notes)
|
||||
|
||||
def _warp_anchors(anchors):
|
||||
for a in anchors or []:
|
||||
if _once(a):
|
||||
a.time = warp(a.time)
|
||||
|
||||
def _warp_handshapes(shapes):
|
||||
for h in shapes or []:
|
||||
if not _once(h):
|
||||
continue
|
||||
start = warp(h.start_time)
|
||||
end = warp(h.end_time)
|
||||
h.start_time = start
|
||||
h.end_time = max(start, end)
|
||||
|
||||
song.song_length = max(0.0, warp(song.song_length))
|
||||
for b in song.beats:
|
||||
b.time = warp(b.time)
|
||||
for s in song.sections:
|
||||
s.start_time = warp(s.start_time)
|
||||
for arr in song.arrangements:
|
||||
_warp_notes(arr.notes)
|
||||
_warp_chords(arr.chords)
|
||||
_warp_anchors(arr.anchors)
|
||||
_warp_handshapes(arr.hand_shapes)
|
||||
for ph in arr.phrases or []:
|
||||
ph.start_time = warp(ph.start_time)
|
||||
ph.end_time = warp(ph.end_time)
|
||||
for lvl in ph.levels or []:
|
||||
_warp_notes(lvl.notes)
|
||||
_warp_chords(lvl.chords)
|
||||
_warp_anchors(lvl.anchors)
|
||||
_warp_handshapes(lvl.hand_shapes)
|
||||
if arr.tones and isinstance(arr.tones, dict):
|
||||
for change in arr.tones.get('changes') or []:
|
||||
if isinstance(change, dict) and isinstance(change.get('t'), (int, float)):
|
||||
change['t'] = warp(float(change['t']))
|
||||
for tempo_ev in arr.tempos or []:
|
||||
if isinstance(tempo_ev, dict) and isinstance(tempo_ev.get('time'), (int, float)):
|
||||
tempo_ev['time'] = warp(float(tempo_ev['time']))
|
||||
|
||||
|
||||
def _estimate_audio_offset(
|
||||
root: ET.Element,
|
||||
audio_path: str,
|
||||
@@ -1222,7 +932,12 @@ def auto_sync(
|
||||
# below line up with the chroma timeline.
|
||||
_tempo_events_gp345 = _gp345_tempo_events(_gp345x_song)
|
||||
# Convert tick events to bar events using actual measure start ticks
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
_measure_starts = [] # cumulative tick at start of each bar
|
||||
_cum = 0
|
||||
for _mh2 in _gp345x_song.measureHeaders:
|
||||
_measure_starts.append(_cum)
|
||||
_ts = _mh2.timeSignature
|
||||
_cum += int(_ts.numerator * (4.0 / _ts.denominator.value) * _GP345_TICKS_PER_QUARTER)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1309,216 +1024,6 @@ def auto_sync(
|
||||
sync_points=sync_points,
|
||||
)
|
||||
|
||||
def refine_sync(
|
||||
sync: GpSyncData,
|
||||
audio_path: str,
|
||||
bars_per_point: int = 8,
|
||||
gp_path: str | None = None,
|
||||
sr: int = _SR,
|
||||
search_radius: float = 0.35,
|
||||
phase_step: float = 0.005,
|
||||
onset_tolerance: float = 0.05,
|
||||
) -> GpSyncData:
|
||||
"""Refine coarse DTW sync points with a per-bar onset phase sweep.
|
||||
|
||||
auto_sync's mid-song points inherit the DTW frame granularity (~186ms at
|
||||
the default hop). This pass re-times a denser grid of bars — every
|
||||
`bars_per_point`-th bar plus the first and last — by sweeping a local
|
||||
beat grid (±`search_radius`s in `phase_step` steps) against detected
|
||||
onsets and keeping the phase that aligns best, narrowing each kept point
|
||||
to roughly the phase-step resolution on percussive material.
|
||||
|
||||
Args:
|
||||
sync: Coarse sync data from auto_sync (or a prior refine).
|
||||
audio_path: The same audio file auto_sync aligned against.
|
||||
bars_per_point: Refined-point density; every Nth bar gets a point.
|
||||
gp_path: Optional path to the GP file. When given, exact
|
||||
per-bar score times (bar_start_times) drive the
|
||||
densified grid; without it the grid is limited to
|
||||
a 4/4 approximation built from the points' authored
|
||||
tempos, and accuracy degrades on odd meters.
|
||||
sr: Analysis sample rate.
|
||||
search_radius: ±seconds around each coarse estimate to sweep.
|
||||
phase_step: Sweep resolution in seconds.
|
||||
onset_tolerance: Max onset-to-click distance that counts as aligned.
|
||||
|
||||
Returns:
|
||||
A new GpSyncData with the refined (and usually denser) points and a
|
||||
recomputed audio_offset. Returns `sync` unchanged when it has no
|
||||
usable points. Quiet bars (fewer than 4 onsets nearby) keep their
|
||||
coarse interpolated time rather than locking onto noise.
|
||||
"""
|
||||
if not sync.sync_points:
|
||||
return sync
|
||||
|
||||
pts = sorted(sync.sync_points, key=lambda p: p.bar)
|
||||
|
||||
bar_starts: list[float] | None = None
|
||||
if gp_path:
|
||||
try:
|
||||
bar_starts = bar_start_times(gp_path)
|
||||
except Exception as exc:
|
||||
_log.warning("refine_sync: bar_start_times(%s) failed (%s) — "
|
||||
"falling back to 4/4 tempo model", gp_path, exc)
|
||||
if bar_starts is None:
|
||||
# Approximate score bar starts from the points' authored tempos,
|
||||
# assuming 4 beats per bar (all GpSyncData carries without the file).
|
||||
max_bar = pts[-1].bar
|
||||
bar_starts = [0.0]
|
||||
ti = 0
|
||||
cur_bpm = pts[0].original_tempo or 120.0
|
||||
for b in range(1, max_bar + 1):
|
||||
while ti + 1 < len(pts) and pts[ti + 1].bar <= b - 1:
|
||||
ti += 1
|
||||
cur_bpm = pts[ti].original_tempo or cur_bpm
|
||||
bar_starts.append(bar_starts[-1] + 4 * 60.0 / max(cur_bpm, 1e-3))
|
||||
|
||||
anchors = build_warp_anchors(pts, bar_starts)
|
||||
if len(anchors) < 2:
|
||||
_log.warning("refine_sync: fewer than 2 usable anchors — returning "
|
||||
"input unchanged")
|
||||
return sync
|
||||
|
||||
# Authored-tempo lookup via the shared bar-map scan (_tempo_at_bar) so
|
||||
# boundary semantics can't drift from the rest of the module.
|
||||
_orig_map = [(p.bar, p.original_tempo or 120.0) for p in pts]
|
||||
|
||||
def _orig_bpm_at(bar: int) -> float:
|
||||
return max(_tempo_at_bar(_orig_map, bar), 1e-3)
|
||||
|
||||
n_bars = len(bar_starts)
|
||||
step = max(1, int(bars_per_point))
|
||||
targets = sorted(set(range(0, n_bars, step)) | {n_bars - 1})
|
||||
|
||||
# Deferred past the pure early-return paths above so degenerate inputs
|
||||
# (no points, <2 anchors) resolve without librosa installed.
|
||||
import librosa
|
||||
import numpy as np
|
||||
|
||||
y, _ = librosa.load(audio_path, sr=sr, mono=True)
|
||||
audio_dur = len(y) / sr
|
||||
hop = 512 # ~23ms at 22050Hz — fine enough for onset timing
|
||||
onset_frames = librosa.onset.onset_detect(
|
||||
y=y, sr=sr, hop_length=hop, backtrack=True
|
||||
)
|
||||
onset_times = np.asarray(
|
||||
librosa.frames_to_time(onset_frames, sr=sr, hop_length=hop)
|
||||
)
|
||||
|
||||
refined: list[tuple[int, float]] = []
|
||||
for b in targets:
|
||||
score_t = bar_starts[b]
|
||||
coarse = warp_time(score_t, anchors)
|
||||
if coarse > audio_dur + 1.0:
|
||||
break # bar falls past the end of the recording
|
||||
# Local beat period in AUDIO time: authored beat period scaled by the
|
||||
# local warp slope (recording tempo / authored tempo around this bar).
|
||||
slope = warp_time(score_t + 1.0, anchors) - coarse
|
||||
slope = min(max(slope, 0.25), 4.0)
|
||||
beat_period = (60.0 / _orig_bpm_at(b)) * slope
|
||||
|
||||
# Keep the scoring grid short: beat_period is estimated from the
|
||||
# coarse anchors (a few % off), and grid drift grows linearly with
|
||||
# distance — 16 beats at 2% error is already ~150ms of skew at the
|
||||
# far end, which drags the sweep. 8 beats bounds that to ~beat noise.
|
||||
grid_span = 8 * beat_period
|
||||
# Clamp the sweep window below half a beat so the neighbouring beat
|
||||
# is never a candidate — on periodic material (steady drums) a grid
|
||||
# shifted by one whole beat scores identically and the sweep could
|
||||
# lock a full beat off. DTW coarse error is ~1 analysis frame, which
|
||||
# this window still covers at all but extreme tempos.
|
||||
radius = min(search_radius, 0.45 * beat_period)
|
||||
w_lo = coarse - radius - onset_tolerance
|
||||
w_hi = coarse + radius + grid_span + onset_tolerance
|
||||
local = onset_times[(onset_times >= w_lo) & (onset_times <= w_hi)]
|
||||
if len(local) < 4:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
best_t, best_score, best_dist = coarse, -1, 0.0
|
||||
for phase in np.arange(coarse - radius, coarse + radius + 1e-9,
|
||||
phase_step):
|
||||
clicks = np.arange(phase, phase + grid_span, beat_period)
|
||||
score = int(sum(
|
||||
1 for t in local
|
||||
if float(np.min(np.abs(clicks - t))) < onset_tolerance
|
||||
))
|
||||
dist = abs(float(phase) - coarse)
|
||||
# Ties break toward the coarse estimate so a flat score surface
|
||||
# (sustained pads, sparse onsets) can't drag the point sideways.
|
||||
if score > best_score or (score == best_score and dist < best_dist):
|
||||
best_score, best_t, best_dist = score, float(phase), dist
|
||||
|
||||
# A sweep that matched almost nothing found a spurious edge
|
||||
# alignment, not the beat grid — this happens when the true phase
|
||||
# lies outside the (ambiguity-clamped) window, e.g. fast tempos
|
||||
# where the DTW coarse error exceeds half a beat. Keeping the
|
||||
# coarse estimate degrades gracefully instead of locking a
|
||||
# fraction of a beat off.
|
||||
if best_score < 3:
|
||||
refined.append((b, coarse))
|
||||
continue
|
||||
|
||||
# The onset-count score is flat within ±onset_tolerance of the true
|
||||
# phase, so the sweep alone can be off by up to the tolerance. Snap
|
||||
# inside that plateau: shift by the median residual between matched
|
||||
# onsets and their nearest grid click. Only the first few beats
|
||||
# count here — they are nearly insensitive to beat_period error,
|
||||
# while far clicks would leak that error into the residuals.
|
||||
if best_score > 0:
|
||||
clicks = np.arange(best_t, best_t + 4 * beat_period + 1e-9,
|
||||
beat_period)
|
||||
residuals = []
|
||||
for t in local:
|
||||
d = clicks - float(t)
|
||||
j = int(np.argmin(np.abs(d)))
|
||||
if abs(d[j]) < onset_tolerance:
|
||||
residuals.append(-float(d[j])) # onset minus click
|
||||
if residuals:
|
||||
best_t += float(np.median(residuals))
|
||||
refined.append((b, best_t))
|
||||
|
||||
if not refined:
|
||||
return sync
|
||||
|
||||
# Enforce monotonicity: a point refined earlier than its predecessor
|
||||
# would fold the warp. Clamp to a small positive gap.
|
||||
mono: list[tuple[int, float]] = []
|
||||
prev_t: float | None = None
|
||||
for b, t in refined:
|
||||
t = max(t, 0.0)
|
||||
if prev_t is not None and t <= prev_t + 0.02:
|
||||
t = prev_t + 0.02
|
||||
mono.append((b, t))
|
||||
prev_t = t
|
||||
|
||||
# Recompute per-segment modified tempos from the refined times (same
|
||||
# formula _extract_sync_points uses; the last point carries the previous
|
||||
# segment's tempo forward).
|
||||
new_points: list[SyncPoint] = []
|
||||
for i, (b, t) in enumerate(mono):
|
||||
obpm = _orig_bpm_at(b)
|
||||
if i + 1 < len(mono):
|
||||
b2, t2 = mono[i + 1]
|
||||
score_seg = bar_starts[b2] - bar_starts[b]
|
||||
audio_seg = t2 - t
|
||||
mod = obpm * (score_seg / audio_seg) if audio_seg > 1e-3 else obpm
|
||||
mod = max(20.0, min(300.0, mod))
|
||||
else:
|
||||
mod = new_points[-1].modified_tempo if new_points else obpm
|
||||
new_points.append(SyncPoint(
|
||||
bar=b, time_secs=t, modified_tempo=mod, original_tempo=obpm,
|
||||
))
|
||||
|
||||
_log.info("refine_sync: %d points (was %d), audio_offset=%.3fs",
|
||||
len(new_points), len(pts), -new_points[0].time_secs)
|
||||
return GpSyncData(
|
||||
audio_offset=-new_points[0].time_secs,
|
||||
audio_asset_id=sync.audio_asset_id,
|
||||
sync_points=new_points,
|
||||
)
|
||||
|
||||
|
||||
def estimate_audio_offset(gp_path: str, audio_path: str) -> float:
|
||||
"""
|
||||
Estimate the audio_offset for a GP file aligned to an audio file.
|
||||
|
||||
+7
-160
@@ -203,13 +203,7 @@ def convert_midi_track_to_keys_wire(
|
||||
# a foreign track's tempo events do NOT apply to the chosen
|
||||
# track. Merging would mis-time the notes — restrict the tempo
|
||||
# scan to the selected track only.
|
||||
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
|
||||
# division (mido returns the signed short as-is). Both feed the two
|
||||
# divisions below (tempo-table build + tick_to_seconds), so guard here:
|
||||
# 0 would raise ZeroDivisionError and a negative value would yield
|
||||
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
|
||||
# case also falls back to the SMF default.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -358,14 +352,7 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||
- type 2: independent timelines; tempo only from the chosen track.
|
||||
"""
|
||||
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||
# negative value and a malformed header as 0 — both make the two division
|
||||
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||
# SMF default here, the single place every caller routes ticks through, so
|
||||
# each caller's own fallback is real rather than cosmetic.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -406,141 +393,6 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
return tick_to_seconds
|
||||
|
||||
|
||||
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||
# orders of magnitude below this.
|
||||
_TEMPO_MAP_MAX_BARS = 20000
|
||||
|
||||
|
||||
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||
signatures, and a full beat grid — the data the note converters here
|
||||
always computed internally (to bake note times) and then threw away,
|
||||
which left every MIDI import with no bars, no measures, and an implied
|
||||
4/4 no matter what the file said.
|
||||
|
||||
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||
|
||||
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||
|
||||
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||
(independent timelines — callers must never share one grid across
|
||||
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||
are computed from absolute ticks through the cumulative tempo table and
|
||||
rounded once at emit — rounding error never accumulates with song
|
||||
length. An SMF with no note events yields empty ``beats``.
|
||||
"""
|
||||
midi = mido.MidiFile(midi_path)
|
||||
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
# Same scope both converters use: type 2 reads only the chosen track
|
||||
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||
source_tracks = (
|
||||
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||
)
|
||||
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||
|
||||
# ── collect meta + the end of musical content in one pass ────────────
|
||||
sig_events: list[tuple[int, int, int]] = []
|
||||
tempo_events: list[tuple[int, int]] = []
|
||||
end_tick = 0
|
||||
for tr in source_tracks:
|
||||
abs_tick = 0
|
||||
for msg in tr:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "time_signature":
|
||||
num = int(getattr(msg, "numerator", 4) or 4)
|
||||
den = int(getattr(msg, "denominator", 4) or 4)
|
||||
if num > 0 and den > 0:
|
||||
sig_events.append((abs_tick, num, den))
|
||||
elif msg.type == "set_tempo":
|
||||
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||
elif msg.type in ("note_on", "note_off"):
|
||||
end_tick = max(end_tick, abs_tick)
|
||||
|
||||
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||
sig_events.sort(key=lambda e: e[0])
|
||||
sigs: list[tuple[int, int, int]] = []
|
||||
for ev in sig_events:
|
||||
if sigs and sigs[-1][0] == ev[0]:
|
||||
sigs[-1] = ev
|
||||
else:
|
||||
sigs.append(ev)
|
||||
if not sigs or sigs[0][0] > 0:
|
||||
sigs.insert(0, (0, 4, 4))
|
||||
|
||||
tempo_events.sort(key=lambda e: e[0])
|
||||
seen_tempo_ticks: dict[int, int] = {}
|
||||
for ev_tick, ev_tempo in tempo_events:
|
||||
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||
tempos_out: list[dict] = []
|
||||
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||
# lands after the start (or there are none). The beat grid already runs
|
||||
# at 120 for the head of the song, so the sidecar must say so too —
|
||||
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||
for ev_tick in sorted_tempo_ticks:
|
||||
tempos_out.append({
|
||||
"time": round(tick_to_seconds(ev_tick), 3),
|
||||
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||
})
|
||||
|
||||
time_signatures_out = [
|
||||
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||
for t, num, den in sigs
|
||||
]
|
||||
|
||||
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||
beats: list[dict] = []
|
||||
if end_tick > 0:
|
||||
cur_tick = 0.0
|
||||
measure = 1
|
||||
sig_idx = 0
|
||||
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||
# Active signature: the latest event at or before this bar's
|
||||
# start. Mid-bar events wait for the next boundary by
|
||||
# construction (we only re-read between bars).
|
||||
while (sig_idx + 1 < len(sigs)
|
||||
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||
sig_idx += 1
|
||||
_, num, den = sigs[sig_idx]
|
||||
beat_ticks = ticks_per_beat * 4.0 / den
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||
"measure": measure,
|
||||
"den": den,
|
||||
})
|
||||
for k in range(1, num):
|
||||
sub_tick = cur_tick + k * beat_ticks
|
||||
if sub_tick >= end_tick:
|
||||
break
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||
"measure": -1,
|
||||
})
|
||||
cur_tick += num * beat_ticks
|
||||
measure += 1
|
||||
|
||||
return {
|
||||
"tempos": tempos_out,
|
||||
"time_signatures": time_signatures_out,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||
|
||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||
@@ -634,12 +486,10 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -677,13 +527,10 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
midi_note, {"count": 0, "times": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
+14
-26
@@ -703,32 +703,20 @@ def load_song(
|
||||
and isinstance(e.get("d"), (int, float))
|
||||
]
|
||||
if song.lyrics:
|
||||
# Provenance. The feedpak spec (§7.1) vocabulary is
|
||||
# {authored, transcribed, user}; older manifests + the
|
||||
# in-tree readers also use the source-format names
|
||||
# (xml/notechart) and the WhisperX engine name
|
||||
# (whisperx). Accept the union so both spec-compliant
|
||||
# writers (e.g. the stem_splitter plugin emitting
|
||||
# `transcribed`) and legacy packs validate. Validate
|
||||
# against the closed enum so a hand-edited (or otherwise
|
||||
# malformed) manifest can't propagate a YAML dict / list /
|
||||
# arbitrary string into the highway WS `lyrics.source`
|
||||
# field and out to plugin badges. Anything outside the
|
||||
# enum (or the wrong type) falls back to "xml" — the
|
||||
# back-compat default — instead of being stringified and
|
||||
# trusted.
|
||||
# Post-alias values only: `whisperx` is normalised to
|
||||
# `transcribed` before the membership check below, so (like
|
||||
# `sng`) it is intentionally absent from this set.
|
||||
_ALLOWED_LYRICS_SOURCES = {
|
||||
"xml", "notechart", "user",
|
||||
"authored", "transcribed",
|
||||
}
|
||||
# Legacy aliases: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name, and the WhisperX
|
||||
# fallback with the engine name — normalise both to the
|
||||
# spec vocabulary the badges now expect.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
|
||||
# Provenance — populated by the converter (xml/notechart),
|
||||
# the WhisperX fallback (whisperx), or hand-edits
|
||||
# (user). Validate against the closed enum so a
|
||||
# hand-edited (or otherwise malformed) manifest can't
|
||||
# propagate a YAML dict / list / arbitrary string
|
||||
# into the highway WS `lyrics.source` field and out
|
||||
# to plugin badges. Anything outside the enum (or
|
||||
# the wrong type) falls back to "xml" — the spec's
|
||||
# back-compat default — instead of being stringified
|
||||
# and trusted.
|
||||
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
|
||||
# Legacy alias: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name; normalise it.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
|
||||
raw_source = manifest.get("lyrics_source")
|
||||
if isinstance(raw_source, str):
|
||||
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
|
||||
|
||||
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:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
"install:playwright": "playwright install chromium"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
"@playwright/test": "^1.59.1"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-99
@@ -18,54 +18,6 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1421,12 +1373,6 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2143,11 +2089,6 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2201,9 +2142,6 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2369,7 +2307,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@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:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2377,11 +2315,8 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
@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")
|
||||
|
||||
@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/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
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)
|
||||
break
|
||||
if target.is_file():
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.3",
|
||||
"version": "3.31.2",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -3747,15 +3747,6 @@
|
||||
// ── Per-instance Three.js state ───────────────────────────────────
|
||||
let scene = null, cam = null, ren = null;
|
||||
let wrap = null;
|
||||
// WebGL context-loss recovery. Switching the active window / alt-tabbing
|
||||
// (especially on Windows) can trigger a GPU context reset; with no
|
||||
// handler the lost context escalates into a render-process crash. The
|
||||
// listeners (bound in initScene on ren.domElement, removed in teardown)
|
||||
// preventDefault the loss so the browser keeps the context restorable,
|
||||
// _ctxLost gates draw() off the dead context, and on restore we reset the
|
||||
// viewport + resume (Three re-uploads scene resources on the next render).
|
||||
let _ctxLost = false;
|
||||
let _onCtxLost = null, _onCtxRestored = null;
|
||||
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
|
||||
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
|
||||
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
|
||||
@@ -6535,26 +6526,6 @@
|
||||
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
|
||||
wrap.appendChild(ren.domElement);
|
||||
|
||||
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
|
||||
// on Three's own canvas — the context that actually resets on a GPU
|
||||
// reset / alt-tab. preventDefault() keeps the context restorable
|
||||
// instead of letting the loss escalate to a render-process crash;
|
||||
// _ctxLost then makes draw() bail so no GL work runs on the dead
|
||||
// context; on restore we reset the viewport and resume (Three
|
||||
// re-uploads geometry/materials/textures lazily on the next render).
|
||||
_onCtxLost = (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
_ctxLost = true;
|
||||
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
|
||||
};
|
||||
_onCtxRestored = () => {
|
||||
_ctxLost = false;
|
||||
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
|
||||
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
|
||||
};
|
||||
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
|
||||
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
|
||||
|
||||
lyricsCanvas = document.createElement('canvas');
|
||||
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
|
||||
lyricsCtx = lyricsCanvas.getContext('2d');
|
||||
@@ -14858,15 +14829,6 @@
|
||||
// mid-teardown settings change doesn't try to rebuild a torn-
|
||||
// down scene; then dispose the active style's resources.
|
||||
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
|
||||
// WebGL context-loss listeners (bound in initScene on ren.domElement).
|
||||
// Remove before ren is disposed below so a torn-down instance can't
|
||||
// keep firing them; reset the flag so a reused instance starts clean.
|
||||
if (ren && ren.domElement) {
|
||||
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
|
||||
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
|
||||
}
|
||||
_onCtxLost = _onCtxRestored = null;
|
||||
_ctxLost = false;
|
||||
// Notedetect listeners (issue #9). Remove on destroy so a
|
||||
// panel that stops doesn't keep accumulating marks. Marks
|
||||
// arrays are cleared too — they hold stale chart positions
|
||||
@@ -15192,7 +15154,6 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
|
||||
@@ -3,34 +3,12 @@
|
||||
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
|
||||
|
||||
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class 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.
|
||||
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
|
||||
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
|
||||
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
|
||||
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1).
|
||||
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
|
||||
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
|
||||
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
|
||||
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
|
||||
them, and each sharp leaned toward the edge natural beside it so the naturals come
|
||||
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
|
||||
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
|
||||
naturals always rendered full, full black keys drawn on top and only occluding a
|
||||
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
|
||||
**Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the
|
||||
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
|
||||
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
|
||||
rather than every lane — the notes keep their colors; toward 1 it fills in full,
|
||||
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
|
||||
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
|
||||
gap a touch at each B→C octave boundary. **Octave line contrast**
|
||||
(`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line
|
||||
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
|
||||
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
|
||||
switch needed. All are geometry-time — applied on the next chart build via
|
||||
`init()`'s re-read.
|
||||
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class 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.
|
||||
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
|
||||
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.1",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
||||
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
||||
// keyboard, per-key Synthesia-style PITCH-CLASS 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,
|
||||
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
||||
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
|
||||
@@ -59,7 +59,7 @@
|
||||
// World scroll speed (units / second) — matches the sibling highways.
|
||||
const TS = 130 * K;
|
||||
|
||||
// Per-pitch-class 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, …).
|
||||
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
|
||||
// their neighbours so black-key notes stay distinguishable.
|
||||
@@ -79,196 +79,10 @@
|
||||
];
|
||||
|
||||
// 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.
|
||||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||||
|
||||
// Selectable note-color palettes. Index = midi % 12, same contract as
|
||||
// PITCH_CLASS_COLORS — which stays byte-identical as the 'classic'
|
||||
// entry, so anyone who never touches the setting sees the stock look.
|
||||
// Two palette families:
|
||||
// per-pitch — every pitch class gets its own hue (classic/vivid/pastel)
|
||||
// two-tone — naturals share one hue, sharps a darker shade of it, so
|
||||
// a dark gem always telegraphs "black key coming"
|
||||
// (emerald/ice)
|
||||
const NOTE_PALETTES = {
|
||||
classic: PITCH_CLASS_COLORS,
|
||||
emerald: [
|
||||
0x3fe25f, // C — bright green (naturals)
|
||||
0x17863a, // C# — dark green (sharps)
|
||||
0x3fe25f, // D
|
||||
0x17863a, // D#
|
||||
0x3fe25f, // E
|
||||
0x3fe25f, // F
|
||||
0x17863a, // F#
|
||||
0x3fe25f, // G
|
||||
0x17863a, // G#
|
||||
0x3fe25f, // A
|
||||
0x17863a, // A#
|
||||
0x3fe25f, // B
|
||||
],
|
||||
vivid: [
|
||||
0xff2020, // C
|
||||
0xd45a10, // C#
|
||||
0xffe000, // D
|
||||
0xb8c010, // D#
|
||||
0x1e6aff, // E
|
||||
0x9fd0e8, // F
|
||||
0x2fae7e, // F#
|
||||
0x20e050, // G
|
||||
0xa89a20, // G#
|
||||
0xff8a00, // A
|
||||
0xd05888, // A#
|
||||
0xd040ff, // B
|
||||
],
|
||||
pastel: [
|
||||
0xff9a9a, // C
|
||||
0xd0a078, // C#
|
||||
0xffe9a0, // D
|
||||
0xcfd08a, // D#
|
||||
0x9ec0ff, // E
|
||||
0xc8dde8, // F
|
||||
0x9ecfba, // F#
|
||||
0x9fe8b0, // G
|
||||
0xcfc79a, // G#
|
||||
0xffc890, // A
|
||||
0xd8a8ba, // A#
|
||||
0xe0b0ff, // B
|
||||
],
|
||||
ice: [
|
||||
0x58c8ff, // C — bright ice blue (naturals)
|
||||
0x2a6a9a, // C# — deep blue (sharps)
|
||||
0x58c8ff, // D
|
||||
0x2a6a9a, // D#
|
||||
0x58c8ff, // E
|
||||
0x58c8ff, // F
|
||||
0x2a6a9a, // F#
|
||||
0x58c8ff, // G
|
||||
0x2a6a9a, // G#
|
||||
0x58c8ff, // A
|
||||
0x2a6a9a, // A#
|
||||
0x58c8ff, // B
|
||||
],
|
||||
};
|
||||
|
||||
// Octave-based color scheme ('octaves'): every octave gets a distinct
|
||||
// hue that steps like a rainbow (clear, uniform sections — NOT a smooth
|
||||
// blend — so each octave is uniquely identifiable, but neighbouring
|
||||
// octaves stay close so the change isn't jarring). Loops if a song runs
|
||||
// past the table. Within an octave, sharps/flats take a darker shade of
|
||||
// the same hue — the same two-tone idea as 'emerald'. Octave index =
|
||||
// floor(midi/12) - 1 (so C1..B1 = octave 1). The three keys below C1
|
||||
// (A0/A#0/B0 = octave 0) and anything lower get a distinct cool slate so
|
||||
// the very bottom of the board reads apart from the red start.
|
||||
const OCTAVE_HUES = [
|
||||
0xe23a3a, // oct 1 (C1–B1) red
|
||||
0xe2803a, // oct 2 orange
|
||||
0xe0c73a, // oct 3 yellow
|
||||
0x5fc23a, // oct 4 green
|
||||
0x3ac2a0, // oct 5 teal
|
||||
0x3a86e2, // oct 6 blue
|
||||
0x6a4ae2, // oct 7 indigo
|
||||
0xc23ae2, // oct 8 (C8) magenta
|
||||
];
|
||||
const OCTAVE_SUBC1_HUE = 0x8090a0; // A0/A#0/B0 and below — cool slate
|
||||
const OCTAVE_SHARP_DARKEN = 0.5; // sharps render at 50% of the octave hue
|
||||
function _darkenHex(hex, f) {
|
||||
const r = Math.round(((hex >> 16) & 0xff) * f);
|
||||
const g = Math.round(((hex >> 8) & 0xff) * f);
|
||||
const b = Math.round((hex & 0xff) * f);
|
||||
return (r << 16) | (g << 8) | b;
|
||||
}
|
||||
function _isBlackPc(midi) {
|
||||
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
|
||||
// natural next to it. +1 = up (toward the higher natural), −1 = down, 0 =
|
||||
// centred. C#/F# sit below an inner natural so they lean down to C/F;
|
||||
// D#/A# lean up to E/B; G# has an inner natural on both sides, so it can't
|
||||
// lean and stays put.
|
||||
function _sharpLeanDir(pc) {
|
||||
if (pc === 1 || pc === 6) return -1; // C#, F#
|
||||
if (pc === 3 || pc === 10) return 1; // D#, A#
|
||||
return 0; // G#
|
||||
}
|
||||
// Floor span [left,right] of a key's lane in the FLAT (piano-shaped)
|
||||
// layout, in world units, given the key's centre x (`cx`). Pure/isolated
|
||||
// on purpose — this ONE function defines the layout, so a variant is a
|
||||
// one-function swap. Zero-overlap tiling: a white lane is trimmed by
|
||||
// `sharpHalf` wherever it meets a sharp, and the sharp fills that gap. Each
|
||||
// sharp is nudged `shift` toward the edge natural beside it (see
|
||||
// _sharpLeanDir), which steals a sliver from that edge natural and widens
|
||||
// the squeezed inner natural — at shift = sharpHalf/3 the C-D-E-F-B
|
||||
// naturals come out equal. Lanes still tile edge-to-edge (no overlap, no
|
||||
// gap). With `gaps`, each B→C octave boundary opens an extra `octGap`
|
||||
// divider by shaving half of it off the B and the C (naturals only).
|
||||
// `range`, when given, gates the trim to a neighbouring sharp that is
|
||||
// itself inside `range.activeLow..range.activeHigh`. A white key at the
|
||||
// active-range boundary (see the `midi < range.activeLow ||
|
||||
// midi > range.activeHigh` skip around the lane-strip loop) may sit next
|
||||
// to a sharp pitch-class that falls just outside the active range — that
|
||||
// sharp's lane is never drawn, so trimming the white key's edge for it
|
||||
// leaves a dark, unfilled sliver. Gating on range keeps that edge full
|
||||
// while leaving the normal (fully in-range) zero-overlap tiling intact.
|
||||
// Callers that don't pass `range` (e.g. the unit tests exercising raw
|
||||
// tiling geometry) keep the unconditional trim.
|
||||
function laneSpanFlat(midi, black, cx, dims, gaps, range) {
|
||||
const { whiteW, sharpHalf, shift, octGap } = dims;
|
||||
if (black) {
|
||||
const c = cx + _sharpLeanDir(((midi % 12) + 12) % 12) * shift;
|
||||
return { left: c - sharpHalf, right: c + sharpHalf };
|
||||
}
|
||||
const neighborActive = (m) => !range || (m >= range.activeLow && m <= range.activeHigh);
|
||||
// White: each side that meets a sharp is trimmed to that (leaned) sharp's
|
||||
// near edge; a side that meets another white keeps the half-slot edge.
|
||||
let left = cx - whiteW / 2;
|
||||
let right = cx + whiteW / 2;
|
||||
if (_isBlackPc(midi - 1) && neighborActive(midi - 1)) {
|
||||
const bc = (cx - whiteW / 2) + _sharpLeanDir(((midi - 1) % 12 + 12) % 12) * shift;
|
||||
left = bc + sharpHalf;
|
||||
}
|
||||
if (_isBlackPc(midi + 1) && neighborActive(midi + 1)) {
|
||||
const bc = (cx + whiteW / 2) + _sharpLeanDir(((midi + 1) % 12 + 12) % 12) * shift;
|
||||
right = bc - sharpHalf;
|
||||
}
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (gaps) {
|
||||
if (pc === 11) right -= octGap / 2; // B: gap on its right (→ C)
|
||||
if (pc === 0) left += octGap / 2; // C: gap on its left (← B)
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
// 'realistic' layout span: every bar sized to the physical key it lands on.
|
||||
// Naturals are the same full width (2·natHalf) centred on the key; sharps are
|
||||
// the full black-key width (2·sharpHalf) at their standard half-slot, which
|
||||
// makes them overlap — the caller draws sharps on top. A natural therefore
|
||||
// always renders full and is only covered where a sharp note actually
|
||||
// coincides in time. `gaps` widens the B→C divider (naturals only).
|
||||
function laneSpanReal(midi, black, cx, dims, gaps) {
|
||||
const half = black ? dims.sharpHalf : dims.natHalf;
|
||||
let left = cx - half, right = cx + half;
|
||||
if (gaps && !black) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (pc === 11) right -= dims.octGap / 2;
|
||||
if (pc === 0) left += dims.octGap / 2;
|
||||
}
|
||||
return { left, right };
|
||||
}
|
||||
// Color (24-bit int) for a midi note under the octave scheme: hue by
|
||||
// octave, darker for sharps. Pure (no THREE) so it is unit-testable.
|
||||
function octaveNoteColor(midi) {
|
||||
const oct = Math.floor(midi / 12) - 1; // C1..B1 => 1
|
||||
let hex = (oct <= 0)
|
||||
? OCTAVE_SUBC1_HUE
|
||||
: OCTAVE_HUES[(oct - 1) % OCTAVE_HUES.length];
|
||||
if (_isBlackPc(midi)) hex = _darkenHex(hex, OCTAVE_SHARP_DARKEN);
|
||||
return hex;
|
||||
}
|
||||
// Every valid palette id: the 12-entry pitch-class tables PLUS the
|
||||
// procedural 'octaves' scheme (which is not a 12-array, so it lives
|
||||
// outside NOTE_PALETTES and is validated through this list).
|
||||
const PALETTE_IDS = [...Object.keys(NOTE_PALETTES), 'octaves'];
|
||||
|
||||
// Note block cross-section height and bevel (world units). The bevel
|
||||
// turns the flat slabs into glossy gem-like blocks that catch the light
|
||||
// on their edges — the RS+ reference look.
|
||||
@@ -283,8 +97,8 @@
|
||||
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
|
||||
// the note geometry so a block reads as a lit 3D gem instead of a flat fill —
|
||||
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
|
||||
// greyscale so one geometry serves every pitch-class color; the material
|
||||
// multiplies its color by it via vertexColors.
|
||||
// greyscale so one geometry serves every pitch-class colour; the material
|
||||
// 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 NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
@@ -1099,35 +913,7 @@
|
||||
scoreFx: true, // 2D overlay: +N pops, combo rings, streak-break wash
|
||||
bgIntensity: 0.5, // background-ambience density/strength
|
||||
bgReactive: true, // background reacts to the audio analyser
|
||||
// Highway-layout options (apply on the next chart build via init()'s
|
||||
// fx re-read). The sharp LAYOUT is a separate string setting
|
||||
// (keys3d_bg_sharpMode); these two are the booleans.
|
||||
octaveGaps: true, // ON: wider divider gap at each B→C octave boundary
|
||||
laneOpacity: 0.0, // 0–1: lane-color strength. 0 (default) = dark floor +
|
||||
// block guide lines (E→F, B→C); 1 = full colored lanes; crossfades.
|
||||
octaveContrast: 0.5, // 0–1: how strongly the B→C octave line stands out. It
|
||||
// auto-darkens with lane opacity and brightens as it fades.
|
||||
// Camera base-rig fine-tune. These shift the BASE vantage point the
|
||||
// auto-pan/zoom follow-motion is built on (they multiply/offset the
|
||||
// active CAM_PRESET before the per-frame pan + dolly), so the camera
|
||||
// still tracks the notes — just from a nudged height/distance/tilt.
|
||||
camHeight: 1.0, // ×preset camera height (higher = more overhead)
|
||||
camDist: 1.0, // ×preset camera distance (larger = further back)
|
||||
camTilt: 0.0, // aim offset up(+)/down(−); 0 = neutral — the tuned overhead aim lives in CAM_PRESETS.overhead, so this fine-tune only nudges from a preset (and Classic + tilt 0 == the historical rig)
|
||||
};
|
||||
// Numeric FX keys clamp to a declared [min, max]; keys absent from this
|
||||
// table keep the historical 0–1 slider range. The camera fine-tune knobs
|
||||
// are multipliers/offsets centred on 1 (or 0), so they need headroom and a
|
||||
// floor a 0–1 range couldn't express.
|
||||
const FX_RANGES = {
|
||||
camHeight: [0.4, 2.2],
|
||||
camDist: [0.4, 2.2],
|
||||
camTilt: [-1.0, 1.0],
|
||||
};
|
||||
function _fxClamp(key, n) {
|
||||
const r = FX_RANGES[key] || [0, 1];
|
||||
return Math.min(r[1], Math.max(r[0], n));
|
||||
}
|
||||
const FX_LS_PREFIX = 'keys3d_bg_';
|
||||
// Theme id lives OUTSIDE FX_DEFAULTS (string, not bool/number) — its own
|
||||
// localStorage key + validation against BG_THEMES.
|
||||
@@ -1304,88 +1090,6 @@
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Note-color palette id — string-valued like the theme, so it gets its
|
||||
// own validated key + setter rather than an FX_DEFAULTS slot.
|
||||
const FX_LS_PALETTE = 'keys3d_bg_palette';
|
||||
function readPaletteSetting() {
|
||||
try {
|
||||
const id = localStorage.getItem(FX_LS_PALETTE);
|
||||
if (id && PALETTE_IDS.indexOf(id) !== -1) return id;
|
||||
} catch (_) {}
|
||||
// Default: the octave scheme (each octave its own color, darker
|
||||
// sharps) — the plug-and-play piano look. Emerald/classic/etc. remain
|
||||
// selectable.
|
||||
return 'octaves';
|
||||
}
|
||||
window.keys3dSetPalette = function (id) {
|
||||
if (PALETTE_IDS.indexOf(id) === -1) return;
|
||||
try { localStorage.setItem(FX_LS_PALETTE, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { palette: id } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Sharp-display layout id — string-valued (3-way), its own validated key +
|
||||
// setter. 'floating' = the original raised-plane sharps with white-only
|
||||
// lanes; 'flat' = every note on one plane with piano-shaped tiled lanes
|
||||
// (sharps leaned to even the naturals); 'realistic'
|
||||
// = one plane with note bars sized like the physical keys (full naturals,
|
||||
// full sharps overlapping on top). Geometry-time — applied on the next chart
|
||||
// build via init()'s re-read.
|
||||
const FX_LS_SHARPMODE = 'keys3d_bg_sharpMode';
|
||||
const SHARP_MODES = ['floating', 'flat', 'realistic'];
|
||||
function readSharpModeSetting() {
|
||||
try {
|
||||
const id = localStorage.getItem(FX_LS_SHARPMODE);
|
||||
if (id && SHARP_MODES.indexOf(id) !== -1) return id;
|
||||
} catch (_) {}
|
||||
return 'realistic'; // default layout: physical-key-sized bars on one plane
|
||||
}
|
||||
window.keys3dSetSharpMode = function (id) {
|
||||
if (SHARP_MODES.indexOf(id) === -1) return;
|
||||
try { localStorage.setItem(FX_LS_SHARPMODE, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { sharpMode: id } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
// Camera-rig presets. 'classic' is the original low, near-telephoto rig
|
||||
// (numerically identical to the historical constants, so 'classic' with the
|
||||
// neutral camTilt default reproduces the exact stock framing). y/z/lookY/lookZ
|
||||
// are in pre-K world units — the instance multiplies by K at the use sites,
|
||||
// same as the old constants did. Zoom scales position AND look-at every
|
||||
// frame, so all presets inherit the adaptive dolly behaviour unchanged.
|
||||
// Each preset carries its OWN tuned aim in lookY: 'overhead' bakes in the
|
||||
// plug-and-play downward tilt (the −0.6 × CAM_TILT_UNITS = −33 that used to
|
||||
// ship as the camTilt default) so the default look is unchanged while
|
||||
// camTilt now defaults to 0 (a neutral nudge from whatever preset is picked).
|
||||
const CAM_PRESETS = {
|
||||
classic: { fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 },
|
||||
elevated: { fov: 44, y: 78, z: 118, lookY: 4, lookZ: -150 },
|
||||
overhead: { fov: 48, y: 118, z: 74, lookY: -33, lookZ: -115 },
|
||||
};
|
||||
// Camera preset id — string-valued like the theme, so it gets its own
|
||||
// validated key + setter rather than an FX_DEFAULTS slot.
|
||||
const FX_LS_CAMERA = 'keys3d_bg_camera';
|
||||
function readCameraSetting() {
|
||||
try {
|
||||
const id = localStorage.getItem(FX_LS_CAMERA);
|
||||
if (id && CAM_PRESETS[id]) return id;
|
||||
} catch (_) {}
|
||||
// Default: the overhead reading rig — its lookY already carries the
|
||||
// tuned downward aim, so with the neutral camTilt default it gives the
|
||||
// plug-and-play piano view out of the box. Others selectable ('classic'
|
||||
// + neutral tilt = the exact historical rig).
|
||||
return 'overhead';
|
||||
}
|
||||
window.keys3dSetCamera = function (id) {
|
||||
if (!CAM_PRESETS[id]) return;
|
||||
try { localStorage.setItem(FX_LS_CAMERA, id); } catch (_) {}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('keys3d:settings', { detail: { camera: id } }));
|
||||
} catch (_) { /* dispatch unavailable — persisted value applies next init */ }
|
||||
};
|
||||
|
||||
function readFxSettings() {
|
||||
const fx = Object.assign({}, FX_DEFAULTS);
|
||||
try {
|
||||
@@ -1400,10 +1104,10 @@
|
||||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||||
} else {
|
||||
const n = parseFloat(raw);
|
||||
// Numeric FX keys clamp to their declared range
|
||||
// (default 0-1) so a corrupt/foreign write can't
|
||||
// overdrive opacities or the geometry multipliers.
|
||||
if (Number.isFinite(n)) fx[k] = _fxClamp(k, n);
|
||||
// All numeric FX keys are 0-1 sliders — clamp so a
|
||||
// corrupt/foreign write can't overdrive opacities
|
||||
// or the camera pulse.
|
||||
if (Number.isFinite(n)) fx[k] = Math.min(1, Math.max(0, n));
|
||||
}
|
||||
}
|
||||
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||||
@@ -1423,7 +1127,7 @@
|
||||
} else {
|
||||
v = Number(value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
v = _fxClamp(key, v); // declared range, default 0-1
|
||||
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||||
@@ -1665,8 +1369,6 @@
|
||||
// Theme/material handles (built by buildScene/buildKeyboardAndHighway;
|
||||
// _applyTheme / _applyCinematic / the glow slider retune them live).
|
||||
let _theme = readThemeSetting();
|
||||
let _palette = readPaletteSetting();
|
||||
let _sharpMode = readSharpModeSetting(); // 'floating' | 'flat' | 'realistic'
|
||||
let ambLight = null, dirLight = null;
|
||||
let _floorMat = null;
|
||||
const _railMats = []; // lane-edge rail materials (theme laneDim)
|
||||
@@ -1730,20 +1432,8 @@
|
||||
return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12);
|
||||
}
|
||||
|
||||
function _paletteColor(pc) {
|
||||
return (NOTE_PALETTES[_palette] || PITCH_CLASS_COLORS)[pc];
|
||||
}
|
||||
|
||||
// Base color (24-bit int, no hand dimming) for a midi note under the
|
||||
// active palette — the octave scheme is procedural, every other
|
||||
// palette is a 12-entry pitch-class table.
|
||||
function _noteHex(midi) {
|
||||
if (_palette === 'octaves') return octaveNoteColor(midi);
|
||||
return _paletteColor(((midi % 12) + 12) % 12);
|
||||
}
|
||||
|
||||
function noteColor(midi, hand) {
|
||||
const base = new T.Color(_noteHex(midi));
|
||||
const base = new T.Color(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]);
|
||||
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
|
||||
base.multiplyScalar(b);
|
||||
return base;
|
||||
@@ -1770,51 +1460,15 @@
|
||||
const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K;
|
||||
const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K;
|
||||
const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible
|
||||
// 'flat' piano-shaped-lane geometry (see laneSpanFlat). Zero-overlap tiling:
|
||||
// white lanes are trimmed by FLAT_SHARP_HALF where they meet a sharp and
|
||||
// the sharp fills the gap, so nothing overlaps. To keep the naturals
|
||||
// even, each sharp is nudged FLAT_SHARP_SHIFT toward the EDGE natural
|
||||
// beside it (C#→C, D#→E, F#→F, A#→B; G# stays centred, no edge to lean
|
||||
// on) — that steals a sliver from the edge natural and hands it to the
|
||||
// squeezed inner natural. At shift = sharpHalf/3 the C-D-E-F-B naturals
|
||||
// come out exactly equal; G/A land a hair smaller (G# can't lean). The
|
||||
// sharps ride the same flat plane (no lift — they never overlap a
|
||||
// natural). OCT_GAP is the extra divider opened at each octave boundary
|
||||
// when the octaveGaps option is on.
|
||||
const FLAT_SHARP_HALF = 2.2 * K; // sharp half-width (4.4K wide)
|
||||
const FLAT_SHARP_SHIFT = FLAT_SHARP_HALF / 3; // sharp lean that evens the naturals
|
||||
const OCT_GAP = 0.9 * K;
|
||||
const LANE_DIMS_FLAT = {
|
||||
whiteW: WHITE_W, sharpHalf: FLAT_SHARP_HALF, shift: FLAT_SHARP_SHIFT, octGap: OCT_GAP,
|
||||
};
|
||||
// 'realistic' layout (laneSpanReal): every note bar is the size of the
|
||||
// physical key it lands on — naturals the full white-key width (always
|
||||
// rendered full, only occluded where a sharp note actually overlaps in
|
||||
// time) and sharps the full black-key width at their standard positions,
|
||||
// drawn on top with a hair of REAL_SHARP_LIFT (anti z-fight).
|
||||
const REAL_NAT_HALF = WHITE_W * 0.47; // natural bar ≈ physical white key (~11.3K)
|
||||
const REAL_SHARP_HALF = BLACK_W / 2; // sharp bar = physical black key (6.4K)
|
||||
const REAL_SHARP_LIFT = 0.3 * K;
|
||||
const LANE_DIMS_REAL = { natHalf: REAL_NAT_HALF, sharpHalf: REAL_SHARP_HALF, octGap: OCT_GAP };
|
||||
// Lane span for the active non-floating sharp mode. `range`
|
||||
// (activeLow/activeHigh) is optional and only consulted by the flat
|
||||
// layout, to gate the boundary-key edge trim (see laneSpanFlat).
|
||||
const _flatMode = () => _sharpMode === 'flat' || _sharpMode === 'realistic';
|
||||
function laneSpanFor(midi, black, cx, gaps, range) {
|
||||
return _sharpMode === 'realistic'
|
||||
? laneSpanReal(midi, black, cx, LANE_DIMS_REAL, gaps)
|
||||
: laneSpanFlat(midi, black, cx, LANE_DIMS_FLAT, gaps, range);
|
||||
}
|
||||
|
||||
// Camera — the default 'classic' preset is a low, near-telephoto rig
|
||||
// (RS+-style): a narrow FOV from low and back gives a deep receding
|
||||
// runway and frames ~2 octaves instead of cramming the whole note
|
||||
// range full-width. The x position pans to follow the active notes
|
||||
// (see updateScene), so wide pieces stay zoomed in on the played hand
|
||||
// rather than shrinking every key. The rig numbers now come from the
|
||||
// user-selectable CAM_PRESETS table; switching applies live because
|
||||
// position/lookAt are re-derived every frame.
|
||||
let _camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||
// Camera — low, near-telephoto rig (RS+-style): a narrow FOV from low
|
||||
// and back gives a deep receding runway and frames ~2 octaves instead
|
||||
// of cramming the whole note range full-width. The x position pans to
|
||||
// follow the active notes (see updateScene), so wide pieces stay zoomed
|
||||
// in on the played hand rather than shrinking every key.
|
||||
const CAM_FOV = 40;
|
||||
const CAM_Y = 46 * K, CAM_Z = 112 * K;
|
||||
const LOOK_Y = 8 * K, LOOK_Z = -165 * K;
|
||||
// Pan-follow: a slow ease toward a wide, gently-weighted centroid so the
|
||||
// camera glides with the melody instead of darting as notes enter/leave.
|
||||
const CAM_PAN_LERP = 0.022; // per-frame ease (~1s glide @60fps)
|
||||
@@ -1830,26 +1484,7 @@
|
||||
const CAM_ZOOM_BASE_KEYS = 11; // white keys framed at zoom = 1
|
||||
const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8;
|
||||
const CAM_ZOOM_LERP = 0.025; // smooth zoom ease
|
||||
// Full-swing of the camTilt aim offset (pre-K units) at slider ±1.
|
||||
const CAM_TILT_UNITS = 55;
|
||||
|
||||
// Base rig with the live fine-tune knobs applied — height/distance
|
||||
// multiply the preset, tilt offsets the aim height. Returns the
|
||||
// effective {y, z, lookY, lookZ} in pre-K units; the caller scales by
|
||||
// K and the auto-zoom. Keeps the pan/dolly follow-motion intact —
|
||||
// these only move the vantage point it orbits around. Writes into a
|
||||
// reusable object (returned live) so the per-frame camera update stays
|
||||
// allocation-free — the callers read it synchronously and never retain
|
||||
// it, so a single shared instance is safe.
|
||||
const _rigOut = { y: 0, z: 0, lookY: 0, lookZ: 0 };
|
||||
function _rig() {
|
||||
_rigOut.y = _camPreset.y * fx.camHeight;
|
||||
_rigOut.z = _camPreset.z * fx.camDist;
|
||||
_rigOut.lookY = _camPreset.lookY + fx.camTilt * CAM_TILT_UNITS;
|
||||
_rigOut.lookZ = _camPreset.lookZ;
|
||||
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.
|
||||
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)
|
||||
@@ -2310,9 +1945,10 @@
|
||||
_envRT = _makeStudioEnv(T, ren);
|
||||
if (_envRT) scene.environment = _envRT.texture;
|
||||
|
||||
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
|
||||
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
|
||||
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
|
||||
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
|
||||
cam.position.set(0, CAM_Y, CAM_Z);
|
||||
cam.lookAt(0, LOOK_Y, LOOK_Z);
|
||||
|
||||
ambLight = new T.AmbientLight(0xffffff, 0.75);
|
||||
dirLight = new T.DirectionalLight(0xffffff, 1.1);
|
||||
@@ -2438,9 +2074,9 @@
|
||||
});
|
||||
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
||||
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
|
||||
// pitch-class color by this (vertexColors).
|
||||
// pitch-class colour by this (vertexColors).
|
||||
geo.computeBoundingBox();
|
||||
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
||||
const pos = geo.attributes.position;
|
||||
@@ -2455,16 +2091,13 @@
|
||||
return geo;
|
||||
}
|
||||
|
||||
// Glossy note material, cached per resolved color. Keying by the
|
||||
// final color int (hand brightness already baked in by noteColor)
|
||||
// works for every palette — including 'octaves', where two notes of
|
||||
// the same pitch class in different octaves are DIFFERENT colors and
|
||||
// must not share a material (a pitch-class key would collide them).
|
||||
// Glossy note material, cached per (pitch class, hand).
|
||||
function _noteMaterial(midi, hand) {
|
||||
const col = noteColor(midi, hand);
|
||||
const key = col.getHex();
|
||||
const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh';
|
||||
const key = (((midi % 12) + 12) % 12) + '|' + handKey;
|
||||
let mat = _noteMatCache.get(key);
|
||||
if (mat) return mat;
|
||||
const col = noteColor(midi, hand);
|
||||
// MeshPhysicalMaterial with a clearcoat: lacquered glass-gem
|
||||
// look — a sharp coat highlight over a colored body, lit by the
|
||||
// studio env map. This is the "not plastic" ask: the old matte
|
||||
@@ -2474,7 +2107,7 @@
|
||||
// share one shader program.)
|
||||
mat = new T.MeshPhysicalMaterial({
|
||||
color: col,
|
||||
vertexColors: true, // multiply color by the baked gem ramp
|
||||
vertexColors: true, // multiply colour by the baked gem ramp
|
||||
emissive: col,
|
||||
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
||||
roughness: 0.32,
|
||||
@@ -2496,10 +2129,7 @@
|
||||
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
}
|
||||
function _laneGuideOpacity() {
|
||||
// Vibrancy sets the ceiling (much brighter than the old subtle
|
||||
// 0.10–0.22 range); the laneOpacity slider then scales 0 → ceiling.
|
||||
const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
|
||||
return vib * Math.min(1, Math.max(0, fx.laneOpacity));
|
||||
return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
}
|
||||
|
||||
// Live vibrancy slider: retint everything already built — the
|
||||
@@ -2515,39 +2145,6 @@
|
||||
for (const m of _laneGuideMats) m.opacity = lop;
|
||||
}
|
||||
|
||||
// Live palette switch: recolor everything already built — cached
|
||||
// note materials (future clones), per-note clones, key emissives
|
||||
// (incl. the wrong-flash restore state), lane guides — and drop the
|
||||
// pitch-class flame textures so the next spawn bakes the new hues.
|
||||
// Same no-rebuild approach as _applyVibrancy.
|
||||
function _applyPalette() {
|
||||
// The base-material cache is keyed by resolved color, so old
|
||||
// entries are simply stale under a new palette — drop them and let
|
||||
// the next build re-cache. The live per-note clones below are
|
||||
// retinted directly from each note's midi (palette-correct).
|
||||
for (const m of _noteMatCache.values()) m.dispose();
|
||||
_noteMatCache.clear();
|
||||
for (const nm of noteMeshes) {
|
||||
if (!nm.mesh || !nm.mesh.material) continue;
|
||||
const col = noteColor(nm.note.midi, nm.note.hand);
|
||||
nm.mesh.material.color.copy(col);
|
||||
nm.mesh.material.emissive.copy(col);
|
||||
}
|
||||
for (const [midi, km] of keyMeshes) {
|
||||
const col = noteColor(midi, 'rh');
|
||||
km.material.emissive.copy(col);
|
||||
km.userData.origEmissive = col.getHex();
|
||||
}
|
||||
for (const m of _laneGuideMats) {
|
||||
if (m.userData.midi != null) m.color.copy(noteColor(m.userData.midi, 'rh'));
|
||||
}
|
||||
_clearFlameTextures();
|
||||
// Re-arm the pool so no slot keeps rendering a disposed texture
|
||||
// (a flame mid-flight briefly re-tints — next spawn sets its
|
||||
// true pitch texture).
|
||||
for (const s of _flamePool) s.mat.map = _flameTexture(0);
|
||||
}
|
||||
|
||||
function _barNumberTexture(idx) {
|
||||
let tex = _barTexCache.get(idx);
|
||||
if (tex) return tex;
|
||||
@@ -2584,15 +2181,13 @@
|
||||
return _glowTex;
|
||||
}
|
||||
|
||||
// Vertical flame texture for hit flares / held-key halos: white-hot
|
||||
// base fading up into the note's color, with a horizontal falloff.
|
||||
// Cached per resolved color (bounded — 12 for pitch-class palettes,
|
||||
// up to ~one-per-octave for 'octaves'), so a flare always matches the
|
||||
// struck note's color whatever the palette.
|
||||
function _flameTexture(midi) {
|
||||
const c = _noteHex(midi);
|
||||
let tex = _flameTexCache.get(c);
|
||||
// Vertical flame texture for hit flares: white-hot base fading up
|
||||
// into the pitch-class colour, with a horizontal falloff. Cached per
|
||||
// pitch class (bounded, 12 entries).
|
||||
function _flameTexture(pc) {
|
||||
let tex = _flameTexCache.get(pc);
|
||||
if (tex) return tex;
|
||||
const c = PITCH_CLASS_COLORS[pc];
|
||||
const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff;
|
||||
const cnv = document.createElement('canvas');
|
||||
cnv.width = 64;
|
||||
@@ -2612,7 +2207,7 @@
|
||||
ctx.fillStyle = falloff;
|
||||
ctx.fillRect(0, 0, 64, 128);
|
||||
tex = new T.CanvasTexture(cnv);
|
||||
_flameTexCache.set(c, tex);
|
||||
_flameTexCache.set(pc, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
@@ -2699,7 +2294,7 @@
|
||||
if (!entry) return;
|
||||
const slot = _flamePool[_flameIdx];
|
||||
_flameIdx = (_flameIdx + 1) % _flamePool.length;
|
||||
slot.mat.map = _flameTexture(midi);
|
||||
slot.mat.map = _flameTexture(((midi % 12) + 12) % 12);
|
||||
slot.start = wallNow;
|
||||
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
|
||||
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
|
||||
@@ -2768,9 +2363,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Lane guides: a faint color strip running up the runway from each
|
||||
// active key, in that key's pitch-class color. A falling note shares
|
||||
// its target key's color, so the player can trace it straight down
|
||||
// Lane guides: a faint colour strip running up the runway from each
|
||||
// active key, in that key's pitch-class colour. A falling note shares
|
||||
// its target key's colour, so the player can trace it straight down
|
||||
// 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
|
||||
@@ -2780,87 +2375,28 @@
|
||||
// lane, perfectly aligned with the lane and its key.
|
||||
const guideLen = HIGHWAY_LEN - WHITE_L;
|
||||
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
|
||||
const gaps = fx.octaveGaps;
|
||||
const floating = _sharpMode === 'floating';
|
||||
const t = Math.min(1, Math.max(0, fx.laneOpacity)); // lane-color opacity
|
||||
const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
|
||||
const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
|
||||
// A vertical guide line running the full runway at world x (skips
|
||||
// near-transparent lines so the crossfade never builds dead meshes).
|
||||
const addLine = (x, color, opacity, wpx, trackTheme) => {
|
||||
if (opacity < 0.02) return;
|
||||
const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
|
||||
if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
|
||||
const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
|
||||
line.rotation.x = -Math.PI / 2;
|
||||
line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(line);
|
||||
};
|
||||
for (const [midi, entry] of layout) {
|
||||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||||
// Floating: white-only lanes (blacks float, lane-less). Flat/
|
||||
// realistic: every key gets a piano-shaped lane.
|
||||
if (entry.black && floating) continue;
|
||||
// Lane footprint per mode.
|
||||
let left, right, stripY = laneY;
|
||||
if (floating) {
|
||||
const cx = keyX(entry, whiteCount);
|
||||
left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
|
||||
if (gaps) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
|
||||
if (pc === 0) left += OCT_GAP / 2;
|
||||
}
|
||||
} else {
|
||||
const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
|
||||
left = span.left; right = span.right;
|
||||
if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
|
||||
}
|
||||
const center = (left + right) / 2;
|
||||
// Colored lane strip + a subtle per-lane separator — fade in with
|
||||
// lane opacity. (As lanes fade, the block/octave lines below take
|
||||
// over as the guide.)
|
||||
if (t > 0.02) {
|
||||
// Floating keeps the historical 0.84-wide white strip; the
|
||||
// piano-shaped lanes inset a touch for a dark separator.
|
||||
const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
|
||||
});
|
||||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(center, stripY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Per-lane separator, fading with the strips. Skip realistic
|
||||
// sharps (they overlap the white columns).
|
||||
if (!(entry.black && _sharpMode === 'realistic')) {
|
||||
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
|
||||
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
|
||||
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
|
||||
// dark layer (reads over bright lanes, scales with lane opacity) plus
|
||||
// a bright layer (reads over the dark floor, scales with the inverse),
|
||||
// so it auto-shifts dark→bright as you fade lanes; octaveContrast
|
||||
// scales the whole thing.
|
||||
for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
const isEF = pc === 4; // E → F block boundary
|
||||
const isBC = pc === 11; // B → C octave boundary
|
||||
if (!isEF && !isBC) continue;
|
||||
const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
|
||||
if (isBC) {
|
||||
addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false); // dark, over lanes
|
||||
addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
|
||||
} else {
|
||||
// E→F block divider — a guide that appears as the lanes fade.
|
||||
addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
|
||||
}
|
||||
if (entry.black) continue; // one strip per semitone-slot lands on whites
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false,
|
||||
});
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Thin brighter rails at the lane edges for crisp separation.
|
||||
const railMat = new T.MeshBasicMaterial({
|
||||
color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(),
|
||||
transparent: true, opacity: 0.5, depthWrite: false,
|
||||
});
|
||||
_railMats.push(railMat);
|
||||
const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat);
|
||||
rail.rotation.x = -Math.PI / 2;
|
||||
rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(rail);
|
||||
}
|
||||
|
||||
// Keys (whites first so blacks overlay). Geometries are shared
|
||||
@@ -2880,7 +2416,7 @@
|
||||
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
||||
const material = new T.MeshStandardMaterial({
|
||||
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
|
||||
// intensity up by proximity.
|
||||
emissive: noteColor(midi, 'rh'),
|
||||
@@ -2966,31 +2502,11 @@
|
||||
const entry = layout.get(note.midi);
|
||||
if (!entry) continue;
|
||||
const len = Math.max(4 * K, note.durSec * TS);
|
||||
// Non-floating layouts: notes ride the naturals' plane and take
|
||||
// their piano-shaped lane's width/centre. Floating (default):
|
||||
// original elevated sharps, key-centred bars.
|
||||
let w, x, y;
|
||||
if (_flatMode()) {
|
||||
const span = laneSpanFor(
|
||||
note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
|
||||
// 'realistic' bars are full (physical-key size); 'flat' bars are
|
||||
// inset a touch for a dark separator in the tight tiling.
|
||||
const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
|
||||
w = (span.right - span.left) * inset;
|
||||
x = (span.left + span.right) / 2;
|
||||
// Coplanar; in 'realistic' the sharps ride a hair proud so they
|
||||
// draw over the naturals they overlap without z-fighting.
|
||||
const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
|
||||
y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
|
||||
} else {
|
||||
w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
x = keyX(entry, whiteCount);
|
||||
y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
}
|
||||
const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
// Clone per note so each can glow independently while being consumed.
|
||||
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
|
||||
mesh.position.x = x;
|
||||
mesh.position.y = y;
|
||||
mesh.position.x = keyX(entry, whiteCount);
|
||||
mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
mesh.visible = false;
|
||||
notesGroup.add(mesh);
|
||||
// Note-name label: a camera-facing sprite (readable at this low camera
|
||||
@@ -3259,7 +2775,8 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
{ const r = _rig(); cam.position.set(_camX, r.y * K * _camZoom, r.z * K * _camZoom); cam.lookAt(_camX, r.lookY * K * _camZoom, r.lookZ * K * _camZoom); }
|
||||
cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom);
|
||||
cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom);
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -3700,13 +3217,9 @@
|
||||
if (_isReady) teardown();
|
||||
highwayCanvas = canvas;
|
||||
fx = readFxSettings();
|
||||
// Persisted string settings refresh here too — a palette,
|
||||
// camera, theme or background style saved while no instance was
|
||||
// listening (e.g. changed on the Settings screen, where the live
|
||||
// viz is torn down) must not come up stale on a later init().
|
||||
_palette = readPaletteSetting();
|
||||
_sharpMode = readSharpModeSetting();
|
||||
_camPreset = CAM_PRESETS[readCameraSetting()] || CAM_PRESETS.classic;
|
||||
// Persisted string settings refresh here too — a theme saved
|
||||
// while no instance was listening must not come up stale on a
|
||||
// later init().
|
||||
_theme = readThemeSetting();
|
||||
_bgStyle = readBgStyleSetting();
|
||||
loadThree().then(() => {
|
||||
@@ -3752,23 +3265,6 @@
|
||||
_bgStyle = d.bgStyle;
|
||||
_bgMountStyle();
|
||||
}
|
||||
if (d && d.palette && PALETTE_IDS.indexOf(d.palette) !== -1) {
|
||||
_palette = d.palette;
|
||||
_applyPalette();
|
||||
}
|
||||
if (d && d.sharpMode && SHARP_MODES.indexOf(d.sharpMode) !== -1) {
|
||||
// Geometry-time — takes effect on the next chart build.
|
||||
_sharpMode = d.sharpMode;
|
||||
}
|
||||
if (d && d.camera && CAM_PRESETS[d.camera]) {
|
||||
_camPreset = CAM_PRESETS[d.camera];
|
||||
// Position/lookAt re-derive next frame; only the
|
||||
// projection needs an explicit poke.
|
||||
if (cam) {
|
||||
cam.fov = _camPreset.fov;
|
||||
cam.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keys3d:settings', _fxThemeHandler);
|
||||
window.addEventListener('keys3d:settings', _fxHandler);
|
||||
@@ -3974,24 +3470,10 @@
|
||||
readFxSettings,
|
||||
readThemeSetting,
|
||||
readBgStyleSetting,
|
||||
readPaletteSetting,
|
||||
readCameraSetting,
|
||||
readSharpModeSetting,
|
||||
SHARP_MODES,
|
||||
_bgThemeColors,
|
||||
BG_THEMES,
|
||||
BG_STYLE_IDS,
|
||||
NOTE_PALETTES,
|
||||
PITCH_CLASS_COLORS,
|
||||
PALETTE_IDS,
|
||||
OCTAVE_HUES,
|
||||
octaveNoteColor,
|
||||
_isBlackPc,
|
||||
laneSpanFlat,
|
||||
laneSpanReal,
|
||||
CAM_PRESETS,
|
||||
FX_DEFAULTS,
|
||||
FX_RANGES,
|
||||
_classifyTiming,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,22 +12,6 @@
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
||||
<select id="keysh3d-fx-palette"
|
||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
<option value="vivid">Vivid (per-pitch, punchier)</option>
|
||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Choose the color scheme for the falling notes, key glow, lane
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="keysh3d-fx-theme"
|
||||
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
|
||||
@@ -46,117 +30,7 @@
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background gradient, floor and lane rails — the same theme names
|
||||
as the guitar highway. Note colors come from the
|
||||
"Note colors" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
<select id="keysh3d-fx-camera"
|
||||
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="classic">Classic (low, deep runway)</option>
|
||||
<option value="elevated">Elevated (higher, more board)</option>
|
||||
<option value="overhead" selected>Overhead (top-down reading view)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Where the camera sits. Classic is the original low rig; Elevated
|
||||
lifts it for a fuller view of the keybed; Overhead looks down the
|
||||
lanes for a sheet-reading feel. Applies live, keeps the
|
||||
auto-pan/zoom that follows your hands.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camheight"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Raise or lower the camera around the angle above (higher = more
|
||||
top-down). Fine-tunes the base view; the follow-motion stays.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camdist"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Pull the camera back or push it in (larger = further away, smaller
|
||||
= closer).
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camtilt"
|
||||
min="-1" max="1" step="0.02" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Tilt the view up (+) or down (−) without moving the camera —
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
|
||||
|
||||
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps & 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.
|
||||
as the guitar highway. Pitch-class note colours never change.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
@@ -242,7 +116,7 @@
|
||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||
Timing colors
|
||||
Timing colours
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
@@ -305,9 +179,6 @@
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
|
||||
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
|
||||
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
@@ -319,26 +190,6 @@
|
||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
|
||||
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
|
||||
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
|
||||
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const el = document.getElementById(elId);
|
||||
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
|
||||
el.value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
|
||||
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
|
||||
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
|
||||
const storedCamera = localStorage.getItem('keys3d_bg_camera');
|
||||
const cameraSel = document.getElementById('keysh3d-fx-camera');
|
||||
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
|
||||
cameraSel.value = storedCamera;
|
||||
}
|
||||
const storedStyle = localStorage.getItem('keys3d_bg_style');
|
||||
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
@@ -349,16 +200,6 @@
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
const storedPalette = localStorage.getItem('keys3d_bg_palette');
|
||||
const paletteSel = document.getElementById('keysh3d-fx-palette');
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
|
||||
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
|
||||
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
|
||||
sharpSel.value = storedSharp;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -148,405 +148,3 @@ test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
|
||||
|
||||
test('note palettes: 12 entries each, classic IS the stock table', () => {
|
||||
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
|
||||
load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(NOTE_PALETTES),
|
||||
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
|
||||
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
|
||||
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
|
||||
for (const c of colors) {
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
|
||||
id + ' colours are 24-bit ints');
|
||||
}
|
||||
}
|
||||
// 'classic' preserves the shipped look byte-identically — it is the
|
||||
// same array, not a copy that could drift.
|
||||
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
|
||||
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
|
||||
});
|
||||
|
||||
test('note palettes: two-tone tables use darker sharps than naturals', () => {
|
||||
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
for (const id of ['emerald', 'ice']) {
|
||||
const p = NOTE_PALETTES[id];
|
||||
for (const sharp of [1, 3, 6, 8, 10]) {
|
||||
assert.ok(luma(p[sharp]) < luma(p[0]),
|
||||
id + ' sharp pc ' + sharp + ' darker than naturals');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readPaletteSetting: octaves default, validated overrides only', () => {
|
||||
// No localStorage in the vm → the plug-and-play default.
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(bare.readPaletteSetting(), 'octaves');
|
||||
// An explicit non-default value (classic) overrides.
|
||||
const store = { keys3d_bg_palette: 'classic' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readPaletteSetting(), 'classic');
|
||||
// Corrupt/foreign value → the default rather than an undefined scheme.
|
||||
store.keys3d_bg_palette = 'banana';
|
||||
assert.equal(readPaletteSetting(), 'octaves');
|
||||
});
|
||||
|
||||
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetPalette('emerald');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.palette, 'emerald');
|
||||
// Unknown id: no write, no event.
|
||||
win.keys3dSetPalette('banana');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
|
||||
win.keys3dSetPalette('octaves');
|
||||
assert.equal(store.keys3d_bg_palette, 'octaves');
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
|
||||
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...PALETTE_IDS],
|
||||
[...Object.keys(NOTE_PALETTES), 'octaves']);
|
||||
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
|
||||
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
|
||||
});
|
||||
|
||||
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
|
||||
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
|
||||
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
|
||||
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
|
||||
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
|
||||
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
|
||||
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
|
||||
// Naturals across one octave (C1..B1 whites) all share the octave hue.
|
||||
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
|
||||
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
|
||||
}
|
||||
// Sharps in an octave are a DARKER shade of that same hue.
|
||||
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
|
||||
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
|
||||
'sharp ' + sharp + ' darker than the octave natural');
|
||||
}
|
||||
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
|
||||
// different from the red octave-1 start.
|
||||
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
|
||||
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
|
||||
// Loop: an octave past the table wraps (safety for out-of-88 midi).
|
||||
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
|
||||
});
|
||||
|
||||
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
|
||||
|
||||
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
|
||||
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.camHeight, 1.0);
|
||||
assert.equal(FX_DEFAULTS.camDist, 1.0);
|
||||
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
|
||||
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
|
||||
// and 'classic' + this default reproduces the exact historical rig.
|
||||
assert.equal(FX_DEFAULTS.camTilt, 0.0);
|
||||
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
|
||||
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
|
||||
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
|
||||
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
|
||||
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
|
||||
});
|
||||
|
||||
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
win.keys3dSetFx('camTilt', -0.5);
|
||||
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
|
||||
win.keys3dSetFx('camTilt', -99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
|
||||
});
|
||||
|
||||
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
|
||||
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
|
||||
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
|
||||
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
|
||||
// Setter: same clamp on the way in; a value above 1 must survive
|
||||
// (the historical 0-1 clamp would have crushed 1.3 to 1).
|
||||
win.keys3dSetFx('camHeight', 1.3);
|
||||
assert.equal(store.keys3d_bg_camHeight, '1.3');
|
||||
win.keys3dSetFx('camDist', 99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
|
||||
// Un-ranged keys keep the historical 0-1 clamp.
|
||||
win.keys3dSetFx('vibrancy', 2);
|
||||
assert.equal(store.keys3d_bg_vibrancy, '1');
|
||||
});
|
||||
|
||||
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
|
||||
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const hitZ = 0;
|
||||
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
|
||||
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
|
||||
assert.equal(d2, d1 * 2);
|
||||
// At the hit moment the note is at the hit-line regardless of speed.
|
||||
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
|
||||
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
|
||||
});
|
||||
|
||||
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
|
||||
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
|
||||
// 'classic' preserves the historical constants (pre-K units) even though
|
||||
// it is no longer the default — anyone who picks it gets the old rig back
|
||||
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
|
||||
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
|
||||
assert.deepEqual({ ...CAM_PRESETS.classic },
|
||||
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
|
||||
for (const [id, p] of Object.entries(CAM_PRESETS)) {
|
||||
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
|
||||
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
|
||||
}
|
||||
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
|
||||
}
|
||||
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
|
||||
});
|
||||
|
||||
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
|
||||
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
|
||||
// The shipped default look = overhead preset + the default camTilt. Before,
|
||||
// that was lookY 0 + (−0.6 × 55) = −33; the tuned aim now lives in the
|
||||
// preset (lookY −33) with a neutral camTilt (0), so the effective aim — and
|
||||
// thus the out-of-the-box framing — is byte-identical.
|
||||
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effOverhead, -33);
|
||||
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
|
||||
// the "pick Classic for the original look" promise, now actually true.
|
||||
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effClassic, 8);
|
||||
});
|
||||
|
||||
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetCamera('overhead');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.camera, 'overhead');
|
||||
win.keys3dSetCamera('helicopter');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
store.keys3d_bg_camera = 'garbage';
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
|
||||
|
||||
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
|
||||
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
|
||||
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
|
||||
// Sharp LAYOUT is a string setting, not an FX bool.
|
||||
assert.equal('flatSharps' in FX_DEFAULTS, false);
|
||||
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
|
||||
});
|
||||
|
||||
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetFx('octaveGaps', true);
|
||||
assert.equal(store.keys3d_bg_octaveGaps, '1');
|
||||
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
|
||||
win.keys3dSetFx('laneOpacity', 0.35);
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
|
||||
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '1');
|
||||
win.keys3dSetFx('octaveContrast', 0.8);
|
||||
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
|
||||
});
|
||||
|
||||
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
|
||||
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events[0].detail.sharpMode, 'flat');
|
||||
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
|
||||
// Unknown id ignored (no write, no event).
|
||||
win.keys3dSetSharpMode('bogus');
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
|
||||
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const sh = 2.2, shift = 2.2 / 3;
|
||||
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
|
||||
// cx for one octave: whites on integer slots, blacks on half-slots — the
|
||||
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
|
||||
const CX = {
|
||||
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
|
||||
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
|
||||
};
|
||||
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
|
||||
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
|
||||
const wOf = (s) => s.right - s.left;
|
||||
const w = (m) => wOf(spans[midis.indexOf(m)]);
|
||||
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
|
||||
}
|
||||
// Sharps are all the same width.
|
||||
for (const m of [61, 63, 66, 68, 70]) {
|
||||
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
|
||||
}
|
||||
// The lean evens the naturals: C, D, E, F, B all come out equal.
|
||||
for (const m of [62, 64, 65, 71]) {
|
||||
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
|
||||
}
|
||||
// G and A are the only slightly-smaller naturals (G# can't lean) — still
|
||||
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
|
||||
// (which would leave D at 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,7 +1,7 @@
|
||||
{
|
||||
"id": "tuner",
|
||||
"name": "Guitar/Bass Tuner",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.3",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -869,15 +869,8 @@ window._tunerUI = function(state, actions) {
|
||||
btn.textContent = 'Tuner';
|
||||
btn.title = 'Open Tuner';
|
||||
btn.onclick = window.tuner.toggle;
|
||||
// Anchor to the last DIRECT-child button of `controls` (the classic
|
||||
// transport's close/exit button). A bare `button:last-child` can match
|
||||
// a NESTED button that is not a direct child of `controls`, and
|
||||
// `insertBefore()` then throws NotFoundError — which propagated out of
|
||||
// the player-screen transition and aborted its render (feedBack#800).
|
||||
// `:scope > button:last-of-type` restricts the anchor to a direct child;
|
||||
// the parentNode check is a belt-and-suspenders guard before insertBefore.
|
||||
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
|
||||
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
|
||||
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
|
||||
if (closeBtn) controls.insertBefore(btn, closeBtn);
|
||||
else controls.appendChild(btn);
|
||||
updatePlayerButton();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -199,7 +199,6 @@ _DEMO_BLOCKED: list[tuple[str, re.Pattern]] = [
|
||||
("DELETE", re.compile(r"^/api/audio-effects/active-mapping$")),
|
||||
("POST", re.compile(r"^/api/song/.*/meta$")),
|
||||
("POST", re.compile(r"^/api/song/.*/art/upload$")),
|
||||
("PUT", re.compile(r"^/api/song/.+/overrides$")),
|
||||
("GET", re.compile(r"^/api/plugins/updates$")),
|
||||
("POST", re.compile(r"^/api/plugins/[^/]+/update$")),
|
||||
("POST", re.compile(r"^/api/plugins/editor/save$")),
|
||||
@@ -556,13 +555,7 @@ def next_library_cursor(sort: str, last_song: dict | None) -> str | None:
|
||||
key = "mtime" if col == "mtime" else col
|
||||
if key not in last_song or "filename" not in last_song:
|
||||
return None
|
||||
# A title display-override (Fix-metadata popup) replaces last_song["title"]
|
||||
# for the card, but the keyset seek runs on the RAW title column — resume
|
||||
# from the raw value query_page stashed (present only when the last row's
|
||||
# title was overridden), so paging never skips/dupes.
|
||||
val = (last_song["_sort_title"] if (key == "title" and "_sort_title" in last_song)
|
||||
else last_song[key])
|
||||
return _encode_cursor([val, last_song["filename"]])
|
||||
return _encode_cursor([last_song[key], last_song["filename"]])
|
||||
|
||||
|
||||
# Song-level "mastered" threshold — best accuracy across a song's arrangements
|
||||
@@ -674,26 +667,6 @@ class MetadataDB:
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_song_tags_tag ON song_tags(tag COLLATE NOCASE)")
|
||||
# Per-field metadata OVERRIDES + LOCKS (the Fix-metadata popup). A
|
||||
# reversible DISPLAY overlay, never written to the pack: `value` is the
|
||||
# user's corrected value for a catalog field (title/artist/album/year/
|
||||
# genre), `locked=1` pins the field so a metadata refresh / auto-match
|
||||
# never changes what's shown for it (Plex-style field lock). Effective
|
||||
# display value = override → matched-MusicBrainz → pack → derived.
|
||||
# Filename-keyed → purged with the song on delete_song, NEVER on a
|
||||
# rescan (delete_missing), so an edit survives re-import like every other
|
||||
# local layer.
|
||||
self.conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS song_field_override (
|
||||
filename TEXT NOT NULL,
|
||||
field TEXT NOT NULL, -- title|artist|album|year|genre
|
||||
value TEXT, -- corrected value (NULL = lock only, no override)
|
||||
locked INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (filename, field)
|
||||
)
|
||||
""")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_field_override_fn ON song_field_override(filename)")
|
||||
# Artist-name aliases (P4): "ACDC" → "AC/DC", "the beatles" → "The Beatles".
|
||||
# A CANONICALIZATION OVERRIDE applied AT DISPLAY only — the scanner-derived
|
||||
# `songs.artist` and the feedpak files are never rewritten (a rescan can't
|
||||
@@ -1195,125 +1168,6 @@ class MetadataDB:
|
||||
self.conn.commit()
|
||||
return self.get_song_user_meta(filename)
|
||||
|
||||
# ── Per-field metadata overrides + locks (Fix-metadata popup) ─────────────
|
||||
def get_song_overrides(self, filename: str) -> dict:
|
||||
"""{field: {"value": str|None, "locked": bool}} for one song."""
|
||||
rows = self.conn.execute(
|
||||
"SELECT field, value, locked FROM song_field_override WHERE filename = ?",
|
||||
(filename,)).fetchall()
|
||||
return {r[0]: {"value": r[1], "locked": bool(r[2])} for r in rows}
|
||||
|
||||
def set_song_override(self, filename: str, field: str, *,
|
||||
value="__keep__", locked="__keep__") -> dict:
|
||||
"""Partial upsert of one field's override value and/or lock. Pass a
|
||||
value/locked to set it or leave the sentinel to keep the current one. A
|
||||
row with neither a value nor a lock is dropped (no empty shell). Returns
|
||||
the song's full override map."""
|
||||
with self._lock:
|
||||
cur = self.conn.execute(
|
||||
"SELECT value, locked FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field)).fetchone()
|
||||
new_val = (cur[0] if cur else None) if value == "__keep__" else value
|
||||
new_lock = (bool(cur[1]) if cur else False) if locked == "__keep__" else bool(locked)
|
||||
new_val = (new_val or "").strip() or None
|
||||
if new_val is None and not new_lock:
|
||||
self.conn.execute(
|
||||
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field))
|
||||
else:
|
||||
self.conn.execute(
|
||||
"INSERT INTO song_field_override (filename, field, value, locked, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now')) "
|
||||
"ON CONFLICT(filename, field) DO UPDATE SET "
|
||||
"value = excluded.value, locked = excluded.locked, updated_at = excluded.updated_at",
|
||||
(filename, field, new_val, 1 if new_lock else 0))
|
||||
self.conn.commit()
|
||||
return self.get_song_overrides(filename)
|
||||
|
||||
def locked_fields(self, filename: str) -> set:
|
||||
"""The catalog fields the user LOCKED for a song (Fix-metadata popup).
|
||||
An automatic match must never (re)canonicalize these, and gap-fill must
|
||||
never write them to the file. Locked read (the enrichment worker calls
|
||||
it), minimal projection."""
|
||||
with self._lock:
|
||||
return {r[0] for r in self.conn.execute(
|
||||
"SELECT field FROM song_field_override WHERE filename = ? AND locked = 1",
|
||||
(filename,)).fetchall()}
|
||||
|
||||
def clear_song_override(self, filename: str, field: str) -> dict:
|
||||
"""Remove a field's override + lock entirely (revert to the resolved
|
||||
pack/matched value)."""
|
||||
with self._lock:
|
||||
self.conn.execute(
|
||||
"DELETE FROM song_field_override WHERE filename = ? AND field = ?",
|
||||
(filename, field))
|
||||
self.conn.commit()
|
||||
return self.get_song_overrides(filename)
|
||||
|
||||
def overrides_map(self, filenames) -> dict:
|
||||
"""{filename: {field: {value, locked}}} for a batch — feeds the grid's
|
||||
effective-value resolution (display slice). Chunked under SQLite's
|
||||
variable limit."""
|
||||
fns = list(filenames)
|
||||
out: dict = {}
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
q = ("SELECT filename, field, value, locked FROM song_field_override "
|
||||
"WHERE filename IN (%s)" % ",".join("?" * len(chunk)))
|
||||
for fn, field, value, locked in self.conn.execute(q, chunk).fetchall():
|
||||
out.setdefault(fn, {})[field] = {"value": value, "locked": bool(locked)}
|
||||
return out
|
||||
|
||||
def _romaji_display(self, filename: str, artist: str, title: str):
|
||||
"""English-base display fallback. A blank-artist CDLC pack named
|
||||
'Artist_Title_v1_p' has no readable name (artist blank; title = the raw
|
||||
filename), and a match would fill it with the artist's NATIVE script
|
||||
(kanji/kana). Surface the author's own romaji parsed from the filename
|
||||
instead, so an English base reads 'Junko Yagami - BAY CITY'. Only kicks in
|
||||
when the pack has no artist of its own — a real pack artist is untouched."""
|
||||
if (artist or "").strip():
|
||||
return artist, title
|
||||
d = _artist_title_from_filename(filename)
|
||||
return (d["artist"], d["title"]) if d else (artist, title)
|
||||
|
||||
def pack_fields(self, filename: str) -> dict:
|
||||
"""The stored (pack) values for the overridable catalog fields — the
|
||||
Fix-metadata popup shows these behind each override as the 'revert to
|
||||
pack' reference + the Yours/Pack provenance. Empty strings for a missing
|
||||
song so the popup always has a value to render."""
|
||||
keys = ("title", "artist", "album", "year", "genre")
|
||||
row = self.conn.execute(
|
||||
"SELECT title, artist, album, year, genre FROM songs WHERE filename = ?",
|
||||
(filename,)).fetchone()
|
||||
vals = {k: ((row[i] or "") if row else "") for i, k in enumerate(keys)}
|
||||
# Baseline the author's romaji (from the filename) for a blank-artist pack,
|
||||
# so the Details tab's Pack reference matches what the grid shows.
|
||||
vals["artist"], vals["title"] = self._romaji_display(filename, vals["artist"], vals["title"])
|
||||
return vals
|
||||
|
||||
# Effective genre = a per-song genre OVERRIDE (Fix-metadata popup) else the
|
||||
# scanned pack genre. Applied at FILTER/FACET time (like the P4 artist alias)
|
||||
# so a corrected genre is browsable — the correlated subquery is used ONLY
|
||||
# when genre overrides actually exist; the common case stays on the plain
|
||||
# indexed `genre` column. Genre stays a library-only overlay (it isn't a
|
||||
# write-to-file field), so it never touches the pack.
|
||||
_EFFECTIVE_GENRE_SQL = (
|
||||
"COALESCE((SELECT o.value FROM song_field_override o "
|
||||
"WHERE o.filename = songs.filename AND o.field = 'genre' "
|
||||
"AND o.value IS NOT NULL AND o.value != ''), genre)"
|
||||
)
|
||||
|
||||
def _has_genre_overrides(self) -> bool:
|
||||
return self.conn.execute(
|
||||
"SELECT 1 FROM song_field_override WHERE field = 'genre' "
|
||||
"AND value IS NOT NULL AND value != '' LIMIT 1").fetchone() is not None
|
||||
|
||||
def _effective_genre_expr(self) -> str:
|
||||
"""`genre` normally; the override-aware COALESCE only when overrides exist."""
|
||||
return self._EFFECTIVE_GENRE_SQL if self._has_genre_overrides() else "genre"
|
||||
|
||||
def set_song_tags(self, filename: str, tags) -> list:
|
||||
"""Replace ALL of a song's tags with the given set (each normalized;
|
||||
blanks + case-dupes dropped). Full-replace so the whole personal-meta
|
||||
@@ -1348,23 +1202,16 @@ class MetadataDB:
|
||||
return [{"tag": r[0], "count": r[1]} for r in rows]
|
||||
|
||||
def user_meta_map(self, filenames) -> dict:
|
||||
"""Batch {filename: user_difficulty} for a set of rows (set values
|
||||
only). Lets query_page / query_artists embed difficulty without an
|
||||
N+1. Chunked under SQLite's variable limit — query_artists can pass
|
||||
every song across 50 artists, well past a single IN (...)."""
|
||||
"""Batch {filename: user_difficulty} for a page of rows (set values
|
||||
only). Lets query_page embed difficulty without an N+1."""
|
||||
fns = list(filenames)
|
||||
out: dict = {}
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
ph = ",".join("?" * len(chunk))
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, user_difficulty FROM song_user_meta "
|
||||
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", chunk).fetchall()
|
||||
for fn, diff in rows:
|
||||
out[fn] = diff
|
||||
return out
|
||||
if not fns:
|
||||
return {}
|
||||
ph = ",".join("?" * len(fns))
|
||||
rows = self.conn.execute(
|
||||
f"SELECT filename, user_difficulty FROM song_user_meta "
|
||||
f"WHERE filename IN ({ph}) AND user_difficulty IS NOT NULL", fns).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
def tags_map(self, filenames) -> dict:
|
||||
"""Batch {filename: [tags]} for a page of rows."""
|
||||
@@ -1385,7 +1232,6 @@ class MetadataDB:
|
||||
INSIDE the caller's `meta_db._lock` — must not re-acquire the lock."""
|
||||
self.conn.execute("DELETE FROM song_user_meta WHERE filename = ?", (filename,))
|
||||
self.conn.execute("DELETE FROM song_tags WHERE filename = ?", (filename,))
|
||||
self.conn.execute("DELETE FROM song_field_override WHERE filename = ?", (filename,))
|
||||
|
||||
def batch_user_meta(self, filenames, *, set_difficulty="__keep__",
|
||||
add_tags=None, remove_tags=None) -> int:
|
||||
@@ -3192,22 +3038,6 @@ class MetadataDB:
|
||||
out[fn] = st
|
||||
return out
|
||||
|
||||
def _unmatched_set(self, filenames) -> set:
|
||||
"""The subset of `filenames` whose enrichment landed in the 'failed'
|
||||
(no-match) state — feeds the grid's persistent per-card "no match" badge,
|
||||
so the misses stay visible at rest (the batch tile only shows while a
|
||||
refresh runs). Chunked set membership, like favorite_set."""
|
||||
fns = list(filenames)
|
||||
out: set = set()
|
||||
for i in range(0, len(fns), 400):
|
||||
chunk = fns[i:i + 400]
|
||||
if not chunk:
|
||||
break
|
||||
q = ("SELECT filename FROM song_enrichment WHERE match_state = 'failed' "
|
||||
"AND filename IN (%s)" % ",".join("?" * len(chunk)))
|
||||
out.update(r[0] for r in self.conn.execute(q, chunk).fetchall())
|
||||
return out
|
||||
|
||||
def enrichment_song_row(self, filename: str) -> dict | None:
|
||||
"""The identity fields the matcher/scorer keys on, for one song."""
|
||||
row = self.conn.execute(
|
||||
@@ -3508,7 +3338,7 @@ class MetadataDB:
|
||||
# list on scan). OR within the selected set.
|
||||
if genre:
|
||||
_gph = ",".join(["?"] * len(genre))
|
||||
where += f" AND ({self._effective_genre_expr()}) COLLATE NOCASE IN ({_gph})"
|
||||
where += f" AND genre COLLATE NOCASE IN ({_gph})"
|
||||
params += list(genre)
|
||||
# Mastery bands = best accuracy across a song's arrangements (song_stats,
|
||||
# a separate table -> correlated subquery). mastered >= 0.9, in_progress =
|
||||
@@ -4114,18 +3944,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) 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"):
|
||||
# Sort law (§7.1): mastery aggregates MAX across the WHOLE group —
|
||||
@@ -4224,20 +4042,6 @@ class MetadataDB:
|
||||
fns = [s["filename"] for s in songs]
|
||||
udm = self.user_meta_map(fns)
|
||||
tgm = self.tags_map(fns)
|
||||
# Enrichment "no match" (failed) set for the page, so a card can show a
|
||||
# persistent "no match" badge — the Refresh-Metadata batch's transient
|
||||
# per-tile state only paints while a pass runs. Cheap set membership like
|
||||
# favs/estd, so the misses stay visible at rest.
|
||||
um = self._unmatched_set(fns)
|
||||
# Per-song display OVERRIDES (Fix-metadata popup, slice 3). "Grid shows
|
||||
# only overrides": the effective cell is the user's override else the
|
||||
# pack value — a matched MusicBrainz canon NEVER silently re-titles a
|
||||
# card (canon lives in the Details drawer + art). Overlaid in Python
|
||||
# over the visible window, keyset-safe exactly like the P4 alias re-label
|
||||
# below: the seek still runs on the raw column (the one overridable
|
||||
# keyset column, title, stashes its raw value for the cursor — see
|
||||
# _sort_title / next_library_cursor).
|
||||
omap = self.overrides_map(fns)
|
||||
# Canonical artist at display (P4): re-label the card's artist through the
|
||||
# alias override so "ACDC" reads as "AC/DC". Display-only — the row's sort
|
||||
# position (raw artist) is untouched, so a card can show a canonical name
|
||||
@@ -4247,32 +4051,8 @@ class MetadataDB:
|
||||
for s in songs:
|
||||
s["user_difficulty"] = udm.get(s["filename"])
|
||||
s["tags"] = tgm.get(s["filename"], [])
|
||||
s["unmatched"] = s["filename"] in um
|
||||
if amap:
|
||||
s["artist"] = amap.get((s.get("artist") or "").lower(), s.get("artist"))
|
||||
# English-base romaji fallback: a blank-artist CDLC pack shows nothing
|
||||
# useful (artist blank; title = the raw filename). Surface the author's
|
||||
# romaji from the "Artist_Title_v1_p" filename so the card reads
|
||||
# "Junko Yagami — BAY CITY", never blank or native script. Display-only;
|
||||
# a user override (below) still wins. Keyset-safe: stash the raw title
|
||||
# for the cursor before replacing it.
|
||||
if not (s.get("artist") or "").strip():
|
||||
r_artist, r_title = self._romaji_display(s["filename"], s.get("artist"), s.get("title"))
|
||||
if r_title != s.get("title") and "_sort_title" not in s:
|
||||
s["_sort_title"] = s["title"]
|
||||
s["artist"], s["title"] = r_artist, r_title
|
||||
# Override wins over the pack AND the alias re-label — it's the user's
|
||||
# explicit per-song choice. Only a non-empty override VALUE replaces a
|
||||
# cell; a lock-only row (value None) leaves the displayed value alone.
|
||||
ov = omap.get(s["filename"])
|
||||
if ov:
|
||||
for field in ("title", "artist", "album", "year"):
|
||||
cell = ov.get(field)
|
||||
val = cell.get("value") if cell else None
|
||||
if val:
|
||||
if field == "title" and "_sort_title" not in s:
|
||||
s["_sort_title"] = s["title"] # raw title, for the keyset cursor
|
||||
s[field] = val
|
||||
# Grouped rows carry the ⚑ N (chart_count) + the work_key from the
|
||||
# materialized read-model, so the card can render the "N charts" chip and
|
||||
# address the Charts drawer (GET /api/work/{work_key}/charts) without a
|
||||
@@ -4400,11 +4180,6 @@ class MetadataDB:
|
||||
from collections import OrderedDict
|
||||
estd = self._estd_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()
|
||||
for r in rows:
|
||||
artist = r[2] or "Unknown Artist"
|
||||
@@ -4426,7 +4201,6 @@ class MetadataDB:
|
||||
"tuning_name": r[12] or "",
|
||||
"has_estd": r[0] in estd,
|
||||
"favorite": r[0] in favs,
|
||||
"user_difficulty": udm.get(r[0]),
|
||||
})
|
||||
|
||||
# Pick most common name variant per artist/album
|
||||
@@ -6572,36 +6346,6 @@ def _mb_search_recordings(artist, title, limit: int = 12) -> list[dict]:
|
||||
return cands
|
||||
|
||||
|
||||
def _mb_search_release_groups(query: str, limit: int = 8) -> list[dict]:
|
||||
"""Text search /release-group for the Change-cover picker: albums matching a
|
||||
free query, each mapped to its Cover Art Archive front thumb. One request;
|
||||
tiles whose CAA art is missing self-hide client-side (front-250 404s). Lets a
|
||||
cover be found even for a song with no metadata match (the city-pop pile)."""
|
||||
q = (query or "").strip()
|
||||
if not q:
|
||||
return []
|
||||
body = _mb_http_get("release-group", {"query": q, "limit": limit})
|
||||
out: list[dict] = []
|
||||
for rg in ((body or {}).get("release-groups") or []):
|
||||
rid = rg.get("id")
|
||||
if not rid:
|
||||
continue
|
||||
# artist-credit is a list of {name, joinphrase, artist} (joinphrase glues
|
||||
# collaborations) — reconstruct the credited name.
|
||||
artist = "".join(
|
||||
(c.get("name", "") + c.get("joinphrase", "")) if isinstance(c, dict) else str(c)
|
||||
for c in (rg.get("artist-credit") or [])
|
||||
).strip()
|
||||
title = rg.get("title") or ""
|
||||
year = (rg.get("first-release-date") or "")[:4]
|
||||
out.append({
|
||||
"id": rid,
|
||||
"label": " · ".join(x for x in (title, artist, year) if x) or title or "Cover",
|
||||
"thumb_url": f"https://coverartarchive.org/release-group/{rid}/front-250",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# ── AcoustID audio fingerprinting (content-based identification) ──────────────
|
||||
# Optional path: requires the Chromaprint `fpcalc` binary AND an AcoustID API
|
||||
# key ($ACOUSTID_API_KEY). Both absent ⇒ graceful no-op; the text matcher runs.
|
||||
@@ -7220,34 +6964,6 @@ def _artist_title_from_filename(filename: str) -> dict | None:
|
||||
return {"artist": artist, "title": title}
|
||||
|
||||
|
||||
# A per-song LOCK (Fix-metadata popup) → the candidate display keys it
|
||||
# suppresses on an AUTOMATIC match. Identity keys (recording/release/artist ids,
|
||||
# isrc) are deliberately absent: a locked DISPLAY field still gets matched for
|
||||
# art + future re-match, it just isn't re-canonicalized behind the user's back.
|
||||
_LOCK_FIELD_TO_CAND = {
|
||||
"artist": ("artist", "artist_sort"),
|
||||
"title": ("title",),
|
||||
"album": ("album",),
|
||||
"year": ("year",),
|
||||
"genre": ("genres",),
|
||||
}
|
||||
|
||||
|
||||
def _compose_lock_filter(base_filter, locked_fields):
|
||||
"""Wrap the pass's global per-field apply-filter with a per-song filter that
|
||||
also strips the song's LOCKED display fields, so an automatic match never
|
||||
re-canonicalizes a field the user pinned. Returns base_filter unchanged when
|
||||
the song has no relevant lock (the common path)."""
|
||||
blocked = {ck for f in locked_fields for ck in _LOCK_FIELD_TO_CAND.get(f, ())}
|
||||
if not blocked:
|
||||
return base_filter
|
||||
|
||||
def lock_filter(cand):
|
||||
c = base_filter(cand) if base_filter else cand
|
||||
return {k: v for k, v in c.items() if k not in blocked}
|
||||
return lock_filter
|
||||
|
||||
|
||||
def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||||
apply_mask: str = "") -> None:
|
||||
"""The matcher (P8; replaces P7's no-op). Precedence per design §5:
|
||||
@@ -7273,14 +6989,6 @@ def _enrich_one(row: dict, auto_min: float | None = None, field_filter=None,
|
||||
siblings. Network errors raise EnrichTransportError so the pass pauses
|
||||
instead of burning attempts while offline."""
|
||||
fn, chash = row["filename"], row["content_hash"]
|
||||
# Respect per-song field LOCKS (Fix-metadata popup): an automatic match must
|
||||
# not re-canonicalize a field the user pinned. Compose the lock filter onto
|
||||
# the pass's global apply-filter — both the cache-copy and text-match auto
|
||||
# paths run their candidate through it. (Review/manual picks bypass the
|
||||
# filter, so confirming a match in the modal is an explicit override.)
|
||||
locked = meta_db.locked_fields(fn)
|
||||
if locked:
|
||||
field_filter = _compose_lock_filter(field_filter, locked)
|
||||
|
||||
cached = meta_db.enrichment_cache_lookup(chash, exclude_filename=fn)
|
||||
if cached:
|
||||
@@ -8966,10 +8674,6 @@ async def list_library(q: str = "", page: int = 0, size: int = 24, sort: str = "
|
||||
# The cursor to resume after this page (effective sort folds in dir=desc).
|
||||
next_cursor = (next_library_cursor(_effective_keyset_sort(sort, dir), songs[-1])
|
||||
if (is_local and songs) else None)
|
||||
# Drop the private raw-title stash query_page attached for the cursor — it's
|
||||
# an internal keyset detail, not part of the card payload.
|
||||
for s in songs:
|
||||
s.pop("_sort_title", None)
|
||||
return {"songs": songs, "total": total, "page": page, "size": size,
|
||||
"next_cursor": next_cursor}
|
||||
|
||||
@@ -9221,10 +8925,9 @@ def library_genres(provider: str = "local"):
|
||||
if is_remote:
|
||||
return {"genres": []}
|
||||
with meta_db._lock:
|
||||
g = meta_db._effective_genre_expr()
|
||||
rows = meta_db.conn.execute(
|
||||
f"SELECT g FROM (SELECT DISTINCT ({g}) AS g FROM songs) "
|
||||
"WHERE g IS NOT NULL AND g != '' ORDER BY g COLLATE NOCASE"
|
||||
"SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' "
|
||||
"ORDER BY genre COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return {"genres": [r[0] for r in rows]}
|
||||
|
||||
@@ -9309,60 +9012,6 @@ def put_song_user_meta(filename: str, data: dict):
|
||||
return meta_db.get_song_user_meta(key)
|
||||
|
||||
|
||||
# Catalog fields the Fix-metadata popup may override/lock — the intersection of
|
||||
# "displayable identity" and "safe to correct locally". Guitar/practice facts
|
||||
# and personal fields are never overrides.
|
||||
_OVERRIDE_FIELDS = frozenset({"title", "artist", "album", "year", "genre"})
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/overrides")
|
||||
def get_song_overrides(filename: str):
|
||||
"""Per-field metadata overrides + locks for one song (Fix-metadata popup):
|
||||
{"overrides": {field: {"value": str|null, "locked": bool}},
|
||||
"pack": {field: str}}. `pack` is the stored value each override sits on top
|
||||
of — the popup's Details tab renders it as the revert-to-pack reference and
|
||||
the Yours/Pack provenance."""
|
||||
key = meta_db._canonical_song_filename(filename)
|
||||
return {"overrides": meta_db.get_song_overrides(key),
|
||||
"pack": meta_db.pack_fields(key)}
|
||||
|
||||
|
||||
@app.put("/api/song/{filename:path}/overrides")
|
||||
def put_song_overrides(filename: str, data: dict):
|
||||
"""Set/clear per-field overrides + locks. Body:
|
||||
`{"overrides": {field: {"value": str|null, "locked": bool}}}`. Only catalog
|
||||
fields (title/artist/album/year/genre) are accepted. A field left with no
|
||||
value and unlocked is removed. Returns the merged override map.
|
||||
|
||||
Clearing rides this PUT (send value:null, locked:false) rather than a DELETE
|
||||
sub-route, because `DELETE /api/song/{filename:path}` already owns every
|
||||
DELETE under /api/song and would shadow it (same reason as tags)."""
|
||||
ov = (data or {}).get("overrides")
|
||||
if not isinstance(ov, dict) or not ov:
|
||||
return JSONResponse({"error": "overrides must be a non-empty object"}, 400)
|
||||
bad = sorted(f for f in ov if f not in _OVERRIDE_FIELDS)
|
||||
if bad:
|
||||
return JSONResponse({"error": "unknown field(s): " + ", ".join(bad)}, 400)
|
||||
key = meta_db._canonical_song_filename(filename)
|
||||
for field, spec in ov.items():
|
||||
if not isinstance(spec, dict):
|
||||
return JSONResponse({"error": f"'{field}' must be an object with value/locked"}, 400)
|
||||
kwargs: dict = {}
|
||||
if "value" in spec:
|
||||
v = spec["value"]
|
||||
if v is None:
|
||||
kwargs["value"] = None
|
||||
elif isinstance(v, (str, int, float)) and not isinstance(v, bool):
|
||||
kwargs["value"] = str(v).strip()[:500]
|
||||
else:
|
||||
return JSONResponse({"error": f"'{field}' value must be a string or null"}, 400)
|
||||
if "locked" in spec:
|
||||
kwargs["locked"] = bool(spec["locked"])
|
||||
if kwargs:
|
||||
meta_db.set_song_override(key, field, **kwargs)
|
||||
return {"overrides": meta_db.get_song_overrides(key)}
|
||||
|
||||
|
||||
@app.post("/api/songs/user-meta/batch")
|
||||
def batch_song_user_meta(data: dict):
|
||||
"""Bulk personal-meta edit over a selection — one request instead of N×2
|
||||
@@ -10790,9 +10439,6 @@ def save_settings(data: dict):
|
||||
config_file = CONFIG_DIR / "config.json"
|
||||
updates: dict = {}
|
||||
messages: list[str] = []
|
||||
# Named dlc_warnings (not `warnings`) so it can't shadow the module-level
|
||||
# `import warnings` used elsewhere in this file.
|
||||
dlc_warnings: list[str] = []
|
||||
|
||||
if "dlc_dir" in data:
|
||||
dlc_path = data["dlc_dir"]
|
||||
@@ -10812,16 +10458,7 @@ def save_settings(data: dict):
|
||||
if f.suffix.lower() in sloppak_mod.SONG_EXTS)
|
||||
messages.append(f"DLC folder: {count} song files found")
|
||||
else:
|
||||
# A non-resolving DLC path (a stale value, an unplugged
|
||||
# external/network drive, or a path carried over from another
|
||||
# machine) must NOT abort the whole POST. saveSettings() bundles
|
||||
# dlc_dir together with demucs_server_url / default_arrangement /
|
||||
# av_offset_ms in a single request, so an early `return` here
|
||||
# silently dropped every co-submitted key — this is the "can't
|
||||
# set the Demucs server address" report (feedBack-demucs-server
|
||||
# #3). Record it as a warning, skip persisting dlc_dir, and keep
|
||||
# validating the rest so the other settings still save.
|
||||
dlc_warnings.append(f"DLC directory not found: {dlc_path}")
|
||||
return {"error": f"DLC directory not found: {dlc_path}"}
|
||||
|
||||
# Both of these are consumed downstream as strings (e.g.
|
||||
# demucs_server_url.rstrip('/')), so reject non-string shapes
|
||||
@@ -11078,15 +10715,7 @@ def save_settings(data: dict):
|
||||
return {"error": str(exc)}
|
||||
cfg = settings_with_instrument_profiles(cfg)
|
||||
_atomic_write_file(config_file, json.dumps(cfg, indent=2).encode("utf-8"))
|
||||
resp = {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
if dlc_warnings:
|
||||
# `warnings` is an additive response field (existing clients read
|
||||
# `message || error`); fold the text into `message` too so the current
|
||||
# settings status line still surfaces the bad DLC path even though the
|
||||
# rest of the save succeeded.
|
||||
resp["warnings"] = dlc_warnings
|
||||
resp["message"] = resp["message"] + " — " + "; ".join(dlc_warnings)
|
||||
return resp
|
||||
return {"message": ". ".join(messages) if messages else "Settings saved"}
|
||||
|
||||
|
||||
# Keys a client "Reset {category}" action may clear. Resetting removes the key
|
||||
@@ -12324,26 +11953,6 @@ async def get_song_art(filename: str, request: Request = None, source: str = "")
|
||||
_ART_PICKER_MAX_CAA = 12
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/art/cover-search")
|
||||
def api_art_cover_search(filename: str, q: str = ""):
|
||||
"""Search Cover Art Archive (via MusicBrainz release-groups) for album covers
|
||||
— powers the Change-cover picker's search box, so a cover can be found even
|
||||
for a song with no metadata match (the unmatched city-pop pile, where
|
||||
/art/candidates is empty). `q` defaults to the song's own artist + album/
|
||||
title (romaji fallback applied). Read-only; the picker renders the thumbs and
|
||||
applies a pick through the existing /art/url route."""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
pack = meta_db.pack_fields(meta_db._canonical_song_filename(filename))
|
||||
query = " ".join(x for x in (pack.get("artist"), pack.get("album") or pack.get("title")) if x).strip()
|
||||
if not query:
|
||||
return {"query": "", "covers": []}
|
||||
try:
|
||||
return {"query": query, "covers": _mb_search_release_groups(query, limit=8)}
|
||||
except EnrichTransportError:
|
||||
return {"query": query, "covers": [], "error": "unavailable"}
|
||||
|
||||
|
||||
@app.get("/api/song/{filename:path}/art/candidates")
|
||||
def get_song_art_candidates(filename: str):
|
||||
"""Everything the cover picker can offer for one song, without fetching a
|
||||
@@ -12571,21 +12180,15 @@ def _gap_fill_proposals(cache_key: str, resolved) -> tuple[dict, str]:
|
||||
manifest = sloppak_mod.load_manifest(resolved) or {}
|
||||
except Exception:
|
||||
return {}, "not-sloppak"
|
||||
# A LOCKED field (Fix-metadata popup) is never gap-filled — the user pinned
|
||||
# it away from the matched value, so writing that value to the file would
|
||||
# be exactly the clobber the lock exists to prevent. (The lock field name is
|
||||
# `genre`; the manifest/gap-fill key is `genres`.)
|
||||
locked = meta_db.locked_fields(cache_key)
|
||||
out = {}
|
||||
album = (row.get("canon_album") or "").strip()
|
||||
if album and "album" not in locked and _gap_fill_manifest_absent(manifest, "album"):
|
||||
if album and _gap_fill_manifest_absent(manifest, "album"):
|
||||
out["album"] = album
|
||||
year = (row.get("canon_year") or "").strip()
|
||||
if (year.isdigit() and int(year) and "year" not in locked
|
||||
and _gap_fill_manifest_absent(manifest, "year")):
|
||||
if year.isdigit() and int(year) and _gap_fill_manifest_absent(manifest, "year"):
|
||||
out["year"] = int(year)
|
||||
genres = [str(g) for g in (row.get("genres") or []) if isinstance(g, str) and g.strip()]
|
||||
if genres and "genre" not in locked and _gap_fill_manifest_absent(manifest, "genres"):
|
||||
if genres and _gap_fill_manifest_absent(manifest, "genres"):
|
||||
out["genres"] = genres
|
||||
# Identity keys (feedpak spec 1.14.0) — written in canonical form only.
|
||||
mbid = (row.get("mb_recording_id") or "").strip().lower()
|
||||
@@ -12941,57 +12544,53 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
|
||||
_extract_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _resolve_sloppak_local_file(filename: str, rel_path: str):
|
||||
"""Resolve a file inside a sloppak to its on-disk path.
|
||||
|
||||
Applies the same containment guards as ``serve_sloppak_file``. Returns the
|
||||
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
|
||||
callers can produce their endpoint-appropriate response.
|
||||
"""
|
||||
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
|
||||
def serve_sloppak_file(filename: str, rel_path: str):
|
||||
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
|
||||
dlc = _get_dlc_dir()
|
||||
if not dlc:
|
||||
return ("not configured", 404)
|
||||
# `filename` is caller-controlled. Contain it under DLC_DIR before it
|
||||
# reaches the resolver (see serve_sloppak_file for the traversal rationale).
|
||||
return JSONResponse({"error": "not configured"}, 404)
|
||||
# `filename` is an attacker-controlled `:path` param. Contain it under
|
||||
# DLC_DIR before it reaches the resolver, which does a bare
|
||||
# `dlc_root / filename`. Without this, `../../../etc` escapes the root
|
||||
# and the rel_path guard below validates `target` against the already-
|
||||
# escaped `src`, which trivially passes — yielding arbitrary file reads
|
||||
# (e.g. /api/sloppak/../../../../etc/file/passwd). Mirrors the guard
|
||||
# `get_song_art` applies to the same filename param.
|
||||
resolved = _resolve_dlc_path(dlc, filename)
|
||||
if resolved is None:
|
||||
return ("forbidden", 403)
|
||||
# Confine to actual sloppak bundles — otherwise any plain subdirectory
|
||||
# would become a read-any-file-under-DLC_DIR source.
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
# Confine the endpoint to actual sloppak bundles. Without this, a
|
||||
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or
|
||||
# any plain subdirectory) would make `resolve_source_dir` hand back a
|
||||
# directory and turn this into a read-any-file-under-DLC_DIR endpoint.
|
||||
# Mirrors get_song_art's `is_sloppak` dispatch.
|
||||
if not sloppak_mod.is_sloppak(resolved):
|
||||
return ("not found", 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent URL
|
||||
# forms of the same sloppak converge on one _source_cache entry.
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
# Canonicalise the cache key against the resolved path so equivalent
|
||||
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs
|
||||
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
|
||||
# fragmenting / re-unpacking — mirrors get_song_info's keying.
|
||||
try:
|
||||
filename = resolved.relative_to(dlc.resolve()).as_posix()
|
||||
except ValueError:
|
||||
# safe_join already proved containment; fail closed regardless.
|
||||
return ("forbidden", 403)
|
||||
# safe_join already proved containment, so this is unreachable in
|
||||
# practice; fail closed rather than fall back to the raw param.
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
src = sloppak_mod.get_cached_source_dir(filename)
|
||||
if src is None:
|
||||
try:
|
||||
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
|
||||
except Exception:
|
||||
return ("not found", 404)
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
# Prevent path traversal within the sloppak.
|
||||
target = (src / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(src.resolve())
|
||||
except ValueError:
|
||||
return ("forbidden", 403)
|
||||
return JSONResponse({"error": "forbidden"}, 403)
|
||||
if not target.exists() or not target.is_file():
|
||||
return ("not found", 404)
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
|
||||
def serve_sloppak_file(filename: str, rel_path: str):
|
||||
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
|
||||
result = _resolve_sloppak_local_file(filename, rel_path)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status)
|
||||
target = result
|
||||
return JSONResponse({"error": "not found"}, 404)
|
||||
ext = target.suffix.lower()
|
||||
mt = {
|
||||
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
|
||||
@@ -13914,19 +13513,13 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
|
||||
|
||||
@app.get("/api/audio-local-path")
|
||||
def audio_local_path(url: str, request: Request):
|
||||
"""Return absolute local filesystem path for a song URL (Electron desktop only).
|
||||
"""Return absolute local filesystem path for an /audio/… URL (Electron desktop only).
|
||||
|
||||
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments —
|
||||
no scheme, no host, no query string, no fragment. The resolved path must stay
|
||||
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
|
||||
absolute ``filename`` values are rejected.
|
||||
|
||||
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
|
||||
emitted by the highway song payload) and resolves it to the unpacked
|
||||
sloppak cache file via the same containment guards as
|
||||
``serve_sloppak_file`` — this lets the desktop engine play a feedpak
|
||||
full-mix natively under WASAPI-exclusive output.
|
||||
|
||||
This endpoint returns a raw filesystem path and is intended exclusively for
|
||||
the Electron desktop process (which runs on loopback). Requests from non-
|
||||
loopback clients are rejected with 403.
|
||||
@@ -13939,21 +13532,6 @@ def audio_local_path(url: str, request: Request):
|
||||
is_loopback = client_host == "localhost"
|
||||
if not is_loopback:
|
||||
return JSONResponse({"error": "forbidden"}, status_code=403)
|
||||
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
|
||||
# Both segments arrive percent-encoded (built with urllib quote() in the
|
||||
# highway payload); decode before handing to the shared resolver, which
|
||||
# re-applies all containment guards on the decoded values.
|
||||
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
|
||||
if slop_match:
|
||||
from urllib.parse import unquote
|
||||
|
||||
result = _resolve_sloppak_local_file(
|
||||
unquote(slop_match.group(1)), unquote(slop_match.group(2))
|
||||
)
|
||||
if isinstance(result, tuple):
|
||||
error, status = result
|
||||
return JSONResponse({"error": error}, status_code=status)
|
||||
return JSONResponse({"path": str(result)})
|
||||
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
|
||||
if not re.fullmatch(r"/audio/[^?#]+", url):
|
||||
return JSONResponse({"error": "invalid url"}, status_code=400)
|
||||
|
||||
+21
-347
@@ -1110,7 +1110,6 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
||||
const _LIB_SORT_VALUES = new Set([
|
||||
'artist', 'artist-desc', 'title', 'title-desc',
|
||||
'recent', 'year-desc', 'year', 'tuning',
|
||||
'difficulty', 'difficulty-desc',
|
||||
]);
|
||||
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
||||
// Tree-view expand/collapse persistence. Three states per tree:
|
||||
@@ -2079,7 +2078,6 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
</div>
|
||||
${retuneBtn}
|
||||
@@ -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>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>`;
|
||||
if (duration)
|
||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||
if (stdRetune)
|
||||
@@ -2874,10 +2870,6 @@ const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '
|
||||
// - colorblind: the Okabe–Ito accessible qualitative palette (vermillion,
|
||||
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
|
||||
// distinguishable option for deuteranopia/protanopia.
|
||||
// - colorblind_deuteranope: a deuteranope-tuned variant of the Okabe–Ito set
|
||||
// above, contributed by a deuteranopic player who still found that set hard
|
||||
// to separate. Retunes the six main strings (red / yellow-green / blue /
|
||||
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
|
||||
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
|
||||
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
|
||||
// violet) so adjacent strings separate harder than vivid — a stage/stream
|
||||
@@ -2914,10 +2906,6 @@ const HWC_PRESETS = [
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
@@ -4383,7 +4371,7 @@ async function uploadSongs(fileList) {
|
||||
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||
files.push(f);
|
||||
} else {
|
||||
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
|
||||
failures.push(`${f.name}: only .feedpak accepted`);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
@@ -4864,47 +4852,6 @@ window.jucePlayer = jucePlayer;
|
||||
// (a network blip on /api/audio-local-path, an isAudioRunning() race
|
||||
// during a device restart) are deliberately NOT memoised so they retry.
|
||||
let _rerouteRejectedUrl = null;
|
||||
// Exclusive-style output backends silence every other client on the
|
||||
// endpoint — including our own <audio> element. The share mode IS the
|
||||
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
|
||||
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
|
||||
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
|
||||
// shared and must NOT match.
|
||||
function _isExclusiveOutputType(t) {
|
||||
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
|
||||
}
|
||||
// [feedpak-route] diagnostics: log the raw outputType string once per
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
_loggedOutputType = '<no-getCurrentDevice>';
|
||||
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dev = await juceApi.getCurrentDevice();
|
||||
const t = dev?.outputType || dev?.type || '';
|
||||
const excl = _isExclusiveOutputType(t);
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
|
||||
_loggedOutputType = '<getCurrentDevice-failed>';
|
||||
console.warn('[feedpak-route] getCurrentDevice failed:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
// snapshot object captured at reroute entry — i.e. the song was swapped (or
|
||||
// cleared) mid-flight. Staleness is detected by object-reference identity,
|
||||
@@ -4945,12 +4892,8 @@ window.jucePlayer = jucePlayer;
|
||||
audio.pause();
|
||||
try {
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
|
||||
if (!res.ok) {
|
||||
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const { path } = await res.json();
|
||||
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
|
||||
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
|
||||
const ok = await juceApi.loadBackingTrack(path);
|
||||
if (ok === false) {
|
||||
@@ -5168,12 +5111,8 @@ window.jucePlayer = jucePlayer;
|
||||
async function _reevaluateJuceRouting() {
|
||||
if (_rerouteInFlight) return;
|
||||
const songAudio = window._currentSongAudio;
|
||||
// /audio/ songs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack, no stems) is routable ONLY under an
|
||||
// exclusive-style output — in shared mode it must stay on HTML5 so
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.
|
||||
if (!songAudio || !songAudio.juceEligible) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
@@ -5191,30 +5130,13 @@ window.jucePlayer = jucePlayer;
|
||||
try { running = await juceApi.isAudioRunning(); }
|
||||
catch (_) { return; }
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
// Eligibility is evaluated per tick, not snapshotted at song load:
|
||||
// the output share mode can change mid-song (device switch in the
|
||||
// Audio Engine panel), and a feedpak full-mix must follow it —
|
||||
// exclusive → ride the engine; back to shared → return to HTML5.
|
||||
let eligible = !!songAudio.juceEligible;
|
||||
if (!eligible && songAudio.feedpakFullMix && running) {
|
||||
eligible = await _outputIsExclusive();
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
}
|
||||
const wantJuce = !!(running && eligible);
|
||||
// [feedpak-route] diagnostics: one line per decision change (the
|
||||
// watcher polls at 350ms; steady state must not spam the buffer).
|
||||
const _decision = 'running=' + running + ' eligible=' + eligible
|
||||
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
|
||||
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
|
||||
if (_decision !== window._lastFeedpakRouteDecision) {
|
||||
window._lastFeedpakRouteDecision = _decision;
|
||||
console.log('[feedpak-route] watcher:', _decision);
|
||||
}
|
||||
if (wantJuce === !!window._juceMode) return; // routing already consistent
|
||||
if (!!running === !!window._juceMode) return; // routing already consistent
|
||||
|
||||
const wantJuce = running && !window._juceMode;
|
||||
// Don't keep retrying a track JUCE explicitly rejected.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (wantJuce) {
|
||||
if (running) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
@@ -5229,10 +5151,9 @@ window.jucePlayer = jucePlayer;
|
||||
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
|
||||
} else {
|
||||
await _switchJuceToHtml5(songAudio);
|
||||
// The engine stopped (or a feedpak's output left exclusive
|
||||
// mode). Clear any hard-reject memo so a later engine restart
|
||||
// or mode change re-evaluates the track at least once — the
|
||||
// rejection may have been a transient device/decoder state.
|
||||
// The engine just stopped. Clear any hard-reject memo so a
|
||||
// later engine restart re-evaluates the track at least once —
|
||||
// the rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -5264,209 +5185,6 @@ window.jucePlayer = jucePlayer;
|
||||
}, 350);
|
||||
})();
|
||||
|
||||
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
|
||||
// output endpoint in an exclusive-style mode, Chromium cannot reach the
|
||||
// device, so any song audio still played by the renderer goes silent. The
|
||||
// Phase 1 watcher above already migrates what a single-file transport can
|
||||
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
|
||||
// transport. This feeder covers the rest — the stems plugin's multi-stem
|
||||
// WebAudio graph, plus <audio>-element songs the native transport could not
|
||||
// take (e.g. a codec loadBackingTrack rejected).
|
||||
//
|
||||
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
|
||||
// re-point the owning AudioContext at a null sink so it keeps rendering
|
||||
// without a device, and push ~10 ms chunks over IPC into the engine's
|
||||
// renderer bus, where they are mixed into the exclusive output like a
|
||||
// backing track (~10-20 ms added latency on song audio only; the guitar
|
||||
// monitoring path is untouched). Validated by the fix12 tester spike:
|
||||
// null-sink rendering works, clocks hold (drift → 0), no overflow.
|
||||
//
|
||||
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
|
||||
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
|
||||
// captured contexts keep/regain their default sink.
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') return;
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const inp = inputs[0];
|
||||
if (inp && inp[0]) {
|
||||
const L = inp[0], R = inp[1] || inp[0];
|
||||
const out = new Float32Array(L.length * 2);
|
||||
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
|
||||
this.port.postMessage(out, [out.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('feedback-bus-tap', FeedbackBusTap);
|
||||
`;
|
||||
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
|
||||
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
|
||||
|
||||
// One tap per captured graph. `active` gates the push (the worklet keeps
|
||||
// running when inactive — it's silent bookkeeping, not audio).
|
||||
function _makeTap(ctx) {
|
||||
const state = { node: null, active: false, batch: [], batchFrames: 0 };
|
||||
state.attach = async (sourceNode) => {
|
||||
if (!_tapModuleLoaded.has(ctx)) {
|
||||
await ctx.audioWorklet.addModule(_tapModuleUrl);
|
||||
_tapModuleLoaded.add(ctx);
|
||||
}
|
||||
if (!state.node) {
|
||||
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
|
||||
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
|
||||
state.node.port.onmessage = (e) => {
|
||||
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
|
||||
state.batch.push(e.data);
|
||||
state.batchFrames += e.data.length / 2;
|
||||
if (state.batchFrames >= BATCH) {
|
||||
const merged = new Float32Array(state.batchFrames * 2);
|
||||
let o = 0;
|
||||
for (const c of state.batch) { merged.set(c, o); o += c.length; }
|
||||
api.pushRendererAudio(merged, ctx.sampleRate);
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
sourceNode.connect(state.node);
|
||||
// No onward connection: the tap is a sink-side observer; audibility
|
||||
// in shared mode comes from the graph's own destination path.
|
||||
};
|
||||
state.detach = (sourceNode) => {
|
||||
state.active = false;
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
if (state.node && sourceNode) {
|
||||
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
|
||||
}
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Core <audio> element capture ─────────────────────────────────────────
|
||||
// createMediaElementSource permanently reroutes the element into its
|
||||
// context, so it is created lazily — only the first time an exclusive
|
||||
// device actually needs it — and never torn down. From then on the element
|
||||
// always plays through _elCtx; sink toggling routes it to the speakers
|
||||
// (shared mode) or the null sink + bus (exclusive mode).
|
||||
let _elCtx = null, _elSource = null, _elTap = null;
|
||||
async function _ensureElementCapture() {
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'element' | 'stems'
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
|
||||
let _busy = false;
|
||||
|
||||
async function _setSink(ctx, exclusive) {
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
|
||||
await _setSink(_stemsGraph.context, false).catch(() => {});
|
||||
_stemsGraph = null; _stemsTap = null;
|
||||
}
|
||||
console.log('[renderer-bus] disengaged (' + prev + ')');
|
||||
}
|
||||
|
||||
async function _engageStems(graph) {
|
||||
await _setSink(graph.context, true);
|
||||
let tap = _stemsTaps.get(graph.context);
|
||||
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
|
||||
await tap.attach(graph.masterNode);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_stemsGraph = graph; _stemsTap = tap;
|
||||
_mode = 'stems';
|
||||
console.log('[renderer-bus] engaged: stems graph → engine bus');
|
||||
}
|
||||
|
||||
async function _engageElement() {
|
||||
await _ensureElementCapture();
|
||||
await _setSink(_elCtx, true);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
_elTap.active = true;
|
||||
_mode = 'element';
|
||||
console.log('[renderer-bus] engaged: <audio> element → engine bus');
|
||||
}
|
||||
|
||||
async function _reevaluate() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
let running = false, exclusive = false;
|
||||
try {
|
||||
running = await api.isAudioRunning();
|
||||
} catch (_) { /* engine unreachable → treat as not running */ }
|
||||
if (running) {
|
||||
// Reuse the Phase 1 predicate installed by the routing watcher
|
||||
// (getCurrentDevice + exclusive-type check with change-logged
|
||||
// diagnostics). Fail closed if it is somehow absent.
|
||||
exclusive = !!(await window._juceOutputIsExclusive?.());
|
||||
}
|
||||
|
||||
// The stems plugin publishes its live graph while a multi-stem
|
||||
// song is loaded (and removes it on teardown).
|
||||
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
|
||||
// Element songs: a song is loaded, it is NOT riding the native
|
||||
// transport (Phase 1 owns those), and the stems graph is not the
|
||||
// player. Covers native-transport rejects (codec) in exclusive
|
||||
// mode — without this they would be silent.
|
||||
const songAudio = window._currentSongAudio;
|
||||
const elementSong = !!songAudio && !window._juceMode && !stems;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||
_mode = 'off';
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same cadence/rationale as the routing watcher above. Also re-check on
|
||||
// visibility return so a device switch made while hidden is reconciled.
|
||||
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
|
||||
window._reevaluateRendererBus = _reevaluate;
|
||||
})();
|
||||
|
||||
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
|
||||
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
|
||||
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
|
||||
@@ -5838,24 +5556,9 @@ function _playbackApi() {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
const _bridgeRecordLast = new Map();
|
||||
const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
@@ -8229,10 +7932,6 @@ function setLoopEnd() {
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||
// transport event so event-driven consumers (note_detect drill sync) see
|
||||
// button-armed loops without having to poll getLoop().
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
|
||||
function clearLoop(options) {
|
||||
@@ -8393,26 +8092,6 @@ function _resolveEditRegion() {
|
||||
return { a: Math.max(0, t - 4), b: t + 4 };
|
||||
}
|
||||
|
||||
/* @pure:editor-pending-view:start */
|
||||
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
|
||||
const options = opts || {};
|
||||
const view = {
|
||||
filename,
|
||||
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
|
||||
barSel: region ? { startTime: region.a, endTime: region.b } : null,
|
||||
};
|
||||
if (options.returnToHighway) view.returnToHighway = true;
|
||||
if (typeof options.cursorTime === 'number') {
|
||||
view.cursorTime = options.cursorTime;
|
||||
} else if (region && typeof region.a === 'number') {
|
||||
view.cursorTime = region.a;
|
||||
}
|
||||
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
|
||||
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
|
||||
return view;
|
||||
}
|
||||
/* @pure:editor-pending-view:end */
|
||||
|
||||
// Enable "Edit region" whenever the editor plugin is present and a song is
|
||||
// loaded; show "↩ Editor" only while a return context is pending.
|
||||
function _updateEditRegionBtn() {
|
||||
@@ -8439,9 +8118,12 @@ function editRegionInEditor() {
|
||||
arrangement = si.arrangement_index;
|
||||
}
|
||||
} catch (_) { /* default to 0 */ }
|
||||
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
|
||||
window._editorPendingView = {
|
||||
filename: currentFilename,
|
||||
arrangement,
|
||||
barSel: { startTime: region.a, endTime: region.b },
|
||||
returnToHighway: true,
|
||||
});
|
||||
};
|
||||
window.editSong(currentFilename);
|
||||
}
|
||||
window.editRegionInEditor = editRegionInEditor;
|
||||
@@ -8453,14 +8135,14 @@ function returnToEditorFromHighway() {
|
||||
const ctx = window._highwayReturnCtx;
|
||||
if (!ctx || typeof window.editSong !== 'function') return;
|
||||
window._highwayReturnCtx = null;
|
||||
const region = ctx.barSel
|
||||
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
|
||||
: null;
|
||||
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
|
||||
window._editorPendingView = {
|
||||
filename: ctx.filename,
|
||||
arrangement: ctx.arrangement,
|
||||
scrollX: ctx.scrollX,
|
||||
zoom: ctx.zoom,
|
||||
cursorTime: ctx.cursorTime,
|
||||
});
|
||||
barSel: ctx.barSel,
|
||||
};
|
||||
window.editSong(ctx.filename);
|
||||
}
|
||||
window.returnToEditorFromHighway = returnToEditorFromHighway;
|
||||
@@ -11837,14 +11519,6 @@ async function loadPlugins() {
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
|
||||
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
|
||||
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
|
||||
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const OWNER_ID = 'core.audio.effects';
|
||||
const DEFAULT_ROUTE_KEY = 'desktop-main';
|
||||
const DEFAULT_TIMEOUT_MS = 2000;
|
||||
@@ -736,7 +734,7 @@ const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
|
||||
const errors = [];
|
||||
const source = _plainObject(rawPlan);
|
||||
const schema = _string(source.schema || source.version, PLAN_SCHEMA);
|
||||
if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
|
||||
const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
|
||||
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
|
||||
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
|
||||
|
||||
+15
-82
@@ -2004,32 +2004,18 @@ function createHighway() {
|
||||
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = col;
|
||||
// Shimmering glow WITHOUT ctx.shadowBlur: blur cost scales with
|
||||
// the blurred DEVICE-pixel area, and a held sustain's trail can
|
||||
// span half the (DPR-scaled) canvas — profiling the "stutters
|
||||
// while playing" report put this per-frame blur pass at the top
|
||||
// exactly while a sustain is held. Three inflated low-alpha
|
||||
// fills of the same quad read as the same soft glow at a flat,
|
||||
// area-independent cost. The shimmer LUT still drives the
|
||||
// per-frame size/brightness flicker (feedBack#254 intent).
|
||||
const glowPx = (8 + 6 * _shimmerNoise(seedBase)) * a;
|
||||
const baseA = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
const fillTrail = (inflate) => {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0 - inflate, y0);
|
||||
ctx.lineTo(x0 + sw0 + inflate, y0);
|
||||
ctx.lineTo(x1 + sw1 + inflate, y1);
|
||||
ctx.lineTo(x1 - sw1 - inflate, y1);
|
||||
ctx.fill();
|
||||
};
|
||||
ctx.globalAlpha = baseA * 0.22;
|
||||
fillTrail(glowPx);
|
||||
ctx.globalAlpha = baseA * 0.4;
|
||||
fillTrail(glowPx * 0.45);
|
||||
ctx.globalAlpha = baseA;
|
||||
fillTrail(0);
|
||||
ctx.shadowColor = col;
|
||||
ctx.shadowBlur = (8 + 6 * _shimmerNoise(seedBase)) * a; // shimmering glow
|
||||
ctx.globalAlpha = (0.45 + 0.45 * a) * (0.78 + 0.22 * _shimmerNoise(seedBase + 17));
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x0 - sw0, y0);
|
||||
ctx.lineTo(x0 + sw0, y0);
|
||||
ctx.lineTo(x1 + sw1, y1);
|
||||
ctx.lineTo(x1 - sw1, y1);
|
||||
ctx.fill();
|
||||
// Crackling "current" — a jittery white core line down
|
||||
// the trail, re-randomised each frame.
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.globalAlpha = a * (0.55 + 0.45 * _shimmerNoise(seedBase + 31));
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
@@ -3409,57 +3395,21 @@ function createHighway() {
|
||||
if (msg.audio_url) {
|
||||
const audio = document.getElementById('audio');
|
||||
const audioFilename = msg.audio_url.split('/').pop();
|
||||
// /audio/ URLs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack: original audio, no stems) is routable too,
|
||||
// but ONLY under an exclusive-style output — the actual
|
||||
// exclusive check happens at routing time (app.js watcher /
|
||||
// the async block below), not here, because the share mode
|
||||
// can change while the song is loaded. Sloppak stem URLs are
|
||||
// never routable.
|
||||
// Only attempt JUCE routing for /audio/ URLs — sloppak stems
|
||||
// (/api/sloppak/…) are not resolvable via audio-local-path.
|
||||
const isAudioUrl = msg.audio_url.startsWith('/audio/');
|
||||
// "Full mix" covers BOTH single-mix pack shapes:
|
||||
// - stem-less packs (original_audio: in the manifest,
|
||||
// audio_url == original_audio_url), and
|
||||
// - single-stem packs (stems: [full.ogg] only) — the server
|
||||
// puts the full mix in the stems list, has_original_audio
|
||||
// is false, and audio_url points at the one stem. With one
|
||||
// stem there is no per-stem mix to preserve, so routing it
|
||||
// natively loses nothing. Real multi-stem (>1) stays out
|
||||
// until Phase 2.
|
||||
const isFeedpakFullMix = !isAudioUrl
|
||||
&& msg.audio_url.startsWith('/api/sloppak/')
|
||||
&& ((!!msg.has_original_audio && !msg.has_stems)
|
||||
|| (msg.stems || []).length === 1);
|
||||
// Record the loaded song's audio so app.js can re-route it
|
||||
// between the HTML5 and JUCE paths if the audio engine is
|
||||
// started/stopped after the song is already loaded. Set this
|
||||
// unconditionally (not just on reload): when alreadyLoaded is
|
||||
// true the watcher must still see correct, current metadata.
|
||||
window._currentSongAudio = {
|
||||
url: msg.audio_url,
|
||||
juceEligible: isAudioUrl,
|
||||
feedpakFullMix: isFeedpakFullMix,
|
||||
};
|
||||
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl };
|
||||
const alreadyLoaded = window._juceMode
|
||||
? window._juceAudioUrl === msg.audio_url
|
||||
: (audio.src && audio.src.includes(audioFilename));
|
||||
// [feedpak-route] diagnostics: every eligibility input in one
|
||||
// line — shows up in the exported diagnostics bundle. If
|
||||
// has_stems is true the pack is multi-stem and Phase 1
|
||||
// deliberately does not route it (Phase 2 work).
|
||||
console.log('[feedpak-route] song-load:',
|
||||
'url=', msg.audio_url,
|
||||
'isAudioUrl=', isAudioUrl,
|
||||
'isFeedpakFullMix=', isFeedpakFullMix,
|
||||
'has_stems=', !!msg.has_stems,
|
||||
'stems=', (msg.stems || []).length,
|
||||
'has_original_audio=', !!msg.has_original_audio,
|
||||
'format=', msg.format,
|
||||
'alreadyLoaded=', alreadyLoaded,
|
||||
'juceApi=', !!window.feedBackDesktop?.audio);
|
||||
if (!alreadyLoaded) {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
|
||||
if (isAudioUrl && juceApi) {
|
||||
// Run JUCE routing off the critical message-processing chain
|
||||
// so subsequent notes/chords/ready messages aren't blocked
|
||||
// waiting for IPC + HTTP round-trips. The 'ready' handler
|
||||
@@ -3502,24 +3452,7 @@ function createHighway() {
|
||||
clearTimeout(barrierTimer);
|
||||
if (gen !== _wsGen) return; // navigated away during the wait
|
||||
}
|
||||
// Feedpak full-mix rides the engine ONLY under an
|
||||
// exclusive-style output (shared mode falls through to
|
||||
// the HTML5 fallback below, keeping the WebAudio path
|
||||
// fully working). /audio/ songs route whenever the
|
||||
// engine runs, as before. If the share mode changes
|
||||
// later, the app.js watcher re-evaluates and migrates.
|
||||
let routeToJuce = await juceApi.isAudioRunning();
|
||||
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
|
||||
if (routeToJuce) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
if (isFeedpakFullMix) {
|
||||
const exclFn = window._juceOutputIsExclusive;
|
||||
routeToJuce = !!(await exclFn?.());
|
||||
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
|
||||
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
|
||||
}
|
||||
}
|
||||
if (routeToJuce) {
|
||||
if (await juceApi.isAudioRunning()) {
|
||||
if (gen !== _wsGen) return; // stale
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
|
||||
@@ -119,8 +119,6 @@
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -451,12 +451,6 @@
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
// Handedness — a left-hander flips the whole highway (frets mirror).
|
||||
// Lives with the other player-orientation choices so it's part of the
|
||||
// same "Choose your instrument" step the onboarding tour spotlights —
|
||||
// i.e. set before you ever tune up or calibrate.
|
||||
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
|
||||
pill('hand', 'left', 'Left', _leftyPref())) +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
@@ -519,10 +513,6 @@
|
||||
setWorkingInstrument(settings.instrument, newSc);
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="hand"]').forEach((b) => b.addEventListener('click', () => {
|
||||
_setLeftyPref(b.getAttribute('data-val') === 'left');
|
||||
renderInstrument(); keepOpen(); // reflect the active pill; keep the menu open
|
||||
}));
|
||||
menu.querySelector('[data-inst-tuning]').addEventListener('change', (e) => saveSettings({ tuning: e.target.value }));
|
||||
menu.querySelector('[data-inst-pathway]').addEventListener('change', (e) => saveSettings({ pathway: e.target.value }));
|
||||
const ref = menu.querySelector('[data-inst-ref]');
|
||||
@@ -538,22 +528,6 @@
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
// Handedness (left-handed) preference. The canonical store is the highway's
|
||||
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
|
||||
// it immediately. Feature-detected so it works on the dashboard before any
|
||||
// highway has been created (the value is read on the highway's next init).
|
||||
function _leftyPref() {
|
||||
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
|
||||
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
function _setLeftyPref(on) {
|
||||
try {
|
||||
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
|
||||
else localStorage.setItem('lefty', on ? '1' : '0');
|
||||
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
|
||||
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
|
||||
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
|
||||
|
||||
@@ -138,17 +138,6 @@
|
||||
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
|
||||
'</div>' +
|
||||
// Search Cover Art Archive — find an album cover even when the song has
|
||||
// no match (the auto candidates above are empty then). Pre-filled from
|
||||
// the song's artist + album/title; the source is rate-limited.
|
||||
'<div class="space-y-2 pt-1">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||||
'</div>' +
|
||||
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
|
||||
'</div>' +
|
||||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||||
'</div></div>' +
|
||||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||||
@@ -196,47 +185,9 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||||
// render the album covers as pickable tiles — the same apply('url') path as
|
||||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||||
async function coverSearch(panel, query) {
|
||||
const out = panel.querySelector('[data-ip-search-results]');
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!out || !fn) return;
|
||||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the empty state */ }
|
||||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||||
const covers = (body && body.covers) || [];
|
||||
if (!covers.length) {
|
||||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = covers.map((c, i) =>
|
||||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The one candidates fetch, cancelled if the modal closes first. Failure
|
||||
// (offline, demo mode, aborted) is silent: the skeletons just clear and
|
||||
// the instant tiles remain — never an error wall.
|
||||
@@ -330,13 +281,7 @@
|
||||
const filename = opts && opts.filename;
|
||||
if (!filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
const title = (opts && opts.title) || filename;
|
||||
const artist = (opts && opts.artist) || '';
|
||||
const album = (opts && opts.album) || '';
|
||||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||||
// just the artist, else the title — the server default backs it up.
|
||||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||||
_cur = { filename: filename, title: title, query: query };
|
||||
_cur = { filename: filename, title: (opts && opts.title) || filename };
|
||||
_busy = false;
|
||||
const m = ensureModal();
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
|
||||
@@ -194,7 +194,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
|
||||
<div id="home" class="screen">
|
||||
@@ -641,12 +641,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<!-- Autosave on blur/enter via a single-key POST (like every other v3
|
||||
setting), so setting the address never depends on the shared Save
|
||||
button — whose bundled dlc_dir could otherwise block it. Save button
|
||||
kept for discoverability. -->
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
onchange="persistSetting('demucs_server_url', this.value.trim())"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
|
||||
+54
-370
@@ -131,8 +131,7 @@
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-metadata mode: one song, no queue navigation
|
||||
let _tab = 'details'; // active tab in single mode: details | cover | match
|
||||
let _single = false; // Fix-match mode: one song, no queue navigation
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
@@ -181,17 +180,14 @@
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// Fix metadata (R2 → popup slice 4): the tabbed per-song editor for ONE
|
||||
// song, reachable from the card's ⋮ / right-click menu. Three tabs —
|
||||
// Details (type + lock the displayed fields), Cover art (launch the picker),
|
||||
// Match (pin a MusicBrainz identity). Opens on Details: for the obscure /
|
||||
// blank-artist packs this exists to fix, typing the right title is the tool,
|
||||
// and Match is the escape hatch when text search can surface a record.
|
||||
// Fix-match (R2): the same modal for ONE song — the escape hatch for a
|
||||
// wrong (or missing) match, reachable from the card's ⋮ / right-click
|
||||
// menu. No stored candidates are required: the search panel opens
|
||||
// pre-filled, and a pick pins the match exactly like the review flow.
|
||||
function fixMatch(song) {
|
||||
if (!song || !song.filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_single = true;
|
||||
_tab = 'details';
|
||||
_queue = [{
|
||||
filename: song.filename, title: song.title || song.filename,
|
||||
artist: song.artist || '', album: song.album || '',
|
||||
@@ -202,7 +198,10 @@
|
||||
const m = ensureModal();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
renderCurrent(); // _single ⇒ renderTabbed()
|
||||
renderCurrent();
|
||||
// Straight to the point: the search panel is why this mode exists.
|
||||
document.getElementById('v3-match-panel')
|
||||
?.querySelector('[data-mr-search-toggle]')?.click();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -215,7 +214,7 @@
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (_single || !_queue.length) return; // single mode has no queue to page
|
||||
if (!_queue.length) return;
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
@@ -303,20 +302,20 @@
|
||||
'<span class="text-xs text-fb-textDim shrink-0">' + esc(pct) + '</span></span>' +
|
||||
'<span class="block text-xs text-fb-textDim truncate">' + esc(meta) + '</span>' +
|
||||
diffChips(song, c) +
|
||||
(_single ? '<span class="block text-xs text-fb-primary pt-1">Use these values →</span>' : '') +
|
||||
'</button>';
|
||||
}
|
||||
|
||||
// The middle content shared by the queue-review render and the single-song
|
||||
// popup's Match tab: the chart being matched, its candidate list, and the
|
||||
// "search instead" panel. Header + footer differ per surface. When there
|
||||
// are no stored candidates (a manual fix), the search panel opens pre-filled
|
||||
// — searching IS the point in that case.
|
||||
function reviewBodyHtml(song) {
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
|
||||
const noCands = !(song.candidates || []).length;
|
||||
const prefill = noCands ? [song.artist, song.title].filter(Boolean).join(' – ') : '';
|
||||
return '<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
|
||||
// The chart being matched
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img data-mr-art src="' + esc(artUrl(song)) + '" alt="" loading="lazy" class="w-16 h-16 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
@@ -326,27 +325,25 @@
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
(noCands
|
||||
? ''
|
||||
: '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
// Candidates (Fix-match mode arrives with none — the search panel
|
||||
// is its whole point, so the empty header is suppressed).
|
||||
((song.candidates || []).length
|
||||
? '<div class="space-y-1" role="radiogroup" aria-label="Candidates">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Candidates (MusicBrainz)</div>' +
|
||||
song.candidates.map((c, i) => candRowHtml(song, c, i, i === song._sel)).join('') +
|
||||
'</div>') +
|
||||
// Search panel — hidden when candidates exist (a "Search instead…"
|
||||
// toggle reveals it); open + pre-filled when there are none.
|
||||
'<div data-mr-search-panel class="' + (noCands ? '' : 'hidden') + ' space-y-2">' +
|
||||
'</div>'
|
||||
: '') +
|
||||
// Search-instead panel
|
||||
'<div data-mr-search-panel class="hidden space-y-2">' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-mr-search-input type="text" value="' + esc(prefill) + '" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<input data-mr-search-input type="text" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary" placeholder="Artist – Title">' +
|
||||
'<button data-mr-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button></div>' +
|
||||
'<div data-mr-search-results class="space-y-1"></div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// Footer actions. Single mode drops Skip / Not-a-match (no queue); the
|
||||
// accept button only shows when there is a stored candidate to accept —
|
||||
// search-result rows carry their own pick action.
|
||||
function footerHtml(song) {
|
||||
return '<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'</div>' +
|
||||
// Footer actions. Fix-match mode drops Skip (no queue) and the
|
||||
// accept button when there is nothing to accept — search-result
|
||||
// rows carry their own pick action.
|
||||
'<div class="flex items-center justify-between gap-3 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<div class="flex items-center gap-3">' +
|
||||
(_single ? '' : '<button data-mr-reject class="text-sm text-fb-textDim hover:text-fb-text">Not a match</button>') +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
|
||||
@@ -355,362 +352,57 @@
|
||||
(_single ? '' : '<button data-mr-skip class="text-sm text-fb-textDim hover:text-fb-text px-3 py-2">Skip</button>') +
|
||||
((song.candidates || []).length ? '<button data-mr-accept class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Use selected</button>' : '') +
|
||||
'</div></div>';
|
||||
|
||||
wireCurrent(panel, song);
|
||||
}
|
||||
|
||||
function renderCurrent() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
if (_single) { renderTabbed(); return; } // popup: the tabbed shell
|
||||
if (!_queue.length) { renderDone(); return; }
|
||||
_idx = Math.min(_idx, _queue.length - 1);
|
||||
const song = _queue[_idx];
|
||||
if (song._sel == null) song._sel = 0;
|
||||
panel.innerHTML = headerHtml() + reviewBodyHtml(song) + footerHtml(song);
|
||||
function wireCurrent(panel, song) {
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelector('[data-mr-prev]')?.addEventListener('click', () => nav(-1));
|
||||
panel.querySelector('[data-mr-next]')?.addEventListener('click', () => nav(1));
|
||||
panel.querySelector('[data-mr-skip]')?.addEventListener('click', () => nav(1));
|
||||
wireReviewBody(panel, song);
|
||||
}
|
||||
|
||||
// Candidate / search / accept-reject wiring shared by the queue render and
|
||||
// the popup's Match tab. Scoped to `root` so the tabbed shell can wire just
|
||||
// its tab body — its close + tab chrome live in the header (wired once by
|
||||
// renderTabbed), so wiring here must NOT touch close/prev/next/skip.
|
||||
function wireReviewBody(root, song) {
|
||||
// Art failure → flag + re-render once so the "cover art" chip shows.
|
||||
const img = root.querySelector('[data-mr-art]');
|
||||
const img = panel.querySelector('[data-mr-art]');
|
||||
if (img) img.onerror = () => {
|
||||
img.style.visibility = 'hidden';
|
||||
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
|
||||
};
|
||||
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
root.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
const cand = (song.candidates || [])[song._sel || 0];
|
||||
if (!cand) return;
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/accept',
|
||||
{ recording_id: cand.recording_id });
|
||||
settle(song);
|
||||
});
|
||||
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
|
||||
settle(song);
|
||||
});
|
||||
const sp = root.querySelector('[data-mr-search-panel]');
|
||||
const input = root.querySelector('[data-mr-search-input]');
|
||||
root.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
sp?.classList.toggle('hidden');
|
||||
if (sp && !sp.classList.contains('hidden') && input && !input.value) {
|
||||
input.value = [song.artist, song.title].filter(Boolean).join(' – ');
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
const go = () => runSearch(root, song);
|
||||
root.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
const go = () => runSearch(panel, song);
|
||||
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); go(); } });
|
||||
// Identify-by-audio (AcoustID, #759) renders its hits into the same
|
||||
// search-results area — scope to `root` (the tab body / panel), not the
|
||||
// out-of-scope `panel` the pre-refactor #759 wiring referenced.
|
||||
root.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(root, song));
|
||||
}
|
||||
|
||||
// ── Tabbed single-song popup (slice 4) ───────────────────────────────────
|
||||
// Header + tab bar, then the active tab's body. The queue-review render
|
||||
// above is untouched; this is only reached in _single mode.
|
||||
function tabHeaderHtml() {
|
||||
const tab = (id, label) =>
|
||||
'<button data-mr-tab="' + id + '" role="tab" aria-selected="' + (_tab === id ? 'true' : 'false') + '" ' +
|
||||
'class="px-3 py-2 text-sm -mb-px border-b-2 ' + (_tab === id
|
||||
? 'border-fb-primary text-fb-text'
|
||||
: 'border-transparent text-fb-textDim hover:text-fb-text') + '">' + label + '</button>';
|
||||
return '<div class="flex items-center justify-between gap-3 px-5 pt-4 shrink-0">' +
|
||||
'<h3 class="text-lg font-semibold text-fb-text">Fix metadata</h3>' +
|
||||
'<button data-mr-close class="text-fb-textDim hover:text-fb-text" aria-label="Close">✕</button></div>' +
|
||||
'<div role="tablist" class="flex gap-1 px-4 border-b border-fb-border/40 shrink-0">' +
|
||||
tab('details', 'Details') + tab('cover', 'Cover art') + tab('match', 'Match') + '</div>';
|
||||
}
|
||||
|
||||
function renderTabbed() {
|
||||
const panel = document.getElementById('v3-match-panel');
|
||||
if (!panel) return;
|
||||
const song = _queue[0];
|
||||
if (!song) { closeModal(); return; }
|
||||
panel.innerHTML = tabHeaderHtml() +
|
||||
'<div data-mr-tabbody role="tabpanel" class="flex flex-col min-h-0 flex-1 overflow-hidden"></div>';
|
||||
panel.querySelector('[data-mr-close]')?.addEventListener('click', closeModal);
|
||||
panel.querySelectorAll('[data-mr-tab]').forEach((b) => b.addEventListener('click', () => {
|
||||
const t = b.getAttribute('data-mr-tab');
|
||||
if (t !== _tab) { _tab = t; renderTabbed(); }
|
||||
}));
|
||||
const body = panel.querySelector('[data-mr-tabbody]');
|
||||
if (_tab === 'details') { renderDetailsTab(body, song); }
|
||||
else if (_tab === 'cover') { renderCoverTab(body, song); }
|
||||
else {
|
||||
body.innerHTML = reviewBodyHtml(song) + footerHtml(song);
|
||||
wireReviewBody(body, song);
|
||||
if (!(song.candidates || []).length) body.querySelector('[data-mr-search-input]')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Details tab: type + lock the DISPLAYED fields. Values ride the reversible
|
||||
// override store (GET/PUT /api/song/{fn}/overrides) — never the pack file.
|
||||
// Each field sits on its pack value: editing above the pack makes it an
|
||||
// override ("Yours"); a lock pins it so an auto-match can't recanonicalize
|
||||
// it; revert (↺) drops back to the pack value.
|
||||
const DETAIL_FIELDS = [['title', 'Title'], ['artist', 'Artist'], ['album', 'Album'], ['year', 'Year'], ['genre', 'Genre']];
|
||||
// Only these four are written into the pack file; genre is a library-only
|
||||
// overlay (drives the genre filter/facet + the auto-match lock), never baked
|
||||
// to the file — so Write to file leaves genre's override in place.
|
||||
const WRITE_FIELDS = ['title', 'artist', 'album', 'year'];
|
||||
|
||||
async function renderDetailsTab(body, song) {
|
||||
body.innerHTML = '<div class="p-5"><p class="text-sm text-fb-textDim">Loading…</p></div>';
|
||||
let data = { overrides: {}, pack: {} };
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides');
|
||||
if (r.ok) data = await r.json();
|
||||
} catch (_) { /* offline — fall back to the empty baseline */ }
|
||||
if (!_single || _tab !== 'details') return; // tab/modal changed while fetching
|
||||
const pack = data.pack || {};
|
||||
const ov = data.overrides || {};
|
||||
const st = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const o = ov[f] || {};
|
||||
st[f] = {
|
||||
pack: pack[f] || '',
|
||||
value: (o.value != null ? o.value : (pack[f] || '')),
|
||||
locked: !!o.locked,
|
||||
};
|
||||
}
|
||||
song._detailsState = st;
|
||||
// Match→Details bridge: a candidate picked with "Use these values" lands
|
||||
// its fields here as the pending (unsaved) input values, shown pre-filled
|
||||
// for review — the grid never adopts a match silently, so the user still
|
||||
// Saves (or Writes to file).
|
||||
const adopted = song._pendingDetails;
|
||||
if (adopted) {
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
if (f in adopted) st[f].value = String(adopted[f] || '');
|
||||
}
|
||||
song._pendingDetails = null;
|
||||
}
|
||||
paintDetails(body, song);
|
||||
if (adopted) {
|
||||
const s = body.querySelector('[data-df-status]');
|
||||
if (s) { s.className = 'text-xs leading-relaxed text-fb-textDim'; s.textContent = 'Filled from the match — review, then Save or Write to file.'; }
|
||||
}
|
||||
}
|
||||
|
||||
// Match→Details bridge: adopt a candidate's display fields into the Details
|
||||
// tab (opt-in — never silent). Pin the match too so the art/canon follow,
|
||||
// then land on Details pre-filled for review.
|
||||
async function useTheseValues(song, cand) {
|
||||
if (!cand) return;
|
||||
// Smart adopt for an English base: KEEP the readable name + title the card
|
||||
// already shows (the author's romaji, e.g. "Junko Yagami / BAY CITY") — the
|
||||
// match is often native script (kanji/kana). Take only what the pack lacks
|
||||
// — album / year / genre — from the match; the pin below still brings the
|
||||
// correct art + identity. The user can still edit any field.
|
||||
song._pendingDetails = {
|
||||
artist: String(song.artist || cand.artist || ''),
|
||||
title: String(song.title || cand.title || ''),
|
||||
album: String(cand.album || song.album || ''),
|
||||
year: String(cand.year || song.year || ''),
|
||||
genre: String((Array.isArray(cand.genres) && cand.genres[0]) || cand.genre || ''),
|
||||
};
|
||||
try {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick', { candidate: cand });
|
||||
} catch (_) { /* pin is best-effort; the values still populate Details */ }
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
_tab = 'details';
|
||||
renderTabbed();
|
||||
}
|
||||
|
||||
function paintDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const row = ([f, label]) => {
|
||||
const s = st[f];
|
||||
const isYours = !!(String(s.value).trim() && String(s.value).trim() !== String(s.pack).trim());
|
||||
return '<div class="space-y-1">' +
|
||||
'<div class="flex items-center justify-between">' +
|
||||
'<label class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">' + esc(label) + '</label>' +
|
||||
(isYours
|
||||
? '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-primary/15 text-fb-primary">Yours</span>'
|
||||
: '<span class="text-[0.625rem] px-1.5 py-0.5 rounded bg-fb-card text-fb-textDim">Pack</span>') +
|
||||
'</div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
'<input data-df-input="' + f + '" type="text" value="' + esc(s.value) + '" ' +
|
||||
'class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none focus:border-fb-primary" ' +
|
||||
'placeholder="' + esc(s.pack || label) + '">' +
|
||||
'<button data-df-lock="' + f + '" type="button" aria-pressed="' + (s.locked ? 'true' : 'false') + '" ' +
|
||||
'title="' + (s.locked ? 'Locked — auto-match won’t change this field' : 'Lock this field against auto-match') + '" ' +
|
||||
'class="px-2 py-1.5 rounded-md border ' + (s.locked ? 'border-fb-primary text-fb-primary bg-fb-primary/10' : 'border-fb-border/50 text-fb-textDim hover:text-fb-text') + '">' +
|
||||
(s.locked ? '🔒' : '🔓') + '</button>' +
|
||||
'<button data-df-revert="' + f + '" type="button" title="Revert to the pack value" ' +
|
||||
'class="px-2 py-1.5 rounded-md border border-fb-border/50 text-fb-textDim hover:text-fb-text">↺</button>' +
|
||||
'</div></div>';
|
||||
};
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0">' +
|
||||
'<div class="flex items-start gap-3">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-14 h-14 rounded-lg object-cover bg-fb-card shrink-0">' +
|
||||
'<p class="text-xs text-fb-textDim pt-1"><span class="text-fb-text">Save</span> keeps edits as a reversible library overlay — the song files aren\'t touched. <span class="text-fb-text">Write to file</span> bakes the title, artist, album and year into the pack (genre stays a library-only tag). Lock a field to keep an auto-match from changing it.</p>' +
|
||||
'</div>' +
|
||||
DETAIL_FIELDS.map(row).join('') +
|
||||
'<p data-df-status class="text-xs leading-relaxed"></p>' +
|
||||
'</div>' +
|
||||
'<div class="flex items-center justify-between gap-2 p-5 pt-3 border-t border-fb-border/40 shrink-0">' +
|
||||
'<button data-df-write type="button" title="Write these values into the song file itself — permanent, survives a full rescan. The rest of the pack is untouched." class="text-sm text-fb-textDim hover:text-fb-text border border-fb-border/50 rounded-md px-3 py-2">Write to file</button>' +
|
||||
'<button data-df-save class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Save</button>' +
|
||||
'</div>';
|
||||
body.querySelectorAll('[data-df-input]').forEach((inp) => {
|
||||
inp.addEventListener('input', () => { st[inp.getAttribute('data-df-input')].value = inp.value; });
|
||||
});
|
||||
body.querySelectorAll('[data-df-lock]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-lock'); st[f].locked = !st[f].locked; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelectorAll('[data-df-revert]').forEach((b) => {
|
||||
b.addEventListener('click', () => { const f = b.getAttribute('data-df-revert'); st[f].value = st[f].pack || ''; st[f].locked = false; paintDetails(body, song); });
|
||||
});
|
||||
body.querySelector('[data-df-save]')?.addEventListener('click', () => saveDetails(body, song));
|
||||
body.querySelector('[data-df-write]')?.addEventListener('click', () => writeToFile(body, song));
|
||||
}
|
||||
|
||||
async function saveDetails(body, song) {
|
||||
const st = song._detailsState;
|
||||
const overrides = {};
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim();
|
||||
const p = String(st[f].pack || '').trim();
|
||||
// Only store a value that differs from the pack; equal / blank clears
|
||||
// the override (the server drops a value-less, unlocked row).
|
||||
overrides[f] = { value: (v && v !== p) ? v : null, locked: !!st[f].locked };
|
||||
}
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
let ok = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides }),
|
||||
});
|
||||
ok = r.ok;
|
||||
} catch (_) { ok = false; }
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-accent'; status.textContent = 'Could not save — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Reflect the new effective values on the in-memory song (keeps the Match
|
||||
// tab header consistent) and repaint the library so the card shows them —
|
||||
// the grid reloads on library:changed (slice 3 overlay does the rest).
|
||||
for (const [f] of DETAIL_FIELDS) {
|
||||
const v = String(st[f].value || '').trim(); const p = String(st[f].pack || '').trim();
|
||||
song[f] = (v && v !== p) ? v : (st[f].pack || '');
|
||||
}
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'override' }); } catch (_) { }
|
||||
if (status) { status.className = 'text-xs h-4 text-fb-good'; status.textContent = 'Saved.'; }
|
||||
}
|
||||
|
||||
// "Write to file" — bake the shown title/artist/album/year INTO the pack
|
||||
// itself (the one action here that touches the file), via the existing
|
||||
// POST /api/song/{fn}/meta (writes the manifest, re-stats, coalesces a
|
||||
// rescan). On a real file write the display overrides for those fields are
|
||||
// now redundant, so clear their VALUES (keeping any locks) and re-render —
|
||||
// the field then reads from the file as "Pack". Loose-folder / unwritable
|
||||
// packs fall back to a DB-only update: we say so and keep the overlay.
|
||||
async function writeToFile(body, song) {
|
||||
const st = song._detailsState;
|
||||
const fields = {};
|
||||
for (const f of WRITE_FIELDS) fields[f] = String(st[f].value || '').trim();
|
||||
const status = body.querySelector('[data-df-status]');
|
||||
const writeBtn = body.querySelector('[data-df-write]');
|
||||
const saveBtn = body.querySelector('[data-df-save]');
|
||||
if (writeBtn) writeBtn.disabled = true;
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-textDim'; status.textContent = 'Writing to the song file…'; }
|
||||
let ok = false, persisted = false;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(song.filename) + '/meta', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
ok = r.ok;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
persisted = !!(j && j.persisted);
|
||||
} catch (_) { ok = false; }
|
||||
if (writeBtn) writeBtn.disabled = false;
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
if (!ok) {
|
||||
if (status) { status.className = 'text-xs leading-relaxed text-fb-accent'; status.textContent = 'Could not write to the file — try again.'; }
|
||||
return;
|
||||
}
|
||||
// Keep the in-memory song + grid in step with what was *persisted*, not
|
||||
// the raw input: the server coerces a non-numeric/empty year to "" (see
|
||||
// update_song_meta), so mirror that here or the grid card flashes the
|
||||
// typed text (e.g. "abcd") until the next natural refresh corrects it.
|
||||
const applied = { ...fields };
|
||||
if ('year' in applied) {
|
||||
const yr = /^[+-]?\d+$/.test(applied.year) ? parseInt(applied.year, 10) : 0;
|
||||
applied.year = yr ? String(yr) : '';
|
||||
}
|
||||
for (const f of WRITE_FIELDS) song[f] = applied[f];
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'write' }); } catch (_) { }
|
||||
if (persisted) {
|
||||
const clear = {};
|
||||
for (const f of WRITE_FIELDS) clear[f] = { value: null, locked: !!st[f].locked };
|
||||
try {
|
||||
await fetch('/api/song/' + enc(song.filename) + '/overrides', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overrides: clear }),
|
||||
});
|
||||
} catch (_) { /* the file write still succeeded; the overlay just lingers */ }
|
||||
await renderDetailsTab(body, song); // re-fetch: pack now = written values, overrides cleared
|
||||
const s2 = body.querySelector('[data-df-status]');
|
||||
if (s2) { s2.className = 'text-xs leading-relaxed text-fb-good'; s2.textContent = 'Written to the song file.'; }
|
||||
} else if (status) {
|
||||
status.className = 'text-xs leading-relaxed text-fb-textDim';
|
||||
status.textContent = 'Saved to the library — this pack’s file couldn’t be written, so it may revert on a full rescan.';
|
||||
}
|
||||
}
|
||||
|
||||
// Cover-art tab: the current art + a button that hands off to the shared
|
||||
// cover picker (image-picker.js, its own z-[200] modal). A pick there
|
||||
// refreshes every <img> for this song's art — including this thumbnail — so
|
||||
// there's nothing to wire back.
|
||||
function renderCoverTab(body, song) {
|
||||
body.innerHTML =
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll min-h-0 flex flex-col items-center text-center">' +
|
||||
'<img src="' + esc(artUrl(song)) + '" alt="" onerror="this.style.visibility=\'hidden\'" class="w-40 h-40 rounded-xl object-cover bg-fb-card">' +
|
||||
'<p class="text-sm text-fb-textDim max-w-sm">Choose from the Cover Art Archive, paste an image link, or upload your own. Your song files are never changed.</p>' +
|
||||
'<button data-cover-open class="bg-fb-primary hover:bg-fb-primaryHi text-white px-4 py-2 rounded-md text-sm">Choose cover art…</button>' +
|
||||
'</div>';
|
||||
body.querySelector('[data-cover-open]')?.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
}
|
||||
});
|
||||
panel.querySelector('[data-mr-identify]')?.addEventListener('click', () => runIdentify(panel, song));
|
||||
}
|
||||
|
||||
// Silent-on-success: the chart just leaves the queue and the next one
|
||||
// renders; the last one renders the done state. No toasts, no sounds.
|
||||
function settle(song) {
|
||||
if (_single) {
|
||||
// Popup Match tab: a pinned identity can change the art/canon — nudge
|
||||
// the grid to repaint (silent otherwise, like the queue flow).
|
||||
try { window.feedBack?.emit('library:changed', { reason: 'match' }); } catch (_) { }
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
if (_single) { closeModal(); return; } // Fix-match: done means done
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
@@ -750,7 +442,6 @@
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
@@ -773,37 +464,30 @@
|
||||
status = r.status;
|
||||
body = await r.json().catch(() => null);
|
||||
} catch (_) { /* falls through to the no-results line */ }
|
||||
// Honest states — never a fake hit. Each says plainly WHICH outcome this
|
||||
// is, so an empty result reads as "it ran, found nothing" (not "broken")
|
||||
// and points at the manual fallback when there's nothing to pick.
|
||||
const note = (html) => { out.innerHTML = '<p class="text-xs text-fb-textDim leading-relaxed">' + html + '</p>'; };
|
||||
const manual = _single
|
||||
? ' Try <b class="text-fb-text">Search</b>, or just set the album in <b class="text-fb-text">Details</b> and the cover in <b class="text-fb-text">Cover art</b> by hand.'
|
||||
: ' Try <b class="text-fb-text">Search instead</b>.';
|
||||
// Honest states — never a fake hit.
|
||||
if (status === 412 || (body && body.needs_setup)) {
|
||||
note('Audio identification is <b class="text-fb-text">off</b>. Turn it on and add a free AcoustID API key in Settings → Library to use it.');
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is off — enable AcoustID and add a free API key to use it.</p>';
|
||||
return;
|
||||
}
|
||||
if (status === 404) {
|
||||
note('This pack has <b class="text-fb-text">no full mix to fingerprint</b> (it\'s chart-only or stems-only).' + manual);
|
||||
out.innerHTML = "<p class=\"text-xs text-fb-textDim\">No full-mix audio to fingerprint for this song.</p>";
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Audio identification is unavailable right now — try again.</p>';
|
||||
return;
|
||||
}
|
||||
const cands = (body && body.candidates) || [];
|
||||
if (!cands.length) {
|
||||
note('<span class="text-fb-good">✓ Fingerprinted the audio</span> — but AcoustID has <b class="text-fb-text">no match</b> for this exact recording (common for obscure or import tracks).' + manual);
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">No fingerprint match — try text search.</p>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good mb-1">✓ Fingerprint matches (AcoustID)</div>' +
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim mb-1">Fingerprint matches (AcoustID)</div>' +
|
||||
cands.map((c, i) => candRowHtml(song, c, i, false)).join('');
|
||||
out.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const cand = cands[Number(btn.getAttribute('data-mr-cand'))];
|
||||
if (!cand) return;
|
||||
if (_single) { useTheseValues(song, cand); return; } // popup → adopt into Details
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/pick',
|
||||
{ candidate: cand });
|
||||
settle(song);
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
|
||||
+20
-78
@@ -38,9 +38,6 @@
|
||||
// Mastery = best accuracy across arrangements (song_stats); unscored songs
|
||||
// sort last either way. Ascending surfaces what needs work; never default.
|
||||
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
|
||||
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
|
||||
// songs sort last either way.
|
||||
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
|
||||
];
|
||||
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
|
||||
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
||||
@@ -169,15 +166,15 @@
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||
// mastery + match + genre are session-only facets (deliberately not
|
||||
// mastery + match are session-only facets (deliberately not
|
||||
// persisted), but the restored object must still CARRY the keys —
|
||||
// the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
|
||||
// so dropping them here breaks the drawer for anyone with saved prefs.
|
||||
// the filter drawer indexes f.mastery/f.match unconditionally, so
|
||||
// dropping them here breaks the drawer for anyone with saved prefs.
|
||||
state.filters = {
|
||||
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
|
||||
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
|
||||
lyrics: f.lyrics || '', tunings: arr(f.tunings),
|
||||
mastery: [], match: [], genre: [],
|
||||
mastery: [], match: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -501,39 +498,21 @@
|
||||
// to before (keeps the windowed grid's height math untouched). Honest state
|
||||
// transitions, NOT a fake per-song %: a match is binary (design §11).
|
||||
const _metaTile = {}; // fn -> 'queued' | 'working' | 'done' | 'nochange'
|
||||
// Cards whose enrichment landed 'failed' (from the grid payload) — tracked so
|
||||
// the PERSISTENT "no match" badge survives a batch tile clearing (a
|
||||
// _patchCardEnrich with no flag falls back to this instead of wiping it).
|
||||
// Populated as cards render (enrichBadge is called per card with the flag).
|
||||
const _unmatched = new Set();
|
||||
function enrichBadge(fn, unmatched) {
|
||||
if (unmatched !== undefined) { if (unmatched) _unmatched.add(fn); else _unmatched.delete(fn); }
|
||||
// A live batch tile wins over the resting no-match marker (they never
|
||||
// coexist — the batch clears its tiles when it finishes).
|
||||
const st = _metaTile[fn] || (_unmatched.has(fn) ? 'nomatch' : null);
|
||||
function enrichBadge(fn) {
|
||||
const st = _metaTile[fn];
|
||||
if (!st) return '';
|
||||
const M = {
|
||||
queued: ['bg-black/60 text-fb-textDim', '• Queued', ''],
|
||||
working: ['bg-fb-primary text-white', '⟳ Matching…', ''],
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated', ''],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match', ''],
|
||||
// Resting indicator: subtle, so a mostly-unmatched library isn't a
|
||||
// wall of loud badges. Clickable — a one-click handoff into the
|
||||
// Fix-metadata popup for this song (see the [data-meta-fix] wiring).
|
||||
nomatch: ['bg-black/60 text-fb-textDim', 'No match', 'Click to fix the metadata by hand'],
|
||||
queued: ['bg-black/60 text-fb-textDim', '• Queued'],
|
||||
working: ['bg-fb-primary text-white', '⟳ Matching…'],
|
||||
done: ['bg-fb-good/90 text-black', '✓ Updated'],
|
||||
nochange: ['bg-black/60 text-fb-textDim', '— No match'],
|
||||
};
|
||||
const conf = M[st] || M.queued;
|
||||
const fixable = st === 'nomatch'; // resting badge → opens Fix-metadata
|
||||
// top-10 clears the tuning chip (top-2) in both normal and select mode;
|
||||
// z-20 sits it above the art. Batch states are non-interactive; the
|
||||
// resting "no match" badge is the handoff into the popup.
|
||||
const cls = 'v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
|
||||
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight ' +
|
||||
(fixable ? 'pointer-events-auto cursor-pointer hover:bg-fb-primary hover:text-white transition-colors' : 'pointer-events-none');
|
||||
return '<span class="' + cls + '"' +
|
||||
(fixable ? ' data-meta-fix="1"' : '') +
|
||||
(conf[2] ? ' title="' + conf[2] + '"' : '') +
|
||||
'>' + conf[1] + '</span>';
|
||||
// z-20 sits it above the art. Non-interactive so it never eats a click.
|
||||
return '<span class="v3-meta-tile absolute top-10 left-2 z-20 ' + conf[0] +
|
||||
' text-[0.5625rem] font-bold px-1.5 py-0.5 rounded-sm leading-tight pointer-events-none">' +
|
||||
conf[1] + '</span>';
|
||||
}
|
||||
|
||||
// After a song is scored, the badge for that card is stale until the next
|
||||
@@ -891,7 +870,7 @@
|
||||
return '<div class="group relative" data-fn="' + esc(key) + '" data-letter="' + esc(songBucket(song)) + '" data-library-song="' + esc(songId(song)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||
'<div class="relative aspect-square rounded-lg overflow-hidden bg-fb-card cursor-pointer' + selRing + '" data-v3-play>' +
|
||||
'<img src="' + esc(artUrl(shown)) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + overlay +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key) + overlay +
|
||||
'<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">' +
|
||||
inlineBtns +
|
||||
'<button data-fav data-fav-idle="text-white" title="Favorite" aria-label="Favorite" aria-pressed="' + (fav ? 'true' : 'false') + '" class="w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-sm ' + (fav ? 'text-fb-accent' : 'text-white') + '">' + (fav ? '♥' : '♡') + '</button>' +
|
||||
@@ -959,7 +938,7 @@
|
||||
// address the local DB / filesystem). Both openers (⋮ and
|
||||
// right-click) share this list, so parity is structural.
|
||||
...(state.provider === 'local' && song.filename ? [
|
||||
{ id: '__fixmatch', label: 'Fix metadata…' },
|
||||
{ id: '__fixmatch', label: 'Fix match…' },
|
||||
{ id: '__cover', label: 'Change cover…' },
|
||||
{ id: '__refreshmeta', label: 'Refresh metadata' },
|
||||
{ id: '__getinfo', label: 'Get info…' },
|
||||
@@ -1010,7 +989,7 @@
|
||||
// the group's work_key/chart_count and pre-ticks the shown chart.)
|
||||
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
|
||||
if (id === '__cover') {
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
|
||||
return;
|
||||
}
|
||||
if (id === '__refreshmeta') {
|
||||
@@ -1477,13 +1456,6 @@
|
||||
e.stopPropagation();
|
||||
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
|
||||
});
|
||||
// "No match" badge → straight into the Fix-metadata popup for this
|
||||
// song (the batch → fix handoff). stopPropagation so it doesn't also
|
||||
// trigger the card's play. Follows the displayed chart, like the menu.
|
||||
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
|
||||
});
|
||||
// Artist line → the artist page (PR-B). In select mode the grid's
|
||||
// capture-phase toggle intercepts first, so selection still wins.
|
||||
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
|
||||
@@ -2824,7 +2796,7 @@
|
||||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
|
||||
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
|
||||
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
|
||||
section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
|
||||
// Progress (mastery bands) — multi-select; server filters via song_stats.
|
||||
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
@@ -3129,7 +3101,7 @@
|
||||
// when image-picker.js isn't loaded.
|
||||
artWrap.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
|
||||
} else {
|
||||
artFile.click();
|
||||
}
|
||||
@@ -3517,7 +3489,6 @@
|
||||
'<button id="v3-songs-select" class="' + ctrl + (state.selectMode ? ' bg-fb-primary text-white' : '') + '">Select</button>' +
|
||||
'<button id="v3-songs-refresh" title="Refresh library (scan for new songs)" class="' + ctrl + '">⟳ Refresh</button>' +
|
||||
'<button id="v3-songs-refresh-meta" title="Refresh metadata for the songs shown (re-match titles, artwork & more)" class="' + ctrl + '">🏷 Metadata</button>' +
|
||||
'<button id="v3-songs-unmatched" title="Show only songs with no metadata match" class="' + ctrl + ((state.filters.match || []).includes('unmatched') ? ' bg-fb-primary text-white' : '') + '">Unmatched</button>' +
|
||||
'<button id="v3-songs-upload" class="' + ctrl + '">Upload</button>' +
|
||||
'</div></div></div>' +
|
||||
// Practice-aware library home: a repertoire progress meter + a
|
||||
@@ -3588,7 +3559,6 @@
|
||||
// Refresh Metadata: local-only, so hide it for remote providers. The
|
||||
// button doubles as its own Stop while a pass runs (see onMetaBtnClick).
|
||||
byId('v3-songs-refresh-meta')?.addEventListener('click', onMetaBtnClick);
|
||||
byId('v3-songs-unmatched')?.addEventListener('click', toggleUnmatchedFilter);
|
||||
_updateMetaBtnVisibility();
|
||||
// Reflect a scan already in progress (Settings button or a background
|
||||
// pass) on the Refresh button, so its state isn't just tied to clicks here.
|
||||
@@ -3838,24 +3808,8 @@
|
||||
let _metaRunning = false;
|
||||
|
||||
function _updateMetaBtnVisibility() {
|
||||
const local = state.provider === 'local'; // enrichment + its filter are local-only
|
||||
const btn = document.getElementById('v3-songs-refresh-meta');
|
||||
if (btn) btn.style.display = local ? '' : 'none';
|
||||
const um = document.getElementById('v3-songs-unmatched');
|
||||
if (um) um.style.display = local ? '' : 'none';
|
||||
}
|
||||
|
||||
// Quick "Show unmatched" — the same filter as the drawer's Match → Unmatched,
|
||||
// one click from the toolbar so the no-match pile is reachable right after a
|
||||
// batch. Toggles the button + re-queries the grid.
|
||||
function toggleUnmatchedFilter() {
|
||||
const m = state.filters.match || (state.filters.match = []);
|
||||
const i = m.indexOf('unmatched');
|
||||
const on = i < 0;
|
||||
if (on) m.push('unmatched'); else m.splice(i, 1);
|
||||
const btn = document.getElementById('v3-songs-unmatched');
|
||||
if (btn) { btn.classList.toggle('bg-fb-primary', on); btn.classList.toggle('text-white', on); }
|
||||
reload();
|
||||
if (btn) btn.style.display = (state.provider === 'local') ? '' : 'none';
|
||||
}
|
||||
|
||||
// The local filenames the grid is currently SHOWING (data-fn is the local
|
||||
@@ -3884,18 +3838,6 @@
|
||||
function _clearMetaTiles() {
|
||||
Object.keys(_metaTile).forEach((fn) => { delete _metaTile[fn]; });
|
||||
document.querySelectorAll('.v3-meta-tile').forEach((el) => el.remove());
|
||||
// The persistent "No match" badge derives from _unmatched (not _metaTile),
|
||||
// yet shares the .v3-meta-tile class — so the blanket remove above strips it.
|
||||
// Repaint the resting indicator on any rendered card so a metadata rescan's
|
||||
// tile-clear doesn't silently drop it until the next scroll/re-render.
|
||||
_unmatched.forEach((fn) => {
|
||||
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
|
||||
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
|
||||
if (play.querySelector('.v3-meta-tile')) return;
|
||||
const html = enrichBadge(fn);
|
||||
if (html) play.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Pins the onboarding handedness control: a Right/Left choice lives in the
|
||||
// instrument selector (the "Choose your instrument" onboarding step, which the
|
||||
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
|
||||
// highway 'lefty' preference. Source-level, matching the other tests/js/
|
||||
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
|
||||
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
|
||||
|
||||
test('instrument selector offers a Handedness Right/Left choice', () => {
|
||||
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
|
||||
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
|
||||
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
|
||||
});
|
||||
|
||||
test('clicking a handedness pill writes the lefty preference from its value', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
|
||||
'the handedness click handler sets lefty from the pill value');
|
||||
});
|
||||
|
||||
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
|
||||
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
|
||||
assert.ok(setter, '_setLeftyPref must exist');
|
||||
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
|
||||
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
|
||||
});
|
||||
|
||||
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
|
||||
'_leftyPref reads the current handedness with a storage fallback');
|
||||
});
|
||||
|
||||
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
|
||||
assert.match(TOUR, /Choose your instrument/);
|
||||
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
|
||||
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
|
||||
if (!m) throw new Error('pending-view helper block not found');
|
||||
|
||||
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
|
||||
|
||||
test('edit-region handoff defaults cursor to region start and marks return path', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 2,
|
||||
barSel: { startTime: 12.5, endTime: 20 },
|
||||
returnToHighway: true,
|
||||
cursorTime: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
test('return-trip handoff preserves explicit viewport state', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
|
||||
scrollX: -4,
|
||||
zoom: 160,
|
||||
cursorTime: 9.25,
|
||||
});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 1,
|
||||
barSel: { startTime: 8, endTime: 14 },
|
||||
cursorTime: 9.25,
|
||||
scrollX: 0,
|
||||
zoom: 160,
|
||||
});
|
||||
});
|
||||
|
||||
test('missing region still produces a stable pending view shell', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 0,
|
||||
barSel: null,
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// Contract test: 3D Highway WebGL context-loss recovery.
|
||||
//
|
||||
// Switching the active window / alt-tabbing (especially on Windows) can trigger
|
||||
// a GPU context reset. Without a handler the lost WebGL context escalates into a
|
||||
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
|
||||
// lifecycle (too much to construct in a vm sandbox), so — like the other
|
||||
// highway_* source-contract tests here — this pins the wiring at the source
|
||||
// level: the loss must be preventDefault()'d (so the browser restores it), draw
|
||||
// must bail while lost, and the listeners must be torn down.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'must listen for webglcontextrestored on ren.domElement');
|
||||
});
|
||||
|
||||
test('the context-lost handler preventDefaults and pauses drawing', () => {
|
||||
// Without preventDefault() the browser will not attempt to restore the
|
||||
// context and the loss can escalate to a renderer crash.
|
||||
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
|
||||
assert.ok(m, '_onCtxLost handler must exist');
|
||||
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
|
||||
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
|
||||
});
|
||||
|
||||
test('draw() early-returns while the context is lost', () => {
|
||||
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
|
||||
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
|
||||
});
|
||||
|
||||
test('teardown removes the context-loss listeners', () => {
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'teardown must remove the webglcontextlost listener');
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'teardown must remove the webglcontextrestored listener');
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
|
||||
|
||||
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
|
||||
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
|
||||
function makeSandbox({ isAudioRunning, loadBackingTrack }) {
|
||||
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
|
||||
|
||||
const audio = {
|
||||
@@ -65,7 +65,6 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
const juceApi = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
|
||||
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
|
||||
getBackingDuration: () => Promise.resolve(180),
|
||||
seekBacking: () => Promise.resolve(),
|
||||
startBacking: () => Promise.resolve(),
|
||||
@@ -153,87 +152,6 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'Windows Audio (Exclusive Mode)',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 1);
|
||||
});
|
||||
|
||||
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: 'ASIO',
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
|
||||
});
|
||||
|
||||
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
|
||||
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: shared,
|
||||
});
|
||||
sb.window._juceMode = false;
|
||||
sb.window._currentSongAudio = {
|
||||
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
|
||||
juceEligible: false,
|
||||
feedpakFullMix: true,
|
||||
};
|
||||
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
|
||||
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
|
||||
assert.equal(sb.__calls.loadBackingTrack.length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
|
||||
let type = 'Windows Audio (Exclusive Mode)';
|
||||
const sb = makeSandbox({
|
||||
isAudioRunning: () => true,
|
||||
loadBackingTrack: () => true,
|
||||
outputType: () => type,
|
||||
});
|
||||
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
|
||||
sb.window._juceMode = true;
|
||||
sb.window._juceAudioUrl = url;
|
||||
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
|
||||
|
||||
// Still exclusive: routing is consistent, no switch.
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
|
||||
|
||||
// Device switched to shared mid-song: must return to HTML5.
|
||||
type = 'Windows Audio';
|
||||
await sb.window._reevaluateJuceRouting();
|
||||
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
|
||||
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
|
||||
});
|
||||
|
||||
test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
|
||||
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
|
||||
sb.window._juceMode = false;
|
||||
|
||||
@@ -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,195 +0,0 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
// pushes it into the desktop engine's renderer bus while the output device is
|
||||
// exclusive-style — the Phase 2 path for audio the native backing transport
|
||||
// cannot carry. These tests extract that IIFE from source and exercise
|
||||
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
|
||||
// under exclusive output, disengagement on return to shared mode, inertness
|
||||
// in shared mode / while the native transport owns the song, and the
|
||||
// element-capture fallback.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
while (i < src.length && depth > 0) {
|
||||
const ch = src[i];
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') depth--;
|
||||
i++;
|
||||
}
|
||||
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
|
||||
const tail = src.slice(i, i + 5);
|
||||
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
|
||||
return src.slice(start, i) + ')();';
|
||||
}
|
||||
|
||||
function makeFakeContext(sampleRate = 48000) {
|
||||
const ctx = {
|
||||
sampleRate,
|
||||
state: 'running',
|
||||
sinkIdCalls: [],
|
||||
destination: { isDestination: true },
|
||||
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
|
||||
resume() { this.state = 'running'; return Promise.resolve(); },
|
||||
audioWorklet: { addModule: () => Promise.resolve() },
|
||||
createMediaElementSource(el) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
|
||||
const api = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
constructor() { this.port = { onmessage: null }; }
|
||||
connect() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
|
||||
Blob: class { constructor() {} },
|
||||
AudioWorkletNode: FakeWorkletNode,
|
||||
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
|
||||
WeakSet, WeakMap, Promise, Float32Array, Math,
|
||||
setInterval: () => 0,
|
||||
document: {
|
||||
hidden: false,
|
||||
addEventListener() {},
|
||||
getElementById: () => sandbox.__audioEl,
|
||||
},
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
feedBackDesktop: { audio: api },
|
||||
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
|
||||
_juceMode: false,
|
||||
_currentSongAudio: null,
|
||||
feedBack: { stems: {} },
|
||||
};
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
'feeder must expose window._reevaluateRendererBus');
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function makeStemsGraph() {
|
||||
return {
|
||||
context: makeFakeContext(),
|
||||
masterNode: { connect() {}, disconnect() {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||
});
|
||||
|
||||
test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
let excl = true;
|
||||
const sb = makeSandbox({ exclusive: () => excl });
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
||||
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
|
||||
});
|
||||
|
||||
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => false });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||
});
|
||||
|
||||
test('engine stops → bus disabled', async () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
running = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
// Regression test for feedBack#800: tuner injectPlayerButton() must anchor the
|
||||
// injected button to a DIRECT-child button of #player-controls. The old
|
||||
// `controls.querySelector('button:last-child')` could resolve to a NESTED
|
||||
// button, and `controls.insertBefore(btn, nestedButton)` then throws
|
||||
// NotFoundError — which propagated out of the player-screen transition and
|
||||
// aborted its render.
|
||||
//
|
||||
// Same isolation strategy as the core tests/js suite: extract the real function
|
||||
// from source with extractFunction() and run it in a vm sandbox over a small
|
||||
// but faithful DOM model. The model's insertBefore() enforces the real DOM
|
||||
// invariant (reference node must be a direct child, else NotFoundError), and
|
||||
// querySelector() implements the exact semantics of both the old
|
||||
// (`button:last-child`) and new (`:scope > button:last-of-type`) selectors — so
|
||||
// reverting the fix makes this test throw.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('../../../js/test_utils');
|
||||
|
||||
const UI_JS = path.join(__dirname, '..', '..', '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const SRC = fs.readFileSync(UI_JS, 'utf8');
|
||||
const FN_SRC = extractFunction(SRC, 'function injectPlayerButton(');
|
||||
|
||||
// ── Minimal, faithful DOM model ──────────────────────────────────────────────
|
||||
|
||||
class El {
|
||||
constructor(tag, id = '') {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.id = id;
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.textContent = '';
|
||||
this.title = '';
|
||||
this.onclick = null;
|
||||
}
|
||||
appendChild(node) {
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
return node;
|
||||
}
|
||||
insertBefore(node, ref) {
|
||||
const idx = this.children.indexOf(ref);
|
||||
if (ref == null || idx === -1) {
|
||||
// Faithful to the browser: ref must be a direct child.
|
||||
const e = new Error(
|
||||
"Failed to execute 'insertBefore' on 'Node': The node before which the "
|
||||
+ 'new node is to be inserted is not a child of this node.'
|
||||
);
|
||||
e.name = 'NotFoundError';
|
||||
throw e;
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.children.splice(idx, 0, node);
|
||||
return node;
|
||||
}
|
||||
querySelector(sel) {
|
||||
if (sel === ':scope > button:last-of-type') {
|
||||
// Last direct-child <button>.
|
||||
const btns = this.children.filter((c) => c.tagName === 'BUTTON');
|
||||
return btns.length ? btns[btns.length - 1] : null;
|
||||
}
|
||||
if (sel === 'button:last-child') {
|
||||
// First descendant <button> (document order) that is the last child
|
||||
// of its own parent — the buggy legacy anchor.
|
||||
let found = null;
|
||||
const walk = (node) => {
|
||||
for (const c of node.children) {
|
||||
if (found) return;
|
||||
const isLast = c.parentNode.children[c.parentNode.children.length - 1] === c;
|
||||
if (c.tagName === 'BUTTON' && isLast) { found = c; return; }
|
||||
walk(c);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
throw new Error(`unhandled selector in stub: ${sel}`);
|
||||
}
|
||||
}
|
||||
|
||||
function findById(node, id) {
|
||||
if (!node) return null;
|
||||
if (node.id === id) return node;
|
||||
for (const c of node.children) {
|
||||
const r = findById(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run the extracted injectPlayerButton() against a given controls tree.
|
||||
// Returns { controls, threw }.
|
||||
function run({ controls, isV3 = false, slot = null }) {
|
||||
const roots = [controls, slot].filter(Boolean);
|
||||
const document = {
|
||||
getElementById(id) {
|
||||
if (id === 'player-controls') return controls;
|
||||
for (const r of roots) {
|
||||
const hit = findById(r, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
createElement(tag) { return new El(tag); },
|
||||
};
|
||||
const window = {
|
||||
feedBack: isV3
|
||||
? { uiVersion: 'v3', ui: { playerControlSlot: () => slot } }
|
||||
: { uiVersion: 'v2' },
|
||||
tuner: { toggle: () => {} },
|
||||
};
|
||||
const sandbox = {
|
||||
window,
|
||||
document,
|
||||
Element: El,
|
||||
updatePlayerButton: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
let threw = null;
|
||||
try {
|
||||
vm.runInContext(FN_SRC + '\nglobalThis.__run = injectPlayerButton;\n__run();', sandbox);
|
||||
} catch (e) {
|
||||
threw = e;
|
||||
}
|
||||
return { controls, slot, threw };
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('does not throw when the last button is nested (feedBack#800 repro)', () => {
|
||||
// controls > div.transport > [play, close]; `close` is button:last-child of
|
||||
// the div but NOT a direct child of controls. The old anchor threw here.
|
||||
const controls = new El('div', 'player-controls');
|
||||
const transport = new El('div');
|
||||
transport.appendChild(new El('button', 'play'));
|
||||
transport.appendChild(new El('button', 'close'));
|
||||
controls.appendChild(transport);
|
||||
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
// With no direct-child button, it appends to controls.
|
||||
assert.ok(findById(controls, 'btn-tuner-player'), 'tuner button was added');
|
||||
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
|
||||
});
|
||||
|
||||
test('inserts before the last direct-child button when one exists', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('button', 'play'));
|
||||
controls.appendChild(new El('button', 'close'));
|
||||
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
const ids = controls.children.map((c) => c.id);
|
||||
// tuner button sits immediately before the last direct-child button.
|
||||
assert.deepEqual(ids, ['play', 'btn-tuner-player', 'close']);
|
||||
});
|
||||
|
||||
test('appends when controls has no buttons at all', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('span'));
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
|
||||
});
|
||||
|
||||
test('is idempotent — a second call does not add a duplicate', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('button', 'close'));
|
||||
run({ controls });
|
||||
run({ controls });
|
||||
const injected = controls.children.filter((c) => c.id === 'btn-tuner-player');
|
||||
assert.equal(injected.length, 1);
|
||||
});
|
||||
|
||||
test('v3 mounts into the plugin-control slot and never uses the legacy anchor', () => {
|
||||
const slot = new El('div', 'plugin-control-slot');
|
||||
// A nested button in the slot would trip the legacy anchor; v3 must ignore it.
|
||||
const inner = new El('div');
|
||||
inner.appendChild(new El('button', 'other'));
|
||||
slot.appendChild(inner);
|
||||
const controls = new El('div', 'player-controls');
|
||||
|
||||
const { threw } = run({ controls, isV3: true, slot });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
assert.ok(findById(slot, 'btn-tuner-player'), 'tuner button mounted into the slot');
|
||||
assert.equal(findById(controls, 'btn-tuner-player'), null, 'not mounted into #player-controls');
|
||||
});
|
||||
@@ -103,99 +103,13 @@ def test_returns_404_for_nonexistent_file(client_and_server):
|
||||
assert "error" in r.json()
|
||||
|
||||
|
||||
# ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
|
||||
|
||||
@pytest.fixture()
|
||||
def dlc_client(tmp_path, monkeypatch):
|
||||
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
monkeypatch.setenv("DLC_DIR", str(dlc))
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
|
||||
sys.modules.pop("server", None)
|
||||
server = importlib.import_module("server")
|
||||
# Module-level sloppak source-dir cache survives re-import; clear it so a
|
||||
# prior test's filename key can't shadow this test's temp DLC_DIR.
|
||||
server.sloppak_mod._source_cache.clear()
|
||||
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(server, "startup_scan", lambda: None)
|
||||
static_tmp = tmp_path / "static"
|
||||
static_tmp.mkdir()
|
||||
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
|
||||
tc = TestClient(server.app, client=("127.0.0.1", 50000))
|
||||
try:
|
||||
yield tc, server, dlc
|
||||
finally:
|
||||
tc.close()
|
||||
meta_db = getattr(server, "meta_db", None)
|
||||
conn = getattr(meta_db, "conn", None)
|
||||
if conn is not None:
|
||||
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
|
||||
|
||||
def _make_sloppak(dlc, name="song.sloppak"):
|
||||
"""Create a minimal directory-form sloppak with a full-mix file."""
|
||||
pak = dlc / name
|
||||
(pak / "stems").mkdir(parents=True)
|
||||
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
|
||||
return pak
|
||||
|
||||
|
||||
def test_sloppak_url_resolves_to_local_path(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
pak = _make_sloppak(dlc)
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
|
||||
|
||||
|
||||
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
_make_sloppak(dlc, name="My Song.sloppak")
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_rel_traversal_is_403(dlc_client):
|
||||
tc, _server, dlc = dlc_client
|
||||
_make_sloppak(dlc)
|
||||
(dlc / "secret.txt").write_text("top secret")
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_filename_traversal_is_403(dlc_client):
|
||||
tc, _server, _dlc = dlc_client
|
||||
r = tc.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
|
||||
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
|
||||
client, _ = client_and_server
|
||||
r = client.get(
|
||||
"/api/audio-local-path",
|
||||
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
|
||||
|
||||
def test_rejects_sloppak_url(client_and_server):
|
||||
client, _ = client_and_server
|
||||
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_rejects_empty_url(client_and_server):
|
||||
client, _ = client_and_server
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Tests for the per-field metadata override + lock store (Fix-metadata popup).
|
||||
|
||||
A reversible DISPLAY overlay, never written to the pack: filename-keyed, so it
|
||||
survives a rescan (never purged by delete_missing) and is dropped only with the
|
||||
song (delete_song). Locks pin a field against a later auto-match.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch, isolate_logging):
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("FEEDBACK_SKIP_STARTUP_TASKS", "1")
|
||||
sys.modules.pop("server", None)
|
||||
srv = importlib.import_module("server")
|
||||
try:
|
||||
yield srv
|
||||
finally:
|
||||
conn = getattr(getattr(srv, "meta_db", None), "conn", None)
|
||||
if conn is not None:
|
||||
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
|
||||
conn.close()
|
||||
sys.modules.pop("server", None)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(server):
|
||||
return TestClient(server.app)
|
||||
|
||||
|
||||
def _put(server, fn, **meta):
|
||||
base = {"title": "Song", "artist": "Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]}
|
||||
base.update(meta)
|
||||
server.meta_db.put(fn, 0, 0, base)
|
||||
|
||||
|
||||
# ── store semantics ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_set_get_and_partial_upsert(server):
|
||||
db = server.meta_db
|
||||
assert db.get_song_overrides("a.archive") == {}
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC")
|
||||
assert db.get_song_overrides("a.archive") == {"artist": {"value": "AC/DC", "locked": False}}
|
||||
# partial: lock without touching the value
|
||||
db.set_song_override("a.archive", "artist", locked=True)
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC", "locked": True}
|
||||
# partial: change the value, keep the lock
|
||||
db.set_song_override("a.archive", "artist", value="AC/DC (fixed)")
|
||||
assert db.get_song_overrides("a.archive")["artist"] == {"value": "AC/DC (fixed)", "locked": True}
|
||||
|
||||
|
||||
def test_lock_only_row_persists_without_a_value(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "year", locked=True)
|
||||
# a pure lock (no override value) is a valid, kept row
|
||||
assert db.get_song_overrides("a.archive") == {"year": {"value": None, "locked": True}}
|
||||
|
||||
|
||||
def test_empty_and_unlocked_drops_the_row(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "album", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "album", value="", locked=False)
|
||||
assert db.get_song_overrides("a.archive") == {} # no empty shell
|
||||
|
||||
|
||||
def test_clear_one_field_leaves_others(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "title", value="T")
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.clear_song_override("a.archive", "title")
|
||||
assert set(db.get_song_overrides("a.archive")) == {"artist"}
|
||||
|
||||
|
||||
# ── lifecycle: rescan survival vs explicit delete ─────────────────────────────
|
||||
|
||||
def test_rescan_never_purges_overrides_delete_does(server):
|
||||
_put(server, "a.archive")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC/DC", locked=True)
|
||||
server.meta_db.delete_missing(set()) # file vanished from a scan
|
||||
assert server.meta_db.get_song_overrides("a.archive")["artist"]["value"] == "AC/DC"
|
||||
server.meta_db.purge_song_user_data("a.archive") # the delete_song purge
|
||||
assert server.meta_db.get_song_overrides("a.archive") == {}
|
||||
|
||||
|
||||
def test_overrides_map_batches(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="A")
|
||||
db.set_song_override("b.archive", "title", value="B", locked=True)
|
||||
m = db.overrides_map(["a.archive", "b.archive", "missing.archive"])
|
||||
assert m["a.archive"]["artist"]["value"] == "A"
|
||||
assert m["b.archive"]["title"] == {"value": "B", "locked": True}
|
||||
assert "missing.archive" not in m
|
||||
assert db.overrides_map([]) == {}
|
||||
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_api_put_get_and_clear(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": "AC/DC", "locked": True},
|
||||
"year": {"value": "1979"}}})
|
||||
assert r.status_code == 200
|
||||
ov = r.json()["overrides"]
|
||||
assert ov["artist"] == {"value": "AC/DC", "locked": True}
|
||||
assert ov["year"] == {"value": "1979", "locked": False}
|
||||
assert client.get("/api/song/a.archive/overrides").json()["overrides"]["artist"]["value"] == "AC/DC"
|
||||
# clear via PUT (value null + unlocked) — DELETE is shadowed by /api/song/{path}
|
||||
client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"artist": {"value": None, "locked": False}}})
|
||||
assert "artist" not in client.get("/api/song/a.archive/overrides").json()["overrides"]
|
||||
|
||||
|
||||
def test_api_get_returns_pack_values(client, server):
|
||||
_put(server, "a.archive", title="Pack Title", artist="Pack Artist",
|
||||
album="Pack Album", year="1988")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Fixed Title")
|
||||
body = client.get("/api/song/a.archive/overrides").json()
|
||||
# the override rides "overrides"; the pack baseline rides "pack" (all 5 fields)
|
||||
assert body["overrides"]["title"]["value"] == "Fixed Title"
|
||||
assert body["pack"] == {"title": "Pack Title", "artist": "Pack Artist",
|
||||
"album": "Pack Album", "year": "1988", "genre": ""}
|
||||
# a song with no row still gets an all-empty pack (popup always has values)
|
||||
assert client.get("/api/song/ghost.archive/overrides").json()["pack"]["title"] == ""
|
||||
|
||||
|
||||
def test_api_rejects_unknown_field(client, server):
|
||||
_put(server, "a.archive")
|
||||
r = client.put("/api/song/a.archive/overrides",
|
||||
json={"overrides": {"tuning": {"value": "Drop D"}}})
|
||||
assert r.status_code == 400
|
||||
assert "unknown field" in r.json()["error"]
|
||||
|
||||
|
||||
# ── lock enforcement (slice 2) ────────────────────────────────────────────────
|
||||
|
||||
def test_locked_fields_reader(server):
|
||||
db = server.meta_db
|
||||
db.set_song_override("a.archive", "artist", value="X", locked=True)
|
||||
db.set_song_override("a.archive", "title", value="Y") # override, not locked
|
||||
db.set_song_override("a.archive", "year", locked=True) # lock only
|
||||
assert db.locked_fields("a.archive") == {"artist", "year"}
|
||||
|
||||
|
||||
def test_compose_lock_filter_strips_locked_cand_keys(server):
|
||||
f = server._compose_lock_filter(None, {"artist", "year"})
|
||||
cand = {"recording_id": "r", "artist": "X", "artist_sort": "X", "title": "T",
|
||||
"year": "1990", "album": "A", "genres": ["rock"]}
|
||||
out = f(cand)
|
||||
# locked display keys stripped (artist maps to artist + artist_sort)…
|
||||
assert not ({"artist", "artist_sort", "year"} & set(out))
|
||||
# …identity + unlocked display fields survive
|
||||
assert out["recording_id"] == "r" and out["title"] == "T" and out["album"] == "A"
|
||||
# no locks → base filter returned unchanged (zero-copy common path)
|
||||
assert server._compose_lock_filter(None, set()) is None
|
||||
|
||||
|
||||
# ── display overlay in the grid (slice 3) ─────────────────────────────────────
|
||||
# "Grid shows only overrides": the effective cell is the user's override else the
|
||||
# pack value. Display-only + keyset-safe — the seek stays on the raw column.
|
||||
|
||||
def _grid(server, **kw):
|
||||
songs, _ = server.meta_db.query_page(**kw)
|
||||
return {s["filename"]: s for s in songs}
|
||||
|
||||
|
||||
def test_grid_shows_override_value_over_pack(server):
|
||||
_put(server, "a.archive", title="Wrong Title", artist="Wrong",
|
||||
album="Pack Album", year="1999")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Right Title")
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="Right Artist")
|
||||
server.meta_db.set_song_override("a.archive", "year", value="1979")
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Right Title"
|
||||
assert s["artist"] == "Right Artist"
|
||||
assert s["year"] == "1979"
|
||||
assert s["album"] == "Pack Album" # no override → pack value shows
|
||||
assert s["_sort_title"] == "Wrong Title" # raw title stashed for the cursor
|
||||
|
||||
|
||||
def test_grid_ignores_lock_only_override(server):
|
||||
_put(server, "a.archive", title="Pack Title")
|
||||
server.meta_db.set_song_override("a.archive", "title", locked=True) # lock, no value
|
||||
s = _grid(server)["a.archive"]
|
||||
assert s["title"] == "Pack Title" # a lock without a value never retitles
|
||||
assert "_sort_title" not in s # …and stashes nothing
|
||||
|
||||
|
||||
def test_override_beats_alias_relabel_for_artist(server):
|
||||
_put(server, "a.archive", artist="ACDC")
|
||||
server.meta_db.set_artist_alias("ACDC", "AC/DC") # P4 alias
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC/DC" # alias applies alone
|
||||
server.meta_db.set_song_override("a.archive", "artist", value="AC-DC (mine)")
|
||||
assert _grid(server)["a.archive"]["artist"] == "AC-DC (mine)" # override wins over alias
|
||||
|
||||
|
||||
def test_route_strips_private_sort_title(client, server):
|
||||
_put(server, "a.archive", title="Pack")
|
||||
server.meta_db.set_song_override("a.archive", "title", value="Shown")
|
||||
row = next(s for s in client.get("/api/library?sort=title").json()["songs"]
|
||||
if s["filename"] == "a.archive")
|
||||
assert row["title"] == "Shown"
|
||||
assert "_sort_title" not in row # private keyset stash never leaks to the client
|
||||
|
||||
|
||||
def test_genre_override_drives_facet_and_filter(client, server):
|
||||
# a.archive: pack genre "Rock"; b.archive: blank genre, overridden to "City Pop".
|
||||
_put(server, "a.archive", title="A", genre="Rock")
|
||||
_put(server, "b.archive", title="B", genre="")
|
||||
server.meta_db.set_song_override("b.archive", "genre", value="City Pop")
|
||||
# Facet lists the EFFECTIVE genres (override surfaces; empty raw doesn't).
|
||||
genres = client.get("/api/library/genres").json()["genres"]
|
||||
assert "City Pop" in genres and "Rock" in genres
|
||||
# Filtering by the override genre returns the overridden song…
|
||||
fns = [s["filename"] for s in client.get("/api/library?genre=City%20Pop").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
# …and its raw (blank) genre no longer matches a stale query for it.
|
||||
rock = [s["filename"] for s in client.get("/api/library?genre=Rock").json()["songs"]]
|
||||
assert rock == ["a.archive"]
|
||||
|
||||
|
||||
def test_lock_only_genre_does_not_change_facet(server):
|
||||
# A pure lock (no value) must not invent an effective genre.
|
||||
_put(server, "a.archive", title="A", genre="Metal")
|
||||
server.meta_db.set_song_override("a.archive", "genre", locked=True)
|
||||
assert server.meta_db._has_genre_overrides() is False # value-less rows don't count
|
||||
assert server.meta_db._effective_genre_expr() == "genre"
|
||||
|
||||
|
||||
def test_romaji_fallback_for_blank_artist_pack(server):
|
||||
fn = "CDLC/0 - City Pop/Junko-Yagami_BAY-CITY_v1_p.feedpak"
|
||||
_put(server, fn, title="Junko-Yagami_BAY-CITY_v1_p", artist="") # scanner fell back to the filename
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}[fn]
|
||||
# the grid shows the author's romaji, not blank / the raw filename / kanji
|
||||
assert s["artist"] == "Junko Yagami"
|
||||
assert s["title"] == "BAY CITY"
|
||||
# the Details baseline (pack_fields) matches, so the popup agrees with the grid
|
||||
pack = server.meta_db.pack_fields(fn)
|
||||
assert pack["artist"] == "Junko Yagami" and pack["title"] == "BAY CITY"
|
||||
|
||||
|
||||
def test_romaji_fallback_left_alone_when_pack_has_artist(server):
|
||||
_put(server, "a.archive", title="Real Title", artist="Real Artist")
|
||||
s = {x["filename"]: x for x in server.meta_db.query_page()[0]}["a.archive"]
|
||||
assert s["artist"] == "Real Artist" and s["title"] == "Real Title"
|
||||
|
||||
|
||||
def test_title_keyset_paging_is_complete_with_overrides(client, server):
|
||||
# Raw titles A/B/C → title-sort order is A, B, C on the RAW column.
|
||||
_put(server, "b.archive", title="B")
|
||||
_put(server, "a.archive", title="A")
|
||||
_put(server, "c.archive", title="C")
|
||||
# Overrides that would reshuffle the order IF the cursor wrongly used the
|
||||
# displayed value — the seek must stay on the raw title, so paging still
|
||||
# covers every row exactly once (no skip/dupe).
|
||||
server.meta_db.set_song_override("a.archive", "title", value="ZZZ")
|
||||
server.meta_db.set_song_override("c.archive", "title", value="AAA")
|
||||
seen, cursor = [], None
|
||||
for _ in range(10):
|
||||
url = "/api/library?sort=title&size=1" + (f"&after={cursor}" if cursor else "")
|
||||
data = client.get(url).json()
|
||||
if not data["songs"]:
|
||||
break
|
||||
seen.append(data["songs"][0]["filename"])
|
||||
cursor = data["next_cursor"]
|
||||
if not cursor:
|
||||
break
|
||||
assert sorted(seen) == ["a.archive", "b.archive", "c.archive"] # each exactly once
|
||||
@@ -110,19 +110,6 @@ def test_preview_excludes_author_set_keys(server, client):
|
||||
assert {"genres", "mbid", "isrc"} <= got
|
||||
|
||||
|
||||
def test_preview_excludes_locked_fields(server, client):
|
||||
"""A field LOCKED in the Fix-metadata popup is never gap-filled — writing
|
||||
the matched value would be exactly the clobber the lock prevents — even
|
||||
though the match has a value and the manifest lacks it."""
|
||||
make_dir_sloppak(server, "a.sloppak")
|
||||
seed_match(server, "a.sloppak")
|
||||
server.meta_db.set_song_override("a.sloppak", "album", locked=True)
|
||||
server.meta_db.set_song_override("a.sloppak", "year", locked=True)
|
||||
got = {m["key"] for m in client.get("/api/song/a.sloppak/gap-fill").json()["missing"]}
|
||||
assert "album" not in got and "year" not in got
|
||||
assert {"genres", "mbid", "isrc"} <= got # unlocked keys still offered
|
||||
|
||||
|
||||
def test_preview_excludes_present_but_empty_keys(server, client):
|
||||
"""Gap-fill is append-only, so a present-but-empty value (album: '',
|
||||
year: 0) is NOT a gap the writer can fill — appending would duplicate the
|
||||
|
||||
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
|
||||
|
||||
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36, 54],
|
||||
beats=[
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
|
||||
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
@@ -247,44 +247,6 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
# Times are captured (rounded to 3 dp).
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[54]["times"] == [1.5]
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
|
||||
"""Multi-voice measures can capture times out of order; the final sort
|
||||
must reorder velocities WITH their times, not leave them behind."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[
|
||||
# Deliberately reversed chronology within the measure.
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]),
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[56]["velocities"] == [88, 44], \
|
||||
"velocity must follow its time through the sort"
|
||||
|
||||
|
||||
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
|
||||
"""A corrupt/zero GP velocity records the 100 import default rather
|
||||
than poisoning the aligned list."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["velocities"] == [100]
|
||||
|
||||
|
||||
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Tests for the librosa-free piecewise time-warp helpers in lib/gp_autosync.py
|
||||
(bar_start_times / build_warp_anchors / warp_time / warp_song_times /
|
||||
gp_has_expandable_repeats) plus refine_sync's pure fallbacks.
|
||||
|
||||
Fixture-free, matching tests/test_gp_audio_sync.py: every test drives a pure
|
||||
helper with hand-built inputs (in-memory GPIF zips, synthetic sync points,
|
||||
hand-rolled Song objects). The librosa-backed sweep inside refine_sync needs
|
||||
real audio and is covered by manual validation in the PR.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
|
||||
import gp_autosync as ga
|
||||
from gp8_audio_sync import GpSyncData, SyncPoint
|
||||
from song import (
|
||||
Anchor,
|
||||
Arrangement,
|
||||
Beat,
|
||||
Chord,
|
||||
HandShape,
|
||||
Note,
|
||||
Phrase,
|
||||
PhraseLevel,
|
||||
Section,
|
||||
Song,
|
||||
)
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _gpif_zip(gpif_xml: str) -> bytes:
|
||||
"""Build an in-memory .gp container holding the given score.gpif."""
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("Content/score.gpif", gpif_xml)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _gpif(tempo_autos: list[tuple[int, float]], bar_sigs: list[str]) -> str:
|
||||
autos = "".join(
|
||||
f"<Automation><Type>Tempo</Type><Bar>{bar}</Bar>"
|
||||
f"<Value>{bpm} 2</Value></Automation>"
|
||||
for bar, bpm in tempo_autos
|
||||
)
|
||||
bars = "".join(f"<MasterBar><Time>{sig}</Time></MasterBar>" for sig in bar_sigs)
|
||||
return (
|
||||
"<GPIF><MasterTrack><Automations>"
|
||||
f"{autos}</Automations></MasterTrack>"
|
||||
f"<MasterBars>{bars}</MasterBars></GPIF>"
|
||||
)
|
||||
|
||||
|
||||
def _sp(bar, t, mod=120.0, orig=120.0):
|
||||
return SyncPoint(bar=bar, time_secs=t, modified_tempo=mod, original_tempo=orig)
|
||||
|
||||
|
||||
# ── bar_start_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_bar_start_times_constant_tempo(tmp_path):
|
||||
# 120 BPM, 4/4 → every bar is exactly 2s
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"] * 4)))
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 6.0])
|
||||
|
||||
|
||||
def test_bar_start_times_tempo_change_and_meter(tmp_path):
|
||||
# Bar 0-1 at 120 (4/4 → 2s each), bar 2 switches to 60 in 3/4 (3s)
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(
|
||||
_gpif_zip(_gpif([(0, 120.0), (2, 60.0)], ["4/4", "4/4", "3/4", "3/4"]))
|
||||
)
|
||||
assert ga.bar_start_times(str(gp)) == pytest.approx([0.0, 2.0, 4.0, 7.0])
|
||||
|
||||
|
||||
# ── build_warp_anchors ────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_warp_anchors_maps_bars_to_score_time():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [_sp(0, 1.0), _sp(2, 5.4)]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_nonmonotonic_and_out_of_range():
|
||||
bar_starts = [0.0, 2.0, 4.0, 6.0]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(1, 0.5), # audio time goes backwards — dropped
|
||||
_sp(2, 5.4),
|
||||
_sp(99, 9.9), # bar out of range — dropped
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [(0.0, 1.0), (4.0, 5.4)]
|
||||
|
||||
|
||||
def test_build_warp_anchors_drops_implausible_slopes():
|
||||
# 2s score bars. A DTW fold (or a run of monotonicity-clamped refine
|
||||
# points) can produce a near-flat audio segment — slope far below the
|
||||
# 0.2x plausibility floor — which would crush every bar in the span.
|
||||
bar_starts = [float(2 * b) for b in range(11)]
|
||||
points = [
|
||||
_sp(0, 1.0),
|
||||
_sp(4, 9.0), # slope 1.0 — kept
|
||||
_sp(8, 9.1), # slope 0.0125 over 8s of score — dropped
|
||||
_sp(10, 21.0), # slope 1.0 vs the last KEPT anchor — kept
|
||||
]
|
||||
assert ga.build_warp_anchors(points, bar_starts) == [
|
||||
(0.0, 1.0), (8.0, 9.0), (20.0, 21.0)
|
||||
]
|
||||
|
||||
|
||||
def test_build_warp_anchors_requires_two_points():
|
||||
assert ga.build_warp_anchors([_sp(0, 1.0)], [0.0, 2.0]) == []
|
||||
assert ga.build_warp_anchors([], [0.0, 2.0]) == []
|
||||
|
||||
|
||||
# ── warp_time ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_warp_time_interpolates_between_anchors():
|
||||
anchors = [(0.0, 1.0), (10.0, 21.0)] # slope 2, offset 1
|
||||
assert ga.warp_time(0.0, anchors) == pytest.approx(1.0)
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(11.0)
|
||||
assert ga.warp_time(10.0, anchors) == pytest.approx(21.0)
|
||||
|
||||
|
||||
def test_warp_time_piecewise_segments():
|
||||
# First half plays at authored speed, second half at half speed
|
||||
anchors = [(0.0, 0.0), (10.0, 10.0), (20.0, 30.0)]
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(5.0)
|
||||
assert ga.warp_time(15.0, anchors) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_warp_time_extrapolates_with_edge_slopes():
|
||||
anchors = [(10.0, 20.0), (20.0, 40.0)] # slope 2
|
||||
assert ga.warp_time(5.0, anchors) == pytest.approx(10.0) # before first
|
||||
assert ga.warp_time(25.0, anchors) == pytest.approx(50.0) # after last
|
||||
|
||||
|
||||
def test_warp_time_preserves_order():
|
||||
anchors = [(0.0, 0.5), (4.0, 4.1), (8.0, 9.3), (12.0, 12.9)]
|
||||
times = [i * 0.37 for i in range(40)]
|
||||
warped = [ga.warp_time(t, anchors) for t in times]
|
||||
assert warped == sorted(warped)
|
||||
|
||||
|
||||
# ── warp_song_times ───────────────────────────────────────────────────────────
|
||||
|
||||
def _shifted_double(t):
|
||||
return 2.0 * t + 1.0
|
||||
|
||||
|
||||
def test_warp_song_times_covers_all_time_fields():
|
||||
song = Song(
|
||||
song_length=100.0,
|
||||
beats=[Beat(time=0.0, measure=1), Beat(time=1.0, measure=-1)],
|
||||
sections=[Section(name="verse", number=1, start_time=10.0)],
|
||||
arrangements=[
|
||||
Arrangement(
|
||||
name="Lead",
|
||||
notes=[Note(time=2.0, string=0, fret=3, sustain=1.0)],
|
||||
chords=[
|
||||
Chord(
|
||||
time=4.0,
|
||||
chord_id=0,
|
||||
notes=[Note(time=4.0, string=1, fret=2, sustain=0.5)],
|
||||
)
|
||||
],
|
||||
anchors=[Anchor(time=6.0, fret=3)],
|
||||
hand_shapes=[HandShape(chord_id=0, start_time=4.0, end_time=5.0)],
|
||||
phrases=[
|
||||
Phrase(
|
||||
start_time=0.0,
|
||||
end_time=8.0,
|
||||
max_difficulty=0,
|
||||
levels=[
|
||||
PhraseLevel(
|
||||
difficulty=0,
|
||||
notes=[Note(time=3.0, string=0, fret=0, sustain=2.0)],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
tones={"base": "clean", "changes": [{"t": 7.0, "name": "lead"}]},
|
||||
tempos=[{"time": 0.0, "bpm": 120.0}],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
ga.warp_song_times(song, _shifted_double)
|
||||
|
||||
assert song.song_length == pytest.approx(201.0)
|
||||
assert [b.time for b in song.beats] == pytest.approx([1.0, 3.0])
|
||||
assert song.sections[0].start_time == pytest.approx(21.0)
|
||||
|
||||
arr = song.arrangements[0]
|
||||
n = arr.notes[0]
|
||||
assert n.time == pytest.approx(5.0)
|
||||
assert n.sustain == pytest.approx(2.0) # (2+1)*2+1 - 5
|
||||
ch = arr.chords[0]
|
||||
assert ch.time == pytest.approx(9.0)
|
||||
assert ch.notes[0].time == pytest.approx(9.0)
|
||||
assert ch.notes[0].sustain == pytest.approx(1.0)
|
||||
assert arr.anchors[0].time == pytest.approx(13.0)
|
||||
hs = arr.hand_shapes[0]
|
||||
assert (hs.start_time, hs.end_time) == (pytest.approx(9.0), pytest.approx(11.0))
|
||||
ph = arr.phrases[0]
|
||||
assert (ph.start_time, ph.end_time) == (pytest.approx(1.0), pytest.approx(17.0))
|
||||
assert ph.levels[0].notes[0].time == pytest.approx(7.0)
|
||||
assert ph.levels[0].notes[0].sustain == pytest.approx(4.0)
|
||||
assert arr.tones["changes"][0]["t"] == pytest.approx(15.0)
|
||||
assert arr.tempos[0]["time"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_warp_song_times_clamps_negative_sustain():
|
||||
# A non-monotonic warp callable must not produce negative sustains
|
||||
song = Song(arrangements=[
|
||||
Arrangement(name="Lead",
|
||||
notes=[Note(time=1.0, string=0, fret=0, sustain=1.0)])
|
||||
])
|
||||
ga.warp_song_times(song, lambda t: 5.0 - t) # decreasing map
|
||||
assert song.arrangements[0].notes[0].sustain == 0.0
|
||||
|
||||
|
||||
# ── gp_has_expandable_repeats ─────────────────────────────────────────────────
|
||||
|
||||
def test_gpif_files_never_expand_repeats(tmp_path):
|
||||
# GPIF conversion is single-pass as-written, so .gp/.gpx are always False
|
||||
gp = tmp_path / "song.gp"
|
||||
gp.write_bytes(_gpif_zip(_gpif([(0, 120.0)], ["4/4"])))
|
||||
assert ga.gp_has_expandable_repeats(str(gp)) is False
|
||||
|
||||
|
||||
def test_gp345_unparseable_returns_false(tmp_path):
|
||||
bad = tmp_path / "song.gp5"
|
||||
bad.write_bytes(b"not a real gp5 file")
|
||||
assert ga.gp_has_expandable_repeats(str(bad)) is False
|
||||
|
||||
|
||||
def test_gp345_repeats_detected(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
# Bar 2 of 3 opens a repeat
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
song.measureHeaders[1].isRepeatOpen = True
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "repeat.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is True
|
||||
|
||||
|
||||
def test_gp345_plain_song_no_repeats(tmp_path):
|
||||
guitarpro = pytest.importorskip("guitarpro")
|
||||
song = guitarpro.models.Song()
|
||||
track = guitarpro.models.Track(song)
|
||||
song.tracks = [track]
|
||||
for _ in range(2):
|
||||
header = guitarpro.models.MeasureHeader()
|
||||
song.addMeasureHeader(header)
|
||||
for header in song.measureHeaders:
|
||||
track.measures.append(guitarpro.models.Measure(track, header))
|
||||
path = tmp_path / "plain.gp5"
|
||||
guitarpro.write(song, str(path))
|
||||
assert ga.gp_has_expandable_repeats(str(path)) is False
|
||||
|
||||
|
||||
# ── refine_sync pure fallbacks ────────────────────────────────────────────────
|
||||
|
||||
def test_refine_sync_empty_points_returns_input():
|
||||
sync = GpSyncData(audio_offset=0.0, audio_asset_id="", sync_points=[])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
|
||||
|
||||
def test_refine_sync_single_point_returns_input():
|
||||
# One point → fewer than 2 warp anchors → unchanged, no audio load
|
||||
sync = GpSyncData(audio_offset=-1.0, audio_asset_id="",
|
||||
sync_points=[_sp(0, 1.0)])
|
||||
assert ga.refine_sync(sync, "/nonexistent.ogg") is sync
|
||||
@@ -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"]
|
||||
|
||||
|
||||
def test_difficulty_sort_pushes_unrated_to_bottom(client, server_mod):
|
||||
"""Personal difficulty (song_user_meta.user_difficulty) sorts like
|
||||
mastery: an unrated (NULL) row must fall to the bottom in BOTH
|
||||
directions rather than colliding with a real 1..5 rating at either
|
||||
end."""
|
||||
_put(server_mod, filename="easy.archive", title="Easy", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="hard.archive", title="Hard", artist="B",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="C",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("easy.archive", user_difficulty=1)
|
||||
server_mod.meta_db.set_song_user_meta("hard.archive", user_difficulty=5)
|
||||
|
||||
asc = [s["filename"] for s in _get(client, sort="difficulty")["songs"]]
|
||||
assert asc == ["easy.archive", "hard.archive", "unrated.archive"]
|
||||
|
||||
desc = [s["filename"] for s in _get(client, sort="difficulty-desc")["songs"]]
|
||||
assert desc == ["hard.archive", "easy.archive", "unrated.archive"]
|
||||
|
||||
|
||||
def test_tree_view_songs_carry_user_difficulty(client, server_mod):
|
||||
"""`/api/library/artists` (the classic tree view's `query_artists`) must
|
||||
batch-attach `user_difficulty` the same way `query_page` does for the
|
||||
grid — otherwise the tree view's difficulty badge silently never
|
||||
renders (song.user_difficulty stays undefined for every row)."""
|
||||
_put(server_mod, filename="rated.archive", title="Rated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("rated.archive", user_difficulty=4)
|
||||
|
||||
data = client.get("/api/library/artists").json()
|
||||
songs = data["artists"][0]["albums"][0]["songs"]
|
||||
by_filename = {s["filename"]: s for s in songs}
|
||||
assert by_filename["rated.archive"]["user_difficulty"] == 4
|
||||
assert by_filename["unrated.archive"]["user_difficulty"] is None
|
||||
|
||||
|
||||
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
|
||||
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
|
||||
must come before the up-tuned one so the order matches the chart's
|
||||
@@ -653,16 +614,3 @@ def test_artist_filter_is_case_insensitive(client, seeded):
|
||||
data = _get(client, artist="a band")
|
||||
assert data["total"] == 1
|
||||
assert data["songs"][0]["filename"] == "a.archive"
|
||||
|
||||
|
||||
def test_unmatched_flag_and_quick_filter(server_mod, client):
|
||||
# A per-card "no match" badge needs the row to carry the enrichment state.
|
||||
_put(server_mod, filename="a.archive", title="Matched", artist="A")
|
||||
_put(server_mod, filename="b.archive", title="Missed", artist="B")
|
||||
server_mod.meta_db.apply_enrichment_match("b.archive", "h", "failed") # no-match
|
||||
rows = {s["filename"]: s for s in server_mod.meta_db.query_page()[0]}
|
||||
assert rows["b.archive"]["unmatched"] is True
|
||||
assert rows["a.archive"]["unmatched"] is False
|
||||
# The "Unmatched" quick-filter (match=unmatched) returns only the failed song.
|
||||
fns = [s["filename"] for s in client.get("/api/library?match=unmatched").json()["songs"]]
|
||||
assert fns == ["b.archive"]
|
||||
|
||||
@@ -185,22 +185,6 @@ def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
assert row["mb_recording_id"] == "rec-jp"
|
||||
|
||||
|
||||
# ── per-song field locks respected by the auto-matcher ───────────────────────
|
||||
|
||||
def test_locked_field_not_canonicalized_by_auto_match(server, monkeypatch):
|
||||
_put(server, "x.sloppak") # title "Thunderstruck (v2)", artist "ACDC"
|
||||
server.meta_db.set_song_override("x.sloppak", "artist", locked=True)
|
||||
monkeypatch.setattr(server, "_mb_http_get",
|
||||
lambda path, params: {"recordings": [mb_doc()]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
assert row["match_state"] == "matched" # still matches (identity applies)…
|
||||
assert row["canon_artist"] is None # …but the LOCKED artist isn't canonicalized
|
||||
assert row["canon_title"] == "Thunderstruck" # unlocked display fields still apply
|
||||
assert row["mb_recording_id"] # identity keys still stored (art needs them)
|
||||
|
||||
|
||||
# ── offline safety (the pytest-never-hits-network contract) ──────────────────
|
||||
|
||||
def test_offline_default_skips_matching(server, monkeypatch):
|
||||
|
||||
@@ -277,56 +277,3 @@ def test_wire_format_shape(tmp_path):
|
||||
assert "anchors" in result
|
||||
assert "tuning" in result
|
||||
assert "capo" in result
|
||||
|
||||
|
||||
# ── non-positive division guard (legacy inline tempo path) ───────────────────
|
||||
|
||||
def test_zero_division_does_not_crash(tmp_path):
|
||||
"""A malformed header (ticks_per_beat == 0) must not raise ZeroDivisionError.
|
||||
|
||||
The legacy inline tempo map in convert_midi_track_to_keys_wire divides by
|
||||
ticks_per_beat at two sites; a 0 division falls back to the SMF default so
|
||||
the note is still emitted with a sane, non-negative time.
|
||||
"""
|
||||
mid = mido.MidiFile(ticks_per_beat=0)
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
# Note starts after a one-"beat" rest so a bad divisor would skew its start.
|
||||
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
|
||||
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
||||
|
||||
path = _save(mid, tmp_path)
|
||||
assert mido.MidiFile(path).ticks_per_beat == 0 # precondition: divisor is 0
|
||||
result = convert_midi_track_to_keys_wire(path, track_index=0)
|
||||
assert len(result["notes"]) == 1
|
||||
n = result["notes"][0]
|
||||
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
|
||||
assert n["t"] == pytest.approx(0.5)
|
||||
assert n["t"] >= 0.0
|
||||
assert n["sus"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_smpte_negative_division_produces_nonnegative_times(tmp_path):
|
||||
"""SMPTE division (mido returns a NEGATIVE ticks_per_beat) must not yield
|
||||
negative times through the legacy inline path.
|
||||
|
||||
``or 480`` would miss this (a negative value is truthy); the ``> 0`` guard
|
||||
falls back so the emitted note keeps a sane, non-negative start time.
|
||||
"""
|
||||
mid = mido.MidiFile()
|
||||
mid.ticks_per_beat = -1 # simulate a SMPTE / malformed signed-short division
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
track.append(mido.Message("note_on", channel=0, note=60, velocity=64, time=480))
|
||||
track.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=480))
|
||||
|
||||
path = _save(mid, tmp_path)
|
||||
assert mido.MidiFile(path).ticks_per_beat < 0 # precondition: negative divisor
|
||||
result = convert_midi_track_to_keys_wire(path, track_index=0)
|
||||
assert len(result["notes"]) == 1
|
||||
n = result["notes"][0]
|
||||
assert n["t"] >= 0.0
|
||||
assert n["sus"] >= 0.0
|
||||
# 480-tick fallback @ 120 BPM: one beat = 0.5 s.
|
||||
assert n["t"] == pytest.approx(0.5)
|
||||
assert n["sus"] == pytest.approx(0.5)
|
||||
|
||||
@@ -185,18 +185,18 @@ def test_unmapped_drum_note_skipped(tmp_path):
|
||||
|
||||
def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times + 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)
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=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_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
|
||||
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=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_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))
|
||||
|
||||
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).
|
||||
assert all(isinstance(t, float) for t in unmapped[56]["times"])
|
||||
assert len(unmapped[56]["times"]) == 2
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_non_channel9_events_ignored(tmp_path):
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
"""Tests for lib/midi_import.py — convert_midi_tempo_map.
|
||||
|
||||
The note converters always computed a tempo-aware tick→seconds map internally
|
||||
(to bake note times) and then threw it away — and never read time_signature
|
||||
meta at all — so every MIDI import landed with no bars, no measures, and an
|
||||
implied 4/4 regardless of the file. convert_midi_tempo_map extracts the grid:
|
||||
tempos, time signatures (song-timeline shape), and a full beat grid on the
|
||||
editor's row shape (numbered downbeats with a `den` hint, `-1` sub-beats).
|
||||
|
||||
Every test drives the REAL function against a real .mid built in-memory with
|
||||
mido and saved to tmp_path — no stubs, adversarial inputs included (type-2
|
||||
scoping, mid-bar signatures, duplicate meta ticks, empty files, long files
|
||||
for rounding drift).
|
||||
|
||||
Run: pytest tests/test_midi_tempo_map.py -v
|
||||
"""
|
||||
|
||||
import mido
|
||||
import pytest
|
||||
|
||||
from midi_import import _TEMPO_MAP_MAX_BARS, convert_midi_tempo_map
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _save(mid: mido.MidiFile, tmp_path, name: str = "t.mid") -> str:
|
||||
p = tmp_path / name
|
||||
mid.save(str(p))
|
||||
return str(p)
|
||||
|
||||
|
||||
def _note_pair(track, pitch=60, at=0, dur=240):
|
||||
track.append(mido.Message("note_on", note=pitch, velocity=90, time=at))
|
||||
track.append(mido.Message("note_off", note=pitch, velocity=0, time=dur))
|
||||
|
||||
|
||||
def _downbeats(result):
|
||||
return [b for b in result["beats"] if b["measure"] > 0]
|
||||
|
||||
|
||||
def _subbeats(result):
|
||||
return [b for b in result["beats"] if b["measure"] == -1]
|
||||
|
||||
|
||||
# ── the plain case ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_default_grid_is_120_bpm_four_four(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
_note_pair(tr, at=0, dur=480 * 8) # two 4/4 bars of content
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||
dbs = _downbeats(res)
|
||||
assert [d["measure"] for d in dbs] == [1, 2]
|
||||
assert [d["time"] for d in dbs] == [0.0, 2.0] # 4 beats at 0.5 s
|
||||
assert all(d["den"] == 4 for d in dbs)
|
||||
# 3 interior beats per full bar at 0.5 s spacing.
|
||||
assert [b["time"] for b in _subbeats(res)][:3] == [0.5, 1.0, 1.5]
|
||||
|
||||
|
||||
# ── tempo handling ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_tempo_change_bends_the_grid(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
meta.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
||||
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 8)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert [t["bpm"] for t in res["tempos"]] == [120.0, 240.0]
|
||||
dbs = _downbeats(res)
|
||||
# Bar 1 spans 2.0 s at 120; bar 2 starts at 2.0 and its beats halve.
|
||||
assert dbs[0]["time"] == 0.0 and dbs[1]["time"] == 2.0
|
||||
bar2_subs = [b["time"] for b in _subbeats(res) if b["time"] > 2.0]
|
||||
assert bar2_subs[:3] == [2.25, 2.5, 2.75]
|
||||
|
||||
def test_rounding_does_not_accumulate_over_a_long_file(tmp_path):
|
||||
# 500 bars at 120 BPM: beat times must stay exactly on the 0.5 s lattice
|
||||
# (absolute-tick computation — never beat N derived from beat N-1).
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
_note_pair(tr, at=0, dur=480 * 4 * 500)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
dbs = _downbeats(res)
|
||||
assert len(dbs) == 500
|
||||
assert dbs[-1]["time"] == pytest.approx((500 - 1) * 2.0, abs=0.0005)
|
||||
assert dbs[250]["time"] == pytest.approx(250 * 2.0, abs=0.0005)
|
||||
|
||||
|
||||
# ── time signatures (the previously-unread meta) ─────────────────────────────
|
||||
|
||||
def test_time_signature_changes_shape_the_bars(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 4))
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 10) # 4/4 bar + two 3/4 bars
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert [s["ts"] for s in res["time_signatures"]] == [[4, 4], [3, 4]]
|
||||
dbs = _downbeats(res)
|
||||
assert [d["time"] for d in dbs] == [0.0, 2.0, 3.5] # 3/4 bars are 1.5 s
|
||||
# Bar 2 has exactly two interior beats.
|
||||
bar2 = [b for b in res["beats"] if 2.0 < b["time"] < 3.5]
|
||||
assert [b["measure"] for b in bar2] == [-1, -1]
|
||||
|
||||
def test_six_eight_uses_eighth_note_rows(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=6, denominator=8, time=0))
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 3) # one full 6/8 bar
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
dbs = _downbeats(res)
|
||||
assert dbs[0]["den"] == 8
|
||||
bar1 = [b["time"] for b in res["beats"] if b["time"] < 1.5]
|
||||
# Six eighth-note rows at 120 BPM (quarter = 0.5 s ⇒ eighth = 0.25 s).
|
||||
assert bar1 == [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]
|
||||
|
||||
def test_mid_bar_signature_applies_at_the_next_boundary(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
# Ill-formed: 3/4 lands halfway through bar 1.
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 2))
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 8)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
dbs = _downbeats(res)
|
||||
# Bar 1 stays 4/4 (2.0 s); bar 2 onward is 3/4.
|
||||
assert dbs[0]["time"] == 0.0 and dbs[0]["den"] == 4
|
||||
# Bar 2 is the 3/4 bar, but its denominator is still 4 (3 quarter notes).
|
||||
assert dbs[1]["time"] == 2.0 and dbs[1]["den"] == 4
|
||||
assert dbs[2]["time"] - dbs[1]["time"] == pytest.approx(1.5, abs=0.002)
|
||||
|
||||
def test_duplicate_signature_ticks_last_wins(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
||||
meta.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 4)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert res["time_signatures"][-1]["ts"] == [7, 8]
|
||||
assert _downbeats(res)[0]["den"] == 8
|
||||
|
||||
|
||||
# ── SMF type scoping (adversarial) ───────────────────────────────────────────
|
||||
|
||||
def test_type2_reads_meta_from_the_chosen_track_only(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480, type=2)
|
||||
bogus = mido.MidiTrack(); mid.tracks.append(bogus)
|
||||
bogus.append(mido.MetaMessage("set_tempo", tempo=100000, time=0)) # 600 BPM
|
||||
bogus.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
||||
_note_pair(bogus, at=0, dur=480)
|
||||
real = mido.MidiTrack(); mid.tracks.append(real)
|
||||
real.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120 BPM
|
||||
_note_pair(real, at=0, dur=480 * 4)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path), track_index=1)
|
||||
# The bogus track's 600 BPM / 7-8 never leak into track 1's grid.
|
||||
assert [t["bpm"] for t in res["tempos"]] == [120.0]
|
||||
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||
assert _downbeats(res)[0]["den"] == 4
|
||||
|
||||
|
||||
# ── degenerate inputs ────────────────────────────────────────────────────────
|
||||
|
||||
def test_empty_file_yields_empty_beats_but_valid_shape(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
mid.tracks.append(mido.MidiTrack())
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert res["beats"] == []
|
||||
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||
|
||||
def test_grid_covers_all_notes_and_stops_after_them(tmp_path):
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
_note_pair(tr, at=480 * 5, dur=480) # note inside bar 2 only
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
dbs = _downbeats(res)
|
||||
assert dbs[0]["time"] == 0.0, "grid starts at zero (SMF convention)"
|
||||
assert dbs[-1]["measure"] == 2
|
||||
assert all(b["time"] <= 3.0 + 1e-9 for b in res["beats"]), \
|
||||
"no beats past the end of musical content"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("division", [0, -1, -25600])
|
||||
def test_non_positive_division_header_does_not_crash(tmp_path, division):
|
||||
# A malformed header reloads with ticks_per_beat == 0; a true SMPTE-division
|
||||
# file reloads negative (mido reads the division as a signed short). Either
|
||||
# way the tick→seconds closure would divide by a non-positive number —
|
||||
# raising ZeroDivisionError (0) or walking off into negative times
|
||||
# (negative) — without the header fallback. The grid must still come out on
|
||||
# a sane, bounded 4/4 / 120-BPM default.
|
||||
mid = mido.MidiFile(ticks_per_beat=division)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
_note_pair(tr, at=0, dur=480 * 8)
|
||||
assert mido.MidiFile(_save(mid, tmp_path)).ticks_per_beat == division # precondition
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||
dbs = _downbeats(res)
|
||||
assert [d["measure"] for d in dbs] == [1, 2]
|
||||
assert all(isinstance(b["time"], float) and b["time"] >= 0.0
|
||||
for b in res["beats"])
|
||||
|
||||
|
||||
def test_first_tempo_after_start_seeds_default_120_at_zero(tmp_path):
|
||||
# First (and only) set_tempo lands at bar 2. The head of the song already
|
||||
# played at the MIDI default of 120 BPM, so the tempos sidecar must open
|
||||
# with a 120-BPM row at time 0 — symmetric with the 4/4 signature default.
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
||||
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||
_note_pair(notes, at=0, dur=480 * 8)
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert res["tempos"][0] == {"time": 0.0, "bpm": 120.0}
|
||||
assert res["tempos"][1] == {"time": 2.0, "bpm": 240.0}
|
||||
# The seeded default actually matches the grid the head of the song used.
|
||||
assert _downbeats(res)[0]["time"] == 0.0
|
||||
|
||||
|
||||
def test_type0_single_track_carries_tempo_timesig_and_notes(tmp_path):
|
||||
# Explicit SMF format 0: one track holds tempo + signature + notes.
|
||||
mid = mido.MidiFile(ticks_per_beat=480, type=0)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
tr.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
||||
tr.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=0))
|
||||
_note_pair(tr, at=0, dur=480 * 6) # two 3/4 bars
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
assert mido.MidiFile(_save(mid, tmp_path)).type == 0 # precondition
|
||||
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||
assert res["time_signatures"] == [{"time": 0.0, "ts": [3, 4]}]
|
||||
dbs = _downbeats(res)
|
||||
assert [d["measure"] for d in dbs] == [1, 2]
|
||||
assert [d["time"] for d in dbs] == [0.0, 1.5] # 3/4 bar = 1.5 s at 120
|
||||
assert all(d["den"] == 4 for d in dbs)
|
||||
|
||||
|
||||
def test_max_bars_safety_valve_caps_the_walk(tmp_path):
|
||||
# A note one bar past the cap must not blow the walk past its ceiling.
|
||||
mid = mido.MidiFile(ticks_per_beat=480)
|
||||
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||
_note_pair(tr, at=0, dur=480 * 4 * (_TEMPO_MAP_MAX_BARS + 1))
|
||||
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||
dbs = _downbeats(res)
|
||||
assert len(dbs) == _TEMPO_MAP_MAX_BARS
|
||||
assert dbs[-1]["measure"] == _TEMPO_MAP_MAX_BARS
|
||||
@@ -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
|
||||
@@ -219,47 +219,6 @@ def test_dlc_dir_empty_string_clears(client, tmp_path):
|
||||
assert _read_cfg(tmp_path)["dlc_dir"] == ""
|
||||
|
||||
|
||||
def test_unresolvable_dlc_dir_does_not_block_other_keys(client, tmp_path):
|
||||
# Regression (feedBack-demucs-server#3): the v3 "Save" button next to the
|
||||
# Demucs field bundles dlc_dir with demucs_server_url in one POST. A DLC
|
||||
# path that doesn't resolve on THIS machine (stale value / unplugged drive)
|
||||
# must not abort the whole request — the co-submitted demucs_server_url has
|
||||
# to persist, and the bad path is surfaced as a warning rather than a hard
|
||||
# error that drops every other key.
|
||||
missing = str(tmp_path / "does-not-exist")
|
||||
r = client.post("/api/settings", json={
|
||||
"dlc_dir": missing,
|
||||
"demucs_server_url": "http://demucs.example:7865",
|
||||
"default_arrangement": "Lead",
|
||||
})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# No hard error; the bad path is reported as a warning.
|
||||
assert "error" not in body
|
||||
assert any("does-not-exist" in w for w in body.get("warnings", []))
|
||||
assert "does-not-exist" in body["message"]
|
||||
cfg = _read_cfg(tmp_path)
|
||||
# The valid keys persisted...
|
||||
assert cfg["demucs_server_url"] == "http://demucs.example:7865"
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
# ...and the unresolvable path was NOT written.
|
||||
assert cfg.get("dlc_dir", "") != missing
|
||||
|
||||
|
||||
def test_valid_dlc_dir_still_reports_song_count(client, tmp_path):
|
||||
# The happy path is unchanged: a resolvable DLC dir persists and the
|
||||
# response message still carries the "N song files found" summary (no
|
||||
# warnings key when nothing went wrong).
|
||||
dlc = tmp_path / "dlc"
|
||||
dlc.mkdir()
|
||||
r = client.post("/api/settings", json={"dlc_dir": str(dlc)})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "warnings" not in body
|
||||
assert "song files found" in body["message"]
|
||||
assert _read_cfg(tmp_path)["dlc_dir"] == str(dlc)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["default_arrangement", "demucs_server_url"])
|
||||
def test_string_key_null_is_noop(client, tmp_path, key):
|
||||
# Match the dlc_dir contract: null preserves the on-disk value.
|
||||
|
||||
Reference in New Issue
Block a user