mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 20:24:30 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cb3e4eaae | ||
|
|
233f5280e8 | ||
|
|
950e348357 | ||
|
|
a18a818e8b | ||
|
|
5cb4ea0623 | ||
|
|
fadaa154e9 | ||
|
|
e446b05a99 | ||
|
|
115c3529e9 | ||
|
|
1bccb8a9e8 | ||
|
|
010edc239b | ||
|
|
9fb63fd3b5 | ||
|
|
cb72c5ab34 | ||
|
|
92e78be62d | ||
|
|
1840170e95 | ||
|
|
7cbf9824b1 | ||
|
|
33146cc7f6 | ||
|
|
69c8ad4e0c | ||
|
|
a20dca21bb | ||
|
|
021ee55f2a | ||
|
|
1f621e5fe5 | ||
|
|
612b1f2e0d | ||
|
|
4f6dc233f1 | ||
|
|
5be70939e4 | ||
|
|
6aaa2dcf47 | ||
|
|
1a8540935b | ||
|
|
d567fd5597 | ||
|
|
b914612f9d | ||
|
|
de002cdc24 | ||
|
|
3100d68a45 | ||
|
|
6397a959a4 | ||
|
|
5f499a8a3a | ||
|
|
af1170cec3 | ||
|
|
3e036e3db6 | ||
|
|
92dc321fdf | ||
|
|
74cff4e0d6 | ||
|
|
18c4e229e1 | ||
|
|
bde25c0bc8 | ||
|
|
c7aa5a10b0 | ||
|
|
fa2d12222a | ||
|
|
73c5ab149e | ||
|
|
a65d8cfa13 | ||
|
|
a86abadb14 | ||
|
|
41e907fa52 | ||
|
|
2c1c6f7eac | ||
|
|
b6169af6aa | ||
|
|
a3f1bceb15 | ||
|
|
c2153b277b |
@@ -123,3 +123,28 @@ jobs:
|
||||
sys.exit(1)
|
||||
print(f"Validated {len(manifests)} manifest(s) — OK")
|
||||
EOF
|
||||
|
||||
lint:
|
||||
# Maintainer/CI-only size + module-hygiene gate (constitution Principle I:
|
||||
# dev tooling, never on the serve/Docker path — same category as
|
||||
# scripts/build-tailwind.sh). max-lines WARNS (the 1,500-line size ratchet;
|
||||
# non-blocking), while import-x/no-unresolved + no-cycle HARD-ERROR on the
|
||||
# ES-module graphs the refactor produces. Exemptions: docs/size-exemptions.md.
|
||||
name: lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# npm ci runs third-party postinstall scripts; don't leave the token in
|
||||
# git config for them (this job never pushes).
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: ESLint (size norm + module hygiene)
|
||||
run: npm run lint
|
||||
|
||||
@@ -6,7 +6,7 @@ name: Nightly
|
||||
# builds from release/** come from rc.yml instead.
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * *'
|
||||
- cron: '0 23 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -48,11 +48,30 @@ output is committed, so Docker / desktop / end users never build. New
|
||||
features extend `app.js` and the existing globals (`window.playSong`,
|
||||
`window.showScreen`, `window.createHighway`, `window.feedBack`).
|
||||
|
||||
Native ES modules are a first-class, build-free extension mechanism.
|
||||
Because `<script type="module">` and `import` are browser features — not
|
||||
a bundler — a large source file MAY be split into an `import`-ed module
|
||||
graph of plain source files, with **no build step and no framework**. A
|
||||
plugin opts in with `"scriptType": "module"` in `plugin.json`: its
|
||||
`screen.js` becomes a one-line `import './src/main.js'`, and the host
|
||||
serves the `src/` subtree from the sandboxed `/api/plugins/<id>/src/…`
|
||||
route and injects the entry as `<script type="module">`. The classic
|
||||
global-scope `screen.js` path remains fully supported; both coexist, and
|
||||
module scripts are still source-served — the no-bundler, no-transpiler,
|
||||
build-free-at-serve rule is unchanged. Core's own `static/` tree may
|
||||
migrate to the same module-graph shape (`static/js/…`) over time under
|
||||
this rule.
|
||||
|
||||
**Non-negotiable rules**
|
||||
|
||||
- Do not introduce a frontend framework, JSX, or a JS build pipeline in
|
||||
core. Plugins MAY ship their own bundled assets but core MUST remain
|
||||
source-served.
|
||||
- ES-module plugins remain source-served: no bundler or transpiler, and
|
||||
their own asset URLs (worklets, WASM, images) resolve via
|
||||
`import.meta.url` — never `document.currentScript`, which is `null`
|
||||
inside a module. `scriptType:"module"` and the optional `minHost`
|
||||
version floor are the only new `plugin.json` keys the module path adds.
|
||||
- Because the core Tailwind stylesheet is prebuilt, it contains only the
|
||||
classes present in core source at build time. Core's committed
|
||||
`static/tailwind.min.css` MUST stay in sync with source — CI enforces
|
||||
@@ -214,6 +233,15 @@ no `..`, no absolute paths).
|
||||
runs first). Plugins MUST tolerate dependent globals being absent
|
||||
at load time and check at runtime
|
||||
(`typeof window.X === 'function'`).
|
||||
- **Module load contract**: a `scriptType:"module"` plugin is injected
|
||||
as `<script type="module">`, whose load event fires only after its
|
||||
whole static-import graph fetches and evaluates — so the loader's
|
||||
completion-by-`onload` guarantee (and the `playSong` wrapper-chain
|
||||
order above) is preserved exactly. The host loads `screen.js` once per
|
||||
version and `showScreen` re-injects nothing, so a plugin's per-visit
|
||||
re-initialization comes from its `screen:changed` handler, not from
|
||||
screen.js re-running; ES-module plugins inherit this unchanged (module
|
||||
top-level code does not re-execute on same-version re-mount).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -256,4 +284,4 @@ no `..`, no absolute paths).
|
||||
higher-numbered principle's escape hatch is to live in a plugin
|
||||
with its own bundled assets.
|
||||
|
||||
**Version**: 1.1.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-06-01
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-07-08
|
||||
|
||||
@@ -8,12 +8,47 @@ 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`).
|
||||
- **Edit Metadata now writes into `.feedpak` files, not just legacy `.sloppak` ones.** `lib/songmeta.py`'s suffix gate predated the format rename — core reads both suffixes everywhere else (`sloppak.SONG_EXTS`), but the metadata writer only dispatched on `.sloppak`, so editing a zip-form `.feedpak`'s title/artist/album/year silently fell back to a DB-only update. That looked fine until the next **full library rescan** re-derived metadata from the file and reverted the edit (directory-form packages were unaffected — they dispatch on manifest presence, not suffix). The gate now accepts both package suffixes. Tests: `tests/test_songmeta.py` `TestWriteSongMetadata` (both zip suffixes, mixed-case suffix, directory form, unknown-suffix fallback).
|
||||
- **3D Drum & Keys highways now re-frame on fullscreen/layout drift under splitscreen.** The guitar/bass `highway_3d` self-detects when its panel canvas changes size and re-runs `applySize()` every frame, because the splitscreen host overrides `hw.resize` and never calls `renderer.resize()`. The drum and keys highways lacked that fallback — they only re-framed when the host explicitly called `resize(w, h)` — so their panels stayed framed for the pre-fullscreen size while the guitar/bass panels adapted (visible as a too-small, off-center highway after maximizing a split-screen session). Both draw loops now port `highway_3d`'s per-frame drift check: they re-apply on backing-store change (`canvas.width/height`) AND on CSS-box drift (`clientWidth/clientHeight` vs the last applied logical size, throttled to every 10th frame), and reset the tracking in `destroy()` so a reused instance re-frames on the next song. `plugins/drum_highway_3d` → 0.3.1, `plugins/keys_highway_3d` → 0.1.1. Tests: `tests/js/drum_keys_highway_3d_resize_reframe.test.js`.
|
||||
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
|
||||
|
||||
@@ -117,6 +117,8 @@ Notes:
|
||||
|
||||
**Frontend scripts** — `screen.js` runs in the global scope via a `<script>` tag. It can access `window.playSong`, `window.showScreen`, `window.createHighway`, the `<audio>` element, and the `window.feedBack` event emitter.
|
||||
|
||||
**ES-module plugins (`scriptType:"module"`)** — a plugin may instead ship a native ES-module graph with **no build step**: set `"scriptType": "module"` in `plugin.json`, make `screen.js` a one-line `import './src/main.js'`, and put the module tree under `src/` (served by the sandboxed `/api/plugins/<id>/src/{path}` route). The host injects it as `<script type="module">`, whose `onload` fires only after the whole static-import graph evaluates — so the loader's completion-by-`onload` + `_loadingPluginId` + `playSong` wrapper-chain ordering all hold. Resolve your own asset URLs (worklets, WASM) with `import.meta.url` — `document.currentScript` is `null` in a module. Module top-level code does **not** re-run when the user re-enters the screen at the same version (the host loads screen.js once and `showScreen` re-injects nothing), so keep per-visit re-init in a `screen:changed` handler, exactly as classic plugins do. Classic global-scope `screen.js` remains fully supported. See `docs/plugin-modules.md`.
|
||||
|
||||
**The playSong wrapper chain** — Plugins commonly wrap `window.playSong` to hook into song playback. Plugins load alphabetically, so the last-loaded (alphabetically later) wrapper runs first, while the alphabetically first plugin runs closest to the original. Be aware that `await` calls in inner wrappers yield to the event loop — WebSocket messages can arrive before outer wrappers finish setup.
|
||||
|
||||
## Plugin Best Practices
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Plugin ES-module migration playbook
|
||||
|
||||
How to move a plugin off a single global-scope `screen.js` IIFE onto a native
|
||||
ES-module graph — **no build step, no framework, no bundler**. This is the
|
||||
mechanism the monolith-killing refactor uses; the host rails for it shipped in
|
||||
R0 (see `.specify/memory/constitution.md` Principle II + the "Module load
|
||||
contract" in Operating Constraints).
|
||||
|
||||
## The shape
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json + "scriptType": "module" ← opt in
|
||||
screen.js import './src/main.js'; ← the entire file
|
||||
src/
|
||||
state.js (0) module state + accessors
|
||||
util/… (1) pure helpers — real-import testable
|
||||
…/… (2..4) model → render/audio/io → input
|
||||
globals.js (5) THE ONLY file that writes window.*
|
||||
main.js (5) boot: wire modules, register screen:changed
|
||||
assets/… worklets / WASM / images (unchanged, served as today)
|
||||
```
|
||||
|
||||
`screen.js` becomes a one-line static `import`. The host injects it as
|
||||
`<script type="module">`, whose load event fires **only after the whole
|
||||
static-import graph fetches and evaluates** — so the loader's
|
||||
completion-by-`onload` + `_loadingPluginId` window + `playSong` wrapper-chain
|
||||
order are all preserved. (A classic IIFE that fired a fire-and-forget
|
||||
`import()` would break that contract — don't do that; use `scriptType:"module"`.)
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
1. **Source-served, no build.** Modules are plain source files fetched from
|
||||
`/api/plugins/<id>/src/<path>`. No bundler, transpiler, or TypeScript.
|
||||
2. **Layering points downward** — `state → util → commands/model →
|
||||
render/audio/io → input → globals/main`. A lint check (`import-x/no-cycle`)
|
||||
enforces acyclicity; extract bottom-up so each move only imports
|
||||
already-extracted layers.
|
||||
3. **`globals.js` is the only writer of `window.*`.** The deliberate global
|
||||
surface shrinks to one auditable file; everything else is module-scoped.
|
||||
4. **Import-time purity.** `node --test` runs a module's top-level code on
|
||||
import, so a module you want to unit-test must be side-effect-free at import:
|
||||
no `document` / `window` / `localStorage` at module top level — lift init
|
||||
into an exported `init()` called by `main.js`. (Constitution Principle V's
|
||||
"no implicit IO at import time", applied to the frontend.) Tests are `.mjs`
|
||||
and use real `import`, retiring the regex/`extractFunction` harness.
|
||||
5. **Assets resolve via `import.meta.url`.** `document.currentScript` is `null`
|
||||
inside a module. `assets/` lives at the plugin root, so a `src/` module must
|
||||
climb out of `src/`: from `src/main.js`, `new URL('../assets/x.js',
|
||||
import.meta.url)` (deeper modules need more `../`). Simpler and
|
||||
depth-independent: the absolute route `/api/plugins/<id>/assets/x.js`.
|
||||
Worklets run in a *separate* module graph (`AudioWorkletGlobalScope`) and
|
||||
cannot share modules with `src/`.
|
||||
6. **Re-init comes from `screen:changed`, not re-execution.** The host loads
|
||||
`screen.js` once per version and `showScreen` re-injects nothing, so module
|
||||
top-level code does **not** re-run when the user re-enters the screen at the
|
||||
same version. Keep per-visit setup/teardown in a `window.feedBack.on(
|
||||
'screen:changed', …)` handler — exactly as classic plugins (tuner,
|
||||
minigames) already do. Do not rely on the IIFE re-running.
|
||||
7. **Inline `onclick=` keeps working** during migration via `globals.js` (which
|
||||
keeps every referenced symbol on `window`); retire inline handlers to
|
||||
module-side `addEventListener` opportunistically, never as a blocking step.
|
||||
|
||||
## The live-edit loop
|
||||
|
||||
The host serves `screen.js`, `src/**`, and `assets/**` with
|
||||
`Cache-Control: no-cache` + a weak `ETag` and honors `If-None-Match` → `304`.
|
||||
So: edit a `src/` file → **refresh the browser** → the edited module returns
|
||||
`200` and reloads while every unchanged module `304`s. There is no hot-reload;
|
||||
the loop is edit → refresh → see change, exactly as before. The `?v=<version>`
|
||||
query on `screen.js` is the legacy version buster; it does **not** propagate
|
||||
into the `src/` graph and does not need to — ETag/mtime is the correctness
|
||||
authority for the whole graph.
|
||||
|
||||
## Host-version floor (`minHost`)
|
||||
|
||||
A migrated plugin *requires* a host new enough to serve `src/` and inject
|
||||
`type=module`. Declare the floor with `"minHost": "X.Y.Z"` in `plugin.json`.
|
||||
(R0 plumbs the field through `/api/plugins`; enforcement — refuse-with-message
|
||||
on an older host — is deferred, so bundled plugins are unaffected. Community
|
||||
plugins should state the floor and not migrate below it.)
|
||||
|
||||
## Migration mechanics
|
||||
|
||||
- **Move-only PRs.** One slice extracts one module: cut code, add
|
||||
imports/exports, update `globals.js` — zero behavior change. Behavior fixes
|
||||
are separate PRs. (Init-lifts for import purity are the one non-pure move —
|
||||
budget them.)
|
||||
- **Bottom-up, layer by layer.** Within a layer, independent modules are
|
||||
independent PRs (a DAG, not a chain); use a git worktree per branch.
|
||||
- Tests move with their subject and convert to real `.mjs` imports in the same
|
||||
PR (assertions unchanged).
|
||||
- Size norm: no source file over **1,500 lines**; legitimate exceptions
|
||||
(hot renderers, etc.) go in the signed register at `docs/size-exemptions.md`.
|
||||
|
||||
## Verifying a migration
|
||||
|
||||
`node --test <plugin>/tests/*.mjs`; load the plugin on the `:8000` testbed and
|
||||
confirm it boots (`<script type=module>` in DevTools, the `src/` graph in
|
||||
Network); edit a `src/` file → refresh → change visible (`200` on the edited
|
||||
file, `304` on the rest); leave and re-enter the screen at the same version →
|
||||
it re-inits via `screen:changed`. The R1 pilots (stems, then studio) certify
|
||||
this end-to-end before the flagship repos migrate.
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,66 @@
|
||||
// Flat ESLint config — MAINTAINER / CI ONLY. Never runs on the serve or Docker
|
||||
// path (constitution Principle I: dev-only tooling is exempt, same category as
|
||||
// scripts/build-tailwind.sh). It enforces the module-migration guardrails:
|
||||
//
|
||||
// * max-lines — the 1,500-line size norm, as a WARNING ratchet. Legacy
|
||||
// monoliths warn (the "this is over the norm, split it" signal) and shrink
|
||||
// as the refactor lands; warnings do not fail CI. Genuinely-large files are
|
||||
// exempted below, mirroring the signed register in docs/size-exemptions.md.
|
||||
// * import-x/no-unresolved + no-cycle — module hygiene, scoped to the real
|
||||
// ES-module graphs the refactor produces (a plugin's src/ tree, .mjs
|
||||
// tests). no-unresolved (a HARD error) catches broken import paths;
|
||||
// no-cycle enforces the downward-only layering rule. Core's classic scripts
|
||||
// have no import graph, so both are dormant today and become live gates the
|
||||
// moment module code appears — validated against the first real module
|
||||
// plugin (R1 pilot).
|
||||
|
||||
const importX = require('eslint-plugin-import-x');
|
||||
|
||||
// Per-file size ceilings — a mirror of docs/size-exemptions.md (canonical).
|
||||
// Keep in sync; each entry corresponds to a signed row in the register.
|
||||
const SIZE_EXEMPTIONS = [
|
||||
{ files: ['**/static/capabilities.js'], max: 1600 },
|
||||
{ files: ['**/plugins/capability_inspector/screen.js'], max: 100000 },
|
||||
{ files: ['**/plugins/folder_library/screen.js'], max: 100000 },
|
||||
];
|
||||
|
||||
const sizeRule = (max) => ['warn', { max, skipBlankLines: false, skipComments: false }];
|
||||
|
||||
module.exports = [
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'static/vendor/**',
|
||||
'plugins/**/assets/vendor/**',
|
||||
'**/*.min.js',
|
||||
'static/tailwind.min.css',
|
||||
],
|
||||
},
|
||||
// Size norm across all first-party JS. Classic scripts are parsed as
|
||||
// scripts (no import/export); module files get their own block below.
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'script' },
|
||||
rules: { 'max-lines': sizeRule(1500) },
|
||||
},
|
||||
// ES-module graphs (a plugin's src/ tree, .mjs tests): module parsing + the
|
||||
// acyclic-imports hard gate + the size norm. A migrated bundled plugin's
|
||||
// entry `import './src/main.js'` screen.js must parse as a module — add its
|
||||
// glob here in that plugin's migration PR (classic screen.js stays a script).
|
||||
{
|
||||
files: ['**/src/**/*.js', '**/*.mjs'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: { 'import-x': importX },
|
||||
// v4 flat-config resolver (resolver-next + createNodeResolver). Without
|
||||
// it the import rules silently skip imports they can't resolve.
|
||||
settings: { 'import-x/resolver-next': [importX.createNodeResolver()] },
|
||||
rules: {
|
||||
'max-lines': sizeRule(1500),
|
||||
'import-x/no-unresolved': 'error',
|
||||
'import-x/no-cycle': 'error',
|
||||
},
|
||||
},
|
||||
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
|
||||
// registered files don't warn below it.
|
||||
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
|
||||
];
|
||||
@@ -0,0 +1,152 @@
|
||||
"""AcoustID audio-fingerprint identification for MusicBrainz enrichment.
|
||||
|
||||
A flat MusicBrainz *text* search ties every take of a song at the same score —
|
||||
studio, a dozen live bootlegs, and every compilation — so "AC/DC — Highway to
|
||||
Hell" returns junk (see lib/mb_match.py's canonical re-ranking, which mitigates
|
||||
it). The definitive fix is content-based: fingerprint the actual audio with
|
||||
Chromaprint (`fpcalc`) and look it up on AcoustID, which maps the fingerprint
|
||||
straight to the *exact* MusicBrainz recording — the same approach Lidarr uses.
|
||||
|
||||
This module is the PURE half (no network, no subprocess): response parsing +
|
||||
config gating, so it is unit-testable in isolation. server.py owns the `fpcalc`
|
||||
subprocess and the throttled HTTP GET to api.acoustid.org.
|
||||
|
||||
Operational requirements (both optional — absent ⇒ this path is a graceful
|
||||
no-op and the text matcher still runs):
|
||||
* `fpcalc` (Chromaprint) on PATH or at $FPCALC — generates the fingerprint.
|
||||
* an AcoustID application API key in $ACOUSTID_API_KEY — free from
|
||||
https://acoustid.org/new-application ; AcoustID etiquette limits to ~3 req/s.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
ACOUSTID_API_ROOT = "https://api.acoustid.org/v2"
|
||||
|
||||
# The `meta` fields we ask AcoustID to return so a hit resolves to displayable
|
||||
# metadata without a second MusicBrainz round-trip. SPACE-separated, not
|
||||
# `+`-joined: a literal `+` in the value gets percent-encoded to %2B, which
|
||||
# AcoustID does NOT split into flags — it then attaches no recording metadata
|
||||
# and every hit comes back empty (verified: `+` → 0 recordings, space → 28).
|
||||
# `releases` is what carries the per-release DATE (nested under each
|
||||
# releasegroup), which we need to pick the earliest original album + fill year.
|
||||
LOOKUP_META = "recordings releasegroups releases compress"
|
||||
|
||||
# Mirror mb_match._SECONDARY_SKIP: release-group secondary types that mark a
|
||||
# non-canonical (live/comp/remix) release, so we can flag the studio take.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
|
||||
def api_key(explicit: str | None = None) -> str:
|
||||
"""The AcoustID application API key: an explicit value (e.g. a host setting)
|
||||
wins, else $ACOUSTID_API_KEY, else "" (⇒ fingerprinting disabled)."""
|
||||
return (explicit or os.environ.get("ACOUSTID_API_KEY") or "").strip()
|
||||
|
||||
|
||||
def is_configured(explicit_key: str | None = None) -> bool:
|
||||
"""True when an API key is available. `fpcalc` presence is checked by
|
||||
server.py (it owns the binary lookup); both are required to actually run."""
|
||||
return bool(api_key(explicit_key))
|
||||
|
||||
|
||||
def _rg_is_studio(rg: dict) -> bool:
|
||||
if str(rg.get("type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondarytypes") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _rg_earliest_year(rg: dict) -> "int | None":
|
||||
"""Earliest release YEAR in a release-group (min over its nested releases'
|
||||
dates). None when no release carries a date. This is what separates the
|
||||
original pressing from later reissues/comps sharing the same group."""
|
||||
years = []
|
||||
for rel in (rg.get("releases") or []):
|
||||
d = (rel or {}).get("date")
|
||||
if isinstance(d, dict) and d.get("year"):
|
||||
try:
|
||||
years.append(int(d["year"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return min(years) if years else None
|
||||
|
||||
|
||||
def _best_group(recording: dict) -> dict:
|
||||
"""Pick the display album: a clean studio Album first, and among those the
|
||||
EARLIEST-released one — the original, not a later reissue or a compilation
|
||||
that happens to be typed 'Album' (e.g. a soundtrack). This is what pulls
|
||||
"Machine Head" ahead of a later comp for "Smoke on the Water". Falls back to
|
||||
the first group when nothing is a studio album or nothing carries a date."""
|
||||
groups = [g for g in (recording.get("releasegroups") or []) if isinstance(g, dict)]
|
||||
if not groups:
|
||||
return {}
|
||||
|
||||
def sort_key(g):
|
||||
yr = _rg_earliest_year(g)
|
||||
# studio (0) before non-studio (1); then earliest year (undated last).
|
||||
return (0 if _rg_is_studio(g) else 1, yr if yr is not None else 9999)
|
||||
|
||||
return sorted(groups, key=sort_key)[0]
|
||||
|
||||
|
||||
def _first_artist(recording: dict) -> str:
|
||||
for a in (recording.get("artists") or []):
|
||||
if isinstance(a, dict) and a.get("name"):
|
||||
return str(a["name"])
|
||||
return ""
|
||||
|
||||
|
||||
def parse_lookup_response(body: dict) -> list[dict]:
|
||||
"""Normalize an AcoustID /v2/lookup response into the same flat candidate
|
||||
shape as mb_match (recording_id / title / artist / album / year / duration /
|
||||
studio / mb_score / score), so the review UI and the editor's Match popup
|
||||
render fingerprint hits and text hits identically. `mb_score` carries the
|
||||
AcoustID confidence (0-100) — a fingerprint hit is high-signal by nature."""
|
||||
if not isinstance(body, dict) or body.get("status") != "ok":
|
||||
return []
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for result in (body.get("results") or []):
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
try:
|
||||
score = float(result.get("score") or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
score = 0.0
|
||||
for rec in (result.get("recordings") or []):
|
||||
if not isinstance(rec, dict) or not rec.get("id"):
|
||||
continue
|
||||
rid = str(rec["id"])
|
||||
if rid in seen:
|
||||
continue
|
||||
seen.add(rid)
|
||||
rg = _best_group(rec)
|
||||
_yr = _rg_earliest_year(rg)
|
||||
year = str(_yr) if _yr else ""
|
||||
dur = rec.get("duration")
|
||||
try:
|
||||
duration = int(round(float(dur))) if dur else None
|
||||
except (TypeError, ValueError):
|
||||
duration = None
|
||||
out.append({
|
||||
"recording_id": rid,
|
||||
"title": str(rec.get("title", "") or ""),
|
||||
"artist": _first_artist(rec),
|
||||
"album": str(rg.get("title", "") or ""),
|
||||
"year": year,
|
||||
"duration": duration,
|
||||
"isrc": "",
|
||||
"genres": [],
|
||||
"studio": _rg_is_studio(rg),
|
||||
"acoustid_score": round(score, 4),
|
||||
# Fingerprint hits are content-verified, not text-guessed — carry
|
||||
# the AcoustID confidence as the display score band.
|
||||
"mb_score": int(round(score * 100)),
|
||||
"score": round(score, 4),
|
||||
"source": "acoustid",
|
||||
})
|
||||
# Best AcoustID confidence first; studio take breaks ties.
|
||||
out.sort(key=lambda c: (c["acoustid_score"], 1 if c["studio"] else 0), reverse=True)
|
||||
return out
|
||||
+43
-12
@@ -1114,7 +1114,7 @@ def _build_xml(
|
||||
ET.SubElement(root, "arrangement").text = arrangement
|
||||
ET.SubElement(root, "offset").text = f"{audio_offset:.3f}"
|
||||
ET.SubElement(root, "songLength").text = f"{song_length:.3f}"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.3f}" if beats else "0.000"
|
||||
ET.SubElement(root, "startBeat").text = f"{beats[0].time:.6f}" if beats else "0.000000"
|
||||
ET.SubElement(root, "averageTempo").text = str(tempo)
|
||||
ET.SubElement(root, "artistName").text = artist
|
||||
ET.SubElement(root, "albumName").text = album
|
||||
@@ -1139,10 +1139,17 @@ def _build_xml(
|
||||
tuning_el.set(f"string{i}", str(tuning[i] if i < len(tuning) else 0))
|
||||
ET.SubElement(root, "capo").text = "0"
|
||||
|
||||
# Ebeats
|
||||
# Ebeats — write beat times at MICROSECOND (6-decimal) precision, not
|
||||
# millisecond (3-decimal). The editor/timeline DERIVES per-bar BPM from beat
|
||||
# spans (bpm = beats·60/span), which amplifies any rounding: at 3 decimals a
|
||||
# constant-tempo GP (e.g. 140) shows a spurious ±0.05–0.7 BPM per-bar drift
|
||||
# (worse for fast/odd meters) because most bar lengths don't land on a ms
|
||||
# boundary. gp2rs computes these times exactly from the GP tempo map, so the
|
||||
# only loss is this format string — 6 decimals makes the derived tempo match
|
||||
# GP's authored value. (Everything else stays at :.3f; only beats drive tempo.)
|
||||
ebeats = ET.SubElement(root, "ebeats", count=str(len(beats)))
|
||||
for b in beats:
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.3f}", measure=str(b.measure))
|
||||
ET.SubElement(ebeats, "ebeat", time=f"{b.time:.6f}", measure=str(b.measure))
|
||||
|
||||
# Sections
|
||||
sections_el = ET.SubElement(root, "sections", count=str(len(sections)))
|
||||
@@ -1836,9 +1843,10 @@ def convert_drum_track_to_drumtab(
|
||||
drum strings. Unknown percussion sounds (cowbell, tambourine etc.) are
|
||||
skipped — round-tripping them would require teaching `lib/drums.py` first.
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a per-MIDI
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...]}}``,
|
||||
times capped at 100 samples per note) so they can surface a warning or
|
||||
offer a manual mapping UI.
|
||||
record of every skipped note (``{midi: {"count": int, "times": [...],
|
||||
"velocities": [...]}}``, times/velocities index-aligned and capped at
|
||||
100 samples per note — velocities carry the source notes' real dynamics)
|
||||
so they can surface a warning or offer a manual mapping UI.
|
||||
|
||||
Honours GP repeat brackets and D.S./D.C./Coda/Fine jumps when
|
||||
``expand_repeats`` is true — same `_build_playback_schedule` machinery
|
||||
@@ -1894,18 +1902,29 @@ def convert_drum_track_to_drumtab(
|
||||
# NB: do NOT shadow the outer `entry` loop
|
||||
# variable from `for entry in schedule:`.
|
||||
unmapped_rec = out_unmapped.setdefault(
|
||||
int(midi_note), {"count": 0, "times": []})
|
||||
int(midi_note),
|
||||
{"count": 0, "times": [], "velocities": []})
|
||||
unmapped_rec["count"] += 1
|
||||
if len(unmapped_rec["times"]) < 100:
|
||||
unmapped_rec["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real
|
||||
# dynamics (same 1-127 gate as mapped hits,
|
||||
# falling back to the 100 import default) so
|
||||
# a hand-mapping UI doesn't flatten them.
|
||||
_uv = int(getattr(note, "velocity", 0) or 0)
|
||||
unmapped_rec["velocities"].append(
|
||||
_uv if 1 <= _uv <= 127 else 100)
|
||||
continue
|
||||
|
||||
hit: dict = {"t": round(t, 3), "p": piece}
|
||||
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly; default
|
||||
# is 95 (Velocities.default). Pass through verbatim,
|
||||
# clamping defensively so a corrupt file can't poison the
|
||||
# wire format.
|
||||
# Velocity: GP stores 1-127 MIDI velocity directly. Note
|
||||
# this is GP's *authoring* default (95, Velocities.default)
|
||||
# — unrelated to the drumtab render default of 100
|
||||
# (DEFAULT_VELOCITY, lib/drums.py:179), which only applies
|
||||
# when `v` is omitted from a hit. Pass the GP value through
|
||||
# verbatim, clamping defensively so a corrupt file can't
|
||||
# poison the wire format.
|
||||
vel = int(getattr(note, "velocity", 0) or 0)
|
||||
if 1 <= vel <= 127:
|
||||
hit["v"] = vel
|
||||
@@ -1946,9 +1965,21 @@ def convert_drum_track_to_drumtab(
|
||||
# Times for unmapped notes were collected in beat-iteration order;
|
||||
# multi-voice measures can produce out-of-order beats, so sort each
|
||||
# entry's `times` list chronologically before returning to the caller.
|
||||
# Velocities are index-aligned with times, so they must sort in
|
||||
# LOCKSTEP — sorting times alone would silently reassign dynamics.
|
||||
if out_unmapped is not None:
|
||||
for _rec in out_unmapped.values():
|
||||
_rec["times"].sort()
|
||||
_vels = _rec.get("velocities")
|
||||
if _vels and len(_vels) == len(_rec["times"]):
|
||||
_pairs = sorted(zip(_rec["times"], _vels))
|
||||
_rec["times"] = [p[0] for p in _pairs]
|
||||
_rec["velocities"] = [p[1] for p in _pairs]
|
||||
else:
|
||||
# Belt-and-suspenders: times & velocities are always appended
|
||||
# together under the same `len(times) < 100` guard above, so
|
||||
# in practice the lengths can't diverge. Kept as a defensive
|
||||
# fallback, not a real divergence case.
|
||||
_rec["times"].sort()
|
||||
|
||||
return {
|
||||
"version": drums_mod.SCHEMA_VERSION,
|
||||
|
||||
+15
-3
@@ -121,10 +121,18 @@ def _parse_bcfs(bcfs: bytes) -> dict:
|
||||
while sc <= max_sectors:
|
||||
s = _gi(po + 4 * sc); sc += 1
|
||||
if s == 0: break
|
||||
so = s * SECTOR
|
||||
if HDR + so + SECTOR > len(data):
|
||||
start = HDR + s * SECTOR
|
||||
# Real .gpx files' final sector is a few bytes short of a full
|
||||
# 0x1000 block: the BCFZ-declared decompressed size isn't
|
||||
# sector-aligned, so the last (small) container file lands in a
|
||||
# partial trailing sector. Clamp the read to the buffer end —
|
||||
# the per-file size field (`fs`, applied below) trims any
|
||||
# padding — matching canonical GPX readers (alphaTab /
|
||||
# PyGuitarPro slice-and-clamp). Only a sector whose *start* is
|
||||
# past the end is genuinely malformed.
|
||||
if start < 0 or start >= len(data):
|
||||
raise ValueError("GPX BCFS sector pointer out of range (malformed file)")
|
||||
fb.extend(data[HDR + so: HDR + so + SECTOR])
|
||||
fb.extend(data[start: min(start + SECTOR, len(data))])
|
||||
else:
|
||||
raise ValueError("GPX BCFS sector chain too long (malformed file)")
|
||||
files[fn] = bytes(fb[:fs])
|
||||
@@ -1498,6 +1506,10 @@ 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
|
||||
|
||||
+524
-29
@@ -18,8 +18,22 @@ 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
|
||||
@@ -353,15 +367,22 @@ 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. Shared by chroma synthesis and bar-time
|
||||
computation so both use one identical tempo model (mirrors
|
||||
``gp2rs._build_tempo_map``).
|
||||
``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``).
|
||||
"""
|
||||
events: list[tuple[int, float]] = [(0, float(song.tempo))]
|
||||
for track in song.tracks:
|
||||
@@ -371,7 +392,10 @@ 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((beat.start, float(mtc.tempo.value)))
|
||||
events.append((
|
||||
max(0, beat.start - _GP345_TICK_ORIGIN),
|
||||
float(mtc.tempo.value),
|
||||
))
|
||||
events.sort(key=lambda e: e[0])
|
||||
seen_ticks: set[int] = set()
|
||||
unique: list[tuple[int, float]] = []
|
||||
@@ -459,8 +483,9 @@ def _synthesise_score_chroma_gp345(
|
||||
for beat in voice.beats:
|
||||
if not beat.notes:
|
||||
continue
|
||||
beat_secs = tick_to_secs(beat.start)
|
||||
cur_tempo = tempo_at_tick(beat.start)
|
||||
beat_tick = max(0, beat.start - _GP345_TICK_ORIGIN)
|
||||
beat_secs = tick_to_secs(beat_tick)
|
||||
cur_tempo = tempo_at_tick(beat_tick)
|
||||
dur_secs = duration_to_secs(beat.duration, cur_tempo)
|
||||
|
||||
for note in beat.notes:
|
||||
@@ -513,13 +538,75 @@ def _dtw_align(
|
||||
Returns wp where wp[i] = [score_frame_index, audio_frame_index].
|
||||
"""
|
||||
import librosa
|
||||
import numpy as np
|
||||
cs = _safe_normalise(chroma_score)
|
||||
ca = _safe_normalise(chroma_audio)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
# Slope-constrained step pattern ([[1,1],[1,2],[2,1]], Müller's standard
|
||||
# music-sync config): every step advances BOTH axes, bounding the local
|
||||
# tempo ratio to 0.5x-2x. librosa's default steps allow pure
|
||||
# horizontal/vertical runs, and on riff-based music (long self-similar
|
||||
# chroma stretches, e.g. stoner/doom) the flat cost surface let the path
|
||||
# collapse — whole minutes of score mapped onto a single audio frame,
|
||||
# producing garbage sync points. The constrained pattern makes that
|
||||
# degenerate path impossible.
|
||||
steps = np.array([[1, 1], [1, 2], [2, 1]])
|
||||
weights = np.array([1.0, 1.0, 1.0])
|
||||
try:
|
||||
_D, wp = librosa.sequence.dtw(
|
||||
cs, ca, metric='cosine',
|
||||
step_sizes_sigma=steps, weights_mul=weights,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The constrained pattern needs the global length ratio within its
|
||||
# 0.5x-2x slope bounds; a pathological pairing (e.g. a 3-minute tab
|
||||
# against a 20-minute video) is infeasible and librosa raises. Fall
|
||||
# back to the unconstrained path rather than failing the whole sync.
|
||||
_log.warning("gp_autosync: constrained DTW infeasible (%s) — "
|
||||
"falling back to unconstrained steps", exc)
|
||||
_D, wp = librosa.sequence.dtw(cs, ca, metric='cosine')
|
||||
return wp[::-1] # reverse to forward order
|
||||
|
||||
# ── Sync point extraction from DTW path ──────────────────────────────────────
|
||||
|
||||
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,
|
||||
@@ -565,22 +652,7 @@ def _extract_sync_points(
|
||||
if bar_starts_override is not None:
|
||||
bar_starts_score = list(bar_starts_override)
|
||||
else:
|
||||
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)
|
||||
bar_starts_score = _gpif_bar_starts(root)
|
||||
|
||||
# Map each sampled bar to its audio time via the DTW path
|
||||
sync_points: list[SyncPoint] = []
|
||||
@@ -663,6 +735,224 @@ 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,
|
||||
@@ -932,12 +1222,7 @@ 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 = [] # 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)
|
||||
_measure_starts = _gp345_measure_start_ticks(_gp345x_song)
|
||||
|
||||
def _tick_to_bar(tick):
|
||||
"""Return 0-based bar index for a given tick position."""
|
||||
@@ -1024,6 +1309,216 @@ 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.
|
||||
|
||||
+125
-14
@@ -39,6 +39,14 @@ DURATION_BONUS_LOOSE = 0.025 # …within 15s
|
||||
_DURATION_TIGHT = 5
|
||||
_DURATION_LOOSE = 15
|
||||
|
||||
# Release-group secondary types that mark a NON-canonical release (a live album,
|
||||
# a greatest-hits comp, a remix/DJ set, …). Used both to pick the canonical
|
||||
# studio album for display and to reward studio recordings in ranking.
|
||||
_SECONDARY_SKIP = {
|
||||
"live", "compilation", "remix", "dj-mix", "mixtape/street",
|
||||
"demo", "interview", "audiobook", "spokenword",
|
||||
}
|
||||
|
||||
# ── Denoise ───────────────────────────────────────────────────────────────────
|
||||
# A parenthetical/bracketed group is dropped when it contains any of these
|
||||
# noise terms as a whole word (chart-variant markers, tuning/pitch notes,
|
||||
@@ -136,12 +144,31 @@ def _duration_int(v):
|
||||
return None
|
||||
|
||||
|
||||
def cand_artist_sim(song: dict, cand: dict) -> float:
|
||||
"""Best artist similarity between the song's reference artist and the
|
||||
candidate's PRIMARY name OR any of its `artist_aliases` (romanized/alternate
|
||||
names). MusicBrainz stores many artists under a non-Latin primary name
|
||||
(大橋純子) with the romanized form ("Junko Ohashi") only as an alias, so a
|
||||
reference typed/derived in romaji scores 0 against the primary but 1.0
|
||||
against the alias. The caller (server) attaches `artist_aliases` only for
|
||||
promising near-misses, so this is a plain max when they're present and the
|
||||
original single comparison when they're not."""
|
||||
best = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
for alias in cand.get("artist_aliases") or []:
|
||||
if best >= 1.0:
|
||||
break
|
||||
s = similarity(song.get("artist"), alias, artist=True)
|
||||
if s > best:
|
||||
best = s
|
||||
return best
|
||||
|
||||
|
||||
def score_candidate(song: dict, cand: dict) -> float:
|
||||
"""Combined confidence that MusicBrainz candidate `cand` is the song the
|
||||
chart transcribes. 0.5*artist + 0.5*title, plus small year/duration
|
||||
corroboration bonuses, capped at 1.0. Missing fields score 0 on their
|
||||
half — classify() separately refuses to auto-match without both."""
|
||||
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
score = 0.5 * artist_sim + 0.5 * title_sim
|
||||
sy, cy = _year_int(song.get("year")), _year_int(cand.get("year"))
|
||||
@@ -154,6 +181,10 @@ def score_candidate(song: dict, cand: dict) -> float:
|
||||
score += DURATION_BONUS
|
||||
elif diff <= _DURATION_LOOSE:
|
||||
score += DURATION_BONUS_LOOSE
|
||||
# NB: the studio-vs-live distinction is deliberately NOT scored here — a live
|
||||
# take is still the RIGHT SONG (same title/artist), so it must not change the
|
||||
# auto/review confidence. Canonical-version preference lives in the RANK sort
|
||||
# (rank_candidates) instead, where it only reorders same-song candidates.
|
||||
return min(score, 1.0)
|
||||
|
||||
|
||||
@@ -168,7 +199,7 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
"""
|
||||
if auto_min is None:
|
||||
auto_min = AUTO_MIN
|
||||
artist_sim = similarity(song.get("artist"), cand.get("artist"), artist=True)
|
||||
artist_sim = cand_artist_sim(song, cand)
|
||||
title_sim = similarity(song.get("title"), cand.get("title"))
|
||||
if (score >= auto_min and artist_sim >= AUTO_ARTIST_MIN
|
||||
and title_sim >= AUTO_TITLE_MIN):
|
||||
@@ -179,15 +210,34 @@ def classify(song: dict, cand: dict, score: float, auto_min: float | None = None
|
||||
|
||||
|
||||
def rank_candidates(song: dict, candidates: list[dict]) -> list[dict]:
|
||||
"""Score every candidate against the song and return them sorted by our
|
||||
score (MusicBrainz's own search score is only a tiebreak). Each returned
|
||||
dict is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
"""Score every candidate against the song and return them sorted best-first.
|
||||
The combined `score` caps at 1.0, so a perfect-text-match query (every "AC/DC
|
||||
Highway to Hell" recording) ties at the top — there the studio flag and, when
|
||||
the caller knows the audio length, the duration match break the tie so the
|
||||
canonical studio take wins over live/promo/extended cuts. Each returned dict
|
||||
is a copy carrying `score` (rounded — it's displayed and stored)."""
|
||||
sd = _duration_int(song.get("duration"))
|
||||
# For a chart that IS a live take (build_recording_query keeps live
|
||||
# recordings for these) the studio take is the WRONG recording, so drop the
|
||||
# studio tiebreak — duration proximity + text/mb score then pick the right
|
||||
# live version instead of auto-matching the studio one.
|
||||
prefer_studio = not _LIVE_GROUP_RE.search(str(song.get("title") or ""))
|
||||
|
||||
def _dur_diff(c):
|
||||
cd = _duration_int(c.get("duration"))
|
||||
return abs(sd - cd) if (sd and cd) else 10 ** 6
|
||||
|
||||
ranked = []
|
||||
for cand in candidates or []:
|
||||
c = dict(cand)
|
||||
c["score"] = round(score_candidate(song, cand), 4)
|
||||
ranked.append(c)
|
||||
ranked.sort(key=lambda c: (c["score"], c.get("mb_score") or 0), reverse=True)
|
||||
ranked.sort(
|
||||
key=lambda c: (c["score"],
|
||||
(1 if c.get("studio") else 0) if prefer_studio else 0,
|
||||
-_dur_diff(c), # closest to the audio length
|
||||
c.get("mb_score") or 0),
|
||||
reverse=True)
|
||||
return ranked
|
||||
|
||||
|
||||
@@ -198,18 +248,63 @@ def _lucene_escape_phrase(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def build_recording_query(artist, title) -> str:
|
||||
# A parenthetical/bracketed "(Live …)" marker — the live signal denoise() strips
|
||||
# from the title. Mirrors _NOISE_GROUP_RE but for the `live` term only.
|
||||
_LIVE_GROUP_RE = re.compile(r"[(\[][^)\]]*\blive\b[^)\]]*[)\]]", re.IGNORECASE)
|
||||
|
||||
|
||||
def build_recording_query(artist, title, *, loose: bool = False) -> str:
|
||||
"""Lucene query for /ws/2/recording. Built from the DENOISED fields —
|
||||
the noise we strip (author credits, "(Live)", "(v2)") would otherwise
|
||||
poison the search server's own scoring."""
|
||||
poison the search server's own scoring.
|
||||
|
||||
``loose=True`` drops the field-scoped quoted PHRASES for plain AND-ed
|
||||
term groups (``(telephone number) AND (junko ohashi)``). The point:
|
||||
a field phrase like ``artist:"Junko Ohashi"`` only matches MusicBrainz's
|
||||
*primary* artist name — it never searches ALIASES — so a recording stored
|
||||
under a non-Latin primary (大橋純子) whose romanized name is only an alias
|
||||
is invisible to the strict query. A loose term query searches the whole
|
||||
document, aliases included, and surfaces it. Lower precision by design: it
|
||||
is a FALLBACK for when the strict query returns nothing, and its results
|
||||
are re-scored by ``rank_candidates`` (and, for auto-match, gated by the
|
||||
per-field floors), so noise never auto-applies."""
|
||||
t = denoise(title)
|
||||
a = denoise(artist)
|
||||
if loose:
|
||||
# denoise() already reduced each field to lowercase [a-z0-9 and] tokens
|
||||
# (punctuation → spaces, diacritics stripped, & → "and"), so no
|
||||
# Lucene-special character survives to need escaping. Group each field's
|
||||
# terms and require both groups.
|
||||
q = " AND ".join("(%s)" % g for g in (t, a) if g)
|
||||
# Keep the SAME live exclusion as the strict path: the loose query is
|
||||
# lower-precision, and score_candidate doesn't penalize a live take, so
|
||||
# without this a studio chart whose strict query missed could fall back
|
||||
# to — and auto-confirm — a live-only recording. Skipped only when the
|
||||
# source title is itself a live take (mirrors the strict path).
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
parts = []
|
||||
if t:
|
||||
parts.append('recording:"%s"' % _lucene_escape_phrase(t))
|
||||
if a:
|
||||
parts.append('artist:"%s"' % _lucene_escape_phrase(a))
|
||||
return " AND ".join(parts)
|
||||
q = " AND ".join(parts)
|
||||
# Drop live-ONLY recordings (bootlegs, live albums) — the canonical studio
|
||||
# take is never tagged Live, and this is the single biggest source of junk in
|
||||
# a flat recording search. Compilations are deliberately NOT excluded: they
|
||||
# REUSE the studio recording, so filtering them would drop the very recording
|
||||
# we want (verified against MusicBrainz — `-secondarytype:Compilation` cut the
|
||||
# AC/DC studio "Highway to Hell" recording entirely).
|
||||
#
|
||||
# EXCEPT when the source chart is itself a live take: denoise() strips the
|
||||
# "(Live at …)" qualifier from the query, so filtering Live would leave the
|
||||
# genuinely-live chart with NO correct recording. Only a parenthetical marker
|
||||
# counts — a bare title word ("Live and Let Die") is a real word, not a live
|
||||
# tag — mirroring what denoise removes.
|
||||
if q and not _LIVE_GROUP_RE.search(str(title or "")):
|
||||
q += " AND -secondarytype:Live"
|
||||
return q
|
||||
|
||||
|
||||
def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
@@ -226,19 +321,33 @@ def _artist_credit(doc: dict) -> tuple[str, str, str]:
|
||||
return name, str(artist.get("id", "") or ""), str(artist.get("sort-name", "") or "")
|
||||
|
||||
|
||||
def _is_clean_studio_album(rg: dict) -> bool:
|
||||
"""A release-group that is a primary-type Album with NO non-canonical
|
||||
secondary type (Live / Compilation / Remix / …) — i.e. a studio album."""
|
||||
if str(rg.get("primary-type", "")).lower() != "album":
|
||||
return False
|
||||
secs = {str(s).lower() for s in (rg.get("secondary-types") or [])}
|
||||
return not (secs & _SECONDARY_SKIP)
|
||||
|
||||
|
||||
def _best_release(doc: dict) -> dict:
|
||||
"""Pick the release used for canon album/year: prefer Official status and
|
||||
an Album release-group, then the earliest date. Returns {} if none."""
|
||||
"""Pick the release used for canon album/year: prefer an OFFICIAL studio
|
||||
Album (primary Album with no Live/Compilation/… secondary type), then the
|
||||
earliest date. Falls back to any release when none is clean. {} if none."""
|
||||
releases = [r for r in (doc.get("releases") or []) if isinstance(r, dict)]
|
||||
if not releases:
|
||||
return {}
|
||||
|
||||
def sort_key(r):
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
rg = r.get("release-group") or {}
|
||||
album_ok = 0 if str(rg.get("primary-type", "")).lower() == "album" else 1
|
||||
clean = 0 if _is_clean_studio_album(rg) else 1
|
||||
status_ok = 0 if str(r.get("status", "")).lower() == "official" else 1
|
||||
date = str(r.get("date", "") or "9999")
|
||||
return (status_ok, album_ok, date)
|
||||
# Official FIRST, then prefer a clean studio album: this still surfaces
|
||||
# the studio album over an (official) live/comp album for the display
|
||||
# album/year, but never lets an UNofficial bootleg album outrank an
|
||||
# official single/EP/comp — which `(clean, status_ok, …)` would.
|
||||
return (status_ok, clean, date)
|
||||
|
||||
return sorted(releases, key=sort_key)[0]
|
||||
|
||||
@@ -261,6 +370,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
return None
|
||||
artist_name, artist_id, artist_sort = _artist_credit(doc)
|
||||
release = _best_release(doc)
|
||||
studio = _is_clean_studio_album(release.get("release-group") or {})
|
||||
length = doc.get("length")
|
||||
try:
|
||||
duration = int(round(float(length) / 1000.0)) if length else None
|
||||
@@ -281,6 +391,7 @@ def parse_recording_doc(doc: dict) -> dict | None:
|
||||
"isrc": isrcs[0] if isrcs else "",
|
||||
"genres": _genres(doc),
|
||||
"mb_score": int(doc.get("score") or 0),
|
||||
"studio": studio,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+160
-7
@@ -203,7 +203,13 @@ def convert_midi_track_to_keys_wire(
|
||||
# a foreign track's tempo events do NOT apply to the chosen
|
||||
# track. Merging would mis-time the notes — restrict the tempo
|
||||
# scan to the selected track only.
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# ``ticks_per_beat`` is 0 for a malformed header and NEGATIVE for SMPTE
|
||||
# division (mido returns the signed short as-is). Both feed the two
|
||||
# divisions below (tempo-table build + tick_to_seconds), so guard here:
|
||||
# 0 would raise ZeroDivisionError and a negative value would yield
|
||||
# negative/garbage times. Use ``> 0`` (not ``or``) so the negative SMPTE
|
||||
# case also falls back to the SMF default.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -352,7 +358,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||
- type 2: independent timelines; tempo only from the chosen track.
|
||||
"""
|
||||
ticks_per_beat = midi.ticks_per_beat
|
||||
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||
# negative value and a malformed header as 0 — both make the two division
|
||||
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||
# SMF default here, the single place every caller routes ticks through, so
|
||||
# each caller's own fallback is real rather than cosmetic.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
tempo_source = (
|
||||
@@ -393,6 +406,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
||||
return tick_to_seconds
|
||||
|
||||
|
||||
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||
# orders of magnitude below this.
|
||||
_TEMPO_MAP_MAX_BARS = 20000
|
||||
|
||||
|
||||
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||
signatures, and a full beat grid — the data the note converters here
|
||||
always computed internally (to bake note times) and then threw away,
|
||||
which left every MIDI import with no bars, no measures, and an implied
|
||||
4/4 no matter what the file said.
|
||||
|
||||
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||
|
||||
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||
|
||||
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||
(independent timelines — callers must never share one grid across
|
||||
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||
are computed from absolute ticks through the cumulative tempo table and
|
||||
rounded once at emit — rounding error never accumulates with song
|
||||
length. An SMF with no note events yields empty ``beats``.
|
||||
"""
|
||||
midi = mido.MidiFile(midi_path)
|
||||
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||
midi_type = getattr(midi, "type", 1)
|
||||
# Same scope both converters use: type 2 reads only the chosen track
|
||||
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||
source_tracks = (
|
||||
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||
)
|
||||
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||
|
||||
# ── collect meta + the end of musical content in one pass ────────────
|
||||
sig_events: list[tuple[int, int, int]] = []
|
||||
tempo_events: list[tuple[int, int]] = []
|
||||
end_tick = 0
|
||||
for tr in source_tracks:
|
||||
abs_tick = 0
|
||||
for msg in tr:
|
||||
abs_tick += msg.time
|
||||
if msg.type == "time_signature":
|
||||
num = int(getattr(msg, "numerator", 4) or 4)
|
||||
den = int(getattr(msg, "denominator", 4) or 4)
|
||||
if num > 0 and den > 0:
|
||||
sig_events.append((abs_tick, num, den))
|
||||
elif msg.type == "set_tempo":
|
||||
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||
elif msg.type in ("note_on", "note_off"):
|
||||
end_tick = max(end_tick, abs_tick)
|
||||
|
||||
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||
sig_events.sort(key=lambda e: e[0])
|
||||
sigs: list[tuple[int, int, int]] = []
|
||||
for ev in sig_events:
|
||||
if sigs and sigs[-1][0] == ev[0]:
|
||||
sigs[-1] = ev
|
||||
else:
|
||||
sigs.append(ev)
|
||||
if not sigs or sigs[0][0] > 0:
|
||||
sigs.insert(0, (0, 4, 4))
|
||||
|
||||
tempo_events.sort(key=lambda e: e[0])
|
||||
seen_tempo_ticks: dict[int, int] = {}
|
||||
for ev_tick, ev_tempo in tempo_events:
|
||||
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||
tempos_out: list[dict] = []
|
||||
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||
# lands after the start (or there are none). The beat grid already runs
|
||||
# at 120 for the head of the song, so the sidecar must say so too —
|
||||
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||
for ev_tick in sorted_tempo_ticks:
|
||||
tempos_out.append({
|
||||
"time": round(tick_to_seconds(ev_tick), 3),
|
||||
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||
})
|
||||
|
||||
time_signatures_out = [
|
||||
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||
for t, num, den in sigs
|
||||
]
|
||||
|
||||
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||
beats: list[dict] = []
|
||||
if end_tick > 0:
|
||||
cur_tick = 0.0
|
||||
measure = 1
|
||||
sig_idx = 0
|
||||
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||
# Active signature: the latest event at or before this bar's
|
||||
# start. Mid-bar events wait for the next boundary by
|
||||
# construction (we only re-read between bars).
|
||||
while (sig_idx + 1 < len(sigs)
|
||||
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||
sig_idx += 1
|
||||
_, num, den = sigs[sig_idx]
|
||||
beat_ticks = ticks_per_beat * 4.0 / den
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||
"measure": measure,
|
||||
"den": den,
|
||||
})
|
||||
for k in range(1, num):
|
||||
sub_tick = cur_tick + k * beat_ticks
|
||||
if sub_tick >= end_tick:
|
||||
break
|
||||
beats.append({
|
||||
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||
"measure": -1,
|
||||
})
|
||||
cur_tick += num * beat_ticks
|
||||
measure += 1
|
||||
|
||||
return {
|
||||
"tempos": tempos_out,
|
||||
"time_signatures": time_signatures_out,
|
||||
"beats": beats,
|
||||
}
|
||||
|
||||
|
||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||
|
||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||
@@ -486,10 +634,12 @@ def convert_drum_track_from_midi(
|
||||
|
||||
Callers can pass an empty dict as ``out_unmapped`` to receive a
|
||||
per-MIDI record of every channel-9 note_on that didn't resolve to a
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...]}}``, times
|
||||
capped at 100 samples per note). The default path skips this
|
||||
capture entirely so MIDIs heavy with cowbell/tambourine/etc. take
|
||||
no extra work.
|
||||
piece-id (``{midi: {"count": int, "times": [float, ...],
|
||||
"velocities": [int, ...]}}``, times/velocities index-aligned and
|
||||
capped at 100 samples per note — velocities carry the source notes'
|
||||
real dynamics so a hand-mapping UI doesn't have to flatten them to a
|
||||
default). The default path skips this capture entirely so MIDIs
|
||||
heavy with cowbell/tambourine/etc. take no extra work.
|
||||
"""
|
||||
offset = float(audio_offset)
|
||||
if not math.isfinite(offset):
|
||||
@@ -527,10 +677,13 @@ def convert_drum_track_from_midi(
|
||||
continue
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
entry = out_unmapped.setdefault(
|
||||
midi_note, {"count": 0, "times": []})
|
||||
midi_note, {"count": 0, "times": [], "velocities": []})
|
||||
entry["count"] += 1
|
||||
if len(entry["times"]) < 100:
|
||||
entry["times"].append(round(t, 3))
|
||||
# Index-aligned with times: the note's real dynamics,
|
||||
# so hand-mapping doesn't flatten everything to 100.
|
||||
entry["velocities"].append(int(msg.velocity))
|
||||
continue
|
||||
# Mapped note: compute t once for the raw entry.
|
||||
t = tick_to_seconds(abs_tick) + offset
|
||||
|
||||
@@ -26,6 +26,13 @@ def safe_join(root: Path, name: str) -> Path | None:
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
|
||||
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
|
||||
# raises for an embedded NUL, so the byte would otherwise leak through
|
||||
# containment. An explicit guard is strictly-more-rejection (no effect on
|
||||
# the zip-slip / traversal contract).
|
||||
if "\x00" in name:
|
||||
return None
|
||||
safe = name.replace("\\", "/")
|
||||
try:
|
||||
root_resolved = root.resolve()
|
||||
|
||||
+26
-14
@@ -703,20 +703,32 @@ def load_song(
|
||||
and isinstance(e.get("d"), (int, float))
|
||||
]
|
||||
if song.lyrics:
|
||||
# Provenance — populated by the converter (xml/notechart),
|
||||
# the WhisperX fallback (whisperx), or hand-edits
|
||||
# (user). Validate against the closed enum so a
|
||||
# hand-edited (or otherwise malformed) manifest can't
|
||||
# propagate a YAML dict / list / arbitrary string
|
||||
# into the highway WS `lyrics.source` field and out
|
||||
# to plugin badges. Anything outside the enum (or
|
||||
# the wrong type) falls back to "xml" — the spec's
|
||||
# back-compat default — instead of being stringified
|
||||
# and trusted.
|
||||
_ALLOWED_LYRICS_SOURCES = {"xml", "notechart", "whisperx", "user"}
|
||||
# Legacy alias: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name; normalise it.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart"}
|
||||
# Provenance. The feedpak spec (§7.1) vocabulary is
|
||||
# {authored, transcribed, user}; older manifests + the
|
||||
# in-tree readers also use the source-format names
|
||||
# (xml/notechart) and the WhisperX engine name
|
||||
# (whisperx). Accept the union so both spec-compliant
|
||||
# writers (e.g. the stem_splitter plugin emitting
|
||||
# `transcribed`) and legacy packs validate. Validate
|
||||
# against the closed enum so a hand-edited (or otherwise
|
||||
# malformed) manifest can't propagate a YAML dict / list /
|
||||
# arbitrary string into the highway WS `lyrics.source`
|
||||
# field and out to plugin badges. Anything outside the
|
||||
# enum (or the wrong type) falls back to "xml" — the
|
||||
# back-compat default — instead of being stringified and
|
||||
# trusted.
|
||||
# Post-alias values only: `whisperx` is normalised to
|
||||
# `transcribed` before the membership check below, so (like
|
||||
# `sng`) it is intentionally absent from this set.
|
||||
_ALLOWED_LYRICS_SOURCES = {
|
||||
"xml", "notechart", "user",
|
||||
"authored", "transcribed",
|
||||
}
|
||||
# Legacy aliases: older manifests labelled note-chart-derived
|
||||
# lyrics with the source format's name, and the WhisperX
|
||||
# fallback with the engine name — normalise both to the
|
||||
# spec vocabulary the badges now expect.
|
||||
_LYRICS_SOURCE_ALIASES = {"sng": "notechart", "whisperx": "transcribed"}
|
||||
raw_source = manifest.get("lyrics_source")
|
||||
if isinstance(raw_source, str):
|
||||
raw_source = _LYRICS_SOURCE_ALIASES.get(raw_source, raw_source)
|
||||
|
||||
+364
-33
@@ -4,51 +4,132 @@ Kept separate from server.py so tests can import it without triggering
|
||||
FastAPI / SQLite module-level side effects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
DEFAULT_REFERENCE_PITCH = 440.0
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. This is the authoritative source; tuner/routes.py previously
|
||||
# held a copy — it was removed in favour of this one.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
# Canonical open strings, low to high, as MIDI notes. This is the host-level
|
||||
# source of truth for guitar/bass tuning profiles; UI surfaces derive names,
|
||||
# frequencies, and semitone offsets from these absolute pitches.
|
||||
STANDARD_OPEN_MIDIS: dict[str, list[int]] = {
|
||||
"guitar-6": [40, 45, 50, 55, 59, 64],
|
||||
"guitar-7": [35, 40, 45, 50, 55, 59, 64],
|
||||
"guitar-8": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"bass-4": [28, 33, 38, 43],
|
||||
"bass-5": [23, 28, 33, 38, 43],
|
||||
"bass-6": [23, 28, 33, 38, 43, 48],
|
||||
}
|
||||
|
||||
# Curated built-in profiles. This intentionally starts by absorbing the useful
|
||||
# Virtuoso guitar/bass coverage into host-owned data so the host selector,
|
||||
# tuner, practice tools, and plugins can converge on one profile model.
|
||||
TUNING_PRESET_MIDIS: dict[str, dict[str, list[int]]] = {
|
||||
"guitar-6": {
|
||||
"Standard": [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Eb Standard": [77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Drop D": [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"D Standard": [73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop C": [65.41, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Open G": [73.42, 98.00, 146.83, 196.00, 246.94, 293.66],
|
||||
"Open D": [73.42, 110.00, 146.83, 185.00, 220.00, 293.66],
|
||||
"DADGAD": [73.42, 110.00, 146.83, 196.00, 220.00, 293.66],
|
||||
"Open E": [82.41, 123.47, 164.81, 207.65, 246.94, 329.63],
|
||||
"Standard": [40, 45, 50, 55, 59, 64],
|
||||
"Eb Standard": [39, 44, 49, 54, 58, 63],
|
||||
"D Standard": [38, 43, 48, 53, 57, 62],
|
||||
"C# Standard": [37, 42, 47, 52, 56, 61],
|
||||
"C Standard": [36, 41, 46, 51, 55, 60],
|
||||
"Drop D": [38, 45, 50, 55, 59, 64],
|
||||
"Drop C": [36, 43, 48, 53, 57, 62],
|
||||
"Drop B": [35, 42, 47, 52, 56, 61],
|
||||
"Drop A": [33, 40, 45, 50, 54, 59],
|
||||
"Drop Ab": [32, 39, 44, 49, 53, 58],
|
||||
"Open G": [38, 43, 50, 55, 59, 62],
|
||||
"Open D": [38, 45, 50, 54, 57, 62],
|
||||
"DADGAD": [38, 45, 50, 55, 57, 62],
|
||||
"Open E": [40, 47, 52, 56, 59, 64],
|
||||
},
|
||||
"guitar-7": {
|
||||
"Standard": [61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop A": [55.00, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"A Standard": [55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop G": [49.00, 73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Bb Standard": [58.27, 77.78, 103.83, 138.59, 185.00, 233.08, 311.13],
|
||||
"Standard": [35, 40, 45, 50, 55, 59, 64],
|
||||
"Bb Standard": [34, 39, 44, 49, 54, 58, 63],
|
||||
"A Standard": [33, 38, 43, 48, 53, 57, 62],
|
||||
"G Standard": [31, 36, 41, 46, 51, 55, 60],
|
||||
"Drop A": [33, 40, 45, 50, 55, 59, 64],
|
||||
"Drop G": [31, 38, 43, 48, 53, 57, 62],
|
||||
"Drop F#": [30, 37, 42, 47, 52, 56, 61],
|
||||
},
|
||||
"guitar-8": {
|
||||
"Standard": [46.25, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"Drop E": [41.20, 61.74, 82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
"E Standard": [41.20, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00, 130.81, 174.61, 220.00, 293.66],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50, 123.47, 164.81, 207.65, 277.18],
|
||||
"Standard": [30, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop E": [28, 35, 40, 45, 50, 55, 59, 64],
|
||||
"Drop A + Drop E": [28, 33, 40, 45, 50, 55, 59, 64],
|
||||
"E Standard": [28, 33, 38, 43, 48, 53, 57, 62],
|
||||
"Eb Standard": [27, 32, 37, 42, 47, 52, 56, 61],
|
||||
"Drop D": [26, 33, 38, 43, 48, 53, 57, 62],
|
||||
},
|
||||
"bass-4": {
|
||||
"Standard": [41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [28, 33, 38, 43],
|
||||
"Eb Standard": [27, 32, 37, 42],
|
||||
"D Standard": [26, 31, 36, 41],
|
||||
"C# Standard": [25, 30, 35, 40],
|
||||
"C Standard": [24, 29, 34, 39],
|
||||
"Drop D": [26, 33, 38, 43],
|
||||
"Drop C": [24, 31, 36, 41],
|
||||
"BEAD": [23, 28, 33, 38],
|
||||
},
|
||||
"bass-5": {
|
||||
"Standard": [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
"Eb Standard": [29.14, 38.89, 51.91, 69.30, 92.50],
|
||||
"Drop D": [30.87, 36.71, 55.00, 73.42, 98.00],
|
||||
"D Standard": [27.50, 36.71, 48.99, 65.41, 87.31],
|
||||
"Drop C": [27.50, 32.70, 48.99, 65.41, 87.31],
|
||||
"Standard": [23, 28, 33, 38, 43],
|
||||
"High C": [28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42],
|
||||
"D Standard": [21, 26, 31, 36, 41],
|
||||
"C# Standard": [20, 25, 30, 35, 40],
|
||||
"C Standard": [19, 24, 29, 34, 39],
|
||||
"Drop A": [21, 28, 33, 38, 43],
|
||||
},
|
||||
"bass-6": {
|
||||
"Standard": [23, 28, 33, 38, 43, 48],
|
||||
"Eb Standard": [22, 27, 32, 37, 42, 47],
|
||||
"D Standard": [21, 26, 31, 36, 41, 46],
|
||||
"C# Standard": [20, 25, 30, 35, 40, 45],
|
||||
"C Standard": [19, 24, 29, 34, 39, 44],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def midi_to_freq(midi: int, reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> float:
|
||||
"""Return the frequency for a MIDI note at the supplied A4 reference."""
|
||||
return reference_pitch * math.pow(2, (midi - 69) / 12)
|
||||
|
||||
|
||||
def open_midis_to_freqs(midis: list[int], reference_pitch: float = DEFAULT_REFERENCE_PITCH) -> list[float]:
|
||||
"""Return rounded frequencies for low-to-high MIDI open strings."""
|
||||
return [round(midi_to_freq(m, reference_pitch), 2) for m in midis]
|
||||
|
||||
|
||||
def tuning_offsets_from_midis(instrument_key: str, midis: list[int]) -> list[int] | None:
|
||||
"""Return semitone offsets from the instrument's standard open strings."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(midis):
|
||||
return None
|
||||
return [int(m - s) for m, s in zip(midis, standard)]
|
||||
|
||||
|
||||
def tuning_midis_from_offsets(instrument_key: str, offsets: list[int]) -> list[int] | None:
|
||||
"""Return absolute open-string MIDI notes for host semitone offsets."""
|
||||
standard = STANDARD_OPEN_MIDIS.get(instrument_key)
|
||||
if not standard or len(standard) != len(offsets):
|
||||
return None
|
||||
return [int(s + o) for s, o in zip(standard, offsets)]
|
||||
|
||||
|
||||
def tuning_preset_offsets(instrument_key: str, name: str) -> list[int] | None:
|
||||
"""Return host semitone offsets for a named preset."""
|
||||
midis = TUNING_PRESET_MIDIS.get(instrument_key, {}).get(name)
|
||||
if not midis:
|
||||
return None
|
||||
return tuning_offsets_from_midis(instrument_key, midis)
|
||||
|
||||
|
||||
# Canonical tuning frequencies at 440 Hz reference, keyed by instrument then
|
||||
# tuning name. Kept for the existing /api/tunings contract.
|
||||
DEFAULT_TUNINGS: dict[str, dict[str, list[float]]] = {
|
||||
instrument: {
|
||||
name: open_midis_to_freqs(midis)
|
||||
for name, midis in presets.items()
|
||||
}
|
||||
for instrument, presets in TUNING_PRESET_MIDIS.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +148,256 @@ def apply_reference_pitch(
|
||||
}
|
||||
|
||||
|
||||
PROFILE_IDS = ("guitar-lead", "guitar-rhythm", "bass")
|
||||
PROFILE_PATHWAYS = ("songs", "practice", "learn", "studio")
|
||||
DEFAULT_ACTIVE_INSTRUMENT_PROFILE = "guitar-lead"
|
||||
PROFILE_DEFAULTS: dict[str, dict] = {
|
||||
"guitar-lead": {
|
||||
"id": "guitar-lead",
|
||||
"label": "Lead Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "lead",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"guitar-rhythm": {
|
||||
"id": "guitar-rhythm",
|
||||
"label": "Rhythm Guitar",
|
||||
"instrument": "guitar",
|
||||
"role": "rhythm",
|
||||
"string_count": 6,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
"bass": {
|
||||
"id": "bass",
|
||||
"label": "Bass",
|
||||
"instrument": "bass",
|
||||
"role": "bass",
|
||||
"string_count": 4,
|
||||
"tuning": "Standard",
|
||||
"reference_pitch": DEFAULT_REFERENCE_PITCH,
|
||||
"pathway": "songs",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def instrument_key(instrument: str, string_count: int) -> str:
|
||||
return f"{instrument}-{string_count}"
|
||||
|
||||
|
||||
def default_instrument_profiles() -> dict[str, dict]:
|
||||
return {profile_id: dict(profile) for profile_id, profile in PROFILE_DEFAULTS.items()}
|
||||
|
||||
|
||||
def _valid_reference_pitch(value) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
ref = float(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
if not math.isfinite(ref) or ref < 430.0 or ref > 450.0:
|
||||
return None
|
||||
return ref
|
||||
|
||||
|
||||
def _valid_tuning_for_key(key: str, tuning):
|
||||
if isinstance(tuning, str):
|
||||
if len(tuning) > 64:
|
||||
return None
|
||||
if tuning in TUNING_PRESET_MIDIS.get(key, {}):
|
||||
return tuning
|
||||
# A name that IS a built-in preset for a different key is a misapplied
|
||||
# built-in (e.g. "Drop D" on a 5-string bass, whose low string is B) —
|
||||
# reject it. A name unknown to every built-in table is a provider/custom
|
||||
# tuning (the tuner plugin's, exposed via /api/tunings) that this pure
|
||||
# layer can't resolve — accept it so settings round-trip; the provider
|
||||
# owns its validity.
|
||||
if any(tuning in names for names in TUNING_PRESET_MIDIS.values()):
|
||||
return None
|
||||
return tuning
|
||||
if isinstance(tuning, list):
|
||||
expected = len(STANDARD_OPEN_MIDIS.get(key, []))
|
||||
if len(tuning) != expected:
|
||||
return None
|
||||
if any(isinstance(o, bool) or not isinstance(o, int) or o < -12 or o > 12 for o in tuning):
|
||||
return None
|
||||
return list(tuning)
|
||||
return None
|
||||
|
||||
|
||||
def normalize_instrument_profile(profile_id: str, raw) -> tuple[dict | None, str | None]:
|
||||
"""Validate one persisted host instrument profile."""
|
||||
base = dict(PROFILE_DEFAULTS.get(profile_id, {}))
|
||||
if not base:
|
||||
return None, f"unknown instrument profile: {profile_id}"
|
||||
if raw is None:
|
||||
return base, None
|
||||
if not isinstance(raw, dict):
|
||||
return None, f"instrument_profiles.{profile_id} must be an object"
|
||||
|
||||
instrument = raw.get("instrument", base["instrument"])
|
||||
if instrument not in ("guitar", "bass"):
|
||||
return None, f"instrument_profiles.{profile_id}.instrument must be 'guitar' or 'bass'"
|
||||
|
||||
try:
|
||||
string_count = int(raw.get("string_count", base["string_count"]))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
key = instrument_key(instrument, string_count)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
return None, f"instrument_profiles.{profile_id}.string_count must be valid for the instrument"
|
||||
|
||||
tuning = _valid_tuning_for_key(key, raw.get("tuning", base["tuning"]))
|
||||
if tuning is None:
|
||||
return None, f"instrument_profiles.{profile_id}.tuning must match {key}"
|
||||
|
||||
ref = _valid_reference_pitch(raw.get("reference_pitch", base["reference_pitch"]))
|
||||
if ref is None:
|
||||
return None, f"instrument_profiles.{profile_id}.reference_pitch must be a number between 430 and 450"
|
||||
|
||||
label = raw.get("label", base["label"])
|
||||
if not isinstance(label, str) or len(label) > 64:
|
||||
return None, f"instrument_profiles.{profile_id}.label must be a short string"
|
||||
role = raw.get("role", base["role"])
|
||||
if not isinstance(role, str) or len(role) > 32:
|
||||
return None, f"instrument_profiles.{profile_id}.role must be a short string"
|
||||
pathway = raw.get("pathway", base["pathway"])
|
||||
if not isinstance(pathway, str) or pathway not in PROFILE_PATHWAYS:
|
||||
return None, f"instrument_profiles.{profile_id}.pathway must be one of songs, practice, learn, studio"
|
||||
|
||||
out = dict(base)
|
||||
out.update({
|
||||
"id": profile_id,
|
||||
"label": label,
|
||||
"instrument": instrument,
|
||||
"role": role,
|
||||
"string_count": string_count,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return out, None
|
||||
|
||||
|
||||
def normalize_instrument_profiles(raw_profiles=None) -> tuple[dict[str, dict] | None, str | None]:
|
||||
"""Validate persisted host profiles, filling omitted built-ins with defaults."""
|
||||
if raw_profiles is None:
|
||||
return default_instrument_profiles(), None
|
||||
if not isinstance(raw_profiles, dict):
|
||||
return None, "instrument_profiles must be an object"
|
||||
profiles = {}
|
||||
for profile_id in PROFILE_IDS:
|
||||
profile, error = normalize_instrument_profile(profile_id, raw_profiles.get(profile_id))
|
||||
if error:
|
||||
return None, error
|
||||
profiles[profile_id] = profile
|
||||
return profiles, None
|
||||
|
||||
|
||||
def active_profile_id(raw) -> str:
|
||||
return raw if raw in PROFILE_DEFAULTS else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
|
||||
|
||||
def profile_from_legacy_settings(cfg: dict) -> dict:
|
||||
"""Build an active profile from the old flat settings keys."""
|
||||
instrument = cfg.get("instrument") if cfg.get("instrument") in ("guitar", "bass") else "guitar"
|
||||
fallback_sc = 4 if instrument == "bass" else 6
|
||||
try:
|
||||
sc = int(cfg.get("string_count", fallback_sc))
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
if key not in STANDARD_OPEN_MIDIS:
|
||||
sc = fallback_sc
|
||||
key = instrument_key(instrument, sc)
|
||||
tuning = _valid_tuning_for_key(key, cfg.get("tuning", "Standard")) or "Standard"
|
||||
ref = _valid_reference_pitch(cfg.get("reference_pitch", DEFAULT_REFERENCE_PITCH)) or DEFAULT_REFERENCE_PITCH
|
||||
pathway = cfg.get("pathway") if cfg.get("pathway") in PROFILE_PATHWAYS else "songs"
|
||||
profile_id = "bass" if instrument == "bass" else DEFAULT_ACTIVE_INSTRUMENT_PROFILE
|
||||
profile = dict(PROFILE_DEFAULTS[profile_id])
|
||||
profile.update({
|
||||
"instrument": instrument,
|
||||
"string_count": sc,
|
||||
"tuning": tuning,
|
||||
"reference_pitch": ref,
|
||||
"pathway": pathway,
|
||||
})
|
||||
return profile
|
||||
|
||||
|
||||
def settings_with_instrument_profiles(cfg: dict) -> dict:
|
||||
"""Return settings with canonical host profiles and mirrored flat keys."""
|
||||
out = dict(cfg)
|
||||
profiles, _error = normalize_instrument_profiles(out.get("instrument_profiles"))
|
||||
if profiles is None:
|
||||
profiles = default_instrument_profiles()
|
||||
if "instrument_profiles" not in out:
|
||||
legacy = profile_from_legacy_settings(out)
|
||||
profiles[legacy["id"]] = legacy
|
||||
# Default the active profile to the one migrated from the legacy flat
|
||||
# fields, but DON'T clobber an explicit request — a fresh-config
|
||||
# `POST {"active_instrument_profile": "bass"}` must switch, not be
|
||||
# overwritten by the guitar-lead inferred from defaults. active_profile_id
|
||||
# below normalizes an invalid value.
|
||||
out.setdefault("active_instrument_profile", legacy["id"])
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
selected = profiles[active]
|
||||
out["instrument_profiles"] = profiles
|
||||
out["active_instrument_profile"] = active
|
||||
out["instrument"] = selected["instrument"]
|
||||
out["string_count"] = selected["string_count"]
|
||||
out["tuning"] = selected["tuning"]
|
||||
out["reference_pitch"] = selected["reference_pitch"]
|
||||
out["pathway"] = selected["pathway"]
|
||||
return out
|
||||
|
||||
|
||||
def apply_flat_instrument_patch_to_profiles(cfg: dict, updates: dict) -> dict:
|
||||
"""Mirror legacy flat instrument updates into the active host profile."""
|
||||
out = settings_with_instrument_profiles(cfg)
|
||||
if not any(k in updates for k in ("instrument", "string_count", "tuning", "reference_pitch", "pathway")):
|
||||
return out
|
||||
active = active_profile_id(out.get("active_instrument_profile"))
|
||||
if "instrument" in updates:
|
||||
active = "bass" if updates["instrument"] == "bass" else "guitar-lead"
|
||||
out["active_instrument_profile"] = active
|
||||
current = dict(out["instrument_profiles"][active])
|
||||
|
||||
if "instrument" in updates:
|
||||
current["instrument"] = updates["instrument"]
|
||||
if "string_count" not in updates:
|
||||
current["string_count"] = 4 if updates["instrument"] == "bass" else 6
|
||||
if "string_count" in updates:
|
||||
current["string_count"] = updates["string_count"]
|
||||
if "reference_pitch" in updates:
|
||||
current["reference_pitch"] = updates["reference_pitch"]
|
||||
if "pathway" in updates:
|
||||
current["pathway"] = updates["pathway"]
|
||||
if "tuning" in updates:
|
||||
current["tuning"] = updates["tuning"]
|
||||
else:
|
||||
key = instrument_key(current["instrument"], current["string_count"])
|
||||
if _valid_tuning_for_key(key, current.get("tuning")) is None:
|
||||
current["tuning"] = "Standard"
|
||||
|
||||
profile, error = normalize_instrument_profile(active, current)
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
out["instrument_profiles"][active] = profile
|
||||
out.update({
|
||||
"instrument": profile["instrument"],
|
||||
"string_count": profile["string_count"],
|
||||
"tuning": profile["tuning"],
|
||||
"reference_pitch": profile["reference_pitch"],
|
||||
"pathway": profile["pathway"],
|
||||
})
|
||||
return out
|
||||
|
||||
def tuning_name(offsets: list[int]) -> str:
|
||||
# All three pattern checks below are gated on `len(offsets) == 6`. The
|
||||
# naming conventions here are 6-string-specific — e.g. a 7-string all-zeros
|
||||
|
||||
Generated
+1758
-1
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -8,9 +8,12 @@
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:debug": "playwright test --debug",
|
||||
"test:js": "node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js'",
|
||||
"install:playwright": "playwright install chromium"
|
||||
"install:playwright": "playwright install chromium",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
"@playwright/test": "^1.59.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-import-x": "^4.17.1"
|
||||
}
|
||||
}
|
||||
|
||||
+99
-15
@@ -18,6 +18,54 @@ from safepath import safe_join
|
||||
log = logging.getLogger("feedBack.plugins")
|
||||
|
||||
|
||||
def _plugin_media_type(path: Path) -> str:
|
||||
"""Best-effort Content-Type for a served plugin file. `.js`/`.css` must come
|
||||
back as JavaScript/CSS so `<script type=module>` / `addModule()` / a `<link>`
|
||||
accept them; `mimetypes.guess_type` can miss these on a stripped platform
|
||||
registry, so fall back explicitly (mirrors the assets/ route)."""
|
||||
media_type = mimetypes.guess_type(path.name)[0]
|
||||
if media_type is None and path.suffix == ".js":
|
||||
return "application/javascript"
|
||||
if media_type is None and path.suffix == ".css":
|
||||
return "text/css"
|
||||
return media_type or "application/octet-stream"
|
||||
|
||||
|
||||
def _plugin_file_etag(path: Path) -> str | None:
|
||||
"""Weak ETag from mtime+size — cheap, stable across reads, changes on edit.
|
||||
This is what makes the live-edit loop work for module graphs: a conditional
|
||||
GET revalidates and 304s unchanged files on refresh instead of re-downloading
|
||||
the whole `src/` tree. Returns None if the file can't be stat'd."""
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return None
|
||||
return f'W/"{st.st_mtime_ns:x}-{st.st_size:x}"'
|
||||
|
||||
|
||||
def _if_none_match(request: Request, etag: str) -> bool:
|
||||
"""True when the client's If-None-Match already holds `etag`."""
|
||||
# ponytail: we serve one weak ETag; the browser echoes it back verbatim, so
|
||||
# a direct compare is enough (comma-split tolerates a proxy concatenation).
|
||||
return etag in [t.strip() for t in request.headers.get("if-none-match", "").split(",")]
|
||||
|
||||
|
||||
def _plugin_file_response(request: Request, path: Path, media_type: str) -> Response:
|
||||
"""Serve a plugin source/asset file with the live-edit cache contract:
|
||||
`Cache-Control: no-cache` (browser may store but MUST revalidate) + a weak
|
||||
ETag, and a bodyless 304 when the client's If-None-Match already matches.
|
||||
Starlette's `FileResponse` emits an ETag but never evaluates If-None-Match
|
||||
itself, so the conditional handling has to live here."""
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
etag = _plugin_file_etag(path)
|
||||
if etag:
|
||||
headers["ETag"] = etag
|
||||
if _if_none_match(request, etag):
|
||||
return Response(status_code=304, headers=headers)
|
||||
# FileResponse sets etag/last-modified via setdefault, so the ETag above wins.
|
||||
return FileResponse(path, media_type=media_type, headers=headers)
|
||||
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent
|
||||
# Holds only *ready* (loaded) plugins — those whose dependencies installed
|
||||
# and whose routes registered. A plugin GRADUATES from PENDING_PLUGINS into
|
||||
@@ -1373,6 +1421,12 @@ def load_plugins(app: FastAPI, context: dict, progress_cb=None, route_setup_fn=N
|
||||
"version": manifest.get("version"),
|
||||
"has_screen": bool(manifest.get("screen")),
|
||||
"has_script": bool(manifest.get("script")),
|
||||
# Module-migration (R0): `scriptType:"module"` tells the loader to
|
||||
# inject screen.js as <script type="module">; `minHost` is the
|
||||
# min core version a migrated plugin needs (passthrough only in R0 —
|
||||
# enforcement is deferred to R4, master §4b). None when unset.
|
||||
"script_type": manifest.get("scriptType"),
|
||||
"min_host": manifest.get("minHost"),
|
||||
"has_settings": bool(manifest.get("settings")),
|
||||
"settings_category": _settings_category,
|
||||
# Drives the v3 shell's immersive (full-screen) mode for this
|
||||
@@ -2089,6 +2143,11 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": p.get("fallback", False),
|
||||
"has_screen": p["has_screen"],
|
||||
"has_script": p["has_script"],
|
||||
# Module-migration passthrough (R0). Re-read from the manifest
|
||||
# like `version` above so stubbed test entries (built without
|
||||
# _nav_entry) don't need the key.
|
||||
"script_type": (p.get("_manifest") or {}).get("scriptType"),
|
||||
"min_host": (p.get("_manifest") or {}).get("minHost"),
|
||||
"has_settings": p["has_settings"],
|
||||
# v3 immersive screen opt-in (full-screen plugin UI).
|
||||
"fullscreen": p.get("fullscreen", False),
|
||||
@@ -2142,6 +2201,9 @@ def register_plugin_api(app: FastAPI):
|
||||
"fallback": False,
|
||||
"has_screen": e.get("has_screen", False),
|
||||
"has_script": e.get("has_script", False),
|
||||
# Pending entries come from _nav_entry, so they carry these.
|
||||
"script_type": e.get("script_type"),
|
||||
"min_host": e.get("min_host"),
|
||||
"has_settings": e.get("has_settings", False),
|
||||
"settings_category": e.get("settings_category"),
|
||||
"fullscreen": e.get("fullscreen", False),
|
||||
@@ -2307,7 +2369,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return HTMLResponse("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/screen.js")
|
||||
def plugin_screen_js(plugin_id: str):
|
||||
def plugin_screen_js(request: Request, plugin_id: str):
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
@@ -2315,8 +2377,11 @@ def register_plugin_api(app: FastAPI):
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
script_file = p["_dir"] / p["_manifest"].get("script", "screen.js")
|
||||
if script_file.exists():
|
||||
return Response(script_file.read_text(encoding="utf-8"), media_type="application/javascript")
|
||||
if script_file.is_file():
|
||||
# no-cache + ETag/304 so an edited screen.js reloads on
|
||||
# refresh while an unchanged one revalidates cheaply — the
|
||||
# same live-edit contract the src/ module graph relies on.
|
||||
return _plugin_file_response(request, script_file, "application/javascript")
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||
@@ -2377,7 +2442,7 @@ def register_plugin_api(app: FastAPI):
|
||||
return Response("{}", status_code=404, media_type="application/json")
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/assets/{asset_path:path}")
|
||||
def plugin_asset(plugin_id: str, asset_path: str):
|
||||
def plugin_asset(request: Request, plugin_id: str, asset_path: str):
|
||||
"""Serve a static file a plugin bundles under its own ``assets/``
|
||||
directory (e.g. an AudioWorklet module, WASM, or image). Unlike the
|
||||
fixed screen.js/settings.html handlers above, this is a generic
|
||||
@@ -2399,16 +2464,35 @@ def register_plugin_api(app: FastAPI):
|
||||
log.warning("Plugin %r: asset path rejected: %r", plugin_id, asset_path)
|
||||
break
|
||||
if target.is_file():
|
||||
media_type = mimetypes.guess_type(target.name)[0]
|
||||
# .js must come back as JavaScript so addModule() / <script>
|
||||
# accept it; guess_type can miss this on some platforms.
|
||||
if media_type is None and target.suffix == ".js":
|
||||
media_type = "application/javascript"
|
||||
# .css must come back as text/css so a <link rel=stylesheet>
|
||||
# (the styles capability) is honoured; guess_type can miss it
|
||||
# on a stripped platform mimetypes registry, same as .js.
|
||||
elif media_type is None and target.suffix == ".css":
|
||||
media_type = "text/css"
|
||||
return FileResponse(target, media_type=media_type or "application/octet-stream")
|
||||
# no-cache + ETag/304 so a live-edited worklet/asset reloads
|
||||
# on refresh (bare FileResponse emits an ETag but never 304s).
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@app.get("/api/plugins/{plugin_id}/src/{src_path:path}")
|
||||
def plugin_src(request: Request, plugin_id: str, src_path: str):
|
||||
"""Serve a file from a plugin's ES-module source tree under ``src/``.
|
||||
|
||||
This is the R0 host capability that lets a migrated plugin's
|
||||
``screen.js`` (a one-line ``import './src/main.js'``) load its whole
|
||||
module graph. Containment mirrors the assets/ route exactly —
|
||||
``safe_join`` against ``<plugin>/src`` rejects ``..``, absolute paths,
|
||||
and NUL bytes — and the live-edit cache contract (no-cache + ETag/304)
|
||||
makes an edited module reload on refresh while unchanged ones 304.
|
||||
Read-only; the src/ tree is source files, never executed server-side.
|
||||
"""
|
||||
with PLUGINS_LOCK:
|
||||
snapshot = list(LOADED_PLUGINS)
|
||||
for p in snapshot:
|
||||
if p["id"] == plugin_id:
|
||||
if p.get("status", "ready") != "ready":
|
||||
break
|
||||
target = safe_join(p["_dir"] / "src", src_path)
|
||||
if target is None:
|
||||
log.warning("Plugin %r: src path rejected: %r", plugin_id, src_path)
|
||||
break
|
||||
if target.is_file():
|
||||
return _plugin_file_response(request, target, _plugin_media_type(target))
|
||||
break
|
||||
return Response("", status_code=404)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "highway_3d",
|
||||
"name": "3D Highway",
|
||||
"version": "3.31.2",
|
||||
"version": "3.31.3",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -3747,6 +3747,15 @@
|
||||
// ── Per-instance Three.js state ───────────────────────────────────
|
||||
let scene = null, cam = null, ren = null;
|
||||
let wrap = null;
|
||||
// WebGL context-loss recovery. Switching the active window / alt-tabbing
|
||||
// (especially on Windows) can trigger a GPU context reset; with no
|
||||
// handler the lost context escalates into a render-process crash. The
|
||||
// listeners (bound in initScene on ren.domElement, removed in teardown)
|
||||
// preventDefault the loss so the browser keeps the context restorable,
|
||||
// _ctxLost gates draw() off the dead context, and on restore we reset the
|
||||
// viewport + resume (Three re-uploads scene resources on the next render).
|
||||
let _ctxLost = false;
|
||||
let _onCtxLost = null, _onCtxRestored = null;
|
||||
let bcCtrl = null; // Butterchurn audio-reactive background (the 'butterchurn' bg-style)
|
||||
let _chartEnv = 0, _chartPrevT = -1, _bcBeatIdx = 0, _bcNoteIdx = 0, _bcChordIdx = 0, _bcTintTarget = null;
|
||||
let _tintR = 20, _tintG = 24, _tintB = 40; // smoothed instrument-color tint for the bg
|
||||
@@ -6526,6 +6535,26 @@
|
||||
ren.setClearColor(0x101820, _bcActive() ? 0 : 1);
|
||||
wrap.appendChild(ren.domElement);
|
||||
|
||||
// WebGL context-loss recovery (see the _ctxLost declaration). Bound
|
||||
// on Three's own canvas — the context that actually resets on a GPU
|
||||
// reset / alt-tab. preventDefault() keeps the context restorable
|
||||
// instead of letting the loss escalate to a render-process crash;
|
||||
// _ctxLost then makes draw() bail so no GL work runs on the dead
|
||||
// context; on restore we reset the viewport and resume (Three
|
||||
// re-uploads geometry/materials/textures lazily on the next render).
|
||||
_onCtxLost = (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
_ctxLost = true;
|
||||
console.warn('[3D-Hwy] WebGL context lost — pausing render until it is restored.');
|
||||
};
|
||||
_onCtxRestored = () => {
|
||||
_ctxLost = false;
|
||||
console.warn('[3D-Hwy] WebGL context restored — resuming render.');
|
||||
try { const s = canvasSize(highwayCanvas); if (s.w > 0 && s.h > 0) applySize(s.w, s.h); } catch (err) {}
|
||||
};
|
||||
ren.domElement.addEventListener('webglcontextlost', _onCtxLost, false);
|
||||
ren.domElement.addEventListener('webglcontextrestored', _onCtxRestored, false);
|
||||
|
||||
lyricsCanvas = document.createElement('canvas');
|
||||
lyricsCanvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:1;';
|
||||
lyricsCtx = lyricsCanvas.getContext('2d');
|
||||
@@ -14829,6 +14858,15 @@
|
||||
// mid-teardown settings change doesn't try to rebuild a torn-
|
||||
// down scene; then dispose the active style's resources.
|
||||
if (_bgListener) { _bgUnsubscribe(_bgListener); _bgListener = null; }
|
||||
// WebGL context-loss listeners (bound in initScene on ren.domElement).
|
||||
// Remove before ren is disposed below so a torn-down instance can't
|
||||
// keep firing them; reset the flag so a reused instance starts clean.
|
||||
if (ren && ren.domElement) {
|
||||
if (_onCtxLost) { try { ren.domElement.removeEventListener('webglcontextlost', _onCtxLost, false); } catch (e) {} }
|
||||
if (_onCtxRestored) { try { ren.domElement.removeEventListener('webglcontextrestored', _onCtxRestored, false); } catch (e) {} }
|
||||
}
|
||||
_onCtxLost = _onCtxRestored = null;
|
||||
_ctxLost = false;
|
||||
// Notedetect listeners (issue #9). Remove on destroy so a
|
||||
// panel that stops doesn't keep accumulating marks. Marks
|
||||
// arrays are cleared too — they hold stale chart positions
|
||||
@@ -15154,6 +15192,7 @@
|
||||
|
||||
draw(bundle) {
|
||||
if (!_isReady) return;
|
||||
if (_ctxLost) return; // GPU context lost (alt-tab / reset) — skip until restored
|
||||
if (!_chartPrewarmed) {
|
||||
_chartPrewarmed = true;
|
||||
_prewarmChart(bundle);
|
||||
|
||||
@@ -3,12 +3,34 @@
|
||||
RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824).
|
||||
|
||||
- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped).
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue.
|
||||
- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colors** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. Selectable **note-color palettes** (settings → Note colors, `keys3d_bg_palette`, default the per-octave scheme): a per-octave rainbow (each octave its own hue, darker sharps), the original per-pitch "Rainbow" table, vivid/pastel per-pitch variants, and single-hue two-tone palettes (uniform naturals, darker sharps) for players who want "black key coming" to read at a glance; notes, key glow, lane guides and hit flames all follow the pick live.
|
||||
- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing).
|
||||
- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances.
|
||||
- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration.
|
||||
- **Camera settings**: camera-rig presets (`keys3d_bg_camera` — classic low rig / elevated / overhead; default overhead, applied live, adaptive pan-zoom preserved) with base-rig fine-tune sliders for height, distance and tilt (`keys3d_bg_camHeight` / `camDist` / `camTilt`) that nudge the vantage point the follow-motion orbits. Numeric FX keys clamp to per-key declared ranges (`FX_RANGES`, default 0–1).
|
||||
- **Highway-layout options** (settings → Highway layout). **Sharps & flats**
|
||||
(`keys3d_bg_sharpMode`, string; default `realistic`) picks the sharp layout:
|
||||
`floating` (original raised-plane sharps, white-only lanes); `flat` (one plane,
|
||||
zero-overlap piano-shaped tiled lanes — white lanes trimmed where a sharp adjoins
|
||||
them, and each sharp leaned toward the edge natural beside it so the naturals come
|
||||
out close to even: C/D/E/F/B equal, G/A a hair smaller since G# can't lean; pure
|
||||
`laneSpanFlat()`); `realistic` (one plane, bars sized like the physical keys — full
|
||||
naturals always rendered full, full black keys drawn on top and only occluding a
|
||||
natural where a sharp note actually coincides in time; pure `laneSpanReal()`).
|
||||
**Lane color opacity** (`keys3d_bg_laneOpacity`, 0–1, default 0) fades the
|
||||
pitch-class lane tint; at 0 (default) the strips are a dark floor with guide lines
|
||||
only at the key-block boundaries (E→F and each octave B→C), so each block is bounded
|
||||
rather than every lane — the notes keep their colors; toward 1 it fills in full,
|
||||
vivid colored lanes. The strips, per-lane separators and block lines crossfade with
|
||||
this value. **Octave separators** (`keys3d_bg_octaveGaps`, default on) widens the
|
||||
gap a touch at each B→C octave boundary. **Octave line contrast**
|
||||
(`keys3d_bg_octaveContrast`, 0–1, default 0.5) scales how hard the B→C octave line
|
||||
reads; it is drawn as a dark layer (scaled by lane opacity) plus a bright layer
|
||||
(scaled by its inverse), so it auto-shifts dark→bright as the lanes fade — no mode
|
||||
switch needed. All are geometry-time — applied on the next chart build via
|
||||
`init()`'s re-read.
|
||||
- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms).
|
||||
- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class color, ~400 ms).
|
||||
- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present.
|
||||
- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`.
|
||||
- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "keys_highway_3d",
|
||||
"name": "Keys Highway 3D",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
|
||||
"type": "visualization",
|
||||
"bundled": true,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//
|
||||
// Visual contract is the frame analysis on slopsmith#824 (RS+ reference):
|
||||
// 3D perspective highway to a vanishing point, notes landing on a real 3D
|
||||
// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a
|
||||
// keyboard, per-key Synthesia-style PITCH-CLASS colors (hand is only a
|
||||
// secondary brightness cue), active-range key highlighting with letters,
|
||||
// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar
|
||||
// numbers, and key-depress + flame feedback driven by the LIVE MIDI input
|
||||
@@ -59,7 +59,7 @@
|
||||
// World scroll speed (units / second) — matches the sibling highways.
|
||||
const TS = 130 * K;
|
||||
|
||||
// Per-pitch-class colours (Synthesia convention observed in the RS+
|
||||
// Per-pitch-class colors (Synthesia convention observed in the RS+
|
||||
// reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …).
|
||||
// Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of
|
||||
// their neighbours so black-key notes stay distinguishable.
|
||||
@@ -79,10 +79,196 @@
|
||||
];
|
||||
|
||||
// Hand cue is SECONDARY (slopsmith#824 design call): right hand renders
|
||||
// at full brightness, left hand slightly darkened — colour stays the
|
||||
// at full brightness, left hand slightly darkened — color stays the
|
||||
// pitch class.
|
||||
const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 };
|
||||
|
||||
// Selectable note-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.
|
||||
@@ -97,8 +283,8 @@
|
||||
// Gem vertical gradient (bottom shade → top highlight), baked per-vertex into
|
||||
// the note geometry so a block reads as a lit 3D gem instead of a flat fill —
|
||||
// same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is
|
||||
// greyscale so one geometry serves every pitch-class colour; the material
|
||||
// multiplies its colour by it via vertexColors.
|
||||
// greyscale so one geometry serves every pitch-class color; the material
|
||||
// multiplies its color by it via vertexColors.
|
||||
const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight)
|
||||
|
||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
@@ -913,7 +1099,35 @@
|
||||
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.
|
||||
@@ -1090,6 +1304,88 @@
|
||||
} 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 {
|
||||
@@ -1104,10 +1400,10 @@
|
||||
else if (raw === '0' || raw === 'false') fx[k] = false;
|
||||
} else {
|
||||
const n = parseFloat(raw);
|
||||
// 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));
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
} catch (_) { /* localStorage unavailable — use defaults */ }
|
||||
@@ -1127,7 +1423,7 @@
|
||||
} else {
|
||||
v = Number(value);
|
||||
if (!Number.isFinite(v)) return;
|
||||
v = Math.min(1, Math.max(0, v)); // all numeric FX keys are 0-1
|
||||
v = _fxClamp(key, v); // declared range, default 0-1
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(FX_LS_PREFIX + key, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));
|
||||
@@ -1369,6 +1665,8 @@
|
||||
// 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)
|
||||
@@ -1432,8 +1730,20 @@
|
||||
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(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]);
|
||||
const base = new T.Color(_noteHex(midi));
|
||||
const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0;
|
||||
base.multiplyScalar(b);
|
||||
return base;
|
||||
@@ -1460,15 +1770,51 @@
|
||||
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 — 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;
|
||||
// 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;
|
||||
// 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)
|
||||
@@ -1484,7 +1830,26 @@
|
||||
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
|
||||
// Per-key approach glow: a key lights in its pitch-class colour ONLY while a
|
||||
// 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
|
||||
// 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)
|
||||
@@ -1945,10 +2310,9 @@
|
||||
_envRT = _makeStudioEnv(T, ren);
|
||||
if (_envRT) scene.environment = _envRT.texture;
|
||||
|
||||
cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K);
|
||||
cam = new T.PerspectiveCamera(_camPreset.fov, 1, 0.1, 2000 * K);
|
||||
_camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1;
|
||||
cam.position.set(0, CAM_Y, CAM_Z);
|
||||
cam.lookAt(0, LOOK_Y, LOOK_Z);
|
||||
{ const r = _rig(); cam.position.set(0, r.y * K, r.z * K); cam.lookAt(0, r.lookY * K, r.lookZ * K); }
|
||||
|
||||
ambLight = new T.AmbientLight(0xffffff, 0.75);
|
||||
dirLight = new T.DirectionalLight(0xffffff, 1.1);
|
||||
@@ -2074,9 +2438,9 @@
|
||||
});
|
||||
// Extrusion spans z ∈ [-bevel, depth + bevel]; centre it.
|
||||
geo.translate(0, 0, -depth / 2);
|
||||
// Bake a vertical brightness ramp into vertex colours (bottom shade →
|
||||
// Bake a vertical brightness ramp into vertex colors (bottom shade →
|
||||
// top highlight) so the gem reads 3D; the material multiplies its
|
||||
// pitch-class colour by this (vertexColors).
|
||||
// pitch-class color by this (vertexColors).
|
||||
geo.computeBoundingBox();
|
||||
const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1;
|
||||
const pos = geo.attributes.position;
|
||||
@@ -2091,13 +2455,16 @@
|
||||
return geo;
|
||||
}
|
||||
|
||||
// Glossy note material, cached per (pitch class, hand).
|
||||
// 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).
|
||||
function _noteMaterial(midi, hand) {
|
||||
const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh';
|
||||
const key = (((midi % 12) + 12) % 12) + '|' + handKey;
|
||||
const col = noteColor(midi, hand);
|
||||
const key = col.getHex();
|
||||
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
|
||||
@@ -2107,7 +2474,7 @@
|
||||
// share one shader program.)
|
||||
mat = new T.MeshPhysicalMaterial({
|
||||
color: col,
|
||||
vertexColors: true, // multiply colour by the baked gem ramp
|
||||
vertexColors: true, // multiply color by the baked gem ramp
|
||||
emissive: col,
|
||||
emissiveIntensity: NOTE_EMISSIVE_BASE * _glowMul(),
|
||||
roughness: 0.32,
|
||||
@@ -2129,7 +2496,10 @@
|
||||
return 0.72 + 0.22 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
}
|
||||
function _laneGuideOpacity() {
|
||||
return 0.10 + 0.12 * Math.min(1, Math.max(0, fx.vibrancy));
|
||||
// Vibrancy sets the ceiling (much brighter than the old subtle
|
||||
// 0.10–0.22 range); the laneOpacity slider then scales 0 → ceiling.
|
||||
const vib = 0.32 + 0.52 * Math.min(1, Math.max(0, fx.vibrancy)); // ~0.32..0.84
|
||||
return vib * Math.min(1, Math.max(0, fx.laneOpacity));
|
||||
}
|
||||
|
||||
// Live vibrancy slider: retint everything already built — the
|
||||
@@ -2145,6 +2515,39 @@
|
||||
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;
|
||||
@@ -2181,13 +2584,15 @@
|
||||
return _glowTex;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
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;
|
||||
@@ -2207,7 +2612,7 @@
|
||||
ctx.fillStyle = falloff;
|
||||
ctx.fillRect(0, 0, 64, 128);
|
||||
tex = new T.CanvasTexture(cnv);
|
||||
_flameTexCache.set(pc, tex);
|
||||
_flameTexCache.set(c, tex);
|
||||
return tex;
|
||||
}
|
||||
|
||||
@@ -2294,7 +2699,7 @@
|
||||
if (!entry) return;
|
||||
const slot = _flamePool[_flameIdx];
|
||||
_flameIdx = (_flameIdx + 1) % _flamePool.length;
|
||||
slot.mat.map = _flameTexture(((midi % 12) + 12) % 12);
|
||||
slot.mat.map = _flameTexture(midi);
|
||||
slot.start = wallNow;
|
||||
slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H;
|
||||
slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount);
|
||||
@@ -2363,9 +2768,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Lane guides: a faint colour strip running up the runway from each
|
||||
// active key, in that key's pitch-class colour. A falling note shares
|
||||
// its target key's colour, so the player can trace it straight down
|
||||
// Lane guides: a faint color strip running up the runway from each
|
||||
// active key, in that key's pitch-class color. A falling note shares
|
||||
// its target key's color, so the player can trace it straight down
|
||||
// its lane to the right key even when it sits near the frame edge.
|
||||
//
|
||||
// The lanes sit at the NOTES' travel height (coplanar), not on the
|
||||
@@ -2375,28 +2780,87 @@
|
||||
// lane, perfectly aligned with the lane and its key.
|
||||
const guideLen = HIGHWAY_LEN - WHITE_L;
|
||||
const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height
|
||||
const gaps = fx.octaveGaps;
|
||||
const floating = _sharpMode === 'floating';
|
||||
const t = Math.min(1, Math.max(0, fx.laneOpacity)); // lane-color opacity
|
||||
const octC = Math.min(1, Math.max(0, fx.octaveContrast)); // 0..1 line-contrast
|
||||
const themeLaneDim = (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })();
|
||||
// A vertical guide line running the full runway at world x (skips
|
||||
// near-transparent lines so the crossfade never builds dead meshes).
|
||||
const addLine = (x, color, opacity, wpx, trackTheme) => {
|
||||
if (opacity < 0.02) return;
|
||||
const m = new T.MeshBasicMaterial({ color, transparent: true, opacity, depthWrite: false });
|
||||
if (trackTheme) _railMats.push(m); // theme retint tracks these; fixed guides stay put
|
||||
const line = new T.Mesh(new T.PlaneGeometry(wpx, guideLen), m);
|
||||
line.rotation.x = -Math.PI / 2;
|
||||
line.position.set(x, laneY + 0.06 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(line);
|
||||
};
|
||||
for (const [midi, entry] of layout) {
|
||||
if (midi < range.activeLow || midi > range.activeHigh) continue;
|
||||
if (entry.black) continue; // one strip per semitone-slot lands on whites
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false,
|
||||
});
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Thin brighter rails at the lane edges for crisp separation.
|
||||
const railMat = new T.MeshBasicMaterial({
|
||||
color: (() => { const c = _bgThemeColors(_theme); return c.laneDim != null ? c.laneDim : 0x2a2a3e; })(),
|
||||
transparent: true, opacity: 0.5, depthWrite: false,
|
||||
});
|
||||
_railMats.push(railMat);
|
||||
const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat);
|
||||
rail.rotation.x = -Math.PI / 2;
|
||||
rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(rail);
|
||||
// Floating: white-only lanes (blacks float, lane-less). Flat/
|
||||
// realistic: every key gets a piano-shaped lane.
|
||||
if (entry.black && floating) continue;
|
||||
// Lane footprint per mode.
|
||||
let left, right, stripY = laneY;
|
||||
if (floating) {
|
||||
const cx = keyX(entry, whiteCount);
|
||||
left = cx - WHITE_W / 2; right = cx + WHITE_W / 2;
|
||||
if (gaps) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
if (pc === 11) right -= OCT_GAP / 2; // B → C boundary
|
||||
if (pc === 0) left += OCT_GAP / 2;
|
||||
}
|
||||
} else {
|
||||
const span = laneSpanFor(midi, entry.black, keyX(entry, whiteCount), gaps, range);
|
||||
left = span.left; right = span.right;
|
||||
if (_sharpMode === 'realistic' && entry.black) stripY = laneY + REAL_SHARP_LIFT;
|
||||
}
|
||||
const center = (left + right) / 2;
|
||||
// Colored lane strip + a subtle per-lane separator — fade in with
|
||||
// lane opacity. (As lanes fade, the block/octave lines below take
|
||||
// over as the guide.)
|
||||
if (t > 0.02) {
|
||||
// Floating keeps the historical 0.84-wide white strip; the
|
||||
// piano-shaped lanes inset a touch for a dark separator.
|
||||
const stripW = floating ? (right - left) - WHITE_W * 0.16 : (right - left) * 0.9;
|
||||
const gmat = new T.MeshBasicMaterial({
|
||||
color: noteColor(midi, 'rh'), transparent: true,
|
||||
opacity: _laneGuideOpacity(), depthWrite: false, // includes lane opacity
|
||||
});
|
||||
gmat.userData.midi = midi; // palette retint needs the lane's pitch
|
||||
_laneGuideMats.push(gmat);
|
||||
const strip = new T.Mesh(new T.PlaneGeometry(stripW, guideLen), gmat);
|
||||
strip.rotation.x = -Math.PI / 2;
|
||||
strip.position.set(center, stripY, hitZ - guideLen / 2);
|
||||
keyboardGroup.add(strip);
|
||||
// Per-lane separator, fading with the strips. Skip realistic
|
||||
// sharps (they overlap the white columns).
|
||||
if (!(entry.black && _sharpMode === 'realistic')) {
|
||||
addLine(left, themeLaneDim, 0.5 * t, 0.6 * K, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Structural divider lines: ONE per "block" boundary — E→F and B→C —
|
||||
// so each block of keys (C-D-E, F-G-A-B) is bounded, not every lane.
|
||||
// They crossfade IN as the lanes fade OUT. The B→C octave line is a
|
||||
// dark layer (reads over bright lanes, scales with lane opacity) plus
|
||||
// a bright layer (reads over the dark floor, scales with the inverse),
|
||||
// so it auto-shifts dark→bright as you fade lanes; octaveContrast
|
||||
// scales the whole thing.
|
||||
for (let midi = range.activeLow; midi <= range.activeHigh; midi++) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
const isEF = pc === 4; // E → F block boundary
|
||||
const isBC = pc === 11; // B → C octave boundary
|
||||
if (!isEF && !isBC) continue;
|
||||
const boundaryX = keyX(layout.get(midi), whiteCount) + WHITE_W / 2;
|
||||
if (isBC) {
|
||||
addLine(boundaryX, 0x05060a, octC * 0.92 * t, 1.1 * K, false); // dark, over lanes
|
||||
addLine(boundaryX, 0xd8dcec, (0.42 + octC * 0.5) * (1 - t), 1.1 * K, false); // bright, over floor
|
||||
} else {
|
||||
// E→F block divider — a guide that appears as the lanes fade.
|
||||
addLine(boundaryX, 0x6a6a7a, 0.5 * (1 - t), 0.8 * K, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Keys (whites first so blacks overlay). Geometries are shared
|
||||
@@ -2416,7 +2880,7 @@
|
||||
const inRange = midi >= range.activeLow && midi <= range.activeHigh;
|
||||
const material = new T.MeshStandardMaterial({
|
||||
color: black ? 0x070708 : 0xe8e8ee,
|
||||
// Pitch-class colour preset on emissive but OFF at rest — the key
|
||||
// Pitch-class color preset on emissive but OFF at rest — the key
|
||||
// is neutral until a note approaches, when updateScene ramps the
|
||||
// intensity up by proximity.
|
||||
emissive: noteColor(midi, 'rh'),
|
||||
@@ -2502,11 +2966,31 @@
|
||||
const entry = layout.get(note.midi);
|
||||
if (!entry) continue;
|
||||
const len = Math.max(4 * K, note.durSec * TS);
|
||||
const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
// Non-floating layouts: notes ride the naturals' plane and take
|
||||
// their piano-shaped lane's width/centre. Floating (default):
|
||||
// original elevated sharps, key-centred bars.
|
||||
let w, x, y;
|
||||
if (_flatMode()) {
|
||||
const span = laneSpanFor(
|
||||
note.midi, entry.black, keyX(entry, whiteCount), fx.octaveGaps, range);
|
||||
// 'realistic' bars are full (physical-key size); 'flat' bars are
|
||||
// inset a touch for a dark separator in the tight tiling.
|
||||
const inset = _sharpMode === 'realistic' ? 1.0 : 0.9;
|
||||
w = (span.right - span.left) * inset;
|
||||
x = (span.left + span.right) / 2;
|
||||
// Coplanar; in 'realistic' the sharps ride a hair proud so they
|
||||
// draw over the naturals they overlap without z-fighting.
|
||||
const lift = (_sharpMode === 'realistic' && entry.black) ? REAL_SHARP_LIFT : 0;
|
||||
y = WHITE_H + NOTE_H / 2 + 0.5 * K + lift;
|
||||
} else {
|
||||
w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9;
|
||||
x = keyX(entry, whiteCount);
|
||||
y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
}
|
||||
// Clone per note so each can glow independently while being consumed.
|
||||
const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone());
|
||||
mesh.position.x = keyX(entry, whiteCount);
|
||||
mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K;
|
||||
mesh.position.x = x;
|
||||
mesh.position.y = y;
|
||||
mesh.visible = false;
|
||||
notesGroup.add(mesh);
|
||||
// Note-name label: a camera-facing sprite (readable at this low camera
|
||||
@@ -2775,8 +3259,7 @@
|
||||
}
|
||||
_camX += (_camTargetX - _camX) * CAM_PAN_LERP;
|
||||
_camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP;
|
||||
cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom);
|
||||
cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom);
|
||||
{ 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); }
|
||||
|
||||
for (const km of keyMeshes.values()) km.userData.glow = 0;
|
||||
for (const { mesh, note, len, label } of noteMeshes) {
|
||||
@@ -3217,9 +3700,13 @@
|
||||
if (_isReady) teardown();
|
||||
highwayCanvas = canvas;
|
||||
fx = readFxSettings();
|
||||
// Persisted string settings refresh here too — a theme saved
|
||||
// while no instance was listening must not come up stale on a
|
||||
// later init().
|
||||
// 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;
|
||||
_theme = readThemeSetting();
|
||||
_bgStyle = readBgStyleSetting();
|
||||
loadThree().then(() => {
|
||||
@@ -3265,6 +3752,23 @@
|
||||
_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);
|
||||
@@ -3470,10 +3974,24 @@
|
||||
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,6 +12,22 @@
|
||||
<div class="mt-3">
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2">Graphics</h4>
|
||||
|
||||
<label for="keysh3d-fx-palette" class="text-xs font-medium text-gray-400 mb-1 block">Note colors</label>
|
||||
<select id="keysh3d-fx-palette"
|
||||
onchange="window.keys3dSetPalette && window.keys3dSetPalette(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="octaves" selected>Octaves (color per octave, darker sharps)</option>
|
||||
<option value="emerald">Emerald (green, darker sharps)</option>
|
||||
<option value="ice">Ice (blue, darker sharps)</option>
|
||||
<option value="classic">Rainbow (per-pitch)</option>
|
||||
<option value="vivid">Vivid (per-pitch, punchier)</option>
|
||||
<option value="pastel">Pastel (per-pitch, soft)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Choose the color scheme for the falling notes, key glow, lane
|
||||
guides and hit flames. Each option is described in its own label.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-theme" class="text-xs font-medium text-gray-400 mb-1 block">Scene theme</label>
|
||||
<select id="keysh3d-fx-theme"
|
||||
onchange="window.keys3dSetTheme && window.keys3dSetTheme(this.value)"
|
||||
@@ -30,7 +46,117 @@
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Background gradient, floor and lane rails — the same theme names
|
||||
as the guitar highway. Pitch-class note colours never change.
|
||||
as the guitar highway. Note colors come from the
|
||||
"Note colors" palette above.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camera" class="text-xs font-medium text-gray-400 mb-1 block">Camera angle</label>
|
||||
<select id="keysh3d-fx-camera"
|
||||
onchange="window.keys3dSetCamera && window.keys3dSetCamera(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="classic">Classic (low, deep runway)</option>
|
||||
<option value="elevated">Elevated (higher, more board)</option>
|
||||
<option value="overhead" selected>Overhead (top-down reading view)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Where the camera sits. Classic is the original low rig; Elevated
|
||||
lifts it for a fuller view of the keybed; Overhead looks down the
|
||||
lanes for a sheet-reading feel. Applies live, keeps the
|
||||
auto-pan/zoom that follows your hands.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camheight" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Camera height <span id="keysh3d-fx-camheight-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camheight"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camHeight', this.value); document.getElementById('keysh3d-fx-camheight-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Raise or lower the camera around the angle above (higher = more
|
||||
top-down). Fine-tunes the base view; the follow-motion stays.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camdist" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera distance <span id="keysh3d-fx-camdist-val" class="text-gray-500 font-mono">1.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camdist"
|
||||
min="0.4" max="2.2" step="0.02" value="1"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camDist', this.value); document.getElementById('keysh3d-fx-camdist-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Pull the camera back or push it in (larger = further away, smaller
|
||||
= closer).
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-camtilt" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Camera tilt <span id="keysh3d-fx-camtilt-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-camtilt"
|
||||
min="-1" max="1" step="0.02" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('camTilt', this.value); document.getElementById('keysh3d-fx-camtilt-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Tilt the view up (+) or down (−) without moving the camera —
|
||||
aims higher up the runway or down toward the keys. 0 = neutral.
|
||||
</p>
|
||||
|
||||
<h4 class="text-xs font-medium text-gray-300 mb-2 mt-4">Highway layout</h4>
|
||||
|
||||
<label for="keysh3d-fx-sharpmode" class="text-xs font-medium text-gray-400 mb-1 block">Sharps & flats</label>
|
||||
<select id="keysh3d-fx-sharpmode"
|
||||
onchange="window.keys3dSetSharpMode && window.keys3dSetSharpMode(this.value)"
|
||||
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
|
||||
<option value="floating">Floating</option>
|
||||
<option value="flat">Non-floating</option>
|
||||
<option value="realistic" selected>Realistic key sizes (default — best with no colored lanes)</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How sharps and flats are drawn. <em>Floating</em>: they ride a raised
|
||||
plane above the naturals. <em>Non-floating</em>: everything on one
|
||||
plane, each key its own even piano-shaped lane. <em>Realistic key
|
||||
sizes</em>: one plane, bars sized like the real keys (full naturals,
|
||||
full black keys on top). Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-laneopacity" class="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Lane color opacity <span id="keysh3d-fx-laneopacity-val" class="text-gray-500 font-mono">0.00</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-laneopacity"
|
||||
min="0" max="1" step="0.05" value="0"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('laneOpacity', this.value); document.getElementById('keysh3d-fx-laneopacity-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly each lane is tinted its note color. 0.00 (default) is a
|
||||
dark floor with plain guide lines only between the key blocks (at E–F
|
||||
and each octave); the notes keep their colors and pop off the floor.
|
||||
Raise toward 1.00 for full, vivid colored lanes. Applies next time you
|
||||
open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavegaps" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-octavegaps" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('octaveGaps', this.checked)">
|
||||
Octave separators
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
Widen the gap a little at each octave boundary (every B to the C
|
||||
above it) so octaves are easier to read. Applies next time you open
|
||||
a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-octavecontrast" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
|
||||
Octave line contrast <span id="keysh3d-fx-octavecontrast-val" class="text-gray-500 font-mono">0.50</span>
|
||||
</label>
|
||||
<input type="range" id="keysh3d-fx-octavecontrast"
|
||||
min="0" max="1" step="0.05" value="0.5"
|
||||
oninput="window.keys3dSetFx && window.keys3dSetFx('octaveContrast', this.value); document.getElementById('keysh3d-fx-octavecontrast-val').textContent = parseFloat(this.value).toFixed(2)"
|
||||
class="w-full">
|
||||
<p class="text-xs text-gray-500 mt-1 mb-3">
|
||||
How strongly the octave line (every B to C) stands out. It adapts to
|
||||
the lane color opacity automatically — darkening the line against
|
||||
bright lanes and brightening it as you fade them toward the dark
|
||||
floor. Applies next time you open a song.
|
||||
</p>
|
||||
|
||||
<label for="keysh3d-fx-cinematic" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer">
|
||||
@@ -116,7 +242,7 @@
|
||||
<label for="keysh3d-fx-timing" class="flex items-center gap-2 text-xs text-gray-300 cursor-pointer mt-3">
|
||||
<input type="checkbox" id="keysh3d-fx-timing" checked
|
||||
onchange="window.keys3dSetFx && window.keys3dSetFx('timingFx', this.checked)">
|
||||
Timing colours
|
||||
Timing colors
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Tint the sparks by timing — on-time green, early cyan, late
|
||||
@@ -179,6 +305,9 @@
|
||||
hydrateFxBool('cinematic', 'keysh3d-fx-cinematic');
|
||||
hydrateFxBool('bgReactive', 'keysh3d-fx-bgreactive');
|
||||
hydrateFxBool('scoreFx', 'keysh3d-fx-scorefx');
|
||||
// Highway-layout: octaveGaps defaults ON (bool); laneOpacity /
|
||||
// octaveContrast are 0-1 sliders hydrated with hydrateFxRange below.
|
||||
hydrateFxBool('octaveGaps', 'keysh3d-fx-octavegaps');
|
||||
const hydrateFxRange = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
@@ -190,6 +319,26 @@
|
||||
hydrateFxRange('vibrancy', 'keysh3d-fx-vibrancy', 'keysh3d-fx-vibrancy-val');
|
||||
hydrateFxRange('glow', 'keysh3d-fx-glow', 'keysh3d-fx-glow-val');
|
||||
hydrateFxRange('bgIntensity', 'keysh3d-fx-bgintensity', 'keysh3d-fx-bgintensity-val');
|
||||
hydrateFxRange('laneOpacity', 'keysh3d-fx-laneopacity', 'keysh3d-fx-laneopacity-val');
|
||||
hydrateFxRange('octaveContrast', 'keysh3d-fx-octavecontrast', 'keysh3d-fx-octavecontrast-val');
|
||||
// Camera fine-tune sliders live outside 0-1 — clamp to the
|
||||
// control's own min/max (mirrors screen.js FX_RANGES).
|
||||
const hydrateFxRangeIn = (key, elId, valId) => {
|
||||
const n = parseFloat(localStorage.getItem('keys3d_bg_' + key));
|
||||
if (!Number.isFinite(n)) return;
|
||||
const el = document.getElementById(elId);
|
||||
const v = Math.min(parseFloat(el.max), Math.max(parseFloat(el.min), n));
|
||||
el.value = String(v);
|
||||
document.getElementById(valId).textContent = v.toFixed(2);
|
||||
};
|
||||
hydrateFxRangeIn('camHeight', 'keysh3d-fx-camheight', 'keysh3d-fx-camheight-val');
|
||||
hydrateFxRangeIn('camDist', 'keysh3d-fx-camdist', 'keysh3d-fx-camdist-val');
|
||||
hydrateFxRangeIn('camTilt', 'keysh3d-fx-camtilt', 'keysh3d-fx-camtilt-val');
|
||||
const storedCamera = localStorage.getItem('keys3d_bg_camera');
|
||||
const cameraSel = document.getElementById('keysh3d-fx-camera');
|
||||
if (storedCamera && Array.from(cameraSel.options).some(o => o.value === storedCamera)) {
|
||||
cameraSel.value = storedCamera;
|
||||
}
|
||||
const storedStyle = localStorage.getItem('keys3d_bg_style');
|
||||
const styleSel = document.getElementById('keysh3d-fx-bgstyle');
|
||||
if (storedStyle && Array.from(styleSel.options).some(o => o.value === storedStyle)) {
|
||||
@@ -200,6 +349,16 @@
|
||||
if (storedTheme && Array.from(themeSel.options).some(o => o.value === storedTheme)) {
|
||||
themeSel.value = storedTheme;
|
||||
}
|
||||
const storedPalette = localStorage.getItem('keys3d_bg_palette');
|
||||
const paletteSel = document.getElementById('keysh3d-fx-palette');
|
||||
if (storedPalette && Array.from(paletteSel.options).some(o => o.value === storedPalette)) {
|
||||
paletteSel.value = storedPalette;
|
||||
}
|
||||
const storedSharp = localStorage.getItem('keys3d_bg_sharpMode');
|
||||
const sharpSel = document.getElementById('keysh3d-fx-sharpmode');
|
||||
if (storedSharp && Array.from(sharpSel.options).some(o => o.value === storedSharp)) {
|
||||
sharpSel.value = storedSharp;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Keys-Hwy3D settings] hydration failed:', e);
|
||||
}
|
||||
|
||||
@@ -148,3 +148,405 @@ test('FX defaults: ambience + score FX ship enabled', () => {
|
||||
assert.equal(FX_DEFAULTS.bgIntensity, 0.5);
|
||||
assert.equal(FX_DEFAULTS.bgReactive, true);
|
||||
});
|
||||
|
||||
/* ── Note-colour palettes (feat/keys3d-note-palettes) ────────────────── */
|
||||
|
||||
test('note palettes: 12 entries each, classic IS the stock table', () => {
|
||||
const { NOTE_PALETTES, PITCH_CLASS_COLORS } =
|
||||
load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(NOTE_PALETTES),
|
||||
['classic', 'emerald', 'vivid', 'pastel', 'ice']);
|
||||
for (const [id, colors] of Object.entries(NOTE_PALETTES)) {
|
||||
assert.equal(colors.length, 12, id + ' has one colour per pitch class');
|
||||
for (const c of colors) {
|
||||
assert.ok(Number.isInteger(c) && c >= 0 && c <= 0xffffff,
|
||||
id + ' colours are 24-bit ints');
|
||||
}
|
||||
}
|
||||
// 'classic' preserves the shipped look byte-identically — it is the
|
||||
// same array, not a copy that could drift.
|
||||
assert.equal(NOTE_PALETTES.classic, PITCH_CLASS_COLORS);
|
||||
assert.equal(PITCH_CLASS_COLORS[0], 0xff3030); // C stays red in classic
|
||||
});
|
||||
|
||||
test('note palettes: two-tone tables use darker sharps than naturals', () => {
|
||||
const { NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
for (const id of ['emerald', 'ice']) {
|
||||
const p = NOTE_PALETTES[id];
|
||||
for (const sharp of [1, 3, 6, 8, 10]) {
|
||||
assert.ok(luma(p[sharp]) < luma(p[0]),
|
||||
id + ' sharp pc ' + sharp + ' darker than naturals');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readPaletteSetting: octaves default, validated overrides only', () => {
|
||||
// No localStorage in the vm → the plug-and-play default.
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(bare.readPaletteSetting(), 'octaves');
|
||||
// An explicit non-default value (classic) overrides.
|
||||
const store = { keys3d_bg_palette: 'classic' };
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
});
|
||||
const { readPaletteSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readPaletteSetting(), 'classic');
|
||||
// Corrupt/foreign value → the default rather than an undefined scheme.
|
||||
store.keys3d_bg_palette = 'banana';
|
||||
assert.equal(readPaletteSetting(), 'octaves');
|
||||
});
|
||||
|
||||
test('keys3dSetPalette: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetPalette('emerald');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.palette, 'emerald');
|
||||
// Unknown id: no write, no event.
|
||||
win.keys3dSetPalette('banana');
|
||||
assert.equal(store.keys3d_bg_palette, 'emerald');
|
||||
assert.equal(events.length, 1);
|
||||
// 'octaves' (procedural, not a 12-array) is a valid selectable id.
|
||||
win.keys3dSetPalette('octaves');
|
||||
assert.equal(store.keys3d_bg_palette, 'octaves');
|
||||
assert.equal(events.length, 2);
|
||||
});
|
||||
|
||||
test('PALETTE_IDS: the array palettes plus the procedural octaves scheme', () => {
|
||||
const { PALETTE_IDS, NOTE_PALETTES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...PALETTE_IDS],
|
||||
[...Object.keys(NOTE_PALETTES), 'octaves']);
|
||||
assert.ok(PALETTE_IDS.indexOf('octaves') !== -1);
|
||||
assert.ok(!('octaves' in NOTE_PALETTES)); // it is NOT a 12-entry table
|
||||
});
|
||||
|
||||
test('octaveNoteColor: hue steps per octave, loops, sharps darker, sub-C1 distinct', () => {
|
||||
const { octaveNoteColor, OCTAVE_HUES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const luma = (c) =>
|
||||
0.2126 * ((c >> 16) & 0xff) + 0.7152 * ((c >> 8) & 0xff) + 0.0722 * (c & 0xff);
|
||||
// C1 (midi 24) = first hue; C2 (36) = second; C8 (108) = 8th (index 7).
|
||||
assert.equal(octaveNoteColor(24), OCTAVE_HUES[0]); // C1 red
|
||||
assert.equal(octaveNoteColor(35), OCTAVE_HUES[0]); // B1 still octave 1
|
||||
assert.equal(octaveNoteColor(36), OCTAVE_HUES[1]); // C2 orange
|
||||
assert.equal(octaveNoteColor(60), OCTAVE_HUES[3]); // C4 (middle C)
|
||||
assert.equal(octaveNoteColor(108), OCTAVE_HUES[7]); // C8 last hue
|
||||
// Naturals across one octave (C1..B1 whites) all share the octave hue.
|
||||
for (const nat of [24, 26, 28, 29, 31, 33, 35]) {
|
||||
assert.equal(octaveNoteColor(nat), OCTAVE_HUES[0], 'natural ' + nat);
|
||||
}
|
||||
// Sharps in an octave are a DARKER shade of that same hue.
|
||||
for (const sharp of [25, 27, 30, 32, 34]) { // C#1..A#1
|
||||
assert.ok(luma(octaveNoteColor(sharp)) < luma(OCTAVE_HUES[0]),
|
||||
'sharp ' + sharp + ' darker than the octave natural');
|
||||
}
|
||||
// The three keys below C1 (A0/A#0/B0) share a distinct sub-C1 colour,
|
||||
// different from the red octave-1 start.
|
||||
assert.equal(octaveNoteColor(21), octaveNoteColor(23)); // A0 == B0 hue
|
||||
assert.notEqual(octaveNoteColor(21), OCTAVE_HUES[0]);
|
||||
// Loop: an octave past the table wraps (safety for out-of-88 midi).
|
||||
assert.equal(octaveNoteColor(24 + 12 * OCTAVE_HUES.length), OCTAVE_HUES[0]);
|
||||
});
|
||||
|
||||
/* ── Camera presets + fine-tune (feat/keys3d-camera) ─────────────────── */
|
||||
|
||||
test('FX defaults: camera height/distance/tilt all neutral (preset carries the tuned aim)', () => {
|
||||
const { FX_DEFAULTS, FX_RANGES } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.camHeight, 1.0);
|
||||
assert.equal(FX_DEFAULTS.camDist, 1.0);
|
||||
// Tilt ships NEUTRAL (0): the tuned plug-and-play aim now lives in
|
||||
// CAM_PRESETS.overhead.lookY, so the fine-tune only nudges from a preset
|
||||
// and 'classic' + this default reproduces the exact historical rig.
|
||||
assert.equal(FX_DEFAULTS.camTilt, 0.0);
|
||||
assert.ok(FX_DEFAULTS.camTilt >= FX_RANGES.camTilt[0] && FX_DEFAULTS.camTilt <= FX_RANGES.camTilt[1]);
|
||||
// Height/distance bracket 1 (can go lower AND higher); tilt spans 0.
|
||||
assert.ok(FX_RANGES.camHeight[0] < 1 && 1 < FX_RANGES.camHeight[1]);
|
||||
assert.ok(FX_RANGES.camDist[0] < 1 && 1 < FX_RANGES.camDist[1]);
|
||||
assert.ok(FX_RANGES.camTilt[0] < 0 && 0 < FX_RANGES.camTilt[1]);
|
||||
});
|
||||
|
||||
test('camTilt: negative values survive the clamp (down-tilt must be reachable)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
const { FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
win.keys3dSetFx('camTilt', -0.5);
|
||||
assert.equal(store.keys3d_bg_camTilt, '-0.5'); // NOT crushed to 0 by a 0-1 clamp
|
||||
win.keys3dSetFx('camTilt', -99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camTilt), FX_RANGES.camTilt[0]);
|
||||
});
|
||||
|
||||
test('FX ranges: reader + setter clamp to the declared range, not 0-1', () => {
|
||||
const store = { keys3d_bg_camHeight: '5', keys3d_bg_camDist: '0.01' };
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
const { readFxSettings, FX_RANGES } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
// Reader: corrupt/out-of-range writes clamp to the declared bounds.
|
||||
assert.equal(readFxSettings().camHeight, FX_RANGES.camHeight[1]);
|
||||
assert.equal(readFxSettings().camDist, FX_RANGES.camDist[0]);
|
||||
// Setter: same clamp on the way in; a value above 1 must survive
|
||||
// (the historical 0-1 clamp would have crushed 1.3 to 1).
|
||||
win.keys3dSetFx('camHeight', 1.3);
|
||||
assert.equal(store.keys3d_bg_camHeight, '1.3');
|
||||
win.keys3dSetFx('camDist', 99);
|
||||
assert.equal(parseFloat(store.keys3d_bg_camDist), FX_RANGES.camDist[1]);
|
||||
// Un-ranged keys keep the historical 0-1 clamp.
|
||||
win.keys3dSetFx('vibrancy', 2);
|
||||
assert.equal(store.keys3d_bg_vibrancy, '1');
|
||||
});
|
||||
|
||||
test('scrollZ: distance-to-hitline scales linearly with the speed argument', () => {
|
||||
const { scrollZ } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const hitZ = 0;
|
||||
const d1 = scrollZ(2, 0, hitZ, 130) - hitZ; // 2s ahead at stock speed
|
||||
const d2 = scrollZ(2, 0, hitZ, 260) - hitZ; // same note at 2x speed
|
||||
assert.equal(d2, d1 * 2);
|
||||
// At the hit moment the note is at the hit-line regardless of speed.
|
||||
assert.equal(scrollZ(5, 5, hitZ, 130), hitZ);
|
||||
assert.equal(scrollZ(5, 5, hitZ, 260), hitZ);
|
||||
});
|
||||
|
||||
test('camera presets: classic preserves the stock rig, overhead is the default', () => {
|
||||
const { CAM_PRESETS, readCameraSetting } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual(Object.keys(CAM_PRESETS), ['classic', 'elevated', 'overhead']);
|
||||
// 'classic' preserves the historical constants (pre-K units) even though
|
||||
// it is no longer the default — anyone who picks it gets the old rig back
|
||||
// EXACTLY, because camTilt now defaults to 0 (neutral): effective aim =
|
||||
// classic.lookY + 0*CAM_TILT_UNITS = 8, the historical LOOK_Y.
|
||||
assert.deepEqual({ ...CAM_PRESETS.classic },
|
||||
{ fov: 40, y: 46, z: 112, lookY: 8, lookZ: -165 });
|
||||
for (const [id, p] of Object.entries(CAM_PRESETS)) {
|
||||
for (const f of ['fov', 'y', 'z', 'lookY', 'lookZ']) {
|
||||
assert.ok(Number.isFinite(p[f]), id + '.' + f + ' is a number');
|
||||
}
|
||||
assert.ok(p.y > 0 && p.z > 0, id + ' sits above and behind the keys');
|
||||
}
|
||||
assert.equal(readCameraSetting(), 'overhead'); // no localStorage in the vm → tuned default
|
||||
});
|
||||
|
||||
test('camera default look is unchanged: overhead bakes the old tuned tilt, camTilt is neutral', () => {
|
||||
const { CAM_PRESETS, FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const CAM_TILT_UNITS = 55; // full-swing of the camTilt offset at ±1 (screen.js)
|
||||
// The shipped default look = overhead preset + the default camTilt. Before,
|
||||
// that was lookY 0 + (−0.6 × 55) = −33; the tuned aim now lives in the
|
||||
// preset (lookY −33) with a neutral camTilt (0), so the effective aim — and
|
||||
// thus the out-of-the-box framing — is byte-identical.
|
||||
const effOverhead = CAM_PRESETS.overhead.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effOverhead, -33);
|
||||
// 'classic' + the neutral default reproduces the historical LOOK_Y (8) —
|
||||
// the "pick Classic for the original look" promise, now actually true.
|
||||
const effClassic = CAM_PRESETS.classic.lookY + FX_DEFAULTS.camTilt * CAM_TILT_UNITS;
|
||||
assert.equal(effClassic, 8);
|
||||
});
|
||||
|
||||
test('keys3dSetCamera: persists + dispatches valid ids, ignores unknown', () => {
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class CustomEvent {
|
||||
constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; }
|
||||
},
|
||||
});
|
||||
win.keys3dSetCamera('overhead');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].type, 'keys3d:settings');
|
||||
assert.equal(events[0].detail.camera, 'overhead');
|
||||
win.keys3dSetCamera('helicopter');
|
||||
assert.equal(store.keys3d_bg_camera, 'overhead');
|
||||
assert.equal(events.length, 1);
|
||||
const { readCameraSetting } = win.slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
store.keys3d_bg_camera = 'garbage';
|
||||
assert.equal(readCameraSetting(), 'overhead');
|
||||
});
|
||||
|
||||
/* ── Flat-sharps / piano-shaped lanes (feat/keys3d-flat-lanes) ───────── */
|
||||
|
||||
test('FX defaults: octave separators on, lanes off (minimal default look)', () => {
|
||||
const { FX_DEFAULTS } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.equal(FX_DEFAULTS.octaveGaps, true); // octave separators ship on
|
||||
assert.equal(FX_DEFAULTS.laneOpacity, 0.0); // dark floor + guide lines by default
|
||||
assert.equal(FX_DEFAULTS.octaveContrast, 0.5);
|
||||
// Sharp LAYOUT is a string setting, not an FX bool.
|
||||
assert.equal('flatSharps' in FX_DEFAULTS, false);
|
||||
assert.equal('laneColors' in FX_DEFAULTS, false); // superseded by laneOpacity
|
||||
});
|
||||
|
||||
test('keys3dSetFx: highway-layout controls persist (bool + sliders)', () => {
|
||||
const store = {};
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: () => true,
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetFx('octaveGaps', true);
|
||||
assert.equal(store.keys3d_bg_octaveGaps, '1');
|
||||
// laneOpacity / octaveContrast are 0-1 numbers, persisted verbatim + clamped.
|
||||
win.keys3dSetFx('laneOpacity', 0.35);
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '0.35');
|
||||
win.keys3dSetFx('laneOpacity', 5); // clamps to the 0-1 range
|
||||
assert.equal(store.keys3d_bg_laneOpacity, '1');
|
||||
win.keys3dSetFx('octaveContrast', 0.8);
|
||||
assert.equal(store.keys3d_bg_octaveContrast, '0.8');
|
||||
});
|
||||
|
||||
test('sharpMode: realistic default, validated ids, persists + dispatches', () => {
|
||||
const bare = load().slopsmithViz_keys_highway_3d.__test;
|
||||
assert.deepEqual([...bare.SHARP_MODES], ['floating', 'flat', 'realistic']);
|
||||
assert.equal(bare.readSharpModeSetting(), 'realistic'); // no localStorage → default
|
||||
const store = {};
|
||||
const events = [];
|
||||
const win = load({
|
||||
localStorage: {
|
||||
getItem: (k) => (k in store ? store[k] : null),
|
||||
setItem: (k, v) => { store[k] = v; },
|
||||
},
|
||||
dispatchEvent: (ev) => { events.push(ev); return true; },
|
||||
CustomEvent: class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } },
|
||||
});
|
||||
win.keys3dSetSharpMode('flat'); // a non-default id, to exercise persistence
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events[0].detail.sharpMode, 'flat');
|
||||
assert.equal(win.slopsmithViz_keys_highway_3d.__test.readSharpModeSetting(), 'flat');
|
||||
// Unknown id ignored (no write, no event).
|
||||
win.keys3dSetSharpMode('bogus');
|
||||
assert.equal(store.keys3d_bg_sharpMode, 'flat');
|
||||
assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test('laneSpanFlat (V5): lanes tile with zero overlap and even the naturals', () => {
|
||||
const { laneSpanFlat, _isBlackPc } = load().slopsmithViz_keys_highway_3d.__test;
|
||||
const sh = 2.2, shift = 2.2 / 3;
|
||||
const dims = { whiteW: 12, sharpHalf: sh, shift, octGap: 0.9 }; // mirrors shipped LANE_DIMS_FLAT
|
||||
// cx for one octave: whites on integer slots, blacks on half-slots — the
|
||||
// same slot geometry keyLayout/keyX produce (cx = slot * whiteW=12).
|
||||
const CX = {
|
||||
60: 0, 61: 6, 62: 12, 63: 18, 64: 24, 65: 36, 66: 42,
|
||||
67: 48, 68: 54, 69: 60, 70: 66, 71: 72, 72: 84,
|
||||
};
|
||||
const midis = [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72];
|
||||
const spans = midis.map((m) => laneSpanFlat(m, _isBlackPc(m), CX[m], dims, false));
|
||||
const wOf = (s) => s.right - s.left;
|
||||
const w = (m) => wOf(spans[midis.indexOf(m)]);
|
||||
// Zero-overlap tiling: every lane abuts the previous one (no gap, no overlap).
|
||||
for (let i = 1; i < spans.length; i++) {
|
||||
assert.ok(Math.abs(spans[i].left - spans[i - 1].right) < 1e-9, 'lane ' + midis[i] + ' abuts');
|
||||
}
|
||||
// Sharps are all the same width.
|
||||
for (const m of [61, 63, 66, 68, 70]) {
|
||||
assert.ok(Math.abs(w(m) - 2 * sh) < 1e-9, 'sharp ' + m + ' width');
|
||||
}
|
||||
// The lean evens the naturals: C, D, E, F, B all come out equal.
|
||||
for (const m of [62, 64, 65, 71]) {
|
||||
assert.ok(Math.abs(w(m) - w(60)) < 1e-9, 'natural ' + m + ' == C (evened)');
|
||||
}
|
||||
// G and A are the only slightly-smaller naturals (G# can't lean) — still
|
||||
// clearly wider than a sharp, and MUCH closer to the rest than plain V2
|
||||
// (which would leave D at 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.3",
|
||||
"version": "1.3.4",
|
||||
"bundled": true,
|
||||
"private": false,
|
||||
"script": "screen.js",
|
||||
|
||||
@@ -869,8 +869,15 @@ window._tunerUI = function(state, actions) {
|
||||
btn.textContent = 'Tuner';
|
||||
btn.title = 'Open Tuner';
|
||||
btn.onclick = window.tuner.toggle;
|
||||
const closeBtn = isV3 ? null : controls.querySelector('button:last-child');
|
||||
if (closeBtn) controls.insertBefore(btn, closeBtn);
|
||||
// Anchor to the last DIRECT-child button of `controls` (the classic
|
||||
// transport's close/exit button). A bare `button:last-child` can match
|
||||
// a NESTED button that is not a direct child of `controls`, and
|
||||
// `insertBefore()` then throws NotFoundError — which propagated out of
|
||||
// the player-screen transition and aborted its render (feedBack#800).
|
||||
// `:scope > button:last-of-type` restricts the anchor to a direct child;
|
||||
// the parentNode check is a belt-and-suspenders guard before insertBefore.
|
||||
const closeBtn = isV3 ? null : controls.querySelector(':scope > button:last-of-type');
|
||||
if (closeBtn && closeBtn.parentNode === controls) controls.insertBefore(btn, closeBtn);
|
||||
else controls.appendChild(btn);
|
||||
updatePlayerButton();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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);
|
||||
+87
-11
@@ -1110,6 +1110,7 @@ const _LIB_VIEW_VALUES = new Set(['grid', 'tree', 'folder']);
|
||||
const _LIB_SORT_VALUES = new Set([
|
||||
'artist', 'artist-desc', 'title', 'title-desc',
|
||||
'recent', 'year-desc', 'year', 'tuning',
|
||||
'difficulty', 'difficulty-desc',
|
||||
]);
|
||||
const _LIB_FORMAT_VALUES = new Set(['', 'sloppak', 'loose']);
|
||||
// Tree-view expand/collapse persistence. Three states per tree:
|
||||
@@ -2078,6 +2079,7 @@ function renderGridCards(songs, containerId = 'lib-grid', mode = 'replace') {
|
||||
${(() => { const _nm = _getArrangementNamingMode(); return (song.arrangements || []).map(a => _arrangementBadgeHtml(a, _nm)).join(''); })()}
|
||||
${tuning ? `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>` : ''}
|
||||
${song.has_lyrics ? `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>` : ''}
|
||||
${song.user_difficulty != null ? `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>` : ''}
|
||||
${duration ? `<span class="text-gray-600">${duration}</span>` : ''}
|
||||
</div>
|
||||
${retuneBtn}
|
||||
@@ -2277,6 +2279,8 @@ async function renderTreeInto(containerId, countId, stats, letter, q, favoritesO
|
||||
html += `<span class="px-1.5 py-0.5 rounded ${tuning === 'E Standard' ? 'bg-green-900/30 text-green-400' : 'bg-yellow-900/30 text-yellow-400'}">${esc(tuning)}</span>`;
|
||||
if (song.has_lyrics)
|
||||
html += `<span class="px-1.5 py-0.5 bg-purple-900/30 rounded text-purple-300">Lyrics</span>`;
|
||||
if (song.user_difficulty != null)
|
||||
html += `<span class="px-1.5 py-0.5 bg-blue-900/30 rounded text-blue-300" title="Your difficulty rating">◆${esc(song.user_difficulty)}</span>`;
|
||||
if (duration)
|
||||
html += `<span class="text-gray-600 w-10 text-right">${duration}</span>`;
|
||||
if (stdRetune)
|
||||
@@ -2758,6 +2762,12 @@ function goFavTreePage(p) {
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
let _defaultArrangement = '';
|
||||
|
||||
const INSTRUMENT_PATHWAYS = ['songs', 'practice', 'learn', 'studio'];
|
||||
|
||||
function _normalizeInstrumentPathway(value) {
|
||||
return INSTRUMENT_PATHWAYS.includes(value) ? value : 'songs';
|
||||
}
|
||||
|
||||
function _syncDefaultArrangementSelect(value) {
|
||||
const sel = document.getElementById('default-arrangement');
|
||||
if (!sel) return;
|
||||
@@ -2864,6 +2874,10 @@ 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
|
||||
@@ -2900,6 +2914,10 @@ const HWC_PRESETS = [
|
||||
id: 'colorblind', label: 'Colorblind-friendly',
|
||||
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
|
||||
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
|
||||
},
|
||||
{
|
||||
id: 'neon', label: 'Neon',
|
||||
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
|
||||
@@ -3410,6 +3428,8 @@ async function loadSettings() {
|
||||
if (dlcEl) dlcEl.value = data.dlc_dir || '';
|
||||
_defaultArrangement = data.default_arrangement || '';
|
||||
_syncDefaultArrangementSelect(_defaultArrangement);
|
||||
const pathwayEl = document.getElementById('setting-instrument-pathway');
|
||||
if (pathwayEl) pathwayEl.value = _normalizeInstrumentPathway(data.pathway);
|
||||
const demucsEl = document.getElementById('demucs-server-url');
|
||||
if (demucsEl) demucsEl.value = data.demucs_server_url || '';
|
||||
const leftyEl = document.getElementById('setting-lefty');
|
||||
@@ -3901,6 +3921,18 @@ function persistSetting(key, value) {
|
||||
_settingSaveChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
function setInstrumentPathway(value) {
|
||||
const pathway = _normalizeInstrumentPathway(value);
|
||||
const el = document.getElementById('setting-instrument-pathway');
|
||||
if (el) el.value = pathway;
|
||||
persistSetting('pathway', pathway).then(() => {
|
||||
if (window.v3Badges && typeof window.v3Badges.reload === 'function') {
|
||||
try { window.v3Badges.reload(); } catch (_) { /* noop */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function _postSetting(key, value) {
|
||||
const status = document.getElementById('settings-status');
|
||||
try {
|
||||
@@ -4351,7 +4383,7 @@ async function uploadSongs(fileList) {
|
||||
if (lower.endsWith('.feedpak') || lower.endsWith('.sloppak')) {
|
||||
files.push(f);
|
||||
} else {
|
||||
failures.push(`${f.name}: only .feedpak accepted`);
|
||||
failures.push(`${f.name}: only .feedpak or .sloppak accepted`);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) {
|
||||
@@ -5536,9 +5568,24 @@ function _playbackApi() {
|
||||
: null;
|
||||
}
|
||||
|
||||
// Bridge hits are a "this legacy surface is still in use" signal, not a call
|
||||
// counter — but recordBridgeHit is not cheap (compat-shim bookkeeping, a
|
||||
// playback:bridge-hit event, and a diagnostics snapshot rebuild per call).
|
||||
// Plugins legitimately poll read surfaces like window.feedBack.getLoop() from
|
||||
// HUD ticks (note_detect polled at ~30 Hz), which turned every tick into a
|
||||
// snapshot serialization on the main thread and saturated the inspector's
|
||||
// hitCount. Throttle per surface: the first call records immediately, repeats
|
||||
// within the window are dropped.
|
||||
const _bridgeRecordLast = new Map();
|
||||
const _BRIDGE_RECORD_MIN_MS = 5000;
|
||||
function _recordPlaybackBridge(bridgeId, legacySurface, reason) {
|
||||
const playback = _playbackApi();
|
||||
if (!playback || typeof playback.recordBridgeHit !== 'function') return;
|
||||
const key = `${bridgeId}|${legacySurface}`;
|
||||
const now = Date.now();
|
||||
const last = _bridgeRecordLast.get(key);
|
||||
if (last != null && now - last < _BRIDGE_RECORD_MIN_MS) return;
|
||||
_bridgeRecordLast.set(key, now);
|
||||
playback.recordBridgeHit({
|
||||
bridgeId,
|
||||
legacySurface,
|
||||
@@ -7912,6 +7959,10 @@ function setLoopEnd() {
|
||||
if (loopB <= loopA) { loopB = null; return; }
|
||||
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
|
||||
updateLoopUI();
|
||||
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
|
||||
// transport event so event-driven consumers (note_detect drill sync) see
|
||||
// button-armed loops without having to poll getLoop().
|
||||
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
|
||||
}
|
||||
|
||||
function clearLoop(options) {
|
||||
@@ -8072,6 +8123,26 @@ function _resolveEditRegion() {
|
||||
return { a: Math.max(0, t - 4), b: t + 4 };
|
||||
}
|
||||
|
||||
/* @pure:editor-pending-view:start */
|
||||
function _buildEditorPendingViewPure(filename, arrangement, region, opts) {
|
||||
const options = opts || {};
|
||||
const view = {
|
||||
filename,
|
||||
arrangement: Number.isFinite(arrangement) && arrangement >= 0 ? arrangement : 0,
|
||||
barSel: region ? { startTime: region.a, endTime: region.b } : null,
|
||||
};
|
||||
if (options.returnToHighway) view.returnToHighway = true;
|
||||
if (typeof options.cursorTime === 'number') {
|
||||
view.cursorTime = options.cursorTime;
|
||||
} else if (region && typeof region.a === 'number') {
|
||||
view.cursorTime = region.a;
|
||||
}
|
||||
if (typeof options.scrollX === 'number') view.scrollX = Math.max(0, options.scrollX);
|
||||
if (typeof options.zoom === 'number' && options.zoom > 0) view.zoom = options.zoom;
|
||||
return view;
|
||||
}
|
||||
/* @pure:editor-pending-view:end */
|
||||
|
||||
// Enable "Edit region" whenever the editor plugin is present and a song is
|
||||
// loaded; show "↩ Editor" only while a return context is pending.
|
||||
function _updateEditRegionBtn() {
|
||||
@@ -8098,12 +8169,9 @@ function editRegionInEditor() {
|
||||
arrangement = si.arrangement_index;
|
||||
}
|
||||
} catch (_) { /* default to 0 */ }
|
||||
window._editorPendingView = {
|
||||
filename: currentFilename,
|
||||
arrangement,
|
||||
barSel: { startTime: region.a, endTime: region.b },
|
||||
window._editorPendingView = _buildEditorPendingViewPure(currentFilename, arrangement, region, {
|
||||
returnToHighway: true,
|
||||
};
|
||||
});
|
||||
window.editSong(currentFilename);
|
||||
}
|
||||
window.editRegionInEditor = editRegionInEditor;
|
||||
@@ -8115,14 +8183,14 @@ function returnToEditorFromHighway() {
|
||||
const ctx = window._highwayReturnCtx;
|
||||
if (!ctx || typeof window.editSong !== 'function') return;
|
||||
window._highwayReturnCtx = null;
|
||||
window._editorPendingView = {
|
||||
filename: ctx.filename,
|
||||
arrangement: ctx.arrangement,
|
||||
const region = ctx.barSel
|
||||
? { a: ctx.barSel.startTime, b: ctx.barSel.endTime }
|
||||
: null;
|
||||
window._editorPendingView = _buildEditorPendingViewPure(ctx.filename, ctx.arrangement, region, {
|
||||
scrollX: ctx.scrollX,
|
||||
zoom: ctx.zoom,
|
||||
cursorTime: ctx.cursorTime,
|
||||
barSel: ctx.barSel,
|
||||
};
|
||||
});
|
||||
window.editSong(ctx.filename);
|
||||
}
|
||||
window.returnToEditorFromHighway = returnToEditorFromHighway;
|
||||
@@ -11499,6 +11567,14 @@ async function loadPlugins() {
|
||||
// URL ?v=mtime convention elsewhere in this file).
|
||||
const v = encodeURIComponent(wantedVersion);
|
||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
||||
// Module-migration (R0): a migrated plugin declares
|
||||
// scriptType:"module" and its screen.js is `import
|
||||
// './src/main.js'`. A <script type="module"> fires load
|
||||
// only after its whole static-import graph evaluates, so
|
||||
// the await-onload completion + _loadingPluginId contract
|
||||
// below is preserved (a classic-IIFE dynamic import()
|
||||
// would not). Classic plugins are unaffected.
|
||||
if (plugin.script_type === 'module') script.type = 'module';
|
||||
script.dataset.pluginId = plugin.id;
|
||||
script.dataset.pluginVersion = wantedVersion;
|
||||
window.feedBack._loadingPluginId = plugin.id;
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
return fetch('/api/tunings')
|
||||
.then(function (r) { return r && r.ok ? r.json() : null; })
|
||||
.then(function (t) {
|
||||
const byName = t && t[key];
|
||||
const byName = t && ((t.tunings && t.tunings[key]) || t[key]);
|
||||
commit(byName ? _offsetsFromFreqs(byName[s.tuning], byName.Standard) : null);
|
||||
})
|
||||
.catch(function () { commit(null); });
|
||||
|
||||
+24
-10
@@ -2004,18 +2004,32 @@ function createHighway() {
|
||||
const seedBase = (_frameIdx + n.s + ((n.t * 60) | 0)) | 0;
|
||||
ctx.save();
|
||||
ctx.fillStyle = col;
|
||||
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();
|
||||
// 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);
|
||||
// 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';
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
<option value="year-desc">Year (newest)</option>
|
||||
<option value="year">Year (oldest)</option>
|
||||
<option value="tuning">Tuning</option>
|
||||
<option value="difficulty">Difficulty (easiest first)</option>
|
||||
<option value="difficulty-desc">Difficulty (hardest first)</option>
|
||||
</select>
|
||||
<!-- Format filter (shared) -->
|
||||
<select id="lib-format" onchange="sortLibrary()"
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+79
-5
@@ -21,7 +21,13 @@
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5] };
|
||||
const STRING_COUNTS = { guitar: [6, 7, 8], bass: [4, 5, 6] };
|
||||
const PATHWAY_OPTIONS = [
|
||||
{ id: 'songs', label: 'Songs' },
|
||||
{ id: 'practice', label: 'Practice' },
|
||||
{ id: 'learn', label: 'Learn' },
|
||||
{ id: 'studio', label: 'Studio' },
|
||||
];
|
||||
// Tuning names per instrument key (e.g. 'guitar-6', 'bass-4'), loaded from
|
||||
// GET /api/tunings. Falls back to empty arrays until the fetch resolves.
|
||||
let _tuningsByKey = {};
|
||||
@@ -106,7 +112,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440 };
|
||||
let settings = { instrument: 'guitar', string_count: 6, tuning: 'Standard', reference_pitch: 440, pathway: 'songs', instrument_profiles: {}, active_instrument_profile: 'guitar-lead' };
|
||||
|
||||
async function loadTunings() {
|
||||
try {
|
||||
@@ -126,6 +132,15 @@
|
||||
} catch (_) { /* non-fatal — TUNINGS falls back to empty, dropdown shows nothing */ }
|
||||
}
|
||||
|
||||
function pathwayForProfile(profiles, profileId, fallback) {
|
||||
const p = profiles && profiles[profileId];
|
||||
return p && PATHWAY_OPTIONS.some((o) => o.id === p.pathway) ? p.pathway : (fallback || 'songs');
|
||||
}
|
||||
|
||||
function profileIdForInstrument(inst) {
|
||||
return inst === 'bass' ? 'bass' : 'guitar-lead';
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const r = await fetch('/api/settings');
|
||||
@@ -150,16 +165,34 @@
|
||||
if (typeof s.tuning === 'string') tuning = tunings.includes(s.tuning) ? s.tuning : (tunings[0] || 'Standard');
|
||||
else if (Array.isArray(s.tuning)) tuning = s.tuning;
|
||||
else tuning = tunings[0] || 'Standard';
|
||||
const profiles = s.instrument_profiles && typeof s.instrument_profiles === 'object' ? s.instrument_profiles : {};
|
||||
const pathway = PATHWAY_OPTIONS.some((o) => o.id === s.pathway) ? s.pathway : 'songs';
|
||||
settings = {
|
||||
instrument: instrument,
|
||||
string_count: scValid,
|
||||
tuning: tuning,
|
||||
reference_pitch: Math.min(450, Math.max(430, ref)),
|
||||
pathway: pathway,
|
||||
instrument_profiles: profiles,
|
||||
active_instrument_profile: typeof s.active_instrument_profile === 'string' ? s.active_instrument_profile : profileIdForInstrument(instrument),
|
||||
};
|
||||
}
|
||||
} catch (e) { /* settings endpoint always present */ }
|
||||
}
|
||||
|
||||
function syncLocalProfilePatch(patch) {
|
||||
const profileId = profileIdForInstrument(patch.instrument || settings.instrument);
|
||||
if (!settings.instrument_profiles || typeof settings.instrument_profiles !== 'object') settings.instrument_profiles = {};
|
||||
if (patch.instrument) settings.active_instrument_profile = profileId;
|
||||
const profile = Object.assign({}, settings.instrument_profiles[profileId] || {});
|
||||
let changed = false;
|
||||
if (patch.instrument) { profile.instrument = patch.instrument; changed = true; }
|
||||
if (patch.string_count != null) { profile.string_count = patch.string_count; changed = true; }
|
||||
if (patch.tuning != null) { profile.tuning = patch.tuning; changed = true; }
|
||||
if (patch.reference_pitch != null) { profile.reference_pitch = patch.reference_pitch; changed = true; }
|
||||
if (patch.pathway != null) { profile.pathway = patch.pathway; changed = true; }
|
||||
if (changed) settings.instrument_profiles[profileId] = profile;
|
||||
}
|
||||
async function saveSettings(patch) {
|
||||
// Only adopt the patch once the server accepts it. /api/settings returns
|
||||
// {error: ...} with HTTP 200 on a validation failure, so a rejected
|
||||
@@ -177,8 +210,9 @@
|
||||
} catch (e) { /* non-fatal — leave settings unchanged */ }
|
||||
if (!accepted) return false;
|
||||
Object.assign(settings, patch);
|
||||
syncLocalProfilePatch(patch);
|
||||
if (sm && sm.emit) sm.emit('instrument:changed', {
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning,
|
||||
instrument: settings.instrument, stringCount: settings.string_count, tuning: settings.tuning, pathway: settings.pathway,
|
||||
});
|
||||
pushToTuner();
|
||||
renderTuner(); // reflect new tuning on the tuner card
|
||||
@@ -417,6 +451,12 @@
|
||||
pill('inst', v, v[0].toUpperCase() + v.slice(1), settings.instrument === v)).join('')) +
|
||||
instRow('Strings', STRING_COUNTS[settings.instrument].map((v) =>
|
||||
pill('strings', v, v + '', settings.string_count === v)).join('')) +
|
||||
// Handedness — a left-hander flips the whole highway (frets mirror).
|
||||
// Lives with the other player-orientation choices so it's part of the
|
||||
// same "Choose your instrument" step the onboarding tour spotlights —
|
||||
// i.e. set before you ever tune up or calibrate.
|
||||
instRow('Handedness', pill('hand', 'right', 'Right', !_leftyPref()) +
|
||||
pill('hand', 'left', 'Left', _leftyPref())) +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Tuning</div>' +
|
||||
'<select data-inst-tuning class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-xs text-fb-text outline-none focus:border-fb-primary">' +
|
||||
// An offset-array tuning has no named option — surface it as a
|
||||
@@ -424,6 +464,9 @@
|
||||
// (picking a named tuning still works and replaces the custom one).
|
||||
(typeof settings.tuning === 'string' ? '' : '<option selected disabled>Custom</option>') +
|
||||
_tuningsForInstrument(settings.instrument, settings.string_count).map((t) => '<option' + (t === settings.tuning ? ' selected' : '') + '>' + esc(t) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">Pathway</div>' +
|
||||
'<select data-inst-pathway 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">' +
|
||||
PATHWAY_OPTIONS.map((p) => '<option value="' + esc(p.id) + '"' + (p.id === settings.pathway ? ' selected' : '') + '>' + esc(p.label) + '</option>').join('') + '</select></div>' +
|
||||
'<div><div class="flex justify-between text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1"><span>Reference pitch</span><span data-ref-val>' + settings.reference_pitch + ' Hz</span></div>' +
|
||||
'<input data-inst-ref type="range" min="430" max="450" step="1" value="' + settings.reference_pitch + '" class="w-full slider-input"></div>' +
|
||||
'</div></div>';
|
||||
@@ -454,6 +497,7 @@
|
||||
instrument: v,
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
pathway: pathwayForProfile(settings.instrument_profiles, profileIdForInstrument(v), settings.pathway),
|
||||
});
|
||||
// Only move the working-tuning context once the switch was actually persisted —
|
||||
// otherwise the selector stays on the old instrument while the card shows the
|
||||
@@ -462,11 +506,25 @@
|
||||
renderInstrument(); keepOpen();
|
||||
}));
|
||||
menu.querySelectorAll('[data-pill="strings"]').forEach((b) => b.addEventListener('click', async () => {
|
||||
await saveSettings({ string_count: Number(b.getAttribute('data-val')) });
|
||||
setWorkingInstrument(settings.instrument, settings.string_count);
|
||||
const newSc = Number(b.getAttribute('data-val'));
|
||||
// Clamp the tuning to one valid for the new string count and post it
|
||||
// alongside string_count — otherwise the backend silently resets a
|
||||
// now-invalid tuning to Standard while this UI keeps showing the old
|
||||
// one (settings/tuner desync). Mirrors the instrument-switch clamp.
|
||||
const tunings = _tuningsForInstrument(settings.instrument, newSc);
|
||||
await saveSettings({
|
||||
string_count: newSc,
|
||||
tuning: tunings.includes(settings.tuning) ? settings.tuning : (tunings[0] || settings.tuning),
|
||||
});
|
||||
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]');
|
||||
ref.addEventListener('input', (e) => { menu.querySelector('[data-ref-val]').textContent = e.target.value + ' Hz'; });
|
||||
ref.addEventListener('change', (e) => saveSettings({ reference_pitch: Number(e.target.value) }));
|
||||
@@ -480,6 +538,22 @@
|
||||
function instRow(label, inner) {
|
||||
return '<div><div class="text-[0.625rem] uppercase tracking-wider text-fb-textDim mb-1">' + label + '</div><div class="flex flex-wrap gap-1">' + inner + '</div></div>';
|
||||
}
|
||||
// Handedness (left-handed) preference. The canonical store is the highway's
|
||||
// `lefty` localStorage key; when a live highway exists, setLefty() also flips
|
||||
// it immediately. Feature-detected so it works on the dashboard before any
|
||||
// highway has been created (the value is read on the highway's next init).
|
||||
function _leftyPref() {
|
||||
try { if (window.highway && typeof window.highway.getLefty === 'function') return !!window.highway.getLefty(); } catch (_) { /* */ }
|
||||
try { return localStorage.getItem('lefty') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
function _setLeftyPref(on) {
|
||||
try {
|
||||
if (window.highway && typeof window.highway.setLefty === 'function') window.highway.setLefty(!!on);
|
||||
else localStorage.setItem('lefty', on ? '1' : '0');
|
||||
} catch (_) { /* storage blocked — the pill still reflects the choice via re-render */ }
|
||||
// Keep the Settings "Left-handed" checkbox in sync when it's mounted.
|
||||
try { const cb = document.getElementById('setting-lefty'); if (cb) cb.checked = !!on; } catch (_) { /* */ }
|
||||
}
|
||||
function pill(group, val, label, active) {
|
||||
return '<button type="button" data-pill="' + group + '" data-val="' + val + '" class="px-2 py-1 rounded-md text-xs ' +
|
||||
(active ? 'bg-fb-primary text-white' : 'bg-gray-800/50 text-fb-textDim hover:text-fb-text') + '">' + esc(label) + '</button>';
|
||||
|
||||
@@ -138,6 +138,17 @@
|
||||
'<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + SKELETON_TILE + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim pt-2">Fetching covers… the source is rate-limited.</div>' +
|
||||
'</div>' +
|
||||
// Search Cover Art Archive — find an album cover even when the song has
|
||||
// no match (the auto candidates above are empty then). Pre-filled from
|
||||
// the song's artist + album/title; the source is rate-limited.
|
||||
'<div class="space-y-2 pt-1">' +
|
||||
'<div class="text-xs font-semibold uppercase tracking-wider text-fb-textDim">Search covers</div>' +
|
||||
'<div class="flex gap-2">' +
|
||||
'<input data-ip-search-input type="text" value="' + esc(_cur.query || '') + '" placeholder="artist album" class="flex-1 bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1 text-sm text-fb-text outline-none focus:border-fb-primary">' +
|
||||
'<button data-ip-search-go class="text-sm text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-md px-3">Search</button>' +
|
||||
'</div>' +
|
||||
'<div data-ip-search-results class="flex flex-wrap gap-3"></div>' +
|
||||
'</div>' +
|
||||
'<div data-ip-status class="hidden text-xs text-fb-accent"></div>' +
|
||||
'</div></div>' +
|
||||
'<input type="file" accept="image/*" data-ip-file class="hidden">';
|
||||
@@ -185,9 +196,47 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchInput = panel.querySelector('[data-ip-search-input]');
|
||||
const runSearch = () => coverSearch(panel, (searchInput && searchInput.value) || '');
|
||||
panel.querySelector('[data-ip-search-go]')?.addEventListener('click', runSearch);
|
||||
searchInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
|
||||
panel.querySelector('[data-ip-close]')?.focus();
|
||||
}
|
||||
|
||||
// Search Cover Art Archive (via the song-scoped cover-search endpoint) and
|
||||
// render the album covers as pickable tiles — the same apply('url') path as
|
||||
// the auto candidates. Covers with no CAA art self-hide (img onerror).
|
||||
async function coverSearch(panel, query) {
|
||||
const out = panel.querySelector('[data-ip-search-results]');
|
||||
const fn = _cur && _cur.filename;
|
||||
if (!out || !fn) return;
|
||||
out.innerHTML = '<div class="flex flex-wrap gap-3">' + SKELETON_TILE + SKELETON_TILE + '</div>';
|
||||
let body = null;
|
||||
try {
|
||||
const r = await fetch('/api/song/' + enc(fn) + '/art/cover-search?q=' + enc(String(query).trim()));
|
||||
if (r.ok) body = await r.json();
|
||||
} catch (_) { /* falls through to the empty state */ }
|
||||
if (!_cur || _cur.filename !== fn) return; // closed / changed song while searching
|
||||
const covers = (body && body.covers) || [];
|
||||
if (!covers.length) {
|
||||
out.innerHTML = '<div class="text-xs text-fb-textDim">' +
|
||||
((body && body.error) ? 'Cover search is unavailable right now.' : 'No covers found — try a different search.') +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = covers.map((c, i) =>
|
||||
tileHtml('data-ip-cover="' + i + '"', imgFace(c.thumb_url), c.label || 'Cover')).join('');
|
||||
out.querySelectorAll('[data-ip-cover]').forEach((btn) => {
|
||||
const img = btn.querySelector('img');
|
||||
if (img) img.onerror = () => btn.classList.add('hidden'); // no CAA art for this album → hide
|
||||
btn.addEventListener('click', () => {
|
||||
if (_busy) return;
|
||||
const c = covers[Number(btn.getAttribute('data-ip-cover'))];
|
||||
if (c) apply('url', c.thumb_url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The one candidates fetch, cancelled if the modal closes first. Failure
|
||||
// (offline, demo mode, aborted) is silent: the skeletons just clear and
|
||||
// the instant tiles remain — never an error wall.
|
||||
@@ -281,7 +330,13 @@
|
||||
const filename = opts && opts.filename;
|
||||
if (!filename) return;
|
||||
_lastFocus = document.activeElement;
|
||||
_cur = { filename: filename, title: (opts && opts.title) || filename };
|
||||
const title = (opts && opts.title) || filename;
|
||||
const artist = (opts && opts.artist) || '';
|
||||
const album = (opts && opts.album) || '';
|
||||
// Pre-fill the cover search: "artist album" when the album is known, else
|
||||
// just the artist, else the title — the server default backs it up.
|
||||
const query = [artist, album].filter(Boolean).join(' ').trim() || title;
|
||||
_cur = { filename: filename, title: title, query: query };
|
||||
_busy = false;
|
||||
const m = ensureModal();
|
||||
const panel = document.getElementById('v3-imgpick-panel');
|
||||
|
||||
+30
-1
@@ -194,7 +194,7 @@
|
||||
|
||||
<!-- Hidden file input shared by the navbar "Upload" link. Kept at body
|
||||
level so it stays reachable regardless of which screen is active. -->
|
||||
<input type="file" id="upload-songs-file" accept=".sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
<input type="file" id="upload-songs-file" accept=".feedpak,.sloppak" multiple class="hidden" onchange="uploadSongs(this.files); this.value=''">
|
||||
|
||||
<!-- ══ HOME (Hero + Library) — reused as the v3 "Songs" screen ════════ -->
|
||||
<div id="home" class="screen">
|
||||
@@ -429,6 +429,23 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Instrument pathway -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
<div class="fb-srow-main">
|
||||
<div class="fb-srow-title">Instrument pathway</div>
|
||||
<div class="fb-srow-desc">Preferred path for the selected instrument. This is remembered per instrument profile.</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<select id="setting-instrument-pathway" onchange="setInstrumentPathway(this.value)"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-3 py-2.5 text-sm text-gray-300 outline-none">
|
||||
<option value="songs">Songs</option>
|
||||
<option value="practice">Practice</option>
|
||||
<option value="learn">Learn</option>
|
||||
<option value="studio">Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Arrangement routes (naming mode) -->
|
||||
<div class="fb-srow">
|
||||
<span class="fb-srow-icon"><svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"/></svg></span>
|
||||
@@ -624,7 +641,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="fb-srow-control">
|
||||
<!-- Autosave on blur/enter via a single-key POST (like every other v3
|
||||
setting), so setting the address never depends on the shared Save
|
||||
button — whose bundled dlc_dir could otherwise block it. Save button
|
||||
kept for discoverability. -->
|
||||
<input type="text" id="demucs-server-url" placeholder="http://192.168.1.100:7865"
|
||||
onchange="persistSetting('demucs_server_url', this.value.trim())"
|
||||
class="bg-dark-700 border border-gray-800 rounded-xl px-4 py-2.5 text-sm text-gray-300 placeholder-gray-600 focus:border-accent/50 outline-none">
|
||||
<button onclick="saveSettings()" class="bg-accent hover:bg-accent-light px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition">Save</button>
|
||||
</div>
|
||||
@@ -771,6 +793,13 @@
|
||||
</div>
|
||||
<div class="text-[11px] text-gray-600 mt-1">What a confident match may fill in on its own — matches you confirm in the review queue always apply in full.</div>
|
||||
</div>
|
||||
<!-- Audio fingerprint (AcoustID) — opt-in, default OFF. Wired by match-review.js. -->
|
||||
<div class="fb-srow-wide mb-1">
|
||||
<div class="text-[10px] uppercase tracking-wide text-gray-500 mb-1">Audio fingerprint (AcoustID)</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-400 mb-1"><input type="checkbox" id="acoustid-enabled" class="rounded border-gray-600 bg-dark-700 text-accent"> Identify by audio — reads the recording itself for the exact version (studio vs live/extended)</label>
|
||||
<input type="text" id="acoustid-api-key" placeholder="AcoustID application key" class="w-full bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
<div class="text-[11px] text-gray-600 mt-1">Opt-in. Get a free key at acoustid.org/new-application; the fpcalc (Chromaprint) binary must be on the server's PATH.</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mb-1 text-xs text-gray-400 fb-srow-wide">
|
||||
<label class="flex items-center gap-2">Review queue order
|
||||
<select id="enrich-review-order" class="bg-dark-700 border border-gray-800 rounded-xl px-2 py-1.5 text-xs text-gray-300 outline-none">
|
||||
|
||||
+417
-48
@@ -131,7 +131,8 @@
|
||||
let _queue = [];
|
||||
let _idx = 0;
|
||||
let _lastFocus = null;
|
||||
let _single = false; // Fix-match mode: one song, no queue navigation
|
||||
let _single = false; // Fix-metadata mode: one song, no queue navigation
|
||||
let _tab = 'details'; // active tab in single mode: details | cover | match
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('v3-match-modal');
|
||||
@@ -180,14 +181,17 @@
|
||||
loadQueue();
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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 || '',
|
||||
@@ -198,10 +202,7 @@
|
||||
const m = ensureModal();
|
||||
m.classList.remove('hidden');
|
||||
document.getElementById('v3-match-overlay')?.classList.remove('hidden');
|
||||
renderCurrent();
|
||||
// Straight to the point: the search panel is why this mode exists.
|
||||
document.getElementById('v3-match-panel')
|
||||
?.querySelector('[data-mr-search-toggle]')?.click();
|
||||
renderCurrent(); // _single ⇒ renderTabbed()
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -214,7 +215,7 @@
|
||||
}
|
||||
|
||||
function nav(step) {
|
||||
if (!_queue.length) return;
|
||||
if (_single || !_queue.length) return; // single mode has no queue to page
|
||||
_idx = Math.min(Math.max(_idx + step, 0), _queue.length - 1);
|
||||
renderCurrent();
|
||||
}
|
||||
@@ -302,20 +303,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>';
|
||||
}
|
||||
|
||||
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;
|
||||
// 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) {
|
||||
const sub = [song.artist, song.album, song.year, fmtDur(song.duration)].filter(Boolean).join(' · ');
|
||||
|
||||
panel.innerHTML = headerHtml() +
|
||||
'<div class="p-5 space-y-4 overflow-y-auto v3-scroll">' +
|
||||
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">' +
|
||||
// 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">' +
|
||||
@@ -325,82 +326,391 @@
|
||||
'<div class="text-xs text-fb-textDim/70 truncate" title="' + esc(song.filename) + '">' + esc(song.filename) + '</div>' +
|
||||
missingChips(song) +
|
||||
'</div></div>' +
|
||||
// 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">' +
|
||||
(noCands
|
||||
? ''
|
||||
: '<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-instead panel
|
||||
'<div data-mr-search-panel class="hidden space-y-2">' +
|
||||
'</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 class="flex gap-2">' +
|
||||
'<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">' +
|
||||
'<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">' +
|
||||
'<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. 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>';
|
||||
}
|
||||
|
||||
// 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 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></div>' +
|
||||
'<button data-mr-search-toggle class="text-sm text-fb-textDim hover:text-fb-text">Search instead…</button>' +
|
||||
'<button data-mr-identify class="text-sm text-fb-primary hover:text-fb-primaryHi" title="Fingerprint this song\'s audio to find the exact recording">Identify by audio</button></div>' +
|
||||
'<div class="flex items-center gap-2">' +
|
||||
(_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 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);
|
||||
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 = panel.querySelector('[data-mr-art]');
|
||||
const img = root.querySelector('[data-mr-art]');
|
||||
if (img) img.onerror = () => {
|
||||
img.style.visibility = 'hidden';
|
||||
if (!song._artMissing) { song._artMissing = true; renderCurrent(); }
|
||||
};
|
||||
panel.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
root.querySelectorAll('[data-mr-cand]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
song._sel = Number(btn.getAttribute('data-mr-cand'));
|
||||
renderCurrent();
|
||||
});
|
||||
});
|
||||
panel.querySelector('[data-mr-accept]')?.addEventListener('click', async () => {
|
||||
root.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);
|
||||
});
|
||||
panel.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
root.querySelector('[data-mr-reject]')?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/review/' + enc(song.filename) + '/reject');
|
||||
settle(song);
|
||||
});
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
const input = panel.querySelector('[data-mr-search-input]');
|
||||
panel.querySelector('[data-mr-search-toggle]')?.addEventListener('click', () => {
|
||||
const sp = root.querySelector('[data-mr-search-panel]');
|
||||
const input = root.querySelector('[data-mr-search-input]');
|
||||
root.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(panel, song);
|
||||
panel.querySelector('[data-mr-search-go]')?.addEventListener('click', go);
|
||||
const go = () => runSearch(root, song);
|
||||
root.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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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) { closeModal(); return; } // Fix-match: done means done
|
||||
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;
|
||||
}
|
||||
const i = _queue.indexOf(song);
|
||||
if (i >= 0) _queue.splice(i, 1);
|
||||
if (_idx >= _queue.length) _idx = Math.max(0, _queue.length - 1);
|
||||
@@ -440,6 +750,60 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// "Identify by audio" — fingerprint the song's OWN master audio (AcoustID)
|
||||
// and render the hits into the same search-results area. The reliable path
|
||||
// when text search can't tell the studio take from live/comp versions.
|
||||
async function runIdentify(panel, song) {
|
||||
const out = panel.querySelector('[data-mr-search-results]');
|
||||
const sp = panel.querySelector('[data-mr-search-panel]');
|
||||
if (!out) return;
|
||||
sp?.classList.remove('hidden'); // give the results somewhere to render
|
||||
out.innerHTML = '<p class="text-xs text-fb-textDim">Fingerprinting audio…</p>';
|
||||
let body = null, status = 0;
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/identify/' + enc(song.filename), { method: 'POST' });
|
||||
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>.';
|
||||
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.');
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (status === 503) {
|
||||
note('Could not run the fingerprint right now — the audio tool or network is unavailable. Try again in a moment.');
|
||||
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);
|
||||
return;
|
||||
}
|
||||
out.innerHTML = '<div class="text-xs font-semibold uppercase tracking-wider text-fb-good 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);
|
||||
@@ -483,7 +847,10 @@
|
||||
// opt-IN per the dev-chat thread.
|
||||
const optInToggles = [
|
||||
['artist-external-links', 'artist_external_links'],
|
||||
// Audio fingerprinting is opt-in (needs a key + fpcalc), default OFF.
|
||||
['acoustid-enabled', 'acoustid_enabled'],
|
||||
].map(([id, key]) => [document.getElementById(id), key]).filter(([el]) => el);
|
||||
const acoustidKeyEl = document.getElementById('acoustid-api-key');
|
||||
if (!toggles.length && !optInToggles.length && !sel && !btn) return;
|
||||
(async () => {
|
||||
try {
|
||||
@@ -492,6 +859,7 @@
|
||||
const cfg = await r.json();
|
||||
for (const [el, key] of toggles) el.checked = cfg[key] !== false;
|
||||
for (const [el, key] of optInToggles) el.checked = cfg[key] === true;
|
||||
if (acoustidKeyEl) acoustidKeyEl.value = cfg.acoustid_api_key || '';
|
||||
if (sel) {
|
||||
const t = Number(cfg.enrich_auto_threshold);
|
||||
const want = Number.isFinite(t) ? t : 0.9;
|
||||
@@ -516,6 +884,7 @@
|
||||
}
|
||||
sel?.addEventListener('change', () => save('enrich_auto_threshold', Number(sel.value)));
|
||||
order?.addEventListener('change', () => save('enrich_review_order', order.value));
|
||||
acoustidKeyEl?.addEventListener('change', () => save('acoustid_api_key', acoustidKeyEl.value.trim()));
|
||||
btn?.addEventListener('click', async () => {
|
||||
await post('/api/enrichment/kick');
|
||||
const line = document.getElementById('enrich-status');
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
id: 'instrument', shape: 'spotlight', position: 'bottom',
|
||||
selector: '#v3-instrument-wrap', waitFor: '#v3-instrument-wrap',
|
||||
title: 'Choose your instrument',
|
||||
content: 'Set your instrument, string count and tuning here. The highway, tuner and scoring all adapt to this selection.',
|
||||
content: 'Set your instrument, string count and tuning here — and if you play left-handed, flip Handedness to Left so the whole highway mirrors. The highway, tuner and scoring all adapt to this selection.',
|
||||
},
|
||||
{
|
||||
id: 'tuner', shape: 'spotlight', position: 'bottom',
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var RESET_MAP = {
|
||||
gameplay: {
|
||||
server: ['master_difficulty', 'av_offset_ms', 'miss_penalty',
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement'],
|
||||
'fail_behavior', 'countdown_before_song', 'default_arrangement', 'pathway'],
|
||||
local: ['lefty', 'autoplayExit', 'showUpNext', 'confirmExitSong', 'arrangementNamingMode', 'countdownBeforeSong'],
|
||||
after: function () {
|
||||
// Left-handed is held on the highway object, not re-derived
|
||||
|
||||
+345
-16
@@ -38,6 +38,9 @@
|
||||
// Mastery = best accuracy across arrangements (song_stats); unscored songs
|
||||
// sort last either way. Ascending surfaces what needs work; never default.
|
||||
['mastery', 'Needs practice first'], ['mastery-desc', 'Most mastered first'],
|
||||
// Personal difficulty (song_user_meta.user_difficulty, 1-5); unrated
|
||||
// songs sort last either way.
|
||||
['difficulty', 'Difficulty (easiest first)'], ['difficulty-desc', 'Difficulty (hardest first)'],
|
||||
];
|
||||
const FORMATS = [['', 'All formats'], ['sloppak', 'Feedpak'], ['loose', 'Folder']];
|
||||
const ARRANGEMENTS = ['Lead', 'Rhythm', 'Bass', 'Combo', 'Vocals'];
|
||||
@@ -166,15 +169,15 @@
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
const arr = (x) => (Array.isArray(x) ? x.slice() : []);
|
||||
// mastery + match are session-only facets (deliberately not
|
||||
// mastery + match + genre are session-only facets (deliberately not
|
||||
// persisted), but the restored object must still CARRY the keys —
|
||||
// the filter drawer indexes f.mastery/f.match unconditionally, so
|
||||
// dropping them here breaks the drawer for anyone with saved prefs.
|
||||
// the filter drawer indexes f.mastery/f.match/f.genre unconditionally,
|
||||
// so dropping them here breaks the drawer for anyone with saved prefs.
|
||||
state.filters = {
|
||||
arr_has: arr(f.arr_has), arr_lacks: arr(f.arr_lacks),
|
||||
stem_has: arr(f.stem_has), stem_lacks: arr(f.stem_lacks),
|
||||
lyrics: f.lyrics || '', tunings: arr(f.tunings),
|
||||
mastery: [], match: [],
|
||||
mastery: [], match: [], genre: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -490,6 +493,49 @@
|
||||
'<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/></svg>' + pct + '%</span>';
|
||||
}
|
||||
|
||||
// ── Metadata-refresh per-tile state (the "Refresh Metadata" batch) ─────────
|
||||
// A transient badge painted ONLY while a metadata refresh is running: the
|
||||
// songs actually being (re)matched animate queued → working → done. Keyed by
|
||||
// the card's data-fn (= the local filename the enrichment cache keys on).
|
||||
// Empty for every song outside a refresh, so an idle card is byte-identical
|
||||
// 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);
|
||||
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'],
|
||||
};
|
||||
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>';
|
||||
}
|
||||
|
||||
// After a song is scored, the badge for that card is stale until the next
|
||||
// full render(). Refresh state.accuracy from the server and patch the badge
|
||||
// of any currently-rendered card/row in place (grid + tree). `_dirtyScores`
|
||||
@@ -845,7 +891,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) + overlay +
|
||||
tuning + checkbox + accuracyBadge(key) + fmtBadge(shown) + personalBadges(song) + enrichBadge(key, song.unmatched) + 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>' +
|
||||
@@ -913,7 +959,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 match…' },
|
||||
{ id: '__fixmatch', label: 'Fix metadata…' },
|
||||
{ id: '__cover', label: 'Change cover…' },
|
||||
{ id: '__refreshmeta', label: 'Refresh metadata' },
|
||||
{ id: '__getinfo', label: 'Get info…' },
|
||||
@@ -964,7 +1010,7 @@
|
||||
// the group's work_key/chart_count and pre-ticks the shown chart.)
|
||||
if (id === '__fixmatch') { if (window.__fbFixMatch) window.__fbFixMatch(playTarget); return; }
|
||||
if (id === '__cover') {
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename });
|
||||
if (window.__fbOpenImagePicker) window.__fbOpenImagePicker({ filename: playTarget.filename, title: playTarget.title || playTarget.filename, artist: playTarget.artist, album: playTarget.album });
|
||||
return;
|
||||
}
|
||||
if (id === '__refreshmeta') {
|
||||
@@ -1431,6 +1477,13 @@
|
||||
e.stopPropagation();
|
||||
openChartsDrawer(e.currentTarget.getAttribute('data-charts'), song);
|
||||
});
|
||||
// "No match" badge → straight into the Fix-metadata popup for this
|
||||
// song (the batch → fix handoff). stopPropagation so it doesn't also
|
||||
// trigger the card's play. Follows the displayed chart, like the menu.
|
||||
el.querySelector('[data-meta-fix]')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.__fbFixMatch) window.__fbFixMatch(playTarget);
|
||||
});
|
||||
// Artist line → the artist page (PR-B). In select mode the grid's
|
||||
// capture-phase toggle intercepts first, so selection still wins.
|
||||
el.querySelector('[data-v3-artist]')?.addEventListener('click', (e) => {
|
||||
@@ -1834,13 +1887,57 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function _renderCardsRange(start, end) {
|
||||
let html = '';
|
||||
// Signature of the card at absolute index i: real-card vs skeleton, plus the
|
||||
// select-mode it was built under. A change here is the ONLY reason a recycled
|
||||
// node must be rebuilt (a hole filled after a fetch, or select mode toggled) —
|
||||
// otherwise the node is reused as-is across window slides.
|
||||
function _cardSig(i) {
|
||||
return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0');
|
||||
}
|
||||
|
||||
function _buildCardNode(i) {
|
||||
const s = state.songs[i];
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = s ? songCard(s) : _skeletonCard();
|
||||
const node = tmp.firstElementChild;
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
|
||||
// Reconcile the grid's children to exactly cover [start, end) in ascending
|
||||
// index order, REUSING the card nodes that stay in-window. Sliding the window
|
||||
// one row now mutates only the row that entered/left instead of tearing down +
|
||||
// rebuilding (+ re-wiring) the whole ~60-card window every frame — that
|
||||
// per-slide teardown was the main-thread stall behind the "library skips every
|
||||
// so many scrolls, up or down" report (the stall buffers held-arrow key-repeats
|
||||
// that then flush in a burst). wireCards()'s data-wired guard wires only the
|
||||
// freshly-built nodes.
|
||||
function _syncWindow(grid, start, end) {
|
||||
// Pass 1: drop nodes that left the window, are untagged, or whose content
|
||||
// signature is stale (skeleton→real, or select-mode toggled). What remains
|
||||
// is a reusable, correctly-rendered subset in ascending DOM order.
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
// Pass 2: walk [start, end) in order, reusing survivors and inserting new
|
||||
// nodes into their correct slot; `ref` tracks the child expected next.
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
const s = state.songs[i];
|
||||
html += s ? songCard(s) : _skeletonCard();
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) {
|
||||
ref = ref.nextSibling;
|
||||
} else {
|
||||
grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// Fetch a single OFFSET page into the sparse store. Uses the stage-1 keyset
|
||||
@@ -1958,7 +2055,7 @@
|
||||
}
|
||||
if (_closeCardMenu) _closeCardMenu(); // its DOM is about to be replaced
|
||||
grid.style.top = (firstRow * rowH) + 'px';
|
||||
grid.innerHTML = _renderCardsRange(start, end);
|
||||
_syncWindow(grid, start, end); // recycle in-window nodes; only the entering/leaving row rebuilds
|
||||
wireCards(grid);
|
||||
decorateTuningChips(grid); // colour tuning chips by working-tuning match (async, feature-detected)
|
||||
state.winRange = { start, end };
|
||||
@@ -2727,7 +2824,7 @@
|
||||
'<div class="flex items-center justify-between"><h3 class="text-lg font-semibold text-fb-text">Filters</h3>' +
|
||||
'<button data-drawer-close class="text-fb-textDim hover:text-fb-text">✕</button></div>' +
|
||||
section('Arrangements', ARRANGEMENTS.map((a) => triPill('arr', a, a, triState(f.arr_has, f.arr_lacks, a))).join('')) +
|
||||
section('Stems (sloppak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Stems (feedpak)', STEMS.map((s) => triPill('stem', s, s, triState(f.stem_has, f.stem_lacks, s))).join('')) +
|
||||
section('Lyrics', ['', '1', '0'].map((v) => '<button data-lyrics="' + v + '" class="px-2 py-1 rounded-md text-xs border ' + (f.lyrics === v ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + (v === '' ? 'Any' : v === '1' ? 'Has lyrics' : 'No lyrics') + '</button>').join('')) +
|
||||
// Progress (mastery bands) — multi-select; server filters via song_stats.
|
||||
section('Progress', [['mastered', 'Mastered'], ['in_progress', 'In progress'], ['not_started', 'Not started']].map((it) => '<button data-mastery="' + it[0] + '" class="px-2 py-1 rounded-md text-xs border ' + (f.mastery.includes(it[0]) ? 'bg-fb-primary text-white border-fb-primary' : 'bg-gray-800/50 text-fb-textDim border-gray-700') + '">' + it[1] + '</button>').join('')) +
|
||||
@@ -3032,7 +3129,7 @@
|
||||
// when image-picker.js isn't loaded.
|
||||
artWrap.addEventListener('click', () => {
|
||||
if (window.__fbOpenImagePicker) {
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename });
|
||||
window.__fbOpenImagePicker({ filename: song.filename, title: song.title || song.filename, artist: song.artist, album: song.album });
|
||||
} else {
|
||||
artFile.click();
|
||||
}
|
||||
@@ -3402,7 +3499,13 @@
|
||||
// shown by match-review.js (window.__fbMatchReviewChip), which
|
||||
// also owns the drawer the click opens.
|
||||
'<div class="flex items-baseline gap-3"><p class="text-fb-textDim text-sm" id="v3-songs-count"></p>' +
|
||||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button></div>' +
|
||||
'<button id="v3-songs-match-review" class="hidden text-xs text-fb-primary hover:text-fb-primaryHi border border-fb-primary/40 rounded-full px-2.5 py-0.5"></button>' +
|
||||
// Batch progress for the Refresh Metadata button (shown only while a
|
||||
// pass runs). A real songs-processed ratio, not a fake per-song %.
|
||||
'<span id="v3-meta-progress" class="hidden items-center gap-2 text-xs text-fb-textDim">' +
|
||||
'<span id="v3-meta-progress-label"></span>' +
|
||||
'<span class="inline-block w-24 rounded-full bg-fb-border/40 overflow-hidden align-middle" style="height:6px"><span id="v3-meta-progress-fill" class="block h-full bg-fb-primary transition-all" style="width:0%"></span></span>' +
|
||||
'</span></div>' +
|
||||
'<div class="flex flex-wrap gap-2">' +
|
||||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
@@ -3413,6 +3516,8 @@
|
||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||
'<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
|
||||
@@ -3450,6 +3555,7 @@
|
||||
state.artist = '';
|
||||
state.album = '';
|
||||
try { sm.libraryProviders && await sm.libraryProviders.select(state.provider); } catch (err) { /* */ }
|
||||
_updateMetaBtnVisibility(); // enrichment is local-only
|
||||
await loadArtistCatalog();
|
||||
refreshArtistAlbumSelects();
|
||||
reload();
|
||||
@@ -3479,6 +3585,11 @@
|
||||
});
|
||||
byId('v3-songs-select').addEventListener('click', () => setSelectMode(!state.selectMode));
|
||||
byId('v3-songs-refresh')?.addEventListener('click', refreshLibrary);
|
||||
// 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.
|
||||
(async () => {
|
||||
@@ -3488,6 +3599,15 @@
|
||||
if (sd && sd.running) { _setRefreshState(sd); _watchScan({ announce: false }); }
|
||||
} catch (e) { /* */ }
|
||||
})();
|
||||
// Reflect an enrichment pass already running (Settings "Match now" or a
|
||||
// post-scan background pass) on the Metadata button + bar.
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/status');
|
||||
const es = r.ok ? await r.json() : null;
|
||||
if (es && es.running) { _setMetaState(es); _watchEnrich({ announce: false }); }
|
||||
} catch (e) { /* */ }
|
||||
})();
|
||||
|
||||
// Capture-phase select-mode guard on each persistent list host. Without
|
||||
// it, clicking a card/row (or its arrangement chip) in select mode falls
|
||||
@@ -3706,6 +3826,215 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ── Refresh Metadata (batch enrichment) from the Songs toolbar ─────────────
|
||||
// The metadata counterpart to ⟳ Refresh (which scans FILES): matches
|
||||
// titles/artist/album/artwork against MusicBrainz for the songs that still
|
||||
// need it — the ambient background matcher, run on demand (a media-server's
|
||||
// "Refresh Metadata" vs "Scan Files"). Mirrors the scan machinery: a 1 Hz
|
||||
// poll of /api/enrichment/status drives the button + batch bar, while
|
||||
// /api/enrichment/states drives per-tile badges on the visible window.
|
||||
// Enrichment is local-only, so the button hides for remote providers.
|
||||
let _metaPoll = null;
|
||||
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();
|
||||
}
|
||||
|
||||
// The local filenames the grid is currently SHOWING (data-fn is the local
|
||||
// filename the enrichment cache keys on). The grid is windowed, so this is
|
||||
// the visible slice only — exactly what the per-tile poll should cover.
|
||||
function _visibleLocalFilenames() {
|
||||
const grid = document.getElementById('v3-songs-grid');
|
||||
if (!grid) return [];
|
||||
return [...grid.querySelectorAll('[data-fn]')]
|
||||
.map((el) => el.getAttribute('data-fn')).filter(Boolean);
|
||||
}
|
||||
|
||||
// Set/clear one card's live badge (recycled cards re-derive from _metaTile on
|
||||
// the next paint, so update the map too — mirrors _patchCardFav).
|
||||
function _patchCardEnrich(fn, st) {
|
||||
if (st) _metaTile[fn] = st; else delete _metaTile[fn];
|
||||
const sel = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn;
|
||||
document.querySelectorAll('[data-fn="' + sel + '"] [data-v3-play]').forEach((play) => {
|
||||
const el = play.querySelector('.v3-meta-tile');
|
||||
const html = enrichBadge(fn);
|
||||
if (!html) { if (el) el.remove(); return; }
|
||||
if (el) el.outerHTML = html; else play.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Drive the button (which doubles as Stop) + the batch bar from a status body.
|
||||
function _setMetaState(es) {
|
||||
const btn = document.getElementById('v3-songs-refresh-meta');
|
||||
const prog = document.getElementById('v3-meta-progress');
|
||||
const fill = document.getElementById('v3-meta-progress-fill');
|
||||
const label = document.getElementById('v3-meta-progress-label');
|
||||
if (!btn) return;
|
||||
const running = !!(es && es.running);
|
||||
_metaRunning = running;
|
||||
if (running) {
|
||||
const total = (es && es.total) || 0, done = (es && es.matched) || 0;
|
||||
const cancelling = !!(es && es.cancelling);
|
||||
btn.textContent = cancelling ? 'Stopping…' : ('⏹ Stop' + (total ? ' · ' + done + '/' + total : ''));
|
||||
btn.disabled = cancelling;
|
||||
btn.classList.toggle('opacity-70', cancelling);
|
||||
btn.title = cancelling ? 'Stopping after the current song…' : 'Stop refreshing metadata';
|
||||
if (prog) {
|
||||
prog.classList.remove('hidden'); prog.classList.add('flex');
|
||||
if (label) label.textContent = total ? ('Matching metadata ' + done + '/' + total) : 'Matching metadata…';
|
||||
// Real songs-processed ratio; a tiny sliver while the queue size
|
||||
// is still being computed (phase 1) so the bar isn't dead-empty.
|
||||
if (fill) fill.style.width = (total ? Math.round((done / total) * 100) : 6) + '%';
|
||||
}
|
||||
} else {
|
||||
btn.textContent = '🏷 Metadata';
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-70');
|
||||
btn.title = 'Refresh metadata for the songs shown (re-match titles, artwork & more)';
|
||||
if (prog) { prog.classList.add('hidden'); prog.classList.remove('flex'); }
|
||||
}
|
||||
}
|
||||
|
||||
// Completion toast — reuse the shared fbNotify surface (visual-only, so
|
||||
// hearing-safe for free). Honest + never-punishing copy, in-game suppressed.
|
||||
function _metaCompleteToast(es) {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') return;
|
||||
if (!window.fbNotify) return;
|
||||
const matched = (es && es.matched) || 0;
|
||||
const msg = matched
|
||||
? (matched + ' song' + (matched === 1 ? '' : 's') + ' matched')
|
||||
: 'Your library metadata is up to date';
|
||||
try { window.fbNotify.show({ title: 'Metadata refresh complete', message: msg, icon: '🏷️', accent: '#22C55E' }); } catch (e) { /* */ }
|
||||
}
|
||||
|
||||
// Poll enrichment status (button + bar) AND the visible window's per-song
|
||||
// states (tile badges) until the pass finishes. announce:false = we only
|
||||
// attached to a pass we didn't start (no toast unless it actually changed
|
||||
// something).
|
||||
function _watchEnrich(opts) {
|
||||
if (_metaPoll) return;
|
||||
const announce = !opts || opts.announce !== false;
|
||||
let sawRunning = false, ticks = 0, lastStatus = null;
|
||||
_metaPoll = setInterval(async () => {
|
||||
ticks++;
|
||||
let es = null;
|
||||
try { const r = await fetch('/api/enrichment/status'); if (r.ok) es = await r.json(); } catch (e) { /* */ }
|
||||
if (es) { lastStatus = es; _setMetaState(es); if (es.running) sawRunning = true; }
|
||||
// Per-tile badges: only songs we're tracking (seeded 'queued'). A
|
||||
// tile flips to 'working' when it's the current song, then to
|
||||
// 'done' (matched) / 'nochange' (failed) once it leaves unscanned.
|
||||
if (Object.keys(_metaTile).length) {
|
||||
const fns = _visibleLocalFilenames();
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/states', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames: fns }),
|
||||
});
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
const states = j.states || {}, current = j.current;
|
||||
fns.forEach((fn) => {
|
||||
if (!(fn in _metaTile)) return;
|
||||
if (fn === current) { _patchCardEnrich(fn, 'working'); return; }
|
||||
const s = states[fn];
|
||||
if (s && s !== 'unscanned' && s !== 'pending') {
|
||||
_patchCardEnrich(fn, s === 'failed' ? 'nochange' : 'done');
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) { /* */ }
|
||||
}
|
||||
// Cap at 20 min (a ~1000-song trickle at ≤1/s is ~17 min); a
|
||||
// user-initiated no-op that never saw a running pass ends quickly.
|
||||
const noopDone = announce && !sawRunning && ticks >= 3;
|
||||
if ((sawRunning && es && !es.running) || noopDone || ticks >= 1200) {
|
||||
clearInterval(_metaPoll); _metaPoll = null;
|
||||
_setMetaState(null);
|
||||
const changed = sawRunning && lastStatus && (lastStatus.matched || 0) > 0;
|
||||
if (announce || changed) _metaCompleteToast(lastStatus);
|
||||
// Let the final 'done' badges register, then clear + (if anything
|
||||
// matched) reload so new canonical titles/art show.
|
||||
setTimeout(() => {
|
||||
_clearMetaTiles();
|
||||
if (changed && window.feedBack) { try { window.feedBack.emit('library:changed', { reason: 'enrich', matched: lastStatus.matched }); } catch (e) { /* */ } }
|
||||
}, 1600);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Force a fresh re-match of the songs currently SHOWN (the visible grid
|
||||
// window) — a media-server-style per-view "Refresh Metadata". Resets those
|
||||
// songs and re-fetches, so it's visible even on an already-matched library.
|
||||
// Manual pins are skipped server-side; scoped to the visible set so it's
|
||||
// fast + can't blow the whole rate budget.
|
||||
async function refreshMetadata() {
|
||||
if (_metaRunning || _metaPoll) return; // already running
|
||||
const fns = _visibleLocalFilenames();
|
||||
_clearMetaTiles();
|
||||
if (!fns.length) { _metaCompleteToast({ matched: 0 }); return; }
|
||||
let queued = [];
|
||||
try {
|
||||
const r = await fetch('/api/enrichment/rematch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filenames: fns }),
|
||||
});
|
||||
if (r.ok) queued = (await r.json()).queued || [];
|
||||
} catch (e) { /* offline → nothing queued */ }
|
||||
// Badge exactly what the server queued (everything visible except your
|
||||
// manual pins). Nothing queued = all visible songs are pinned/unknown.
|
||||
queued.forEach((fn) => _patchCardEnrich(fn, 'queued'));
|
||||
if (!queued.length) { _metaCompleteToast({ matched: 0 }); return; }
|
||||
_watchEnrich({ announce: true });
|
||||
}
|
||||
|
||||
async function stopMetadata() {
|
||||
try { await fetch('/api/enrichment/cancel', { method: 'POST' }); } catch (e) { /* */ }
|
||||
_setMetaState({ running: true, cancelling: true }); // optimistic; the poll confirms
|
||||
}
|
||||
|
||||
// The Metadata button toggles role: kick a refresh when idle, Stop when a
|
||||
// pass is running.
|
||||
function onMetaBtnClick() {
|
||||
if (_metaRunning) stopMetadata(); else refreshMetadata();
|
||||
}
|
||||
|
||||
// Topbar search drives this screen.
|
||||
async function search(q) {
|
||||
state.q = q || '';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Pins the onboarding handedness control: a Right/Left choice lives in the
|
||||
// instrument selector (the "Choose your instrument" onboarding step, which the
|
||||
// tour spotlights BEFORE the tuner/audio-calibration steps) and writes the
|
||||
// highway 'lefty' preference. Source-level, matching the other tests/js/
|
||||
// browser-heavy regression guards (the runtime path is DOM/WebGL-heavy).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const BADGES = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'badges.js'), 'utf8');
|
||||
const TOUR = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'onboarding-tour.js'), 'utf8');
|
||||
|
||||
test('instrument selector offers a Handedness Right/Left choice', () => {
|
||||
assert.match(BADGES, /instRow\('Handedness'/, 'a Handedness row must be in the instrument menu');
|
||||
assert.match(BADGES, /pill\('hand',\s*'right'/, 'Right handedness pill');
|
||||
assert.match(BADGES, /pill\('hand',\s*'left'/, 'Left handedness pill');
|
||||
});
|
||||
|
||||
test('clicking a handedness pill writes the lefty preference from its value', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/\[data-pill="hand"\][\s\S]*?_setLeftyPref\(\s*b\.getAttribute\('data-val'\)\s*===\s*'left'\s*\)/,
|
||||
'the handedness click handler sets lefty from the pill value');
|
||||
});
|
||||
|
||||
test('_setLeftyPref prefers highway.setLefty and falls back to the lefty localStorage key', () => {
|
||||
const setter = BADGES.match(/function _setLeftyPref\(on\)\s*\{[\s\S]*?\n \}/);
|
||||
assert.ok(setter, '_setLeftyPref must exist');
|
||||
assert.match(setter[0], /highway\.setLefty/, 'prefers highway.setLefty (flips a live highway + persists)');
|
||||
assert.match(setter[0], /localStorage\.setItem\('lefty'/, 'falls back to the lefty localStorage key the highway reads on init');
|
||||
});
|
||||
|
||||
test('_leftyPref reads highway.getLefty with a localStorage fallback', () => {
|
||||
assert.match(
|
||||
BADGES,
|
||||
/function _leftyPref\(\)\s*\{[\s\S]*?getLefty[\s\S]*?localStorage\.getItem\('lefty'\)/,
|
||||
'_leftyPref reads the current handedness with a storage fallback');
|
||||
});
|
||||
|
||||
test('onboarding instrument step calls out left-handed players + the Handedness control', () => {
|
||||
assert.match(TOUR, /Choose your instrument/);
|
||||
assert.match(TOUR, /left-handed/i, 'the instrument step must call out left-handed players');
|
||||
assert.match(TOUR, /Handedness/, 'and name the Handedness control');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/\/\* @pure:editor-pending-view:start \*\/[\s\S]*?\/\* @pure:editor-pending-view:end \*\//);
|
||||
if (!m) throw new Error('pending-view helper block not found');
|
||||
|
||||
const api = new Function('"use strict";' + m[0] + '\nreturn { _buildEditorPendingViewPure };')();
|
||||
|
||||
test('edit-region handoff defaults cursor to region start and marks return path', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 2, { a: 12.5, b: 20 }, { returnToHighway: true });
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 2,
|
||||
barSel: { startTime: 12.5, endTime: 20 },
|
||||
returnToHighway: true,
|
||||
cursorTime: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
test('return-trip handoff preserves explicit viewport state', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', 1, { a: 8, b: 14 }, {
|
||||
scrollX: -4,
|
||||
zoom: 160,
|
||||
cursorTime: 9.25,
|
||||
});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 1,
|
||||
barSel: { startTime: 8, endTime: 14 },
|
||||
cursorTime: 9.25,
|
||||
scrollX: 0,
|
||||
zoom: 160,
|
||||
});
|
||||
});
|
||||
|
||||
test('missing region still produces a stable pending view shell', () => {
|
||||
const out = api._buildEditorPendingViewPure('song.sloppak', -1, null, {});
|
||||
assert.deepStrictEqual(out, {
|
||||
filename: 'song.sloppak',
|
||||
arrangement: 0,
|
||||
barSel: null,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Contract test: 3D Highway WebGL context-loss recovery.
|
||||
//
|
||||
// Switching the active window / alt-tabbing (especially on Windows) can trigger
|
||||
// a GPU context reset. Without a handler the lost WebGL context escalates into a
|
||||
// render-process crash. The renderer owns its own WebGL canvas + heavy Three.js
|
||||
// lifecycle (too much to construct in a vm sandbox), so — like the other
|
||||
// highway_* source-contract tests here — this pins the wiring at the source
|
||||
// level: the loss must be preventDefault()'d (so the browser restores it), draw
|
||||
// must bail while lost, and the listeners must be torn down.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
|
||||
const src = fs.readFileSync(SCREEN_JS, 'utf8');
|
||||
|
||||
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
|
||||
assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'must listen for webglcontextrestored on ren.domElement');
|
||||
});
|
||||
|
||||
test('the context-lost handler preventDefaults and pauses drawing', () => {
|
||||
// Without preventDefault() the browser will not attempt to restore the
|
||||
// context and the loss can escalate to a renderer crash.
|
||||
const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
|
||||
assert.ok(m, '_onCtxLost handler must exist');
|
||||
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
|
||||
assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
|
||||
});
|
||||
|
||||
test('draw() early-returns while the context is lost', () => {
|
||||
assert.match(src, /draw\(bundle\)\s*\{[\s\S]*?if\s*\(_ctxLost\)\s*return;/,
|
||||
'draw() must bail while _ctxLost is set so no GL work runs on a dead context');
|
||||
});
|
||||
|
||||
test('teardown removes the context-loss listeners', () => {
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextlost['"]/,
|
||||
'teardown must remove the webglcontextlost listener');
|
||||
assert.match(src, /removeEventListener\(\s*['"]webglcontextrestored['"]/,
|
||||
'teardown must remove the webglcontextrestored listener');
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Guards the R0 module-migration loader change in static/app.js: a migrated
|
||||
// plugin (manifest scriptType:"module", surfaced as plugin.script_type) must be
|
||||
// injected as <script type="module"> so its screen.js `import './src/main.js'`
|
||||
// graph loads, while classic plugins stay untouched.
|
||||
//
|
||||
// The injection is a single line inside the large async loadPlugins() closure
|
||||
// (it depends on loadedScripts, _removePluginScriptTags, and the
|
||||
// _loadingPluginId completion window), so a faithful behavioural harness would
|
||||
// need to stub the whole loader. Instead this asserts the *structural*
|
||||
// contract in source — the guard exists, is gated (not unconditional), and sits
|
||||
// inside the screen.js injection block before appendChild. The behavioural proof
|
||||
// is the R0 end-to-end live-edit check (a real module plugin booting in-browser).
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Isolate the screen.js <script> injection block: from where its src is built
|
||||
// to where the element is appended.
|
||||
function injectionBlock() {
|
||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
||||
return src.slice(start, end);
|
||||
}
|
||||
|
||||
test('module plugins are injected as <script type="module">', () => {
|
||||
const block = injectionBlock();
|
||||
assert.match(
|
||||
block,
|
||||
/if\s*\(\s*plugin\.script_type\s*===\s*['"]module['"]\s*\)\s*script\.type\s*=\s*['"]module['"]\s*;/,
|
||||
'expected a guarded `script.type = "module"` keyed on plugin.script_type === "module"',
|
||||
);
|
||||
});
|
||||
|
||||
test('the module type is gated, never set unconditionally', () => {
|
||||
const block = injectionBlock();
|
||||
// Every assignment of script.type in the block must be on the same line as
|
||||
// the plugin.script_type guard (i.e. no bare `script.type = 'module'`).
|
||||
for (const line of block.split('\n')) {
|
||||
if (/script\.type\s*=/.test(line)) {
|
||||
assert.match(line, /plugin\.script_type\s*===\s*['"]module['"]/,
|
||||
`unguarded script.type assignment: ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||
const guardAt = src.indexOf('script.type = \'module\'');
|
||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||
'the module guard must live inside the screen.js injection block');
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
// Mirror of static/v3/songs.js _cardSig / _buildCardNode / _syncWindow (the
|
||||
// windowed-grid recycle path, #636 item 3 follow-up) — keep in sync. Exercised
|
||||
// against a minimal DOM shim so the reconcile invariants are covered off-browser:
|
||||
// (1) after every slide the grid's children are exactly [start,end) ascending,
|
||||
// (2) card nodes for indices that stay in-window are REUSED (identity kept) —
|
||||
// i.e. sliding one row never tears down + rebuilds the whole window (the
|
||||
// per-slide stall behind the "skips every so many scrolls" report), and
|
||||
// (3) a select-mode toggle rebuilds the visible window (checkbox/ring change).
|
||||
|
||||
let NODE_SEQ = 0;
|
||||
function makeNode() {
|
||||
const attrs = {};
|
||||
return {
|
||||
_uid: ++NODE_SEQ,
|
||||
parent: null,
|
||||
getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
||||
setAttribute(k, v) { attrs[k] = String(v); },
|
||||
get nextSibling() {
|
||||
const p = this.parent; if (!p) return null;
|
||||
const i = p._kids.indexOf(this);
|
||||
return i >= 0 && i + 1 < p._kids.length ? p._kids[i + 1] : null;
|
||||
},
|
||||
remove() {
|
||||
const p = this.parent; if (!p) return;
|
||||
const i = p._kids.indexOf(this);
|
||||
if (i >= 0) p._kids.splice(i, 1);
|
||||
this.parent = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function makeGrid() {
|
||||
return {
|
||||
_kids: [],
|
||||
get children() { return this._kids.slice(); },
|
||||
get firstChild() { return this._kids[0] || null; },
|
||||
insertBefore(node, ref) {
|
||||
if (node.parent) node.remove();
|
||||
if (ref == null) this._kids.push(node);
|
||||
else { const i = this._kids.indexOf(ref); this._kids.splice(i < 0 ? this._kids.length : i, 0, node); }
|
||||
node.parent = this;
|
||||
return node;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- state + the three helpers, mirrored from songs.js ---
|
||||
const state = { songs: [], selectMode: false };
|
||||
for (let i = 0; i < 5000; i++) state.songs[i] = { filename: 'song' + i };
|
||||
|
||||
function _cardSig(i) { return (state.songs[i] ? 'r' : 's') + (state.selectMode ? '1' : '0'); }
|
||||
function _buildCardNode(i) {
|
||||
const node = makeNode();
|
||||
node.setAttribute('data-idx', String(i));
|
||||
node.setAttribute('data-sig', _cardSig(i));
|
||||
return node;
|
||||
}
|
||||
function _syncWindow(grid, start, end) {
|
||||
for (const el of Array.from(grid.children)) {
|
||||
const a = el.getAttribute('data-idx');
|
||||
const idx = a == null ? NaN : Number(a);
|
||||
if (!(idx >= start && idx < end) || el.getAttribute('data-sig') !== _cardSig(idx)) el.remove();
|
||||
}
|
||||
const existing = new Map();
|
||||
for (const el of grid.children) existing.set(Number(el.getAttribute('data-idx')), el);
|
||||
let ref = grid.firstChild;
|
||||
for (let i = start; i < end; i++) {
|
||||
let node = existing.get(i);
|
||||
if (!node) node = _buildCardNode(i);
|
||||
if (node === ref) ref = ref.nextSibling;
|
||||
else grid.insertBefore(node, ref);
|
||||
}
|
||||
}
|
||||
|
||||
const idxOf = (g) => g._kids.map((n) => Number(n.getAttribute('data-idx')));
|
||||
const uidOf = (g) => { const m = new Map(); for (const n of g._kids) m.set(Number(n.getAttribute('data-idx')), n._uid); return m; };
|
||||
function assertContig(g, start, end) {
|
||||
const a = idxOf(g);
|
||||
assert.strictEqual(a.length, end - start, `len == ${end - start}`);
|
||||
for (let k = 0; k < a.length; k++) assert.strictEqual(a[k], start + k, `child ${k} == ${start + k}`);
|
||||
}
|
||||
|
||||
const COLS = 6, WIN = 12 * COLS; // 12 rows visible
|
||||
|
||||
test('window stays [start,end) contiguous scrolling down, one row at a time', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 40; row++) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
}
|
||||
});
|
||||
|
||||
test('in-window card nodes are reused across a slide (no whole-window teardown)', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
const before = uidOf(grid);
|
||||
_syncWindow(grid, COLS, COLS + WIN); // slide down one row
|
||||
const after = uidOf(grid);
|
||||
let reused = 0, built = 0;
|
||||
for (const [i, uid] of after) (before.get(i) === uid ? reused++ : built++);
|
||||
assert.strictEqual(built, COLS, `only the entering row is built (${COLS}), got ${built}`);
|
||||
assert.strictEqual(reused, WIN - COLS, 'every overlapping card node is reused');
|
||||
});
|
||||
|
||||
test('scrolling back UP reuses nodes too and keeps order', () => {
|
||||
const grid = makeGrid();
|
||||
for (let row = 0; row < 30; row++) _syncWindow(grid, row * COLS, row * COLS + WIN);
|
||||
let prev = uidOf(grid);
|
||||
for (let row = 29; row >= 0; row--) {
|
||||
const start = row * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
assertContig(grid, start, start + WIN);
|
||||
const now = uidOf(grid);
|
||||
for (const [i, uid] of prev) if (i >= start && i < start + WIN) assert.strictEqual(now.get(i), uid, `idx ${i} reused going up`);
|
||||
prev = now;
|
||||
}
|
||||
});
|
||||
|
||||
test('a select-mode toggle rebuilds the visible window', () => {
|
||||
const grid = makeGrid();
|
||||
const start = 6 * COLS;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const before = uidOf(grid);
|
||||
state.selectMode = true;
|
||||
_syncWindow(grid, start, start + WIN);
|
||||
const after = uidOf(grid);
|
||||
let rebuilt = 0;
|
||||
for (const [i, uid] of before) if (after.get(i) !== uid) rebuilt++;
|
||||
assert.strictEqual(rebuilt, WIN, 'select-mode change rebuilds every visible card');
|
||||
assertContig(grid, start, start + WIN);
|
||||
state.selectMode = false;
|
||||
});
|
||||
|
||||
test('a large jump (rail seek) rebuilds cleanly with no stale survivors', () => {
|
||||
const grid = makeGrid();
|
||||
_syncWindow(grid, 0, WIN);
|
||||
_syncWindow(grid, 1000 * COLS, 1000 * COLS + WIN); // non-overlapping jump
|
||||
assertContig(grid, 1000 * COLS, 1000 * COLS + WIN);
|
||||
});
|
||||
@@ -16,8 +16,8 @@ const { createWindow, ROOT } = require('./capabilities_test_harness');
|
||||
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
|
||||
const WORKING_TUNING_JS = path.join(ROOT, 'static', 'capabilities', 'working-tuning.js');
|
||||
|
||||
// A /api/tunings-shaped fixture (frequencies at 440), enough to resolve names to offsets.
|
||||
const TUNINGS = {
|
||||
// Tuning frequency fixture at 440 Hz, enough to resolve names to offsets.
|
||||
const TUNING_TABLE = {
|
||||
'guitar-6': {
|
||||
Standard: [82.41, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
'Drop D': [73.42, 110.00, 146.83, 196.00, 246.94, 329.63],
|
||||
@@ -26,6 +26,7 @@ const TUNINGS = {
|
||||
Standard: [30.87, 41.20, 55.00, 73.42, 98.00],
|
||||
},
|
||||
};
|
||||
const API_TUNINGS = { referencePitch: 440, tunings: TUNING_TABLE };
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
@@ -159,7 +160,7 @@ test('bare-instrument writes target the current selection, not a hard-coded defa
|
||||
test('seed resolves a NAMED tuning to offsets via /api/tunings', async () => {
|
||||
const { wt, changes } = loadWorkingTuning({
|
||||
'/api/settings': { instrument: 'guitar', string_count: 6, tuning: 'Drop D', reference_pitch: 440 },
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
await flush();
|
||||
const s = wt.get('guitar-6');
|
||||
@@ -183,7 +184,7 @@ test('boot race: an explicit set() before settings resolve is not clobbered by t
|
||||
const settings = deferred();
|
||||
const { wt } = loadWorkingTuning({
|
||||
'/api/settings': settings.promise, // held open
|
||||
'/api/tunings': TUNINGS,
|
||||
'/api/tunings': API_TUNINGS,
|
||||
});
|
||||
// A consumer writes before the seed lands.
|
||||
wt.set({ offsets: [-5, -5, -5, -5, -5, -5] }, { instrument: 'guitar-6' });
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Regression test for feedBack#800: tuner injectPlayerButton() must anchor the
|
||||
// injected button to a DIRECT-child button of #player-controls. The old
|
||||
// `controls.querySelector('button:last-child')` could resolve to a NESTED
|
||||
// button, and `controls.insertBefore(btn, nestedButton)` then throws
|
||||
// NotFoundError — which propagated out of the player-screen transition and
|
||||
// aborted its render.
|
||||
//
|
||||
// Same isolation strategy as the core tests/js suite: extract the real function
|
||||
// from source with extractFunction() and run it in a vm sandbox over a small
|
||||
// but faithful DOM model. The model's insertBefore() enforces the real DOM
|
||||
// invariant (reference node must be a direct child, else NotFoundError), and
|
||||
// querySelector() implements the exact semantics of both the old
|
||||
// (`button:last-child`) and new (`:scope > button:last-of-type`) selectors — so
|
||||
// reverting the fix makes this test throw.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('../../../js/test_utils');
|
||||
|
||||
const UI_JS = path.join(__dirname, '..', '..', '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
|
||||
const SRC = fs.readFileSync(UI_JS, 'utf8');
|
||||
const FN_SRC = extractFunction(SRC, 'function injectPlayerButton(');
|
||||
|
||||
// ── Minimal, faithful DOM model ──────────────────────────────────────────────
|
||||
|
||||
class El {
|
||||
constructor(tag, id = '') {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.id = id;
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.textContent = '';
|
||||
this.title = '';
|
||||
this.onclick = null;
|
||||
}
|
||||
appendChild(node) {
|
||||
node.parentNode = this;
|
||||
this.children.push(node);
|
||||
return node;
|
||||
}
|
||||
insertBefore(node, ref) {
|
||||
const idx = this.children.indexOf(ref);
|
||||
if (ref == null || idx === -1) {
|
||||
// Faithful to the browser: ref must be a direct child.
|
||||
const e = new Error(
|
||||
"Failed to execute 'insertBefore' on 'Node': The node before which the "
|
||||
+ 'new node is to be inserted is not a child of this node.'
|
||||
);
|
||||
e.name = 'NotFoundError';
|
||||
throw e;
|
||||
}
|
||||
node.parentNode = this;
|
||||
this.children.splice(idx, 0, node);
|
||||
return node;
|
||||
}
|
||||
querySelector(sel) {
|
||||
if (sel === ':scope > button:last-of-type') {
|
||||
// Last direct-child <button>.
|
||||
const btns = this.children.filter((c) => c.tagName === 'BUTTON');
|
||||
return btns.length ? btns[btns.length - 1] : null;
|
||||
}
|
||||
if (sel === 'button:last-child') {
|
||||
// First descendant <button> (document order) that is the last child
|
||||
// of its own parent — the buggy legacy anchor.
|
||||
let found = null;
|
||||
const walk = (node) => {
|
||||
for (const c of node.children) {
|
||||
if (found) return;
|
||||
const isLast = c.parentNode.children[c.parentNode.children.length - 1] === c;
|
||||
if (c.tagName === 'BUTTON' && isLast) { found = c; return; }
|
||||
walk(c);
|
||||
}
|
||||
};
|
||||
walk(this);
|
||||
return found;
|
||||
}
|
||||
throw new Error(`unhandled selector in stub: ${sel}`);
|
||||
}
|
||||
}
|
||||
|
||||
function findById(node, id) {
|
||||
if (!node) return null;
|
||||
if (node.id === id) return node;
|
||||
for (const c of node.children) {
|
||||
const r = findById(c, id);
|
||||
if (r) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Run the extracted injectPlayerButton() against a given controls tree.
|
||||
// Returns { controls, threw }.
|
||||
function run({ controls, isV3 = false, slot = null }) {
|
||||
const roots = [controls, slot].filter(Boolean);
|
||||
const document = {
|
||||
getElementById(id) {
|
||||
if (id === 'player-controls') return controls;
|
||||
for (const r of roots) {
|
||||
const hit = findById(r, id);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
createElement(tag) { return new El(tag); },
|
||||
};
|
||||
const window = {
|
||||
feedBack: isV3
|
||||
? { uiVersion: 'v3', ui: { playerControlSlot: () => slot } }
|
||||
: { uiVersion: 'v2' },
|
||||
tuner: { toggle: () => {} },
|
||||
};
|
||||
const sandbox = {
|
||||
window,
|
||||
document,
|
||||
Element: El,
|
||||
updatePlayerButton: () => {},
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
let threw = null;
|
||||
try {
|
||||
vm.runInContext(FN_SRC + '\nglobalThis.__run = injectPlayerButton;\n__run();', sandbox);
|
||||
} catch (e) {
|
||||
threw = e;
|
||||
}
|
||||
return { controls, slot, threw };
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('does not throw when the last button is nested (feedBack#800 repro)', () => {
|
||||
// controls > div.transport > [play, close]; `close` is button:last-child of
|
||||
// the div but NOT a direct child of controls. The old anchor threw here.
|
||||
const controls = new El('div', 'player-controls');
|
||||
const transport = new El('div');
|
||||
transport.appendChild(new El('button', 'play'));
|
||||
transport.appendChild(new El('button', 'close'));
|
||||
controls.appendChild(transport);
|
||||
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
// With no direct-child button, it appends to controls.
|
||||
assert.ok(findById(controls, 'btn-tuner-player'), 'tuner button was added');
|
||||
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
|
||||
});
|
||||
|
||||
test('inserts before the last direct-child button when one exists', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('button', 'play'));
|
||||
controls.appendChild(new El('button', 'close'));
|
||||
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
const ids = controls.children.map((c) => c.id);
|
||||
// tuner button sits immediately before the last direct-child button.
|
||||
assert.deepEqual(ids, ['play', 'btn-tuner-player', 'close']);
|
||||
});
|
||||
|
||||
test('appends when controls has no buttons at all', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('span'));
|
||||
const { threw } = run({ controls });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
assert.equal(controls.children[controls.children.length - 1].id, 'btn-tuner-player');
|
||||
});
|
||||
|
||||
test('is idempotent — a second call does not add a duplicate', () => {
|
||||
const controls = new El('div', 'player-controls');
|
||||
controls.appendChild(new El('button', 'close'));
|
||||
run({ controls });
|
||||
run({ controls });
|
||||
const injected = controls.children.filter((c) => c.id === 'btn-tuner-player');
|
||||
assert.equal(injected.length, 1);
|
||||
});
|
||||
|
||||
test('v3 mounts into the plugin-control slot and never uses the legacy anchor', () => {
|
||||
const slot = new El('div', 'plugin-control-slot');
|
||||
// A nested button in the slot would trip the legacy anchor; v3 must ignore it.
|
||||
const inner = new El('div');
|
||||
inner.appendChild(new El('button', 'other'));
|
||||
slot.appendChild(inner);
|
||||
const controls = new El('div', 'player-controls');
|
||||
|
||||
const { threw } = run({ controls, isV3: true, slot });
|
||||
assert.equal(threw, null, threw && threw.message);
|
||||
assert.ok(findById(slot, 'btn-tuner-player'), 'tuner button mounted into the slot');
|
||||
assert.equal(findById(controls, 'btn-tuner-player'), null, 'not mounted into #player-controls');
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Pure-function tests for AcoustID fingerprint response parsing + config
|
||||
gating. No network, no fpcalc binary — server.py owns those seams."""
|
||||
import acoustid_match as a
|
||||
|
||||
|
||||
def _resp(score=0.97, rec_id="rec-1", title="Highway to Hell", artist="AC/DC",
|
||||
rg_title="Highway to Hell", rg_type="Album", secondary=None,
|
||||
year=1979, duration=208.4):
|
||||
return {
|
||||
"status": "ok",
|
||||
"results": [{
|
||||
"id": "acoustid-uuid",
|
||||
"score": score,
|
||||
"recordings": [{
|
||||
"id": rec_id,
|
||||
"title": title,
|
||||
"duration": duration,
|
||||
"artists": [{"id": "a1", "name": artist}],
|
||||
"releasegroups": [{
|
||||
"id": "rg1", "title": rg_title, "type": rg_type,
|
||||
"secondarytypes": secondary or [],
|
||||
"releases": [{"date": {"year": year}}],
|
||||
}],
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_maps_the_studio_recording():
|
||||
out = a.parse_lookup_response(_resp())
|
||||
assert len(out) == 1
|
||||
c = out[0]
|
||||
assert c["recording_id"] == "rec-1"
|
||||
assert c["title"] == "Highway to Hell"
|
||||
assert c["artist"] == "AC/DC"
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["year"] == "1979"
|
||||
assert c["duration"] == 208
|
||||
assert c["studio"] is True
|
||||
assert c["source"] == "acoustid"
|
||||
assert c["mb_score"] == 97 # 0.97 → 0..100 confidence band
|
||||
assert c["score"] == 0.97
|
||||
|
||||
|
||||
def test_live_release_group_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(rg_type="Album", secondary=["Live"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_compilation_is_not_studio():
|
||||
out = a.parse_lookup_response(_resp(secondary=["Compilation"]))
|
||||
assert out[0]["studio"] is False
|
||||
|
||||
|
||||
def test_prefers_studio_group_for_album_display():
|
||||
resp = _resp()
|
||||
# Add a comp release-group first; the studio one must win the album pick.
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg0", "title": "Greatest Hits", "type": "Album",
|
||||
"secondarytypes": ["Compilation"], "releases": [{"date": {"year": 2000}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Highway to Hell"
|
||||
assert c["studio"] is True
|
||||
|
||||
|
||||
def test_earliest_studio_album_wins_over_later_one():
|
||||
# Two studio "Album" groups (e.g. a later soundtrack typed Album). The
|
||||
# ORIGINAL — earliest release year — must win the album pick, not whichever
|
||||
# AcoustID happened to list first. (Real case: "Machine Head" over a later
|
||||
# comp for "Smoke on the Water".)
|
||||
resp = _resp(rg_title="Machine Head", year=1972)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"].insert(0, {
|
||||
"id": "rg-late", "title": "Later Studio Album", "type": "Album",
|
||||
"secondarytypes": [], "releases": [{"date": {"year": 1997}}],
|
||||
})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["album"] == "Machine Head"
|
||||
assert c["year"] == "1972"
|
||||
|
||||
|
||||
def test_year_is_earliest_release_not_a_reissue():
|
||||
# A group's first-listed release is often a reissue; the year must be the
|
||||
# EARLIEST across the group's releases (real case: British Steel's 1980
|
||||
# original, not a 2010 reissue listed first).
|
||||
resp = _resp(rg_title="British Steel", year=2010)
|
||||
resp["results"][0]["recordings"][0]["releasegroups"][0]["releases"].append(
|
||||
{"date": {"year": 1980}})
|
||||
c = a.parse_lookup_response(resp)[0]
|
||||
assert c["year"] == "1980"
|
||||
|
||||
|
||||
def test_dedupes_recording_across_results():
|
||||
resp = _resp()
|
||||
resp["results"].append(dict(resp["results"][0])) # same recording again
|
||||
assert len(a.parse_lookup_response(resp)) == 1
|
||||
|
||||
|
||||
def test_non_ok_status_and_garbage_return_empty():
|
||||
assert a.parse_lookup_response({"status": "error"}) == []
|
||||
assert a.parse_lookup_response({}) == []
|
||||
assert a.parse_lookup_response(None) == []
|
||||
assert a.parse_lookup_response({"status": "ok", "results": []}) == []
|
||||
|
||||
|
||||
def test_higher_acoustid_score_ranks_first():
|
||||
resp = _resp(score=0.55, rec_id="low")
|
||||
resp["results"].append(_resp(score=0.99, rec_id="high")["results"][0])
|
||||
out = a.parse_lookup_response(resp)
|
||||
assert out[0]["recording_id"] == "high"
|
||||
|
||||
|
||||
def test_config_gating(monkeypatch):
|
||||
monkeypatch.delenv("ACOUSTID_API_KEY", raising=False)
|
||||
assert a.api_key() == ""
|
||||
assert a.is_configured() is False
|
||||
assert a.is_configured("explicit-key") is True
|
||||
monkeypatch.setenv("ACOUSTID_API_KEY", "envkey")
|
||||
assert a.api_key() == "envkey"
|
||||
assert a.is_configured() is True
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for ``server._resolve_dlc_path`` — the DLC-library containment
|
||||
guard.
|
||||
|
||||
It must (1) allow a library mounted through a directory JUNCTION/symlink (the
|
||||
shared-library-across-installs / desktop-app case that a ``.resolve()``-based
|
||||
check wrongly rejected, breaking album art + song load), while (2) still
|
||||
rejecting ``..`` traversal and absolute paths — the only escapes a ``:path``
|
||||
filename can express. ``safe_join`` stays strict on purpose (zip-slip guard),
|
||||
so the contrast is pinned here too.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def server(tmp_path, monkeypatch):
|
||||
(tmp_path / "cfg").mkdir()
|
||||
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
|
||||
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)
|
||||
|
||||
|
||||
def _dlc(tmp_path):
|
||||
d = tmp_path / "dlc"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
# ── still-rejected escapes (the security contract) ────────────────────────────
|
||||
|
||||
def test_dotdot_traversal_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "../../etc/passwd") is None
|
||||
# a Windows-style backslash traversal is normalised + rejected identically
|
||||
assert server._resolve_dlc_path(dlc, "..\\..\\secret") is None
|
||||
assert server._resolve_dlc_path(dlc, "a/../../b") is None
|
||||
|
||||
|
||||
def test_absolute_path_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "/etc/passwd") is None
|
||||
assert server._resolve_dlc_path(dlc, "C:/Windows/system32/x") is None
|
||||
|
||||
|
||||
def test_empty_and_nul_rejected(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
assert server._resolve_dlc_path(dlc, "") is None
|
||||
assert server._resolve_dlc_path(dlc, "a\x00b") is None
|
||||
|
||||
|
||||
# ── allowed: legitimate in-library paths ──────────────────────────────────────
|
||||
|
||||
def test_safe_relative_allowed(server, tmp_path):
|
||||
dlc = _dlc(tmp_path)
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/City Pop/song.feedpak")
|
||||
assert p is not None
|
||||
assert p.is_relative_to(dlc.resolve())
|
||||
|
||||
|
||||
def test_junction_subfolder_allowed(server, tmp_path):
|
||||
"""A library mounted through a directory junction/symlink must resolve —
|
||||
the case that broke album art for Christian's shared city-pop library."""
|
||||
dlc = _dlc(tmp_path)
|
||||
real = tmp_path / "real_library"
|
||||
real.mkdir()
|
||||
(real / "song.feedpak").write_bytes(b"pack")
|
||||
link = dlc / "CDLC"
|
||||
try:
|
||||
os.symlink(real, link, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlink/junction creation not permitted on this host")
|
||||
|
||||
p = server._resolve_dlc_path(dlc, "CDLC/song.feedpak")
|
||||
assert p is not None, "a junctioned library subfolder was wrongly rejected"
|
||||
assert p.exists(), "the resolved path should reach the file through the junction"
|
||||
# Contrast: safe_join stays strict (it .resolve()s and follows the junction
|
||||
# to its real target outside the root), which is correct for its zip-slip
|
||||
# callers but is exactly why _resolve_dlc_path can't reuse it here.
|
||||
assert server.safe_join(dlc, "CDLC/song.feedpak") is None
|
||||
@@ -150,3 +150,152 @@ def test_art_cache_dir_created(server):
|
||||
d = server._enrichment_art_dir()
|
||||
assert d.is_dir()
|
||||
assert d.name == "art_cache"
|
||||
|
||||
|
||||
# ── Refresh Metadata batch: per-tile states, progress, Stop ───────────────────
|
||||
|
||||
def test_states_for_returns_only_known_filenames(server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
got = server.meta_db.enrichment_states_for(["a.archive", "nope.archive"])
|
||||
assert got == {"a.archive": "unscanned"} # unknown filename absent
|
||||
assert server.meta_db.enrichment_states_for([]) == {}
|
||||
|
||||
|
||||
def test_states_endpoint(client, server):
|
||||
_put(server, "a.archive")
|
||||
_put(server, "b.archive", title="Other")
|
||||
server._background_enrich()
|
||||
body = client.post("/api/enrichment/states",
|
||||
json={"filenames": ["a.archive", "zzz.missing"]}).json()
|
||||
assert body["states"] == {"a.archive": "unscanned"}
|
||||
assert body["running"] is False
|
||||
assert body["current"] is None
|
||||
|
||||
|
||||
def test_status_exposes_progress_fields(client, server):
|
||||
_put(server, "a.archive")
|
||||
server._background_enrich()
|
||||
body = client.get("/api/enrichment/status").json()
|
||||
for k in ("total", "matched", "current", "cancelling"):
|
||||
assert k in body
|
||||
assert body["cancelling"] is False
|
||||
|
||||
|
||||
def test_cancel_is_noop_when_idle(client, server):
|
||||
body = client.post("/api/enrichment/cancel").json()
|
||||
assert body == {"ok": True, "was_running": False}
|
||||
# A no-op must not arm the flag (which would then poison the next pass).
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
|
||||
def test_cancel_flag_halts_matching_loop_between_songs(server, monkeypatch):
|
||||
for i in range(4):
|
||||
_put(server, f"s{i}.archive", title=f"Song {i}")
|
||||
# Force the matcher path on (the test env is offline by default) and stub the
|
||||
# per-song matcher so nothing touches the network — it just trips Stop after
|
||||
# the first song, exactly as the /cancel route would mid-pass.
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
calls = []
|
||||
|
||||
def fake_enrich_one(row, **_kw):
|
||||
calls.append(row["filename"])
|
||||
server._enrich_cancel.set()
|
||||
|
||||
monkeypatch.setattr(server, "_enrich_one", fake_enrich_one)
|
||||
server._enrich_cancel.clear()
|
||||
server._background_enrich()
|
||||
# The loop checks cancel BEFORE each song, so exactly one is processed before
|
||||
# it breaks — not the whole 4-row queue.
|
||||
assert calls == ["s0.archive"]
|
||||
assert server._enrich_status["total"] == 4
|
||||
assert server._enrich_status["matched"] == 1
|
||||
|
||||
|
||||
def test_rematch_requeues_visible_but_skips_manual(server, client):
|
||||
_put(server, "a.archive") # will be 'matched'
|
||||
_put(server, "b.archive", title="Other") # will be 'failed'
|
||||
_put(server, "c.archive", title="Pinned") # will be 'manual' — untouchable
|
||||
server._background_enrich()
|
||||
with server.meta_db._lock:
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='matched' WHERE filename='a.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='failed' WHERE filename='b.archive'")
|
||||
server.meta_db.conn.execute(
|
||||
"UPDATE song_enrichment SET match_state='manual' WHERE filename='c.archive'")
|
||||
server.meta_db.conn.commit()
|
||||
body = client.post("/api/enrichment/rematch", json={
|
||||
"filenames": ["a.archive", "b.archive", "c.archive", "nope.archive"]}).json()
|
||||
# A per-view refresh re-runs everything shown EXCEPT the manual pin (and an
|
||||
# unknown filename); matched + failed are both re-queued.
|
||||
assert set(body["queued"]) == {"a.archive", "b.archive"}
|
||||
assert body["count"] == 2
|
||||
server._join_background_db_threads()
|
||||
assert server.meta_db.get_enrichment("a.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("b.archive")["match_state"] == "unscanned"
|
||||
assert server.meta_db.get_enrichment("c.archive")["match_state"] == "manual"
|
||||
|
||||
|
||||
# ── filename-derived artist/title fallback (blank-artist packs) ───────────────
|
||||
|
||||
def test_filename_artist_title_parse(server):
|
||||
f = server._artist_title_from_filename
|
||||
assert f("CDLC/0 - City Pop/Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak") == \
|
||||
{"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
assert f("Anri_Windy-Summer_v1_p.feedpak") == {"artist": "Anri", "title": "Windy Summer"}
|
||||
# a trailing "(440Hz)" retune tag is stripped before parsing
|
||||
assert f("Cindy_Watashitachi-o-Shinjite-Ite_v1_p (440Hz).feedpak") == \
|
||||
{"artist": "Cindy", "title": "Watashitachi o Shinjite Ite"}
|
||||
# doesn't fit the convention → no guess
|
||||
assert f("nounderscore.feedpak") is None
|
||||
|
||||
|
||||
def test_blank_artist_seeds_match_from_filename(server, monkeypatch):
|
||||
server.meta_db.put("Tatsuro-Yamashita_Ride-On-Time_v1_p.feedpak", 0, 0, {
|
||||
"title": "Tatsuro-Yamashita_Ride-On-Time_v1_p", "artist": "", "album": "",
|
||||
"duration": 240, "arrangements": [{"name": "Bass", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Tatsuro"))
|
||||
server._enrich_one(row)
|
||||
# the blank pack artist was replaced by the filename-derived identity for
|
||||
# the search (this is exactly what rescues the 'failed' pile)
|
||||
assert seen == {"artist": "Tatsuro Yamashita", "title": "Ride On Time"}
|
||||
|
||||
|
||||
def test_present_artist_is_not_overridden_by_filename(server, monkeypatch):
|
||||
server.meta_db.put("Weird-Filename_x_y.feedpak", 0, 0, {
|
||||
"title": "Real Title", "artist": "Real Artist", "album": "", "duration": 100,
|
||||
"arrangements": [{"name": "Lead", "index": 0}]})
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
monkeypatch.setattr(server, "_manifest_exact_ids", lambda fn: {})
|
||||
seen = {}
|
||||
|
||||
def fake_search(artist, title, limit=8):
|
||||
seen["artist"], seen["title"] = artist, title
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_mb_search_recordings", fake_search)
|
||||
row = next(r for r in server.meta_db.enrichment_pending()
|
||||
if r["filename"].startswith("Weird"))
|
||||
server._enrich_one(row)
|
||||
# a pack that DOES carry an artist keeps it — the filename is never consulted
|
||||
assert seen == {"artist": "Real Artist", "title": "Real Title"}
|
||||
|
||||
|
||||
def test_kick_clears_a_stale_cancel(server):
|
||||
# A cancelled-then-rekicked pass must start clean: _kick_enrich clears the
|
||||
# flag so the fresh pass isn't aborted the instant it checks.
|
||||
server._enrich_cancel.set()
|
||||
server._kick_enrich()
|
||||
server._join_background_db_threads()
|
||||
assert server._enrich_cancel.is_set() is False
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""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,6 +110,19 @@ 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
|
||||
|
||||
+4
-1
@@ -121,7 +121,10 @@ def _converter_ebeats(converter, numerator, denominator, tempo_changes=None):
|
||||
def _assert_ebeats(converter, numerator, denominator, expected_times, tempo_changes=None):
|
||||
ebeats = _converter_ebeats(converter, numerator, denominator, tempo_changes)
|
||||
|
||||
assert [ebeat.get("time") for ebeat in ebeats] == expected_times
|
||||
# Compare by value, not string: beat times are written at 6-decimal
|
||||
# (microsecond) precision so the derived per-bar tempo matches the authored
|
||||
# GP value, but these tests only care about the spacing, not the format.
|
||||
assert [float(ebeat.get("time")) for ebeat in ebeats] == [float(t) for t in expected_times]
|
||||
assert [ebeat.get("measure") for ebeat in ebeats] == [
|
||||
"1",
|
||||
*["-1"] * (len(expected_times) - 1),
|
||||
|
||||
@@ -223,15 +223,15 @@ def test_unmapped_percussion_silently_skipped(monkeypatch):
|
||||
|
||||
def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
"""Opting in via out_unmapped records the dropped MIDI notes (count +
|
||||
times) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56, 36, 54],
|
||||
beats=[
|
||||
(0.0, [_fake_note(string_idx=1)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1)]), # cowbell again — drop
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]), # cowbell — drop
|
||||
(1.0, [_fake_note(string_idx=2)]), # kick — keep
|
||||
(1.5, [_fake_note(string_idx=3, velocity=25)]), # tambourine — drop
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]), # cowbell again — drop
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
@@ -247,6 +247,44 @@ def test_unmapped_percussion_reported_via_out_unmapped(monkeypatch):
|
||||
# Times are captured (rounded to 3 dp).
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[54]["times"] == [1.5]
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_unmapped_velocities_sort_in_lockstep_with_times(monkeypatch):
|
||||
"""Multi-voice measures can capture times out of order; the final sort
|
||||
must reorder velocities WITH their times, not leave them behind."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[
|
||||
# Deliberately reversed chronology within the measure.
|
||||
(2.0, [_fake_note(string_idx=1, velocity=44)]),
|
||||
(0.0, [_fake_note(string_idx=1, velocity=88)]),
|
||||
],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["times"] == [0.0, 2.0]
|
||||
assert unmapped[56]["velocities"] == [88, 44], \
|
||||
"velocity must follow its time through the sort"
|
||||
|
||||
|
||||
def test_unmapped_out_of_range_velocity_falls_back_to_default(monkeypatch):
|
||||
"""A corrupt/zero GP velocity records the 100 import default rather
|
||||
than poisoning the aligned list."""
|
||||
_setup(monkeypatch)
|
||||
track = _fake_track(
|
||||
string_midis=[56],
|
||||
beats=[(0.0, [_fake_note(string_idx=1, velocity=0)])],
|
||||
)
|
||||
song = SimpleNamespace(tracks=[track])
|
||||
unmapped: dict[int, dict] = {}
|
||||
gp2rs.convert_drum_track_to_drumtab(song, 0, out_unmapped=unmapped)
|
||||
assert unmapped[56]["velocities"] == [100]
|
||||
|
||||
|
||||
def test_zero_velocity_omitted_from_wire(monkeypatch):
|
||||
|
||||
@@ -122,6 +122,79 @@ def test_parse_bcfs_rejects_bad_magic():
|
||||
_parse_bcfs(b"NOPE" + b"\x00" * 16)
|
||||
|
||||
|
||||
# ── _parse_bcfs container round-trip (GP6 .gpx partial final-sector) ─────────
|
||||
|
||||
def _build_bcfs(entries, short_by=0):
|
||||
"""Assemble a minimal in-memory BCFS container for _parse_bcfs.
|
||||
|
||||
``entries`` is ``[(name: bytes, payload: bytes, data_sector: int), ...]``.
|
||||
The directory entry for entry *i* is written to sector ``i + 1``; each
|
||||
entry's payload goes in the sector index it names. ``short_by`` truncates
|
||||
the final buffer by N bytes to emulate a real .gpx's partial trailing
|
||||
sector (the BCFZ-declared decompressed size isn't 0x1000-aligned). Layout
|
||||
mirrors the reader: a 4-byte ``BCFS`` header, then 0x1000-byte sectors,
|
||||
with every value read at ``HDR + sector * 0x1000``.
|
||||
"""
|
||||
SECTOR = 0x1000
|
||||
HDR = 4
|
||||
max_sector = max([e[2] for e in entries] + [len(entries)])
|
||||
buf = bytearray(b"BCFS" + b"\x00" * ((max_sector + 1) * SECTOR))
|
||||
|
||||
def put_u32(off, val):
|
||||
struct.pack_into("<I", buf, HDR + off, val)
|
||||
|
||||
for i, (name, payload, data_sector) in enumerate(entries):
|
||||
dir_off = (i + 1) * SECTOR # directory entry -> sector i+1
|
||||
put_u32(dir_off + 0x00, 2) # entry type: file
|
||||
nm = name[:127]
|
||||
buf[HDR + dir_off + 0x04: HDR + dir_off + 0x04 + len(nm)] = nm
|
||||
put_u32(dir_off + 0x8C, len(payload)) # declared file size
|
||||
put_u32(dir_off + 0x94, data_sector) # first data-sector pointer
|
||||
put_u32(dir_off + 0x94 + 4, 0) # chain terminator
|
||||
dpos = HDR + data_sector * SECTOR
|
||||
buf[dpos: dpos + len(payload)] = payload
|
||||
if short_by:
|
||||
del buf[len(buf) - short_by:]
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def test_parse_bcfs_reads_short_final_sector():
|
||||
"""The regression: a real .gpx ends a byte short of a full 0x1000 sector,
|
||||
so its last (small) container file lands in a partial trailing sector. The
|
||||
reader must clamp that read, not reject the whole container — rejecting it
|
||||
is what made every GP6 .gpx fail to import with 'sector pointer out of
|
||||
range'."""
|
||||
bcfs = _build_bcfs([(b"score.gpif", b"hello", 2)], short_by=1)
|
||||
assert (len(bcfs) - 4) % 0x1000 == 0x1000 - 1 # final sector is 1 short
|
||||
assert _parse_bcfs(bcfs)["score.gpif"] == b"hello"
|
||||
|
||||
|
||||
def test_parse_bcfs_full_sector_round_trip():
|
||||
"""A sector-aligned container round-trips unchanged (baseline)."""
|
||||
assert _parse_bcfs(_build_bcfs([(b"misc.xml", b"<x/>", 2)]))["misc.xml"] == b"<x/>"
|
||||
|
||||
|
||||
def test_parse_bcfs_multi_file_short_final_sector():
|
||||
"""Real-world shape: score.gpif plus small config files, the last one in
|
||||
the partial trailing sector."""
|
||||
out = _parse_bcfs(_build_bcfs([
|
||||
(b"score.gpif", b"<GPIF/>", 3),
|
||||
(b"LayoutConfiguration", b"AB", 4),
|
||||
], short_by=1))
|
||||
assert out["score.gpif"] == b"<GPIF/>"
|
||||
assert out["LayoutConfiguration"] == b"AB"
|
||||
|
||||
|
||||
def test_parse_bcfs_rejects_sector_starting_past_end():
|
||||
"""A sector pointer whose *start* is beyond the container is genuinely
|
||||
malformed and must still raise — the clamp tolerates a partial final
|
||||
sector, not arbitrary out-of-range pointers."""
|
||||
bcfs = bytearray(_build_bcfs([(b"x", b"y", 2)]))
|
||||
struct.pack_into("<I", bcfs, 4 + 0x1000 + 0x94, 9999) # absurd data-sector ptr
|
||||
with pytest.raises(ValueError, match="out of range"):
|
||||
_parse_bcfs(bytes(bcfs))
|
||||
|
||||
|
||||
# ── _note_is_tie ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_note_is_tie_destination():
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""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,6 +293,45 @@ def test_year_sort_asc_oldest_first(client, seeded):
|
||||
assert files == ["b.archive", "a.archive", "f.archive", "d.sloppak", "c.sloppak", "e.sloppak"]
|
||||
|
||||
|
||||
def test_difficulty_sort_pushes_unrated_to_bottom(client, server_mod):
|
||||
"""Personal difficulty (song_user_meta.user_difficulty) sorts like
|
||||
mastery: an unrated (NULL) row must fall to the bottom in BOTH
|
||||
directions rather than colliding with a real 1..5 rating at either
|
||||
end."""
|
||||
_put(server_mod, filename="easy.archive", title="Easy", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="hard.archive", title="Hard", artist="B",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="C",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("easy.archive", user_difficulty=1)
|
||||
server_mod.meta_db.set_song_user_meta("hard.archive", user_difficulty=5)
|
||||
|
||||
asc = [s["filename"] for s in _get(client, sort="difficulty")["songs"]]
|
||||
assert asc == ["easy.archive", "hard.archive", "unrated.archive"]
|
||||
|
||||
desc = [s["filename"] for s in _get(client, sort="difficulty-desc")["songs"]]
|
||||
assert desc == ["hard.archive", "easy.archive", "unrated.archive"]
|
||||
|
||||
|
||||
def test_tree_view_songs_carry_user_difficulty(client, server_mod):
|
||||
"""`/api/library/artists` (the classic tree view's `query_artists`) must
|
||||
batch-attach `user_difficulty` the same way `query_page` does for the
|
||||
grid — otherwise the tree view's difficulty badge silently never
|
||||
renders (song.user_difficulty stays undefined for every row)."""
|
||||
_put(server_mod, filename="rated.archive", title="Rated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
_put(server_mod, filename="unrated.archive", title="Unrated", artist="A",
|
||||
arrangements=[{"index": 0, "name": "Lead", "notes": 1}])
|
||||
server_mod.meta_db.set_song_user_meta("rated.archive", user_difficulty=4)
|
||||
|
||||
data = client.get("/api/library/artists").json()
|
||||
songs = data["artists"][0]["albums"][0]["songs"]
|
||||
by_filename = {s["filename"]: s for s in songs}
|
||||
assert by_filename["rated.archive"]["user_difficulty"] == 4
|
||||
assert by_filename["unrated.archive"]["user_difficulty"] is None
|
||||
|
||||
|
||||
def test_tuning_sort_down_tuned_before_up_tuned_at_same_distance(client, server_mod):
|
||||
"""Within an ABS(tuning_sort_key) tier, the down-tuned variant
|
||||
must come before the up-tuned one so the order matches the chart's
|
||||
@@ -614,3 +653,16 @@ 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"]
|
||||
|
||||
@@ -97,6 +97,110 @@ def mb_doc(rid="rec-1", title="Thunderstruck", artist="AC/DC", artist_id="art-1"
|
||||
}
|
||||
|
||||
|
||||
# ── strict-then-loose search fallback ────────────────────────────────────────
|
||||
|
||||
def test_search_falls_back_to_loose_when_strict_is_empty(server, monkeypatch):
|
||||
"""The strict field-phrase query misses a non-Latin-primary artist; the
|
||||
loose retry (no field scoping) searches aliases and finds it."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
q = params.get("query", "")
|
||||
calls.append(q)
|
||||
if q.startswith("recording:"): # strict phrase → nothing
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-x", title="Telephone Number")]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("Junko Ohashi", "Telephone Number")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 2 # strict first, then the loose retry
|
||||
assert calls[0].startswith("recording:") # strict is the field-phrase form
|
||||
assert "artist:" not in calls[1] and '"' not in calls[1] # loose retry
|
||||
|
||||
|
||||
def test_search_does_not_retry_when_strict_hits(server, monkeypatch):
|
||||
"""A strict hit must not spend a second (throttled) request on the loose
|
||||
query."""
|
||||
calls = []
|
||||
|
||||
def _routed(path, params):
|
||||
calls.append(params.get("query", ""))
|
||||
return {"recordings": [mb_doc()]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
cands = server._mb_search_recordings("AC/DC", "Thunderstruck")
|
||||
assert len(cands) == 1
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# ── alias-aware scoring (non-Latin-primary artists) ──────────────────────────
|
||||
|
||||
_AID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
|
||||
|
||||
def test_artist_aliases_fetched_and_cached(server, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake(path, params):
|
||||
calls.append(path)
|
||||
return {"sort-name": "Ohashi, Junko",
|
||||
"aliases": [{"name": "Junko Ohashi"}, {"name": "大橋 純子"}]}
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", fake)
|
||||
names = server._mb_artist_aliases(_AID)
|
||||
assert "Junko Ohashi" in names and "Ohashi, Junko" in names
|
||||
server._mb_artist_aliases(_AID) # cached → no second request
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_artist_aliases_rejects_bad_id(server, monkeypatch):
|
||||
def boom(path, params):
|
||||
raise AssertionError("must not fetch for a non-UUID id")
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", boom)
|
||||
assert server._mb_artist_aliases("not-a-uuid") == []
|
||||
|
||||
|
||||
def test_enrich_auto_matches_japanese_primary_via_alias(server, monkeypatch):
|
||||
# A pack whose (romanized) artist MB stores under a Japanese primary name.
|
||||
_put(server, "x.sloppak", title="Telephone Number", artist="Junko Ohashi")
|
||||
|
||||
def _routed(path, params):
|
||||
if path.startswith("artist/"): # alias lookup
|
||||
return {"sort-name": "Ohashi, Junko",
|
||||
"aliases": [{"name": "Junko Ohashi"}]}
|
||||
q = params.get("query", "")
|
||||
if q.startswith("recording:"): # strict phrase → nothing
|
||||
return {"recordings": []}
|
||||
return {"recordings": [mb_doc(rid="rec-jp", title="Telephone Number",
|
||||
artist="大橋純子", artist_id=_AID)]} # loose hit
|
||||
|
||||
monkeypatch.setattr(server, "_mb_http_get", _routed)
|
||||
monkeypatch.setattr(server, "_enrich_network_enabled", lambda: True)
|
||||
server._background_enrich()
|
||||
row = server.meta_db.get_enrichment("x.sloppak")
|
||||
# The romanized alias lifts the artist over the auto floor → auto-confirmed.
|
||||
assert row["match_state"] == "matched"
|
||||
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):
|
||||
|
||||
+101
-1
@@ -145,11 +145,40 @@ def test_rank_candidates_orders_by_our_score():
|
||||
assert all("score" in c for c in ranked)
|
||||
|
||||
|
||||
def test_rank_candidates_studio_preference_is_dropped_for_live_charts():
|
||||
"""Tied-score candidates: a studio chart prefers the studio take, but a
|
||||
LIVE chart must NOT be forced to the studio recording."""
|
||||
studio = {"recording_id": "studio", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": True, "mb_score": 90}
|
||||
live = {"recording_id": "live", "artist": "AC/DC", "title": "Highway to Hell",
|
||||
"studio": False, "mb_score": 95}
|
||||
# Studio chart -> studio take wins the tie (studio flag), despite lower mb_score.
|
||||
studio_song = {"artist": "AC/DC", "title": "Highway to Hell"}
|
||||
assert m.rank_candidates(studio_song, [live, studio])[0]["recording_id"] == "studio"
|
||||
# Live chart -> studio preference dropped, so the higher-mb_score live take wins.
|
||||
live_song = {"artist": "AC/DC", "title": "Highway to Hell (Live at Donington)"}
|
||||
assert m.rank_candidates(live_song, [studio, live])[0]["recording_id"] == "live"
|
||||
|
||||
|
||||
# ── query building ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_build_recording_query_denoises_and_quotes():
|
||||
q = m.build_recording_query("ACDC", 'Thunderstruck (v2)')
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc"'
|
||||
# Live-only recordings are excluded — the studio take is never tagged Live,
|
||||
# and it's the biggest source of junk in a flat recording search.
|
||||
assert q == 'recording:"thunderstruck" AND artist:"acdc" AND -secondarytype:Live'
|
||||
|
||||
|
||||
def test_build_recording_query_keeps_live_for_live_charts():
|
||||
"""A chart that IS a live take must NOT get the live filter, or its only
|
||||
correct recording is excluded. A bare title word ("Live and Let Die") is a
|
||||
real word, not a marker, so it still filters."""
|
||||
live = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)")
|
||||
assert "-secondarytype:Live" not in live
|
||||
assert 'recording:"highway to hell"' in live
|
||||
# A real word "live" in the title is not a live marker → still filtered.
|
||||
bare = m.build_recording_query("Wings", "Live and Let Die")
|
||||
assert "-secondarytype:Live" in bare
|
||||
|
||||
|
||||
def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
@@ -160,6 +189,54 @@ def test_build_recording_query_escapes_and_handles_missing_artist():
|
||||
assert "artist:" not in q
|
||||
|
||||
|
||||
def test_build_recording_query_loose_drops_field_phrases():
|
||||
# The strict form locks to the *primary* artist/title phrase (and drops
|
||||
# live-only recordings — the chart isn't a live take).
|
||||
assert m.build_recording_query("Junko Ohashi", "Telephone Number") == \
|
||||
'recording:"telephone number" AND artist:"junko ohashi" AND -secondarytype:Live'
|
||||
# The loose form has no field scoping and no phrases, so MusicBrainz also
|
||||
# searches artist ALIASES — rescues non-Latin-primary artists (大橋純子) —
|
||||
# but keeps the same live exclusion (a studio chart must not fall back to a
|
||||
# live-only recording).
|
||||
loose = m.build_recording_query("Junko Ohashi", "Telephone Number", loose=True)
|
||||
assert loose == "(telephone number) AND (junko ohashi) AND -secondarytype:Live"
|
||||
assert "artist:" not in loose and '"' not in loose
|
||||
|
||||
|
||||
def test_build_recording_query_loose_missing_artist():
|
||||
assert m.build_recording_query("", "Fantasy", loose=True) == \
|
||||
"(fantasy) AND -secondarytype:Live"
|
||||
|
||||
|
||||
def test_build_recording_query_loose_keeps_live_for_live_charts():
|
||||
# A live chart's loose fallback must NOT exclude live recordings (same gate
|
||||
# as the strict path) — else its only correct recording is filtered out.
|
||||
loose = m.build_recording_query("AC/DC", "Highway to Hell (Live at Donington)", loose=True)
|
||||
assert "-secondarytype:Live" not in loose
|
||||
assert loose == "(highway to hell) AND (ac dc)"
|
||||
|
||||
|
||||
# ── alias-aware artist scoring ────────────────────────────────────────────────
|
||||
|
||||
def test_cand_artist_sim_uses_aliases():
|
||||
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
|
||||
# primary is the Japanese name → romanized reference scores 0…
|
||||
assert m.cand_artist_sim(song, {"artist": "大橋純子"}) == 0.0
|
||||
# …but a romanized alias confirms it
|
||||
assert m.cand_artist_sim(
|
||||
song, {"artist": "大橋純子", "artist_aliases": ["Ohashi Junko", "Junko Ohashi"]}) == 1.0
|
||||
|
||||
|
||||
def test_alias_lifts_candidate_to_auto():
|
||||
song = {"artist": "Junko Ohashi", "title": "Telephone Number"}
|
||||
jp = {"artist": "大橋純子", "title": "Telephone Number"}
|
||||
# Without the alias: title matches but the artist floor fails → never auto.
|
||||
assert m.classify(song, jp, m.score_candidate(song, jp)) != "auto"
|
||||
# With the romanized alias attached: artist clears the floor → auto.
|
||||
jp_alias = dict(jp, artist_aliases=["Junko Ohashi"])
|
||||
assert m.classify(song, jp_alias, m.score_candidate(song, jp_alias)) == "auto"
|
||||
|
||||
|
||||
# ── MusicBrainz response parsing ──────────────────────────────────────────────
|
||||
|
||||
MB_DOC = {
|
||||
@@ -200,6 +277,29 @@ def test_parse_recording_doc_normalizes():
|
||||
assert c["mb_score"] == 98
|
||||
|
||||
|
||||
def test_best_release_prefers_official_single_over_unofficial_album():
|
||||
"""An OFFICIAL single/EP must outrank an UNofficial bootleg album for the
|
||||
canonical album/year: official comes before the studio-album preference, so
|
||||
a single-only song is never seeded from a bootleg. (`(clean, status_ok, …)`
|
||||
would wrongly pick the bootleg.)"""
|
||||
doc = {
|
||||
"id": "rec-x", "title": "One-Off", "score": 90,
|
||||
"artist-credit": [
|
||||
{"name": "A", "joinphrase": "",
|
||||
"artist": {"id": "a", "name": "A", "sort-name": "A"}}],
|
||||
"releases": [
|
||||
{"id": "rel-boot", "title": "Boot LP", "status": "Bootleg",
|
||||
"date": "1990-01-01", "release-group": {"primary-type": "Album"}},
|
||||
{"id": "rel-single", "title": "The Single", "status": "Official",
|
||||
"date": "1988-01-01", "release-group": {"primary-type": "Single"}},
|
||||
],
|
||||
}
|
||||
c = m.parse_recording_doc(doc)
|
||||
assert c["release_id"] == "rel-single"
|
||||
assert c["album"] == "The Single"
|
||||
assert c["studio"] is False # a Single isn't a clean studio ALBUM
|
||||
|
||||
|
||||
def test_parse_recording_doc_joined_artist_credit():
|
||||
doc = dict(MB_DOC)
|
||||
doc["artist-credit"] = [
|
||||
|
||||
@@ -277,3 +277,56 @@ 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) so a caller can surface a warning / mapping UI."""
|
||||
times + velocities) so a caller can surface a warning / mapping UI."""
|
||||
mid = mido.MidiFile(type=1, ticks_per_beat=480)
|
||||
track = mido.MidiTrack()
|
||||
mid.tracks.append(track)
|
||||
track.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=88, time=0)) # cowbell — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=36, velocity=100, time=0)) # kick — keep
|
||||
track.append(mido.Message("note_off", channel=9, note=36, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=100, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=54, velocity=25, time=0)) # tambourine — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=54, velocity=0, time=240))
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=100, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_on", channel=9, note=56, velocity=44, time=0)) # cowbell again — drop
|
||||
track.append(mido.Message("note_off", channel=9, note=56, velocity=0, time=240))
|
||||
|
||||
unmapped: dict[int, dict] = {}
|
||||
@@ -209,6 +209,10 @@ def test_unmapped_drum_note_reported_via_out_unmapped(tmp_path):
|
||||
# Each unmapped MIDI carries the times at which it fired (rounded 3 dp).
|
||||
assert all(isinstance(t, float) for t in unmapped[56]["times"])
|
||||
assert len(unmapped[56]["times"]) == 2
|
||||
# Velocities ride index-aligned with times — the mapping UI can carry
|
||||
# the source dynamics through instead of flattening to a default.
|
||||
assert unmapped[56]["velocities"] == [88, 44]
|
||||
assert unmapped[54]["velocities"] == [25]
|
||||
|
||||
|
||||
def test_non_channel9_events_ignored(tmp_path):
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""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
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for the plugin `src/` module-serving route and the live-edit cache
|
||||
contract added in R0 (module-migration rails).
|
||||
|
||||
Covers:
|
||||
* GET /api/plugins/{id}/src/{path} serves a plugin's ES-module source tree
|
||||
with the right Content-Type, including nested paths.
|
||||
* Path containment: `..`, absolute, and NUL are rejected (404) — the same
|
||||
`safe_join` guard the assets/ route uses.
|
||||
* The live-edit cache contract: no-cache + a weak ETag, a bodyless 304 on
|
||||
matching If-None-Match, and no stale 304 after an in-place edit.
|
||||
* screen.js and assets/ now also emit an ETag and honor If-None-Match
|
||||
(previously screen.js sent no headers and assets/ never returned 304).
|
||||
|
||||
The routes read the module-global `plugins.LOADED_PLUGINS`, so each test
|
||||
registers a fake ready plugin directly (save/restore that global) and drives
|
||||
`register_plugin_api` on a fresh FastAPI app — no full server import needed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import plugins
|
||||
|
||||
|
||||
PLUGIN_ID = "srctest"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(tmp_path):
|
||||
"""A TestClient with `register_plugin_api` wired and a single fake ready
|
||||
plugin whose dir (`tmp_path`) holds a src/ tree, an asset, and a screen.js.
|
||||
Restores LOADED_PLUGINS afterward."""
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "main.js").write_text("import './util/x.js';\nexport const boot = 1;\n")
|
||||
(tmp_path / "src" / "util").mkdir()
|
||||
(tmp_path / "src" / "util" / "x.js").write_text("export const x = 42;\n")
|
||||
(tmp_path / "src" / "theme.css").write_text(".a{color:red}\n")
|
||||
(tmp_path / "assets").mkdir()
|
||||
(tmp_path / "assets" / "worklet.js").write_text("// worklet\n")
|
||||
(tmp_path / "screen.js").write_text("import './src/main.js';\n")
|
||||
|
||||
saved = list(plugins.LOADED_PLUGINS)
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.append({
|
||||
"id": PLUGIN_ID,
|
||||
"status": "ready",
|
||||
"_dir": tmp_path,
|
||||
"_manifest": {"script": "screen.js", "scriptType": "module"},
|
||||
})
|
||||
app = FastAPI()
|
||||
plugins.register_plugin_api(app)
|
||||
c = TestClient(app, raise_server_exceptions=True)
|
||||
try:
|
||||
yield c, tmp_path
|
||||
finally:
|
||||
c.close()
|
||||
plugins.LOADED_PLUGINS.clear()
|
||||
plugins.LOADED_PLUGINS.extend(saved)
|
||||
|
||||
|
||||
def test_src_file_served_with_js_media_type(client):
|
||||
c, _ = client
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
assert r.status_code == 200
|
||||
# Either application/javascript or text/javascript is a valid module-script
|
||||
# MIME (guess_type returns text/javascript on newer platforms); browsers
|
||||
# accept both for <script type=module>.
|
||||
assert "javascript" in r.headers["content-type"]
|
||||
assert "export const boot" in r.text
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
assert r.headers.get("etag")
|
||||
|
||||
|
||||
def test_src_nested_path_and_css_media_type(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/util/x.js").status_code == 200
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/theme.css")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/css")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [
|
||||
"..%2f..%2fplugin.json", # escape the src/ dir
|
||||
"..%2f..%2f..%2fetc%2fpasswd",
|
||||
"%2fetc%2fpasswd", # absolute
|
||||
"util%2f..%2f..%2fscreen.js",
|
||||
])
|
||||
def test_src_traversal_rejected(client, bad):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}").status_code == 404
|
||||
|
||||
|
||||
def test_src_missing_is_404(client):
|
||||
c, _ = client
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/nope.js").status_code == 404
|
||||
|
||||
|
||||
def test_src_conditional_304(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js")
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
assert r2.content == b""
|
||||
|
||||
|
||||
def test_src_no_stale_304_after_edit(client):
|
||||
c, root = client
|
||||
etag = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").headers["etag"]
|
||||
(root / "src" / "main.js").write_text("export const boot = 2; // edited, longer body\n")
|
||||
r = c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js", headers={"If-None-Match": etag})
|
||||
assert r.status_code == 200
|
||||
assert "boot = 2" in r.text
|
||||
assert r.headers["etag"] != etag
|
||||
|
||||
|
||||
def test_screen_js_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
|
||||
assert r1.status_code == 200
|
||||
assert r1.headers["cache-control"] == "no-cache"
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_asset_now_conditional(client):
|
||||
c, _ = client
|
||||
r1 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
|
||||
assert r1.status_code == 200
|
||||
etag = r1.headers["etag"]
|
||||
r2 = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js", headers={"If-None-Match": etag})
|
||||
assert r2.status_code == 304
|
||||
|
||||
|
||||
def test_unready_plugin_src_is_404(client):
|
||||
c, _ = client
|
||||
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
||||
+156
-2
@@ -219,6 +219,47 @@ 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.
|
||||
@@ -753,29 +794,142 @@ def test_defaults_include_gameplay_keys(client, tmp_path):
|
||||
assert data["fail_behavior"] == "continue"
|
||||
|
||||
|
||||
|
||||
def test_get_settings_exposes_default_instrument_profiles(client, tmp_path):
|
||||
data = client.get("/api/settings").json()
|
||||
assert data["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(data["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert data["instrument"] == "guitar"
|
||||
assert data["string_count"] == 6
|
||||
assert data["tuning"] == "Standard"
|
||||
assert data["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_flat_instrument_updates_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={"instrument": "bass", "pathway": "practice"})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "bass"
|
||||
assert cfg["instrument"] == "bass"
|
||||
assert cfg["string_count"] == 4
|
||||
assert cfg["tuning"] == "Standard"
|
||||
assert cfg["pathway"] == "practice"
|
||||
assert cfg["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
assert cfg["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_mirrors_active_profile(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"active_instrument_profile": "guitar-rhythm",
|
||||
"instrument_profiles": {
|
||||
"guitar-rhythm": {
|
||||
"string_count": 7,
|
||||
"tuning": "Drop A",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "studio",
|
||||
},
|
||||
"bass": {
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
},
|
||||
},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
cfg = _read_cfg(tmp_path)
|
||||
assert cfg["active_instrument_profile"] == "guitar-rhythm"
|
||||
assert cfg["instrument"] == "guitar"
|
||||
assert cfg["string_count"] == 7
|
||||
assert cfg["tuning"] == "Drop A"
|
||||
assert cfg["reference_pitch"] == 432
|
||||
assert cfg["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_post_pathway_rejects_bad_value(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"pathway": "songs"}))
|
||||
r = client.post("/api/settings", json={"pathway": "invalid"})
|
||||
assert "error" in r.json()
|
||||
assert _read_cfg(tmp_path)["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_post_instrument_profiles_rejects_bad_custom_string_count(client, tmp_path):
|
||||
r = client.post("/api/settings", json={
|
||||
"instrument_profiles": {
|
||||
"bass": {"string_count": 6, "tuning": [0, 0, 0, 0]},
|
||||
},
|
||||
})
|
||||
assert "error" in r.json()
|
||||
|
||||
# ── /api/settings/reset ─────────────────────────────────────────────────────
|
||||
|
||||
def test_reset_clears_requested_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({
|
||||
"master_difficulty": 40,
|
||||
"countdown_before_song": True,
|
||||
"pathway": "studio",
|
||||
"default_arrangement": "Lead",
|
||||
"demucs_server_url": "http://demucs.example:9000",
|
||||
}))
|
||||
r = client.post("/api/settings/reset",
|
||||
json={"keys": ["master_difficulty", "countdown_before_song"]})
|
||||
json={"keys": ["master_difficulty", "countdown_before_song", "pathway"]})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song"}
|
||||
assert set(body["reset"]) == {"master_difficulty", "countdown_before_song", "pathway"}
|
||||
cfg = _read_cfg(tmp_path)
|
||||
# Reset removes the key so GET falls back to the default.
|
||||
assert "master_difficulty" not in cfg
|
||||
assert "countdown_before_song" not in cfg
|
||||
assert "pathway" not in cfg
|
||||
# Unlisted keys are untouched.
|
||||
assert cfg["default_arrangement"] == "Lead"
|
||||
assert cfg["demucs_server_url"] == "http://demucs.example:9000"
|
||||
|
||||
|
||||
def test_partial_instrument_profiles_update_preserves_others(client, tmp_path):
|
||||
# /api/settings is a partial-merge endpoint, so a POST that carries only ONE
|
||||
# instrument profile must not reset the others to defaults.
|
||||
gl = client.get("/api/settings").json()["instrument_profiles"]["guitar-lead"]
|
||||
gl = dict(gl); gl["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"guitar-lead": gl}})
|
||||
assert (client.get("/api/settings").json()["instrument_profiles"]
|
||||
["guitar-lead"]["tuning"] == "Drop D")
|
||||
# Now update ONLY bass (Drop D is valid for a 4-string bass).
|
||||
bass = client.get("/api/settings").json()["instrument_profiles"]["bass"]
|
||||
bass = dict(bass); bass["tuning"] = "Drop D"
|
||||
client.post("/api/settings", json={"instrument_profiles": {"bass": bass}})
|
||||
out = client.get("/api/settings").json()["instrument_profiles"]
|
||||
assert out["guitar-lead"]["tuning"] == "Drop D", "the untouched profile survived"
|
||||
assert out["bass"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_active_profile_switch_on_fresh_config(client, tmp_path):
|
||||
# A fresh config has no instrument_profiles; an explicit active-profile
|
||||
# switch must be honored, not overwritten by the profile inferred from the
|
||||
# legacy flat defaults (guitar-lead).
|
||||
r = client.post("/api/settings", json={"active_instrument_profile": "bass"})
|
||||
assert r.status_code == 200 and "error" not in r.json()
|
||||
got = client.get("/api/settings").json()
|
||||
assert got["active_instrument_profile"] == "bass"
|
||||
assert got["instrument"] == "bass"
|
||||
|
||||
|
||||
def test_reset_pathway_reaches_into_instrument_profiles(client, tmp_path):
|
||||
# pathway is mirrored into every instrument profile, so a Gameplay reset
|
||||
# that only deleted the flat key would leave GET re-deriving the old value
|
||||
# from the profile. The reset must reach into the persisted profiles too.
|
||||
client.post("/api/settings", json={"pathway": "studio"})
|
||||
assert client.get("/api/settings").json()["pathway"] == "studio"
|
||||
profiles = _read_cfg(tmp_path)["instrument_profiles"]
|
||||
assert any(p["pathway"] == "studio" for p in profiles.values())
|
||||
|
||||
r = client.post("/api/settings/reset", json={"keys": ["pathway"]})
|
||||
assert r.status_code == 200
|
||||
assert "pathway" in r.json()["reset"]
|
||||
# GET re-derives from the profile — which must now be back to the default.
|
||||
assert client.get("/api/settings").json()["pathway"] == "songs"
|
||||
for prof in _read_cfg(tmp_path)["instrument_profiles"].values():
|
||||
assert prof["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_reset_ignores_unknown_keys(client, tmp_path):
|
||||
(tmp_path / "config.json").write_text(json.dumps({"master_difficulty": 40}))
|
||||
# Unknown / non-resettable keys are silently ignored, not an error, and
|
||||
|
||||
@@ -31,12 +31,14 @@ def _cfg(tmp_path):
|
||||
def test_instrument_fields_persist(env):
|
||||
srv, tmp = env
|
||||
c = TestClient(srv.app)
|
||||
# "Drop A" is the 5-string bass drop tuning (its low string is B, not E, so
|
||||
# "Drop D" is a 4-string tuning — now correctly rejected per-profile).
|
||||
r = c.post("/api/settings", json={"instrument": "bass", "string_count": 5,
|
||||
"tuning": "Drop D", "reference_pitch": 442})
|
||||
"tuning": "Drop A", "reference_pitch": 442})
|
||||
assert r.status_code == 200
|
||||
cfg = _cfg(tmp)
|
||||
assert cfg["instrument"] == "bass" and cfg["string_count"] == 5
|
||||
assert cfg["tuning"] == "Drop D" and cfg["reference_pitch"] == 442.0
|
||||
assert cfg["tuning"] == "Drop A" and cfg["reference_pitch"] == 442.0
|
||||
# Reflected back through GET.
|
||||
got = c.get("/api/settings").json()
|
||||
assert got["instrument"] == "bass" and got["reference_pitch"] == 442.0
|
||||
|
||||
+118
-1
@@ -2,7 +2,31 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from tunings import tuning_name
|
||||
from tunings import (
|
||||
DEFAULT_TUNINGS,
|
||||
TUNING_PRESET_MIDIS,
|
||||
_valid_tuning_for_key,
|
||||
apply_flat_instrument_patch_to_profiles,
|
||||
open_midis_to_freqs,
|
||||
settings_with_instrument_profiles,
|
||||
tuning_midis_from_offsets,
|
||||
tuning_name,
|
||||
tuning_offsets_from_midis,
|
||||
tuning_preset_offsets,
|
||||
)
|
||||
|
||||
|
||||
def test_valid_tuning_for_key_builtin_and_provider_names():
|
||||
# A built-in valid for the key is accepted; a built-in valid only for a
|
||||
# DIFFERENT key (misapplied, e.g. "Drop D" on a 5-string bass) is rejected.
|
||||
assert _valid_tuning_for_key("bass-5", "Drop A") == "Drop A"
|
||||
assert _valid_tuning_for_key("bass-5", "Drop D") is None
|
||||
assert _valid_tuning_for_key("guitar-6", "Standard") == "Standard"
|
||||
# A name unknown to every built-in table is a provider/custom tuning (tuner
|
||||
# plugin, /api/tunings) the pure layer can't resolve — accept it so settings
|
||||
# round-trip rather than normalizing it away to Standard.
|
||||
assert _valid_tuning_for_key("bass-5", "My Custom DADGAD") == "My Custom DADGAD"
|
||||
assert _valid_tuning_for_key("guitar-6", "x" * 65) is None # length cap kept
|
||||
|
||||
|
||||
# ── Standard tunings (all six strings share the same offset) ─────────────────
|
||||
@@ -132,3 +156,96 @@ def test_drop_pattern_takes_precedence_over_named_dict():
|
||||
# auto-generator fires first and produces the same string. The named dict entry
|
||||
# is effectively dead code for this case — this test documents the behavior.
|
||||
assert tuning_name([-2, 0, 0, 0, 0, 0]) == "Drop D"
|
||||
|
||||
|
||||
# ── Host tuning profile catalogue -------------------------------------------
|
||||
|
||||
def test_default_tunings_include_extended_host_profiles():
|
||||
assert "bass-6" in DEFAULT_TUNINGS
|
||||
assert "C Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "C# Standard" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "Drop Ab" in DEFAULT_TUNINGS["guitar-6"]
|
||||
assert "BEAD" in DEFAULT_TUNINGS["bass-4"]
|
||||
assert "High C" in DEFAULT_TUNINGS["bass-5"]
|
||||
assert "Drop A + Drop E" in DEFAULT_TUNINGS["guitar-8"]
|
||||
|
||||
|
||||
def test_default_tuning_frequencies_are_derived_from_midis():
|
||||
assert DEFAULT_TUNINGS["guitar-6"]["Standard"] == open_midis_to_freqs([40, 45, 50, 55, 59, 64])
|
||||
assert DEFAULT_TUNINGS["bass-6"]["Standard"] == open_midis_to_freqs([23, 28, 33, 38, 43, 48])
|
||||
|
||||
|
||||
def test_tuning_offsets_from_named_presets():
|
||||
assert tuning_preset_offsets("guitar-6", "Drop D") == [-2, 0, 0, 0, 0, 0]
|
||||
assert tuning_preset_offsets("guitar-6", "C Standard") == [-4, -4, -4, -4, -4, -4]
|
||||
assert tuning_preset_offsets("bass-4", "BEAD") == [-5, -5, -5, -5]
|
||||
assert tuning_preset_offsets("bass-5", "High C") == [5, 5, 5, 5, 5]
|
||||
|
||||
|
||||
def test_tuning_midis_round_trip_offsets():
|
||||
offsets = [-2, 0, 0, 0, 0, 0]
|
||||
midis = tuning_midis_from_offsets("guitar-6", offsets)
|
||||
assert midis == TUNING_PRESET_MIDIS["guitar-6"]["Drop D"]
|
||||
assert tuning_offsets_from_midis("guitar-6", midis) == offsets
|
||||
|
||||
|
||||
def test_tuning_conversion_rejects_wrong_string_count():
|
||||
assert tuning_offsets_from_midis("guitar-6", [40, 45, 50, 55]) is None
|
||||
assert tuning_midis_from_offsets("bass-4", [0, 0, 0, 0, 0]) is None
|
||||
|
||||
def test_settings_profiles_default_to_lead_rhythm_and_bass():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
assert settings["active_instrument_profile"] == "guitar-lead"
|
||||
assert set(settings["instrument_profiles"]) == {"guitar-lead", "guitar-rhythm", "bass"}
|
||||
assert settings["instrument"] == "guitar"
|
||||
assert settings["string_count"] == 6
|
||||
assert settings["tuning"] == "Standard"
|
||||
assert settings["pathway"] == "songs"
|
||||
assert settings["instrument_profiles"]["guitar-lead"]["pathway"] == "songs"
|
||||
|
||||
|
||||
def test_settings_profiles_migrate_legacy_flat_bass_selection():
|
||||
settings = settings_with_instrument_profiles({
|
||||
"instrument": "bass",
|
||||
"string_count": 6,
|
||||
"tuning": "C Standard",
|
||||
"reference_pitch": 432,
|
||||
"pathway": "practice",
|
||||
})
|
||||
assert settings["active_instrument_profile"] == "bass"
|
||||
assert settings["instrument_profiles"]["bass"]["string_count"] == 6
|
||||
assert settings["instrument_profiles"]["bass"]["tuning"] == "C Standard"
|
||||
assert settings["reference_pitch"] == 432
|
||||
assert settings["pathway"] == "practice"
|
||||
assert settings["instrument_profiles"]["bass"]["pathway"] == "practice"
|
||||
|
||||
|
||||
def test_flat_patch_updates_active_profile_and_mirrors_legacy_keys():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"tuning": "Drop D"})
|
||||
assert patched["tuning"] == "Drop D"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["tuning"] == "Drop D"
|
||||
|
||||
|
||||
def test_flat_pathway_patch_updates_active_profile_and_mirrors_legacy_key():
|
||||
settings = settings_with_instrument_profiles({})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"pathway": "studio"})
|
||||
assert patched["pathway"] == "studio"
|
||||
assert patched["instrument_profiles"]["guitar-lead"]["pathway"] == "studio"
|
||||
|
||||
|
||||
def test_flat_instrument_patch_defaults_to_target_string_count():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "Drop D"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"instrument": "bass"})
|
||||
assert patched["instrument"] == "bass"
|
||||
assert patched["string_count"] == 4
|
||||
assert patched["tuning"] == "Standard"
|
||||
assert patched["active_instrument_profile"] == "bass"
|
||||
assert patched["instrument_profiles"]["bass"]["string_count"] == 4
|
||||
|
||||
|
||||
def test_flat_string_count_patch_resets_incompatible_named_tuning():
|
||||
settings = settings_with_instrument_profiles({"instrument": "guitar", "string_count": 6, "tuning": "DADGAD"})
|
||||
patched = apply_flat_instrument_patch_to_profiles(settings, {"string_count": 7})
|
||||
assert patched["string_count"] == 7
|
||||
assert patched["tuning"] == "Standard"
|
||||
|
||||
Reference in New Issue
Block a user